diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts index 9a9ec5f2282d..03f9e3df3fa9 100644 --- a/apps/mobile/src/features/usage/usageProviders.ts +++ b/apps/mobile/src/features/usage/usageProviders.ts @@ -5,11 +5,12 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe * Series and table order. The chart stacks providers from the bottom in this * order, so it also fixes which band sits on top of the bars. */ -export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; +export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "grok"]; export const PROVIDER_LABEL: Record = { claude: "Claude Code", codex: "Codex", + grok: "Grok Build", }; /** @@ -21,5 +22,6 @@ export function useProviderColors(): Record { return { claude: "#d97757", codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43", + grok: "#8884d8", }; } diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index bc7828dd8547..b6b81e130edf 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -19,6 +19,10 @@ const emitInterleavedAssistantToolCalls = const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; +const xAiSessionNotificationMethod = + process.env.T3_ACP_XAI_SESSION_METHOD ?? "x.ai/session_notification"; +const emitSessionExtras = process.env.T3_ACP_EMIT_SESSION_EXTRAS === "1"; +const emitQueueChanged = process.env.T3_ACP_EMIT_QUEUE === "1"; const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1"; const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; @@ -279,9 +283,29 @@ function modeState(): AcpSchema.SessionModeState { } const grokAcpModels: ReadonlyArray = [ - { modelId: "grok-build", name: "Grok Build" }, + { + modelId: "grok-build", + name: "Grok Build", + _meta: { + supportsReasoningEffort: true, + reasoningEffort: "high", + totalContextTokens: 500000, + reasoningEfforts: [ + { id: "xhigh", value: "xhigh", label: "Extra High Effort" }, + { id: "high", value: "high", label: "High Effort", default: true }, + { id: "medium", value: "medium", label: "Medium Effort" }, + { id: "low", value: "low", label: "Low Effort" }, + ], + }, + }, { modelId: "grok-mock-alt", name: "Grok Mock Alt" }, ]; +const enableRewind = process.env.T3_ACP_ENABLE_REWIND === "1"; +const ghostRewindOnCancel = process.env.T3_ACP_REWIND_GHOST_ON_CANCEL === "1"; +const emitUsage = process.env.T3_ACP_EMIT_USAGE === "1"; +const emitWorkflow = process.env.T3_ACP_EMIT_WORKFLOW === "1"; +const emitSubagent = process.env.T3_ACP_EMIT_SUBAGENT === "1"; +let rewindPoints: Array<{ prompt_index: number; prompt_preview: string }> = []; function modelState(): AcpSchema.SessionModelState { const modelId = grokAcpModels.some((model) => model.modelId === currentModelId) @@ -392,7 +416,12 @@ const program = Effect.gen(function* () { ); } currentModelId = request.modelId; - return {}; + return { + _meta: { + model: { Ok: request.modelId }, + ...(request._meta ?? {}), + }, + }; }), ); @@ -437,6 +466,12 @@ const program = Effect.gen(function* () { Effect.gen(function* () { const cancelledSessionId = String(sessionId ?? "mock-session-1"); cancelledSessions.add(cancelledSessionId); + if (enableRewind && ghostRewindOnCancel) { + rewindPoints.push({ + prompt_index: rewindPoints.length, + prompt_preview: "cancelled-ghost", + }); + } if (emitLateUpdateAfterCancel) { yield* Effect.sleep("50 millis"); yield* Effect.sync(() => { @@ -545,7 +580,13 @@ const program = Effect.gen(function* () { sessionId: requestedSessionId, promptId: promptIdFromRequestMeta(request) ?? "mock-xai-prompt-1", ...(omitXAiPromptCompleteStopReason ? {} : { stopReason: "end_turn" }), - agentResult: null, + agentResult: emitUsage + ? { + input_tokens: 10, + output_tokens: 4, + reasoning_tokens: 3, + } + : null, }); if (emitForeignSessionUpdates) { @@ -873,11 +914,170 @@ const program = Effect.gen(function* () { }, }); - return { stopReason: "end_turn" }; + if (enableRewind && !cancelledSessions.has(requestedSessionId)) { + const preview = + request.prompt.find((block) => block.type === "text" && "text" in block)?.text ?? ""; + rewindPoints.push({ + prompt_index: rewindPoints.length, + prompt_preview: typeof preview === "string" ? preview : "", + }); + } + + if (emitSubagent) { + writeJsonRpcNotification(xAiSessionNotificationMethod, { + sessionId: requestedSessionId, + update: { + sessionUpdate: "subagent_spawned", + subagent_id: "sa_explore_1", + parent_session_id: requestedSessionId, + child_session_id: "child-explore-1", + subagent_type: "explore", + }, + }); + } + + if (emitWorkflow) { + writeJsonRpcNotification(xAiSessionNotificationMethod, { + sessionId: requestedSessionId, + update: { + sessionUpdate: "workflow_updated", + run_id: "wf_review_1", + revision: 1, + name: "review-changes", + objective: "Review the latest diff", + status: "active", + phases: [ + { title: "Plan", state: "done" }, + { title: "Execute", state: "active" }, + ], + current_phase: "Execute", + elapsed_ms: 1200, + active_agents: 1, + agents: [ + { + agent_id: "agent_reviewer", + label: "Reviewer", + phase: "Execute", + model: "grok-4.6", + state: "running", + tokens_used: 42, + duration_ms: 800, + }, + ], + }, + }); + } + + if (emitSessionExtras) { + writeJsonRpcNotification(xAiSessionNotificationMethod, { + sessionId: requestedSessionId, + update: { + sessionUpdate: "hook_execution", + event_name: "user_prompt_submit", + runs: [ + { + name: "global/settings:user_prompt_submit[0].hooks[0]", + status: { status: "success", elapsed_ms: 12 }, + }, + ], + }, + }); + writeJsonRpcNotification(xAiSessionNotificationMethod, { + sessionId: requestedSessionId, + update: { + sessionUpdate: "auto_compact_started", + tokens_used: 402_072, + context_window: 500_000, + percentage: 80, + reason: "Context window 80% full", + }, + }); + writeJsonRpcNotification(xAiSessionNotificationMethod, { + sessionId: requestedSessionId, + update: { + sessionUpdate: "auto_compact_completed", + tokens_before: 402_072, + tokens_after: 42_380, + elapsed_ms: 80, + }, + }); + writeJsonRpcNotification(xAiSessionNotificationMethod, { + sessionId: requestedSessionId, + update: { + sessionUpdate: "session_recap", + summary: "Mapped Grok extras onto T3 runtime events.", + auto: true, + }, + }); + writeJsonRpcNotification(xAiSessionNotificationMethod, { + sessionId: requestedSessionId, + update: { + sessionUpdate: "turn_completed", + prompt_id: "prompt-extras-1", + stop_reason: "end_turn", + usage: { + inputTokens: 100, + outputTokens: 20, + costUsdTicks: 1_626_488_800, + }, + }, + }); + writeJsonRpcNotification(xAiSessionNotificationMethod, { + sessionId: requestedSessionId, + update: { + sessionUpdate: "task_backgrounded", + task_id: "call-bg-1", + command: "sleep 1", + description: "Background wait", + output_file: "/tmp/grok-bg.log", + }, + }); + } + + if (emitQueueChanged) { + writeJsonRpcNotification("_x.ai/queue/changed", { + sessionId: requestedSessionId, + entries: [{ prompt: "follow up" }], + }); + } + + return { + stopReason: "end_turn", + ...(emitUsage + ? { + _meta: { + usage: { + input_tokens: 10, + output_tokens: 4, + reasoning_tokens: 3, + }, + }, + } + : {}), + }; }), ); yield* agent.handleUnknownExtRequest((method, params) => { + if (method === "_x.ai/rewind/points") { + return Effect.succeed({ rewind_points: rewindPoints }); + } + if (method === "_x.ai/rewind/execute") { + const record = typeof params === "object" && params !== null ? params : {}; + const target = + "targetPromptIndex" in record && typeof record.targetPromptIndex === "number" + ? record.targetPromptIndex + : undefined; + if (target === undefined) { + return Effect.succeed({ success: false, error: "missing targetPromptIndex" }); + } + rewindPoints = rewindPoints.filter((point) => point.prompt_index < target); + return Effect.succeed({ + success: true, + target_prompt_index: target, + mode: "conversation_only", + }); + } if (method === "cursor/list_available_models") { return Effect.succeed({ models: availableModels(), diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 112f11013161..778fc94c9666 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -86,6 +86,8 @@ export const GrokDriver: ProviderDriver = { Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; @@ -113,10 +115,13 @@ export const GrokDriver: ProviderDriver = { }); const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe( + const { cwd: projectRoot } = yield* ServerConfig; + const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, projectRoot).pipe( Effect.map(stampIdentity), Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), ); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); @@ -126,7 +131,14 @@ export const GrokDriver: ProviderDriver = { streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - buildInitialGrokProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + buildInitialGrokProviderSnapshot(settings.provider, { + environment: processEnv, + projectRoot, + }).pipe( + Effect.map(stampIdentity), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), checkProvider, enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => enrichGrokSnapshot({ diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 6cb71660a74c..54a24aac0cc0 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -26,7 +26,11 @@ import { } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; -import { grokPromptSettlementBelongsToContext, makeGrokAdapter } from "./GrokAdapter.ts"; +import { + grokPromptSettlementBelongsToContext, + makeGrokAdapter, + selectGrokPermissionOptionId, +} from "./GrokAdapter.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); @@ -89,6 +93,24 @@ const grokAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { const makeTestAdapter = (binaryPath: string, options?: Parameters[1]) => makeGrokAdapter(decodeGrokSettings({ binaryPath }), options).pipe(Effect.orDie); +it("falls back to allow_once when Grok omits allow_always", () => { + const request = { + sessionId: "sess-1", + options: [ + { optionId: "allow-once", name: "Allow once", kind: "allow_once" as const }, + { optionId: "reject-once", name: "Reject", kind: "reject_once" as const }, + ], + toolCall: { + toolCallId: "tool-1", + title: "run", + kind: "execute" as const, + status: "pending" as const, + }, + }; + assert.equal(selectGrokPermissionOptionId(request, "acceptForSession"), "allow-once"); + assert.equal(selectGrokPermissionOptionId(request, "accept"), "allow-once"); +}); + it("requires a settlement to match the live Grok turn", () => { const staleTurnId = TurnId.make("stale-turn"); const replacementTurnId = TurnId.make("replacement-turn"); @@ -424,13 +446,21 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper({ T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1", + T3_ACP_EMIT_USAGE: "1", }), ); const adapter = yield* makeTestAdapter(wrapperPath); const contentDelta = yield* Deferred.make(); - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - event.type === "content.delta" ? Deferred.succeed(contentDelta, undefined) : Effect.void, - ).pipe(Effect.forkChild); + const usageUpdated = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (event.type === "content.delta") { + return Deferred.succeed(contentDelta, undefined).pipe(Effect.ignore); + } + if (event.type === "thread.token-usage.updated") { + return Deferred.succeed(usageUpdated, event).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkChild); yield* adapter.startSession({ threadId, @@ -460,10 +490,16 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { const snapshot = yield* adapter.readThread(threadId); assert.equal(snapshot.turns.length, 1); assert.equal(snapshot.turns[0]?.items.length, 1); + const usageEvent = yield* Deferred.await(usageUpdated).pipe(Effect.timeout("2 seconds")); + assert.equal(usageEvent.type, "thread.token-usage.updated"); + if (usageEvent.type === "thread.token-usage.updated") { + assert.equal(usageEvent.payload.usage.usedTokens, 17); + assert.equal(usageEvent.payload.usage.inputTokens, 10); + } yield* Fiber.interrupt(runtimeEventsFiber); yield* adapter.stopSession(threadId); - }), + }).pipe(TestClock.withLive), ); it.effect("does not report a synthetic stop reason when xAI omits one", () => @@ -1197,7 +1233,6 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { yield* adapter.stopSession(threadId); }), ); - // Production calls startSession from a request fiber that finishes as soon as // the session exists. `Effect.forkChild` made the notification consumer a // child of that fiber, and Effect interrupts a fiber's children when it @@ -1264,4 +1299,495 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { // hang until the suite timeout instead of failing here. }).pipe(TestClock.withLive), ); + + it.effect("sends session/set_model _meta.reasoningEffort", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-effort-set-model"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-effort-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + }); + + yield* waitForFileContent(requestLogPath, 80, "session/set_model"); + const lines = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const setModel = lines.find((line) => line.method === "session/set_model"); + assert.isDefined(setModel); + assert.equal( + (setModel?.params as { _meta?: { reasoningEffort?: string } } | undefined)?._meta + ?.reasoningEffort, + "xhigh", + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("emits token usage from the Grok prompt result", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-usage"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_EMIT_USAGE: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const usage = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "thread.token-usage.updated" ? Deferred.succeed(usage, event) : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + yield* adapter.sendTurn({ threadId, input: "count tokens", attachments: [] }); + const event = yield* Deferred.await(usage); + assert.equal(event.type, "thread.token-usage.updated"); + if (event.type === "thread.token-usage.updated") { + assert.equal(event.payload.usage.usedTokens, 17); + assert.equal(event.payload.usage.inputTokens, 10); + } + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("rolls back Grok conversation turns through rewind", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-rewind"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_ENABLE_REWIND: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const firstTurnDone = yield* Deferred.make(); + const secondTurnDone = yield* Deferred.make(); + const completedTurns = yield* Ref.make(0); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "turn.completed" + ? Ref.updateAndGet(completedTurns, (count) => count + 1).pipe( + Effect.flatMap((count) => { + if (count === 1) { + return Deferred.succeed(firstTurnDone, undefined); + } + if (count === 2) { + return Deferred.succeed(secondTurnDone, undefined); + } + return Effect.void; + }), + ) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + yield* adapter.sendTurn({ threadId, input: "first", attachments: [] }); + yield* Deferred.await(firstTurnDone); + yield* adapter.sendTurn({ threadId, input: "second", attachments: [] }); + yield* Deferred.await(secondTurnDone); + + const rolled = yield* adapter.rollbackThread(threadId, 1); + assert.equal(rolled.turns.length, 1); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("rejects rewind when numTurns exceeds recorded turns", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-rewind-overshoot"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_ENABLE_REWIND: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + + const error = yield* Effect.flip(adapter.rollbackThread(threadId, 1)); + assert.equal(error._tag, "ProviderAdapterValidationError"); + if (error._tag === "ProviderAdapterValidationError") { + assert.match(error.issue, /exceeds recorded turns/); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("cancels an in-flight prompt before rewind so the discarded turn cannot return", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-rewind-inflight"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-rewind-inflight-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_ENABLE_REWIND: "1", + T3_ACP_PROMPT_DELAY_MS: "400", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const firstTurnDone = yield* Deferred.make(); + const secondTurnDone = yield* Deferred.make(); + const completedTurns = yield* Ref.make(0); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "turn.completed" + ? Ref.updateAndGet(completedTurns, (count) => count + 1).pipe( + Effect.flatMap((count) => { + if (count === 1) { + return Deferred.succeed(firstTurnDone, undefined); + } + if (count === 2) { + return Deferred.succeed(secondTurnDone, undefined); + } + return Effect.void; + }), + ) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + yield* adapter.sendTurn({ threadId, input: "first", attachments: [] }); + yield* Deferred.await(firstTurnDone).pipe(Effect.timeout("5 seconds")); + yield* adapter.sendTurn({ threadId, input: "second", attachments: [] }); + yield* Deferred.await(secondTurnDone).pipe(Effect.timeout("5 seconds")); + + const thirdSend = yield* adapter + .sendTurn({ threadId, input: "third-in-flight", attachments: [] }) + .pipe(Effect.forkChild); + yield* waitForFileContent(requestLogPath, 80, "third-in-flight"); + + const rolled = yield* adapter.rollbackThread(threadId, 1); + yield* Fiber.join(thirdSend).pipe(Effect.timeout("5 seconds"), Effect.ignore); + yield* Effect.sleep("500 millis"); + const afterLatePrompt = yield* adapter.readThread(threadId); + + assert.equal(rolled.turns.length, 1); + assert.equal(afterLatePrompt.turns.length, 1); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + + it.effect("rewinds past a cancelled prompt that still appears in Grok rewind points", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-rewind-ghost"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-rewind-ghost-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_ENABLE_REWIND: "1", + T3_ACP_PROMPT_DELAY_MS: "400", + T3_ACP_REWIND_GHOST_ON_CANCEL: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const firstTurnDone = yield* Deferred.make(); + const secondTurnDone = yield* Deferred.make(); + const completedTurns = yield* Ref.make(0); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "turn.completed" + ? Ref.updateAndGet(completedTurns, (count) => count + 1).pipe( + Effect.flatMap((count) => { + if (count === 1) { + return Deferred.succeed(firstTurnDone, undefined); + } + if (count === 2) { + return Deferred.succeed(secondTurnDone, undefined); + } + return Effect.void; + }), + ) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + yield* adapter.sendTurn({ threadId, input: "first", attachments: [] }); + yield* Deferred.await(firstTurnDone).pipe(Effect.timeout("5 seconds")); + yield* adapter.sendTurn({ threadId, input: "second", attachments: [] }); + yield* Deferred.await(secondTurnDone).pipe(Effect.timeout("5 seconds")); + + const thirdSend = yield* adapter + .sendTurn({ threadId, input: "third-in-flight", attachments: [] }) + .pipe(Effect.forkChild); + yield* waitForFileContent(requestLogPath, 80, "third-in-flight"); + + const rolled = yield* adapter.rollbackThread(threadId, 1); + yield* Fiber.join(thirdSend).pipe(Effect.timeout("5 seconds"), Effect.ignore); + + assert.equal(rolled.turns.length, 1); + const lines = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const execute = lines.find((line) => line.method === "_x.ai/rewind/execute"); + assert.isDefined(execute); + assert.equal( + (execute?.params as { targetPromptIndex?: number } | undefined)?.targetPromptIndex, + 1, + ); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + + it.effect("projects Grok workflow_updated notifications as task events", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-workflow"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_EMIT_WORKFLOW: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const started = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "task.started" && event.payload.taskType === "local_workflow" + ? Deferred.succeed(started, event) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + yield* adapter.sendTurn({ threadId, input: "review this", attachments: [] }); + const event = yield* Deferred.await(started); + assert.equal(event.type, "task.started"); + if (event.type === "task.started") { + assert.equal(event.payload.workflowName, "review-changes"); + assert.equal(event.payload.taskType, "local_workflow"); + assert.equal(event.payload.phases?.[0]?.title, "Plan"); + } + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("projects Grok subagent_spawned notifications as bypassed child tasks", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-subagent"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_EMIT_SUBAGENT: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const started = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "task.started" && event.payload.timelineBypass === true + ? Deferred.succeed(started, event) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + yield* adapter.sendTurn({ threadId, input: "explore", attachments: [] }); + const event = yield* Deferred.await(started); + assert.equal(event.type, "task.started"); + if (event.type === "task.started") { + assert.equal(event.payload.role, "explore"); + assert.equal(event.payload.timelineBypass, true); + assert.equal(event.payload.taskType, "subagent"); + } + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("sends session/set_mode when the turn is in plan mode", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-plan-mode"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-plan-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + yield* adapter.sendTurn({ + threadId, + input: "plan this change", + attachments: [], + interactionMode: "plan", + }); + + yield* waitForFileContent(requestLogPath, 80, "session/prompt"); + const lines = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const modeRequest = lines + .toReversed() + .find( + (entry) => + entry.method === "session/set_mode" || + (entry.method === "session/set_config_option" && + (entry.params as Record | undefined)?.configId === "mode"), + ); + assert.isDefined(modeRequest); + assert.include( + ["architect", "plan"], + String( + (modeRequest?.params as Record | undefined)?.modeId ?? + (modeRequest?.params as Record | undefined)?.value, + ), + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("projects live _x.ai/session/update extras onto existing runtime events", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-session-extras"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_SESSION_EXTRAS: "1", + T3_ACP_XAI_SESSION_METHOD: "_x.ai/session/update", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const compacted = yield* Deferred.make(); + const hookStarted = yield* Deferred.make(); + const recap = yield* Deferred.make(); + const background = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (event.type === "thread.state.changed" && event.payload.state === "compacted") { + yield* Deferred.succeed(compacted, event).pipe(Effect.ignore); + } + if (event.type === "hook.started") { + yield* Deferred.succeed(hookStarted, event).pipe(Effect.ignore); + } + if (event.type === "thread.metadata.updated" && event.payload.metadata?.recap) { + yield* Deferred.succeed(recap, event).pipe(Effect.ignore); + } + if (event.type === "task.started" && event.payload.taskType === "local_bash") { + yield* Deferred.succeed(background, event).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + yield* adapter.sendTurn({ threadId, input: "continue", attachments: [] }); + + const compactEvent = yield* Deferred.await(compacted); + const hookEvent = yield* Deferred.await(hookStarted); + const recapEvent = yield* Deferred.await(recap); + const backgroundEvent = yield* Deferred.await(background); + + assert.equal(compactEvent.type, "thread.state.changed"); + if (hookEvent.type === "hook.started") { + assert.equal(hookEvent.payload.hookEvent, "user_prompt_submit"); + } + if (recapEvent.type === "thread.metadata.updated") { + assert.equal( + recapEvent.payload.metadata?.recap, + "Mapped Grok extras onto T3 runtime events.", + ); + } + if (backgroundEvent.type === "task.started") { + assert.equal(backgroundEvent.payload.taskType, "local_bash"); + } + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("projects Grok queue/changed onto session state", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-queue"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_EMIT_QUEUE: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const waiting = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "session.state.changed" && event.payload.reason === "queue:1" + ? Deferred.succeed(waiting, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + yield* adapter.sendTurn({ threadId, input: "queue me", attachments: [] }); + const event = yield* Deferred.await(waiting); + assert.equal(event.type, "session.state.changed"); + if (event.type === "session.state.changed") { + assert.equal(event.payload.state, "waiting"); + assert.equal(event.payload.reason, "queue:1"); + } + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); }); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 858d862e6d5f..cfec9bff702f 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -6,12 +6,15 @@ import { type ProviderRuntimeEvent, type ProviderSession, type ProviderUserInputAnswers, + type ThreadTokenUsageSnapshot, ProviderDriverKind, ProviderInstanceId, RuntimeRequestId, + RuntimeTaskId, type ThreadId, TurnId, } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -54,18 +57,66 @@ import { import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; import { + advertisedGrokReasoningEffortsForModel, + advertisedGrokReasoningEffortsFromSessionSetup, applyGrokAcpModelSelection, + applyGrokAcpSessionMode, + currentGrokMaxTokensFromSessionSetup, currentGrokModelIdFromSessionSetup, + currentGrokReasoningEffortFromSessionSetup, + grokMaxTokensByModelFromSessionSetup, + grokReasoningEffortMenusFromSessionSetup, makeGrokAcpRuntime, + requestedGrokReasoningEffort, resolveGrokAcpBaseModelId, + availableGrokSessionModelIds, } from "../acp/GrokAcpSupport.ts"; import { + boundGrokToolCallForEvent, + grokToolCallFingerprint, + shouldEmitGrokToolUpdate, + type GrokToolUpdateGate, +} from "../acp/GrokAcpToolUpdates.ts"; +import { + extractGrokTokenUsage, extractXAiAskUserQuestions, + grokPromptCount, + grokRewindFailureDetail, + grokRewindTargetKeepingPromptCount, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, + parseGrokRewindExecute, + parseGrokRewindPoints, promptResponseHasMissingXAiStopReason, XAiAskUserQuestionRequest, + XAiSessionNotification, } from "../acp/XAiAcpExtension.ts"; +import { + applyGrokSubagentUpdate, + applyGrokWorkflowUpdate, + emptyGrokWorkflowTrackState, + parseXAiSubagentUpdate, + parseXAiWorkflowUpdated, + type GrokWorkflowTrackState, +} from "../acp/GrokAcpWorkflow.ts"; +import { + GROK_QUEUE_CHANGED_METHODS, + GROK_SESSION_NOTIFICATION_METHODS, + XAiQueueChangedNotification, + grokAutoCompactEvents, + grokBackgroundTaskEvents, + grokHookEvents, + grokQueueChangedEvents, + grokSessionRecapEvents, + parseXAiAutoCompact, + parseXAiBackgroundTask, + parseXAiHookExecution, + parseXAiQueueChanged, + parseXAiSessionRecap, + parseXAiTurnCompletedUsage, + type GrokExtraEventSpec, + type GrokSessionNotificationMethod, +} from "../acp/GrokAcpSessionExtras.ts"; import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -117,6 +168,16 @@ interface GrokSessionContext { * continues it, and only the last remaining prompt settles the turn. */ promptsInFlight: number; currentModelId: string | undefined; + currentReasoningEffort: string | undefined; + reasoningEffortMenus: Map>; + maxTokensByModel: Map; + maxTokens: number | undefined; + lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; + lastCompleteCostUsd: number | undefined; + lastQueueLength: number | undefined; + availableModelIds: ReadonlyArray; + workflowTrack: GrokWorkflowTrackState; + readonly toolUpdateGates: Map; stopped: boolean; } @@ -172,6 +233,12 @@ const resolveSessionCallbackTurnId = ( return ctx ? resolveCallbackTurnId(ctx) : undefined; }; +function takeLastCompleteCostUsd(ctx: GrokSessionContext): number | undefined { + const cost = ctx.lastCompleteCostUsd; + ctx.lastCompleteCostUsd = undefined; + return cost; +} + function parseGrokResume(raw: unknown): { sessionId: string } | undefined { if (!isRecord(raw)) return undefined; if (raw.schemaVersion !== GROK_RESUME_VERSION) return undefined; @@ -179,7 +246,7 @@ function parseGrokResume(raw: unknown): { sessionId: string } | undefined { return { sessionId: raw.sessionId.trim() }; } -function selectPermissionOptionId( +export function selectGrokPermissionOptionId( request: EffectAcpSchema.RequestPermissionRequest, decision: Exclude, ): string | undefined { @@ -190,15 +257,24 @@ function selectPermissionOptionId( ? "allow_once" : "reject_once"; const option = request.options.find((entry) => entry.kind === kind); - return option?.optionId.trim() || undefined; + if (option?.optionId.trim()) { + return option.optionId.trim(); + } + // Grok often omits allow_always (#6502). Falling back to allow_once keeps + // the turn alive instead of answering the permission request as cancelled. + if (decision === "acceptForSession") { + const once = request.options.find((entry) => entry.kind === "allow_once"); + return once?.optionId.trim() || undefined; + } + return undefined; } function selectAutoApprovedPermissionOption( request: EffectAcpSchema.RequestPermissionRequest, ): string | undefined { return ( - selectPermissionOptionId(request, "acceptForSession") ?? - selectPermissionOptionId(request, "accept") + selectGrokPermissionOptionId(request, "acceptForSession") ?? + selectGrokPermissionOptionId(request, "accept") ); } @@ -273,6 +349,87 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const offerRuntimeEvent = (event: ProviderRuntimeEvent) => PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + const emitGrokTaskSpecs = (input: { + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly method: string; + readonly payload: unknown; + readonly specs: ReadonlyArray<{ + readonly type: "task.started" | "task.progress" | "task.completed" | "task.updated"; + readonly payload: Record; + }>; + }) => + Effect.forEach( + input.specs, + (spec) => + Effect.gen(function* () { + const taskIdValue = spec.payload.taskId; + if (typeof taskIdValue !== "string" || taskIdValue.length === 0) { + return; + } + yield* offerRuntimeEvent({ + type: spec.type, + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: input.turnId, + payload: { + ...spec.payload, + taskId: RuntimeTaskId.make(taskIdValue), + }, + raw: { + source: "acp.grok.extension", + method: input.method, + payload: input.payload, + }, + } as ProviderRuntimeEvent); + }), + { discard: true }, + ); + + const emitGrokExtraSpecs = (input: { + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly method: string; + readonly payload: unknown; + readonly specs: ReadonlyArray; + }) => + Effect.forEach( + input.specs, + (spec) => + Effect.gen(function* () { + if ( + spec.type === "task.started" || + spec.type === "task.progress" || + spec.type === "task.completed" || + spec.type === "task.updated" + ) { + yield* emitGrokTaskSpecs({ + threadId: input.threadId, + turnId: input.turnId, + method: input.method, + payload: input.payload, + specs: [spec], + }); + return; + } + yield* offerRuntimeEvent({ + type: spec.type, + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: input.turnId, + payload: spec.payload, + raw: { + source: "acp.grok.extension", + method: input.method, + payload: input.payload, + }, + } as ProviderRuntimeEvent); + }), + { discard: true }, + ); + const getThreadSemaphore = (threadId: string) => SynchronizedRef.modifyEffect(threadLocksRef, (current) => { const existing: Option.Option = Option.fromNullishOr( @@ -330,6 +487,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } if (options?.emitTurnCompletion !== false) { if (options?.errorMessage !== undefined) { + const totalCostUsd = takeLastCompleteCostUsd(liveCtx); yield* offerRuntimeEvent({ type: "turn.completed", ...(yield* makeEventStamp()), @@ -339,9 +497,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte payload: { state: "failed", errorMessage: options.errorMessage, + ...(totalCostUsd !== undefined ? { totalCostUsd } : {}), }, }); } else if (options?.completedStopReason !== undefined) { + const totalCostUsd = takeLastCompleteCostUsd(liveCtx); yield* offerRuntimeEvent({ type: "turn.completed", ...(yield* makeEventStamp()), @@ -351,6 +511,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte payload: { state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", stopReason: options.completedStopReason ?? null, + ...(totalCostUsd !== undefined ? { totalCostUsd } : {}), }, }); } @@ -403,9 +564,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte updatedAt, }; if (options?.emitTurnCompletion === false) { + liveCtx.lastCompleteCostUsd = undefined; return; } if (shouldEmitFailedTurn) { + const totalCostUsd = takeLastCompleteCostUsd(liveCtx); yield* offerRuntimeEvent({ type: "turn.completed", ...(yield* makeEventStamp()), @@ -415,9 +578,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte payload: { state: "failed", errorMessage: options.errorMessage, + ...(totalCostUsd !== undefined ? { totalCostUsd } : {}), }, }); } else if (shouldEmitCompletedTurn) { + const totalCostUsd = takeLastCompleteCostUsd(liveCtx); yield* offerRuntimeEvent({ type: "turn.completed", ...(yield* makeEventStamp()), @@ -427,9 +592,33 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte payload: { state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", stopReason: options.completedStopReason ?? null, + ...(totalCostUsd !== undefined ? { totalCostUsd } : {}), }, }); + } else { + liveCtx.lastCompleteCostUsd = undefined; + } + }); + + const publishGrokPromptUsage = ( + ctx: GrokSessionContext, + turnId: TurnId, + result: EffectAcpSchema.PromptResponse, + ) => + Effect.gen(function* () { + const tokenUsage = extractGrokTokenUsage(result._meta, ctx.maxTokens); + if (!tokenUsage) { + return; } + ctx.lastKnownTokenUsage = tokenUsage; + yield* offerRuntimeEvent({ + type: "thread.token-usage.updated", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload: { usage: tokenUsage }, + }); }); const logNative = (threadId: ThreadId, method: string, payload: unknown) => @@ -549,6 +738,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const cwd = path.resolve(input.cwd.trim()); const grokModelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const requestedStartModelId = grokModelSelection?.model + ? resolveGrokAcpBaseModelId(grokModelSelection.model) + : undefined; + const requestedStartEffort = requestedGrokReasoningEffort(grokModelSelection, []); const existing = sessions.get(input.threadId); if (existing && !existing.stopped) { yield* stopSessionInternal(existing); @@ -573,6 +766,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const acp = yield* makeGrokAcpRuntime({ grokSettings, ...(options?.environment ? { environment: options.environment } : {}), + ...(requestedStartEffort ? { reasoningEffort: requestedStartEffort } : {}), childProcessSpawner, cwd, ...(resumeSessionId ? { resumeSessionId } : {}), @@ -608,6 +802,152 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }), ), ); + const pendingSessionNotifications: Array<{ + readonly method: GrokSessionNotificationMethod; + readonly params: unknown; + }> = []; + const pendingQueueChanges: Array<{ + readonly method: (typeof GROK_QUEUE_CHANGED_METHODS)[number]; + readonly params: unknown; + }> = []; + let sessionNotificationsReady = false; + const sessionNotificationLock = yield* Semaphore.make(1); + const applySessionNotification = ( + ctx: GrokSessionContext, + method: GrokSessionNotificationMethod, + params: unknown, + ) => + Effect.gen(function* () { + const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); + const workflow = parseXAiWorkflowUpdated(params); + if (workflow) { + const applied = applyGrokWorkflowUpdate(ctx.workflowTrack, workflow); + ctx.workflowTrack = applied.state; + yield* emitGrokTaskSpecs({ + threadId: input.threadId, + turnId, + method, + payload: params, + specs: applied.events, + }); + return; + } + const subagent = parseXAiSubagentUpdate(params); + if (subagent) { + const applied = applyGrokSubagentUpdate(ctx.workflowTrack, subagent); + ctx.workflowTrack = applied.state; + yield* emitGrokTaskSpecs({ + threadId: input.threadId, + turnId, + method, + payload: params, + specs: applied.events, + }); + return; + } + const hook = parseXAiHookExecution(params); + if (hook) { + yield* emitGrokExtraSpecs({ + threadId: input.threadId, + turnId, + method, + payload: params, + specs: grokHookEvents(hook), + }); + return; + } + const compact = parseXAiAutoCompact(params); + if (compact) { + const specs = grokAutoCompactEvents( + compact, + ctx.lastKnownTokenUsage, + ctx.promptsInFlight > 0 || ctx.session.status === "running", + ); + const usageEvent = specs.find((spec) => spec.type === "thread.token-usage.updated"); + if (usageEvent?.type === "thread.token-usage.updated") { + ctx.lastKnownTokenUsage = usageEvent.payload.usage; + } + yield* emitGrokExtraSpecs({ + threadId: input.threadId, + turnId, + method, + payload: params, + specs, + }); + return; + } + const recap = parseXAiSessionRecap(params); + if (recap) { + yield* emitGrokExtraSpecs({ + threadId: input.threadId, + turnId, + method, + payload: params, + specs: grokSessionRecapEvents(recap), + }); + return; + } + const turnCompleted = parseXAiTurnCompletedUsage(params, ctx.maxTokens); + if (turnCompleted) { + ctx.lastKnownTokenUsage = { + ...turnCompleted.usage, + ...(ctx.lastKnownTokenUsage?.compactsAutomatically + ? { compactsAutomatically: true } + : {}), + }; + if (turnCompleted.costUsd !== undefined) { + ctx.lastCompleteCostUsd = turnCompleted.costUsd; + } + yield* offerRuntimeEvent({ + type: "thread.token-usage.updated", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { usage: ctx.lastKnownTokenUsage }, + raw: { + source: "acp.grok.extension", + method, + payload: params, + }, + }); + return; + } + const background = parseXAiBackgroundTask(params); + if (!background) { + return; + } + yield* emitGrokExtraSpecs({ + threadId: input.threadId, + turnId, + method, + payload: params, + specs: grokBackgroundTaskEvents(background), + }); + }); + const applyQueueChange = ( + ctx: GrokSessionContext, + method: (typeof GROK_QUEUE_CHANGED_METHODS)[number], + params: unknown, + ) => + Effect.gen(function* () { + const queue = parseXAiQueueChanged(params); + if (!queue || ctx.lastQueueLength === queue.entries.length) { + return; + } + ctx.lastQueueLength = queue.entries.length; + const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); + yield* emitGrokExtraSpecs({ + threadId: input.threadId, + turnId, + method, + payload: params, + specs: grokQueueChangedEvents( + queue, + ctx.promptsInFlight > 0 || ctx.session.status === "running", + ), + }); + }); const started = yield* Effect.gen(function* () { yield* Effect.forEach( ["x.ai/ask_user_question", "_x.ai/ask_user_question"] as const, @@ -663,6 +1003,57 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ), { discard: true }, ); + // Grok Build's private session channel. Claude maps workflow_progress + // onto task.*; Codex maps collabAgent/* the same way. Keep that + // seam: parse here, emit canonical events, never a third UI shape. + yield* Effect.forEach( + GROK_SESSION_NOTIFICATION_METHODS, + (method) => + acp.handleExtNotification(method, XAiSessionNotification, (params) => + mapAcpCallbackFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, method, params); + if (!sessionNotificationsReady) { + pendingSessionNotifications.push({ method, params }); + return; + } + const ctx = sessions.get(input.threadId); + if (!ctx) { + pendingSessionNotifications.push({ method, params }); + return; + } + yield* sessionNotificationLock.withPermits(1)( + applySessionNotification(ctx, method, params), + ); + }), + ), + ), + { discard: true }, + ); + yield* Effect.forEach( + GROK_QUEUE_CHANGED_METHODS, + (method) => + acp.handleExtNotification(method, XAiQueueChangedNotification, (params) => + mapAcpCallbackFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, method, params); + if (!sessionNotificationsReady) { + pendingQueueChanges.push({ method, params }); + return; + } + const ctx = sessions.get(input.threadId); + if (!ctx) { + pendingQueueChanges.push({ method, params }); + return; + } + yield* sessionNotificationLock.withPermits(1)( + applyQueueChange(ctx, method, params), + ); + }), + ), + ), + { discard: true }, + ); yield* acp.handleRequestPermission((params) => mapAcpCallbackFailure( Effect.gen(function* () { @@ -716,7 +1107,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }), ); const selectedOptionId = - resolved === "cancel" ? undefined : selectPermissionOptionId(params, resolved); + resolved === "cancel" + ? undefined + : selectGrokPermissionOptionId(params, resolved); return { outcome: selectedOptionId ? { @@ -735,16 +1128,29 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ), ); - const requestedStartModelId = grokModelSelection?.model - ? resolveGrokAcpBaseModelId(grokModelSelection.model) - : undefined; - const boundModelId = yield* applyGrokAcpModelSelection({ + const startedModelId = currentGrokModelIdFromSessionSetup(started.sessionSetupResult); + const availableModelIds = availableGrokSessionModelIds(started.sessionSetupResult); + const advertisedStartEfforts = advertisedGrokReasoningEffortsFromSessionSetup( + started.sessionSetupResult, + requestedStartModelId ?? startedModelId, + ); + const boundSelection = yield* applyGrokAcpModelSelection({ runtime: acp, - currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult), + currentModelId: startedModelId, requestedModelId: requestedStartModelId, + availableModelIds, + currentReasoningEffort: currentGrokReasoningEffortFromSessionSetup( + started.sessionSetupResult, + ), + requestedReasoningEffort: requestedGrokReasoningEffort( + grokModelSelection, + advertisedStartEfforts, + ), mapError: (cause) => mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), }); + const boundModelId = boundSelection.modelId; + const maxTokensByModel = grokMaxTokensByModelFromSessionSetup(started.sessionSetupResult); const now = yield* nowIso; const session: ProviderSession = { @@ -778,6 +1184,20 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte interruptedTurnIds: new Set(), promptsInFlight: 0, currentModelId: boundModelId, + currentReasoningEffort: boundSelection.reasoningEffort, + reasoningEffortMenus: grokReasoningEffortMenusFromSessionSetup( + started.sessionSetupResult, + ), + maxTokensByModel, + maxTokens: + (boundModelId ? maxTokensByModel.get(boundModelId) : undefined) ?? + currentGrokMaxTokensFromSessionSetup(started.sessionSetupResult), + lastKnownTokenUsage: undefined, + lastCompleteCostUsd: undefined, + lastQueueLength: undefined, + availableModelIds, + workflowTrack: emptyGrokWorkflowTrackState(), + toolUpdateGates: new Map(), stopped: false, }; @@ -844,18 +1264,37 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte "session/update", ); return; - case "ToolCallUpdated": + case "ToolCallUpdated": { + const nowMs = yield* Clock.currentTimeMillis; + if ( + !shouldEmitGrokToolUpdate({ + toolCall: event.toolCall, + previous: ctx.toolUpdateGates.get(event.toolCall.toolCallId), + nowMs, + }) + ) { + return; + } + ctx.toolUpdateGates.set(event.toolCall.toolCallId, { + fingerprint: grokToolCallFingerprint(event.toolCall), + lastEmittedAt: nowMs, + }); + const bounded = boundGrokToolCallForEvent({ + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }); yield* offerRuntimeEvent( makeAcpToolCallEvent({ stamp, provider: PROVIDER, threadId: ctx.threadId, turnId: notificationTurnId, - toolCall: event.toolCall, - rawPayload: event.rawPayload, + toolCall: bounded.toolCall, + rawPayload: bounded.rawPayload, }), ); return; + } case "ContentDelta": yield* offerRuntimeEvent( makeAcpContentDeltaEvent({ @@ -888,6 +1327,22 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ctx.notificationFiber = nf; sessions.set(input.threadId, ctx); sessionScopeTransferred = true; + // One startup snapshot, then live delivery. The lock keeps a + // later tick from mutating workflow state mid-replay without + // waiting for a flood of notifications to drain. + yield* sessionNotificationLock.withPermits(1)( + Effect.gen(function* () { + const batch = pendingSessionNotifications.splice(0); + const queued = pendingQueueChanges.splice(0); + sessionNotificationsReady = true; + yield* Effect.forEach(batch, (pending) => + applySessionNotification(ctx, pending.method, pending.params), + ); + yield* Effect.forEach(queued, (pending) => + applyQueueChange(ctx, pending.method, pending.params), + ); + }), + ); yield* offerRuntimeEvent({ type: "session.started", @@ -948,13 +1403,42 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const requestedTurnModelId = turnModelSelection?.model ? resolveGrokAcpBaseModelId(turnModelSelection.model) : undefined; - const currentModelId = yield* applyGrokAcpModelSelection({ + const advertisedTurnEfforts = advertisedGrokReasoningEffortsForModel({ + menus: ctx.reasoningEffortMenus, + requestedModelId: requestedTurnModelId, + currentModelId: ctx.currentModelId, + availableModelIds: ctx.availableModelIds, + }); + const turnSelection = yield* applyGrokAcpModelSelection({ runtime: ctx.acp, currentModelId: ctx.currentModelId, + availableModelIds: ctx.availableModelIds, requestedModelId: requestedTurnModelId, + currentReasoningEffort: ctx.currentReasoningEffort, + requestedReasoningEffort: requestedGrokReasoningEffort( + turnModelSelection, + advertisedTurnEfforts, + ), mapError: (cause) => mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), }); + const currentModelId = turnSelection.modelId; + ctx.currentModelId = currentModelId; + ctx.currentReasoningEffort = turnSelection.reasoningEffort; + if (currentModelId) { + const maxTokens = ctx.maxTokensByModel.get(currentModelId); + if (maxTokens !== undefined) { + ctx.maxTokens = maxTokens; + } + } + + yield* applyGrokAcpSessionMode({ + runtime: ctx.acp, + runtimeMode: ctx.session.runtimeMode, + interactionMode: input.interactionMode, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_mode", cause), + }); const text = input.input?.trim(); const imagePromptParts = yield* Effect.forEach( @@ -1149,6 +1633,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } appendPromptResultToTurn(ctx, prepared.turnId, prepared.promptParts, result); + yield* publishGrokPromptUsage(ctx, prepared.turnId, result); ctx.session = { ...ctx.session, status: "running", @@ -1185,6 +1670,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ...(prepared.displayModel ? { model: prepared.displayModel } : {}), }; const completedStopReason = completedStopReasonFromPromptResponse(result); + const totalCostUsd = takeLastCompleteCostUsd(ctx); yield* offerRuntimeEvent({ type: "turn.completed", ...(yield* makeEventStamp()), @@ -1194,6 +1680,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte payload: { state: result.stopReason === "cancelled" ? "cancelled" : "completed", stopReason: completedStopReason, + ...(totalCostUsd !== undefined ? { totalCostUsd } : {}), }, }); ctx.interruptedTurnIds.delete(prepared.turnId); @@ -1253,6 +1740,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte prepared.promptParts, promptResult, ); + yield* publishGrokPromptUsage(ctx, prepared.turnId, promptResult); yield* settlePromptInFlight( input.threadId, prepared.turnId, @@ -1403,23 +1891,146 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte return { threadId, turns: ctx.turns }; }); - const rollbackThread: GrokAdapterShape["rollbackThread"] = (threadId, numTurns) => + const cancelActivePromptsBeforeRewind = (ctx: GrokSessionContext) => Effect.gen(function* () { - yield* requireSession(threadId); - if (!Number.isInteger(numTurns) || numTurns < 1) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "rollbackThread", - issue: "numTurns must be an integer >= 1.", + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + const hasLivePrompt = + ctx.promptsInFlight > 0 || + ctx.session.status === "running" || + ctx.session.status === "connecting"; + if (!hasLivePrompt) { + return; + } + if (activeTurnId !== undefined) { + ctx.interruptedTurnIds.add(activeTurnId); + } + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, ctx.threadId, "session/cancel", error), + ), + ), + ); + if (activeTurnId) { + yield* settlePromptInFlight(ctx.threadId, activeTurnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + settleAllPrompts: true, }); + return; } - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "thread/rollback", - detail: "Grok ACP sessions do not support provider-side rollback yet.", - }); + const updatedAt = yield* nowIso; + ctx.promptsInFlight = 0; + ctx.activeTurnId = undefined; + const { activeTurnId: _activeTurnId, ...readySession } = ctx.session; + ctx.session = { + ...readySession, + status: "ready", + updatedAt, + }; }); + const rollbackThread: GrokAdapterShape["rollbackThread"] = (threadId, numTurns) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + if (numTurns > ctx.turns.length) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: `numTurns (${numTurns}) exceeds recorded turns (${ctx.turns.length}).`, + }); + } + yield* cancelActivePromptsBeforeRewind(ctx); + const keepTurns = ctx.turns.slice(0, Math.max(0, ctx.turns.length - numTurns)); + const keepPromptCount = grokPromptCount(keepTurns); + const acpSessionId = ctx.acpSessionId; + const pointsPayload = yield* ctx.acp + .request("_x.ai/rewind/points", { + sessionId: acpSessionId, + }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "_x.ai/rewind/points", error), + ), + ); + const liveCtx = yield* requireSession(threadId); + if (liveCtx.acpSessionId !== acpSessionId) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "_x.ai/rewind/execute", + detail: "Grok session changed before rewind completed.", + }); + } + const rewindPoints = parseGrokRewindPoints(pointsPayload); + const target = grokRewindTargetKeepingPromptCount(rewindPoints, keepPromptCount); + if (!target) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "_x.ai/rewind/execute", + detail: "Grok has no rewind point for that many turns.", + }); + } + const executePayload = yield* liveCtx.acp + .request("_x.ai/rewind/execute", { + sessionId: acpSessionId, + targetPromptIndex: target.promptIndex, + mode: "conversation_only", + force: true, + }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "_x.ai/rewind/execute", error), + ), + ); + const committedCtx = yield* requireSession(threadId); + if (committedCtx.acpSessionId !== acpSessionId) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "_x.ai/rewind/execute", + detail: "Grok session changed before rewind completed.", + }); + } + const executed = parseGrokRewindExecute(executePayload); + if (!executed?.success) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "_x.ai/rewind/execute", + detail: grokRewindFailureDetail(executed?.error), + ...(executed?.error ? { cause: executed.error } : {}), + }); + } + const trimmedCtx = yield* requireSession(threadId); + if (trimmedCtx.acpSessionId !== acpSessionId) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "_x.ai/rewind/execute", + detail: "Grok session changed before rewind completed.", + }); + } + trimmedCtx.turns = trimmedCtx.turns.slice( + 0, + Math.max(0, trimmedCtx.turns.length - numTurns), + ); + trimmedCtx.lastPlanFingerprint = undefined; + trimmedCtx.lastKnownTokenUsage = undefined; + trimmedCtx.lastCompleteCostUsd = undefined; + trimmedCtx.lastQueueLength = undefined; + trimmedCtx.workflowTrack = emptyGrokWorkflowTrackState(); + trimmedCtx.toolUpdateGates.clear(); + return { threadId, turns: trimmedCtx.turns }; + }), + ); + const stopSession: GrokAdapterShape["stopSession"] = (threadId) => withThreadLock( threadId, diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 1c9bf1f26de7..3088d4a5a296 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -10,7 +10,7 @@ import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus } from "./Gro const decodeGrokSettings = Schema.decodeSync(GrokSettings); -describe("buildInitialGrokProviderSnapshot", () => { +it.layer(NodeServices.layer)("buildInitialGrokProviderSnapshot", (it) => { it.effect("returns a disabled snapshot when settings.enabled is false", () => Effect.gen(function* () { const snapshot = yield* buildInitialGrokProviderSnapshot( @@ -41,7 +41,67 @@ describe("buildInitialGrokProviderSnapshot", () => { expect(snapshot.status).toBe("warning"); expect(snapshot.version).toBeNull(); expect(snapshot.message).toContain("Checking Grok"); - expect(snapshot.requiresNewThreadForModelChange).toBe(true); + expect(snapshot.requiresNewThreadForModelChange).toBe(false); + expect(snapshot.showInteractionModeToggle).toBe(true); + expect(snapshot.slashCommands.map((command) => command.name)).toEqual( + expect.arrayContaining(["workflow pause", "workflow resume", "workflow stop"]), + ); + expect(snapshot.models[0]?.capabilities?.optionDescriptors?.[0]?.id).toBe("reasoningEffort"); + }), + ); +}); + +it.layer(NodeServices.layer)("buildInitialGrokProviderSnapshot workflows", (it) => { + it.effect("includes project workflow slash commands on the initial snapshot", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-initial-wf-" }); + const home = path.join(dir, "home"); + const project = path.join(dir, "project"); + yield* fs.makeDirectory(path.join(home, ".grok", "workflows"), { recursive: true }); + yield* fs.makeDirectory(path.join(project, ".grok", "workflows"), { recursive: true }); + yield* fs.writeFileString( + path.join(project, ".grok", "workflows", "from-project.rhai"), + `let meta = #{ name: "from-project", description: "project script" };\n`, + ); + return yield* buildInitialGrokProviderSnapshot(decodeGrokSettings({ enabled: true }), { + environment: { HOME: home }, + projectRoot: project, + }); + }), + ); + + expect(snapshot.slashCommands.map((command) => command.name)).toEqual( + expect.arrayContaining(["workflow pause", "workflow from-project"]), + ); + }), + ); + + it.effect("discovers user workflow scripts from USERPROFILE when HOME is unset", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-userprofile-wf-" }); + const home = path.join(dir, "home"); + yield* fs.makeDirectory(path.join(home, ".grok", "workflows"), { recursive: true }); + yield* fs.writeFileString( + path.join(home, ".grok", "workflows", "from-profile.rhai"), + `let meta = #{ name: "from-profile", description: "userprofile script" };\n`, + ); + return yield* buildInitialGrokProviderSnapshot(decodeGrokSettings({ enabled: true }), { + environment: { USERPROFILE: home }, + }); + }), + ); + + expect(snapshot.slashCommands.map((command) => command.name)).toEqual( + expect.arrayContaining(["workflow pause", "workflow from-profile"]), + ); }), ); }); @@ -107,6 +167,7 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { return yield* checkGrokProviderStatus( decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { HOME: dir, PATH: process.env.PATH ?? "", XAI_API_KEY: "probe-only" }, ); }), ); @@ -117,4 +178,44 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { expect(snapshot.message).toContain("ACP startup failed"); }), ); + + it.effect("discovers workflow slash commands from injected home and project roots", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-wf-" }); + const home = path.join(dir, "home"); + const project = path.join(dir, "project"); + const grokPath = path.join(dir, "grok"); + yield* fs.makeDirectory(path.join(home, ".grok", "workflows"), { recursive: true }); + yield* fs.makeDirectory(path.join(project, ".grok", "workflows"), { recursive: true }); + yield* fs.writeFileString( + path.join(home, ".grok", "workflows", "from-home.rhai"), + `let meta = #{ name: "from-home", description: "home script" };\n`, + ); + yield* fs.writeFileString( + path.join(project, ".grok", "workflows", "from-project.rhai"), + `let meta = #{ name: "from-project", description: "project script" };\n`, + ); + yield* fs.writeFileString( + grokPath, + ["#!/bin/sh", 'printf "grok-cli 0.0.99\\n"', "exit 0", ""].join("\n"), + ); + yield* fs.chmod(grokPath, 0o755); + + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { HOME: home, PATH: process.env.PATH ?? "", XAI_API_KEY: "probe-only" }, + project, + ); + }), + ); + + expect(snapshot.slashCommands.map((command) => command.name)).toEqual( + expect.arrayContaining(["workflow pause", "workflow from-home", "workflow from-project"]), + ); + }), + ); }); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 934eecdb5ae6..72639d368fe2 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -10,11 +10,12 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Result from "effect/Result"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { @@ -29,66 +30,110 @@ import { enrichProviderSnapshotWithVersionAdvisory, type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; -import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSupport.ts"; +import { + fallbackGrokReasoningEffortCapabilities, + grokDiscoveredModelCapabilities, + isGrokAcpAuthFailure, + makeGrokAcpRuntime, + parseGrokAcpModelMeta, + resolveGrokAcpBaseModelId, +} from "../acp/GrokAcpSupport.ts"; +import { + grokWorkflowHomeDirFromEnvironment, + readGrokWorkflowSlashCommands, +} from "../acp/GrokWorkflowCommands.ts"; const GROK_PRESENTATION = { displayName: "Grok", badgeLabel: "Early Access", - showInteractionModeToggle: false, - requiresNewThreadForModelChange: true, + showInteractionModeToggle: true, + requiresNewThreadForModelChange: false, } as const; -const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ - optionDescriptors: [], -}); +const FALLBACK_CAPABILITIES: ModelCapabilities = fallbackGrokReasoningEffortCapabilities(); + +const buildGrokServerProvider = ( + input: Parameters[0], + discovery: { + readonly environment: NodeJS.ProcessEnv; + readonly projectRoot?: string | undefined; + }, +) => + Effect.gen(function* () { + const slashCommands = + input.slashCommands ?? + (yield* readGrokWorkflowSlashCommands({ + homeDir: grokWorkflowHomeDirFromEnvironment(discovery.environment), + projectRoot: discovery.projectRoot, + })); + return buildServerProvider({ + ...input, + slashCommands, + }); + }); const VERSION_PROBE_TIMEOUT_MS = 4_000; const GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; +const GROK_API_KEY_ENV = "XAI_API_KEY"; const GROK_BUILT_IN_MODELS: ReadonlyArray = [ { slug: "grok-build", name: "Grok Build", isCustom: false, - capabilities: EMPTY_CAPABILITIES, + capabilities: FALLBACK_CAPABILITIES, }, ]; export function buildInitialGrokProviderSnapshot( grokSettings: GrokSettings, -): Effect.Effect { + discovery?: { + readonly environment?: NodeJS.ProcessEnv | undefined; + readonly projectRoot?: string | undefined; + }, +): Effect.Effect { return Effect.gen(function* () { const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); const models = grokModelsFromSettings(grokSettings.customModels); + const resolvedDiscovery = { + environment: discovery?.environment ?? process.env, + projectRoot: discovery?.projectRoot, + }; if (!grokSettings.enabled) { - return buildServerProvider({ + return yield* buildGrokServerProvider( + { + presentation: GROK_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Grok is disabled in T3 Code settings.", + }, + }, + resolvedDiscovery, + ); + } + + return yield* buildGrokServerProvider( + { presentation: GROK_PRESENTATION, - enabled: false, + enabled: true, checkedAt, models, probe: { - installed: false, + installed: true, version: null, status: "warning", auth: { status: "unknown" }, - message: "Grok is disabled in T3 Code settings.", + message: "Checking Grok CLI availability...", }, - }); - } - - return buildServerProvider({ - presentation: GROK_PRESENTATION, - enabled: true, - checkedAt, - models, - probe: { - installed: true, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Checking Grok CLI availability...", }, - }); + resolvedDiscovery, + ); }); } @@ -96,7 +141,7 @@ function grokModelsFromSettings( customModels: ReadonlyArray | undefined, builtInModels: ReadonlyArray = GROK_BUILT_IN_MODELS, ): ReadonlyArray { - return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); + return providerModelsFromSettings(builtInModels, customModels ?? [], FALLBACK_CAPABILITIES); } function buildGrokDiscoveredModelsFromSessionModelState( @@ -113,11 +158,12 @@ function buildGrokDiscoveredModelsFromSessionModelState( return undefined; } seen.add(slug); + const meta = parseGrokAcpModelMeta(model._meta); return { slug, name: model.name.trim() || slug, isCustom: false, - capabilities: EMPTY_CAPABILITIES, + capabilities: grokDiscoveredModelCapabilities(meta), }; }) .filter((model): model is ServerProviderModel => model !== undefined); @@ -129,9 +175,15 @@ const discoverGrokModelsViaAcp = ( ) => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const probeEnvironment = { + ...environment, + CI: environment.CI ?? "1", + NO_BROWSER: environment.NO_BROWSER ?? "1", + BROWSER: environment.BROWSER ?? "", + }; const acp = yield* makeGrokAcpRuntime({ grokSettings, - environment, + environment: probeEnvironment, childProcessSpawner, cwd: process.cwd(), clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, @@ -161,16 +213,20 @@ const runGrokVersionCommand = ( export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, + projectRoot?: string, ): Effect.fn.Return< ServerProviderDraft, never, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path > { const checkedAt = DateTime.formatIso(yield* DateTime.now); const fallbackModels = grokModelsFromSettings(grokSettings.customModels); + const discovery = { environment, projectRoot }; + const providerDraft = (input: Parameters[0]) => + buildGrokServerProvider(input, discovery); if (!grokSettings.enabled) { - return buildServerProvider({ + return yield* providerDraft({ presentation: GROK_PRESENTATION, enabled: false, checkedAt, @@ -195,7 +251,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func yield* Effect.logWarning("Grok CLI health check failed.", { errorTag: error._tag, }); - return buildServerProvider({ + return yield* providerDraft({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, @@ -213,7 +269,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func } if (Option.isNone(versionResult.success)) { - return buildServerProvider({ + return yield* providerDraft({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, @@ -236,7 +292,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func stdoutLength: versionOutput.stdout.length, stderrLength: versionOutput.stderr.length, }); - return buildServerProvider({ + return yield* providerDraft({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, @@ -256,10 +312,12 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func Effect.exit, ); if (Exit.isFailure(discoveryExit)) { + const authFailed = isGrokAcpAuthFailure(discoveryExit.cause); yield* Effect.logWarning("Grok ACP model discovery failed", { errorTag: causeErrorTag(discoveryExit.cause), + authFailed, }); - return buildServerProvider({ + return yield* providerDraft({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, @@ -268,8 +326,10 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func installed: true, version, status: "error", - auth: { status: "unknown" }, - message: "Grok CLI is installed but ACP startup failed. Check server logs for details.", + auth: { status: authFailed ? "unauthenticated" : "unknown" }, + message: authFailed + ? "Grok CLI is not authenticated. Run `grok login` and try again." + : "Grok CLI is installed but ACP startup failed. Check server logs for details.", }, }); } @@ -277,7 +337,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func yield* Effect.logWarning( `Grok ACP model discovery timed out after ${GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, ); - return buildServerProvider({ + return yield* providerDraft({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, @@ -297,7 +357,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func ? grokModelsFromSettings(grokSettings.customModels, discoveredModels) : fallbackModels; - return buildServerProvider({ + return yield* providerDraft({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, @@ -306,7 +366,9 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func installed: true, version, status: "ready", - auth: { status: "unknown" }, + auth: environment[GROK_API_KEY_ENV]?.trim() + ? { status: "authenticated", type: "api_key", label: "XAI_API_KEY" } + : { status: "authenticated", type: "session", label: "grok.com" }, }, }); }); diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 09fce6d56f9d..5d002835ff3d 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -226,6 +226,7 @@ export class AcpSessionRuntime extends Context.Service< */ readonly setSessionModel: ( modelId: string, + options?: { readonly _meta?: { readonly [x: string]: unknown } }, ) => Effect.Effect; /** * Sends a generic ACP extension request and records it through the request logger. @@ -789,12 +790,13 @@ export const make = ( Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), Effect.asVoid, ), - setSessionModel: (modelId) => + setSessionModel: (modelId, options) => getStartedState.pipe( Effect.flatMap((started) => { const requestPayload = { sessionId: started.sessionId, modelId, + ...(options?._meta ? { _meta: options._meta } : {}), } satisfies EffectAcpSchema.SetSessionModelRequest; return runLoggedRequest( "session/set_model", diff --git a/apps/server/src/provider/acp/GrokAcpSessionExtras.test.ts b/apps/server/src/provider/acp/GrokAcpSessionExtras.test.ts new file mode 100644 index 000000000000..285870f93775 --- /dev/null +++ b/apps/server/src/provider/acp/GrokAcpSessionExtras.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + grokAutoCompactEvents, + grokBackgroundTaskEvents, + grokHookEvents, + grokQueueChangedEvents, + grokSessionRecapEvents, + parseXAiAutoCompact, + parseXAiBackgroundTask, + parseXAiHookExecution, + parseXAiQueueChanged, + parseXAiSessionRecap, + parseXAiTurnCompletedUsage, +} from "./GrokAcpSessionExtras.ts"; + +describe("GrokAcpSessionExtras", () => { + it("maps hook_execution runs onto hook.started then hook.completed", () => { + const parsed = parseXAiHookExecution({ + update: { + sessionUpdate: "hook_execution", + event_name: "user_prompt_submit", + runs: [ + { + name: "global/settings:user_prompt_submit[0].hooks[0]", + status: { status: "success", elapsed_ms: 177 }, + }, + ], + }, + }); + expect(parsed?.hookEvent).toBe("user_prompt_submit"); + expect(grokHookEvents(parsed!)).toEqual([ + { + type: "hook.started", + payload: { + hookId: "global/settings:user_prompt_submit[0].hooks[0]", + hookName: "global/settings:user_prompt_submit[0].hooks[0]", + hookEvent: "user_prompt_submit", + }, + }, + { + type: "hook.completed", + payload: { + hookId: "global/settings:user_prompt_submit[0].hooks[0]", + outcome: "success", + }, + }, + ]); + }); + + it("maps auto_compact_started onto the context window with compactsAutomatically", () => { + const parsed = parseXAiAutoCompact({ + update: { + sessionUpdate: "auto_compact_started", + tokens_used: 402_072, + context_window: 500_000, + percentage: 80, + reason: "Context window 80% full", + }, + }); + const events = grokAutoCompactEvents(parsed!, undefined, false); + expect(events).toContainEqual({ + type: "thread.token-usage.updated", + payload: { + usage: { + usedTokens: 402_072, + lastUsedTokens: 402_072, + maxTokens: 500_000, + compactsAutomatically: true, + }, + }, + }); + expect(events[0]).toMatchObject({ + type: "session.state.changed", + payload: { state: "waiting", reason: "Context window 80% full" }, + }); + }); + + it("maps auto_compact_completed onto compacted thread state", () => { + const parsed = parseXAiAutoCompact({ + update: { + sessionUpdate: "auto_compact_completed", + tokens_before: 402_072, + tokens_after: 42_380, + elapsed_ms: 118_411, + }, + }); + const events = grokAutoCompactEvents( + parsed!, + { + usedTokens: 402_072, + maxTokens: 500_000, + lastUsedTokens: 402_072, + compactsAutomatically: true, + }, + false, + ); + expect(events).toEqual([ + { + type: "thread.token-usage.updated", + payload: { + usage: { + usedTokens: 42_380, + lastUsedTokens: 402_072, + totalProcessedTokens: 402_072, + maxTokens: 500_000, + compactsAutomatically: true, + }, + }, + }, + { + type: "session.state.changed", + payload: { state: "ready", reason: "compaction completed", detail: parsed }, + }, + { + type: "thread.state.changed", + payload: { state: "compacted", detail: parsed }, + }, + ]); + }); + + it("keeps compaction-complete running only while a turn is live", () => { + const parsed = parseXAiAutoCompact({ + update: { + sessionUpdate: "auto_compact_completed", + tokens_before: 402_072, + tokens_after: 42_380, + }, + }); + const events = grokAutoCompactEvents(parsed!, undefined, true); + expect(events).toContainEqual({ + type: "session.state.changed", + payload: { state: "running", reason: "compaction completed", detail: parsed }, + }); + }); + + it("publishes session_recap onto thread.metadata, never as a title", () => { + const parsed = parseXAiSessionRecap({ + update: { + sessionUpdate: "session_recap", + summary: "Mapped Grok extras onto T3 runtime events.", + auto: true, + }, + }); + expect(grokSessionRecapEvents(parsed!)).toEqual([ + { + type: "thread.metadata.updated", + payload: { + metadata: { + recap: "Mapped Grok extras onto T3 runtime events.", + recapAuto: true, + }, + }, + }, + ]); + }); + + it("reads complete PromptUsage costUsdTicks and skips incomplete bills", () => { + const complete = parseXAiTurnCompletedUsage({ + update: { + sessionUpdate: "turn_completed", + usage: { + inputTokens: 100, + outputTokens: 20, + costUsdTicks: 1_626_488_800, + }, + }, + }); + expect(complete?.usage.usedTokens).toBe(120); + expect(complete?.costUsd).toBeCloseTo(0.16264888); + + const incomplete = parseXAiTurnCompletedUsage({ + update: { + sessionUpdate: "turn_completed", + usage: { + inputTokens: 100, + outputTokens: 20, + costUsdTicks: 100, + incomplete: true, + }, + }, + }); + expect(incomplete?.usage.usedTokens).toBe(120); + expect(incomplete?.costUsd).toBeUndefined(); + }); + + it("maps backgrounded shells onto local_bash tasks", () => { + const started = parseXAiBackgroundTask({ + update: { + sessionUpdate: "task_backgrounded", + task_id: "call-bg-1", + command: "sleep 10", + output_file: "/tmp/out.log", + description: "Wait in the background", + }, + }); + expect(grokBackgroundTaskEvents(started!)[0]).toMatchObject({ + type: "task.started", + payload: { taskId: "call-bg-1", taskType: "local_bash", outputFile: "/tmp/out.log" }, + }); + + const finished = parseXAiBackgroundTask({ + update: { + sessionUpdate: "task_completed", + task_snapshot: { + task_id: "call-bg-1", + command: "sleep 10", + exit_code: 0, + output: "done", + }, + }, + }); + expect(grokBackgroundTaskEvents(finished!)[0]).toMatchObject({ + type: "task.completed", + payload: { status: "completed", summary: "done" }, + }); + }); + + it("projects queue length onto session state without inventing a third surface", () => { + const parsed = parseXAiQueueChanged({ + sessionId: "sess-1", + entries: [{ prompt: "follow up" }], + }); + expect(grokQueueChangedEvents(parsed!, true)).toEqual([ + { + type: "session.state.changed", + payload: { + state: "waiting", + reason: "queue:1", + detail: { queueLength: 1 }, + }, + }, + { + type: "thread.metadata.updated", + payload: { metadata: { grokQueueLength: 1 } }, + }, + ]); + }); +}); diff --git a/apps/server/src/provider/acp/GrokAcpSessionExtras.ts b/apps/server/src/provider/acp/GrokAcpSessionExtras.ts new file mode 100644 index 000000000000..f9817103d0c6 --- /dev/null +++ b/apps/server/src/provider/acp/GrokAcpSessionExtras.ts @@ -0,0 +1,497 @@ +import type { ThreadTokenUsageSnapshot } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +import { extractGrokTokenUsage } from "./XAiAcpExtension.ts"; +import { grokCompleteCostUsd } from "../../usage/usageTranscripts.ts"; + +/** + * Pure mapping of Grok Build `_x.ai/session/update` extras onto existing T3 + * runtime events. Claude maps compact_boundary / hook_* / session recap the + * same way. Do not invent a third UI shape. + */ + +export const GROK_SESSION_NOTIFICATION_METHODS = [ + "x.ai/session_notification", + "_x.ai/session_notification", + "x.ai/session/update", + "_x.ai/session/update", +] as const; + +export type GrokSessionNotificationMethod = (typeof GROK_SESSION_NOTIFICATION_METHODS)[number]; + +export const GROK_QUEUE_CHANGED_METHODS = ["_x.ai/queue/changed", "x.ai/queue/changed"] as const; + +export const XAiQueueChangedNotification = Schema.Struct({ + sessionId: Schema.optional(Schema.Unknown), + session_id: Schema.optional(Schema.Unknown), + entries: Schema.Array(Schema.Unknown), +}); +export type XAiQueueChangedNotification = typeof XAiQueueChangedNotification.Type; + +export interface GrokHookRun { + readonly hookId: string; + readonly hookName: string; + readonly outcome: "success" | "error" | "cancelled"; + readonly elapsedMs: number | undefined; +} + +export interface GrokHookExecution { + readonly hookEvent: string; + readonly promptId: string | undefined; + readonly runs: ReadonlyArray; +} + +export interface GrokAutoCompactStarted { + readonly kind: "started"; + readonly tokensUsed: number; + readonly contextWindow: number | undefined; + readonly percentage: number | undefined; + readonly reason: string | undefined; +} + +export interface GrokAutoCompactCompleted { + readonly kind: "completed"; + readonly tokensBefore: number | undefined; + readonly tokensAfter: number; + readonly elapsedMs: number | undefined; +} + +export type GrokAutoCompact = GrokAutoCompactStarted | GrokAutoCompactCompleted; + +export interface GrokSessionRecap { + readonly summary: string; + readonly auto: boolean; +} + +export interface GrokTurnCompletedUsage { + readonly usage: ThreadTokenUsageSnapshot; + readonly costUsd: number | undefined; +} + +export interface GrokBackgroundTask { + readonly kind: "started" | "completed"; + readonly taskId: string; + readonly description: string; + readonly command: string | undefined; + readonly outputFile: string | undefined; + readonly exitCode: number | undefined; + readonly output: string | undefined; +} + +export interface GrokQueueChanged { + readonly sessionId: string | undefined; + readonly entries: ReadonlyArray; +} + +export type GrokExtraEventSpec = + | { + readonly type: "hook.started"; + readonly payload: { hookId: string; hookName: string; hookEvent: string }; + } + | { + readonly type: "hook.completed"; + readonly payload: { + hookId: string; + outcome: "success" | "error" | "cancelled"; + }; + } + | { + readonly type: "thread.token-usage.updated"; + readonly payload: { usage: ThreadTokenUsageSnapshot }; + } + | { + readonly type: "thread.state.changed"; + readonly payload: { state: "compacted"; detail?: unknown }; + } + | { + readonly type: "session.state.changed"; + readonly payload: { + state: "waiting" | "running" | "ready"; + reason: string; + detail?: unknown; + }; + } + | { + readonly type: "thread.metadata.updated"; + readonly payload: { metadata: Record }; + } + | { + readonly type: "task.started" | "task.progress" | "task.completed" | "task.updated"; + readonly payload: Record; + }; + +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function nonNegativeInt(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return undefined; + } + return Math.trunc(value); +} + +function sessionUpdateTag(update: Record): string | undefined { + return readString(update.sessionUpdate) ?? readString(update.session_update); +} + +function unwrapSessionUpdate(payload: unknown): Record | undefined { + const envelope = asRecord(payload); + return asRecord(envelope?.update) ?? envelope; +} + +function hookOutcome(status: string | undefined): "success" | "error" | "cancelled" { + switch (status) { + case "success": + case "ok": + case "completed": + return "success"; + case "cancelled": + case "canceled": + case "skipped": + return "cancelled"; + default: + return "error"; + } +} + +export function parseXAiHookExecution(payload: unknown): GrokHookExecution | undefined { + const update = unwrapSessionUpdate(payload); + if (!update) { + return undefined; + } + const tag = sessionUpdateTag(update); + if (tag !== "hook_execution" && tag !== "HookExecution") { + return undefined; + } + const hookEvent = readString(update.event_name) ?? readString(update.eventName); + if (hookEvent === undefined) { + return undefined; + } + const runs = Array.isArray(update.runs) + ? update.runs.flatMap((entry): ReadonlyArray => { + const record = asRecord(entry); + const name = readString(record?.name); + if (name === undefined) { + return []; + } + const statusRecord = asRecord(record?.status); + const status = + readString(statusRecord?.status) ?? + readString(record?.status) ?? + readString(record?.outcome); + return [ + { + hookId: name, + hookName: name, + outcome: hookOutcome(status), + elapsedMs: nonNegativeInt(statusRecord?.elapsed_ms ?? statusRecord?.elapsedMs), + }, + ]; + }) + : []; + if (runs.length === 0) { + return undefined; + } + return { + hookEvent, + promptId: readString(update.prompt_id) ?? readString(update.promptId), + runs, + }; +} + +export function grokHookEvents(execution: GrokHookExecution): ReadonlyArray { + return execution.runs.flatMap((run) => [ + { + type: "hook.started" as const, + payload: { + hookId: run.hookId, + hookName: run.hookName, + hookEvent: execution.hookEvent, + }, + }, + { + type: "hook.completed" as const, + payload: { + hookId: run.hookId, + outcome: run.outcome, + }, + }, + ]); +} + +export function parseXAiAutoCompact(payload: unknown): GrokAutoCompact | undefined { + const update = unwrapSessionUpdate(payload); + if (!update) { + return undefined; + } + const tag = sessionUpdateTag(update); + if (tag === "auto_compact_started" || tag === "AutoCompactStarted") { + const tokensUsed = nonNegativeInt(update.tokens_used ?? update.tokensUsed); + if (tokensUsed === undefined || tokensUsed <= 0) { + return undefined; + } + return { + kind: "started", + tokensUsed, + contextWindow: nonNegativeInt(update.context_window ?? update.contextWindow), + percentage: nonNegativeInt(update.percentage), + reason: readString(update.reason), + }; + } + if (tag === "auto_compact_completed" || tag === "AutoCompactCompleted") { + const tokensAfter = nonNegativeInt(update.tokens_after ?? update.tokensAfter); + if (tokensAfter === undefined) { + return undefined; + } + return { + kind: "completed", + tokensBefore: nonNegativeInt(update.tokens_before ?? update.tokensBefore), + tokensAfter, + elapsedMs: nonNegativeInt(update.elapsed_ms ?? update.elapsedMs), + }; + } + return undefined; +} + +export function grokAutoCompactEvents( + compact: GrokAutoCompact, + previous: ThreadTokenUsageSnapshot | undefined, + sessionIsActive: boolean, +): ReadonlyArray { + if (compact.kind === "started") { + const maxTokens = compact.contextWindow ?? previous?.maxTokens; + const usage: ThreadTokenUsageSnapshot = { + usedTokens: compact.tokensUsed, + lastUsedTokens: compact.tokensUsed, + ...(maxTokens !== undefined && maxTokens > 0 ? { maxTokens } : {}), + compactsAutomatically: true, + }; + return [ + { + type: "session.state.changed", + payload: { + state: "waiting", + reason: compact.reason ?? "compacting", + detail: compact, + }, + }, + { type: "thread.token-usage.updated", payload: { usage } }, + ]; + } + + const maxTokens = previous?.maxTokens; + const usedTokens = compact.tokensAfter > 0 ? compact.tokensAfter : (previous?.usedTokens ?? 0); + const events: Array = []; + if (usedTokens > 0) { + events.push({ + type: "thread.token-usage.updated", + payload: { + usage: { + usedTokens, + lastUsedTokens: compact.tokensBefore ?? previous?.usedTokens ?? usedTokens, + ...(compact.tokensBefore !== undefined && compact.tokensBefore > usedTokens + ? { totalProcessedTokens: compact.tokensBefore } + : {}), + ...(maxTokens !== undefined ? { maxTokens } : {}), + compactsAutomatically: true, + }, + }, + }); + } + events.push({ + type: "session.state.changed", + payload: { + state: sessionIsActive ? "running" : "ready", + reason: "compaction completed", + detail: compact, + }, + }); + events.push({ + type: "thread.state.changed", + payload: { state: "compacted", detail: compact }, + }); + return events; +} + +export function parseXAiSessionRecap(payload: unknown): GrokSessionRecap | undefined { + const update = unwrapSessionUpdate(payload); + if (!update) { + return undefined; + } + const tag = sessionUpdateTag(update); + if (tag !== "session_recap" && tag !== "SessionRecap") { + return undefined; + } + const summary = readString(update.summary); + if (summary === undefined) { + return undefined; + } + return { + summary, + auto: update.auto === true, + }; +} + +export function grokSessionRecapEvents(recap: GrokSessionRecap): ReadonlyArray { + return [ + { + type: "thread.metadata.updated", + payload: { + metadata: { + recap: recap.summary, + recapAuto: recap.auto, + }, + }, + }, + ]; +} + +export function parseXAiTurnCompletedUsage( + payload: unknown, + maxTokens?: number, +): GrokTurnCompletedUsage | undefined { + const update = unwrapSessionUpdate(payload); + if (!update) { + return undefined; + } + const tag = sessionUpdateTag(update); + if (tag !== "turn_completed" && tag !== "TurnCompleted") { + return undefined; + } + const usageRecord = asRecord(update.usage); + if (!usageRecord) { + return undefined; + } + const usage = extractGrokTokenUsage(usageRecord, maxTokens); + if (!usage) { + return undefined; + } + return { + usage, + costUsd: grokCompleteCostUsd(usageRecord) ?? undefined, + }; +} + +export function parseXAiBackgroundTask(payload: unknown): GrokBackgroundTask | undefined { + const update = unwrapSessionUpdate(payload); + if (!update) { + return undefined; + } + const tag = sessionUpdateTag(update); + if (tag === "task_backgrounded" || tag === "TaskBackgrounded") { + const taskId = readString(update.task_id) ?? readString(update.taskId); + if (taskId === undefined) { + return undefined; + } + return { + kind: "started", + taskId, + description: + readString(update.description) ?? readString(update.command) ?? "Background command", + command: readString(update.command), + outputFile: readString(update.output_file) ?? readString(update.outputFile), + exitCode: undefined, + output: undefined, + }; + } + if (tag === "task_completed" || tag === "TaskCompleted") { + const snapshot = asRecord(update.task_snapshot) ?? asRecord(update.taskSnapshot) ?? update; + const taskId = readString(snapshot.task_id) ?? readString(snapshot.taskId); + if (taskId === undefined) { + return undefined; + } + return { + kind: "completed", + taskId, + description: + readString(snapshot.description) ?? readString(snapshot.command) ?? "Background command", + command: readString(snapshot.command), + outputFile: readString(snapshot.output_file) ?? readString(snapshot.outputFile), + exitCode: nonNegativeInt(snapshot.exit_code ?? snapshot.exitCode), + output: readString(snapshot.output), + }; + } + return undefined; +} + +export function grokBackgroundTaskEvents( + task: GrokBackgroundTask, +): ReadonlyArray { + const linkage = { + taskId: task.taskId, + description: task.description, + title: task.description, + taskType: "local_bash", + ...(task.outputFile ? { outputFile: task.outputFile } : {}), + }; + if (task.kind === "started") { + return [ + { type: "task.started", payload: linkage }, + { + type: "task.updated", + payload: { + ...linkage, + status: "running", + isBackgrounded: true, + }, + }, + ]; + } + const failed = task.exitCode !== undefined && task.exitCode !== 0; + return [ + { + type: "task.completed", + payload: { + ...linkage, + status: failed ? "failed" : "completed", + summary: task.output ?? task.description, + }, + }, + ]; +} + +export function parseXAiQueueChanged(payload: unknown): GrokQueueChanged | undefined { + const record = asRecord(payload); + if (!record) { + return undefined; + } + if (!Array.isArray(record.entries)) { + return undefined; + } + return { + sessionId: readString(record.sessionId) ?? readString(record.session_id), + entries: record.entries, + }; +} + +export function grokQueueChangedEvents( + queue: GrokQueueChanged, + sessionIsActive: boolean, +): ReadonlyArray { + const length = queue.entries.length; + return [ + { + type: "session.state.changed", + payload: { + state: length > 0 ? "waiting" : sessionIsActive ? "running" : "ready", + reason: `queue:${length}`, + detail: { queueLength: length }, + }, + }, + { + type: "thread.metadata.updated", + payload: { + metadata: { + grokQueueLength: length, + }, + }, + }, + ]; +} diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index 02d60976b24c..51d08b7ad09a 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -1,12 +1,24 @@ import { describe, expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as EffectAcpErrors from "effect-acp/errors"; import { applyGrokAcpModelSelection, + applyGrokAcpSessionMode, buildGrokAcpSpawnInput, + GROK_REASONING_EFFORT_OPTION_ID, + grokReasoningEffortCapabilities, + isGrokAcpAuthFailure, + parseGrokAcpModelMeta, + grokDiscoveredModelCapabilities, + advertisedGrokReasoningEffortsForModel, + requestedGrokReasoningEffort, resolveGrokAcpBaseModelId, + resolveGrokSessionModeId, + resolveGrokSessionModelId, } from "./GrokAcpSupport.ts"; +import { ProviderInstanceId } from "@t3tools/contracts"; describe("resolveGrokAcpBaseModelId", () => { it("normalizes empty and custom Grok model ids", () => { @@ -16,6 +28,38 @@ describe("resolveGrokAcpBaseModelId", () => { }); }); +describe("resolveGrokSessionModelId", () => { + it("keeps a live ACP model id", () => { + expect( + resolveGrokSessionModelId({ + requested: "grok-4.5", + current: "grok-4.6", + availableIds: ["grok-4.6", "grok-4.5"], + }), + ).toBe("grok-4.5"); + }); + + it("aliases grok-build onto the current live model", () => { + expect( + resolveGrokSessionModelId({ + requested: "grok-build", + current: "grok-4.6", + availableIds: ["grok-4.6", "grok-4.5"], + }), + ).toBe("grok-4.6"); + }); + + it("falls back to the first advertised model when current is missing", () => { + expect( + resolveGrokSessionModelId({ + requested: "grok-build", + current: undefined, + availableIds: ["grok-4.5", "grok-4.6"], + }), + ).toBe("grok-4.5"); + }); +}); + describe("buildGrokAcpSpawnInput", () => { it("passes the T3 Code referrer through Grok OAuth env", () => { const spawn = buildGrokAcpSpawnInput({ binaryPath: "/usr/local/bin/grok" }, "/tmp/project", { @@ -33,15 +77,199 @@ describe("buildGrokAcpSpawnInput", () => { }, }); }); + + it("puts --reasoning-effort before stdio", () => { + const spawn = buildGrokAcpSpawnInput({ binaryPath: "grok" }, "/tmp/project", undefined, "high"); + expect(spawn.args).toEqual(["agent", "--reasoning-effort", "high", "stdio"]); + }); + + it("ignores spawn effort values the CLI rejects", () => { + const spawn = buildGrokAcpSpawnInput({ binaryPath: "grok" }, "/tmp/project", undefined, "max"); + expect(spawn.args).toEqual(["agent", "stdio"]); + }); +}); + +describe("parseGrokAcpModelMeta", () => { + it("reads the live Grok effort menu", () => { + const meta = parseGrokAcpModelMeta({ + supportsReasoningEffort: true, + reasoningEffort: "high", + totalContextTokens: 500000, + reasoningEfforts: [ + { id: "xhigh", value: "xhigh", label: "Extra High Effort", default: true }, + { id: "high", value: "high", label: "High Effort", default: true }, + { id: "medium", value: "medium", label: "Medium Effort" }, + ], + }); + expect(meta.supportsReasoningEffort).toBe(true); + expect(meta.reasoningEffort).toBe("high"); + expect(meta.totalContextTokens).toBe(500000); + expect(meta.reasoningEfforts.map((choice) => choice.id)).toEqual(["xhigh", "high", "medium"]); + expect( + meta.reasoningEfforts.filter((choice) => choice.isDefault).map((choice) => choice.id), + ).toEqual(["high"]); + expect(grokReasoningEffortCapabilities(meta.reasoningEfforts).optionDescriptors?.[0]?.id).toBe( + GROK_REASONING_EFFORT_OPTION_ID, + ); + }); +}); + +describe("requestedGrokReasoningEffort", () => { + it("drops effort values the current model does not advertise", () => { + expect( + requestedGrokReasoningEffort( + { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-4.5", + options: [{ id: GROK_REASONING_EFFORT_OPTION_ID, value: "xhigh" }], + }, + ["high", "medium", "low"], + ), + ).toBeUndefined(); + }); + + it("keeps spawnable effort before the ACP menu is known", () => { + expect( + requestedGrokReasoningEffort( + { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + options: [{ id: GROK_REASONING_EFFORT_OPTION_ID, value: "xhigh" }], + }, + [], + ), + ).toBe("xhigh"); + }); +}); + +describe("advertisedGrokReasoningEffortsForModel", () => { + const liveMenu = ["xhigh", "high", "medium"] as const; + const menus = new Map>([ + ["grok-4.6", [...liveMenu]], + ["grok-4.5", ["high", "medium", "low"]], + ]); + + it("resolves grok-build onto the live ACP model's effort menu", () => { + expect( + advertisedGrokReasoningEffortsForModel({ + menus, + requestedModelId: "grok-build", + currentModelId: "grok-4.6", + availableModelIds: ["grok-4.6", "grok-4.5"], + }), + ).toEqual([...liveMenu]); + }); + + it("uses the requested live model menu instead of spawnable fallback", () => { + const advertised = advertisedGrokReasoningEffortsForModel({ + menus, + requestedModelId: "grok-4.5", + currentModelId: "grok-4.6", + availableModelIds: ["grok-4.6", "grok-4.5"], + }); + expect(advertised).toEqual(["high", "medium", "low"]); + expect( + requestedGrokReasoningEffort( + { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-4.5", + options: [{ id: GROK_REASONING_EFFORT_OPTION_ID, value: "xhigh" }], + }, + advertised, + ), + ).toBeUndefined(); + }); + + it("keeps live-model-only efforts when the composer still says grok-build", () => { + const advertised = advertisedGrokReasoningEffortsForModel({ + menus: new Map([["grok-4.6", ["max", "high"]]]), + requestedModelId: "grok-build", + currentModelId: "grok-4.6", + availableModelIds: ["grok-4.6"], + }); + expect( + requestedGrokReasoningEffort( + { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + options: [{ id: GROK_REASONING_EFFORT_OPTION_ID, value: "max" }], + }, + advertised, + ), + ).toBe("max"); + }); +}); + +describe("grokDiscoveredModelCapabilities", () => { + it("hides Reasoning when the model does not support effort", () => { + expect( + grokDiscoveredModelCapabilities({ + supportsReasoningEffort: false, + reasoningEfforts: [], + }).optionDescriptors, + ).toEqual([]); + }); + + it("falls back to the default menu when support is advertised without choices", () => { + expect( + grokDiscoveredModelCapabilities({ + supportsReasoningEffort: true, + reasoningEfforts: [], + }).optionDescriptors?.[0]?.id, + ).toBe(GROK_REASONING_EFFORT_OPTION_ID); + }); +}); + +describe("isGrokAcpAuthFailure", () => { + it("recognizes tagged authenticate and auth-required failures", () => { + expect( + isGrokAcpAuthFailure( + Cause.fail( + new EffectAcpErrors.AcpRequestError({ + code: -32600, + errorMessage: "authenticate rejected", + method: "authenticate", + }), + ), + ), + ).toBe(true); + expect(isGrokAcpAuthFailure(Cause.fail(EffectAcpErrors.AcpRequestError.authRequired()))).toBe( + true, + ); + }); + + it("does not treat payload text or other ACP methods as auth failure", () => { + expect(isGrokAcpAuthFailure(Cause.fail(new Error("authenticate failed: cached_token")))).toBe( + false, + ); + expect( + isGrokAcpAuthFailure( + Cause.fail( + new EffectAcpErrors.AcpRequestError({ + code: -32603, + errorMessage: "session/new timed out", + method: "session/new", + }), + ), + ), + ).toBe(false); + }); }); describe("applyGrokAcpModelSelection", () => { const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => { - const modelCalls: Array = []; + const modelCalls: Array<{ modelId: string; effort?: string }> = []; const runtime = { - setSessionModel: (modelId: string) => + setSessionModel: ( + modelId: string, + options?: { readonly _meta?: { readonly [x: string]: unknown } }, + ) => Effect.gen(function* () { - modelCalls.push(modelId); + const effort = options?._meta?.reasoningEffort; + modelCalls.push({ + modelId, + ...(typeof effort === "string" ? { effort } : {}), + }); if (failure) return yield* failure; return {}; }), @@ -58,8 +286,8 @@ describe("applyGrokAcpModelSelection", () => { requestedModelId: "grok-mock-alt", mapError: (cause) => cause.message, }); - expect(modelCalls).toEqual(["grok-mock-alt"]); - expect(result).toBe("grok-mock-alt"); + expect(modelCalls).toEqual([{ modelId: "grok-mock-alt" }]); + expect(result).toEqual({ modelId: "grok-mock-alt", reasoningEffort: undefined }); }), ); @@ -73,7 +301,53 @@ describe("applyGrokAcpModelSelection", () => { mapError: (cause) => cause.message, }); expect(modelCalls).toEqual([]); - expect(result).toBe("grok-build"); + expect(result).toEqual({ modelId: "grok-build", reasoningEffort: undefined }); + }), + ); + + it.effect("does not send grok-build to session/set_model when the live menu has real ids", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-4.6", + requestedModelId: "grok-build", + availableModelIds: ["grok-4.6", "grok-4.5"], + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([]); + expect(result).toEqual({ modelId: "grok-4.6", reasoningEffort: undefined }); + }), + ); + + it.effect("does not carry the previous effort across a model switch", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-4.6", + requestedModelId: "grok-4.5", + currentReasoningEffort: "xhigh", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([{ modelId: "grok-4.5" }]); + expect(result).toEqual({ modelId: "grok-4.5", reasoningEffort: undefined }); + }), + ); + + it.effect("calls session/set_model when only effort changes", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-4.6", + requestedModelId: "grok-4.6", + currentReasoningEffort: "high", + requestedReasoningEffort: "xhigh", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([{ modelId: "grok-4.6", effort: "xhigh" }]); + expect(result).toEqual({ modelId: "grok-4.6", reasoningEffort: "xhigh" }); }), ); @@ -87,7 +361,7 @@ describe("applyGrokAcpModelSelection", () => { mapError: (cause) => cause.message, }); expect(modelCalls).toEqual([]); - expect(result).toBe("grok-build"); + expect(result).toEqual({ modelId: "grok-build", reasoningEffort: undefined }); }), ); @@ -107,3 +381,86 @@ describe("applyGrokAcpModelSelection", () => { }), ); }); + +const grokModes = { + currentModeId: "ask", + availableModes: [ + { id: "ask", name: "Ask" }, + { id: "architect", name: "Architect" }, + { id: "code", name: "Code" }, + ], +}; + +describe("resolveGrokSessionModeId", () => { + it("maps plan onto architect when that is the advertised plan mode", () => { + expect( + resolveGrokSessionModeId({ + interactionMode: "plan", + runtimeMode: "full-access", + modeState: grokModes, + }), + ).toBe("architect"); + }); + + it("maps default onto code", () => { + expect( + resolveGrokSessionModeId({ + interactionMode: "default", + runtimeMode: "full-access", + modeState: grokModes, + }), + ).toBe("code"); + }); + + it("leaves the mode alone when the user did not pick one", () => { + expect( + resolveGrokSessionModeId({ + interactionMode: undefined, + runtimeMode: "full-access", + modeState: grokModes, + }), + ).toBeUndefined(); + }); +}); + +describe("applyGrokAcpSessionMode", () => { + it.effect("calls setMode when plan is requested", () => + Effect.gen(function* () { + const modeCalls: string[] = []; + yield* applyGrokAcpSessionMode({ + runtime: { + getModeState: Effect.succeed(grokModes), + setMode: (modeId) => + Effect.sync(() => { + modeCalls.push(modeId); + return {}; + }), + }, + runtimeMode: "full-access", + interactionMode: "plan", + mapError: (cause) => cause.message, + }); + expect(modeCalls).toEqual(["architect"]); + }), + ); + + it.effect("skips setMode when the session has no matching mode", () => + Effect.gen(function* () { + const modeCalls: string[] = []; + yield* applyGrokAcpSessionMode({ + runtime: { + getModeState: Effect.succeed(undefined), + setMode: (modeId) => + Effect.sync(() => { + modeCalls.push(modeId); + return {}; + }), + }, + runtimeMode: "full-access", + interactionMode: "plan", + mapError: (cause) => cause.message, + }); + expect(modeCalls).toEqual([]); + }), + ); +}); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index c928b3ed80e0..f0f8bccfb4df 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -1,13 +1,27 @@ -import { type GrokSettings, ProviderDriverKind } from "@t3tools/contracts"; +import { + type GrokSettings, + type ModelCapabilities, + type ModelSelection, + type ProviderInteractionMode, + ProviderDriverKind, + type RuntimeMode, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; -import { normalizeModelSlug } from "@t3tools/shared/model"; +import { + createModelCapabilities, + getModelSelectionStringOptionValue, + normalizeModelSlug, +} from "@t3tools/shared/model"; +import type { AcpSessionMode, AcpSessionModeState } from "./AcpRuntimeModel.ts"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; import { makeXAiPromptCompletionRuntime } from "./XAiAcpExtension.ts"; @@ -18,6 +32,18 @@ const GROK_AUTH_METHOD_API_KEY = "xai.api_key"; const GROK_AUTH_METHOD_CACHED_TOKEN = "cached_token"; const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); +/** Composer option id for Grok reasoning effort. Same shape as Codex. */ +export const GROK_REASONING_EFFORT_OPTION_ID = "reasoningEffort"; + +const GROK_SPAWN_EFFORT_LEVELS = new Set(["none", "minimal", "low", "medium", "high", "xhigh"]); + +export const FALLBACK_GROK_REASONING_EFFORTS = [ + { id: "xhigh", label: "Extra High", description: "Highest effort and reasoning level" }, + { id: "high", label: "High", description: "Higher implementation quality", isDefault: true }, + { id: "medium", label: "Medium", description: "Balanced effort" }, + { id: "low", label: "Low", description: "Quick implementations" }, +] as const; + type GrokAcpRuntimeGrokSettings = Pick; interface GrokAcpRuntimeInput extends Omit< @@ -27,16 +53,38 @@ interface GrokAcpRuntimeInput extends Omit< readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined; readonly environment?: NodeJS.ProcessEnv; + readonly reasoningEffort?: string; +} + +export interface GrokReasoningEffortChoice { + readonly id: string; + readonly label: string; + readonly description?: string; + readonly isDefault?: boolean; +} + +export interface GrokAcpModelMeta { + readonly supportsReasoningEffort: boolean; + readonly reasoningEffort?: string; + readonly reasoningEfforts: ReadonlyArray; + readonly totalContextTokens?: number; +} + +export interface GrokAcpSelection { + readonly modelId: string | undefined; + readonly reasoningEffort: string | undefined; } export function buildGrokAcpSpawnInput( grokSettings: GrokAcpRuntimeGrokSettings | null | undefined, cwd: string, environment?: NodeJS.ProcessEnv, + reasoningEffort?: string, ): AcpSessionRuntime.AcpSpawnInput { + const spawnEffort = spawnableGrokReasoningEffort(reasoningEffort); return { command: grokSettings?.binaryPath || "grok", - args: ["agent", "stdio"], + args: spawnEffort ? ["agent", "--reasoning-effort", spawnEffort, "stdio"] : ["agent", "stdio"], cwd, env: { ...environment, @@ -62,7 +110,12 @@ export const makeGrokAcpRuntime = ( const acpContext = yield* Layer.build( AcpSessionRuntime.layer({ ...input, - spawn: buildGrokAcpSpawnInput(input.grokSettings, input.cwd, input.environment), + spawn: buildGrokAcpSpawnInput( + input.grokSettings, + input.cwd, + input.environment, + input.reasoningEffort, + ), authMethodId: resolveGrokAuthMethodId(input.environment), }).pipe( Layer.provide( @@ -82,6 +135,50 @@ export function resolveGrokAcpBaseModelId(model: string | null | undefined): str return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? "grok-build"; } +/** T3 product slugs that Grok ACP `session/set_model` does not accept. */ +const GROK_PRODUCT_MODEL_ALIASES = new Set(["grok-build", "grok-code", "grok-code-fast-1"]); + +export function availableGrokSessionModelIds( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): ReadonlyArray { + return (sessionSetupResult.models?.availableModels ?? []) + .map((model) => model.modelId.trim()) + .filter((modelId) => modelId.length > 0); +} + +/** + * Map a composer selection onto an id `session/set_model` will accept. + * `grok-build` is T3's product name; live Grok ACP ids are `grok-4.6` / `grok-4.5`. + */ +export function resolveGrokSessionModelId(input: { + readonly requested: string | undefined; + readonly current: string | undefined; + readonly availableIds: ReadonlyArray; +}): string | undefined { + const available = input.availableIds.filter((id) => id.length > 0); + if (available.length === 0) { + return input.requested ?? input.current; + } + if (input.requested && available.includes(input.requested)) { + return input.requested; + } + if ( + input.requested && + !GROK_PRODUCT_MODEL_ALIASES.has(input.requested) && + !available.includes(input.requested) + ) { + // Custom / unknown slug: still try the requested id so set_model can fail loudly. + return input.requested; + } + if (input.current && available.includes(input.current)) { + return input.current; + } + return available[0]; +} + export function currentGrokModelIdFromSessionSetup( sessionSetupResult: | EffectAcpSchema.LoadSessionResponse @@ -91,18 +188,420 @@ export function currentGrokModelIdFromSessionSetup( return sessionSetupResult.models?.currentModelId?.trim() || undefined; } +export function spawnableGrokReasoningEffort(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed || !GROK_SPAWN_EFFORT_LEVELS.has(trimmed)) { + return undefined; + } + return trimmed; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function trimmedString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function parseGrokReasoningEffortChoice(value: unknown): GrokReasoningEffortChoice | undefined { + if (typeof value === "string") { + const id = value.trim(); + return id.length > 0 ? { id, label: id } : undefined; + } + if (!isRecord(value)) { + return undefined; + } + const id = trimmedString(value.value) ?? trimmedString(value.id); + if (!id) { + return undefined; + } + const label = trimmedString(value.label) ?? trimmedString(value.name) ?? id; + const description = trimmedString(value.description); + return { + id, + label, + ...(description ? { description } : {}), + ...(value.default === true || value.isDefault === true ? { isDefault: true } : {}), + }; +} + +/** Reads the per-model effort menu Grok stamps onto ACP `models._meta`. */ +export function parseGrokAcpModelMeta(meta: unknown): GrokAcpModelMeta { + if (!isRecord(meta)) { + return { supportsReasoningEffort: false, reasoningEfforts: [] }; + } + + const reasoningEfforts = Array.isArray(meta.reasoningEfforts) + ? meta.reasoningEfforts.flatMap((entry) => { + const choice = parseGrokReasoningEffortChoice(entry); + return choice ? [choice] : []; + }) + : []; + const unique = new Map(); + for (const choice of reasoningEfforts) { + if (!unique.has(choice.id)) { + unique.set(choice.id, choice); + } + } + const choices = [...unique.values()]; + const current = trimmedString(meta.reasoningEffort); + const defaultId = current ?? choices.find((choice) => choice.isDefault)?.id; + const supportsReasoningEffort = meta.supportsReasoningEffort === true || choices.length > 0; + const totalContextTokens = + typeof meta.totalContextTokens === "number" && + Number.isFinite(meta.totalContextTokens) && + meta.totalContextTokens > 0 + ? Math.trunc(meta.totalContextTokens) + : undefined; + + return { + supportsReasoningEffort, + ...(current ? { reasoningEffort: current } : {}), + reasoningEfforts: choices.map((choice) => ({ + id: choice.id, + label: choice.label, + ...(choice.description ? { description: choice.description } : {}), + ...(choice.id === defaultId ? { isDefault: true } : {}), + })), + ...(totalContextTokens !== undefined ? { totalContextTokens } : {}), + }; +} + +export function grokReasoningEffortCapabilities( + efforts: ReadonlyArray, +): ModelCapabilities { + if (efforts.length === 0) { + return createModelCapabilities({ optionDescriptors: [] }); + } + const defaultId = efforts.find((choice) => choice.isDefault)?.id ?? efforts[0]?.id; + return createModelCapabilities({ + optionDescriptors: [ + { + id: GROK_REASONING_EFFORT_OPTION_ID, + label: "Reasoning", + type: "select", + options: efforts.map((choice) => ({ + id: choice.id, + label: choice.label, + ...(choice.description ? { description: choice.description } : {}), + ...(choice.isDefault ? { isDefault: true } : {}), + })), + ...(defaultId ? { currentValue: defaultId } : {}), + }, + ], + }); +} + +export function fallbackGrokReasoningEffortCapabilities(): ModelCapabilities { + return grokReasoningEffortCapabilities([...FALLBACK_GROK_REASONING_EFFORTS]); +} + +export function requestedGrokReasoningEffort( + modelSelection: ModelSelection | null | undefined, + advertised: ReadonlyArray, +): string | undefined { + const requested = getModelSelectionStringOptionValue( + modelSelection, + GROK_REASONING_EFFORT_OPTION_ID, + )?.trim(); + if (!requested) { + return undefined; + } + // Before ACP discovery the advertised menu is empty. Accept spawnable + // levels so `--reasoning-effort` still reaches the Grok process. + if (advertised.length === 0) { + return spawnableGrokReasoningEffort(requested); + } + if (advertised.includes(requested)) { + return requested; + } + return undefined; +} + +export function grokDiscoveredModelCapabilities(meta: GrokAcpModelMeta): ModelCapabilities { + if (meta.reasoningEfforts.length > 0) { + return grokReasoningEffortCapabilities(meta.reasoningEfforts); + } + if (meta.supportsReasoningEffort) { + return fallbackGrokReasoningEffortCapabilities(); + } + return createModelCapabilities({ optionDescriptors: [] }); +} + +export function grokMaxTokensByModelFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): Map { + const maxTokens = new Map(); + for (const model of sessionSetupResult.models?.availableModels ?? []) { + const tokens = parseGrokAcpModelMeta(model._meta).totalContextTokens; + if (tokens === undefined) { + continue; + } + const slug = resolveGrokAcpBaseModelId(model.modelId); + maxTokens.set(slug, tokens); + maxTokens.set(model.modelId, tokens); + } + return maxTokens; +} + +export function grokReasoningEffortMenusFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): Map> { + const menus = new Map>(); + for (const model of sessionSetupResult.models?.availableModels ?? []) { + const slug = resolveGrokAcpBaseModelId(model.modelId); + const efforts = parseGrokAcpModelMeta(model._meta).reasoningEfforts.map((choice) => choice.id); + if (efforts.length > 0) { + menus.set(slug, efforts); + menus.set(model.modelId, efforts); + } + } + return menus; +} + +/** + * Effort menu for a composer selection. `grok-build` is a product slug; live + * ACP menus are keyed by `grok-4.6` / `grok-4.5`. Resolve the alias before + * reading the map so sendTurn does not treat the menu as empty. + */ +export function advertisedGrokReasoningEffortsForModel(input: { + readonly menus: ReadonlyMap>; + readonly requestedModelId: string | undefined; + readonly currentModelId: string | undefined; + readonly availableModelIds: ReadonlyArray; +}): ReadonlyArray { + const liveId = resolveGrokSessionModelId({ + requested: input.requestedModelId, + current: input.currentModelId, + availableIds: input.availableModelIds, + }); + for (const id of [liveId, input.requestedModelId, input.currentModelId]) { + if (id && input.menus.has(id)) { + return input.menus.get(id) ?? []; + } + } + return []; +} + +export function advertisedGrokReasoningEffortsFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, + modelId: string | undefined, +): ReadonlyArray { + return advertisedGrokReasoningEffortsForModel({ + menus: grokReasoningEffortMenusFromSessionSetup(sessionSetupResult), + requestedModelId: modelId, + currentModelId: currentGrokModelIdFromSessionSetup(sessionSetupResult), + availableModelIds: availableGrokSessionModelIds(sessionSetupResult), + }); +} + +export function currentGrokReasoningEffortFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): string | undefined { + const currentModelId = sessionSetupResult.models?.currentModelId; + const current = sessionSetupResult.models?.availableModels.find( + (model) => model.modelId === currentModelId, + ); + return parseGrokAcpModelMeta(current?._meta).reasoningEffort; +} + +export function currentGrokMaxTokensFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): number | undefined { + const currentModelId = sessionSetupResult.models?.currentModelId; + const current = sessionSetupResult.models?.availableModels.find( + (model) => model.modelId === currentModelId, + ); + return parseGrokAcpModelMeta(current?._meta).totalContextTokens; +} + +const isAcpRequestError = Schema.is(EffectAcpErrors.AcpRequestError); +/** ACP JSON-RPC `authRequired` (`AcpRequestError.authRequired`). */ +const GROK_ACP_AUTH_REQUIRED_CODE = -32000; + +function isGrokAcpAuthRequestError(error: unknown): boolean { + return ( + isAcpRequestError(error) && + (error.method === "authenticate" || error.code === GROK_ACP_AUTH_REQUIRED_CODE) + ); +} + +export function isGrokAcpAuthFailure(cause: Cause.Cause): boolean { + return cause.reasons.some( + (reason) => Cause.isFailReason(reason) && isGrokAcpAuthRequestError(reason.error), + ); +} + export function applyGrokAcpModelSelection(input: { readonly runtime: Pick; readonly currentModelId: string | undefined; readonly requestedModelId: string | undefined; + readonly availableModelIds?: ReadonlyArray; + readonly currentReasoningEffort?: string | undefined; + readonly requestedReasoningEffort?: string | undefined; readonly mapError: (cause: EffectAcpErrors.AcpError) => E; -}): Effect.Effect { +}): Effect.Effect { + const requestedModelId = + input.availableModelIds && input.availableModelIds.length > 0 + ? resolveGrokSessionModelId({ + requested: input.requestedModelId, + current: input.currentModelId, + availableIds: input.availableModelIds, + }) + : input.requestedModelId; + const nextModelId = requestedModelId ?? input.currentModelId; const shouldSwitchModel = - input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; - if (!shouldSwitchModel) { - return Effect.succeed(input.currentModelId); + requestedModelId !== undefined && requestedModelId !== input.currentModelId; + const nextEffort = shouldSwitchModel + ? input.requestedReasoningEffort + : (input.requestedReasoningEffort ?? input.currentReasoningEffort); + const shouldSwitchEffort = + input.requestedReasoningEffort !== undefined && + input.requestedReasoningEffort !== input.currentReasoningEffort; + + if (!shouldSwitchModel && !shouldSwitchEffort) { + return Effect.succeed({ + modelId: input.currentModelId, + reasoningEffort: input.currentReasoningEffort, + }); } + + if (nextModelId === undefined) { + return Effect.succeed({ + modelId: undefined, + reasoningEffort: nextEffort, + }); + } + return input.runtime - .setSessionModel(input.requestedModelId) - .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId)); + .setSessionModel( + nextModelId, + nextEffort ? { _meta: { reasoningEffort: nextEffort } } : undefined, + ) + .pipe( + Effect.mapError(input.mapError), + Effect.as({ + modelId: nextModelId, + reasoningEffort: nextEffort, + }), + ); +} + +const GROK_PLAN_MODE_ALIASES = ["plan", "architect"]; +const GROK_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"]; +const GROK_APPROVAL_MODE_ALIASES = ["ask"]; + +function normalizeModeSearchText(mode: AcpSessionMode): string { + return `${mode.id} ${mode.name}`.trim().toLowerCase(); +} + +function findModeByAliases( + modes: ReadonlyArray, + aliases: ReadonlyArray, +): AcpSessionMode | undefined { + const normalizedAliases = aliases.map((alias) => alias.toLowerCase()); + for (const alias of normalizedAliases) { + const exact = modes.find((mode) => { + const haystack = normalizeModeSearchText(mode); + return haystack === alias || mode.id.toLowerCase() === alias; + }); + if (exact) { + return exact; + } + } + for (const alias of normalizedAliases) { + const partial = modes.find((mode) => normalizeModeSearchText(mode).includes(alias)); + if (partial) { + return partial; + } + } + return undefined; +} + +function isPlanMode(mode: AcpSessionMode): boolean { + return findModeByAliases([mode], GROK_PLAN_MODE_ALIASES) !== undefined; +} + +export function grokSessionAdvertisesPlanMode(modeState: AcpSessionModeState | undefined): boolean { + return ( + modeState !== undefined && + findModeByAliases(modeState.availableModes, GROK_PLAN_MODE_ALIASES) !== undefined + ); +} + +export function resolveGrokSessionModeId(input: { + readonly interactionMode: ProviderInteractionMode | undefined; + readonly runtimeMode: RuntimeMode; + readonly modeState: AcpSessionModeState | undefined; +}): string | undefined { + const modeState = input.modeState; + if (!modeState) { + return undefined; + } + + if (input.interactionMode === "plan") { + return findModeByAliases(modeState.availableModes, GROK_PLAN_MODE_ALIASES)?.id; + } + + if (input.runtimeMode === "approval-required") { + return ( + findModeByAliases(modeState.availableModes, GROK_APPROVAL_MODE_ALIASES)?.id ?? + findModeByAliases(modeState.availableModes, GROK_IMPLEMENT_MODE_ALIASES)?.id ?? + modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? + modeState.currentModeId + ); + } + + if (input.interactionMode !== "default") { + return undefined; + } + + return ( + findModeByAliases(modeState.availableModes, GROK_IMPLEMENT_MODE_ALIASES)?.id ?? + findModeByAliases(modeState.availableModes, GROK_APPROVAL_MODE_ALIASES)?.id ?? + modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? + modeState.currentModeId + ); +} + +export function applyGrokAcpSessionMode(input: { + readonly runtime: Pick< + AcpSessionRuntime.AcpSessionRuntime["Service"], + "getModeState" | "setMode" + >; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode | undefined; + readonly mapError: (cause: EffectAcpErrors.AcpError) => E; +}): Effect.Effect { + return Effect.gen(function* () { + const requestedModeId = resolveGrokSessionModeId({ + interactionMode: input.interactionMode, + runtimeMode: input.runtimeMode, + modeState: yield* input.runtime.getModeState, + }); + if (!requestedModeId) { + return; + } + yield* input.runtime.setMode(requestedModeId).pipe(Effect.mapError(input.mapError)); + }); } diff --git a/apps/server/src/provider/acp/GrokAcpToolUpdates.test.ts b/apps/server/src/provider/acp/GrokAcpToolUpdates.test.ts new file mode 100644 index 000000000000..79249a5604f5 --- /dev/null +++ b/apps/server/src/provider/acp/GrokAcpToolUpdates.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + GROK_TOOL_CONTENT_CHAR_LIMIT, + boundGrokToolCallForEvent, + grokToolCallFingerprint, + shouldEmitGrokToolUpdate, +} from "./GrokAcpToolUpdates.ts"; + +const running = { + toolCallId: "term-1", + kind: "execute", + status: "inProgress" as const, + title: "Terminal", + data: { content: "x".repeat(200) }, +}; + +describe("GrokAcpToolUpdates", () => { + it("drops identical in-progress ticks", () => { + expect( + shouldEmitGrokToolUpdate({ + toolCall: running, + previous: { + fingerprint: grokToolCallFingerprint(running), + lastEmittedAt: 0, + }, + nowMs: 1_000, + }), + ).toBe(false); + }); + + it("emits same-length content changes after the interval", () => { + expect( + shouldEmitGrokToolUpdate({ + toolCall: { ...running, data: { content: "y".repeat(200) } }, + previous: { + fingerprint: grokToolCallFingerprint(running), + lastEmittedAt: 0, + }, + nowMs: 1_000, + }), + ).toBe(true); + }); + + it("rate-limits growing in-progress terminal output", () => { + expect( + shouldEmitGrokToolUpdate({ + toolCall: { ...running, data: { content: "x".repeat(400) } }, + previous: { fingerprint: "other", lastEmittedAt: 900 }, + nowMs: 1_000, + }), + ).toBe(false); + }); + + it("does not stringify large in-progress payloads while rate-limited", () => { + const lines = Array.from({ length: 20_000 }, (_, index) => `line-${index}`); + expect( + shouldEmitGrokToolUpdate({ + toolCall: { ...running, data: { lines } }, + previous: { fingerprint: "other", lastEmittedAt: 900 }, + nowMs: 1_000, + }), + ).toBe(false); + }); + + it("always emits a terminal status", () => { + expect( + shouldEmitGrokToolUpdate({ + toolCall: { ...running, status: "completed" }, + previous: { fingerprint: "other", lastEmittedAt: 999 }, + nowMs: 1_000, + }), + ).toBe(true); + }); + + it("truncates cumulative content and strips the raw payload", () => { + const huge = "y".repeat(GROK_TOOL_CONTENT_CHAR_LIMIT + 50); + const bounded = boundGrokToolCallForEvent({ + toolCall: { ...running, data: { content: huge }, detail: huge }, + rawPayload: { update: { content: huge } }, + }); + expect(String(bounded.toolCall.data.content).length).toBe(GROK_TOOL_CONTENT_CHAR_LIMIT); + expect(bounded.rawPayload).toEqual({ + truncated: true, + toolCallId: "term-1", + status: "inProgress", + }); + }); + + it("bounds an array of many short strings by serialized size", () => { + const bounded = boundGrokToolCallForEvent({ + toolCall: { + ...running, + data: { lines: Array.from({ length: 4_000 }, (_, index) => `line-${index}`) }, + }, + rawPayload: { lines: Array.from({ length: 4_000 }, (_, index) => `line-${index}`) }, + }); + expect(bounded.toolCall.data).toMatchObject({ truncated: true }); + expect(String(bounded.toolCall.data.tail).length).toBeLessThanOrEqual( + GROK_TOOL_CONTENT_CHAR_LIMIT, + ); + expect(bounded.rawPayload).toMatchObject({ truncated: true, toolCallId: "term-1" }); + }); +}); diff --git a/apps/server/src/provider/acp/GrokAcpToolUpdates.ts b/apps/server/src/provider/acp/GrokAcpToolUpdates.ts new file mode 100644 index 000000000000..14f42d6598ab --- /dev/null +++ b/apps/server/src/provider/acp/GrokAcpToolUpdates.ts @@ -0,0 +1,127 @@ +import type { AcpToolCallState } from "./AcpRuntimeModel.ts"; + +/** In-progress execute updates faster than this are dropped (#6556). */ +export const GROK_TOOL_UPDATE_MIN_INTERVAL_MS = 250; +/** Keep the tail of cumulative terminal output so a progress bar still reads. */ +export const GROK_TOOL_CONTENT_CHAR_LIMIT = 8_192; + +export interface GrokToolUpdateGate { + fingerprint: string; + lastEmittedAt: number; +} + +function grokToolDataSignature(data: unknown): string { + if (typeof data === "string") { + return `s${data.length}:${data.slice(-64)}`; + } + if (Array.isArray(data)) { + const last = data.length === 0 ? "" : grokToolDataSignature(data[data.length - 1]); + return `a${data.length}:${last}`; + } + if (data !== null && typeof data === "object") { + return `{${Object.entries(data as Record) + .map(([key, value]) => `${key}:${grokToolDataSignature(value)}`) + .join(",")}}`; + } + if (data === undefined) { + return "u"; + } + if (data === null) { + return "n"; + } + return String(data); +} + +export function grokToolCallFingerprint(toolCall: AcpToolCallState): string { + return [ + toolCall.toolCallId, + toolCall.status ?? "", + toolCall.title ?? "", + toolCall.detail ?? "", + grokToolDataSignature(toolCall.data), + ].join("\u001f"); +} + +export function shouldEmitGrokToolUpdate(input: { + readonly toolCall: AcpToolCallState; + readonly previous: GrokToolUpdateGate | undefined; + readonly nowMs: number; +}): boolean { + const status = input.toolCall.status; + if (status === "completed" || status === "failed") { + return true; + } + if ( + input.previous !== undefined && + input.nowMs - input.previous.lastEmittedAt < GROK_TOOL_UPDATE_MIN_INTERVAL_MS + ) { + return false; + } + return input.previous?.fingerprint !== grokToolCallFingerprint(input.toolCall); +} + +function truncateText(value: string): string { + if (value.length <= GROK_TOOL_CONTENT_CHAR_LIMIT) { + return value; + } + return value.slice(-GROK_TOOL_CONTENT_CHAR_LIMIT); +} + +function boundUnknown(value: unknown): unknown { + if (typeof value === "string") { + return truncateText(value); + } + if (Array.isArray(value)) { + return value.map(boundUnknown); + } + if (value !== null && typeof value === "object") { + const record = value as Record; + const next: Record = {}; + for (const [key, entry] of Object.entries(record)) { + next[key] = boundUnknown(entry); + } + return next; + } + return value; +} + +function boundDataToBudget(data: Record): Record { + const perField = boundUnknown(data) as Record; + const serialized = JSON.stringify(perField) ?? ""; + if (serialized.length <= GROK_TOOL_CONTENT_CHAR_LIMIT + 256) { + return perField; + } + return { + truncated: true, + tail: serialized.slice(-(GROK_TOOL_CONTENT_CHAR_LIMIT - 64)), + }; +} + +/** Shrink cumulative Grok terminal payloads before they hit ingestion / NDJSON. */ +export function boundGrokToolCallForEvent(input: { + readonly toolCall: AcpToolCallState; + readonly rawPayload: unknown; +}): { readonly toolCall: AcpToolCallState; readonly rawPayload: unknown } { + const serialized = JSON.stringify(input.toolCall.data) ?? ""; + const rawSerialized = JSON.stringify(input.rawPayload) ?? ""; + if ( + serialized.length <= GROK_TOOL_CONTENT_CHAR_LIMIT && + rawSerialized.length <= GROK_TOOL_CONTENT_CHAR_LIMIT + ) { + return input; + } + return { + toolCall: { + ...input.toolCall, + data: boundDataToBudget(input.toolCall.data), + ...(typeof input.toolCall.detail === "string" + ? { detail: truncateText(input.toolCall.detail) } + : {}), + }, + rawPayload: { + truncated: true, + toolCallId: input.toolCall.toolCallId, + status: input.toolCall.status, + }, + }; +} diff --git a/apps/server/src/provider/acp/GrokAcpWorkflow.test.ts b/apps/server/src/provider/acp/GrokAcpWorkflow.test.ts new file mode 100644 index 000000000000..013946d8743e --- /dev/null +++ b/apps/server/src/provider/acp/GrokAcpWorkflow.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + applyGrokSubagentUpdate, + applyGrokWorkflowUpdate, + emptyGrokWorkflowTrackState, + grokWorkflowMemberTaskId, + parseXAiSubagentUpdate, + parseXAiWorkflowUpdated, +} from "./GrokAcpWorkflow.ts"; + +const workflowEnvelope = { + sessionId: "sess-1", + update: { + sessionUpdate: "workflow_updated", + run_id: "wf_review_1", + name: "review-changes", + objective: "Review the latest diff", + status: "active", + phases: [ + { title: "Plan", state: "done" }, + { title: "Execute", state: "active" }, + ], + current_phase: "Execute", + agents: [ + { + agent_id: "agent_reviewer", + label: "Reviewer", + state: "running", + tokens_used: 42, + duration_ms: 800, + }, + ], + }, +}; + +describe("GrokAcpWorkflow", () => { + it("stamps workflow members like Claude: parentAgentId + timelineBypass + stable slot", () => { + const update = parseXAiWorkflowUpdated(workflowEnvelope); + expect(update).toBeDefined(); + const first = applyGrokWorkflowUpdate(emptyGrokWorkflowTrackState(), update!); + const memberStarted = first.events.find( + (event) => event.type === "task.started" && event.payload.taskType === "subagent", + ); + expect(memberStarted?.payload).toMatchObject({ + taskId: grokWorkflowMemberTaskId("wf_review_1", "agent_reviewer"), + parentAgentId: "wf_review_1", + timelineBypass: true, + }); + expect(memberStarted?.payload.agentId).toBeUndefined(); + const eventTypes: ReadonlyArray = first.events.map((event) => event.type); + expect(eventTypes).not.toContain("thread.token-usage.updated"); + }); + + it("completes a run that is already terminal on the first notification", () => { + const update = parseXAiWorkflowUpdated({ + update: { + sessionUpdate: "workflow_updated", + run_id: "wf_done", + name: "review-changes", + status: "complete", + result_summary: "Shipped", + phases: [], + agents: [], + }, + }); + const applied = applyGrokWorkflowUpdate(emptyGrokWorkflowTrackState(), update!); + expect(applied.events.map((event) => event.type)).toEqual(["task.started", "task.completed"]); + expect(applied.events[1]?.payload).toMatchObject({ status: "completed", summary: "Shipped" }); + }); + + it("does not re-emit unchanged member ticks", () => { + const update = parseXAiWorkflowUpdated(workflowEnvelope)!; + const first = applyGrokWorkflowUpdate(emptyGrokWorkflowTrackState(), update); + const second = applyGrokWorkflowUpdate(first.state, update); + expect(second.events.some((event) => event.payload.taskType === "subagent")).toBe(false); + }); + + it("maps SubagentFinished onto a terminal child task", () => { + const spawned = parseXAiSubagentUpdate({ + update: { + sessionUpdate: "subagent_spawned", + subagent_id: "sa_1", + parent_session_id: "sess-1", + child_session_id: "child-1", + subagent_type: "explore", + }, + }); + const finished = parseXAiSubagentUpdate({ + update: { + sessionUpdate: "subagent_finished", + subagent_id: "sa_1", + child_session_id: "child-1", + status: "completed", + tokens_used: 90, + }, + }); + const afterSpawn = applyGrokSubagentUpdate(emptyGrokWorkflowTrackState(), spawned!); + const afterFinish = applyGrokSubagentUpdate(afterSpawn.state, finished!); + expect(afterSpawn.events[0]).toMatchObject({ + type: "task.started", + payload: { timelineBypass: true, role: "explore", parentAgentId: "sess-1" }, + }); + expect(afterFinish.events[0]).toMatchObject({ + type: "task.completed", + payload: { status: "completed", typedUsage: { totalTokens: 90 } }, + }); + }); + + it("keeps member identity on the Grok agent_id when the array is filtered or reordered", () => { + const update = parseXAiWorkflowUpdated({ + update: { + sessionUpdate: "workflow_updated", + run_id: "wf_review_1", + name: "review-changes", + status: "active", + agents: [ + { label: "broken" }, + { + agent_id: "agent_reviewer", + label: "Reviewer", + state: "running", + }, + ], + }, + }); + const applied = applyGrokWorkflowUpdate(emptyGrokWorkflowTrackState(), update!); + const member = applied.events.find( + (event) => event.type === "task.started" && event.payload.taskType === "subagent", + ); + expect(member?.payload.taskId).toBe("wf_review_1:wf:agent_reviewer"); + }); + + it("carries duration and tool-use counts on subagent progress and finish", () => { + const progress = parseXAiSubagentUpdate({ + update: { + sessionUpdate: "subagent_progress", + subagent_id: "sa_1", + tool_call_count: 3, + }, + }); + const afterProgress = applyGrokSubagentUpdate(emptyGrokWorkflowTrackState(), progress!); + expect(afterProgress.events.at(-1)?.payload.typedUsage).toEqual({ + totalTokens: 0, + toolUses: 3, + }); + + const finished = parseXAiSubagentUpdate({ + update: { + sessionUpdate: "subagent_finished", + subagent_id: "sa_1", + status: "completed", + tokens_used: 90, + duration_ms: 1200, + tool_calls: 4, + }, + }); + const afterFinish = applyGrokSubagentUpdate(afterProgress.state, finished!); + expect(afterFinish.events[0]?.payload.typedUsage).toEqual({ + totalTokens: 90, + durationMs: 1200, + toolUses: 4, + }); + }); + + it("does not let a tool-only tick zero earlier subagent tokens", () => { + const withTokens = parseXAiSubagentUpdate({ + update: { + sessionUpdate: "subagent_progress", + subagent_id: "sa_1", + tokens_used: 90, + }, + }); + const toolsOnly = parseXAiSubagentUpdate({ + update: { + sessionUpdate: "subagent_progress", + subagent_id: "sa_1", + tool_call_count: 3, + }, + }); + const finishedDurationOnly = parseXAiSubagentUpdate({ + update: { + sessionUpdate: "subagent_finished", + subagent_id: "sa_1", + status: "completed", + duration_ms: 1200, + }, + }); + const afterTokens = applyGrokSubagentUpdate(emptyGrokWorkflowTrackState(), withTokens!); + const afterTools = applyGrokSubagentUpdate(afterTokens.state, toolsOnly!); + const afterFinish = applyGrokSubagentUpdate(afterTools.state, finishedDurationOnly!); + expect(afterTools.events.at(-1)?.payload.typedUsage).toEqual({ + totalTokens: 90, + toolUses: 3, + }); + expect(afterFinish.events[0]?.payload.typedUsage).toEqual({ + totalTokens: 90, + durationMs: 1200, + toolUses: 3, + }); + }); +}); diff --git a/apps/server/src/provider/acp/GrokAcpWorkflow.ts b/apps/server/src/provider/acp/GrokAcpWorkflow.ts new file mode 100644 index 000000000000..d22e580e890c --- /dev/null +++ b/apps/server/src/provider/acp/GrokAcpWorkflow.ts @@ -0,0 +1,560 @@ +import type { RuntimeTaskStatus } from "@t3tools/contracts"; + +/** + * Pure mapping of Grok Build `x.ai/session_notification` updates onto T3's + * shared task.* surface. Claude stamps workflow members with parentAgentId + + * timelineBypass and a stable slot id; Codex does the same for collab + * children. Grok must not invent a third shape. + */ + +export interface GrokWorkflowPhase { + readonly title: string; + readonly state: string; +} + +export interface GrokWorkflowAgent { + readonly agentId: string; + readonly label: string; + readonly phase: string | undefined; + readonly model: string | undefined; + readonly state: string; + readonly tokensUsed: number; + readonly durationMs: number; +} + +export interface GrokWorkflowUpdated { + readonly runId: string; + readonly revision: number; + readonly name: string; + readonly objective: string; + readonly status: string; + readonly phases: ReadonlyArray; + readonly currentPhase: string | undefined; + readonly agentBudget: number | undefined; + readonly agentsUsed: number | undefined; + readonly elapsedMs: number | undefined; + readonly activeAgents: number | undefined; + readonly currentAgentLabel: string | undefined; + readonly agents: ReadonlyArray; + readonly pauseMessage: string | undefined; + readonly resultSummary: string | undefined; +} + +export interface GrokSubagentUpdate { + readonly kind: "spawned" | "progress" | "finished"; + readonly subagentId: string; + readonly childSessionId: string | undefined; + readonly parentSessionId: string | undefined; + readonly role: string | undefined; + readonly status: string | undefined; + readonly error: string | undefined; + readonly tokensUsed: number | undefined; + readonly durationMs: number | undefined; + readonly turnCount: number | undefined; + readonly toolCallCount: number | undefined; + readonly output: string | undefined; +} + +export interface GrokTypedUsageSnapshot { + readonly totalTokens: number; + readonly durationMs?: number; + readonly toolUses?: number; +} + +export interface GrokWorkflowTrackState { + readonly seenRunIds: ReadonlySet; + readonly completedRunIds: ReadonlySet; + readonly seenMemberIds: ReadonlySet; + readonly completedMemberIds: ReadonlySet; + readonly memberFingerprints: ReadonlyMap; + readonly seenSubagentIds: ReadonlySet; + readonly completedSubagentIds: ReadonlySet; + /** Last published usage per task id so a tool-only tick cannot zero tokens. */ + readonly usageByTaskId: ReadonlyMap; +} + +export function emptyGrokWorkflowTrackState(): GrokWorkflowTrackState { + return { + seenRunIds: new Set(), + completedRunIds: new Set(), + seenMemberIds: new Set(), + completedMemberIds: new Set(), + memberFingerprints: new Map(), + seenSubagentIds: new Set(), + completedSubagentIds: new Set(), + usageByTaskId: new Map(), + }; +} + +export interface GrokTaskEventSpec { + readonly type: "task.started" | "task.progress" | "task.completed"; + readonly payload: Record; +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function nonNegativeInt(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return undefined; + } + return Math.trunc(value); +} + +function sessionUpdateTag(update: Record): string | undefined { + return readString(update.sessionUpdate) ?? readString(update.session_update); +} + +function unwrapSessionUpdate(payload: unknown): Record | undefined { + const envelope = asRecord(payload); + return asRecord(envelope?.update) ?? envelope; +} + +export function parseXAiWorkflowUpdated(payload: unknown): GrokWorkflowUpdated | undefined { + const update = unwrapSessionUpdate(payload); + if (!update) { + return undefined; + } + const tag = sessionUpdateTag(update); + if (tag !== undefined && tag !== "workflow_updated" && tag !== "WorkflowUpdated") { + return undefined; + } + const runId = readString(update.run_id) ?? readString(update.runId); + const name = readString(update.name); + if (runId === undefined || name === undefined) { + return undefined; + } + if ( + tag === undefined && + (readString(update.status) === undefined || !Array.isArray(update.phases)) + ) { + return undefined; + } + const phases = Array.isArray(update.phases) + ? update.phases.flatMap((entry): ReadonlyArray => { + const record = asRecord(entry); + const title = readString(record?.title); + const state = readString(record?.state); + return title && state ? [{ title, state }] : []; + }) + : []; + const agents = Array.isArray(update.agents) + ? update.agents.flatMap((entry): ReadonlyArray => { + const record = asRecord(entry); + const agentId = readString(record?.agent_id) ?? readString(record?.agentId); + const state = readString(record?.state); + if (agentId === undefined || state === undefined) { + return []; + } + return [ + { + agentId, + label: readString(record?.label) ?? agentId, + phase: readString(record?.phase), + model: readString(record?.model), + state, + tokensUsed: nonNegativeInt(record?.tokens_used ?? record?.tokensUsed) ?? 0, + durationMs: nonNegativeInt(record?.duration_ms ?? record?.durationMs) ?? 0, + }, + ]; + }) + : []; + return { + runId, + revision: nonNegativeInt(update.revision) ?? 0, + name, + objective: readString(update.objective) ?? "", + status: readString(update.status) ?? "active", + phases, + currentPhase: readString(update.current_phase) ?? readString(update.currentPhase), + agentBudget: nonNegativeInt(update.agent_budget ?? update.agentBudget), + agentsUsed: nonNegativeInt(update.agents_used ?? update.agentsUsed), + elapsedMs: nonNegativeInt(update.elapsed_ms ?? update.elapsedMs), + activeAgents: nonNegativeInt(update.active_agents ?? update.activeAgents), + currentAgentLabel: + readString(update.current_agent_label) ?? readString(update.currentAgentLabel), + agents, + pauseMessage: readString(update.pause_message) ?? readString(update.pauseMessage), + resultSummary: readString(update.result_summary) ?? readString(update.resultSummary), + }; +} + +export function parseXAiSubagentUpdate(payload: unknown): GrokSubagentUpdate | undefined { + const update = unwrapSessionUpdate(payload); + if (!update) { + return undefined; + } + const tag = sessionUpdateTag(update); + const kind = + tag === "subagent_spawned" || tag === "SubagentSpawned" + ? "spawned" + : tag === "subagent_progress" || tag === "SubagentProgress" + ? "progress" + : tag === "subagent_finished" || tag === "SubagentFinished" + ? "finished" + : undefined; + if (kind === undefined) { + return undefined; + } + const subagentId = readString(update.subagent_id) ?? readString(update.subagentId); + if (subagentId === undefined) { + return undefined; + } + return { + kind, + subagentId, + childSessionId: readString(update.child_session_id) ?? readString(update.childSessionId), + parentSessionId: readString(update.parent_session_id) ?? readString(update.parentSessionId), + role: + readString(update.subagent_type) ?? + readString(update.subagentType) ?? + readString(update.agent_type) ?? + readString(update.agentType), + status: readString(update.status), + error: readString(update.error), + tokensUsed: nonNegativeInt(update.tokens_used ?? update.tokensUsed), + durationMs: nonNegativeInt(update.duration_ms ?? update.durationMs), + turnCount: nonNegativeInt(update.turn_count ?? update.turnCount ?? update.turns), + toolCallCount: nonNegativeInt( + update.tool_call_count ?? update.toolCallCount ?? update.tool_calls ?? update.toolCalls, + ), + output: readString(update.output), + }; +} + +export function grokWorkflowRunStatus(status: string): RuntimeTaskStatus { + switch (status) { + case "active": + return "running"; + case "complete": + return "completed"; + case "failed": + return "failed"; + case "cancelled": + case "interrupted": + case "cleared": + return "cancelled"; + default: + return "idle"; + } +} + +export function grokWorkflowAgentStatus(state: string): RuntimeTaskStatus { + switch (state) { + case "queued": + case "pending": + return "pending"; + case "running": + case "start": + return "running"; + case "done": + case "completed": + return "completed"; + case "failed": + case "error": + return "failed"; + case "cancelled": + return "cancelled"; + default: + return "running"; + } +} + +export function grokWorkflowRunIsTerminal(status: string): boolean { + return ( + status === "complete" || + status === "failed" || + status === "cancelled" || + status === "interrupted" || + status === "cleared" + ); +} + +export function grokWorkflowAgentIsTerminal(state: string): boolean { + return ( + state === "done" || + state === "completed" || + state === "failed" || + state === "error" || + state === "cancelled" + ); +} + +export function grokWorkflowMemberTaskId(runId: string, agentId: string): string { + return `${runId}:wf:${agentId}`; +} + +function memberFingerprint(agent: GrokWorkflowAgent, status: RuntimeTaskStatus): string { + return [ + status, + agent.label, + agent.model ?? "", + agent.phase ?? "", + agent.tokensUsed, + agent.durationMs, + ].join("\u001f"); +} + +function runCompletedStatus(status: string): "completed" | "failed" | "stopped" { + if (status === "failed") return "failed"; + if (status === "complete") return "completed"; + return "stopped"; +} + +function agentCompletedStatus(state: string): "completed" | "failed" | "stopped" { + if (state === "failed" || state === "error") return "failed"; + if (state === "cancelled") return "stopped"; + return "completed"; +} + +function mergeTypedUsageFromCounts( + input: { + readonly tokensUsed?: number | undefined; + readonly durationMs?: number | undefined; + readonly toolCallCount?: number | undefined; + }, + previous: GrokTypedUsageSnapshot | undefined, +): GrokTypedUsageSnapshot | undefined { + if ( + input.tokensUsed === undefined && + input.durationMs === undefined && + input.toolCallCount === undefined + ) { + return previous; + } + // RuntimeTaskUsage requires totalTokens. A later tool/duration-only tick + // must reuse the last known count; `?? 0` would replace the task-usage row. + const durationMs = input.durationMs ?? previous?.durationMs; + const toolUses = input.toolCallCount ?? previous?.toolUses; + return { + totalTokens: input.tokensUsed ?? previous?.totalTokens ?? 0, + ...(durationMs !== undefined ? { durationMs } : {}), + ...(toolUses !== undefined ? { toolUses } : {}), + }; +} + +export function applyGrokWorkflowUpdate( + state: GrokWorkflowTrackState, + update: GrokWorkflowUpdated, +): { readonly state: GrokWorkflowTrackState; readonly events: ReadonlyArray } { + const seenRunIds = new Set(state.seenRunIds); + const completedRunIds = new Set(state.completedRunIds); + const seenMemberIds = new Set(state.seenMemberIds); + const completedMemberIds = new Set(state.completedMemberIds); + const memberFingerprints = new Map(state.memberFingerprints); + const usageByTaskId = new Map(state.usageByTaskId); + const events: Array = []; + + const phases = update.phases.map((phase, index) => ({ index, title: phase.title })); + const currentPhaseIndex = update.currentPhase + ? update.phases.findIndex((phase) => phase.title === update.currentPhase) + : -1; + const runStatus = grokWorkflowRunStatus(update.status); + const runSeen = seenRunIds.has(update.runId); + if (!runSeen) { + seenRunIds.add(update.runId); + events.push({ + type: "task.started", + payload: { + taskId: update.runId, + description: update.objective || update.name, + taskType: "local_workflow", + workflowName: update.name, + title: update.name, + ...(phases.length > 0 ? { phases } : {}), + ...(currentPhaseIndex >= 0 ? { phaseIndex: currentPhaseIndex } : {}), + ...(update.currentPhase ? { phaseTitle: update.currentPhase } : {}), + runHandles: { runId: update.runId }, + }, + }); + } else if (!completedRunIds.has(update.runId)) { + events.push({ + type: "task.progress", + payload: { + taskId: update.runId, + description: update.objective || update.name, + summary: update.currentAgentLabel ?? update.currentPhase ?? update.status, + status: runStatus, + taskType: "local_workflow", + workflowName: update.name, + title: update.name, + ...(phases.length > 0 ? { phases } : {}), + ...(currentPhaseIndex >= 0 ? { phaseIndex: currentPhaseIndex } : {}), + ...(update.currentPhase ? { phaseTitle: update.currentPhase } : {}), + runHandles: { runId: update.runId }, + }, + }); + } + + if (grokWorkflowRunIsTerminal(update.status) && !completedRunIds.has(update.runId)) { + completedRunIds.add(update.runId); + events.push({ + type: "task.completed", + payload: { + taskId: update.runId, + status: runCompletedStatus(update.status), + summary: update.resultSummary ?? update.pauseMessage ?? update.status, + taskType: "local_workflow", + workflowName: update.name, + title: update.name, + ...(phases.length > 0 ? { phases } : {}), + runHandles: { runId: update.runId }, + }, + }); + } + + for (const [agentIndex, agent] of update.agents.entries()) { + const memberId = grokWorkflowMemberTaskId(update.runId, agent.agentId); + const status = grokWorkflowAgentStatus(agent.state); + const fingerprint = memberFingerprint(agent, status); + if (memberFingerprints.get(memberId) === fingerprint) { + continue; + } + memberFingerprints.set(memberId, fingerprint); + const memberSeen = seenMemberIds.has(memberId); + const linkage = { + taskId: memberId, + description: agent.label, + taskType: "subagent", + parentAgentId: update.runId, + title: agent.label, + workflowName: update.name, + ...(agent.model ? { model: agent.model } : {}), + ...(agent.phase ? { phaseTitle: agent.phase } : {}), + agentIndex, + timelineBypass: true, + }; + if (!memberSeen) { + seenMemberIds.add(memberId); + events.push({ type: "task.started", payload: linkage }); + } + const typedUsage = mergeTypedUsageFromCounts( + { + tokensUsed: agent.tokensUsed > 0 ? agent.tokensUsed : undefined, + durationMs: agent.durationMs > 0 ? agent.durationMs : undefined, + }, + usageByTaskId.get(memberId), + ); + if (typedUsage) { + usageByTaskId.set(memberId, typedUsage); + } + events.push({ + type: "task.progress", + payload: { + ...linkage, + summary: agent.state, + status, + ...(typedUsage ? { typedUsage } : {}), + }, + }); + if (grokWorkflowAgentIsTerminal(agent.state) && !completedMemberIds.has(memberId)) { + completedMemberIds.add(memberId); + events.push({ + type: "task.completed", + payload: { + ...linkage, + status: agentCompletedStatus(agent.state), + summary: agent.label, + ...(typedUsage ? { typedUsage } : {}), + }, + }); + } + } + + return { + state: { + ...state, + seenRunIds, + completedRunIds, + seenMemberIds, + completedMemberIds, + memberFingerprints, + usageByTaskId, + }, + events, + }; +} + +export function applyGrokSubagentUpdate( + state: GrokWorkflowTrackState, + update: GrokSubagentUpdate, +): { readonly state: GrokWorkflowTrackState; readonly events: ReadonlyArray } { + const seenSubagentIds = new Set(state.seenSubagentIds); + const completedSubagentIds = new Set(state.completedSubagentIds); + const usageByTaskId = new Map(state.usageByTaskId); + const events: Array = []; + const title = update.role ?? update.subagentId; + const linkage = { + taskId: update.subagentId, + description: title, + title, + taskType: "subagent", + role: update.role ?? "general-purpose", + ...(update.parentSessionId ? { parentAgentId: update.parentSessionId } : {}), + ...(update.childSessionId ? { agentPath: update.childSessionId } : {}), + timelineBypass: true, + }; + + if (update.kind === "spawned" && !seenSubagentIds.has(update.subagentId)) { + seenSubagentIds.add(update.subagentId); + events.push({ type: "task.started", payload: linkage }); + } else if (update.kind === "progress") { + if (!seenSubagentIds.has(update.subagentId)) { + seenSubagentIds.add(update.subagentId); + events.push({ type: "task.started", payload: linkage }); + } + if (!completedSubagentIds.has(update.subagentId)) { + const typedUsage = mergeTypedUsageFromCounts(update, usageByTaskId.get(update.subagentId)); + if (typedUsage) { + usageByTaskId.set(update.subagentId, typedUsage); + } + events.push({ + type: "task.progress", + payload: { + ...linkage, + status: "running", + summary: update.role ?? "running", + ...(typedUsage ? { typedUsage } : {}), + }, + }); + } + } else if (update.kind === "finished" && !completedSubagentIds.has(update.subagentId)) { + if (!seenSubagentIds.has(update.subagentId)) { + seenSubagentIds.add(update.subagentId); + events.push({ type: "task.started", payload: linkage }); + } + completedSubagentIds.add(update.subagentId); + const finished = update.status ?? "completed"; + const typedUsage = mergeTypedUsageFromCounts(update, usageByTaskId.get(update.subagentId)); + if (typedUsage) { + usageByTaskId.set(update.subagentId, typedUsage); + } + events.push({ + type: "task.completed", + payload: { + ...linkage, + status: + finished === "failed" ? "failed" : finished === "cancelled" ? "stopped" : "completed", + summary: update.error ?? update.output ?? finished, + ...(typedUsage ? { typedUsage } : {}), + }, + }); + } + + return { + state: { + ...state, + seenSubagentIds, + completedSubagentIds, + usageByTaskId, + }, + events, + }; +} diff --git a/apps/server/src/provider/acp/GrokWorkflowCommands.test.ts b/apps/server/src/provider/acp/GrokWorkflowCommands.test.ts new file mode 100644 index 000000000000..b53049117749 --- /dev/null +++ b/apps/server/src/provider/acp/GrokWorkflowCommands.test.ts @@ -0,0 +1,149 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { + GROK_WORKFLOW_CONTROL_COMMANDS, + grokWorkflowHomeDirFromEnvironment, + parseGrokWorkflowScriptMeta, + readGrokWorkflowSlashCommands, +} from "./GrokWorkflowCommands.ts"; + +describe("grokWorkflowHomeDirFromEnvironment", () => { + it("prefers HOME over USERPROFILE", () => { + expect( + grokWorkflowHomeDirFromEnvironment({ + HOME: "/home/ada", + USERPROFILE: "C:\\Users\\ada", + }), + ).toBe("/home/ada"); + }); + + it("uses USERPROFILE when HOME is unset", () => { + expect(grokWorkflowHomeDirFromEnvironment({ USERPROFILE: "C:\\Users\\ada" })).toBe( + "C:\\Users\\ada", + ); + }); + + it("ignores blank HOME so Windows profiles still resolve", () => { + expect( + grokWorkflowHomeDirFromEnvironment({ + HOME: " ", + USERPROFILE: "C:\\Users\\ada", + }), + ).toBe("C:\\Users\\ada"); + }); +}); + +describe("parseGrokWorkflowScriptMeta", () => { + it("reads name and description from the Rhai meta block", () => { + const meta = parseGrokWorkflowScriptMeta( + `let meta = #{ + name: "review-changes", + description: "Review the latest diff" +}; +agent("review", "look at the diff") +`, + ); + expect(meta).toEqual({ + name: "review-changes", + description: "Review the latest diff", + }); + }); + + it("falls back to the filename when meta has no name", () => { + expect(parseGrokWorkflowScriptMeta('agent("hello", "there")', "t1")).toEqual({ + name: "t1", + description: undefined, + }); + }); + + it("rejects path-like names", () => { + expect( + parseGrokWorkflowScriptMeta(`let meta = #{ name: "../escape" };`, "safe"), + ).toBeUndefined(); + }); +}); + +it.layer(NodeServices.layer)("readGrokWorkflowSlashCommands", (it) => { + it.effect("includes pause/resume/stop and project scripts override user scripts", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "grok-wf-" }); + const home = path.join(root, "home"); + const project = path.join(root, "project"); + yield* fs.makeDirectory(path.join(home, ".grok", "workflows"), { recursive: true }); + yield* fs.makeDirectory(path.join(project, ".grok", "workflows"), { recursive: true }); + yield* fs.writeFileString( + path.join(home, ".grok", "workflows", "review-changes.rhai"), + `let meta = #{ name: "review-changes", description: "user copy" };\n`, + ); + yield* fs.writeFileString( + path.join(project, ".grok", "workflows", "review-changes.rhai"), + `let meta = #{ name: "review-changes", description: "project copy" };\n`, + ); + yield* fs.writeFileString( + path.join(project, ".grok", "workflows", "nowah-web-e2e.rhai"), + `let meta = #{ name: "nowah-web-e2e", description: "Write locked Playwright specs" };\n`, + ); + + const commands = yield* readGrokWorkflowSlashCommands({ + homeDir: home, + projectRoot: project, + }); + expect(commands.slice(0, 3)).toEqual([...GROK_WORKFLOW_CONTROL_COMMANDS]); + expect(commands).toContainEqual({ + name: "workflow review-changes", + description: "project copy", + }); + expect(commands).toContainEqual({ + name: "workflow nowah-web-e2e", + description: "Write locked Playwright specs", + }); + }), + ), + ); + + it.effect("reads only the capped prefix of an oversized workflow script", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "grok-wf-cap-" }); + yield* fs.makeDirectory(path.join(root, ".grok", "workflows"), { recursive: true }); + yield* fs.writeFileString( + path.join(root, ".grok", "workflows", "huge.rhai"), + `let meta = #{ name: "huge", description: "from prefix" };\n` + "x".repeat(80 * 1024), + ); + const commands = yield* readGrokWorkflowSlashCommands({ homeDir: root }); + expect(commands).toContainEqual({ + name: "workflow huge", + description: "from prefix", + }); + }), + ), + ); + + it.effect("does not parse workflow meta past the 64 KiB prefix", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "grok-wf-cap-tail-" }); + yield* fs.makeDirectory(path.join(root, ".grok", "workflows"), { recursive: true }); + yield* fs.writeFileString( + path.join(root, ".grok", "workflows", "late.rhai"), + "x".repeat(80 * 1024) + `\nlet meta = #{ name: "late", description: "after prefix" };\n`, + ); + const commands = yield* readGrokWorkflowSlashCommands({ homeDir: root }); + expect(commands.find((command) => command.name === "workflow late")?.description).not.toBe( + "after prefix", + ); + }), + ), + ); +}); diff --git a/apps/server/src/provider/acp/GrokWorkflowCommands.ts b/apps/server/src/provider/acp/GrokWorkflowCommands.ts new file mode 100644 index 000000000000..5d88fd99307a --- /dev/null +++ b/apps/server/src/provider/acp/GrokWorkflowCommands.ts @@ -0,0 +1,139 @@ +import type { ServerProviderSlashCommand } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +const SCRIPT_BYTE_CAP = 64 * 1024; + +export const GROK_WORKFLOW_CONTROL_COMMANDS: ReadonlyArray = [ + { + name: "workflow pause", + description: "Pause the active Grok workflow run", + }, + { + name: "workflow resume", + description: "Resume a paused Grok workflow run", + }, + { + name: "workflow stop", + description: "Stop a Grok workflow run", + input: { hint: "run name" }, + }, +]; + +export interface GrokWorkflowScriptMeta { + readonly name: string; + readonly description: string | undefined; +} + +function trimmed(value: string | undefined): string | undefined { + const text = value?.trim(); + return text && text.length > 0 ? text : undefined; +} + +/** HOME on Unix; USERPROFILE when HOME is unset (Windows / stripped instance env). */ +export function grokWorkflowHomeDirFromEnvironment( + environment: NodeJS.ProcessEnv, +): string | undefined { + return trimmed(environment.HOME) ?? trimmed(environment.USERPROFILE); +} + +function quotedField(source: string, field: string): string | undefined { + const match = source.match(new RegExp(`\\b${field}\\s*:\\s*"([^"]+)"`)); + return trimmed(match?.[1]); +} + +export function parseGrokWorkflowScriptMeta( + source: string, + fallbackName?: string, +): GrokWorkflowScriptMeta | undefined { + const block = source.match(/let\s+meta\s*=\s*#\{([\s\S]*?)\};/); + const scope = block?.[1] ?? source; + const name = quotedField(scope, "name") ?? trimmed(fallbackName); + if (name === undefined || name.includes("/") || name.includes("\\")) { + return undefined; + } + return { + name, + description: quotedField(scope, "description"), + }; +} + +export function grokWorkflowSlashCommandFromMeta( + meta: GrokWorkflowScriptMeta, +): ServerProviderSlashCommand { + return { + name: `workflow ${meta.name}`, + ...(meta.description + ? { description: meta.description } + : { description: `Launch ${meta.name}` }), + }; +} + +const readWorkflowDir = Effect.fn("grok.readWorkflowDir")(function* ( + dir: string, + byName: Map, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const entries = yield* fileSystem + .readDirectory(dir) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + + for (const entry of entries) { + if (!entry.endsWith(".rhai")) { + continue; + } + const filePath = path.join(dir, entry); + const info = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => undefined)); + if (!info || info.type !== "File" || info.size <= 0n) { + continue; + } + const length = info.size > BigInt(SCRIPT_BYTE_CAP) ? SCRIPT_BYTE_CAP : Number(info.size); + const bytes = yield* Effect.scoped( + fileSystem + .open(filePath, { flag: "r" }) + .pipe(Effect.flatMap((file) => file.readAlloc(length))), + ).pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isNone(bytes)) { + continue; + } + const source = new TextDecoder().decode(bytes.value); + const fallbackName = entry.endsWith(".rhai") ? entry.slice(0, -".rhai".length) : entry; + const meta = parseGrokWorkflowScriptMeta(source, fallbackName); + if (!meta) { + continue; + } + const command = grokWorkflowSlashCommandFromMeta(meta); + byName.set(command.name, command); + } +}); + +/** + * Built-in `/workflow pause|resume|stop` plus `~/.grok/workflows` and + * `/.grok/workflows` scripts. Project scripts override user scripts + * of the same command name. T3 sends the slash text as a prompt — it does not + * host Rhai. + */ +export const readGrokWorkflowSlashCommands = Effect.fn("grok.readWorkflowSlashCommands")( + function* (input: { + readonly projectRoot?: string | undefined; + readonly homeDir?: string | undefined; + }) { + const path = yield* Path.Path; + const byName = new Map(); + for (const command of GROK_WORKFLOW_CONTROL_COMMANDS) { + byName.set(command.name, command); + } + const homeDir = trimmed(input.homeDir) ?? grokWorkflowHomeDirFromEnvironment(process.env); + if (homeDir) { + yield* readWorkflowDir(path.join(homeDir, ".grok", "workflows"), byName); + } + const projectRoot = trimmed(input.projectRoot); + if (projectRoot && path.isAbsolute(projectRoot)) { + yield* readWorkflowDir(path.join(projectRoot, ".grok", "workflows"), byName); + } + return [...byName.values()]; + }, +); diff --git a/apps/server/src/provider/acp/XAiAcpExtension.test.ts b/apps/server/src/provider/acp/XAiAcpExtension.test.ts index c435269fd76d..d8e76cc6fdb8 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.test.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.test.ts @@ -9,12 +9,19 @@ import * as Schema from "effect/Schema"; import { describe, expect } from "vite-plus/test"; import { + extractGrokTokenUsage, extractXAiAskUserQuestions, + grokPromptCount, + grokPromptCountForTurns, + grokRewindFailureDetail, + grokRewindTargetKeepingPromptCount, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, makeXAiPromptCompletionRuntime, + parseGrokRewindPoints, XAiAskUserQuestionRequest, } from "./XAiAcpExtension.ts"; +import { grokWorkflowRunStatus, parseXAiWorkflowUpdated } from "./GrokAcpWorkflow.ts"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); @@ -330,3 +337,128 @@ describe("XAiAcpExtension", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); }); + +describe("Grok rewind and usage helpers", () => { + it("picks the rewind target so Grok keeps the remaining local prompts", () => { + const points = parseGrokRewindPoints({ + rewind_points: [ + { prompt_index: 0, prompt_preview: "first" }, + { prompt_index: 1, prompt_preview: "second" }, + { prompt_index: 2, prompt_preview: "third" }, + ], + }); + expect(grokRewindTargetKeepingPromptCount(points, 2)?.promptIndex).toBe(2); + expect(grokRewindTargetKeepingPromptCount(points, 1)?.promptIndex).toBe(1); + expect(grokRewindTargetKeepingPromptCount(points, 0)?.promptIndex).toBe(0); + expect(grokRewindTargetKeepingPromptCount(points, 3)).toBeUndefined(); + expect(grokPromptCount([{ items: [1] }, { items: [2, 3] }])).toBe(3); + expect(grokPromptCountForTurns([{ items: [1] }, { items: [2, 3] }], 1)).toBe(2); + }); + + it("discards a cancelled-prompt ghost with the rest of the dropped history", () => { + const points = parseGrokRewindPoints({ + rewind_points: [ + { prompt_index: 0, prompt_preview: "first" }, + { prompt_index: 1, prompt_preview: "second" }, + { prompt_index: 2, prompt_preview: "cancelled-ghost" }, + ], + }); + // Two completed local turns, rewind one: keep prompt 0, drop local turn 2 + // and the ghost that landed after cancel. End-relative targeting would + // keep prompt 1 on Grok. + expect(grokRewindTargetKeepingPromptCount(points, 1)?.promptIndex).toBe(1); + }); + + it("keeps rewind failure detail bounded and includes the provider error", () => { + expect(grokRewindFailureDetail(null)).toBe("Grok rewind did not succeed."); + expect(grokRewindFailureDetail("target is stale")).toBe( + "Grok rewind did not succeed. target is stale", + ); + expect(grokRewindFailureDetail(` ${"x".repeat(400)} `).length).toBeLessThanOrEqual( + "Grok rewind did not succeed. ".length + 240, + ); + }); + + it("reads Grok token usage from prompt _meta", () => { + expect( + extractGrokTokenUsage({ + usage: { input_tokens: 10, output_tokens: 4, reasoning_tokens: 3 }, + }), + ).toMatchObject({ + usedTokens: 17, + inputTokens: 10, + outputTokens: 4, + reasoningOutputTokens: 3, + }); + }); + + it("reads Grok Build PromptUsage totals and cache-read tokens", () => { + expect( + extractGrokTokenUsage({ + usage: { + inputTokens: 20, + outputTokens: 5, + cached_read_tokens: 8, + totals: { inputTokens: 20, outputTokens: 5, cachedReadTokens: 8 }, + }, + }), + ).toMatchObject({ + usedTokens: 25, + inputTokens: 20, + outputTokens: 5, + cachedInputTokens: 8, + }); + }); +}); + +describe("Grok workflow notifications", () => { + it("parses the official workflow_updated ACP envelope", () => { + const update = parseXAiWorkflowUpdated({ + sessionId: "sess-1", + update: { + sessionUpdate: "workflow_updated", + run_id: "wf_review_1", + name: "review-changes", + objective: "Review the latest diff", + status: "active", + phases: [ + { title: "Plan", state: "done" }, + { title: "Execute", state: "active" }, + ], + current_phase: "Execute", + elapsed_ms: 1200, + agents: [ + { + agent_id: "agent_reviewer", + label: "Reviewer", + state: "running", + tokens_used: 42, + duration_ms: 800, + }, + ], + }, + }); + expect(update).toMatchObject({ + runId: "wf_review_1", + name: "review-changes", + status: "active", + currentPhase: "Execute", + }); + expect(update?.phases).toHaveLength(2); + expect(update?.agents[0]).toMatchObject({ + agentId: "agent_reviewer", + tokensUsed: 42, + }); + expect(grokWorkflowRunStatus("active")).toBe("running"); + expect(grokWorkflowRunStatus("complete")).toBe("completed"); + }); + + it("ignores non-workflow session notifications", () => { + expect( + parseXAiWorkflowUpdated({ + sessionId: "sess-1", + update: { sessionUpdate: "model_changed", model_id: "grok-4.6" }, + }), + ).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts index d36a5fcfc895..2a8c853be26b 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -1,4 +1,8 @@ -import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; +import type { + ProviderUserInputAnswers, + ThreadTokenUsageSnapshot, + UserInputQuestion, +} from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Ref from "effect/Ref"; @@ -430,3 +434,250 @@ function normalizeXAiStopReason(value: string | undefined): EffectAcpSchema.Stop return "end_turn"; } } + +export interface GrokRewindPoint { + readonly promptIndex: number; + readonly promptPreview: string; +} + +export interface GrokRewindExecuteResult { + readonly success: boolean; + readonly error: string | null; +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function unwrapExtResult(value: unknown): unknown { + const record = asRecord(value); + return record && "result" in record ? record.result : value; +} + +function nonNegativeInt(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return undefined; + } + return Math.trunc(value); +} + +/** Parses `_x.ai/rewind/points` into chronological prompt indexes. */ +export function parseGrokRewindPoints(payload: unknown): ReadonlyArray { + const unwrapped = unwrapExtResult(payload); + const list = Array.isArray(unwrapped) + ? unwrapped + : (asRecord(unwrapped)?.rewind_points ?? + asRecord(unwrapped)?.rewindPoints ?? + asRecord(unwrapped)?.points); + if (!Array.isArray(list)) { + return []; + } + return list.flatMap((entry) => { + const record = asRecord(entry); + if (!record) { + return []; + } + const promptIndex = nonNegativeInt(record.prompt_index ?? record.promptIndex); + if (promptIndex === undefined) { + return []; + } + const preview = + (typeof record.prompt_preview === "string" ? record.prompt_preview : undefined) ?? + (typeof record.promptPreview === "string" ? record.promptPreview : undefined) ?? + ""; + if ( + /^\s*/.test(preview) || + /^\s*\[Plan (approved|rejected|cancelled)\]\s*$/i.test(preview.trim()) + ) { + return []; + } + return [{ promptIndex, promptPreview: preview }]; + }); +} + +export function grokPromptCount( + turns: ReadonlyArray<{ readonly items: ReadonlyArray }>, +): number { + return turns.reduce((count, turn) => count + turn.items.length, 0); +} + +export function grokPromptCountForTurns( + turns: ReadonlyArray<{ readonly items: ReadonlyArray }>, + numTurns: number, +): number { + if (!Number.isInteger(numTurns) || numTurns < 1) { + return 0; + } + return grokPromptCount(turns.slice(-numTurns)); +} + +function orderedGrokRewindPoints( + points: ReadonlyArray, +): ReadonlyArray { + return [...points].sort((left, right) => left.promptIndex - right.promptIndex); +} + +/** + * First rewind point to discard so Grok keeps `keepPromptCount` user prompts. + * Execute drops the target and everything after it, including extra points from + * a cancelled in-flight prompt that still landed on the list. + */ +export function grokRewindTargetKeepingPromptCount( + points: ReadonlyArray, + keepPromptCount: number, +): GrokRewindPoint | undefined { + if (!Number.isInteger(keepPromptCount) || keepPromptCount < 0) { + return undefined; + } + const ordered = orderedGrokRewindPoints(points); + if (keepPromptCount >= ordered.length) { + return undefined; + } + return ordered[keepPromptCount]; +} + +export function parseGrokRewindExecute(payload: unknown): GrokRewindExecuteResult | undefined { + const record = asRecord(unwrapExtResult(payload)); + if (!record || typeof record.success !== "boolean") { + return undefined; + } + return { + success: record.success, + error: + typeof record.error === "string" + ? record.error + : record.error == null + ? null + : String(record.error), + }; +} + +export const GROK_REWIND_FAILURE_DETAIL = "Grok rewind did not succeed."; + +/** Stable, bounded rewind failure text. The raw provider error stays in `cause`. */ +export function grokRewindFailureDetail(error: string | null | undefined): string { + const clipped = error?.replace(/\s+/g, " ").trim().slice(0, 240) ?? ""; + return clipped.length > 0 + ? `${GROK_REWIND_FAILURE_DETAIL} ${clipped}` + : GROK_REWIND_FAILURE_DETAIL; +} + +function readTokenCount(...values: ReadonlyArray): number | undefined { + for (const value of values) { + const parsed = nonNegativeInt(value); + if (parsed !== undefined) { + return parsed; + } + } + return undefined; +} + +function usageRecordFromUnknown(value: unknown): Record | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + if (isRecord(record.usage)) { + return usageRecordFromUnknown(record.usage); + } + if (isRecord(record.tokenUsage)) { + return usageRecordFromUnknown(record.tokenUsage); + } + if (isRecord(record.token_usage)) { + return usageRecordFromUnknown(record.token_usage); + } + if (isRecord(record.agentResult)) { + return usageRecordFromUnknown(record.agentResult); + } + // Grok Build PromptUsage flattens totals onto the object, but older + // envelopes nest them. Merge so both shapes read the same fields. + if (isRecord(record.totals)) { + return { ...record.totals, ...record }; + } + return record; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Pulls a T3 usage snapshot from a Grok prompt result or prompt-complete payload. */ +export function extractGrokTokenUsage( + payload: unknown, + maxTokens?: number, +): ThreadTokenUsageSnapshot | undefined { + const usage = usageRecordFromUnknown(payload); + if (!usage) { + return undefined; + } + + const inputTokens = readTokenCount( + usage.inputTokens, + usage.input_tokens, + usage.promptTokens, + usage.prompt_tokens, + ); + const outputTokens = readTokenCount( + usage.outputTokens, + usage.output_tokens, + usage.completionTokens, + usage.completion_tokens, + ); + const reasoningOutputTokens = readTokenCount( + usage.reasoningOutputTokens, + usage.reasoning_tokens, + usage.reasoningTokens, + ); + const cachedInputTokens = readTokenCount( + usage.cachedInputTokens, + usage.cache_read_input_tokens, + usage.cacheReadInputTokens, + usage.cached_read_tokens, + usage.cachedReadTokens, + ); + const usedTokens = readTokenCount( + usage.usedTokens, + usage.used_tokens, + usage.totalTokens, + usage.total_tokens, + ); + + const inferredUsed = + usedTokens ?? + (inputTokens !== undefined || outputTokens !== undefined + ? (inputTokens ?? 0) + (outputTokens ?? 0) + (reasoningOutputTokens ?? 0) + : undefined); + if (inferredUsed === undefined || inferredUsed <= 0) { + return undefined; + } + + return { + usedTokens: inferredUsed, + ...(inputTokens !== undefined ? { inputTokens } : {}), + ...(outputTokens !== undefined ? { outputTokens } : {}), + ...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}), + ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}), + ...(maxTokens !== undefined ? { maxTokens } : {}), + lastUsedTokens: inferredUsed, + ...(inputTokens !== undefined ? { lastInputTokens: inputTokens } : {}), + ...(outputTokens !== undefined ? { lastOutputTokens: outputTokens } : {}), + ...(reasoningOutputTokens !== undefined + ? { lastReasoningOutputTokens: reasoningOutputTokens } + : {}), + ...(cachedInputTokens !== undefined ? { lastCachedInputTokens: cachedInputTokens } : {}), + }; +} + +/** + * Grok Build fires workflow and subagent progress as `x.ai/session_notification`. + * The update body is parsed by GrokAcpWorkflow. + */ +export const XAiSessionNotification = Schema.Struct({ + sessionId: Schema.optional(Schema.Unknown), + session_id: Schema.optional(Schema.Unknown), + update: Schema.Unknown, + _meta: Schema.optional(Schema.Unknown), +}); +export type XAiSessionNotification = typeof XAiSessionNotification.Type; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 0bf131ac973b..0d5e5ec1cbae 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -218,10 +218,12 @@ export const make = Effect.gen(function* () { const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent); const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome); const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex); + const grokHome = process.env.GROK_HOME?.trim() || path.join(NodeOS.homedir(), ".grok"); return [ { provider: "claude" as const, dir: claudeDir }, { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") }, + { provider: "grok" as const, dir: path.join(grokHome, "sessions") }, ]; }); @@ -373,7 +375,11 @@ export const make = Effect.gen(function* () { } walkedRoots.push(dir); - const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs)); + const listed = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs)); + const files = + provider === "grok" + ? listed.filter((file) => path.basename(file.path) === "updates.jsonl") + : listed; let scannedFiles = 0; let skippedFiles = 0; // Distinct per directory. Buckets carry per-cell session counts, but a diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index cc15ee9cee62..02daf5ebbd70 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -134,7 +134,7 @@ export function decodeScanCache(document: unknown): ScanCache { if (typeof raw !== "object" || raw === null) continue; const entry = raw as Partial; if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; - if (entry.p !== "claude" && entry.p !== "codex") continue; + if (entry.p !== "claude" && entry.p !== "codex" && entry.p !== "grok") continue; if (!isRecordArray(entry.r)) continue; const provider: UsageProviderKind = entry.p; diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index c72f0c24db65..9120796e8ea0 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -22,6 +22,7 @@ import { mightCarryUsage, parseClaudeLine, parseCodexLine, + parseGrokLine, type UsageRecord, } from "./usageTranscripts.ts"; @@ -129,6 +130,13 @@ export async function readTranscriptRecords( continue; } + if (provider === "grok") { + if (!mightCarryUsage(line, provider)) continue; + const record = parseGrokLine(line); + if (record !== null) records.push(record); + continue; + } + if (!mightCarryUsage(line, provider)) continue; const record = parseClaudeLine(line); if (record !== null) records.push(record); diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index 8f86a3d836bd..88a026e2cc3e 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from "@effect/vitest"; import { + GROK_COST_USD_TICKS_PER_DOLLAR, initialCodexScanState, + mightCarryUsage, parseClaudeLine, parseCodexLine, + parseGrokLine, totalTokens, } from "./usageTranscripts.ts"; @@ -236,6 +239,131 @@ describe("parseCodexLine", () => { }); }); +describe("parseGrokLine", () => { + function grokTurnCompleted(overrides?: { + promptId?: string; + incomplete?: boolean; + costUsdTicks?: number | null; + }): string { + return JSON.stringify({ + timestamp: "2026-08-15T03:57:36.535Z", + method: "_x.ai/session/update", + params: { + sessionId: "sess-1", + update: { + sessionUpdate: "turn_completed", + prompt_id: overrides?.promptId ?? "prompt-1", + stop_reason: "end_turn", + usage: { + inputTokens: 974_514, + outputTokens: 7_246, + totalTokens: 981_760, + cachedReadTokens: 847_360, + cacheCreationTokens: 0, + reasoningTokens: 4_743, + ...(overrides?.incomplete === true ? { usageIsIncomplete: true } : {}), + ...(overrides?.costUsdTicks === null + ? {} + : { costUsdTicks: overrides?.costUsdTicks ?? 1_626_488_800 }), + modelUsage: { + "grok-4.6-build": { + inputTokens: 974_514, + outputTokens: 7_246, + }, + }, + }, + }, + }, + }); + } + + it("reads PromptUsage totals, cache, and complete cost ticks", () => { + const record = parseGrokLine(grokTurnCompleted()); + expect(record).not.toBeNull(); + expect(record?.provider).toBe("grok"); + expect(record?.model).toBe("grok-4.6-build"); + expect(record?.sessionId).toBe("sess-1"); + expect(record?.dedupeKey).toBe("sess-1:prompt-1"); + expect(record?.totals).toEqual({ + uncachedInputTokens: 974_514 - 847_360, + cachedInputTokens: 847_360, + cacheCreationTokens: 0, + outputTokens: 7_246, + reasoningTokens: 4_743, + }); + expect(record?.reportedCostUsd).toBeCloseTo(1_626_488_800 / GROK_COST_USD_TICKS_PER_DOLLAR); + }); + + it("does not treat an incomplete bill as $0", () => { + const record = parseGrokLine(grokTurnCompleted({ incomplete: true, costUsdTicks: 100 })); + expect(record?.reportedCostUsd).toBeNull(); + }); + + it("dedupes the same prompt across ACP method aliases", () => { + const first = parseGrokLine(grokTurnCompleted({ promptId: "p2" })); + const second = parseGrokLine( + grokTurnCompleted({ promptId: "p2" }).replace("_x.ai/session/update", "session/update"), + ); + expect(first?.dedupeKey).toBe(second?.dedupeKey); + }); + + it("ignores non-turn updates", () => { + expect( + parseGrokLine( + JSON.stringify({ + timestamp: "2026-08-15T03:57:36.535Z", + params: { update: { sessionUpdate: "agent_message_chunk" } }, + }), + ), + ).toBeNull(); + }); + + it("merges nested PromptUsage totals before extracting tokens", () => { + const record = parseGrokLine( + JSON.stringify({ + timestamp: "2026-08-15T03:57:36.535Z", + params: { + sessionId: "sess-1", + update: { + sessionUpdate: "turn_completed", + prompt_id: "nested", + usage: { + totals: { + inputTokens: 40, + outputTokens: 10, + cachedReadTokens: 8, + }, + costUsdTicks: 1_000_000_000, + }, + }, + }, + }), + ); + expect(record?.totals).toEqual({ + uncachedInputTokens: 32, + cachedInputTokens: 8, + cacheCreationTokens: 0, + outputTokens: 10, + reasoningTokens: 0, + }); + expect(record?.reportedCostUsd).toBeCloseTo(0.1); + }); + + it("does not treat a session id as a turn dedupe key", () => { + const record = parseGrokLine(grokTurnCompleted({ promptId: "" })); + expect(record?.dedupeKey).toBeNull(); + expect(record?.sessionId).toBe("sess-1"); + }); +}); + +describe("mightCarryUsage", () => { + it("accepts camelCase Grok turnCompleted lines", () => { + expect(mightCarryUsage('{"sessionUpdate":"turnCompleted"}', "grok")).toBe(true); + expect(mightCarryUsage('{"sessionUpdate":"turn_completed"}', "grok")).toBe(true); + expect(mightCarryUsage('{"sessionUpdate":"agent_message_chunk"}', "grok")).toBe(false); + }); +}); + describe("totalTokens", () => { it("does not add reasoning on top of output", () => { expect( diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 49f9a1935ccc..50295c54b68b 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -68,7 +68,11 @@ export function totalTokens(totals: UsageTokenTotals): number { * an order of magnitude. */ export function mightCarryUsage(line: string, provider: UsageProviderKind): boolean { - return provider === "claude" ? line.includes('"usage"') : line.includes('"token_count"'); + if (provider === "claude") return line.includes('"usage"'); + if (provider === "grok") { + return line.includes("turn_completed") || line.includes("turnCompleted"); + } + return line.includes('"token_count"'); } /* -------------------------------------------------------------------------- */ @@ -297,4 +301,133 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord }; } +/* -------------------------------------------------------------------------- */ +/* Grok Build */ +/* -------------------------------------------------------------------------- */ + +/** Grok PromptUsage: 1e10 ticks = $1. Incomplete bills must not become $0. */ +export const GROK_COST_USD_TICKS_PER_DOLLAR = 10_000_000_000; + +/** Complete PromptUsage only. Incomplete bills must not become $0. */ +export function grokCompleteCostUsd(usage: Record): number | null { + if ( + usage.usageIsIncomplete === true || + usage.usage_is_incomplete === true || + usage.incomplete === true || + usage.partial === true + ) { + return null; + } + const ticks = usage.costUsdTicks ?? usage.cost_usd_ticks; + if (typeof ticks === "number" && Number.isFinite(ticks) && ticks >= 0) { + return ticks / GROK_COST_USD_TICKS_PER_DOLLAR; + } + const dollars = usage.costUsd ?? usage.cost_usd; + if (typeof dollars === "number" && Number.isFinite(dollars) && dollars >= 0) { + return dollars; + } + return null; +} + +function grokModelId(usage: Record): string { + const named = typeof usage.model === "string" ? usage.model.trim() : ""; + if (named.length > 0) return named; + const modelUsage = usage.modelUsage ?? usage.model_usage; + if (typeof modelUsage === "object" && modelUsage !== null && !Array.isArray(modelUsage)) { + const first = Object.keys(modelUsage as Record).find( + (key) => key.trim().length > 0, + ); + if (first) return first; + } + return "grok-build"; +} + +/** + * Parses one line of a Grok `updates.jsonl` ACP envelope. + * + * Grok writes `turn_completed` with PromptUsage (`inputTokens`, cache-read + * tokens, `costUsdTicks`). Input is inclusive of cache, same as Codex. + */ +export function parseGrokLine(line: string): UsageRecord | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const record = parsed as Record; + const params = + typeof record.params === "object" && record.params !== null + ? (record.params as Record) + : record; + const update = + typeof params.update === "object" && params.update !== null + ? (params.update as Record) + : params; + const tag = update.sessionUpdate ?? update.session_update; + if (tag !== "turn_completed" && tag !== "turnCompleted") return null; + + const usageRaw = update.usage; + if (typeof usageRaw !== "object" || usageRaw === null) return null; + const usageRecord = usageRaw as Record; + const nestedTotals = usageRecord.totals; + const usage = + typeof nestedTotals === "object" && nestedTotals !== null && !Array.isArray(nestedTotals) + ? { ...(nestedTotals as Record), ...usageRecord } + : usageRecord; + + const timestampRaw = + typeof record.timestamp === "string" + ? Date.parse(record.timestamp) + : typeof params._meta === "object" && + params._meta !== null && + typeof (params._meta as Record).agentTimestampMs === "number" + ? ((params._meta as Record).agentTimestampMs as number) + : Number.NaN; + if (!Number.isFinite(timestampRaw)) return null; + const timestampMs = Math.trunc(timestampRaw); + + const inputTokens = int(usage.inputTokens ?? usage.input_tokens); + const cachedInputTokens = int( + usage.cachedReadTokens ?? usage.cached_read_tokens ?? usage.cache_read_input_tokens, + ); + const cacheCreationTokens = int( + usage.cacheCreationTokens ?? usage.cache_creation_tokens ?? usage.cache_creation_input_tokens, + ); + const outputTokens = int(usage.outputTokens ?? usage.output_tokens); + const reasoningTokens = Math.min( + outputTokens, + int(usage.reasoningTokens ?? usage.reasoning_tokens), + ); + + const totals: UsageTokenTotals = { + uncachedInputTokens: Math.max(0, inputTokens - cachedInputTokens - cacheCreationTokens), + cachedInputTokens, + cacheCreationTokens, + outputTokens, + reasoningTokens, + }; + if (totalTokens(totals) === 0) return null; + + const promptId = + (typeof update.prompt_id === "string" && update.prompt_id) || + (typeof update.promptId === "string" && update.promptId) || + ""; + const sessionId = + (typeof params.sessionId === "string" && params.sessionId) || + (typeof params.session_id === "string" && params.session_id) || + ""; + + return { + provider: "grok", + timestampMs, + model: grokModelId(usage), + sessionId, + totals, + reportedCostUsd: grokCompleteCostUsd(usage), + dedupeKey: promptId.length > 0 ? `${sessionId}:${promptId}` : null, + }; +} + export { EMPTY_TOTALS }; diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 5755e73b1bd4..1f3124a0faa6 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -54,10 +54,11 @@ vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" })) vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" })); vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" })); vi.mock("./usageProviders", () => ({ - PROVIDER_ORDER: ["codex", "claude"], + PROVIDER_ORDER: ["codex", "claude", "grok"], PROVIDER_PRESENTATION: { codex: { color: "white", label: "Codex", mark: "span" }, claude: { color: "orange", label: "Claude Code", mark: "span" }, + grok: { color: "purple", label: "Grok Build", mark: "span" }, }, })); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 3c99271c1b2b..a9c7c464cba7 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -390,7 +390,10 @@ export function UsagePage() { {breakdownPeriods.length === 0 ? ( - + No activity in this window. diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index 1c91ab1b42ef..9bdc44d5c1eb 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -85,6 +85,7 @@ describe("buildDayColumns", () => { expect(first?.bands).toEqual([ { provider: "codex", value: 10 }, { provider: "claude", value: 20 }, + { provider: "grok", value: 0 }, ]); }); diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index 74ee27a2b8e9..216b122be06a 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -1,6 +1,6 @@ import type { UsageProviderKind } from "@t3tools/contracts"; -import { ClaudeAI, type Icon, OpenAI } from "../Icons"; +import { ClaudeAI, GrokIcon, type Icon, OpenAI } from "../Icons"; type UsageProviderPresentation = { readonly label: string; @@ -24,6 +24,11 @@ export const PROVIDER_PRESENTATION = { color: "#d97757", mark: ClaudeAI, }, + grok: { + label: "Grok Build", + color: "#8884d8", + mark: GrokIcon, + }, } satisfies Record; /** Stable provider reading order across charts, summaries, tables, and hover rows. */ diff --git a/docs/README.md b/docs/README.md index 622d81064387..7e0ec47969f5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,7 @@ - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) - [Background service (Linux)](./user/background-service.md) -- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) +- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [Grok Build](./user/providers-grok.md) Mobile app: [apps/mobile/README.md](../apps/mobile/README.md) diff --git a/docs/user/install.md b/docs/user/install.md index 15f96e00d4f3..8edf1e633813 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -86,6 +86,7 @@ authenticated shows its status in **Settings** and fails at session start with t to run. For multi-account setups, see [Codex](./providers-codex.md) and [Claude](./providers-claude.md). +For Grok Build login and effort, see [Grok Build](./providers-grok.md). ## Next Steps diff --git a/docs/user/providers-grok.md b/docs/user/providers-grok.md new file mode 100644 index 000000000000..b9776fec2d46 --- /dev/null +++ b/docs/user/providers-grok.md @@ -0,0 +1,96 @@ +# Grok Build + +This guide is for people who want to use Grok Build in T3 Code. For first-time setup, see +[Install T3 Code](./install.md). + +Log in with the Grok CLI on the machine that runs the T3 Code server: + +```bash +grok login +``` + +You can also set `XAI_API_KEY` in the server environment instead of running `grok login`. +Background provider checks start ACP with browser login disabled (`CI` / `NO_BROWSER`). If +authenticate fails, Settings shows an unauthenticated status and asks you to run `grok login`. + +In T3 Code Settings, the default Grok provider can stay like this: + +```text +Display name: Grok +Binary path: grok +``` + +Use an explicit binary path when `grok` is not on the `PATH` of the shell that started T3 Code. + +## Models and effort + +T3 Code reads the live Grok model list from the CLI. Current Grok Build installs advertise +`grok-4.6` and `grok-4.5`. The product slug `grok-build` is treated as an alias for the session's +current ACP model — T3 does not send it to `session/set_model`. Each model that supports reasoning +effort shows a Reasoning control in the composer. The menu comes from the CLI, so the levels can +differ by model. + +T3 Code sends the selected effort on the live session. You do not need a new thread to change +model or effort. + +## Plan mode + +The composer Plan / Default control is on for Grok. T3 sends `session/set_mode` for the +matching advertised ACP mode (`plan` / `architect` for Plan, `code` / `agent` for Default). +ACP `plan` entries already update the turn plan list. `/plan` and `/default` in the composer +are the same control. + +## Workflows + +Grok Build workflows are Rhai scripts that orchestrate child agents as one background run. The +CLI launches them with the `workflow` tool or `/workflow` and streams progress as +`x.ai/session_notification` / `workflow_updated`. + +T3 Code now maps those updates onto the same Agents / task surface used by Claude workflows and Codex collab children: + +- the run becomes a `local_workflow` task (name, objective, phases) +- each child agent becomes a `subagent` task with `parentAgentId` + `timelineBypass` (not a parent-timeline row) +- member tokens stay on the child `typedUsage` snapshot — they do not replace the thread context window +- standalone Grok `subagent_spawned` / `subagent_progress` / `subagent_finished` updates use the same child-task path + +T3 does not reimplement the Rhai host. Composer `/` lists `/workflow pause`, `/workflow resume`, +`/workflow stop`, and each script in `~/.grok/workflows` plus the project `.grok/workflows` +directory. Picking one sends that slash text as a prompt so the Grok CLI can run it. Project +scripts override user scripts of the same name. + +## Usage + +After each prompt T3 reads Grok's prompt `_meta.usage` (including the official PromptUsage +totals / `cached_read_tokens` shape) and emits `thread.token-usage.updated`. Workflow child +tokens are added as they arrive. + +The Usage page scans `~/.grok/sessions/**/updates.jsonl` the same way it reads Claude and +Codex transcripts. Complete PromptUsage rows contribute token totals and, when +`costUsdTicks` is present and the bill is not marked incomplete, a dollar amount +(1e10 ticks = $1). Incomplete bills stay on the token side and never become $0. + +Live turns do the same: a complete `turn_completed` PromptUsage row updates the +context window and, when the bill is complete, attaches `totalCostUsd` to the +turn. Auto-compact notifications fill the context-window meter +(`compactsAutomatically`) and mark the thread compacted, matching Claude's +compact boundary. Session recap lands on thread metadata, not the title. +Hook runs and background shells use the same `hook.*` and `local_bash` task +events as Claude. Queued prompts (`_x.ai/queue/changed`) update session state +and thread metadata with the queue length. + +## Rewind + +Conversation rollback uses Grok's `_x.ai/rewind` extension. T3 maps "undo N turns" onto rewind +points from the remaining conversation, so a cancelled in-flight prompt cannot leave Grok and T3 +on different histories, then trims the in-memory turn list when execute succeeds. + +## If Grok looks ready but will not start + +Run `grok login` again on the server machine. T3 Code reports an unauthenticated Grok install in +Settings when ACP login fails. + +## What T3 still does not surface + +Grok Build's ACP session channel also carries plugins and marketplace updates. Those +notifications are accepted and ignored until a later change maps them. The Grok CLI TUI +remains the source of truth for `/usage`. diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd6..b6a8edd9140a 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -162,6 +162,7 @@ export const DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER: Partial< [CODEX_DRIVER_KIND]: DEFAULT_TEXT_GENERATION_MODEL, [CLAUDE_DRIVER_KIND]: "claude-haiku-4-5", [CURSOR_DRIVER_KIND]: "composer-2", + [GROK_DRIVER_KIND]: "grok-build", [OPENCODE_DRIVER_KIND]: "openai/gpt-5", }; diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index cde888a6153e..7462d9ea6ef7 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -2,10 +2,10 @@ * Usage reporting contract. * * Each environment scans the provider CLIs' own on-disk session transcripts - * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`) rather than - * relying on T3 Code's own orchestration projections, so usage stays complete - * even for turns that were never driven through T3 Code. This mirrors the - * approach `ccusage` takes. + * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`, + * `~/.grok/sessions/**\/updates.jsonl`) rather than relying on T3 Code's own + * orchestration projections, so usage stays complete even for turns that were + * never driven through T3 Code. This mirrors the approach `ccusage` takes. * * Environments return pre-aggregated `(day, hourStart?, provider, model)` * buckets. Raw transcript records never cross the wire. @@ -21,9 +21,9 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; * client renders partial coverage when an environment reports an older version * rather than failing the whole page. */ -export const USAGE_CONTRACT_VERSION = 4 as const; +export const USAGE_CONTRACT_VERSION = 5 as const; -export const UsageProviderKind = Schema.Literals(["claude", "codex"]); +export const UsageProviderKind = Schema.Literals(["claude", "codex", "grok"]); export type UsageProviderKind = typeof UsageProviderKind.Type; /**