| author | |
| committer | |
| log | b707eeb052782cfd8b1ff0445f84b2aaf01da38b |
| tree | 2dd823ac602c1c2b8fad7fe0277b4ffef9a41b22 |
| parent | 664bd5e524bb58b23ecab0f2e413fc2ecb4caf55 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | } |
| 95 | 95 | ||
| 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 @@ |
| 1 | import { isProviderDriverKind } from "@t3tools/contracts"; | 1 | import { isProviderDriverKind } from "@t3tools/contracts"; |
| 2 | import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; | 2 | import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; |
| 3 | import { cursorClient } from "@t3tools/provider-cursor/client"; | 3 | import { cursorClient } from "@t3tools/provider-cursor/client"; |
| 4 | import { grokClient } from "@t3tools/provider-grok/client"; | ||
| 4 | import { museClient } from "@t3tools/provider-muse/client"; | 5 | import { museClient } from "@t3tools/provider-muse/client"; |
| 5 | import { openCodeClient } from "@t3tools/provider-opencode/client"; | 6 | import { openCodeClient } from "@t3tools/provider-opencode/client"; |
| 6 | import { piClient } from "@t3tools/provider-pi/client"; | 7 | import { 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. */ |
| 9 | const providerClients = makeProviderClientRegistry([ | 10 | const 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 simple | 10 | * node scripts/record-grok-acp-replay-fixture.ts --scenario simple |
| 11 | */ | 11 | */ |
| 12 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 12 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 13 | import { GrokSettings, type ProviderReplayEntry } from "@t3tools/contracts"; | 13 | import { type ProviderReplayEntry } from "@t3tools/contracts"; |
| 14 | import { GrokSettings } from "@t3tools/provider-grok/settings"; | ||
| 14 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 15 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 15 | import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; | 16 | import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; |
| 16 | import * as Clock from "effect/Clock"; | 17 | import * 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"; |
| 35 | import { ACP_PROTOCOL } from "@t3tools/provider-acp/server/adapter"; | 36 | import { ACP_PROTOCOL } from "@t3tools/provider-acp/server/adapter"; |
| 36 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | 37 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 37 | import type { ProviderAdapterV2SessionRuntime } from "@t3tools/provider-core/server/ProviderAdapter"; | 38 | import 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 |
| 43 | import { runOrchestratorV2Scenario } from "../src/orchestration-v2/testkit/OrchestratorScenario.ts"; | 44 | import { runOrchestratorV2Scenario } from "../src/orchestration-v2/testkit/OrchestratorScenario.ts"; |
| 44 | import * as ProviderReplayHarness from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts"; | 45 | import * as ProviderReplayHarness from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts"; |
| 45 | import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; | 46 | import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; |
| 46 | import { makeGrokAcpRuntime } from "../src/provider/acp/GrokAcpSupport.ts"; | 47 | import { makeGrokAcpRuntime } from "@t3tools/provider-grok/testing"; |
| 47 | import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; | 48 | import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; |
| 48 | 49 | ||
| 49 | const wallClock = Clock.Clock.defaultValue(); | 50 | const 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"; |
| 12 | import { assert, describe, it } from "@effect/vitest"; | 12 | import { assert, describe, it } from "@effect/vitest"; |
| 13 | import { | 13 | import { |
| 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"; |
| 29 | import { GrokSettings } from "@t3tools/provider-grok/settings"; | ||
| 30 | import { HostProcessIsExecutable, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 30 | import { HostProcessIsExecutable, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 31 | import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; | 31 | import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; |
| 32 | import * as DateTime from "effect/DateTime"; | 32 | import * 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"; |
| 68 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | 68 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 69 | import { | 69 | import { |
| 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"; |
| 95 | 95 | ||
| 96 | import { makeGrokAdapterV2 } from "./GrokAdapterV2.ts"; | 96 | import { makeGrokAdapterV2 } from "@t3tools/provider-grok/testing"; |
| 97 | import { | 97 | import { |
| 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 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { | 2 | import { |
| 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"; |
| 9 | import { GrokSettings } from "@t3tools/provider-grok/settings"; | ||
| 10 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 10 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 11 | import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; | 11 | import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; |
| 12 | import * as EffectAcpErrors from "effect-acp/errors"; | 12 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 13 | import { xAiRateLimitedErrorCode } from "../../provider/acp/XAiAcpExtension.ts"; | 13 | import { xAiRateLimitedErrorCode } from "@t3tools/provider-grok/testing"; |
| 14 | import { assert, describe, it } from "@effect/vitest"; | 14 | import { assert, describe, it } from "@effect/vitest"; |
| 15 | import * as Crypto from "effect/Crypto"; | 15 | import * as Crypto from "effect/Crypto"; |
| 16 | import * as DateTime from "effect/DateTime"; | 16 | import * 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"; |
| 25 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; | 25 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| 26 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; | 26 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; |
| 27 | import * as ProjectStore from "../ProjectStore.ts"; | 27 | import * as ProjectStore from "../ProjectStore.ts"; |
| 28 | import { buildInitialGrokProviderSnapshot } from "../../provider/GrokProvider.ts"; | 28 | import { buildInitialGrokProviderSnapshot } from "@t3tools/provider-grok/testing"; |
| 29 | import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; | 29 | import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; |
| 30 | import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts"; | 30 | import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts"; |
| 31 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | 31 | import * 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"; |
| 47 | 47 | ||
| 48 | const LAUNCH_TEST_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({ | 48 | const 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 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { GrokSettings } from "@t3tools/contracts"; | 2 | import { GrokSettings } from "@t3tools/provider-grok/settings"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Crypto from "effect/Crypto"; | 4 | import * as Crypto from "effect/Crypto"; |
| 5 | import * as FileSystem from "effect/FileSystem"; | 5 | import * 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"; |
| 12 | 12 | ||
| 13 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; | 13 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| 14 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; | 14 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; |
| 15 | import { | 15 | import { GROK_ACP_CANCEL_META, GROK_ACP_INITIALIZE_META } from "@t3tools/provider-grok/testing"; |
| 16 | GROK_ACP_CANCEL_META, | 16 | import { makeXAiPromptCompletionRuntime } from "@t3tools/provider-grok/testing"; |
| 17 | GROK_ACP_INITIALIZE_META, | ||
| 18 | } from "../../provider/acp/GrokAcpSupport.ts"; | ||
| 19 | import { makeXAiPromptCompletionRuntime } from "../../provider/acp/XAiAcpExtension.ts"; | ||
| 20 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | 17 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 21 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | 18 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; |
| 22 | import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; | 19 | import * 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"; |
| 32 | import { GROK_DEFAULT_INSTANCE_ID, GROK_PROVIDER, makeGrokAdapterV2 } from "./GrokAdapterV2.ts"; | 29 | import { |
| 30 | GROK_DEFAULT_INSTANCE_ID, | ||
| 31 | GROK_PROVIDER, | ||
| 32 | makeGrokAdapterV2, | ||
| 33 | } from "@t3tools/provider-grok/testing"; | ||
| 33 | 34 | ||
| 34 | const DEFAULT_GROK_SETTINGS = Schema.decodeUnknownSync(GrokSettings)({}); | 35 | const DEFAULT_GROK_SETTINGS = Schema.decodeUnknownSync(GrokSettings)({}); |
| 35 | 36 |
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts deleted-455| ... | @@ -1,455 +0,0 @@ | ||
| 1 | import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; | ||
| 2 | import { | ||
| 3 | XAiPromptFailureText, | ||
| 4 | isXAiTaskCompletedWakeNotification, | ||
| 5 | xAiRateLimitedErrorCode, | ||
| 6 | } from "../../provider/acp/XAiAcpExtension.ts"; | ||
| 7 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 8 | import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/nodeRuntime"; | ||
| 9 | import { | ||
| 10 | defaultInstanceIdForDriver, | ||
| 11 | GrokSettings, | ||
| 12 | ProviderDriverKind, | ||
| 13 | type OrchestrationV2ProviderCapabilities, | ||
| 14 | type RuntimeMode, | ||
| 15 | } from "@t3tools/contracts"; | ||
| 16 | import * as Crypto from "effect/Crypto"; | ||
| 17 | import * as Effect from "effect/Effect"; | ||
| 18 | import * as FileSystem from "effect/FileSystem"; | ||
| 19 | import * as Path from "effect/Path"; | ||
| 20 | import * as Layer from "effect/Layer"; | ||
| 21 | import * as Schema from "effect/Schema"; | ||
| 22 | import type * as Scope from "effect/Scope"; | ||
| 23 | import { ChildProcessSpawner } from "effect/process"; | ||
| 24 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 25 | |||
| 26 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; | ||
| 27 | import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; | ||
| 28 | import { | ||
| 29 | applyGrokAcpModelSelection, | ||
| 30 | currentGrokModelIdFromSessionSetup, | ||
| 31 | grokApprovalOptions, | ||
| 32 | makeGrokAcpRuntime, | ||
| 33 | resolveGrokAcpBaseModelId, | ||
| 34 | } from "../../provider/acp/GrokAcpSupport.ts"; | ||
| 35 | import { | ||
| 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"; | ||
| 56 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | ||
| 57 | import { acpPermissionDisposition } from "@t3tools/provider-acp/server/clientPolicy"; | ||
| 58 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; | ||
| 59 | import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; | ||
| 60 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | ||
| 61 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | ||
| 62 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | ||
| 63 | import { | ||
| 64 | ProviderAdapterDriverCreateError, | ||
| 65 | type ProviderAdapterDriver, | ||
| 66 | type ProviderAdapterDriverCreateInput, | ||
| 67 | } from "@t3tools/provider-core/server/adapterDriver"; | ||
| 68 | import { | ||
| 69 | AcpProviderCapabilitiesV2, | ||
| 70 | makeAcpAdapterV2, | ||
| 71 | type AcpAdapterV2ExtensionContext, | ||
| 72 | type AcpAdapterV2Flavor, | ||
| 73 | type AcpAdapterV2RuntimeInput, | ||
| 74 | } from "@t3tools/provider-acp/server/adapter"; | ||
| 75 | |||
| 76 | export const GROK_PROVIDER = ProviderDriverKind.make("grok"); | ||
| 77 | const GROK_DRIVER_KIND = GROK_PROVIDER; | ||
| 78 | export const GROK_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(GROK_DRIVER_KIND); | ||
| 79 | const DEFAULT_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({}); | ||
| 80 | |||
| 81 | export 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 | |||
| 110 | export 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 | |||
| 134 | const 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 | */ | ||
| 161 | const 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 | |||
| 184 | const 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 | */ | ||
| 224 | export function grokLaunchRuntimeMode( | ||
| 225 | runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, | ||
| 226 | ): RuntimeMode { | ||
| 227 | return runtimePolicy.approvalPolicy === undefined && runtimePolicy.sandboxPolicy === undefined | ||
| 228 | ? runtimePolicy.runtimeMode | ||
| 229 | : "approval-required"; | ||
| 230 | } | ||
| 231 | |||
| 232 | export 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 | |||
| 332 | export 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 | |||
| 350 | export type GrokAdapterV2DriverEnv = | ||
| 351 | | ChildProcessSpawner.ChildProcessSpawner | ||
| 352 | | Crypto.Crypto | ||
| 353 | | FileSystem.FileSystem | ||
| 354 | | IdAllocator.IdAllocatorV2 | ||
| 355 | | Path.Path | ||
| 356 | | ProviderEventLoggers.ProviderEventLoggers | ||
| 357 | | ProviderHost.ProviderHost; | ||
| 358 | |||
| 359 | export 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 | |||
| 411 | const 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"; |
| 13 | 13 | ||
| 14 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; | 14 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; |
| 15 | import { CursorProviderCapabilitiesV2 } from "@t3tools/provider-cursor/testing"; | 15 | import { CursorProviderCapabilitiesV2 } from "@t3tools/provider-cursor/testing"; |
| 16 | import { GrokProviderCapabilitiesV2 } from "./Adapters/GrokAdapterV2.ts"; | 16 | import { GrokProviderCapabilitiesV2 } from "@t3tools/provider-grok/testing"; |
| 17 | import * as CommandPolicy from "./CommandPolicy.ts"; | 17 | import * as CommandPolicy from "./CommandPolicy.ts"; |
| 18 | 18 | ||
| 19 | const commandId = CommandId.make("command-policy-test"); | 19 | const 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"; |
| 16 | import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./Adapters/GrokAdapterV2.ts"; | 16 | import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "@t3tools/provider-grok/server"; |
| 17 | import { | 17 | import { |
| 18 | OpenCodeAdapterV2Driver, | 18 | OpenCodeAdapterV2Driver, |
| 19 | type OpenCodeAdapterV2DriverEnv, | 19 | type OpenCodeAdapterV2DriverEnv, |
apps/server/src/provider/Drivers/GrokDriver.test.ts deleted-111| ... | @@ -1,111 +0,0 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { expect, it } from "@effect/vitest"; | ||
| 3 | import { ProviderInstanceId } from "@t3tools/contracts"; | ||
| 4 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as FileSystem from "effect/FileSystem"; | ||
| 7 | import * as Layer from "effect/Layer"; | ||
| 8 | import * as Path from "effect/Path"; | ||
| 9 | import { HttpClient } from "effect/http"; | ||
| 10 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | ||
| 11 | |||
| 12 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | ||
| 13 | import * as ServerConfig from "../../config.ts"; | ||
| 14 | import * as ServerSettings from "../../serverSettings.ts"; | ||
| 15 | import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; | ||
| 16 | import { GrokDriver } from "./GrokDriver.ts"; | ||
| 17 | |||
| 18 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | ||
| 19 | import * as ProviderHostLive from "../ProviderHostLive.ts"; | ||
| 20 | import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; | ||
| 21 | |||
| 22 | const 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 | ); | ||
| 46 | const layerTest = ProviderHostLive.layer.pipe( | ||
| 47 | Layer.provideMerge(ServerSecretStore.layer), | ||
| 48 | Layer.provideMerge(layerDeps), | ||
| 49 | ); | ||
| 50 | |||
| 51 | const 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. | ||
| 56 | const windowsHost = HostProcessPlatform.defaultValue() === "win32"; | ||
| 57 | |||
| 58 | it.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 @@ | ||
| 1 | import { GrokSettings, ProviderDriverKind } from "@t3tools/contracts"; | ||
| 2 | import * as Crypto from "effect/Crypto"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Path from "effect/Path"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | import { HttpClient } from "effect/http"; | ||
| 8 | import { ChildProcessSpawner } from "effect/process"; | ||
| 9 | |||
| 10 | import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; | ||
| 11 | import * as ServerConfig from "../../config.ts"; | ||
| 12 | import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; | ||
| 13 | import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts"; | ||
| 14 | import { | ||
| 15 | GrokAdapterV2Driver, | ||
| 16 | type GrokAdapterV2DriverEnv, | ||
| 17 | } from "../../orchestration-v2/Adapters/GrokAdapterV2.ts"; | ||
| 18 | import { ProviderDriverError } from "../Errors.ts"; | ||
| 19 | import { | ||
| 20 | buildInitialGrokProviderSnapshot, | ||
| 21 | checkGrokProviderStatus, | ||
| 22 | enrichGrokSnapshot, | ||
| 23 | } from "../GrokProvider.ts"; | ||
| 24 | import { readGrokAccount } from "../grokUsageLimits.ts"; | ||
| 25 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; | ||
| 26 | import { | ||
| 27 | defaultProviderContinuationIdentity, | ||
| 28 | type ProviderDriver, | ||
| 29 | type ProviderInstance, | ||
| 30 | } from "@t3tools/provider-core/server/driver"; | ||
| 31 | import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; | ||
| 32 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | ||
| 33 | import { discoverGrokSkills } from "./GrokSkills.ts"; | ||
| 34 | import { | ||
| 35 | makeCachedProviderMaintenanceResolution, | ||
| 36 | makeManualOnlyProviderMaintenanceCapabilities, | ||
| 37 | makeProviderMaintenanceCapabilities, | ||
| 38 | type ProviderMaintenanceCapabilitiesResolver, | ||
| 39 | resolveProviderMaintenanceCapabilitiesEffect, | ||
| 40 | } from "@t3tools/provider-core/server/maintenanceResolver"; | ||
| 41 | import { | ||
| 42 | haveProviderSnapshotSettingsChanged, | ||
| 43 | makeProviderSnapshotSettingsSource, | ||
| 44 | type ProviderSnapshotSettings, | ||
| 45 | } from "@t3tools/provider-core/server/snapshotSettings"; | ||
| 46 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); | ||
| 47 | |||
| 48 | const 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". | ||
| 51 | const 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". | ||
| 56 | const 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 | |||
| 76 | export 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 | |||
| 87 | export 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 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Sink from "effect/Sink"; | ||
| 4 | import * as Stream from "effect/Stream"; | ||
| 5 | import { ChildProcessSpawner } from "effect/process"; | ||
| 6 | |||
| 7 | import { discoverGrokSkills } from "./GrokSkills.ts"; | ||
| 8 | |||
| 9 | const inspectPayload = (skills: ReadonlyArray<unknown>) => JSON.stringify({ skills }); | ||
| 10 | |||
| 11 | const 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 | |||
| 31 | describe("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 | */ | ||
| 18 | import type { GrokSettings, ServerProviderSkill } from "@t3tools/contracts"; | ||
| 19 | import * as Effect from "effect/Effect"; | ||
| 20 | import * as Option from "effect/Option"; | ||
| 21 | import * as Schema from "effect/Schema"; | ||
| 22 | import { ChildProcess } from "effect/process"; | ||
| 23 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 24 | |||
| 25 | import { spawnAndCollect } from "@t3tools/provider-core/server/snapshotProbe"; | ||
| 26 | |||
| 27 | const GROK_SKILLS_PROBE_TIMEOUT_MS = 4_000; | ||
| 28 | |||
| 29 | class 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 | */ | ||
| 50 | function 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 | */ | ||
| 99 | export 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"; |
| 8 | import * as FileSystem from "effect/FileSystem"; | 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Schema from "effect/Schema"; | 9 | import * as Schema from "effect/Schema"; |
| 10 | import { HttpClient, HttpClientResponse } from "effect/http"; | 10 | import { HttpClient, HttpClientResponse } from "effect/http"; |
| 11 | import { GrokSettings } from "@t3tools/contracts"; | 11 | import { GrokSettings } from "@t3tools/provider-grok/settings"; |
| 12 | 12 | ||
| 13 | import { | 13 | import { |
| 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"; |
| 21 | import { execScriptSource, writeFakeCli } from "@t3tools/provider-testing/fakeCli"; | 21 | import { execScriptSource, writeFakeCli } from "@t3tools/provider-testing/fakeCli"; |
| 22 | import { grokUsageResponseToLimits, readGrokAccount } from "./grokUsageLimits.ts"; | 22 | import { grokUsageResponseToLimits, readGrokAccount } from "@t3tools/provider-grok/testing"; |
| 23 | 23 | ||
| 24 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); | 24 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); |
| 25 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); | 25 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); |
apps/server/src/provider/GrokProvider.ts deleted-586| ... | @@ -1,586 +0,0 @@ | ||
| 1 | import { | ||
| 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"; | ||
| 10 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 11 | import { causeErrorTag } from "@t3tools/shared/observability"; | ||
| 12 | import * as Crypto from "effect/Crypto"; | ||
| 13 | import * as DateTime from "effect/DateTime"; | ||
| 14 | import * as Effect from "effect/Effect"; | ||
| 15 | import * as Exit from "effect/Exit"; | ||
| 16 | import * as Option from "effect/Option"; | ||
| 17 | import * as Result from "effect/Result"; | ||
| 18 | import * as Schema from "effect/Schema"; | ||
| 19 | import { HttpClient } from "effect/http"; | ||
| 20 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | ||
| 21 | import { createModelCapabilities } from "@t3tools/shared/model"; | ||
| 22 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 23 | |||
| 24 | import { | ||
| 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"; | ||
| 34 | import { | ||
| 35 | enrichProviderSnapshotWithVersionAdvisory, | ||
| 36 | type ProviderMaintenanceCapabilities, | ||
| 37 | } from "@t3tools/provider-core/server/maintenanceResolver"; | ||
| 38 | import { | ||
| 39 | GROK_DEFAULT_MODEL_SLUG, | ||
| 40 | GROK_SUPPORTED_RUNTIME_MODES, | ||
| 41 | isValidGrokReasoningEffortToken, | ||
| 42 | makeGrokAcpRuntime, | ||
| 43 | resolveGrokAcpBaseModelId, | ||
| 44 | } from "./acp/GrokAcpSupport.ts"; | ||
| 45 | import { sessionModelStateFromInitialize } from "@t3tools/provider-acp/server/runtimeModel"; | ||
| 46 | import { discoverGrokSkills } from "./Drivers/GrokSkills.ts"; | ||
| 47 | |||
| 48 | const GROK_PRESENTATION = { | ||
| 49 | displayName: "Grok", | ||
| 50 | supportsConversationRollback: false, | ||
| 51 | showInteractionModeToggle: false, | ||
| 52 | supportedRuntimeModes: GROK_SUPPORTED_RUNTIME_MODES, | ||
| 53 | } as const; | ||
| 54 | const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | ||
| 55 | optionDescriptors: [], | ||
| 56 | }); | ||
| 57 | |||
| 58 | const VERSION_PROBE_TIMEOUT_MS = 4_000; | ||
| 59 | // `initialize` is a single local round trip, so this is generous even on slow machines. | ||
| 60 | const GROK_ACP_INITIALIZE_TIMEOUT_MS = 8_000; | ||
| 61 | const GROK_API_KEY_ENV = "XAI_API_KEY"; | ||
| 62 | |||
| 63 | const 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 | |||
| 72 | export 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 | |||
| 111 | function 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 | |||
| 118 | function nonEmptyString(value: unknown): string | undefined { | ||
| 119 | return typeof value === "string" ? value.trim() || undefined : undefined; | ||
| 120 | } | ||
| 121 | |||
| 122 | function isRecord(value: unknown): value is Record<string, unknown> { | ||
| 123 | return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 124 | } | ||
| 125 | |||
| 126 | function 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 | |||
| 194 | export 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. */ | ||
| 217 | export 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 | |||
| 243 | export 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 | */ | ||
| 259 | export 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 | |||
| 289 | function displayNameFromGrokModelSlug(slug: string): string { | ||
| 290 | return slug | ||
| 291 | .split(/[-_]/g) | ||
| 292 | .map((part) => (part.toLowerCase() === "grok" ? "Grok" : part)) | ||
| 293 | .join(" "); | ||
| 294 | } | ||
| 295 | |||
| 296 | const 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 | |||
| 313 | const decodeAvailableCommands = Schema.decodeUnknownOption(Schema.Array(Schema.Unknown)); | ||
| 314 | const 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 | |||
| 322 | export 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 | */ | ||
| 353 | const 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 | |||
| 373 | export 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 | |||
| 565 | export 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"; |
| 38 | import type { GrokSettings } from "@t3tools/provider-grok/settings"; | ||
| 39 | import type { CursorSettings } from "@t3tools/provider-cursor/settings"; | 39 | import type { CursorSettings } from "@t3tools/provider-cursor/settings"; |
| 40 | import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess"; | 40 | import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess"; |
| 41 | import * as DateTime from "effect/DateTime"; | 41 | import * 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"; |
| 54 | import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; | 54 | import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; |
| 55 | import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; | 55 | import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; |
| 56 | import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server"; | 56 | import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server"; |
| 57 | import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; | 57 | import { GrokDriver, type GrokDriverEnv } from "@t3tools/provider-grok/server"; |
| 58 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; | 58 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; |
| 59 | import type { OpenCodeSettings } from "@t3tools/provider-opencode/settings"; | 59 | import type { OpenCodeSettings } from "@t3tools/provider-opencode/settings"; |
| 60 | import * as ModelManifest from "./ModelManifest.ts"; | 60 | import * 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 | */ | ||
| 11 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 12 | import { it } from "@effect/vitest"; | ||
| 13 | import * as Effect from "effect/Effect"; | ||
| 14 | import * as Deferred from "effect/Deferred"; | ||
| 15 | import * as Fiber from "effect/Fiber"; | ||
| 16 | import * as FileSystem from "effect/FileSystem"; | ||
| 17 | import * as Stream from "effect/Stream"; | ||
| 18 | import { ChildProcessSpawner } from "effect/process"; | ||
| 19 | import { describe, expect } from "vite-plus/test"; | ||
| 20 | |||
| 21 | import { makeGrokAcpRuntime } from "./GrokAcpSupport.ts"; | ||
| 22 | |||
| 23 | const 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 | |||
| 34 | describe.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 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 5 | |||
| 6 | import { | ||
| 7 | applyGrokAcpModelSelection, | ||
| 8 | buildGrokAcpSpawnInput, | ||
| 9 | grokAcpRuntimeProcessOwnership, | ||
| 10 | grokAcpSpawnArgs, | ||
| 11 | isValidGrokReasoningEffortToken, | ||
| 12 | resolveGrokAcpBaseModelId, | ||
| 13 | } from "./GrokAcpSupport.ts"; | ||
| 14 | |||
| 15 | describe("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 | |||
| 38 | describe("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 | |||
| 46 | describe("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 | |||
| 75 | describe("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 | |||
| 104 | describe("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 | |||
| 114 | describe("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 @@ | ||
| 1 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { | ||
| 4 | type GrokSettings, | ||
| 5 | type ProviderApprovalOption, | ||
| 6 | ProviderDriverKind, | ||
| 7 | type RuntimeMode, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import * as Crypto from "effect/Crypto"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Layer from "effect/Layer"; | ||
| 12 | import * as Scope from "effect/Scope"; | ||
| 13 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | ||
| 14 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 15 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 16 | import { normalizeModelSlug } from "@t3tools/shared/model"; | ||
| 17 | |||
| 18 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; | ||
| 19 | import { makeXAiPromptCompletionRuntime } from "./XAiAcpExtension.ts"; | ||
| 20 | |||
| 21 | const GROK_API_KEY_ENV = "XAI_API_KEY"; | ||
| 22 | const GROK_OAUTH2_REFERRER_ENV = "GROK_OAUTH2_REFERRER"; | ||
| 23 | const T3_CODE_OAUTH_REFERRER = "t3code"; | ||
| 24 | const GROK_AUTH_METHOD_API_KEY = "xai.api_key"; | ||
| 25 | const GROK_AUTH_METHOD_CACHED_TOKEN = "cached_token"; | ||
| 26 | const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); | ||
| 27 | |||
| 28 | type GrokAcpRuntimeGrokSettings = Pick<GrokSettings, "binaryPath">; | ||
| 29 | |||
| 30 | interface 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 | */ | ||
| 45 | export 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 | */ | ||
| 56 | export 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 | |||
| 69 | export 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 | |||
| 86 | function 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 | |||
| 92 | export 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 | */ | ||
| 112 | export 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 | */ | ||
| 121 | export 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 | */ | ||
| 131 | const 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 | */ | ||
| 137 | export 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 | |||
| 155 | export 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 | */ | ||
| 195 | export const GROK_DEFAULT_MODEL_SLUG = "grok-build"; | ||
| 196 | |||
| 197 | export 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 | |||
| 203 | const GROK_REASONING_EFFORT_TOKEN = /^[a-z0-9][a-z0-9._-]{0,31}$/i; | ||
| 204 | |||
| 205 | export function isValidGrokReasoningEffortToken(value: string): boolean { | ||
| 206 | return GROK_REASONING_EFFORT_TOKEN.test(value); | ||
| 207 | } | ||
| 208 | |||
| 209 | function normalizeGrokReasoningEffort(value: string | undefined): string | undefined { | ||
| 210 | const effort = value?.trim(); | ||
| 211 | return effort && isValidGrokReasoningEffortToken(effort) ? effort : undefined; | ||
| 212 | } | ||
| 213 | |||
| 214 | export 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 | |||
| 223 | export 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 | |||
| 246 | export 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. | ||
| 2 | import * as NodeOS from "node:os"; | ||
| 3 | import { it } from "@effect/vitest"; | ||
| 4 | import * as Deferred from "effect/Deferred"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Fiber from "effect/Fiber"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | import { describe, expect } from "vite-plus/test"; | ||
| 9 | |||
| 10 | import { | ||
| 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"; | ||
| 37 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; | ||
| 38 | import { | ||
| 39 | type AcpToolCallState, | ||
| 40 | mergeToolCallState, | ||
| 41 | parseSessionUpdateEvent, | ||
| 42 | } from "@t3tools/provider-acp/server/runtimeModel"; | ||
| 43 | |||
| 44 | const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest); | ||
| 45 | |||
| 46 | describe("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. | ||
| 91 | function 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 | |||
| 100 | describe("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 | |||
| 1882 | describe("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. | ||
| 2 | import * as NodeOS from "node:os"; | ||
| 3 | |||
| 4 | import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; | ||
| 5 | import * as Cause from "effect/Cause"; | ||
| 6 | import * as Deferred from "effect/Deferred"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Fiber from "effect/Fiber"; | ||
| 9 | import * as Ref from "effect/Ref"; | ||
| 10 | import * as Schema from "effect/Schema"; | ||
| 11 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 12 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 13 | |||
| 14 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; | ||
| 15 | import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel"; | ||
| 16 | |||
| 17 | const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing"; | ||
| 18 | export const xAiRateLimitedErrorCode = -32003; | ||
| 19 | const completedXAiPromptIdLimit = 128; | ||
| 20 | |||
| 21 | const 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 | |||
| 28 | type 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 | */ | ||
| 40 | const 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 | |||
| 58 | type XAiSessionUpdateNotification = typeof XAiSessionUpdateNotification.Type; | ||
| 59 | |||
| 60 | const decodeXAiSessionUpdateNotification = Schema.decodeUnknownEffect(XAiSessionUpdateNotification); | ||
| 61 | |||
| 62 | const xAiSessionNotificationMethods = [ | ||
| 63 | "x.ai/session_notification", | ||
| 64 | "_x.ai/session_notification", | ||
| 65 | "_x.ai/session/update", | ||
| 66 | ] as const; | ||
| 67 | |||
| 68 | const 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 | */ | ||
| 75 | export 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 | */ | ||
| 101 | export 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 | |||
| 108 | interface PendingXAiPromptCompletion { | ||
| 109 | readonly sessionId: string; | ||
| 110 | readonly promptId: string; | ||
| 111 | readonly deferred: Deferred.Deferred< | ||
| 112 | EffectAcpSchema.PromptResponse, | ||
| 113 | EffectAcpErrors.AcpRequestError | ||
| 114 | >; | ||
| 115 | } | ||
| 116 | |||
| 117 | export 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 | |||
| 132 | export interface XAiAcpBackgroundToolMutation { | ||
| 133 | readonly taskId: string; | ||
| 134 | readonly status: "running" | "completed" | "failed"; | ||
| 135 | readonly appendOutput: string; | ||
| 136 | } | ||
| 137 | |||
| 138 | export interface XAiAcpSubagentEndNotice { | ||
| 139 | readonly childSessionId: string; | ||
| 140 | readonly status: "completed" | "failed"; | ||
| 141 | } | ||
| 142 | |||
| 143 | const XAI_UUID_RE = "[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}"; | ||
| 144 | |||
| 145 | function 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 | |||
| 151 | function nonEmptyString(value: unknown): string | undefined { | ||
| 152 | return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; | ||
| 153 | } | ||
| 154 | |||
| 155 | function titleKey(toolCall: AcpToolCallState): string { | ||
| 156 | return (toolCall.title ?? "").trim().toLowerCase(); | ||
| 157 | } | ||
| 158 | |||
| 159 | function 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 | |||
| 171 | function 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 | |||
| 204 | function firstUuidMatch(text: string, pattern: RegExp): string | undefined { | ||
| 205 | const match = pattern.exec(text); | ||
| 206 | return match?.[1]; | ||
| 207 | } | ||
| 208 | |||
| 209 | function 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 | |||
| 219 | function 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 | |||
| 228 | function isXAiMonitorStartAck(output: string | undefined): boolean { | ||
| 229 | if (output === undefined) return false; | ||
| 230 | return /monitor started\s*\(\s*task\s+/i.test(output); | ||
| 231 | } | ||
| 232 | |||
| 233 | function 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 | |||
| 250 | function 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 | |||
| 272 | export 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 | */ | ||
| 298 | function 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 | |||
| 305 | export 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 | */ | ||
| 335 | export 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 | */ | ||
| 349 | export 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 | */ | ||
| 389 | export 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 | |||
| 416 | const 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 | |||
| 441 | type XAiTaskLifecycleNotification = typeof XAiTaskLifecycleNotification.Type; | ||
| 442 | |||
| 443 | export 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 | |||
| 455 | function 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 | |||
| 482 | export 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 | */ | ||
| 514 | export 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 | */ | ||
| 544 | export 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. */ | ||
| 552 | export 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 | */ | ||
| 575 | export 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 | */ | ||
| 594 | export 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 | |||
| 599 | export 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 | */ | ||
| 640 | export 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 | */ | ||
| 689 | export 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 | */ | ||
| 757 | function 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 | |||
| 772 | export 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 | |||
| 791 | function 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). */ | ||
| 813 | function 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 | |||
| 835 | function 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 | |||
| 860 | function 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 | */ | ||
| 899 | export 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 | |||
| 966 | const 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 | |||
| 973 | const 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 | |||
| 980 | const XAiAskUserQuestionParams = Schema.Struct({ | ||
| 981 | sessionId: Schema.String, | ||
| 982 | toolCallId: Schema.String, | ||
| 983 | questions: Schema.Array(XAiAskUserQuestion), | ||
| 984 | mode: Schema.Literals(["default", "plan"]), | ||
| 985 | }); | ||
| 986 | |||
| 987 | const XAiWrappedAskUserQuestionParams = Schema.Struct({ | ||
| 988 | method: Schema.Literals(["x.ai/ask_user_question", "_x.ai/ask_user_question"]), | ||
| 989 | params: XAiAskUserQuestionParams, | ||
| 990 | }); | ||
| 991 | |||
| 992 | export const XAiAskUserQuestionRequest = Schema.Union([ | ||
| 993 | XAiAskUserQuestionParams, | ||
| 994 | XAiWrappedAskUserQuestionParams, | ||
| 995 | ]); | ||
| 996 | |||
| 997 | type XAiAskUserQuestionRequestParams = typeof XAiAskUserQuestionParams.Type; | ||
| 998 | type XAiAskUserQuestionRequest = typeof XAiAskUserQuestionRequest.Type; | ||
| 999 | |||
| 1000 | // --------------------------------------------------------------------------- | ||
| 1001 | // x.ai/exit_plan_mode — plan approval gate (mirrors Grok Build TUI plan window) | ||
| 1002 | // --------------------------------------------------------------------------- | ||
| 1003 | |||
| 1004 | const XAiExitPlanModeParams = Schema.Struct({ | ||
| 1005 | sessionId: Schema.String, | ||
| 1006 | toolCallId: Schema.String, | ||
| 1007 | planContent: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 1008 | }); | ||
| 1009 | |||
| 1010 | const XAiWrappedExitPlanModeParams = Schema.Struct({ | ||
| 1011 | method: Schema.Literals(["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"]), | ||
| 1012 | params: XAiExitPlanModeParams, | ||
| 1013 | }); | ||
| 1014 | |||
| 1015 | export const XAiExitPlanModeRequest = Schema.Union([ | ||
| 1016 | XAiExitPlanModeParams, | ||
| 1017 | XAiWrappedExitPlanModeParams, | ||
| 1018 | ]); | ||
| 1019 | |||
| 1020 | type XAiExitPlanModeRequestParams = typeof XAiExitPlanModeParams.Type; | ||
| 1021 | type XAiExitPlanModeRequest = typeof XAiExitPlanModeRequest.Type; | ||
| 1022 | |||
| 1023 | function 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. */ | ||
| 1028 | export const XAI_EMPTY_PLAN_MARKDOWN = | ||
| 1029 | "# No plan written yet\n\n(The agent exited plan mode without writing a plan.)"; | ||
| 1030 | |||
| 1031 | export 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 | |||
| 1047 | export type XAiExitPlanModeOutcome = "approved" | "abandoned" | "request_changes"; | ||
| 1048 | |||
| 1049 | export 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 | */ | ||
| 1058 | export 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 | |||
| 1067 | function normalizeFsPath(value: string): string { | ||
| 1068 | return value.trim().replace(/\\/g, "/").replace(/\/+$/, ""); | ||
| 1069 | } | ||
| 1070 | |||
| 1071 | function pathHasTraversalSegment(normalized: string): boolean { | ||
| 1072 | return normalized.split("/").includes(".."); | ||
| 1073 | } | ||
| 1074 | |||
| 1075 | function 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`. */ | ||
| 1088 | export interface GrokPlanPathHost { | ||
| 1089 | readonly platform: NodeJS.Platform; | ||
| 1090 | readonly environment: NodeJS.ProcessEnv; | ||
| 1091 | } | ||
| 1092 | |||
| 1093 | function 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 | |||
| 1107 | const CANONICAL_HOME_GROK_SESSION_PATH = | ||
| 1108 | /^(?:\/home\/[^/]+|\/Users\/[^/]+|[a-zA-Z]:\/Users\/[^/]+)\/\.grok\/sessions\/(?:[^/]+\/)+plan\.md$/; | ||
| 1109 | const 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 | */ | ||
| 1120 | export 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 | */ | ||
| 1157 | export 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 | |||
| 1208 | function isRecord(value: unknown): value is Record<string, unknown> { | ||
| 1209 | return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 1210 | } | ||
| 1211 | |||
| 1212 | function trimmed(value: string | undefined): string | undefined { | ||
| 1213 | const text = value?.trim(); | ||
| 1214 | return text && text.length > 0 ? text : undefined; | ||
| 1215 | } | ||
| 1216 | |||
| 1217 | function unwrapAskUserQuestionParams( | ||
| 1218 | params: XAiAskUserQuestionRequest, | ||
| 1219 | ): XAiAskUserQuestionRequestParams { | ||
| 1220 | return "params" in params ? params.params : params; | ||
| 1221 | } | ||
| 1222 | |||
| 1223 | export 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 | |||
| 1241 | export 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 | |||
| 1252 | interface XAiAskUserQuestionAnnotation { | ||
| 1253 | readonly preview?: string; | ||
| 1254 | readonly notes?: string; | ||
| 1255 | } | ||
| 1256 | |||
| 1257 | interface XAiAskUserQuestionAcceptedResponse { | ||
| 1258 | readonly outcome: "accepted"; | ||
| 1259 | readonly answers: Record<string, ReadonlyArray<string>>; | ||
| 1260 | readonly annotations?: Record<string, XAiAskUserQuestionAnnotation>; | ||
| 1261 | } | ||
| 1262 | |||
| 1263 | interface XAiAskUserQuestionCancelledResponse { | ||
| 1264 | readonly outcome: "cancelled"; | ||
| 1265 | } | ||
| 1266 | |||
| 1267 | interface NormalizedXAiAnswer { | ||
| 1268 | readonly questionText: string; | ||
| 1269 | readonly selectedLabels: ReadonlyArray<string>; | ||
| 1270 | readonly annotation?: XAiAskUserQuestionAnnotation; | ||
| 1271 | } | ||
| 1272 | |||
| 1273 | function 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 | |||
| 1284 | function 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 | |||
| 1320 | function 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 | |||
| 1328 | export 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 | |||
| 1352 | export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCancelledResponse { | ||
| 1353 | return { outcome: "cancelled" }; | ||
| 1354 | } | ||
| 1355 | |||
| 1356 | function 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 | |||
| 1365 | function 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 | |||
| 1378 | function 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 | */ | ||
| 1408 | function 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 | */ | ||
| 1435 | export class XAiPromptFailureText extends Error {} | ||
| 1436 | |||
| 1437 | const 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 | |||
| 1449 | const 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 | |||
| 1454 | const 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 | |||
| 1490 | const 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 | |||
| 1543 | const 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 | */ | ||
| 1570 | export 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 | |||
| 1710 | function 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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import { TurnId } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | buildGrokBackgroundTaskEvents, | ||
| 6 | type GrokBackgroundTaskRecord, | ||
| 7 | } from "./XAiBackgroundTasks.ts"; | ||
| 8 | |||
| 9 | const turnId = TurnId.make("turn-1"); | ||
| 10 | const monitor = { type: "Monitor", taskId: "monitor-1", timeoutMs: 60_000 }; | ||
| 11 | const shell = { type: "BackgroundTaskStarted", task_id: "shell-1", command: "sleep 40" }; | ||
| 12 | |||
| 13 | function 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 | |||
| 31 | describe("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 @@ | ||
| 1 | import { | ||
| 2 | RuntimeTaskId, | ||
| 3 | type ProviderRuntimeTaskStartedEvent, | ||
| 4 | type ProviderRuntimeTaskProgressEvent, | ||
| 5 | type ProviderRuntimeTaskCompletedEvent, | ||
| 6 | type TurnId, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | |||
| 9 | type TaskEvent = | ||
| 10 | | Pick<ProviderRuntimeTaskStartedEvent, "type" | "payload" | "turnId"> | ||
| 11 | | Pick<ProviderRuntimeTaskProgressEvent, "type" | "payload" | "turnId"> | ||
| 12 | | Pick<ProviderRuntimeTaskCompletedEvent, "type" | "payload" | "turnId">; | ||
| 13 | |||
| 14 | export 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 | |||
| 25 | function 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 | |||
| 31 | function text(value: unknown): string | undefined { | ||
| 32 | return typeof value === "string" ? value.trim() || undefined : undefined; | ||
| 33 | } | ||
| 34 | |||
| 35 | function 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. */ | ||
| 61 | export 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 |
| 25 | import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; | 25 | import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; |
| 26 | import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; | 26 | import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; |
| 27 | import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server"; | 27 | import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server"; |
| 28 | import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; | 28 | import { GrokDriver, type GrokDriverEnv } from "@t3tools/provider-grok/server"; |
| 29 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; | 29 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; |
| 30 | import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server"; | 30 | import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server"; |
| 31 | import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server"; | 31 | import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server"; |
apps/server/src/provider/grokUsageLimits.ts deleted-175| ... | @@ -1,175 +0,0 @@ | ||
| 1 | import * as NodeOS from "node:os"; | ||
| 2 | import type { ServerProviderUsageWindow } from "@t3tools/contracts"; | ||
| 3 | import * as DateTime from "effect/DateTime"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 6 | import * as Option from "effect/Option"; | ||
| 7 | import * as Path from "effect/Path"; | ||
| 8 | import * as Schema from "effect/Schema"; | ||
| 9 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | ||
| 10 | import { | ||
| 11 | clampPercent, | ||
| 12 | makeUnavailableUsageLimits, | ||
| 13 | makeUsageLimits, | ||
| 14 | } from "@t3tools/provider-core/server/usageLimits"; | ||
| 15 | |||
| 16 | const 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 | ); | ||
| 24 | const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(GrokCredentials)); | ||
| 25 | const 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 | |||
| 39 | export 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 | */ | ||
| 73 | const 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 | */ | ||
| 140 | export 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"; |
| 11 | import * as Schema from "effect/Schema"; | 11 | import * as Schema from "effect/Schema"; |
| 12 | import { createModelSelection } from "@t3tools/shared/model"; | 12 | import { createModelSelection } from "@t3tools/shared/model"; |
| 13 | import { expect } from "vite-plus/test"; | 13 | import { expect } from "vite-plus/test"; |
| 14 | import { GrokSettings, ProviderInstanceId } from "@t3tools/contracts"; | 14 | import { ProviderInstanceId } from "@t3tools/contracts"; |
| 15 | import { GrokSettings } from "@t3tools/provider-grok/settings"; | ||
| 15 | 16 | ||
| 16 | import * as ServerConfig from "../config.ts"; | 17 | import * as ServerConfig from "../config.ts"; |
| 17 | import * as TextGeneration from "./TextGeneration.ts"; | 18 | import * as TextGeneration from "./TextGeneration.ts"; |
| 18 | import { makeGrokTextGeneration } from "./GrokTextGeneration.ts"; | 19 | import { makeGrokTextGeneration } from "@t3tools/provider-grok/testing"; |
| 19 | import { execScriptSource, writeFakeCli } from "@t3tools/provider-testing/fakeCli"; | 20 | import { execScriptSource, writeFakeCli } from "@t3tools/provider-testing/fakeCli"; |
| 20 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); | 21 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); |
| 21 | 22 |
apps/server/src/textGeneration/GrokTextGeneration.ts deleted-130| ... | @@ -1,130 +0,0 @@ | ||
| 1 | import * as Crypto from "effect/Crypto"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import * as Ref from "effect/Ref"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 6 | import { ChildProcessSpawner } from "effect/process"; | ||
| 7 | import type * as EffectAcpErrors from "effect-acp/errors"; | ||
| 8 | |||
| 9 | import { type GrokSettings, TextGenerationError } from "@t3tools/contracts"; | ||
| 10 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | ||
| 11 | |||
| 12 | import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; | ||
| 13 | import { | ||
| 14 | applyGrokAcpModelSelection, | ||
| 15 | currentGrokModelIdFromSessionSetup, | ||
| 16 | currentGrokReasoningEffortFromSessionSetup, | ||
| 17 | makeGrokAcpRuntime, | ||
| 18 | resolveGrokAcpBaseModelId, | ||
| 19 | } from "../provider/acp/GrokAcpSupport.ts"; | ||
| 20 | |||
| 21 | const GROK_TIMEOUT_MS = 180_000; | ||
| 22 | |||
| 23 | const isTextGenerationError = Schema.is(TextGenerationError); | ||
| 24 | |||
| 25 | export 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 | ); |
| 366 | 366 | ||
| 367 | export 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 | |||
| 379 | export const TraeIcon: Icon = (props) => ( | 367 | export 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"; |
| 3 | import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display"; | 3 | import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display"; |
| 4 | 4 | ||
| 5 | import { ProviderDriverKind } from "@t3tools/contracts"; | 5 | import { ProviderDriverKind } from "@t3tools/contracts"; |
| 6 | import { AntigravityIcon, ClaudeAI, GrokIcon, Icon, OpenAI } from "../Icons"; | 6 | import { AntigravityIcon, ClaudeAI, Icon, OpenAI } from "../Icons"; |
| 7 | 7 | ||
| 8 | import { cn } from "~/lib/utils"; | 8 | import { cn } from "~/lib/utils"; |
| 9 | import { providerClients } from "../settings/providerDriverMeta"; | 9 | import { providerClients } from "../settings/providerDriverMeta"; |
| ... | @@ -17,14 +17,12 @@ import { | ... | @@ -17,14 +17,12 @@ import { |
| 17 | const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = { | 17 | const 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 | }; |
| 23 | 22 | ||
| 24 | const PROVIDER_TEXT_COLOR_BY_PROVIDER: Partial<Record<ProviderDriverKind, string>> = { | 23 | const 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 | }; |
| 30 | 28 |
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"; |
| 9 | import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; | 8 | import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; |
| 10 | import { cursorClient } from "@t3tools/provider-cursor/client"; | 9 | import { cursorClient } from "@t3tools/provider-cursor/client"; |
| 10 | import { grokClient } from "@t3tools/provider-grok/client"; | ||
| 11 | import { museClient } from "@t3tools/provider-muse/client"; | 11 | import { museClient } from "@t3tools/provider-muse/client"; |
| 12 | import { openCodeClient } from "@t3tools/provider-opencode/client"; | 12 | import { openCodeClient } from "@t3tools/provider-opencode/client"; |
| 13 | import { piClient } from "@t3tools/provider-pi/client"; | 13 | import { 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 fails | 26 | 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, through | 28 | - **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 missing | 30 | 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. The | 31 | 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 Supervised | 32 | 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 effect | 52 | ## Setup must not happen as a health-check side effect |
| 53 | 53 | ||
| 54 | Opening a provider session can start MCP servers, run hooks, or launch a login browser. | 54 | Opening 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 and | 55 | [Grok probes](../../packages/provider-grok/src/server/status.ts) avoid authentication and |
| 56 | session creation for this reason. Antigravity likewise reserves authenticated catalog sessions for | 56 | session creation for this reason. Antigravity likewise reserves authenticated catalog sessions for |
| 57 | explicit setup or model refresh; background checks use initialization only. | 57 | explicit setup or model refresh; background checks use initialization only. |
| 58 | 58 |
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 | ); |
| 708 | export type ClaudeSettings = typeof ClaudeSettings.Type; | 708 | export type ClaudeSettings = typeof ClaudeSettings.Type; |
| 709 | 709 | ||
| 710 | export 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 | ); | ||
| 734 | export type GrokSettings = typeof GrokSettings.Type; | ||
| 735 | |||
| 736 | /** | 710 | /** |
| 737 | * Antigravity ACP auth methods. Personal and Enterprise open a Google sign-in | 711 | * 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 from | 712 | * 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 | */ | ||
| 7 | import { ProviderDriverKind } from "@t3tools/contracts"; | ||
| 8 | import { defineProviderClient } from "@t3tools/provider-core/client"; | ||
| 9 | |||
| 10 | import { GrokSettings } from "./settings.ts"; | ||
| 11 | |||
| 12 | export 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 | */ | ||
| 6 | export { GrokDriver, type GrokDriverEnv } from "./server/driver.ts"; | ||
| 7 | export { 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 | */ | ||
| 11 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 12 | import { it } from "@effect/vitest"; | ||
| 13 | import * as Effect from "effect/Effect"; | ||
| 14 | import * as Deferred from "effect/Deferred"; | ||
| 15 | import * as Fiber from "effect/Fiber"; | ||
| 16 | import * as FileSystem from "effect/FileSystem"; | ||
| 17 | import * as Stream from "effect/Stream"; | ||
| 18 | import { ChildProcessSpawner } from "effect/process"; | ||
| 19 | import { describe, expect } from "vite-plus/test"; | ||
| 20 | |||
| 21 | import { makeGrokAcpRuntime } from "./acpSupport.ts"; | ||
| 22 | |||
| 23 | const 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 | |||
| 34 | describe.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 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 5 | |||
| 6 | import { | ||
| 7 | applyGrokAcpModelSelection, | ||
| 8 | buildGrokAcpSpawnInput, | ||
| 9 | grokAcpRuntimeProcessOwnership, | ||
| 10 | grokAcpSpawnArgs, | ||
| 11 | isValidGrokReasoningEffortToken, | ||
| 12 | resolveGrokAcpBaseModelId, | ||
| 13 | } from "./acpSupport.ts"; | ||
| 14 | |||
| 15 | describe("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 | |||
| 38 | describe("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 | |||
| 46 | describe("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 | |||
| 75 | describe("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 | |||
| 104 | describe("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 | |||
| 114 | describe("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 @@ | ||
| 1 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { | ||
| 4 | type ProviderApprovalOption, | ||
| 5 | ProviderDriverKind, | ||
| 6 | type RuntimeMode, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import type { GrokSettings } from "../settings.ts"; | ||
| 9 | import * as Crypto from "effect/Crypto"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Layer from "effect/Layer"; | ||
| 12 | import * as Scope from "effect/Scope"; | ||
| 13 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | ||
| 14 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 15 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 16 | import { normalizeModelSlug } from "@t3tools/shared/model"; | ||
| 17 | |||
| 18 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; | ||
| 19 | import { makeXAiPromptCompletionRuntime } from "./xaiAcpExtension.ts"; | ||
| 20 | |||
| 21 | const GROK_API_KEY_ENV = "XAI_API_KEY"; | ||
| 22 | const GROK_OAUTH2_REFERRER_ENV = "GROK_OAUTH2_REFERRER"; | ||
| 23 | const T3_CODE_OAUTH_REFERRER = "t3code"; | ||
| 24 | const GROK_AUTH_METHOD_API_KEY = "xai.api_key"; | ||
| 25 | const GROK_AUTH_METHOD_CACHED_TOKEN = "cached_token"; | ||
| 26 | const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); | ||
| 27 | |||
| 28 | type GrokAcpRuntimeGrokSettings = Pick<GrokSettings, "binaryPath">; | ||
| 29 | |||
| 30 | interface 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 | */ | ||
| 45 | export 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 | */ | ||
| 56 | export 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 | |||
| 69 | export 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 | |||
| 86 | function 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 | |||
| 92 | export 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 | */ | ||
| 112 | export 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 | */ | ||
| 121 | export 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 | */ | ||
| 131 | const 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 | */ | ||
| 137 | export 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 | |||
| 155 | export 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 | */ | ||
| 195 | export const GROK_DEFAULT_MODEL_SLUG = "grok-build"; | ||
| 196 | |||
| 197 | export 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 | |||
| 203 | const GROK_REASONING_EFFORT_TOKEN = /^[a-z0-9][a-z0-9._-]{0,31}$/i; | ||
| 204 | |||
| 205 | export function isValidGrokReasoningEffortToken(value: string): boolean { | ||
| 206 | return GROK_REASONING_EFFORT_TOKEN.test(value); | ||
| 207 | } | ||
| 208 | |||
| 209 | function normalizeGrokReasoningEffort(value: string | undefined): string | undefined { | ||
| 210 | const effort = value?.trim(); | ||
| 211 | return effort && isValidGrokReasoningEffortToken(effort) ? effort : undefined; | ||
| 212 | } | ||
| 213 | |||
| 214 | export 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 | |||
| 223 | export 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 | |||
| 246 | export 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 @@ | ||
| 1 | import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; | ||
| 2 | import { | ||
| 3 | XAiPromptFailureText, | ||
| 4 | isXAiTaskCompletedWakeNotification, | ||
| 5 | xAiRateLimitedErrorCode, | ||
| 6 | } from "./xaiAcpExtension.ts"; | ||
| 7 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 8 | import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/nodeRuntime"; | ||
| 9 | import { | ||
| 10 | defaultInstanceIdForDriver, | ||
| 11 | ProviderDriverKind, | ||
| 12 | type OrchestrationV2ProviderCapabilities, | ||
| 13 | type RuntimeMode, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | import { GrokSettings } from "../settings.ts"; | ||
| 16 | import * as Crypto from "effect/Crypto"; | ||
| 17 | import * as Effect from "effect/Effect"; | ||
| 18 | import * as FileSystem from "effect/FileSystem"; | ||
| 19 | import * as Path from "effect/Path"; | ||
| 20 | import * as Layer from "effect/Layer"; | ||
| 21 | import * as Schema from "effect/Schema"; | ||
| 22 | import type * as Scope from "effect/Scope"; | ||
| 23 | import { ChildProcessSpawner } from "effect/process"; | ||
| 24 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 25 | |||
| 26 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; | ||
| 27 | import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeLogging"; | ||
| 28 | import { | ||
| 29 | applyGrokAcpModelSelection, | ||
| 30 | currentGrokModelIdFromSessionSetup, | ||
| 31 | grokApprovalOptions, | ||
| 32 | makeGrokAcpRuntime, | ||
| 33 | resolveGrokAcpBaseModelId, | ||
| 34 | } from "./acpSupport.ts"; | ||
| 35 | import { | ||
| 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"; | ||
| 56 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | ||
| 57 | import { acpPermissionDisposition } from "@t3tools/provider-acp/server/clientPolicy"; | ||
| 58 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; | ||
| 59 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; | ||
| 60 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | ||
| 61 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | ||
| 62 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | ||
| 63 | import { | ||
| 64 | ProviderAdapterDriverCreateError, | ||
| 65 | type ProviderAdapterDriver, | ||
| 66 | type ProviderAdapterDriverCreateInput, | ||
| 67 | } from "@t3tools/provider-core/server/adapterDriver"; | ||
| 68 | import { | ||
| 69 | AcpProviderCapabilitiesV2, | ||
| 70 | makeAcpAdapterV2, | ||
| 71 | type AcpAdapterV2ExtensionContext, | ||
| 72 | type AcpAdapterV2Flavor, | ||
| 73 | type AcpAdapterV2RuntimeInput, | ||
| 74 | } from "@t3tools/provider-acp/server/adapter"; | ||
| 75 | |||
| 76 | export const GROK_PROVIDER = ProviderDriverKind.make("grok"); | ||
| 77 | const GROK_DRIVER_KIND = GROK_PROVIDER; | ||
| 78 | export const GROK_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(GROK_DRIVER_KIND); | ||
| 79 | const DEFAULT_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({}); | ||
| 80 | |||
| 81 | export 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 | |||
| 110 | export 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 | |||
| 134 | const 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 | */ | ||
| 161 | const 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 | |||
| 184 | const 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 | */ | ||
| 224 | export function grokLaunchRuntimeMode( | ||
| 225 | runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, | ||
| 226 | ): RuntimeMode { | ||
| 227 | return runtimePolicy.approvalPolicy === undefined && runtimePolicy.sandboxPolicy === undefined | ||
| 228 | ? runtimePolicy.runtimeMode | ||
| 229 | : "approval-required"; | ||
| 230 | } | ||
| 231 | |||
| 232 | export 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 | |||
| 332 | export 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 | |||
| 350 | export type GrokAdapterV2DriverEnv = | ||
| 351 | | ChildProcessSpawner.ChildProcessSpawner | ||
| 352 | | Crypto.Crypto | ||
| 353 | | FileSystem.FileSystem | ||
| 354 | | IdAllocator.IdAllocatorV2 | ||
| 355 | | Path.Path | ||
| 356 | | ProviderEventLoggers.ProviderEventLoggers | ||
| 357 | | ProviderHost.ProviderHost; | ||
| 358 | |||
| 359 | export 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 | |||
| 411 | const 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 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { expect, it } from "@effect/vitest"; | ||
| 3 | import { ProviderInstanceId } from "@t3tools/contracts"; | ||
| 4 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as FileSystem from "effect/FileSystem"; | ||
| 7 | import * as Layer from "effect/Layer"; | ||
| 8 | import * as Path from "effect/Path"; | ||
| 9 | import { HttpClient } from "effect/http"; | ||
| 10 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | ||
| 11 | |||
| 12 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; | ||
| 13 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; | ||
| 14 | import { GrokDriver } from "./driver.ts"; | ||
| 15 | |||
| 16 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | ||
| 17 | |||
| 18 | const 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 | |||
| 35 | const 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. | ||
| 40 | const windowsHost = HostProcessPlatform.defaultValue() === "win32"; | ||
| 41 | |||
| 42 | it.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 @@ | ||
| 1 | import { ProviderDriverKind } from "@t3tools/contracts"; | ||
| 2 | import { GrokSettings } from "../settings.ts"; | ||
| 3 | import * as Crypto from "effect/Crypto"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 6 | import * as Path from "effect/Path"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | import { HttpClient } from "effect/http"; | ||
| 9 | import { ChildProcessSpawner } from "effect/process"; | ||
| 10 | |||
| 11 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; | ||
| 12 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; | ||
| 13 | import { makeGrokTextGeneration } from "./textGeneration.ts"; | ||
| 14 | import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./adapter.ts"; | ||
| 15 | import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; | ||
| 16 | import { | ||
| 17 | buildInitialGrokProviderSnapshot, | ||
| 18 | checkGrokProviderStatus, | ||
| 19 | enrichGrokSnapshot, | ||
| 20 | } from "./status.ts"; | ||
| 21 | import { readGrokAccount } from "./usageLimits.ts"; | ||
| 22 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; | ||
| 23 | import { | ||
| 24 | defaultProviderContinuationIdentity, | ||
| 25 | type ProviderDriver, | ||
| 26 | type ProviderInstance, | ||
| 27 | } from "@t3tools/provider-core/server/driver"; | ||
| 28 | import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; | ||
| 29 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | ||
| 30 | import { discoverGrokSkills } from "./skills.ts"; | ||
| 31 | import { | ||
| 32 | makeCachedProviderMaintenanceResolution, | ||
| 33 | makeManualOnlyProviderMaintenanceCapabilities, | ||
| 34 | makeProviderMaintenanceCapabilities, | ||
| 35 | type ProviderMaintenanceCapabilitiesResolver, | ||
| 36 | resolveProviderMaintenanceCapabilitiesEffect, | ||
| 37 | } from "@t3tools/provider-core/server/maintenanceResolver"; | ||
| 38 | import { | ||
| 39 | haveProviderSnapshotSettingsChanged, | ||
| 40 | makeProviderSnapshotSettingsSource, | ||
| 41 | type ProviderSnapshotSettings, | ||
| 42 | } from "@t3tools/provider-core/server/snapshotSettings"; | ||
| 43 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); | ||
| 44 | |||
| 45 | const 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". | ||
| 48 | const 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". | ||
| 53 | const 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 | |||
| 73 | export 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 | |||
| 83 | export 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 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Sink from "effect/Sink"; | ||
| 4 | import * as Stream from "effect/Stream"; | ||
| 5 | import { ChildProcessSpawner } from "effect/process"; | ||
| 6 | |||
| 7 | import { discoverGrokSkills } from "./skills.ts"; | ||
| 8 | |||
| 9 | const inspectPayload = (skills: ReadonlyArray<unknown>) => JSON.stringify({ skills }); | ||
| 10 | |||
| 11 | const 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 | |||
| 31 | describe("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 | */ | ||
| 18 | import type { ServerProviderSkill } from "@t3tools/contracts"; | ||
| 19 | import type { GrokSettings } from "../settings.ts"; | ||
| 20 | import * as Effect from "effect/Effect"; | ||
| 21 | import * as Option from "effect/Option"; | ||
| 22 | import * as Schema from "effect/Schema"; | ||
| 23 | import { ChildProcess } from "effect/process"; | ||
| 24 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 25 | |||
| 26 | import { spawnAndCollect } from "@t3tools/provider-core/server/snapshotProbe"; | ||
| 27 | |||
| 28 | const GROK_SKILLS_PROBE_TIMEOUT_MS = 4_000; | ||
| 29 | |||
| 30 | class 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 | */ | ||
| 51 | function 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 | */ | ||
| 100 | export 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 @@ | ||
| 1 | import { | ||
| 2 | type CustomModelSetting, | ||
| 3 | type ModelCapabilities, | ||
| 4 | type ServerProvider, | ||
| 5 | type ServerProviderAuth, | ||
| 6 | type ServerProviderModel, | ||
| 7 | type ServerProviderSlashCommand, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import type { GrokSettings } from "../settings.ts"; | ||
| 10 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 11 | import { causeErrorTag } from "@t3tools/shared/observability"; | ||
| 12 | import * as Crypto from "effect/Crypto"; | ||
| 13 | import * as DateTime from "effect/DateTime"; | ||
| 14 | import * as Effect from "effect/Effect"; | ||
| 15 | import * as Exit from "effect/Exit"; | ||
| 16 | import * as Option from "effect/Option"; | ||
| 17 | import * as Result from "effect/Result"; | ||
| 18 | import * as Schema from "effect/Schema"; | ||
| 19 | import { HttpClient } from "effect/http"; | ||
| 20 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | ||
| 21 | import { createModelCapabilities } from "@t3tools/shared/model"; | ||
| 22 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 23 | |||
| 24 | import { | ||
| 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"; | ||
| 34 | import { | ||
| 35 | enrichProviderSnapshotWithVersionAdvisory, | ||
| 36 | type ProviderMaintenanceCapabilities, | ||
| 37 | } from "@t3tools/provider-core/server/maintenanceResolver"; | ||
| 38 | import { | ||
| 39 | GROK_DEFAULT_MODEL_SLUG, | ||
| 40 | GROK_SUPPORTED_RUNTIME_MODES, | ||
| 41 | isValidGrokReasoningEffortToken, | ||
| 42 | makeGrokAcpRuntime, | ||
| 43 | resolveGrokAcpBaseModelId, | ||
| 44 | } from "./acpSupport.ts"; | ||
| 45 | import { sessionModelStateFromInitialize } from "@t3tools/provider-acp/server/runtimeModel"; | ||
| 46 | import { discoverGrokSkills } from "./skills.ts"; | ||
| 47 | |||
| 48 | const GROK_PRESENTATION = { | ||
| 49 | displayName: "Grok", | ||
| 50 | supportsConversationRollback: false, | ||
| 51 | showInteractionModeToggle: false, | ||
| 52 | supportedRuntimeModes: GROK_SUPPORTED_RUNTIME_MODES, | ||
| 53 | } as const; | ||
| 54 | const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | ||
| 55 | optionDescriptors: [], | ||
| 56 | }); | ||
| 57 | |||
| 58 | const VERSION_PROBE_TIMEOUT_MS = 4_000; | ||
| 59 | // `initialize` is a single local round trip, so this is generous even on slow machines. | ||
| 60 | const GROK_ACP_INITIALIZE_TIMEOUT_MS = 8_000; | ||
| 61 | const GROK_API_KEY_ENV = "XAI_API_KEY"; | ||
| 62 | |||
| 63 | const 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 | |||
| 72 | export 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 | |||
| 111 | function 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 | |||
| 118 | function nonEmptyString(value: unknown): string | undefined { | ||
| 119 | return typeof value === "string" ? value.trim() || undefined : undefined; | ||
| 120 | } | ||
| 121 | |||
| 122 | function isRecord(value: unknown): value is Record<string, unknown> { | ||
| 123 | return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 124 | } | ||
| 125 | |||
| 126 | function 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 | |||
| 194 | export 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. */ | ||
| 217 | export 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 | |||
| 243 | export 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 | */ | ||
| 259 | export 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 | |||
| 289 | function displayNameFromGrokModelSlug(slug: string): string { | ||
| 290 | return slug | ||
| 291 | .split(/[-_]/g) | ||
| 292 | .map((part) => (part.toLowerCase() === "grok" ? "Grok" : part)) | ||
| 293 | .join(" "); | ||
| 294 | } | ||
| 295 | |||
| 296 | const 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 | |||
| 313 | const decodeAvailableCommands = Schema.decodeUnknownOption(Schema.Array(Schema.Unknown)); | ||
| 314 | const 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 | |||
| 322 | export 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 | */ | ||
| 353 | const 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 | |||
| 373 | export 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 | |||
| 565 | export 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 @@ | ||
| 1 | import * as Crypto from "effect/Crypto"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import * as Ref from "effect/Ref"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 6 | import { ChildProcessSpawner } from "effect/process"; | ||
| 7 | import type * as EffectAcpErrors from "effect-acp/errors"; | ||
| 8 | |||
| 9 | import { TextGenerationError } from "@t3tools/contracts"; | ||
| 10 | import type { GrokSettings } from "../settings.ts"; | ||
| 11 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | ||
| 12 | |||
| 13 | import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; | ||
| 14 | import { | ||
| 15 | applyGrokAcpModelSelection, | ||
| 16 | currentGrokModelIdFromSessionSetup, | ||
| 17 | currentGrokReasoningEffortFromSessionSetup, | ||
| 18 | makeGrokAcpRuntime, | ||
| 19 | resolveGrokAcpBaseModelId, | ||
| 20 | } from "./acpSupport.ts"; | ||
| 21 | |||
| 22 | const GROK_TIMEOUT_MS = 180_000; | ||
| 23 | |||
| 24 | const isTextGenerationError = Schema.is(TextGenerationError); | ||
| 25 | |||
| 26 | export 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 @@ | ||
| 1 | import * as NodeOS from "node:os"; | ||
| 2 | import type { ServerProviderUsageWindow } from "@t3tools/contracts"; | ||
| 3 | import * as DateTime from "effect/DateTime"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 6 | import * as Option from "effect/Option"; | ||
| 7 | import * as Path from "effect/Path"; | ||
| 8 | import * as Schema from "effect/Schema"; | ||
| 9 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | ||
| 10 | import { | ||
| 11 | clampPercent, | ||
| 12 | makeUnavailableUsageLimits, | ||
| 13 | makeUsageLimits, | ||
| 14 | } from "@t3tools/provider-core/server/usageLimits"; | ||
| 15 | |||
| 16 | const 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 | ); | ||
| 24 | const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(GrokCredentials)); | ||
| 25 | const 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 | |||
| 39 | export 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 | */ | ||
| 73 | const 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 | */ | ||
| 140 | export 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. | ||
| 2 | import * as NodeOS from "node:os"; | ||
| 3 | import { it } from "@effect/vitest"; | ||
| 4 | import * as Deferred from "effect/Deferred"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Fiber from "effect/Fiber"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | import { describe, expect } from "vite-plus/test"; | ||
| 9 | |||
| 10 | import { | ||
| 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"; | ||
| 37 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; | ||
| 38 | import { | ||
| 39 | type AcpToolCallState, | ||
| 40 | mergeToolCallState, | ||
| 41 | parseSessionUpdateEvent, | ||
| 42 | } from "@t3tools/provider-acp/server/runtimeModel"; | ||
| 43 | |||
| 44 | const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest); | ||
| 45 | |||
| 46 | describe("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. | ||
| 91 | function 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 | |||
| 100 | describe("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 | |||
| 1882 | describe("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. | ||
| 2 | import * as NodeOS from "node:os"; | ||
| 3 | |||
| 4 | import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; | ||
| 5 | import * as Cause from "effect/Cause"; | ||
| 6 | import * as Deferred from "effect/Deferred"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Fiber from "effect/Fiber"; | ||
| 9 | import * as Ref from "effect/Ref"; | ||
| 10 | import * as Schema from "effect/Schema"; | ||
| 11 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 12 | import type * as EffectAcpSchema from "effect-acp/compat"; | ||
| 13 | |||
| 14 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; | ||
| 15 | import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel"; | ||
| 16 | |||
| 17 | const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing"; | ||
| 18 | export const xAiRateLimitedErrorCode = -32003; | ||
| 19 | const completedXAiPromptIdLimit = 128; | ||
| 20 | |||
| 21 | const 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 | |||
| 28 | type 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 | */ | ||
| 40 | const 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 | |||
| 58 | type XAiSessionUpdateNotification = typeof XAiSessionUpdateNotification.Type; | ||
| 59 | |||
| 60 | const decodeXAiSessionUpdateNotification = Schema.decodeUnknownEffect(XAiSessionUpdateNotification); | ||
| 61 | |||
| 62 | const xAiSessionNotificationMethods = [ | ||
| 63 | "x.ai/session_notification", | ||
| 64 | "_x.ai/session_notification", | ||
| 65 | "_x.ai/session/update", | ||
| 66 | ] as const; | ||
| 67 | |||
| 68 | const 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 | */ | ||
| 75 | export 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 | */ | ||
| 101 | export 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 | |||
| 108 | interface PendingXAiPromptCompletion { | ||
| 109 | readonly sessionId: string; | ||
| 110 | readonly promptId: string; | ||
| 111 | readonly deferred: Deferred.Deferred< | ||
| 112 | EffectAcpSchema.PromptResponse, | ||
| 113 | EffectAcpErrors.AcpRequestError | ||
| 114 | >; | ||
| 115 | } | ||
| 116 | |||
| 117 | export 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 | |||
| 132 | export interface XAiAcpBackgroundToolMutation { | ||
| 133 | readonly taskId: string; | ||
| 134 | readonly status: "running" | "completed" | "failed"; | ||
| 135 | readonly appendOutput: string; | ||
| 136 | } | ||
| 137 | |||
| 138 | export interface XAiAcpSubagentEndNotice { | ||
| 139 | readonly childSessionId: string; | ||
| 140 | readonly status: "completed" | "failed"; | ||
| 141 | } | ||
| 142 | |||
| 143 | const XAI_UUID_RE = "[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}"; | ||
| 144 | |||
| 145 | function 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 | |||
| 151 | function nonEmptyString(value: unknown): string | undefined { | ||
| 152 | return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; | ||
| 153 | } | ||
| 154 | |||
| 155 | function titleKey(toolCall: AcpToolCallState): string { | ||
| 156 | return (toolCall.title ?? "").trim().toLowerCase(); | ||
| 157 | } | ||
| 158 | |||
| 159 | function 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 | |||
| 171 | function 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 | |||
| 204 | function firstUuidMatch(text: string, pattern: RegExp): string | undefined { | ||
| 205 | const match = pattern.exec(text); | ||
| 206 | return match?.[1]; | ||
| 207 | } | ||
| 208 | |||
| 209 | function 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 | |||
| 219 | function 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 | |||
| 228 | function isXAiMonitorStartAck(output: string | undefined): boolean { | ||
| 229 | if (output === undefined) return false; | ||
| 230 | return /monitor started\s*\(\s*task\s+/i.test(output); | ||
| 231 | } | ||
| 232 | |||
| 233 | function 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 | |||
| 250 | function 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 | |||
| 272 | export 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 | */ | ||
| 298 | function 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 | |||
| 305 | export 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 | */ | ||
| 335 | export 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 | */ | ||
| 349 | export 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 | */ | ||
| 389 | export 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 | |||
| 416 | const 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 | |||
| 441 | type XAiTaskLifecycleNotification = typeof XAiTaskLifecycleNotification.Type; | ||
| 442 | |||
| 443 | export 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 | |||
| 455 | function 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 | |||
| 482 | export 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 | */ | ||
| 514 | export 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 | */ | ||
| 544 | export 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. */ | ||
| 552 | export 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 | */ | ||
| 575 | export 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 | */ | ||
| 594 | export 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 | |||
| 599 | export 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 | */ | ||
| 640 | export 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 | */ | ||
| 689 | export 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 | */ | ||
| 757 | function 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 | |||
| 772 | export 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 | |||
| 791 | function 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). */ | ||
| 813 | function 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 | |||
| 835 | function 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 | |||
| 860 | function 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 | */ | ||
| 899 | export 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 | |||
| 966 | const 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 | |||
| 973 | const 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 | |||
| 980 | const XAiAskUserQuestionParams = Schema.Struct({ | ||
| 981 | sessionId: Schema.String, | ||
| 982 | toolCallId: Schema.String, | ||
| 983 | questions: Schema.Array(XAiAskUserQuestion), | ||
| 984 | mode: Schema.Literals(["default", "plan"]), | ||
| 985 | }); | ||
| 986 | |||
| 987 | const XAiWrappedAskUserQuestionParams = Schema.Struct({ | ||
| 988 | method: Schema.Literals(["x.ai/ask_user_question", "_x.ai/ask_user_question"]), | ||
| 989 | params: XAiAskUserQuestionParams, | ||
| 990 | }); | ||
| 991 | |||
| 992 | export const XAiAskUserQuestionRequest = Schema.Union([ | ||
| 993 | XAiAskUserQuestionParams, | ||
| 994 | XAiWrappedAskUserQuestionParams, | ||
| 995 | ]); | ||
| 996 | |||
| 997 | type XAiAskUserQuestionRequestParams = typeof XAiAskUserQuestionParams.Type; | ||
| 998 | type XAiAskUserQuestionRequest = typeof XAiAskUserQuestionRequest.Type; | ||
| 999 | |||
| 1000 | // --------------------------------------------------------------------------- | ||
| 1001 | // x.ai/exit_plan_mode — plan approval gate (mirrors Grok Build TUI plan window) | ||
| 1002 | // --------------------------------------------------------------------------- | ||
| 1003 | |||
| 1004 | const XAiExitPlanModeParams = Schema.Struct({ | ||
| 1005 | sessionId: Schema.String, | ||
| 1006 | toolCallId: Schema.String, | ||
| 1007 | planContent: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 1008 | }); | ||
| 1009 | |||
| 1010 | const XAiWrappedExitPlanModeParams = Schema.Struct({ | ||
| 1011 | method: Schema.Literals(["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"]), | ||
| 1012 | params: XAiExitPlanModeParams, | ||
| 1013 | }); | ||
| 1014 | |||
| 1015 | export const XAiExitPlanModeRequest = Schema.Union([ | ||
| 1016 | XAiExitPlanModeParams, | ||
| 1017 | XAiWrappedExitPlanModeParams, | ||
| 1018 | ]); | ||
| 1019 | |||
| 1020 | type XAiExitPlanModeRequestParams = typeof XAiExitPlanModeParams.Type; | ||
| 1021 | type XAiExitPlanModeRequest = typeof XAiExitPlanModeRequest.Type; | ||
| 1022 | |||
| 1023 | function 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. */ | ||
| 1028 | export const XAI_EMPTY_PLAN_MARKDOWN = | ||
| 1029 | "# No plan written yet\n\n(The agent exited plan mode without writing a plan.)"; | ||
| 1030 | |||
| 1031 | export 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 | |||
| 1047 | export type XAiExitPlanModeOutcome = "approved" | "abandoned" | "request_changes"; | ||
| 1048 | |||
| 1049 | export 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 | */ | ||
| 1058 | export 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 | |||
| 1067 | function normalizeFsPath(value: string): string { | ||
| 1068 | return value.trim().replace(/\\/g, "/").replace(/\/+$/, ""); | ||
| 1069 | } | ||
| 1070 | |||
| 1071 | function pathHasTraversalSegment(normalized: string): boolean { | ||
| 1072 | return normalized.split("/").includes(".."); | ||
| 1073 | } | ||
| 1074 | |||
| 1075 | function 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`. */ | ||
| 1088 | export interface GrokPlanPathHost { | ||
| 1089 | readonly platform: NodeJS.Platform; | ||
| 1090 | readonly environment: NodeJS.ProcessEnv; | ||
| 1091 | } | ||
| 1092 | |||
| 1093 | function 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 | |||
| 1107 | const CANONICAL_HOME_GROK_SESSION_PATH = | ||
| 1108 | /^(?:\/home\/[^/]+|\/Users\/[^/]+|[a-zA-Z]:\/Users\/[^/]+)\/\.grok\/sessions\/(?:[^/]+\/)+plan\.md$/; | ||
| 1109 | const 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 | */ | ||
| 1120 | export 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 | */ | ||
| 1157 | export 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 | |||
| 1208 | function isRecord(value: unknown): value is Record<string, unknown> { | ||
| 1209 | return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 1210 | } | ||
| 1211 | |||
| 1212 | function trimmed(value: string | undefined): string | undefined { | ||
| 1213 | const text = value?.trim(); | ||
| 1214 | return text && text.length > 0 ? text : undefined; | ||
| 1215 | } | ||
| 1216 | |||
| 1217 | function unwrapAskUserQuestionParams( | ||
| 1218 | params: XAiAskUserQuestionRequest, | ||
| 1219 | ): XAiAskUserQuestionRequestParams { | ||
| 1220 | return "params" in params ? params.params : params; | ||
| 1221 | } | ||
| 1222 | |||
| 1223 | export 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 | |||
| 1241 | export 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 | |||
| 1252 | interface XAiAskUserQuestionAnnotation { | ||
| 1253 | readonly preview?: string; | ||
| 1254 | readonly notes?: string; | ||
| 1255 | } | ||
| 1256 | |||
| 1257 | interface XAiAskUserQuestionAcceptedResponse { | ||
| 1258 | readonly outcome: "accepted"; | ||
| 1259 | readonly answers: Record<string, ReadonlyArray<string>>; | ||
| 1260 | readonly annotations?: Record<string, XAiAskUserQuestionAnnotation>; | ||
| 1261 | } | ||
| 1262 | |||
| 1263 | interface XAiAskUserQuestionCancelledResponse { | ||
| 1264 | readonly outcome: "cancelled"; | ||
| 1265 | } | ||
| 1266 | |||
| 1267 | interface NormalizedXAiAnswer { | ||
| 1268 | readonly questionText: string; | ||
| 1269 | readonly selectedLabels: ReadonlyArray<string>; | ||
| 1270 | readonly annotation?: XAiAskUserQuestionAnnotation; | ||
| 1271 | } | ||
| 1272 | |||
| 1273 | function 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 | |||
| 1284 | function 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 | |||
| 1320 | function 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 | |||
| 1328 | export 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 | |||
| 1352 | export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCancelledResponse { | ||
| 1353 | return { outcome: "cancelled" }; | ||
| 1354 | } | ||
| 1355 | |||
| 1356 | function 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 | |||
| 1365 | function 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 | |||
| 1378 | function 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 | */ | ||
| 1408 | function 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 | */ | ||
| 1435 | export class XAiPromptFailureText extends Error {} | ||
| 1436 | |||
| 1437 | const 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 | |||
| 1449 | const 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 | |||
| 1454 | const 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 | |||
| 1490 | const 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 | |||
| 1543 | const 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 | */ | ||
| 1570 | export 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 | |||
| 1710 | function 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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import { TurnId } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | buildGrokBackgroundTaskEvents, | ||
| 6 | type GrokBackgroundTaskRecord, | ||
| 7 | } from "./xaiBackgroundTasks.ts"; | ||
| 8 | |||
| 9 | const turnId = TurnId.make("turn-1"); | ||
| 10 | const monitor = { type: "Monitor", taskId: "monitor-1", timeoutMs: 60_000 }; | ||
| 11 | const shell = { type: "BackgroundTaskStarted", task_id: "shell-1", command: "sleep 40" }; | ||
| 12 | |||
| 13 | function 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 | |||
| 31 | describe("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 @@ | ||
| 1 | import { | ||
| 2 | RuntimeTaskId, | ||
| 3 | type ProviderRuntimeTaskStartedEvent, | ||
| 4 | type ProviderRuntimeTaskProgressEvent, | ||
| 5 | type ProviderRuntimeTaskCompletedEvent, | ||
| 6 | type TurnId, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | |||
| 9 | type TaskEvent = | ||
| 10 | | Pick<ProviderRuntimeTaskStartedEvent, "type" | "payload" | "turnId"> | ||
| 11 | | Pick<ProviderRuntimeTaskProgressEvent, "type" | "payload" | "turnId"> | ||
| 12 | | Pick<ProviderRuntimeTaskCompletedEvent, "type" | "payload" | "turnId">; | ||
| 13 | |||
| 14 | export 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 | |||
| 25 | function 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 | |||
| 31 | function text(value: unknown): string | undefined { | ||
| 32 | return typeof value === "string" ? value.trim() || undefined : undefined; | ||
| 33 | } | ||
| 34 | |||
| 35 | function 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. */ | ||
| 61 | export 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 | */ | ||
| 7 | import { | ||
| 8 | CustomModelSetting, | ||
| 9 | makeBinaryPathSetting, | ||
| 10 | makeProviderSettingsSchema, | ||
| 11 | } from "@t3tools/contracts"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as Schema from "effect/Schema"; | ||
| 14 | |||
| 15 | export 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 | ); | ||
| 39 | export 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 | */ | ||
| 7 | export { | ||
| 8 | GROK_DEFAULT_INSTANCE_ID, | ||
| 9 | GROK_PROVIDER, | ||
| 10 | GrokProviderCapabilitiesV2, | ||
| 11 | grokLaunchRuntimeMode, | ||
| 12 | makeGrokAcpAdapterFlavor, | ||
| 13 | makeGrokAdapterV2, | ||
| 14 | type GrokAdapterV2Options, | ||
| 15 | } from "./server/adapter.ts"; | ||
| 16 | export { | ||
| 17 | GROK_ACP_CANCEL_META, | ||
| 18 | GROK_ACP_INITIALIZE_META, | ||
| 19 | makeGrokAcpRuntime, | ||
| 20 | } from "./server/acpSupport.ts"; | ||
| 21 | export { | ||
| 22 | buildGrokModelCapabilities, | ||
| 23 | buildGrokModelsFromSessionModelState, | ||
| 24 | buildInitialGrokProviderSnapshot, | ||
| 25 | checkGrokProviderStatus, | ||
| 26 | grokSlashCommandsFromInitialize, | ||
| 27 | parseGrokModelsCliOutput, | ||
| 28 | } from "./server/status.ts"; | ||
| 29 | export { makeGrokTextGeneration } from "./server/textGeneration.ts"; | ||
| 30 | export { grokUsageResponseToLimits, readGrokAccount } from "./server/usageLimits.ts"; | ||
| 31 | export { | ||
| 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-cursor | 361 | 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-muse | 367 | 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-cursor | 614 | 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-muse | 620 | 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-cursor | 756 | 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-muse | 762 | version: link:../../packages/provider-muse |
| ... | @@ -1194,6 +1203,43 @@ importers: | ... | @@ -1194,6 +1203,43 @@ importers: |
| 1194 | specifier: 1.0.0 | 1203 | 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) |
| 1196 | 1205 | ||
| 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", |