From 2263b8c167e7bc174e605b8e36aa7ee45af9dc9f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 12 Aug 2026 14:42:37 -0700 Subject: [PATCH 01/26] feat(server): wire Grok Build effort, auth, rewind, and usage Grok already advertised reasoning effort and rewind over ACP. T3 dropped the metadata and forced a new thread for model changes. Map the live effort menu into composer options, send session/set_model _meta.reasoningEffort, probe login, roll back turns through _x.ai/rewind, and emit thread token usage. Made with Grok 4.6. --- apps/server/scripts/acp-mock-agent.ts | 69 ++++- .../src/provider/Layers/GrokAdapter.test.ts | 117 ++++++- .../server/src/provider/Layers/GrokAdapter.ts | 116 ++++++- .../src/provider/Layers/GrokProvider.test.ts | 3 +- .../src/provider/Layers/GrokProvider.ts | 38 ++- .../src/provider/acp/AcpSessionRuntime.ts | 4 +- .../src/provider/acp/GrokAcpSupport.test.ts | 97 +++++- .../server/src/provider/acp/GrokAcpSupport.ts | 292 +++++++++++++++++- .../src/provider/acp/XAiAcpExtension.test.ts | 30 ++ .../src/provider/acp/XAiAcpExtension.ts | 196 +++++++++++- docs/README.md | 2 +- docs/user/install.md | 1 + docs/user/providers-grok.md | 35 +++ packages/contracts/src/model.ts | 1 + 14 files changed, 953 insertions(+), 48 deletions(-) create mode 100644 docs/user/providers-grok.md diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index bc7828dd8547..114c6224bb54 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -279,9 +279,26 @@ 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 emitUsage = process.env.T3_ACP_EMIT_USAGE === "1"; +let rewindPoints: Array<{ prompt_index: number; prompt_preview: string }> = []; function modelState(): AcpSchema.SessionModelState { const modelId = grokAcpModels.some((model) => model.modelId === currentModelId) @@ -392,7 +409,12 @@ const program = Effect.gen(function* () { ); } currentModelId = request.modelId; - return {}; + return { + _meta: { + model: { Ok: request.modelId }, + ...(request._meta ?? {}), + }, + }; }), ); @@ -873,11 +895,52 @@ const program = Effect.gen(function* () { }, }); - return { stopReason: "end_turn" }; + if (enableRewind) { + 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 : "", + }); + } + + 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/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 6cb71660a74c..419f6ec97176 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -1197,7 +1197,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 +1263,120 @@ 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); + }), + ); }); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 858d862e6d5f..99841254b68f 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -54,15 +54,24 @@ import { import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; import { + advertisedGrokReasoningEffortsFromSessionSetup, applyGrokAcpModelSelection, + currentGrokMaxTokensFromSessionSetup, currentGrokModelIdFromSessionSetup, + currentGrokReasoningEffortFromSessionSetup, + grokReasoningEffortMenusFromSessionSetup, makeGrokAcpRuntime, + requestedGrokReasoningEffort, resolveGrokAcpBaseModelId, } from "../acp/GrokAcpSupport.ts"; import { + extractGrokTokenUsage, extractXAiAskUserQuestions, + grokRewindTargetForTurnCount, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, + parseGrokRewindExecute, + parseGrokRewindPoints, promptResponseHasMissingXAiStopReason, XAiAskUserQuestionRequest, } from "../acp/XAiAcpExtension.ts"; @@ -117,6 +126,9 @@ interface GrokSessionContext { * continues it, and only the last remaining prompt settles the turn. */ promptsInFlight: number; currentModelId: string | undefined; + currentReasoningEffort: string | undefined; + reasoningEffortMenus: Map>; + maxTokens: number | undefined; stopped: boolean; } @@ -549,6 +561,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 +589,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 } : {}), @@ -735,16 +752,26 @@ 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 advertisedStartEfforts = advertisedGrokReasoningEffortsFromSessionSetup( + started.sessionSetupResult, + requestedStartModelId ?? startedModelId, + ); + const boundSelection = yield* applyGrokAcpModelSelection({ runtime: acp, - currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult), + currentModelId: startedModelId, requestedModelId: requestedStartModelId, + currentReasoningEffort: currentGrokReasoningEffortFromSessionSetup( + started.sessionSetupResult, + ), + requestedReasoningEffort: requestedGrokReasoningEffort( + grokModelSelection, + advertisedStartEfforts, + ), mapError: (cause) => mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), }); + const boundModelId = boundSelection.modelId; const now = yield* nowIso; const session: ProviderSession = { @@ -778,6 +805,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte interruptedTurnIds: new Set(), promptsInFlight: 0, currentModelId: boundModelId, + currentReasoningEffort: boundSelection.reasoningEffort, + reasoningEffortMenus: grokReasoningEffortMenusFromSessionSetup( + started.sessionSetupResult, + ), + maxTokens: currentGrokMaxTokensFromSessionSetup(started.sessionSetupResult), stopped: false, }; @@ -948,13 +980,28 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const requestedTurnModelId = turnModelSelection?.model ? resolveGrokAcpBaseModelId(turnModelSelection.model) : undefined; - const currentModelId = yield* applyGrokAcpModelSelection({ + const advertisedTurnEfforts = + (requestedTurnModelId + ? ctx.reasoningEffortMenus.get(requestedTurnModelId) + : undefined) ?? + (ctx.currentModelId + ? ctx.reasoningEffortMenus.get(ctx.currentModelId) + : undefined) ?? + []; + const turnSelection = yield* applyGrokAcpModelSelection({ runtime: ctx.acp, currentModelId: ctx.currentModelId, requestedModelId: requestedTurnModelId, + currentReasoningEffort: ctx.currentReasoningEffort, + requestedReasoningEffort: requestedGrokReasoningEffort( + turnModelSelection, + advertisedTurnEfforts, + ), mapError: (cause) => mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), }); + const currentModelId = turnSelection.modelId; + ctx.currentReasoningEffort = turnSelection.reasoningEffort; const text = input.input?.trim(); const imagePromptParts = yield* Effect.forEach( @@ -1149,6 +1196,17 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } appendPromptResultToTurn(ctx, prepared.turnId, prepared.promptParts, result); + const tokenUsage = extractGrokTokenUsage(result._meta, ctx.maxTokens); + if (tokenUsage) { + yield* offerRuntimeEvent({ + type: "thread.token-usage.updated", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: prepared.turnId, + payload: { usage: tokenUsage }, + }); + } ctx.session = { ...ctx.session, status: "running", @@ -1405,7 +1463,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const rollbackThread: GrokAdapterShape["rollbackThread"] = (threadId, numTurns) => Effect.gen(function* () { - yield* requireSession(threadId); + const ctx = yield* requireSession(threadId); if (!Number.isInteger(numTurns) || numTurns < 1) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, @@ -1413,11 +1471,45 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte issue: "numTurns must be an integer >= 1.", }); } - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "thread/rollback", - detail: "Grok ACP sessions do not support provider-side rollback yet.", - }); + const pointsPayload = yield* ctx.acp + .request("_x.ai/rewind/points", { + sessionId: ctx.acpSessionId, + }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "_x.ai/rewind/points", error), + ), + ); + const target = grokRewindTargetForTurnCount(parseGrokRewindPoints(pointsPayload), numTurns); + 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* ctx.acp + .request("_x.ai/rewind/execute", { + sessionId: ctx.acpSessionId, + targetPromptIndex: target.promptIndex, + mode: "conversation_only", + force: true, + }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "_x.ai/rewind/execute", error), + ), + ); + const executed = parseGrokRewindExecute(executePayload); + if (!executed?.success) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "_x.ai/rewind/execute", + detail: executed?.error ?? "Grok rewind did not succeed.", + }); + } + ctx.turns = ctx.turns.slice(0, Math.max(0, ctx.turns.length - numTurns)); + return { threadId, turns: ctx.turns }; }); const stopSession: GrokAdapterShape["stopSession"] = (threadId) => diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 000243869c9e..31ebe5e1ae47 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -31,7 +31,8 @@ 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.models[0]?.capabilities?.optionDescriptors?.[0]?.id).toBe("reasoningEffort"); }), ); }); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 934eecdb5ae6..a19f63597784 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -14,7 +14,6 @@ import * as Option from "effect/Option"; 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,27 +28,33 @@ import { enrichProviderSnapshotWithVersionAdvisory, type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; -import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSupport.ts"; +import { + fallbackGrokReasoningEffortCapabilities, + grokReasoningEffortCapabilities, + isGrokAcpAuthFailure, + makeGrokAcpRuntime, + parseGrokAcpModelMeta, + resolveGrokAcpBaseModelId, +} from "../acp/GrokAcpSupport.ts"; const GROK_PRESENTATION = { displayName: "Grok", badgeLabel: "Early Access", showInteractionModeToggle: false, - requiresNewThreadForModelChange: true, + requiresNewThreadForModelChange: false, } as const; -const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ - optionDescriptors: [], -}); +const FALLBACK_CAPABILITIES: ModelCapabilities = fallbackGrokReasoningEffortCapabilities(); 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, }, ]; @@ -96,7 +101,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 +118,14 @@ 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: meta.supportsReasoningEffort + ? grokReasoningEffortCapabilities(meta.reasoningEfforts) + : FALLBACK_CAPABILITIES, }; }) .filter((model): model is ServerProviderModel => model !== undefined); @@ -256,8 +264,10 @@ 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({ presentation: GROK_PRESENTATION, @@ -268,8 +278,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.", }, }); } @@ -306,7 +318,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/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index 02d60976b24c..f35da545ecc5 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -5,8 +5,14 @@ import * as EffectAcpErrors from "effect-acp/errors"; import { applyGrokAcpModelSelection, buildGrokAcpSpawnInput, + GROK_REASONING_EFFORT_OPTION_ID, + grokReasoningEffortCapabilities, + isGrokAcpAuthFailure, + parseGrokAcpModelMeta, + requestedGrokReasoningEffort, resolveGrokAcpBaseModelId, } from "./GrokAcpSupport.ts"; +import { ProviderInstanceId } from "@t3tools/contracts"; describe("resolveGrokAcpBaseModelId", () => { it("normalizes empty and custom Grok model ids", () => { @@ -33,15 +39,76 @@ 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" }, + { 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(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(); + }); +}); + +describe("isGrokAcpAuthFailure", () => { + it("recognizes authenticate failures", () => { + expect(isGrokAcpAuthFailure(new Error("authenticate failed: cached_token"))).toBe(true); + expect(isGrokAcpAuthFailure(new Error("session/new timed out"))).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 +125,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 +140,23 @@ describe("applyGrokAcpModelSelection", () => { mapError: (cause) => cause.message, }); expect(modelCalls).toEqual([]); - expect(result).toBe("grok-build"); + expect(result).toEqual({ modelId: "grok-build", 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 +170,7 @@ describe("applyGrokAcpModelSelection", () => { mapError: (cause) => cause.message, }); expect(modelCalls).toEqual([]); - expect(result).toBe("grok-build"); + expect(result).toEqual({ modelId: "grok-build", reasoningEffort: undefined }); }), ); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index c928b3ed80e0..b92c982d603c 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -1,4 +1,9 @@ -import { type GrokSettings, ProviderDriverKind } from "@t3tools/contracts"; +import { + type GrokSettings, + type ModelCapabilities, + type ModelSelection, + ProviderDriverKind, +} from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -6,7 +11,11 @@ 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 * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; import { makeXAiPromptCompletionRuntime } from "./XAiAcpExtension.ts"; @@ -18,6 +27,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 +48,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 +105,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( @@ -91,18 +139,244 @@ 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 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) => + current && choice.id === current && choice.isDefault !== true + ? { ...choice, isDefault: true } + : choice, + ), + ...(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; + } + if (advertised.length === 0 || advertised.includes(requested)) { + return requested; + } + return undefined; +} + +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; +} + +export function advertisedGrokReasoningEffortsFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, + modelId: string | undefined, +): ReadonlyArray { + const menus = grokReasoningEffortMenusFromSessionSetup(sessionSetupResult); + if (modelId && menus.has(modelId)) { + return menus.get(modelId) ?? []; + } + const current = sessionSetupResult.models?.currentModelId; + return (current ? menus.get(current) : undefined) ?? []; +} + +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; +} + +export function isGrokAcpAuthFailure(error: unknown): boolean { + const text = [ + error instanceof Error ? `${error.name} ${error.message}` : "", + typeof error === "string" ? error : "", + String(error), + ].join(" "); + return /authenticat|unauthorized|not logged in|login required|no credentials|cached_token|xai\.api_key|401\b/i.test( + text, + ); +} + export function applyGrokAcpModelSelection(input: { readonly runtime: Pick; readonly currentModelId: string | undefined; readonly requestedModelId: string | undefined; + readonly currentReasoningEffort?: string; + readonly requestedReasoningEffort?: string; readonly mapError: (cause: EffectAcpErrors.AcpError) => E; -}): Effect.Effect { +}): Effect.Effect { + const nextModelId = input.requestedModelId ?? input.currentModelId; + const nextEffort = input.requestedReasoningEffort ?? input.currentReasoningEffort; const shouldSwitchModel = input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; - if (!shouldSwitchModel) { - return Effect.succeed(input.currentModelId); + 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, + }), + ); } diff --git a/apps/server/src/provider/acp/XAiAcpExtension.test.ts b/apps/server/src/provider/acp/XAiAcpExtension.test.ts index c435269fd76d..45a5b150e521 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.test.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.test.ts @@ -9,10 +9,13 @@ import * as Schema from "effect/Schema"; import { describe, expect } from "vite-plus/test"; import { + extractGrokTokenUsage, extractXAiAskUserQuestions, + grokRewindTargetForTurnCount, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, makeXAiPromptCompletionRuntime, + parseGrokRewindPoints, XAiAskUserQuestionRequest, } from "./XAiAcpExtension.ts"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; @@ -330,3 +333,30 @@ describe("XAiAcpExtension", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); }); + +describe("Grok rewind and usage helpers", () => { + it("picks the rewind target that drops the last N turns", () => { + const points = parseGrokRewindPoints({ + rewind_points: [ + { prompt_index: 0, prompt_preview: "first" }, + { prompt_index: 1, prompt_preview: "second" }, + { prompt_index: 2, prompt_preview: "third" }, + ], + }); + expect(grokRewindTargetForTurnCount(points, 1)?.promptIndex).toBe(2); + expect(grokRewindTargetForTurnCount(points, 2)?.promptIndex).toBe(1); + }); + + 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, + }); + }); +}); diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts index d36a5fcfc895..d3f71ffd55dc 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,193 @@ 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 }]; + }); +} + +/** Target for rolling back the last `numTurns` user prompts. Execute discards the target and everything after it. */ +export function grokRewindTargetForTurnCount( + points: ReadonlyArray, + numTurns: number, +): GrokRewindPoint | undefined { + if (!Number.isInteger(numTurns) || numTurns < 1 || points.length === 0) { + return undefined; + } + const ordered = [...points].sort((left, right) => left.promptIndex - right.promptIndex); + const target = ordered[Math.max(0, ordered.length - numTurns)]; + return target; +} + +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), + }; +} + +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 record.usage; + } + if (isRecord(record.tokenUsage)) { + return record.tokenUsage; + } + if (isRecord(record.token_usage)) { + return record.token_usage; + } + if (isRecord(record.agentResult)) { + return usageRecordFromUnknown(record.agentResult); + } + 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, + ); + 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 } : {}), + }; +} diff --git a/docs/README.md b/docs/README.md index 30653e7d5035..4dd22aecdfe4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,7 +12,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 96776c7ea1f1..fc03a61c0ce2 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -83,6 +83,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..ffae94a0cf92 --- /dev/null +++ b/docs/user/providers-grok.md @@ -0,0 +1,35 @@ +# 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`. + +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`. 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. + +## 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. 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", }; From d3dcbf54f408289e0db8b39d358c7b937097233f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 12 Aug 2026 14:49:39 -0700 Subject: [PATCH 02/26] fix(server): allow undefined Grok effort on session/set_model exactOptionalPropertyTypes rejected passing string | undefined into the optional effort fields, which also leaked unknown into the adapter Effect error channel. Made with Grok 4.6. --- apps/server/src/provider/acp/GrokAcpSupport.test.ts | 2 +- apps/server/src/provider/acp/GrokAcpSupport.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index f35da545ecc5..8f626d1a4a63 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -67,7 +67,7 @@ describe("parseGrokAcpModelMeta", () => { expect(meta.reasoningEffort).toBe("high"); expect(meta.totalContextTokens).toBe(500000); expect(meta.reasoningEfforts.map((choice) => choice.id)).toEqual(["xhigh", "high", "medium"]); - expect(grokReasoningEffortCapabilities(meta.reasoningEfforts).optionDescriptors[0]?.id).toBe( + expect(grokReasoningEffortCapabilities(meta.reasoningEfforts).optionDescriptors?.[0]?.id).toBe( GROK_REASONING_EFFORT_OPTION_ID, ); }); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index b92c982d603c..2bb72cc96bef 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -341,8 +341,8 @@ export function applyGrokAcpModelSelection(input: { readonly runtime: Pick; readonly currentModelId: string | undefined; readonly requestedModelId: string | undefined; - readonly currentReasoningEffort?: string; - readonly requestedReasoningEffort?: string; + readonly currentReasoningEffort?: string | undefined; + readonly requestedReasoningEffort?: string | undefined; readonly mapError: (cause: EffectAcpErrors.AcpError) => E; }): Effect.Effect { const nextModelId = input.requestedModelId ?? input.currentModelId; From cf18ba5bfd35aea63f3cabb7694c4f74909a5f89 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 12 Aug 2026 14:51:27 -0700 Subject: [PATCH 03/26] fix(server): keep Grok effort and rewind aligned with the live session Do not carry an old effort onto a new model. Count steered prompts when rewinding. Lock rollback against a replaced session. Keep one default effort in the composer menu. Made with Grok 4.6. --- .../server/src/provider/Layers/GrokAdapter.ts | 154 +++++++++++------- .../src/provider/acp/GrokAcpSupport.test.ts | 20 ++- .../server/src/provider/acp/GrokAcpSupport.ts | 37 ++++- .../src/provider/acp/XAiAcpExtension.test.ts | 3 + .../src/provider/acp/XAiAcpExtension.ts | 16 +- 5 files changed, 164 insertions(+), 66 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 99841254b68f..fd198f755d3e 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -59,6 +59,7 @@ import { currentGrokMaxTokensFromSessionSetup, currentGrokModelIdFromSessionSetup, currentGrokReasoningEffortFromSessionSetup, + grokMaxTokensByModelFromSessionSetup, grokReasoningEffortMenusFromSessionSetup, makeGrokAcpRuntime, requestedGrokReasoningEffort, @@ -67,6 +68,7 @@ import { import { extractGrokTokenUsage, extractXAiAskUserQuestions, + grokPromptCountForTurns, grokRewindTargetForTurnCount, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, @@ -128,6 +130,7 @@ interface GrokSessionContext { currentModelId: string | undefined; currentReasoningEffort: string | undefined; reasoningEffortMenus: Map>; + maxTokensByModel: Map; maxTokens: number | undefined; stopped: boolean; } @@ -772,6 +775,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), }); const boundModelId = boundSelection.modelId; + const maxTokensByModel = grokMaxTokensByModelFromSessionSetup(started.sessionSetupResult); const now = yield* nowIso; const session: ProviderSession = { @@ -809,7 +813,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte reasoningEffortMenus: grokReasoningEffortMenusFromSessionSetup( started.sessionSetupResult, ), - maxTokens: currentGrokMaxTokensFromSessionSetup(started.sessionSetupResult), + maxTokensByModel, + maxTokens: + (boundModelId ? maxTokensByModel.get(boundModelId) : undefined) ?? + currentGrokMaxTokensFromSessionSetup(started.sessionSetupResult), stopped: false, }; @@ -980,14 +987,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const requestedTurnModelId = turnModelSelection?.model ? resolveGrokAcpBaseModelId(turnModelSelection.model) : undefined; - const advertisedTurnEfforts = - (requestedTurnModelId - ? ctx.reasoningEffortMenus.get(requestedTurnModelId) - : undefined) ?? - (ctx.currentModelId - ? ctx.reasoningEffortMenus.get(ctx.currentModelId) - : undefined) ?? - []; + const advertisedTurnEfforts = requestedTurnModelId + ? (ctx.reasoningEffortMenus.get(requestedTurnModelId) ?? []) + : ctx.currentModelId + ? (ctx.reasoningEffortMenus.get(ctx.currentModelId) ?? []) + : []; const turnSelection = yield* applyGrokAcpModelSelection({ runtime: ctx.acp, currentModelId: ctx.currentModelId, @@ -1001,7 +1005,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), }); const currentModelId = turnSelection.modelId; + ctx.currentModelId = currentModelId; ctx.currentReasoningEffort = turnSelection.reasoningEffort; + if (currentModelId) { + ctx.maxTokens = ctx.maxTokensByModel.get(currentModelId) ?? ctx.maxTokens; + } const text = input.input?.trim(); const imagePromptParts = yield* Effect.forEach( @@ -1462,55 +1470,89 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }); const rollbackThread: GrokAdapterShape["rollbackThread"] = (threadId, numTurns) => - 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.", - }); - } - const pointsPayload = yield* ctx.acp - .request("_x.ai/rewind/points", { - sessionId: ctx.acpSessionId, - }) - .pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, threadId, "_x.ai/rewind/points", error), - ), + 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.", + }); + } + const promptCount = grokPromptCountForTurns(ctx.turns, numTurns); + if (promptCount < 1) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "_x.ai/rewind/execute", + detail: "Grok has no rewind point for that many turns.", + }); + } + 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 target = grokRewindTargetForTurnCount( + parseGrokRewindPoints(pointsPayload), + promptCount, ); - const target = grokRewindTargetForTurnCount(parseGrokRewindPoints(pointsPayload), numTurns); - 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* ctx.acp - .request("_x.ai/rewind/execute", { - sessionId: ctx.acpSessionId, - targetPromptIndex: target.promptIndex, - mode: "conversation_only", - force: true, - }) - .pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, threadId, "_x.ai/rewind/execute", error), - ), + 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: executed?.error ?? "Grok rewind did not succeed.", + }); + } + committedCtx.turns = committedCtx.turns.slice( + 0, + Math.max(0, committedCtx.turns.length - numTurns), ); - const executed = parseGrokRewindExecute(executePayload); - if (!executed?.success) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "_x.ai/rewind/execute", - detail: executed?.error ?? "Grok rewind did not succeed.", - }); - } - ctx.turns = ctx.turns.slice(0, Math.max(0, ctx.turns.length - numTurns)); - return { threadId, turns: ctx.turns }; - }); + return { threadId, turns: committedCtx.turns }; + }), + ); const stopSession: GrokAdapterShape["stopSession"] = (threadId) => withThreadLock( diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index 8f626d1a4a63..d3011ef818d1 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -58,7 +58,7 @@ describe("parseGrokAcpModelMeta", () => { reasoningEffort: "high", totalContextTokens: 500000, reasoningEfforts: [ - { id: "xhigh", value: "xhigh", label: "Extra High Effort" }, + { 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" }, ], @@ -67,6 +67,9 @@ describe("parseGrokAcpModelMeta", () => { 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, ); @@ -144,6 +147,21 @@ describe("applyGrokAcpModelSelection", () => { }), ); + 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(); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index 2bb72cc96bef..309e8bedc9e1 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -201,6 +201,7 @@ export function parseGrokAcpModelMeta(meta: unknown): GrokAcpModelMeta { } 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" && @@ -212,11 +213,12 @@ export function parseGrokAcpModelMeta(meta: unknown): GrokAcpModelMeta { return { supportsReasoningEffort, ...(current ? { reasoningEffort: current } : {}), - reasoningEfforts: choices.map((choice) => - current && choice.id === current && choice.isDefault !== true - ? { ...choice, isDefault: true } - : choice, - ), + reasoningEfforts: choices.map((choice) => ({ + id: choice.id, + label: choice.label, + ...(choice.description ? { description: choice.description } : {}), + ...(choice.id === defaultId ? { isDefault: true } : {}), + })), ...(totalContextTokens !== undefined ? { totalContextTokens } : {}), }; } @@ -261,12 +263,31 @@ export function requestedGrokReasoningEffort( if (!requested) { return undefined; } - if (advertised.length === 0 || advertised.includes(requested)) { + if (advertised.includes(requested)) { return requested; } return undefined; } +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 @@ -346,9 +367,11 @@ export function applyGrokAcpModelSelection(input: { readonly mapError: (cause: EffectAcpErrors.AcpError) => E; }): Effect.Effect { const nextModelId = input.requestedModelId ?? input.currentModelId; - const nextEffort = input.requestedReasoningEffort ?? input.currentReasoningEffort; const shouldSwitchModel = input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; + const nextEffort = shouldSwitchModel + ? input.requestedReasoningEffort + : (input.requestedReasoningEffort ?? input.currentReasoningEffort); const shouldSwitchEffort = input.requestedReasoningEffort !== undefined && input.requestedReasoningEffort !== input.currentReasoningEffort; diff --git a/apps/server/src/provider/acp/XAiAcpExtension.test.ts b/apps/server/src/provider/acp/XAiAcpExtension.test.ts index 45a5b150e521..5e976cd8ae32 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.test.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.test.ts @@ -11,6 +11,7 @@ import { describe, expect } from "vite-plus/test"; import { extractGrokTokenUsage, extractXAiAskUserQuestions, + grokPromptCountForTurns, grokRewindTargetForTurnCount, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, @@ -345,6 +346,8 @@ describe("Grok rewind and usage helpers", () => { }); expect(grokRewindTargetForTurnCount(points, 1)?.promptIndex).toBe(2); expect(grokRewindTargetForTurnCount(points, 2)?.promptIndex).toBe(1); + expect(grokRewindTargetForTurnCount(points, 4)).toBeUndefined(); + expect(grokPromptCountForTurns([{ items: [1] }, { items: [2, 3] }], 1)).toBe(2); }); it("reads Grok token usage from prompt _meta", () => { diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts index d3f71ffd55dc..2b480da5e06e 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -506,8 +506,20 @@ export function grokRewindTargetForTurnCount( return undefined; } const ordered = [...points].sort((left, right) => left.promptIndex - right.promptIndex); - const target = ordered[Math.max(0, ordered.length - numTurns)]; - return target; + if (numTurns > ordered.length) { + return undefined; + } + return ordered[ordered.length - numTurns]; +} + +export function grokPromptCountForTurns( + turns: ReadonlyArray<{ readonly items: ReadonlyArray }>, + numTurns: number, +): number { + if (!Number.isInteger(numTurns) || numTurns < 1) { + return 0; + } + return turns.slice(-numTurns).reduce((count, turn) => count + turn.items.length, 0); } export function parseGrokRewindExecute(payload: unknown): GrokRewindExecuteResult | undefined { From fe46af867ef7e9b5efec9011a126b4e522e9b13a Mon Sep 17 00:00:00 2001 From: maslin Date: Sat, 15 Aug 2026 18:33:47 +0700 Subject: [PATCH 04/26] feat(server): ingest Grok Build workflows and real PromptUsage #6383 wired effort, rewind, and a thin usage parser. Grok Build already streams workflow_updated over x.ai/session_notification and bills through PromptUsage totals. T3 dropped both. Map workflow runs onto local_workflow / subagent tasks, fold child tokens into thread usage, and accept the official cached_read_tokens shape. --- apps/server/scripts/acp-mock-agent.ts | 33 +++ .../src/provider/Layers/GrokAdapter.test.ts | 35 +++ .../server/src/provider/Layers/GrokAdapter.ts | 208 ++++++++++++++++++ .../src/provider/acp/XAiAcpExtension.test.ts | 72 ++++++ .../src/provider/acp/XAiAcpExtension.ts | 187 +++++++++++++++- docs/user/providers-grok.md | 36 +++ 6 files changed, 568 insertions(+), 3 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 114c6224bb54..84481eaa0b3d 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -298,6 +298,7 @@ const grokAcpModels: ReadonlyArray = [ ]; const enableRewind = process.env.T3_ACP_ENABLE_REWIND === "1"; const emitUsage = process.env.T3_ACP_EMIT_USAGE === "1"; +const emitWorkflow = process.env.T3_ACP_EMIT_WORKFLOW === "1"; let rewindPoints: Array<{ prompt_index: number; prompt_preview: string }> = []; function modelState(): AcpSchema.SessionModelState { @@ -904,6 +905,38 @@ const program = Effect.gen(function* () { }); } + if (emitWorkflow) { + writeJsonRpcNotification("x.ai/session_notification", { + 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, + }, + ], + }, + }); + } + return { stopReason: "end_turn", ...(emitUsage diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 419f6ec97176..d3e2bb383ca1 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -1379,4 +1379,39 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { yield* adapter.stopSession(threadId); }), ); + + 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); + }), + ); }); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index fd198f755d3e..cc10b4253aa5 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -9,6 +9,7 @@ import { ProviderDriverKind, ProviderInstanceId, RuntimeRequestId, + RuntimeTaskId, type ThreadId, TurnId, } from "@t3tools/contracts"; @@ -70,12 +71,19 @@ import { extractXAiAskUserQuestions, grokPromptCountForTurns, grokRewindTargetForTurnCount, + grokWorkflowAgentIsTerminal, + grokWorkflowAgentStatus, + grokWorkflowRunIsTerminal, + grokWorkflowRunStatus, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, parseGrokRewindExecute, parseGrokRewindPoints, + parseXAiWorkflowUpdated, promptResponseHasMissingXAiStopReason, XAiAskUserQuestionRequest, + XAiSessionNotification, + type GrokWorkflowUpdated, } from "../acp/XAiAcpExtension.ts"; import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -132,6 +140,8 @@ interface GrokSessionContext { reasoningEffortMenus: Map>; maxTokensByModel: Map; maxTokens: number | undefined; + workflowRunIds: Set; + workflowAgentIds: Set; stopped: boolean; } @@ -288,6 +298,179 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const offerRuntimeEvent = (event: ProviderRuntimeEvent) => PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + const emitGrokWorkflowRuntimeEvents = (input: { + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly update: GrokWorkflowUpdated; + readonly method: string; + readonly payload: unknown; + }) => + Effect.gen(function* () { + const ctx = sessions.get(input.threadId); + if (!ctx) { + return; + } + const stamp = yield* makeEventStamp(); + const raw = { + source: "acp.grok.extension" as const, + method: input.method, + payload: input.payload, + }; + const phases = input.update.phases.map((phase, index) => ({ + index, + title: phase.title, + })); + const currentPhaseIndex = input.update.currentPhase + ? input.update.phases.findIndex((phase) => phase.title === input.update.currentPhase) + : -1; + const runStatus = grokWorkflowRunStatus(input.update.status); + const runStarted = ctx.workflowRunIds.has(input.update.runId); + if (!runStarted) { + ctx.workflowRunIds.add(input.update.runId); + } + const runPayload = { + taskId: RuntimeTaskId.make(input.update.runId), + description: input.update.objective || input.update.name, + taskType: "local_workflow", + workflowName: input.update.name, + title: input.update.name, + ...(phases.length > 0 ? { phases } : {}), + ...(currentPhaseIndex >= 0 ? { phaseIndex: currentPhaseIndex } : {}), + ...(input.update.currentPhase ? { phaseTitle: input.update.currentPhase } : {}), + runHandles: { runId: input.update.runId }, + }; + yield* offerRuntimeEvent({ + type: runStarted ? "task.progress" : "task.started", + ...stamp, + provider: PROVIDER, + threadId: input.threadId, + turnId: input.turnId, + payload: runStarted + ? { + ...runPayload, + summary: + input.update.currentAgentLabel ?? + input.update.currentPhase ?? + input.update.status, + status: runStatus, + } + : runPayload, + raw, + }); + if (runStarted && grokWorkflowRunIsTerminal(input.update.status)) { + yield* offerRuntimeEvent({ + type: "task.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: input.turnId, + payload: { + taskId: RuntimeTaskId.make(input.update.runId), + status: + input.update.status === "failed" + ? "failed" + : input.update.status === "complete" + ? "completed" + : "stopped", + summary: + input.update.resultSummary ?? input.update.pauseMessage ?? input.update.status, + taskType: "local_workflow", + workflowName: input.update.name, + title: input.update.name, + ...(phases.length > 0 ? { phases } : {}), + runHandles: { runId: input.update.runId }, + }, + raw, + }); + } + + let workflowTokens = 0; + for (const [agentIndex, agent] of input.update.agents.entries()) { + workflowTokens += agent.tokensUsed; + const agentStarted = ctx.workflowAgentIds.has(agent.agentId); + if (!agentStarted) { + ctx.workflowAgentIds.add(agent.agentId); + } + const agentStatus = grokWorkflowAgentStatus(agent.state); + const agentPayload = { + taskId: RuntimeTaskId.make(agent.agentId), + description: agent.label, + taskType: "subagent", + agentId: input.update.runId, + title: agent.label, + workflowName: input.update.name, + ...(agent.model ? { model: agent.model } : {}), + ...(agent.phase ? { phaseTitle: agent.phase } : {}), + agentIndex, + }; + yield* offerRuntimeEvent({ + type: agentStarted ? "task.progress" : "task.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: input.turnId, + payload: agentStarted + ? { + ...agentPayload, + summary: agent.state, + status: agentStatus, + ...(agent.tokensUsed > 0 || agent.durationMs > 0 + ? { + typedUsage: { + totalTokens: agent.tokensUsed, + ...(agent.durationMs > 0 ? { durationMs: agent.durationMs } : {}), + }, + } + : {}), + } + : agentPayload, + raw, + }); + if (agentStarted && grokWorkflowAgentIsTerminal(agent.state)) { + yield* offerRuntimeEvent({ + type: "task.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: input.turnId, + payload: { + taskId: RuntimeTaskId.make(agent.agentId), + status: + agent.state === "failed" || agent.state === "error" + ? "failed" + : agent.state === "cancelled" + ? "stopped" + : "completed", + summary: agent.label, + taskType: "subagent", + agentId: input.update.runId, + title: agent.label, + workflowName: input.update.name, + ...(agent.tokensUsed > 0 ? { typedUsage: { totalTokens: agent.tokensUsed } } : {}), + }, + raw, + }); + } + } + + if (workflowTokens > 0) { + yield* offerRuntimeEvent({ + type: "thread.token-usage.updated", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: input.turnId, + payload: { + usage: { + usedTokens: workflowTokens, + lastUsedTokens: workflowTokens, + }, + }, + raw, + }); + } + }); + const getThreadSemaphore = (threadId: string) => SynchronizedRef.modifyEffect(threadLocksRef, (current) => { const existing: Option.Option = Option.fromNullishOr( @@ -683,6 +866,29 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ), { discard: true }, ); + yield* Effect.forEach( + ["x.ai/session_notification", "_x.ai/session_notification"] as const, + (method) => + acp.handleExtNotification(method, XAiSessionNotification, (params) => + mapAcpCallbackFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, method, params); + const update = parseXAiWorkflowUpdated(params); + if (!update) { + return; + } + yield* emitGrokWorkflowRuntimeEvents({ + threadId: input.threadId, + turnId: resolveSessionCallbackTurnId(sessions, input.threadId), + update, + method, + payload: params, + }); + }), + ), + ), + { discard: true }, + ); yield* acp.handleRequestPermission((params) => mapAcpCallbackFailure( Effect.gen(function* () { @@ -817,6 +1023,8 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte maxTokens: (boundModelId ? maxTokensByModel.get(boundModelId) : undefined) ?? currentGrokMaxTokensFromSessionSetup(started.sessionSetupResult), + workflowRunIds: new Set(), + workflowAgentIds: new Set(), stopped: false, }; diff --git a/apps/server/src/provider/acp/XAiAcpExtension.test.ts b/apps/server/src/provider/acp/XAiAcpExtension.test.ts index 5e976cd8ae32..21bbb97640c3 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.test.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.test.ts @@ -13,10 +13,12 @@ import { extractXAiAskUserQuestions, grokPromptCountForTurns, grokRewindTargetForTurnCount, + grokWorkflowRunStatus, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, makeXAiPromptCompletionRuntime, parseGrokRewindPoints, + parseXAiWorkflowUpdated, XAiAskUserQuestionRequest, } from "./XAiAcpExtension.ts"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; @@ -362,4 +364,74 @@ describe("Grok rewind and usage helpers", () => { 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 2b480da5e06e..24fec72de4cc 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -1,5 +1,6 @@ import type { ProviderUserInputAnswers, + RuntimeTaskStatus, ThreadTokenUsageSnapshot, UserInputQuestion, } from "@t3tools/contracts"; @@ -554,17 +555,22 @@ function usageRecordFromUnknown(value: unknown): Record | undef return undefined; } if (isRecord(record.usage)) { - return record.usage; + return usageRecordFromUnknown(record.usage); } if (isRecord(record.tokenUsage)) { - return record.tokenUsage; + return usageRecordFromUnknown(record.tokenUsage); } if (isRecord(record.token_usage)) { - return 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; } @@ -603,6 +609,8 @@ export function extractGrokTokenUsage( usage.cachedInputTokens, usage.cache_read_input_tokens, usage.cacheReadInputTokens, + usage.cached_read_tokens, + usage.cachedReadTokens, ); const usedTokens = readTokenCount( usage.usedTokens, @@ -636,3 +644,176 @@ export function extractGrokTokenUsage( ...(cachedInputTokens !== undefined ? { lastCachedInputTokens: cachedInputTokens } : {}), }; } + +/** + * Grok Build fires workflow progress as `x.ai/session_notification` with + * `update.sessionUpdate === "workflow_updated"` (snake_case tag from the + * Rust `SessionUpdate` enum). T3 previously dropped the whole extension. + */ +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; + +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; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +export function parseXAiWorkflowUpdated(payload: unknown): GrokWorkflowUpdated | undefined { + const envelope = asRecord(payload); + const update = asRecord(envelope?.update) ?? envelope; + if (!update) { + return undefined; + } + const tag = readString(update.sessionUpdate) ?? readString(update.session_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; + } + 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 label = readString(record?.label) ?? agentId; + const state = readString(record?.state); + if (agentId === undefined || state === undefined) { + return []; + } + return [ + { + agentId, + label: 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 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" + ); +} diff --git a/docs/user/providers-grok.md b/docs/user/providers-grok.md index ffae94a0cf92..7908493a8363 100644 --- a/docs/user/providers-grok.md +++ b/docs/user/providers-grok.md @@ -29,7 +29,43 @@ 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. +## 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: + +- the run becomes a `local_workflow` task (name, objective, phases) +- each child agent becomes a `subagent` task under that run +- tokens reported on workflow agents are folded into thread usage + +Slash commands such as `/workflow pause|resume|stop` still belong to the Grok CLI session. T3 +does not reimplement the Rhai host. It consumes the ACP notifications the host already emits. + +## 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. + +Cost ticks (`costUsdTicks`) are not billed in T3 yet. Incomplete or partial Grok bills are +treated as token counts only. + +## Rewind + +Conversation rollback uses Grok's `_x.ai/rewind` extension. T3 maps "undo N turns" onto rewind +points and 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 plan mode, goals, queue, hooks, plugins, +marketplace updates, auto-compact, monitors, and scheduled tasks. Those notifications are +accepted and ignored until a later change maps them. The Grok CLI TUI remains the source of +truth for `/workflows`, `/plan`, and `/usage`. From 1d45fdb229f837cf38fc96a95809519d74ff921a Mon Sep 17 00:00:00 2001 From: maslin Date: Sat, 15 Aug 2026 18:45:08 +0700 Subject: [PATCH 05/26] fix(grok): align workflow ingest with Claude and Codex Claude stamps workflow members with parentAgentId, timelineBypass, and a stable slot id. Codex does the same for collab children and keeps child tokens on typedUsage. The first Grok ingest used agentId and overwrote the thread context window with the child token sum. Extract a pure mapper, complete first-seen terminal runs, skip unchanged member ticks, and ingest Grok's standalone subagent ACP updates on that same path. --- apps/server/scripts/acp-mock-agent.ts | 14 + .../src/provider/Layers/GrokAdapter.test.ts | 35 ++ .../server/src/provider/Layers/GrokAdapter.ts | 240 +++----- .../src/provider/acp/GrokAcpWorkflow.test.ts | 108 ++++ .../src/provider/acp/GrokAcpWorkflow.ts | 520 ++++++++++++++++++ .../src/provider/acp/XAiAcpExtension.test.ts | 3 +- .../src/provider/acp/XAiAcpExtension.ts | 166 +----- docs/user/providers-grok.md | 7 +- 8 files changed, 750 insertions(+), 343 deletions(-) create mode 100644 apps/server/src/provider/acp/GrokAcpWorkflow.test.ts create mode 100644 apps/server/src/provider/acp/GrokAcpWorkflow.ts diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 84481eaa0b3d..c1b98a7ac411 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -299,6 +299,7 @@ const grokAcpModels: ReadonlyArray = [ const enableRewind = process.env.T3_ACP_ENABLE_REWIND === "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 { @@ -905,6 +906,19 @@ const program = Effect.gen(function* () { }); } + if (emitSubagent) { + writeJsonRpcNotification("x.ai/session_notification", { + 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("x.ai/session_notification", { sessionId: requestedSessionId, diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index d3e2bb383ca1..89233b90e29b 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -1414,4 +1414,39 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { 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); + }), + ); }); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index cc10b4253aa5..b9118b9eb560 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -6,6 +6,7 @@ import { type ProviderRuntimeEvent, type ProviderSession, type ProviderUserInputAnswers, + type ThreadTokenUsageSnapshot, ProviderDriverKind, ProviderInstanceId, RuntimeRequestId, @@ -71,20 +72,22 @@ import { extractXAiAskUserQuestions, grokPromptCountForTurns, grokRewindTargetForTurnCount, - grokWorkflowAgentIsTerminal, - grokWorkflowAgentStatus, - grokWorkflowRunIsTerminal, - grokWorkflowRunStatus, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, parseGrokRewindExecute, parseGrokRewindPoints, - parseXAiWorkflowUpdated, promptResponseHasMissingXAiStopReason, XAiAskUserQuestionRequest, XAiSessionNotification, - type GrokWorkflowUpdated, } from "../acp/XAiAcpExtension.ts"; +import { + applyGrokSubagentUpdate, + applyGrokWorkflowUpdate, + emptyGrokWorkflowTrackState, + parseXAiSubagentUpdate, + parseXAiWorkflowUpdated, + type GrokWorkflowTrackState, +} from "../acp/GrokAcpWorkflow.ts"; import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -140,8 +143,8 @@ interface GrokSessionContext { reasoningEffortMenus: Map>; maxTokensByModel: Map; maxTokens: number | undefined; - workflowRunIds: Set; - workflowAgentIds: Set; + lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; + workflowTrack: GrokWorkflowTrackState; stopped: boolean; } @@ -298,178 +301,43 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const offerRuntimeEvent = (event: ProviderRuntimeEvent) => PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); - const emitGrokWorkflowRuntimeEvents = (input: { + const emitGrokTaskSpecs = (input: { readonly threadId: ThreadId; readonly turnId: TurnId | undefined; - readonly update: GrokWorkflowUpdated; readonly method: string; readonly payload: unknown; + readonly specs: ReadonlyArray<{ + readonly type: "task.started" | "task.progress" | "task.completed"; + readonly payload: Record; + }>; }) => - Effect.gen(function* () { - const ctx = sessions.get(input.threadId); - if (!ctx) { - return; - } - const stamp = yield* makeEventStamp(); - const raw = { - source: "acp.grok.extension" as const, - method: input.method, - payload: input.payload, - }; - const phases = input.update.phases.map((phase, index) => ({ - index, - title: phase.title, - })); - const currentPhaseIndex = input.update.currentPhase - ? input.update.phases.findIndex((phase) => phase.title === input.update.currentPhase) - : -1; - const runStatus = grokWorkflowRunStatus(input.update.status); - const runStarted = ctx.workflowRunIds.has(input.update.runId); - if (!runStarted) { - ctx.workflowRunIds.add(input.update.runId); - } - const runPayload = { - taskId: RuntimeTaskId.make(input.update.runId), - description: input.update.objective || input.update.name, - taskType: "local_workflow", - workflowName: input.update.name, - title: input.update.name, - ...(phases.length > 0 ? { phases } : {}), - ...(currentPhaseIndex >= 0 ? { phaseIndex: currentPhaseIndex } : {}), - ...(input.update.currentPhase ? { phaseTitle: input.update.currentPhase } : {}), - runHandles: { runId: input.update.runId }, - }; - yield* offerRuntimeEvent({ - type: runStarted ? "task.progress" : "task.started", - ...stamp, - provider: PROVIDER, - threadId: input.threadId, - turnId: input.turnId, - payload: runStarted - ? { - ...runPayload, - summary: - input.update.currentAgentLabel ?? - input.update.currentPhase ?? - input.update.status, - status: runStatus, - } - : runPayload, - raw, - }); - if (runStarted && grokWorkflowRunIsTerminal(input.update.status)) { - yield* offerRuntimeEvent({ - type: "task.completed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId: input.turnId, - payload: { - taskId: RuntimeTaskId.make(input.update.runId), - status: - input.update.status === "failed" - ? "failed" - : input.update.status === "complete" - ? "completed" - : "stopped", - summary: - input.update.resultSummary ?? input.update.pauseMessage ?? input.update.status, - taskType: "local_workflow", - workflowName: input.update.name, - title: input.update.name, - ...(phases.length > 0 ? { phases } : {}), - runHandles: { runId: input.update.runId }, - }, - raw, - }); - } - - let workflowTokens = 0; - for (const [agentIndex, agent] of input.update.agents.entries()) { - workflowTokens += agent.tokensUsed; - const agentStarted = ctx.workflowAgentIds.has(agent.agentId); - if (!agentStarted) { - ctx.workflowAgentIds.add(agent.agentId); - } - const agentStatus = grokWorkflowAgentStatus(agent.state); - const agentPayload = { - taskId: RuntimeTaskId.make(agent.agentId), - description: agent.label, - taskType: "subagent", - agentId: input.update.runId, - title: agent.label, - workflowName: input.update.name, - ...(agent.model ? { model: agent.model } : {}), - ...(agent.phase ? { phaseTitle: agent.phase } : {}), - agentIndex, - }; - yield* offerRuntimeEvent({ - type: agentStarted ? "task.progress" : "task.started", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId: input.turnId, - payload: agentStarted - ? { - ...agentPayload, - summary: agent.state, - status: agentStatus, - ...(agent.tokensUsed > 0 || agent.durationMs > 0 - ? { - typedUsage: { - totalTokens: agent.tokensUsed, - ...(agent.durationMs > 0 ? { durationMs: agent.durationMs } : {}), - }, - } - : {}), - } - : agentPayload, - raw, - }); - if (agentStarted && grokWorkflowAgentIsTerminal(agent.state)) { + Effect.forEach( + input.specs, + (spec) => + Effect.gen(function* () { + const taskIdValue = spec.payload.taskId; + if (typeof taskIdValue !== "string" || taskIdValue.length === 0) { + return; + } yield* offerRuntimeEvent({ - type: "task.completed", + type: spec.type, ...(yield* makeEventStamp()), provider: PROVIDER, threadId: input.threadId, turnId: input.turnId, payload: { - taskId: RuntimeTaskId.make(agent.agentId), - status: - agent.state === "failed" || agent.state === "error" - ? "failed" - : agent.state === "cancelled" - ? "stopped" - : "completed", - summary: agent.label, - taskType: "subagent", - agentId: input.update.runId, - title: agent.label, - workflowName: input.update.name, - ...(agent.tokensUsed > 0 ? { typedUsage: { totalTokens: agent.tokensUsed } } : {}), + ...spec.payload, + taskId: RuntimeTaskId.make(taskIdValue), }, - raw, - }); - } - } - - if (workflowTokens > 0) { - yield* offerRuntimeEvent({ - type: "thread.token-usage.updated", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId: input.turnId, - payload: { - usage: { - usedTokens: workflowTokens, - lastUsedTokens: workflowTokens, + raw: { + source: "acp.grok.extension", + method: input.method, + payload: input.payload, }, - }, - raw, - }); - } - }); + } as ProviderRuntimeEvent); + }), + { discard: true }, + ); const getThreadSemaphore = (threadId: string) => SynchronizedRef.modifyEffect(threadLocksRef, (current) => { @@ -866,6 +734,9 @@ 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( ["x.ai/session_notification", "_x.ai/session_notification"] as const, (method) => @@ -873,16 +744,36 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte mapAcpCallbackFailure( Effect.gen(function* () { yield* logNative(input.threadId, method, params); - const update = parseXAiWorkflowUpdated(params); - if (!update) { + const ctx = sessions.get(input.threadId); + if (!ctx) { + return; + } + 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; } - yield* emitGrokWorkflowRuntimeEvents({ + const subagent = parseXAiSubagentUpdate(params); + if (!subagent) { + return; + } + const applied = applyGrokSubagentUpdate(ctx.workflowTrack, subagent); + ctx.workflowTrack = applied.state; + yield* emitGrokTaskSpecs({ threadId: input.threadId, - turnId: resolveSessionCallbackTurnId(sessions, input.threadId), - update, + turnId, method, payload: params, + specs: applied.events, }); }), ), @@ -1023,8 +914,8 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte maxTokens: (boundModelId ? maxTokensByModel.get(boundModelId) : undefined) ?? currentGrokMaxTokensFromSessionSetup(started.sessionSetupResult), - workflowRunIds: new Set(), - workflowAgentIds: new Set(), + lastKnownTokenUsage: undefined, + workflowTrack: emptyGrokWorkflowTrackState(), stopped: false, }; @@ -1414,6 +1305,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte appendPromptResultToTurn(ctx, prepared.turnId, prepared.promptParts, result); const tokenUsage = extractGrokTokenUsage(result._meta, ctx.maxTokens); if (tokenUsage) { + ctx.lastKnownTokenUsage = tokenUsage; yield* offerRuntimeEvent({ type: "thread.token-usage.updated", ...(yield* makeEventStamp()), 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..d9268b756811 --- /dev/null +++ b/apps/server/src/provider/acp/GrokAcpWorkflow.test.ts @@ -0,0 +1,108 @@ +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", 0), + parentAgentId: "wf_review_1", + timelineBypass: true, + }); + expect(memberStarted?.payload.agentId).toBeUndefined(); + expect(first.events.some((event) => event.type === "thread.token-usage.updated")).toBe(false); + }); + + 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 } }, + }); + }); +}); diff --git a/apps/server/src/provider/acp/GrokAcpWorkflow.ts b/apps/server/src/provider/acp/GrokAcpWorkflow.ts new file mode 100644 index 000000000000..3e0bb04738d5 --- /dev/null +++ b/apps/server/src/provider/acp/GrokAcpWorkflow.ts @@ -0,0 +1,520 @@ +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 GrokWorkflowTrackState { + readonly seenRunIds: ReadonlySet; + readonly completedRunIds: ReadonlySet; + readonly seenMemberIds: ReadonlySet; + readonly completedMemberIds: ReadonlySet; + readonly memberFingerprints: ReadonlyMap; + readonly seenSubagentIds: ReadonlySet; + readonly completedSubagentIds: ReadonlySet; +} + +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(), + }; +} + +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, index: number): string { + return `${runId}:wf:${index}`; +} + +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"; +} + +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 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, agentIndex); + 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 }); + } + events.push({ + type: "task.progress", + payload: { + ...linkage, + summary: agent.state, + status, + ...(agent.tokensUsed > 0 || agent.durationMs > 0 + ? { + typedUsage: { + totalTokens: agent.tokensUsed, + ...(agent.durationMs > 0 ? { durationMs: agent.durationMs } : {}), + }, + } + : {}), + }, + }); + if (grokWorkflowAgentIsTerminal(agent.state) && !completedMemberIds.has(memberId)) { + completedMemberIds.add(memberId); + events.push({ + type: "task.completed", + payload: { + ...linkage, + status: agentCompletedStatus(agent.state), + summary: agent.label, + ...(agent.tokensUsed > 0 ? { typedUsage: { totalTokens: agent.tokensUsed } } : {}), + }, + }); + } + } + + return { + state: { + ...state, + seenRunIds, + completedRunIds, + seenMemberIds, + completedMemberIds, + memberFingerprints, + }, + 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 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)) { + events.push({ + type: "task.progress", + payload: { + ...linkage, + status: "running", + summary: update.role ?? "running", + ...(update.tokensUsed !== undefined || update.durationMs !== undefined + ? { + typedUsage: { + totalTokens: update.tokensUsed ?? 0, + ...(update.durationMs !== undefined ? { durationMs: update.durationMs } : {}), + ...(update.toolCallCount !== undefined ? { toolUses: update.toolCallCount } : {}), + }, + } + : {}), + }, + }); + } + } 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"; + events.push({ + type: "task.completed", + payload: { + ...linkage, + status: + finished === "failed" ? "failed" : finished === "cancelled" ? "stopped" : "completed", + summary: update.error ?? update.output ?? finished, + ...(update.tokensUsed !== undefined + ? { typedUsage: { totalTokens: update.tokensUsed } } + : {}), + }, + }); + } + + return { + state: { + ...state, + seenSubagentIds, + completedSubagentIds, + }, + events, + }; +} diff --git a/apps/server/src/provider/acp/XAiAcpExtension.test.ts b/apps/server/src/provider/acp/XAiAcpExtension.test.ts index 21bbb97640c3..7c6dd98eb5a8 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.test.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.test.ts @@ -13,14 +13,13 @@ import { extractXAiAskUserQuestions, grokPromptCountForTurns, grokRewindTargetForTurnCount, - grokWorkflowRunStatus, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, makeXAiPromptCompletionRuntime, parseGrokRewindPoints, - parseXAiWorkflowUpdated, 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)); diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts index 24fec72de4cc..69784bd62385 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -1,6 +1,5 @@ import type { ProviderUserInputAnswers, - RuntimeTaskStatus, ThreadTokenUsageSnapshot, UserInputQuestion, } from "@t3tools/contracts"; @@ -646,9 +645,8 @@ export function extractGrokTokenUsage( } /** - * Grok Build fires workflow progress as `x.ai/session_notification` with - * `update.sessionUpdate === "workflow_updated"` (snake_case tag from the - * Rust `SessionUpdate` enum). T3 previously dropped the whole extension. + * 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), @@ -657,163 +655,3 @@ export const XAiSessionNotification = Schema.Struct({ _meta: Schema.optional(Schema.Unknown), }); export type XAiSessionNotification = typeof XAiSessionNotification.Type; - -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; -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - -export function parseXAiWorkflowUpdated(payload: unknown): GrokWorkflowUpdated | undefined { - const envelope = asRecord(payload); - const update = asRecord(envelope?.update) ?? envelope; - if (!update) { - return undefined; - } - const tag = readString(update.sessionUpdate) ?? readString(update.session_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; - } - 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 label = readString(record?.label) ?? agentId; - const state = readString(record?.state); - if (agentId === undefined || state === undefined) { - return []; - } - return [ - { - agentId, - label: 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 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" - ); -} diff --git a/docs/user/providers-grok.md b/docs/user/providers-grok.md index 7908493a8363..0ed7680f506a 100644 --- a/docs/user/providers-grok.md +++ b/docs/user/providers-grok.md @@ -35,11 +35,12 @@ Grok Build workflows are Rhai scripts that orchestrate child agents as one backg 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: +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 under that run -- tokens reported on workflow agents are folded into thread usage +- 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 Slash commands such as `/workflow pause|resume|stop` still belong to the Grok CLI session. T3 does not reimplement the Rhai host. It consumes the ACP notifications the host already emits. From c99fc87ac9e27b403486a268035f941913a00643 Mon Sep 17 00:00:00 2001 From: maslin Date: Sat, 15 Aug 2026 19:12:19 +0700 Subject: [PATCH 06/26] fix(grok): close remaining Macroscope and Bugbot findings Pass spawn-time effort before ACP discovery. Hide Reasoning on models that do not support it, and keep the fallback menu only when support is advertised without choices. Key workflow members by agent_id, clear stale maxTokens on model switch, and keep duration/tool-use on subagent finish. --- .../server/src/provider/Layers/GrokAdapter.ts | 2 +- .../src/provider/Layers/GrokProvider.ts | 6 +- .../src/provider/acp/GrokAcpSupport.test.ts | 34 +++++++++++ .../server/src/provider/acp/GrokAcpSupport.ts | 15 +++++ .../src/provider/acp/GrokAcpWorkflow.test.ts | 58 ++++++++++++++++++- .../src/provider/acp/GrokAcpWorkflow.ts | 41 ++++++++----- 6 files changed, 135 insertions(+), 21 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index b9118b9eb560..e6aa74b53879 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -1107,7 +1107,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ctx.currentModelId = currentModelId; ctx.currentReasoningEffort = turnSelection.reasoningEffort; if (currentModelId) { - ctx.maxTokens = ctx.maxTokensByModel.get(currentModelId) ?? ctx.maxTokens; + ctx.maxTokens = ctx.maxTokensByModel.get(currentModelId); } const text = input.input?.trim(); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index a19f63597784..c41d83d9a4f0 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -30,7 +30,7 @@ import { } from "../providerMaintenance.ts"; import { fallbackGrokReasoningEffortCapabilities, - grokReasoningEffortCapabilities, + grokDiscoveredModelCapabilities, isGrokAcpAuthFailure, makeGrokAcpRuntime, parseGrokAcpModelMeta, @@ -123,9 +123,7 @@ function buildGrokDiscoveredModelsFromSessionModelState( slug, name: model.name.trim() || slug, isCustom: false, - capabilities: meta.supportsReasoningEffort - ? grokReasoningEffortCapabilities(meta.reasoningEfforts) - : FALLBACK_CAPABILITIES, + capabilities: grokDiscoveredModelCapabilities(meta), }; }) .filter((model): model is ServerProviderModel => model !== undefined); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index d3011ef818d1..15f126526a51 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -9,6 +9,7 @@ import { grokReasoningEffortCapabilities, isGrokAcpAuthFailure, parseGrokAcpModelMeta, + grokDiscoveredModelCapabilities, requestedGrokReasoningEffort, resolveGrokAcpBaseModelId, } from "./GrokAcpSupport.ts"; @@ -89,6 +90,39 @@ describe("requestedGrokReasoningEffort", () => { ), ).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("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", () => { diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index 309e8bedc9e1..0dcd80cdad5d 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -263,12 +263,27 @@ export function requestedGrokReasoningEffort( 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 diff --git a/apps/server/src/provider/acp/GrokAcpWorkflow.test.ts b/apps/server/src/provider/acp/GrokAcpWorkflow.test.ts index d9268b756811..3db2529c651c 100644 --- a/apps/server/src/provider/acp/GrokAcpWorkflow.test.ts +++ b/apps/server/src/provider/acp/GrokAcpWorkflow.test.ts @@ -43,7 +43,7 @@ describe("GrokAcpWorkflow", () => { (event) => event.type === "task.started" && event.payload.taskType === "subagent", ); expect(memberStarted?.payload).toMatchObject({ - taskId: grokWorkflowMemberTaskId("wf_review_1", 0), + taskId: grokWorkflowMemberTaskId("wf_review_1", "agent_reviewer"), parentAgentId: "wf_review_1", timelineBypass: true, }); @@ -105,4 +105,60 @@ describe("GrokAcpWorkflow", () => { 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, + }); + }); }); diff --git a/apps/server/src/provider/acp/GrokAcpWorkflow.ts b/apps/server/src/provider/acp/GrokAcpWorkflow.ts index 3e0bb04738d5..0f8aab08bfa8 100644 --- a/apps/server/src/provider/acp/GrokAcpWorkflow.ts +++ b/apps/server/src/provider/acp/GrokAcpWorkflow.ts @@ -278,8 +278,8 @@ export function grokWorkflowAgentIsTerminal(state: string): boolean { ); } -export function grokWorkflowMemberTaskId(runId: string, index: number): string { - return `${runId}:wf:${index}`; +export function grokWorkflowMemberTaskId(runId: string, agentId: string): string { + return `${runId}:wf:${agentId}`; } function memberFingerprint(agent: GrokWorkflowAgent, status: RuntimeTaskStatus): string { @@ -305,6 +305,25 @@ function agentCompletedStatus(state: string): "completed" | "failed" | "stopped" return "completed"; } +function typedUsageFromCounts(input: { + readonly tokensUsed?: number; + readonly durationMs?: number; + readonly toolCallCount?: number; +}): Record | undefined { + if ( + input.tokensUsed === undefined && + input.durationMs === undefined && + input.toolCallCount === undefined + ) { + return undefined; + } + return { + totalTokens: input.tokensUsed ?? 0, + ...(input.durationMs !== undefined ? { durationMs: input.durationMs } : {}), + ...(input.toolCallCount !== undefined ? { toolUses: input.toolCallCount } : {}), + }; +} + export function applyGrokWorkflowUpdate( state: GrokWorkflowTrackState, update: GrokWorkflowUpdated, @@ -375,7 +394,7 @@ export function applyGrokWorkflowUpdate( } for (const [agentIndex, agent] of update.agents.entries()) { - const memberId = grokWorkflowMemberTaskId(update.runId, agentIndex); + const memberId = grokWorkflowMemberTaskId(update.runId, agent.agentId); const status = grokWorkflowAgentStatus(agent.state); const fingerprint = memberFingerprint(agent, status); if (memberFingerprints.get(memberId) === fingerprint) { @@ -470,21 +489,14 @@ export function applyGrokSubagentUpdate( events.push({ type: "task.started", payload: linkage }); } if (!completedSubagentIds.has(update.subagentId)) { + const typedUsage = typedUsageFromCounts(update); events.push({ type: "task.progress", payload: { ...linkage, status: "running", summary: update.role ?? "running", - ...(update.tokensUsed !== undefined || update.durationMs !== undefined - ? { - typedUsage: { - totalTokens: update.tokensUsed ?? 0, - ...(update.durationMs !== undefined ? { durationMs: update.durationMs } : {}), - ...(update.toolCallCount !== undefined ? { toolUses: update.toolCallCount } : {}), - }, - } - : {}), + ...(typedUsage ? { typedUsage } : {}), }, }); } @@ -495,6 +507,7 @@ export function applyGrokSubagentUpdate( } completedSubagentIds.add(update.subagentId); const finished = update.status ?? "completed"; + const typedUsage = typedUsageFromCounts(update); events.push({ type: "task.completed", payload: { @@ -502,9 +515,7 @@ export function applyGrokSubagentUpdate( status: finished === "failed" ? "failed" : finished === "cancelled" ? "stopped" : "completed", summary: update.error ?? update.output ?? finished, - ...(update.tokensUsed !== undefined - ? { typedUsage: { totalTokens: update.tokensUsed } } - : {}), + ...(typedUsage ? { typedUsage } : {}), }, }); } From 615135ece51b1a83a1aab67c5e227c769a281f9d Mon Sep 17 00:00:00 2001 From: maslin Date: Sat, 15 Aug 2026 23:25:21 +0700 Subject: [PATCH 07/26] fix(grok): cancel in-flight prompts before rewind and keep subagent tokens Rewind now stops the live ACP prompt first so a late session/prompt cannot re-append a discarded turn. Tool-only subagent ticks reuse the last known token count instead of publishing totalTokens: 0. --- .../src/provider/Layers/GrokAdapter.test.ts | 64 +++++++++++++++++ .../server/src/provider/Layers/GrokAdapter.ts | 41 +++++++++++ .../src/provider/acp/GrokAcpWorkflow.test.ts | 37 ++++++++++ .../src/provider/acp/GrokAcpWorkflow.ts | 69 +++++++++++++------ 4 files changed, 191 insertions(+), 20 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 89233b90e29b..9c5b620e1c02 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -1380,6 +1380,70 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + 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("projects Grok workflow_updated notifications as task events", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-workflow"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index e6aa74b53879..e170731e560e 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -1569,6 +1569,46 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte return { threadId, turns: ctx.turns }; }); + const cancelActivePromptsBeforeRewind = (ctx: GrokSessionContext) => + Effect.gen(function* () { + 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; + } + 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, @@ -1581,6 +1621,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte issue: "numTurns must be an integer >= 1.", }); } + yield* cancelActivePromptsBeforeRewind(ctx); const promptCount = grokPromptCountForTurns(ctx.turns, numTurns); if (promptCount < 1) { return yield* new ProviderAdapterRequestError({ diff --git a/apps/server/src/provider/acp/GrokAcpWorkflow.test.ts b/apps/server/src/provider/acp/GrokAcpWorkflow.test.ts index 3db2529c651c..70dc785ce09e 100644 --- a/apps/server/src/provider/acp/GrokAcpWorkflow.test.ts +++ b/apps/server/src/provider/acp/GrokAcpWorkflow.test.ts @@ -161,4 +161,41 @@ describe("GrokAcpWorkflow", () => { 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 index 0f8aab08bfa8..b42ea2f606d8 100644 --- a/apps/server/src/provider/acp/GrokAcpWorkflow.ts +++ b/apps/server/src/provider/acp/GrokAcpWorkflow.ts @@ -55,6 +55,12 @@ export interface GrokSubagentUpdate { 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; @@ -63,6 +69,8 @@ export interface GrokWorkflowTrackState { 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 { @@ -74,6 +82,7 @@ export function emptyGrokWorkflowTrackState(): GrokWorkflowTrackState { memberFingerprints: new Map(), seenSubagentIds: new Set(), completedSubagentIds: new Set(), + usageByTaskId: new Map(), }; } @@ -305,22 +314,29 @@ function agentCompletedStatus(state: string): "completed" | "failed" | "stopped" return "completed"; } -function typedUsageFromCounts(input: { - readonly tokensUsed?: number; - readonly durationMs?: number; - readonly toolCallCount?: number; -}): Record | undefined { +function mergeTypedUsageFromCounts( + input: { + readonly tokensUsed?: number; + readonly durationMs?: number; + readonly toolCallCount?: number; + }, + previous: GrokTypedUsageSnapshot | undefined, +): GrokTypedUsageSnapshot | undefined { if ( input.tokensUsed === undefined && input.durationMs === undefined && input.toolCallCount === undefined ) { - return 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 ?? 0, - ...(input.durationMs !== undefined ? { durationMs: input.durationMs } : {}), - ...(input.toolCallCount !== undefined ? { toolUses: input.toolCallCount } : {}), + totalTokens: input.tokensUsed ?? previous?.totalTokens ?? 0, + ...(durationMs !== undefined ? { durationMs } : {}), + ...(toolUses !== undefined ? { toolUses } : {}), }; } @@ -333,6 +349,7 @@ export function applyGrokWorkflowUpdate( 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 })); @@ -418,20 +435,23 @@ export function applyGrokWorkflowUpdate( 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, - ...(agent.tokensUsed > 0 || agent.durationMs > 0 - ? { - typedUsage: { - totalTokens: agent.tokensUsed, - ...(agent.durationMs > 0 ? { durationMs: agent.durationMs } : {}), - }, - } - : {}), + ...(typedUsage ? { typedUsage } : {}), }, }); if (grokWorkflowAgentIsTerminal(agent.state) && !completedMemberIds.has(memberId)) { @@ -442,7 +462,7 @@ export function applyGrokWorkflowUpdate( ...linkage, status: agentCompletedStatus(agent.state), summary: agent.label, - ...(agent.tokensUsed > 0 ? { typedUsage: { totalTokens: agent.tokensUsed } } : {}), + ...(typedUsage ? { typedUsage } : {}), }, }); } @@ -456,6 +476,7 @@ export function applyGrokWorkflowUpdate( seenMemberIds, completedMemberIds, memberFingerprints, + usageByTaskId, }, events, }; @@ -467,6 +488,7 @@ export function applyGrokSubagentUpdate( ): { 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 = { @@ -489,7 +511,10 @@ export function applyGrokSubagentUpdate( events.push({ type: "task.started", payload: linkage }); } if (!completedSubagentIds.has(update.subagentId)) { - const typedUsage = typedUsageFromCounts(update); + const typedUsage = mergeTypedUsageFromCounts(update, usageByTaskId.get(update.subagentId)); + if (typedUsage) { + usageByTaskId.set(update.subagentId, typedUsage); + } events.push({ type: "task.progress", payload: { @@ -507,7 +532,10 @@ export function applyGrokSubagentUpdate( } completedSubagentIds.add(update.subagentId); const finished = update.status ?? "completed"; - const typedUsage = typedUsageFromCounts(update); + const typedUsage = mergeTypedUsageFromCounts(update, usageByTaskId.get(update.subagentId)); + if (typedUsage) { + usageByTaskId.set(update.subagentId, typedUsage); + } events.push({ type: "task.completed", payload: { @@ -525,6 +553,7 @@ export function applyGrokSubagentUpdate( ...state, seenSubagentIds, completedSubagentIds, + usageByTaskId, }, events, }; From 5c8bd7c6f745f0adb4197a29611f8a74fb354ee4 Mon Sep 17 00:00:00 2001 From: maslin Date: Sat, 15 Aug 2026 23:54:21 +0700 Subject: [PATCH 08/26] fix(grok): emit token usage from the sendTurn success fallback The Effect.ensuring path already kept the prompt result after sendTurn was interrupted. It now publishes thread.token-usage.updated the same way the happy path does. --- apps/server/scripts/acp-mock-agent.ts | 8 ++++- .../src/provider/Layers/GrokAdapter.test.ts | 22 +++++++++--- .../server/src/provider/Layers/GrokAdapter.ts | 35 ++++++++++++------- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index c1b98a7ac411..c874b0bb0262 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -569,7 +569,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) { diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 9c5b620e1c02..2ceb583e5906 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -424,13 +424,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 +468,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", () => diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index e170731e560e..e100e68917f1 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -498,6 +498,27 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } }); + 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) => Effect.gen(function* () { if (!nativeEventLogger) return; @@ -1303,18 +1324,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } appendPromptResultToTurn(ctx, prepared.turnId, prepared.promptParts, result); - const tokenUsage = extractGrokTokenUsage(result._meta, ctx.maxTokens); - if (tokenUsage) { - ctx.lastKnownTokenUsage = tokenUsage; - yield* offerRuntimeEvent({ - type: "thread.token-usage.updated", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId: prepared.turnId, - payload: { usage: tokenUsage }, - }); - } + yield* publishGrokPromptUsage(ctx, prepared.turnId, result); ctx.session = { ...ctx.session, status: "running", @@ -1419,6 +1429,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte prepared.promptParts, promptResult, ); + yield* publishGrokPromptUsage(ctx, prepared.turnId, promptResult); yield* settlePromptInFlight( input.threadId, prepared.turnId, From b0079ede306615cfe38fd8d2df5713660cb118cc Mon Sep 17 00:00:00 2001 From: maslin Date: Sun, 16 Aug 2026 11:11:21 +0700 Subject: [PATCH 09/26] fix(grok): close reported model, login, permission, and tool-update issues Map the product slug grok-build onto a live ACP model before session/set_model (#6075). If Always allow has no allow_always option, fall back to allow_once instead of cancelling the turn (#6502). Skip ACP startup on provider probe when there is no XAI_API_KEY or ~/.grok/auth.json, so a background check cannot open a login browser (#5852, #4983). Bound high-churn tool_call_update payloads so a redrawing terminal does not flood ingestion (#6556). --- .../src/provider/Layers/GrokAdapter.test.ts | 24 ++++- .../server/src/provider/Layers/GrokAdapter.ts | 59 +++++++++++- .../src/provider/Layers/GrokProvider.test.ts | 28 ++++++ .../src/provider/Layers/GrokProvider.ts | 58 ++++++++++- .../src/provider/acp/GrokAcpSupport.test.ts | 48 ++++++++++ .../server/src/provider/acp/GrokAcpSupport.ts | 57 ++++++++++- .../provider/acp/GrokAcpToolUpdates.test.ts | 64 +++++++++++++ .../src/provider/acp/GrokAcpToolUpdates.ts | 96 +++++++++++++++++++ docs/user/providers-grok.md | 8 +- 9 files changed, 430 insertions(+), 12 deletions(-) create mode 100644 apps/server/src/provider/acp/GrokAcpToolUpdates.test.ts create mode 100644 apps/server/src/provider/acp/GrokAcpToolUpdates.ts diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 2ceb583e5906..6089dfc9105a 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"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index e100e68917f1..8f6384fe9adf 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -66,7 +66,14 @@ import { makeGrokAcpRuntime, requestedGrokReasoningEffort, resolveGrokAcpBaseModelId, + availableGrokSessionModelIds, } from "../acp/GrokAcpSupport.ts"; +import { + boundGrokToolCallForEvent, + grokToolCallFingerprint, + shouldEmitGrokToolUpdate, + type GrokToolUpdateGate, +} from "../acp/GrokAcpToolUpdates.ts"; import { extractGrokTokenUsage, extractXAiAskUserQuestions, @@ -144,7 +151,9 @@ interface GrokSessionContext { maxTokensByModel: Map; maxTokens: number | undefined; lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; + availableModelIds: ReadonlyArray; workflowTrack: GrokWorkflowTrackState; + readonly toolUpdateGates: Map; stopped: boolean; } @@ -207,7 +216,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 { @@ -218,7 +227,23 @@ 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 selectPermissionOptionId( + request: EffectAcpSchema.RequestPermissionRequest, + decision: Exclude, +): string | undefined { + return selectGrokPermissionOptionId(request, decision); } function selectAutoApprovedPermissionOption( @@ -874,6 +899,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ); const startedModelId = currentGrokModelIdFromSessionSetup(started.sessionSetupResult); + const availableModelIds = availableGrokSessionModelIds(started.sessionSetupResult); const advertisedStartEfforts = advertisedGrokReasoningEffortsFromSessionSetup( started.sessionSetupResult, requestedStartModelId ?? startedModelId, @@ -882,6 +908,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte runtime: acp, currentModelId: startedModelId, requestedModelId: requestedStartModelId, + availableModelIds, currentReasoningEffort: currentGrokReasoningEffortFromSessionSetup( started.sessionSetupResult, ), @@ -936,7 +963,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte (boundModelId ? maxTokensByModel.get(boundModelId) : undefined) ?? currentGrokMaxTokensFromSessionSetup(started.sessionSetupResult), lastKnownTokenUsage: undefined, + availableModelIds, workflowTrack: emptyGrokWorkflowTrackState(), + toolUpdateGates: new Map(), stopped: false, }; @@ -1003,18 +1032,37 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte "session/update", ); return; - case "ToolCallUpdated": + case "ToolCallUpdated": { + const nowMs = Date.now(); + 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({ @@ -1115,6 +1163,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const turnSelection = yield* applyGrokAcpModelSelection({ runtime: ctx.acp, currentModelId: ctx.currentModelId, + availableModelIds: ctx.availableModelIds, requestedModelId: requestedTurnModelId, currentReasoningEffort: ctx.currentReasoningEffort, requestedReasoningEffort: requestedGrokReasoningEffort( diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 31ebe5e1ae47..e127d9c9803e 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -98,6 +98,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" }, ); }), ); @@ -108,4 +109,31 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { expect(snapshot.message).toContain("ACP startup failed"); }), ); + + it.effect("does not start ACP when Grok has no saved credentials", () => + 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-unauth-" }); + const grokPath = path.join(dir, "grok"); + 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: dir, PATH: process.env.PATH ?? "" }, + ); + }), + ); + + expect(snapshot.status).toBe("error"); + expect(snapshot.auth.status).toBe("unauthenticated"); + expect(snapshot.message).toContain("grok login"); + }), + ); }); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index c41d83d9a4f0..acf84154fe5f 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -36,6 +36,8 @@ import { parseGrokAcpModelMeta, resolveGrokAcpBaseModelId, } from "../acp/GrokAcpSupport.ts"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; const GROK_PRESENTATION = { displayName: "Grok", @@ -129,15 +131,50 @@ function buildGrokDiscoveredModelsFromSessionModelState( .filter((model): model is ServerProviderModel => model !== undefined); } +const grokAuthFileCandidates = ( + environment: NodeJS.ProcessEnv, + path: Path.Path["Service"], +): ReadonlyArray => { + const home = + environment.GROK_HOME?.trim() || environment.HOME?.trim() || environment.USERPROFILE?.trim(); + if (!home) { + return []; + } + return [path.join(home, ".grok", "auth.json"), path.join(home, ".grok", "credentials.json")]; +}; + +const grokHasLocalAuthMaterial = ( + environment: NodeJS.ProcessEnv, +): Effect.Effect => + Effect.gen(function* () { + if (environment[GROK_API_KEY_ENV]?.trim()) { + return true; + } + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const candidate of grokAuthFileCandidates(environment, path)) { + if (yield* fileSystem.exists(candidate)) { + return true; + } + } + return false; + }); + const discoverGrokModelsViaAcp = ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, ) => 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" }, @@ -170,7 +207,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func ): 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); @@ -257,6 +294,23 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func }); } + const hasAuthMaterial = yield* grokHasLocalAuthMaterial(environment); + if (!hasAuthMaterial) { + return buildServerProvider({ + presentation: GROK_PRESENTATION, + enabled: grokSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unauthenticated" }, + message: "Grok CLI is not authenticated. Run `grok login` and try again.", + }, + }); + } + const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe( Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS), Effect.exit, diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index 15f126526a51..4a6517bfb756 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -12,6 +12,7 @@ import { grokDiscoveredModelCapabilities, requestedGrokReasoningEffort, resolveGrokAcpBaseModelId, + resolveGrokSessionModelId, } from "./GrokAcpSupport.ts"; import { ProviderInstanceId } from "@t3tools/contracts"; @@ -23,6 +24,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", { @@ -181,6 +214,21 @@ describe("applyGrokAcpModelSelection", () => { }), ); + 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(); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index 0dcd80cdad5d..3cbf5d30c7c2 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -130,6 +130,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 @@ -377,13 +421,22 @@ 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 { - const nextModelId = input.requestedModelId ?? input.currentModelId; + 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; + requestedModelId !== undefined && requestedModelId !== input.currentModelId; const nextEffort = shouldSwitchModel ? input.requestedReasoningEffort : (input.requestedReasoningEffort ?? input.currentReasoningEffort); 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..bd3169056255 --- /dev/null +++ b/apps/server/src/provider/acp/GrokAcpToolUpdates.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + GROK_TOOL_CONTENT_CHAR_LIMIT, + boundGrokToolCallForEvent, + 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: `${running.toolCallId}\u001finProgress\u001fTerminal\u001f200`, + lastEmittedAt: 0, + }, + nowMs: 1_000, + }), + ).toBe(false); + }); + + 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("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", + }); + }); +}); diff --git a/apps/server/src/provider/acp/GrokAcpToolUpdates.ts b/apps/server/src/provider/acp/GrokAcpToolUpdates.ts new file mode 100644 index 000000000000..ac54db9d909c --- /dev/null +++ b/apps/server/src/provider/acp/GrokAcpToolUpdates.ts @@ -0,0 +1,96 @@ +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; +} + +export function grokToolCallFingerprint(toolCall: AcpToolCallState): string { + const content = toolCall.data.content; + const contentLength = + typeof content === "string" ? content.length : JSON.stringify(content ?? "").length; + return [toolCall.toolCallId, toolCall.status ?? "", toolCall.title ?? "", contentLength].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; + } + const fingerprint = grokToolCallFingerprint(input.toolCall); + if (input.previous?.fingerprint === fingerprint) { + return false; + } + if ( + input.previous !== undefined && + input.nowMs - input.previous.lastEmittedAt < GROK_TOOL_UPDATE_MIN_INTERVAL_MS + ) { + return false; + } + return true; +} + +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; +} + +/** 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: boundUnknown(input.toolCall.data) as Record, + ...(typeof input.toolCall.detail === "string" + ? { detail: truncateText(input.toolCall.detail) } + : {}), + }, + rawPayload: { + truncated: true, + toolCallId: input.toolCall.toolCallId, + status: input.toolCall.status, + }, + }; +} diff --git a/docs/user/providers-grok.md b/docs/user/providers-grok.md index 0ed7680f506a..5cd5d80bc33f 100644 --- a/docs/user/providers-grok.md +++ b/docs/user/providers-grok.md @@ -10,6 +10,8 @@ grok login ``` You can also set `XAI_API_KEY` in the server environment instead of running `grok login`. +Background provider checks do not start a Grok browser login. If the CLI is installed but has no +saved credentials, Settings shows an unauthenticated status and asks you to run `grok login`. In T3 Code Settings, the default Grok provider can stay like this: @@ -23,8 +25,10 @@ Use an explicit binary path when `grok` is not on the `PATH` of the shell that s ## 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`. 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. +`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. From 5f297108af7cbbde59355a510e935c367343a186 Mon Sep 17 00:00:00 2001 From: maslin Date: Sun, 16 Aug 2026 15:23:13 +0700 Subject: [PATCH 10/26] fix(grok): address Macroscope and Bugbot findings on probe, tools, and rewind Drop the incomplete auth-file pre-check so GROK_HOME and provider-command credentials still reach ACP; keep CI/NO_BROWSER on the probe. Read the tool-update clock through Effect Clock, fingerprint more than content length, and bound aggregate tool payloads. Replay session notifications that arrive during acp.start, delete the permission-option wrapper, and keep rewind errors as cause rather than wire detail. --- .../server/src/provider/Layers/GrokAdapter.ts | 92 +++++++++++-------- .../src/provider/Layers/GrokProvider.test.ts | 27 ------ .../src/provider/Layers/GrokProvider.ts | 50 +--------- .../provider/acp/GrokAcpToolUpdates.test.ts | 31 ++++++- .../src/provider/acp/GrokAcpToolUpdates.ts | 29 ++++-- docs/user/providers-grok.md | 4 +- 6 files changed, 108 insertions(+), 125 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 8f6384fe9adf..f9d044952760 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -14,6 +14,7 @@ import { 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"; @@ -239,19 +240,12 @@ export function selectGrokPermissionOptionId( return undefined; } -function selectPermissionOptionId( - request: EffectAcpSchema.RequestPermissionRequest, - decision: Exclude, -): string | undefined { - return selectGrokPermissionOptionId(request, decision); -} - function selectAutoApprovedPermissionOption( request: EffectAcpSchema.RequestPermissionRequest, ): string | undefined { return ( - selectPermissionOptionId(request, "acceptForSession") ?? - selectPermissionOptionId(request, "accept") + selectGrokPermissionOptionId(request, "acceptForSession") ?? + selectGrokPermissionOptionId(request, "accept") ); } @@ -725,6 +719,44 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }), ), ); + const pendingSessionNotifications: Array<{ + readonly method: "x.ai/session_notification" | "_x.ai/session_notification"; + readonly params: unknown; + }> = []; + const applySessionNotification = ( + ctx: GrokSessionContext, + method: "x.ai/session_notification" | "_x.ai/session_notification", + 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) { + return; + } + const applied = applyGrokSubagentUpdate(ctx.workflowTrack, subagent); + ctx.workflowTrack = applied.state; + yield* emitGrokTaskSpecs({ + threadId: input.threadId, + turnId, + method, + payload: params, + specs: applied.events, + }); + }); const started = yield* Effect.gen(function* () { yield* Effect.forEach( ["x.ai/ask_user_question", "_x.ai/ask_user_question"] as const, @@ -792,35 +824,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte yield* logNative(input.threadId, method, params); const ctx = sessions.get(input.threadId); if (!ctx) { + pendingSessionNotifications.push({ method, params }); return; } - 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) { - return; - } - const applied = applyGrokSubagentUpdate(ctx.workflowTrack, subagent); - ctx.workflowTrack = applied.state; - yield* emitGrokTaskSpecs({ - threadId: input.threadId, - turnId, - method, - payload: params, - specs: applied.events, - }); + yield* applySessionNotification(ctx, method, params); }), ), ), @@ -879,7 +886,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 ? { @@ -1033,7 +1042,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ); return; case "ToolCallUpdated": { - const nowMs = Date.now(); + const nowMs = yield* Clock.currentTimeMillis; if ( !shouldEmitGrokToolUpdate({ toolCall: event.toolCall, @@ -1095,6 +1104,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ctx.notificationFiber = nf; sessions.set(input.threadId, ctx); sessionScopeTransferred = true; + yield* Effect.forEach(pendingSessionNotifications, (pending) => + applySessionNotification(ctx, pending.method, pending.params), + ); + pendingSessionNotifications.length = 0; yield* offerRuntimeEvent({ type: "session.started", @@ -1744,7 +1757,8 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "_x.ai/rewind/execute", - detail: executed?.error ?? "Grok rewind did not succeed.", + detail: "Grok rewind did not succeed.", + ...(executed?.error ? { cause: executed.error } : {}), }); } committedCtx.turns = committedCtx.turns.slice( diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index e127d9c9803e..49b251977f4f 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -109,31 +109,4 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { expect(snapshot.message).toContain("ACP startup failed"); }), ); - - it.effect("does not start ACP when Grok has no saved credentials", () => - 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-unauth-" }); - const grokPath = path.join(dir, "grok"); - 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: dir, PATH: process.env.PATH ?? "" }, - ); - }), - ); - - expect(snapshot.status).toBe("error"); - expect(snapshot.auth.status).toBe("unauthenticated"); - expect(snapshot.message).toContain("grok login"); - }), - ); }); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index acf84154fe5f..3d0b974e29be 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -36,8 +36,6 @@ import { parseGrokAcpModelMeta, resolveGrokAcpBaseModelId, } from "../acp/GrokAcpSupport.ts"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; const GROK_PRESENTATION = { displayName: "Grok", @@ -131,35 +129,6 @@ function buildGrokDiscoveredModelsFromSessionModelState( .filter((model): model is ServerProviderModel => model !== undefined); } -const grokAuthFileCandidates = ( - environment: NodeJS.ProcessEnv, - path: Path.Path["Service"], -): ReadonlyArray => { - const home = - environment.GROK_HOME?.trim() || environment.HOME?.trim() || environment.USERPROFILE?.trim(); - if (!home) { - return []; - } - return [path.join(home, ".grok", "auth.json"), path.join(home, ".grok", "credentials.json")]; -}; - -const grokHasLocalAuthMaterial = ( - environment: NodeJS.ProcessEnv, -): Effect.Effect => - Effect.gen(function* () { - if (environment[GROK_API_KEY_ENV]?.trim()) { - return true; - } - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - for (const candidate of grokAuthFileCandidates(environment, path)) { - if (yield* fileSystem.exists(candidate)) { - return true; - } - } - return false; - }); - const discoverGrokModelsViaAcp = ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, @@ -207,7 +176,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func ): Effect.fn.Return< ServerProviderDraft, never, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto > { const checkedAt = DateTime.formatIso(yield* DateTime.now); const fallbackModels = grokModelsFromSettings(grokSettings.customModels); @@ -294,23 +263,6 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func }); } - const hasAuthMaterial = yield* grokHasLocalAuthMaterial(environment); - if (!hasAuthMaterial) { - return buildServerProvider({ - presentation: GROK_PRESENTATION, - enabled: grokSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version, - status: "error", - auth: { status: "unauthenticated" }, - message: "Grok CLI is not authenticated. Run `grok login` and try again.", - }, - }); - } - const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe( Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS), Effect.exit, diff --git a/apps/server/src/provider/acp/GrokAcpToolUpdates.test.ts b/apps/server/src/provider/acp/GrokAcpToolUpdates.test.ts index bd3169056255..3052912f9206 100644 --- a/apps/server/src/provider/acp/GrokAcpToolUpdates.test.ts +++ b/apps/server/src/provider/acp/GrokAcpToolUpdates.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { GROK_TOOL_CONTENT_CHAR_LIMIT, boundGrokToolCallForEvent, + grokToolCallFingerprint, shouldEmitGrokToolUpdate, } from "./GrokAcpToolUpdates.ts"; @@ -20,7 +21,7 @@ describe("GrokAcpToolUpdates", () => { shouldEmitGrokToolUpdate({ toolCall: running, previous: { - fingerprint: `${running.toolCallId}\u001finProgress\u001fTerminal\u001f200`, + fingerprint: grokToolCallFingerprint(running), lastEmittedAt: 0, }, nowMs: 1_000, @@ -28,6 +29,19 @@ describe("GrokAcpToolUpdates", () => { ).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({ @@ -61,4 +75,19 @@ describe("GrokAcpToolUpdates", () => { 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 index ac54db9d909c..024bbcc1b147 100644 --- a/apps/server/src/provider/acp/GrokAcpToolUpdates.ts +++ b/apps/server/src/provider/acp/GrokAcpToolUpdates.ts @@ -11,12 +11,15 @@ export interface GrokToolUpdateGate { } export function grokToolCallFingerprint(toolCall: AcpToolCallState): string { - const content = toolCall.data.content; - const contentLength = - typeof content === "string" ? content.length : JSON.stringify(content ?? "").length; - return [toolCall.toolCallId, toolCall.status ?? "", toolCall.title ?? "", contentLength].join( - "\u001f", - ); + const data = JSON.stringify(toolCall.data) ?? ""; + return [ + toolCall.toolCallId, + toolCall.status ?? "", + toolCall.title ?? "", + toolCall.detail ?? "", + String(data.length), + data.slice(-128), + ].join("\u001f"); } export function shouldEmitGrokToolUpdate(input: { @@ -66,6 +69,18 @@ function boundUnknown(value: unknown): unknown { 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; @@ -82,7 +97,7 @@ export function boundGrokToolCallForEvent(input: { return { toolCall: { ...input.toolCall, - data: boundUnknown(input.toolCall.data) as Record, + data: boundDataToBudget(input.toolCall.data), ...(typeof input.toolCall.detail === "string" ? { detail: truncateText(input.toolCall.detail) } : {}), diff --git a/docs/user/providers-grok.md b/docs/user/providers-grok.md index 5cd5d80bc33f..0f77099ee562 100644 --- a/docs/user/providers-grok.md +++ b/docs/user/providers-grok.md @@ -10,8 +10,8 @@ grok login ``` You can also set `XAI_API_KEY` in the server environment instead of running `grok login`. -Background provider checks do not start a Grok browser login. If the CLI is installed but has no -saved credentials, Settings shows an unauthenticated status and asks you to run `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: From 180f6b45f81e7810130e4532f02df5877241bd3d Mon Sep 17 00:00:00 2001 From: maslin Date: Sun, 16 Aug 2026 15:51:12 +0700 Subject: [PATCH 11/26] fix(grok): surface rewind errors, serialize startup notifications, cheap-fingerprint tools Keep rewind failure detail as a stable prefix plus a bounded provider error so checkpoints still see why rewind failed. Buffer live session notifications until the startup replay drain finishes. Rate-limit tool ticks before hashing and fingerprint data by length and tail instead of JSON.stringify of the whole payload. --- .../server/src/provider/Layers/GrokAdapter.ts | 29 ++++++++++++++--- .../provider/acp/GrokAcpToolUpdates.test.ts | 11 +++++++ .../src/provider/acp/GrokAcpToolUpdates.ts | 32 ++++++++++++++----- .../src/provider/acp/XAiAcpExtension.test.ts | 11 +++++++ .../src/provider/acp/XAiAcpExtension.ts | 10 ++++++ 5 files changed, 80 insertions(+), 13 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index f9d044952760..b05d72c8fd55 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -79,6 +79,7 @@ import { extractGrokTokenUsage, extractXAiAskUserQuestions, grokPromptCountForTurns, + grokRewindFailureDetail, grokRewindTargetForTurnCount, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, @@ -723,6 +724,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte readonly method: "x.ai/session_notification" | "_x.ai/session_notification"; readonly params: unknown; }> = []; + let sessionNotificationsReady = false; const applySessionNotification = ( ctx: GrokSessionContext, method: "x.ai/session_notification" | "_x.ai/session_notification", @@ -822,6 +824,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte 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 }); @@ -1104,10 +1110,23 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ctx.notificationFiber = nf; sessions.set(input.threadId, ctx); sessionScopeTransferred = true; - yield* Effect.forEach(pendingSessionNotifications, (pending) => - applySessionNotification(ctx, pending.method, pending.params), - ); - pendingSessionNotifications.length = 0; + // Keep live notifications queued until every buffered startup + // update has been applied. `sessions.set` would otherwise let a + // later tick mutate workflow state mid-replay. + for (;;) { + const batch = pendingSessionNotifications.splice(0); + if (batch.length === 0) { + sessionNotificationsReady = true; + if (pendingSessionNotifications.length === 0) { + break; + } + sessionNotificationsReady = false; + continue; + } + yield* Effect.forEach(batch, (pending) => + applySessionNotification(ctx, pending.method, pending.params), + ); + } yield* offerRuntimeEvent({ type: "session.started", @@ -1757,7 +1776,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "_x.ai/rewind/execute", - detail: "Grok rewind did not succeed.", + detail: grokRewindFailureDetail(executed?.error), ...(executed?.error ? { cause: executed.error } : {}), }); } diff --git a/apps/server/src/provider/acp/GrokAcpToolUpdates.test.ts b/apps/server/src/provider/acp/GrokAcpToolUpdates.test.ts index 3052912f9206..79249a5604f5 100644 --- a/apps/server/src/provider/acp/GrokAcpToolUpdates.test.ts +++ b/apps/server/src/provider/acp/GrokAcpToolUpdates.test.ts @@ -52,6 +52,17 @@ describe("GrokAcpToolUpdates", () => { ).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({ diff --git a/apps/server/src/provider/acp/GrokAcpToolUpdates.ts b/apps/server/src/provider/acp/GrokAcpToolUpdates.ts index 024bbcc1b147..14f42d6598ab 100644 --- a/apps/server/src/provider/acp/GrokAcpToolUpdates.ts +++ b/apps/server/src/provider/acp/GrokAcpToolUpdates.ts @@ -10,15 +10,35 @@ export interface GrokToolUpdateGate { 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 { - const data = JSON.stringify(toolCall.data) ?? ""; return [ toolCall.toolCallId, toolCall.status ?? "", toolCall.title ?? "", toolCall.detail ?? "", - String(data.length), - data.slice(-128), + grokToolDataSignature(toolCall.data), ].join("\u001f"); } @@ -31,17 +51,13 @@ export function shouldEmitGrokToolUpdate(input: { if (status === "completed" || status === "failed") { return true; } - const fingerprint = grokToolCallFingerprint(input.toolCall); - if (input.previous?.fingerprint === fingerprint) { - return false; - } if ( input.previous !== undefined && input.nowMs - input.previous.lastEmittedAt < GROK_TOOL_UPDATE_MIN_INTERVAL_MS ) { return false; } - return true; + return input.previous?.fingerprint !== grokToolCallFingerprint(input.toolCall); } function truncateText(value: string): string { diff --git a/apps/server/src/provider/acp/XAiAcpExtension.test.ts b/apps/server/src/provider/acp/XAiAcpExtension.test.ts index 7c6dd98eb5a8..2ee87d9c9d7c 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.test.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.test.ts @@ -12,6 +12,7 @@ import { extractGrokTokenUsage, extractXAiAskUserQuestions, grokPromptCountForTurns, + grokRewindFailureDetail, grokRewindTargetForTurnCount, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, @@ -351,6 +352,16 @@ describe("Grok rewind and usage helpers", () => { expect(grokPromptCountForTurns([{ items: [1] }, { items: [2, 3] }], 1)).toBe(2); }); + 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({ diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts index 69784bd62385..9a97e27481af 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -538,6 +538,16 @@ export function parseGrokRewindExecute(payload: unknown): GrokRewindExecuteResul }; } +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); From 3dac3f66687833af2755dc7b95f4111c98f1e96e Mon Sep 17 00:00:00 2001 From: maslin Date: Sun, 16 Aug 2026 16:07:31 +0700 Subject: [PATCH 12/26] fix(grok): reject oversized rewind and snapshot startup notifications once Rewinding more turns than the session recorded now fails validation instead of silently rolling back everything. Startup session notifications are drained from a single snapshot under a lock so startSession cannot stall on a live flood. --- .../src/provider/Layers/GrokAdapter.test.ts | 25 ++++++++++++ .../server/src/provider/Layers/GrokAdapter.ts | 39 +++++++++++-------- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 6089dfc9105a..c587b0eb01c6 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -1416,6 +1416,31 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + 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"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index b05d72c8fd55..34253f18cc03 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -725,6 +725,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte readonly params: unknown; }> = []; let sessionNotificationsReady = false; + const sessionNotificationLock = yield* Semaphore.make(1); const applySessionNotification = ( ctx: GrokSessionContext, method: "x.ai/session_notification" | "_x.ai/session_notification", @@ -833,7 +834,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte pendingSessionNotifications.push({ method, params }); return; } - yield* applySessionNotification(ctx, method, params); + yield* sessionNotificationLock.withPermits(1)( + applySessionNotification(ctx, method, params), + ); }), ), ), @@ -1110,23 +1113,18 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ctx.notificationFiber = nf; sessions.set(input.threadId, ctx); sessionScopeTransferred = true; - // Keep live notifications queued until every buffered startup - // update has been applied. `sessions.set` would otherwise let a - // later tick mutate workflow state mid-replay. - for (;;) { - const batch = pendingSessionNotifications.splice(0); - if (batch.length === 0) { + // 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); sessionNotificationsReady = true; - if (pendingSessionNotifications.length === 0) { - break; - } - sessionNotificationsReady = false; - continue; - } - yield* Effect.forEach(batch, (pending) => - applySessionNotification(ctx, pending.method, pending.params), - ); - } + yield* Effect.forEach(batch, (pending) => + applySessionNotification(ctx, pending.method, pending.params), + ); + }), + ); yield* offerRuntimeEvent({ type: "session.started", @@ -1713,6 +1711,13 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte 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 promptCount = grokPromptCountForTurns(ctx.turns, numTurns); if (promptCount < 1) { From 7288e6a5ef1dbf25aa9acba5004c5b3966703140 Mon Sep 17 00:00:00 2001 From: maslin Date: Sun, 16 Aug 2026 16:16:11 +0700 Subject: [PATCH 13/26] fix(grok): classify ACP auth failures from tagged errors Inspect authenticate method and JSON-RPC -32000 on Cause fail reasons instead of regex-matching stringified text. Echoed cached_token payload text is no longer treated as unauthenticated. --- .../src/provider/acp/GrokAcpSupport.test.ts | 36 +++++++++++++++++-- .../server/src/provider/acp/GrokAcpSupport.ts | 24 ++++++++----- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index 4a6517bfb756..722b3e244af0 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -1,4 +1,5 @@ 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"; @@ -159,9 +160,38 @@ describe("grokDiscoveredModelCapabilities", () => { }); describe("isGrokAcpAuthFailure", () => { - it("recognizes authenticate failures", () => { - expect(isGrokAcpAuthFailure(new Error("authenticate failed: cached_token"))).toBe(true); - expect(isGrokAcpAuthFailure(new Error("session/new timed out"))).toBe(false); + 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); }); }); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index 3cbf5d30c7c2..b927e38711df 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -4,9 +4,11 @@ import { type ModelSelection, ProviderDriverKind, } 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"; @@ -406,14 +408,20 @@ export function currentGrokMaxTokensFromSessionSetup( return parseGrokAcpModelMeta(current?._meta).totalContextTokens; } -export function isGrokAcpAuthFailure(error: unknown): boolean { - const text = [ - error instanceof Error ? `${error.name} ${error.message}` : "", - typeof error === "string" ? error : "", - String(error), - ].join(" "); - return /authenticat|unauthorized|not logged in|login required|no credentials|cached_token|xai\.api_key|401\b/i.test( - text, +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), ); } From 8fc08fd45ee279688ea982a2427d5f47aa5e67d9 Mon Sep 17 00:00:00 2001 From: maslin Date: Sat, 22 Aug 2026 23:22:29 +0700 Subject: [PATCH 14/26] fix(grok): rewind from remaining history and resolve effort menus through aliases Cancelled Grok prompts can still appear in rewind points. Targeting from the end of that list kept an earlier turn on the provider while local history dropped it. Pick the first point after the prompts we are keeping so a ghost cancelled prompt is discarded with the rest. grok-build is a product slug. Effort menus are keyed by live ACP ids, so sendTurn treated the menu as empty and either dropped live-only efforts or sent spawnable levels the aliased model does not advertise. Resolve the slug onto the session model before reading the menu. --- apps/server/scripts/acp-mock-agent.ts | 9 +- .../src/provider/Layers/GrokAdapter.test.ts | 69 +++++++++++++ .../server/src/provider/Layers/GrokAdapter.ts | 96 ++++++++++--------- .../src/provider/acp/GrokAcpSupport.test.ts | 59 ++++++++++++ .../server/src/provider/acp/GrokAcpSupport.ts | 36 +++++-- .../src/provider/acp/XAiAcpExtension.test.ts | 27 +++++- .../src/provider/acp/XAiAcpExtension.ts | 44 ++++++--- docs/user/providers-grok.md | 3 +- 8 files changed, 270 insertions(+), 73 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index c874b0bb0262..213d219b3243 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -297,6 +297,7 @@ const grokAcpModels: ReadonlyArray = [ { 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"; @@ -461,6 +462,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(() => { @@ -903,7 +910,7 @@ const program = Effect.gen(function* () { }, }); - if (enableRewind) { + if (enableRewind && !cancelledSessions.has(requestedSessionId)) { const preview = request.prompt.find((block) => block.type === "text" && "text" in block)?.text ?? ""; rewindPoints.push({ diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index c587b0eb01c6..2869443a6c46 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -1505,6 +1505,75 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }).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"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 34253f18cc03..c4b6d814f310 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -57,6 +57,7 @@ import { import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; import { + advertisedGrokReasoningEffortsForModel, advertisedGrokReasoningEffortsFromSessionSetup, applyGrokAcpModelSelection, currentGrokMaxTokensFromSessionSetup, @@ -78,9 +79,9 @@ import { import { extractGrokTokenUsage, extractXAiAskUserQuestions, - grokPromptCountForTurns, + grokPromptCount, grokRewindFailureDetail, - grokRewindTargetForTurnCount, + grokRewindTargetKeepingPromptCount, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, parseGrokRewindExecute, @@ -1185,11 +1186,12 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const requestedTurnModelId = turnModelSelection?.model ? resolveGrokAcpBaseModelId(turnModelSelection.model) : undefined; - const advertisedTurnEfforts = requestedTurnModelId - ? (ctx.reasoningEffortMenus.get(requestedTurnModelId) ?? []) - : ctx.currentModelId - ? (ctx.reasoningEffortMenus.get(ctx.currentModelId) ?? []) - : []; + const advertisedTurnEfforts = advertisedGrokReasoningEffortsForModel({ + menus: ctx.reasoningEffortMenus, + requestedModelId: requestedTurnModelId, + currentModelId: ctx.currentModelId, + availableModelIds: ctx.availableModelIds, + }); const turnSelection = yield* applyGrokAcpModelSelection({ runtime: ctx.acp, currentModelId: ctx.currentModelId, @@ -1719,14 +1721,8 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }); } yield* cancelActivePromptsBeforeRewind(ctx); - const promptCount = grokPromptCountForTurns(ctx.turns, numTurns); - if (promptCount < 1) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "_x.ai/rewind/execute", - detail: "Grok has no rewind point for that many turns.", - }); - } + 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", { @@ -1745,51 +1741,59 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte detail: "Grok session changed before rewind completed.", }); } - const target = grokRewindTargetForTurnCount( - parseGrokRewindPoints(pointsPayload), - promptCount, - ); - if (!target) { + const rewindPoints = parseGrokRewindPoints(pointsPayload); + const target = grokRewindTargetKeepingPromptCount(rewindPoints, keepPromptCount); + if (!target && rewindPoints.length === 0) { 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.", - }); + if (target) { + 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 executed = parseGrokRewindExecute(executePayload); - if (!executed?.success) { + const trimmedCtx = yield* requireSession(threadId); + if (trimmedCtx.acpSessionId !== acpSessionId) { return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "_x.ai/rewind/execute", - detail: grokRewindFailureDetail(executed?.error), - ...(executed?.error ? { cause: executed.error } : {}), + detail: "Grok session changed before rewind completed.", }); } - committedCtx.turns = committedCtx.turns.slice( + trimmedCtx.turns = trimmedCtx.turns.slice( 0, - Math.max(0, committedCtx.turns.length - numTurns), + Math.max(0, trimmedCtx.turns.length - numTurns), ); - return { threadId, turns: committedCtx.turns }; + return { threadId, turns: trimmedCtx.turns }; }), ); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index 722b3e244af0..e358c57edf48 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -11,6 +11,7 @@ import { isGrokAcpAuthFailure, parseGrokAcpModelMeta, grokDiscoveredModelCapabilities, + advertisedGrokReasoningEffortsForModel, requestedGrokReasoningEffort, resolveGrokAcpBaseModelId, resolveGrokSessionModelId, @@ -139,6 +140,64 @@ describe("requestedGrokReasoningEffort", () => { }); }); +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( diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index b927e38711df..1ceecca07d80 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -367,6 +367,30 @@ export function grokReasoningEffortMenusFromSessionSetup( 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 @@ -374,12 +398,12 @@ export function advertisedGrokReasoningEffortsFromSessionSetup( | EffectAcpSchema.ResumeSessionResponse, modelId: string | undefined, ): ReadonlyArray { - const menus = grokReasoningEffortMenusFromSessionSetup(sessionSetupResult); - if (modelId && menus.has(modelId)) { - return menus.get(modelId) ?? []; - } - const current = sessionSetupResult.models?.currentModelId; - return (current ? menus.get(current) : undefined) ?? []; + return advertisedGrokReasoningEffortsForModel({ + menus: grokReasoningEffortMenusFromSessionSetup(sessionSetupResult), + requestedModelId: modelId, + currentModelId: currentGrokModelIdFromSessionSetup(sessionSetupResult), + availableModelIds: availableGrokSessionModelIds(sessionSetupResult), + }); } export function currentGrokReasoningEffortFromSessionSetup( diff --git a/apps/server/src/provider/acp/XAiAcpExtension.test.ts b/apps/server/src/provider/acp/XAiAcpExtension.test.ts index 2ee87d9c9d7c..d8e76cc6fdb8 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.test.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.test.ts @@ -11,9 +11,10 @@ import { describe, expect } from "vite-plus/test"; import { extractGrokTokenUsage, extractXAiAskUserQuestions, + grokPromptCount, grokPromptCountForTurns, grokRewindFailureDetail, - grokRewindTargetForTurnCount, + grokRewindTargetKeepingPromptCount, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, makeXAiPromptCompletionRuntime, @@ -338,7 +339,7 @@ describe("XAiAcpExtension", () => { }); describe("Grok rewind and usage helpers", () => { - it("picks the rewind target that drops the last N turns", () => { + it("picks the rewind target so Grok keeps the remaining local prompts", () => { const points = parseGrokRewindPoints({ rewind_points: [ { prompt_index: 0, prompt_preview: "first" }, @@ -346,12 +347,28 @@ describe("Grok rewind and usage helpers", () => { { prompt_index: 2, prompt_preview: "third" }, ], }); - expect(grokRewindTargetForTurnCount(points, 1)?.promptIndex).toBe(2); - expect(grokRewindTargetForTurnCount(points, 2)?.promptIndex).toBe(1); - expect(grokRewindTargetForTurnCount(points, 4)).toBeUndefined(); + 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( diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts index 9a97e27481af..2a8c853be26b 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -497,19 +497,10 @@ export function parseGrokRewindPoints(payload: unknown): ReadonlyArray, - numTurns: number, -): GrokRewindPoint | undefined { - if (!Number.isInteger(numTurns) || numTurns < 1 || points.length === 0) { - return undefined; - } - const ordered = [...points].sort((left, right) => left.promptIndex - right.promptIndex); - if (numTurns > ordered.length) { - return undefined; - } - return ordered[ordered.length - numTurns]; +export function grokPromptCount( + turns: ReadonlyArray<{ readonly items: ReadonlyArray }>, +): number { + return turns.reduce((count, turn) => count + turn.items.length, 0); } export function grokPromptCountForTurns( @@ -519,7 +510,32 @@ export function grokPromptCountForTurns( if (!Number.isInteger(numTurns) || numTurns < 1) { return 0; } - return turns.slice(-numTurns).reduce((count, turn) => count + turn.items.length, 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 { diff --git a/docs/user/providers-grok.md b/docs/user/providers-grok.md index 0f77099ee562..be75836cf942 100644 --- a/docs/user/providers-grok.md +++ b/docs/user/providers-grok.md @@ -61,7 +61,8 @@ treated as token counts only. ## Rewind Conversation rollback uses Grok's `_x.ai/rewind` extension. T3 maps "undo N turns" onto rewind -points and trims the in-memory turn list when execute succeeds. +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 From 8b86f834d093d56321ceeb6289794919b77b8679 Mon Sep 17 00:00:00 2001 From: maslin Date: Sat, 22 Aug 2026 23:36:35 +0700 Subject: [PATCH 15/26] feat(usage): scan Grok session transcripts on the usage page The usage page only knew Claude and Codex. Grok already writes PromptUsage on turn_completed in ~/.grok/sessions/**/updates.jsonl, including cache-read tokens and costUsdTicks. Parse those rows, add grok to the usage contract, and show Grok Build next to Claude and Codex. Incomplete bills stay token-only and never become $0. --- .../src/features/usage/usageProviders.ts | 4 +- apps/server/src/usage/UsageService.ts | 8 +- apps/server/src/usage/usageScanCache.ts | 2 +- .../server/src/usage/usageTranscriptReader.ts | 8 ++ .../server/src/usage/usageTranscripts.test.ts | 82 +++++++++++ apps/server/src/usage/usageTranscripts.ts | 127 +++++++++++++++++- .../src/components/usage/UsagePage.test.tsx | 3 +- .../src/components/usage/usageProviders.ts | 7 +- docs/user/providers-grok.md | 6 +- packages/contracts/src/usage.ts | 12 +- 10 files changed, 245 insertions(+), 14 deletions(-) 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/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..2dfa713f8508 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "@effect/vitest"; import { + GROK_COST_USD_TICKS_PER_DOLLAR, initialCodexScanState, parseClaudeLine, parseCodexLine, + parseGrokLine, totalTokens, } from "./usageTranscripts.ts"; @@ -236,6 +238,86 @@ 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(); + }); +}); + 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..ae4b3e929a5f 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -68,7 +68,9 @@ 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"); + return line.includes('"token_count"'); } /* -------------------------------------------------------------------------- */ @@ -297,4 +299,127 @@ 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; + +function grokReportedCostUsd(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 usage = usageRaw as Record; + + 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: grokReportedCostUsd(usage), + dedupeKey: promptId.length > 0 ? `${sessionId}:${promptId}` : sessionId || 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/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index 615980cd460a..8eb41d31a6a1 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/user/providers-grok.md b/docs/user/providers-grok.md index be75836cf942..deceb4c67916 100644 --- a/docs/user/providers-grok.md +++ b/docs/user/providers-grok.md @@ -55,8 +55,10 @@ After each prompt T3 reads Grok's prompt `_meta.usage` (including the official P totals / `cached_read_tokens` shape) and emits `thread.token-usage.updated`. Workflow child tokens are added as they arrive. -Cost ticks (`costUsdTicks`) are not billed in T3 yet. Incomplete or partial Grok bills are -treated as token counts only. +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. ## Rewind 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; /** From 29016bb0f1d4bd369ed60a80625dec0a0387c330 Mon Sep 17 00:00:00 2001 From: maslin Date: Sat, 22 Aug 2026 23:51:33 +0700 Subject: [PATCH 16/26] feat(grok): map session extras onto existing runtime events Live Grok extras arrive on `_x.ai/session/update`, not only `x.ai/session_notification`. Auto-compact, hooks, recap, background shells, and complete PromptUsage costUsdTicks were dropped. Parse those updates onto the same compact, hook.*, metadata, local_bash, and token-usage events Claude already uses. Incomplete bills stay token-only. --- apps/server/scripts/acp-mock-agent.ts | 73 ++- .../src/provider/Layers/GrokAdapter.test.ts | 64 +++ .../server/src/provider/Layers/GrokAdapter.ts | 164 +++++- .../provider/acp/GrokAcpSessionExtras.test.ts | 216 ++++++++ .../src/provider/acp/GrokAcpSessionExtras.ts | 478 ++++++++++++++++++ apps/server/src/usage/usageTranscripts.ts | 5 +- docs/user/providers-grok.md | 16 +- 7 files changed, 999 insertions(+), 17 deletions(-) create mode 100644 apps/server/src/provider/acp/GrokAcpSessionExtras.test.ts create mode 100644 apps/server/src/provider/acp/GrokAcpSessionExtras.ts diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 213d219b3243..040cb0e4ed35 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -19,6 +19,9 @@ 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 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"; @@ -920,7 +923,7 @@ const program = Effect.gen(function* () { } if (emitSubagent) { - writeJsonRpcNotification("x.ai/session_notification", { + writeJsonRpcNotification(xAiSessionNotificationMethod, { sessionId: requestedSessionId, update: { sessionUpdate: "subagent_spawned", @@ -933,7 +936,7 @@ const program = Effect.gen(function* () { } if (emitWorkflow) { - writeJsonRpcNotification("x.ai/session_notification", { + writeJsonRpcNotification(xAiSessionNotificationMethod, { sessionId: requestedSessionId, update: { sessionUpdate: "workflow_updated", @@ -964,6 +967,72 @@ const program = Effect.gen(function* () { }); } + 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", + }, + }); + } + return { stopReason: "end_turn", ...(emitUsage diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 2869443a6c46..75872d9e1689 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -1643,4 +1643,68 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { 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); + }), + ); }); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index c4b6d814f310..0b9aabd37649 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -98,6 +98,20 @@ import { parseXAiWorkflowUpdated, type GrokWorkflowTrackState, } from "../acp/GrokAcpWorkflow.ts"; +import { + GROK_SESSION_NOTIFICATION_METHODS, + grokAutoCompactEvents, + grokBackgroundTaskEvents, + grokHookEvents, + grokSessionRecapEvents, + parseXAiAutoCompact, + parseXAiBackgroundTask, + parseXAiHookExecution, + parseXAiSessionRecap, + parseXAiTurnCompletedUsage, + type GrokExtraEventSpec, + type GrokSessionNotificationMethod, +} from "../acp/GrokAcpSessionExtras.ts"; import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -154,6 +168,7 @@ interface GrokSessionContext { maxTokensByModel: Map; maxTokens: number | undefined; lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; + lastCompleteCostUsd: number | undefined; availableModelIds: ReadonlyArray; workflowTrack: GrokWorkflowTrackState; readonly toolUpdateGates: Map; @@ -328,7 +343,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte readonly method: string; readonly payload: unknown; readonly specs: ReadonlyArray<{ - readonly type: "task.started" | "task.progress" | "task.completed"; + readonly type: "task.started" | "task.progress" | "task.completed" | "task.updated"; readonly payload: Record; }>; }) => @@ -360,6 +375,49 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte { 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( @@ -438,8 +496,12 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte payload: { state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", stopReason: options.completedStopReason ?? null, + ...(liveCtx.lastCompleteCostUsd !== undefined + ? { totalCostUsd: liveCtx.lastCompleteCostUsd } + : {}), }, }); + liveCtx.lastCompleteCostUsd = undefined; } } return; @@ -514,8 +576,12 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte payload: { state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", stopReason: options.completedStopReason ?? null, + ...(liveCtx.lastCompleteCostUsd !== undefined + ? { totalCostUsd: liveCtx.lastCompleteCostUsd } + : {}), }, }); + liveCtx.lastCompleteCostUsd = undefined; } }); @@ -722,14 +788,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ), ); const pendingSessionNotifications: Array<{ - readonly method: "x.ai/session_notification" | "_x.ai/session_notification"; + readonly method: GrokSessionNotificationMethod; readonly params: unknown; }> = []; let sessionNotificationsReady = false; const sessionNotificationLock = yield* Semaphore.make(1); const applySessionNotification = ( ctx: GrokSessionContext, - method: "x.ai/session_notification" | "_x.ai/session_notification", + method: GrokSessionNotificationMethod, params: unknown, ) => Effect.gen(function* () { @@ -748,17 +814,92 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte return; } const subagent = parseXAiSubagentUpdate(params); - if (!subagent) { + 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 applied = applyGrokSubagentUpdate(ctx.workflowTrack, subagent); - ctx.workflowTrack = applied.state; - yield* emitGrokTaskSpecs({ + 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); + 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: applied.events, + specs: grokBackgroundTaskEvents(background), }); }); const started = yield* Effect.gen(function* () { @@ -820,7 +961,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte // onto task.*; Codex maps collabAgent/* the same way. Keep that // seam: parse here, emit canonical events, never a third UI shape. yield* Effect.forEach( - ["x.ai/session_notification", "_x.ai/session_notification"] as const, + GROK_SESSION_NOTIFICATION_METHODS, (method) => acp.handleExtNotification(method, XAiSessionNotification, (params) => mapAcpCallbackFailure( @@ -982,6 +1123,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte (boundModelId ? maxTokensByModel.get(boundModelId) : undefined) ?? currentGrokMaxTokensFromSessionSetup(started.sessionSetupResult), lastKnownTokenUsage: undefined, + lastCompleteCostUsd: undefined, availableModelIds, workflowTrack: emptyGrokWorkflowTrackState(), toolUpdateGates: new Map(), @@ -1451,8 +1593,12 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte payload: { state: result.stopReason === "cancelled" ? "cancelled" : "completed", stopReason: completedStopReason, + ...(ctx.lastCompleteCostUsd !== undefined + ? { totalCostUsd: ctx.lastCompleteCostUsd } + : {}), }, }); + ctx.lastCompleteCostUsd = undefined; ctx.interruptedTurnIds.delete(prepared.turnId); yield* Ref.set(promptSettled, true); } else if (remainingPrompts > 0) { 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..0d1b27487c02 --- /dev/null +++ b/apps/server/src/provider/acp/GrokAcpSessionExtras.test.ts @@ -0,0 +1,216 @@ +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); + 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, + }); + 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: "thread.state.changed", + payload: { state: "compacted", 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..7f5cb1754f5a --- /dev/null +++ b/apps/server/src/provider/acp/GrokAcpSessionExtras.ts @@ -0,0 +1,478 @@ +import type { ThreadTokenUsageSnapshot } from "@t3tools/contracts"; + +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 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, +): 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: "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/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index ae4b3e929a5f..a4db9d35ce9d 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -306,7 +306,8 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord /** Grok PromptUsage: 1e10 ticks = $1. Incomplete bills must not become $0. */ export const GROK_COST_USD_TICKS_PER_DOLLAR = 10_000_000_000; -function grokReportedCostUsd(usage: Record): number | null { +/** 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 || @@ -417,7 +418,7 @@ export function parseGrokLine(line: string): UsageRecord | null { model: grokModelId(usage), sessionId, totals, - reportedCostUsd: grokReportedCostUsd(usage), + reportedCostUsd: grokCompleteCostUsd(usage), dedupeKey: promptId.length > 0 ? `${sessionId}:${promptId}` : sessionId || null, }; } diff --git a/docs/user/providers-grok.md b/docs/user/providers-grok.md index deceb4c67916..af3d3e069b95 100644 --- a/docs/user/providers-grok.md +++ b/docs/user/providers-grok.md @@ -60,6 +60,14 @@ 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. + ## Rewind Conversation rollback uses Grok's `_x.ai/rewind` extension. T3 maps "undo N turns" onto rewind @@ -73,7 +81,7 @@ Settings when ACP login fails. ## What T3 still does not surface -Grok Build's ACP session channel also carries plan mode, goals, queue, hooks, plugins, -marketplace updates, auto-compact, monitors, and scheduled tasks. Those notifications are -accepted and ignored until a later change maps them. The Grok CLI TUI remains the source of -truth for `/workflows`, `/plan`, and `/usage`. +Grok Build's ACP session channel also carries plan mode, goals, queued prompts, 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 `/workflows`, `/plan`, and +`/usage`. From 935cd6cafa92e60bf2042b9fa4f709e32892105a Mon Sep 17 00:00:00 2001 From: maslin Date: Sat, 22 Aug 2026 23:53:54 +0700 Subject: [PATCH 17/26] feat(grok): honor plan mode through session/set_mode The Grok composer hid Plan/Default even though ACP advertises architect and code modes, and sendTurn never called set_mode. Turn the toggle on and map Plan onto the advertised plan/architect mode and Default onto code/agent, the same way Cursor does. ACP plan entries already update the turn plan list. --- .../src/provider/Layers/GrokAdapter.test.ts | 49 +++++++++ .../server/src/provider/Layers/GrokAdapter.ts | 9 ++ .../src/provider/Layers/GrokProvider.test.ts | 1 + .../src/provider/Layers/GrokProvider.ts | 2 +- .../src/provider/acp/GrokAcpSupport.test.ts | 85 +++++++++++++++ .../server/src/provider/acp/GrokAcpSupport.ts | 102 ++++++++++++++++++ docs/user/providers-grok.md | 14 ++- 7 files changed, 257 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 75872d9e1689..3d4d3b23ff44 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -1644,6 +1644,55 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + 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"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 0b9aabd37649..472ddfc4b72e 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -60,6 +60,7 @@ import { advertisedGrokReasoningEffortsForModel, advertisedGrokReasoningEffortsFromSessionSetup, applyGrokAcpModelSelection, + applyGrokAcpSessionMode, currentGrokMaxTokensFromSessionSetup, currentGrokModelIdFromSessionSetup, currentGrokReasoningEffortFromSessionSetup, @@ -1354,6 +1355,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ctx.maxTokens = ctx.maxTokensByModel.get(currentModelId); } + 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( input.attachments ?? [], diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 1bf3d8a5b9f7..c3b274f869a7 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -42,6 +42,7 @@ describe("buildInitialGrokProviderSnapshot", () => { expect(snapshot.version).toBeNull(); expect(snapshot.message).toContain("Checking Grok"); expect(snapshot.requiresNewThreadForModelChange).toBe(false); + expect(snapshot.showInteractionModeToggle).toBe(true); expect(snapshot.models[0]?.capabilities?.optionDescriptors?.[0]?.id).toBe("reasoningEffort"); }), ); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 3d0b974e29be..f95f1afa1aa9 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -40,7 +40,7 @@ import { const GROK_PRESENTATION = { displayName: "Grok", badgeLabel: "Early Access", - showInteractionModeToggle: false, + showInteractionModeToggle: true, requiresNewThreadForModelChange: false, } as const; const FALLBACK_CAPABILITIES: ModelCapabilities = fallbackGrokReasoningEffortCapabilities(); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index e358c57edf48..51d08b7ad09a 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -5,6 +5,7 @@ import * as EffectAcpErrors from "effect-acp/errors"; import { applyGrokAcpModelSelection, + applyGrokAcpSessionMode, buildGrokAcpSpawnInput, GROK_REASONING_EFFORT_OPTION_ID, grokReasoningEffortCapabilities, @@ -14,6 +15,7 @@ import { advertisedGrokReasoningEffortsForModel, requestedGrokReasoningEffort, resolveGrokAcpBaseModelId, + resolveGrokSessionModeId, resolveGrokSessionModelId, } from "./GrokAcpSupport.ts"; import { ProviderInstanceId } from "@t3tools/contracts"; @@ -379,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 1ceecca07d80..f0f8bccfb4df 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -2,7 +2,9 @@ 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"; @@ -19,6 +21,7 @@ import { normalizeModelSlug, } from "@t3tools/shared/model"; +import type { AcpSessionMode, AcpSessionModeState } from "./AcpRuntimeModel.ts"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; import { makeXAiPromptCompletionRuntime } from "./XAiAcpExtension.ts"; @@ -503,3 +506,102 @@ export function applyGrokAcpModelSelection(input: { }), ); } + +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/docs/user/providers-grok.md b/docs/user/providers-grok.md index af3d3e069b95..eb7112cd41ed 100644 --- a/docs/user/providers-grok.md +++ b/docs/user/providers-grok.md @@ -33,6 +33,13 @@ 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 @@ -81,7 +88,6 @@ Settings when ACP login fails. ## What T3 still does not surface -Grok Build's ACP session channel also carries plan mode, goals, queued prompts, 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 `/workflows`, `/plan`, and -`/usage`. +Grok Build's ACP session channel also carries queued prompts, 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 `/workflows` and `/usage`. From 103affadc8d006cd43134d1f0e0e8a5362b7db1c Mon Sep 17 00:00:00 2001 From: maslin Date: Sat, 22 Aug 2026 23:55:49 +0700 Subject: [PATCH 18/26] feat(grok): advertise workflow slash commands from disk scripts Pause, resume, and stop still lived only in the Grok TUI, and T3 had no way to launch a named Rhai workflow without reimplementing the host. Surface `/workflow pause|resume|stop` plus `~/.grok/workflows` and project `.grok/workflows` scripts as Grok slash commands. Selecting one sends that text as a prompt. Project scripts override user scripts of the same name. --- .../src/provider/Layers/GrokProvider.test.ts | 3 + .../src/provider/Layers/GrokProvider.ts | 34 +++-- .../provider/acp/GrokWorkflowCommands.test.ts | 78 +++++++++++ .../src/provider/acp/GrokWorkflowCommands.ts | 125 ++++++++++++++++++ docs/user/providers-grok.md | 8 +- 5 files changed, 236 insertions(+), 12 deletions(-) create mode 100644 apps/server/src/provider/acp/GrokWorkflowCommands.test.ts create mode 100644 apps/server/src/provider/acp/GrokWorkflowCommands.ts diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index c3b274f869a7..7dfc20a0bcfb 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -43,6 +43,9 @@ describe("buildInitialGrokProviderSnapshot", () => { expect(snapshot.message).toContain("Checking Grok"); 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"); }), ); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index f95f1afa1aa9..18662aff4f21 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -36,6 +36,7 @@ import { parseGrokAcpModelMeta, resolveGrokAcpBaseModelId, } from "../acp/GrokAcpSupport.ts"; +import { readGrokWorkflowSlashCommands } from "../acp/GrokWorkflowCommands.ts"; const GROK_PRESENTATION = { displayName: "Grok", @@ -45,6 +46,21 @@ const GROK_PRESENTATION = { } as const; const FALLBACK_CAPABILITIES: ModelCapabilities = fallbackGrokReasoningEffortCapabilities(); +function buildGrokServerProvider( + input: Parameters[0], + environment: NodeJS.ProcessEnv = process.env, +): ServerProviderDraft { + return buildServerProvider({ + ...input, + slashCommands: + input.slashCommands ?? + readGrokWorkflowSlashCommands({ + projectRoot: process.cwd(), + homeDir: environment.HOME, + }), + }); +} + const VERSION_PROBE_TIMEOUT_MS = 4_000; const GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; const GROK_API_KEY_ENV = "XAI_API_KEY"; @@ -66,7 +82,7 @@ export function buildInitialGrokProviderSnapshot( const models = grokModelsFromSettings(grokSettings.customModels); if (!grokSettings.enabled) { - return buildServerProvider({ + return buildGrokServerProvider({ presentation: GROK_PRESENTATION, enabled: false, checkedAt, @@ -81,7 +97,7 @@ export function buildInitialGrokProviderSnapshot( }); } - return buildServerProvider({ + return buildGrokServerProvider({ presentation: GROK_PRESENTATION, enabled: true, checkedAt, @@ -182,7 +198,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func const fallbackModels = grokModelsFromSettings(grokSettings.customModels); if (!grokSettings.enabled) { - return buildServerProvider({ + return buildGrokServerProvider({ presentation: GROK_PRESENTATION, enabled: false, checkedAt, @@ -207,7 +223,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func yield* Effect.logWarning("Grok CLI health check failed.", { errorTag: error._tag, }); - return buildServerProvider({ + return buildGrokServerProvider({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, @@ -225,7 +241,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func } if (Option.isNone(versionResult.success)) { - return buildServerProvider({ + return buildGrokServerProvider({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, @@ -248,7 +264,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func stdoutLength: versionOutput.stdout.length, stderrLength: versionOutput.stderr.length, }); - return buildServerProvider({ + return buildGrokServerProvider({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, @@ -273,7 +289,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func errorTag: causeErrorTag(discoveryExit.cause), authFailed, }); - return buildServerProvider({ + return buildGrokServerProvider({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, @@ -293,7 +309,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 buildGrokServerProvider({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, @@ -313,7 +329,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func ? grokModelsFromSettings(grokSettings.customModels, discoveredModels) : fallbackModels; - return buildServerProvider({ + return buildGrokServerProvider({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, 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..ce7e2a81839f --- /dev/null +++ b/apps/server/src/provider/acp/GrokWorkflowCommands.test.ts @@ -0,0 +1,78 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { describe, expect, it } from "vite-plus/test"; + +import { + GROK_WORKFLOW_CONTROL_COMMANDS, + parseGrokWorkflowScriptMeta, + readGrokWorkflowSlashCommands, +} from "./GrokWorkflowCommands.ts"; + +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(); + }); +}); + +describe("readGrokWorkflowSlashCommands", () => { + it("includes pause/resume/stop and project scripts override user scripts", () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "grok-wf-")); + const userDir = NodePath.join(root, "home", ".grok", "workflows"); + const projectDir = NodePath.join(root, "project", ".grok", "workflows"); + NodeFS.mkdirSync(userDir, { recursive: true }); + NodeFS.mkdirSync(projectDir, { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(userDir, "review-changes.rhai"), + `let meta = #{ name: "review-changes", description: "user copy" };\n`, + ); + NodeFS.writeFileSync( + NodePath.join(projectDir, "review-changes.rhai"), + `let meta = #{ name: "review-changes", description: "project copy" };\n`, + ); + NodeFS.writeFileSync( + NodePath.join(projectDir, "nowah-web-e2e.rhai"), + `let meta = #{ name: "nowah-web-e2e", description: "Write locked Playwright specs" };\n`, + ); + + const commands = readGrokWorkflowSlashCommands({ + homeDir: NodePath.join(root, "home"), + projectRoot: NodePath.join(root, "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", + }); + }); +}); diff --git a/apps/server/src/provider/acp/GrokWorkflowCommands.ts b/apps/server/src/provider/acp/GrokWorkflowCommands.ts new file mode 100644 index 000000000000..774bd47142df --- /dev/null +++ b/apps/server/src/provider/acp/GrokWorkflowCommands.ts @@ -0,0 +1,125 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import type { ServerProviderSlashCommand } from "@t3tools/contracts"; + +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; +} + +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}` }), + }; +} + +function readWorkflowDir(dir: string, byName: Map): void { + let entries: NodeFS.Dirent[]; + try { + entries = NodeFS.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".rhai")) { + continue; + } + const filePath = NodePath.join(dir, entry.name); + let source: string; + try { + const stat = NodeFS.statSync(filePath); + if (!stat.isFile() || stat.size <= 0) { + continue; + } + const bytes = NodeFS.readFileSync(filePath, { + encoding: "utf8", + flag: "r", + }); + source = bytes.length > SCRIPT_BYTE_CAP ? bytes.slice(0, SCRIPT_BYTE_CAP) : bytes; + } catch { + continue; + } + const fallbackName = NodePath.basename(entry.name, ".rhai"); + 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 function readGrokWorkflowSlashCommands(input: { + readonly projectRoot?: string; + readonly homeDir?: string; +}): ReadonlyArray { + const byName = new Map(); + for (const command of GROK_WORKFLOW_CONTROL_COMMANDS) { + byName.set(command.name, command); + } + const homeDir = trimmed(input.homeDir) ?? NodeOS.homedir(); + readWorkflowDir(NodePath.join(homeDir, ".grok", "workflows"), byName); + const projectRoot = trimmed(input.projectRoot); + if (projectRoot && NodePath.isAbsolute(projectRoot)) { + readWorkflowDir(NodePath.join(projectRoot, ".grok", "workflows"), byName); + } + return [...byName.values()]; +} diff --git a/docs/user/providers-grok.md b/docs/user/providers-grok.md index eb7112cd41ed..b17cad98333a 100644 --- a/docs/user/providers-grok.md +++ b/docs/user/providers-grok.md @@ -53,8 +53,10 @@ T3 Code now maps those updates onto the same Agents / task surface used by Claud - 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 -Slash commands such as `/workflow pause|resume|stop` still belong to the Grok CLI session. T3 -does not reimplement the Rhai host. It consumes the ACP notifications the host already emits. +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 @@ -90,4 +92,4 @@ Settings when ACP login fails. Grok Build's ACP session channel also carries queued prompts, 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 `/workflows` and `/usage`. +Grok CLI TUI remains the source of truth for `/usage`. From fdd87657acc0800da44fcefb119f2d34ecf9bb4b Mon Sep 17 00:00:00 2001 From: maslin Date: Sat, 22 Aug 2026 23:57:18 +0700 Subject: [PATCH 19/26] feat(grok): project prompt queue onto session state Grok emits `_x.ai/queue/changed` when prompts wait behind the current turn. T3 already kept the JSON-RPC path alive but dropped the payload. Map a length change onto session.state.changed and thread metadata so the existing session surface can show that work is queued, without a third queue UI. --- apps/server/scripts/acp-mock-agent.ts | 8 ++++ .../src/provider/Layers/GrokAdapter.test.ts | 34 +++++++++++++++++ .../server/src/provider/Layers/GrokAdapter.ts | 38 +++++++++++++++++++ .../src/provider/acp/GrokAcpSessionExtras.ts | 10 +++++ docs/user/providers-grok.md | 9 +++-- 5 files changed, 95 insertions(+), 4 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 040cb0e4ed35..b6b81e130edf 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -22,6 +22,7 @@ const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === 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"; @@ -1033,6 +1034,13 @@ const program = Effect.gen(function* () { }); } + if (emitQueueChanged) { + writeJsonRpcNotification("_x.ai/queue/changed", { + sessionId: requestedSessionId, + entries: [{ prompt: "follow up" }], + }); + } + return { stopReason: "end_turn", ...(emitUsage diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 3d4d3b23ff44..54a24aac0cc0 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -1756,4 +1756,38 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { 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 472ddfc4b72e..288695265262 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -100,14 +100,18 @@ import { 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, @@ -170,6 +174,7 @@ interface GrokSessionContext { maxTokens: number | undefined; lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; lastCompleteCostUsd: number | undefined; + lastQueueLength: number | undefined; availableModelIds: ReadonlyArray; workflowTrack: GrokWorkflowTrackState; readonly toolUpdateGates: Map; @@ -985,6 +990,38 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ), { 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); + const ctx = sessions.get(input.threadId); + if (!ctx) { + return; + } + 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", + ), + }); + }), + ), + ), + { discard: true }, + ); yield* acp.handleRequestPermission((params) => mapAcpCallbackFailure( Effect.gen(function* () { @@ -1125,6 +1162,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte currentGrokMaxTokensFromSessionSetup(started.sessionSetupResult), lastKnownTokenUsage: undefined, lastCompleteCostUsd: undefined, + lastQueueLength: undefined, availableModelIds, workflowTrack: emptyGrokWorkflowTrackState(), toolUpdateGates: new Map(), diff --git a/apps/server/src/provider/acp/GrokAcpSessionExtras.ts b/apps/server/src/provider/acp/GrokAcpSessionExtras.ts index 7f5cb1754f5a..83cea1ebef72 100644 --- a/apps/server/src/provider/acp/GrokAcpSessionExtras.ts +++ b/apps/server/src/provider/acp/GrokAcpSessionExtras.ts @@ -1,4 +1,5 @@ import type { ThreadTokenUsageSnapshot } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { extractGrokTokenUsage } from "./XAiAcpExtension.ts"; import { grokCompleteCostUsd } from "../../usage/usageTranscripts.ts"; @@ -18,6 +19,15 @@ export const GROK_SESSION_NOTIFICATION_METHODS = [ 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; diff --git a/docs/user/providers-grok.md b/docs/user/providers-grok.md index b17cad98333a..b9776fec2d46 100644 --- a/docs/user/providers-grok.md +++ b/docs/user/providers-grok.md @@ -75,7 +75,8 @@ 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. +events as Claude. Queued prompts (`_x.ai/queue/changed`) update session state +and thread metadata with the queue length. ## Rewind @@ -90,6 +91,6 @@ Settings when ACP login fails. ## What T3 still does not surface -Grok Build's ACP session channel also carries queued prompts, 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`. +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`. From ef5cc54b2b7ea15166be5ea189f12355c9bba827 Mon Sep 17 00:00:00 2001 From: maslin Date: Sun, 23 Aug 2026 00:22:06 +0700 Subject: [PATCH 20/26] fix(grok): close Macroscope and Bugbot findings Usage empty rows still spanned five columns after Grok joined the table. Transcript parsing dropped nested PromptUsage totals, camelCase turnCompleted lines, and later turns that shared a session id as the dedupe key. Failed Grok turns left lastCompleteCostUsd on the session. Queue updates before startSession were dropped. Auto-compact never left waiting. Rewind truncated local turns when Grok had no matching point. Workflow discovery read ambient HOME/cwd and whole oversized Rhai files. Derive the cost-table span from PROVIDER_ORDER, parse both usage shapes, consume cost on every terminal turn, buffer queue/changed until the session exists, emit running after compact, refuse rewind without a target, and discover workflows from injected home and project roots with a bounded read. --- .../server/src/provider/Drivers/GrokDriver.ts | 6 +- .../server/src/provider/Layers/GrokAdapter.ts | 144 +++++++++++------- .../src/provider/Layers/GrokProvider.test.ts | 40 +++++ .../src/provider/Layers/GrokProvider.ts | 72 +++++---- .../provider/acp/GrokAcpSessionExtras.test.ts | 4 + .../src/provider/acp/GrokAcpSessionExtras.ts | 4 + .../provider/acp/GrokWorkflowCommands.test.ts | 15 ++ .../src/provider/acp/GrokWorkflowCommands.ts | 14 +- .../server/src/usage/usageTranscripts.test.ts | 46 ++++++ apps/server/src/usage/usageTranscripts.ts | 13 +- apps/web/src/components/usage/UsagePage.tsx | 5 +- 11 files changed, 266 insertions(+), 97 deletions(-) diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 112f11013161..4098ce044a18 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -113,7 +113,11 @@ export const GrokDriver: ProviderDriver = { }); const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe( + const checkProvider = checkGrokProviderStatus( + effectiveConfig, + processEnv, + process.cwd(), + ).pipe( Effect.map(stampIdentity), Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 288695265262..8c02c992bf6d 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -233,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; @@ -481,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()), @@ -490,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()), @@ -502,12 +511,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte payload: { state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", stopReason: options.completedStopReason ?? null, - ...(liveCtx.lastCompleteCostUsd !== undefined - ? { totalCostUsd: liveCtx.lastCompleteCostUsd } - : {}), + ...(totalCostUsd !== undefined ? { totalCostUsd } : {}), }, }); - liveCtx.lastCompleteCostUsd = undefined; } } return; @@ -558,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()), @@ -570,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()), @@ -582,11 +592,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte payload: { state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", stopReason: options.completedStopReason ?? null, - ...(liveCtx.lastCompleteCostUsd !== undefined - ? { totalCostUsd: liveCtx.lastCompleteCostUsd } - : {}), + ...(totalCostUsd !== undefined ? { totalCostUsd } : {}), }, }); + } else { liveCtx.lastCompleteCostUsd = undefined; } }); @@ -797,6 +806,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte 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 = ( @@ -908,6 +921,29 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte 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, @@ -997,26 +1033,18 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte mapAcpCallbackFailure( Effect.gen(function* () { yield* logNative(input.threadId, method, params); - const ctx = sessions.get(input.threadId); - if (!ctx) { + if (!sessionNotificationsReady) { + pendingQueueChanges.push({ method, params }); return; } - const queue = parseXAiQueueChanged(params); - if (!queue || ctx.lastQueueLength === queue.entries.length) { + const ctx = sessions.get(input.threadId); + if (!ctx) { + pendingQueueChanges.push({ method, params }); 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", - ), - }); + yield* sessionNotificationLock.withPermits(1)( + applyQueueChange(ctx, method, params), + ); }), ), ), @@ -1301,10 +1329,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte 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), + ); }), ); @@ -1631,6 +1663,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()), @@ -1640,12 +1673,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte payload: { state: result.stopReason === "cancelled" ? "cancelled" : "completed", stopReason: completedStopReason, - ...(ctx.lastCompleteCostUsd !== undefined - ? { totalCostUsd: ctx.lastCompleteCostUsd } - : {}), + ...(totalCostUsd !== undefined ? { totalCostUsd } : {}), }, }); - ctx.lastCompleteCostUsd = undefined; ctx.interruptedTurnIds.delete(prepared.turnId); yield* Ref.set(promptSettled, true); } else if (remainingPrompts > 0) { @@ -1936,43 +1966,41 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } const rewindPoints = parseGrokRewindPoints(pointsPayload); const target = grokRewindTargetKeepingPromptCount(rewindPoints, keepPromptCount); - if (!target && rewindPoints.length === 0) { + if (!target) { return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "_x.ai/rewind/execute", detail: "Grok has no rewind point for that many turns.", }); } - if (target) { - 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 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) { diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 7dfc20a0bcfb..e4d7f3da7553 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -123,4 +123,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 18662aff4f21..83836a16534f 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -48,15 +48,18 @@ const FALLBACK_CAPABILITIES: ModelCapabilities = fallbackGrokReasoningEffortCapa function buildGrokServerProvider( input: Parameters[0], - environment: NodeJS.ProcessEnv = process.env, + discovery: { + readonly environment: NodeJS.ProcessEnv; + readonly projectRoot?: string; + }, ): ServerProviderDraft { return buildServerProvider({ ...input, slashCommands: input.slashCommands ?? readGrokWorkflowSlashCommands({ - projectRoot: process.cwd(), - homeDir: environment.HOME, + homeDir: discovery.environment.HOME, + projectRoot: discovery.projectRoot, }), }); } @@ -81,35 +84,42 @@ export function buildInitialGrokProviderSnapshot( const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); const models = grokModelsFromSettings(grokSettings.customModels); + const discovery = { environment: process.env }; if (!grokSettings.enabled) { - return buildGrokServerProvider({ + return 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.", + }, + }, + discovery, + ); + } + + return 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 buildGrokServerProvider({ - presentation: GROK_PRESENTATION, - enabled: true, - checkedAt, - models, - probe: { - installed: true, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Checking Grok CLI availability...", }, - }); + discovery, + ); }); } @@ -189,6 +199,7 @@ const runGrokVersionCommand = ( export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, + projectRoot?: string, ): Effect.fn.Return< ServerProviderDraft, never, @@ -196,9 +207,12 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func > { 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 buildGrokServerProvider({ + return providerDraft({ presentation: GROK_PRESENTATION, enabled: false, checkedAt, @@ -223,7 +237,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func yield* Effect.logWarning("Grok CLI health check failed.", { errorTag: error._tag, }); - return buildGrokServerProvider({ + return providerDraft({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, @@ -241,7 +255,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func } if (Option.isNone(versionResult.success)) { - return buildGrokServerProvider({ + return providerDraft({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, @@ -264,7 +278,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func stdoutLength: versionOutput.stdout.length, stderrLength: versionOutput.stderr.length, }); - return buildGrokServerProvider({ + return providerDraft({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, @@ -289,7 +303,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func errorTag: causeErrorTag(discoveryExit.cause), authFailed, }); - return buildGrokServerProvider({ + return providerDraft({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, @@ -309,7 +323,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 buildGrokServerProvider({ + return providerDraft({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, @@ -329,7 +343,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func ? grokModelsFromSettings(grokSettings.customModels, discoveredModels) : fallbackModels; - return buildGrokServerProvider({ + return providerDraft({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, diff --git a/apps/server/src/provider/acp/GrokAcpSessionExtras.test.ts b/apps/server/src/provider/acp/GrokAcpSessionExtras.test.ts index 0d1b27487c02..278bdc142e2e 100644 --- a/apps/server/src/provider/acp/GrokAcpSessionExtras.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSessionExtras.test.ts @@ -104,6 +104,10 @@ describe("GrokAcpSessionExtras", () => { }, }, }, + { + type: "session.state.changed", + payload: { state: "running", reason: "compaction completed", detail: parsed }, + }, { type: "thread.state.changed", payload: { state: "compacted", detail: parsed }, diff --git a/apps/server/src/provider/acp/GrokAcpSessionExtras.ts b/apps/server/src/provider/acp/GrokAcpSessionExtras.ts index 83cea1ebef72..17cec013a174 100644 --- a/apps/server/src/provider/acp/GrokAcpSessionExtras.ts +++ b/apps/server/src/provider/acp/GrokAcpSessionExtras.ts @@ -304,6 +304,10 @@ export function grokAutoCompactEvents( }, }); } + events.push({ + type: "session.state.changed", + payload: { state: "running", reason: "compaction completed", detail: compact }, + }); events.push({ type: "thread.state.changed", payload: { state: "compacted", detail: compact }, diff --git a/apps/server/src/provider/acp/GrokWorkflowCommands.test.ts b/apps/server/src/provider/acp/GrokWorkflowCommands.test.ts index ce7e2a81839f..507ee043368b 100644 --- a/apps/server/src/provider/acp/GrokWorkflowCommands.test.ts +++ b/apps/server/src/provider/acp/GrokWorkflowCommands.test.ts @@ -75,4 +75,19 @@ describe("readGrokWorkflowSlashCommands", () => { description: "Write locked Playwright specs", }); }); + + it("reads only the capped prefix of an oversized workflow script", () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "grok-wf-cap-")); + const userDir = NodePath.join(root, ".grok", "workflows"); + NodeFS.mkdirSync(userDir, { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(userDir, "huge.rhai"), + `let meta = #{ name: "huge", description: "from prefix" };\n` + "x".repeat(80 * 1024), + ); + const commands = readGrokWorkflowSlashCommands({ homeDir: root }); + expect(commands).toContainEqual({ + name: "workflow huge", + description: "from prefix", + }); + }); }); diff --git a/apps/server/src/provider/acp/GrokWorkflowCommands.ts b/apps/server/src/provider/acp/GrokWorkflowCommands.ts index 774bd47142df..4204c9d3c8fc 100644 --- a/apps/server/src/provider/acp/GrokWorkflowCommands.ts +++ b/apps/server/src/provider/acp/GrokWorkflowCommands.ts @@ -83,11 +83,15 @@ function readWorkflowDir(dir: string, byName: Map SCRIPT_BYTE_CAP ? bytes.slice(0, SCRIPT_BYTE_CAP) : bytes; + const length = Math.min(stat.size, SCRIPT_BYTE_CAP); + const handle = NodeFS.openSync(filePath, "r"); + try { + const buffer = Buffer.alloc(length); + const bytesRead = NodeFS.readSync(handle, buffer, 0, length, 0); + source = buffer.subarray(0, bytesRead).toString("utf8"); + } finally { + NodeFS.closeSync(handle); + } } catch { continue; } diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index 2dfa713f8508..88a026e2cc3e 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { GROK_COST_USD_TICKS_PER_DOLLAR, initialCodexScanState, + mightCarryUsage, parseClaudeLine, parseCodexLine, parseGrokLine, @@ -316,6 +317,51 @@ describe("parseGrokLine", () => { ), ).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", () => { diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index a4db9d35ce9d..50295c54b68b 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -69,7 +69,9 @@ export function totalTokens(totals: UsageTokenTotals): number { */ export function mightCarryUsage(line: string, provider: UsageProviderKind): boolean { if (provider === "claude") return line.includes('"usage"'); - if (provider === "grok") return line.includes("turn_completed"); + if (provider === "grok") { + return line.includes("turn_completed") || line.includes("turnCompleted"); + } return line.includes('"token_count"'); } @@ -368,7 +370,12 @@ export function parseGrokLine(line: string): UsageRecord | null { const usageRaw = update.usage; if (typeof usageRaw !== "object" || usageRaw === null) return null; - const usage = usageRaw as Record; + 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" @@ -419,7 +426,7 @@ export function parseGrokLine(line: string): UsageRecord | null { sessionId, totals, reportedCostUsd: grokCompleteCostUsd(usage), - dedupeKey: promptId.length > 0 ? `${sessionId}:${promptId}` : sessionId || null, + dedupeKey: promptId.length > 0 ? `${sessionId}:${promptId}` : null, }; } 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. From 2ec14f6a22583e03b37245fcedad970d99e957b8 Mon Sep 17 00:00:00 2001 From: maslin Date: Sun, 23 Aug 2026 00:30:28 +0700 Subject: [PATCH 21/26] fix(grok): include project workflows on the initial snapshot The health check scanned project .grok/workflows, but the pending snapshot did not. Composer /workflow was missing those scripts until ACP discovery finished. Pass the same injected project root into the initial snapshot. --- .../server/src/provider/Drivers/GrokDriver.ts | 12 ++++---- .../src/provider/Layers/GrokProvider.test.ts | 30 +++++++++++++++++++ .../src/provider/Layers/GrokProvider.ts | 13 ++++++-- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 4098ce044a18..db9cdea1b596 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -113,11 +113,8 @@ export const GrokDriver: ProviderDriver = { }); const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkGrokProviderStatus( - effectiveConfig, - processEnv, - process.cwd(), - ).pipe( + const projectRoot = process.cwd(); + const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, projectRoot).pipe( Effect.map(stampIdentity), Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), @@ -130,7 +127,10 @@ 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)), checkProvider, enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => enrichGrokSnapshot({ diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index e4d7f3da7553..40fcaf63d502 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -51,6 +51,36 @@ describe("buildInitialGrokProviderSnapshot", () => { ); }); +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.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { it.effect("reports the binary as missing when the binary path does not resolve", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 83836a16534f..08c371b1a859 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -79,12 +79,19 @@ const GROK_BUILT_IN_MODELS: ReadonlyArray = [ export function buildInitialGrokProviderSnapshot( grokSettings: GrokSettings, + discovery?: { + readonly environment?: NodeJS.ProcessEnv; + readonly projectRoot?: string; + }, ): Effect.Effect { return Effect.gen(function* () { const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); const models = grokModelsFromSettings(grokSettings.customModels); - const discovery = { environment: process.env }; + const resolvedDiscovery = { + environment: discovery?.environment ?? process.env, + projectRoot: discovery?.projectRoot, + }; if (!grokSettings.enabled) { return buildGrokServerProvider( { @@ -100,7 +107,7 @@ export function buildInitialGrokProviderSnapshot( message: "Grok is disabled in T3 Code settings.", }, }, - discovery, + resolvedDiscovery, ); } @@ -118,7 +125,7 @@ export function buildInitialGrokProviderSnapshot( message: "Checking Grok CLI availability...", }, }, - discovery, + resolvedDiscovery, ); }); } From 110efe82ba63a82bee51b11ee3301f434175175c Mon Sep 17 00:00:00 2001 From: maslin Date: Sun, 23 Aug 2026 00:38:05 +0700 Subject: [PATCH 22/26] fix(grok): satisfy exactOptionalPropertyTypes and usage chart bands Server typecheck failed because optional projectRoot, homeDir, and usage counts were passed as T | undefined into exact-optional bags. Allow undefined on those fields and stop comparing workflow events to a thread usage type they cannot have. The usage chart test still expected only Codex and Claude bands after Grok joined PROVIDER_ORDER. Include the zero Grok band. --- apps/server/src/provider/Layers/GrokProvider.ts | 6 +++--- apps/server/src/provider/acp/GrokAcpWorkflow.test.ts | 3 ++- apps/server/src/provider/acp/GrokAcpWorkflow.ts | 6 +++--- apps/server/src/provider/acp/GrokWorkflowCommands.ts | 4 ++-- apps/web/src/components/usage/UsageProviderChart.test.ts | 1 + 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 08c371b1a859..226b9a7f288b 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -50,7 +50,7 @@ function buildGrokServerProvider( input: Parameters[0], discovery: { readonly environment: NodeJS.ProcessEnv; - readonly projectRoot?: string; + readonly projectRoot?: string | undefined; }, ): ServerProviderDraft { return buildServerProvider({ @@ -80,8 +80,8 @@ const GROK_BUILT_IN_MODELS: ReadonlyArray = [ export function buildInitialGrokProviderSnapshot( grokSettings: GrokSettings, discovery?: { - readonly environment?: NodeJS.ProcessEnv; - readonly projectRoot?: string; + readonly environment?: NodeJS.ProcessEnv | undefined; + readonly projectRoot?: string | undefined; }, ): Effect.Effect { return Effect.gen(function* () { diff --git a/apps/server/src/provider/acp/GrokAcpWorkflow.test.ts b/apps/server/src/provider/acp/GrokAcpWorkflow.test.ts index 70dc785ce09e..013946d8743e 100644 --- a/apps/server/src/provider/acp/GrokAcpWorkflow.test.ts +++ b/apps/server/src/provider/acp/GrokAcpWorkflow.test.ts @@ -48,7 +48,8 @@ describe("GrokAcpWorkflow", () => { timelineBypass: true, }); expect(memberStarted?.payload.agentId).toBeUndefined(); - expect(first.events.some((event) => event.type === "thread.token-usage.updated")).toBe(false); + 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", () => { diff --git a/apps/server/src/provider/acp/GrokAcpWorkflow.ts b/apps/server/src/provider/acp/GrokAcpWorkflow.ts index b42ea2f606d8..d22e580e890c 100644 --- a/apps/server/src/provider/acp/GrokAcpWorkflow.ts +++ b/apps/server/src/provider/acp/GrokAcpWorkflow.ts @@ -316,9 +316,9 @@ function agentCompletedStatus(state: string): "completed" | "failed" | "stopped" function mergeTypedUsageFromCounts( input: { - readonly tokensUsed?: number; - readonly durationMs?: number; - readonly toolCallCount?: number; + readonly tokensUsed?: number | undefined; + readonly durationMs?: number | undefined; + readonly toolCallCount?: number | undefined; }, previous: GrokTypedUsageSnapshot | undefined, ): GrokTypedUsageSnapshot | undefined { diff --git a/apps/server/src/provider/acp/GrokWorkflowCommands.ts b/apps/server/src/provider/acp/GrokWorkflowCommands.ts index 4204c9d3c8fc..2dbebdd9f534 100644 --- a/apps/server/src/provider/acp/GrokWorkflowCommands.ts +++ b/apps/server/src/provider/acp/GrokWorkflowCommands.ts @@ -112,8 +112,8 @@ function readWorkflowDir(dir: string, byName: Map { const byName = new Map(); for (const command of GROK_WORKFLOW_CONTROL_COMMANDS) { 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 }, ]); }); From 899ffa21dd55e42a85aa888a6aeac3a6825939e3 Mon Sep 17 00:00:00 2001 From: maslin Date: Sun, 23 Aug 2026 14:50:51 +0700 Subject: [PATCH 23/26] fix(grok): discover workflow scripts through Effect FileSystem Macroscope flagged readGrokWorkflowSlashCommands as blocking node:fs behind a plain function, so snapshot effects never declared the filesystem they used. Scan ~/.grok/workflows and project scripts with FileSystem and Path, and thread that requirement through the initial snapshot and health check. --- .../src/provider/Layers/GrokProvider.test.ts | 2 +- .../src/provider/Layers/GrokProvider.ts | 42 ++++--- .../provider/acp/GrokWorkflowCommands.test.ts | 116 ++++++++++-------- .../src/provider/acp/GrokWorkflowCommands.ts | 93 +++++++------- 4 files changed, 132 insertions(+), 121 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 40fcaf63d502..c3f06a8fc9aa 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( diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 226b9a7f288b..ac77d9404b05 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -10,7 +10,9 @@ 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"; @@ -46,23 +48,25 @@ const GROK_PRESENTATION = { } as const; const FALLBACK_CAPABILITIES: ModelCapabilities = fallbackGrokReasoningEffortCapabilities(); -function buildGrokServerProvider( +const buildGrokServerProvider = ( input: Parameters[0], discovery: { readonly environment: NodeJS.ProcessEnv; readonly projectRoot?: string | undefined; }, -): ServerProviderDraft { - return buildServerProvider({ - ...input, - slashCommands: +) => + Effect.gen(function* () { + const slashCommands = input.slashCommands ?? - readGrokWorkflowSlashCommands({ + (yield* readGrokWorkflowSlashCommands({ homeDir: discovery.environment.HOME, projectRoot: discovery.projectRoot, - }), + })); + return buildServerProvider({ + ...input, + slashCommands, + }); }); -} const VERSION_PROBE_TIMEOUT_MS = 4_000; const GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; @@ -83,7 +87,7 @@ export function buildInitialGrokProviderSnapshot( readonly environment?: NodeJS.ProcessEnv | undefined; readonly projectRoot?: string | undefined; }, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); const models = grokModelsFromSettings(grokSettings.customModels); @@ -93,7 +97,7 @@ export function buildInitialGrokProviderSnapshot( projectRoot: discovery?.projectRoot, }; if (!grokSettings.enabled) { - return buildGrokServerProvider( + return yield* buildGrokServerProvider( { presentation: GROK_PRESENTATION, enabled: false, @@ -111,7 +115,7 @@ export function buildInitialGrokProviderSnapshot( ); } - return buildGrokServerProvider( + return yield* buildGrokServerProvider( { presentation: GROK_PRESENTATION, enabled: true, @@ -210,7 +214,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func ): 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); @@ -219,7 +223,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func buildGrokServerProvider(input, discovery); if (!grokSettings.enabled) { - return providerDraft({ + return yield* providerDraft({ presentation: GROK_PRESENTATION, enabled: false, checkedAt, @@ -244,7 +248,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func yield* Effect.logWarning("Grok CLI health check failed.", { errorTag: error._tag, }); - return providerDraft({ + return yield* providerDraft({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, @@ -262,7 +266,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func } if (Option.isNone(versionResult.success)) { - return providerDraft({ + return yield* providerDraft({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, @@ -285,7 +289,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func stdoutLength: versionOutput.stdout.length, stderrLength: versionOutput.stderr.length, }); - return providerDraft({ + return yield* providerDraft({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, @@ -310,7 +314,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func errorTag: causeErrorTag(discoveryExit.cause), authFailed, }); - return providerDraft({ + return yield* providerDraft({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, @@ -330,7 +334,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 providerDraft({ + return yield* providerDraft({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, @@ -350,7 +354,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func ? grokModelsFromSettings(grokSettings.customModels, discoveredModels) : fallbackModels; - return providerDraft({ + return yield* providerDraft({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, diff --git a/apps/server/src/provider/acp/GrokWorkflowCommands.test.ts b/apps/server/src/provider/acp/GrokWorkflowCommands.test.ts index 507ee043368b..b99fc880b725 100644 --- a/apps/server/src/provider/acp/GrokWorkflowCommands.test.ts +++ b/apps/server/src/provider/acp/GrokWorkflowCommands.test.ts @@ -1,9 +1,8 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; - -import { describe, expect, it } from "vite-plus/test"; +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, @@ -41,53 +40,64 @@ agent("review", "look at the diff") }); }); -describe("readGrokWorkflowSlashCommands", () => { - it("includes pause/resume/stop and project scripts override user scripts", () => { - const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "grok-wf-")); - const userDir = NodePath.join(root, "home", ".grok", "workflows"); - const projectDir = NodePath.join(root, "project", ".grok", "workflows"); - NodeFS.mkdirSync(userDir, { recursive: true }); - NodeFS.mkdirSync(projectDir, { recursive: true }); - NodeFS.writeFileSync( - NodePath.join(userDir, "review-changes.rhai"), - `let meta = #{ name: "review-changes", description: "user copy" };\n`, - ); - NodeFS.writeFileSync( - NodePath.join(projectDir, "review-changes.rhai"), - `let meta = #{ name: "review-changes", description: "project copy" };\n`, - ); - NodeFS.writeFileSync( - NodePath.join(projectDir, "nowah-web-e2e.rhai"), - `let meta = #{ name: "nowah-web-e2e", description: "Write locked Playwright specs" };\n`, - ); +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 = readGrokWorkflowSlashCommands({ - homeDir: NodePath.join(root, "home"), - projectRoot: NodePath.join(root, "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", - }); - }); + 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("reads only the capped prefix of an oversized workflow script", () => { - const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "grok-wf-cap-")); - const userDir = NodePath.join(root, ".grok", "workflows"); - NodeFS.mkdirSync(userDir, { recursive: true }); - NodeFS.writeFileSync( - NodePath.join(userDir, "huge.rhai"), - `let meta = #{ name: "huge", description: "from prefix" };\n` + "x".repeat(80 * 1024), - ); - const commands = readGrokWorkflowSlashCommands({ homeDir: root }); - expect(commands).toContainEqual({ - name: "workflow huge", - description: "from prefix", - }); - }); + 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", + }); + }), + ), + ); }); diff --git a/apps/server/src/provider/acp/GrokWorkflowCommands.ts b/apps/server/src/provider/acp/GrokWorkflowCommands.ts index 2dbebdd9f534..10e6339ad29d 100644 --- a/apps/server/src/provider/acp/GrokWorkflowCommands.ts +++ b/apps/server/src/provider/acp/GrokWorkflowCommands.ts @@ -1,9 +1,7 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; - import type { ServerProviderSlashCommand } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; const SCRIPT_BYTE_CAP = 64 * 1024; @@ -65,37 +63,31 @@ export function grokWorkflowSlashCommandFromMeta( }; } -function readWorkflowDir(dir: string, byName: Map): void { - let entries: NodeFS.Dirent[]; - try { - entries = NodeFS.readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } +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.isFile() || !entry.name.endsWith(".rhai")) { + if (!entry.endsWith(".rhai")) { continue; } - const filePath = NodePath.join(dir, entry.name); - let source: string; - try { - const stat = NodeFS.statSync(filePath); - if (!stat.isFile() || stat.size <= 0) { - continue; - } - const length = Math.min(stat.size, SCRIPT_BYTE_CAP); - const handle = NodeFS.openSync(filePath, "r"); - try { - const buffer = Buffer.alloc(length); - const bytesRead = NodeFS.readSync(handle, buffer, 0, length, 0); - source = buffer.subarray(0, bytesRead).toString("utf8"); - } finally { - NodeFS.closeSync(handle); - } - } catch { + 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 fallbackName = NodePath.basename(entry.name, ".rhai"); + const bytes = yield* fileSystem.readFile(filePath).pipe(Effect.orElseSucceed(() => undefined)); + if (!bytes) { + continue; + } + const source = new TextDecoder("utf8").decode(bytes.subarray(0, SCRIPT_BYTE_CAP)); + const fallbackName = entry.endsWith(".rhai") ? entry.slice(0, -".rhai".length) : entry; const meta = parseGrokWorkflowScriptMeta(source, fallbackName); if (!meta) { continue; @@ -103,7 +95,7 @@ function readWorkflowDir(dir: string, byName: Map { - const byName = new Map(); - for (const command of GROK_WORKFLOW_CONTROL_COMMANDS) { - byName.set(command.name, command); - } - const homeDir = trimmed(input.homeDir) ?? NodeOS.homedir(); - readWorkflowDir(NodePath.join(homeDir, ".grok", "workflows"), byName); - const projectRoot = trimmed(input.projectRoot); - if (projectRoot && NodePath.isAbsolute(projectRoot)) { - readWorkflowDir(NodePath.join(projectRoot, ".grok", "workflows"), byName); - } - return [...byName.values()]; -} +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); + 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()]; + }, +); From af3cc51d11da50abbd23314ad6cb33680d60cb12 Mon Sep 17 00:00:00 2001 From: maslin Date: Sun, 23 Aug 2026 14:58:03 +0700 Subject: [PATCH 24/26] fix(grok): keep context max, rewind extras, and compact session honest Bugbot: a model-map miss wrote undefined into ctx.maxTokens and dropped the context ceiling. Rewind only trimmed turns, so usage, cost, queue, workflow, and tool gates stayed pre-undo. Compact complete always emitted session running, even with no live turn. Assign maxTokens only when the map has a number. After a successful rewind, clear those derived fields. Compact complete is ready unless a prompt is in flight or the session is already running. --- .../server/src/provider/Layers/GrokAdapter.ts | 17 +++++++-- .../provider/acp/GrokAcpSessionExtras.test.ts | 35 ++++++++++++++----- .../src/provider/acp/GrokAcpSessionExtras.ts | 7 +++- 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 8c02c992bf6d..cfec9bff702f 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -858,7 +858,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } const compact = parseXAiAutoCompact(params); if (compact) { - const specs = grokAutoCompactEvents(compact, ctx.lastKnownTokenUsage); + 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; @@ -1422,7 +1426,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ctx.currentModelId = currentModelId; ctx.currentReasoningEffort = turnSelection.reasoningEffort; if (currentModelId) { - ctx.maxTokens = ctx.maxTokensByModel.get(currentModelId); + const maxTokens = ctx.maxTokensByModel.get(currentModelId); + if (maxTokens !== undefined) { + ctx.maxTokens = maxTokens; + } } yield* applyGrokAcpSessionMode({ @@ -2014,6 +2021,12 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte 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 }; }), ); diff --git a/apps/server/src/provider/acp/GrokAcpSessionExtras.test.ts b/apps/server/src/provider/acp/GrokAcpSessionExtras.test.ts index 278bdc142e2e..285870f93775 100644 --- a/apps/server/src/provider/acp/GrokAcpSessionExtras.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSessionExtras.test.ts @@ -58,7 +58,7 @@ describe("GrokAcpSessionExtras", () => { reason: "Context window 80% full", }, }); - const events = grokAutoCompactEvents(parsed!, undefined); + const events = grokAutoCompactEvents(parsed!, undefined, false); expect(events).toContainEqual({ type: "thread.token-usage.updated", payload: { @@ -85,12 +85,16 @@ describe("GrokAcpSessionExtras", () => { elapsed_ms: 118_411, }, }); - const events = grokAutoCompactEvents(parsed!, { - usedTokens: 402_072, - maxTokens: 500_000, - lastUsedTokens: 402_072, - compactsAutomatically: true, - }); + const events = grokAutoCompactEvents( + parsed!, + { + usedTokens: 402_072, + maxTokens: 500_000, + lastUsedTokens: 402_072, + compactsAutomatically: true, + }, + false, + ); expect(events).toEqual([ { type: "thread.token-usage.updated", @@ -106,7 +110,7 @@ describe("GrokAcpSessionExtras", () => { }, { type: "session.state.changed", - payload: { state: "running", reason: "compaction completed", detail: parsed }, + payload: { state: "ready", reason: "compaction completed", detail: parsed }, }, { type: "thread.state.changed", @@ -115,6 +119,21 @@ describe("GrokAcpSessionExtras", () => { ]); }); + 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: { diff --git a/apps/server/src/provider/acp/GrokAcpSessionExtras.ts b/apps/server/src/provider/acp/GrokAcpSessionExtras.ts index 17cec013a174..f9817103d0c6 100644 --- a/apps/server/src/provider/acp/GrokAcpSessionExtras.ts +++ b/apps/server/src/provider/acp/GrokAcpSessionExtras.ts @@ -263,6 +263,7 @@ export function parseXAiAutoCompact(payload: unknown): GrokAutoCompact | undefin export function grokAutoCompactEvents( compact: GrokAutoCompact, previous: ThreadTokenUsageSnapshot | undefined, + sessionIsActive: boolean, ): ReadonlyArray { if (compact.kind === "started") { const maxTokens = compact.contextWindow ?? previous?.maxTokens; @@ -306,7 +307,11 @@ export function grokAutoCompactEvents( } events.push({ type: "session.state.changed", - payload: { state: "running", reason: "compaction completed", detail: compact }, + payload: { + state: sessionIsActive ? "running" : "ready", + reason: "compaction completed", + detail: compact, + }, }); events.push({ type: "thread.state.changed", From 56d72556a6dd66dd14dcec69a9a54d543a5e88cc Mon Sep 17 00:00:00 2001 From: maslin Date: Sun, 23 Aug 2026 15:06:56 +0700 Subject: [PATCH 25/26] fix(grok): bound workflow reads and resolve home without HOME Bugbot: Effect discovery loaded whole .rhai files then sliced, and skipped ~/.grok/workflows unless HOME was set. Windows uses USERPROFILE. GrokDriver still took project scripts from process.cwd() even though ServerConfig was already in its environment. Read only the 64 KiB prefix through FileSystem.open/readAlloc. Resolve the user workflow directory from HOME or USERPROFILE. Discover project scripts from ServerConfig.cwd. --- .../server/src/provider/Drivers/GrokDriver.ts | 2 +- .../src/provider/Layers/GrokProvider.test.ts | 25 ++++++++++ .../src/provider/Layers/GrokProvider.ts | 7 ++- .../provider/acp/GrokWorkflowCommands.test.ts | 46 +++++++++++++++++++ .../src/provider/acp/GrokWorkflowCommands.ts | 21 +++++++-- 5 files changed, 94 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index db9cdea1b596..910985867807 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -113,7 +113,7 @@ export const GrokDriver: ProviderDriver = { }); const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); - const projectRoot = process.cwd(); + const { cwd: projectRoot } = yield* ServerConfig; const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, projectRoot).pipe( Effect.map(stampIdentity), Effect.provideService(Crypto.Crypto, crypto), diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index c3f06a8fc9aa..3088d4a5a296 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -79,6 +79,31 @@ it.layer(NodeServices.layer)("buildInitialGrokProviderSnapshot workflows", (it) ); }), ); + + 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"]), + ); + }), + ); }); it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index ac77d9404b05..72639d368fe2 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -38,7 +38,10 @@ import { parseGrokAcpModelMeta, resolveGrokAcpBaseModelId, } from "../acp/GrokAcpSupport.ts"; -import { readGrokWorkflowSlashCommands } from "../acp/GrokWorkflowCommands.ts"; +import { + grokWorkflowHomeDirFromEnvironment, + readGrokWorkflowSlashCommands, +} from "../acp/GrokWorkflowCommands.ts"; const GROK_PRESENTATION = { displayName: "Grok", @@ -59,7 +62,7 @@ const buildGrokServerProvider = ( const slashCommands = input.slashCommands ?? (yield* readGrokWorkflowSlashCommands({ - homeDir: discovery.environment.HOME, + homeDir: grokWorkflowHomeDirFromEnvironment(discovery.environment), projectRoot: discovery.projectRoot, })); return buildServerProvider({ diff --git a/apps/server/src/provider/acp/GrokWorkflowCommands.test.ts b/apps/server/src/provider/acp/GrokWorkflowCommands.test.ts index b99fc880b725..b53049117749 100644 --- a/apps/server/src/provider/acp/GrokWorkflowCommands.test.ts +++ b/apps/server/src/provider/acp/GrokWorkflowCommands.test.ts @@ -6,10 +6,37 @@ 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( @@ -100,4 +127,23 @@ it.layer(NodeServices.layer)("readGrokWorkflowSlashCommands", (it) => { }), ), ); + + 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 index 10e6339ad29d..5c3fad6fa02a 100644 --- a/apps/server/src/provider/acp/GrokWorkflowCommands.ts +++ b/apps/server/src/provider/acp/GrokWorkflowCommands.ts @@ -1,6 +1,7 @@ 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; @@ -31,6 +32,13 @@ function trimmed(value: string | undefined): string | undefined { 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]); @@ -82,11 +90,16 @@ const readWorkflowDir = Effect.fn("grok.readWorkflowDir")(function* ( if (!info || info.type !== "File" || info.size <= 0n) { continue; } - const bytes = yield* fileSystem.readFile(filePath).pipe(Effect.orElseSucceed(() => undefined)); - if (!bytes) { + 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("utf8").decode(bytes.subarray(0, SCRIPT_BYTE_CAP)); + const source = new TextDecoder("utf8").decode(bytes.value); const fallbackName = entry.endsWith(".rhai") ? entry.slice(0, -".rhai".length) : entry; const meta = parseGrokWorkflowScriptMeta(source, fallbackName); if (!meta) { @@ -113,7 +126,7 @@ export const readGrokWorkflowSlashCommands = Effect.fn("grok.readWorkflowSlashCo for (const command of GROK_WORKFLOW_CONTROL_COMMANDS) { byName.set(command.name, command); } - const homeDir = trimmed(input.homeDir); + const homeDir = trimmed(input.homeDir) ?? grokWorkflowHomeDirFromEnvironment(process.env); if (homeDir) { yield* readWorkflowDir(path.join(homeDir, ".grok", "workflows"), byName); } From 1a3608f4211f999d3ab290847fcffc37a31e482b Mon Sep 17 00:00:00 2001 From: maslin Date: Sun, 23 Aug 2026 15:48:19 +0700 Subject: [PATCH 26/26] fix(grok): provide FileSystem to snapshot effects and decode utf-8 Check failed: initialSnapshot and checkProvider still required FileSystem and Path after workflow discovery moved onto Effect, and TextDecoder rejected the 'utf8' label. Provide those services the same way Claude does. Decode the capped prefix with the default utf-8 decoder. --- apps/server/src/provider/Drivers/GrokDriver.ts | 10 +++++++++- apps/server/src/provider/acp/GrokWorkflowCommands.ts | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 910985867807..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; @@ -118,6 +120,8 @@ export const GrokDriver: ProviderDriver = { 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); @@ -130,7 +134,11 @@ export const GrokDriver: ProviderDriver = { buildInitialGrokProviderSnapshot(settings.provider, { environment: processEnv, projectRoot, - }).pipe(Effect.map(stampIdentity)), + }).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/acp/GrokWorkflowCommands.ts b/apps/server/src/provider/acp/GrokWorkflowCommands.ts index 5c3fad6fa02a..5d88fd99307a 100644 --- a/apps/server/src/provider/acp/GrokWorkflowCommands.ts +++ b/apps/server/src/provider/acp/GrokWorkflowCommands.ts @@ -99,7 +99,7 @@ const readWorkflowDir = Effect.fn("grok.readWorkflowDir")(function* ( if (Option.isNone(bytes)) { continue; } - const source = new TextDecoder("utf8").decode(bytes.value); + 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) {