authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-08 18:30:58-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 18:30:58-07:00
logb707eeb052782cfd8b1ff0445f84b2aaf01da38b
tree2dd823ac602c1c2b8fad7fe0277b4ffef9a41b22
parent664bd5e524bb58b23ecab0f2e413fc2ecb4caf55
signature Signed by PGP key B5690EEEBB952194

refactor(provider-grok): move Grok into its own provider package (#17357)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

60 files changed, 7007 insertions(+), 6869 deletions(-)

apps/mobile/package.json+1
...@@ -71,6 +71,7 @@...@@ -71,6 +71,7 @@
71 "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal",71 "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal",
72 "@t3tools/provider-core": "workspace:*",72 "@t3tools/provider-core": "workspace:*",
73 "@t3tools/provider-cursor": "workspace:*",73 "@t3tools/provider-cursor": "workspace:*",
74 "@t3tools/provider-grok": "workspace:*",
74 "@t3tools/provider-muse": "workspace:*",75 "@t3tools/provider-muse": "workspace:*",
75 "@t3tools/provider-opencode": "workspace:*",76 "@t3tools/provider-opencode": "workspace:*",
76 "@t3tools/provider-pi": "workspace:*",77 "@t3tools/provider-pi": "workspace:*",
apps/mobile/src/components/ProviderIcon.tsx-16
...@@ -93,22 +93,6 @@ export function ProviderIcon(props: ProviderIconProps) {...@@ -93,22 +93,6 @@ export function ProviderIcon(props: ProviderIconProps) {
93 );93 );
94 }94 }
9595
96 if (props.provider === "grok") {
97 const fill = isDarkMode ? "#F5F5F5" : "#0F0F0F";
98 return (
99 <Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
100 <Path
101 fill={fill}
102 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"
103 />
104 <Path
105 fill={fill}
106 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"
107 />
108 </Svg>
109 );
110 }
111
112 const packageIcon = getProviderClient(props.provider)?.icon;96 const packageIcon = getProviderClient(props.provider)?.icon;
113 if (packageIcon) {97 if (packageIcon) {
114 const themeFill = (fill: { readonly light: string; readonly dark: string }) =>98 const themeFill = (fill: { readonly light: string; readonly dark: string }) =>
apps/mobile/src/lib/providerClients.ts+2
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { isProviderDriverKind } from "@t3tools/contracts";1import { isProviderDriverKind } from "@t3tools/contracts";
2import { makeProviderClientRegistry } from "@t3tools/provider-core/client";2import { makeProviderClientRegistry } from "@t3tools/provider-core/client";
3import { cursorClient } from "@t3tools/provider-cursor/client";3import { cursorClient } from "@t3tools/provider-cursor/client";
4import { grokClient } from "@t3tools/provider-grok/client";
4import { museClient } from "@t3tools/provider-muse/client";5import { museClient } from "@t3tools/provider-muse/client";
5import { openCodeClient } from "@t3tools/provider-opencode/client";6import { openCodeClient } from "@t3tools/provider-opencode/client";
6import { piClient } from "@t3tools/provider-pi/client";7import { piClient } from "@t3tools/provider-pi/client";
...@@ -8,6 +9,7 @@ import { piClient } from "@t3tools/provider-pi/client";...@@ -8,6 +9,7 @@ import { piClient } from "@t3tools/provider-pi/client";
8/** The provider client definitions this mobile build ships. */9/** The provider client definitions this mobile build ships. */
9const providerClients = makeProviderClientRegistry([10const providerClients = makeProviderClientRegistry([
10 cursorClient,11 cursorClient,
12 grokClient,
11 museClient,13 museClient,
12 openCodeClient,14 openCodeClient,
13 piClient,15 piClient,
apps/server/package.json+1
...@@ -41,6 +41,7 @@...@@ -41,6 +41,7 @@
41 "@t3tools/provider-acp": "workspace:*",41 "@t3tools/provider-acp": "workspace:*",
42 "@t3tools/provider-core": "workspace:*",42 "@t3tools/provider-core": "workspace:*",
43 "@t3tools/provider-cursor": "workspace:*",43 "@t3tools/provider-cursor": "workspace:*",
44 "@t3tools/provider-grok": "workspace:*",
44 "@t3tools/provider-muse": "workspace:*",45 "@t3tools/provider-muse": "workspace:*",
45 "@t3tools/provider-opencode": "workspace:*",46 "@t3tools/provider-opencode": "workspace:*",
46 "@t3tools/provider-pi": "workspace:*",47 "@t3tools/provider-pi": "workspace:*",
apps/server/scripts/record-grok-acp-replay-fixture.ts+4-3
...@@ -10,7 +10,8 @@...@@ -10,7 +10,8 @@
10 * node scripts/record-grok-acp-replay-fixture.ts --scenario simple10 * node scripts/record-grok-acp-replay-fixture.ts --scenario simple
11 */11 */
12import * as NodeServices from "@effect/platform-node/NodeServices";12import * as NodeServices from "@effect/platform-node/NodeServices";
13import { GrokSettings, type ProviderReplayEntry } from "@t3tools/contracts";13import { type ProviderReplayEntry } from "@t3tools/contracts";
14import { GrokSettings } from "@t3tools/provider-grok/settings";
14import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";15import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
15import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";16import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";
16import * as Clock from "effect/Clock";17import * as Clock from "effect/Clock";
...@@ -31,7 +32,7 @@ import {...@@ -31,7 +32,7 @@ import {
31 GROK_PROVIDER,32 GROK_PROVIDER,
32 grokLaunchRuntimeMode,33 grokLaunchRuntimeMode,
33 makeGrokAdapterV2,34 makeGrokAdapterV2,
34} from "../src/orchestration-v2/Adapters/GrokAdapterV2.ts";35} from "@t3tools/provider-grok/testing";
35import { ACP_PROTOCOL } from "@t3tools/provider-acp/server/adapter";36import { ACP_PROTOCOL } from "@t3tools/provider-acp/server/adapter";
36import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";37import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
37import type { ProviderAdapterV2SessionRuntime } from "@t3tools/provider-core/server/ProviderAdapter";38import type { ProviderAdapterV2SessionRuntime } from "@t3tools/provider-core/server/ProviderAdapter";
...@@ -43,7 +44,7 @@ import { materializeFixtureInput } from "../src/orchestration-v2/testkit/fixture...@@ -43,7 +44,7 @@ import { materializeFixtureInput } from "../src/orchestration-v2/testkit/fixture
43import { runOrchestratorV2Scenario } from "../src/orchestration-v2/testkit/OrchestratorScenario.ts";44import { runOrchestratorV2Scenario } from "../src/orchestration-v2/testkit/OrchestratorScenario.ts";
44import * as ProviderReplayHarness from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts";45import * as ProviderReplayHarness from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts";
45import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace";46import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace";
46import { makeGrokAcpRuntime } from "../src/provider/acp/GrokAcpSupport.ts";47import { makeGrokAcpRuntime } from "@t3tools/provider-grok/testing";
47import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions";48import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions";
4849
49const wallClock = Clock.Clock.defaultValue();50const wallClock = Clock.Clock.defaultValue();
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts+3-3
...@@ -12,7 +12,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices";...@@ -12,7 +12,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
12import { assert, describe, it } from "@effect/vitest";12import { assert, describe, it } from "@effect/vitest";
13import {13import {
14 CheckpointId,14 CheckpointId,
15 GrokSettings,
16 EnvironmentId,15 EnvironmentId,
17 MessageId,16 MessageId,
18 type ModelSelection,17 type ModelSelection,
...@@ -27,6 +26,7 @@ import {...@@ -27,6 +26,7 @@ import {
27 ThreadId,26 ThreadId,
28 type OrchestrationV2ProviderThread,27 type OrchestrationV2ProviderThread,
29} from "@t3tools/contracts";28} from "@t3tools/contracts";
29import { GrokSettings } from "@t3tools/provider-grok/settings";
30import { HostProcessIsExecutable, HostProcessPlatform } from "@t3tools/shared/hostProcess";30import { HostProcessIsExecutable, HostProcessPlatform } from "@t3tools/shared/hostProcess";
31import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";31import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";
32import * as DateTime from "effect/DateTime";32import * as DateTime from "effect/DateTime";
...@@ -64,7 +64,7 @@ import {...@@ -64,7 +64,7 @@ import {
64 makeXAiPromptCompletionRuntime,64 makeXAiPromptCompletionRuntime,
65 normalizeXAiAcpToolCallState,65 normalizeXAiAcpToolCallState,
66 registerXAiBackgroundTaskTracking,66 registerXAiBackgroundTaskTracking,
67} from "../../provider/acp/XAiAcpExtension.ts";67} from "@t3tools/provider-grok/testing";
68import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";68import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
69import {69import {
70 ProviderAdapterProtocolError,70 ProviderAdapterProtocolError,
...@@ -93,7 +93,7 @@ import {...@@ -93,7 +93,7 @@ import {
93 type AcpAdapterV2SubagentUpdate,93 type AcpAdapterV2SubagentUpdate,
94} from "@t3tools/provider-acp/server/adapter";94} from "@t3tools/provider-acp/server/adapter";
9595
96import { makeGrokAdapterV2 } from "./GrokAdapterV2.ts";96import { makeGrokAdapterV2 } from "@t3tools/provider-grok/testing";
97import {97import {
98 acpRegistryPromptFailure,98 acpRegistryPromptFailure,
99 registerMistralVibeAcpExtensions,99 registerMistralVibeAcpExtensions,
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts+4-4
...@@ -1,16 +1,16 @@...@@ -1,16 +1,16 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import {2import {
3 GrokSettings,
4 ProjectId,3 ProjectId,
5 ProviderInstanceId,4 ProviderInstanceId,
6 ProviderSessionId,5 ProviderSessionId,
7 type RuntimeMode,6 type RuntimeMode,
8 ThreadId,7 ThreadId,
9} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import { GrokSettings } from "@t3tools/provider-grok/settings";
10import { HostProcessPlatform } from "@t3tools/shared/hostProcess";10import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
11import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";11import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";
12import * as EffectAcpErrors from "effect-acp/errors";12import * as EffectAcpErrors from "effect-acp/errors";
13import { xAiRateLimitedErrorCode } from "../../provider/acp/XAiAcpExtension.ts";13import { xAiRateLimitedErrorCode } from "@t3tools/provider-grok/testing";
14import { assert, describe, it } from "@effect/vitest";14import { assert, describe, it } from "@effect/vitest";
15import * as Crypto from "effect/Crypto";15import * as Crypto from "effect/Crypto";
16import * as DateTime from "effect/DateTime";16import * as DateTime from "effect/DateTime";
...@@ -25,7 +25,7 @@ import type * as EffectAcpSchema from "effect-acp/compat";...@@ -25,7 +25,7 @@ import type * as EffectAcpSchema from "effect-acp/compat";
25import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";25import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
26import { layerTestProviderHost } from "@t3tools/provider-testing/host";26import { layerTestProviderHost } from "@t3tools/provider-testing/host";
27import * as ProjectStore from "../ProjectStore.ts";27import * as ProjectStore from "../ProjectStore.ts";
28import { buildInitialGrokProviderSnapshot } from "../../provider/GrokProvider.ts";28import { buildInitialGrokProviderSnapshot } from "@t3tools/provider-grok/testing";
29import type { ProviderInstance } from "@t3tools/provider-core/server/driver";29import type { ProviderInstance } from "@t3tools/provider-core/server/driver";
30import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts";30import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts";
31import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";31import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
...@@ -43,7 +43,7 @@ import {...@@ -43,7 +43,7 @@ import {
43 makeGrokAdapterV2,43 makeGrokAdapterV2,
44 GrokProviderCapabilitiesV2,44 GrokProviderCapabilitiesV2,
45 type GrokAdapterV2Options,45 type GrokAdapterV2Options,
46} from "./GrokAdapterV2.ts";46} from "@t3tools/provider-grok/testing";
4747
48const LAUNCH_TEST_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({48const LAUNCH_TEST_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({
49 binaryPath: "grok-launch-test",49 binaryPath: "grok-launch-test",
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts+8-7
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { GrokSettings } from "@t3tools/contracts";2import { GrokSettings } from "@t3tools/provider-grok/settings";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Crypto from "effect/Crypto";4import * as Crypto from "effect/Crypto";
5import * as FileSystem from "effect/FileSystem";5import * as FileSystem from "effect/FileSystem";
...@@ -12,11 +12,8 @@ import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";...@@ -12,11 +12,8 @@ import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";
1212
13import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";13import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
14import { layerTestProviderHost } from "@t3tools/provider-testing/host";14import { layerTestProviderHost } from "@t3tools/provider-testing/host";
15import {15import { GROK_ACP_CANCEL_META, GROK_ACP_INITIALIZE_META } from "@t3tools/provider-grok/testing";
16 GROK_ACP_CANCEL_META,16import { makeXAiPromptCompletionRuntime } from "@t3tools/provider-grok/testing";
17 GROK_ACP_INITIALIZE_META,
18} from "../../provider/acp/GrokAcpSupport.ts";
19import { makeXAiPromptCompletionRuntime } from "../../provider/acp/XAiAcpExtension.ts";
20import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";17import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
21import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests";18import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests";
22import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts";19import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts";
...@@ -29,7 +26,11 @@ import {...@@ -29,7 +26,11 @@ import {
29 makeAcpReplayCompletenessAssertion,26 makeAcpReplayCompletenessAssertion,
30 makeAcpReplayRuntime,27 makeAcpReplayRuntime,
31} from "./AcpAdapterV2.testkit.ts";28} from "./AcpAdapterV2.testkit.ts";
32import { GROK_DEFAULT_INSTANCE_ID, GROK_PROVIDER, makeGrokAdapterV2 } from "./GrokAdapterV2.ts";29import {
30 GROK_DEFAULT_INSTANCE_ID,
31 GROK_PROVIDER,
32 makeGrokAdapterV2,
33} from "@t3tools/provider-grok/testing";
3334
34const DEFAULT_GROK_SETTINGS = Schema.decodeUnknownSync(GrokSettings)({});35const DEFAULT_GROK_SETTINGS = Schema.decodeUnknownSync(GrokSettings)({});
3536
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts deleted-455
...@@ -1,455 +0,0 @@
1import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
2import {
3 XAiPromptFailureText,
4 isXAiTaskCompletedWakeNotification,
5 xAiRateLimitedErrorCode,
6} from "../../provider/acp/XAiAcpExtension.ts";
7import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
8import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/nodeRuntime";
9import {
10 defaultInstanceIdForDriver,
11 GrokSettings,
12 ProviderDriverKind,
13 type OrchestrationV2ProviderCapabilities,
14 type RuntimeMode,
15} from "@t3tools/contracts";
16import * as Crypto from "effect/Crypto";
17import * as Effect from "effect/Effect";
18import * as FileSystem from "effect/FileSystem";
19import * as Path from "effect/Path";
20import * as Layer from "effect/Layer";
21import * as Schema from "effect/Schema";
22import type * as Scope from "effect/Scope";
23import { ChildProcessSpawner } from "effect/process";
24import * as EffectAcpErrors from "effect-acp/errors";
25
26import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
27import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging";
28import {
29 applyGrokAcpModelSelection,
30 currentGrokModelIdFromSessionSetup,
31 grokApprovalOptions,
32 makeGrokAcpRuntime,
33 resolveGrokAcpBaseModelId,
34} from "../../provider/acp/GrokAcpSupport.ts";
35import {
36 extractGrokPlanMarkdownFromToolCallData,
37 extractXAiAcpBackgroundToolMutation,
38 extractXAiAcpSubagentEndNotice,
39 extractXAiAcpSubagentUpdate,
40 extractXAiAskUserQuestionIdentity,
41 extractXAiAskUserQuestions,
42 extractXAiBackgroundTaskCompletion,
43 extractXAiKilledBackgroundTasks,
44 extractXAiMonitorTaskId,
45 isXAiPersistentMonitor,
46 extractXAiExitPlanMarkdown,
47 registerXAiSubagentFinished,
48 makeXAiAskUserQuestionCancelledResponse,
49 makeXAiAskUserQuestionResponse,
50 makeXAiExitPlanModeCapturedResponse,
51 normalizeXAiAcpToolCallState,
52 registerXAiBackgroundTaskTracking,
53 XAiAskUserQuestionRequest,
54 XAiExitPlanModeRequest,
55} from "../../provider/acp/XAiAcpExtension.ts";
56import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
57import { acpPermissionDisposition } from "@t3tools/provider-acp/server/clientPolicy";
58import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
59import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts";
60import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
61import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests";
62import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
63import {
64 ProviderAdapterDriverCreateError,
65 type ProviderAdapterDriver,
66 type ProviderAdapterDriverCreateInput,
67} from "@t3tools/provider-core/server/adapterDriver";
68import {
69 AcpProviderCapabilitiesV2,
70 makeAcpAdapterV2,
71 type AcpAdapterV2ExtensionContext,
72 type AcpAdapterV2Flavor,
73 type AcpAdapterV2RuntimeInput,
74} from "@t3tools/provider-acp/server/adapter";
75
76export const GROK_PROVIDER = ProviderDriverKind.make("grok");
77const GROK_DRIVER_KIND = GROK_PROVIDER;
78export const GROK_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(GROK_DRIVER_KIND);
79const DEFAULT_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({});
80
81export const GrokProviderCapabilitiesV2 = {
82 ...AcpProviderCapabilitiesV2,
83 sessions: {
84 ...AcpProviderCapabilitiesV2.sessions,
85 supportsModelSwitchInSession: true,
86 supportsRuntimeModeSwitchInSession: false,
87 },
88 threads: {
89 ...AcpProviderCapabilitiesV2.threads,
90 canReadThreadSnapshot: true,
91 canForkThread: false,
92 canForkFromTurn: false,
93 },
94 subagents: {
95 ...AcpProviderCapabilitiesV2.subagents,
96 supportsSubagents: true,
97 exposesSubagentThreadIds: true,
98 emitsSubagentLifecycle: true,
99 },
100 tools: {
101 ...AcpProviderCapabilitiesV2.tools,
102 supportsMcpTools: true,
103 },
104 checkpointing: {
105 ...AcpProviderCapabilitiesV2.checkpointing,
106 providerCanReadConversationSnapshot: true,
107 },
108} satisfies OrchestrationV2ProviderCapabilities;
109
110export interface GrokAdapterV2Options {
111 readonly instanceId: Parameters<typeof makeAcpAdapterV2>[0]["instanceId"];
112 readonly settings: GrokSettings;
113 readonly environment: NodeJS.ProcessEnv;
114 readonly hostPlatform: NodeJS.Platform;
115 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
116 readonly crypto: Crypto.Crypto;
117 readonly selfInvocation: SelfInvocation;
118 readonly fileSystem: FileSystem.FileSystem;
119 readonly idAllocator: IdAllocator.IdAllocatorV2["Service"];
120 readonly host: ProviderHost.ProviderHostShape;
121 readonly nativeLogging?: Parameters<typeof makeAcpAdapterV2>[0]["nativeLogging"];
122 readonly continuationRequests?: Parameters<typeof makeAcpAdapterV2>[0]["continuationRequests"];
123 readonly testHooks?: Parameters<typeof makeAcpAdapterV2>[0]["testHooks"];
124 readonly makeRuntime?: (
125 input: AcpAdapterV2RuntimeInput,
126 ) => Effect.Effect<
127 AcpSessionRuntime.AcpSessionRuntime["Service"],
128 EffectAcpErrors.AcpError,
129 Crypto.Crypto | Scope.Scope
130 >;
131 readonly assertComplete?: Effect.Effect<void, EffectAcpErrors.AcpError>;
132}
133
134const registerGrokAcpExtensions: NonNullable<AcpAdapterV2Flavor["registerExtensions"]> = ({
135 runtime,
136 requestUserInput,
137 applyBackgroundTaskMutation,
138 finishSubagent,
139 captureProposedPlan,
140 lastProposedPlanMarkdown,
141}) =>
142 registerXAiBackgroundTaskTracking(runtime, applyBackgroundTaskMutation).pipe(
143 Effect.andThen(registerXAiSubagentFinished(runtime, finishSubagent)),
144 Effect.andThen(registerGrokAskUserQuestionExtensions({ runtime, requestUserInput })),
145 Effect.andThen(
146 registerGrokExitPlanModeExtensions({
147 runtime,
148 captureProposedPlan,
149 lastProposedPlanMarkdown,
150 }),
151 ),
152 );
153
154/**
155 * Grok intercepts exit_plan_mode and reverse-requests client approval. Capture
156 * the plan into T3's proposed-plan card and abandon the native gate so the
157 * turn does not hang (#8358; mirrors the Claude ExitPlanMode pattern). Plan
158 * content preference: the request payload, then the plan.md contents sniffed
159 * from tool calls this turn, then the empty-state placeholder.
160 */
161const registerGrokExitPlanModeExtensions = ({
162 runtime,
163 captureProposedPlan,
164 lastProposedPlanMarkdown,
165}: Pick<
166 AcpAdapterV2ExtensionContext,
167 "runtime" | "captureProposedPlan" | "lastProposedPlanMarkdown"
168>) =>
169 Effect.forEach(
170 ["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"] as const,
171 (method) =>
172 runtime.handleExtRequest(method, XAiExitPlanModeRequest, (params) =>
173 Effect.gen(function* () {
174 const fallback = yield* lastProposedPlanMarkdown;
175 yield* captureProposedPlan({
176 planMarkdown: extractXAiExitPlanMarkdown(params, fallback),
177 });
178 return makeXAiExitPlanModeCapturedResponse();
179 }),
180 ),
181 { discard: true },
182 );
183
184const registerGrokAskUserQuestionExtensions = ({
185 runtime,
186 requestUserInput,
187}: Pick<AcpAdapterV2ExtensionContext, "runtime" | "requestUserInput">) =>
188 Effect.forEach(
189 ["x.ai/ask_user_question", "_x.ai/ask_user_question"] as const,
190 (method) =>
191 runtime.handleExtRequest(method, XAiAskUserQuestionRequest, (params, requestContext) => {
192 const identity = extractXAiAskUserQuestionIdentity(params);
193 const questions = extractXAiAskUserQuestions(params).map((question) => ({
194 id: question.id,
195 header: question.header,
196 question: question.question,
197 options: [...question.options],
198 }));
199 return requestUserInput(
200 {
201 nativeItemId: `${identity.sessionId}:xai-question:${identity.toolCallId}`,
202 nativeRequestId: identity.toolCallId,
203 questions,
204 },
205 requestContext,
206 ).pipe(
207 Effect.flatMap(({ acknowledgeNativeResponse, answers }) =>
208 Effect.succeed(
209 answers === null
210 ? makeXAiAskUserQuestionCancelledResponse()
211 : makeXAiAskUserQuestionResponse(params, answers),
212 ).pipe(Effect.tap(() => acknowledgeNativeResponse)),
213 ),
214 );
215 }),
216 { discard: true },
217 );
218
219/**
220 * Grok's permission mode is fixed at launch. Explicit approval or sandbox
221 * overrides launch it asking, so every mutating prompt reaches T3's policy
222 * check instead of being bypassed by always-approve or Grok's auto classifier.
223 */
224export function grokLaunchRuntimeMode(
225 runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy,
226): RuntimeMode {
227 return runtimePolicy.approvalPolicy === undefined && runtimePolicy.sandboxPolicy === undefined
228 ? runtimePolicy.runtimeMode
229 : "approval-required";
230}
231
232export function makeGrokAcpAdapterFlavor(options: GrokAdapterV2Options): AcpAdapterV2Flavor {
233 return {
234 driver: GROK_PROVIDER,
235 runtimeHarness: "Grok",
236 capabilities: GrokProviderCapabilitiesV2,
237 interruptPromptOnCancel: false,
238 // User Stop (requestRuntimeRestart) still hard-kills the process group and
239 // respawns so existing background tasks stop too. Older 0.2.x builds could
240 // detach a cancelled foreground command (E3 harness 2026-07-18); current
241 // source kills foreground work but intentionally preserves already-
242 // backgrounded tasks. Non-Stop interrupts (mid-prompt steering,
243 // restart_active) omit requestRuntimeRestart and stay soft: session/cancel
244 // carries cancelTrigger=ctrl_c, the session survives, and background work
245 // remains available to the replacement turn.
246 restartRuntimeAfterInterrupt: true,
247 terminateRuntimeProcessGroupOnInterrupt: true,
248 // Steering restarts on a settled turn additionally skip session/cancel so
249 // fire-and-forget subagents survive the steer (E1 harness confirmed the
250 // Grok CLI accepts a concurrent session/prompt in that state).
251 preserveRuntimeOnSettledInterrupt: true,
252 // Grok ACP initialize reports promptCapabilities.image:false but the agent
253 // still accepts image content blocks (verified with real screenshots).
254 supportsImagePrompts: true,
255 supportsCompaction: true,
256 resolveModelId: (selection) => resolveGrokAcpBaseModelId(selection.model),
257 applyModelSelection: ({ runtime, startResult, modelSelection }) =>
258 Effect.gen(function* () {
259 const legacy = startResult.initializeResult.protocolVersion === 1;
260 const options = legacy ? [] : yield* runtime.getConfigOptions;
261 const configuredModel = options.find((option) => option.category === "model")?.currentValue;
262 return yield* applyGrokAcpModelSelection({
263 runtime: legacy
264 ? runtime
265 : { setSessionModel: (model) => runtime.setModel(model).pipe(Effect.as({})) },
266 currentModelId: legacy
267 ? currentGrokModelIdFromSessionSetup(startResult.sessionSetupResult)
268 : typeof configuredModel === "string"
269 ? configuredModel
270 : undefined,
271 requestedModelId: resolveGrokAcpBaseModelId(modelSelection.model),
272 mapError: (cause) => cause,
273 });
274 }),
275 makeRuntime:
276 options.makeRuntime ??
277 (({ runtimePolicy, ...input }) =>
278 makeGrokAcpRuntime({
279 ...input,
280 interruptPromptOnCancel: input.interruptPromptOnCancel ?? false,
281 grokSettings: options.settings,
282 environment: options.environment,
283 childProcessSpawner: options.childProcessSpawner,
284 runtimeMode: grokLaunchRuntimeMode(runtimePolicy),
285 })),
286 // In its Auto mode Grok decides routine actions itself and only asks about
287 // what its classifier blocked, so every prompt it sends goes to the user.
288 permissionDisposition: (policy, request) =>
289 grokLaunchRuntimeMode(policy) === "auto" ? "ask" : acpPermissionDisposition(policy, request),
290 approvalOptions: grokApprovalOptions,
291 promptFailure: (cause) =>
292 makeProviderFailure({
293 cause,
294 ...(Schema.is(EffectAcpErrors.AcpRequestError)(cause)
295 ? {
296 // Grok's own failure text rides on the cause; makeProviderFailure
297 // redacts and bounds it before it reaches the user.
298 message:
299 cause.cause instanceof XAiPromptFailureText
300 ? cause.cause.message
301 : cause.errorMessage,
302 code: String(cause.code),
303 class: cause.code === xAiRateLimitedErrorCode ? "usage_limit" : "provider_error",
304 }
305 : { class: "provider_error" }),
306 }),
307 registerExtensions: registerGrokAcpExtensions,
308 extractSubagentUpdate: extractXAiAcpSubagentUpdate,
309 extractSubagentEndNotice: extractXAiAcpSubagentEndNotice,
310 normalizeToolCall: normalizeXAiAcpToolCallState,
311 // Show the plan while Grok is still writing it: plan.md writes under the
312 // Grok session dir surface as the proposed-plan card before exit (#8358).
313 extractProposedPlanMarkdown: (toolCall) =>
314 extractGrokPlanMarkdownFromToolCallData(toolCall.data, {
315 platform: options.hostPlatform,
316 environment: options.environment,
317 }),
318 extractBackgroundTaskId: extractXAiMonitorTaskId,
319 extractBackgroundToolMutation: extractXAiAcpBackgroundToolMutation,
320 extractBackgroundTaskCompletion: (toolCall) => [
321 ...extractXAiBackgroundTaskCompletion(toolCall),
322 ...extractXAiKilledBackgroundTasks(toolCall),
323 ],
324 isPersistentBackgroundTool: isXAiPersistentMonitor,
325 isProviderWakeNotification: isXAiTaskCompletedWakeNotification,
326 deferFinalizeForBackgroundWork: true,
327 enablePostSettleContinuation: true,
328 ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }),
329 };
330}
331
332export function makeGrokAdapterV2(options: GrokAdapterV2Options) {
333 const flavor = makeGrokAcpAdapterFlavor(options);
334 return makeAcpAdapterV2({
335 instanceId: options.instanceId,
336 flavor,
337 crypto: options.crypto,
338 fileSystem: options.fileSystem,
339 idAllocator: options.idAllocator,
340 host: options.host,
341 selfInvocation: options.selfInvocation,
342 ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }),
343 ...(options.continuationRequests === undefined
344 ? {}
345 : { continuationRequests: options.continuationRequests }),
346 ...(options.testHooks === undefined ? {} : { testHooks: options.testHooks }),
347 });
348}
349
350export type GrokAdapterV2DriverEnv =
351 | ChildProcessSpawner.ChildProcessSpawner
352 | Crypto.Crypto
353 | FileSystem.FileSystem
354 | IdAllocator.IdAllocatorV2
355 | Path.Path
356 | ProviderEventLoggers.ProviderEventLoggers
357 | ProviderHost.ProviderHost;
358
359export const GrokAdapterV2Driver: ProviderAdapterDriver<GrokSettings, GrokAdapterV2DriverEnv> = {
360 driverKind: GROK_DRIVER_KIND,
361 configSchema: GrokSettings,
362 defaultConfig: (): GrokSettings => DEFAULT_GROK_SETTINGS,
363 create: Effect.fn("GrokAdapterV2Driver.create")(
364 function* (input: ProviderAdapterDriverCreateInput<GrokSettings>) {
365 const hostEnvironment = yield* HostProcessEnvironment;
366 const hostPlatform = yield* HostProcessPlatform;
367 const selfInvocation = yield* resolveSelfInvocation();
368 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
369 const crypto = yield* Crypto.Crypto;
370 const fileSystem = yield* FileSystem.FileSystem;
371 const idAllocator = yield* IdAllocator.IdAllocatorV2;
372 const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers;
373 const host = yield* ProviderHost.ProviderHost;
374 const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
375 const makeNativeLogger = yield* makeAcpNativeLoggerFactory();
376 return makeGrokAdapterV2({
377 instanceId: input.instanceId,
378 settings: { ...input.config, enabled: input.enabled },
379 environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment),
380 hostPlatform,
381 childProcessSpawner,
382 crypto,
383 fileSystem,
384 idAllocator,
385 host,
386 selfInvocation,
387 continuationRequests,
388 nativeLogging: (threadId) =>
389 makeNativeLogger({
390 nativeEventLogger: providerEventLoggers.native,
391 provider: GROK_PROVIDER,
392 threadId,
393 }),
394 });
395 },
396 (effect, input) =>
397 effect.pipe(
398 Effect.mapError(
399 (cause) =>
400 new ProviderAdapterDriverCreateError({
401 driver: GROK_DRIVER_KIND,
402 instanceId: input.instanceId,
403 detail: "Failed to create Grok ACP adapter.",
404 cause,
405 }),
406 ),
407 ),
408 ),
409};
410
411const layer: Layer.Layer<
412 ProviderAdapter.ProviderAdapterV2,
413 never,
414 | Path.Path
415 | ChildProcessSpawner.ChildProcessSpawner
416 | Crypto.Crypto
417 | FileSystem.FileSystem
418 | IdAllocator.IdAllocatorV2
419 | ProviderEventLoggers.ProviderEventLoggers
420 | ProviderHost.ProviderHost
421> = Layer.effect(
422 ProviderAdapter.ProviderAdapterV2,
423 Effect.gen(function* () {
424 const hostEnvironment = yield* HostProcessEnvironment;
425 const hostPlatform = yield* HostProcessPlatform;
426 const selfInvocation = yield* resolveSelfInvocation();
427 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
428 const crypto = yield* Crypto.Crypto;
429 const fileSystem = yield* FileSystem.FileSystem;
430 const idAllocator = yield* IdAllocator.IdAllocatorV2;
431 const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers;
432 const host = yield* ProviderHost.ProviderHost;
433 const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
434 const makeNativeLogger = yield* makeAcpNativeLoggerFactory();
435 return makeGrokAdapterV2({
436 instanceId: GROK_DEFAULT_INSTANCE_ID,
437 settings: DEFAULT_GROK_SETTINGS,
438 environment: hostEnvironment,
439 hostPlatform,
440 childProcessSpawner,
441 crypto,
442 fileSystem,
443 idAllocator,
444 host,
445 selfInvocation,
446 continuationRequests,
447 nativeLogging: (threadId) =>
448 makeNativeLogger({
449 nativeEventLogger: providerEventLoggers.native,
450 provider: GROK_PROVIDER,
451 threadId,
452 }),
453 });
454 }),
455);
apps/server/src/orchestration-v2/CommandPolicy.test.ts+1-1
...@@ -13,7 +13,7 @@ import * as Effect from "effect/Effect";...@@ -13,7 +13,7 @@ import * as Effect from "effect/Effect";
1313
14import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";14import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
15import { CursorProviderCapabilitiesV2 } from "@t3tools/provider-cursor/testing";15import { CursorProviderCapabilitiesV2 } from "@t3tools/provider-cursor/testing";
16import { GrokProviderCapabilitiesV2 } from "./Adapters/GrokAdapterV2.ts";16import { GrokProviderCapabilitiesV2 } from "@t3tools/provider-grok/testing";
17import * as CommandPolicy from "./CommandPolicy.ts";17import * as CommandPolicy from "./CommandPolicy.ts";
1818
19const commandId = CommandId.make("command-policy-test");19const commandId = CommandId.make("command-policy-test");
apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts+1-1
...@@ -13,7 +13,7 @@ import {...@@ -13,7 +13,7 @@ import {
13 CursorAdapterV2Driver,13 CursorAdapterV2Driver,
14 type CursorAdapterV2DriverEnv,14 type CursorAdapterV2DriverEnv,
15} from "@t3tools/provider-cursor/server";15} from "@t3tools/provider-cursor/server";
16import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./Adapters/GrokAdapterV2.ts";16import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "@t3tools/provider-grok/server";
17import {17import {
18 OpenCodeAdapterV2Driver,18 OpenCodeAdapterV2Driver,
19 type OpenCodeAdapterV2DriverEnv,19 type OpenCodeAdapterV2DriverEnv,
apps/server/src/provider/Drivers/GrokDriver.test.ts deleted-111
...@@ -1,111 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { ProviderInstanceId } from "@t3tools/contracts";
4import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Path from "effect/Path";
9import { HttpClient } from "effect/http";
10import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
11
12import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
13import * as ServerConfig from "../../config.ts";
14import * as ServerSettings from "../../serverSettings.ts";
15import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
16import { GrokDriver } from "./GrokDriver.ts";
17
18import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
19import * as ProviderHostLive from "../ProviderHostLive.ts";
20import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";
21
22const layerDeps = ServerConfig.layerTest(process.cwd(), {
23 prefix: "t3-grok-driver-update-",
24}).pipe(
25 Layer.provideMerge(NodeServices.layer),
26 Layer.provideMerge(IdAllocator.layer),
27 Layer.provideMerge(ServerSettings.layerTest()),
28 Layer.provideMerge(
29 Layer.mock(BackgroundPolicy.BackgroundPolicy)({
30 shouldRunScopeWork: () => Effect.succeed(false),
31 }),
32 ),
33 Layer.provideMerge(
34 Layer.succeed(
35 ProviderEventLoggers.ProviderEventLoggers,
36 ProviderEventLoggers.NoOpProviderEventLoggers,
37 ),
38 ),
39 Layer.provideMerge(
40 Layer.succeed(
41 HttpClient.HttpClient,
42 HttpClient.make(() => Effect.die("Disabled Grok must not make an HTTP request")),
43 ),
44 ),
45);
46const layerTest = ProviderHostLive.layer.pipe(
47 Layer.provideMerge(ServerSecretStore.layer),
48 Layer.provideMerge(layerDeps),
49);
50
51const noSpawner = ChildProcessSpawner.make(() =>
52 Effect.die("Disabled Grok must not spawn a process"),
53);
54
55// The `#!/bin/sh` stub below cannot be resolved as an executable on Windows.
56const windowsHost = HostProcessPlatform.defaultValue() === "win32";
57
58it.layer(layerTest)("GrokDriver", (it) => {
59 it.effect.skipIf(windowsHost)("updates through the configured executable's own updater", () =>
60 Effect.gen(function* () {
61 const fs = yield* FileSystem.FileSystem;
62 const path = yield* Path.Path;
63 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-grok-driver-" });
64 const grokHome = path.join(tempDir, "Grok Home");
65 const binaryPath = path.join(grokHome, "bin", "grok");
66 yield* fs.makeDirectory(path.dirname(binaryPath), { recursive: true });
67 yield* fs.writeFileString(binaryPath, "#!/bin/sh\n");
68 yield* fs.chmod(binaryPath, 0o755);
69
70 const instance = yield* GrokDriver.create({
71 instanceId: ProviderInstanceId.make("grok-update"),
72 displayName: "Grok test",
73 enabled: false,
74 environment: [{ name: "GROK_HOME", value: grokHome, sensitive: false }],
75 config: { ...GrokDriver.defaultConfig(), binaryPath },
76 });
77
78 const capabilities = yield* instance.snapshot.resolveMaintenance();
79 expect(capabilities.packageName).toBe("@xai-official/grok");
80 expect(capabilities.update).toMatchObject({
81 command: `'${binaryPath}' update`,
82 executable: binaryPath,
83 args: ["update"],
84 });
85 // `grok update` installs under GROK_HOME, so it must target this instance's home.
86 expect(capabilities.update?.env?.GROK_HOME).toBe(grokHome);
87 }).pipe(
88 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawner),
89 Effect.scoped,
90 ),
91 );
92
93 it.effect("stays manual-only when the configured executable does not exist", () =>
94 Effect.gen(function* () {
95 const fs = yield* FileSystem.FileSystem;
96 const path = yield* Path.Path;
97 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-grok-missing-" });
98 const instance = yield* GrokDriver.create({
99 instanceId: ProviderInstanceId.make("grok-missing"),
100 displayName: "Grok test",
101 enabled: false,
102 environment: [],
103 config: { ...GrokDriver.defaultConfig(), binaryPath: path.join(tempDir, "grok") },
104 });
105 expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull();
106 }).pipe(
107 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawner),
108 Effect.scoped,
109 ),
110 );
111});
apps/server/src/provider/Drivers/GrokDriver.ts deleted-231
...@@ -1,231 +0,0 @@
1import { GrokSettings, ProviderDriverKind } from "@t3tools/contracts";
2import * as Crypto from "effect/Crypto";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as Schema from "effect/Schema";
7import { HttpClient } from "effect/http";
8import { ChildProcessSpawner } from "effect/process";
9
10import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost";
11import * as ServerConfig from "../../config.ts";
12import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
13import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts";
14import {
15 GrokAdapterV2Driver,
16 type GrokAdapterV2DriverEnv,
17} from "../../orchestration-v2/Adapters/GrokAdapterV2.ts";
18import { ProviderDriverError } from "../Errors.ts";
19import {
20 buildInitialGrokProviderSnapshot,
21 checkGrokProviderStatus,
22 enrichGrokSnapshot,
23} from "../GrokProvider.ts";
24import { readGrokAccount } from "../grokUsageLimits.ts";
25import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";
26import {
27 defaultProviderContinuationIdentity,
28 type ProviderDriver,
29 type ProviderInstance,
30} from "@t3tools/provider-core/server/driver";
31import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity";
32import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
33import { discoverGrokSkills } from "./GrokSkills.ts";
34import {
35 makeCachedProviderMaintenanceResolution,
36 makeManualOnlyProviderMaintenanceCapabilities,
37 makeProviderMaintenanceCapabilities,
38 type ProviderMaintenanceCapabilitiesResolver,
39 resolveProviderMaintenanceCapabilitiesEffect,
40} from "@t3tools/provider-core/server/maintenanceResolver";
41import {
42 haveProviderSnapshotSettingsChanged,
43 makeProviderSnapshotSettingsSource,
44 type ProviderSnapshotSettings,
45} from "@t3tools/provider-core/server/snapshotSettings";
46const decodeGrokSettings = Schema.decodeSync(GrokSettings);
47
48const DRIVER_KIND = ProviderDriverKind.make("grok");
49// npm's `latest` tracks Grok's stable channel, the one `grok update` installs
50// by default, so the registry stays the source for "latest".
51const GROK_NPM_PACKAGE = "@xai-official/grok";
52// `grok update` finds the installer that owns the binary itself, so the
53// resolved executable is its own updater. It installs under `GROK_HOME`, so it
54// runs with the instance's environment. No executable means nothing to update,
55// not "whatever is on PATH".
56const UPDATE: ProviderMaintenanceCapabilitiesResolver = {
57 resolve: (context) =>
58 Effect.succeed(
59 context
60 ? makeProviderMaintenanceCapabilities({
61 provider: DRIVER_KIND,
62 packageName: GROK_NPM_PACKAGE,
63 updateExecutable: context.resolvedCommandPath,
64 updateArgs: ["update"],
65 updateLockKey: "grok",
66 platform: context.platform,
67 env: context.env,
68 })
69 : makeManualOnlyProviderMaintenanceCapabilities({
70 provider: DRIVER_KIND,
71 packageName: GROK_NPM_PACKAGE,
72 }),
73 ),
74};
75
76export type GrokDriverEnv =
77 | GrokAdapterV2DriverEnv
78 | ProviderHost
79 | ChildProcessSpawner.ChildProcessSpawner
80 | Crypto.Crypto
81 | FileSystem.FileSystem
82 | HttpClient.HttpClient
83 | Path.Path
84 | ProviderEventLoggers.ProviderEventLoggers
85 | ServerConfig.ServerConfig;
86
87export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
88 driverKind: DRIVER_KIND,
89 metadata: {
90 displayName: "Grok",
91 supportsMultipleInstances: true,
92 },
93 configSchema: GrokSettings,
94 defaultConfig: (): GrokSettings => decodeGrokSettings({}),
95 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
96 Effect.gen(function* () {
97 const crypto = yield* Crypto.Crypto;
98 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
99 const httpClient = yield* HttpClient.HttpClient;
100 const fileSystem = yield* FileSystem.FileSystem;
101 const path = yield* Path.Path;
102 const host = yield* ProviderHost;
103 const { cwd } = yield* ServerConfig.ServerConfig;
104 const processEnv = mergeProviderInstanceEnvironment(environment);
105 const continuationIdentity = defaultProviderContinuationIdentity({
106 driverKind: DRIVER_KIND,
107 instanceId,
108 });
109 const stampIdentity = withInstanceIdentity({
110 instanceId,
111 driverKind: DRIVER_KIND,
112 displayName,
113 accentColor,
114 continuationGroupKey: continuationIdentity.continuationKey,
115 });
116 const effectiveConfig = { ...config, enabled } satisfies GrokSettings;
117 const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(
118 resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
119 binaryPath: effectiveConfig.binaryPath,
120 env: processEnv,
121 }).pipe(
122 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
123 Effect.provideService(FileSystem.FileSystem, fileSystem),
124 Effect.provideService(Path.Path, path),
125 ),
126 );
127 const orchestrationAdapter = yield* GrokAdapterV2Driver.create({
128 instanceId,
129 displayName,
130 accentColor,
131 environment,
132 enabled,
133 config,
134 }).pipe(
135 Effect.mapError(
136 (cause) =>
137 new ProviderDriverError({
138 driver: DRIVER_KIND,
139 instanceId,
140 detail: "Failed to build Grok orchestration adapter.",
141 cause,
142 }),
143 ),
144 );
145 const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv);
146
147 const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe(
148 Effect.flatMap((snapshot) =>
149 effectiveConfig.enabled && snapshot.installed && snapshot.auth.status === "authenticated"
150 ? readGrokAccount(processEnv).pipe(
151 Effect.map(({ email, usageLimits }) => ({
152 ...snapshot,
153 auth: email ? { ...snapshot.auth, email } : snapshot.auth,
154 usageLimits,
155 })),
156 )
157 : Effect.succeed(snapshot),
158 ),
159 Effect.map(stampIdentity),
160 Effect.provideService(HttpClient.HttpClient, httpClient),
161 Effect.provideService(FileSystem.FileSystem, fileSystem),
162 Effect.provideService(Path.Path, path),
163 Effect.provideService(Crypto.Crypto, crypto),
164 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
165 );
166
167 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings);
168 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<GrokSettings>>({
169 resolveMaintenance,
170 getSettings: snapshotSettings.getSettings,
171 streamSettings: snapshotSettings.streamSettings,
172 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
173 initialSnapshot: (settings) =>
174 buildInitialGrokProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)),
175 checkProvider,
176 enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>
177 resolveMaintenance().pipe(
178 Effect.flatMap((maintenanceCapabilities) =>
179 enrichGrokSnapshot({
180 snapshot: currentSnapshot,
181 maintenanceCapabilities,
182 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
183 publishSnapshot,
184 httpClient,
185 }),
186 ),
187 ),
188 }).pipe(
189 Effect.mapError(
190 (cause) =>
191 new ProviderDriverError({
192 driver: DRIVER_KIND,
193 instanceId,
194 detail: `Failed to build Grok snapshot: ${cause.message ?? String(cause)}`,
195 cause,
196 }),
197 ),
198 );
199 const snapshotForCwd = (workspaceCwd: string) =>
200 !effectiveConfig.enabled
201 ? snapshot.getSnapshot
202 : Effect.all([
203 snapshot.getSnapshot,
204 discoverGrokSkills(effectiveConfig, processEnv, workspaceCwd).pipe(
205 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
206 Effect.mapError(
207 (cause) =>
208 new ProviderDriverError({
209 driver: DRIVER_KIND,
210 instanceId,
211 detail: `Failed to discover Grok skills for '${workspaceCwd}'`,
212 cause,
213 }),
214 ),
215 ),
216 ]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })));
217
218 return {
219 instanceId,
220 driverKind: DRIVER_KIND,
221 continuationIdentity,
222 displayName,
223 accentColor,
224 enabled,
225 snapshot,
226 snapshotForCwd,
227 orchestrationAdapter,
228 textGeneration,
229 } satisfies ProviderInstance;
230 }),
231};
apps/server/src/provider/Drivers/GrokSkills.test.ts deleted-174
...@@ -1,174 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Sink from "effect/Sink";
4import * as Stream from "effect/Stream";
5import { ChildProcessSpawner } from "effect/process";
6
7import { discoverGrokSkills } from "./GrokSkills.ts";
8
9const inspectPayload = (skills: ReadonlyArray<unknown>) => JSON.stringify({ skills });
10
11const makeInspectSpawner = (stdout: string, exitCode = 0, spawnCwds?: Array<string | undefined>) =>
12 ChildProcessSpawner.make((command) => {
13 spawnCwds?.push(command._tag === "StandardCommand" ? command.options.cwd : undefined);
14 return Effect.succeed(
15 ChildProcessSpawner.makeHandle({
16 pid: ChildProcessSpawner.ProcessId(1),
17 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),
18 isRunning: Effect.succeed(false),
19 kill: () => Effect.void,
20 unref: Effect.succeed(Effect.void),
21 stdin: Sink.drain,
22 stdout: Stream.encodeText(Stream.make(stdout)),
23 stderr: Stream.empty,
24 all: Stream.empty,
25 getInputFd: () => Sink.drain,
26 getOutputFd: () => Stream.empty,
27 }),
28 );
29 });
30
31describe("discoverGrokSkills", () => {
32 it.effect("maps inspect entries onto provider skills, sorted by name", () =>
33 Effect.gen(function* () {
34 const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {});
35
36 expect(skills).toEqual([
37 {
38 name: "deploy",
39 description: "Deploy the app.",
40 path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md",
41 scope: "plugin",
42 enabled: true,
43 },
44 {
45 name: "writing-docs",
46 description: "Write user docs.",
47 path: "/home/dev/.grok/skills/writing-docs/SKILL.md",
48 scope: "user",
49 enabled: true,
50 },
51 ]);
52 }).pipe(
53 Effect.provideService(
54 ChildProcessSpawner.ChildProcessSpawner,
55 makeInspectSpawner(
56 inspectPayload([
57 {
58 name: "writing-docs",
59 description: "Write user docs.",
60 source: { type: "user", path: "/home/dev/.grok/skills/writing-docs/SKILL.md" },
61 userInvocable: true,
62 },
63 {
64 name: "deploy",
65 description: "Deploy the app.",
66 source: {
67 type: "plugin",
68 path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md",
69 },
70 userInvocable: true,
71 },
72 ]),
73 ),
74 ),
75 ),
76 );
77
78 it.effect("disables skills the CLI marks as not user-invocable", () =>
79 Effect.gen(function* () {
80 const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {});
81
82 expect(skills).toEqual([
83 {
84 name: "internal-helper",
85 path: "/opt/grok/bundled/skills/internal-helper/SKILL.md",
86 scope: "bundled",
87 enabled: false,
88 },
89 ]);
90 }).pipe(
91 Effect.provideService(
92 ChildProcessSpawner.ChildProcessSpawner,
93 makeInspectSpawner(
94 inspectPayload([
95 {
96 name: "internal-helper",
97 source: {
98 type: "bundled",
99 path: "/opt/grok/bundled/skills/internal-helper/SKILL.md",
100 },
101 userInvocable: false,
102 },
103 ]),
104 ),
105 ),
106 ),
107 );
108
109 it.effect("skips entries without a name or a filesystem path", () =>
110 Effect.gen(function* () {
111 const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {});
112 expect(skills.map((skill) => skill.name)).toEqual(["kept"]);
113 }).pipe(
114 Effect.provideService(
115 ChildProcessSpawner.ChildProcessSpawner,
116 makeInspectSpawner(
117 inspectPayload([
118 { name: " ", source: { type: "user", path: "/tmp/skills/a/SKILL.md" } },
119 { name: "no-path", source: { type: "user" } },
120 { name: "no-source" },
121 "not-an-object",
122 { name: "kept", source: { type: "project", path: "/repo/.grok/skills/kept/SKILL.md" } },
123 ]),
124 ),
125 ),
126 ),
127 );
128
129 it.effect("rejects malformed or unexpected output as a decode failure", () =>
130 Effect.gen(function* () {
131 for (const stdout of ["not json", "null", '{"skills":"nope"}', "{}"]) {
132 const error = yield* discoverGrokSkills({ binaryPath: "grok" }, {}).pipe(
133 Effect.flip,
134 Effect.provideService(
135 ChildProcessSpawner.ChildProcessSpawner,
136 makeInspectSpawner(stdout),
137 ),
138 );
139 expect(error).toMatchObject({ _tag: "GrokSkillsProbeError", stage: "decode" });
140 }
141 }),
142 );
143
144 it.effect("spawns in the configured cwd and rejects a failed probe", () => {
145 const spawnCwds: Array<string | undefined> = [];
146 const stdout = inspectPayload([
147 {
148 name: "kept",
149 source: { type: "project", path: "/workspaces/demo/.grok/skills/kept/SKILL.md" },
150 },
151 ]);
152
153 return Effect.gen(function* () {
154 const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}, "/workspaces/demo").pipe(
155 Effect.provideService(
156 ChildProcessSpawner.ChildProcessSpawner,
157 makeInspectSpawner(stdout, 0, spawnCwds),
158 ),
159 );
160
161 expect(spawnCwds).toEqual(["/workspaces/demo"]);
162 expect(skills.map((skill) => skill.name)).toEqual(["kept"]);
163
164 const failed = yield* discoverGrokSkills({ binaryPath: "grok" }).pipe(
165 Effect.result,
166 Effect.provideService(
167 ChildProcessSpawner.ChildProcessSpawner,
168 makeInspectSpawner(stdout, 1),
169 ),
170 );
171 expect(failed._tag).toBe("Failure");
172 });
173 });
174});
apps/server/src/provider/Drivers/GrokSkills.ts deleted-151
...@@ -1,151 +0,0 @@
1/**
2 * GrokSkills — skill discovery for the `$` picker via `grok inspect --json`.
3 *
4 * Unlike Claude Code, the Grok CLI reports its full skill catalog itself:
5 * `grok inspect --json` returns `skills[]` with `name`, `description`,
6 * `source.type` (`user` / `project` / `bundled` / `plugin`), `source.path`
7 * (the absolute `SKILL.md` path), and `userInvocable`. Asking the CLI beats
8 * scanning the filesystem because the catalog honors Grok's own skill config
9 * (ignore lists, disabled skills) and includes plugin skills, which live
10 * three levels deep under `~/.grok/installed-plugins/` where a flat scan
11 * cannot see them. This mirrors how the Codex app-server reports skills over
12 * `skills/list`. Probe failures stay typed so workspace snapshots do not
13 * cache an empty catalog; machine-level discovery recovers them to an empty
14 * list without degrading the provider.
15 *
16 * @module provider/Drivers/GrokSkills
17 */
18import type { GrokSettings, ServerProviderSkill } from "@t3tools/contracts";
19import * as Effect from "effect/Effect";
20import * as Option from "effect/Option";
21import * as Schema from "effect/Schema";
22import { ChildProcess } from "effect/process";
23import { resolveSpawnCommand } from "@t3tools/shared/shell";
24
25import { spawnAndCollect } from "@t3tools/provider-core/server/snapshotProbe";
26
27const GROK_SKILLS_PROBE_TIMEOUT_MS = 4_000;
28
29class GrokSkillsProbeError extends Schema.TaggedError<GrokSkillsProbeError>()(
30 "GrokSkillsProbeError",
31 {
32 stage: Schema.Literals(["spawn", "timeout", "exit", "decode"]),
33 cwd: Schema.optional(Schema.String),
34 exitCode: Schema.optional(Schema.Number),
35 cause: Schema.optional(Schema.Defect()),
36 },
37) {
38 override get message(): string {
39 const location = this.cwd === undefined ? "" : ` for '${this.cwd}'`;
40 const exitCode = this.exitCode === undefined ? "" : ` with exit code ${this.exitCode}`;
41 return `\`grok inspect --json\` failed during ${this.stage}${location}${exitCode}.`;
42 }
43}
44
45/**
46 * Map `grok inspect --json` output onto provider skills. Entries without a
47 * name or a filesystem path are skipped; `userInvocable: false` skills are
48 * kept but disabled so pickers that filter on `enabled` hide them.
49 */
50function decodeGrokInspectSkills(stdout: string): ReadonlyArray<ServerProviderSkill> | undefined {
51 let parsed: unknown;
52 try {
53 parsed = JSON.parse(stdout);
54 } catch {
55 return undefined;
56 }
57 if (typeof parsed !== "object" || parsed === null) {
58 return undefined;
59 }
60 const entries = (parsed as Record<string, unknown>).skills;
61 if (!Array.isArray(entries)) {
62 return undefined;
63 }
64
65 const skillsByName = new Map<string, ServerProviderSkill>();
66 for (const entry of entries) {
67 if (typeof entry !== "object" || entry === null) {
68 continue;
69 }
70 const record = entry as Record<string, unknown>;
71 const name = typeof record.name === "string" ? record.name.trim() : "";
72 const source =
73 typeof record.source === "object" && record.source !== null
74 ? (record.source as Record<string, unknown>)
75 : undefined;
76 const path = typeof source?.path === "string" ? source.path.trim() : "";
77 if (!name || !path) {
78 continue;
79 }
80 const scope = typeof source?.type === "string" ? source.type.trim() : "";
81 const description = typeof record.description === "string" ? record.description.trim() : "";
82 skillsByName.set(name, {
83 name,
84 path,
85 enabled: record.userInvocable !== false,
86 ...(scope ? { scope } : {}),
87 ...(description ? { description } : {}),
88 });
89 }
90
91 return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name));
92}
93
94/**
95 * Run `grok inspect --json` and map the reported catalog onto provider
96 * skills. Callers that need best-effort discovery can recover this effect to
97 * an empty list; workspace callers leave failures typed so they are not cached.
98 */
99export const discoverGrokSkills = Effect.fn("discoverGrokSkills")(function* (
100 grokSettings: Pick<GrokSettings, "binaryPath">,
101 environment: NodeJS.ProcessEnv = process.env,
102 cwd?: string,
103) {
104 const command = grokSettings.binaryPath || "grok";
105 const inspectResult = yield* Effect.gen(function* () {
106 const spawnCommand = yield* resolveSpawnCommand(command, ["inspect", "--json"], {
107 env: environment,
108 });
109 return yield* spawnAndCollect(
110 command,
111 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
112 ...(cwd ? { cwd } : {}),
113 env: environment,
114 shell: spawnCommand.shell,
115 }),
116 );
117 }).pipe(
118 Effect.mapError(
119 (cause) =>
120 new GrokSkillsProbeError({
121 stage: "spawn",
122 ...(cwd ? { cwd } : {}),
123 cause,
124 }),
125 ),
126 Effect.timeoutOption(GROK_SKILLS_PROBE_TIMEOUT_MS),
127 );
128
129 if (Option.isNone(inspectResult)) {
130 return yield* new GrokSkillsProbeError({
131 stage: "timeout",
132 ...(cwd ? { cwd } : {}),
133 });
134 }
135 const output = inspectResult.value;
136 if (output.code !== 0) {
137 return yield* new GrokSkillsProbeError({
138 stage: "exit",
139 ...(cwd ? { cwd } : {}),
140 exitCode: output.code,
141 });
142 }
143 const skills = decodeGrokInspectSkills(output.stdout);
144 if (!skills) {
145 return yield* new GrokSkillsProbeError({
146 stage: "decode",
147 ...(cwd ? { cwd } : {}),
148 });
149 }
150 return skills;
151});
apps/server/src/provider/GrokProvider.test.ts+3-3
...@@ -8,7 +8,7 @@ import * as Effect from "effect/Effect";...@@ -8,7 +8,7 @@ import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";8import * as FileSystem from "effect/FileSystem";
9import * as Schema from "effect/Schema";9import * as Schema from "effect/Schema";
10import { HttpClient, HttpClientResponse } from "effect/http";10import { HttpClient, HttpClientResponse } from "effect/http";
11import { GrokSettings } from "@t3tools/contracts";11import { GrokSettings } from "@t3tools/provider-grok/settings";
1212
13import {13import {
14 buildGrokModelCapabilities,14 buildGrokModelCapabilities,
...@@ -17,9 +17,9 @@ import {...@@ -17,9 +17,9 @@ import {
17 checkGrokProviderStatus,17 checkGrokProviderStatus,
18 grokSlashCommandsFromInitialize,18 grokSlashCommandsFromInitialize,
19 parseGrokModelsCliOutput,19 parseGrokModelsCliOutput,
20} from "./GrokProvider.ts";20} from "@t3tools/provider-grok/testing";
21import { execScriptSource, writeFakeCli } from "@t3tools/provider-testing/fakeCli";21import { execScriptSource, writeFakeCli } from "@t3tools/provider-testing/fakeCli";
22import { grokUsageResponseToLimits, readGrokAccount } from "./grokUsageLimits.ts";22import { grokUsageResponseToLimits, readGrokAccount } from "@t3tools/provider-grok/testing";
2323
24const decodeGrokSettings = Schema.decodeSync(GrokSettings);24const decodeGrokSettings = Schema.decodeSync(GrokSettings);
25const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));25const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
apps/server/src/provider/GrokProvider.ts deleted-586
...@@ -1,586 +0,0 @@
1import {
2 type CustomModelSetting,
3 type GrokSettings,
4 type ModelCapabilities,
5 type ServerProvider,
6 type ServerProviderAuth,
7 type ServerProviderModel,
8 type ServerProviderSlashCommand,
9} from "@t3tools/contracts";
10import type * as EffectAcpSchema from "effect-acp/compat";
11import { causeErrorTag } from "@t3tools/shared/observability";
12import * as Crypto from "effect/Crypto";
13import * as DateTime from "effect/DateTime";
14import * as Effect from "effect/Effect";
15import * as Exit from "effect/Exit";
16import * as Option from "effect/Option";
17import * as Result from "effect/Result";
18import * as Schema from "effect/Schema";
19import { HttpClient } from "effect/http";
20import { ChildProcess, ChildProcessSpawner } from "effect/process";
21import { createModelCapabilities } from "@t3tools/shared/model";
22import { resolveSpawnCommand } from "@t3tools/shared/shell";
23
24import {
25 AUTH_PROBE_TIMEOUT_MS,
26 buildServerProvider,
27 COMPACT_SLASH_COMMAND,
28 isCommandMissingCause,
29 parseGenericCliVersion,
30 providerModelsFromSettings,
31 spawnAndCollect,
32 type ServerProviderDraft,
33} from "@t3tools/provider-core/server/snapshotProbe";
34import {
35 enrichProviderSnapshotWithVersionAdvisory,
36 type ProviderMaintenanceCapabilities,
37} from "@t3tools/provider-core/server/maintenanceResolver";
38import {
39 GROK_DEFAULT_MODEL_SLUG,
40 GROK_SUPPORTED_RUNTIME_MODES,
41 isValidGrokReasoningEffortToken,
42 makeGrokAcpRuntime,
43 resolveGrokAcpBaseModelId,
44} from "./acp/GrokAcpSupport.ts";
45import { sessionModelStateFromInitialize } from "@t3tools/provider-acp/server/runtimeModel";
46import { discoverGrokSkills } from "./Drivers/GrokSkills.ts";
47
48const GROK_PRESENTATION = {
49 displayName: "Grok",
50 supportsConversationRollback: false,
51 showInteractionModeToggle: false,
52 supportedRuntimeModes: GROK_SUPPORTED_RUNTIME_MODES,
53} as const;
54const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
55 optionDescriptors: [],
56});
57
58const VERSION_PROBE_TIMEOUT_MS = 4_000;
59// `initialize` is a single local round trip, so this is generous even on slow machines.
60const GROK_ACP_INITIALIZE_TIMEOUT_MS = 8_000;
61const GROK_API_KEY_ENV = "XAI_API_KEY";
62
63const GROK_BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
64 {
65 slug: GROK_DEFAULT_MODEL_SLUG,
66 name: "Grok Build",
67 isCustom: false,
68 capabilities: EMPTY_CAPABILITIES,
69 },
70];
71
72export function buildInitialGrokProviderSnapshot(
73 grokSettings: GrokSettings,
74): Effect.Effect<ServerProviderDraft> {
75 return Effect.gen(function* () {
76 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
77 const models = grokModelsFromSettings(grokSettings.customModels);
78
79 if (!grokSettings.enabled) {
80 return buildServerProvider({
81 presentation: GROK_PRESENTATION,
82 enabled: false,
83 checkedAt,
84 models,
85 probe: {
86 installed: false,
87 version: null,
88 status: "warning",
89 auth: { status: "unknown" },
90 message: "Grok is disabled in T3 Code settings.",
91 },
92 });
93 }
94
95 return buildServerProvider({
96 presentation: GROK_PRESENTATION,
97 enabled: true,
98 checkedAt,
99 models,
100 probe: {
101 installed: true,
102 version: null,
103 status: "warning",
104 auth: { status: "unknown" },
105 message: "Checking Grok CLI availability...",
106 },
107 });
108 });
109}
110
111function grokModelsFromSettings(
112 customModels: ReadonlyArray<CustomModelSetting> | undefined,
113 builtInModels: ReadonlyArray<ServerProviderModel> = GROK_BUILT_IN_MODELS,
114): ReadonlyArray<ServerProviderModel> {
115 return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES);
116}
117
118function nonEmptyString(value: unknown): string | undefined {
119 return typeof value === "string" ? value.trim() || undefined : undefined;
120}
121
122function isRecord(value: unknown): value is Record<string, unknown> {
123 return typeof value === "object" && value !== null && !Array.isArray(value);
124}
125
126function grokReasoningOptionsFromModel(model: EffectAcpSchema.ModelInfo): {
127 readonly options: ReadonlyArray<{
128 value: string;
129 label: string;
130 description?: string;
131 isDefault?: boolean;
132 }>;
133 readonly currentValue: string | undefined;
134} {
135 const meta = model._meta;
136 if (!meta || meta.supportsReasoningEffort === false) {
137 return { options: [], currentValue: undefined };
138 }
139
140 const currentEffort = nonEmptyString(meta.reasoningEffort);
141 const advertisedOptions = Array.isArray(meta.reasoningEfforts) ? meta.reasoningEfforts : [];
142 const seen = new Set<string>();
143 const options: Array<{
144 value: string;
145 label: string;
146 description?: string;
147 advertisedDefault: boolean;
148 }> = [];
149
150 for (const entry of advertisedOptions) {
151 if (!isRecord(entry)) {
152 continue;
153 }
154 const rawValue = nonEmptyString(entry.value);
155 const rawId = nonEmptyString(entry.id);
156 const value =
157 rawValue && isValidGrokReasoningEffortToken(rawValue)
158 ? rawValue
159 : rawId && isValidGrokReasoningEffortToken(rawId)
160 ? rawId
161 : undefined;
162 if (value === undefined || seen.has(value)) {
163 continue;
164 }
165 seen.add(value);
166 const description = nonEmptyString(entry.description);
167 options.push({
168 value,
169 label: nonEmptyString(entry.label) ?? value,
170 ...(description ? { description } : {}),
171 advertisedDefault: entry.default === true || entry.isDefault === true,
172 });
173 }
174
175 const currentValue =
176 currentEffort && options.some((option) => option.value === currentEffort)
177 ? currentEffort
178 : undefined;
179 const advertisedDefaults = options.filter((option) => option.advertisedDefault);
180 const selectedDefault =
181 advertisedDefaults.find((option) => option.value === currentValue)?.value ??
182 advertisedDefaults[0]?.value;
183 return {
184 options: options.map(({ value, label, description }) => ({
185 value,
186 label,
187 ...(description ? { description } : {}),
188 ...(value === selectedDefault ? { isDefault: true } : {}),
189 })),
190 currentValue: currentValue ?? selectedDefault,
191 };
192}
193
194export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): ModelCapabilities {
195 const reasoning = grokReasoningOptionsFromModel(model);
196 return reasoning.options.length > 0
197 ? createModelCapabilities({
198 optionDescriptors: [
199 {
200 id: "reasoningEffort",
201 label: "Reasoning",
202 type: "select",
203 options: reasoning.options.map((option) => ({
204 id: option.value,
205 label: option.label,
206 ...(option.description ? { description: option.description } : {}),
207 ...(option.isDefault ? { isDefault: true } : {}),
208 })),
209 ...(reasoning.currentValue ? { currentValue: reasoning.currentValue } : {}),
210 },
211 ],
212 })
213 : EMPTY_CAPABILITIES;
214}
215
216/** Models advertised by the ACP agent, with the session's current model marked as default. */
217export function buildGrokModelsFromSessionModelState(
218 modelState: EffectAcpSchema.SessionModelState | null | undefined,
219): ReadonlyArray<ServerProviderModel> {
220 if (!modelState || modelState.availableModels.length === 0) {
221 return [];
222 }
223 const currentModelId = modelState.currentModelId.trim();
224 const seen = new Set<string>();
225 return modelState.availableModels.flatMap((model): ServerProviderModel[] => {
226 const slug = resolveGrokAcpBaseModelId(model.modelId);
227 if (!slug || seen.has(slug)) {
228 return [];
229 }
230 seen.add(slug);
231 return [
232 {
233 slug,
234 name: model.name.trim() || slug,
235 isCustom: false,
236 ...(model.modelId.trim() === currentModelId ? { isDefault: true } : {}),
237 capabilities: buildGrokModelCapabilities(model),
238 },
239 ];
240 });
241}
242
243export interface GrokModelsCliOutput {
244 /** True or false when the CLI printed a login line, null when it printed neither. */
245 readonly authenticated: boolean | null;
246 readonly models: ReadonlyArray<ServerProviderModel>;
247}
248
249/**
250 * Parses `grok models`. The command exits 0 whether or not the user is logged in, so the
251 * text is the only signal. Current output looks like:
252 *
253 * You are logged in with grok.com.
254 * Default model: grok-4.6
255 * Available models:
256 * * grok-4.6 (default)
257 * - grok-4.5
258 */
259export function parseGrokModelsCliOutput(output: string): GrokModelsCliOutput {
260 const authenticated = /you are logged in/i.test(output)
261 ? true
262 : /not authenticated|not logged in/i.test(output)
263 ? false
264 : null;
265
266 const seen = new Set<string>();
267 const models: ServerProviderModel[] = [];
268 for (const line of output.split(/\r?\n/)) {
269 const bullet = line.match(/^\s*[*-]\s+(\S+)(.*)$/);
270 if (!bullet?.[1]) {
271 continue;
272 }
273 const slug = resolveGrokAcpBaseModelId(bullet[1]);
274 if (seen.has(slug)) {
275 continue;
276 }
277 seen.add(slug);
278 models.push({
279 slug,
280 name: displayNameFromGrokModelSlug(slug),
281 isCustom: false,
282 ...(/\(default\)/i.test(bullet[2] ?? "") ? { isDefault: true } : {}),
283 capabilities: EMPTY_CAPABILITIES,
284 });
285 }
286 return { authenticated, models };
287}
288
289function displayNameFromGrokModelSlug(slug: string): string {
290 return slug
291 .split(/[-_]/g)
292 .map((part) => (part.toLowerCase() === "grok" ? "Grok" : part))
293 .join(" ");
294}
295
296const runGrokCliCommand = (
297 grokSettings: GrokSettings,
298 args: ReadonlyArray<string>,
299 environment: NodeJS.ProcessEnv,
300) =>
301 Effect.gen(function* () {
302 const command = grokSettings.binaryPath || "grok";
303 const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment });
304 return yield* spawnAndCollect(
305 command,
306 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
307 env: environment,
308 shell: spawnCommand.shell,
309 }),
310 );
311 });
312
313const decodeAvailableCommands = Schema.decodeUnknownOption(Schema.Array(Schema.Unknown));
314const decodeAvailableCommand = Schema.decodeUnknownOption(
315 Schema.Struct({
316 name: Schema.String,
317 description: Schema.String,
318 input: Schema.optional(Schema.NullOr(Schema.Struct({ hint: Schema.String }))),
319 }),
320);
321
322export function grokSlashCommandsFromInitialize(
323 initialized: EffectAcpSchema.InitializeResponse,
324): ReadonlyArray<ServerProviderSlashCommand> {
325 const commands = decodeAvailableCommands(initialized._meta?.availableCommands);
326 const byName = new Map<string, ServerProviderSlashCommand>([
327 [COMPACT_SLASH_COMMAND.name, COMPACT_SLASH_COMMAND],
328 ]);
329 for (const entry of Option.getOrElse(commands, () => [])) {
330 const decoded = decodeAvailableCommand(entry);
331 if (Option.isNone(decoded)) continue;
332 const command = decoded.value;
333 const name = command.name.trim();
334 // Permission changes must go through T3 so the client and provider agree.
335 if (!name || name.toLowerCase() === "always-approve") continue;
336 // Grok advertises /context, but its ACP handler completes without emitting output.
337 if (name.toLowerCase() === "context") continue;
338 const description = command.description.trim();
339 const hint = command.input?.hint.trim();
340 byName.set(name, {
341 name,
342 ...(description ? { description } : {}),
343 ...(hint ? { input: { hint } } : {}),
344 });
345 }
346 return [...byName.values()];
347}
348
349/**
350 * Reads model and command metadata from `initialize._meta`. This never calls `authenticate`
351 * or `session/new`, so it cannot open a browser login or boot the workspace's MCP servers.
352 */
353const discoverGrokMetadataViaAcpInitialize = (
354 grokSettings: GrokSettings,
355 environment: NodeJS.ProcessEnv,
356) =>
357 Effect.gen(function* () {
358 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
359 const acp = yield* makeGrokAcpRuntime({
360 grokSettings,
361 environment,
362 childProcessSpawner,
363 cwd: process.cwd(),
364 clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" },
365 });
366 const initialized = yield* acp.initialize();
367 return {
368 models: buildGrokModelsFromSessionModelState(sessionModelStateFromInitialize(initialized)),
369 slashCommands: grokSlashCommandsFromInitialize(initialized),
370 };
371 }).pipe(Effect.scoped);
372
373export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* (
374 grokSettings: GrokSettings,
375 environment: NodeJS.ProcessEnv = process.env,
376 cwd?: string,
377): Effect.fn.Return<
378 ServerProviderDraft,
379 never,
380 ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto
381> {
382 const checkedAt = DateTime.formatIso(yield* DateTime.now);
383 const fallbackModels = grokModelsFromSettings(grokSettings.customModels);
384
385 if (!grokSettings.enabled) {
386 return buildServerProvider({
387 presentation: GROK_PRESENTATION,
388 enabled: false,
389 checkedAt,
390 models: fallbackModels,
391 probe: {
392 installed: false,
393 version: null,
394 status: "warning",
395 auth: { status: "unknown" },
396 message: "Grok is disabled in T3 Code settings.",
397 },
398 });
399 }
400
401 const versionResult = yield* runGrokCliCommand(grokSettings, ["--version"], environment).pipe(
402 Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS),
403 Effect.result,
404 );
405
406 if (Result.isFailure(versionResult)) {
407 const error = versionResult.failure;
408 yield* Effect.logWarning("Grok CLI health check failed.", {
409 errorTag: error._tag,
410 });
411 return buildServerProvider({
412 presentation: GROK_PRESENTATION,
413 enabled: grokSettings.enabled,
414 checkedAt,
415 models: fallbackModels,
416 probe: {
417 installed: !isCommandMissingCause(error),
418 version: null,
419 status: "error",
420 auth: { status: "unknown" },
421 message: isCommandMissingCause(error)
422 ? "Grok CLI (`grok`) is not installed or not on PATH."
423 : "Failed to execute Grok CLI health check.",
424 },
425 });
426 }
427
428 if (Option.isNone(versionResult.success)) {
429 return buildServerProvider({
430 presentation: GROK_PRESENTATION,
431 enabled: grokSettings.enabled,
432 checkedAt,
433 models: fallbackModels,
434 probe: {
435 installed: true,
436 version: null,
437 status: "error",
438 auth: { status: "unknown" },
439 message: "Grok CLI is installed but timed out while running `grok --version`.",
440 },
441 });
442 }
443
444 const versionOutput = versionResult.success.value;
445 const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`);
446 if (versionOutput.code !== 0) {
447 yield* Effect.logWarning("Grok CLI version probe exited with a non-zero status.", {
448 exitCode: versionOutput.code,
449 stdoutLength: versionOutput.stdout.length,
450 stderrLength: versionOutput.stderr.length,
451 });
452 return buildServerProvider({
453 presentation: GROK_PRESENTATION,
454 enabled: grokSettings.enabled,
455 checkedAt,
456 models: fallbackModels,
457 probe: {
458 installed: true,
459 version,
460 status: "error",
461 auth: { status: "unknown" },
462 message: "Grok CLI is installed but failed to run.",
463 },
464 });
465 }
466
467 // `grok models` reports login state and model slugs without starting the agent.
468 const modelsResult = yield* runGrokCliCommand(grokSettings, ["models"], environment).pipe(
469 Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS),
470 Effect.result,
471 );
472 // Only a clean exit is parsed. Failed invocations print help or error text that
473 // must not be read as model slugs or as a login verdict.
474 const modelsOutput =
475 Result.isSuccess(modelsResult) &&
476 Option.isSome(modelsResult.success) &&
477 modelsResult.success.value.code === 0
478 ? modelsResult.success.value
479 : undefined;
480 const cliModels: GrokModelsCliOutput = modelsOutput
481 ? parseGrokModelsCliOutput(`${modelsOutput.stdout}\n${modelsOutput.stderr}`)
482 : { authenticated: null, models: [] };
483 if (!modelsOutput) {
484 yield* Effect.logWarning("Grok CLI model listing failed or timed out.", {
485 errorTag: Result.isFailure(modelsResult)
486 ? modelsResult.failure._tag
487 : Option.isNone(modelsResult.success)
488 ? "Timeout"
489 : `ExitCode${modelsResult.success.value.code}`,
490 });
491 }
492
493 const auth: ServerProviderAuth = environment[GROK_API_KEY_ENV]?.trim()
494 ? { status: "authenticated", type: "api_key", label: "xAI API key" }
495 : cliModels.authenticated === true
496 ? { status: "authenticated", type: "cached_token", label: "Grok account" }
497 : cliModels.authenticated === false
498 ? { status: "unauthenticated" }
499 : { status: "unknown" };
500
501 const skills = yield* discoverGrokSkills(grokSettings, environment, cwd).pipe(
502 Effect.tapError((cause) => Effect.logDebug("Grok skill discovery failed.", { cause })),
503 Effect.orElseSucceed(() => []),
504 );
505
506 const acpExit = yield* discoverGrokMetadataViaAcpInitialize(grokSettings, environment).pipe(
507 Effect.timeoutOption(GROK_ACP_INITIALIZE_TIMEOUT_MS),
508 Effect.exit,
509 );
510 const acpMetadata = Exit.isSuccess(acpExit) ? Option.getOrUndefined(acpExit.value) : undefined;
511 const acpModels = acpMetadata?.models ?? [];
512 const acpFailed = Exit.isFailure(acpExit) || Option.isNone(acpExit.value);
513 if (acpFailed) {
514 yield* Effect.logWarning("Grok ACP initialize probe failed or timed out.", {
515 errorTag: Exit.isFailure(acpExit) ? causeErrorTag(acpExit.cause) : "Timeout",
516 });
517 }
518
519 const discoveredModels = acpModels.length > 0 ? acpModels : cliModels.models;
520 const models =
521 discoveredModels.length > 0
522 ? grokModelsFromSettings(grokSettings.customModels, discoveredModels)
523 : fallbackModels;
524
525 if (auth.status === "unauthenticated") {
526 return buildServerProvider({
527 presentation: GROK_PRESENTATION,
528 enabled: grokSettings.enabled,
529 checkedAt,
530 models,
531 skills,
532 probe: {
533 installed: true,
534 version,
535 status: "error",
536 auth,
537 message: "Grok CLI is installed but not logged in. Run `grok login`.",
538 },
539 });
540 }
541
542 return buildServerProvider({
543 presentation: GROK_PRESENTATION,
544 enabled: grokSettings.enabled,
545 checkedAt,
546 models,
547 skills,
548 slashCommands: acpMetadata?.slashCommands ?? [COMPACT_SLASH_COMMAND],
549 probe: {
550 installed: true,
551 version,
552 // A failed metadata probe degrades the model picker, it does not make chats fail.
553 status: acpFailed ? "warning" : "ready",
554 auth,
555 ...(acpFailed
556 ? {
557 message:
558 "Grok CLI is installed but ACP initialize failed. Model options may be incomplete.",
559 }
560 : {}),
561 },
562 });
563});
564
565export const enrichGrokSnapshot = (input: {
566 readonly snapshot: ServerProvider;
567 readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;
568 readonly enableProviderUpdateChecks?: boolean;
569 readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>;
570 readonly httpClient: HttpClient.HttpClient;
571}): Effect.Effect<void> => {
572 const { snapshot, publishSnapshot } = input;
573
574 return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, {
575 enableProviderUpdateChecks: input.enableProviderUpdateChecks,
576 }).pipe(
577 Effect.provideService(HttpClient.HttpClient, input.httpClient),
578 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
579 Effect.catchCause((cause) =>
580 Effect.logWarning("Grok version advisory enrichment failed", {
581 errorTag: causeErrorTag(cause),
582 }),
583 ),
584 Effect.asVoid,
585 );
586};
apps/server/src/provider/ProviderInstanceRegistry.test.ts+2-2
...@@ -31,11 +31,11 @@ import {...@@ -31,11 +31,11 @@ import {
31 EnvironmentId,31 EnvironmentId,
32 type ClaudeSettings,32 type ClaudeSettings,
33 type CodexSettings,33 type CodexSettings,
34 type GrokSettings,
35 ProviderDriverKind,34 ProviderDriverKind,
36 type ProviderInstanceConfigMap,35 type ProviderInstanceConfigMap,
37 ProviderInstanceId,36 ProviderInstanceId,
38} from "@t3tools/contracts";37} from "@t3tools/contracts";
38import type { GrokSettings } from "@t3tools/provider-grok/settings";
39import type { CursorSettings } from "@t3tools/provider-cursor/settings";39import type { CursorSettings } from "@t3tools/provider-cursor/settings";
40import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess";40import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess";
41import * as DateTime from "effect/DateTime";41import * as DateTime from "effect/DateTime";
...@@ -54,7 +54,7 @@ import * as ServerSettings from "../serverSettings.ts";...@@ -54,7 +54,7 @@ import * as ServerSettings from "../serverSettings.ts";
54import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts";54import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts";
55import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";55import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";
56import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server";56import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server";
57import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts";57import { GrokDriver, type GrokDriverEnv } from "@t3tools/provider-grok/server";
58import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server";58import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server";
59import type { OpenCodeSettings } from "@t3tools/provider-opencode/settings";59import type { OpenCodeSettings } from "@t3tools/provider-opencode/settings";
60import * as ModelManifest from "./ModelManifest.ts";60import * as ModelManifest from "./ModelManifest.ts";
apps/server/src/provider/acp/GrokAcpCliProbe.test.ts deleted-110
...@@ -1,110 +0,0 @@
1/**
2 * Optional integration check against a real `grok agent stdio` install.
3 * Enable with: T3_GROK_ACP_PROBE=1 vp test run GrokAcpCliProbe
4 * Set T3_GROK_LIVE_TURN=1 to also send a small prompt to the real model.
5 *
6 * The probe assumes either `XAI_API_KEY` is set in the environment or
7 * the user has previously run `grok login`. Without credentials the
8 * agent's `authenticate` request will fail and the test will surface
9 * the error.
10 */
11import * as NodeServices from "@effect/platform-node/NodeServices";
12import { it } from "@effect/vitest";
13import * as Effect from "effect/Effect";
14import * as Deferred from "effect/Deferred";
15import * as Fiber from "effect/Fiber";
16import * as FileSystem from "effect/FileSystem";
17import * as Stream from "effect/Stream";
18import { ChildProcessSpawner } from "effect/process";
19import { describe, expect } from "vite-plus/test";
20
21import { makeGrokAcpRuntime } from "./GrokAcpSupport.ts";
22
23const makeProbeRuntime = Effect.gen(function* () {
24 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
25 return yield* makeGrokAcpRuntime({
26 grokSettings: { binaryPath: "grok" },
27 environment: process.env,
28 childProcessSpawner,
29 cwd: process.cwd(),
30 clientInfo: { name: "t3-grok-probe", version: "0.0.0" },
31 });
32});
33
34describe.runIf(process.env.T3_GROK_ACP_PROBE === "1")("Grok ACP CLI probe", () => {
35 it.effect("initialize and authenticate against real grok agent stdio", () =>
36 Effect.gen(function* () {
37 const runtime = yield* makeProbeRuntime;
38 const started = yield* runtime.start();
39 expect(started.initializeResult).toBeDefined();
40 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
41 );
42
43 it.effect("completes a prompt on the agent's default model", () =>
44 Effect.gen(function* () {
45 const runtime = yield* makeProbeRuntime;
46 const started = yield* runtime.start();
47 expect(typeof started.sessionId).toBe("string");
48
49 const result = yield* runtime
50 .prompt({ prompt: [{ type: "text", text: "Respond with exactly: grok switch ok" }] })
51 .pipe(Effect.timeout("60 seconds"));
52 expect(result.stopReason).toBe("end_turn");
53 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
54 );
55 it.effect("session/set_model accepts advertised reasoning effort metadata", () =>
56 Effect.gen(function* () {
57 const runtime = yield* makeProbeRuntime;
58 const started = yield* runtime.start();
59 const modelState = started.sessionSetupResult.models;
60 const currentModelId = modelState?.currentModelId.trim();
61 expect(currentModelId).toBeDefined();
62 if (!currentModelId) return;
63
64 const currentModel = modelState?.availableModels.find(
65 (model) => model.modelId.trim() === currentModelId,
66 );
67 const reasoningEffort = currentModel?._meta?.reasoningEffort;
68 expect(typeof reasoningEffort).toBe("string");
69 if (typeof reasoningEffort !== "string") return;
70
71 yield* runtime.setSessionModel(currentModelId, { reasoningEffort });
72 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
73 );
74
75 it.effect.skipIf(process.env.T3_GROK_LIVE_TURN !== "1")(
76 "finishes a real Grok turn and streams its answer",
77 () =>
78 Effect.gen(function* () {
79 const fileSystem = yield* FileSystem.FileSystem;
80 const cwd = yield* fileSystem.makeTempDirectoryScoped();
81 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
82 const runtime = yield* makeGrokAcpRuntime({
83 grokSettings: { binaryPath: "grok" },
84 environment: process.env,
85 childProcessSpawner,
86 cwd,
87 runtimeMode: "approval-required",
88 clientInfo: { name: "t3-grok-probe", version: "0.0.0" },
89 });
90 yield* runtime.start();
91 const chunks: string[] = [];
92 const events = yield* Stream.runForEach(runtime.getEvents(), (event) => {
93 if (event._tag === "EventStreamBarrier") {
94 return Deferred.succeed(event.acknowledge, undefined);
95 }
96 if (event._tag === "ContentDelta") {
97 chunks.push(event.text);
98 }
99 return Effect.void;
100 }).pipe(Effect.forkChild);
101 const result = yield* runtime.prompt({
102 prompt: [{ type: "text", text: "Reply exactly GROK_T3_OK. Do not use any tools." }],
103 });
104 yield* runtime.drainEvents;
105 expect(result.stopReason).toBe("end_turn");
106 expect(chunks.join("")).toContain("GROK_T3_OK");
107 yield* Fiber.interrupt(events);
108 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
109 );
110});
apps/server/src/provider/acp/GrokAcpSupport.test.ts deleted-265
...@@ -1,265 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import * as Effect from "effect/Effect";
4import * as EffectAcpErrors from "effect-acp/errors";
5
6import {
7 applyGrokAcpModelSelection,
8 buildGrokAcpSpawnInput,
9 grokAcpRuntimeProcessOwnership,
10 grokAcpSpawnArgs,
11 isValidGrokReasoningEffortToken,
12 resolveGrokAcpBaseModelId,
13} from "./GrokAcpSupport.ts";
14
15describe("grokAcpRuntimeProcessOwnership", () => {
16 it("opts Grok into detached process-tree ownership on the injected host platform", () => {
17 expect(grokAcpRuntimeProcessOwnership("linux")).toEqual({
18 ownDescendantProcessGroups: true,
19 ownDetachedProcessGroup: true,
20 processGroupPlatform: "linux",
21 });
22 });
23
24 it("uses the prior provider-group path on Darwin and Windows", () => {
25 expect(grokAcpRuntimeProcessOwnership("darwin")).toEqual({
26 ownDescendantProcessGroups: false,
27 ownDetachedProcessGroup: true,
28 processGroupPlatform: "darwin",
29 });
30 expect(grokAcpRuntimeProcessOwnership("win32")).toEqual({
31 ownDescendantProcessGroups: false,
32 ownDetachedProcessGroup: true,
33 processGroupPlatform: "win32",
34 });
35 });
36});
37
38describe("resolveGrokAcpBaseModelId", () => {
39 it("normalizes empty and custom Grok model ids", () => {
40 expect(resolveGrokAcpBaseModelId(undefined)).toBe("grok-build");
41 expect(resolveGrokAcpBaseModelId(" ")).toBe("grok-build");
42 expect(resolveGrokAcpBaseModelId(" grok-test-custom-model ")).toBe("grok-test-custom-model");
43 });
44});
45
46describe("grokAcpSpawnArgs", () => {
47 it("inherits the Grok CLI config when no T3 runtime mode is set", () => {
48 expect(grokAcpSpawnArgs()).toEqual(["agent", "stdio"]);
49 });
50
51 it("forces Grok to ask when T3 is Supervised", () => {
52 expect(grokAcpSpawnArgs("approval-required")).toEqual([
53 "--permission-mode",
54 "default",
55 "agent",
56 "stdio",
57 ]);
58 });
59
60 it("maps Full access to Grok always-approve", () => {
61 expect(grokAcpSpawnArgs("full-access")).toEqual(["agent", "--always-approve", "stdio"]);
62 });
63
64 it("launches Auto on Grok's classifier and a mode Grok does not offer asking", () => {
65 expect(grokAcpSpawnArgs("auto")).toEqual(["--permission-mode", "auto", "agent", "stdio"]);
66 expect(grokAcpSpawnArgs("auto-accept-edits")).toEqual([
67 "--permission-mode",
68 "default",
69 "agent",
70 "stdio",
71 ]);
72 });
73});
74
75describe("buildGrokAcpSpawnInput", () => {
76 it("passes the T3 Code referrer through Grok OAuth env", () => {
77 const spawn = buildGrokAcpSpawnInput({ binaryPath: "/usr/local/bin/grok" }, "/tmp/project", {
78 XAI_API_KEY: "secret",
79 GROK_OAUTH2_REFERRER: "other-client",
80 });
81
82 expect(spawn).toEqual({
83 command: "/usr/local/bin/grok",
84 args: ["agent", "stdio"],
85 cwd: "/tmp/project",
86 env: {
87 XAI_API_KEY: "secret",
88 GROK_OAUTH2_REFERRER: "t3code",
89 },
90 });
91 });
92
93 it("puts Supervised on the Grok argv so config always-approve cannot win", () => {
94 const spawn = buildGrokAcpSpawnInput(
95 { binaryPath: "/usr/local/bin/grok" },
96 "/tmp/project",
97 undefined,
98 "approval-required",
99 );
100 expect(spawn.args).toEqual(["--permission-mode", "default", "agent", "stdio"]);
101 });
102});
103
104describe("isValidGrokReasoningEffortToken", () => {
105 it("accepts future ACP tokens and rejects malformed metadata values", () => {
106 expect(isValidGrokReasoningEffortToken("xhigh")).toBe(true);
107 expect(isValidGrokReasoningEffortToken("turbo_v2")).toBe(true);
108 expect(isValidGrokReasoningEffortToken("not a token")).toBe(false);
109 expect(isValidGrokReasoningEffortToken("-leading-dash")).toBe(false);
110 expect(isValidGrokReasoningEffortToken("x".repeat(33))).toBe(false);
111 });
112});
113
114describe("applyGrokAcpModelSelection", () => {
115 const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => {
116 const modelCalls: Array<{
117 modelId: string;
118 meta?: { readonly [key: string]: unknown } | null;
119 }> = [];
120 const runtime = {
121 setSessionModel: (modelId: string, meta?: { readonly [key: string]: unknown } | null) =>
122 Effect.gen(function* () {
123 modelCalls.push(meta === undefined ? { modelId } : { modelId, meta });
124 if (failure) return yield* failure;
125 return {};
126 }),
127 };
128 return { runtime, modelCalls };
129 };
130
131 it.effect("calls session/set_model when the requested model differs from current", () =>
132 Effect.gen(function* () {
133 const { runtime, modelCalls } = makeRecordingRuntime();
134 const result = yield* applyGrokAcpModelSelection({
135 runtime,
136 currentModelId: "grok-build",
137 requestedModelId: "grok-mock-alt",
138 mapError: (cause) => cause.message,
139 });
140 expect(modelCalls).toEqual([{ modelId: "grok-mock-alt" }]);
141 expect(result).toBe("grok-mock-alt");
142 }),
143 );
144
145 it.effect("applies reasoning effort through session/set_model metadata", () =>
146 Effect.gen(function* () {
147 const { runtime, modelCalls } = makeRecordingRuntime();
148 const result = yield* applyGrokAcpModelSelection({
149 runtime,
150 currentModelId: "grok-4.6",
151 currentReasoningEffort: "high",
152 requestedModelId: "grok-4.6",
153 requestedReasoningEffort: "xhigh",
154 mapError: (cause) => cause.message,
155 });
156 expect(modelCalls).toEqual([{ modelId: "grok-4.6", meta: { reasoningEffort: "xhigh" } }]);
157 expect(result).toBe("grok-4.6");
158 }),
159 );
160
161 it.effect("does not clear reasoning when same-model selection omits effort", () =>
162 Effect.gen(function* () {
163 const { runtime, modelCalls } = makeRecordingRuntime();
164 const result = yield* applyGrokAcpModelSelection({
165 runtime,
166 currentModelId: "grok-4.6",
167 currentReasoningEffort: "high",
168 requestedModelId: "grok-4.6",
169 requestedReasoningEffort: undefined,
170 mapError: (cause) => cause.message,
171 });
172 expect(modelCalls).toEqual([]);
173 expect(result).toBe("grok-4.6");
174 }),
175 );
176
177 it.effect("drops malformed effort metadata instead of sending it", () =>
178 Effect.gen(function* () {
179 const { runtime, modelCalls } = makeRecordingRuntime();
180 yield* applyGrokAcpModelSelection({
181 runtime,
182 currentModelId: "grok-4.6",
183 currentReasoningEffort: "high",
184 requestedModelId: "grok-4.6",
185 requestedReasoningEffort: "not a token",
186 mapError: (cause) => cause.message,
187 });
188 expect(modelCalls).toEqual([{ modelId: "grok-4.6" }]);
189 }),
190 );
191
192 it.effect("skips set_model when requested matches current", () =>
193 Effect.gen(function* () {
194 const { runtime, modelCalls } = makeRecordingRuntime();
195 const result = yield* applyGrokAcpModelSelection({
196 runtime,
197 currentModelId: "grok-build",
198 requestedModelId: "grok-build",
199 mapError: (cause) => cause.message,
200 });
201 expect(modelCalls).toEqual([]);
202 expect(result).toBe("grok-build");
203 }),
204 );
205
206 it.effect("keeps the session's current model when the product slug is requested", () =>
207 Effect.gen(function* () {
208 const { runtime, modelCalls } = makeRecordingRuntime();
209 const result = yield* applyGrokAcpModelSelection({
210 runtime,
211 currentModelId: "grok-4.6",
212 requestedModelId: "grok-build",
213 mapError: (cause) => cause.message,
214 });
215 expect(modelCalls).toEqual([]);
216 expect(result).toBe("grok-4.6");
217 }),
218 );
219
220 it.effect("applies reasoning to the current model when the product slug is requested", () =>
221 Effect.gen(function* () {
222 const { runtime, modelCalls } = makeRecordingRuntime();
223 const result = yield* applyGrokAcpModelSelection({
224 runtime,
225 currentModelId: "grok-4.6",
226 currentReasoningEffort: "high",
227 requestedModelId: "grok-build",
228 requestedReasoningEffort: "xhigh",
229 mapError: (cause) => cause.message,
230 });
231 expect(modelCalls).toEqual([{ modelId: "grok-4.6", meta: { reasoningEffort: "xhigh" } }]);
232 expect(result).toBe("grok-4.6");
233 }),
234 );
235
236 it.effect("skips set_model when no model is requested", () =>
237 Effect.gen(function* () {
238 const { runtime, modelCalls } = makeRecordingRuntime();
239 const result = yield* applyGrokAcpModelSelection({
240 runtime,
241 currentModelId: "grok-build",
242 requestedModelId: undefined,
243 mapError: (cause) => cause.message,
244 });
245 expect(modelCalls).toEqual([]);
246 expect(result).toBe("grok-build");
247 }),
248 );
249
250 it.effect("propagates session/set_model failures via mapError", () =>
251 Effect.gen(function* () {
252 const failure = EffectAcpErrors.AcpRequestError.invalidParams("session id not known");
253 const { runtime } = makeRecordingRuntime(failure);
254 const error = yield* Effect.flip(
255 applyGrokAcpModelSelection({
256 runtime,
257 currentModelId: "grok-build",
258 requestedModelId: "grok-mock-alt",
259 mapError: (cause) => cause.message,
260 }),
261 );
262 expect(error).toBe(failure.message);
263 }),
264 );
265});
apps/server/src/provider/acp/GrokAcpSupport.ts deleted-278
...@@ -1,278 +0,0 @@
1import type * as EffectAcpSchema from "effect-acp/compat";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import {
4 type GrokSettings,
5 type ProviderApprovalOption,
6 ProviderDriverKind,
7 type RuntimeMode,
8} from "@t3tools/contracts";
9import * as Crypto from "effect/Crypto";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as Scope from "effect/Scope";
13import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
14import * as EffectAcpErrors from "effect-acp/errors";
15import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
16import { normalizeModelSlug } from "@t3tools/shared/model";
17
18import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
19import { makeXAiPromptCompletionRuntime } from "./XAiAcpExtension.ts";
20
21const GROK_API_KEY_ENV = "XAI_API_KEY";
22const GROK_OAUTH2_REFERRER_ENV = "GROK_OAUTH2_REFERRER";
23const T3_CODE_OAUTH_REFERRER = "t3code";
24const GROK_AUTH_METHOD_API_KEY = "xai.api_key";
25const GROK_AUTH_METHOD_CACHED_TOKEN = "cached_token";
26const GROK_DRIVER_KIND = ProviderDriverKind.make("grok");
27
28type GrokAcpRuntimeGrokSettings = Pick<GrokSettings, "binaryPath">;
29
30interface GrokAcpRuntimeInput extends Omit<
31 AcpSessionRuntime.AcpSessionRuntimeOptions,
32 "authMethodId" | "clientCapabilities" | "spawn"
33> {
34 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
35 readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined;
36 readonly environment?: NodeJS.ProcessEnv;
37 readonly runtimeMode?: RuntimeMode;
38}
39
40/**
41 * The runtime modes `grok agent` can launch in: ask, its auto classifier, and
42 * always-approve. It has no Auto-accept edits: `acceptEdits` only exists as a
43 * settings-file `permissions.defaultMode`, and `grok agent` treats it as ask.
44 */
45export const GROK_SUPPORTED_RUNTIME_MODES = [
46 "approval-required",
47 "auto",
48 "full-access",
49] as const satisfies ReadonlyArray<RuntimeMode>;
50
51/**
52 * Launch argv for a runtime mode. `--permission-mode` on the argv beats the
53 * user's Grok config, so Supervised cannot inherit a configured always-approve.
54 * A mode Grok does not offer launches asking.
55 */
56export function grokAcpSpawnArgs(runtimeMode?: RuntimeMode): ReadonlyArray<string> {
57 switch (runtimeMode) {
58 case undefined:
59 return ["agent", "stdio"];
60 case "auto":
61 return ["--permission-mode", "auto", "agent", "stdio"];
62 case "full-access":
63 return ["agent", "--always-approve", "stdio"];
64 default:
65 return ["--permission-mode", "default", "agent", "stdio"];
66 }
67}
68
69export function buildGrokAcpSpawnInput(
70 grokSettings: GrokAcpRuntimeGrokSettings | null | undefined,
71 cwd: string,
72 environment?: NodeJS.ProcessEnv,
73 runtimeMode?: RuntimeMode,
74): AcpSessionRuntime.AcpSpawnInput {
75 return {
76 command: grokSettings?.binaryPath || "grok",
77 args: [...grokAcpSpawnArgs(runtimeMode)],
78 cwd,
79 env: {
80 ...environment,
81 [GROK_OAUTH2_REFERRER_ENV]: T3_CODE_OAUTH_REFERRER,
82 },
83 };
84}
85
86function resolveGrokAuthMethodId(environment: NodeJS.ProcessEnv | undefined): string {
87 return environment?.[GROK_API_KEY_ENV]?.trim()
88 ? GROK_AUTH_METHOD_API_KEY
89 : GROK_AUTH_METHOD_CACHED_TOKEN;
90}
91
92export function grokAcpRuntimeProcessOwnership(
93 processGroupPlatform: NodeJS.Platform,
94): Pick<
95 AcpSessionRuntime.AcpSessionRuntimeOptions,
96 "ownDescendantProcessGroups" | "ownDetachedProcessGroup" | "processGroupPlatform"
97> {
98 return {
99 // macOS keeps the prior provider-group teardown until a stable libproc
100 // identity provider can cover Grok's nested detached tool groups.
101 ownDescendantProcessGroups: processGroupPlatform === "linux",
102 ownDetachedProcessGroup: true,
103 processGroupPlatform,
104 };
105}
106
107/**
108 * Current Grok treats Ctrl+C cancellation as a barrier against stale
109 * background-task wake prompts until the next genuine user turn. Replay sends
110 * the same metadata so recorded cancels match.
111 */
112export const GROK_ACP_CANCEL_META = { cancelTrigger: "ctrl_c" } as const;
113
114/**
115 * Grok's Auto mode asks the client about an action its classifier blocks only
116 * when the client declares a type that can show a prompt; the default
117 * (`generic`) gets a silent denial instead. `extension` is the prompting type
118 * that keeps the permission options T3 already maps (no always-approve row,
119 * no per-command persistent grants).
120 */
121export const GROK_ACP_INITIALIZE_META = { clientType: "extension" } as const;
122
123/**
124 * Grok's only session-scoped `allow_always` answer: "Yes, allow all edits
125 * during this session" on an edit prompt. Its bash, monitor and MCP
126 * `always-allow` rows instead save a grant for the whole project that outlives
127 * the session (grok-build `crates/codegen/xai-grok-workspace/src/permission/`
128 * `prompter.rs` `ALLOW_EDITS_SESSION_OPTION_ID`, `grants.rs`
129 * `record_prompt_outcome`).
130 */
131const GROK_ALLOW_EDITS_SESSION_OPTION_ID = "allow-edits-session";
132
133/**
134 * The approval choices a Grok permission prompt can honor. The session choice
135 * appears only where Grok's answer lasts for the session.
136 */
137export function grokApprovalOptions(
138 request: EffectAcpSchema.RequestPermissionRequest,
139): ReadonlyArray<ProviderApprovalOption> {
140 const has = (kind: EffectAcpSchema.PermissionOption["kind"], optionId?: string) =>
141 request.options.some(
142 (option) =>
143 option.kind === kind && (optionId === undefined || option.optionId.trim() === optionId),
144 );
145 return [
146 { decision: "cancel", label: "Cancel" },
147 ...(has("reject_once") ? [{ decision: "decline", label: "Decline" } as const] : []),
148 ...(has("allow_always", GROK_ALLOW_EDITS_SESSION_OPTION_ID)
149 ? [{ decision: "acceptForSession", label: "Allow all edits this session" } as const]
150 : []),
151 ...(has("allow_once") ? [{ decision: "accept", label: "Approve" } as const] : []),
152 ];
153}
154
155export const makeGrokAcpRuntime = (
156 input: GrokAcpRuntimeInput,
157): Effect.Effect<
158 AcpSessionRuntime.AcpSessionRuntime["Service"],
159 EffectAcpErrors.AcpError,
160 Crypto.Crypto | Scope.Scope
161> =>
162 Effect.gen(function* () {
163 const processGroupPlatform = yield* HostProcessPlatform.pipe(
164 Effect.provide(NodeServices.layer),
165 );
166 const acpContext = yield* Layer.build(
167 AcpSessionRuntime.layer({
168 ...input,
169 spawn: buildGrokAcpSpawnInput(
170 input.grokSettings,
171 input.cwd,
172 input.environment,
173 input.runtimeMode,
174 ),
175 authMethodId: resolveGrokAuthMethodId(input.environment),
176 cancelMeta: { ...input.cancelMeta, ...GROK_ACP_CANCEL_META },
177 initializeMeta: GROK_ACP_INITIALIZE_META,
178 ...grokAcpRuntimeProcessOwnership(processGroupPlatform),
179 }).pipe(
180 Layer.provide(
181 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner),
182 ),
183 ),
184 );
185 const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
186 Effect.provide(acpContext),
187 );
188 return yield* makeXAiPromptCompletionRuntime(runtime);
189 });
190
191/**
192 * T3's built-in Grok slug. It is the CLI's product name, not a model id the ACP accepts,
193 * so selecting it means "use whatever model the Grok session currently runs on".
194 */
195export const GROK_DEFAULT_MODEL_SLUG = "grok-build";
196
197export function resolveGrokAcpBaseModelId(model: string | null | undefined): string {
198 const trimmed = model?.trim();
199 const base = trimmed && trimmed.length > 0 ? trimmed : GROK_DEFAULT_MODEL_SLUG;
200 return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? GROK_DEFAULT_MODEL_SLUG;
201}
202
203const GROK_REASONING_EFFORT_TOKEN = /^[a-z0-9][a-z0-9._-]{0,31}$/i;
204
205export function isValidGrokReasoningEffortToken(value: string): boolean {
206 return GROK_REASONING_EFFORT_TOKEN.test(value);
207}
208
209function normalizeGrokReasoningEffort(value: string | undefined): string | undefined {
210 const effort = value?.trim();
211 return effort && isValidGrokReasoningEffortToken(effort) ? effort : undefined;
212}
213
214export function currentGrokModelIdFromSessionSetup(
215 sessionSetupResult:
216 | EffectAcpSchema.LoadSessionResponse
217 | EffectAcpSchema.NewSessionResponse
218 | EffectAcpSchema.ResumeSessionResponse,
219): string | undefined {
220 return sessionSetupResult.models?.currentModelId?.trim() || undefined;
221}
222
223export function currentGrokReasoningEffortFromSessionSetup(
224 sessionSetupResult:
225 | EffectAcpSchema.LoadSessionResponse
226 | EffectAcpSchema.NewSessionResponse
227 | EffectAcpSchema.ResumeSessionResponse,
228): string | undefined {
229 const modelState = sessionSetupResult.models;
230 if (!modelState) {
231 return undefined;
232 }
233 const currentModelId = modelState.currentModelId.trim();
234 if (currentModelId.length === 0) {
235 return undefined;
236 }
237 const currentModel = modelState.availableModels.find(
238 (model) => model.modelId.trim() === currentModelId,
239 );
240 const reasoningEffort = currentModel?._meta?.reasoningEffort;
241 return typeof reasoningEffort === "string"
242 ? normalizeGrokReasoningEffort(reasoningEffort)
243 : undefined;
244}
245
246export function applyGrokAcpModelSelection<E>(input: {
247 readonly runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "setSessionModel">;
248 readonly currentModelId: string | undefined;
249 readonly currentReasoningEffort?: string | undefined;
250 readonly requestedModelId: string | undefined;
251 readonly requestedReasoningEffort?: string | undefined;
252 readonly mapError: (cause: EffectAcpErrors.AcpError) => E;
253}): Effect.Effect<string | undefined, E> {
254 // The product slug is never sent over the wire; it keeps the session's current model.
255 const requestedModelId =
256 input.requestedModelId === GROK_DEFAULT_MODEL_SLUG ? undefined : input.requestedModelId;
257 const modelChanged = requestedModelId !== undefined && requestedModelId !== input.currentModelId;
258 const reasoningProvided = input.requestedReasoningEffort !== undefined;
259 const reasoningEffort = reasoningProvided
260 ? normalizeGrokReasoningEffort(input.requestedReasoningEffort)
261 : undefined;
262 const reasoningEffortChanged =
263 reasoningProvided && reasoningEffort !== input.currentReasoningEffort;
264 const targetModelId = requestedModelId ?? input.currentModelId;
265 if ((!modelChanged && !reasoningEffortChanged) || targetModelId === undefined) {
266 return Effect.succeed(input.currentModelId);
267 }
268 const reasoningMeta =
269 reasoningProvided && reasoningEffort !== undefined ? { reasoningEffort } : undefined;
270 // When reasoning was explicitly provided but invalid (normalize => undefined), we deliberately
271 // send no meta so the invalid value is dropped rather than forwarded. When reasoning was not
272 // provided at all, we also send no meta, but we only reach this call when the model itself
273 // changed - an omitted reasoning preference must not be treated as an explicit clear of the
274 // CLI-advertised default (e.g. Extra High) on same-model reselections.
275 return input.runtime
276 .setSessionModel(targetModelId, reasoningMeta)
277 .pipe(Effect.mapError(input.mapError), Effect.as(targetModelId));
278}
apps/server/src/provider/acp/XAiAcpExtension.test.ts deleted-2039
...@@ -1,2039 +0,0 @@
1// @effect-diagnostics nodeBuiltinImport:off -- plan-path tests resolve the OS home dir.
2import * as NodeOS from "node:os";
3import { it } from "@effect/vitest";
4import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";
7import * as Schema from "effect/Schema";
8import { describe, expect } from "vite-plus/test";
9
10import {
11 XAiExitPlanModeRequest,
12 XAI_EMPTY_PLAN_MARKDOWN,
13 isGrokPlanMarkdownPath,
14 extractXAiExitPlanMarkdown,
15 extractGrokPlanMarkdownFromToolCallData,
16 extractXAiAcpBackgroundToolMutation,
17 extractXAiAcpSubagentEndNotice,
18 extractXAiAcpSubagentUpdate,
19 extractXAiAskUserQuestions,
20 extractXAiBackgroundTaskCompletion,
21 extractXAiKilledBackgroundTasks,
22 extractXAiMonitorTaskId,
23 isGenericAcpToolTitle,
24 isXAiMonitorTool,
25 isXAiPersistentMonitor,
26 makeXAiAskUserQuestionResponse,
27 makeXAiPromptCompletionRuntime,
28 normalizeXAiAcpToolCallState,
29 registerXAiBackgroundTaskTracking,
30 registerXAiSubagentFinished,
31 resolveXAiAcpToolTitle,
32 xAiBackgroundTaskLifecycleMutation,
33 xAiPromptCompleteFromSessionUpdate,
34 xAiSubagentFinishedNotice,
35 XAiAskUserQuestionRequest,
36} from "./XAiAcpExtension.ts";
37import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
38import {
39 type AcpToolCallState,
40 mergeToolCallState,
41 parseSessionUpdateEvent,
42} from "@t3tools/provider-acp/server/runtimeModel";
43
44const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest);
45
46describe("xAiPromptCompleteFromSessionUpdate", () => {
47 it("maps live turn_completed snake_case payloads", () => {
48 expect(
49 xAiPromptCompleteFromSessionUpdate({
50 sessionId: "019f4428-4bf1-7e52-b7c6-c29b506543b1",
51 update: {
52 sessionUpdate: "turn_completed",
53 prompt_id: "t3-xai-prompt-1",
54 stop_reason: "end_turn",
55 },
56 }),
57 ).toEqual({
58 sessionId: "019f4428-4bf1-7e52-b7c6-c29b506543b1",
59 promptId: "t3-xai-prompt-1",
60 stopReason: "end_turn",
61 });
62 });
63
64 it("ignores non-turn updates and task-completed prompt ids", () => {
65 expect(
66 xAiPromptCompleteFromSessionUpdate({
67 sessionId: "root",
68 update: { sessionUpdate: "hook_execution", prompt_id: "t3-xai-prompt-1" },
69 }),
70 ).toBeNull();
71 expect(
72 xAiPromptCompleteFromSessionUpdate({
73 sessionId: "root",
74 update: {
75 sessionUpdate: "turn_completed",
76 prompt_id: "task-completed-call-abc",
77 stop_reason: "end_turn",
78 },
79 }),
80 ).toBeNull();
81 expect(
82 xAiPromptCompleteFromSessionUpdate({
83 sessionId: "root",
84 update: { sessionUpdate: "turn_completed", stop_reason: "end_turn" },
85 }),
86 ).toBeNull();
87 });
88});
89
90// Runs a recorded Grok tool_call_update through the adapter's parse path.
91function parseRecordedToolCallUpdate(update: Record<string, unknown>): AcpToolCallState {
92 const [event] = parseSessionUpdateEvent({
93 sessionId: "01a0d660-c1bb-7842-91a3-02d91dc8b0d2",
94 update,
95 } as Parameters<typeof parseSessionUpdateEvent>[0]).events;
96 if (event?._tag !== "ToolCallUpdated") throw new Error("expected a tool call update");
97 return event.toolCall;
98}
99
100describe("XAiAcpExtension", () => {
101 it("recognizes Grok Task starts as native subagents", () => {
102 expect(
103 extractXAiAcpSubagentUpdate({
104 toolCallId: "task-1",
105 title: "Task",
106 status: "inProgress",
107 data: {
108 rawInput: {
109 description: "Explore server architecture",
110 prompt: "Audit apps/server.",
111 subagent_type: "generalPurpose",
112 model: "composer-2.5-fast",
113 },
114 },
115 }),
116 ).toEqual({
117 nativeTaskId: "task-1",
118 prompt: "Audit apps/server.",
119 title: "Explore server architecture",
120 model: "composer-2.5-fast",
121 status: "running",
122 childSessionId: null,
123 result: null,
124 suppressNormalTool: true,
125 });
126 });
127
128 it("extracts Grok child session lineage from completed Task output", () => {
129 expect(
130 extractXAiAcpSubagentUpdate({
131 toolCallId: "task-1",
132 title: "Task",
133 status: "completed",
134 data: {
135 rawInput: {
136 description: "Explore server architecture",
137 prompt: "Audit apps/server.",
138 subagent_type: "generalPurpose",
139 },
140 rawOutput: {
141 type: "Text",
142 text: [
143 "Server audit complete.",
144 "",
145 "Agent ID: 019f0220-e192-7c41-9e9d-b406bc3459c8 (resume supported)",
146 ].join("\n"),
147 },
148 },
149 }),
150 ).toEqual({
151 nativeTaskId: "task-1",
152 prompt: "Audit apps/server.",
153 title: "Explore server architecture",
154 model: null,
155 status: "completed",
156 childSessionId: "019f0220-e192-7c41-9e9d-b406bc3459c8",
157 result: "Server audit complete.",
158 suppressNormalTool: true,
159 });
160 });
161
162 it("keeps async spawn_subagent ACKs running and binds subagent_id", () => {
163 expect(
164 extractXAiAcpSubagentUpdate({
165 toolCallId: "call-spawn-1",
166 title: "spawn_subagent",
167 status: "completed",
168 data: {
169 rawInput: {
170 description: "Demo subagent wait and ls",
171 prompt: "Wait then ls.",
172 subagent_type: "general-purpose",
173 },
174 rawOutput: {
175 type: "Text",
176 text: [
177 "Subagent started in background.",
178 "subagent_id: 019f44a6-4820-7402-925d-bc862ee711dd",
179 "type: general-purpose",
180 "description: Demo subagent wait and ls",
181 "",
182 'Use get_command_or_subagent_output with task_ids=["019f44a6-4820-7402-925d-bc862ee711dd"] and timeout_ms to wait for results.',
183 ].join("\n"),
184 },
185 },
186 }),
187 ).toEqual({
188 nativeTaskId: "call-spawn-1",
189 prompt: "Wait then ls.",
190 title: "Demo subagent wait and ls",
191 model: null,
192 status: "running",
193 childSessionId: "019f44a6-4820-7402-925d-bc862ee711dd",
194 result: null,
195 suppressNormalTool: true,
196 });
197 });
198
199 it("hydrates subagent completion from get_command_or_subagent_output", () => {
200 expect(
201 extractXAiAcpSubagentUpdate({
202 toolCallId: "call-get-1",
203 title: "get_command_or_subagent_output",
204 status: "completed",
205 data: {
206 rawInput: {
207 task_ids: ["019f44a6-4820-7402-925d-bc862ee711dd"],
208 timeout_ms: 30000,
209 },
210 rawOutput: {
211 type: "Text",
212 text: [
213 "=== Task 019f44a6-4820-7402-925d-bc862ee711dd ===",
214 "Status: completed",
215 "Exit Code: 0",
216 "",
217 "=== Output ===",
218 "SUBAGENT_MARKER: 35 entries",
219 ].join("\n"),
220 },
221 },
222 }),
223 ).toEqual({
224 nativeTaskId: "call-get-1",
225 prompt: "",
226 title: null,
227 model: null,
228 status: "completed",
229 childSessionId: "019f44a6-4820-7402-925d-bc862ee711dd",
230 result: "SUBAGENT_MARKER: 35 entries",
231 suppressNormalTool: true,
232 });
233 });
234
235 it("attributes multi-task get_output envelopes to the result task_id", () => {
236 expect(
237 extractXAiAcpSubagentUpdate({
238 toolCallId: "call-get-multi",
239 title: "get_command_or_subagent_output",
240 status: "completed",
241 data: {
242 rawInput: {
243 task_ids: [
244 "019f44a6-4820-7402-925d-bc862ee711dd",
245 "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
246 ],
247 timeout_ms: 30000,
248 },
249 rawOutput: {
250 type: "TaskOutput",
251 Result: {
252 task_id: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
253 status: "completed",
254 exit_code: 0,
255 output: "SECOND_SUB_DONE\n",
256 },
257 },
258 },
259 }),
260 ).toMatchObject({
261 childSessionId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
262 status: "completed",
263 result: "SECOND_SUB_DONE",
264 });
265 });
266
267 it("does not treat a subagent_id mentioned in result text as the completed identity", () => {
268 const requestedTaskId = "019f44a6-4820-7402-925d-bc862ee711dd";
269 const mentionedTaskId = "019f44b0-1b73-7f32-bb1b-1ff696f536e3";
270 const toolCall = {
271 toolCallId: "call-get-mentioned-id",
272 title: "get_command_or_subagent_output",
273 status: "completed" as const,
274 data: {
275 rawInput: {
276 variant: "TaskOutput",
277 task_ids: [requestedTaskId],
278 },
279 rawOutput: {
280 type: "TaskOutput",
281 Result: {
282 status: "completed",
283 exit_code: 0,
284 output: `Audited subagent_id: ${mentionedTaskId}; no issues found.`,
285 },
286 },
287 },
288 };
289
290 expect(extractXAiAcpSubagentUpdate(toolCall)).toMatchObject({
291 childSessionId: requestedTaskId,
292 status: "completed",
293 });
294 expect(extractXAiBackgroundTaskCompletion(toolCall)).toEqual([
295 {
296 taskId: requestedTaskId,
297 status: "completed",
298 appendOutput: `Audited subagent_id: ${mentionedTaskId}; no issues found.`,
299 },
300 ]);
301 });
302
303 it("hydrates structured ACP TaskOutput tool envelopes", () => {
304 expect(
305 extractXAiAcpSubagentUpdate({
306 toolCallId: "call-get-2",
307 title: "[subagent:general-purpose] Sleep then return SUBAGENT_DONE (019f44b0)",
308 status: "completed",
309 data: {
310 rawInput: {
311 variant: "TaskOutput",
312 task_ids: ["019f44b0-1b73-7f32-bb1b-1ff696f536e3"],
313 timeout_ms: 20000,
314 },
315 rawOutput: {
316 type: "TaskOutput",
317 Result: {
318 task_id: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
319 status: "completed",
320 exit_code: 0,
321 output:
322 "SUBAGENT_DONE\n\n<subagent_meta>id=019f44b0-1b73-7f32-bb1b-1ff696f536e3</subagent_meta>\n",
323 },
324 },
325 },
326 }),
327 ).toEqual({
328 nativeTaskId: "call-get-2",
329 prompt: "",
330 title: null,
331 model: null,
332 status: "completed",
333 childSessionId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
334 result: "SUBAGENT_DONE",
335 suppressNormalTool: true,
336 });
337 });
338
339 it("does not leak raw machine tags when get_output is only meta/result blocks", () => {
340 const metaOnlyToolCall = {
341 toolCallId: "call-get-meta-only",
342 title: "get_command_or_subagent_output",
343 status: "completed" as const,
344 data: {
345 rawInput: {
346 variant: "TaskOutput",
347 task_ids: ["019f44b0-1b73-7f32-bb1b-1ff696f536e3"],
348 timeout_ms: 20000,
349 },
350 rawOutput: {
351 type: "TaskOutput",
352 Result: {
353 task_id: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
354 status: "completed",
355 exit_code: 0,
356 output:
357 "<subagent_meta>id=019f44b0-1b73-7f32-bb1b-1ff696f536e3</subagent_meta>\n<subagent_result>done</subagent_result>\n",
358 },
359 },
360 },
361 };
362 expect(extractXAiAcpSubagentUpdate(metaOnlyToolCall)).toEqual({
363 nativeTaskId: "call-get-meta-only",
364 prompt: "",
365 title: null,
366 model: null,
367 status: "completed",
368 childSessionId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
369 result: null,
370 suppressNormalTool: true,
371 });
372 expect(extractXAiBackgroundTaskCompletion(metaOnlyToolCall)).toEqual([
373 {
374 taskId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
375 status: "completed",
376 appendOutput: "",
377 },
378 ]);
379 });
380
381 it("keeps monitor start ACKs running and extracts task ids", () => {
382 const toolCall = {
383 toolCallId: "call-mon-1",
384 title: "monitor",
385 status: "completed" as const,
386 data: {
387 rawInput: {
388 command: "echo mon_line_1",
389 description: "Demo monitor",
390 },
391 rawOutput: {
392 type: "Text",
393 text: "Monitor started (task 019f44a5-87d1-7640-8e35-6a4667ffc873, timeout 36000000ms).\nYou will be notified on each event.",
394 },
395 },
396 };
397 expect(normalizeXAiAcpToolCallState(toolCall).status).toBe("inProgress");
398 expect(extractXAiMonitorTaskId(toolCall)).toBe("019f44a5-87d1-7640-8e35-6a4667ffc873");
399 });
400
401 it("tracks text Monitor ACKs with generic titles and description-only input", () => {
402 const toolCall = {
403 toolCallId: "call-mon-generic-text",
404 title: "Tool",
405 status: "completed" as const,
406 data: {
407 rawInput: {
408 command: "echo mon_line_1",
409 description: "Demo monitor without variant",
410 },
411 rawOutput: {
412 type: "Text",
413 text: "Monitor started (task 019f44a5-87d1-7640-8e35-6a4667ffc873, timeout 36000000ms).\nYou will be notified on each event.",
414 },
415 },
416 };
417
418 const normalized = normalizeXAiAcpToolCallState(toolCall);
419 expect(isXAiMonitorTool(normalized)).toBe(true);
420 expect(normalized.status).toBe("inProgress");
421 expect(extractXAiMonitorTaskId(normalized)).toBe("019f44a5-87d1-7640-8e35-6a4667ffc873");
422 });
423
424 it("keeps Monitor ticks running while Grok reports them in progress", () => {
425 // Recorded from Grok 1.0.41: every tick is an in_progress update whose
426 // Bash-shaped rawOutput already carries exit_code 0. The monitor ends only
427 // through `_x.ai/task_completed`, never through a completed status.
428 const command = "for i in 1 2 3; do sleep 8; echo tick $i; done";
429 const started = parseRecordedToolCallUpdate({
430 sessionUpdate: "tool_call_update",
431 toolCallId: "call-6b025a4e-621c-4526-a579-ba86ada2669c-0",
432 kind: "other",
433 title: "Start monitor: Watch three ticks eight seconds apart",
434 locations: [],
435 rawInput: {
436 variant: "Monitor",
437 command,
438 description: "Watch three ticks eight seconds apart",
439 timeout_ms: 36000000,
440 persistent: false,
441 },
442 });
443 const tick = parseRecordedToolCallUpdate({
444 sessionUpdate: "tool_call_update",
445 toolCallId: "call-6b025a4e-621c-4526-a579-ba86ada2669c-0",
446 status: "in_progress",
447 content: [{ type: "content", content: { type: "text", text: "tick 1\n" } }],
448 rawOutput: {
449 type: "Bash",
450 output: [116, 105, 99, 107, 32, 49, 10],
451 output_for_prompt: "tick 1\n",
452 exit_code: 0,
453 command,
454 truncated: false,
455 signal: null,
456 timed_out: false,
457 description: null,
458 current_dir: "<workspace>",
459 output_file: "",
460 total_bytes: 7,
461 },
462 });
463 const toolCall = normalizeXAiAcpToolCallState(mergeToolCallState(started, tick));
464 expect(isXAiMonitorTool(toolCall)).toBe(true);
465 expect(toolCall.status).toBe("inProgress");
466 });
467
468 it("finishes monitors from the recorded task_completed snapshot", () => {
469 // Fields from the grok_monitor recording (Grok 1.0.41).
470 const completed = {
471 sessionId: "01a0d660-c1bb-7842-91a3-02d91dc8b0d2",
472 update: {
473 sessionUpdate: "task_completed",
474 task_snapshot: {
475 task_id: "01a0d660-f98d-7ef3-a291-97af1c2b6455",
476 command: "for i in 1 2 3; do sleep 8; echo tick $i; done",
477 display_command: "[monitor] Watch three tick echoes",
478 output: "tick 1\ntick 2\ntick 3\n",
479 exit_code: 0,
480 kind: "monitor",
481 description: "Watch three tick echoes",
482 },
483 },
484 };
485 expect(xAiBackgroundTaskLifecycleMutation(completed, "completed")).toEqual({
486 sessionId: "01a0d660-c1bb-7842-91a3-02d91dc8b0d2",
487 taskId: "01a0d660-f98d-7ef3-a291-97af1c2b6455",
488 status: "completed",
489 output: "tick 1\ntick 2\ntick 3\n",
490 report: { kind: "monitor", label: "Watch three tick echoes" },
491 });
492 expect(
493 xAiBackgroundTaskLifecycleMutation(
494 {
495 ...completed,
496 update: { ...completed.update, task_snapshot: { task_id: "t", exit_code: 2 } },
497 },
498 "completed",
499 )?.status,
500 ).toBe("failed");
501 });
502
503 it("detects Monitor start ACKs from structured rawOutput when title is generic", () => {
504 const toolCall = {
505 toolCallId: "call-mon-generic-title",
506 title: "Tool",
507 status: "completed" as const,
508 data: {
509 rawInput: {
510 command: "echo mon_line",
511 description: "Demo monitor without variant",
512 },
513 rawOutput: {
514 type: "Monitor",
515 taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873",
516 timeoutMs: 36000000,
517 persistent: false,
518 },
519 },
520 };
521 expect(isXAiMonitorTool(toolCall)).toBe(true);
522 expect(normalizeXAiAcpToolCallState(toolCall).status).toBe("inProgress");
523 expect(extractXAiMonitorTaskId(toolCall)).toBe("019f44a5-87d1-7640-8e35-6a4667ffc873");
524 });
525
526 it("completes status-less Bash results despite generic titles", () => {
527 // A Bash result that carries no status (empty rawInput, generic title) is
528 // terminalized from its exit_code. An explicit in_progress status wins:
529 // that is how Grok streams a still-running monitor.
530 const toolCall = {
531 toolCallId: "call-wake-1",
532 title: "Tool",
533 data: {
534 rawInput: {},
535 rawOutput: {
536 type: "Bash",
537 output: Array.from(new TextEncoder().encode("STREAM_1\nSTREAM_DONE\n")),
538 output_for_prompt: "STREAM_1\nSTREAM_DONE\n",
539 exit_code: 0,
540 command: "for i in 1 2; do echo STREAM_$i; done; echo STREAM_DONE",
541 },
542 },
543 };
544 expect(isXAiMonitorTool(toolCall)).toBe(false);
545 const normalized = normalizeXAiAcpToolCallState(toolCall);
546 expect(normalized.status).toBe("completed");
547 expect(normalized.title).toBe("for i in 1 2; do echo STREAM_$i; done; echo STREAM_DONE");
548 expect(
549 normalizeXAiAcpToolCallState({
550 ...toolCall,
551 data: {
552 ...toolCall.data,
553 rawOutput: { ...toolCall.data.rawOutput, exit_code: 2 },
554 },
555 }).status,
556 ).toBe("failed");
557 });
558
559 it("keeps empty successful Bash updates running until native output or turn settlement", () => {
560 const normalized = normalizeXAiAcpToolCallState({
561 toolCallId: "call-bash-permission-ack",
562 title: "Ran command",
563 status: "completed",
564 data: {
565 rawInput: { command: "sleep 40" },
566 rawOutput: { type: "Bash", exit_code: 0 },
567 },
568 });
569 expect(normalized.status).toBe("inProgress");
570 });
571
572 it("keeps structured Monitor start ACKs running and extracts taskId", () => {
573 const toolCall = {
574 toolCallId: "call-mon-3",
575 title: "Start monitor: Wait 30s then list directory",
576 status: "completed" as const,
577 data: {
578 rawInput: {
579 variant: "Monitor",
580 command: "sleep 30 && ls",
581 description: "Wait 30s then list directory",
582 },
583 rawOutput: {
584 type: "Monitor",
585 taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3",
586 timeoutMs: 36000000,
587 persistent: false,
588 },
589 },
590 };
591 expect(normalizeXAiAcpToolCallState(toolCall).status).toBe("inProgress");
592 expect(extractXAiMonitorTaskId(toolCall)).toBe("019f44b8-8e98-7c80-a40e-df1e26a5f9e3");
593 });
594
595 it("replaces generic ACP titles with description / Monitor labels", () => {
596 expect(isGenericAcpToolTitle("Tool")).toBe(true);
597 expect(isGenericAcpToolTitle("Read package.json")).toBe(false);
598 expect(
599 resolveXAiAcpToolTitle({
600 toolCallId: "call-mon-title",
601 title: "Tool",
602 status: "completed",
603 data: {
604 rawInput: {
605 variant: "Monitor",
606 command: "sleep 30 && ls",
607 description: "Wait 30s then list directory",
608 },
609 },
610 }),
611 ).toBe("Monitor: Wait 30s then list directory");
612 const normalized = normalizeXAiAcpToolCallState({
613 toolCallId: "call-mon-title",
614 title: "Tool",
615 status: "completed",
616 data: {
617 rawInput: {
618 variant: "Monitor",
619 command: "sleep 30 && ls",
620 description: "Wait 30s then list directory",
621 },
622 rawOutput: {
623 type: "Monitor",
624 taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3",
625 timeoutMs: 36000000,
626 persistent: false,
627 },
628 },
629 });
630 expect(normalized.title).toBe("Monitor: Wait 30s then list directory");
631 expect(normalized.status).toBe("inProgress");
632 // Non-generic titles from the CLI are preserved.
633 expect(
634 resolveXAiAcpToolTitle({
635 toolCallId: "call-read",
636 title: "Read package.json",
637 status: "inProgress",
638 data: { rawInput: { path: "package.json" } },
639 }),
640 ).toBe("Read package.json");
641 });
642
643 it("hydrates monitor completion from TaskOutput get_command envelopes", () => {
644 expect(
645 extractXAiBackgroundTaskCompletion({
646 toolCallId: "call-get-mon",
647 title: "Wait 30s then list directory",
648 status: "completed",
649 data: {
650 rawInput: {
651 variant: "TaskOutput",
652 task_ids: ["019f44b8-8e98-7c80-a40e-df1e26a5f9e3"],
653 },
654 rawOutput: {
655 type: "TaskOutput",
656 Result: {
657 task_id: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3",
658 command: "[monitor] Wait 30s then list directory",
659 status: "completed",
660 exit_code: 0,
661 output: "agents\nAGENTS.md\nnotes\n",
662 },
663 },
664 },
665 }),
666 ).toEqual([
667 {
668 taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3",
669 status: "completed",
670 appendOutput: "agents\nAGENTS.md\nnotes",
671 },
672 ]);
673 });
674
675 it("hydrates monitor completion from a standalone TaskOutput frame without rawInput", () => {
676 // Post-settle shape observed live (2026-07-12): the final
677 // get_command_or_subagent_output update carries only rawOutput; the frame
678 // is parsed in isolation (no in-turn merge), and this completion is the
679 // ONLY end signal when the agent consumed the output itself (no "Monitor
680 // ended" reminder follows).
681 expect(
682 extractXAiBackgroundTaskCompletion({
683 toolCallId: "call-c29e64dd-ce5d-4eac-a6ca-c20513fefac3-1",
684 title: "[monitor] Stream numbered markers every 3s (019f54a0)",
685 status: "completed",
686 data: {
687 rawOutput: {
688 type: "TaskOutput",
689 Result: {
690 task_id: "019f54a0-06a8-77f2-8214-e24937cad564",
691 command: "[monitor] Stream numbered markers every 3s",
692 status: "completed",
693 exit_code: 0,
694 output: "STREAM_1\nSTREAM_DONE\n",
695 },
696 },
697 },
698 }),
699 ).toMatchObject([
700 {
701 taskId: "019f54a0-06a8-77f2-8214-e24937cad564",
702 status: "completed",
703 },
704 ]);
705 });
706
707 it("hydrates every requested monitor when TaskOutput has no result task_id", () => {
708 expect(
709 extractXAiBackgroundTaskCompletion({
710 toolCallId: "call-get-multi-mon",
711 title: "get_command_or_subagent_output",
712 status: "completed",
713 data: {
714 rawInput: {
715 variant: "TaskOutput",
716 task_ids: [
717 "019f44a6-4820-7402-925d-bc862ee711dd",
718 "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
719 ],
720 },
721 rawOutput: {
722 type: "TaskOutput",
723 Result: {
724 status: "completed",
725 exit_code: 0,
726 output: "BOTH_DONE\n",
727 },
728 },
729 },
730 }),
731 ).toEqual([
732 {
733 taskId: "019f44a6-4820-7402-925d-bc862ee711dd",
734 status: "completed",
735 appendOutput: "BOTH_DONE",
736 },
737 {
738 taskId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
739 status: "completed",
740 appendOutput: "BOTH_DONE",
741 },
742 ]);
743 });
744
745 it("keeps every requested monitor running when a completed poll has no terminal task status", () => {
746 expect(
747 extractXAiBackgroundTaskCompletion({
748 toolCallId: "call-get-running-monitors",
749 title: "get_command_or_subagent_output",
750 status: "completed",
751 data: {
752 rawInput: {
753 variant: "TaskOutput",
754 task_ids: [
755 "019f44a6-4820-7402-925d-bc862ee711dd",
756 "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
757 ],
758 },
759 rawOutput: {
760 type: "TaskOutput",
761 Result: {
762 output: "No task has reached a terminal state.",
763 },
764 },
765 },
766 }),
767 ).toEqual([
768 {
769 taskId: "019f44a6-4820-7402-925d-bc862ee711dd",
770 status: "running",
771 appendOutput: "No task has reached a terminal state.",
772 },
773 {
774 taskId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
775 status: "running",
776 appendOutput: "No task has reached a terminal state.",
777 },
778 ]);
779 });
780
781 it("treats Exit Code: -1 text envelopes as failed", () => {
782 expect(
783 extractXAiBackgroundTaskCompletion({
784 toolCallId: "call-get-neg",
785 title: "get_command_or_subagent_output",
786 status: "completed",
787 data: {
788 rawInput: {
789 variant: "TaskOutput",
790 task_ids: ["019f44b8-8e98-7c80-a40e-df1e26a5f9e3"],
791 },
792 rawOutput: {
793 type: "Text",
794 text: ["=== Task 019f44b8-8e98-7c80-a40e-df1e26a5f9e3 ===", "Exit Code: -1"].join("\n"),
795 },
796 },
797 }),
798 ).toMatchObject([
799 {
800 taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3",
801 status: "failed",
802 },
803 ]);
804 });
805
806 it("tombstones confirmed kills and ignores failed kill calls", () => {
807 expect(
808 extractXAiKilledBackgroundTasks({
809 toolCallId: "call-kill-1",
810 title: "kill_command_or_subagent",
811 status: "completed",
812 data: {
813 rawInput: {
814 variant: "Kill",
815 task_ids: ["call-bg-1", "call-bg-2", "call-bg-1"],
816 },
817 },
818 }),
819 ).toEqual([
820 { taskId: "call-bg-1", status: "completed", appendOutput: "" },
821 { taskId: "call-bg-2", status: "completed", appendOutput: "" },
822 ]);
823 // Singular task_id shape, matched by variant when the title is generic.
824 expect(
825 extractXAiKilledBackgroundTasks({
826 toolCallId: "call-kill-2",
827 title: "Tool",
828 status: "failed",
829 data: { rawInput: { variant: "kill", task_id: "call-bg-3" } },
830 }),
831 ).toEqual([]);
832 // In-flight kill calls and unrelated tools contribute nothing.
833 expect(
834 extractXAiKilledBackgroundTasks({
835 toolCallId: "call-kill-3",
836 title: "kill_command_or_subagent",
837 status: "inProgress",
838 data: { rawInput: { task_ids: ["call-bg-4"] } },
839 }),
840 ).toEqual([]);
841 expect(
842 extractXAiKilledBackgroundTasks({
843 toolCallId: "call-get",
844 title: "get_command_or_subagent_output",
845 status: "completed",
846 data: { rawInput: { variant: "TaskOutput", task_ids: ["call-bg-5"] } },
847 }),
848 ).toEqual([]);
849 });
850
851 it("maps task lifecycle notifications to background mutations", () => {
852 // task_backgrounded carries the cancelled call's tool_call_id as task_id.
853 expect(
854 xAiBackgroundTaskLifecycleMutation(
855 {
856 sessionId: "session-1",
857 update: {
858 sessionUpdate: "task_backgrounded",
859 tool_call_id: "call-bg-1",
860 task_id: "call-bg-1",
861 },
862 },
863 "running",
864 ),
865 ).toEqual({ sessionId: "session-1", taskId: "call-bg-1", status: "running" });
866 // task_completed nests the id inside task_snapshot.
867 expect(
868 xAiBackgroundTaskLifecycleMutation(
869 {
870 sessionId: "session-1",
871 update: {
872 sessionUpdate: "task_completed",
873 task_snapshot: { task_id: "call-bg-1" },
874 },
875 },
876 "completed",
877 ),
878 ).toEqual({ sessionId: "session-1", taskId: "call-bg-1", status: "completed" });
879 expect(
880 xAiBackgroundTaskLifecycleMutation(
881 { sessionId: "session-1", update: { sessionUpdate: "task_completed" } },
882 "completed",
883 ),
884 ).toBeNull();
885 });
886
887 it.effect("tracks canonical Grok task lifecycle notifications", () =>
888 Effect.gen(function* () {
889 const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
890 const mutations: Array<{
891 readonly sessionId: string;
892 readonly taskId: string;
893 readonly status: "running" | "completed" | "failed";
894 }> = [];
895 const runtime = {
896 handleExtNotification: (
897 method: string,
898 _schema: unknown,
899 handler: (notification: unknown) => Effect.Effect<void>,
900 ) => {
901 handlers.set(method, handler);
902 return Effect.void;
903 },
904 } as unknown as Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "handleExtNotification">;
905
906 yield* registerXAiBackgroundTaskTracking(runtime, (mutation) =>
907 Effect.sync(() => mutations.push(mutation)),
908 );
909
910 const backgrounded = handlers.get("x.ai/task_backgrounded");
911 const completed = handlers.get("x.ai/task_completed");
912 expect(backgrounded).toBeDefined();
913 expect(completed).toBeDefined();
914 yield* backgrounded!({
915 sessionId: "session-1",
916 update: {
917 sessionUpdate: "task_backgrounded",
918 tool_call_id: "call-bg-1",
919 task_id: "task-bg-1",
920 },
921 });
922 yield* completed!({
923 sessionId: "session-1",
924 update: {
925 sessionUpdate: "task_completed",
926 task_snapshot: { task_id: "task-bg-1" },
927 },
928 });
929 expect(mutations).toEqual([
930 { sessionId: "session-1", taskId: "task-bg-1", status: "running" },
931 { sessionId: "session-1", taskId: "task-bg-1", status: "completed" },
932 ]);
933 expect(handlers.has("_x.ai/task_backgrounded")).toBe(true);
934 expect(handlers.has("_x.ai/task_completed")).toBe(true);
935 }),
936 );
937
938 it("detects persistent Monitor start ACKs", () => {
939 expect(
940 isXAiPersistentMonitor({
941 toolCallId: "call-mon-persist",
942 title: "Monitor",
943 status: "completed",
944 data: {
945 rawInput: { variant: "Monitor" },
946 rawOutput: {
947 type: "Monitor",
948 taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3",
949 timeoutMs: 36000000,
950 persistent: true,
951 },
952 },
953 }),
954 ).toBe(true);
955 expect(
956 isXAiPersistentMonitor({
957 toolCallId: "call-mon-ephemeral",
958 title: "Monitor",
959 status: "completed",
960 data: {
961 rawInput: { variant: "Monitor" },
962 rawOutput: {
963 type: "Monitor",
964 taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3",
965 timeoutMs: 36000000,
966 persistent: false,
967 },
968 },
969 }),
970 ).toBe(false);
971 });
972
973 it("keeps a standalone TaskOutput completion terminal through normalize", () => {
974 // Must remain offer evidence post-settle: normalize must not demote it to
975 // inProgress the way monitor start ACKs are.
976 expect(
977 normalizeXAiAcpToolCallState({
978 toolCallId: "call-c29e64dd-ce5d-4eac-a6ca-c20513fefac3-1",
979 title: "[monitor] Stream numbered markers every 3s (019f54a0)",
980 status: "completed",
981 data: {
982 rawOutput: {
983 type: "TaskOutput",
984 Result: {
985 task_id: "019f54a0-06a8-77f2-8214-e24937cad564",
986 status: "completed",
987 exit_code: 0,
988 output: "STREAM_DONE\n",
989 },
990 },
991 },
992 }).status,
993 ).toBe("completed");
994 });
995
996 it("parses monitor event lines and end reminders", () => {
997 expect(
998 extractXAiAcpBackgroundToolMutation(
999 '<monitor-event task_id="019f44a5-87d1-7640-8e35-6a4667ffc873">\n[Demo] mon_line_1\n</monitor-event>',
1000 ),
1001 ).toEqual([
1002 {
1003 taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873",
1004 status: "running",
1005 appendOutput: "[Demo] mon_line_1\n",
1006 },
1007 ]);
1008 expect(
1009 extractXAiAcpBackgroundToolMutation(
1010 [
1011 "<system-reminder>",
1012 'Monitor "019f44a5-87d1-7640-8e35-6a4667ffc873" ended: [monitor ended: exited (code 0)].',
1013 "Description: Demo monitor",
1014 "</system-reminder>",
1015 ].join("\n"),
1016 ),
1017 ).toMatchObject([
1018 {
1019 taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873",
1020 status: "completed",
1021 },
1022 ]);
1023 // Description/output may contain "error" without meaning the monitor failed.
1024 expect(
1025 extractXAiAcpBackgroundToolMutation(
1026 [
1027 "<system-reminder>",
1028 'Monitor "019f44a5-87d1-7640-8e35-6a4667ffc873" ended: [monitor ended: exited (code 0)].',
1029 "Description: watch error logs for failed deploys",
1030 "error: nothing found",
1031 "</system-reminder>",
1032 ].join("\n"),
1033 ),
1034 ).toMatchObject([
1035 {
1036 taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873",
1037 status: "completed",
1038 },
1039 ]);
1040 });
1041
1042 it("parses every monitor-event and trailing end notice in one chunk", () => {
1043 const mutations = extractXAiAcpBackgroundToolMutation(
1044 [
1045 '<monitor-event task_id="019f44a5-87d1-7640-8e35-6a4667ffc873">',
1046 "[Demo] mon_line_1",
1047 "</monitor-event>",
1048 '<monitor-event task_id="019f44a5-87d1-7640-8e35-6a4667ffc873">',
1049 "[Demo] mon_line_2",
1050 "</monitor-event>",
1051 'Monitor "019f44a5-87d1-7640-8e35-6a4667ffc873" ended: [monitor ended: exited (code 0)].',
1052 ].join("\n"),
1053 );
1054 expect(mutations).toHaveLength(3);
1055 expect(mutations[0]).toEqual({
1056 taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873",
1057 status: "running",
1058 appendOutput: "[Demo] mon_line_1\n",
1059 });
1060 expect(mutations[1]).toEqual({
1061 taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873",
1062 status: "running",
1063 appendOutput: "[Demo] mon_line_2\n",
1064 });
1065 expect(mutations[2]).toMatchObject({
1066 taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873",
1067 status: "completed",
1068 });
1069 });
1070
1071 it("parses batched monitor event blocks as running mutations", () => {
1072 expect(
1073 extractXAiAcpBackgroundToolMutation(
1074 [
1075 "3 monitor events from 1 monitor (use get_command_or_subagent_output to identify each monitor):",
1076 "",
1077 '<monitor description="Stream STREAM_i every 3s" task_id="019f545d-4d39-7001-b1c8-c7744c448ec1">',
1078 "[1] STREAM_7",
1079 "[2] STREAM_8",
1080 "[3] STREAM_9",
1081 "</monitor>",
1082 ].join("\n"),
1083 ),
1084 ).toEqual([
1085 {
1086 taskId: "019f545d-4d39-7001-b1c8-c7744c448ec1",
1087 status: "running",
1088 appendOutput: "[1] STREAM_7\n[2] STREAM_8\n[3] STREAM_9\n",
1089 },
1090 ]);
1091 });
1092
1093 it("parses the subagent completed reminder as an end notice", () => {
1094 expect(
1095 extractXAiAcpSubagentEndNotice(
1096 [
1097 "<system-reminder>",
1098 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Run sleep then echo token") completed successfully.',
1099 "Duration: 28.8s | Tool calls: 1 | Turns: 1",
1100 'Use get_task_output("019f5470-bf92-7a90-afb3-5a6cea5b34a3") to see the full output.',
1101 "</system-reminder>",
1102 ].join("\n"),
1103 ),
1104 ).toEqual({
1105 childSessionId: "019f5470-bf92-7a90-afb3-5a6cea5b34a3",
1106 status: "completed",
1107 });
1108 });
1109
1110 it("parses a failed subagent reminder as a failed end notice", () => {
1111 expect(
1112 extractXAiAcpSubagentEndNotice(
1113 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Run sleep then echo token") failed.',
1114 ),
1115 ).toEqual({
1116 childSessionId: "019f5470-bf92-7a90-afb3-5a6cea5b34a3",
1117 status: "failed",
1118 });
1119 });
1120
1121 it("ignores prompt text that merely mentions subagents", () => {
1122 expect(
1123 extractXAiAcpSubagentEndNotice("Live-test post-settle subagent completion."),
1124 ).toBeUndefined();
1125 expect(
1126 extractXAiAcpSubagentEndNotice(
1127 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Run sleep then echo token") is still running.',
1128 ),
1129 ).toBeUndefined();
1130 });
1131
1132 it("does not treat nested parentheses in the title as the outcome verb", () => {
1133 expect(
1134 extractXAiAcpSubagentEndNotice(
1135 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Check (backend) failed tests") is still running.',
1136 ),
1137 ).toBeUndefined();
1138 expect(
1139 extractXAiAcpSubagentEndNotice(
1140 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Check (backend) failed tests") completed successfully.',
1141 ),
1142 ).toEqual({
1143 childSessionId: "019f5470-bf92-7a90-afb3-5a6cea5b34a3",
1144 status: "completed",
1145 });
1146 });
1147
1148 it("extracts questions from the real xAI ask_user_question payload shape", () => {
1149 const questions = extractXAiAskUserQuestions({
1150 sessionId: "session-1",
1151 toolCallId: "tool-call-1",
1152 mode: "default",
1153 questions: [
1154 {
1155 id: "scope",
1156 question: "Which scope should Grok use?",
1157 options: [
1158 { label: "Workspace", description: "Use the current workspace" },
1159 { label: "Session", description: "Only use this session" },
1160 ],
1161 },
1162 ],
1163 });
1164
1165 expect(questions).toEqual([
1166 {
1167 id: "scope",
1168 header: "Question",
1169 question: "Which scope should Grok use?",
1170 multiSelect: false,
1171 options: [
1172 { label: "Workspace", description: "Use the current workspace" },
1173 { label: "Session", description: "Only use this session" },
1174 ],
1175 },
1176 ]);
1177 });
1178
1179 it("extracts questions from wrapped _x.ai extension payloads", () => {
1180 const payload = {
1181 method: "_x.ai/ask_user_question",
1182 params: {
1183 sessionId: "session-1",
1184 toolCallId: "tool-call-1",
1185 mode: "plan",
1186 questions: [
1187 {
1188 question: "Which changes should be included?",
1189 multiSelect: true,
1190 options: [{ label: "Tests" }, { label: "Docs" }],
1191 },
1192 ],
1193 },
1194 };
1195 const decoded = decodeXAiAskUserQuestionRequest(payload);
1196 const questions = extractXAiAskUserQuestions(decoded);
1197
1198 expect(questions).toEqual([
1199 {
1200 id: "Which changes should be included?",
1201 header: "Question",
1202 question: "Which changes should be included?",
1203 multiSelect: true,
1204 options: [
1205 { label: "Tests", description: "Tests" },
1206 { label: "Docs", description: "Docs" },
1207 ],
1208 },
1209 ]);
1210 });
1211
1212 it("treats nullable multiSelect from Grok as single-select", () => {
1213 const questions = extractXAiAskUserQuestions({
1214 sessionId: "session-1",
1215 toolCallId: "tool-call-1",
1216 mode: "default",
1217 questions: [
1218 {
1219 question: "Which label should Grok use?",
1220 multiSelect: null,
1221 options: [
1222 { label: "Alpha", description: "Use the Alpha label" },
1223 { label: "Beta", description: "Use the Beta label" },
1224 { label: "Other", description: "Use the Other label" },
1225 ],
1226 },
1227 ],
1228 });
1229
1230 expect(questions).toEqual([
1231 {
1232 id: "Which label should Grok use?",
1233 header: "Question",
1234 question: "Which label should Grok use?",
1235 multiSelect: false,
1236 options: [
1237 { label: "Alpha", description: "Use the Alpha label" },
1238 { label: "Beta", description: "Use the Beta label" },
1239 { label: "Other", description: "Use the Other label" },
1240 ],
1241 },
1242 ]);
1243 });
1244
1245 it("maps UI question ids back to xAI question text in accepted responses", () => {
1246 const response = makeXAiAskUserQuestionResponse(
1247 {
1248 sessionId: "session-1",
1249 toolCallId: "tool-call-1",
1250 mode: "default",
1251 questions: [
1252 {
1253 id: "scope",
1254 question: "Which scope should Grok use?",
1255 options: [
1256 { label: "workspace", description: "Use the current workspace" },
1257 { label: "session", description: "Only use this session" },
1258 ],
1259 },
1260 ],
1261 },
1262 { scope: "workspace" },
1263 );
1264
1265 expect(response).toEqual({
1266 outcome: "accepted",
1267 answers: {
1268 "Which scope should Grok use?": ["workspace"],
1269 },
1270 });
1271 });
1272
1273 it("orders accepted answers by the original xAI question order", () => {
1274 const response = makeXAiAskUserQuestionResponse(
1275 {
1276 sessionId: "session-1",
1277 toolCallId: "tool-call-1",
1278 mode: "default",
1279 questions: [
1280 {
1281 id: "first",
1282 question: "First question?",
1283 options: [{ label: "A", description: "A" }],
1284 },
1285 {
1286 id: "second",
1287 question: "Second question?",
1288 options: [{ label: "B", description: "B" }],
1289 },
1290 ],
1291 },
1292 {
1293 second: "B",
1294 first: "A",
1295 },
1296 );
1297
1298 expect(Object.keys(response.answers)).toEqual(["First question?", "Second question?"]);
1299 expect(response).toMatchObject({
1300 outcome: "accepted",
1301 answers: {
1302 "First question?": ["A"],
1303 "Second question?": ["B"],
1304 },
1305 });
1306 });
1307
1308 it("encodes typed custom answers as xAI Other annotations", () => {
1309 const response = makeXAiAskUserQuestionResponse(
1310 {
1311 method: "x.ai/ask_user_question",
1312 params: {
1313 sessionId: "session-1",
1314 toolCallId: "tool-call-1",
1315 mode: "default",
1316 questions: [
1317 {
1318 question: "Which ice cream flavor?",
1319 options: [
1320 { label: "vanilla", description: "Vanilla flavor" },
1321 { label: "chocolate", description: "Chocolate flavor" },
1322 ],
1323 },
1324 ],
1325 },
1326 },
1327 { "Which ice cream flavor?": "pistachio" },
1328 );
1329
1330 expect(response).toEqual({
1331 outcome: "accepted",
1332 answers: {
1333 "Which ice cream flavor?": ["Other"],
1334 },
1335 annotations: {
1336 "Which ice cream flavor?": {
1337 notes: "pistachio",
1338 },
1339 },
1340 });
1341 });
1342
1343 it("does not echo preview annotations for multi-select answers", () => {
1344 const response = makeXAiAskUserQuestionResponse(
1345 {
1346 sessionId: "session-1",
1347 toolCallId: "tool-call-1",
1348 mode: "default",
1349 questions: [
1350 {
1351 question: "Which files should Grok touch?",
1352 multiSelect: true,
1353 options: [
1354 {
1355 label: "Tests",
1356 description: "Update tests",
1357 preview: "test preview",
1358 },
1359 {
1360 label: "Docs",
1361 description: "Update docs",
1362 preview: "docs preview",
1363 },
1364 ],
1365 },
1366 ],
1367 },
1368 { "Which files should Grok touch?": ["Tests", "Docs"] },
1369 );
1370
1371 expect(response).toEqual({
1372 outcome: "accepted",
1373 answers: {
1374 "Which files should Grok touch?": ["Tests", "Docs"],
1375 },
1376 });
1377 });
1378
1379 it.effect("settles a hung prompt from a root-session prompt_complete notification", () =>
1380 Effect.gen(function* () {
1381 const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
1382 const hungPrompt = yield* Deferred.make<never>();
1383 const baseRuntime = {
1384 start: () =>
1385 Effect.succeed({
1386 sessionId: "root-session",
1387 initializeResult: {},
1388 sessionSetupResult: {},
1389 modelConfigId: undefined,
1390 }),
1391 prompt: () => Deferred.await(hungPrompt),
1392 cancel: Effect.void,
1393 handleExtNotification: (
1394 method: string,
1395 _schema: unknown,
1396 handler: (notification: unknown) => Effect.Effect<void>,
1397 ) => {
1398 handlers.set(method, handler);
1399 return Effect.void;
1400 },
1401 handleExtRequest: () => Effect.void,
1402 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
1403
1404 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
1405 const promptFiber = yield* runtime
1406 .prompt({ prompt: [{ type: "text", text: "hi" }] })
1407 .pipe(Effect.forkChild);
1408 yield* Effect.yieldNow;
1409 const promptCompleteHandler = handlers.get("x.ai/session/prompt_complete");
1410 expect(promptCompleteHandler).toBeDefined();
1411 yield* promptCompleteHandler!({
1412 sessionId: "root-session",
1413 stopReason: "end_turn",
1414 });
1415 const response = yield* Fiber.join(promptFiber);
1416 expect(response.stopReason).toBe("end_turn");
1417 expect(handlers.has("_x.ai/session/prompt_complete")).toBe(true);
1418 }),
1419 );
1420
1421 it.effect("fails a hung prompt from an xAI rate-limit completion (#8358)", () =>
1422 Effect.gen(function* () {
1423 const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
1424 const hungPrompt = yield* Deferred.make<never>();
1425 const baseRuntime = {
1426 start: () =>
1427 Effect.succeed({
1428 sessionId: "root-session",
1429 initializeResult: {},
1430 sessionSetupResult: {},
1431 modelConfigId: undefined,
1432 }),
1433 prompt: () => Deferred.await(hungPrompt),
1434 cancel: Effect.void,
1435 handleExtNotification: (
1436 method: string,
1437 _schema: unknown,
1438 handler: (notification: unknown) => Effect.Effect<void>,
1439 ) => {
1440 handlers.set(method, handler);
1441 return Effect.void;
1442 },
1443 handleExtRequest: () => Effect.void,
1444 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
1445
1446 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
1447 const promptFiber = yield* runtime
1448 .prompt({ prompt: [{ type: "text", text: "hi" }] })
1449 .pipe(Effect.forkChild);
1450 yield* Effect.yieldNow;
1451
1452 const promptCompleteHandler = handlers.get("x.ai/session/prompt_complete");
1453 expect(promptCompleteHandler).toBeDefined();
1454 yield* promptCompleteHandler!({
1455 sessionId: "root-session",
1456 stopReason: "rate_limit",
1457 });
1458 const error = yield* Fiber.join(promptFiber).pipe(Effect.flip);
1459 expect(error).toMatchObject({
1460 _tag: "AcpRequestError",
1461 code: -32003,
1462 errorMessage: "Grok usage limit reached. Try again later.",
1463 });
1464 }),
1465 );
1466
1467 it("maps subagent_finished to a notice only for statuses Grok defines", () => {
1468 // Recorded from Grok 1.0.41 (ids normalized); statuses per grok-build
1469 // xai-grok-tools/.../task/types.rs `status()`.
1470 const finished = (update: Record<string, unknown>) =>
1471 xAiSubagentFinishedNotice({
1472 sessionId: "root-session",
1473 update: {
1474 sessionUpdate: "subagent_finished",
1475 subagent_id: "child-session",
1476 child_session_id: "child-session",
1477 tool_calls: 2,
1478 turns: 1,
1479 duration_ms: 23734,
1480 ...update,
1481 } as never,
1482 });
1483 expect(finished({ status: "completed", output: "SUBAGENT_DONE", will_wake: true })).toEqual({
1484 sessionId: "root-session",
1485 childSessionId: "child-session",
1486 status: "completed",
1487 result: "SUBAGENT_DONE",
1488 });
1489 expect(finished({ status: "failed", error: "tool crashed" })).toMatchObject({
1490 status: "failed",
1491 result: "tool crashed",
1492 });
1493 expect(
1494 finished({ status: "cancelled", error: "interrupted by process restart" }),
1495 ).toMatchObject({ status: "cancelled", result: "interrupted by process restart" });
1496 // A failed subagent's error is its result; `output` only accompanies success.
1497 expect(finished({ status: "failed", output: "partial" })).toMatchObject({ result: null });
1498 expect(finished({ status: "timed_out" })).toBeNull();
1499 expect(finished({})).toBeNull();
1500 expect(finished({ status: "completed", child_session_id: undefined })).toBeNull();
1501 });
1502
1503 it.effect("settles prompts and finishes subagents from the same session notifications", () =>
1504 Effect.gen(function* () {
1505 const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
1506 let capturedMeta: Record<string, unknown> | null | undefined;
1507 const hungPrompt = yield* Deferred.make<never>();
1508 const baseRuntime = {
1509 start: () =>
1510 Effect.succeed({
1511 sessionId: "root-session",
1512 initializeResult: {},
1513 sessionSetupResult: {},
1514 modelConfigId: undefined,
1515 }),
1516 prompt: (payload: { readonly _meta?: Record<string, unknown> | null }) => {
1517 capturedMeta = payload._meta ?? null;
1518 return Deferred.await(hungPrompt);
1519 },
1520 cancel: Effect.void,
1521 handleExtNotification: (
1522 method: string,
1523 _schema: unknown,
1524 handler: (notification: unknown) => Effect.Effect<void>,
1525 ) => {
1526 handlers.set(method, handler);
1527 return Effect.void;
1528 },
1529 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
1530 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
1531 const notices: Array<unknown> = [];
1532 // Registered after the wrapper, as the adapter does: it must not replace
1533 // the wrapper's prompt completion on the shared method.
1534 yield* registerXAiSubagentFinished(runtime, (notice) =>
1535 Effect.sync(() => notices.push(notice)),
1536 );
1537 const sessionNotification = handlers.get("_x.ai/session_notification")!;
1538 yield* sessionNotification({
1539 sessionId: "root-session",
1540 update: {
1541 sessionUpdate: "subagent_finished",
1542 child_session_id: "child-session",
1543 status: "completed",
1544 output: "SUBAGENT_DONE",
1545 },
1546 });
1547 expect(notices).toEqual([
1548 {
1549 sessionId: "root-session",
1550 childSessionId: "child-session",
1551 status: "completed",
1552 result: "SUBAGENT_DONE",
1553 },
1554 ]);
1555
1556 const promptFiber = yield* runtime
1557 .prompt({ prompt: [{ type: "text", text: "hi" }] })
1558 .pipe(Effect.forkChild);
1559 yield* Effect.yieldNow;
1560 yield* sessionNotification({
1561 sessionId: "root-session",
1562 update: {
1563 sessionUpdate: "turn_completed",
1564 prompt_id: capturedMeta?.promptId,
1565 stop_reason: "end_turn",
1566 },
1567 });
1568 const response = yield* Fiber.join(promptFiber);
1569 expect(response.stopReason).toBe("end_turn");
1570 expect(notices).toHaveLength(1);
1571 }),
1572 );
1573
1574 it.effect("settles a hung prompt from _x.ai/session/update turn_completed", () =>
1575 Effect.gen(function* () {
1576 const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
1577 let capturedMeta: Record<string, unknown> | null | undefined;
1578 const hungPrompt = yield* Deferred.make<never>();
1579 const baseRuntime = {
1580 start: () =>
1581 Effect.succeed({
1582 sessionId: "root-session",
1583 initializeResult: {},
1584 sessionSetupResult: {},
1585 modelConfigId: undefined,
1586 }),
1587 prompt: (payload: { readonly _meta?: Record<string, unknown> | null }) => {
1588 capturedMeta = payload._meta ?? null;
1589 return Deferred.await(hungPrompt);
1590 },
1591 cancel: Effect.void,
1592 handleExtNotification: (
1593 method: string,
1594 _schema: unknown,
1595 handler: (notification: unknown) => Effect.Effect<void>,
1596 ) => {
1597 handlers.set(method, handler);
1598 return Effect.void;
1599 },
1600 handleExtRequest: () => Effect.void,
1601 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
1602
1603 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
1604 const promptFiber = yield* runtime
1605 .prompt({ prompt: [{ type: "text", text: "hi" }] })
1606 .pipe(Effect.forkChild);
1607 yield* Effect.yieldNow;
1608 const promptId = capturedMeta?.promptId;
1609 expect(typeof promptId).toBe("string");
1610 const sessionUpdateHandler = handlers.get("_x.ai/session/update");
1611 expect(sessionUpdateHandler).toBeDefined();
1612 yield* sessionUpdateHandler!({
1613 sessionId: "root-session",
1614 update: {
1615 sessionUpdate: "turn_completed",
1616 prompt_id: promptId,
1617 stop_reason: "end_turn",
1618 },
1619 });
1620 const response = yield* Fiber.join(promptFiber);
1621 expect(response.stopReason).toBe("end_turn");
1622 }),
1623 );
1624
1625 it.effect("settles a hung prompt from released _x.ai/session_notification turns", () =>
1626 Effect.gen(function* () {
1627 const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
1628 const hungPrompt = yield* Deferred.make<never>();
1629 const baseRuntime = {
1630 start: () =>
1631 Effect.succeed({
1632 sessionId: "root-session",
1633 initializeResult: {},
1634 sessionSetupResult: {},
1635 modelConfigId: undefined,
1636 }),
1637 prompt: () => Deferred.await(hungPrompt),
1638 cancel: Effect.void,
1639 handleExtNotification: (
1640 method: string,
1641 _schema: unknown,
1642 handler: (notification: unknown) => Effect.Effect<void>,
1643 ) => {
1644 handlers.set(method, handler);
1645 return Effect.void;
1646 },
1647 handleExtRequest: () => Effect.void,
1648 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
1649
1650 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
1651 const promptFiber = yield* runtime
1652 .prompt({ prompt: [{ type: "text", text: "hi" }] })
1653 .pipe(Effect.forkChild);
1654 yield* Effect.yieldNow;
1655 const sessionNotificationHandler = handlers.get("_x.ai/session_notification");
1656 expect(sessionNotificationHandler).toBeDefined();
1657 yield* sessionNotificationHandler!({
1658 sessionId: "root-session",
1659 update: {
1660 sessionUpdate: "turn_completed",
1661 prompt_id: "t3-xai-prompt-1",
1662 stop_reason: "end_turn",
1663 },
1664 });
1665 const response = yield* Fiber.join(promptFiber);
1666 expect(response.stopReason).toBe("end_turn");
1667 expect(handlers.has("_x.ai/session/update")).toBe(true);
1668 }),
1669 );
1670
1671 it.effect("ignores turn_completed for non-pending prompt ids and task completions", () =>
1672 Effect.gen(function* () {
1673 const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
1674 const hungPrompt = yield* Deferred.make<never>();
1675 const baseRuntime = {
1676 start: () =>
1677 Effect.succeed({
1678 sessionId: "root-session",
1679 initializeResult: {},
1680 sessionSetupResult: {},
1681 modelConfigId: undefined,
1682 }),
1683 prompt: () => Deferred.await(hungPrompt),
1684 cancel: Effect.void,
1685 handleExtNotification: (
1686 method: string,
1687 _schema: unknown,
1688 handler: (notification: unknown) => Effect.Effect<void>,
1689 ) => {
1690 handlers.set(method, handler);
1691 return Effect.void;
1692 },
1693 handleExtRequest: () => Effect.void,
1694 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
1695
1696 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
1697 const promptFiber = yield* runtime
1698 .prompt({ prompt: [{ type: "text", text: "hi" }] })
1699 .pipe(Effect.forkChild);
1700 yield* Effect.yieldNow;
1701 const sessionUpdateHandler = handlers.get("_x.ai/session/update");
1702 expect(sessionUpdateHandler).toBeDefined();
1703 yield* sessionUpdateHandler!({
1704 sessionId: "root-session",
1705 update: {
1706 sessionUpdate: "turn_completed",
1707 prompt_id: "task-completed-call-abc",
1708 stop_reason: "end_turn",
1709 },
1710 });
1711 yield* sessionUpdateHandler!({
1712 sessionId: "root-session",
1713 update: {
1714 sessionUpdate: "turn_completed",
1715 prompt_id: "some-other-cli-turn",
1716 stop_reason: "end_turn",
1717 },
1718 });
1719 yield* Effect.yieldNow;
1720 expect(promptFiber.pollUnsafe()).toBeUndefined();
1721 yield* Fiber.interrupt(promptFiber);
1722 }),
1723 );
1724
1725 it.effect("ignores prompt_complete notifications for foreign session ids", () =>
1726 Effect.gen(function* () {
1727 const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
1728 const hungPrompt = yield* Deferred.make<never>();
1729 const baseRuntime = {
1730 start: () =>
1731 Effect.succeed({
1732 sessionId: "root-session",
1733 initializeResult: {},
1734 sessionSetupResult: {},
1735 modelConfigId: undefined,
1736 }),
1737 prompt: () => Deferred.await(hungPrompt),
1738 cancel: Effect.void,
1739 handleExtNotification: (
1740 method: string,
1741 _schema: unknown,
1742 handler: (notification: unknown) => Effect.Effect<void>,
1743 ) => {
1744 handlers.set(method, handler);
1745 return Effect.void;
1746 },
1747 handleExtRequest: () => Effect.void,
1748 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
1749
1750 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
1751 const promptFiber = yield* runtime
1752 .prompt({ prompt: [{ type: "text", text: "hi" }] })
1753 .pipe(Effect.forkChild);
1754 yield* Effect.yieldNow;
1755 const promptCompleteHandler = handlers.get("_x.ai/session/prompt_complete");
1756 expect(promptCompleteHandler).toBeDefined();
1757 yield* promptCompleteHandler!({
1758 sessionId: "child-session",
1759 });
1760 yield* Effect.yieldNow;
1761 expect(promptFiber.pollUnsafe()).toBeUndefined();
1762 yield* Fiber.interrupt(promptFiber);
1763 }),
1764 );
1765
1766 it.effect(
1767 "ignores promptId-less prompt_complete when multiple prompts are pending on the same session",
1768 () =>
1769 Effect.gen(function* () {
1770 const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
1771 const hungPrompt = yield* Deferred.make<never>();
1772 const baseRuntime = {
1773 start: () =>
1774 Effect.succeed({
1775 sessionId: "root-session",
1776 initializeResult: {},
1777 sessionSetupResult: {},
1778 modelConfigId: undefined,
1779 }),
1780 prompt: () => Deferred.await(hungPrompt),
1781 cancel: Effect.void,
1782 handleExtNotification: (
1783 method: string,
1784 _schema: unknown,
1785 handler: (notification: unknown) => Effect.Effect<void>,
1786 ) => {
1787 handlers.set(method, handler);
1788 return Effect.void;
1789 },
1790 handleExtRequest: () => Effect.void,
1791 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
1792
1793 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
1794 const firstPromptFiber = yield* runtime
1795 .prompt({ prompt: [{ type: "text", text: "first" }] })
1796 .pipe(Effect.forkChild);
1797 yield* Effect.yieldNow;
1798 const secondPromptFiber = yield* runtime
1799 .prompt({ prompt: [{ type: "text", text: "second" }] })
1800 .pipe(Effect.forkChild);
1801 yield* Effect.yieldNow;
1802 const promptCompleteHandler = handlers.get("_x.ai/session/prompt_complete");
1803 expect(promptCompleteHandler).toBeDefined();
1804 yield* promptCompleteHandler!({
1805 sessionId: "root-session",
1806 stopReason: "end_turn",
1807 });
1808 yield* Effect.yieldNow;
1809 expect(firstPromptFiber.pollUnsafe()).toBeUndefined();
1810 expect(secondPromptFiber.pollUnsafe()).toBeUndefined();
1811 yield* Fiber.interrupt(firstPromptFiber);
1812 yield* Fiber.interrupt(secondPromptFiber);
1813 }),
1814 );
1815
1816 it.effect("injects promptId and requestId into prompt _meta", () =>
1817 Effect.gen(function* () {
1818 let capturedMeta: Record<string, unknown> | null | undefined;
1819 const baseRuntime = {
1820 start: () => Effect.succeed({ sessionId: "session-1" }),
1821 prompt: (payload: { readonly _meta?: Record<string, unknown> | null }) => {
1822 capturedMeta = payload._meta ?? null;
1823 return Effect.succeed({ stopReason: "end_turn" as const });
1824 },
1825 cancel: Effect.void,
1826 handleExtNotification: () => Effect.void,
1827 handleExtRequest: () => Effect.void,
1828 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
1829
1830 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
1831 yield* runtime.prompt({ prompt: [{ type: "text", text: "hi" }] });
1832
1833 expect(typeof capturedMeta?.promptId).toBe("string");
1834 expect(capturedMeta).toMatchObject({
1835 promptId: capturedMeta?.promptId,
1836 requestId: capturedMeta?.promptId,
1837 });
1838 }),
1839 );
1840
1841 it.effect("cancels pending completions without restarting a stalled runtime", () =>
1842 Effect.gen(function* () {
1843 const hungStart = yield* Deferred.make<never>();
1844 const hungPrompt = yield* Deferred.make<never>();
1845 let startCount = 0;
1846 let cancelCalled = false;
1847 const baseRuntime = {
1848 start: () => {
1849 startCount += 1;
1850 return startCount === 1
1851 ? Effect.succeed({
1852 sessionId: "root-session",
1853 initializeResult: {},
1854 sessionSetupResult: {},
1855 modelConfigId: undefined,
1856 })
1857 : Deferred.await(hungStart);
1858 },
1859 prompt: () => Deferred.await(hungPrompt),
1860 cancel: Effect.sync(() => {
1861 cancelCalled = true;
1862 }),
1863 handleExtNotification: () => Effect.void,
1864 handleExtRequest: () => Effect.void,
1865 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
1866
1867 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
1868 const promptFiber = yield* runtime
1869 .prompt({ prompt: [{ type: "text", text: "hi" }] })
1870 .pipe(Effect.forkChild);
1871 yield* Effect.yieldNow;
1872
1873 yield* runtime.cancel;
1874
1875 expect(cancelCalled).toBe(true);
1876 expect(startCount).toBe(1);
1877 expect((yield* Fiber.join(promptFiber)).stopReason).toBe("cancelled");
1878 }),
1879 );
1880});
1881
1882describe("Grok exit_plan_mode capture (#8358)", () => {
1883 it("extracts plan markdown from exit_plan_mode payloads", () => {
1884 const decode = Schema.decodeUnknownSync(XAiExitPlanModeRequest);
1885 const direct = decode({
1886 sessionId: "session-1",
1887 toolCallId: "exit-1",
1888 planContent: "# Plan\n\n- do the thing\n",
1889 });
1890 expect(extractXAiExitPlanMarkdown(direct)).toBe("# Plan\n\n- do the thing");
1891
1892 const wrapped = decode({
1893 method: "_x.ai/exit_plan_mode",
1894 params: {
1895 sessionId: "session-1",
1896 toolCallId: "exit-1",
1897 planContent: null,
1898 },
1899 });
1900 expect(extractXAiExitPlanMarkdown(wrapped, " # fallback plan ")).toBe("# fallback plan");
1901 expect(extractXAiExitPlanMarkdown(wrapped, "")).toBe(XAI_EMPTY_PLAN_MARKDOWN);
1902 expect(extractXAiExitPlanMarkdown(wrapped)).toBe(XAI_EMPTY_PLAN_MARKDOWN);
1903 });
1904 it("identifies Grok plan.md paths and extracts markdown from tool call data", () => {
1905 const linuxHost = { platform: "linux" as const, environment: {} };
1906 const windowsHost = { platform: "win32" as const, environment: {} };
1907 const grokHomeHost = {
1908 platform: "linux" as const,
1909 environment: { GROK_HOME: "/opt/grok-data" },
1910 };
1911 const home = NodeOS.homedir().replace(/\\/g, "/");
1912 const sessionPlan = `${home}/.grok/sessions/abc/plan.md`;
1913 const nestedSessionPlan = `${home}/.grok/sessions/%2Fhome%2Fproj/019fd20e-c563-70a0-b801-a6bc51815a9b/plan.md`;
1914 expect(isGrokPlanMarkdownPath(sessionPlan, linuxHost)).toBe(true);
1915 expect(isGrokPlanMarkdownPath(nestedSessionPlan, linuxHost)).toBe(true);
1916 expect(isGrokPlanMarkdownPath("~/.grok/sessions/abc/plan.md", linuxHost)).toBe(true);
1917 expect(isGrokPlanMarkdownPath("/tmp/mock-home/.grok/sessions/sess/plan.md", linuxHost)).toBe(
1918 true,
1919 );
1920 expect(isGrokPlanMarkdownPath("/home/other/.grok/sessions/sess/plan.md", linuxHost)).toBe(true);
1921 expect(isGrokPlanMarkdownPath("/HOME/other/.grok/sessions/sess/plan.md", linuxHost)).toBe(
1922 false,
1923 );
1924 expect(isGrokPlanMarkdownPath("C:/Users/other/.grok/sessions/id/plan.md", windowsHost)).toBe(
1925 true,
1926 );
1927 expect(isGrokPlanMarkdownPath("c:/users/OTHER/.GROK/SESSIONS/id/PLAN.MD", windowsHost)).toBe(
1928 true,
1929 );
1930 expect(
1931 isGrokPlanMarkdownPath("C:\\Users\\other\\.grok\\sessions\\id\\plan.md", windowsHost),
1932 ).toBe(true);
1933 expect(isGrokPlanMarkdownPath("/opt/grok-data/sessions/sess/plan.md", grokHomeHost)).toBe(true);
1934 expect(
1935 isGrokPlanMarkdownPath("/OPT/GROK-DATA/sessions/sess/plan.md", {
1936 platform: "win32",
1937 environment: { GROK_HOME: "/opt/grok-data" },
1938 }),
1939 ).toBe(true);
1940 expect(isGrokPlanMarkdownPath("/OPT/GROK-DATA/sessions/sess/plan.md", grokHomeHost)).toBe(
1941 false,
1942 );
1943 // Workspace plan.md must not be treated as the session plan file.
1944 expect(isGrokPlanMarkdownPath("plan.md", linuxHost)).toBe(false);
1945 expect(isGrokPlanMarkdownPath("/repo/docs/plan.md", linuxHost)).toBe(false);
1946 expect(isGrokPlanMarkdownPath("/tmp/other.md", linuxHost)).toBe(false);
1947 expect(isGrokPlanMarkdownPath("/repo/.grok/sessions/example/plan.md", linuxHost)).toBe(false);
1948 expect(
1949 isGrokPlanMarkdownPath(`${home}/project/.grok/sessions/example/plan.md`, linuxHost),
1950 ).toBe(false);
1951 expect(
1952 isGrokPlanMarkdownPath("/home/other/.grok/sessions/../../project/plan.md", linuxHost),
1953 ).toBe(false);
1954 expect(
1955 isGrokPlanMarkdownPath("/home/other/.grok/sessions/foo/../../../project/plan.md", linuxHost),
1956 ).toBe(false);
1957
1958 expect(
1959 extractGrokPlanMarkdownFromToolCallData(
1960 {
1961 rawInput: {
1962 file_path: sessionPlan,
1963 content: "# From rawInput\n\n- a\n",
1964 },
1965 },
1966 linuxHost,
1967 ),
1968 ).toBe("# From rawInput\n\n- a");
1969
1970 expect(
1971 extractGrokPlanMarkdownFromToolCallData(
1972 {
1973 content: [
1974 {
1975 type: "diff",
1976 path: sessionPlan,
1977 oldText: "",
1978 newText: "# From diff\n\n- b\n",
1979 },
1980 ],
1981 },
1982 linuxHost,
1983 ),
1984 ).toBe("# From diff\n\n- b");
1985
1986 expect(
1987 extractGrokPlanMarkdownFromToolCallData(
1988 {
1989 rawInput: { file_path: sessionPlan, content: "" },
1990 content: [
1991 {
1992 type: "diff",
1993 path: sessionPlan,
1994 oldText: "",
1995 newText: "# From diff after empty rawInput\n",
1996 },
1997 ],
1998 },
1999 linuxHost,
2000 ),
2001 ).toBe("# From diff after empty rawInput");
2002
2003 expect(
2004 extractGrokPlanMarkdownFromToolCallData(
2005 {
2006 rawInput: { file_path: sessionPlan, content: "" },
2007 },
2008 linuxHost,
2009 ),
2010 ).toBe("");
2011
2012 expect(
2013 extractGrokPlanMarkdownFromToolCallData(
2014 {
2015 content: [{ type: "diff", path: sessionPlan, oldText: "# old", newText: "" }],
2016 },
2017 linuxHost,
2018 ),
2019 ).toBe("");
2020
2021 expect(
2022 extractGrokPlanMarkdownFromToolCallData(
2023 {
2024 rawInput: { file_path: "/tmp/readme.md", content: "nope" },
2025 },
2026 linuxHost,
2027 ),
2028 ).toBeUndefined();
2029
2030 expect(
2031 extractGrokPlanMarkdownFromToolCallData(
2032 {
2033 rawInput: { file_path: "/repo/docs/plan.md", content: "# Project plan\n" },
2034 },
2035 linuxHost,
2036 ),
2037 ).toBeUndefined();
2038 });
2039});
apps/server/src/provider/acp/XAiAcpExtension.ts deleted-1713
...@@ -1,1713 +0,0 @@
1// @effect-diagnostics nodeBuiltinImport:off -- Grok's plan file lives under the OS home dir.
2import * as NodeOS from "node:os";
3
4import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts";
5import * as Cause from "effect/Cause";
6import * as Deferred from "effect/Deferred";
7import * as Effect from "effect/Effect";
8import * as Fiber from "effect/Fiber";
9import * as Ref from "effect/Ref";
10import * as Schema from "effect/Schema";
11import * as EffectAcpErrors from "effect-acp/errors";
12import type * as EffectAcpSchema from "effect-acp/compat";
13
14import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
15import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel";
16
17const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing";
18export const xAiRateLimitedErrorCode = -32003;
19const completedXAiPromptIdLimit = 128;
20
21const XAiPromptCompleteNotification = Schema.Struct({
22 sessionId: Schema.String,
23 promptId: Schema.optional(Schema.String),
24 stopReason: Schema.optional(Schema.String),
25 agentResult: Schema.optional(Schema.NullOr(Schema.Unknown)),
26});
27
28type XAiPromptCompleteNotification = typeof XAiPromptCompleteNotification.Type;
29
30/**
31 * Grok persists root turn completion as an extension session update:
32 * `{ params: { sessionId, update: {
33 * sessionUpdate: "turn_completed", prompt_id, stop_reason } } }`.
34 * The open-source runtime forwards it as `x.ai/session_notification` and
35 * stores/replays it as `_x.ai/session/update`; released 0.2.106 forwards the
36 * underscore alias `_x.ai/session_notification`. Current source also emits
37 * the fire-and-forget canonical `x.ai/session/prompt_complete`, while released
38 * builds use the underscore alias.
39 */
40const XAiSessionUpdateNotification = Schema.Struct({
41 sessionId: Schema.String,
42 update: Schema.Struct({
43 sessionUpdate: Schema.String,
44 prompt_id: Schema.optional(Schema.String),
45 promptId: Schema.optional(Schema.String),
46 stop_reason: Schema.optional(Schema.String),
47 stopReason: Schema.optional(Schema.String),
48 agent_result: Schema.optional(Schema.NullOr(Schema.Unknown)),
49 // subagent_finished
50 child_session_id: Schema.optional(Schema.String),
51 status: Schema.optional(Schema.String),
52 output: Schema.optional(Schema.NullOr(Schema.String)),
53 error: Schema.optional(Schema.NullOr(Schema.String)),
54 }),
55 _meta: Schema.optional(Schema.Unknown),
56});
57
58type XAiSessionUpdateNotification = typeof XAiSessionUpdateNotification.Type;
59
60const decodeXAiSessionUpdateNotification = Schema.decodeUnknownEffect(XAiSessionUpdateNotification);
61
62const xAiSessionNotificationMethods = [
63 "x.ai/session_notification",
64 "_x.ai/session_notification",
65 "_x.ai/session/update",
66] as const;
67
68const XAI_TASK_COMPLETED_PROMPT_ID_PREFIX = "task-completed-";
69
70/**
71 * Map a Grok `_x.ai/session/update` payload to a prompt-complete shape when it
72 * is a root `turn_completed` for a real prompt id. Returns null for hooks,
73 * background task completions, and other update kinds.
74 */
75export function xAiPromptCompleteFromSessionUpdate(
76 notification: XAiSessionUpdateNotification,
77): XAiPromptCompleteNotification | null {
78 const update = notification.update;
79 if (update.sessionUpdate !== "turn_completed") {
80 return null;
81 }
82 const promptId = nonEmptyString(update.prompt_id) ?? nonEmptyString(update.promptId);
83 // Require prompt id so we never session-fallback match a background task
84 // completion (`task-completed-*`) or an unrelated CLI turn.
85 if (promptId === undefined || promptId.startsWith(XAI_TASK_COMPLETED_PROMPT_ID_PREFIX)) {
86 return null;
87 }
88 const stopReason = nonEmptyString(update.stop_reason) ?? nonEmptyString(update.stopReason);
89 return {
90 sessionId: notification.sessionId,
91 promptId,
92 ...(stopReason === undefined ? {} : { stopReason }),
93 ...(update.agent_result === undefined ? {} : { agentResult: update.agent_result }),
94 };
95}
96
97/**
98 * Grok answers a finished background command in its own turn, tagging every
99 * frame with a `task-completed-*` prompt id instead of the one T3 sent.
100 */
101export function isXAiTaskCompletedWakeNotification(
102 notification: EffectAcpSchema.SessionNotification,
103): boolean {
104 const promptId = notification._meta?.promptId;
105 return typeof promptId === "string" && promptId.startsWith(XAI_TASK_COMPLETED_PROMPT_ID_PREFIX);
106}
107
108interface PendingXAiPromptCompletion {
109 readonly sessionId: string;
110 readonly promptId: string;
111 readonly deferred: Deferred.Deferred<
112 EffectAcpSchema.PromptResponse,
113 EffectAcpErrors.AcpRequestError
114 >;
115}
116
117export interface XAiAcpSubagentUpdate {
118 readonly nativeTaskId: string;
119 readonly prompt: string;
120 readonly title: string | null;
121 readonly model: string | null;
122 readonly status: "running" | "completed" | "failed";
123 readonly childSessionId: string | null;
124 readonly result: string | null;
125 /**
126 * When false, the ACP adapter also projects a normal tool turn item for this
127 * tool call (used for get_command_or_subagent_output hydration). Default true.
128 */
129 readonly suppressNormalTool?: boolean;
130}
131
132export interface XAiAcpBackgroundToolMutation {
133 readonly taskId: string;
134 readonly status: "running" | "completed" | "failed";
135 readonly appendOutput: string;
136}
137
138export interface XAiAcpSubagentEndNotice {
139 readonly childSessionId: string;
140 readonly status: "completed" | "failed";
141}
142
143const XAI_UUID_RE = "[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}";
144
145function unknownRecord(value: unknown): Record<string, unknown> | undefined {
146 return typeof value === "object" && value !== null && !Array.isArray(value)
147 ? (value as Record<string, unknown>)
148 : undefined;
149}
150
151function nonEmptyString(value: unknown): string | undefined {
152 return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
153}
154
155function titleKey(toolCall: AcpToolCallState): string {
156 return (toolCall.title ?? "").trim().toLowerCase();
157}
158
159function decodeByteText(value: unknown): string | undefined {
160 if (typeof value === "string") return nonEmptyString(value);
161 if (!Array.isArray(value)) return undefined;
162 if (value.length === 0) return undefined;
163 if (!value.every((entry) => typeof entry === "number")) return undefined;
164 try {
165 return nonEmptyString(new TextDecoder().decode(Uint8Array.from(value as number[])));
166 } catch {
167 return undefined;
168 }
169}
170
171function xAiToolOutputText(toolCall: AcpToolCallState): string | undefined {
172 const rawOutput = unknownRecord(toolCall.data.rawOutput);
173 if (rawOutput !== undefined) {
174 const direct =
175 nonEmptyString(rawOutput.text) ??
176 nonEmptyString(rawOutput.output_for_prompt) ??
177 decodeByteText(rawOutput.output);
178 if (direct !== undefined) return direct;
179 const result = unknownRecord(rawOutput.Result) ?? unknownRecord(rawOutput.result);
180 if (result !== undefined) {
181 const nested =
182 nonEmptyString(result.output) ??
183 nonEmptyString(result.text) ??
184 decodeByteText(result.output);
185 if (nested !== undefined) return nested;
186 }
187 }
188 if (typeof toolCall.data.rawOutput === "string") {
189 return nonEmptyString(toolCall.data.rawOutput);
190 }
191 const content = toolCall.data.content;
192 if (!Array.isArray(content)) return undefined;
193 const text = content
194 .flatMap((entry) => {
195 const record = unknownRecord(entry);
196 const nested = unknownRecord(record?.content);
197 return nonEmptyString(nested?.text) ?? nonEmptyString(record?.text) ?? [];
198 })
199 .join("\n")
200 .trim();
201 return text.length > 0 ? text : undefined;
202}
203
204function firstUuidMatch(text: string, pattern: RegExp): string | undefined {
205 const match = pattern.exec(text);
206 return match?.[1];
207}
208
209function extractXAiChildSessionId(output: string | undefined): string | null {
210 if (output === undefined) return null;
211 return (
212 firstUuidMatch(output, new RegExp(`(?:^|\\n)\\s*Agent ID:\\s*(${XAI_UUID_RE})\\b`, "i")) ??
213 firstUuidMatch(output, new RegExp(`(?:^|\\n)\\s*subagent_id:\\s*(${XAI_UUID_RE})\\b`, "i")) ??
214 firstUuidMatch(output, new RegExp(`===\\s*Task\\s+(${XAI_UUID_RE})\\s*===`, "i")) ??
215 null
216 );
217}
218
219function isXAiAsyncSpawnAck(output: string | undefined): boolean {
220 if (output === undefined) return false;
221 if (/subagent started in background/i.test(output)) return true;
222 return (
223 new RegExp(`subagent_id:\\s*${XAI_UUID_RE}`, "i").test(output) &&
224 /get_command_or_subagent_output/i.test(output)
225 );
226}
227
228function isXAiMonitorStartAck(output: string | undefined): boolean {
229 if (output === undefined) return false;
230 return /monitor started\s*\(\s*task\s+/i.test(output);
231}
232
233function isXAiSpawnOrTaskTool(
234 toolCall: AcpToolCallState,
235 rawInput: Record<string, unknown> | undefined,
236): boolean {
237 const title = titleKey(toolCall);
238 if (title === "task" || title === "spawn_subagent" || title.includes("spawn subagent")) {
239 return true;
240 }
241 const variant = nonEmptyString(rawInput?.variant)?.toLowerCase();
242 if (variant === "cursortask" || variant === "task" || variant === "spawn_subagent") {
243 return true;
244 }
245 if (nonEmptyString(rawInput?.subagent_type) !== undefined) return true;
246 if (nonEmptyString(rawInput?.subagentType) !== undefined) return true;
247 return false;
248}
249
250function isXAiGetSubagentOutputTool(
251 toolCall: AcpToolCallState,
252 rawInput: Record<string, unknown> | undefined,
253): boolean {
254 const title = titleKey(toolCall);
255 if (
256 title === "get_command_or_subagent_output" ||
257 title.includes("get_command_or_subagent_output") ||
258 title === "wait_commands_or_subagents"
259 ) {
260 return true;
261 }
262 const variant = nonEmptyString(rawInput?.variant)?.toLowerCase();
263 if (variant === "taskoutput" || variant === "task_output") return true;
264 const rawOutput = unknownRecord(toolCall.data.rawOutput);
265 const outputType = nonEmptyString(rawOutput?.type)?.toLowerCase();
266 if (outputType === "taskoutput" || outputType === "task_output") return true;
267 // ACP often titles this tool with the finished subagent label.
268 if (title.startsWith("[subagent:") && Array.isArray(rawInput?.task_ids)) return true;
269 return false;
270}
271
272export function isXAiMonitorTool(toolCall: AcpToolCallState): boolean {
273 const title = titleKey(toolCall);
274 if (title === "monitor" || title.startsWith("monitor ")) return true;
275 const rawInput = unknownRecord(toolCall.data.rawInput);
276 const variant = nonEmptyString(rawInput?.variant)?.toLowerCase();
277 if (variant === "monitor") return true;
278 // Live start ACKs often keep a generic title ("Tool") and only mark the
279 // structured envelope as Monitor; title rewrite to description must not
280 // prevent monitor registration / inProgress forcing.
281 const rawOutput = unknownRecord(toolCall.data.rawOutput);
282 const outputType = nonEmptyString(rawOutput?.type)?.toLowerCase();
283 if (outputType === "monitor") return true;
284 // Released clients can wrap the start ACK as plain Text and omit the Monitor
285 // variant. The ACK is still unambiguous and contains the task id required for
286 // background tracking, even after a generic title is rewritten from
287 // rawInput.description.
288 return isXAiMonitorStartAck(xAiToolOutputText(toolCall));
289}
290
291/**
292 * A shell command Grok runs in the background (asked for, or moved there after
293 * its auto-background timeout) acknowledges with
294 * `{ type: "BackgroundTaskStarted", task_id }` and completes its tool call
295 * while the process still runs. Like a monitor, it ends only with
296 * `x.ai/task_completed` for that task id.
297 */
298function xAiBackgroundShellTaskId(toolCall: AcpToolCallState): string | undefined {
299 const rawOutput = unknownRecord(toolCall.data.rawOutput);
300 return nonEmptyString(rawOutput?.type) === "BackgroundTaskStarted"
301 ? nonEmptyString(rawOutput?.task_id)
302 : undefined;
303}
304
305export function extractXAiMonitorTaskId(toolCall: AcpToolCallState): string | undefined {
306 const backgroundShellTaskId = xAiBackgroundShellTaskId(toolCall);
307 if (backgroundShellTaskId !== undefined) return backgroundShellTaskId;
308 if (!isXAiMonitorTool(toolCall)) return undefined;
309 const rawOutput = unknownRecord(toolCall.data.rawOutput);
310 // Live ACP start ACK: { type: "Monitor", taskId, timeoutMs, persistent }
311 if (rawOutput !== undefined && nonEmptyString(rawOutput.type)?.toLowerCase() === "monitor") {
312 const structured = nonEmptyString(rawOutput.taskId) ?? nonEmptyString(rawOutput.task_id);
313 if (structured !== undefined) return structured;
314 }
315 const output = xAiToolOutputText(toolCall);
316 if (output !== undefined) {
317 const fromAck = firstUuidMatch(
318 output,
319 new RegExp(`monitor started\\s*\\(\\s*task\\s+(${XAI_UUID_RE})\\b`, "i"),
320 );
321 if (fromAck !== undefined) return fromAck;
322 }
323 const rawInput = unknownRecord(toolCall.data.rawInput);
324 return (
325 nonEmptyString(rawInput?.task_id) ??
326 nonEmptyString(rawInput?.taskId) ??
327 (Array.isArray(rawInput?.task_ids) ? nonEmptyString(rawInput.task_ids[0]) : undefined)
328 );
329}
330
331/**
332 * Live Monitor start ACK `{ type: "Monitor", persistent: true }` (or matching
333 * rawInput). Persistent monitors should not hold root-turn deferred finalize.
334 */
335export function isXAiPersistentMonitor(toolCall: AcpToolCallState): boolean {
336 if (!isXAiMonitorTool(toolCall)) return false;
337 const rawOutput = unknownRecord(toolCall.data.rawOutput);
338 if (rawOutput !== undefined && nonEmptyString(rawOutput.type)?.toLowerCase() === "monitor") {
339 return rawOutput.persistent === true;
340 }
341 const rawInput = unknownRecord(toolCall.data.rawInput);
342 return rawInput?.persistent === true;
343}
344
345/**
346 * When get_command_or_subagent_output / TaskOutput completes registered monitor
347 * task(s), return hydration for each completed background tool id.
348 */
349export function extractXAiBackgroundTaskCompletion(toolCall: AcpToolCallState): ReadonlyArray<{
350 readonly taskId: string;
351 readonly status: "running" | "completed" | "failed";
352 readonly appendOutput: string;
353}> {
354 const rawInput = unknownRecord(toolCall.data.rawInput);
355 if (!isXAiGetSubagentOutputTool(toolCall, rawInput)) return [];
356 // Caller matches taskId against registered background tools (monitors).
357 // Subagent hydration stays on extractXAiAcpSubagentUpdate.
358 const text = xAiToolOutputText(toolCall);
359 const status = statusFromGetOutputTool(toolCall, text);
360 const appendOutput = resultFromGetOutputTool(toolCall, text) ?? "";
361 // Prefer the completed identity from the result/header over the request list
362 // (one call can poll multiple task_ids).
363 const resultTaskId = resultTaskIdFromGetOutputTool(toolCall, text);
364 if (resultTaskId !== undefined) {
365 return [{ taskId: resultTaskId, status, appendOutput }];
366 }
367 const requestIds: string[] = [];
368 const push = (value: unknown) => {
369 const id = nonEmptyString(value);
370 if (id !== undefined && new RegExp(`^${XAI_UUID_RE}$`, "i").test(id)) {
371 requestIds.push(id);
372 }
373 };
374 if (Array.isArray(rawInput?.task_ids)) {
375 for (const entry of rawInput.task_ids) push(entry);
376 }
377 push(rawInput?.task_id);
378 push(rawInput?.taskId);
379 const unique = [...new Set(requestIds)];
380 return unique.map((taskId) => ({ taskId, status, appendOutput }));
381}
382
383/**
384 * A finished kill_command_or_subagent call is the genuine end signal for every
385 * task id it names: the Grok CLI emits no `x.ai/task_completed` for a killed
386 * task. A failed kill is not proof the target stopped, so leave the existing
387 * task state unchanged until a genuine terminal signal arrives.
388 */
389export function extractXAiKilledBackgroundTasks(toolCall: AcpToolCallState): ReadonlyArray<{
390 readonly taskId: string;
391 readonly status: "running" | "completed" | "failed";
392 readonly appendOutput: string;
393}> {
394 const title = titleKey(toolCall);
395 const rawInput = unknownRecord(toolCall.data.rawInput);
396 const variant = nonEmptyString(rawInput?.variant)?.toLowerCase();
397 if (!title.includes("kill_command_or_subagent") && variant !== "kill") return [];
398 if (toolCall.status !== "completed") return [];
399 const taskIds: string[] = [];
400 const push = (value: unknown) => {
401 const id = nonEmptyString(value);
402 if (id !== undefined) taskIds.push(id);
403 };
404 if (Array.isArray(rawInput?.task_ids)) {
405 for (const entry of rawInput.task_ids) push(entry);
406 }
407 push(rawInput?.task_id);
408 push(rawInput?.taskId);
409 return [...new Set(taskIds)].map((taskId) => ({
410 taskId,
411 status: "completed" as const,
412 appendOutput: "",
413 }));
414}
415
416const XAiTaskLifecycleNotification = Schema.Struct({
417 sessionId: Schema.String,
418 update: Schema.Struct({
419 sessionUpdate: Schema.String,
420 task_id: Schema.optional(Schema.String),
421 tool_call_id: Schema.optional(Schema.String),
422 command: Schema.optional(Schema.String),
423 description: Schema.optional(Schema.NullOr(Schema.String)),
424 monitor_description: Schema.optional(Schema.NullOr(Schema.String)),
425 task_snapshot: Schema.optional(
426 Schema.Struct({
427 task_id: Schema.optional(Schema.String),
428 output: Schema.optional(Schema.String),
429 exit_code: Schema.optional(Schema.NullOr(Schema.Number)),
430 command: Schema.optional(Schema.String),
431 display_command: Schema.optional(Schema.NullOr(Schema.String)),
432 description: Schema.optional(Schema.NullOr(Schema.String)),
433 // `TaskKind` in grok-build crates/common/xai-tool-runtime/src/notification.rs.
434 kind: Schema.optional(Schema.String),
435 }),
436 ),
437 }),
438 _meta: Schema.optional(Schema.Unknown),
439});
440
441type XAiTaskLifecycleNotification = typeof XAiTaskLifecycleNotification.Type;
442
443export interface XAiBackgroundTaskLifecycleMutation {
444 readonly sessionId: string;
445 readonly taskId: string;
446 readonly status: "running" | "completed" | "failed";
447 /** Final output from `task_completed.task_snapshot`, when Grok sent one. */
448 readonly output?: string;
449 /** What the task is and how to name it, when Grok said. */
450 readonly report?:
451 | { readonly kind: "command"; readonly label?: string; readonly exitCode?: number }
452 | { readonly kind: "monitor"; readonly label?: string };
453}
454
455function xAiBackgroundTaskReport(
456 update: XAiTaskLifecycleNotification["update"],
457): XAiBackgroundTaskLifecycleMutation["report"] {
458 const snapshot = update.task_snapshot;
459 const monitorDescription = nonEmptyString(update.monitor_description ?? undefined);
460 const displayCommand = nonEmptyString(snapshot?.display_command ?? undefined);
461 const isMonitor =
462 snapshot?.kind === "monitor" ||
463 monitorDescription !== undefined ||
464 displayCommand?.startsWith("[monitor]") === true;
465 // Grok's snapshot defaults `kind` to bash; anything that carries a command is one.
466 const isCommand =
467 snapshot?.kind === "bash" || snapshot?.command !== undefined || update.command !== undefined;
468 if (!isMonitor && !isCommand) return undefined;
469 const label =
470 monitorDescription ??
471 nonEmptyString(snapshot?.description ?? undefined) ??
472 nonEmptyString(update.description ?? undefined) ??
473 displayCommand?.replace(/^\[monitor\]\s*/, "") ??
474 nonEmptyString(snapshot?.command) ??
475 nonEmptyString(update.command);
476 const named = label === undefined ? {} : { label };
477 if (isMonitor) return { kind: "monitor", ...named };
478 const exitCode = snapshot?.exit_code;
479 return { kind: "command", ...named, ...(typeof exitCode === "number" ? { exitCode } : {}) };
480}
481
482export function xAiBackgroundTaskLifecycleMutation(
483 notification: XAiTaskLifecycleNotification,
484 status: "running" | "completed",
485): XAiBackgroundTaskLifecycleMutation | null {
486 const update = notification.update;
487 const taskId =
488 nonEmptyString(update.task_snapshot?.task_id) ??
489 nonEmptyString(update.task_id) ??
490 nonEmptyString(update.tool_call_id);
491 if (taskId === undefined) return null;
492 const exitCode = update.task_snapshot?.exit_code;
493 const output = update.task_snapshot?.output;
494 const report = xAiBackgroundTaskReport(update);
495 return {
496 sessionId: notification.sessionId,
497 taskId,
498 status:
499 status === "completed" && typeof exitCode === "number" && exitCode !== 0 ? "failed" : status,
500 ...(output === undefined ? {} : { output }),
501 ...(report === undefined ? {} : { report }),
502 };
503}
504
505/**
506 * Grok task lifecycle notifications use canonical `x.ai/task_*` methods in
507 * current builds; older 0.2.x builds used `_x.ai/task_*`. Interactive cancel
508 * preserves tasks that were already backgrounded (and some older builds could
509 * background the cancelled foreground command), so both spellings must be
510 * tracked. Residual frames then buffer instead of waking synthetic
511 * continuations, and pending-background-work stays truthful until the task
512 * ends (or the model kills it via kill_command_or_subagent).
513 */
514export const registerXAiBackgroundTaskTracking = (
515 runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "handleExtNotification">,
516 apply: (mutation: XAiBackgroundTaskLifecycleMutation) => Effect.Effect<void>,
517): Effect.Effect<void, EffectAcpErrors.AcpError> =>
518 Effect.forEach(
519 [
520 ["x.ai/task_backgrounded", "running"],
521 ["_x.ai/task_backgrounded", "running"],
522 ["x.ai/task_completed", "completed"],
523 ["_x.ai/task_completed", "completed"],
524 ] as const,
525 ([method, status]) =>
526 runtime.handleExtNotification(method, XAiTaskLifecycleNotification, (notification) => {
527 const mutation = xAiBackgroundTaskLifecycleMutation(notification, status);
528 return mutation === null ? Effect.void : apply(mutation);
529 }),
530 { discard: true },
531 );
532
533/**
534 * A background subagent's end, sent on its PARENT session as
535 * `{ sessionUpdate: "subagent_finished", child_session_id, status, output, error }`
536 * (grok-build `crates/codegen/xai-grok-shell/src/extensions/notification.rs`
537 * `SessionUpdate::SubagentFinished`). `status` is exactly "completed",
538 * "failed" or "cancelled" (`SubagentResult::status()` in
539 * `crates/codegen/xai-grok-tools/src/implementations/grok_build/task/types.rs`);
540 * `output` is the final text of a completed subagent and `error` the message
541 * of a failed one. Grok follows it with its own `subagent-completed-<id>` wake
542 * turn when `will_wake` is true.
543 */
544export interface XAiSubagentFinishedNotice {
545 readonly sessionId: string;
546 readonly childSessionId: string;
547 readonly status: "completed" | "failed" | "cancelled";
548 readonly result: string | null;
549}
550
551/** Null for other session updates, and for a status Grok does not define. */
552export function xAiSubagentFinishedNotice(
553 notification: XAiSessionUpdateNotification,
554): XAiSubagentFinishedNotice | null {
555 const update = notification.update;
556 const childSessionId = nonEmptyString(update.child_session_id);
557 if (update.sessionUpdate !== "subagent_finished" || childSessionId === undefined) return null;
558 const status = update.status;
559 if (status !== "completed" && status !== "failed" && status !== "cancelled") return null;
560 const text = status === "completed" ? update.output : update.error;
561 return {
562 sessionId: notification.sessionId,
563 childSessionId,
564 status,
565 result: nonEmptyString(text ?? undefined) ?? null,
566 };
567}
568
569/**
570 * Finishes background subagents from Grok's `subagent_finished`. These arrive
571 * on the session notification methods that also carry turn completion; a
572 * runtime from {@link makeXAiPromptCompletionRuntime} settles its prompt from
573 * each notification before passing it to this handler.
574 */
575export const registerXAiSubagentFinished = (
576 runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "handleExtNotification">,
577 finish: (notice: XAiSubagentFinishedNotice) => Effect.Effect<void>,
578): Effect.Effect<void> =>
579 Effect.forEach(
580 xAiSessionNotificationMethods,
581 (method) =>
582 runtime.handleExtNotification(method, XAiSessionUpdateNotification, (notification) => {
583 const notice = xAiSubagentFinishedNotice(notification);
584 return notice === null ? Effect.void : finish(notice);
585 }),
586 { discard: true },
587 );
588
589/**
590 * Grok ACP often sends `title: "Tool"` even when rawInput has a useful
591 * description or variant (especially Monitor). Prefer description/variant so
592 * the timeline matches Claude-style tool names rather than a generic label.
593 */
594export function isGenericAcpToolTitle(title: string | undefined): boolean {
595 const key = (title ?? "").trim().toLowerCase();
596 return key.length === 0 || key === "tool" || key === "tool call" || key === "terminal";
597}
598
599export function resolveXAiAcpToolTitle(toolCall: AcpToolCallState): string | undefined {
600 if (!isGenericAcpToolTitle(toolCall.title)) {
601 return nonEmptyString(toolCall.title);
602 }
603 const rawInput = unknownRecord(toolCall.data.rawInput);
604 const description = nonEmptyString(rawInput?.description);
605 const variant = nonEmptyString(rawInput?.variant)?.toLowerCase();
606 if (description !== undefined) {
607 if (variant === "monitor" && !description.toLowerCase().startsWith("monitor")) {
608 return `Monitor: ${description}`;
609 }
610 return description;
611 }
612 if (variant === "monitor") return "Monitor";
613 if (variant === "task" || variant === "cursortask" || variant === "spawn_subagent") {
614 return "Task";
615 }
616 if (variant === "taskoutput" || variant === "task_output") return "Task output";
617 if (variant !== undefined) {
618 return variant;
619 }
620 const command =
621 nonEmptyString(rawInput?.command) ??
622 nonEmptyString(unknownRecord(toolCall.data.rawOutput)?.command);
623 if (command !== undefined) {
624 return command.length > 80 ? `${command.slice(0, 77)}...` : command;
625 }
626 return nonEmptyString(toolCall.title);
627}
628
629/**
630 * Normalize Grok ACP tool presentation and monitor lifecycle:
631 * - replace generic titles ("Tool") with description / variant labels
632 * - structured Monitor start ACK stays running
633 * - text start ACK stays running
634 * - structured Bash results with exit_code are terminal, unless Grok itself
635 * still reports the tool running. Grok 1.0.41 streams every monitor tick as
636 * `status: "in_progress"` with a Bash-shaped rawOutput whose exit_code is
637 * already 0, and never sends a completed status for the monitor; its end
638 * arrives as `_x.ai/task_completed`.
639 */
640export function normalizeXAiAcpToolCallState(toolCall: AcpToolCallState): AcpToolCallState {
641 const resolvedTitle = resolveXAiAcpToolTitle(toolCall);
642 const withTitle =
643 resolvedTitle !== undefined && resolvedTitle !== toolCall.title
644 ? { ...toolCall, title: resolvedTitle }
645 : toolCall;
646
647 const rawOutput = unknownRecord(withTitle.data.rawOutput);
648 const outputType = nonEmptyString(rawOutput?.type)?.toLowerCase();
649 const reportedRunning = withTitle.status === "inProgress" || withTitle.status === "pending";
650 if (outputType === "bash" && !reportedRunning) {
651 // Same key set as the adapter's command projection (commandExitCode).
652 const exitCode = ["exit_code", "exitCode", "code"]
653 .map((key) => rawOutput?.[key])
654 .find((value) => typeof value === "number" && Number.isInteger(value));
655 if (typeof exitCode === "number") {
656 if (exitCode === 0 && (xAiToolOutputText(withTitle)?.trim().length ?? 0) === 0) {
657 return { ...withTitle, status: "inProgress" };
658 }
659 return {
660 ...withTitle,
661 status: exitCode === 0 ? "completed" : "failed",
662 };
663 }
664 }
665 if (xAiBackgroundShellTaskId(withTitle) !== undefined) {
666 // Start acknowledgement only; the command still runs in the background.
667 return { ...withTitle, status: "inProgress" };
668 }
669 if (!isXAiMonitorTool(withTitle)) {
670 return withTitle;
671 }
672 if (outputType === "monitor") {
673 // Start registration only; process still running in the background.
674 return { ...withTitle, status: "inProgress" };
675 }
676 const output = xAiToolOutputText(withTitle);
677 if (withTitle.status === "completed" && isXAiMonitorStartAck(output)) {
678 return { ...withTitle, status: "inProgress" };
679 }
680 return withTitle;
681}
682
683/**
684 * Parse Grok synthetic monitor traffic that arrives as root text chunks
685 * (`<monitor-event>` lines, batched `<monitor>` event blocks, and
686 * "Monitor ... ended" reminders). Coalesced chunks may contain several events;
687 * return every match so later progress / end notices are not dropped.
688 */
689export function extractXAiAcpBackgroundToolMutation(
690 text: string,
691): ReadonlyArray<XAiAcpBackgroundToolMutation> {
692 const mutations: XAiAcpBackgroundToolMutation[] = [];
693
694 const eventRe = new RegExp(
695 `<monitor-event\\s+task_id=["']?(${XAI_UUID_RE})["']?\\s*>\\s*([\\s\\S]*?)\\s*</monitor-event>`,
696 "gi",
697 );
698 for (const eventMatch of text.matchAll(eventRe)) {
699 if (eventMatch[1] === undefined) continue;
700 const line = (eventMatch[2] ?? "").trim();
701 mutations.push({
702 taskId: eventMatch[1],
703 status: "running",
704 appendOutput: line.length > 0 ? `${line}\n` : "",
705 });
706 }
707
708 // Batched form: "N monitor events from 1 monitor ...:\n<monitor
709 // description="..." task_id="...">\n[1] line\n</monitor>". Still a running
710 // monitor; must track and filter like single-event chatter.
711 const batchedRe = new RegExp(
712 `<monitor\\s[^>]*task_id=["']?(${XAI_UUID_RE})["']?[^>]*>\\s*([\\s\\S]*?)\\s*</monitor>`,
713 "gi",
714 );
715 for (const batchedMatch of text.matchAll(batchedRe)) {
716 if (batchedMatch[1] === undefined) continue;
717 const lines = (batchedMatch[2] ?? "").trim();
718 mutations.push({
719 taskId: batchedMatch[1],
720 status: "running",
721 appendOutput: lines.length > 0 ? `${lines}\n` : "",
722 });
723 }
724
725 const endedRe = new RegExp(`Monitor\\s+["']?(${XAI_UUID_RE})["']?\\s+ended`, "gi");
726 for (const endedMatch of text.matchAll(endedRe)) {
727 if (endedMatch[1] === undefined) continue;
728 // Only the "Monitor … ended …" clause is outcome text. Description/output
729 // after the header can contain words like "error" without meaning failure.
730 const outcomeClause = (endedMatch[0] ?? "").trim();
731 const afterHeader = text.slice((endedMatch.index ?? 0) + endedMatch[0].length);
732 const outcomeTail = afterHeader.split(/\n/)[0] ?? "";
733 const outcome = `${outcomeClause}${outcomeTail}`;
734 const summary = text.replace(/<\/?system-reminder>/gi, "").trim();
735 mutations.push({
736 taskId: endedMatch[1],
737 status:
738 /exited\s*\(\s*code\s*0\s*\)/i.test(outcome) || /ended cleanly/i.test(outcome)
739 ? "completed"
740 : /exited|failed|error|signal/i.test(outcome)
741 ? "failed"
742 : "completed",
743 appendOutput: summary.length > 0 ? `${summary}\n` : "Monitor ended.\n",
744 });
745 }
746
747 return mutations;
748}
749
750/**
751 * Parse Grok's root-session subagent end reminder ("Background subagent
752 * "<uuid>" (general-purpose: "...") completed successfully."). The agent is
753 * free to skip get_command_or_subagent_output hydration, so this reminder can
754 * be the only terminal signal for the subagent row; a row stuck on running
755 * holds deferred finalize (and the whole run) open until harness timeout.
756 */
757function stripLeadingBalancedParenGroup(text: string): string {
758 const trimmed = text.replace(/^\s+/, "");
759 if (!trimmed.startsWith("(")) return trimmed;
760 let depth = 0;
761 for (let index = 0; index < trimmed.length; index += 1) {
762 const char = trimmed[index];
763 if (char === "(") depth += 1;
764 else if (char === ")") {
765 depth -= 1;
766 if (depth === 0) return trimmed.slice(index + 1).replace(/^\s+/, "");
767 }
768 }
769 return trimmed;
770}
771
772export function extractXAiAcpSubagentEndNotice(text: string): XAiAcpSubagentEndNotice | undefined {
773 const match = text.match(new RegExp(`Background subagent\\s+["']?(${XAI_UUID_RE})["']?`, "i"));
774 if (match?.[1] === undefined) return undefined;
775 // Skip the parenthesized descriptor (subagent type and title) so its words
776 // cannot masquerade as the outcome verb. Balance parens so nested titles
777 // like `(general-purpose: "Check (backend) failed")` do not leak.
778 const tail = stripLeadingBalancedParenGroup(text.slice((match.index ?? 0) + match[0].length));
779 if (/completed successfully/i.test(tail)) {
780 return { childSessionId: match[1], status: "completed" };
781 }
782 if (/\b(failed|errored|error|crashed|cancelled|canceled|interrupted|timed out)\b/i.test(tail)) {
783 return { childSessionId: match[1], status: "failed" };
784 }
785 if (/\b(completed|finished|ended)\b/i.test(tail)) {
786 return { childSessionId: match[1], status: "completed" };
787 }
788 return undefined;
789}
790
791function taskIdsFromGetOutputTool(
792 toolCall: AcpToolCallState,
793 rawInput: Record<string, unknown> | undefined,
794): ReadonlyArray<string> {
795 const ids: string[] = [];
796 const push = (value: unknown) => {
797 const text = nonEmptyString(value);
798 if (text !== undefined && new RegExp(`^${XAI_UUID_RE}$`, "i").test(text)) {
799 ids.push(text);
800 }
801 };
802 if (Array.isArray(rawInput?.task_ids)) {
803 for (const entry of rawInput.task_ids) push(entry);
804 }
805 push(rawInput?.task_id);
806 push(rawInput?.taskId);
807 const resultId = resultTaskIdFromGetOutputTool(toolCall, xAiToolOutputText(toolCall));
808 if (resultId !== undefined) ids.push(resultId);
809 return [...new Set(ids)];
810}
811
812/** Identity of the task the get_output envelope actually reports (not the request list). */
813function resultTaskIdFromGetOutputTool(
814 toolCall: AcpToolCallState,
815 output: string | undefined,
816): string | undefined {
817 const rawOutput = unknownRecord(toolCall.data.rawOutput);
818 const result = unknownRecord(rawOutput?.Result) ?? unknownRecord(rawOutput?.result);
819 const structured =
820 nonEmptyString(result?.task_id) ??
821 nonEmptyString(result?.taskId) ??
822 nonEmptyString(rawOutput?.task_id) ??
823 nonEmptyString(rawOutput?.taskId);
824 if (structured !== undefined && new RegExp(`^${XAI_UUID_RE}$`, "i").test(structured)) {
825 return structured;
826 }
827 if (output === undefined) return undefined;
828 const taskHeader = firstUuidMatch(
829 output,
830 new RegExp(`===\\s*Task\\s+(${XAI_UUID_RE})\\s*===`, "i"),
831 );
832 return taskHeader;
833}
834
835function resultFromGetOutputTool(
836 toolCall: AcpToolCallState,
837 output: string | undefined,
838): string | null {
839 const rawOutput = unknownRecord(toolCall.data.rawOutput);
840 const result = unknownRecord(rawOutput?.Result) ?? unknownRecord(rawOutput?.result);
841 const structured =
842 nonEmptyString(result?.output) ??
843 nonEmptyString(result?.text) ??
844 decodeByteText(result?.output);
845 if (structured !== undefined) {
846 // Drop trailing machine meta blocks when present.
847 const cleaned = structured
848 .replace(/<subagent_meta>[\s\S]*?<\/subagent_meta>/gi, "")
849 .replace(/<subagent_result>[\s\S]*?<\/subagent_result>/gi, "")
850 .trim();
851 if (cleaned.length > 0) return cleaned;
852 return null;
853 }
854 if (output === undefined) return null;
855 const marker = output.match(/=== Output ===\s*([\s\S]*)$/i);
856 const body = (marker?.[1] ?? output).trim();
857 return body.length > 0 ? body : null;
858}
859
860function statusFromGetOutputTool(
861 toolCall: AcpToolCallState,
862 output: string | undefined,
863): "running" | "completed" | "failed" {
864 if (toolCall.status === "failed") return "failed";
865 const rawOutput = unknownRecord(toolCall.data.rawOutput);
866 const result = unknownRecord(rawOutput?.Result) ?? unknownRecord(rawOutput?.result);
867 const structuredStatus = nonEmptyString(result?.status)?.toLowerCase();
868 if (structuredStatus === "completed" || structuredStatus === "success") return "completed";
869 if (structuredStatus === "failed" || structuredStatus === "error") return "failed";
870 if (structuredStatus === "running" || structuredStatus === "pending") return "running";
871 if (typeof result?.exit_code === "number") {
872 return result.exit_code === 0 ? "completed" : "failed";
873 }
874 if (output !== undefined) {
875 if (/Status:\s*failed/i.test(output) || /Exit Code:\s*-?(?!0\b)\d+/i.test(output)) {
876 return "failed";
877 }
878 if (/Status:\s*completed/i.test(output) || /Status:\s*success/i.test(output)) {
879 return "completed";
880 }
881 if (/Status:\s*running/i.test(output) || /Status:\s*pending/i.test(output)) {
882 return "running";
883 }
884 }
885 // A completed get_output tool call only means the poll RPC returned. Without
886 // a task-level terminal status or exit code, the requested task is still
887 // running and must remain registered for subsequent polling.
888 return "running";
889}
890
891/**
892 * Recognizes Grok Task / spawn_subagent envelopes and get_command hydration
893 * without teaching the generic ACP adapter about xAI tool names.
894 *
895 * Current Grok CLI returns spawn_subagent immediately with a background ACK
896 * (`subagent_id: ...`). That must stay `running` until child work finishes or
897 * get_command_or_subagent_output reports a terminal status.
898 */
899export function extractXAiAcpSubagentUpdate(
900 toolCall: AcpToolCallState,
901): XAiAcpSubagentUpdate | undefined {
902 const rawInput = unknownRecord(toolCall.data.rawInput);
903 const output = xAiToolOutputText(toolCall);
904
905 if (isXAiGetSubagentOutputTool(toolCall, rawInput)) {
906 const taskIds = taskIdsFromGetOutputTool(toolCall, rawInput);
907 // Prefer the completed task identity from the result/header over the first
908 // requested task_ids entry (one call can poll multiple task_ids).
909 const childSessionId =
910 resultTaskIdFromGetOutputTool(toolCall, output) ??
911 taskIds[0] ??
912 extractXAiChildSessionId(output);
913 if (childSessionId === null) return undefined;
914 // Prefer the durable subagent row as the completion surface (Claude/Codex
915 // style). Suppress the noisy TaskOutput tool card; monitor hydration is
916 // handled separately via extractBackgroundTaskCompletion.
917 return {
918 nativeTaskId: toolCall.toolCallId,
919 prompt: "",
920 title: null,
921 model: null,
922 status: statusFromGetOutputTool(toolCall, output),
923 childSessionId,
924 result: resultFromGetOutputTool(toolCall, output),
925 suppressNormalTool: true,
926 };
927 }
928
929 if (!isXAiSpawnOrTaskTool(toolCall, rawInput)) return undefined;
930
931 const childSessionId = extractXAiChildSessionId(output);
932 const asyncSpawnAck = isXAiAsyncSpawnAck(output);
933 const legacyResult =
934 output
935 ?.replace(new RegExp(`(?:^|\\n)\\s*Agent ID:\\s*${XAI_UUID_RE}[^\\n]*(?:\\n|$)`, "gi"), "\n")
936 .replace(
937 new RegExp(`(?:^|\\n)\\s*subagent_id:\\s*${XAI_UUID_RE}[^\\n]*(?:\\n|$)`, "gi"),
938 "\n",
939 )
940 .trim() || null;
941
942 let status: "running" | "completed" | "failed";
943 if (toolCall.status === "failed") {
944 status = "failed";
945 } else if (asyncSpawnAck) {
946 // Spawn RPC completed, but the child is still running.
947 status = "running";
948 } else if (toolCall.status === "completed") {
949 status = "completed";
950 } else {
951 status = "running";
952 }
953
954 return {
955 nativeTaskId: toolCall.toolCallId,
956 prompt: nonEmptyString(rawInput?.prompt) ?? "",
957 title: nonEmptyString(rawInput?.description) ?? null,
958 model: nonEmptyString(rawInput?.model) ?? null,
959 status,
960 childSessionId,
961 result: asyncSpawnAck ? null : legacyResult,
962 suppressNormalTool: true,
963 };
964}
965
966const XAiAskUserQuestionOption = Schema.Struct({
967 label: Schema.String,
968 description: Schema.optional(Schema.String),
969 preview: Schema.optional(Schema.String),
970 id: Schema.optional(Schema.String),
971});
972
973const XAiAskUserQuestion = Schema.Struct({
974 id: Schema.optional(Schema.String),
975 question: Schema.String,
976 options: Schema.Array(XAiAskUserQuestionOption),
977 multiSelect: Schema.optional(Schema.NullOr(Schema.Boolean)),
978});
979
980const XAiAskUserQuestionParams = Schema.Struct({
981 sessionId: Schema.String,
982 toolCallId: Schema.String,
983 questions: Schema.Array(XAiAskUserQuestion),
984 mode: Schema.Literals(["default", "plan"]),
985});
986
987const XAiWrappedAskUserQuestionParams = Schema.Struct({
988 method: Schema.Literals(["x.ai/ask_user_question", "_x.ai/ask_user_question"]),
989 params: XAiAskUserQuestionParams,
990});
991
992export const XAiAskUserQuestionRequest = Schema.Union([
993 XAiAskUserQuestionParams,
994 XAiWrappedAskUserQuestionParams,
995]);
996
997type XAiAskUserQuestionRequestParams = typeof XAiAskUserQuestionParams.Type;
998type XAiAskUserQuestionRequest = typeof XAiAskUserQuestionRequest.Type;
999
1000// ---------------------------------------------------------------------------
1001// x.ai/exit_plan_mode — plan approval gate (mirrors Grok Build TUI plan window)
1002// ---------------------------------------------------------------------------
1003
1004const XAiExitPlanModeParams = Schema.Struct({
1005 sessionId: Schema.String,
1006 toolCallId: Schema.String,
1007 planContent: Schema.optional(Schema.NullOr(Schema.String)),
1008});
1009
1010const XAiWrappedExitPlanModeParams = Schema.Struct({
1011 method: Schema.Literals(["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"]),
1012 params: XAiExitPlanModeParams,
1013});
1014
1015export const XAiExitPlanModeRequest = Schema.Union([
1016 XAiExitPlanModeParams,
1017 XAiWrappedExitPlanModeParams,
1018]);
1019
1020type XAiExitPlanModeRequestParams = typeof XAiExitPlanModeParams.Type;
1021type XAiExitPlanModeRequest = typeof XAiExitPlanModeRequest.Type;
1022
1023function unwrapExitPlanModeParams(params: XAiExitPlanModeRequest): XAiExitPlanModeRequestParams {
1024 return "params" in params ? params.params : params;
1025}
1026
1027/** Empty-state copy when Grok exits plan mode without a plan file. */
1028export const XAI_EMPTY_PLAN_MARKDOWN =
1029 "# No plan written yet\n\n(The agent exited plan mode without writing a plan.)";
1030
1031export function extractXAiExitPlanMarkdown(
1032 params: XAiExitPlanModeRequest,
1033 fallback?: string | null,
1034): string {
1035 const content = unwrapExitPlanModeParams(params).planContent;
1036 const fromRequest = typeof content === "string" ? trimmed(content) : undefined;
1037 if (fromRequest) {
1038 return fromRequest;
1039 }
1040 const fromFallback = fallback?.trim();
1041 if (fromFallback && fromFallback.length > 0) {
1042 return fromFallback;
1043 }
1044 return XAI_EMPTY_PLAN_MARKDOWN;
1045}
1046
1047export type XAiExitPlanModeOutcome = "approved" | "abandoned" | "request_changes";
1048
1049export interface XAiExitPlanModeResponse {
1050 readonly outcome: XAiExitPlanModeOutcome;
1051 readonly feedback?: string;
1052}
1053
1054/**
1055 * Client captured the plan for T3's proposed-plan card. Abandon the native
1056 * Grok plan-approval gate so the turn unblocks; the user implements via T3 UI.
1057 */
1058export function makeXAiExitPlanModeCapturedResponse(feedback?: string): XAiExitPlanModeResponse {
1059 return {
1060 outcome: "abandoned",
1061 feedback:
1062 feedback ??
1063 "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.",
1064 };
1065}
1066
1067function normalizeFsPath(value: string): string {
1068 return value.trim().replace(/\\/g, "/").replace(/\/+$/, "");
1069}
1070
1071function pathHasTraversalSegment(normalized: string): boolean {
1072 return normalized.split("/").includes("..");
1073}
1074
1075function addGrokSessionPrefix(
1076 prefixes: Set<string>,
1077 homeOrRoot: string,
1078 nestedGrokDir: boolean,
1079): void {
1080 const root = normalizeFsPath(homeOrRoot);
1081 if (!root) {
1082 return;
1083 }
1084 prefixes.add(nestedGrokDir ? `${root}/.grok/sessions/` : `${root}/sessions/`);
1085}
1086
1087/** Injected host bits so these helpers stay off `process.platform` / `process.env`. */
1088export interface GrokPlanPathHost {
1089 readonly platform: NodeJS.Platform;
1090 readonly environment: NodeJS.ProcessEnv;
1091}
1092
1093function grokPlanSessionPrefixes(environment: NodeJS.ProcessEnv): ReadonlySet<string> {
1094 const prefixes = new Set<string>();
1095 addGrokSessionPrefix(prefixes, NodeOS.homedir(), true);
1096 addGrokSessionPrefix(prefixes, "~", true);
1097 addGrokSessionPrefix(prefixes, environment.HOME ?? "", true);
1098 addGrokSessionPrefix(prefixes, environment.USERPROFILE ?? "", true);
1099 // ACP mock and isolated Grok spawns use a HOME that is not the server process home.
1100 addGrokSessionPrefix(prefixes, "/tmp/mock-home", true);
1101 const grokHome = environment.GROK_HOME ?? "";
1102 addGrokSessionPrefix(prefixes, grokHome, false);
1103 addGrokSessionPrefix(prefixes, grokHome, true);
1104 return prefixes;
1105}
1106
1107const CANONICAL_HOME_GROK_SESSION_PATH =
1108 /^(?:\/home\/[^/]+|\/Users\/[^/]+|[a-zA-Z]:\/Users\/[^/]+)\/\.grok\/sessions\/(?:[^/]+\/)+plan\.md$/;
1109const CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH = new RegExp(
1110 CANONICAL_HOME_GROK_SESSION_PATH.source,
1111 "i",
1112);
1113
1114/**
1115 * True when a path is Grok's session plan file under a Grok home
1116 * (`~/.grok/sessions/.../plan.md`, `$HOME/.grok/sessions/...`, or `$GROK_HOME/sessions/...`).
1117 * Deliberately does not match workspace files named `plan.md` (e.g. docs/plan.md
1118 * or a repo-local `.grok/sessions/.../plan.md`).
1119 */
1120export function isGrokPlanMarkdownPath(
1121 path: string | undefined | null,
1122 host: GrokPlanPathHost,
1123): boolean {
1124 if (typeof path !== "string") {
1125 return false;
1126 }
1127 const normalized = path.trim().replace(/\\/g, "/");
1128 const win32 = host.platform === "win32";
1129 const haystack = win32 ? normalized.toLowerCase() : normalized;
1130 if (
1131 normalized.length === 0 ||
1132 !haystack.endsWith("/plan.md") ||
1133 pathHasTraversalSegment(normalized)
1134 ) {
1135 return false;
1136 }
1137 for (const prefix of grokPlanSessionPrefixes(host.environment)) {
1138 const needle = win32 ? prefix.toLowerCase() : prefix;
1139 if (!haystack.startsWith(needle)) {
1140 continue;
1141 }
1142 const rest = haystack.slice(needle.length);
1143 // Session layout: <home>/.grok/sessions/<encoded-cwd>/<session-id>/plan.md
1144 if (rest !== "plan.md" && rest.endsWith("plan.md")) {
1145 return true;
1146 }
1147 }
1148 return (
1149 win32 ? CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH : CANONICAL_HOME_GROK_SESSION_PATH
1150 ).test(haystack);
1151}
1152
1153/**
1154 * Extract plan markdown from a Grok write/edit tool call targeting plan.md.
1155 * Used so T3 can show the plan while plan mode is still active (before exit).
1156 */
1157export function extractGrokPlanMarkdownFromToolCallData(
1158 data: Record<string, unknown> | undefined,
1159 host: GrokPlanPathHost,
1160): string | undefined {
1161 if (!data) {
1162 return undefined;
1163 }
1164
1165 let sawPlanWrite = false;
1166 const takePlanText = (
1167 value: string | undefined,
1168 filePath: string | undefined,
1169 ): string | undefined => {
1170 if (!isGrokPlanMarkdownPath(filePath, host) || value === undefined) {
1171 return undefined;
1172 }
1173 sawPlanWrite = true;
1174 const trimmed = value.trim();
1175 return trimmed.length > 0 ? trimmed : undefined;
1176 };
1177
1178 const rawInput = data.rawInput;
1179 if (isRecord(rawInput)) {
1180 const filePath =
1181 (typeof rawInput.file_path === "string" ? rawInput.file_path : undefined) ??
1182 (typeof rawInput.path === "string" ? rawInput.path : undefined);
1183 const content = typeof rawInput.content === "string" ? rawInput.content : undefined;
1184 const fromRaw = takePlanText(content, filePath);
1185 if (fromRaw !== undefined) {
1186 return fromRaw;
1187 }
1188 }
1189
1190 const content = data.content;
1191 if (Array.isArray(content)) {
1192 for (const block of content) {
1193 if (!isRecord(block) || block.type !== "diff") {
1194 continue;
1195 }
1196 const path = typeof block.path === "string" ? block.path : undefined;
1197 const newText = typeof block.newText === "string" ? block.newText : undefined;
1198 const fromDiff = takePlanText(newText, path);
1199 if (fromDiff !== undefined) {
1200 return fromDiff;
1201 }
1202 }
1203 }
1204
1205 return sawPlanWrite ? "" : undefined;
1206}
1207
1208function isRecord(value: unknown): value is Record<string, unknown> {
1209 return typeof value === "object" && value !== null && !Array.isArray(value);
1210}
1211
1212function trimmed(value: string | undefined): string | undefined {
1213 const text = value?.trim();
1214 return text && text.length > 0 ? text : undefined;
1215}
1216
1217function unwrapAskUserQuestionParams(
1218 params: XAiAskUserQuestionRequest,
1219): XAiAskUserQuestionRequestParams {
1220 return "params" in params ? params.params : params;
1221}
1222
1223export function extractXAiAskUserQuestions(
1224 params: XAiAskUserQuestionRequest,
1225): ReadonlyArray<UserInputQuestion> {
1226 return unwrapAskUserQuestionParams(params).questions.map((question) => ({
1227 id: question.id ?? question.question,
1228 header: "Question",
1229 question: question.question,
1230 multiSelect: question.multiSelect === true,
1231 options:
1232 question.options.length > 0
1233 ? question.options.map((option) => ({
1234 label: option.label,
1235 description: option.description ?? option.label,
1236 }))
1237 : [{ label: "OK", description: "Continue" }],
1238 }));
1239}
1240
1241export function extractXAiAskUserQuestionIdentity(params: XAiAskUserQuestionRequest): {
1242 readonly sessionId: string;
1243 readonly toolCallId: string;
1244} {
1245 const unwrapped = unwrapAskUserQuestionParams(params);
1246 return {
1247 sessionId: unwrapped.sessionId,
1248 toolCallId: unwrapped.toolCallId,
1249 };
1250}
1251
1252interface XAiAskUserQuestionAnnotation {
1253 readonly preview?: string;
1254 readonly notes?: string;
1255}
1256
1257interface XAiAskUserQuestionAcceptedResponse {
1258 readonly outcome: "accepted";
1259 readonly answers: Record<string, ReadonlyArray<string>>;
1260 readonly annotations?: Record<string, XAiAskUserQuestionAnnotation>;
1261}
1262
1263interface XAiAskUserQuestionCancelledResponse {
1264 readonly outcome: "cancelled";
1265}
1266
1267interface NormalizedXAiAnswer {
1268 readonly questionText: string;
1269 readonly selectedLabels: ReadonlyArray<string>;
1270 readonly annotation?: XAiAskUserQuestionAnnotation;
1271}
1272
1273function answerValues(answer: unknown): ReadonlyArray<string> {
1274 if (Array.isArray(answer)) {
1275 return answer.flatMap((entry) => {
1276 const text = typeof entry === "string" ? trimmed(entry) : undefined;
1277 return text ? [text] : [];
1278 });
1279 }
1280 const text = typeof answer === "string" ? trimmed(answer) : undefined;
1281 return text ? [text] : [];
1282}
1283
1284function normalizeAnswerForXAi(
1285 question: XAiAskUserQuestionRequestParams["questions"][number],
1286 answer: unknown,
1287): NormalizedXAiAnswer | undefined {
1288 const values = answerValues(answer);
1289 if (values.length === 0) {
1290 return undefined;
1291 }
1292
1293 const optionByLabel = new Map(question.options.map((option) => [option.label, option]));
1294 const resolvedValues = values.map((value) => ({
1295 value,
1296 option: optionByLabel.get(value),
1297 }));
1298 const selectedLabels = resolvedValues.flatMap(({ option }) => (option ? [option.label] : []));
1299 const notes = resolvedValues.flatMap(({ option, value }) => (option ? [] : [value]));
1300 const preview =
1301 question.multiSelect === true
1302 ? undefined
1303 : resolvedValues.map(({ option }) => trimmed(option?.preview)).find((value) => value);
1304
1305 const annotation =
1306 preview || notes.length > 0
1307 ? {
1308 ...(preview ? { preview } : {}),
1309 ...(notes.length > 0 ? { notes: notes.join("\n") } : {}),
1310 }
1311 : undefined;
1312
1313 return {
1314 questionText: question.question,
1315 selectedLabels: selectedLabels.length > 0 ? selectedLabels : ["Other"],
1316 ...(annotation ? { annotation } : {}),
1317 };
1318}
1319
1320function findQuestionAnswer(
1321 answers: ProviderUserInputAnswers,
1322 question: XAiAskUserQuestionRequestParams["questions"][number],
1323): unknown {
1324 const key = question.id ?? question.question;
1325 return answers[key] ?? answers[question.question];
1326}
1327
1328export function makeXAiAskUserQuestionResponse(
1329 params: XAiAskUserQuestionRequest,
1330 answers: ProviderUserInputAnswers,
1331): XAiAskUserQuestionAcceptedResponse {
1332 const questions = unwrapAskUserQuestionParams(params).questions;
1333 const normalized = questions.flatMap((question) => {
1334 const entry = normalizeAnswerForXAi(question, findQuestionAnswer(answers, question));
1335 return entry ? [entry] : [];
1336 });
1337 const annotations = Object.fromEntries(
1338 normalized.flatMap((entry) =>
1339 entry.annotation ? [[entry.questionText, entry.annotation] as const] : [],
1340 ),
1341 );
1342
1343 return {
1344 outcome: "accepted",
1345 answers: Object.fromEntries(
1346 normalized.map((entry) => [entry.questionText, entry.selectedLabels]),
1347 ),
1348 ...(Object.keys(annotations).length > 0 ? { annotations } : {}),
1349 };
1350}
1351
1352export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCancelledResponse {
1353 return { outcome: "cancelled" };
1354}
1355
1356function promptIdFromResponse(response: EffectAcpSchema.PromptResponse): string | undefined {
1357 const meta = response._meta;
1358 if (meta === null || typeof meta !== "object") {
1359 return undefined;
1360 }
1361 const promptId = meta.promptId ?? meta.requestId;
1362 return typeof promptId === "string" && promptId.length > 0 ? promptId : undefined;
1363}
1364
1365function normalizeXAiStopReason(value: string | undefined): EffectAcpSchema.StopReason {
1366 switch (value) {
1367 case "cancelled":
1368 case "end_turn":
1369 case "max_tokens":
1370 case "max_turn_requests":
1371 case "refusal":
1372 return value;
1373 default:
1374 return "end_turn";
1375 }
1376}
1377
1378function promptResponseFromXAi(
1379 notification: XAiPromptCompleteNotification,
1380): EffectAcpSchema.PromptResponse {
1381 const stopReason = normalizeXAiStopReason(notification.stopReason);
1382 // Values originate from a decoded JSON-RPC payload, so they are JSON even
1383 // though the x.ai schema types agentResult as unknown.
1384 const meta: Record<string, Schema.Json> = {
1385 sessionId: notification.sessionId,
1386 };
1387 if (notification.stopReason === undefined) {
1388 meta[xAiStopReasonMissingMetaKey] = true;
1389 }
1390 if (notification.promptId !== undefined) {
1391 meta.promptId = notification.promptId;
1392 meta.requestId = notification.promptId;
1393 }
1394 if (notification.agentResult !== undefined) {
1395 meta.agentResult = notification.agentResult as Schema.Json;
1396 }
1397 return {
1398 stopReason,
1399 _meta: meta,
1400 };
1401}
1402
1403/**
1404 * Grok settles a failed prompt with `stopReason: "error"` and the provider's
1405 * message in `agentResult` before its `session/prompt` RPC error arrives. The
1406 * completion wins the race, so it must carry the failure itself.
1407 */
1408function xAiPromptFailure(
1409 notification: XAiPromptCompleteNotification,
1410): EffectAcpErrors.AcpRequestError | null {
1411 if (notification.stopReason === "rate_limit") {
1412 return new EffectAcpErrors.AcpRequestError({
1413 code: xAiRateLimitedErrorCode,
1414 errorMessage: "Grok usage limit reached. Try again later.",
1415 });
1416 }
1417 if (notification.stopReason === "error") {
1418 // Grok's raw result is unbounded provider text: keep it only as the cause.
1419 const agentResult = nonEmptyString(notification.agentResult);
1420 return new EffectAcpErrors.AcpRequestError({
1421 code: -32603,
1422 errorMessage: "Grok ended the turn with an error.",
1423 operation: "receive-response",
1424 ...(agentResult === undefined ? {} : { cause: new XAiPromptFailureText(agentResult) }),
1425 });
1426 }
1427 return null;
1428}
1429
1430/**
1431 * Grok's own text for a failed prompt. A plain Error rather than a schema
1432 * error so the unbounded provider text never becomes a structured attribute;
1433 * it is read back only at the presentation boundary.
1434 */
1435export class XAiPromptFailureText extends Error {}
1436
1437const registerXAiPromptCompletionFallback = (
1438 pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>,
1439 sessionId: string,
1440 promptId: string,
1441) =>
1442 Deferred.make<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpRequestError>().pipe(
1443 Effect.tap((deferred) =>
1444 Ref.update(pendingRef, (pending) => [...pending, { sessionId, promptId, deferred }]),
1445 ),
1446 Effect.map((deferred) => ({ deferred, promptId })),
1447 );
1448
1449const unregisterXAiPromptCompletionFallback = (
1450 pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>,
1451 deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpRequestError>,
1452) => Ref.update(pendingRef, (pending) => pending.filter((entry) => entry.deferred !== deferred));
1453
1454const abortPendingPromptCompletions = (
1455 pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>,
1456 sessionId?: string,
1457) =>
1458 Ref.modify(pendingRef, (pending) => {
1459 const [toAbort, remaining] = pending.reduce<
1460 [ReadonlyArray<PendingXAiPromptCompletion>, ReadonlyArray<PendingXAiPromptCompletion>]
1461 >(
1462 ([aborting, kept], entry) =>
1463 sessionId === undefined || entry.sessionId === sessionId
1464 ? [[...aborting, entry], kept]
1465 : [aborting, [...kept, entry]],
1466 [[], []],
1467 );
1468 if (toAbort.length === 0) {
1469 return [Effect.void, pending] as const;
1470 }
1471 return [
1472 Effect.forEach(
1473 toAbort,
1474 (entry) =>
1475 Deferred.succeed(
1476 entry.deferred,
1477 promptResponseFromXAi({
1478 sessionId: entry.sessionId,
1479 promptId: entry.promptId,
1480 stopReason: "cancelled",
1481 agentResult: null,
1482 }),
1483 ),
1484 { concurrency: "unbounded" },
1485 ).pipe(Effect.asVoid),
1486 remaining,
1487 ] as const;
1488 }).pipe(Effect.flatten);
1489
1490const resolveXAiPromptCompletionFallback = ({
1491 pendingRef,
1492 completedPromptIdsRef,
1493 notification,
1494}: {
1495 readonly pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>;
1496 readonly completedPromptIdsRef: Ref.Ref<ReadonlyArray<string>>;
1497 readonly notification: XAiPromptCompleteNotification;
1498}) =>
1499 Ref.get(completedPromptIdsRef).pipe(
1500 Effect.flatMap((completedPromptIds) => {
1501 if (
1502 notification.promptId !== undefined &&
1503 completedPromptIds.includes(notification.promptId)
1504 ) {
1505 return Effect.void;
1506 }
1507 return Ref.modify(pendingRef, (pending) => {
1508 const index =
1509 notification.promptId !== undefined
1510 ? pending.findIndex(
1511 (entry) =>
1512 entry.sessionId === notification.sessionId &&
1513 entry.promptId === notification.promptId,
1514 )
1515 : (() => {
1516 const sessionPendingIndexes = pending.flatMap((entry, entryIndex) =>
1517 entry.sessionId === notification.sessionId ? [entryIndex] : [],
1518 );
1519 if (sessionPendingIndexes.length !== 1) {
1520 return -1;
1521 }
1522 return sessionPendingIndexes[0] ?? -1;
1523 })();
1524 if (index < 0) {
1525 return [Effect.void, pending] as const;
1526 }
1527 const entry = pending[index];
1528 if (!entry) {
1529 return [Effect.void, pending] as const;
1530 }
1531 const failure = xAiPromptFailure(notification);
1532 const settle =
1533 failure !== null
1534 ? Deferred.fail(entry.deferred, failure).pipe(Effect.asVoid)
1535 : Deferred.succeed(entry.deferred, promptResponseFromXAi(notification)).pipe(
1536 Effect.asVoid,
1537 );
1538 return [settle, [...pending.slice(0, index), ...pending.slice(index + 1)]] as const;
1539 }).pipe(Effect.flatten);
1540 }),
1541 );
1542
1543const rememberCompletedXAiPromptId = (
1544 completedPromptIdsRef: Ref.Ref<ReadonlyArray<string>>,
1545 response: EffectAcpSchema.PromptResponse,
1546 fallbackPromptId: string,
1547) => {
1548 const promptId = promptIdFromResponse(response) ?? fallbackPromptId;
1549 if (promptId.length === 0) {
1550 return Effect.void;
1551 }
1552 return Ref.update(completedPromptIdsRef, (completedPromptIds) => {
1553 if (completedPromptIds.includes(promptId)) {
1554 return completedPromptIds;
1555 }
1556 return [...completedPromptIds, promptId].slice(-completedXAiPromptIdLimit);
1557 });
1558};
1559
1560/**
1561 * Grok-specific ACP runtime wrapper. Races `session/prompt` against root-matched
1562 * terminal notifications:
1563 * - session notification/update + `turn_completed` + matching `prompt_id`
1564 * - `x.ai/session/prompt_complete` (open-source fire-and-forget signal)
1565 * - `_x.ai/session/prompt_complete` (released-build alias)
1566 *
1567 * Pending entries are keyed by root sessionId + T3-injected promptId, so
1568 * foreign/child sessions and `task-completed-*` ids do not settle the root turn.
1569 */
1570export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletionRuntime")(
1571 function* (runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]) {
1572 // Grok sends turn completion and other session updates (subagent_finished)
1573 // on the same extension methods, and the client keeps one handler per
1574 // method. This wrapper owns those methods: it settles prompts itself, then
1575 // passes each notification to the handler registered on the wrapped runtime.
1576 const sessionNotificationHandlers = new Map<
1577 string,
1578 (params: unknown) => Effect.Effect<void, EffectAcpErrors.AcpError>
1579 >();
1580 let nextPromptFallbackId = 0;
1581 const allocatePromptFallbackId = Effect.sync(() => {
1582 nextPromptFallbackId += 1;
1583 return `t3-xai-prompt-${nextPromptFallbackId}`;
1584 });
1585 const pendingXAiPromptCompletionsRef = yield* Ref.make<
1586 ReadonlyArray<PendingXAiPromptCompletion>
1587 >([]);
1588 const completedXAiPromptIdsRef = yield* Ref.make<ReadonlyArray<string>>([]);
1589
1590 const settleFromPromptComplete = (notification: XAiPromptCompleteNotification) =>
1591 resolveXAiPromptCompletionFallback({
1592 pendingRef: pendingXAiPromptCompletionsRef,
1593 completedPromptIdsRef: completedXAiPromptIdsRef,
1594 notification,
1595 }).pipe(Effect.catch(() => Effect.void));
1596
1597 yield* Effect.forEach(
1598 ["x.ai/session/prompt_complete", "_x.ai/session/prompt_complete"] as const,
1599 (method) =>
1600 runtime.handleExtNotification(
1601 method,
1602 XAiPromptCompleteNotification,
1603 settleFromPromptComplete,
1604 ),
1605 { discard: true },
1606 );
1607
1608 yield* Effect.forEach(
1609 xAiSessionNotificationMethods,
1610 (method) =>
1611 runtime.handleExtNotification(method, Schema.Unknown, (params) =>
1612 decodeXAiSessionUpdateNotification(params).pipe(
1613 Effect.flatMap((notification) => {
1614 const complete = xAiPromptCompleteFromSessionUpdate(notification);
1615 return complete === null ? Effect.void : settleFromPromptComplete(complete);
1616 }),
1617 Effect.ignore,
1618 Effect.andThen(
1619 Effect.suspend(
1620 () => sessionNotificationHandlers.get(method)?.(params) ?? Effect.void,
1621 ),
1622 ),
1623 ),
1624 ),
1625 { discard: true },
1626 );
1627
1628 return {
1629 ...runtime,
1630 handleExtNotification: (method, payload, handler) =>
1631 xAiSessionNotificationMethods.some((owned) => owned === method)
1632 ? Effect.sync(() => {
1633 sessionNotificationHandlers.set(method, (params) =>
1634 Schema.decodeUnknownEffect(payload)(params).pipe(
1635 Effect.mapError((error) =>
1636 EffectAcpErrors.AcpProtocolParseError.fromSchemaError(
1637 "decode-notification-payload",
1638 method,
1639 error,
1640 ),
1641 ),
1642 Effect.flatMap(handler),
1643 ),
1644 );
1645 })
1646 : runtime.handleExtNotification(method, payload, handler),
1647 prompt: (payload, promptOptions?) =>
1648 Effect.gen(function* () {
1649 const started = yield* runtime.start();
1650 const promptId = yield* allocatePromptFallbackId;
1651 const fallback = yield* registerXAiPromptCompletionFallback(
1652 pendingXAiPromptCompletionsRef,
1653 started.sessionId,
1654 promptId,
1655 );
1656 const cancelledResponse = promptResponseFromXAi({
1657 sessionId: started.sessionId,
1658 promptId: fallback.promptId,
1659 stopReason: "cancelled",
1660 agentResult: null,
1661 });
1662 const promptRpcFiber = yield* runtime
1663 .prompt(
1664 {
1665 ...payload,
1666 _meta: {
1667 ...payload._meta,
1668 promptId: fallback.promptId,
1669 requestId: fallback.promptId,
1670 },
1671 },
1672 promptOptions,
1673 )
1674 .pipe(Effect.forkChild);
1675 return yield* Effect.raceFirst(
1676 Fiber.join(promptRpcFiber).pipe(
1677 Effect.catchCause((cause) =>
1678 Cause.hasInterruptsOnly(cause)
1679 ? Effect.succeed(cancelledResponse)
1680 : Effect.failCause(cause),
1681 ),
1682 ),
1683 Deferred.await(fallback.deferred),
1684 ).pipe(
1685 Effect.tap((response) =>
1686 rememberCompletedXAiPromptId(completedXAiPromptIdsRef, response, fallback.promptId),
1687 ),
1688 Effect.ensuring(
1689 Effect.gen(function* () {
1690 yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore);
1691 yield* unregisterXAiPromptCompletionFallback(
1692 pendingXAiPromptCompletionsRef,
1693 fallback.deferred,
1694 );
1695 }),
1696 ),
1697 );
1698 }),
1699 cancel: Effect.gen(function* () {
1700 // Do not call start here: cancellation must be able to reach the
1701 // underlying runtime when startup is stalled. Pending entries already
1702 // carry their session ids, and this runtime owns all of them.
1703 yield* abortPendingPromptCompletions(pendingXAiPromptCompletionsRef);
1704 yield* runtime.cancel;
1705 }),
1706 } satisfies AcpSessionRuntime.AcpSessionRuntime["Service"];
1707 },
1708);
1709
1710function promptResponseHasMissingXAiStopReason(response: EffectAcpSchema.PromptResponse): boolean {
1711 const meta = response._meta;
1712 return meta !== null && typeof meta === "object" && meta[xAiStopReasonMissingMetaKey] === true;
1713}
apps/server/src/provider/acp/XAiBackgroundTasks.test.ts deleted-202
...@@ -1,202 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2import { TurnId } from "@t3tools/contracts";
3
4import {
5 buildGrokBackgroundTaskEvents,
6 type GrokBackgroundTaskRecord,
7} from "./XAiBackgroundTasks.ts";
8
9const turnId = TurnId.make("turn-1");
10const monitor = { type: "Monitor", taskId: "monitor-1", timeoutMs: 60_000 };
11const shell = { type: "BackgroundTaskStarted", task_id: "shell-1", command: "sleep 40" };
12
13function mapper() {
14 const tasks = new Map<string, GrokBackgroundTaskRecord>();
15 const update = (
16 rawOutput: unknown,
17 overrides: Partial<Parameters<typeof buildGrokBackgroundTaskEvents>[0]> = {},
18 ) =>
19 buildGrokBackgroundTaskEvents({
20 tasks,
21 toolCallId: "call-1",
22 rawInput: { description: "Watch" },
23 rawOutput,
24 toolCallStatus: "completed",
25 turnId,
26 ...overrides,
27 });
28 return { tasks, update };
29}
30
31describe("Grok background tasks", () => {
32 it.each([
33 [monitor, "monitor-1", "monitor", "Watch"],
34 [shell, "shell-1", "shell", "sleep 40"],
35 ] as const)("starts and deduplicates %j", (output, id, taskType, description) => {
36 const { tasks, update } = mapper();
37 expect(update(output)).toEqual([
38 {
39 type: "task.started",
40 turnId,
41 payload: { taskId: id, taskType, description, title: description, toolUseId: "call-1" },
42 },
43 ]);
44 expect(update(output)).toEqual([]);
45 expect(tasks.size).toBe(1);
46 });
47
48 it("accepts automatic backgrounding before the tool becomes terminal", () => {
49 const { update } = mapper();
50 expect(update(shell, { toolCallStatus: "inProgress" })[0]?.type).toBe("task.started");
51 expect(update(monitor, { toolCallStatus: "inProgress" })).toEqual([]);
52 expect(update(monitor, { toolCallStatus: "failed" })).toEqual([]);
53 });
54
55 it.each([
56 ["running", null, "task.progress", undefined],
57 ["pending", null, "task.progress", undefined],
58 ["completed", 0, "task.completed", "completed"],
59 ["success", 0, "task.completed", "completed"],
60 ["succeeded", 0, "task.completed", "completed"],
61 ["failed", 1, "task.completed", "failed"],
62 ["error", 1, "task.completed", "failed"],
63 ["stopped", 137, "task.completed", "stopped"],
64 ["killed", 137, "task.completed", "stopped"],
65 ["cancelled", 137, "task.completed", "stopped"],
66 [undefined, 0, "task.completed", "completed"],
67 [undefined, 1, "task.completed", "failed"],
68 ])("maps poll status %s / exit %s", (status, exit_code, type, expectedStatus) => {
69 const { tasks, update } = mapper();
70 update(monitor);
71 const events = update({
72 type: "TaskOutput",
73 Result: {
74 task_id: "monitor-1",
75 command: "[monitor:Watch]",
76 status,
77 exit_code,
78 output: "\n result\nmore",
79 },
80 });
81 expect(events).toEqual([
82 {
83 type,
84 turnId,
85 payload: {
86 taskId: "monitor-1",
87 taskType: "monitor",
88 description: "Watch",
89 title: "Watch",
90 toolUseId: "call-1",
91 summary: "result",
92 ...(expectedStatus ? { status: expectedStatus } : {}),
93 },
94 },
95 ]);
96 expect(tasks.size).toBe(type === "task.progress" ? 1 : 0);
97 });
98
99 it.each([undefined, TurnId.make("turn-2")])(
100 "does not attribute old tasks to a later turn: %s",
101 (laterTurnId) => {
102 const { tasks, update } = mapper();
103 update(shell);
104 const events = update(
105 {
106 type: "TaskOutput",
107 Result: { task_id: "shell-1", command: "sleep 40", status: "completed" },
108 },
109 { turnId: laterTurnId },
110 );
111 expect(events).toHaveLength(1);
112 expect(events[0]?.turnId).toBeUndefined();
113 expect(tasks.size).toBe(0);
114 },
115 );
116
117 it("starts unknown poll tasks before progress or completion and ignores subagents", () => {
118 const { tasks, update } = mapper();
119 const events = update({
120 type: "TaskOutput",
121 MultiResult: {
122 results: [
123 { task_id: "shell-1", command: "sleep 40", status: "running" },
124 { task_id: "monitor-1", command: "[monitor] Watch", status: "completed" },
125 { task_id: "agent-1", command: "[subagent:executor] work", status: "running" },
126 ],
127 },
128 });
129 expect(events.map(({ type }) => type)).toEqual([
130 "task.started",
131 "task.progress",
132 "task.started",
133 "task.completed",
134 ]);
135 expect(events.map(({ payload }) => payload.taskType)).toEqual([
136 "shell",
137 "shell",
138 "monitor",
139 "monitor",
140 ]);
141 expect([...tasks.keys()]).toEqual(["shell-1"]);
142 expect(events.map((event) => event.turnId)).toEqual([
143 undefined,
144 undefined,
145 undefined,
146 undefined,
147 ]);
148 });
149
150 it("retires only successfully killed tasks, including mixed results", () => {
151 const { tasks, update } = mapper();
152 update(shell);
153 update(monitor);
154 const result = { type: "KillTask", Result: { task_id: "shell-1", outcome: "killed" } };
155 expect(update(result, { toolCallStatus: "failed" })).toEqual([]);
156 expect(tasks.size).toBe(2);
157 const events = update({
158 type: "KillTask",
159 MultiResult: {
160 results: [
161 result.Result,
162 { task_id: "monitor-1", outcome: "error" },
163 { task_id: "unknown", outcome: "killed" },
164 ],
165 },
166 });
167 expect(events).toEqual([
168 {
169 type: "task.completed",
170 turnId,
171 payload: {
172 taskId: "shell-1",
173 taskType: "shell",
174 description: "sleep 40",
175 title: "sleep 40",
176 toolUseId: "call-1",
177 status: "stopped",
178 },
179 },
180 ]);
181 expect([...tasks.keys()]).toEqual(["monitor-1"]);
182 });
183
184 it.each([
185 null,
186 [],
187 {},
188 { type: "Text", text: "subagent_id: fake\ntype: executor\ndescription: fake" },
189 { type: "Monitor", taskId: " " },
190 { type: "BackgroundTaskStarted", task_id: "shell-1" },
191 {
192 type: "TaskOutput",
193 MultiResult: {
194 results: [null, {}, { task_id: "task", command: "sleep 40", exit_code: Infinity }],
195 },
196 },
197 ])("ignores malformed or unrelated outputs: %j", (output) => {
198 const { tasks, update } = mapper();
199 expect(update(output)).toEqual([]);
200 expect(tasks.size).toBe(0);
201 });
202});
apps/server/src/provider/acp/XAiBackgroundTasks.ts deleted-156
...@@ -1,156 +0,0 @@
1import {
2 RuntimeTaskId,
3 type ProviderRuntimeTaskStartedEvent,
4 type ProviderRuntimeTaskProgressEvent,
5 type ProviderRuntimeTaskCompletedEvent,
6 type TurnId,
7} from "@t3tools/contracts";
8
9type TaskEvent =
10 | Pick<ProviderRuntimeTaskStartedEvent, "type" | "payload" | "turnId">
11 | Pick<ProviderRuntimeTaskProgressEvent, "type" | "payload" | "turnId">
12 | Pick<ProviderRuntimeTaskCompletedEvent, "type" | "payload" | "turnId">;
13
14export interface GrokBackgroundTaskRecord {
15 readonly payload: {
16 readonly taskId: RuntimeTaskId;
17 readonly taskType: "monitor" | "shell";
18 readonly description: string;
19 readonly title: string;
20 readonly toolUseId?: string;
21 };
22 readonly turnId: TurnId | undefined;
23}
24
25function record(value: unknown): Record<string, unknown> {
26 return typeof value === "object" && value !== null && !Array.isArray(value)
27 ? (value as Record<string, unknown>)
28 : {};
29}
30
31function text(value: unknown): string | undefined {
32 return typeof value === "string" ? value.trim() || undefined : undefined;
33}
34
35function lifecycle(status: unknown, exitCode: unknown) {
36 switch (text(status)?.toLowerCase()) {
37 case "pending":
38 case "running":
39 return "running";
40 case "completed":
41 case "success":
42 case "succeeded":
43 return "completed";
44 case "failed":
45 case "error":
46 return "failed";
47 case "stopped":
48 case "killed":
49 case "cancelled":
50 return "stopped";
51 default:
52 return typeof exitCode === "number" && Number.isFinite(exitCode)
53 ? exitCode === 0
54 ? "completed"
55 : "failed"
56 : undefined;
57 }
58}
59
60/** Map Grok's discriminated tool results, including notifications after the turn ends. */
61export function buildGrokBackgroundTaskEvents(input: {
62 readonly tasks: Map<string, GrokBackgroundTaskRecord>;
63 readonly toolCallId: string;
64 readonly rawInput: unknown;
65 readonly rawOutput: unknown;
66 readonly toolCallStatus: string | undefined;
67 readonly turnId?: TurnId | undefined;
68}): TaskEvent[] {
69 const { tasks, toolCallId, toolCallStatus, turnId } = input;
70 const output = record(input.rawOutput);
71 const events: TaskEvent[] = [];
72 if (
73 toolCallStatus !== "completed" &&
74 toolCallStatus !== "failed" &&
75 output.type !== "BackgroundTaskStarted"
76 ) {
77 return events;
78 }
79 const attribution = (task: GrokBackgroundTaskRecord) =>
80 task.turnId !== undefined && task.turnId === turnId ? { turnId } : {};
81 const start = (
82 id: string,
83 taskType: "monitor" | "shell",
84 description: string,
85 toolUseId?: string,
86 ) => {
87 const known = tasks.get(id);
88 if (known) return known;
89 const task: GrokBackgroundTaskRecord = {
90 payload: {
91 taskId: RuntimeTaskId.make(id),
92 taskType,
93 description,
94 title: description,
95 ...(toolUseId ? { toolUseId } : {}),
96 },
97 // Polls can rediscover older tasks without establishing their originating turn.
98 turnId: toolUseId ? turnId : undefined,
99 };
100 tasks.set(id, task);
101 events.push({ type: "task.started", payload: task.payload, ...attribution(task) });
102 return task;
103 };
104 const complete = (
105 task: GrokBackgroundTaskRecord,
106 status: "completed" | "failed" | "stopped",
107 summary?: string,
108 ) => {
109 tasks.delete(task.payload.taskId);
110 events.push({
111 type: "task.completed",
112 payload: { ...task.payload, status, ...(summary ? { summary } : {}) },
113 ...attribution(task),
114 });
115 };
116
117 if (output.type === "Monitor" && toolCallStatus === "completed") {
118 const id = text(output.taskId);
119 if (id) start(id, "monitor", text(record(input.rawInput).description) ?? "Monitor", toolCallId);
120 } else if (output.type === "BackgroundTaskStarted") {
121 const id = text(output.task_id) ?? text(output.taskId);
122 const command = text(output.command);
123 if (id && command) start(id, "shell", command.split("\n")[0]!.slice(0, 200), toolCallId);
124 } else if (output.type === "TaskOutput" || output.type === "KillTask") {
125 const results = record(output.MultiResult).results;
126 for (const value of Array.isArray(results) ? results : [output.Result]) {
127 const result = record(value);
128 const id = text(result.task_id);
129 if (!id) continue;
130 if (output.type === "KillTask") {
131 const task = tasks.get(id);
132 if (task && toolCallStatus === "completed" && result.outcome === "killed")
133 complete(task, "stopped");
134 continue;
135 }
136 const command = text(result.command);
137 const status = lifecycle(result.status, result.exit_code);
138 if (!command || !status || command.startsWith("[subagent:")) continue;
139 const task = start(id, /^\[monitor[:\]]/.test(command) ? "monitor" : "shell", command);
140 const summary = text(result.output)
141 ?.split("\n")
142 .find((line) => line.trim())
143 ?.trim();
144 if (status === "running") {
145 events.push({
146 type: "task.progress",
147 payload: { ...task.payload, ...(summary ? { summary } : {}) },
148 ...attribution(task),
149 });
150 } else {
151 complete(task, status, summary);
152 }
153 }
154 }
155 return events;
156}
apps/server/src/provider/builtInDrivers.ts+1-1
...@@ -25,7 +25,7 @@ import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/Antigrav...@@ -25,7 +25,7 @@ import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/Antigrav
25import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts";25import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts";
26import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";26import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";
27import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server";27import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server";
28import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts";28import { GrokDriver, type GrokDriverEnv } from "@t3tools/provider-grok/server";
29import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server";29import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server";
30import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server";30import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server";
31import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server";31import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server";
apps/server/src/provider/grokUsageLimits.ts deleted-175
...@@ -1,175 +0,0 @@
1import * as NodeOS from "node:os";
2import type { ServerProviderUsageWindow } from "@t3tools/contracts";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";
9import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
10import {
11 clampPercent,
12 makeUnavailableUsageLimits,
13 makeUsageLimits,
14} from "@t3tools/provider-core/server/usageLimits";
15
16const GrokCredentials = Schema.Record(
17 Schema.String,
18 Schema.Struct({
19 key: Schema.optional(Schema.String),
20 auth_mode: Schema.optional(Schema.String),
21 email: Schema.optional(Schema.String),
22 }),
23);
24const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(GrokCredentials));
25const GrokUsageResponse = Schema.Struct({
26 config: Schema.optional(
27 Schema.Struct({
28 creditUsagePercent: Schema.optional(Schema.Number),
29 currentPeriod: Schema.optional(
30 Schema.Struct({
31 type: Schema.optional(Schema.String),
32 end: Schema.optional(Schema.String),
33 }),
34 ),
35 }),
36 ),
37});
38
39export function grokUsageResponseToLimits(
40 response: typeof GrokUsageResponse.Type,
41 checkedAt: string,
42) {
43 const usedPercent = response.config?.creditUsagePercent;
44 if (usedPercent === undefined || !Number.isFinite(usedPercent)) {
45 // A billing read that succeeded but carries no percentage is an account
46 // with nothing metered yet, not one that can never report: xAI omits the
47 // field entirely (rather than sending 0) until usage registers, then fills
48 // it in. Calling that `unsupported` would strand the account — the Limits
49 // view drops unsupported entries and deliberately mutes their notice, so a
50 // freshly signed-in Grok account would vanish with no explanation until it
51 // happened to be used, and `applyUsageLimitsUpdate` would refuse the
52 // mid-turn windows that could have recovered it.
53 return makeUsageLimits({ checkedAt, windows: [] });
54 }
55 const period = response.config?.currentPeriod;
56 const periodType = period?.type?.replace(/^USAGE_PERIOD_TYPE_/, "");
57 const kind = periodType === "WEEKLY" ? "weekly" : periodType === "MONTHLY" ? "monthly" : "other";
58 const reset = period?.end ? DateTime.make(period.end) : Option.none();
59 const window: ServerProviderUsageWindow = {
60 id: "subscription",
61 kind,
62 label: kind === "weekly" ? "Weekly" : kind === "monthly" ? "Monthly" : "Subscription",
63 usedPercent: clampPercent(usedPercent),
64 ...(Option.isSome(reset) ? { resetsAt: DateTime.formatIso(reset.value) } : {}),
65 };
66 return makeUsageLimits({ checkedAt, windows: [window] });
67}
68
69/**
70 * The grok.com login the CLI uses by default, or undefined when the CLI is
71 * configured to pick another account, endpoint, or an API key.
72 */
73const readGrokCredential = Effect.fn("readGrokCredential")(function* (
74 environment: NodeJS.ProcessEnv,
75) {
76 // T3's ACP adapter explicitly selects API-key auth when this variable is set.
77 if (environment.XAI_API_KEY?.trim()) return undefined;
78 // Alternate auth deployments can select another scope or account from the same file.
79 if (
80 [
81 "GROK_OIDC_ISSUER",
82 "GROK_OIDC_CLIENT_ID",
83 "GROK_OAUTH2_ISSUER",
84 "GROK_OAUTH2_CLIENT_ID",
85 "GROK_OAUTH2_PRINCIPAL_TYPE",
86 "GROK_OAUTH2_PRINCIPAL_ID",
87 "GROK_AUTH_PROVIDER_COMMAND",
88 "GROK_LOCAL_AUTH",
89 "GROK_CLI_CHAT_PROXY_BASE_URL",
90 "GROK_MODELS_BASE_URL",
91 "GROK_CONFIG",
92 "GROK_CONFIG_PATH",
93 ].some((name) => environment[name]?.trim())
94 ) {
95 return undefined;
96 }
97 const fs = yield* FileSystem.FileSystem;
98 const path = yield* Path.Path;
99 const home =
100 environment.GROK_HOME?.trim() ||
101 path.join(environment.HOME || environment.USERPROFILE || NodeOS.homedir(), ".grok");
102 for (const configPath of [
103 path.join(home, "config.toml"),
104 path.join(home, "managed_config.toml"),
105 path.join(home, "requirements.toml"),
106 "/etc/grok/managed_config.toml",
107 "/etc/grok/requirements.toml",
108 ]) {
109 const config = yield* fs.readFileString(configPath).pipe(
110 Effect.catchTags({
111 PlatformError: (error) =>
112 error.reason._tag === "NotFound" ? Effect.succeed("") : Effect.fail(error),
113 }),
114 );
115 // These sections can change the selected account or endpoint. Leave custom deployments to the CLI.
116 if (/^\s*(?:\[\[?\s*)?["']?(?:auth|grok_com_config|endpoints)["']?\s*[.\]=]/m.test(config)) {
117 return undefined;
118 }
119 }
120 const contents =
121 environment.GROK_AUTH?.trim() ||
122 (yield* fs.readFileString(path.join(home, "auth.json")).pipe(
123 Effect.catchTags({
124 PlatformError: (error) =>
125 error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error),
126 }),
127 ));
128 const credentials = yield* decodeCredentials(contents);
129 // Never pick an arbitrary account from other deployments stored in the same file.
130 const credential =
131 credentials["https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"] ??
132 credentials["https://accounts.x.ai/sign-in"];
133 return credential?.auth_mode === "api_key" ? undefined : credential;
134});
135
136/**
137 * Reads the default grok.com login once and reports its usage limits along with
138 * its email, so the email always names the account whose quota was read.
139 */
140export const readGrokAccount = Effect.fn("readGrokAccount")(function* (
141 environment: NodeJS.ProcessEnv = process.env,
142) {
143 const checkedAt = DateTime.formatIso(yield* DateTime.now);
144 const probeFailed = makeUnavailableUsageLimits({
145 checkedAt,
146 reason: "probeFailed",
147 message: "Grok could not read usage limits.",
148 });
149 const credential = yield* Effect.option(
150 readGrokCredential(environment).pipe(Effect.timeout("10 seconds")),
151 );
152 if (Option.isNone(credential)) return { email: undefined, usageLimits: probeFailed };
153 const email = credential.value?.email?.trim() || undefined;
154 const token = credential.value?.key?.trim();
155 if (!token) {
156 return { email, usageLimits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) };
157 }
158 // A failed quota request still knows which account it asked about.
159 const usageLimits = yield* Effect.gen(function* () {
160 const client = yield* HttpClient.HttpClient;
161 const response = yield* client.execute(
162 HttpClientRequest.get("https://cli-chat-proxy.grok.com/v1/billing?format=credits").pipe(
163 HttpClientRequest.bearerToken(token),
164 ),
165 );
166 const body = yield* HttpClientResponse.schemaBodyJson(GrokUsageResponse)(
167 yield* HttpClientResponse.filterStatusOk(response),
168 );
169 return grokUsageResponseToLimits(body, checkedAt);
170 }).pipe(
171 Effect.timeout("10 seconds"),
172 Effect.orElseSucceed(() => probeFailed),
173 );
174 return { email, usageLimits };
175});
apps/server/src/textGeneration/GrokTextGeneration.test.ts+3-2
...@@ -11,11 +11,12 @@ import * as Layer from "effect/Layer";...@@ -11,11 +11,12 @@ import * as Layer from "effect/Layer";
11import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
12import { createModelSelection } from "@t3tools/shared/model";12import { createModelSelection } from "@t3tools/shared/model";
13import { expect } from "vite-plus/test";13import { expect } from "vite-plus/test";
14import { GrokSettings, ProviderInstanceId } from "@t3tools/contracts";14import { ProviderInstanceId } from "@t3tools/contracts";
15import { GrokSettings } from "@t3tools/provider-grok/settings";
1516
16import * as ServerConfig from "../config.ts";17import * as ServerConfig from "../config.ts";
17import * as TextGeneration from "./TextGeneration.ts";18import * as TextGeneration from "./TextGeneration.ts";
18import { makeGrokTextGeneration } from "./GrokTextGeneration.ts";19import { makeGrokTextGeneration } from "@t3tools/provider-grok/testing";
19import { execScriptSource, writeFakeCli } from "@t3tools/provider-testing/fakeCli";20import { execScriptSource, writeFakeCli } from "@t3tools/provider-testing/fakeCli";
20const decodeGrokSettings = Schema.decodeSync(GrokSettings);21const decodeGrokSettings = Schema.decodeSync(GrokSettings);
2122
apps/server/src/textGeneration/GrokTextGeneration.ts deleted-130
...@@ -1,130 +0,0 @@
1import * as Crypto from "effect/Crypto";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Ref from "effect/Ref";
5import * as Schema from "effect/Schema";
6import { ChildProcessSpawner } from "effect/process";
7import type * as EffectAcpErrors from "effect-acp/errors";
8
9import { type GrokSettings, TextGenerationError } from "@t3tools/contracts";
10import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
11
12import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations";
13import {
14 applyGrokAcpModelSelection,
15 currentGrokModelIdFromSessionSetup,
16 currentGrokReasoningEffortFromSessionSetup,
17 makeGrokAcpRuntime,
18 resolveGrokAcpBaseModelId,
19} from "../provider/acp/GrokAcpSupport.ts";
20
21const GROK_TIMEOUT_MS = 180_000;
22
23const isTextGenerationError = Schema.is(TextGenerationError);
24
25export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(function* (
26 grokSettings: GrokSettings,
27 environment: NodeJS.ProcessEnv = process.env,
28) {
29 const crypto = yield* Crypto.Crypto;
30 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
31
32 const runGrokJson: TextGenerationOperations.Runner = (request) => {
33 const { operation, cwd, prompt, modelSelection } = request;
34 return Effect.gen(function* () {
35 const outputRef = yield* Ref.make("");
36 const runtime = yield* makeGrokAcpRuntime({
37 grokSettings,
38 environment,
39 childProcessSpawner: commandSpawner,
40 cwd,
41 clientInfo: { name: "t3-code-git-text", version: "0.0.0" },
42 }).pipe(Effect.provideService(Crypto.Crypto, crypto));
43
44 yield* runtime.handleSessionUpdate((notification) => {
45 const update = notification.update;
46 if (update.sessionUpdate !== "agent_message_chunk") {
47 return Effect.void;
48 }
49 const content = update.content;
50 if (content.type !== "text") {
51 return Effect.void;
52 }
53 return Ref.update(outputRef, (current) => current + content.text);
54 });
55
56 const promptResult = yield* Effect.gen(function* () {
57 const resolvedModel = resolveGrokAcpBaseModelId(modelSelection.model);
58 const started = yield* runtime.start();
59 const requestedReasoningEffort = getModelSelectionStringOptionValue(
60 modelSelection,
61 "reasoningEffort",
62 );
63 yield* applyGrokAcpModelSelection({
64 runtime,
65 currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult),
66 currentReasoningEffort: currentGrokReasoningEffortFromSessionSetup(
67 started.sessionSetupResult,
68 ),
69 requestedModelId: resolvedModel,
70 requestedReasoningEffort,
71 mapError: (cause) =>
72 new TextGenerationError({
73 operation,
74 detail: "Failed to set Grok ACP base model for text generation.",
75 cause,
76 }),
77 });
78 return yield* runtime.prompt({
79 prompt: [{ type: "text", text: prompt }],
80 });
81 }).pipe(
82 Effect.timeoutOption(GROK_TIMEOUT_MS),
83 Effect.flatMap(
84 Option.match({
85 onNone: () =>
86 Effect.fail(
87 new TextGenerationError({ operation, detail: "Grok ACP request timed out." }),
88 ),
89 onSome: (value) => Effect.succeed(value),
90 }),
91 ),
92 Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) =>
93 isTextGenerationError(cause)
94 ? cause
95 : new TextGenerationError({
96 operation,
97 detail: "Grok ACP request failed.",
98 cause,
99 }),
100 ),
101 );
102
103 const trimmed = (yield* Ref.get(outputRef)).trim();
104 if (!trimmed) {
105 return yield* new TextGenerationError({
106 operation,
107 detail:
108 promptResult.stopReason === "cancelled"
109 ? "Grok ACP request was cancelled."
110 : "Grok Agent returned empty output.",
111 });
112 }
113
114 return yield* TextGenerationOperations.decodeJsonReply(request, "Grok Agent", trimmed);
115 }).pipe(
116 Effect.mapError((cause) =>
117 isTextGenerationError(cause)
118 ? cause
119 : new TextGenerationError({
120 operation,
121 detail: "Grok ACP text generation failed.",
122 cause,
123 }),
124 ),
125 Effect.scoped,
126 );
127 };
128
129 return TextGenerationOperations.fromRunner("GrokTextGeneration", runGrokJson);
130});
apps/web/package.json+1
...@@ -30,6 +30,7 @@...@@ -30,6 +30,7 @@
30 "@t3tools/contracts": "workspace:*",30 "@t3tools/contracts": "workspace:*",
31 "@t3tools/provider-core": "workspace:*",31 "@t3tools/provider-core": "workspace:*",
32 "@t3tools/provider-cursor": "workspace:*",32 "@t3tools/provider-cursor": "workspace:*",
33 "@t3tools/provider-grok": "workspace:*",
33 "@t3tools/provider-muse": "workspace:*",34 "@t3tools/provider-muse": "workspace:*",
34 "@t3tools/provider-opencode": "workspace:*",35 "@t3tools/provider-opencode": "workspace:*",
35 "@t3tools/provider-pi": "workspace:*",36 "@t3tools/provider-pi": "workspace:*",
apps/web/src/components/Icons.tsx-12
...@@ -364,18 +364,6 @@ export const CursorIcon: Icon = ({ className, ...props }) => (...@@ -364,18 +364,6 @@ export const CursorIcon: Icon = ({ className, ...props }) => (
364 </svg>364 </svg>
365);365);
366366
367export const GrokIcon: Icon = ({ className, ...props }) => (
368 <svg
369 {...props}
370 viewBox="0 0 24 24"
371 fill="none"
372 className={cn("fill-[#0F0F0F] dark:fill-[#F5F5F5]", className)}
373 >
374 <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" />
375 <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" />
376 </svg>
377);
378
379export const TraeIcon: Icon = (props) => (367export const TraeIcon: Icon = (props) => (
380 <svg {...props} viewBox="0 0 24 24" fill="currentColor">368 <svg {...props} viewBox="0 0 24 24" fill="currentColor">
381 {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */}369 {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */}
apps/web/src/components/chat/ProviderInstanceIcon.tsx+1-3
...@@ -3,7 +3,7 @@ import { type CSSProperties, memo } from "react";...@@ -3,7 +3,7 @@ import { type CSSProperties, memo } from "react";
3import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display";3import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display";
44
5import { ProviderDriverKind } from "@t3tools/contracts";5import { ProviderDriverKind } from "@t3tools/contracts";
6import { AntigravityIcon, ClaudeAI, GrokIcon, Icon, OpenAI } from "../Icons";6import { AntigravityIcon, ClaudeAI, Icon, OpenAI } from "../Icons";
77
8import { cn } from "~/lib/utils";8import { cn } from "~/lib/utils";
9import { providerClients } from "../settings/providerDriverMeta";9import { providerClients } from "../settings/providerDriverMeta";
...@@ -17,14 +17,12 @@ import {...@@ -17,14 +17,12 @@ import {
17const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = {17const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = {
18 [ProviderDriverKind.make("codex")]: OpenAI,18 [ProviderDriverKind.make("codex")]: OpenAI,
19 [ProviderDriverKind.make("claudeAgent")]: ClaudeAI,19 [ProviderDriverKind.make("claudeAgent")]: ClaudeAI,
20 [ProviderDriverKind.make("grok")]: GrokIcon,
21 [ProviderDriverKind.make("antigravity")]: AntigravityIcon,20 [ProviderDriverKind.make("antigravity")]: AntigravityIcon,
22};21};
2322
24const PROVIDER_TEXT_COLOR_BY_PROVIDER: Partial<Record<ProviderDriverKind, string>> = {23const PROVIDER_TEXT_COLOR_BY_PROVIDER: Partial<Record<ProviderDriverKind, string>> = {
25 [ProviderDriverKind.make("codex")]: "text-black dark:text-white",24 [ProviderDriverKind.make("codex")]: "text-black dark:text-white",
26 [ProviderDriverKind.make("claudeAgent")]: "text-[#d97757]",25 [ProviderDriverKind.make("claudeAgent")]: "text-[#d97757]",
27 [ProviderDriverKind.make("grok")]: "text-[#0F0F0F] dark:text-[#F5F5F5]",
28 [ProviderDriverKind.make("antigravity")]: "text-[#5b87bf]",26 [ProviderDriverKind.make("antigravity")]: "text-[#5b87bf]",
29};27};
3028
apps/web/src/components/settings/providerDriverMeta.ts+2-6
...@@ -3,11 +3,11 @@ import {...@@ -3,11 +3,11 @@ import {
3 AntigravitySettings,3 AntigravitySettings,
4 ClaudeSettings,4 ClaudeSettings,
5 CodexSettings,5 CodexSettings,
6 GrokSettings,
7 ProviderDriverKind,6 ProviderDriverKind,
8} from "@t3tools/contracts";7} from "@t3tools/contracts";
9import { makeProviderClientRegistry } from "@t3tools/provider-core/client";8import { makeProviderClientRegistry } from "@t3tools/provider-core/client";
10import { cursorClient } from "@t3tools/provider-cursor/client";9import { cursorClient } from "@t3tools/provider-cursor/client";
10import { grokClient } from "@t3tools/provider-grok/client";
11import { museClient } from "@t3tools/provider-muse/client";11import { museClient } from "@t3tools/provider-muse/client";
12import { openCodeClient } from "@t3tools/provider-opencode/client";12import { openCodeClient } from "@t3tools/provider-opencode/client";
13import { piClient } from "@t3tools/provider-pi/client";13import { piClient } from "@t3tools/provider-pi/client";
...@@ -25,11 +25,7 @@ export const providerClients = makeProviderClientRegistry([...@@ -25,11 +25,7 @@ export const providerClients = makeProviderClientRegistry([
25 settingsSchema: ClaudeSettings,25 settingsSchema: ClaudeSettings,
26 },26 },
27 cursorClient,27 cursorClient,
28 {28 grokClient,
29 driverKind: ProviderDriverKind.make("grok"),
30 label: "Grok",
31 settingsSchema: GrokSettings,
32 },
33 openCodeClient,29 openCodeClient,
34 {30 {
35 driverKind: ProviderDriverKind.make("antigravity"),31 driverKind: ProviderDriverKind.make("antigravity"),
docs/internals/adding-a-provider.md+1-1
...@@ -26,7 +26,7 @@ capabilities, never the driver kind....@@ -26,7 +26,7 @@ capabilities, never the driver kind.
26 falls back, for example to portable context handoff for forks. A capability left on that fails26 falls back, for example to portable context handoff for forks. A capability left on that fails
27 at runtime is a bug.27 at runtime is a bug.
28- **Permission modes.** Offer only modes the provider enforces natively, through28- **Permission modes.** Offer only modes the provider enforces natively, through
29 `supportedRuntimeModes` in the provider presentation ([Grok](../../apps/server/src/provider/GrokProvider.ts)29 `supportedRuntimeModes` in the provider presentation ([Grok](../../packages/provider-grok/src/server/status.ts)
30 and [Pi](../../packages/provider-pi/src/server/status.ts) are examples). Do not imitate a missing30 and [Pi](../../packages/provider-pi/src/server/status.ts) are examples). Do not imitate a missing
31 mode by answering approvals in T3: T3's check is weaker than the agent's own enforcement. The31 mode by answering approvals in T3: T3's check is weaker than the agent's own enforcement. The
32 server runs an unoffered stored mode as Supervised32 server runs an unoffered stored mode as Supervised
docs/internals/providers.md+1-1
...@@ -52,7 +52,7 @@ and removal must respect those leases instead of replacing executables under a r...@@ -52,7 +52,7 @@ and removal must respect those leases instead of replacing executables under a r
52## Setup must not happen as a health-check side effect52## Setup must not happen as a health-check side effect
5353
54Opening a provider session can start MCP servers, run hooks, or launch a login browser.54Opening a provider session can start MCP servers, run hooks, or launch a login browser.
55[Grok probes](../../apps/server/src/provider/GrokProvider.ts) avoid authentication and55[Grok probes](../../packages/provider-grok/src/server/status.ts) avoid authentication and
56session creation for this reason. Antigravity likewise reserves authenticated catalog sessions for56session creation for this reason. Antigravity likewise reserves authenticated catalog sessions for
57explicit setup or model refresh; background checks use initialization only.57explicit setup or model refresh; background checks use initialization only.
5858
package.json+1-1
...@@ -28,7 +28,7 @@...@@ -28,7 +28,7 @@
28 "tc": "vp run -r --concurrency-limit 2 typecheck",28 "tc": "vp run -r --concurrency-limit 2 typecheck",
29 "lint": "vp lint --report-unused-disable-directives",29 "lint": "vp lint --report-unused-disable-directives",
30 "knip": "knip --preprocessor ./scripts/knip-schemas.ts",30 "knip": "knip --preprocessor ./scripts/knip-schemas.ts",
31 "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",31 "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",
32 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",32 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",
33 "lint:mobile": "node scripts/mobile-native-static-check.ts",33 "lint:mobile": "node scripts/mobile-native-static-check.ts",
34 "test": "vp run -r test",34 "test": "vp run -r test",
packages/contracts/src/settings.ts-26
...@@ -707,32 +707,6 @@ export const ClaudeSettings = makeProviderSettingsSchema(...@@ -707,32 +707,6 @@ export const ClaudeSettings = makeProviderSettingsSchema(
707);707);
708export type ClaudeSettings = typeof ClaudeSettings.Type;708export type ClaudeSettings = typeof ClaudeSettings.Type;
709709
710export const GrokSettings = makeProviderSettingsSchema(
711 {
712 // Off by default (like Cursor and OpenCode): the binding is not yet
713 // stable enough to probe on every install. Users opt in from Settings.
714 enabled: Schema.Boolean.pipe(
715 Schema.withDecodingDefault(Effect.succeed(false)),
716 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
717 ),
718 binaryPath: makeBinaryPathSetting("grok").pipe(
719 Schema.annotateKey({
720 title: "Binary path",
721 description: "Path to the Grok CLI binary.",
722 providerSettingsForm: { placeholder: "grok", clearWhenEmpty: "omit" },
723 }),
724 ),
725 customModels: Schema.Array(CustomModelSetting).pipe(
726 Schema.withDecodingDefault(Effect.succeed([])),
727 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
728 ),
729 },
730 {
731 order: ["binaryPath"],
732 },
733);
734export type GrokSettings = typeof GrokSettings.Type;
735
736/**710/**
737 * Antigravity ACP auth methods. Personal and Enterprise open a Google sign-in711 * Antigravity ACP auth methods. Personal and Enterprise open a Google sign-in
738 * in the browser. The API key and Agent Platform methods take credentials from712 * in the browser. The API key and Agent Platform methods take credentials from
packages/provider-grok/package.json created+42
...@@ -0,0 +1,42 @@
1{
2 "name": "@t3tools/provider-grok",
3 "private": true,
4 "type": "module",
5 "exports": {
6 "./client": {
7 "types": "./src/client.ts",
8 "import": "./src/client.ts"
9 },
10 "./settings": {
11 "types": "./src/settings.ts",
12 "import": "./src/settings.ts"
13 },
14 "./server": {
15 "types": "./src/server.ts",
16 "import": "./src/server.ts"
17 },
18 "./testing": {
19 "types": "./src/testing.ts",
20 "import": "./src/testing.ts"
21 }
22 },
23 "scripts": {
24 "typecheck": "tsc --noEmit",
25 "test": "vp test run --config ../../vite.config.ts --dir ."
26 },
27 "dependencies": {
28 "@t3tools/contracts": "workspace:*",
29 "@t3tools/provider-acp": "workspace:*",
30 "@t3tools/provider-core": "workspace:*",
31 "@t3tools/shared": "workspace:*",
32 "effect": "catalog:"
33 },
34 "devDependencies": {
35 "@effect/platform-node": "catalog:",
36 "@effect/vitest": "catalog:",
37 "@t3tools/provider-testing": "workspace:*",
38 "@types/node": "catalog:",
39 "effect-acp": "workspace:*",
40 "vite-plus": "catalog:"
41 }
42}
packages/provider-grok/src/client.ts created+28
...@@ -0,0 +1,28 @@
1/**
2 * Grok's client definition: settings form, label, and glyph.
3 * Browser- and React Native-safe.
4 *
5 * @module provider-grok/client
6 */
7import { ProviderDriverKind } from "@t3tools/contracts";
8import { defineProviderClient } from "@t3tools/provider-core/client";
9
10import { GrokSettings } from "./settings.ts";
11
12export const grokClient = defineProviderClient({
13 driverKind: ProviderDriverKind.make("grok"),
14 label: "Grok",
15 settingsSchema: GrokSettings,
16 icon: {
17 viewBox: "0 0 24 24",
18 fill: { light: "#0F0F0F", dark: "#F5F5F5" },
19 paths: [
20 {
21 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",
22 },
23 {
24 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",
25 },
26 ],
27 },
28});
packages/provider-grok/src/server.ts created+7
...@@ -0,0 +1,7 @@
1/**
2 * Grok's server entry: the driver and adapter driver the server registers.
3 *
4 * @module provider-grok/server
5 */
6export { GrokDriver, type GrokDriverEnv } from "./server/driver.ts";
7export { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./server/adapter.ts";
packages/provider-grok/src/server/acpCliProbe.test.ts created+110
...@@ -0,0 +1,110 @@
1/**
2 * Optional integration check against a real `grok agent stdio` install.
3 * Enable with: T3_GROK_ACP_PROBE=1 vp test run GrokAcpCliProbe
4 * Set T3_GROK_LIVE_TURN=1 to also send a small prompt to the real model.
5 *
6 * The probe assumes either `XAI_API_KEY` is set in the environment or
7 * the user has previously run `grok login`. Without credentials the
8 * agent's `authenticate` request will fail and the test will surface
9 * the error.
10 */
11import * as NodeServices from "@effect/platform-node/NodeServices";
12import { it } from "@effect/vitest";
13import * as Effect from "effect/Effect";
14import * as Deferred from "effect/Deferred";
15import * as Fiber from "effect/Fiber";
16import * as FileSystem from "effect/FileSystem";
17import * as Stream from "effect/Stream";
18import { ChildProcessSpawner } from "effect/process";
19import { describe, expect } from "vite-plus/test";
20
21import { makeGrokAcpRuntime } from "./acpSupport.ts";
22
23const makeProbeRuntime = Effect.gen(function* () {
24 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
25 return yield* makeGrokAcpRuntime({
26 grokSettings: { binaryPath: "grok" },
27 environment: process.env,
28 childProcessSpawner,
29 cwd: process.cwd(),
30 clientInfo: { name: "t3-grok-probe", version: "0.0.0" },
31 });
32});
33
34describe.runIf(process.env.T3_GROK_ACP_PROBE === "1")("Grok ACP CLI probe", () => {
35 it.effect("initialize and authenticate against real grok agent stdio", () =>
36 Effect.gen(function* () {
37 const runtime = yield* makeProbeRuntime;
38 const started = yield* runtime.start();
39 expect(started.initializeResult).toBeDefined();
40 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
41 );
42
43 it.effect("completes a prompt on the agent's default model", () =>
44 Effect.gen(function* () {
45 const runtime = yield* makeProbeRuntime;
46 const started = yield* runtime.start();
47 expect(typeof started.sessionId).toBe("string");
48
49 const result = yield* runtime
50 .prompt({ prompt: [{ type: "text", text: "Respond with exactly: grok switch ok" }] })
51 .pipe(Effect.timeout("60 seconds"));
52 expect(result.stopReason).toBe("end_turn");
53 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
54 );
55 it.effect("session/set_model accepts advertised reasoning effort metadata", () =>
56 Effect.gen(function* () {
57 const runtime = yield* makeProbeRuntime;
58 const started = yield* runtime.start();
59 const modelState = started.sessionSetupResult.models;
60 const currentModelId = modelState?.currentModelId.trim();
61 expect(currentModelId).toBeDefined();
62 if (!currentModelId) return;
63
64 const currentModel = modelState?.availableModels.find(
65 (model) => model.modelId.trim() === currentModelId,
66 );
67 const reasoningEffort = currentModel?._meta?.reasoningEffort;
68 expect(typeof reasoningEffort).toBe("string");
69 if (typeof reasoningEffort !== "string") return;
70
71 yield* runtime.setSessionModel(currentModelId, { reasoningEffort });
72 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
73 );
74
75 it.effect.skipIf(process.env.T3_GROK_LIVE_TURN !== "1")(
76 "finishes a real Grok turn and streams its answer",
77 () =>
78 Effect.gen(function* () {
79 const fileSystem = yield* FileSystem.FileSystem;
80 const cwd = yield* fileSystem.makeTempDirectoryScoped();
81 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
82 const runtime = yield* makeGrokAcpRuntime({
83 grokSettings: { binaryPath: "grok" },
84 environment: process.env,
85 childProcessSpawner,
86 cwd,
87 runtimeMode: "approval-required",
88 clientInfo: { name: "t3-grok-probe", version: "0.0.0" },
89 });
90 yield* runtime.start();
91 const chunks: string[] = [];
92 const events = yield* Stream.runForEach(runtime.getEvents(), (event) => {
93 if (event._tag === "EventStreamBarrier") {
94 return Deferred.succeed(event.acknowledge, undefined);
95 }
96 if (event._tag === "ContentDelta") {
97 chunks.push(event.text);
98 }
99 return Effect.void;
100 }).pipe(Effect.forkChild);
101 const result = yield* runtime.prompt({
102 prompt: [{ type: "text", text: "Reply exactly GROK_T3_OK. Do not use any tools." }],
103 });
104 yield* runtime.drainEvents;
105 expect(result.stopReason).toBe("end_turn");
106 expect(chunks.join("")).toContain("GROK_T3_OK");
107 yield* Fiber.interrupt(events);
108 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
109 );
110});
packages/provider-grok/src/server/acpSupport.test.ts created+265
...@@ -0,0 +1,265 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import * as Effect from "effect/Effect";
4import * as EffectAcpErrors from "effect-acp/errors";
5
6import {
7 applyGrokAcpModelSelection,
8 buildGrokAcpSpawnInput,
9 grokAcpRuntimeProcessOwnership,
10 grokAcpSpawnArgs,
11 isValidGrokReasoningEffortToken,
12 resolveGrokAcpBaseModelId,
13} from "./acpSupport.ts";
14
15describe("grokAcpRuntimeProcessOwnership", () => {
16 it("opts Grok into detached process-tree ownership on the injected host platform", () => {
17 expect(grokAcpRuntimeProcessOwnership("linux")).toEqual({
18 ownDescendantProcessGroups: true,
19 ownDetachedProcessGroup: true,
20 processGroupPlatform: "linux",
21 });
22 });
23
24 it("uses the prior provider-group path on Darwin and Windows", () => {
25 expect(grokAcpRuntimeProcessOwnership("darwin")).toEqual({
26 ownDescendantProcessGroups: false,
27 ownDetachedProcessGroup: true,
28 processGroupPlatform: "darwin",
29 });
30 expect(grokAcpRuntimeProcessOwnership("win32")).toEqual({
31 ownDescendantProcessGroups: false,
32 ownDetachedProcessGroup: true,
33 processGroupPlatform: "win32",
34 });
35 });
36});
37
38describe("resolveGrokAcpBaseModelId", () => {
39 it("normalizes empty and custom Grok model ids", () => {
40 expect(resolveGrokAcpBaseModelId(undefined)).toBe("grok-build");
41 expect(resolveGrokAcpBaseModelId(" ")).toBe("grok-build");
42 expect(resolveGrokAcpBaseModelId(" grok-test-custom-model ")).toBe("grok-test-custom-model");
43 });
44});
45
46describe("grokAcpSpawnArgs", () => {
47 it("inherits the Grok CLI config when no T3 runtime mode is set", () => {
48 expect(grokAcpSpawnArgs()).toEqual(["agent", "stdio"]);
49 });
50
51 it("forces Grok to ask when T3 is Supervised", () => {
52 expect(grokAcpSpawnArgs("approval-required")).toEqual([
53 "--permission-mode",
54 "default",
55 "agent",
56 "stdio",
57 ]);
58 });
59
60 it("maps Full access to Grok always-approve", () => {
61 expect(grokAcpSpawnArgs("full-access")).toEqual(["agent", "--always-approve", "stdio"]);
62 });
63
64 it("launches Auto on Grok's classifier and a mode Grok does not offer asking", () => {
65 expect(grokAcpSpawnArgs("auto")).toEqual(["--permission-mode", "auto", "agent", "stdio"]);
66 expect(grokAcpSpawnArgs("auto-accept-edits")).toEqual([
67 "--permission-mode",
68 "default",
69 "agent",
70 "stdio",
71 ]);
72 });
73});
74
75describe("buildGrokAcpSpawnInput", () => {
76 it("passes the T3 Code referrer through Grok OAuth env", () => {
77 const spawn = buildGrokAcpSpawnInput({ binaryPath: "/usr/local/bin/grok" }, "/tmp/project", {
78 XAI_API_KEY: "secret",
79 GROK_OAUTH2_REFERRER: "other-client",
80 });
81
82 expect(spawn).toEqual({
83 command: "/usr/local/bin/grok",
84 args: ["agent", "stdio"],
85 cwd: "/tmp/project",
86 env: {
87 XAI_API_KEY: "secret",
88 GROK_OAUTH2_REFERRER: "t3code",
89 },
90 });
91 });
92
93 it("puts Supervised on the Grok argv so config always-approve cannot win", () => {
94 const spawn = buildGrokAcpSpawnInput(
95 { binaryPath: "/usr/local/bin/grok" },
96 "/tmp/project",
97 undefined,
98 "approval-required",
99 );
100 expect(spawn.args).toEqual(["--permission-mode", "default", "agent", "stdio"]);
101 });
102});
103
104describe("isValidGrokReasoningEffortToken", () => {
105 it("accepts future ACP tokens and rejects malformed metadata values", () => {
106 expect(isValidGrokReasoningEffortToken("xhigh")).toBe(true);
107 expect(isValidGrokReasoningEffortToken("turbo_v2")).toBe(true);
108 expect(isValidGrokReasoningEffortToken("not a token")).toBe(false);
109 expect(isValidGrokReasoningEffortToken("-leading-dash")).toBe(false);
110 expect(isValidGrokReasoningEffortToken("x".repeat(33))).toBe(false);
111 });
112});
113
114describe("applyGrokAcpModelSelection", () => {
115 const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => {
116 const modelCalls: Array<{
117 modelId: string;
118 meta?: { readonly [key: string]: unknown } | null;
119 }> = [];
120 const runtime = {
121 setSessionModel: (modelId: string, meta?: { readonly [key: string]: unknown } | null) =>
122 Effect.gen(function* () {
123 modelCalls.push(meta === undefined ? { modelId } : { modelId, meta });
124 if (failure) return yield* failure;
125 return {};
126 }),
127 };
128 return { runtime, modelCalls };
129 };
130
131 it.effect("calls session/set_model when the requested model differs from current", () =>
132 Effect.gen(function* () {
133 const { runtime, modelCalls } = makeRecordingRuntime();
134 const result = yield* applyGrokAcpModelSelection({
135 runtime,
136 currentModelId: "grok-build",
137 requestedModelId: "grok-mock-alt",
138 mapError: (cause) => cause.message,
139 });
140 expect(modelCalls).toEqual([{ modelId: "grok-mock-alt" }]);
141 expect(result).toBe("grok-mock-alt");
142 }),
143 );
144
145 it.effect("applies reasoning effort through session/set_model metadata", () =>
146 Effect.gen(function* () {
147 const { runtime, modelCalls } = makeRecordingRuntime();
148 const result = yield* applyGrokAcpModelSelection({
149 runtime,
150 currentModelId: "grok-4.6",
151 currentReasoningEffort: "high",
152 requestedModelId: "grok-4.6",
153 requestedReasoningEffort: "xhigh",
154 mapError: (cause) => cause.message,
155 });
156 expect(modelCalls).toEqual([{ modelId: "grok-4.6", meta: { reasoningEffort: "xhigh" } }]);
157 expect(result).toBe("grok-4.6");
158 }),
159 );
160
161 it.effect("does not clear reasoning when same-model selection omits effort", () =>
162 Effect.gen(function* () {
163 const { runtime, modelCalls } = makeRecordingRuntime();
164 const result = yield* applyGrokAcpModelSelection({
165 runtime,
166 currentModelId: "grok-4.6",
167 currentReasoningEffort: "high",
168 requestedModelId: "grok-4.6",
169 requestedReasoningEffort: undefined,
170 mapError: (cause) => cause.message,
171 });
172 expect(modelCalls).toEqual([]);
173 expect(result).toBe("grok-4.6");
174 }),
175 );
176
177 it.effect("drops malformed effort metadata instead of sending it", () =>
178 Effect.gen(function* () {
179 const { runtime, modelCalls } = makeRecordingRuntime();
180 yield* applyGrokAcpModelSelection({
181 runtime,
182 currentModelId: "grok-4.6",
183 currentReasoningEffort: "high",
184 requestedModelId: "grok-4.6",
185 requestedReasoningEffort: "not a token",
186 mapError: (cause) => cause.message,
187 });
188 expect(modelCalls).toEqual([{ modelId: "grok-4.6" }]);
189 }),
190 );
191
192 it.effect("skips set_model when requested matches current", () =>
193 Effect.gen(function* () {
194 const { runtime, modelCalls } = makeRecordingRuntime();
195 const result = yield* applyGrokAcpModelSelection({
196 runtime,
197 currentModelId: "grok-build",
198 requestedModelId: "grok-build",
199 mapError: (cause) => cause.message,
200 });
201 expect(modelCalls).toEqual([]);
202 expect(result).toBe("grok-build");
203 }),
204 );
205
206 it.effect("keeps the session's current model when the product slug is requested", () =>
207 Effect.gen(function* () {
208 const { runtime, modelCalls } = makeRecordingRuntime();
209 const result = yield* applyGrokAcpModelSelection({
210 runtime,
211 currentModelId: "grok-4.6",
212 requestedModelId: "grok-build",
213 mapError: (cause) => cause.message,
214 });
215 expect(modelCalls).toEqual([]);
216 expect(result).toBe("grok-4.6");
217 }),
218 );
219
220 it.effect("applies reasoning to the current model when the product slug is requested", () =>
221 Effect.gen(function* () {
222 const { runtime, modelCalls } = makeRecordingRuntime();
223 const result = yield* applyGrokAcpModelSelection({
224 runtime,
225 currentModelId: "grok-4.6",
226 currentReasoningEffort: "high",
227 requestedModelId: "grok-build",
228 requestedReasoningEffort: "xhigh",
229 mapError: (cause) => cause.message,
230 });
231 expect(modelCalls).toEqual([{ modelId: "grok-4.6", meta: { reasoningEffort: "xhigh" } }]);
232 expect(result).toBe("grok-4.6");
233 }),
234 );
235
236 it.effect("skips set_model when no model is requested", () =>
237 Effect.gen(function* () {
238 const { runtime, modelCalls } = makeRecordingRuntime();
239 const result = yield* applyGrokAcpModelSelection({
240 runtime,
241 currentModelId: "grok-build",
242 requestedModelId: undefined,
243 mapError: (cause) => cause.message,
244 });
245 expect(modelCalls).toEqual([]);
246 expect(result).toBe("grok-build");
247 }),
248 );
249
250 it.effect("propagates session/set_model failures via mapError", () =>
251 Effect.gen(function* () {
252 const failure = EffectAcpErrors.AcpRequestError.invalidParams("session id not known");
253 const { runtime } = makeRecordingRuntime(failure);
254 const error = yield* Effect.flip(
255 applyGrokAcpModelSelection({
256 runtime,
257 currentModelId: "grok-build",
258 requestedModelId: "grok-mock-alt",
259 mapError: (cause) => cause.message,
260 }),
261 );
262 expect(error).toBe(failure.message);
263 }),
264 );
265});
packages/provider-grok/src/server/acpSupport.ts created+278
...@@ -0,0 +1,278 @@
1import type * as EffectAcpSchema from "effect-acp/compat";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import {
4 type ProviderApprovalOption,
5 ProviderDriverKind,
6 type RuntimeMode,
7} from "@t3tools/contracts";
8import type { GrokSettings } from "../settings.ts";
9import * as Crypto from "effect/Crypto";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as Scope from "effect/Scope";
13import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
14import * as EffectAcpErrors from "effect-acp/errors";
15import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
16import { normalizeModelSlug } from "@t3tools/shared/model";
17
18import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
19import { makeXAiPromptCompletionRuntime } from "./xaiAcpExtension.ts";
20
21const GROK_API_KEY_ENV = "XAI_API_KEY";
22const GROK_OAUTH2_REFERRER_ENV = "GROK_OAUTH2_REFERRER";
23const T3_CODE_OAUTH_REFERRER = "t3code";
24const GROK_AUTH_METHOD_API_KEY = "xai.api_key";
25const GROK_AUTH_METHOD_CACHED_TOKEN = "cached_token";
26const GROK_DRIVER_KIND = ProviderDriverKind.make("grok");
27
28type GrokAcpRuntimeGrokSettings = Pick<GrokSettings, "binaryPath">;
29
30interface GrokAcpRuntimeInput extends Omit<
31 AcpSessionRuntime.AcpSessionRuntimeOptions,
32 "authMethodId" | "clientCapabilities" | "spawn"
33> {
34 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
35 readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined;
36 readonly environment?: NodeJS.ProcessEnv;
37 readonly runtimeMode?: RuntimeMode;
38}
39
40/**
41 * The runtime modes `grok agent` can launch in: ask, its auto classifier, and
42 * always-approve. It has no Auto-accept edits: `acceptEdits` only exists as a
43 * settings-file `permissions.defaultMode`, and `grok agent` treats it as ask.
44 */
45export const GROK_SUPPORTED_RUNTIME_MODES = [
46 "approval-required",
47 "auto",
48 "full-access",
49] as const satisfies ReadonlyArray<RuntimeMode>;
50
51/**
52 * Launch argv for a runtime mode. `--permission-mode` on the argv beats the
53 * user's Grok config, so Supervised cannot inherit a configured always-approve.
54 * A mode Grok does not offer launches asking.
55 */
56export function grokAcpSpawnArgs(runtimeMode?: RuntimeMode): ReadonlyArray<string> {
57 switch (runtimeMode) {
58 case undefined:
59 return ["agent", "stdio"];
60 case "auto":
61 return ["--permission-mode", "auto", "agent", "stdio"];
62 case "full-access":
63 return ["agent", "--always-approve", "stdio"];
64 default:
65 return ["--permission-mode", "default", "agent", "stdio"];
66 }
67}
68
69export function buildGrokAcpSpawnInput(
70 grokSettings: GrokAcpRuntimeGrokSettings | null | undefined,
71 cwd: string,
72 environment?: NodeJS.ProcessEnv,
73 runtimeMode?: RuntimeMode,
74): AcpSessionRuntime.AcpSpawnInput {
75 return {
76 command: grokSettings?.binaryPath || "grok",
77 args: [...grokAcpSpawnArgs(runtimeMode)],
78 cwd,
79 env: {
80 ...environment,
81 [GROK_OAUTH2_REFERRER_ENV]: T3_CODE_OAUTH_REFERRER,
82 },
83 };
84}
85
86function resolveGrokAuthMethodId(environment: NodeJS.ProcessEnv | undefined): string {
87 return environment?.[GROK_API_KEY_ENV]?.trim()
88 ? GROK_AUTH_METHOD_API_KEY
89 : GROK_AUTH_METHOD_CACHED_TOKEN;
90}
91
92export function grokAcpRuntimeProcessOwnership(
93 processGroupPlatform: NodeJS.Platform,
94): Pick<
95 AcpSessionRuntime.AcpSessionRuntimeOptions,
96 "ownDescendantProcessGroups" | "ownDetachedProcessGroup" | "processGroupPlatform"
97> {
98 return {
99 // macOS keeps the prior provider-group teardown until a stable libproc
100 // identity provider can cover Grok's nested detached tool groups.
101 ownDescendantProcessGroups: processGroupPlatform === "linux",
102 ownDetachedProcessGroup: true,
103 processGroupPlatform,
104 };
105}
106
107/**
108 * Current Grok treats Ctrl+C cancellation as a barrier against stale
109 * background-task wake prompts until the next genuine user turn. Replay sends
110 * the same metadata so recorded cancels match.
111 */
112export const GROK_ACP_CANCEL_META = { cancelTrigger: "ctrl_c" } as const;
113
114/**
115 * Grok's Auto mode asks the client about an action its classifier blocks only
116 * when the client declares a type that can show a prompt; the default
117 * (`generic`) gets a silent denial instead. `extension` is the prompting type
118 * that keeps the permission options T3 already maps (no always-approve row,
119 * no per-command persistent grants).
120 */
121export const GROK_ACP_INITIALIZE_META = { clientType: "extension" } as const;
122
123/**
124 * Grok's only session-scoped `allow_always` answer: "Yes, allow all edits
125 * during this session" on an edit prompt. Its bash, monitor and MCP
126 * `always-allow` rows instead save a grant for the whole project that outlives
127 * the session (grok-build `crates/codegen/xai-grok-workspace/src/permission/`
128 * `prompter.rs` `ALLOW_EDITS_SESSION_OPTION_ID`, `grants.rs`
129 * `record_prompt_outcome`).
130 */
131const GROK_ALLOW_EDITS_SESSION_OPTION_ID = "allow-edits-session";
132
133/**
134 * The approval choices a Grok permission prompt can honor. The session choice
135 * appears only where Grok's answer lasts for the session.
136 */
137export function grokApprovalOptions(
138 request: EffectAcpSchema.RequestPermissionRequest,
139): ReadonlyArray<ProviderApprovalOption> {
140 const has = (kind: EffectAcpSchema.PermissionOption["kind"], optionId?: string) =>
141 request.options.some(
142 (option) =>
143 option.kind === kind && (optionId === undefined || option.optionId.trim() === optionId),
144 );
145 return [
146 { decision: "cancel", label: "Cancel" },
147 ...(has("reject_once") ? [{ decision: "decline", label: "Decline" } as const] : []),
148 ...(has("allow_always", GROK_ALLOW_EDITS_SESSION_OPTION_ID)
149 ? [{ decision: "acceptForSession", label: "Allow all edits this session" } as const]
150 : []),
151 ...(has("allow_once") ? [{ decision: "accept", label: "Approve" } as const] : []),
152 ];
153}
154
155export const makeGrokAcpRuntime = (
156 input: GrokAcpRuntimeInput,
157): Effect.Effect<
158 AcpSessionRuntime.AcpSessionRuntime["Service"],
159 EffectAcpErrors.AcpError,
160 Crypto.Crypto | Scope.Scope
161> =>
162 Effect.gen(function* () {
163 const processGroupPlatform = yield* HostProcessPlatform.pipe(
164 Effect.provide(NodeServices.layer),
165 );
166 const acpContext = yield* Layer.build(
167 AcpSessionRuntime.layer({
168 ...input,
169 spawn: buildGrokAcpSpawnInput(
170 input.grokSettings,
171 input.cwd,
172 input.environment,
173 input.runtimeMode,
174 ),
175 authMethodId: resolveGrokAuthMethodId(input.environment),
176 cancelMeta: { ...input.cancelMeta, ...GROK_ACP_CANCEL_META },
177 initializeMeta: GROK_ACP_INITIALIZE_META,
178 ...grokAcpRuntimeProcessOwnership(processGroupPlatform),
179 }).pipe(
180 Layer.provide(
181 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner),
182 ),
183 ),
184 );
185 const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
186 Effect.provide(acpContext),
187 );
188 return yield* makeXAiPromptCompletionRuntime(runtime);
189 });
190
191/**
192 * T3's built-in Grok slug. It is the CLI's product name, not a model id the ACP accepts,
193 * so selecting it means "use whatever model the Grok session currently runs on".
194 */
195export const GROK_DEFAULT_MODEL_SLUG = "grok-build";
196
197export function resolveGrokAcpBaseModelId(model: string | null | undefined): string {
198 const trimmed = model?.trim();
199 const base = trimmed && trimmed.length > 0 ? trimmed : GROK_DEFAULT_MODEL_SLUG;
200 return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? GROK_DEFAULT_MODEL_SLUG;
201}
202
203const GROK_REASONING_EFFORT_TOKEN = /^[a-z0-9][a-z0-9._-]{0,31}$/i;
204
205export function isValidGrokReasoningEffortToken(value: string): boolean {
206 return GROK_REASONING_EFFORT_TOKEN.test(value);
207}
208
209function normalizeGrokReasoningEffort(value: string | undefined): string | undefined {
210 const effort = value?.trim();
211 return effort && isValidGrokReasoningEffortToken(effort) ? effort : undefined;
212}
213
214export function currentGrokModelIdFromSessionSetup(
215 sessionSetupResult:
216 | EffectAcpSchema.LoadSessionResponse
217 | EffectAcpSchema.NewSessionResponse
218 | EffectAcpSchema.ResumeSessionResponse,
219): string | undefined {
220 return sessionSetupResult.models?.currentModelId?.trim() || undefined;
221}
222
223export function currentGrokReasoningEffortFromSessionSetup(
224 sessionSetupResult:
225 | EffectAcpSchema.LoadSessionResponse
226 | EffectAcpSchema.NewSessionResponse
227 | EffectAcpSchema.ResumeSessionResponse,
228): string | undefined {
229 const modelState = sessionSetupResult.models;
230 if (!modelState) {
231 return undefined;
232 }
233 const currentModelId = modelState.currentModelId.trim();
234 if (currentModelId.length === 0) {
235 return undefined;
236 }
237 const currentModel = modelState.availableModels.find(
238 (model) => model.modelId.trim() === currentModelId,
239 );
240 const reasoningEffort = currentModel?._meta?.reasoningEffort;
241 return typeof reasoningEffort === "string"
242 ? normalizeGrokReasoningEffort(reasoningEffort)
243 : undefined;
244}
245
246export function applyGrokAcpModelSelection<E>(input: {
247 readonly runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "setSessionModel">;
248 readonly currentModelId: string | undefined;
249 readonly currentReasoningEffort?: string | undefined;
250 readonly requestedModelId: string | undefined;
251 readonly requestedReasoningEffort?: string | undefined;
252 readonly mapError: (cause: EffectAcpErrors.AcpError) => E;
253}): Effect.Effect<string | undefined, E> {
254 // The product slug is never sent over the wire; it keeps the session's current model.
255 const requestedModelId =
256 input.requestedModelId === GROK_DEFAULT_MODEL_SLUG ? undefined : input.requestedModelId;
257 const modelChanged = requestedModelId !== undefined && requestedModelId !== input.currentModelId;
258 const reasoningProvided = input.requestedReasoningEffort !== undefined;
259 const reasoningEffort = reasoningProvided
260 ? normalizeGrokReasoningEffort(input.requestedReasoningEffort)
261 : undefined;
262 const reasoningEffortChanged =
263 reasoningProvided && reasoningEffort !== input.currentReasoningEffort;
264 const targetModelId = requestedModelId ?? input.currentModelId;
265 if ((!modelChanged && !reasoningEffortChanged) || targetModelId === undefined) {
266 return Effect.succeed(input.currentModelId);
267 }
268 const reasoningMeta =
269 reasoningProvided && reasoningEffort !== undefined ? { reasoningEffort } : undefined;
270 // When reasoning was explicitly provided but invalid (normalize => undefined), we deliberately
271 // send no meta so the invalid value is dropped rather than forwarded. When reasoning was not
272 // provided at all, we also send no meta, but we only reach this call when the model itself
273 // changed - an omitted reasoning preference must not be treated as an explicit clear of the
274 // CLI-advertised default (e.g. Extra High) on same-model reselections.
275 return input.runtime
276 .setSessionModel(targetModelId, reasoningMeta)
277 .pipe(Effect.mapError(input.mapError), Effect.as(targetModelId));
278}
packages/provider-grok/src/server/adapter.ts created+455
...@@ -0,0 +1,455 @@
1import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
2import {
3 XAiPromptFailureText,
4 isXAiTaskCompletedWakeNotification,
5 xAiRateLimitedErrorCode,
6} from "./xaiAcpExtension.ts";
7import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
8import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/nodeRuntime";
9import {
10 defaultInstanceIdForDriver,
11 ProviderDriverKind,
12 type OrchestrationV2ProviderCapabilities,
13 type RuntimeMode,
14} from "@t3tools/contracts";
15import { GrokSettings } from "../settings.ts";
16import * as Crypto from "effect/Crypto";
17import * as Effect from "effect/Effect";
18import * as FileSystem from "effect/FileSystem";
19import * as Path from "effect/Path";
20import * as Layer from "effect/Layer";
21import * as Schema from "effect/Schema";
22import type * as Scope from "effect/Scope";
23import { ChildProcessSpawner } from "effect/process";
24import * as EffectAcpErrors from "effect-acp/errors";
25
26import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
27import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging";
28import {
29 applyGrokAcpModelSelection,
30 currentGrokModelIdFromSessionSetup,
31 grokApprovalOptions,
32 makeGrokAcpRuntime,
33 resolveGrokAcpBaseModelId,
34} from "./acpSupport.ts";
35import {
36 extractGrokPlanMarkdownFromToolCallData,
37 extractXAiAcpBackgroundToolMutation,
38 extractXAiAcpSubagentEndNotice,
39 extractXAiAcpSubagentUpdate,
40 extractXAiAskUserQuestionIdentity,
41 extractXAiAskUserQuestions,
42 extractXAiBackgroundTaskCompletion,
43 extractXAiKilledBackgroundTasks,
44 extractXAiMonitorTaskId,
45 isXAiPersistentMonitor,
46 extractXAiExitPlanMarkdown,
47 registerXAiSubagentFinished,
48 makeXAiAskUserQuestionCancelledResponse,
49 makeXAiAskUserQuestionResponse,
50 makeXAiExitPlanModeCapturedResponse,
51 normalizeXAiAcpToolCallState,
52 registerXAiBackgroundTaskTracking,
53 XAiAskUserQuestionRequest,
54 XAiExitPlanModeRequest,
55} from "./xaiAcpExtension.ts";
56import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
57import { acpPermissionDisposition } from "@t3tools/provider-acp/server/clientPolicy";
58import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
59import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
60import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
61import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests";
62import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
63import {
64 ProviderAdapterDriverCreateError,
65 type ProviderAdapterDriver,
66 type ProviderAdapterDriverCreateInput,
67} from "@t3tools/provider-core/server/adapterDriver";
68import {
69 AcpProviderCapabilitiesV2,
70 makeAcpAdapterV2,
71 type AcpAdapterV2ExtensionContext,
72 type AcpAdapterV2Flavor,
73 type AcpAdapterV2RuntimeInput,
74} from "@t3tools/provider-acp/server/adapter";
75
76export const GROK_PROVIDER = ProviderDriverKind.make("grok");
77const GROK_DRIVER_KIND = GROK_PROVIDER;
78export const GROK_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(GROK_DRIVER_KIND);
79const DEFAULT_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({});
80
81export const GrokProviderCapabilitiesV2 = {
82 ...AcpProviderCapabilitiesV2,
83 sessions: {
84 ...AcpProviderCapabilitiesV2.sessions,
85 supportsModelSwitchInSession: true,
86 supportsRuntimeModeSwitchInSession: false,
87 },
88 threads: {
89 ...AcpProviderCapabilitiesV2.threads,
90 canReadThreadSnapshot: true,
91 canForkThread: false,
92 canForkFromTurn: false,
93 },
94 subagents: {
95 ...AcpProviderCapabilitiesV2.subagents,
96 supportsSubagents: true,
97 exposesSubagentThreadIds: true,
98 emitsSubagentLifecycle: true,
99 },
100 tools: {
101 ...AcpProviderCapabilitiesV2.tools,
102 supportsMcpTools: true,
103 },
104 checkpointing: {
105 ...AcpProviderCapabilitiesV2.checkpointing,
106 providerCanReadConversationSnapshot: true,
107 },
108} satisfies OrchestrationV2ProviderCapabilities;
109
110export interface GrokAdapterV2Options {
111 readonly instanceId: Parameters<typeof makeAcpAdapterV2>[0]["instanceId"];
112 readonly settings: GrokSettings;
113 readonly environment: NodeJS.ProcessEnv;
114 readonly hostPlatform: NodeJS.Platform;
115 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
116 readonly crypto: Crypto.Crypto;
117 readonly selfInvocation: SelfInvocation;
118 readonly fileSystem: FileSystem.FileSystem;
119 readonly idAllocator: IdAllocator.IdAllocatorV2["Service"];
120 readonly host: ProviderHost.ProviderHostShape;
121 readonly nativeLogging?: Parameters<typeof makeAcpAdapterV2>[0]["nativeLogging"];
122 readonly continuationRequests?: Parameters<typeof makeAcpAdapterV2>[0]["continuationRequests"];
123 readonly testHooks?: Parameters<typeof makeAcpAdapterV2>[0]["testHooks"];
124 readonly makeRuntime?: (
125 input: AcpAdapterV2RuntimeInput,
126 ) => Effect.Effect<
127 AcpSessionRuntime.AcpSessionRuntime["Service"],
128 EffectAcpErrors.AcpError,
129 Crypto.Crypto | Scope.Scope
130 >;
131 readonly assertComplete?: Effect.Effect<void, EffectAcpErrors.AcpError>;
132}
133
134const registerGrokAcpExtensions: NonNullable<AcpAdapterV2Flavor["registerExtensions"]> = ({
135 runtime,
136 requestUserInput,
137 applyBackgroundTaskMutation,
138 finishSubagent,
139 captureProposedPlan,
140 lastProposedPlanMarkdown,
141}) =>
142 registerXAiBackgroundTaskTracking(runtime, applyBackgroundTaskMutation).pipe(
143 Effect.andThen(registerXAiSubagentFinished(runtime, finishSubagent)),
144 Effect.andThen(registerGrokAskUserQuestionExtensions({ runtime, requestUserInput })),
145 Effect.andThen(
146 registerGrokExitPlanModeExtensions({
147 runtime,
148 captureProposedPlan,
149 lastProposedPlanMarkdown,
150 }),
151 ),
152 );
153
154/**
155 * Grok intercepts exit_plan_mode and reverse-requests client approval. Capture
156 * the plan into T3's proposed-plan card and abandon the native gate so the
157 * turn does not hang (#8358; mirrors the Claude ExitPlanMode pattern). Plan
158 * content preference: the request payload, then the plan.md contents sniffed
159 * from tool calls this turn, then the empty-state placeholder.
160 */
161const registerGrokExitPlanModeExtensions = ({
162 runtime,
163 captureProposedPlan,
164 lastProposedPlanMarkdown,
165}: Pick<
166 AcpAdapterV2ExtensionContext,
167 "runtime" | "captureProposedPlan" | "lastProposedPlanMarkdown"
168>) =>
169 Effect.forEach(
170 ["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"] as const,
171 (method) =>
172 runtime.handleExtRequest(method, XAiExitPlanModeRequest, (params) =>
173 Effect.gen(function* () {
174 const fallback = yield* lastProposedPlanMarkdown;
175 yield* captureProposedPlan({
176 planMarkdown: extractXAiExitPlanMarkdown(params, fallback),
177 });
178 return makeXAiExitPlanModeCapturedResponse();
179 }),
180 ),
181 { discard: true },
182 );
183
184const registerGrokAskUserQuestionExtensions = ({
185 runtime,
186 requestUserInput,
187}: Pick<AcpAdapterV2ExtensionContext, "runtime" | "requestUserInput">) =>
188 Effect.forEach(
189 ["x.ai/ask_user_question", "_x.ai/ask_user_question"] as const,
190 (method) =>
191 runtime.handleExtRequest(method, XAiAskUserQuestionRequest, (params, requestContext) => {
192 const identity = extractXAiAskUserQuestionIdentity(params);
193 const questions = extractXAiAskUserQuestions(params).map((question) => ({
194 id: question.id,
195 header: question.header,
196 question: question.question,
197 options: [...question.options],
198 }));
199 return requestUserInput(
200 {
201 nativeItemId: `${identity.sessionId}:xai-question:${identity.toolCallId}`,
202 nativeRequestId: identity.toolCallId,
203 questions,
204 },
205 requestContext,
206 ).pipe(
207 Effect.flatMap(({ acknowledgeNativeResponse, answers }) =>
208 Effect.succeed(
209 answers === null
210 ? makeXAiAskUserQuestionCancelledResponse()
211 : makeXAiAskUserQuestionResponse(params, answers),
212 ).pipe(Effect.tap(() => acknowledgeNativeResponse)),
213 ),
214 );
215 }),
216 { discard: true },
217 );
218
219/**
220 * Grok's permission mode is fixed at launch. Explicit approval or sandbox
221 * overrides launch it asking, so every mutating prompt reaches T3's policy
222 * check instead of being bypassed by always-approve or Grok's auto classifier.
223 */
224export function grokLaunchRuntimeMode(
225 runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy,
226): RuntimeMode {
227 return runtimePolicy.approvalPolicy === undefined && runtimePolicy.sandboxPolicy === undefined
228 ? runtimePolicy.runtimeMode
229 : "approval-required";
230}
231
232export function makeGrokAcpAdapterFlavor(options: GrokAdapterV2Options): AcpAdapterV2Flavor {
233 return {
234 driver: GROK_PROVIDER,
235 runtimeHarness: "Grok",
236 capabilities: GrokProviderCapabilitiesV2,
237 interruptPromptOnCancel: false,
238 // User Stop (requestRuntimeRestart) still hard-kills the process group and
239 // respawns so existing background tasks stop too. Older 0.2.x builds could
240 // detach a cancelled foreground command (E3 harness 2026-07-18); current
241 // source kills foreground work but intentionally preserves already-
242 // backgrounded tasks. Non-Stop interrupts (mid-prompt steering,
243 // restart_active) omit requestRuntimeRestart and stay soft: session/cancel
244 // carries cancelTrigger=ctrl_c, the session survives, and background work
245 // remains available to the replacement turn.
246 restartRuntimeAfterInterrupt: true,
247 terminateRuntimeProcessGroupOnInterrupt: true,
248 // Steering restarts on a settled turn additionally skip session/cancel so
249 // fire-and-forget subagents survive the steer (E1 harness confirmed the
250 // Grok CLI accepts a concurrent session/prompt in that state).
251 preserveRuntimeOnSettledInterrupt: true,
252 // Grok ACP initialize reports promptCapabilities.image:false but the agent
253 // still accepts image content blocks (verified with real screenshots).
254 supportsImagePrompts: true,
255 supportsCompaction: true,
256 resolveModelId: (selection) => resolveGrokAcpBaseModelId(selection.model),
257 applyModelSelection: ({ runtime, startResult, modelSelection }) =>
258 Effect.gen(function* () {
259 const legacy = startResult.initializeResult.protocolVersion === 1;
260 const options = legacy ? [] : yield* runtime.getConfigOptions;
261 const configuredModel = options.find((option) => option.category === "model")?.currentValue;
262 return yield* applyGrokAcpModelSelection({
263 runtime: legacy
264 ? runtime
265 : { setSessionModel: (model) => runtime.setModel(model).pipe(Effect.as({})) },
266 currentModelId: legacy
267 ? currentGrokModelIdFromSessionSetup(startResult.sessionSetupResult)
268 : typeof configuredModel === "string"
269 ? configuredModel
270 : undefined,
271 requestedModelId: resolveGrokAcpBaseModelId(modelSelection.model),
272 mapError: (cause) => cause,
273 });
274 }),
275 makeRuntime:
276 options.makeRuntime ??
277 (({ runtimePolicy, ...input }) =>
278 makeGrokAcpRuntime({
279 ...input,
280 interruptPromptOnCancel: input.interruptPromptOnCancel ?? false,
281 grokSettings: options.settings,
282 environment: options.environment,
283 childProcessSpawner: options.childProcessSpawner,
284 runtimeMode: grokLaunchRuntimeMode(runtimePolicy),
285 })),
286 // In its Auto mode Grok decides routine actions itself and only asks about
287 // what its classifier blocked, so every prompt it sends goes to the user.
288 permissionDisposition: (policy, request) =>
289 grokLaunchRuntimeMode(policy) === "auto" ? "ask" : acpPermissionDisposition(policy, request),
290 approvalOptions: grokApprovalOptions,
291 promptFailure: (cause) =>
292 makeProviderFailure({
293 cause,
294 ...(Schema.is(EffectAcpErrors.AcpRequestError)(cause)
295 ? {
296 // Grok's own failure text rides on the cause; makeProviderFailure
297 // redacts and bounds it before it reaches the user.
298 message:
299 cause.cause instanceof XAiPromptFailureText
300 ? cause.cause.message
301 : cause.errorMessage,
302 code: String(cause.code),
303 class: cause.code === xAiRateLimitedErrorCode ? "usage_limit" : "provider_error",
304 }
305 : { class: "provider_error" }),
306 }),
307 registerExtensions: registerGrokAcpExtensions,
308 extractSubagentUpdate: extractXAiAcpSubagentUpdate,
309 extractSubagentEndNotice: extractXAiAcpSubagentEndNotice,
310 normalizeToolCall: normalizeXAiAcpToolCallState,
311 // Show the plan while Grok is still writing it: plan.md writes under the
312 // Grok session dir surface as the proposed-plan card before exit (#8358).
313 extractProposedPlanMarkdown: (toolCall) =>
314 extractGrokPlanMarkdownFromToolCallData(toolCall.data, {
315 platform: options.hostPlatform,
316 environment: options.environment,
317 }),
318 extractBackgroundTaskId: extractXAiMonitorTaskId,
319 extractBackgroundToolMutation: extractXAiAcpBackgroundToolMutation,
320 extractBackgroundTaskCompletion: (toolCall) => [
321 ...extractXAiBackgroundTaskCompletion(toolCall),
322 ...extractXAiKilledBackgroundTasks(toolCall),
323 ],
324 isPersistentBackgroundTool: isXAiPersistentMonitor,
325 isProviderWakeNotification: isXAiTaskCompletedWakeNotification,
326 deferFinalizeForBackgroundWork: true,
327 enablePostSettleContinuation: true,
328 ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }),
329 };
330}
331
332export function makeGrokAdapterV2(options: GrokAdapterV2Options) {
333 const flavor = makeGrokAcpAdapterFlavor(options);
334 return makeAcpAdapterV2({
335 instanceId: options.instanceId,
336 flavor,
337 crypto: options.crypto,
338 fileSystem: options.fileSystem,
339 idAllocator: options.idAllocator,
340 host: options.host,
341 selfInvocation: options.selfInvocation,
342 ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }),
343 ...(options.continuationRequests === undefined
344 ? {}
345 : { continuationRequests: options.continuationRequests }),
346 ...(options.testHooks === undefined ? {} : { testHooks: options.testHooks }),
347 });
348}
349
350export type GrokAdapterV2DriverEnv =
351 | ChildProcessSpawner.ChildProcessSpawner
352 | Crypto.Crypto
353 | FileSystem.FileSystem
354 | IdAllocator.IdAllocatorV2
355 | Path.Path
356 | ProviderEventLoggers.ProviderEventLoggers
357 | ProviderHost.ProviderHost;
358
359export const GrokAdapterV2Driver: ProviderAdapterDriver<GrokSettings, GrokAdapterV2DriverEnv> = {
360 driverKind: GROK_DRIVER_KIND,
361 configSchema: GrokSettings,
362 defaultConfig: (): GrokSettings => DEFAULT_GROK_SETTINGS,
363 create: Effect.fn("GrokAdapterV2Driver.create")(
364 function* (input: ProviderAdapterDriverCreateInput<GrokSettings>) {
365 const hostEnvironment = yield* HostProcessEnvironment;
366 const hostPlatform = yield* HostProcessPlatform;
367 const selfInvocation = yield* resolveSelfInvocation();
368 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
369 const crypto = yield* Crypto.Crypto;
370 const fileSystem = yield* FileSystem.FileSystem;
371 const idAllocator = yield* IdAllocator.IdAllocatorV2;
372 const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers;
373 const host = yield* ProviderHost.ProviderHost;
374 const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
375 const makeNativeLogger = yield* makeAcpNativeLoggerFactory();
376 return makeGrokAdapterV2({
377 instanceId: input.instanceId,
378 settings: { ...input.config, enabled: input.enabled },
379 environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment),
380 hostPlatform,
381 childProcessSpawner,
382 crypto,
383 fileSystem,
384 idAllocator,
385 host,
386 selfInvocation,
387 continuationRequests,
388 nativeLogging: (threadId) =>
389 makeNativeLogger({
390 nativeEventLogger: providerEventLoggers.native,
391 provider: GROK_PROVIDER,
392 threadId,
393 }),
394 });
395 },
396 (effect, input) =>
397 effect.pipe(
398 Effect.mapError(
399 (cause) =>
400 new ProviderAdapterDriverCreateError({
401 driver: GROK_DRIVER_KIND,
402 instanceId: input.instanceId,
403 detail: "Failed to create Grok ACP adapter.",
404 cause,
405 }),
406 ),
407 ),
408 ),
409};
410
411const layer: Layer.Layer<
412 ProviderAdapter.ProviderAdapterV2,
413 never,
414 | Path.Path
415 | ChildProcessSpawner.ChildProcessSpawner
416 | Crypto.Crypto
417 | FileSystem.FileSystem
418 | IdAllocator.IdAllocatorV2
419 | ProviderEventLoggers.ProviderEventLoggers
420 | ProviderHost.ProviderHost
421> = Layer.effect(
422 ProviderAdapter.ProviderAdapterV2,
423 Effect.gen(function* () {
424 const hostEnvironment = yield* HostProcessEnvironment;
425 const hostPlatform = yield* HostProcessPlatform;
426 const selfInvocation = yield* resolveSelfInvocation();
427 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
428 const crypto = yield* Crypto.Crypto;
429 const fileSystem = yield* FileSystem.FileSystem;
430 const idAllocator = yield* IdAllocator.IdAllocatorV2;
431 const providerEventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers;
432 const host = yield* ProviderHost.ProviderHost;
433 const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
434 const makeNativeLogger = yield* makeAcpNativeLoggerFactory();
435 return makeGrokAdapterV2({
436 instanceId: GROK_DEFAULT_INSTANCE_ID,
437 settings: DEFAULT_GROK_SETTINGS,
438 environment: hostEnvironment,
439 hostPlatform,
440 childProcessSpawner,
441 crypto,
442 fileSystem,
443 idAllocator,
444 host,
445 selfInvocation,
446 continuationRequests,
447 nativeLogging: (threadId) =>
448 makeNativeLogger({
449 nativeEventLogger: providerEventLoggers.native,
450 provider: GROK_PROVIDER,
451 threadId,
452 }),
453 });
454 }),
455);
packages/provider-grok/src/server/driver.test.ts created+95
...@@ -0,0 +1,95 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { ProviderInstanceId } from "@t3tools/contracts";
4import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Path from "effect/Path";
9import { HttpClient } from "effect/http";
10import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
11
12import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
13import { layerTestProviderHost } from "@t3tools/provider-testing/host";
14import { GrokDriver } from "./driver.ts";
15
16import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
17
18const layerTest = layerTestProviderHost({ runBackgroundWork: false }).pipe(
19 Layer.provideMerge(NodeServices.layer),
20 Layer.provideMerge(IdAllocator.layer),
21 Layer.provideMerge(
22 Layer.succeed(
23 ProviderEventLoggers.ProviderEventLoggers,
24 ProviderEventLoggers.NoOpProviderEventLoggers,
25 ),
26 ),
27 Layer.provideMerge(
28 Layer.succeed(
29 HttpClient.HttpClient,
30 HttpClient.make(() => Effect.die("Disabled Grok must not make an HTTP request")),
31 ),
32 ),
33);
34
35const noSpawner = ChildProcessSpawner.make(() =>
36 Effect.die("Disabled Grok must not spawn a process"),
37);
38
39// The `#!/bin/sh` stub below cannot be resolved as an executable on Windows.
40const windowsHost = HostProcessPlatform.defaultValue() === "win32";
41
42it.layer(layerTest)("GrokDriver", (it) => {
43 it.effect.skipIf(windowsHost)("updates through the configured executable's own updater", () =>
44 Effect.gen(function* () {
45 const fs = yield* FileSystem.FileSystem;
46 const path = yield* Path.Path;
47 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-grok-driver-" });
48 const grokHome = path.join(tempDir, "Grok Home");
49 const binaryPath = path.join(grokHome, "bin", "grok");
50 yield* fs.makeDirectory(path.dirname(binaryPath), { recursive: true });
51 yield* fs.writeFileString(binaryPath, "#!/bin/sh\n");
52 yield* fs.chmod(binaryPath, 0o755);
53
54 const instance = yield* GrokDriver.create({
55 instanceId: ProviderInstanceId.make("grok-update"),
56 displayName: "Grok test",
57 enabled: false,
58 environment: [{ name: "GROK_HOME", value: grokHome, sensitive: false }],
59 config: { ...GrokDriver.defaultConfig(), binaryPath },
60 });
61
62 const capabilities = yield* instance.snapshot.resolveMaintenance();
63 expect(capabilities.packageName).toBe("@xai-official/grok");
64 expect(capabilities.update).toMatchObject({
65 command: `'${binaryPath}' update`,
66 executable: binaryPath,
67 args: ["update"],
68 });
69 // `grok update` installs under GROK_HOME, so it must target this instance's home.
70 expect(capabilities.update?.env?.GROK_HOME).toBe(grokHome);
71 }).pipe(
72 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawner),
73 Effect.scoped,
74 ),
75 );
76
77 it.effect("stays manual-only when the configured executable does not exist", () =>
78 Effect.gen(function* () {
79 const fs = yield* FileSystem.FileSystem;
80 const path = yield* Path.Path;
81 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-grok-missing-" });
82 const instance = yield* GrokDriver.create({
83 instanceId: ProviderInstanceId.make("grok-missing"),
84 displayName: "Grok test",
85 enabled: false,
86 environment: [],
87 config: { ...GrokDriver.defaultConfig(), binaryPath: path.join(tempDir, "grok") },
88 });
89 expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull();
90 }).pipe(
91 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawner),
92 Effect.scoped,
93 ),
94 );
95});
packages/provider-grok/src/server/driver.ts created+227
...@@ -0,0 +1,227 @@
1import { ProviderDriverKind } from "@t3tools/contracts";
2import { GrokSettings } from "../settings.ts";
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 { HttpClient } from "effect/http";
9import { ChildProcessSpawner } from "effect/process";
10
11import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
12import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
13import { makeGrokTextGeneration } from "./textGeneration.ts";
14import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./adapter.ts";
15import { ProviderDriverError } from "@t3tools/provider-core/server/errors";
16import {
17 buildInitialGrokProviderSnapshot,
18 checkGrokProviderStatus,
19 enrichGrokSnapshot,
20} from "./status.ts";
21import { readGrokAccount } from "./usageLimits.ts";
22import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";
23import {
24 defaultProviderContinuationIdentity,
25 type ProviderDriver,
26 type ProviderInstance,
27} from "@t3tools/provider-core/server/driver";
28import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity";
29import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
30import { discoverGrokSkills } from "./skills.ts";
31import {
32 makeCachedProviderMaintenanceResolution,
33 makeManualOnlyProviderMaintenanceCapabilities,
34 makeProviderMaintenanceCapabilities,
35 type ProviderMaintenanceCapabilitiesResolver,
36 resolveProviderMaintenanceCapabilitiesEffect,
37} from "@t3tools/provider-core/server/maintenanceResolver";
38import {
39 haveProviderSnapshotSettingsChanged,
40 makeProviderSnapshotSettingsSource,
41 type ProviderSnapshotSettings,
42} from "@t3tools/provider-core/server/snapshotSettings";
43const decodeGrokSettings = Schema.decodeSync(GrokSettings);
44
45const DRIVER_KIND = ProviderDriverKind.make("grok");
46// npm's `latest` tracks Grok's stable channel, the one `grok update` installs
47// by default, so the registry stays the source for "latest".
48const GROK_NPM_PACKAGE = "@xai-official/grok";
49// `grok update` finds the installer that owns the binary itself, so the
50// resolved executable is its own updater. It installs under `GROK_HOME`, so it
51// runs with the instance's environment. No executable means nothing to update,
52// not "whatever is on PATH".
53const UPDATE: ProviderMaintenanceCapabilitiesResolver = {
54 resolve: (context) =>
55 Effect.succeed(
56 context
57 ? makeProviderMaintenanceCapabilities({
58 provider: DRIVER_KIND,
59 packageName: GROK_NPM_PACKAGE,
60 updateExecutable: context.resolvedCommandPath,
61 updateArgs: ["update"],
62 updateLockKey: "grok",
63 platform: context.platform,
64 env: context.env,
65 })
66 : makeManualOnlyProviderMaintenanceCapabilities({
67 provider: DRIVER_KIND,
68 packageName: GROK_NPM_PACKAGE,
69 }),
70 ),
71};
72
73export type GrokDriverEnv =
74 | GrokAdapterV2DriverEnv
75 | ProviderHost.ProviderHost
76 | ChildProcessSpawner.ChildProcessSpawner
77 | Crypto.Crypto
78 | FileSystem.FileSystem
79 | HttpClient.HttpClient
80 | Path.Path
81 | ProviderEventLoggers.ProviderEventLoggers;
82
83export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
84 driverKind: DRIVER_KIND,
85 metadata: {
86 displayName: "Grok",
87 supportsMultipleInstances: true,
88 },
89 configSchema: GrokSettings,
90 defaultConfig: (): GrokSettings => decodeGrokSettings({}),
91 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
92 Effect.gen(function* () {
93 const crypto = yield* Crypto.Crypto;
94 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
95 const httpClient = yield* HttpClient.HttpClient;
96 const fileSystem = yield* FileSystem.FileSystem;
97 const path = yield* Path.Path;
98 const host = yield* ProviderHost.ProviderHost;
99 const { cwd } = host.paths;
100 const processEnv = mergeProviderInstanceEnvironment(environment);
101 const continuationIdentity = defaultProviderContinuationIdentity({
102 driverKind: DRIVER_KIND,
103 instanceId,
104 });
105 const stampIdentity = withInstanceIdentity({
106 instanceId,
107 driverKind: DRIVER_KIND,
108 displayName,
109 accentColor,
110 continuationGroupKey: continuationIdentity.continuationKey,
111 });
112 const effectiveConfig = { ...config, enabled } satisfies GrokSettings;
113 const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(
114 resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
115 binaryPath: effectiveConfig.binaryPath,
116 env: processEnv,
117 }).pipe(
118 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
119 Effect.provideService(FileSystem.FileSystem, fileSystem),
120 Effect.provideService(Path.Path, path),
121 ),
122 );
123 const orchestrationAdapter = yield* GrokAdapterV2Driver.create({
124 instanceId,
125 displayName,
126 accentColor,
127 environment,
128 enabled,
129 config,
130 }).pipe(
131 Effect.mapError(
132 (cause) =>
133 new ProviderDriverError({
134 driver: DRIVER_KIND,
135 instanceId,
136 detail: "Failed to build Grok orchestration adapter.",
137 cause,
138 }),
139 ),
140 );
141 const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv);
142
143 const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe(
144 Effect.flatMap((snapshot) =>
145 effectiveConfig.enabled && snapshot.installed && snapshot.auth.status === "authenticated"
146 ? readGrokAccount(processEnv).pipe(
147 Effect.map(({ email, usageLimits }) => ({
148 ...snapshot,
149 auth: email ? { ...snapshot.auth, email } : snapshot.auth,
150 usageLimits,
151 })),
152 )
153 : Effect.succeed(snapshot),
154 ),
155 Effect.map(stampIdentity),
156 Effect.provideService(HttpClient.HttpClient, httpClient),
157 Effect.provideService(FileSystem.FileSystem, fileSystem),
158 Effect.provideService(Path.Path, path),
159 Effect.provideService(Crypto.Crypto, crypto),
160 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
161 );
162
163 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings);
164 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<GrokSettings>>({
165 resolveMaintenance,
166 getSettings: snapshotSettings.getSettings,
167 streamSettings: snapshotSettings.streamSettings,
168 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
169 initialSnapshot: (settings) =>
170 buildInitialGrokProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)),
171 checkProvider,
172 enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>
173 resolveMaintenance().pipe(
174 Effect.flatMap((maintenanceCapabilities) =>
175 enrichGrokSnapshot({
176 snapshot: currentSnapshot,
177 maintenanceCapabilities,
178 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
179 publishSnapshot,
180 httpClient,
181 }),
182 ),
183 ),
184 }).pipe(
185 Effect.mapError(
186 (cause) =>
187 new ProviderDriverError({
188 driver: DRIVER_KIND,
189 instanceId,
190 detail: `Failed to build Grok snapshot: ${cause.message ?? String(cause)}`,
191 cause,
192 }),
193 ),
194 );
195 const snapshotForCwd = (workspaceCwd: string) =>
196 !effectiveConfig.enabled
197 ? snapshot.getSnapshot
198 : Effect.all([
199 snapshot.getSnapshot,
200 discoverGrokSkills(effectiveConfig, processEnv, workspaceCwd).pipe(
201 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
202 Effect.mapError(
203 (cause) =>
204 new ProviderDriverError({
205 driver: DRIVER_KIND,
206 instanceId,
207 detail: `Failed to discover Grok skills for '${workspaceCwd}'`,
208 cause,
209 }),
210 ),
211 ),
212 ]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })));
213
214 return {
215 instanceId,
216 driverKind: DRIVER_KIND,
217 continuationIdentity,
218 displayName,
219 accentColor,
220 enabled,
221 snapshot,
222 snapshotForCwd,
223 orchestrationAdapter,
224 textGeneration,
225 } satisfies ProviderInstance;
226 }),
227};
packages/provider-grok/src/server/skills.test.ts created+174
...@@ -0,0 +1,174 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Sink from "effect/Sink";
4import * as Stream from "effect/Stream";
5import { ChildProcessSpawner } from "effect/process";
6
7import { discoverGrokSkills } from "./skills.ts";
8
9const inspectPayload = (skills: ReadonlyArray<unknown>) => JSON.stringify({ skills });
10
11const makeInspectSpawner = (stdout: string, exitCode = 0, spawnCwds?: Array<string | undefined>) =>
12 ChildProcessSpawner.make((command) => {
13 spawnCwds?.push(command._tag === "StandardCommand" ? command.options.cwd : undefined);
14 return Effect.succeed(
15 ChildProcessSpawner.makeHandle({
16 pid: ChildProcessSpawner.ProcessId(1),
17 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),
18 isRunning: Effect.succeed(false),
19 kill: () => Effect.void,
20 unref: Effect.succeed(Effect.void),
21 stdin: Sink.drain,
22 stdout: Stream.encodeText(Stream.make(stdout)),
23 stderr: Stream.empty,
24 all: Stream.empty,
25 getInputFd: () => Sink.drain,
26 getOutputFd: () => Stream.empty,
27 }),
28 );
29 });
30
31describe("discoverGrokSkills", () => {
32 it.effect("maps inspect entries onto provider skills, sorted by name", () =>
33 Effect.gen(function* () {
34 const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {});
35
36 expect(skills).toEqual([
37 {
38 name: "deploy",
39 description: "Deploy the app.",
40 path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md",
41 scope: "plugin",
42 enabled: true,
43 },
44 {
45 name: "writing-docs",
46 description: "Write user docs.",
47 path: "/home/dev/.grok/skills/writing-docs/SKILL.md",
48 scope: "user",
49 enabled: true,
50 },
51 ]);
52 }).pipe(
53 Effect.provideService(
54 ChildProcessSpawner.ChildProcessSpawner,
55 makeInspectSpawner(
56 inspectPayload([
57 {
58 name: "writing-docs",
59 description: "Write user docs.",
60 source: { type: "user", path: "/home/dev/.grok/skills/writing-docs/SKILL.md" },
61 userInvocable: true,
62 },
63 {
64 name: "deploy",
65 description: "Deploy the app.",
66 source: {
67 type: "plugin",
68 path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md",
69 },
70 userInvocable: true,
71 },
72 ]),
73 ),
74 ),
75 ),
76 );
77
78 it.effect("disables skills the CLI marks as not user-invocable", () =>
79 Effect.gen(function* () {
80 const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {});
81
82 expect(skills).toEqual([
83 {
84 name: "internal-helper",
85 path: "/opt/grok/bundled/skills/internal-helper/SKILL.md",
86 scope: "bundled",
87 enabled: false,
88 },
89 ]);
90 }).pipe(
91 Effect.provideService(
92 ChildProcessSpawner.ChildProcessSpawner,
93 makeInspectSpawner(
94 inspectPayload([
95 {
96 name: "internal-helper",
97 source: {
98 type: "bundled",
99 path: "/opt/grok/bundled/skills/internal-helper/SKILL.md",
100 },
101 userInvocable: false,
102 },
103 ]),
104 ),
105 ),
106 ),
107 );
108
109 it.effect("skips entries without a name or a filesystem path", () =>
110 Effect.gen(function* () {
111 const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {});
112 expect(skills.map((skill) => skill.name)).toEqual(["kept"]);
113 }).pipe(
114 Effect.provideService(
115 ChildProcessSpawner.ChildProcessSpawner,
116 makeInspectSpawner(
117 inspectPayload([
118 { name: " ", source: { type: "user", path: "/tmp/skills/a/SKILL.md" } },
119 { name: "no-path", source: { type: "user" } },
120 { name: "no-source" },
121 "not-an-object",
122 { name: "kept", source: { type: "project", path: "/repo/.grok/skills/kept/SKILL.md" } },
123 ]),
124 ),
125 ),
126 ),
127 );
128
129 it.effect("rejects malformed or unexpected output as a decode failure", () =>
130 Effect.gen(function* () {
131 for (const stdout of ["not json", "null", '{"skills":"nope"}', "{}"]) {
132 const error = yield* discoverGrokSkills({ binaryPath: "grok" }, {}).pipe(
133 Effect.flip,
134 Effect.provideService(
135 ChildProcessSpawner.ChildProcessSpawner,
136 makeInspectSpawner(stdout),
137 ),
138 );
139 expect(error).toMatchObject({ _tag: "GrokSkillsProbeError", stage: "decode" });
140 }
141 }),
142 );
143
144 it.effect("spawns in the configured cwd and rejects a failed probe", () => {
145 const spawnCwds: Array<string | undefined> = [];
146 const stdout = inspectPayload([
147 {
148 name: "kept",
149 source: { type: "project", path: "/workspaces/demo/.grok/skills/kept/SKILL.md" },
150 },
151 ]);
152
153 return Effect.gen(function* () {
154 const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}, "/workspaces/demo").pipe(
155 Effect.provideService(
156 ChildProcessSpawner.ChildProcessSpawner,
157 makeInspectSpawner(stdout, 0, spawnCwds),
158 ),
159 );
160
161 expect(spawnCwds).toEqual(["/workspaces/demo"]);
162 expect(skills.map((skill) => skill.name)).toEqual(["kept"]);
163
164 const failed = yield* discoverGrokSkills({ binaryPath: "grok" }).pipe(
165 Effect.result,
166 Effect.provideService(
167 ChildProcessSpawner.ChildProcessSpawner,
168 makeInspectSpawner(stdout, 1),
169 ),
170 );
171 expect(failed._tag).toBe("Failure");
172 });
173 });
174});
packages/provider-grok/src/server/skills.ts created+152
...@@ -0,0 +1,152 @@
1/**
2 * GrokSkills — skill discovery for the `$` picker via `grok inspect --json`.
3 *
4 * Unlike Claude Code, the Grok CLI reports its full skill catalog itself:
5 * `grok inspect --json` returns `skills[]` with `name`, `description`,
6 * `source.type` (`user` / `project` / `bundled` / `plugin`), `source.path`
7 * (the absolute `SKILL.md` path), and `userInvocable`. Asking the CLI beats
8 * scanning the filesystem because the catalog honors Grok's own skill config
9 * (ignore lists, disabled skills) and includes plugin skills, which live
10 * three levels deep under `~/.grok/installed-plugins/` where a flat scan
11 * cannot see them. This mirrors how the Codex app-server reports skills over
12 * `skills/list`. Probe failures stay typed so workspace snapshots do not
13 * cache an empty catalog; machine-level discovery recovers them to an empty
14 * list without degrading the provider.
15 *
16 * @module provider/Drivers/GrokSkills
17 */
18import type { ServerProviderSkill } from "@t3tools/contracts";
19import type { GrokSettings } from "../settings.ts";
20import * as Effect from "effect/Effect";
21import * as Option from "effect/Option";
22import * as Schema from "effect/Schema";
23import { ChildProcess } from "effect/process";
24import { resolveSpawnCommand } from "@t3tools/shared/shell";
25
26import { spawnAndCollect } from "@t3tools/provider-core/server/snapshotProbe";
27
28const GROK_SKILLS_PROBE_TIMEOUT_MS = 4_000;
29
30class GrokSkillsProbeError extends Schema.TaggedError<GrokSkillsProbeError>()(
31 "GrokSkillsProbeError",
32 {
33 stage: Schema.Literals(["spawn", "timeout", "exit", "decode"]),
34 cwd: Schema.optional(Schema.String),
35 exitCode: Schema.optional(Schema.Number),
36 cause: Schema.optional(Schema.Defect()),
37 },
38) {
39 override get message(): string {
40 const location = this.cwd === undefined ? "" : ` for '${this.cwd}'`;
41 const exitCode = this.exitCode === undefined ? "" : ` with exit code ${this.exitCode}`;
42 return `\`grok inspect --json\` failed during ${this.stage}${location}${exitCode}.`;
43 }
44}
45
46/**
47 * Map `grok inspect --json` output onto provider skills. Entries without a
48 * name or a filesystem path are skipped; `userInvocable: false` skills are
49 * kept but disabled so pickers that filter on `enabled` hide them.
50 */
51function decodeGrokInspectSkills(stdout: string): ReadonlyArray<ServerProviderSkill> | undefined {
52 let parsed: unknown;
53 try {
54 parsed = JSON.parse(stdout);
55 } catch {
56 return undefined;
57 }
58 if (typeof parsed !== "object" || parsed === null) {
59 return undefined;
60 }
61 const entries = (parsed as Record<string, unknown>).skills;
62 if (!Array.isArray(entries)) {
63 return undefined;
64 }
65
66 const skillsByName = new Map<string, ServerProviderSkill>();
67 for (const entry of entries) {
68 if (typeof entry !== "object" || entry === null) {
69 continue;
70 }
71 const record = entry as Record<string, unknown>;
72 const name = typeof record.name === "string" ? record.name.trim() : "";
73 const source =
74 typeof record.source === "object" && record.source !== null
75 ? (record.source as Record<string, unknown>)
76 : undefined;
77 const path = typeof source?.path === "string" ? source.path.trim() : "";
78 if (!name || !path) {
79 continue;
80 }
81 const scope = typeof source?.type === "string" ? source.type.trim() : "";
82 const description = typeof record.description === "string" ? record.description.trim() : "";
83 skillsByName.set(name, {
84 name,
85 path,
86 enabled: record.userInvocable !== false,
87 ...(scope ? { scope } : {}),
88 ...(description ? { description } : {}),
89 });
90 }
91
92 return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name));
93}
94
95/**
96 * Run `grok inspect --json` and map the reported catalog onto provider
97 * skills. Callers that need best-effort discovery can recover this effect to
98 * an empty list; workspace callers leave failures typed so they are not cached.
99 */
100export const discoverGrokSkills = Effect.fn("discoverGrokSkills")(function* (
101 grokSettings: Pick<GrokSettings, "binaryPath">,
102 environment: NodeJS.ProcessEnv = process.env,
103 cwd?: string,
104) {
105 const command = grokSettings.binaryPath || "grok";
106 const inspectResult = yield* Effect.gen(function* () {
107 const spawnCommand = yield* resolveSpawnCommand(command, ["inspect", "--json"], {
108 env: environment,
109 });
110 return yield* spawnAndCollect(
111 command,
112 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
113 ...(cwd ? { cwd } : {}),
114 env: environment,
115 shell: spawnCommand.shell,
116 }),
117 );
118 }).pipe(
119 Effect.mapError(
120 (cause) =>
121 new GrokSkillsProbeError({
122 stage: "spawn",
123 ...(cwd ? { cwd } : {}),
124 cause,
125 }),
126 ),
127 Effect.timeoutOption(GROK_SKILLS_PROBE_TIMEOUT_MS),
128 );
129
130 if (Option.isNone(inspectResult)) {
131 return yield* new GrokSkillsProbeError({
132 stage: "timeout",
133 ...(cwd ? { cwd } : {}),
134 });
135 }
136 const output = inspectResult.value;
137 if (output.code !== 0) {
138 return yield* new GrokSkillsProbeError({
139 stage: "exit",
140 ...(cwd ? { cwd } : {}),
141 exitCode: output.code,
142 });
143 }
144 const skills = decodeGrokInspectSkills(output.stdout);
145 if (!skills) {
146 return yield* new GrokSkillsProbeError({
147 stage: "decode",
148 ...(cwd ? { cwd } : {}),
149 });
150 }
151 return skills;
152});
packages/provider-grok/src/server/status.ts created+586
...@@ -0,0 +1,586 @@
1import {
2 type CustomModelSetting,
3 type ModelCapabilities,
4 type ServerProvider,
5 type ServerProviderAuth,
6 type ServerProviderModel,
7 type ServerProviderSlashCommand,
8} from "@t3tools/contracts";
9import type { GrokSettings } from "../settings.ts";
10import type * as EffectAcpSchema from "effect-acp/compat";
11import { causeErrorTag } from "@t3tools/shared/observability";
12import * as Crypto from "effect/Crypto";
13import * as DateTime from "effect/DateTime";
14import * as Effect from "effect/Effect";
15import * as Exit from "effect/Exit";
16import * as Option from "effect/Option";
17import * as Result from "effect/Result";
18import * as Schema from "effect/Schema";
19import { HttpClient } from "effect/http";
20import { ChildProcess, ChildProcessSpawner } from "effect/process";
21import { createModelCapabilities } from "@t3tools/shared/model";
22import { resolveSpawnCommand } from "@t3tools/shared/shell";
23
24import {
25 AUTH_PROBE_TIMEOUT_MS,
26 buildServerProvider,
27 COMPACT_SLASH_COMMAND,
28 isCommandMissingCause,
29 parseGenericCliVersion,
30 providerModelsFromSettings,
31 spawnAndCollect,
32 type ServerProviderDraft,
33} from "@t3tools/provider-core/server/snapshotProbe";
34import {
35 enrichProviderSnapshotWithVersionAdvisory,
36 type ProviderMaintenanceCapabilities,
37} from "@t3tools/provider-core/server/maintenanceResolver";
38import {
39 GROK_DEFAULT_MODEL_SLUG,
40 GROK_SUPPORTED_RUNTIME_MODES,
41 isValidGrokReasoningEffortToken,
42 makeGrokAcpRuntime,
43 resolveGrokAcpBaseModelId,
44} from "./acpSupport.ts";
45import { sessionModelStateFromInitialize } from "@t3tools/provider-acp/server/runtimeModel";
46import { discoverGrokSkills } from "./skills.ts";
47
48const GROK_PRESENTATION = {
49 displayName: "Grok",
50 supportsConversationRollback: false,
51 showInteractionModeToggle: false,
52 supportedRuntimeModes: GROK_SUPPORTED_RUNTIME_MODES,
53} as const;
54const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
55 optionDescriptors: [],
56});
57
58const VERSION_PROBE_TIMEOUT_MS = 4_000;
59// `initialize` is a single local round trip, so this is generous even on slow machines.
60const GROK_ACP_INITIALIZE_TIMEOUT_MS = 8_000;
61const GROK_API_KEY_ENV = "XAI_API_KEY";
62
63const GROK_BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
64 {
65 slug: GROK_DEFAULT_MODEL_SLUG,
66 name: "Grok Build",
67 isCustom: false,
68 capabilities: EMPTY_CAPABILITIES,
69 },
70];
71
72export function buildInitialGrokProviderSnapshot(
73 grokSettings: GrokSettings,
74): Effect.Effect<ServerProviderDraft> {
75 return Effect.gen(function* () {
76 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
77 const models = grokModelsFromSettings(grokSettings.customModels);
78
79 if (!grokSettings.enabled) {
80 return buildServerProvider({
81 presentation: GROK_PRESENTATION,
82 enabled: false,
83 checkedAt,
84 models,
85 probe: {
86 installed: false,
87 version: null,
88 status: "warning",
89 auth: { status: "unknown" },
90 message: "Grok is disabled in T3 Code settings.",
91 },
92 });
93 }
94
95 return buildServerProvider({
96 presentation: GROK_PRESENTATION,
97 enabled: true,
98 checkedAt,
99 models,
100 probe: {
101 installed: true,
102 version: null,
103 status: "warning",
104 auth: { status: "unknown" },
105 message: "Checking Grok CLI availability...",
106 },
107 });
108 });
109}
110
111function grokModelsFromSettings(
112 customModels: ReadonlyArray<CustomModelSetting> | undefined,
113 builtInModels: ReadonlyArray<ServerProviderModel> = GROK_BUILT_IN_MODELS,
114): ReadonlyArray<ServerProviderModel> {
115 return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES);
116}
117
118function nonEmptyString(value: unknown): string | undefined {
119 return typeof value === "string" ? value.trim() || undefined : undefined;
120}
121
122function isRecord(value: unknown): value is Record<string, unknown> {
123 return typeof value === "object" && value !== null && !Array.isArray(value);
124}
125
126function grokReasoningOptionsFromModel(model: EffectAcpSchema.ModelInfo): {
127 readonly options: ReadonlyArray<{
128 value: string;
129 label: string;
130 description?: string;
131 isDefault?: boolean;
132 }>;
133 readonly currentValue: string | undefined;
134} {
135 const meta = model._meta;
136 if (!meta || meta.supportsReasoningEffort === false) {
137 return { options: [], currentValue: undefined };
138 }
139
140 const currentEffort = nonEmptyString(meta.reasoningEffort);
141 const advertisedOptions = Array.isArray(meta.reasoningEfforts) ? meta.reasoningEfforts : [];
142 const seen = new Set<string>();
143 const options: Array<{
144 value: string;
145 label: string;
146 description?: string;
147 advertisedDefault: boolean;
148 }> = [];
149
150 for (const entry of advertisedOptions) {
151 if (!isRecord(entry)) {
152 continue;
153 }
154 const rawValue = nonEmptyString(entry.value);
155 const rawId = nonEmptyString(entry.id);
156 const value =
157 rawValue && isValidGrokReasoningEffortToken(rawValue)
158 ? rawValue
159 : rawId && isValidGrokReasoningEffortToken(rawId)
160 ? rawId
161 : undefined;
162 if (value === undefined || seen.has(value)) {
163 continue;
164 }
165 seen.add(value);
166 const description = nonEmptyString(entry.description);
167 options.push({
168 value,
169 label: nonEmptyString(entry.label) ?? value,
170 ...(description ? { description } : {}),
171 advertisedDefault: entry.default === true || entry.isDefault === true,
172 });
173 }
174
175 const currentValue =
176 currentEffort && options.some((option) => option.value === currentEffort)
177 ? currentEffort
178 : undefined;
179 const advertisedDefaults = options.filter((option) => option.advertisedDefault);
180 const selectedDefault =
181 advertisedDefaults.find((option) => option.value === currentValue)?.value ??
182 advertisedDefaults[0]?.value;
183 return {
184 options: options.map(({ value, label, description }) => ({
185 value,
186 label,
187 ...(description ? { description } : {}),
188 ...(value === selectedDefault ? { isDefault: true } : {}),
189 })),
190 currentValue: currentValue ?? selectedDefault,
191 };
192}
193
194export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): ModelCapabilities {
195 const reasoning = grokReasoningOptionsFromModel(model);
196 return reasoning.options.length > 0
197 ? createModelCapabilities({
198 optionDescriptors: [
199 {
200 id: "reasoningEffort",
201 label: "Reasoning",
202 type: "select",
203 options: reasoning.options.map((option) => ({
204 id: option.value,
205 label: option.label,
206 ...(option.description ? { description: option.description } : {}),
207 ...(option.isDefault ? { isDefault: true } : {}),
208 })),
209 ...(reasoning.currentValue ? { currentValue: reasoning.currentValue } : {}),
210 },
211 ],
212 })
213 : EMPTY_CAPABILITIES;
214}
215
216/** Models advertised by the ACP agent, with the session's current model marked as default. */
217export function buildGrokModelsFromSessionModelState(
218 modelState: EffectAcpSchema.SessionModelState | null | undefined,
219): ReadonlyArray<ServerProviderModel> {
220 if (!modelState || modelState.availableModels.length === 0) {
221 return [];
222 }
223 const currentModelId = modelState.currentModelId.trim();
224 const seen = new Set<string>();
225 return modelState.availableModels.flatMap((model): ServerProviderModel[] => {
226 const slug = resolveGrokAcpBaseModelId(model.modelId);
227 if (!slug || seen.has(slug)) {
228 return [];
229 }
230 seen.add(slug);
231 return [
232 {
233 slug,
234 name: model.name.trim() || slug,
235 isCustom: false,
236 ...(model.modelId.trim() === currentModelId ? { isDefault: true } : {}),
237 capabilities: buildGrokModelCapabilities(model),
238 },
239 ];
240 });
241}
242
243export interface GrokModelsCliOutput {
244 /** True or false when the CLI printed a login line, null when it printed neither. */
245 readonly authenticated: boolean | null;
246 readonly models: ReadonlyArray<ServerProviderModel>;
247}
248
249/**
250 * Parses `grok models`. The command exits 0 whether or not the user is logged in, so the
251 * text is the only signal. Current output looks like:
252 *
253 * You are logged in with grok.com.
254 * Default model: grok-4.6
255 * Available models:
256 * * grok-4.6 (default)
257 * - grok-4.5
258 */
259export function parseGrokModelsCliOutput(output: string): GrokModelsCliOutput {
260 const authenticated = /you are logged in/i.test(output)
261 ? true
262 : /not authenticated|not logged in/i.test(output)
263 ? false
264 : null;
265
266 const seen = new Set<string>();
267 const models: ServerProviderModel[] = [];
268 for (const line of output.split(/\r?\n/)) {
269 const bullet = line.match(/^\s*[*-]\s+(\S+)(.*)$/);
270 if (!bullet?.[1]) {
271 continue;
272 }
273 const slug = resolveGrokAcpBaseModelId(bullet[1]);
274 if (seen.has(slug)) {
275 continue;
276 }
277 seen.add(slug);
278 models.push({
279 slug,
280 name: displayNameFromGrokModelSlug(slug),
281 isCustom: false,
282 ...(/\(default\)/i.test(bullet[2] ?? "") ? { isDefault: true } : {}),
283 capabilities: EMPTY_CAPABILITIES,
284 });
285 }
286 return { authenticated, models };
287}
288
289function displayNameFromGrokModelSlug(slug: string): string {
290 return slug
291 .split(/[-_]/g)
292 .map((part) => (part.toLowerCase() === "grok" ? "Grok" : part))
293 .join(" ");
294}
295
296const runGrokCliCommand = (
297 grokSettings: GrokSettings,
298 args: ReadonlyArray<string>,
299 environment: NodeJS.ProcessEnv,
300) =>
301 Effect.gen(function* () {
302 const command = grokSettings.binaryPath || "grok";
303 const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment });
304 return yield* spawnAndCollect(
305 command,
306 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
307 env: environment,
308 shell: spawnCommand.shell,
309 }),
310 );
311 });
312
313const decodeAvailableCommands = Schema.decodeUnknownOption(Schema.Array(Schema.Unknown));
314const decodeAvailableCommand = Schema.decodeUnknownOption(
315 Schema.Struct({
316 name: Schema.String,
317 description: Schema.String,
318 input: Schema.optional(Schema.NullOr(Schema.Struct({ hint: Schema.String }))),
319 }),
320);
321
322export function grokSlashCommandsFromInitialize(
323 initialized: EffectAcpSchema.InitializeResponse,
324): ReadonlyArray<ServerProviderSlashCommand> {
325 const commands = decodeAvailableCommands(initialized._meta?.availableCommands);
326 const byName = new Map<string, ServerProviderSlashCommand>([
327 [COMPACT_SLASH_COMMAND.name, COMPACT_SLASH_COMMAND],
328 ]);
329 for (const entry of Option.getOrElse(commands, () => [])) {
330 const decoded = decodeAvailableCommand(entry);
331 if (Option.isNone(decoded)) continue;
332 const command = decoded.value;
333 const name = command.name.trim();
334 // Permission changes must go through T3 so the client and provider agree.
335 if (!name || name.toLowerCase() === "always-approve") continue;
336 // Grok advertises /context, but its ACP handler completes without emitting output.
337 if (name.toLowerCase() === "context") continue;
338 const description = command.description.trim();
339 const hint = command.input?.hint.trim();
340 byName.set(name, {
341 name,
342 ...(description ? { description } : {}),
343 ...(hint ? { input: { hint } } : {}),
344 });
345 }
346 return [...byName.values()];
347}
348
349/**
350 * Reads model and command metadata from `initialize._meta`. This never calls `authenticate`
351 * or `session/new`, so it cannot open a browser login or boot the workspace's MCP servers.
352 */
353const discoverGrokMetadataViaAcpInitialize = (
354 grokSettings: GrokSettings,
355 environment: NodeJS.ProcessEnv,
356) =>
357 Effect.gen(function* () {
358 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
359 const acp = yield* makeGrokAcpRuntime({
360 grokSettings,
361 environment,
362 childProcessSpawner,
363 cwd: process.cwd(),
364 clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" },
365 });
366 const initialized = yield* acp.initialize();
367 return {
368 models: buildGrokModelsFromSessionModelState(sessionModelStateFromInitialize(initialized)),
369 slashCommands: grokSlashCommandsFromInitialize(initialized),
370 };
371 }).pipe(Effect.scoped);
372
373export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* (
374 grokSettings: GrokSettings,
375 environment: NodeJS.ProcessEnv = process.env,
376 cwd?: string,
377): Effect.fn.Return<
378 ServerProviderDraft,
379 never,
380 ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto
381> {
382 const checkedAt = DateTime.formatIso(yield* DateTime.now);
383 const fallbackModels = grokModelsFromSettings(grokSettings.customModels);
384
385 if (!grokSettings.enabled) {
386 return buildServerProvider({
387 presentation: GROK_PRESENTATION,
388 enabled: false,
389 checkedAt,
390 models: fallbackModels,
391 probe: {
392 installed: false,
393 version: null,
394 status: "warning",
395 auth: { status: "unknown" },
396 message: "Grok is disabled in T3 Code settings.",
397 },
398 });
399 }
400
401 const versionResult = yield* runGrokCliCommand(grokSettings, ["--version"], environment).pipe(
402 Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS),
403 Effect.result,
404 );
405
406 if (Result.isFailure(versionResult)) {
407 const error = versionResult.failure;
408 yield* Effect.logWarning("Grok CLI health check failed.", {
409 errorTag: error._tag,
410 });
411 return buildServerProvider({
412 presentation: GROK_PRESENTATION,
413 enabled: grokSettings.enabled,
414 checkedAt,
415 models: fallbackModels,
416 probe: {
417 installed: !isCommandMissingCause(error),
418 version: null,
419 status: "error",
420 auth: { status: "unknown" },
421 message: isCommandMissingCause(error)
422 ? "Grok CLI (`grok`) is not installed or not on PATH."
423 : "Failed to execute Grok CLI health check.",
424 },
425 });
426 }
427
428 if (Option.isNone(versionResult.success)) {
429 return buildServerProvider({
430 presentation: GROK_PRESENTATION,
431 enabled: grokSettings.enabled,
432 checkedAt,
433 models: fallbackModels,
434 probe: {
435 installed: true,
436 version: null,
437 status: "error",
438 auth: { status: "unknown" },
439 message: "Grok CLI is installed but timed out while running `grok --version`.",
440 },
441 });
442 }
443
444 const versionOutput = versionResult.success.value;
445 const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`);
446 if (versionOutput.code !== 0) {
447 yield* Effect.logWarning("Grok CLI version probe exited with a non-zero status.", {
448 exitCode: versionOutput.code,
449 stdoutLength: versionOutput.stdout.length,
450 stderrLength: versionOutput.stderr.length,
451 });
452 return buildServerProvider({
453 presentation: GROK_PRESENTATION,
454 enabled: grokSettings.enabled,
455 checkedAt,
456 models: fallbackModels,
457 probe: {
458 installed: true,
459 version,
460 status: "error",
461 auth: { status: "unknown" },
462 message: "Grok CLI is installed but failed to run.",
463 },
464 });
465 }
466
467 // `grok models` reports login state and model slugs without starting the agent.
468 const modelsResult = yield* runGrokCliCommand(grokSettings, ["models"], environment).pipe(
469 Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS),
470 Effect.result,
471 );
472 // Only a clean exit is parsed. Failed invocations print help or error text that
473 // must not be read as model slugs or as a login verdict.
474 const modelsOutput =
475 Result.isSuccess(modelsResult) &&
476 Option.isSome(modelsResult.success) &&
477 modelsResult.success.value.code === 0
478 ? modelsResult.success.value
479 : undefined;
480 const cliModels: GrokModelsCliOutput = modelsOutput
481 ? parseGrokModelsCliOutput(`${modelsOutput.stdout}\n${modelsOutput.stderr}`)
482 : { authenticated: null, models: [] };
483 if (!modelsOutput) {
484 yield* Effect.logWarning("Grok CLI model listing failed or timed out.", {
485 errorTag: Result.isFailure(modelsResult)
486 ? modelsResult.failure._tag
487 : Option.isNone(modelsResult.success)
488 ? "Timeout"
489 : `ExitCode${modelsResult.success.value.code}`,
490 });
491 }
492
493 const auth: ServerProviderAuth = environment[GROK_API_KEY_ENV]?.trim()
494 ? { status: "authenticated", type: "api_key", label: "xAI API key" }
495 : cliModels.authenticated === true
496 ? { status: "authenticated", type: "cached_token", label: "Grok account" }
497 : cliModels.authenticated === false
498 ? { status: "unauthenticated" }
499 : { status: "unknown" };
500
501 const skills = yield* discoverGrokSkills(grokSettings, environment, cwd).pipe(
502 Effect.tapError((cause) => Effect.logDebug("Grok skill discovery failed.", { cause })),
503 Effect.orElseSucceed(() => []),
504 );
505
506 const acpExit = yield* discoverGrokMetadataViaAcpInitialize(grokSettings, environment).pipe(
507 Effect.timeoutOption(GROK_ACP_INITIALIZE_TIMEOUT_MS),
508 Effect.exit,
509 );
510 const acpMetadata = Exit.isSuccess(acpExit) ? Option.getOrUndefined(acpExit.value) : undefined;
511 const acpModels = acpMetadata?.models ?? [];
512 const acpFailed = Exit.isFailure(acpExit) || Option.isNone(acpExit.value);
513 if (acpFailed) {
514 yield* Effect.logWarning("Grok ACP initialize probe failed or timed out.", {
515 errorTag: Exit.isFailure(acpExit) ? causeErrorTag(acpExit.cause) : "Timeout",
516 });
517 }
518
519 const discoveredModels = acpModels.length > 0 ? acpModels : cliModels.models;
520 const models =
521 discoveredModels.length > 0
522 ? grokModelsFromSettings(grokSettings.customModels, discoveredModels)
523 : fallbackModels;
524
525 if (auth.status === "unauthenticated") {
526 return buildServerProvider({
527 presentation: GROK_PRESENTATION,
528 enabled: grokSettings.enabled,
529 checkedAt,
530 models,
531 skills,
532 probe: {
533 installed: true,
534 version,
535 status: "error",
536 auth,
537 message: "Grok CLI is installed but not logged in. Run `grok login`.",
538 },
539 });
540 }
541
542 return buildServerProvider({
543 presentation: GROK_PRESENTATION,
544 enabled: grokSettings.enabled,
545 checkedAt,
546 models,
547 skills,
548 slashCommands: acpMetadata?.slashCommands ?? [COMPACT_SLASH_COMMAND],
549 probe: {
550 installed: true,
551 version,
552 // A failed metadata probe degrades the model picker, it does not make chats fail.
553 status: acpFailed ? "warning" : "ready",
554 auth,
555 ...(acpFailed
556 ? {
557 message:
558 "Grok CLI is installed but ACP initialize failed. Model options may be incomplete.",
559 }
560 : {}),
561 },
562 });
563});
564
565export const enrichGrokSnapshot = (input: {
566 readonly snapshot: ServerProvider;
567 readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;
568 readonly enableProviderUpdateChecks?: boolean;
569 readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>;
570 readonly httpClient: HttpClient.HttpClient;
571}): Effect.Effect<void> => {
572 const { snapshot, publishSnapshot } = input;
573
574 return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, {
575 enableProviderUpdateChecks: input.enableProviderUpdateChecks,
576 }).pipe(
577 Effect.provideService(HttpClient.HttpClient, input.httpClient),
578 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
579 Effect.catchCause((cause) =>
580 Effect.logWarning("Grok version advisory enrichment failed", {
581 errorTag: causeErrorTag(cause),
582 }),
583 ),
584 Effect.asVoid,
585 );
586};
packages/provider-grok/src/server/textGeneration.ts created+131
...@@ -0,0 +1,131 @@
1import * as Crypto from "effect/Crypto";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Ref from "effect/Ref";
5import * as Schema from "effect/Schema";
6import { ChildProcessSpawner } from "effect/process";
7import type * as EffectAcpErrors from "effect-acp/errors";
8
9import { TextGenerationError } from "@t3tools/contracts";
10import type { GrokSettings } from "../settings.ts";
11import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
12
13import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations";
14import {
15 applyGrokAcpModelSelection,
16 currentGrokModelIdFromSessionSetup,
17 currentGrokReasoningEffortFromSessionSetup,
18 makeGrokAcpRuntime,
19 resolveGrokAcpBaseModelId,
20} from "./acpSupport.ts";
21
22const GROK_TIMEOUT_MS = 180_000;
23
24const isTextGenerationError = Schema.is(TextGenerationError);
25
26export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(function* (
27 grokSettings: GrokSettings,
28 environment: NodeJS.ProcessEnv = process.env,
29) {
30 const crypto = yield* Crypto.Crypto;
31 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
32
33 const runGrokJson: TextGenerationOperations.Runner = (request) => {
34 const { operation, cwd, prompt, modelSelection } = request;
35 return Effect.gen(function* () {
36 const outputRef = yield* Ref.make("");
37 const runtime = yield* makeGrokAcpRuntime({
38 grokSettings,
39 environment,
40 childProcessSpawner: commandSpawner,
41 cwd,
42 clientInfo: { name: "t3-code-git-text", version: "0.0.0" },
43 }).pipe(Effect.provideService(Crypto.Crypto, crypto));
44
45 yield* runtime.handleSessionUpdate((notification) => {
46 const update = notification.update;
47 if (update.sessionUpdate !== "agent_message_chunk") {
48 return Effect.void;
49 }
50 const content = update.content;
51 if (content.type !== "text") {
52 return Effect.void;
53 }
54 return Ref.update(outputRef, (current) => current + content.text);
55 });
56
57 const promptResult = yield* Effect.gen(function* () {
58 const resolvedModel = resolveGrokAcpBaseModelId(modelSelection.model);
59 const started = yield* runtime.start();
60 const requestedReasoningEffort = getModelSelectionStringOptionValue(
61 modelSelection,
62 "reasoningEffort",
63 );
64 yield* applyGrokAcpModelSelection({
65 runtime,
66 currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult),
67 currentReasoningEffort: currentGrokReasoningEffortFromSessionSetup(
68 started.sessionSetupResult,
69 ),
70 requestedModelId: resolvedModel,
71 requestedReasoningEffort,
72 mapError: (cause) =>
73 new TextGenerationError({
74 operation,
75 detail: "Failed to set Grok ACP base model for text generation.",
76 cause,
77 }),
78 });
79 return yield* runtime.prompt({
80 prompt: [{ type: "text", text: prompt }],
81 });
82 }).pipe(
83 Effect.timeoutOption(GROK_TIMEOUT_MS),
84 Effect.flatMap(
85 Option.match({
86 onNone: () =>
87 Effect.fail(
88 new TextGenerationError({ operation, detail: "Grok ACP request timed out." }),
89 ),
90 onSome: (value) => Effect.succeed(value),
91 }),
92 ),
93 Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) =>
94 isTextGenerationError(cause)
95 ? cause
96 : new TextGenerationError({
97 operation,
98 detail: "Grok ACP request failed.",
99 cause,
100 }),
101 ),
102 );
103
104 const trimmed = (yield* Ref.get(outputRef)).trim();
105 if (!trimmed) {
106 return yield* new TextGenerationError({
107 operation,
108 detail:
109 promptResult.stopReason === "cancelled"
110 ? "Grok ACP request was cancelled."
111 : "Grok Agent returned empty output.",
112 });
113 }
114
115 return yield* TextGenerationOperations.decodeJsonReply(request, "Grok Agent", trimmed);
116 }).pipe(
117 Effect.mapError((cause) =>
118 isTextGenerationError(cause)
119 ? cause
120 : new TextGenerationError({
121 operation,
122 detail: "Grok ACP text generation failed.",
123 cause,
124 }),
125 ),
126 Effect.scoped,
127 );
128 };
129
130 return TextGenerationOperations.fromRunner("GrokTextGeneration", runGrokJson);
131});
packages/provider-grok/src/server/usageLimits.ts created+175
...@@ -0,0 +1,175 @@
1import * as NodeOS from "node:os";
2import type { ServerProviderUsageWindow } from "@t3tools/contracts";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";
9import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
10import {
11 clampPercent,
12 makeUnavailableUsageLimits,
13 makeUsageLimits,
14} from "@t3tools/provider-core/server/usageLimits";
15
16const GrokCredentials = Schema.Record(
17 Schema.String,
18 Schema.Struct({
19 key: Schema.optional(Schema.String),
20 auth_mode: Schema.optional(Schema.String),
21 email: Schema.optional(Schema.String),
22 }),
23);
24const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(GrokCredentials));
25const GrokUsageResponse = Schema.Struct({
26 config: Schema.optional(
27 Schema.Struct({
28 creditUsagePercent: Schema.optional(Schema.Number),
29 currentPeriod: Schema.optional(
30 Schema.Struct({
31 type: Schema.optional(Schema.String),
32 end: Schema.optional(Schema.String),
33 }),
34 ),
35 }),
36 ),
37});
38
39export function grokUsageResponseToLimits(
40 response: typeof GrokUsageResponse.Type,
41 checkedAt: string,
42) {
43 const usedPercent = response.config?.creditUsagePercent;
44 if (usedPercent === undefined || !Number.isFinite(usedPercent)) {
45 // A billing read that succeeded but carries no percentage is an account
46 // with nothing metered yet, not one that can never report: xAI omits the
47 // field entirely (rather than sending 0) until usage registers, then fills
48 // it in. Calling that `unsupported` would strand the account — the Limits
49 // view drops unsupported entries and deliberately mutes their notice, so a
50 // freshly signed-in Grok account would vanish with no explanation until it
51 // happened to be used, and `applyUsageLimitsUpdate` would refuse the
52 // mid-turn windows that could have recovered it.
53 return makeUsageLimits({ checkedAt, windows: [] });
54 }
55 const period = response.config?.currentPeriod;
56 const periodType = period?.type?.replace(/^USAGE_PERIOD_TYPE_/, "");
57 const kind = periodType === "WEEKLY" ? "weekly" : periodType === "MONTHLY" ? "monthly" : "other";
58 const reset = period?.end ? DateTime.make(period.end) : Option.none();
59 const window: ServerProviderUsageWindow = {
60 id: "subscription",
61 kind,
62 label: kind === "weekly" ? "Weekly" : kind === "monthly" ? "Monthly" : "Subscription",
63 usedPercent: clampPercent(usedPercent),
64 ...(Option.isSome(reset) ? { resetsAt: DateTime.formatIso(reset.value) } : {}),
65 };
66 return makeUsageLimits({ checkedAt, windows: [window] });
67}
68
69/**
70 * The grok.com login the CLI uses by default, or undefined when the CLI is
71 * configured to pick another account, endpoint, or an API key.
72 */
73const readGrokCredential = Effect.fn("readGrokCredential")(function* (
74 environment: NodeJS.ProcessEnv,
75) {
76 // T3's ACP adapter explicitly selects API-key auth when this variable is set.
77 if (environment.XAI_API_KEY?.trim()) return undefined;
78 // Alternate auth deployments can select another scope or account from the same file.
79 if (
80 [
81 "GROK_OIDC_ISSUER",
82 "GROK_OIDC_CLIENT_ID",
83 "GROK_OAUTH2_ISSUER",
84 "GROK_OAUTH2_CLIENT_ID",
85 "GROK_OAUTH2_PRINCIPAL_TYPE",
86 "GROK_OAUTH2_PRINCIPAL_ID",
87 "GROK_AUTH_PROVIDER_COMMAND",
88 "GROK_LOCAL_AUTH",
89 "GROK_CLI_CHAT_PROXY_BASE_URL",
90 "GROK_MODELS_BASE_URL",
91 "GROK_CONFIG",
92 "GROK_CONFIG_PATH",
93 ].some((name) => environment[name]?.trim())
94 ) {
95 return undefined;
96 }
97 const fs = yield* FileSystem.FileSystem;
98 const path = yield* Path.Path;
99 const home =
100 environment.GROK_HOME?.trim() ||
101 path.join(environment.HOME || environment.USERPROFILE || NodeOS.homedir(), ".grok");
102 for (const configPath of [
103 path.join(home, "config.toml"),
104 path.join(home, "managed_config.toml"),
105 path.join(home, "requirements.toml"),
106 "/etc/grok/managed_config.toml",
107 "/etc/grok/requirements.toml",
108 ]) {
109 const config = yield* fs.readFileString(configPath).pipe(
110 Effect.catchTags({
111 PlatformError: (error) =>
112 error.reason._tag === "NotFound" ? Effect.succeed("") : Effect.fail(error),
113 }),
114 );
115 // These sections can change the selected account or endpoint. Leave custom deployments to the CLI.
116 if (/^\s*(?:\[\[?\s*)?["']?(?:auth|grok_com_config|endpoints)["']?\s*[.\]=]/m.test(config)) {
117 return undefined;
118 }
119 }
120 const contents =
121 environment.GROK_AUTH?.trim() ||
122 (yield* fs.readFileString(path.join(home, "auth.json")).pipe(
123 Effect.catchTags({
124 PlatformError: (error) =>
125 error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error),
126 }),
127 ));
128 const credentials = yield* decodeCredentials(contents);
129 // Never pick an arbitrary account from other deployments stored in the same file.
130 const credential =
131 credentials["https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"] ??
132 credentials["https://accounts.x.ai/sign-in"];
133 return credential?.auth_mode === "api_key" ? undefined : credential;
134});
135
136/**
137 * Reads the default grok.com login once and reports its usage limits along with
138 * its email, so the email always names the account whose quota was read.
139 */
140export const readGrokAccount = Effect.fn("readGrokAccount")(function* (
141 environment: NodeJS.ProcessEnv = process.env,
142) {
143 const checkedAt = DateTime.formatIso(yield* DateTime.now);
144 const probeFailed = makeUnavailableUsageLimits({
145 checkedAt,
146 reason: "probeFailed",
147 message: "Grok could not read usage limits.",
148 });
149 const credential = yield* Effect.option(
150 readGrokCredential(environment).pipe(Effect.timeout("10 seconds")),
151 );
152 if (Option.isNone(credential)) return { email: undefined, usageLimits: probeFailed };
153 const email = credential.value?.email?.trim() || undefined;
154 const token = credential.value?.key?.trim();
155 if (!token) {
156 return { email, usageLimits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) };
157 }
158 // A failed quota request still knows which account it asked about.
159 const usageLimits = yield* Effect.gen(function* () {
160 const client = yield* HttpClient.HttpClient;
161 const response = yield* client.execute(
162 HttpClientRequest.get("https://cli-chat-proxy.grok.com/v1/billing?format=credits").pipe(
163 HttpClientRequest.bearerToken(token),
164 ),
165 );
166 const body = yield* HttpClientResponse.schemaBodyJson(GrokUsageResponse)(
167 yield* HttpClientResponse.filterStatusOk(response),
168 );
169 return grokUsageResponseToLimits(body, checkedAt);
170 }).pipe(
171 Effect.timeout("10 seconds"),
172 Effect.orElseSucceed(() => probeFailed),
173 );
174 return { email, usageLimits };
175});
packages/provider-grok/src/server/xaiAcpExtension.test.ts created+2039
...@@ -0,0 +1,2039 @@
1// @effect-diagnostics nodeBuiltinImport:off -- plan-path tests resolve the OS home dir.
2import * as NodeOS from "node:os";
3import { it } from "@effect/vitest";
4import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";
7import * as Schema from "effect/Schema";
8import { describe, expect } from "vite-plus/test";
9
10import {
11 XAiExitPlanModeRequest,
12 XAI_EMPTY_PLAN_MARKDOWN,
13 isGrokPlanMarkdownPath,
14 extractXAiExitPlanMarkdown,
15 extractGrokPlanMarkdownFromToolCallData,
16 extractXAiAcpBackgroundToolMutation,
17 extractXAiAcpSubagentEndNotice,
18 extractXAiAcpSubagentUpdate,
19 extractXAiAskUserQuestions,
20 extractXAiBackgroundTaskCompletion,
21 extractXAiKilledBackgroundTasks,
22 extractXAiMonitorTaskId,
23 isGenericAcpToolTitle,
24 isXAiMonitorTool,
25 isXAiPersistentMonitor,
26 makeXAiAskUserQuestionResponse,
27 makeXAiPromptCompletionRuntime,
28 normalizeXAiAcpToolCallState,
29 registerXAiBackgroundTaskTracking,
30 registerXAiSubagentFinished,
31 resolveXAiAcpToolTitle,
32 xAiBackgroundTaskLifecycleMutation,
33 xAiPromptCompleteFromSessionUpdate,
34 xAiSubagentFinishedNotice,
35 XAiAskUserQuestionRequest,
36} from "./xaiAcpExtension.ts";
37import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
38import {
39 type AcpToolCallState,
40 mergeToolCallState,
41 parseSessionUpdateEvent,
42} from "@t3tools/provider-acp/server/runtimeModel";
43
44const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest);
45
46describe("xAiPromptCompleteFromSessionUpdate", () => {
47 it("maps live turn_completed snake_case payloads", () => {
48 expect(
49 xAiPromptCompleteFromSessionUpdate({
50 sessionId: "019f4428-4bf1-7e52-b7c6-c29b506543b1",
51 update: {
52 sessionUpdate: "turn_completed",
53 prompt_id: "t3-xai-prompt-1",
54 stop_reason: "end_turn",
55 },
56 }),
57 ).toEqual({
58 sessionId: "019f4428-4bf1-7e52-b7c6-c29b506543b1",
59 promptId: "t3-xai-prompt-1",
60 stopReason: "end_turn",
61 });
62 });
63
64 it("ignores non-turn updates and task-completed prompt ids", () => {
65 expect(
66 xAiPromptCompleteFromSessionUpdate({
67 sessionId: "root",
68 update: { sessionUpdate: "hook_execution", prompt_id: "t3-xai-prompt-1" },
69 }),
70 ).toBeNull();
71 expect(
72 xAiPromptCompleteFromSessionUpdate({
73 sessionId: "root",
74 update: {
75 sessionUpdate: "turn_completed",
76 prompt_id: "task-completed-call-abc",
77 stop_reason: "end_turn",
78 },
79 }),
80 ).toBeNull();
81 expect(
82 xAiPromptCompleteFromSessionUpdate({
83 sessionId: "root",
84 update: { sessionUpdate: "turn_completed", stop_reason: "end_turn" },
85 }),
86 ).toBeNull();
87 });
88});
89
90// Runs a recorded Grok tool_call_update through the adapter's parse path.
91function parseRecordedToolCallUpdate(update: Record<string, unknown>): AcpToolCallState {
92 const [event] = parseSessionUpdateEvent({
93 sessionId: "01a0d660-c1bb-7842-91a3-02d91dc8b0d2",
94 update,
95 } as Parameters<typeof parseSessionUpdateEvent>[0]).events;
96 if (event?._tag !== "ToolCallUpdated") throw new Error("expected a tool call update");
97 return event.toolCall;
98}
99
100describe("XAiAcpExtension", () => {
101 it("recognizes Grok Task starts as native subagents", () => {
102 expect(
103 extractXAiAcpSubagentUpdate({
104 toolCallId: "task-1",
105 title: "Task",
106 status: "inProgress",
107 data: {
108 rawInput: {
109 description: "Explore server architecture",
110 prompt: "Audit apps/server.",
111 subagent_type: "generalPurpose",
112 model: "composer-2.5-fast",
113 },
114 },
115 }),
116 ).toEqual({
117 nativeTaskId: "task-1",
118 prompt: "Audit apps/server.",
119 title: "Explore server architecture",
120 model: "composer-2.5-fast",
121 status: "running",
122 childSessionId: null,
123 result: null,
124 suppressNormalTool: true,
125 });
126 });
127
128 it("extracts Grok child session lineage from completed Task output", () => {
129 expect(
130 extractXAiAcpSubagentUpdate({
131 toolCallId: "task-1",
132 title: "Task",
133 status: "completed",
134 data: {
135 rawInput: {
136 description: "Explore server architecture",
137 prompt: "Audit apps/server.",
138 subagent_type: "generalPurpose",
139 },
140 rawOutput: {
141 type: "Text",
142 text: [
143 "Server audit complete.",
144 "",
145 "Agent ID: 019f0220-e192-7c41-9e9d-b406bc3459c8 (resume supported)",
146 ].join("\n"),
147 },
148 },
149 }),
150 ).toEqual({
151 nativeTaskId: "task-1",
152 prompt: "Audit apps/server.",
153 title: "Explore server architecture",
154 model: null,
155 status: "completed",
156 childSessionId: "019f0220-e192-7c41-9e9d-b406bc3459c8",
157 result: "Server audit complete.",
158 suppressNormalTool: true,
159 });
160 });
161
162 it("keeps async spawn_subagent ACKs running and binds subagent_id", () => {
163 expect(
164 extractXAiAcpSubagentUpdate({
165 toolCallId: "call-spawn-1",
166 title: "spawn_subagent",
167 status: "completed",
168 data: {
169 rawInput: {
170 description: "Demo subagent wait and ls",
171 prompt: "Wait then ls.",
172 subagent_type: "general-purpose",
173 },
174 rawOutput: {
175 type: "Text",
176 text: [
177 "Subagent started in background.",
178 "subagent_id: 019f44a6-4820-7402-925d-bc862ee711dd",
179 "type: general-purpose",
180 "description: Demo subagent wait and ls",
181 "",
182 'Use get_command_or_subagent_output with task_ids=["019f44a6-4820-7402-925d-bc862ee711dd"] and timeout_ms to wait for results.',
183 ].join("\n"),
184 },
185 },
186 }),
187 ).toEqual({
188 nativeTaskId: "call-spawn-1",
189 prompt: "Wait then ls.",
190 title: "Demo subagent wait and ls",
191 model: null,
192 status: "running",
193 childSessionId: "019f44a6-4820-7402-925d-bc862ee711dd",
194 result: null,
195 suppressNormalTool: true,
196 });
197 });
198
199 it("hydrates subagent completion from get_command_or_subagent_output", () => {
200 expect(
201 extractXAiAcpSubagentUpdate({
202 toolCallId: "call-get-1",
203 title: "get_command_or_subagent_output",
204 status: "completed",
205 data: {
206 rawInput: {
207 task_ids: ["019f44a6-4820-7402-925d-bc862ee711dd"],
208 timeout_ms: 30000,
209 },
210 rawOutput: {
211 type: "Text",
212 text: [
213 "=== Task 019f44a6-4820-7402-925d-bc862ee711dd ===",
214 "Status: completed",
215 "Exit Code: 0",
216 "",
217 "=== Output ===",
218 "SUBAGENT_MARKER: 35 entries",
219 ].join("\n"),
220 },
221 },
222 }),
223 ).toEqual({
224 nativeTaskId: "call-get-1",
225 prompt: "",
226 title: null,
227 model: null,
228 status: "completed",
229 childSessionId: "019f44a6-4820-7402-925d-bc862ee711dd",
230 result: "SUBAGENT_MARKER: 35 entries",
231 suppressNormalTool: true,
232 });
233 });
234
235 it("attributes multi-task get_output envelopes to the result task_id", () => {
236 expect(
237 extractXAiAcpSubagentUpdate({
238 toolCallId: "call-get-multi",
239 title: "get_command_or_subagent_output",
240 status: "completed",
241 data: {
242 rawInput: {
243 task_ids: [
244 "019f44a6-4820-7402-925d-bc862ee711dd",
245 "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
246 ],
247 timeout_ms: 30000,
248 },
249 rawOutput: {
250 type: "TaskOutput",
251 Result: {
252 task_id: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
253 status: "completed",
254 exit_code: 0,
255 output: "SECOND_SUB_DONE\n",
256 },
257 },
258 },
259 }),
260 ).toMatchObject({
261 childSessionId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
262 status: "completed",
263 result: "SECOND_SUB_DONE",
264 });
265 });
266
267 it("does not treat a subagent_id mentioned in result text as the completed identity", () => {
268 const requestedTaskId = "019f44a6-4820-7402-925d-bc862ee711dd";
269 const mentionedTaskId = "019f44b0-1b73-7f32-bb1b-1ff696f536e3";
270 const toolCall = {
271 toolCallId: "call-get-mentioned-id",
272 title: "get_command_or_subagent_output",
273 status: "completed" as const,
274 data: {
275 rawInput: {
276 variant: "TaskOutput",
277 task_ids: [requestedTaskId],
278 },
279 rawOutput: {
280 type: "TaskOutput",
281 Result: {
282 status: "completed",
283 exit_code: 0,
284 output: `Audited subagent_id: ${mentionedTaskId}; no issues found.`,
285 },
286 },
287 },
288 };
289
290 expect(extractXAiAcpSubagentUpdate(toolCall)).toMatchObject({
291 childSessionId: requestedTaskId,
292 status: "completed",
293 });
294 expect(extractXAiBackgroundTaskCompletion(toolCall)).toEqual([
295 {
296 taskId: requestedTaskId,
297 status: "completed",
298 appendOutput: `Audited subagent_id: ${mentionedTaskId}; no issues found.`,
299 },
300 ]);
301 });
302
303 it("hydrates structured ACP TaskOutput tool envelopes", () => {
304 expect(
305 extractXAiAcpSubagentUpdate({
306 toolCallId: "call-get-2",
307 title: "[subagent:general-purpose] Sleep then return SUBAGENT_DONE (019f44b0)",
308 status: "completed",
309 data: {
310 rawInput: {
311 variant: "TaskOutput",
312 task_ids: ["019f44b0-1b73-7f32-bb1b-1ff696f536e3"],
313 timeout_ms: 20000,
314 },
315 rawOutput: {
316 type: "TaskOutput",
317 Result: {
318 task_id: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
319 status: "completed",
320 exit_code: 0,
321 output:
322 "SUBAGENT_DONE\n\n<subagent_meta>id=019f44b0-1b73-7f32-bb1b-1ff696f536e3</subagent_meta>\n",
323 },
324 },
325 },
326 }),
327 ).toEqual({
328 nativeTaskId: "call-get-2",
329 prompt: "",
330 title: null,
331 model: null,
332 status: "completed",
333 childSessionId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
334 result: "SUBAGENT_DONE",
335 suppressNormalTool: true,
336 });
337 });
338
339 it("does not leak raw machine tags when get_output is only meta/result blocks", () => {
340 const metaOnlyToolCall = {
341 toolCallId: "call-get-meta-only",
342 title: "get_command_or_subagent_output",
343 status: "completed" as const,
344 data: {
345 rawInput: {
346 variant: "TaskOutput",
347 task_ids: ["019f44b0-1b73-7f32-bb1b-1ff696f536e3"],
348 timeout_ms: 20000,
349 },
350 rawOutput: {
351 type: "TaskOutput",
352 Result: {
353 task_id: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
354 status: "completed",
355 exit_code: 0,
356 output:
357 "<subagent_meta>id=019f44b0-1b73-7f32-bb1b-1ff696f536e3</subagent_meta>\n<subagent_result>done</subagent_result>\n",
358 },
359 },
360 },
361 };
362 expect(extractXAiAcpSubagentUpdate(metaOnlyToolCall)).toEqual({
363 nativeTaskId: "call-get-meta-only",
364 prompt: "",
365 title: null,
366 model: null,
367 status: "completed",
368 childSessionId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
369 result: null,
370 suppressNormalTool: true,
371 });
372 expect(extractXAiBackgroundTaskCompletion(metaOnlyToolCall)).toEqual([
373 {
374 taskId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
375 status: "completed",
376 appendOutput: "",
377 },
378 ]);
379 });
380
381 it("keeps monitor start ACKs running and extracts task ids", () => {
382 const toolCall = {
383 toolCallId: "call-mon-1",
384 title: "monitor",
385 status: "completed" as const,
386 data: {
387 rawInput: {
388 command: "echo mon_line_1",
389 description: "Demo monitor",
390 },
391 rawOutput: {
392 type: "Text",
393 text: "Monitor started (task 019f44a5-87d1-7640-8e35-6a4667ffc873, timeout 36000000ms).\nYou will be notified on each event.",
394 },
395 },
396 };
397 expect(normalizeXAiAcpToolCallState(toolCall).status).toBe("inProgress");
398 expect(extractXAiMonitorTaskId(toolCall)).toBe("019f44a5-87d1-7640-8e35-6a4667ffc873");
399 });
400
401 it("tracks text Monitor ACKs with generic titles and description-only input", () => {
402 const toolCall = {
403 toolCallId: "call-mon-generic-text",
404 title: "Tool",
405 status: "completed" as const,
406 data: {
407 rawInput: {
408 command: "echo mon_line_1",
409 description: "Demo monitor without variant",
410 },
411 rawOutput: {
412 type: "Text",
413 text: "Monitor started (task 019f44a5-87d1-7640-8e35-6a4667ffc873, timeout 36000000ms).\nYou will be notified on each event.",
414 },
415 },
416 };
417
418 const normalized = normalizeXAiAcpToolCallState(toolCall);
419 expect(isXAiMonitorTool(normalized)).toBe(true);
420 expect(normalized.status).toBe("inProgress");
421 expect(extractXAiMonitorTaskId(normalized)).toBe("019f44a5-87d1-7640-8e35-6a4667ffc873");
422 });
423
424 it("keeps Monitor ticks running while Grok reports them in progress", () => {
425 // Recorded from Grok 1.0.41: every tick is an in_progress update whose
426 // Bash-shaped rawOutput already carries exit_code 0. The monitor ends only
427 // through `_x.ai/task_completed`, never through a completed status.
428 const command = "for i in 1 2 3; do sleep 8; echo tick $i; done";
429 const started = parseRecordedToolCallUpdate({
430 sessionUpdate: "tool_call_update",
431 toolCallId: "call-6b025a4e-621c-4526-a579-ba86ada2669c-0",
432 kind: "other",
433 title: "Start monitor: Watch three ticks eight seconds apart",
434 locations: [],
435 rawInput: {
436 variant: "Monitor",
437 command,
438 description: "Watch three ticks eight seconds apart",
439 timeout_ms: 36000000,
440 persistent: false,
441 },
442 });
443 const tick = parseRecordedToolCallUpdate({
444 sessionUpdate: "tool_call_update",
445 toolCallId: "call-6b025a4e-621c-4526-a579-ba86ada2669c-0",
446 status: "in_progress",
447 content: [{ type: "content", content: { type: "text", text: "tick 1\n" } }],
448 rawOutput: {
449 type: "Bash",
450 output: [116, 105, 99, 107, 32, 49, 10],
451 output_for_prompt: "tick 1\n",
452 exit_code: 0,
453 command,
454 truncated: false,
455 signal: null,
456 timed_out: false,
457 description: null,
458 current_dir: "<workspace>",
459 output_file: "",
460 total_bytes: 7,
461 },
462 });
463 const toolCall = normalizeXAiAcpToolCallState(mergeToolCallState(started, tick));
464 expect(isXAiMonitorTool(toolCall)).toBe(true);
465 expect(toolCall.status).toBe("inProgress");
466 });
467
468 it("finishes monitors from the recorded task_completed snapshot", () => {
469 // Fields from the grok_monitor recording (Grok 1.0.41).
470 const completed = {
471 sessionId: "01a0d660-c1bb-7842-91a3-02d91dc8b0d2",
472 update: {
473 sessionUpdate: "task_completed",
474 task_snapshot: {
475 task_id: "01a0d660-f98d-7ef3-a291-97af1c2b6455",
476 command: "for i in 1 2 3; do sleep 8; echo tick $i; done",
477 display_command: "[monitor] Watch three tick echoes",
478 output: "tick 1\ntick 2\ntick 3\n",
479 exit_code: 0,
480 kind: "monitor",
481 description: "Watch three tick echoes",
482 },
483 },
484 };
485 expect(xAiBackgroundTaskLifecycleMutation(completed, "completed")).toEqual({
486 sessionId: "01a0d660-c1bb-7842-91a3-02d91dc8b0d2",
487 taskId: "01a0d660-f98d-7ef3-a291-97af1c2b6455",
488 status: "completed",
489 output: "tick 1\ntick 2\ntick 3\n",
490 report: { kind: "monitor", label: "Watch three tick echoes" },
491 });
492 expect(
493 xAiBackgroundTaskLifecycleMutation(
494 {
495 ...completed,
496 update: { ...completed.update, task_snapshot: { task_id: "t", exit_code: 2 } },
497 },
498 "completed",
499 )?.status,
500 ).toBe("failed");
501 });
502
503 it("detects Monitor start ACKs from structured rawOutput when title is generic", () => {
504 const toolCall = {
505 toolCallId: "call-mon-generic-title",
506 title: "Tool",
507 status: "completed" as const,
508 data: {
509 rawInput: {
510 command: "echo mon_line",
511 description: "Demo monitor without variant",
512 },
513 rawOutput: {
514 type: "Monitor",
515 taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873",
516 timeoutMs: 36000000,
517 persistent: false,
518 },
519 },
520 };
521 expect(isXAiMonitorTool(toolCall)).toBe(true);
522 expect(normalizeXAiAcpToolCallState(toolCall).status).toBe("inProgress");
523 expect(extractXAiMonitorTaskId(toolCall)).toBe("019f44a5-87d1-7640-8e35-6a4667ffc873");
524 });
525
526 it("completes status-less Bash results despite generic titles", () => {
527 // A Bash result that carries no status (empty rawInput, generic title) is
528 // terminalized from its exit_code. An explicit in_progress status wins:
529 // that is how Grok streams a still-running monitor.
530 const toolCall = {
531 toolCallId: "call-wake-1",
532 title: "Tool",
533 data: {
534 rawInput: {},
535 rawOutput: {
536 type: "Bash",
537 output: Array.from(new TextEncoder().encode("STREAM_1\nSTREAM_DONE\n")),
538 output_for_prompt: "STREAM_1\nSTREAM_DONE\n",
539 exit_code: 0,
540 command: "for i in 1 2; do echo STREAM_$i; done; echo STREAM_DONE",
541 },
542 },
543 };
544 expect(isXAiMonitorTool(toolCall)).toBe(false);
545 const normalized = normalizeXAiAcpToolCallState(toolCall);
546 expect(normalized.status).toBe("completed");
547 expect(normalized.title).toBe("for i in 1 2; do echo STREAM_$i; done; echo STREAM_DONE");
548 expect(
549 normalizeXAiAcpToolCallState({
550 ...toolCall,
551 data: {
552 ...toolCall.data,
553 rawOutput: { ...toolCall.data.rawOutput, exit_code: 2 },
554 },
555 }).status,
556 ).toBe("failed");
557 });
558
559 it("keeps empty successful Bash updates running until native output or turn settlement", () => {
560 const normalized = normalizeXAiAcpToolCallState({
561 toolCallId: "call-bash-permission-ack",
562 title: "Ran command",
563 status: "completed",
564 data: {
565 rawInput: { command: "sleep 40" },
566 rawOutput: { type: "Bash", exit_code: 0 },
567 },
568 });
569 expect(normalized.status).toBe("inProgress");
570 });
571
572 it("keeps structured Monitor start ACKs running and extracts taskId", () => {
573 const toolCall = {
574 toolCallId: "call-mon-3",
575 title: "Start monitor: Wait 30s then list directory",
576 status: "completed" as const,
577 data: {
578 rawInput: {
579 variant: "Monitor",
580 command: "sleep 30 && ls",
581 description: "Wait 30s then list directory",
582 },
583 rawOutput: {
584 type: "Monitor",
585 taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3",
586 timeoutMs: 36000000,
587 persistent: false,
588 },
589 },
590 };
591 expect(normalizeXAiAcpToolCallState(toolCall).status).toBe("inProgress");
592 expect(extractXAiMonitorTaskId(toolCall)).toBe("019f44b8-8e98-7c80-a40e-df1e26a5f9e3");
593 });
594
595 it("replaces generic ACP titles with description / Monitor labels", () => {
596 expect(isGenericAcpToolTitle("Tool")).toBe(true);
597 expect(isGenericAcpToolTitle("Read package.json")).toBe(false);
598 expect(
599 resolveXAiAcpToolTitle({
600 toolCallId: "call-mon-title",
601 title: "Tool",
602 status: "completed",
603 data: {
604 rawInput: {
605 variant: "Monitor",
606 command: "sleep 30 && ls",
607 description: "Wait 30s then list directory",
608 },
609 },
610 }),
611 ).toBe("Monitor: Wait 30s then list directory");
612 const normalized = normalizeXAiAcpToolCallState({
613 toolCallId: "call-mon-title",
614 title: "Tool",
615 status: "completed",
616 data: {
617 rawInput: {
618 variant: "Monitor",
619 command: "sleep 30 && ls",
620 description: "Wait 30s then list directory",
621 },
622 rawOutput: {
623 type: "Monitor",
624 taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3",
625 timeoutMs: 36000000,
626 persistent: false,
627 },
628 },
629 });
630 expect(normalized.title).toBe("Monitor: Wait 30s then list directory");
631 expect(normalized.status).toBe("inProgress");
632 // Non-generic titles from the CLI are preserved.
633 expect(
634 resolveXAiAcpToolTitle({
635 toolCallId: "call-read",
636 title: "Read package.json",
637 status: "inProgress",
638 data: { rawInput: { path: "package.json" } },
639 }),
640 ).toBe("Read package.json");
641 });
642
643 it("hydrates monitor completion from TaskOutput get_command envelopes", () => {
644 expect(
645 extractXAiBackgroundTaskCompletion({
646 toolCallId: "call-get-mon",
647 title: "Wait 30s then list directory",
648 status: "completed",
649 data: {
650 rawInput: {
651 variant: "TaskOutput",
652 task_ids: ["019f44b8-8e98-7c80-a40e-df1e26a5f9e3"],
653 },
654 rawOutput: {
655 type: "TaskOutput",
656 Result: {
657 task_id: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3",
658 command: "[monitor] Wait 30s then list directory",
659 status: "completed",
660 exit_code: 0,
661 output: "agents\nAGENTS.md\nnotes\n",
662 },
663 },
664 },
665 }),
666 ).toEqual([
667 {
668 taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3",
669 status: "completed",
670 appendOutput: "agents\nAGENTS.md\nnotes",
671 },
672 ]);
673 });
674
675 it("hydrates monitor completion from a standalone TaskOutput frame without rawInput", () => {
676 // Post-settle shape observed live (2026-07-12): the final
677 // get_command_or_subagent_output update carries only rawOutput; the frame
678 // is parsed in isolation (no in-turn merge), and this completion is the
679 // ONLY end signal when the agent consumed the output itself (no "Monitor
680 // ended" reminder follows).
681 expect(
682 extractXAiBackgroundTaskCompletion({
683 toolCallId: "call-c29e64dd-ce5d-4eac-a6ca-c20513fefac3-1",
684 title: "[monitor] Stream numbered markers every 3s (019f54a0)",
685 status: "completed",
686 data: {
687 rawOutput: {
688 type: "TaskOutput",
689 Result: {
690 task_id: "019f54a0-06a8-77f2-8214-e24937cad564",
691 command: "[monitor] Stream numbered markers every 3s",
692 status: "completed",
693 exit_code: 0,
694 output: "STREAM_1\nSTREAM_DONE\n",
695 },
696 },
697 },
698 }),
699 ).toMatchObject([
700 {
701 taskId: "019f54a0-06a8-77f2-8214-e24937cad564",
702 status: "completed",
703 },
704 ]);
705 });
706
707 it("hydrates every requested monitor when TaskOutput has no result task_id", () => {
708 expect(
709 extractXAiBackgroundTaskCompletion({
710 toolCallId: "call-get-multi-mon",
711 title: "get_command_or_subagent_output",
712 status: "completed",
713 data: {
714 rawInput: {
715 variant: "TaskOutput",
716 task_ids: [
717 "019f44a6-4820-7402-925d-bc862ee711dd",
718 "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
719 ],
720 },
721 rawOutput: {
722 type: "TaskOutput",
723 Result: {
724 status: "completed",
725 exit_code: 0,
726 output: "BOTH_DONE\n",
727 },
728 },
729 },
730 }),
731 ).toEqual([
732 {
733 taskId: "019f44a6-4820-7402-925d-bc862ee711dd",
734 status: "completed",
735 appendOutput: "BOTH_DONE",
736 },
737 {
738 taskId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
739 status: "completed",
740 appendOutput: "BOTH_DONE",
741 },
742 ]);
743 });
744
745 it("keeps every requested monitor running when a completed poll has no terminal task status", () => {
746 expect(
747 extractXAiBackgroundTaskCompletion({
748 toolCallId: "call-get-running-monitors",
749 title: "get_command_or_subagent_output",
750 status: "completed",
751 data: {
752 rawInput: {
753 variant: "TaskOutput",
754 task_ids: [
755 "019f44a6-4820-7402-925d-bc862ee711dd",
756 "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
757 ],
758 },
759 rawOutput: {
760 type: "TaskOutput",
761 Result: {
762 output: "No task has reached a terminal state.",
763 },
764 },
765 },
766 }),
767 ).toEqual([
768 {
769 taskId: "019f44a6-4820-7402-925d-bc862ee711dd",
770 status: "running",
771 appendOutput: "No task has reached a terminal state.",
772 },
773 {
774 taskId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
775 status: "running",
776 appendOutput: "No task has reached a terminal state.",
777 },
778 ]);
779 });
780
781 it("treats Exit Code: -1 text envelopes as failed", () => {
782 expect(
783 extractXAiBackgroundTaskCompletion({
784 toolCallId: "call-get-neg",
785 title: "get_command_or_subagent_output",
786 status: "completed",
787 data: {
788 rawInput: {
789 variant: "TaskOutput",
790 task_ids: ["019f44b8-8e98-7c80-a40e-df1e26a5f9e3"],
791 },
792 rawOutput: {
793 type: "Text",
794 text: ["=== Task 019f44b8-8e98-7c80-a40e-df1e26a5f9e3 ===", "Exit Code: -1"].join("\n"),
795 },
796 },
797 }),
798 ).toMatchObject([
799 {
800 taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3",
801 status: "failed",
802 },
803 ]);
804 });
805
806 it("tombstones confirmed kills and ignores failed kill calls", () => {
807 expect(
808 extractXAiKilledBackgroundTasks({
809 toolCallId: "call-kill-1",
810 title: "kill_command_or_subagent",
811 status: "completed",
812 data: {
813 rawInput: {
814 variant: "Kill",
815 task_ids: ["call-bg-1", "call-bg-2", "call-bg-1"],
816 },
817 },
818 }),
819 ).toEqual([
820 { taskId: "call-bg-1", status: "completed", appendOutput: "" },
821 { taskId: "call-bg-2", status: "completed", appendOutput: "" },
822 ]);
823 // Singular task_id shape, matched by variant when the title is generic.
824 expect(
825 extractXAiKilledBackgroundTasks({
826 toolCallId: "call-kill-2",
827 title: "Tool",
828 status: "failed",
829 data: { rawInput: { variant: "kill", task_id: "call-bg-3" } },
830 }),
831 ).toEqual([]);
832 // In-flight kill calls and unrelated tools contribute nothing.
833 expect(
834 extractXAiKilledBackgroundTasks({
835 toolCallId: "call-kill-3",
836 title: "kill_command_or_subagent",
837 status: "inProgress",
838 data: { rawInput: { task_ids: ["call-bg-4"] } },
839 }),
840 ).toEqual([]);
841 expect(
842 extractXAiKilledBackgroundTasks({
843 toolCallId: "call-get",
844 title: "get_command_or_subagent_output",
845 status: "completed",
846 data: { rawInput: { variant: "TaskOutput", task_ids: ["call-bg-5"] } },
847 }),
848 ).toEqual([]);
849 });
850
851 it("maps task lifecycle notifications to background mutations", () => {
852 // task_backgrounded carries the cancelled call's tool_call_id as task_id.
853 expect(
854 xAiBackgroundTaskLifecycleMutation(
855 {
856 sessionId: "session-1",
857 update: {
858 sessionUpdate: "task_backgrounded",
859 tool_call_id: "call-bg-1",
860 task_id: "call-bg-1",
861 },
862 },
863 "running",
864 ),
865 ).toEqual({ sessionId: "session-1", taskId: "call-bg-1", status: "running" });
866 // task_completed nests the id inside task_snapshot.
867 expect(
868 xAiBackgroundTaskLifecycleMutation(
869 {
870 sessionId: "session-1",
871 update: {
872 sessionUpdate: "task_completed",
873 task_snapshot: { task_id: "call-bg-1" },
874 },
875 },
876 "completed",
877 ),
878 ).toEqual({ sessionId: "session-1", taskId: "call-bg-1", status: "completed" });
879 expect(
880 xAiBackgroundTaskLifecycleMutation(
881 { sessionId: "session-1", update: { sessionUpdate: "task_completed" } },
882 "completed",
883 ),
884 ).toBeNull();
885 });
886
887 it.effect("tracks canonical Grok task lifecycle notifications", () =>
888 Effect.gen(function* () {
889 const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
890 const mutations: Array<{
891 readonly sessionId: string;
892 readonly taskId: string;
893 readonly status: "running" | "completed" | "failed";
894 }> = [];
895 const runtime = {
896 handleExtNotification: (
897 method: string,
898 _schema: unknown,
899 handler: (notification: unknown) => Effect.Effect<void>,
900 ) => {
901 handlers.set(method, handler);
902 return Effect.void;
903 },
904 } as unknown as Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "handleExtNotification">;
905
906 yield* registerXAiBackgroundTaskTracking(runtime, (mutation) =>
907 Effect.sync(() => mutations.push(mutation)),
908 );
909
910 const backgrounded = handlers.get("x.ai/task_backgrounded");
911 const completed = handlers.get("x.ai/task_completed");
912 expect(backgrounded).toBeDefined();
913 expect(completed).toBeDefined();
914 yield* backgrounded!({
915 sessionId: "session-1",
916 update: {
917 sessionUpdate: "task_backgrounded",
918 tool_call_id: "call-bg-1",
919 task_id: "task-bg-1",
920 },
921 });
922 yield* completed!({
923 sessionId: "session-1",
924 update: {
925 sessionUpdate: "task_completed",
926 task_snapshot: { task_id: "task-bg-1" },
927 },
928 });
929 expect(mutations).toEqual([
930 { sessionId: "session-1", taskId: "task-bg-1", status: "running" },
931 { sessionId: "session-1", taskId: "task-bg-1", status: "completed" },
932 ]);
933 expect(handlers.has("_x.ai/task_backgrounded")).toBe(true);
934 expect(handlers.has("_x.ai/task_completed")).toBe(true);
935 }),
936 );
937
938 it("detects persistent Monitor start ACKs", () => {
939 expect(
940 isXAiPersistentMonitor({
941 toolCallId: "call-mon-persist",
942 title: "Monitor",
943 status: "completed",
944 data: {
945 rawInput: { variant: "Monitor" },
946 rawOutput: {
947 type: "Monitor",
948 taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3",
949 timeoutMs: 36000000,
950 persistent: true,
951 },
952 },
953 }),
954 ).toBe(true);
955 expect(
956 isXAiPersistentMonitor({
957 toolCallId: "call-mon-ephemeral",
958 title: "Monitor",
959 status: "completed",
960 data: {
961 rawInput: { variant: "Monitor" },
962 rawOutput: {
963 type: "Monitor",
964 taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3",
965 timeoutMs: 36000000,
966 persistent: false,
967 },
968 },
969 }),
970 ).toBe(false);
971 });
972
973 it("keeps a standalone TaskOutput completion terminal through normalize", () => {
974 // Must remain offer evidence post-settle: normalize must not demote it to
975 // inProgress the way monitor start ACKs are.
976 expect(
977 normalizeXAiAcpToolCallState({
978 toolCallId: "call-c29e64dd-ce5d-4eac-a6ca-c20513fefac3-1",
979 title: "[monitor] Stream numbered markers every 3s (019f54a0)",
980 status: "completed",
981 data: {
982 rawOutput: {
983 type: "TaskOutput",
984 Result: {
985 task_id: "019f54a0-06a8-77f2-8214-e24937cad564",
986 status: "completed",
987 exit_code: 0,
988 output: "STREAM_DONE\n",
989 },
990 },
991 },
992 }).status,
993 ).toBe("completed");
994 });
995
996 it("parses monitor event lines and end reminders", () => {
997 expect(
998 extractXAiAcpBackgroundToolMutation(
999 '<monitor-event task_id="019f44a5-87d1-7640-8e35-6a4667ffc873">\n[Demo] mon_line_1\n</monitor-event>',
1000 ),
1001 ).toEqual([
1002 {
1003 taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873",
1004 status: "running",
1005 appendOutput: "[Demo] mon_line_1\n",
1006 },
1007 ]);
1008 expect(
1009 extractXAiAcpBackgroundToolMutation(
1010 [
1011 "<system-reminder>",
1012 'Monitor "019f44a5-87d1-7640-8e35-6a4667ffc873" ended: [monitor ended: exited (code 0)].',
1013 "Description: Demo monitor",
1014 "</system-reminder>",
1015 ].join("\n"),
1016 ),
1017 ).toMatchObject([
1018 {
1019 taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873",
1020 status: "completed",
1021 },
1022 ]);
1023 // Description/output may contain "error" without meaning the monitor failed.
1024 expect(
1025 extractXAiAcpBackgroundToolMutation(
1026 [
1027 "<system-reminder>",
1028 'Monitor "019f44a5-87d1-7640-8e35-6a4667ffc873" ended: [monitor ended: exited (code 0)].',
1029 "Description: watch error logs for failed deploys",
1030 "error: nothing found",
1031 "</system-reminder>",
1032 ].join("\n"),
1033 ),
1034 ).toMatchObject([
1035 {
1036 taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873",
1037 status: "completed",
1038 },
1039 ]);
1040 });
1041
1042 it("parses every monitor-event and trailing end notice in one chunk", () => {
1043 const mutations = extractXAiAcpBackgroundToolMutation(
1044 [
1045 '<monitor-event task_id="019f44a5-87d1-7640-8e35-6a4667ffc873">',
1046 "[Demo] mon_line_1",
1047 "</monitor-event>",
1048 '<monitor-event task_id="019f44a5-87d1-7640-8e35-6a4667ffc873">',
1049 "[Demo] mon_line_2",
1050 "</monitor-event>",
1051 'Monitor "019f44a5-87d1-7640-8e35-6a4667ffc873" ended: [monitor ended: exited (code 0)].',
1052 ].join("\n"),
1053 );
1054 expect(mutations).toHaveLength(3);
1055 expect(mutations[0]).toEqual({
1056 taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873",
1057 status: "running",
1058 appendOutput: "[Demo] mon_line_1\n",
1059 });
1060 expect(mutations[1]).toEqual({
1061 taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873",
1062 status: "running",
1063 appendOutput: "[Demo] mon_line_2\n",
1064 });
1065 expect(mutations[2]).toMatchObject({
1066 taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873",
1067 status: "completed",
1068 });
1069 });
1070
1071 it("parses batched monitor event blocks as running mutations", () => {
1072 expect(
1073 extractXAiAcpBackgroundToolMutation(
1074 [
1075 "3 monitor events from 1 monitor (use get_command_or_subagent_output to identify each monitor):",
1076 "",
1077 '<monitor description="Stream STREAM_i every 3s" task_id="019f545d-4d39-7001-b1c8-c7744c448ec1">',
1078 "[1] STREAM_7",
1079 "[2] STREAM_8",
1080 "[3] STREAM_9",
1081 "</monitor>",
1082 ].join("\n"),
1083 ),
1084 ).toEqual([
1085 {
1086 taskId: "019f545d-4d39-7001-b1c8-c7744c448ec1",
1087 status: "running",
1088 appendOutput: "[1] STREAM_7\n[2] STREAM_8\n[3] STREAM_9\n",
1089 },
1090 ]);
1091 });
1092
1093 it("parses the subagent completed reminder as an end notice", () => {
1094 expect(
1095 extractXAiAcpSubagentEndNotice(
1096 [
1097 "<system-reminder>",
1098 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Run sleep then echo token") completed successfully.',
1099 "Duration: 28.8s | Tool calls: 1 | Turns: 1",
1100 'Use get_task_output("019f5470-bf92-7a90-afb3-5a6cea5b34a3") to see the full output.',
1101 "</system-reminder>",
1102 ].join("\n"),
1103 ),
1104 ).toEqual({
1105 childSessionId: "019f5470-bf92-7a90-afb3-5a6cea5b34a3",
1106 status: "completed",
1107 });
1108 });
1109
1110 it("parses a failed subagent reminder as a failed end notice", () => {
1111 expect(
1112 extractXAiAcpSubagentEndNotice(
1113 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Run sleep then echo token") failed.',
1114 ),
1115 ).toEqual({
1116 childSessionId: "019f5470-bf92-7a90-afb3-5a6cea5b34a3",
1117 status: "failed",
1118 });
1119 });
1120
1121 it("ignores prompt text that merely mentions subagents", () => {
1122 expect(
1123 extractXAiAcpSubagentEndNotice("Live-test post-settle subagent completion."),
1124 ).toBeUndefined();
1125 expect(
1126 extractXAiAcpSubagentEndNotice(
1127 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Run sleep then echo token") is still running.',
1128 ),
1129 ).toBeUndefined();
1130 });
1131
1132 it("does not treat nested parentheses in the title as the outcome verb", () => {
1133 expect(
1134 extractXAiAcpSubagentEndNotice(
1135 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Check (backend) failed tests") is still running.',
1136 ),
1137 ).toBeUndefined();
1138 expect(
1139 extractXAiAcpSubagentEndNotice(
1140 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Check (backend) failed tests") completed successfully.',
1141 ),
1142 ).toEqual({
1143 childSessionId: "019f5470-bf92-7a90-afb3-5a6cea5b34a3",
1144 status: "completed",
1145 });
1146 });
1147
1148 it("extracts questions from the real xAI ask_user_question payload shape", () => {
1149 const questions = extractXAiAskUserQuestions({
1150 sessionId: "session-1",
1151 toolCallId: "tool-call-1",
1152 mode: "default",
1153 questions: [
1154 {
1155 id: "scope",
1156 question: "Which scope should Grok use?",
1157 options: [
1158 { label: "Workspace", description: "Use the current workspace" },
1159 { label: "Session", description: "Only use this session" },
1160 ],
1161 },
1162 ],
1163 });
1164
1165 expect(questions).toEqual([
1166 {
1167 id: "scope",
1168 header: "Question",
1169 question: "Which scope should Grok use?",
1170 multiSelect: false,
1171 options: [
1172 { label: "Workspace", description: "Use the current workspace" },
1173 { label: "Session", description: "Only use this session" },
1174 ],
1175 },
1176 ]);
1177 });
1178
1179 it("extracts questions from wrapped _x.ai extension payloads", () => {
1180 const payload = {
1181 method: "_x.ai/ask_user_question",
1182 params: {
1183 sessionId: "session-1",
1184 toolCallId: "tool-call-1",
1185 mode: "plan",
1186 questions: [
1187 {
1188 question: "Which changes should be included?",
1189 multiSelect: true,
1190 options: [{ label: "Tests" }, { label: "Docs" }],
1191 },
1192 ],
1193 },
1194 };
1195 const decoded = decodeXAiAskUserQuestionRequest(payload);
1196 const questions = extractXAiAskUserQuestions(decoded);
1197
1198 expect(questions).toEqual([
1199 {
1200 id: "Which changes should be included?",
1201 header: "Question",
1202 question: "Which changes should be included?",
1203 multiSelect: true,
1204 options: [
1205 { label: "Tests", description: "Tests" },
1206 { label: "Docs", description: "Docs" },
1207 ],
1208 },
1209 ]);
1210 });
1211
1212 it("treats nullable multiSelect from Grok as single-select", () => {
1213 const questions = extractXAiAskUserQuestions({
1214 sessionId: "session-1",
1215 toolCallId: "tool-call-1",
1216 mode: "default",
1217 questions: [
1218 {
1219 question: "Which label should Grok use?",
1220 multiSelect: null,
1221 options: [
1222 { label: "Alpha", description: "Use the Alpha label" },
1223 { label: "Beta", description: "Use the Beta label" },
1224 { label: "Other", description: "Use the Other label" },
1225 ],
1226 },
1227 ],
1228 });
1229
1230 expect(questions).toEqual([
1231 {
1232 id: "Which label should Grok use?",
1233 header: "Question",
1234 question: "Which label should Grok use?",
1235 multiSelect: false,
1236 options: [
1237 { label: "Alpha", description: "Use the Alpha label" },
1238 { label: "Beta", description: "Use the Beta label" },
1239 { label: "Other", description: "Use the Other label" },
1240 ],
1241 },
1242 ]);
1243 });
1244
1245 it("maps UI question ids back to xAI question text in accepted responses", () => {
1246 const response = makeXAiAskUserQuestionResponse(
1247 {
1248 sessionId: "session-1",
1249 toolCallId: "tool-call-1",
1250 mode: "default",
1251 questions: [
1252 {
1253 id: "scope",
1254 question: "Which scope should Grok use?",
1255 options: [
1256 { label: "workspace", description: "Use the current workspace" },
1257 { label: "session", description: "Only use this session" },
1258 ],
1259 },
1260 ],
1261 },
1262 { scope: "workspace" },
1263 );
1264
1265 expect(response).toEqual({
1266 outcome: "accepted",
1267 answers: {
1268 "Which scope should Grok use?": ["workspace"],
1269 },
1270 });
1271 });
1272
1273 it("orders accepted answers by the original xAI question order", () => {
1274 const response = makeXAiAskUserQuestionResponse(
1275 {
1276 sessionId: "session-1",
1277 toolCallId: "tool-call-1",
1278 mode: "default",
1279 questions: [
1280 {
1281 id: "first",
1282 question: "First question?",
1283 options: [{ label: "A", description: "A" }],
1284 },
1285 {
1286 id: "second",
1287 question: "Second question?",
1288 options: [{ label: "B", description: "B" }],
1289 },
1290 ],
1291 },
1292 {
1293 second: "B",
1294 first: "A",
1295 },
1296 );
1297
1298 expect(Object.keys(response.answers)).toEqual(["First question?", "Second question?"]);
1299 expect(response).toMatchObject({
1300 outcome: "accepted",
1301 answers: {
1302 "First question?": ["A"],
1303 "Second question?": ["B"],
1304 },
1305 });
1306 });
1307
1308 it("encodes typed custom answers as xAI Other annotations", () => {
1309 const response = makeXAiAskUserQuestionResponse(
1310 {
1311 method: "x.ai/ask_user_question",
1312 params: {
1313 sessionId: "session-1",
1314 toolCallId: "tool-call-1",
1315 mode: "default",
1316 questions: [
1317 {
1318 question: "Which ice cream flavor?",
1319 options: [
1320 { label: "vanilla", description: "Vanilla flavor" },
1321 { label: "chocolate", description: "Chocolate flavor" },
1322 ],
1323 },
1324 ],
1325 },
1326 },
1327 { "Which ice cream flavor?": "pistachio" },
1328 );
1329
1330 expect(response).toEqual({
1331 outcome: "accepted",
1332 answers: {
1333 "Which ice cream flavor?": ["Other"],
1334 },
1335 annotations: {
1336 "Which ice cream flavor?": {
1337 notes: "pistachio",
1338 },
1339 },
1340 });
1341 });
1342
1343 it("does not echo preview annotations for multi-select answers", () => {
1344 const response = makeXAiAskUserQuestionResponse(
1345 {
1346 sessionId: "session-1",
1347 toolCallId: "tool-call-1",
1348 mode: "default",
1349 questions: [
1350 {
1351 question: "Which files should Grok touch?",
1352 multiSelect: true,
1353 options: [
1354 {
1355 label: "Tests",
1356 description: "Update tests",
1357 preview: "test preview",
1358 },
1359 {
1360 label: "Docs",
1361 description: "Update docs",
1362 preview: "docs preview",
1363 },
1364 ],
1365 },
1366 ],
1367 },
1368 { "Which files should Grok touch?": ["Tests", "Docs"] },
1369 );
1370
1371 expect(response).toEqual({
1372 outcome: "accepted",
1373 answers: {
1374 "Which files should Grok touch?": ["Tests", "Docs"],
1375 },
1376 });
1377 });
1378
1379 it.effect("settles a hung prompt from a root-session prompt_complete notification", () =>
1380 Effect.gen(function* () {
1381 const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
1382 const hungPrompt = yield* Deferred.make<never>();
1383 const baseRuntime = {
1384 start: () =>
1385 Effect.succeed({
1386 sessionId: "root-session",
1387 initializeResult: {},
1388 sessionSetupResult: {},
1389 modelConfigId: undefined,
1390 }),
1391 prompt: () => Deferred.await(hungPrompt),
1392 cancel: Effect.void,
1393 handleExtNotification: (
1394 method: string,
1395 _schema: unknown,
1396 handler: (notification: unknown) => Effect.Effect<void>,
1397 ) => {
1398 handlers.set(method, handler);
1399 return Effect.void;
1400 },
1401 handleExtRequest: () => Effect.void,
1402 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
1403
1404 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
1405 const promptFiber = yield* runtime
1406 .prompt({ prompt: [{ type: "text", text: "hi" }] })
1407 .pipe(Effect.forkChild);
1408 yield* Effect.yieldNow;
1409 const promptCompleteHandler = handlers.get("x.ai/session/prompt_complete");
1410 expect(promptCompleteHandler).toBeDefined();
1411 yield* promptCompleteHandler!({
1412 sessionId: "root-session",
1413 stopReason: "end_turn",
1414 });
1415 const response = yield* Fiber.join(promptFiber);
1416 expect(response.stopReason).toBe("end_turn");
1417 expect(handlers.has("_x.ai/session/prompt_complete")).toBe(true);
1418 }),
1419 );
1420
1421 it.effect("fails a hung prompt from an xAI rate-limit completion (#8358)", () =>
1422 Effect.gen(function* () {
1423 const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
1424 const hungPrompt = yield* Deferred.make<never>();
1425 const baseRuntime = {
1426 start: () =>
1427 Effect.succeed({
1428 sessionId: "root-session",
1429 initializeResult: {},
1430 sessionSetupResult: {},
1431 modelConfigId: undefined,
1432 }),
1433 prompt: () => Deferred.await(hungPrompt),
1434 cancel: Effect.void,
1435 handleExtNotification: (
1436 method: string,
1437 _schema: unknown,
1438 handler: (notification: unknown) => Effect.Effect<void>,
1439 ) => {
1440 handlers.set(method, handler);
1441 return Effect.void;
1442 },
1443 handleExtRequest: () => Effect.void,
1444 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
1445
1446 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
1447 const promptFiber = yield* runtime
1448 .prompt({ prompt: [{ type: "text", text: "hi" }] })
1449 .pipe(Effect.forkChild);
1450 yield* Effect.yieldNow;
1451
1452 const promptCompleteHandler = handlers.get("x.ai/session/prompt_complete");
1453 expect(promptCompleteHandler).toBeDefined();
1454 yield* promptCompleteHandler!({
1455 sessionId: "root-session",
1456 stopReason: "rate_limit",
1457 });
1458 const error = yield* Fiber.join(promptFiber).pipe(Effect.flip);
1459 expect(error).toMatchObject({
1460 _tag: "AcpRequestError",
1461 code: -32003,
1462 errorMessage: "Grok usage limit reached. Try again later.",
1463 });
1464 }),
1465 );
1466
1467 it("maps subagent_finished to a notice only for statuses Grok defines", () => {
1468 // Recorded from Grok 1.0.41 (ids normalized); statuses per grok-build
1469 // xai-grok-tools/.../task/types.rs `status()`.
1470 const finished = (update: Record<string, unknown>) =>
1471 xAiSubagentFinishedNotice({
1472 sessionId: "root-session",
1473 update: {
1474 sessionUpdate: "subagent_finished",
1475 subagent_id: "child-session",
1476 child_session_id: "child-session",
1477 tool_calls: 2,
1478 turns: 1,
1479 duration_ms: 23734,
1480 ...update,
1481 } as never,
1482 });
1483 expect(finished({ status: "completed", output: "SUBAGENT_DONE", will_wake: true })).toEqual({
1484 sessionId: "root-session",
1485 childSessionId: "child-session",
1486 status: "completed",
1487 result: "SUBAGENT_DONE",
1488 });
1489 expect(finished({ status: "failed", error: "tool crashed" })).toMatchObject({
1490 status: "failed",
1491 result: "tool crashed",
1492 });
1493 expect(
1494 finished({ status: "cancelled", error: "interrupted by process restart" }),
1495 ).toMatchObject({ status: "cancelled", result: "interrupted by process restart" });
1496 // A failed subagent's error is its result; `output` only accompanies success.
1497 expect(finished({ status: "failed", output: "partial" })).toMatchObject({ result: null });
1498 expect(finished({ status: "timed_out" })).toBeNull();
1499 expect(finished({})).toBeNull();
1500 expect(finished({ status: "completed", child_session_id: undefined })).toBeNull();
1501 });
1502
1503 it.effect("settles prompts and finishes subagents from the same session notifications", () =>
1504 Effect.gen(function* () {
1505 const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
1506 let capturedMeta: Record<string, unknown> | null | undefined;
1507 const hungPrompt = yield* Deferred.make<never>();
1508 const baseRuntime = {
1509 start: () =>
1510 Effect.succeed({
1511 sessionId: "root-session",
1512 initializeResult: {},
1513 sessionSetupResult: {},
1514 modelConfigId: undefined,
1515 }),
1516 prompt: (payload: { readonly _meta?: Record<string, unknown> | null }) => {
1517 capturedMeta = payload._meta ?? null;
1518 return Deferred.await(hungPrompt);
1519 },
1520 cancel: Effect.void,
1521 handleExtNotification: (
1522 method: string,
1523 _schema: unknown,
1524 handler: (notification: unknown) => Effect.Effect<void>,
1525 ) => {
1526 handlers.set(method, handler);
1527 return Effect.void;
1528 },
1529 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
1530 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
1531 const notices: Array<unknown> = [];
1532 // Registered after the wrapper, as the adapter does: it must not replace
1533 // the wrapper's prompt completion on the shared method.
1534 yield* registerXAiSubagentFinished(runtime, (notice) =>
1535 Effect.sync(() => notices.push(notice)),
1536 );
1537 const sessionNotification = handlers.get("_x.ai/session_notification")!;
1538 yield* sessionNotification({
1539 sessionId: "root-session",
1540 update: {
1541 sessionUpdate: "subagent_finished",
1542 child_session_id: "child-session",
1543 status: "completed",
1544 output: "SUBAGENT_DONE",
1545 },
1546 });
1547 expect(notices).toEqual([
1548 {
1549 sessionId: "root-session",
1550 childSessionId: "child-session",
1551 status: "completed",
1552 result: "SUBAGENT_DONE",
1553 },
1554 ]);
1555
1556 const promptFiber = yield* runtime
1557 .prompt({ prompt: [{ type: "text", text: "hi" }] })
1558 .pipe(Effect.forkChild);
1559 yield* Effect.yieldNow;
1560 yield* sessionNotification({
1561 sessionId: "root-session",
1562 update: {
1563 sessionUpdate: "turn_completed",
1564 prompt_id: capturedMeta?.promptId,
1565 stop_reason: "end_turn",
1566 },
1567 });
1568 const response = yield* Fiber.join(promptFiber);
1569 expect(response.stopReason).toBe("end_turn");
1570 expect(notices).toHaveLength(1);
1571 }),
1572 );
1573
1574 it.effect("settles a hung prompt from _x.ai/session/update turn_completed", () =>
1575 Effect.gen(function* () {
1576 const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
1577 let capturedMeta: Record<string, unknown> | null | undefined;
1578 const hungPrompt = yield* Deferred.make<never>();
1579 const baseRuntime = {
1580 start: () =>
1581 Effect.succeed({
1582 sessionId: "root-session",
1583 initializeResult: {},
1584 sessionSetupResult: {},
1585 modelConfigId: undefined,
1586 }),
1587 prompt: (payload: { readonly _meta?: Record<string, unknown> | null }) => {
1588 capturedMeta = payload._meta ?? null;
1589 return Deferred.await(hungPrompt);
1590 },
1591 cancel: Effect.void,
1592 handleExtNotification: (
1593 method: string,
1594 _schema: unknown,
1595 handler: (notification: unknown) => Effect.Effect<void>,
1596 ) => {
1597 handlers.set(method, handler);
1598 return Effect.void;
1599 },
1600 handleExtRequest: () => Effect.void,
1601 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
1602
1603 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
1604 const promptFiber = yield* runtime
1605 .prompt({ prompt: [{ type: "text", text: "hi" }] })
1606 .pipe(Effect.forkChild);
1607 yield* Effect.yieldNow;
1608 const promptId = capturedMeta?.promptId;
1609 expect(typeof promptId).toBe("string");
1610 const sessionUpdateHandler = handlers.get("_x.ai/session/update");
1611 expect(sessionUpdateHandler).toBeDefined();
1612 yield* sessionUpdateHandler!({
1613 sessionId: "root-session",
1614 update: {
1615 sessionUpdate: "turn_completed",
1616 prompt_id: promptId,
1617 stop_reason: "end_turn",
1618 },
1619 });
1620 const response = yield* Fiber.join(promptFiber);
1621 expect(response.stopReason).toBe("end_turn");
1622 }),
1623 );
1624
1625 it.effect("settles a hung prompt from released _x.ai/session_notification turns", () =>
1626 Effect.gen(function* () {
1627 const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
1628 const hungPrompt = yield* Deferred.make<never>();
1629 const baseRuntime = {
1630 start: () =>
1631 Effect.succeed({
1632 sessionId: "root-session",
1633 initializeResult: {},
1634 sessionSetupResult: {},
1635 modelConfigId: undefined,
1636 }),
1637 prompt: () => Deferred.await(hungPrompt),
1638 cancel: Effect.void,
1639 handleExtNotification: (
1640 method: string,
1641 _schema: unknown,
1642 handler: (notification: unknown) => Effect.Effect<void>,
1643 ) => {
1644 handlers.set(method, handler);
1645 return Effect.void;
1646 },
1647 handleExtRequest: () => Effect.void,
1648 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
1649
1650 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
1651 const promptFiber = yield* runtime
1652 .prompt({ prompt: [{ type: "text", text: "hi" }] })
1653 .pipe(Effect.forkChild);
1654 yield* Effect.yieldNow;
1655 const sessionNotificationHandler = handlers.get("_x.ai/session_notification");
1656 expect(sessionNotificationHandler).toBeDefined();
1657 yield* sessionNotificationHandler!({
1658 sessionId: "root-session",
1659 update: {
1660 sessionUpdate: "turn_completed",
1661 prompt_id: "t3-xai-prompt-1",
1662 stop_reason: "end_turn",
1663 },
1664 });
1665 const response = yield* Fiber.join(promptFiber);
1666 expect(response.stopReason).toBe("end_turn");
1667 expect(handlers.has("_x.ai/session/update")).toBe(true);
1668 }),
1669 );
1670
1671 it.effect("ignores turn_completed for non-pending prompt ids and task completions", () =>
1672 Effect.gen(function* () {
1673 const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
1674 const hungPrompt = yield* Deferred.make<never>();
1675 const baseRuntime = {
1676 start: () =>
1677 Effect.succeed({
1678 sessionId: "root-session",
1679 initializeResult: {},
1680 sessionSetupResult: {},
1681 modelConfigId: undefined,
1682 }),
1683 prompt: () => Deferred.await(hungPrompt),
1684 cancel: Effect.void,
1685 handleExtNotification: (
1686 method: string,
1687 _schema: unknown,
1688 handler: (notification: unknown) => Effect.Effect<void>,
1689 ) => {
1690 handlers.set(method, handler);
1691 return Effect.void;
1692 },
1693 handleExtRequest: () => Effect.void,
1694 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
1695
1696 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
1697 const promptFiber = yield* runtime
1698 .prompt({ prompt: [{ type: "text", text: "hi" }] })
1699 .pipe(Effect.forkChild);
1700 yield* Effect.yieldNow;
1701 const sessionUpdateHandler = handlers.get("_x.ai/session/update");
1702 expect(sessionUpdateHandler).toBeDefined();
1703 yield* sessionUpdateHandler!({
1704 sessionId: "root-session",
1705 update: {
1706 sessionUpdate: "turn_completed",
1707 prompt_id: "task-completed-call-abc",
1708 stop_reason: "end_turn",
1709 },
1710 });
1711 yield* sessionUpdateHandler!({
1712 sessionId: "root-session",
1713 update: {
1714 sessionUpdate: "turn_completed",
1715 prompt_id: "some-other-cli-turn",
1716 stop_reason: "end_turn",
1717 },
1718 });
1719 yield* Effect.yieldNow;
1720 expect(promptFiber.pollUnsafe()).toBeUndefined();
1721 yield* Fiber.interrupt(promptFiber);
1722 }),
1723 );
1724
1725 it.effect("ignores prompt_complete notifications for foreign session ids", () =>
1726 Effect.gen(function* () {
1727 const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
1728 const hungPrompt = yield* Deferred.make<never>();
1729 const baseRuntime = {
1730 start: () =>
1731 Effect.succeed({
1732 sessionId: "root-session",
1733 initializeResult: {},
1734 sessionSetupResult: {},
1735 modelConfigId: undefined,
1736 }),
1737 prompt: () => Deferred.await(hungPrompt),
1738 cancel: Effect.void,
1739 handleExtNotification: (
1740 method: string,
1741 _schema: unknown,
1742 handler: (notification: unknown) => Effect.Effect<void>,
1743 ) => {
1744 handlers.set(method, handler);
1745 return Effect.void;
1746 },
1747 handleExtRequest: () => Effect.void,
1748 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
1749
1750 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
1751 const promptFiber = yield* runtime
1752 .prompt({ prompt: [{ type: "text", text: "hi" }] })
1753 .pipe(Effect.forkChild);
1754 yield* Effect.yieldNow;
1755 const promptCompleteHandler = handlers.get("_x.ai/session/prompt_complete");
1756 expect(promptCompleteHandler).toBeDefined();
1757 yield* promptCompleteHandler!({
1758 sessionId: "child-session",
1759 });
1760 yield* Effect.yieldNow;
1761 expect(promptFiber.pollUnsafe()).toBeUndefined();
1762 yield* Fiber.interrupt(promptFiber);
1763 }),
1764 );
1765
1766 it.effect(
1767 "ignores promptId-less prompt_complete when multiple prompts are pending on the same session",
1768 () =>
1769 Effect.gen(function* () {
1770 const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
1771 const hungPrompt = yield* Deferred.make<never>();
1772 const baseRuntime = {
1773 start: () =>
1774 Effect.succeed({
1775 sessionId: "root-session",
1776 initializeResult: {},
1777 sessionSetupResult: {},
1778 modelConfigId: undefined,
1779 }),
1780 prompt: () => Deferred.await(hungPrompt),
1781 cancel: Effect.void,
1782 handleExtNotification: (
1783 method: string,
1784 _schema: unknown,
1785 handler: (notification: unknown) => Effect.Effect<void>,
1786 ) => {
1787 handlers.set(method, handler);
1788 return Effect.void;
1789 },
1790 handleExtRequest: () => Effect.void,
1791 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
1792
1793 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
1794 const firstPromptFiber = yield* runtime
1795 .prompt({ prompt: [{ type: "text", text: "first" }] })
1796 .pipe(Effect.forkChild);
1797 yield* Effect.yieldNow;
1798 const secondPromptFiber = yield* runtime
1799 .prompt({ prompt: [{ type: "text", text: "second" }] })
1800 .pipe(Effect.forkChild);
1801 yield* Effect.yieldNow;
1802 const promptCompleteHandler = handlers.get("_x.ai/session/prompt_complete");
1803 expect(promptCompleteHandler).toBeDefined();
1804 yield* promptCompleteHandler!({
1805 sessionId: "root-session",
1806 stopReason: "end_turn",
1807 });
1808 yield* Effect.yieldNow;
1809 expect(firstPromptFiber.pollUnsafe()).toBeUndefined();
1810 expect(secondPromptFiber.pollUnsafe()).toBeUndefined();
1811 yield* Fiber.interrupt(firstPromptFiber);
1812 yield* Fiber.interrupt(secondPromptFiber);
1813 }),
1814 );
1815
1816 it.effect("injects promptId and requestId into prompt _meta", () =>
1817 Effect.gen(function* () {
1818 let capturedMeta: Record<string, unknown> | null | undefined;
1819 const baseRuntime = {
1820 start: () => Effect.succeed({ sessionId: "session-1" }),
1821 prompt: (payload: { readonly _meta?: Record<string, unknown> | null }) => {
1822 capturedMeta = payload._meta ?? null;
1823 return Effect.succeed({ stopReason: "end_turn" as const });
1824 },
1825 cancel: Effect.void,
1826 handleExtNotification: () => Effect.void,
1827 handleExtRequest: () => Effect.void,
1828 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
1829
1830 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
1831 yield* runtime.prompt({ prompt: [{ type: "text", text: "hi" }] });
1832
1833 expect(typeof capturedMeta?.promptId).toBe("string");
1834 expect(capturedMeta).toMatchObject({
1835 promptId: capturedMeta?.promptId,
1836 requestId: capturedMeta?.promptId,
1837 });
1838 }),
1839 );
1840
1841 it.effect("cancels pending completions without restarting a stalled runtime", () =>
1842 Effect.gen(function* () {
1843 const hungStart = yield* Deferred.make<never>();
1844 const hungPrompt = yield* Deferred.make<never>();
1845 let startCount = 0;
1846 let cancelCalled = false;
1847 const baseRuntime = {
1848 start: () => {
1849 startCount += 1;
1850 return startCount === 1
1851 ? Effect.succeed({
1852 sessionId: "root-session",
1853 initializeResult: {},
1854 sessionSetupResult: {},
1855 modelConfigId: undefined,
1856 })
1857 : Deferred.await(hungStart);
1858 },
1859 prompt: () => Deferred.await(hungPrompt),
1860 cancel: Effect.sync(() => {
1861 cancelCalled = true;
1862 }),
1863 handleExtNotification: () => Effect.void,
1864 handleExtRequest: () => Effect.void,
1865 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
1866
1867 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
1868 const promptFiber = yield* runtime
1869 .prompt({ prompt: [{ type: "text", text: "hi" }] })
1870 .pipe(Effect.forkChild);
1871 yield* Effect.yieldNow;
1872
1873 yield* runtime.cancel;
1874
1875 expect(cancelCalled).toBe(true);
1876 expect(startCount).toBe(1);
1877 expect((yield* Fiber.join(promptFiber)).stopReason).toBe("cancelled");
1878 }),
1879 );
1880});
1881
1882describe("Grok exit_plan_mode capture (#8358)", () => {
1883 it("extracts plan markdown from exit_plan_mode payloads", () => {
1884 const decode = Schema.decodeUnknownSync(XAiExitPlanModeRequest);
1885 const direct = decode({
1886 sessionId: "session-1",
1887 toolCallId: "exit-1",
1888 planContent: "# Plan\n\n- do the thing\n",
1889 });
1890 expect(extractXAiExitPlanMarkdown(direct)).toBe("# Plan\n\n- do the thing");
1891
1892 const wrapped = decode({
1893 method: "_x.ai/exit_plan_mode",
1894 params: {
1895 sessionId: "session-1",
1896 toolCallId: "exit-1",
1897 planContent: null,
1898 },
1899 });
1900 expect(extractXAiExitPlanMarkdown(wrapped, " # fallback plan ")).toBe("# fallback plan");
1901 expect(extractXAiExitPlanMarkdown(wrapped, "")).toBe(XAI_EMPTY_PLAN_MARKDOWN);
1902 expect(extractXAiExitPlanMarkdown(wrapped)).toBe(XAI_EMPTY_PLAN_MARKDOWN);
1903 });
1904 it("identifies Grok plan.md paths and extracts markdown from tool call data", () => {
1905 const linuxHost = { platform: "linux" as const, environment: {} };
1906 const windowsHost = { platform: "win32" as const, environment: {} };
1907 const grokHomeHost = {
1908 platform: "linux" as const,
1909 environment: { GROK_HOME: "/opt/grok-data" },
1910 };
1911 const home = NodeOS.homedir().replace(/\\/g, "/");
1912 const sessionPlan = `${home}/.grok/sessions/abc/plan.md`;
1913 const nestedSessionPlan = `${home}/.grok/sessions/%2Fhome%2Fproj/019fd20e-c563-70a0-b801-a6bc51815a9b/plan.md`;
1914 expect(isGrokPlanMarkdownPath(sessionPlan, linuxHost)).toBe(true);
1915 expect(isGrokPlanMarkdownPath(nestedSessionPlan, linuxHost)).toBe(true);
1916 expect(isGrokPlanMarkdownPath("~/.grok/sessions/abc/plan.md", linuxHost)).toBe(true);
1917 expect(isGrokPlanMarkdownPath("/tmp/mock-home/.grok/sessions/sess/plan.md", linuxHost)).toBe(
1918 true,
1919 );
1920 expect(isGrokPlanMarkdownPath("/home/other/.grok/sessions/sess/plan.md", linuxHost)).toBe(true);
1921 expect(isGrokPlanMarkdownPath("/HOME/other/.grok/sessions/sess/plan.md", linuxHost)).toBe(
1922 false,
1923 );
1924 expect(isGrokPlanMarkdownPath("C:/Users/other/.grok/sessions/id/plan.md", windowsHost)).toBe(
1925 true,
1926 );
1927 expect(isGrokPlanMarkdownPath("c:/users/OTHER/.GROK/SESSIONS/id/PLAN.MD", windowsHost)).toBe(
1928 true,
1929 );
1930 expect(
1931 isGrokPlanMarkdownPath("C:\\Users\\other\\.grok\\sessions\\id\\plan.md", windowsHost),
1932 ).toBe(true);
1933 expect(isGrokPlanMarkdownPath("/opt/grok-data/sessions/sess/plan.md", grokHomeHost)).toBe(true);
1934 expect(
1935 isGrokPlanMarkdownPath("/OPT/GROK-DATA/sessions/sess/plan.md", {
1936 platform: "win32",
1937 environment: { GROK_HOME: "/opt/grok-data" },
1938 }),
1939 ).toBe(true);
1940 expect(isGrokPlanMarkdownPath("/OPT/GROK-DATA/sessions/sess/plan.md", grokHomeHost)).toBe(
1941 false,
1942 );
1943 // Workspace plan.md must not be treated as the session plan file.
1944 expect(isGrokPlanMarkdownPath("plan.md", linuxHost)).toBe(false);
1945 expect(isGrokPlanMarkdownPath("/repo/docs/plan.md", linuxHost)).toBe(false);
1946 expect(isGrokPlanMarkdownPath("/tmp/other.md", linuxHost)).toBe(false);
1947 expect(isGrokPlanMarkdownPath("/repo/.grok/sessions/example/plan.md", linuxHost)).toBe(false);
1948 expect(
1949 isGrokPlanMarkdownPath(`${home}/project/.grok/sessions/example/plan.md`, linuxHost),
1950 ).toBe(false);
1951 expect(
1952 isGrokPlanMarkdownPath("/home/other/.grok/sessions/../../project/plan.md", linuxHost),
1953 ).toBe(false);
1954 expect(
1955 isGrokPlanMarkdownPath("/home/other/.grok/sessions/foo/../../../project/plan.md", linuxHost),
1956 ).toBe(false);
1957
1958 expect(
1959 extractGrokPlanMarkdownFromToolCallData(
1960 {
1961 rawInput: {
1962 file_path: sessionPlan,
1963 content: "# From rawInput\n\n- a\n",
1964 },
1965 },
1966 linuxHost,
1967 ),
1968 ).toBe("# From rawInput\n\n- a");
1969
1970 expect(
1971 extractGrokPlanMarkdownFromToolCallData(
1972 {
1973 content: [
1974 {
1975 type: "diff",
1976 path: sessionPlan,
1977 oldText: "",
1978 newText: "# From diff\n\n- b\n",
1979 },
1980 ],
1981 },
1982 linuxHost,
1983 ),
1984 ).toBe("# From diff\n\n- b");
1985
1986 expect(
1987 extractGrokPlanMarkdownFromToolCallData(
1988 {
1989 rawInput: { file_path: sessionPlan, content: "" },
1990 content: [
1991 {
1992 type: "diff",
1993 path: sessionPlan,
1994 oldText: "",
1995 newText: "# From diff after empty rawInput\n",
1996 },
1997 ],
1998 },
1999 linuxHost,
2000 ),
2001 ).toBe("# From diff after empty rawInput");
2002
2003 expect(
2004 extractGrokPlanMarkdownFromToolCallData(
2005 {
2006 rawInput: { file_path: sessionPlan, content: "" },
2007 },
2008 linuxHost,
2009 ),
2010 ).toBe("");
2011
2012 expect(
2013 extractGrokPlanMarkdownFromToolCallData(
2014 {
2015 content: [{ type: "diff", path: sessionPlan, oldText: "# old", newText: "" }],
2016 },
2017 linuxHost,
2018 ),
2019 ).toBe("");
2020
2021 expect(
2022 extractGrokPlanMarkdownFromToolCallData(
2023 {
2024 rawInput: { file_path: "/tmp/readme.md", content: "nope" },
2025 },
2026 linuxHost,
2027 ),
2028 ).toBeUndefined();
2029
2030 expect(
2031 extractGrokPlanMarkdownFromToolCallData(
2032 {
2033 rawInput: { file_path: "/repo/docs/plan.md", content: "# Project plan\n" },
2034 },
2035 linuxHost,
2036 ),
2037 ).toBeUndefined();
2038 });
2039});
packages/provider-grok/src/server/xaiAcpExtension.ts created+1713
...@@ -0,0 +1,1713 @@
1// @effect-diagnostics nodeBuiltinImport:off -- Grok's plan file lives under the OS home dir.
2import * as NodeOS from "node:os";
3
4import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts";
5import * as Cause from "effect/Cause";
6import * as Deferred from "effect/Deferred";
7import * as Effect from "effect/Effect";
8import * as Fiber from "effect/Fiber";
9import * as Ref from "effect/Ref";
10import * as Schema from "effect/Schema";
11import * as EffectAcpErrors from "effect-acp/errors";
12import type * as EffectAcpSchema from "effect-acp/compat";
13
14import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
15import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel";
16
17const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing";
18export const xAiRateLimitedErrorCode = -32003;
19const completedXAiPromptIdLimit = 128;
20
21const XAiPromptCompleteNotification = Schema.Struct({
22 sessionId: Schema.String,
23 promptId: Schema.optional(Schema.String),
24 stopReason: Schema.optional(Schema.String),
25 agentResult: Schema.optional(Schema.NullOr(Schema.Unknown)),
26});
27
28type XAiPromptCompleteNotification = typeof XAiPromptCompleteNotification.Type;
29
30/**
31 * Grok persists root turn completion as an extension session update:
32 * `{ params: { sessionId, update: {
33 * sessionUpdate: "turn_completed", prompt_id, stop_reason } } }`.
34 * The open-source runtime forwards it as `x.ai/session_notification` and
35 * stores/replays it as `_x.ai/session/update`; released 0.2.106 forwards the
36 * underscore alias `_x.ai/session_notification`. Current source also emits
37 * the fire-and-forget canonical `x.ai/session/prompt_complete`, while released
38 * builds use the underscore alias.
39 */
40const XAiSessionUpdateNotification = Schema.Struct({
41 sessionId: Schema.String,
42 update: Schema.Struct({
43 sessionUpdate: Schema.String,
44 prompt_id: Schema.optional(Schema.String),
45 promptId: Schema.optional(Schema.String),
46 stop_reason: Schema.optional(Schema.String),
47 stopReason: Schema.optional(Schema.String),
48 agent_result: Schema.optional(Schema.NullOr(Schema.Unknown)),
49 // subagent_finished
50 child_session_id: Schema.optional(Schema.String),
51 status: Schema.optional(Schema.String),
52 output: Schema.optional(Schema.NullOr(Schema.String)),
53 error: Schema.optional(Schema.NullOr(Schema.String)),
54 }),
55 _meta: Schema.optional(Schema.Unknown),
56});
57
58type XAiSessionUpdateNotification = typeof XAiSessionUpdateNotification.Type;
59
60const decodeXAiSessionUpdateNotification = Schema.decodeUnknownEffect(XAiSessionUpdateNotification);
61
62const xAiSessionNotificationMethods = [
63 "x.ai/session_notification",
64 "_x.ai/session_notification",
65 "_x.ai/session/update",
66] as const;
67
68const XAI_TASK_COMPLETED_PROMPT_ID_PREFIX = "task-completed-";
69
70/**
71 * Map a Grok `_x.ai/session/update` payload to a prompt-complete shape when it
72 * is a root `turn_completed` for a real prompt id. Returns null for hooks,
73 * background task completions, and other update kinds.
74 */
75export function xAiPromptCompleteFromSessionUpdate(
76 notification: XAiSessionUpdateNotification,
77): XAiPromptCompleteNotification | null {
78 const update = notification.update;
79 if (update.sessionUpdate !== "turn_completed") {
80 return null;
81 }
82 const promptId = nonEmptyString(update.prompt_id) ?? nonEmptyString(update.promptId);
83 // Require prompt id so we never session-fallback match a background task
84 // completion (`task-completed-*`) or an unrelated CLI turn.
85 if (promptId === undefined || promptId.startsWith(XAI_TASK_COMPLETED_PROMPT_ID_PREFIX)) {
86 return null;
87 }
88 const stopReason = nonEmptyString(update.stop_reason) ?? nonEmptyString(update.stopReason);
89 return {
90 sessionId: notification.sessionId,
91 promptId,
92 ...(stopReason === undefined ? {} : { stopReason }),
93 ...(update.agent_result === undefined ? {} : { agentResult: update.agent_result }),
94 };
95}
96
97/**
98 * Grok answers a finished background command in its own turn, tagging every
99 * frame with a `task-completed-*` prompt id instead of the one T3 sent.
100 */
101export function isXAiTaskCompletedWakeNotification(
102 notification: EffectAcpSchema.SessionNotification,
103): boolean {
104 const promptId = notification._meta?.promptId;
105 return typeof promptId === "string" && promptId.startsWith(XAI_TASK_COMPLETED_PROMPT_ID_PREFIX);
106}
107
108interface PendingXAiPromptCompletion {
109 readonly sessionId: string;
110 readonly promptId: string;
111 readonly deferred: Deferred.Deferred<
112 EffectAcpSchema.PromptResponse,
113 EffectAcpErrors.AcpRequestError
114 >;
115}
116
117export interface XAiAcpSubagentUpdate {
118 readonly nativeTaskId: string;
119 readonly prompt: string;
120 readonly title: string | null;
121 readonly model: string | null;
122 readonly status: "running" | "completed" | "failed";
123 readonly childSessionId: string | null;
124 readonly result: string | null;
125 /**
126 * When false, the ACP adapter also projects a normal tool turn item for this
127 * tool call (used for get_command_or_subagent_output hydration). Default true.
128 */
129 readonly suppressNormalTool?: boolean;
130}
131
132export interface XAiAcpBackgroundToolMutation {
133 readonly taskId: string;
134 readonly status: "running" | "completed" | "failed";
135 readonly appendOutput: string;
136}
137
138export interface XAiAcpSubagentEndNotice {
139 readonly childSessionId: string;
140 readonly status: "completed" | "failed";
141}
142
143const XAI_UUID_RE = "[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}";
144
145function unknownRecord(value: unknown): Record<string, unknown> | undefined {
146 return typeof value === "object" && value !== null && !Array.isArray(value)
147 ? (value as Record<string, unknown>)
148 : undefined;
149}
150
151function nonEmptyString(value: unknown): string | undefined {
152 return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
153}
154
155function titleKey(toolCall: AcpToolCallState): string {
156 return (toolCall.title ?? "").trim().toLowerCase();
157}
158
159function decodeByteText(value: unknown): string | undefined {
160 if (typeof value === "string") return nonEmptyString(value);
161 if (!Array.isArray(value)) return undefined;
162 if (value.length === 0) return undefined;
163 if (!value.every((entry) => typeof entry === "number")) return undefined;
164 try {
165 return nonEmptyString(new TextDecoder().decode(Uint8Array.from(value as number[])));
166 } catch {
167 return undefined;
168 }
169}
170
171function xAiToolOutputText(toolCall: AcpToolCallState): string | undefined {
172 const rawOutput = unknownRecord(toolCall.data.rawOutput);
173 if (rawOutput !== undefined) {
174 const direct =
175 nonEmptyString(rawOutput.text) ??
176 nonEmptyString(rawOutput.output_for_prompt) ??
177 decodeByteText(rawOutput.output);
178 if (direct !== undefined) return direct;
179 const result = unknownRecord(rawOutput.Result) ?? unknownRecord(rawOutput.result);
180 if (result !== undefined) {
181 const nested =
182 nonEmptyString(result.output) ??
183 nonEmptyString(result.text) ??
184 decodeByteText(result.output);
185 if (nested !== undefined) return nested;
186 }
187 }
188 if (typeof toolCall.data.rawOutput === "string") {
189 return nonEmptyString(toolCall.data.rawOutput);
190 }
191 const content = toolCall.data.content;
192 if (!Array.isArray(content)) return undefined;
193 const text = content
194 .flatMap((entry) => {
195 const record = unknownRecord(entry);
196 const nested = unknownRecord(record?.content);
197 return nonEmptyString(nested?.text) ?? nonEmptyString(record?.text) ?? [];
198 })
199 .join("\n")
200 .trim();
201 return text.length > 0 ? text : undefined;
202}
203
204function firstUuidMatch(text: string, pattern: RegExp): string | undefined {
205 const match = pattern.exec(text);
206 return match?.[1];
207}
208
209function extractXAiChildSessionId(output: string | undefined): string | null {
210 if (output === undefined) return null;
211 return (
212 firstUuidMatch(output, new RegExp(`(?:^|\\n)\\s*Agent ID:\\s*(${XAI_UUID_RE})\\b`, "i")) ??
213 firstUuidMatch(output, new RegExp(`(?:^|\\n)\\s*subagent_id:\\s*(${XAI_UUID_RE})\\b`, "i")) ??
214 firstUuidMatch(output, new RegExp(`===\\s*Task\\s+(${XAI_UUID_RE})\\s*===`, "i")) ??
215 null
216 );
217}
218
219function isXAiAsyncSpawnAck(output: string | undefined): boolean {
220 if (output === undefined) return false;
221 if (/subagent started in background/i.test(output)) return true;
222 return (
223 new RegExp(`subagent_id:\\s*${XAI_UUID_RE}`, "i").test(output) &&
224 /get_command_or_subagent_output/i.test(output)
225 );
226}
227
228function isXAiMonitorStartAck(output: string | undefined): boolean {
229 if (output === undefined) return false;
230 return /monitor started\s*\(\s*task\s+/i.test(output);
231}
232
233function isXAiSpawnOrTaskTool(
234 toolCall: AcpToolCallState,
235 rawInput: Record<string, unknown> | undefined,
236): boolean {
237 const title = titleKey(toolCall);
238 if (title === "task" || title === "spawn_subagent" || title.includes("spawn subagent")) {
239 return true;
240 }
241 const variant = nonEmptyString(rawInput?.variant)?.toLowerCase();
242 if (variant === "cursortask" || variant === "task" || variant === "spawn_subagent") {
243 return true;
244 }
245 if (nonEmptyString(rawInput?.subagent_type) !== undefined) return true;
246 if (nonEmptyString(rawInput?.subagentType) !== undefined) return true;
247 return false;
248}
249
250function isXAiGetSubagentOutputTool(
251 toolCall: AcpToolCallState,
252 rawInput: Record<string, unknown> | undefined,
253): boolean {
254 const title = titleKey(toolCall);
255 if (
256 title === "get_command_or_subagent_output" ||
257 title.includes("get_command_or_subagent_output") ||
258 title === "wait_commands_or_subagents"
259 ) {
260 return true;
261 }
262 const variant = nonEmptyString(rawInput?.variant)?.toLowerCase();
263 if (variant === "taskoutput" || variant === "task_output") return true;
264 const rawOutput = unknownRecord(toolCall.data.rawOutput);
265 const outputType = nonEmptyString(rawOutput?.type)?.toLowerCase();
266 if (outputType === "taskoutput" || outputType === "task_output") return true;
267 // ACP often titles this tool with the finished subagent label.
268 if (title.startsWith("[subagent:") && Array.isArray(rawInput?.task_ids)) return true;
269 return false;
270}
271
272export function isXAiMonitorTool(toolCall: AcpToolCallState): boolean {
273 const title = titleKey(toolCall);
274 if (title === "monitor" || title.startsWith("monitor ")) return true;
275 const rawInput = unknownRecord(toolCall.data.rawInput);
276 const variant = nonEmptyString(rawInput?.variant)?.toLowerCase();
277 if (variant === "monitor") return true;
278 // Live start ACKs often keep a generic title ("Tool") and only mark the
279 // structured envelope as Monitor; title rewrite to description must not
280 // prevent monitor registration / inProgress forcing.
281 const rawOutput = unknownRecord(toolCall.data.rawOutput);
282 const outputType = nonEmptyString(rawOutput?.type)?.toLowerCase();
283 if (outputType === "monitor") return true;
284 // Released clients can wrap the start ACK as plain Text and omit the Monitor
285 // variant. The ACK is still unambiguous and contains the task id required for
286 // background tracking, even after a generic title is rewritten from
287 // rawInput.description.
288 return isXAiMonitorStartAck(xAiToolOutputText(toolCall));
289}
290
291/**
292 * A shell command Grok runs in the background (asked for, or moved there after
293 * its auto-background timeout) acknowledges with
294 * `{ type: "BackgroundTaskStarted", task_id }` and completes its tool call
295 * while the process still runs. Like a monitor, it ends only with
296 * `x.ai/task_completed` for that task id.
297 */
298function xAiBackgroundShellTaskId(toolCall: AcpToolCallState): string | undefined {
299 const rawOutput = unknownRecord(toolCall.data.rawOutput);
300 return nonEmptyString(rawOutput?.type) === "BackgroundTaskStarted"
301 ? nonEmptyString(rawOutput?.task_id)
302 : undefined;
303}
304
305export function extractXAiMonitorTaskId(toolCall: AcpToolCallState): string | undefined {
306 const backgroundShellTaskId = xAiBackgroundShellTaskId(toolCall);
307 if (backgroundShellTaskId !== undefined) return backgroundShellTaskId;
308 if (!isXAiMonitorTool(toolCall)) return undefined;
309 const rawOutput = unknownRecord(toolCall.data.rawOutput);
310 // Live ACP start ACK: { type: "Monitor", taskId, timeoutMs, persistent }
311 if (rawOutput !== undefined && nonEmptyString(rawOutput.type)?.toLowerCase() === "monitor") {
312 const structured = nonEmptyString(rawOutput.taskId) ?? nonEmptyString(rawOutput.task_id);
313 if (structured !== undefined) return structured;
314 }
315 const output = xAiToolOutputText(toolCall);
316 if (output !== undefined) {
317 const fromAck = firstUuidMatch(
318 output,
319 new RegExp(`monitor started\\s*\\(\\s*task\\s+(${XAI_UUID_RE})\\b`, "i"),
320 );
321 if (fromAck !== undefined) return fromAck;
322 }
323 const rawInput = unknownRecord(toolCall.data.rawInput);
324 return (
325 nonEmptyString(rawInput?.task_id) ??
326 nonEmptyString(rawInput?.taskId) ??
327 (Array.isArray(rawInput?.task_ids) ? nonEmptyString(rawInput.task_ids[0]) : undefined)
328 );
329}
330
331/**
332 * Live Monitor start ACK `{ type: "Monitor", persistent: true }` (or matching
333 * rawInput). Persistent monitors should not hold root-turn deferred finalize.
334 */
335export function isXAiPersistentMonitor(toolCall: AcpToolCallState): boolean {
336 if (!isXAiMonitorTool(toolCall)) return false;
337 const rawOutput = unknownRecord(toolCall.data.rawOutput);
338 if (rawOutput !== undefined && nonEmptyString(rawOutput.type)?.toLowerCase() === "monitor") {
339 return rawOutput.persistent === true;
340 }
341 const rawInput = unknownRecord(toolCall.data.rawInput);
342 return rawInput?.persistent === true;
343}
344
345/**
346 * When get_command_or_subagent_output / TaskOutput completes registered monitor
347 * task(s), return hydration for each completed background tool id.
348 */
349export function extractXAiBackgroundTaskCompletion(toolCall: AcpToolCallState): ReadonlyArray<{
350 readonly taskId: string;
351 readonly status: "running" | "completed" | "failed";
352 readonly appendOutput: string;
353}> {
354 const rawInput = unknownRecord(toolCall.data.rawInput);
355 if (!isXAiGetSubagentOutputTool(toolCall, rawInput)) return [];
356 // Caller matches taskId against registered background tools (monitors).
357 // Subagent hydration stays on extractXAiAcpSubagentUpdate.
358 const text = xAiToolOutputText(toolCall);
359 const status = statusFromGetOutputTool(toolCall, text);
360 const appendOutput = resultFromGetOutputTool(toolCall, text) ?? "";
361 // Prefer the completed identity from the result/header over the request list
362 // (one call can poll multiple task_ids).
363 const resultTaskId = resultTaskIdFromGetOutputTool(toolCall, text);
364 if (resultTaskId !== undefined) {
365 return [{ taskId: resultTaskId, status, appendOutput }];
366 }
367 const requestIds: string[] = [];
368 const push = (value: unknown) => {
369 const id = nonEmptyString(value);
370 if (id !== undefined && new RegExp(`^${XAI_UUID_RE}$`, "i").test(id)) {
371 requestIds.push(id);
372 }
373 };
374 if (Array.isArray(rawInput?.task_ids)) {
375 for (const entry of rawInput.task_ids) push(entry);
376 }
377 push(rawInput?.task_id);
378 push(rawInput?.taskId);
379 const unique = [...new Set(requestIds)];
380 return unique.map((taskId) => ({ taskId, status, appendOutput }));
381}
382
383/**
384 * A finished kill_command_or_subagent call is the genuine end signal for every
385 * task id it names: the Grok CLI emits no `x.ai/task_completed` for a killed
386 * task. A failed kill is not proof the target stopped, so leave the existing
387 * task state unchanged until a genuine terminal signal arrives.
388 */
389export function extractXAiKilledBackgroundTasks(toolCall: AcpToolCallState): ReadonlyArray<{
390 readonly taskId: string;
391 readonly status: "running" | "completed" | "failed";
392 readonly appendOutput: string;
393}> {
394 const title = titleKey(toolCall);
395 const rawInput = unknownRecord(toolCall.data.rawInput);
396 const variant = nonEmptyString(rawInput?.variant)?.toLowerCase();
397 if (!title.includes("kill_command_or_subagent") && variant !== "kill") return [];
398 if (toolCall.status !== "completed") return [];
399 const taskIds: string[] = [];
400 const push = (value: unknown) => {
401 const id = nonEmptyString(value);
402 if (id !== undefined) taskIds.push(id);
403 };
404 if (Array.isArray(rawInput?.task_ids)) {
405 for (const entry of rawInput.task_ids) push(entry);
406 }
407 push(rawInput?.task_id);
408 push(rawInput?.taskId);
409 return [...new Set(taskIds)].map((taskId) => ({
410 taskId,
411 status: "completed" as const,
412 appendOutput: "",
413 }));
414}
415
416const XAiTaskLifecycleNotification = Schema.Struct({
417 sessionId: Schema.String,
418 update: Schema.Struct({
419 sessionUpdate: Schema.String,
420 task_id: Schema.optional(Schema.String),
421 tool_call_id: Schema.optional(Schema.String),
422 command: Schema.optional(Schema.String),
423 description: Schema.optional(Schema.NullOr(Schema.String)),
424 monitor_description: Schema.optional(Schema.NullOr(Schema.String)),
425 task_snapshot: Schema.optional(
426 Schema.Struct({
427 task_id: Schema.optional(Schema.String),
428 output: Schema.optional(Schema.String),
429 exit_code: Schema.optional(Schema.NullOr(Schema.Number)),
430 command: Schema.optional(Schema.String),
431 display_command: Schema.optional(Schema.NullOr(Schema.String)),
432 description: Schema.optional(Schema.NullOr(Schema.String)),
433 // `TaskKind` in grok-build crates/common/xai-tool-runtime/src/notification.rs.
434 kind: Schema.optional(Schema.String),
435 }),
436 ),
437 }),
438 _meta: Schema.optional(Schema.Unknown),
439});
440
441type XAiTaskLifecycleNotification = typeof XAiTaskLifecycleNotification.Type;
442
443export interface XAiBackgroundTaskLifecycleMutation {
444 readonly sessionId: string;
445 readonly taskId: string;
446 readonly status: "running" | "completed" | "failed";
447 /** Final output from `task_completed.task_snapshot`, when Grok sent one. */
448 readonly output?: string;
449 /** What the task is and how to name it, when Grok said. */
450 readonly report?:
451 | { readonly kind: "command"; readonly label?: string; readonly exitCode?: number }
452 | { readonly kind: "monitor"; readonly label?: string };
453}
454
455function xAiBackgroundTaskReport(
456 update: XAiTaskLifecycleNotification["update"],
457): XAiBackgroundTaskLifecycleMutation["report"] {
458 const snapshot = update.task_snapshot;
459 const monitorDescription = nonEmptyString(update.monitor_description ?? undefined);
460 const displayCommand = nonEmptyString(snapshot?.display_command ?? undefined);
461 const isMonitor =
462 snapshot?.kind === "monitor" ||
463 monitorDescription !== undefined ||
464 displayCommand?.startsWith("[monitor]") === true;
465 // Grok's snapshot defaults `kind` to bash; anything that carries a command is one.
466 const isCommand =
467 snapshot?.kind === "bash" || snapshot?.command !== undefined || update.command !== undefined;
468 if (!isMonitor && !isCommand) return undefined;
469 const label =
470 monitorDescription ??
471 nonEmptyString(snapshot?.description ?? undefined) ??
472 nonEmptyString(update.description ?? undefined) ??
473 displayCommand?.replace(/^\[monitor\]\s*/, "") ??
474 nonEmptyString(snapshot?.command) ??
475 nonEmptyString(update.command);
476 const named = label === undefined ? {} : { label };
477 if (isMonitor) return { kind: "monitor", ...named };
478 const exitCode = snapshot?.exit_code;
479 return { kind: "command", ...named, ...(typeof exitCode === "number" ? { exitCode } : {}) };
480}
481
482export function xAiBackgroundTaskLifecycleMutation(
483 notification: XAiTaskLifecycleNotification,
484 status: "running" | "completed",
485): XAiBackgroundTaskLifecycleMutation | null {
486 const update = notification.update;
487 const taskId =
488 nonEmptyString(update.task_snapshot?.task_id) ??
489 nonEmptyString(update.task_id) ??
490 nonEmptyString(update.tool_call_id);
491 if (taskId === undefined) return null;
492 const exitCode = update.task_snapshot?.exit_code;
493 const output = update.task_snapshot?.output;
494 const report = xAiBackgroundTaskReport(update);
495 return {
496 sessionId: notification.sessionId,
497 taskId,
498 status:
499 status === "completed" && typeof exitCode === "number" && exitCode !== 0 ? "failed" : status,
500 ...(output === undefined ? {} : { output }),
501 ...(report === undefined ? {} : { report }),
502 };
503}
504
505/**
506 * Grok task lifecycle notifications use canonical `x.ai/task_*` methods in
507 * current builds; older 0.2.x builds used `_x.ai/task_*`. Interactive cancel
508 * preserves tasks that were already backgrounded (and some older builds could
509 * background the cancelled foreground command), so both spellings must be
510 * tracked. Residual frames then buffer instead of waking synthetic
511 * continuations, and pending-background-work stays truthful until the task
512 * ends (or the model kills it via kill_command_or_subagent).
513 */
514export const registerXAiBackgroundTaskTracking = (
515 runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "handleExtNotification">,
516 apply: (mutation: XAiBackgroundTaskLifecycleMutation) => Effect.Effect<void>,
517): Effect.Effect<void, EffectAcpErrors.AcpError> =>
518 Effect.forEach(
519 [
520 ["x.ai/task_backgrounded", "running"],
521 ["_x.ai/task_backgrounded", "running"],
522 ["x.ai/task_completed", "completed"],
523 ["_x.ai/task_completed", "completed"],
524 ] as const,
525 ([method, status]) =>
526 runtime.handleExtNotification(method, XAiTaskLifecycleNotification, (notification) => {
527 const mutation = xAiBackgroundTaskLifecycleMutation(notification, status);
528 return mutation === null ? Effect.void : apply(mutation);
529 }),
530 { discard: true },
531 );
532
533/**
534 * A background subagent's end, sent on its PARENT session as
535 * `{ sessionUpdate: "subagent_finished", child_session_id, status, output, error }`
536 * (grok-build `crates/codegen/xai-grok-shell/src/extensions/notification.rs`
537 * `SessionUpdate::SubagentFinished`). `status` is exactly "completed",
538 * "failed" or "cancelled" (`SubagentResult::status()` in
539 * `crates/codegen/xai-grok-tools/src/implementations/grok_build/task/types.rs`);
540 * `output` is the final text of a completed subagent and `error` the message
541 * of a failed one. Grok follows it with its own `subagent-completed-<id>` wake
542 * turn when `will_wake` is true.
543 */
544export interface XAiSubagentFinishedNotice {
545 readonly sessionId: string;
546 readonly childSessionId: string;
547 readonly status: "completed" | "failed" | "cancelled";
548 readonly result: string | null;
549}
550
551/** Null for other session updates, and for a status Grok does not define. */
552export function xAiSubagentFinishedNotice(
553 notification: XAiSessionUpdateNotification,
554): XAiSubagentFinishedNotice | null {
555 const update = notification.update;
556 const childSessionId = nonEmptyString(update.child_session_id);
557 if (update.sessionUpdate !== "subagent_finished" || childSessionId === undefined) return null;
558 const status = update.status;
559 if (status !== "completed" && status !== "failed" && status !== "cancelled") return null;
560 const text = status === "completed" ? update.output : update.error;
561 return {
562 sessionId: notification.sessionId,
563 childSessionId,
564 status,
565 result: nonEmptyString(text ?? undefined) ?? null,
566 };
567}
568
569/**
570 * Finishes background subagents from Grok's `subagent_finished`. These arrive
571 * on the session notification methods that also carry turn completion; a
572 * runtime from {@link makeXAiPromptCompletionRuntime} settles its prompt from
573 * each notification before passing it to this handler.
574 */
575export const registerXAiSubagentFinished = (
576 runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "handleExtNotification">,
577 finish: (notice: XAiSubagentFinishedNotice) => Effect.Effect<void>,
578): Effect.Effect<void> =>
579 Effect.forEach(
580 xAiSessionNotificationMethods,
581 (method) =>
582 runtime.handleExtNotification(method, XAiSessionUpdateNotification, (notification) => {
583 const notice = xAiSubagentFinishedNotice(notification);
584 return notice === null ? Effect.void : finish(notice);
585 }),
586 { discard: true },
587 );
588
589/**
590 * Grok ACP often sends `title: "Tool"` even when rawInput has a useful
591 * description or variant (especially Monitor). Prefer description/variant so
592 * the timeline matches Claude-style tool names rather than a generic label.
593 */
594export function isGenericAcpToolTitle(title: string | undefined): boolean {
595 const key = (title ?? "").trim().toLowerCase();
596 return key.length === 0 || key === "tool" || key === "tool call" || key === "terminal";
597}
598
599export function resolveXAiAcpToolTitle(toolCall: AcpToolCallState): string | undefined {
600 if (!isGenericAcpToolTitle(toolCall.title)) {
601 return nonEmptyString(toolCall.title);
602 }
603 const rawInput = unknownRecord(toolCall.data.rawInput);
604 const description = nonEmptyString(rawInput?.description);
605 const variant = nonEmptyString(rawInput?.variant)?.toLowerCase();
606 if (description !== undefined) {
607 if (variant === "monitor" && !description.toLowerCase().startsWith("monitor")) {
608 return `Monitor: ${description}`;
609 }
610 return description;
611 }
612 if (variant === "monitor") return "Monitor";
613 if (variant === "task" || variant === "cursortask" || variant === "spawn_subagent") {
614 return "Task";
615 }
616 if (variant === "taskoutput" || variant === "task_output") return "Task output";
617 if (variant !== undefined) {
618 return variant;
619 }
620 const command =
621 nonEmptyString(rawInput?.command) ??
622 nonEmptyString(unknownRecord(toolCall.data.rawOutput)?.command);
623 if (command !== undefined) {
624 return command.length > 80 ? `${command.slice(0, 77)}...` : command;
625 }
626 return nonEmptyString(toolCall.title);
627}
628
629/**
630 * Normalize Grok ACP tool presentation and monitor lifecycle:
631 * - replace generic titles ("Tool") with description / variant labels
632 * - structured Monitor start ACK stays running
633 * - text start ACK stays running
634 * - structured Bash results with exit_code are terminal, unless Grok itself
635 * still reports the tool running. Grok 1.0.41 streams every monitor tick as
636 * `status: "in_progress"` with a Bash-shaped rawOutput whose exit_code is
637 * already 0, and never sends a completed status for the monitor; its end
638 * arrives as `_x.ai/task_completed`.
639 */
640export function normalizeXAiAcpToolCallState(toolCall: AcpToolCallState): AcpToolCallState {
641 const resolvedTitle = resolveXAiAcpToolTitle(toolCall);
642 const withTitle =
643 resolvedTitle !== undefined && resolvedTitle !== toolCall.title
644 ? { ...toolCall, title: resolvedTitle }
645 : toolCall;
646
647 const rawOutput = unknownRecord(withTitle.data.rawOutput);
648 const outputType = nonEmptyString(rawOutput?.type)?.toLowerCase();
649 const reportedRunning = withTitle.status === "inProgress" || withTitle.status === "pending";
650 if (outputType === "bash" && !reportedRunning) {
651 // Same key set as the adapter's command projection (commandExitCode).
652 const exitCode = ["exit_code", "exitCode", "code"]
653 .map((key) => rawOutput?.[key])
654 .find((value) => typeof value === "number" && Number.isInteger(value));
655 if (typeof exitCode === "number") {
656 if (exitCode === 0 && (xAiToolOutputText(withTitle)?.trim().length ?? 0) === 0) {
657 return { ...withTitle, status: "inProgress" };
658 }
659 return {
660 ...withTitle,
661 status: exitCode === 0 ? "completed" : "failed",
662 };
663 }
664 }
665 if (xAiBackgroundShellTaskId(withTitle) !== undefined) {
666 // Start acknowledgement only; the command still runs in the background.
667 return { ...withTitle, status: "inProgress" };
668 }
669 if (!isXAiMonitorTool(withTitle)) {
670 return withTitle;
671 }
672 if (outputType === "monitor") {
673 // Start registration only; process still running in the background.
674 return { ...withTitle, status: "inProgress" };
675 }
676 const output = xAiToolOutputText(withTitle);
677 if (withTitle.status === "completed" && isXAiMonitorStartAck(output)) {
678 return { ...withTitle, status: "inProgress" };
679 }
680 return withTitle;
681}
682
683/**
684 * Parse Grok synthetic monitor traffic that arrives as root text chunks
685 * (`<monitor-event>` lines, batched `<monitor>` event blocks, and
686 * "Monitor ... ended" reminders). Coalesced chunks may contain several events;
687 * return every match so later progress / end notices are not dropped.
688 */
689export function extractXAiAcpBackgroundToolMutation(
690 text: string,
691): ReadonlyArray<XAiAcpBackgroundToolMutation> {
692 const mutations: XAiAcpBackgroundToolMutation[] = [];
693
694 const eventRe = new RegExp(
695 `<monitor-event\\s+task_id=["']?(${XAI_UUID_RE})["']?\\s*>\\s*([\\s\\S]*?)\\s*</monitor-event>`,
696 "gi",
697 );
698 for (const eventMatch of text.matchAll(eventRe)) {
699 if (eventMatch[1] === undefined) continue;
700 const line = (eventMatch[2] ?? "").trim();
701 mutations.push({
702 taskId: eventMatch[1],
703 status: "running",
704 appendOutput: line.length > 0 ? `${line}\n` : "",
705 });
706 }
707
708 // Batched form: "N monitor events from 1 monitor ...:\n<monitor
709 // description="..." task_id="...">\n[1] line\n</monitor>". Still a running
710 // monitor; must track and filter like single-event chatter.
711 const batchedRe = new RegExp(
712 `<monitor\\s[^>]*task_id=["']?(${XAI_UUID_RE})["']?[^>]*>\\s*([\\s\\S]*?)\\s*</monitor>`,
713 "gi",
714 );
715 for (const batchedMatch of text.matchAll(batchedRe)) {
716 if (batchedMatch[1] === undefined) continue;
717 const lines = (batchedMatch[2] ?? "").trim();
718 mutations.push({
719 taskId: batchedMatch[1],
720 status: "running",
721 appendOutput: lines.length > 0 ? `${lines}\n` : "",
722 });
723 }
724
725 const endedRe = new RegExp(`Monitor\\s+["']?(${XAI_UUID_RE})["']?\\s+ended`, "gi");
726 for (const endedMatch of text.matchAll(endedRe)) {
727 if (endedMatch[1] === undefined) continue;
728 // Only the "Monitor … ended …" clause is outcome text. Description/output
729 // after the header can contain words like "error" without meaning failure.
730 const outcomeClause = (endedMatch[0] ?? "").trim();
731 const afterHeader = text.slice((endedMatch.index ?? 0) + endedMatch[0].length);
732 const outcomeTail = afterHeader.split(/\n/)[0] ?? "";
733 const outcome = `${outcomeClause}${outcomeTail}`;
734 const summary = text.replace(/<\/?system-reminder>/gi, "").trim();
735 mutations.push({
736 taskId: endedMatch[1],
737 status:
738 /exited\s*\(\s*code\s*0\s*\)/i.test(outcome) || /ended cleanly/i.test(outcome)
739 ? "completed"
740 : /exited|failed|error|signal/i.test(outcome)
741 ? "failed"
742 : "completed",
743 appendOutput: summary.length > 0 ? `${summary}\n` : "Monitor ended.\n",
744 });
745 }
746
747 return mutations;
748}
749
750/**
751 * Parse Grok's root-session subagent end reminder ("Background subagent
752 * "<uuid>" (general-purpose: "...") completed successfully."). The agent is
753 * free to skip get_command_or_subagent_output hydration, so this reminder can
754 * be the only terminal signal for the subagent row; a row stuck on running
755 * holds deferred finalize (and the whole run) open until harness timeout.
756 */
757function stripLeadingBalancedParenGroup(text: string): string {
758 const trimmed = text.replace(/^\s+/, "");
759 if (!trimmed.startsWith("(")) return trimmed;
760 let depth = 0;
761 for (let index = 0; index < trimmed.length; index += 1) {
762 const char = trimmed[index];
763 if (char === "(") depth += 1;
764 else if (char === ")") {
765 depth -= 1;
766 if (depth === 0) return trimmed.slice(index + 1).replace(/^\s+/, "");
767 }
768 }
769 return trimmed;
770}
771
772export function extractXAiAcpSubagentEndNotice(text: string): XAiAcpSubagentEndNotice | undefined {
773 const match = text.match(new RegExp(`Background subagent\\s+["']?(${XAI_UUID_RE})["']?`, "i"));
774 if (match?.[1] === undefined) return undefined;
775 // Skip the parenthesized descriptor (subagent type and title) so its words
776 // cannot masquerade as the outcome verb. Balance parens so nested titles
777 // like `(general-purpose: "Check (backend) failed")` do not leak.
778 const tail = stripLeadingBalancedParenGroup(text.slice((match.index ?? 0) + match[0].length));
779 if (/completed successfully/i.test(tail)) {
780 return { childSessionId: match[1], status: "completed" };
781 }
782 if (/\b(failed|errored|error|crashed|cancelled|canceled|interrupted|timed out)\b/i.test(tail)) {
783 return { childSessionId: match[1], status: "failed" };
784 }
785 if (/\b(completed|finished|ended)\b/i.test(tail)) {
786 return { childSessionId: match[1], status: "completed" };
787 }
788 return undefined;
789}
790
791function taskIdsFromGetOutputTool(
792 toolCall: AcpToolCallState,
793 rawInput: Record<string, unknown> | undefined,
794): ReadonlyArray<string> {
795 const ids: string[] = [];
796 const push = (value: unknown) => {
797 const text = nonEmptyString(value);
798 if (text !== undefined && new RegExp(`^${XAI_UUID_RE}$`, "i").test(text)) {
799 ids.push(text);
800 }
801 };
802 if (Array.isArray(rawInput?.task_ids)) {
803 for (const entry of rawInput.task_ids) push(entry);
804 }
805 push(rawInput?.task_id);
806 push(rawInput?.taskId);
807 const resultId = resultTaskIdFromGetOutputTool(toolCall, xAiToolOutputText(toolCall));
808 if (resultId !== undefined) ids.push(resultId);
809 return [...new Set(ids)];
810}
811
812/** Identity of the task the get_output envelope actually reports (not the request list). */
813function resultTaskIdFromGetOutputTool(
814 toolCall: AcpToolCallState,
815 output: string | undefined,
816): string | undefined {
817 const rawOutput = unknownRecord(toolCall.data.rawOutput);
818 const result = unknownRecord(rawOutput?.Result) ?? unknownRecord(rawOutput?.result);
819 const structured =
820 nonEmptyString(result?.task_id) ??
821 nonEmptyString(result?.taskId) ??
822 nonEmptyString(rawOutput?.task_id) ??
823 nonEmptyString(rawOutput?.taskId);
824 if (structured !== undefined && new RegExp(`^${XAI_UUID_RE}$`, "i").test(structured)) {
825 return structured;
826 }
827 if (output === undefined) return undefined;
828 const taskHeader = firstUuidMatch(
829 output,
830 new RegExp(`===\\s*Task\\s+(${XAI_UUID_RE})\\s*===`, "i"),
831 );
832 return taskHeader;
833}
834
835function resultFromGetOutputTool(
836 toolCall: AcpToolCallState,
837 output: string | undefined,
838): string | null {
839 const rawOutput = unknownRecord(toolCall.data.rawOutput);
840 const result = unknownRecord(rawOutput?.Result) ?? unknownRecord(rawOutput?.result);
841 const structured =
842 nonEmptyString(result?.output) ??
843 nonEmptyString(result?.text) ??
844 decodeByteText(result?.output);
845 if (structured !== undefined) {
846 // Drop trailing machine meta blocks when present.
847 const cleaned = structured
848 .replace(/<subagent_meta>[\s\S]*?<\/subagent_meta>/gi, "")
849 .replace(/<subagent_result>[\s\S]*?<\/subagent_result>/gi, "")
850 .trim();
851 if (cleaned.length > 0) return cleaned;
852 return null;
853 }
854 if (output === undefined) return null;
855 const marker = output.match(/=== Output ===\s*([\s\S]*)$/i);
856 const body = (marker?.[1] ?? output).trim();
857 return body.length > 0 ? body : null;
858}
859
860function statusFromGetOutputTool(
861 toolCall: AcpToolCallState,
862 output: string | undefined,
863): "running" | "completed" | "failed" {
864 if (toolCall.status === "failed") return "failed";
865 const rawOutput = unknownRecord(toolCall.data.rawOutput);
866 const result = unknownRecord(rawOutput?.Result) ?? unknownRecord(rawOutput?.result);
867 const structuredStatus = nonEmptyString(result?.status)?.toLowerCase();
868 if (structuredStatus === "completed" || structuredStatus === "success") return "completed";
869 if (structuredStatus === "failed" || structuredStatus === "error") return "failed";
870 if (structuredStatus === "running" || structuredStatus === "pending") return "running";
871 if (typeof result?.exit_code === "number") {
872 return result.exit_code === 0 ? "completed" : "failed";
873 }
874 if (output !== undefined) {
875 if (/Status:\s*failed/i.test(output) || /Exit Code:\s*-?(?!0\b)\d+/i.test(output)) {
876 return "failed";
877 }
878 if (/Status:\s*completed/i.test(output) || /Status:\s*success/i.test(output)) {
879 return "completed";
880 }
881 if (/Status:\s*running/i.test(output) || /Status:\s*pending/i.test(output)) {
882 return "running";
883 }
884 }
885 // A completed get_output tool call only means the poll RPC returned. Without
886 // a task-level terminal status or exit code, the requested task is still
887 // running and must remain registered for subsequent polling.
888 return "running";
889}
890
891/**
892 * Recognizes Grok Task / spawn_subagent envelopes and get_command hydration
893 * without teaching the generic ACP adapter about xAI tool names.
894 *
895 * Current Grok CLI returns spawn_subagent immediately with a background ACK
896 * (`subagent_id: ...`). That must stay `running` until child work finishes or
897 * get_command_or_subagent_output reports a terminal status.
898 */
899export function extractXAiAcpSubagentUpdate(
900 toolCall: AcpToolCallState,
901): XAiAcpSubagentUpdate | undefined {
902 const rawInput = unknownRecord(toolCall.data.rawInput);
903 const output = xAiToolOutputText(toolCall);
904
905 if (isXAiGetSubagentOutputTool(toolCall, rawInput)) {
906 const taskIds = taskIdsFromGetOutputTool(toolCall, rawInput);
907 // Prefer the completed task identity from the result/header over the first
908 // requested task_ids entry (one call can poll multiple task_ids).
909 const childSessionId =
910 resultTaskIdFromGetOutputTool(toolCall, output) ??
911 taskIds[0] ??
912 extractXAiChildSessionId(output);
913 if (childSessionId === null) return undefined;
914 // Prefer the durable subagent row as the completion surface (Claude/Codex
915 // style). Suppress the noisy TaskOutput tool card; monitor hydration is
916 // handled separately via extractBackgroundTaskCompletion.
917 return {
918 nativeTaskId: toolCall.toolCallId,
919 prompt: "",
920 title: null,
921 model: null,
922 status: statusFromGetOutputTool(toolCall, output),
923 childSessionId,
924 result: resultFromGetOutputTool(toolCall, output),
925 suppressNormalTool: true,
926 };
927 }
928
929 if (!isXAiSpawnOrTaskTool(toolCall, rawInput)) return undefined;
930
931 const childSessionId = extractXAiChildSessionId(output);
932 const asyncSpawnAck = isXAiAsyncSpawnAck(output);
933 const legacyResult =
934 output
935 ?.replace(new RegExp(`(?:^|\\n)\\s*Agent ID:\\s*${XAI_UUID_RE}[^\\n]*(?:\\n|$)`, "gi"), "\n")
936 .replace(
937 new RegExp(`(?:^|\\n)\\s*subagent_id:\\s*${XAI_UUID_RE}[^\\n]*(?:\\n|$)`, "gi"),
938 "\n",
939 )
940 .trim() || null;
941
942 let status: "running" | "completed" | "failed";
943 if (toolCall.status === "failed") {
944 status = "failed";
945 } else if (asyncSpawnAck) {
946 // Spawn RPC completed, but the child is still running.
947 status = "running";
948 } else if (toolCall.status === "completed") {
949 status = "completed";
950 } else {
951 status = "running";
952 }
953
954 return {
955 nativeTaskId: toolCall.toolCallId,
956 prompt: nonEmptyString(rawInput?.prompt) ?? "",
957 title: nonEmptyString(rawInput?.description) ?? null,
958 model: nonEmptyString(rawInput?.model) ?? null,
959 status,
960 childSessionId,
961 result: asyncSpawnAck ? null : legacyResult,
962 suppressNormalTool: true,
963 };
964}
965
966const XAiAskUserQuestionOption = Schema.Struct({
967 label: Schema.String,
968 description: Schema.optional(Schema.String),
969 preview: Schema.optional(Schema.String),
970 id: Schema.optional(Schema.String),
971});
972
973const XAiAskUserQuestion = Schema.Struct({
974 id: Schema.optional(Schema.String),
975 question: Schema.String,
976 options: Schema.Array(XAiAskUserQuestionOption),
977 multiSelect: Schema.optional(Schema.NullOr(Schema.Boolean)),
978});
979
980const XAiAskUserQuestionParams = Schema.Struct({
981 sessionId: Schema.String,
982 toolCallId: Schema.String,
983 questions: Schema.Array(XAiAskUserQuestion),
984 mode: Schema.Literals(["default", "plan"]),
985});
986
987const XAiWrappedAskUserQuestionParams = Schema.Struct({
988 method: Schema.Literals(["x.ai/ask_user_question", "_x.ai/ask_user_question"]),
989 params: XAiAskUserQuestionParams,
990});
991
992export const XAiAskUserQuestionRequest = Schema.Union([
993 XAiAskUserQuestionParams,
994 XAiWrappedAskUserQuestionParams,
995]);
996
997type XAiAskUserQuestionRequestParams = typeof XAiAskUserQuestionParams.Type;
998type XAiAskUserQuestionRequest = typeof XAiAskUserQuestionRequest.Type;
999
1000// ---------------------------------------------------------------------------
1001// x.ai/exit_plan_mode — plan approval gate (mirrors Grok Build TUI plan window)
1002// ---------------------------------------------------------------------------
1003
1004const XAiExitPlanModeParams = Schema.Struct({
1005 sessionId: Schema.String,
1006 toolCallId: Schema.String,
1007 planContent: Schema.optional(Schema.NullOr(Schema.String)),
1008});
1009
1010const XAiWrappedExitPlanModeParams = Schema.Struct({
1011 method: Schema.Literals(["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"]),
1012 params: XAiExitPlanModeParams,
1013});
1014
1015export const XAiExitPlanModeRequest = Schema.Union([
1016 XAiExitPlanModeParams,
1017 XAiWrappedExitPlanModeParams,
1018]);
1019
1020type XAiExitPlanModeRequestParams = typeof XAiExitPlanModeParams.Type;
1021type XAiExitPlanModeRequest = typeof XAiExitPlanModeRequest.Type;
1022
1023function unwrapExitPlanModeParams(params: XAiExitPlanModeRequest): XAiExitPlanModeRequestParams {
1024 return "params" in params ? params.params : params;
1025}
1026
1027/** Empty-state copy when Grok exits plan mode without a plan file. */
1028export const XAI_EMPTY_PLAN_MARKDOWN =
1029 "# No plan written yet\n\n(The agent exited plan mode without writing a plan.)";
1030
1031export function extractXAiExitPlanMarkdown(
1032 params: XAiExitPlanModeRequest,
1033 fallback?: string | null,
1034): string {
1035 const content = unwrapExitPlanModeParams(params).planContent;
1036 const fromRequest = typeof content === "string" ? trimmed(content) : undefined;
1037 if (fromRequest) {
1038 return fromRequest;
1039 }
1040 const fromFallback = fallback?.trim();
1041 if (fromFallback && fromFallback.length > 0) {
1042 return fromFallback;
1043 }
1044 return XAI_EMPTY_PLAN_MARKDOWN;
1045}
1046
1047export type XAiExitPlanModeOutcome = "approved" | "abandoned" | "request_changes";
1048
1049export interface XAiExitPlanModeResponse {
1050 readonly outcome: XAiExitPlanModeOutcome;
1051 readonly feedback?: string;
1052}
1053
1054/**
1055 * Client captured the plan for T3's proposed-plan card. Abandon the native
1056 * Grok plan-approval gate so the turn unblocks; the user implements via T3 UI.
1057 */
1058export function makeXAiExitPlanModeCapturedResponse(feedback?: string): XAiExitPlanModeResponse {
1059 return {
1060 outcome: "abandoned",
1061 feedback:
1062 feedback ??
1063 "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.",
1064 };
1065}
1066
1067function normalizeFsPath(value: string): string {
1068 return value.trim().replace(/\\/g, "/").replace(/\/+$/, "");
1069}
1070
1071function pathHasTraversalSegment(normalized: string): boolean {
1072 return normalized.split("/").includes("..");
1073}
1074
1075function addGrokSessionPrefix(
1076 prefixes: Set<string>,
1077 homeOrRoot: string,
1078 nestedGrokDir: boolean,
1079): void {
1080 const root = normalizeFsPath(homeOrRoot);
1081 if (!root) {
1082 return;
1083 }
1084 prefixes.add(nestedGrokDir ? `${root}/.grok/sessions/` : `${root}/sessions/`);
1085}
1086
1087/** Injected host bits so these helpers stay off `process.platform` / `process.env`. */
1088export interface GrokPlanPathHost {
1089 readonly platform: NodeJS.Platform;
1090 readonly environment: NodeJS.ProcessEnv;
1091}
1092
1093function grokPlanSessionPrefixes(environment: NodeJS.ProcessEnv): ReadonlySet<string> {
1094 const prefixes = new Set<string>();
1095 addGrokSessionPrefix(prefixes, NodeOS.homedir(), true);
1096 addGrokSessionPrefix(prefixes, "~", true);
1097 addGrokSessionPrefix(prefixes, environment.HOME ?? "", true);
1098 addGrokSessionPrefix(prefixes, environment.USERPROFILE ?? "", true);
1099 // ACP mock and isolated Grok spawns use a HOME that is not the server process home.
1100 addGrokSessionPrefix(prefixes, "/tmp/mock-home", true);
1101 const grokHome = environment.GROK_HOME ?? "";
1102 addGrokSessionPrefix(prefixes, grokHome, false);
1103 addGrokSessionPrefix(prefixes, grokHome, true);
1104 return prefixes;
1105}
1106
1107const CANONICAL_HOME_GROK_SESSION_PATH =
1108 /^(?:\/home\/[^/]+|\/Users\/[^/]+|[a-zA-Z]:\/Users\/[^/]+)\/\.grok\/sessions\/(?:[^/]+\/)+plan\.md$/;
1109const CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH = new RegExp(
1110 CANONICAL_HOME_GROK_SESSION_PATH.source,
1111 "i",
1112);
1113
1114/**
1115 * True when a path is Grok's session plan file under a Grok home
1116 * (`~/.grok/sessions/.../plan.md`, `$HOME/.grok/sessions/...`, or `$GROK_HOME/sessions/...`).
1117 * Deliberately does not match workspace files named `plan.md` (e.g. docs/plan.md
1118 * or a repo-local `.grok/sessions/.../plan.md`).
1119 */
1120export function isGrokPlanMarkdownPath(
1121 path: string | undefined | null,
1122 host: GrokPlanPathHost,
1123): boolean {
1124 if (typeof path !== "string") {
1125 return false;
1126 }
1127 const normalized = path.trim().replace(/\\/g, "/");
1128 const win32 = host.platform === "win32";
1129 const haystack = win32 ? normalized.toLowerCase() : normalized;
1130 if (
1131 normalized.length === 0 ||
1132 !haystack.endsWith("/plan.md") ||
1133 pathHasTraversalSegment(normalized)
1134 ) {
1135 return false;
1136 }
1137 for (const prefix of grokPlanSessionPrefixes(host.environment)) {
1138 const needle = win32 ? prefix.toLowerCase() : prefix;
1139 if (!haystack.startsWith(needle)) {
1140 continue;
1141 }
1142 const rest = haystack.slice(needle.length);
1143 // Session layout: <home>/.grok/sessions/<encoded-cwd>/<session-id>/plan.md
1144 if (rest !== "plan.md" && rest.endsWith("plan.md")) {
1145 return true;
1146 }
1147 }
1148 return (
1149 win32 ? CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH : CANONICAL_HOME_GROK_SESSION_PATH
1150 ).test(haystack);
1151}
1152
1153/**
1154 * Extract plan markdown from a Grok write/edit tool call targeting plan.md.
1155 * Used so T3 can show the plan while plan mode is still active (before exit).
1156 */
1157export function extractGrokPlanMarkdownFromToolCallData(
1158 data: Record<string, unknown> | undefined,
1159 host: GrokPlanPathHost,
1160): string | undefined {
1161 if (!data) {
1162 return undefined;
1163 }
1164
1165 let sawPlanWrite = false;
1166 const takePlanText = (
1167 value: string | undefined,
1168 filePath: string | undefined,
1169 ): string | undefined => {
1170 if (!isGrokPlanMarkdownPath(filePath, host) || value === undefined) {
1171 return undefined;
1172 }
1173 sawPlanWrite = true;
1174 const trimmed = value.trim();
1175 return trimmed.length > 0 ? trimmed : undefined;
1176 };
1177
1178 const rawInput = data.rawInput;
1179 if (isRecord(rawInput)) {
1180 const filePath =
1181 (typeof rawInput.file_path === "string" ? rawInput.file_path : undefined) ??
1182 (typeof rawInput.path === "string" ? rawInput.path : undefined);
1183 const content = typeof rawInput.content === "string" ? rawInput.content : undefined;
1184 const fromRaw = takePlanText(content, filePath);
1185 if (fromRaw !== undefined) {
1186 return fromRaw;
1187 }
1188 }
1189
1190 const content = data.content;
1191 if (Array.isArray(content)) {
1192 for (const block of content) {
1193 if (!isRecord(block) || block.type !== "diff") {
1194 continue;
1195 }
1196 const path = typeof block.path === "string" ? block.path : undefined;
1197 const newText = typeof block.newText === "string" ? block.newText : undefined;
1198 const fromDiff = takePlanText(newText, path);
1199 if (fromDiff !== undefined) {
1200 return fromDiff;
1201 }
1202 }
1203 }
1204
1205 return sawPlanWrite ? "" : undefined;
1206}
1207
1208function isRecord(value: unknown): value is Record<string, unknown> {
1209 return typeof value === "object" && value !== null && !Array.isArray(value);
1210}
1211
1212function trimmed(value: string | undefined): string | undefined {
1213 const text = value?.trim();
1214 return text && text.length > 0 ? text : undefined;
1215}
1216
1217function unwrapAskUserQuestionParams(
1218 params: XAiAskUserQuestionRequest,
1219): XAiAskUserQuestionRequestParams {
1220 return "params" in params ? params.params : params;
1221}
1222
1223export function extractXAiAskUserQuestions(
1224 params: XAiAskUserQuestionRequest,
1225): ReadonlyArray<UserInputQuestion> {
1226 return unwrapAskUserQuestionParams(params).questions.map((question) => ({
1227 id: question.id ?? question.question,
1228 header: "Question",
1229 question: question.question,
1230 multiSelect: question.multiSelect === true,
1231 options:
1232 question.options.length > 0
1233 ? question.options.map((option) => ({
1234 label: option.label,
1235 description: option.description ?? option.label,
1236 }))
1237 : [{ label: "OK", description: "Continue" }],
1238 }));
1239}
1240
1241export function extractXAiAskUserQuestionIdentity(params: XAiAskUserQuestionRequest): {
1242 readonly sessionId: string;
1243 readonly toolCallId: string;
1244} {
1245 const unwrapped = unwrapAskUserQuestionParams(params);
1246 return {
1247 sessionId: unwrapped.sessionId,
1248 toolCallId: unwrapped.toolCallId,
1249 };
1250}
1251
1252interface XAiAskUserQuestionAnnotation {
1253 readonly preview?: string;
1254 readonly notes?: string;
1255}
1256
1257interface XAiAskUserQuestionAcceptedResponse {
1258 readonly outcome: "accepted";
1259 readonly answers: Record<string, ReadonlyArray<string>>;
1260 readonly annotations?: Record<string, XAiAskUserQuestionAnnotation>;
1261}
1262
1263interface XAiAskUserQuestionCancelledResponse {
1264 readonly outcome: "cancelled";
1265}
1266
1267interface NormalizedXAiAnswer {
1268 readonly questionText: string;
1269 readonly selectedLabels: ReadonlyArray<string>;
1270 readonly annotation?: XAiAskUserQuestionAnnotation;
1271}
1272
1273function answerValues(answer: unknown): ReadonlyArray<string> {
1274 if (Array.isArray(answer)) {
1275 return answer.flatMap((entry) => {
1276 const text = typeof entry === "string" ? trimmed(entry) : undefined;
1277 return text ? [text] : [];
1278 });
1279 }
1280 const text = typeof answer === "string" ? trimmed(answer) : undefined;
1281 return text ? [text] : [];
1282}
1283
1284function normalizeAnswerForXAi(
1285 question: XAiAskUserQuestionRequestParams["questions"][number],
1286 answer: unknown,
1287): NormalizedXAiAnswer | undefined {
1288 const values = answerValues(answer);
1289 if (values.length === 0) {
1290 return undefined;
1291 }
1292
1293 const optionByLabel = new Map(question.options.map((option) => [option.label, option]));
1294 const resolvedValues = values.map((value) => ({
1295 value,
1296 option: optionByLabel.get(value),
1297 }));
1298 const selectedLabels = resolvedValues.flatMap(({ option }) => (option ? [option.label] : []));
1299 const notes = resolvedValues.flatMap(({ option, value }) => (option ? [] : [value]));
1300 const preview =
1301 question.multiSelect === true
1302 ? undefined
1303 : resolvedValues.map(({ option }) => trimmed(option?.preview)).find((value) => value);
1304
1305 const annotation =
1306 preview || notes.length > 0
1307 ? {
1308 ...(preview ? { preview } : {}),
1309 ...(notes.length > 0 ? { notes: notes.join("\n") } : {}),
1310 }
1311 : undefined;
1312
1313 return {
1314 questionText: question.question,
1315 selectedLabels: selectedLabels.length > 0 ? selectedLabels : ["Other"],
1316 ...(annotation ? { annotation } : {}),
1317 };
1318}
1319
1320function findQuestionAnswer(
1321 answers: ProviderUserInputAnswers,
1322 question: XAiAskUserQuestionRequestParams["questions"][number],
1323): unknown {
1324 const key = question.id ?? question.question;
1325 return answers[key] ?? answers[question.question];
1326}
1327
1328export function makeXAiAskUserQuestionResponse(
1329 params: XAiAskUserQuestionRequest,
1330 answers: ProviderUserInputAnswers,
1331): XAiAskUserQuestionAcceptedResponse {
1332 const questions = unwrapAskUserQuestionParams(params).questions;
1333 const normalized = questions.flatMap((question) => {
1334 const entry = normalizeAnswerForXAi(question, findQuestionAnswer(answers, question));
1335 return entry ? [entry] : [];
1336 });
1337 const annotations = Object.fromEntries(
1338 normalized.flatMap((entry) =>
1339 entry.annotation ? [[entry.questionText, entry.annotation] as const] : [],
1340 ),
1341 );
1342
1343 return {
1344 outcome: "accepted",
1345 answers: Object.fromEntries(
1346 normalized.map((entry) => [entry.questionText, entry.selectedLabels]),
1347 ),
1348 ...(Object.keys(annotations).length > 0 ? { annotations } : {}),
1349 };
1350}
1351
1352export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCancelledResponse {
1353 return { outcome: "cancelled" };
1354}
1355
1356function promptIdFromResponse(response: EffectAcpSchema.PromptResponse): string | undefined {
1357 const meta = response._meta;
1358 if (meta === null || typeof meta !== "object") {
1359 return undefined;
1360 }
1361 const promptId = meta.promptId ?? meta.requestId;
1362 return typeof promptId === "string" && promptId.length > 0 ? promptId : undefined;
1363}
1364
1365function normalizeXAiStopReason(value: string | undefined): EffectAcpSchema.StopReason {
1366 switch (value) {
1367 case "cancelled":
1368 case "end_turn":
1369 case "max_tokens":
1370 case "max_turn_requests":
1371 case "refusal":
1372 return value;
1373 default:
1374 return "end_turn";
1375 }
1376}
1377
1378function promptResponseFromXAi(
1379 notification: XAiPromptCompleteNotification,
1380): EffectAcpSchema.PromptResponse {
1381 const stopReason = normalizeXAiStopReason(notification.stopReason);
1382 // Values originate from a decoded JSON-RPC payload, so they are JSON even
1383 // though the x.ai schema types agentResult as unknown.
1384 const meta: Record<string, Schema.Json> = {
1385 sessionId: notification.sessionId,
1386 };
1387 if (notification.stopReason === undefined) {
1388 meta[xAiStopReasonMissingMetaKey] = true;
1389 }
1390 if (notification.promptId !== undefined) {
1391 meta.promptId = notification.promptId;
1392 meta.requestId = notification.promptId;
1393 }
1394 if (notification.agentResult !== undefined) {
1395 meta.agentResult = notification.agentResult as Schema.Json;
1396 }
1397 return {
1398 stopReason,
1399 _meta: meta,
1400 };
1401}
1402
1403/**
1404 * Grok settles a failed prompt with `stopReason: "error"` and the provider's
1405 * message in `agentResult` before its `session/prompt` RPC error arrives. The
1406 * completion wins the race, so it must carry the failure itself.
1407 */
1408function xAiPromptFailure(
1409 notification: XAiPromptCompleteNotification,
1410): EffectAcpErrors.AcpRequestError | null {
1411 if (notification.stopReason === "rate_limit") {
1412 return new EffectAcpErrors.AcpRequestError({
1413 code: xAiRateLimitedErrorCode,
1414 errorMessage: "Grok usage limit reached. Try again later.",
1415 });
1416 }
1417 if (notification.stopReason === "error") {
1418 // Grok's raw result is unbounded provider text: keep it only as the cause.
1419 const agentResult = nonEmptyString(notification.agentResult);
1420 return new EffectAcpErrors.AcpRequestError({
1421 code: -32603,
1422 errorMessage: "Grok ended the turn with an error.",
1423 operation: "receive-response",
1424 ...(agentResult === undefined ? {} : { cause: new XAiPromptFailureText(agentResult) }),
1425 });
1426 }
1427 return null;
1428}
1429
1430/**
1431 * Grok's own text for a failed prompt. A plain Error rather than a schema
1432 * error so the unbounded provider text never becomes a structured attribute;
1433 * it is read back only at the presentation boundary.
1434 */
1435export class XAiPromptFailureText extends Error {}
1436
1437const registerXAiPromptCompletionFallback = (
1438 pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>,
1439 sessionId: string,
1440 promptId: string,
1441) =>
1442 Deferred.make<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpRequestError>().pipe(
1443 Effect.tap((deferred) =>
1444 Ref.update(pendingRef, (pending) => [...pending, { sessionId, promptId, deferred }]),
1445 ),
1446 Effect.map((deferred) => ({ deferred, promptId })),
1447 );
1448
1449const unregisterXAiPromptCompletionFallback = (
1450 pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>,
1451 deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpRequestError>,
1452) => Ref.update(pendingRef, (pending) => pending.filter((entry) => entry.deferred !== deferred));
1453
1454const abortPendingPromptCompletions = (
1455 pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>,
1456 sessionId?: string,
1457) =>
1458 Ref.modify(pendingRef, (pending) => {
1459 const [toAbort, remaining] = pending.reduce<
1460 [ReadonlyArray<PendingXAiPromptCompletion>, ReadonlyArray<PendingXAiPromptCompletion>]
1461 >(
1462 ([aborting, kept], entry) =>
1463 sessionId === undefined || entry.sessionId === sessionId
1464 ? [[...aborting, entry], kept]
1465 : [aborting, [...kept, entry]],
1466 [[], []],
1467 );
1468 if (toAbort.length === 0) {
1469 return [Effect.void, pending] as const;
1470 }
1471 return [
1472 Effect.forEach(
1473 toAbort,
1474 (entry) =>
1475 Deferred.succeed(
1476 entry.deferred,
1477 promptResponseFromXAi({
1478 sessionId: entry.sessionId,
1479 promptId: entry.promptId,
1480 stopReason: "cancelled",
1481 agentResult: null,
1482 }),
1483 ),
1484 { concurrency: "unbounded" },
1485 ).pipe(Effect.asVoid),
1486 remaining,
1487 ] as const;
1488 }).pipe(Effect.flatten);
1489
1490const resolveXAiPromptCompletionFallback = ({
1491 pendingRef,
1492 completedPromptIdsRef,
1493 notification,
1494}: {
1495 readonly pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>;
1496 readonly completedPromptIdsRef: Ref.Ref<ReadonlyArray<string>>;
1497 readonly notification: XAiPromptCompleteNotification;
1498}) =>
1499 Ref.get(completedPromptIdsRef).pipe(
1500 Effect.flatMap((completedPromptIds) => {
1501 if (
1502 notification.promptId !== undefined &&
1503 completedPromptIds.includes(notification.promptId)
1504 ) {
1505 return Effect.void;
1506 }
1507 return Ref.modify(pendingRef, (pending) => {
1508 const index =
1509 notification.promptId !== undefined
1510 ? pending.findIndex(
1511 (entry) =>
1512 entry.sessionId === notification.sessionId &&
1513 entry.promptId === notification.promptId,
1514 )
1515 : (() => {
1516 const sessionPendingIndexes = pending.flatMap((entry, entryIndex) =>
1517 entry.sessionId === notification.sessionId ? [entryIndex] : [],
1518 );
1519 if (sessionPendingIndexes.length !== 1) {
1520 return -1;
1521 }
1522 return sessionPendingIndexes[0] ?? -1;
1523 })();
1524 if (index < 0) {
1525 return [Effect.void, pending] as const;
1526 }
1527 const entry = pending[index];
1528 if (!entry) {
1529 return [Effect.void, pending] as const;
1530 }
1531 const failure = xAiPromptFailure(notification);
1532 const settle =
1533 failure !== null
1534 ? Deferred.fail(entry.deferred, failure).pipe(Effect.asVoid)
1535 : Deferred.succeed(entry.deferred, promptResponseFromXAi(notification)).pipe(
1536 Effect.asVoid,
1537 );
1538 return [settle, [...pending.slice(0, index), ...pending.slice(index + 1)]] as const;
1539 }).pipe(Effect.flatten);
1540 }),
1541 );
1542
1543const rememberCompletedXAiPromptId = (
1544 completedPromptIdsRef: Ref.Ref<ReadonlyArray<string>>,
1545 response: EffectAcpSchema.PromptResponse,
1546 fallbackPromptId: string,
1547) => {
1548 const promptId = promptIdFromResponse(response) ?? fallbackPromptId;
1549 if (promptId.length === 0) {
1550 return Effect.void;
1551 }
1552 return Ref.update(completedPromptIdsRef, (completedPromptIds) => {
1553 if (completedPromptIds.includes(promptId)) {
1554 return completedPromptIds;
1555 }
1556 return [...completedPromptIds, promptId].slice(-completedXAiPromptIdLimit);
1557 });
1558};
1559
1560/**
1561 * Grok-specific ACP runtime wrapper. Races `session/prompt` against root-matched
1562 * terminal notifications:
1563 * - session notification/update + `turn_completed` + matching `prompt_id`
1564 * - `x.ai/session/prompt_complete` (open-source fire-and-forget signal)
1565 * - `_x.ai/session/prompt_complete` (released-build alias)
1566 *
1567 * Pending entries are keyed by root sessionId + T3-injected promptId, so
1568 * foreign/child sessions and `task-completed-*` ids do not settle the root turn.
1569 */
1570export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletionRuntime")(
1571 function* (runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]) {
1572 // Grok sends turn completion and other session updates (subagent_finished)
1573 // on the same extension methods, and the client keeps one handler per
1574 // method. This wrapper owns those methods: it settles prompts itself, then
1575 // passes each notification to the handler registered on the wrapped runtime.
1576 const sessionNotificationHandlers = new Map<
1577 string,
1578 (params: unknown) => Effect.Effect<void, EffectAcpErrors.AcpError>
1579 >();
1580 let nextPromptFallbackId = 0;
1581 const allocatePromptFallbackId = Effect.sync(() => {
1582 nextPromptFallbackId += 1;
1583 return `t3-xai-prompt-${nextPromptFallbackId}`;
1584 });
1585 const pendingXAiPromptCompletionsRef = yield* Ref.make<
1586 ReadonlyArray<PendingXAiPromptCompletion>
1587 >([]);
1588 const completedXAiPromptIdsRef = yield* Ref.make<ReadonlyArray<string>>([]);
1589
1590 const settleFromPromptComplete = (notification: XAiPromptCompleteNotification) =>
1591 resolveXAiPromptCompletionFallback({
1592 pendingRef: pendingXAiPromptCompletionsRef,
1593 completedPromptIdsRef: completedXAiPromptIdsRef,
1594 notification,
1595 }).pipe(Effect.catch(() => Effect.void));
1596
1597 yield* Effect.forEach(
1598 ["x.ai/session/prompt_complete", "_x.ai/session/prompt_complete"] as const,
1599 (method) =>
1600 runtime.handleExtNotification(
1601 method,
1602 XAiPromptCompleteNotification,
1603 settleFromPromptComplete,
1604 ),
1605 { discard: true },
1606 );
1607
1608 yield* Effect.forEach(
1609 xAiSessionNotificationMethods,
1610 (method) =>
1611 runtime.handleExtNotification(method, Schema.Unknown, (params) =>
1612 decodeXAiSessionUpdateNotification(params).pipe(
1613 Effect.flatMap((notification) => {
1614 const complete = xAiPromptCompleteFromSessionUpdate(notification);
1615 return complete === null ? Effect.void : settleFromPromptComplete(complete);
1616 }),
1617 Effect.ignore,
1618 Effect.andThen(
1619 Effect.suspend(
1620 () => sessionNotificationHandlers.get(method)?.(params) ?? Effect.void,
1621 ),
1622 ),
1623 ),
1624 ),
1625 { discard: true },
1626 );
1627
1628 return {
1629 ...runtime,
1630 handleExtNotification: (method, payload, handler) =>
1631 xAiSessionNotificationMethods.some((owned) => owned === method)
1632 ? Effect.sync(() => {
1633 sessionNotificationHandlers.set(method, (params) =>
1634 Schema.decodeUnknownEffect(payload)(params).pipe(
1635 Effect.mapError((error) =>
1636 EffectAcpErrors.AcpProtocolParseError.fromSchemaError(
1637 "decode-notification-payload",
1638 method,
1639 error,
1640 ),
1641 ),
1642 Effect.flatMap(handler),
1643 ),
1644 );
1645 })
1646 : runtime.handleExtNotification(method, payload, handler),
1647 prompt: (payload, promptOptions?) =>
1648 Effect.gen(function* () {
1649 const started = yield* runtime.start();
1650 const promptId = yield* allocatePromptFallbackId;
1651 const fallback = yield* registerXAiPromptCompletionFallback(
1652 pendingXAiPromptCompletionsRef,
1653 started.sessionId,
1654 promptId,
1655 );
1656 const cancelledResponse = promptResponseFromXAi({
1657 sessionId: started.sessionId,
1658 promptId: fallback.promptId,
1659 stopReason: "cancelled",
1660 agentResult: null,
1661 });
1662 const promptRpcFiber = yield* runtime
1663 .prompt(
1664 {
1665 ...payload,
1666 _meta: {
1667 ...payload._meta,
1668 promptId: fallback.promptId,
1669 requestId: fallback.promptId,
1670 },
1671 },
1672 promptOptions,
1673 )
1674 .pipe(Effect.forkChild);
1675 return yield* Effect.raceFirst(
1676 Fiber.join(promptRpcFiber).pipe(
1677 Effect.catchCause((cause) =>
1678 Cause.hasInterruptsOnly(cause)
1679 ? Effect.succeed(cancelledResponse)
1680 : Effect.failCause(cause),
1681 ),
1682 ),
1683 Deferred.await(fallback.deferred),
1684 ).pipe(
1685 Effect.tap((response) =>
1686 rememberCompletedXAiPromptId(completedXAiPromptIdsRef, response, fallback.promptId),
1687 ),
1688 Effect.ensuring(
1689 Effect.gen(function* () {
1690 yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore);
1691 yield* unregisterXAiPromptCompletionFallback(
1692 pendingXAiPromptCompletionsRef,
1693 fallback.deferred,
1694 );
1695 }),
1696 ),
1697 );
1698 }),
1699 cancel: Effect.gen(function* () {
1700 // Do not call start here: cancellation must be able to reach the
1701 // underlying runtime when startup is stalled. Pending entries already
1702 // carry their session ids, and this runtime owns all of them.
1703 yield* abortPendingPromptCompletions(pendingXAiPromptCompletionsRef);
1704 yield* runtime.cancel;
1705 }),
1706 } satisfies AcpSessionRuntime.AcpSessionRuntime["Service"];
1707 },
1708);
1709
1710function promptResponseHasMissingXAiStopReason(response: EffectAcpSchema.PromptResponse): boolean {
1711 const meta = response._meta;
1712 return meta !== null && typeof meta === "object" && meta[xAiStopReasonMissingMetaKey] === true;
1713}
packages/provider-grok/src/server/xaiBackgroundTasks.test.ts created+202
...@@ -0,0 +1,202 @@
1import { describe, expect, it } from "vite-plus/test";
2import { TurnId } from "@t3tools/contracts";
3
4import {
5 buildGrokBackgroundTaskEvents,
6 type GrokBackgroundTaskRecord,
7} from "./xaiBackgroundTasks.ts";
8
9const turnId = TurnId.make("turn-1");
10const monitor = { type: "Monitor", taskId: "monitor-1", timeoutMs: 60_000 };
11const shell = { type: "BackgroundTaskStarted", task_id: "shell-1", command: "sleep 40" };
12
13function mapper() {
14 const tasks = new Map<string, GrokBackgroundTaskRecord>();
15 const update = (
16 rawOutput: unknown,
17 overrides: Partial<Parameters<typeof buildGrokBackgroundTaskEvents>[0]> = {},
18 ) =>
19 buildGrokBackgroundTaskEvents({
20 tasks,
21 toolCallId: "call-1",
22 rawInput: { description: "Watch" },
23 rawOutput,
24 toolCallStatus: "completed",
25 turnId,
26 ...overrides,
27 });
28 return { tasks, update };
29}
30
31describe("Grok background tasks", () => {
32 it.each([
33 [monitor, "monitor-1", "monitor", "Watch"],
34 [shell, "shell-1", "shell", "sleep 40"],
35 ] as const)("starts and deduplicates %j", (output, id, taskType, description) => {
36 const { tasks, update } = mapper();
37 expect(update(output)).toEqual([
38 {
39 type: "task.started",
40 turnId,
41 payload: { taskId: id, taskType, description, title: description, toolUseId: "call-1" },
42 },
43 ]);
44 expect(update(output)).toEqual([]);
45 expect(tasks.size).toBe(1);
46 });
47
48 it("accepts automatic backgrounding before the tool becomes terminal", () => {
49 const { update } = mapper();
50 expect(update(shell, { toolCallStatus: "inProgress" })[0]?.type).toBe("task.started");
51 expect(update(monitor, { toolCallStatus: "inProgress" })).toEqual([]);
52 expect(update(monitor, { toolCallStatus: "failed" })).toEqual([]);
53 });
54
55 it.each([
56 ["running", null, "task.progress", undefined],
57 ["pending", null, "task.progress", undefined],
58 ["completed", 0, "task.completed", "completed"],
59 ["success", 0, "task.completed", "completed"],
60 ["succeeded", 0, "task.completed", "completed"],
61 ["failed", 1, "task.completed", "failed"],
62 ["error", 1, "task.completed", "failed"],
63 ["stopped", 137, "task.completed", "stopped"],
64 ["killed", 137, "task.completed", "stopped"],
65 ["cancelled", 137, "task.completed", "stopped"],
66 [undefined, 0, "task.completed", "completed"],
67 [undefined, 1, "task.completed", "failed"],
68 ])("maps poll status %s / exit %s", (status, exit_code, type, expectedStatus) => {
69 const { tasks, update } = mapper();
70 update(monitor);
71 const events = update({
72 type: "TaskOutput",
73 Result: {
74 task_id: "monitor-1",
75 command: "[monitor:Watch]",
76 status,
77 exit_code,
78 output: "\n result\nmore",
79 },
80 });
81 expect(events).toEqual([
82 {
83 type,
84 turnId,
85 payload: {
86 taskId: "monitor-1",
87 taskType: "monitor",
88 description: "Watch",
89 title: "Watch",
90 toolUseId: "call-1",
91 summary: "result",
92 ...(expectedStatus ? { status: expectedStatus } : {}),
93 },
94 },
95 ]);
96 expect(tasks.size).toBe(type === "task.progress" ? 1 : 0);
97 });
98
99 it.each([undefined, TurnId.make("turn-2")])(
100 "does not attribute old tasks to a later turn: %s",
101 (laterTurnId) => {
102 const { tasks, update } = mapper();
103 update(shell);
104 const events = update(
105 {
106 type: "TaskOutput",
107 Result: { task_id: "shell-1", command: "sleep 40", status: "completed" },
108 },
109 { turnId: laterTurnId },
110 );
111 expect(events).toHaveLength(1);
112 expect(events[0]?.turnId).toBeUndefined();
113 expect(tasks.size).toBe(0);
114 },
115 );
116
117 it("starts unknown poll tasks before progress or completion and ignores subagents", () => {
118 const { tasks, update } = mapper();
119 const events = update({
120 type: "TaskOutput",
121 MultiResult: {
122 results: [
123 { task_id: "shell-1", command: "sleep 40", status: "running" },
124 { task_id: "monitor-1", command: "[monitor] Watch", status: "completed" },
125 { task_id: "agent-1", command: "[subagent:executor] work", status: "running" },
126 ],
127 },
128 });
129 expect(events.map(({ type }) => type)).toEqual([
130 "task.started",
131 "task.progress",
132 "task.started",
133 "task.completed",
134 ]);
135 expect(events.map(({ payload }) => payload.taskType)).toEqual([
136 "shell",
137 "shell",
138 "monitor",
139 "monitor",
140 ]);
141 expect([...tasks.keys()]).toEqual(["shell-1"]);
142 expect(events.map((event) => event.turnId)).toEqual([
143 undefined,
144 undefined,
145 undefined,
146 undefined,
147 ]);
148 });
149
150 it("retires only successfully killed tasks, including mixed results", () => {
151 const { tasks, update } = mapper();
152 update(shell);
153 update(monitor);
154 const result = { type: "KillTask", Result: { task_id: "shell-1", outcome: "killed" } };
155 expect(update(result, { toolCallStatus: "failed" })).toEqual([]);
156 expect(tasks.size).toBe(2);
157 const events = update({
158 type: "KillTask",
159 MultiResult: {
160 results: [
161 result.Result,
162 { task_id: "monitor-1", outcome: "error" },
163 { task_id: "unknown", outcome: "killed" },
164 ],
165 },
166 });
167 expect(events).toEqual([
168 {
169 type: "task.completed",
170 turnId,
171 payload: {
172 taskId: "shell-1",
173 taskType: "shell",
174 description: "sleep 40",
175 title: "sleep 40",
176 toolUseId: "call-1",
177 status: "stopped",
178 },
179 },
180 ]);
181 expect([...tasks.keys()]).toEqual(["monitor-1"]);
182 });
183
184 it.each([
185 null,
186 [],
187 {},
188 { type: "Text", text: "subagent_id: fake\ntype: executor\ndescription: fake" },
189 { type: "Monitor", taskId: " " },
190 { type: "BackgroundTaskStarted", task_id: "shell-1" },
191 {
192 type: "TaskOutput",
193 MultiResult: {
194 results: [null, {}, { task_id: "task", command: "sleep 40", exit_code: Infinity }],
195 },
196 },
197 ])("ignores malformed or unrelated outputs: %j", (output) => {
198 const { tasks, update } = mapper();
199 expect(update(output)).toEqual([]);
200 expect(tasks.size).toBe(0);
201 });
202});
packages/provider-grok/src/server/xaiBackgroundTasks.ts created+156
...@@ -0,0 +1,156 @@
1import {
2 RuntimeTaskId,
3 type ProviderRuntimeTaskStartedEvent,
4 type ProviderRuntimeTaskProgressEvent,
5 type ProviderRuntimeTaskCompletedEvent,
6 type TurnId,
7} from "@t3tools/contracts";
8
9type TaskEvent =
10 | Pick<ProviderRuntimeTaskStartedEvent, "type" | "payload" | "turnId">
11 | Pick<ProviderRuntimeTaskProgressEvent, "type" | "payload" | "turnId">
12 | Pick<ProviderRuntimeTaskCompletedEvent, "type" | "payload" | "turnId">;
13
14export interface GrokBackgroundTaskRecord {
15 readonly payload: {
16 readonly taskId: RuntimeTaskId;
17 readonly taskType: "monitor" | "shell";
18 readonly description: string;
19 readonly title: string;
20 readonly toolUseId?: string;
21 };
22 readonly turnId: TurnId | undefined;
23}
24
25function record(value: unknown): Record<string, unknown> {
26 return typeof value === "object" && value !== null && !Array.isArray(value)
27 ? (value as Record<string, unknown>)
28 : {};
29}
30
31function text(value: unknown): string | undefined {
32 return typeof value === "string" ? value.trim() || undefined : undefined;
33}
34
35function lifecycle(status: unknown, exitCode: unknown) {
36 switch (text(status)?.toLowerCase()) {
37 case "pending":
38 case "running":
39 return "running";
40 case "completed":
41 case "success":
42 case "succeeded":
43 return "completed";
44 case "failed":
45 case "error":
46 return "failed";
47 case "stopped":
48 case "killed":
49 case "cancelled":
50 return "stopped";
51 default:
52 return typeof exitCode === "number" && Number.isFinite(exitCode)
53 ? exitCode === 0
54 ? "completed"
55 : "failed"
56 : undefined;
57 }
58}
59
60/** Map Grok's discriminated tool results, including notifications after the turn ends. */
61export function buildGrokBackgroundTaskEvents(input: {
62 readonly tasks: Map<string, GrokBackgroundTaskRecord>;
63 readonly toolCallId: string;
64 readonly rawInput: unknown;
65 readonly rawOutput: unknown;
66 readonly toolCallStatus: string | undefined;
67 readonly turnId?: TurnId | undefined;
68}): TaskEvent[] {
69 const { tasks, toolCallId, toolCallStatus, turnId } = input;
70 const output = record(input.rawOutput);
71 const events: TaskEvent[] = [];
72 if (
73 toolCallStatus !== "completed" &&
74 toolCallStatus !== "failed" &&
75 output.type !== "BackgroundTaskStarted"
76 ) {
77 return events;
78 }
79 const attribution = (task: GrokBackgroundTaskRecord) =>
80 task.turnId !== undefined && task.turnId === turnId ? { turnId } : {};
81 const start = (
82 id: string,
83 taskType: "monitor" | "shell",
84 description: string,
85 toolUseId?: string,
86 ) => {
87 const known = tasks.get(id);
88 if (known) return known;
89 const task: GrokBackgroundTaskRecord = {
90 payload: {
91 taskId: RuntimeTaskId.make(id),
92 taskType,
93 description,
94 title: description,
95 ...(toolUseId ? { toolUseId } : {}),
96 },
97 // Polls can rediscover older tasks without establishing their originating turn.
98 turnId: toolUseId ? turnId : undefined,
99 };
100 tasks.set(id, task);
101 events.push({ type: "task.started", payload: task.payload, ...attribution(task) });
102 return task;
103 };
104 const complete = (
105 task: GrokBackgroundTaskRecord,
106 status: "completed" | "failed" | "stopped",
107 summary?: string,
108 ) => {
109 tasks.delete(task.payload.taskId);
110 events.push({
111 type: "task.completed",
112 payload: { ...task.payload, status, ...(summary ? { summary } : {}) },
113 ...attribution(task),
114 });
115 };
116
117 if (output.type === "Monitor" && toolCallStatus === "completed") {
118 const id = text(output.taskId);
119 if (id) start(id, "monitor", text(record(input.rawInput).description) ?? "Monitor", toolCallId);
120 } else if (output.type === "BackgroundTaskStarted") {
121 const id = text(output.task_id) ?? text(output.taskId);
122 const command = text(output.command);
123 if (id && command) start(id, "shell", command.split("\n")[0]!.slice(0, 200), toolCallId);
124 } else if (output.type === "TaskOutput" || output.type === "KillTask") {
125 const results = record(output.MultiResult).results;
126 for (const value of Array.isArray(results) ? results : [output.Result]) {
127 const result = record(value);
128 const id = text(result.task_id);
129 if (!id) continue;
130 if (output.type === "KillTask") {
131 const task = tasks.get(id);
132 if (task && toolCallStatus === "completed" && result.outcome === "killed")
133 complete(task, "stopped");
134 continue;
135 }
136 const command = text(result.command);
137 const status = lifecycle(result.status, result.exit_code);
138 if (!command || !status || command.startsWith("[subagent:")) continue;
139 const task = start(id, /^\[monitor[:\]]/.test(command) ? "monitor" : "shell", command);
140 const summary = text(result.output)
141 ?.split("\n")
142 .find((line) => line.trim())
143 ?.trim();
144 if (status === "running") {
145 events.push({
146 type: "task.progress",
147 payload: { ...task.payload, ...(summary ? { summary } : {}) },
148 ...attribution(task),
149 });
150 } else {
151 complete(task, status, summary);
152 }
153 }
154 }
155 return events;
156}
packages/provider-grok/src/settings.ts created+39
...@@ -0,0 +1,39 @@
1/**
2 * Grok instance settings. Shared by the server driver and the client
3 * settings form, so it holds only browser-safe schema code.
4 *
5 * @module provider-grok/settings
6 */
7import {
8 CustomModelSetting,
9 makeBinaryPathSetting,
10 makeProviderSettingsSchema,
11} from "@t3tools/contracts";
12import * as Effect from "effect/Effect";
13import * as Schema from "effect/Schema";
14
15export const GrokSettings = makeProviderSettingsSchema(
16 {
17 // Off by default (like Cursor and OpenCode): the binding is not yet
18 // stable enough to probe on every install. Users opt in from Settings.
19 enabled: Schema.Boolean.pipe(
20 Schema.withDecodingDefault(Effect.succeed(false)),
21 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
22 ),
23 binaryPath: makeBinaryPathSetting("grok").pipe(
24 Schema.annotateKey({
25 title: "Binary path",
26 description: "Path to the Grok CLI binary.",
27 providerSettingsForm: { placeholder: "grok", clearWhenEmpty: "omit" },
28 }),
29 ),
30 customModels: Schema.Array(CustomModelSetting).pipe(
31 Schema.withDecodingDefault(Effect.succeed([])),
32 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
33 ),
34 },
35 {
36 order: ["binaryPath"],
37 },
38);
39export type GrokSettings = typeof GrokSettings.Type;
packages/provider-grok/src/testing.ts created+38
...@@ -0,0 +1,38 @@
1/**
2 * Grok internals the server's replay testkit, fixture recorder, and
3 * orchestration tests drive directly.
4 *
5 * @module provider-grok/testing
6 */
7export {
8 GROK_DEFAULT_INSTANCE_ID,
9 GROK_PROVIDER,
10 GrokProviderCapabilitiesV2,
11 grokLaunchRuntimeMode,
12 makeGrokAcpAdapterFlavor,
13 makeGrokAdapterV2,
14 type GrokAdapterV2Options,
15} from "./server/adapter.ts";
16export {
17 GROK_ACP_CANCEL_META,
18 GROK_ACP_INITIALIZE_META,
19 makeGrokAcpRuntime,
20} from "./server/acpSupport.ts";
21export {
22 buildGrokModelCapabilities,
23 buildGrokModelsFromSessionModelState,
24 buildInitialGrokProviderSnapshot,
25 checkGrokProviderStatus,
26 grokSlashCommandsFromInitialize,
27 parseGrokModelsCliOutput,
28} from "./server/status.ts";
29export { makeGrokTextGeneration } from "./server/textGeneration.ts";
30export { grokUsageResponseToLimits, readGrokAccount } from "./server/usageLimits.ts";
31export {
32 extractXAiAcpSubagentEndNotice,
33 extractXAiAcpSubagentUpdate,
34 makeXAiPromptCompletionRuntime,
35 normalizeXAiAcpToolCallState,
36 registerXAiBackgroundTaskTracking,
37 xAiRateLimitedErrorCode,
38} from "./server/xaiAcpExtension.ts";
packages/provider-grok/tsconfig.json created+7
...@@ -0,0 +1,7 @@
1{
2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {
4 "types": ["node"]
5 },
6 "include": ["src"]
7}
pnpm-lock.yaml+46
...@@ -359,6 +359,9 @@ importers:...@@ -359,6 +359,9 @@ importers:
359 '@t3tools/provider-cursor':359 '@t3tools/provider-cursor':
360 specifier: workspace:*360 specifier: workspace:*
361 version: link:../../packages/provider-cursor361 version: link:../../packages/provider-cursor
362 '@t3tools/provider-grok':
363 specifier: workspace:*
364 version: link:../../packages/provider-grok
362 '@t3tools/provider-muse':365 '@t3tools/provider-muse':
363 specifier: workspace:*366 specifier: workspace:*
364 version: link:../../packages/provider-muse367 version: link:../../packages/provider-muse
...@@ -609,6 +612,9 @@ importers:...@@ -609,6 +612,9 @@ importers:
609 '@t3tools/provider-cursor':612 '@t3tools/provider-cursor':
610 specifier: workspace:*613 specifier: workspace:*
611 version: link:../../packages/provider-cursor614 version: link:../../packages/provider-cursor
615 '@t3tools/provider-grok':
616 specifier: workspace:*
617 version: link:../../packages/provider-grok
612 '@t3tools/provider-muse':618 '@t3tools/provider-muse':
613 specifier: workspace:*619 specifier: workspace:*
614 version: link:../../packages/provider-muse620 version: link:../../packages/provider-muse
...@@ -748,6 +754,9 @@ importers:...@@ -748,6 +754,9 @@ importers:
748 '@t3tools/provider-cursor':754 '@t3tools/provider-cursor':
749 specifier: workspace:*755 specifier: workspace:*
750 version: link:../../packages/provider-cursor756 version: link:../../packages/provider-cursor
757 '@t3tools/provider-grok':
758 specifier: workspace:*
759 version: link:../../packages/provider-grok
751 '@t3tools/provider-muse':760 '@t3tools/provider-muse':
752 specifier: workspace:*761 specifier: workspace:*
753 version: link:../../packages/provider-muse762 version: link:../../packages/provider-muse
...@@ -1194,6 +1203,43 @@ importers:...@@ -1194,6 +1203,43 @@ importers:
1194 specifier: 1.0.01203 specifier: 1.0.0
1195 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)1204 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
11961205
1206 packages/provider-grok:
1207 dependencies:
1208 '@t3tools/contracts':
1209 specifier: workspace:*
1210 version: link:../contracts
1211 '@t3tools/provider-acp':
1212 specifier: workspace:*
1213 version: link:../provider-acp
1214 '@t3tools/provider-core':
1215 specifier: workspace:*
1216 version: link:../provider-core
1217 '@t3tools/shared':
1218 specifier: workspace:*
1219 version: link:../shared
1220 effect:
1221 specifier: 4.0.1
1222 version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f)
1223 devDependencies:
1224 '@effect/platform-node':
1225 specifier: 4.0.1
1226 version: 4.0.1(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(redis@6.2.1)(utf-8-validate@6.0.6)
1227 '@effect/vitest':
1228 specifier: 4.0.1
1229 version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
1230 '@t3tools/provider-testing':
1231 specifier: workspace:*
1232 version: link:../provider-testing
1233 '@types/node':
1234 specifier: 24.12.4
1235 version: 24.12.4
1236 effect-acp:
1237 specifier: workspace:*
1238 version: link:../effect-acp
1239 vite-plus:
1240 specifier: 1.0.0
1241 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
1242
1197 packages/provider-muse:1243 packages/provider-muse:
1198 dependencies:1244 dependencies:
1199 '@muse-code/sdk':1245 '@muse-code/sdk':
scripts/release-smoke.ts+1
...@@ -33,6 +33,7 @@ const workspaceFiles = [...@@ -33,6 +33,7 @@ const workspaceFiles = [
33 "packages/provider-pi/package.json",33 "packages/provider-pi/package.json",
34 "packages/provider-acp/package.json",34 "packages/provider-acp/package.json",
35 "packages/provider-cursor/package.json",35 "packages/provider-cursor/package.json",
36 "packages/provider-grok/package.json",
36 "packages/provider-muse/package.json",37 "packages/provider-muse/package.json",
37 "packages/provider-opencode/package.json",38 "packages/provider-opencode/package.json",
38 "packages/effect-acp/package.json",39 "packages/effect-acp/package.json",