diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index bc7828dd854..114c6224bb5 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 7b6f0972ae8..9ccb83a0c94 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -1197,4 +1197,120 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { yield* adapter.stopSession(threadId); }), ); + + 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 977cc8caadd..a4d99f942f8 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -54,15 +54,26 @@ import { import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; import { + advertisedGrokReasoningEffortsFromSessionSetup, applyGrokAcpModelSelection, + currentGrokMaxTokensFromSessionSetup, currentGrokModelIdFromSessionSetup, + currentGrokReasoningEffortFromSessionSetup, + grokMaxTokensByModelFromSessionSetup, + grokReasoningEffortMenusFromSessionSetup, makeGrokAcpRuntime, + requestedGrokReasoningEffort, resolveGrokAcpBaseModelId, } from "../acp/GrokAcpSupport.ts"; import { + extractGrokTokenUsage, extractXAiAskUserQuestions, + grokPromptCountForTurns, + grokRewindTargetForTurnCount, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, + parseGrokRewindExecute, + parseGrokRewindPoints, promptResponseHasMissingXAiStopReason, XAiAskUserQuestionRequest, } from "../acp/XAiAcpExtension.ts"; @@ -117,6 +128,10 @@ interface GrokSessionContext { * continues it, and only the last remaining prompt settles the turn. */ promptsInFlight: number; currentModelId: string | undefined; + currentReasoningEffort: string | undefined; + reasoningEffortMenus: Map>; + maxTokensByModel: Map; + maxTokens: number | undefined; stopped: boolean; } @@ -549,6 +564,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 +592,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 +755,27 @@ 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 maxTokensByModel = grokMaxTokensByModelFromSessionSetup(started.sessionSetupResult); const now = yield* nowIso; const session: ProviderSession = { @@ -778,6 +809,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte interruptedTurnIds: new Set(), promptsInFlight: 0, currentModelId: boundModelId, + currentReasoningEffort: boundSelection.reasoningEffort, + reasoningEffortMenus: grokReasoningEffortMenusFromSessionSetup( + started.sessionSetupResult, + ), + maxTokensByModel, + maxTokens: + (boundModelId ? maxTokensByModel.get(boundModelId) : undefined) ?? + currentGrokMaxTokensFromSessionSetup(started.sessionSetupResult), stopped: false, }; @@ -942,13 +981,29 @@ 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) ?? []) + : ctx.currentModelId + ? (ctx.reasoningEffortMenus.get(ctx.currentModelId) ?? []) + : []; + 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.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( @@ -1143,6 +1198,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", @@ -1398,21 +1464,89 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }); const rollbackThread: GrokAdapterShape["rollbackThread"] = (threadId, numTurns) => - Effect.gen(function* () { - yield* requireSession(threadId); - if (!Number.isInteger(numTurns) || numTurns < 1) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "rollbackThread", - issue: "numTurns must be an integer >= 1.", - }); - } - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "thread/rollback", - detail: "Grok ACP sessions do not support provider-side rollback yet.", - }); - }); + 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, + ); + 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), + ); + return { threadId, turns: committedCtx.turns }; + }), + ); const stopSession: GrokAdapterShape["stopSession"] = (threadId) => withThreadLock( diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 000243869c9..31ebe5e1ae4 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 934eecdb5ae..a19f6359778 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 09fce6d56f9..5d002835ff3 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 02d60976b24..d3011ef818d 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,79 @@ describe("buildGrokAcpSpawnInput", () => { }, }); }); + + it("puts --reasoning-effort before stdio", () => { + const spawn = buildGrokAcpSpawnInput({ binaryPath: "grok" }, "/tmp/project", undefined, "high"); + expect(spawn.args).toEqual(["agent", "--reasoning-effort", "high", "stdio"]); + }); + + it("ignores spawn effort values the CLI rejects", () => { + const spawn = buildGrokAcpSpawnInput({ binaryPath: "grok" }, "/tmp/project", undefined, "max"); + expect(spawn.args).toEqual(["agent", "stdio"]); + }); +}); + +describe("parseGrokAcpModelMeta", () => { + it("reads the live Grok effort menu", () => { + const meta = parseGrokAcpModelMeta({ + supportsReasoningEffort: true, + reasoningEffort: "high", + totalContextTokens: 500000, + reasoningEfforts: [ + { id: "xhigh", value: "xhigh", label: "Extra High Effort", default: true }, + { id: "high", value: "high", label: "High Effort", default: true }, + { id: "medium", value: "medium", label: "Medium Effort" }, + ], + }); + expect(meta.supportsReasoningEffort).toBe(true); + expect(meta.reasoningEffort).toBe("high"); + expect(meta.totalContextTokens).toBe(500000); + expect(meta.reasoningEfforts.map((choice) => choice.id)).toEqual(["xhigh", "high", "medium"]); + expect( + meta.reasoningEfforts.filter((choice) => choice.isDefault).map((choice) => choice.id), + ).toEqual(["high"]); + expect(grokReasoningEffortCapabilities(meta.reasoningEfforts).optionDescriptors?.[0]?.id).toBe( + GROK_REASONING_EFFORT_OPTION_ID, + ); + }); +}); + +describe("requestedGrokReasoningEffort", () => { + it("drops effort values the current model does not advertise", () => { + expect( + requestedGrokReasoningEffort( + { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-4.5", + options: [{ id: GROK_REASONING_EFFORT_OPTION_ID, value: "xhigh" }], + }, + ["high", "medium", "low"], + ), + ).toBeUndefined(); + }); +}); + +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 +128,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 +143,38 @@ describe("applyGrokAcpModelSelection", () => { mapError: (cause) => cause.message, }); expect(modelCalls).toEqual([]); - expect(result).toBe("grok-build"); + expect(result).toEqual({ modelId: "grok-build", reasoningEffort: undefined }); + }), + ); + + it.effect("does not carry the previous effort across a model switch", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-4.6", + requestedModelId: "grok-4.5", + currentReasoningEffort: "xhigh", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([{ modelId: "grok-4.5" }]); + expect(result).toEqual({ modelId: "grok-4.5", reasoningEffort: undefined }); + }), + ); + + it.effect("calls session/set_model when only effort changes", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-4.6", + requestedModelId: "grok-4.6", + currentReasoningEffort: "high", + requestedReasoningEffort: "xhigh", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([{ modelId: "grok-4.6", effort: "xhigh" }]); + expect(result).toEqual({ modelId: "grok-4.6", reasoningEffort: "xhigh" }); }), ); @@ -87,7 +188,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 c928b3ed80e..309e8bedc9e 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,267 @@ export function currentGrokModelIdFromSessionSetup( return sessionSetupResult.models?.currentModelId?.trim() || undefined; } +export function spawnableGrokReasoningEffort(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed || !GROK_SPAWN_EFFORT_LEVELS.has(trimmed)) { + return undefined; + } + return trimmed; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function trimmedString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function parseGrokReasoningEffortChoice(value: unknown): GrokReasoningEffortChoice | undefined { + if (typeof value === "string") { + const id = value.trim(); + return id.length > 0 ? { id, label: id } : undefined; + } + if (!isRecord(value)) { + return undefined; + } + const id = trimmedString(value.value) ?? trimmedString(value.id); + if (!id) { + return undefined; + } + const label = trimmedString(value.label) ?? trimmedString(value.name) ?? id; + const description = trimmedString(value.description); + return { + id, + label, + ...(description ? { description } : {}), + ...(value.default === true || value.isDefault === true ? { isDefault: true } : {}), + }; +} + +/** Reads the per-model effort menu Grok stamps onto ACP `models._meta`. */ +export function parseGrokAcpModelMeta(meta: unknown): GrokAcpModelMeta { + if (!isRecord(meta)) { + return { supportsReasoningEffort: false, reasoningEfforts: [] }; + } + + const reasoningEfforts = Array.isArray(meta.reasoningEfforts) + ? meta.reasoningEfforts.flatMap((entry) => { + const choice = parseGrokReasoningEffortChoice(entry); + return choice ? [choice] : []; + }) + : []; + const unique = new Map(); + for (const choice of reasoningEfforts) { + if (!unique.has(choice.id)) { + unique.set(choice.id, choice); + } + } + const choices = [...unique.values()]; + const current = trimmedString(meta.reasoningEffort); + const defaultId = current ?? choices.find((choice) => choice.isDefault)?.id; + const supportsReasoningEffort = meta.supportsReasoningEffort === true || choices.length > 0; + const totalContextTokens = + typeof meta.totalContextTokens === "number" && + Number.isFinite(meta.totalContextTokens) && + meta.totalContextTokens > 0 + ? Math.trunc(meta.totalContextTokens) + : undefined; + + return { + supportsReasoningEffort, + ...(current ? { reasoningEffort: current } : {}), + reasoningEfforts: choices.map((choice) => ({ + id: choice.id, + label: choice.label, + ...(choice.description ? { description: choice.description } : {}), + ...(choice.id === defaultId ? { isDefault: true } : {}), + })), + ...(totalContextTokens !== undefined ? { totalContextTokens } : {}), + }; +} + +export function grokReasoningEffortCapabilities( + efforts: ReadonlyArray, +): ModelCapabilities { + if (efforts.length === 0) { + return createModelCapabilities({ optionDescriptors: [] }); + } + const defaultId = efforts.find((choice) => choice.isDefault)?.id ?? efforts[0]?.id; + return createModelCapabilities({ + optionDescriptors: [ + { + id: GROK_REASONING_EFFORT_OPTION_ID, + label: "Reasoning", + type: "select", + options: efforts.map((choice) => ({ + id: choice.id, + label: choice.label, + ...(choice.description ? { description: choice.description } : {}), + ...(choice.isDefault ? { isDefault: true } : {}), + })), + ...(defaultId ? { currentValue: defaultId } : {}), + }, + ], + }); +} + +export function fallbackGrokReasoningEffortCapabilities(): ModelCapabilities { + return grokReasoningEffortCapabilities([...FALLBACK_GROK_REASONING_EFFORTS]); +} + +export function requestedGrokReasoningEffort( + modelSelection: ModelSelection | null | undefined, + advertised: ReadonlyArray, +): string | undefined { + const requested = getModelSelectionStringOptionValue( + modelSelection, + GROK_REASONING_EFFORT_OPTION_ID, + )?.trim(); + if (!requested) { + return undefined; + } + 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 + | 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 | undefined; + readonly requestedReasoningEffort?: string | undefined; readonly mapError: (cause: EffectAcpErrors.AcpError) => E; -}): Effect.Effect { +}): Effect.Effect { + const nextModelId = input.requestedModelId ?? input.currentModelId; const shouldSwitchModel = input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; - if (!shouldSwitchModel) { - return Effect.succeed(input.currentModelId); + const nextEffort = shouldSwitchModel + ? input.requestedReasoningEffort + : (input.requestedReasoningEffort ?? input.currentReasoningEffort); + const shouldSwitchEffort = + input.requestedReasoningEffort !== undefined && + input.requestedReasoningEffort !== input.currentReasoningEffort; + + if (!shouldSwitchModel && !shouldSwitchEffort) { + return Effect.succeed({ + modelId: input.currentModelId, + reasoningEffort: input.currentReasoningEffort, + }); + } + + if (nextModelId === undefined) { + return Effect.succeed({ + modelId: undefined, + reasoningEffort: nextEffort, + }); } + return input.runtime - .setSessionModel(input.requestedModelId) - .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId)); + .setSessionModel( + nextModelId, + nextEffort ? { _meta: { reasoningEffort: nextEffort } } : undefined, + ) + .pipe( + Effect.mapError(input.mapError), + Effect.as({ + modelId: nextModelId, + reasoningEffort: nextEffort, + }), + ); } diff --git a/apps/server/src/provider/acp/XAiAcpExtension.test.ts b/apps/server/src/provider/acp/XAiAcpExtension.test.ts index c435269fd76..5e976cd8ae3 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.test.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.test.ts @@ -9,10 +9,14 @@ import * as Schema from "effect/Schema"; import { describe, expect } from "vite-plus/test"; import { + extractGrokTokenUsage, extractXAiAskUserQuestions, + grokPromptCountForTurns, + grokRewindTargetForTurnCount, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, makeXAiPromptCompletionRuntime, + parseGrokRewindPoints, XAiAskUserQuestionRequest, } from "./XAiAcpExtension.ts"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; @@ -330,3 +334,32 @@ 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); + expect(grokRewindTargetForTurnCount(points, 4)).toBeUndefined(); + expect(grokPromptCountForTurns([{ items: [1] }, { items: [2, 3] }], 1)).toBe(2); + }); + + 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 d36a5fcfc89..2b480da5e06 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,205 @@ 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); + 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 { + 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 30653e7d503..4dd22aecdfe 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 fe0b418ca1e..bb135094701 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -75,6 +75,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 00000000000..ffae94a0cf9 --- /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 9fcd0d266dd..b6a8edd9140 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", };