From 7f66aee7ff1fcf2f44adc2a75ab352de8bd726fe Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Wed, 12 Aug 2026 15:14:41 -0700 Subject: [PATCH 1/6] fix(grok): expose advertised reasoning levels --- .../server/src/provider/Layers/GrokAdapter.ts | 31 +++++++- .../src/provider/Layers/GrokProvider.test.ts | 46 ++++++++++- .../src/provider/Layers/GrokProvider.ts | 78 ++++++++++++++++++- .../src/provider/acp/AcpSessionRuntime.ts | 4 +- .../src/provider/acp/GrokAcpCliProbe.test.ts | 20 +++++ .../src/provider/acp/GrokAcpSupport.test.ts | 27 ++++++- .../server/src/provider/acp/GrokAcpSupport.ts | 35 ++++++++- .../src/textGeneration/GrokTextGeneration.ts | 10 +++ docs/user/install.md | 4 + 9 files changed, 243 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 977cc8caadd..7195492d25f 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -12,6 +12,7 @@ import { type ThreadId, TurnId, } from "@t3tools/contracts"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -56,6 +57,7 @@ import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; import { applyGrokAcpModelSelection, currentGrokModelIdFromSessionSetup, + currentGrokReasoningEffortFromSessionSetup, makeGrokAcpRuntime, resolveGrokAcpBaseModelId, } from "../acp/GrokAcpSupport.ts"; @@ -117,6 +119,7 @@ interface GrokSessionContext { * continues it, and only the last remaining prompt settles the turn. */ promptsInFlight: number; currentModelId: string | undefined; + currentReasoningEffort: string | undefined; stopped: boolean; } @@ -738,10 +741,22 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const requestedStartModelId = grokModelSelection?.model ? resolveGrokAcpBaseModelId(grokModelSelection.model) : undefined; + const currentStartModelId = currentGrokModelIdFromSessionSetup( + started.sessionSetupResult, + ); + const currentStartReasoningEffort = currentGrokReasoningEffortFromSessionSetup( + started.sessionSetupResult, + ); + const requestedStartReasoningEffort = getModelSelectionStringOptionValue( + grokModelSelection, + "reasoningEffort", + ); const boundModelId = yield* applyGrokAcpModelSelection({ runtime: acp, - currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult), + currentModelId: currentStartModelId, + currentReasoningEffort: currentStartReasoningEffort, requestedModelId: requestedStartModelId, + requestedReasoningEffort: requestedStartReasoningEffort, mapError: (cause) => mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), }); @@ -778,6 +793,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte interruptedTurnIds: new Set(), promptsInFlight: 0, currentModelId: boundModelId, + currentReasoningEffort: requestedStartReasoningEffort ?? currentStartReasoningEffort, stopped: false, }; @@ -942,10 +958,18 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const requestedTurnModelId = turnModelSelection?.model ? resolveGrokAcpBaseModelId(turnModelSelection.model) : undefined; + const requestedTurnReasoningEffort = getModelSelectionStringOptionValue( + turnModelSelection, + "reasoningEffort", + ); + const modelChanged = + requestedTurnModelId !== undefined && requestedTurnModelId !== ctx.currentModelId; const currentModelId = yield* applyGrokAcpModelSelection({ runtime: ctx.acp, currentModelId: ctx.currentModelId, + currentReasoningEffort: ctx.currentReasoningEffort, requestedModelId: requestedTurnModelId, + requestedReasoningEffort: requestedTurnReasoningEffort, mapError: (cause) => mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), }); @@ -998,6 +1022,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } ctx.currentModelId = currentModelId; + if (requestedTurnReasoningEffort !== undefined) { + ctx.currentReasoningEffort = requestedTurnReasoningEffort; + } else if (modelChanged) { + ctx.currentReasoningEffort = undefined; + } const displayModel = currentModelId ? resolveGrokAcpBaseModelId(currentModelId) : undefined; diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 000243869c9..6dbf9fb07f9 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -6,10 +6,54 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { GrokSettings } from "@t3tools/contracts"; -import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus } from "./GrokProvider.ts"; +import { + buildGrokModelCapabilities, + buildInitialGrokProviderSnapshot, + checkGrokProviderStatus, +} from "./GrokProvider.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); +describe("buildGrokModelCapabilities", () => { + it("maps model-specific ACP reasoning metadata and its active default", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: true, + reasoningEffort: "xhigh", + reasoningEfforts: [ + { value: "xhigh", label: "Extra High Effort", default: true }, + { value: "high", label: "High Effort", default: true }, + { value: "medium", label: "Medium Effort" }, + { value: "low", label: "Low Effort" }, + ], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + currentValue: "xhigh", + options: [ + { id: "xhigh", label: "Extra High Effort", isDefault: true }, + { id: "high", label: "High Effort" }, + { id: "medium", label: "Medium Effort" }, + { id: "low", label: "Low Effort" }, + ], + }, + ]); + }); + + it("keeps non-reasoning Grok models free of reasoning controls", () => { + expect( + buildGrokModelCapabilities({ modelId: "grok-build", name: "Grok Build" }).optionDescriptors, + ).toEqual([]); + }); +}); + describe("buildInitialGrokProviderSnapshot", () => { it.effect("returns a disabled snapshot when settings.enabled is false", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 934eecdb5ae..5396e99a8fb 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -18,6 +18,7 @@ import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { + buildSelectOptionDescriptor, buildServerProvider, isCommandMissingCause, parseGenericCliVersion, @@ -40,6 +41,15 @@ const GROK_PRESENTATION = { const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], }); +const LEGACY_GROK_REASONING_EFFORTS = ["xhigh", "high", "medium", "low"] as const; +const GROK_REASONING_EFFORT_LABELS: Readonly> = { + none: "None", + minimal: "Minimal", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", +}; const VERSION_PROBE_TIMEOUT_MS = 4_000; const GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; @@ -99,6 +109,72 @@ function grokModelsFromSettings( return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); } +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" ? value.trim() || undefined : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function grokReasoningEffortLabel(value: string): string { + return GROK_REASONING_EFFORT_LABELS[value] ?? value; +} + +function grokReasoningOptionsFromModel( + model: EffectAcpSchema.ModelInfo, +): ReadonlyArray<{ value: string; label: string; isDefault?: boolean }> { + const meta = model._meta; + if (!meta || meta.supportsReasoningEffort !== true) { + return []; + } + + const defaultEffort = nonEmptyString(meta.reasoningEffort); + const advertisedOptions = Array.isArray(meta.reasoningEfforts) + ? meta.reasoningEfforts + : LEGACY_GROK_REASONING_EFFORTS; + const seen = new Set(); + const options: Array<{ value: string; label: string; advertisedDefault: boolean }> = []; + + for (const entry of advertisedOptions) { + const value = nonEmptyString(isRecord(entry) ? (entry.value ?? entry.id) : entry); + if (!value || seen.has(value)) { + continue; + } + seen.add(value); + options.push({ + value, + label: + nonEmptyString(isRecord(entry) ? entry.label : undefined) ?? + grokReasoningEffortLabel(value), + advertisedDefault: isRecord(entry) && entry.default === true, + }); + } + + const selectedDefault = + (defaultEffort && options.some((option) => option.value === defaultEffort) + ? defaultEffort + : undefined) ?? options.find((option) => option.advertisedDefault)?.value; + return options.map(({ value, label }) => + value === selectedDefault ? { value, label, isDefault: true } : { value, label }, + ); +} + +export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): ModelCapabilities { + const reasoningOptions = grokReasoningOptionsFromModel(model); + return reasoningOptions.length > 0 + ? createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "reasoningEffort", + label: "Reasoning", + options: reasoningOptions, + }), + ], + }) + : EMPTY_CAPABILITIES; +} + function buildGrokDiscoveredModelsFromSessionModelState( modelState: EffectAcpSchema.SessionModelState | null | undefined, ): ReadonlyArray { @@ -117,7 +193,7 @@ function buildGrokDiscoveredModelsFromSessionModelState( slug, name: model.name.trim() || slug, isCustom: false, - capabilities: EMPTY_CAPABILITIES, + capabilities: buildGrokModelCapabilities(model), }; }) .filter((model): model is ServerProviderModel => model !== undefined); diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 09fce6d56f9..67255431666 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, + meta?: EffectAcpSchema.SetSessionModelRequest["_meta"], ) => 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, meta) => getStartedState.pipe( Effect.flatMap((started) => { const requestPayload = { sessionId: started.sessionId, modelId, + ...(meta !== undefined ? { _meta: meta } : {}), } satisfies EffectAcpSchema.SetSessionModelRequest; return runLoggedRequest( "session/set_model", diff --git a/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts b/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts index 222fc4a12d5..abc35205284 100644 --- a/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts +++ b/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts @@ -66,4 +66,24 @@ describe.runIf(process.env.T3_GROK_ACP_PROBE === "1")("Grok ACP CLI probe", () = yield* runtime.setSessionModel(currentModelId); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.effect("session/set_model accepts advertised reasoning effort metadata", () => + Effect.gen(function* () { + const runtime = yield* makeProbeRuntime; + const started = yield* runtime.start(); + const modelState = started.sessionSetupResult.models; + const currentModelId = modelState?.currentModelId.trim(); + expect(currentModelId).toBeDefined(); + if (!currentModelId) return; + + const currentModel = modelState?.availableModels.find( + (model) => model.modelId.trim() === currentModelId, + ); + const reasoningEffort = currentModel?._meta?.reasoningEffort; + expect(typeof reasoningEffort).toBe("string"); + if (typeof reasoningEffort !== "string") return; + + yield* runtime.setSessionModel(currentModelId, { reasoningEffort }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index 02d60976b24..6f9b441202e 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -37,11 +37,14 @@ describe("buildGrokAcpSpawnInput", () => { describe("applyGrokAcpModelSelection", () => { const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => { - const modelCalls: Array = []; + const modelCalls: Array<{ + modelId: string; + meta?: { readonly [key: string]: unknown } | null; + }> = []; const runtime = { - setSessionModel: (modelId: string) => + setSessionModel: (modelId: string, meta?: { readonly [key: string]: unknown } | null) => Effect.gen(function* () { - modelCalls.push(modelId); + modelCalls.push(meta === undefined ? { modelId } : { modelId, meta }); if (failure) return yield* failure; return {}; }), @@ -58,11 +61,27 @@ describe("applyGrokAcpModelSelection", () => { requestedModelId: "grok-mock-alt", mapError: (cause) => cause.message, }); - expect(modelCalls).toEqual(["grok-mock-alt"]); + expect(modelCalls).toEqual([{ modelId: "grok-mock-alt" }]); expect(result).toBe("grok-mock-alt"); }), ); + it.effect("applies reasoning effort through session/set_model metadata", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-4.6", + currentReasoningEffort: "high", + requestedModelId: "grok-4.6", + requestedReasoningEffort: "xhigh", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([{ modelId: "grok-4.6", meta: { reasoningEffort: "xhigh" } }]); + expect(result).toBe("grok-4.6"); + }), + ); + it.effect("skips set_model when requested matches current", () => 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 c928b3ed80e..e93f05d0d10 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -91,18 +91,45 @@ export function currentGrokModelIdFromSessionSetup( return sessionSetupResult.models?.currentModelId?.trim() || undefined; } +export function currentGrokReasoningEffortFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): string | undefined { + const modelState = sessionSetupResult.models; + if (!modelState) { + return undefined; + } + const currentModelId = modelState.currentModelId.trim(); + if (currentModelId.length === 0) { + return undefined; + } + const currentModel = modelState.availableModels.find( + (model) => model.modelId.trim() === currentModelId, + ); + const reasoningEffort = currentModel?._meta?.reasoningEffort; + return typeof reasoningEffort === "string" ? reasoningEffort.trim() || undefined : undefined; +} + export function applyGrokAcpModelSelection(input: { readonly runtime: Pick; readonly currentModelId: string | undefined; + readonly currentReasoningEffort?: string | undefined; readonly requestedModelId: string | undefined; + readonly requestedReasoningEffort?: string | undefined; readonly mapError: (cause: EffectAcpErrors.AcpError) => E; }): Effect.Effect { - const shouldSwitchModel = + const modelChanged = input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; - if (!shouldSwitchModel) { + const reasoningEffort = input.requestedReasoningEffort?.trim() || undefined; + const reasoningEffortChanged = + reasoningEffort !== undefined && reasoningEffort !== input.currentReasoningEffort; + const targetModelId = input.requestedModelId ?? input.currentModelId; + if ((!modelChanged && !reasoningEffortChanged) || targetModelId === undefined) { return Effect.succeed(input.currentModelId); } return input.runtime - .setSessionModel(input.requestedModelId) - .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId)); + .setSessionModel(targetModelId, reasoningEffort !== undefined ? { reasoningEffort } : undefined) + .pipe(Effect.mapError(input.mapError), Effect.as(targetModelId)); } diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index 1cf3d13e225..0b24b260cad 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -8,6 +8,7 @@ import type * as EffectAcpErrors from "effect-acp/errors"; import { type GrokSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; import { TextGenerationError } from "@t3tools/contracts"; @@ -26,6 +27,7 @@ import { import { applyGrokAcpModelSelection, currentGrokModelIdFromSessionSetup, + currentGrokReasoningEffortFromSessionSetup, makeGrokAcpRuntime, resolveGrokAcpBaseModelId, } from "../provider/acp/GrokAcpSupport.ts"; @@ -83,10 +85,18 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi const promptResult = yield* Effect.gen(function* () { const started = yield* runtime.start(); + const requestedReasoningEffort = getModelSelectionStringOptionValue( + modelSelection, + "reasoningEffort", + ); yield* applyGrokAcpModelSelection({ runtime, currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult), + currentReasoningEffort: currentGrokReasoningEffortFromSessionSetup( + started.sessionSetupResult, + ), requestedModelId: resolvedModel, + requestedReasoningEffort, mapError: (cause) => new TextGenerationError({ operation, diff --git a/docs/user/install.md b/docs/user/install.md index fe0b418ca1e..791f94e6464 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -57,6 +57,10 @@ to use, then authenticate it. Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`. +Grok models that support adjustable reasoning show a **Reasoning** control beside the model picker. +The available levels and default come from the installed Grok Build CLI, so they can vary by model +and CLI version. + Run the login command on the machine running the T3 Code server, not on the device you browse from. From 67309be4e9972933339118260fae11d806288c7b Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Wed, 12 Aug 2026 15:20:04 -0700 Subject: [PATCH 2/6] test(grok): keep fallback reasoning labels neutral --- .../src/provider/Layers/GrokProvider.test.ts | 27 ++++++++++++++++++- .../src/provider/Layers/GrokProvider.ts | 8 +++--- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 6dbf9fb07f9..7895b70139a 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -15,7 +15,7 @@ import { const decodeGrokSettings = Schema.decodeSync(GrokSettings); describe("buildGrokModelCapabilities", () => { - it("maps model-specific ACP reasoning metadata and its active default", () => { + it("preserves ACP-provided reasoning labels and the active default", () => { const capabilities = buildGrokModelCapabilities({ modelId: "grok-4.6", name: "Grok 4.6", @@ -47,6 +47,31 @@ describe("buildGrokModelCapabilities", () => { ]); }); + it("does not append an Effort suffix when ACP omits option labels", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: true, + reasoningEffort: "xhigh", + reasoningEfforts: [{ value: "xhigh" }, { value: "medium" }], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + currentValue: "xhigh", + options: [ + { id: "xhigh", label: "Extra High", isDefault: true }, + { id: "medium", label: "Medium" }, + ], + }, + ]); + }); + it("keeps non-reasoning Grok models free of reasoning controls", () => { expect( buildGrokModelCapabilities({ modelId: "grok-build", name: "Grok Build" }).optionDescriptors, diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 5396e99a8fb..65d12f84c41 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -42,7 +42,7 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], }); const LEGACY_GROK_REASONING_EFFORTS = ["xhigh", "high", "medium", "low"] as const; -const GROK_REASONING_EFFORT_LABELS: Readonly> = { +const FALLBACK_GROK_REASONING_LABELS: Readonly> = { none: "None", minimal: "Minimal", low: "Low", @@ -117,8 +117,8 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function grokReasoningEffortLabel(value: string): string { - return GROK_REASONING_EFFORT_LABELS[value] ?? value; +function fallbackGrokReasoningLabel(value: string): string { + return FALLBACK_GROK_REASONING_LABELS[value] ?? value; } function grokReasoningOptionsFromModel( @@ -146,7 +146,7 @@ function grokReasoningOptionsFromModel( value, label: nonEmptyString(isRecord(entry) ? entry.label : undefined) ?? - grokReasoningEffortLabel(value), + fallbackGrokReasoningLabel(value), advertisedDefault: isRecord(entry) && entry.default === true, }); } From 1179146172102bb7dd2f8c7672c616d6c01d1305 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Wed, 12 Aug 2026 15:22:32 -0700 Subject: [PATCH 3/6] fix(grok): remove reasoning capability fallbacks --- .../src/provider/Layers/GrokProvider.test.ts | 16 +++++++++++--- .../src/provider/Layers/GrokProvider.ts | 21 ++----------------- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 7895b70139a..602e35bd292 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -47,7 +47,7 @@ describe("buildGrokModelCapabilities", () => { ]); }); - it("does not append an Effort suffix when ACP omits option labels", () => { + it("uses raw ACP values when option labels are omitted", () => { const capabilities = buildGrokModelCapabilities({ modelId: "grok-4.6", name: "Grok 4.6", @@ -65,13 +65,23 @@ describe("buildGrokModelCapabilities", () => { type: "select", currentValue: "xhigh", options: [ - { id: "xhigh", label: "Extra High", isDefault: true }, - { id: "medium", label: "Medium" }, + { id: "xhigh", label: "xhigh", isDefault: true }, + { id: "medium", label: "medium" }, ], }, ]); }); + it("does not synthesize a reasoning menu when ACP omits it", () => { + expect( + buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { supportsReasoningEffort: true, reasoningEffort: "xhigh" }, + }).optionDescriptors, + ).toEqual([]); + }); + it("keeps non-reasoning Grok models free of reasoning controls", () => { expect( buildGrokModelCapabilities({ modelId: "grok-build", name: "Grok Build" }).optionDescriptors, diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 65d12f84c41..436eff5549b 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -41,15 +41,6 @@ const GROK_PRESENTATION = { const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], }); -const LEGACY_GROK_REASONING_EFFORTS = ["xhigh", "high", "medium", "low"] as const; -const FALLBACK_GROK_REASONING_LABELS: Readonly> = { - none: "None", - minimal: "Minimal", - low: "Low", - medium: "Medium", - high: "High", - xhigh: "Extra High", -}; const VERSION_PROBE_TIMEOUT_MS = 4_000; const GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; @@ -117,10 +108,6 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function fallbackGrokReasoningLabel(value: string): string { - return FALLBACK_GROK_REASONING_LABELS[value] ?? value; -} - function grokReasoningOptionsFromModel( model: EffectAcpSchema.ModelInfo, ): ReadonlyArray<{ value: string; label: string; isDefault?: boolean }> { @@ -130,9 +117,7 @@ function grokReasoningOptionsFromModel( } const defaultEffort = nonEmptyString(meta.reasoningEffort); - const advertisedOptions = Array.isArray(meta.reasoningEfforts) - ? meta.reasoningEfforts - : LEGACY_GROK_REASONING_EFFORTS; + const advertisedOptions = Array.isArray(meta.reasoningEfforts) ? meta.reasoningEfforts : []; const seen = new Set(); const options: Array<{ value: string; label: string; advertisedDefault: boolean }> = []; @@ -144,9 +129,7 @@ function grokReasoningOptionsFromModel( seen.add(value); options.push({ value, - label: - nonEmptyString(isRecord(entry) ? entry.label : undefined) ?? - fallbackGrokReasoningLabel(value), + label: nonEmptyString(isRecord(entry) ? entry.label : undefined) ?? value, advertisedDefault: isRecord(entry) && entry.default === true, }); } From 2999f46e6041eea152251f346ae85de6278b862f Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Wed, 12 Aug 2026 15:25:40 -0700 Subject: [PATCH 4/6] fix(grok): validate turns before changing effort --- .../src/provider/Layers/GrokAdapter.test.ts | 31 +++++++++++++++++-- .../server/src/provider/Layers/GrokAdapter.ts | 18 +++++------ 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 7b6f0972ae8..8d0ccc737d0 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -273,7 +273,13 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { it.effect("restores ready without completing an unstarted turn when preparation fails", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-preparation-failure-while-connecting"); - const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-preparation-failure-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath }), + ); const adapter = yield* makeTestAdapter(wrapperPath); const runtimeEvents: ProviderRuntimeEvent[] = []; @@ -295,6 +301,11 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { adapter.sendTurn({ threadId, input: "prepare invalid attachment", + modelSelection: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + options: [{ id: "reasoningEffort", value: "high" }], + }, attachments: [ { type: "image", @@ -321,6 +332,8 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { assert.isUndefined(turnCompletedEvent); assert.equal(readySession?.status, "ready"); assert.isUndefined(readySession?.activeTurnId); + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.isFalse(requests.some((entry) => entry.method === "session/set_model")); yield* Fiber.interrupt(runtimeEventsFiber); yield* adapter.stopSession(threadId); @@ -1018,8 +1031,13 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { it.effect("rejects sendTurn with empty input and no attachments", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-empty-turn"); - - const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-empty-turn-")), + ); + 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({ @@ -1035,10 +1053,17 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { threadId, input: " ", attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + options: [{ id: "reasoningEffort", value: "high" }], + }, }), ); assert.equal(error._tag, "ProviderAdapterValidationError"); + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.isFalse(requests.some((entry) => entry.method === "session/set_model")); yield* adapter.stopSession(threadId); }), diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 7195492d25f..4cfe750f7cd 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -964,15 +964,6 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ); const modelChanged = requestedTurnModelId !== undefined && requestedTurnModelId !== ctx.currentModelId; - const currentModelId = yield* applyGrokAcpModelSelection({ - runtime: ctx.acp, - currentModelId: ctx.currentModelId, - currentReasoningEffort: ctx.currentReasoningEffort, - requestedModelId: requestedTurnModelId, - requestedReasoningEffort: requestedTurnReasoningEffort, - mapError: (cause) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), - }); const text = input.input?.trim(); const imagePromptParts = yield* Effect.forEach( @@ -1021,6 +1012,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }); } + const currentModelId = yield* applyGrokAcpModelSelection({ + runtime: ctx.acp, + currentModelId: ctx.currentModelId, + currentReasoningEffort: ctx.currentReasoningEffort, + requestedModelId: requestedTurnModelId, + requestedReasoningEffort: requestedTurnReasoningEffort, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + }); ctx.currentModelId = currentModelId; if (requestedTurnReasoningEffort !== undefined) { ctx.currentReasoningEffort = requestedTurnReasoningEffort; From ca7664b7d5be2d1f22b4473d6189220251ebfbe9 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Wed, 12 Aug 2026 16:27:17 -0700 Subject: [PATCH 5/6] fix(grok): reset effort after start model switch --- apps/server/scripts/acp-mock-agent.ts | 10 +++- .../src/provider/Layers/GrokAdapter.test.ts | 49 +++++++++++++++++++ .../server/src/provider/Layers/GrokAdapter.ts | 6 ++- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index bc7828dd854..541647009b0 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -39,6 +39,8 @@ const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1"; const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; +const initialGrokReasoningEffort = + process.env.T3_ACP_INITIAL_GROK_REASONING_EFFORT?.trim() || undefined; const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0"); const permissionOptionIds = { allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once", @@ -279,7 +281,13 @@ function modeState(): AcpSchema.SessionModeState { } const grokAcpModels: ReadonlyArray = [ - { modelId: "grok-build", name: "Grok Build" }, + { + modelId: "grok-build", + name: "Grok Build", + ...(initialGrokReasoningEffort + ? { _meta: { reasoningEffort: initialGrokReasoningEffort } } + : {}), + }, { modelId: "grok-mock-alt", name: "Grok Mock Alt" }, ]; diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 8d0ccc737d0..3db8e270e01 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -188,6 +188,55 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("does not carry reasoning effort across a start-session model switch", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-start-model-effort-reset"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-start-model-effort-reset-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_INITIAL_GROK_REASONING_EFFORT: "high", + }), + ); + 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-mock-alt", + }, + }); + yield* adapter.sendTurn({ + threadId, + input: "select the new model effort", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-mock-alt", + options: [{ id: "reasoningEffort", value: "high" }], + }, + }); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const setModelRequests = requests.filter((entry) => entry.method === "session/set_model"); + assert.lengthOf(setModelRequests, 2); + const [startSelection, turnSelection] = setModelRequests; + assert.nestedPropertyVal(startSelection, "params.modelId", "grok-mock-alt"); + assert.notNestedProperty(startSelection, "params._meta.reasoningEffort"); + assert.nestedPropertyVal(turnSelection, "params.modelId", "grok-mock-alt"); + assert.nestedPropertyVal(turnSelection, "params._meta.reasoningEffort", "high"); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("closes the ACP child process when a session stops", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-stop-session-close"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 4cfe750f7cd..b1e48580062 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -751,6 +751,8 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte grokModelSelection, "reasoningEffort", ); + const startModelChanged = + requestedStartModelId !== undefined && requestedStartModelId !== currentStartModelId; const boundModelId = yield* applyGrokAcpModelSelection({ runtime: acp, currentModelId: currentStartModelId, @@ -793,7 +795,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte interruptedTurnIds: new Set(), promptsInFlight: 0, currentModelId: boundModelId, - currentReasoningEffort: requestedStartReasoningEffort ?? currentStartReasoningEffort, + currentReasoningEffort: + requestedStartReasoningEffort ?? + (startModelChanged ? undefined : currentStartReasoningEffort), stopped: false, }; From e0fc12bdb2ce62ec2091ed2ab084d61ea092c1b7 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Wed, 12 Aug 2026 17:21:51 -0700 Subject: [PATCH 6/6] fix(grok): harden advertised reasoning metadata --- .../src/provider/Layers/GrokAdapter.test.ts | 54 +++++++++++ .../server/src/provider/Layers/GrokAdapter.ts | 19 ++-- .../src/provider/Layers/GrokProvider.test.ts | 96 ++++++++++++++++++- .../src/provider/Layers/GrokProvider.ts | 91 +++++++++++++----- .../src/provider/acp/GrokAcpSupport.test.ts | 42 ++++++++ .../server/src/provider/acp/GrokAcpSupport.ts | 19 +++- 6 files changed, 282 insertions(+), 39 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 3db8e270e01..11156a03a00 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -237,6 +237,60 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("clears and reapplies reasoning effort on the same model", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-same-model-effort-clear"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-same-model-effort-clear-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const selection = (effort?: string) => ({ + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + ...(effort ? { options: [{ id: "reasoningEffort", value: effort }] } : {}), + }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: selection(), + }); + yield* adapter.sendTurn({ + threadId, + input: "use high effort", + attachments: [], + modelSelection: selection("high"), + }); + yield* adapter.sendTurn({ + threadId, + input: "use the model default", + attachments: [], + modelSelection: selection(), + }); + yield* adapter.sendTurn({ + threadId, + input: "use high effort again", + attachments: [], + modelSelection: selection("high"), + }); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const setModelRequests = requests.filter((entry) => entry.method === "session/set_model"); + assert.lengthOf(setModelRequests, 3); + assert.nestedPropertyVal(setModelRequests[0], "params._meta.reasoningEffort", "high"); + assert.notNestedProperty(setModelRequests[1], "params._meta"); + assert.nestedPropertyVal(setModelRequests[2], "params._meta.reasoningEffort", "high"); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("closes the ACP child process when a session stops", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-stop-session-close"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index b1e48580062..00a097fbbab 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -59,6 +59,7 @@ import { currentGrokModelIdFromSessionSetup, currentGrokReasoningEffortFromSessionSetup, makeGrokAcpRuntime, + normalizeGrokReasoningEffort, resolveGrokAcpBaseModelId, } from "../acp/GrokAcpSupport.ts"; import { @@ -751,8 +752,6 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte grokModelSelection, "reasoningEffort", ); - const startModelChanged = - requestedStartModelId !== undefined && requestedStartModelId !== currentStartModelId; const boundModelId = yield* applyGrokAcpModelSelection({ runtime: acp, currentModelId: currentStartModelId, @@ -796,8 +795,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte promptsInFlight: 0, currentModelId: boundModelId, currentReasoningEffort: - requestedStartReasoningEffort ?? - (startModelChanged ? undefined : currentStartReasoningEffort), + requestedStartModelId !== undefined + ? normalizeGrokReasoningEffort(requestedStartReasoningEffort) + : currentStartReasoningEffort, stopped: false, }; @@ -966,9 +966,6 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte turnModelSelection, "reasoningEffort", ); - const modelChanged = - requestedTurnModelId !== undefined && requestedTurnModelId !== ctx.currentModelId; - const text = input.input?.trim(); const imagePromptParts = yield* Effect.forEach( input.attachments ?? [], @@ -1026,10 +1023,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), }); ctx.currentModelId = currentModelId; - if (requestedTurnReasoningEffort !== undefined) { - ctx.currentReasoningEffort = requestedTurnReasoningEffort; - } else if (modelChanged) { - ctx.currentReasoningEffort = undefined; + if (requestedTurnModelId !== undefined) { + ctx.currentReasoningEffort = normalizeGrokReasoningEffort( + requestedTurnReasoningEffort, + ); } const displayModel = currentModelId ? resolveGrokAcpBaseModelId(currentModelId) diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 602e35bd292..be574e64bf8 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -65,13 +65,107 @@ describe("buildGrokModelCapabilities", () => { type: "select", currentValue: "xhigh", options: [ - { id: "xhigh", label: "xhigh", isDefault: true }, + { id: "xhigh", label: "xhigh" }, { id: "medium", label: "medium" }, ], }, ]); }); + it("keeps ACP current effort separate from its collapsed advertised default", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: true, + reasoningEffort: "medium", + reasoningEfforts: [ + { value: "xhigh", label: "Extra High Effort", default: true }, + { value: "high", label: "High Effort", default: true }, + { value: "medium", label: "Medium Effort" }, + ], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + currentValue: "medium", + options: [ + { id: "xhigh", label: "Extra High Effort", isDefault: true }, + { id: "high", label: "High Effort" }, + { id: "medium", label: "Medium Effort" }, + ], + }, + ]); + }); + + it("preserves ACP descriptions and falls back from invalid values to valid ids", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: true, + reasoningEffort: "high", + reasoningEfforts: [ + { + id: "high", + value: "not a token", + label: "High Effort", + description: "Higher implementation quality", + default: true, + }, + { id: "bad id", value: "also invalid", label: "Invalid" }, + ], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + currentValue: "high", + options: [ + { + id: "high", + label: "High Effort", + description: "Higher implementation quality", + isDefault: true, + }, + ], + }, + ]); + }); + + it("accepts an advertised ACP menu when the support flag is omitted", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + reasoningEffort: "high", + reasoningEfforts: [{ value: "high", label: "High Effort", default: true }], + }, + }); + + expect(capabilities.optionDescriptors).toHaveLength(1); + }); + + it("honors an explicit ACP opt-out even when a menu is present", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: false, + reasoningEfforts: [{ value: "high", label: "High Effort", default: true }], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([]); + }); + it("does not synthesize a reasoning menu when ACP omits it", () => { expect( buildGrokModelCapabilities({ diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 436eff5549b..5d395025a0b 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -18,7 +18,6 @@ import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { - buildSelectOptionDescriptor, buildServerProvider, isCommandMissingCause, parseGenericCliVersion, @@ -30,7 +29,11 @@ import { enrichProviderSnapshotWithVersionAdvisory, type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; -import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSupport.ts"; +import { + isValidGrokReasoningEffortToken, + makeGrokAcpRuntime, + resolveGrokAcpBaseModelId, +} from "../acp/GrokAcpSupport.ts"; const GROK_PRESENTATION = { displayName: "Grok", @@ -108,51 +111,91 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function grokReasoningOptionsFromModel( - model: EffectAcpSchema.ModelInfo, -): ReadonlyArray<{ value: string; label: string; isDefault?: boolean }> { +function grokReasoningOptionsFromModel(model: EffectAcpSchema.ModelInfo): { + readonly options: ReadonlyArray<{ + value: string; + label: string; + description?: string; + isDefault?: boolean; + }>; + readonly currentValue: string | undefined; +} { const meta = model._meta; - if (!meta || meta.supportsReasoningEffort !== true) { - return []; + if (!meta || meta.supportsReasoningEffort === false) { + return { options: [], currentValue: undefined }; } - const defaultEffort = nonEmptyString(meta.reasoningEffort); + const currentEffort = nonEmptyString(meta.reasoningEffort); const advertisedOptions = Array.isArray(meta.reasoningEfforts) ? meta.reasoningEfforts : []; const seen = new Set(); - const options: Array<{ value: string; label: string; advertisedDefault: boolean }> = []; + const options: Array<{ + value: string; + label: string; + description?: string; + advertisedDefault: boolean; + }> = []; for (const entry of advertisedOptions) { - const value = nonEmptyString(isRecord(entry) ? (entry.value ?? entry.id) : entry); - if (!value || seen.has(value)) { + if (!isRecord(entry)) { + continue; + } + const rawValue = nonEmptyString(entry.value); + const rawId = nonEmptyString(entry.id); + const value = + rawValue && isValidGrokReasoningEffortToken(rawValue) + ? rawValue + : rawId && isValidGrokReasoningEffortToken(rawId) + ? rawId + : undefined; + if (value === undefined || seen.has(value)) { continue; } seen.add(value); + const description = nonEmptyString(entry.description); options.push({ value, - label: nonEmptyString(isRecord(entry) ? entry.label : undefined) ?? value, - advertisedDefault: isRecord(entry) && entry.default === true, + label: nonEmptyString(entry.label) ?? value, + ...(description ? { description } : {}), + advertisedDefault: entry.default === true || entry.isDefault === true, }); } + const currentValue = + currentEffort && options.some((option) => option.value === currentEffort) + ? currentEffort + : undefined; + const advertisedDefaults = options.filter((option) => option.advertisedDefault); const selectedDefault = - (defaultEffort && options.some((option) => option.value === defaultEffort) - ? defaultEffort - : undefined) ?? options.find((option) => option.advertisedDefault)?.value; - return options.map(({ value, label }) => - value === selectedDefault ? { value, label, isDefault: true } : { value, label }, - ); + advertisedDefaults.find((option) => option.value === currentValue)?.value ?? + advertisedDefaults[0]?.value; + return { + options: options.map(({ value, label, description }) => ({ + value, + label, + ...(description ? { description } : {}), + ...(value === selectedDefault ? { isDefault: true } : {}), + })), + currentValue: currentValue ?? selectedDefault, + }; } export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): ModelCapabilities { - const reasoningOptions = grokReasoningOptionsFromModel(model); - return reasoningOptions.length > 0 + const reasoning = grokReasoningOptionsFromModel(model); + return reasoning.options.length > 0 ? createModelCapabilities({ optionDescriptors: [ - buildSelectOptionDescriptor({ + { id: "reasoningEffort", label: "Reasoning", - options: reasoningOptions, - }), + type: "select", + options: reasoning.options.map((option) => ({ + id: option.value, + label: option.label, + ...(option.description ? { description: option.description } : {}), + ...(option.isDefault ? { isDefault: true } : {}), + })), + ...(reasoning.currentValue ? { currentValue: reasoning.currentValue } : {}), + }, ], }) : EMPTY_CAPABILITIES; diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index 6f9b441202e..13fa604d52d 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, buildGrokAcpSpawnInput, + isValidGrokReasoningEffortToken, resolveGrokAcpBaseModelId, } from "./GrokAcpSupport.ts"; @@ -35,6 +36,16 @@ describe("buildGrokAcpSpawnInput", () => { }); }); +describe("isValidGrokReasoningEffortToken", () => { + it("accepts future ACP tokens and rejects malformed metadata values", () => { + expect(isValidGrokReasoningEffortToken("xhigh")).toBe(true); + expect(isValidGrokReasoningEffortToken("turbo_v2")).toBe(true); + expect(isValidGrokReasoningEffortToken("not a token")).toBe(false); + expect(isValidGrokReasoningEffortToken("-leading-dash")).toBe(false); + expect(isValidGrokReasoningEffortToken("x".repeat(33))).toBe(false); + }); +}); + describe("applyGrokAcpModelSelection", () => { const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => { const modelCalls: Array<{ @@ -82,6 +93,37 @@ describe("applyGrokAcpModelSelection", () => { }), ); + it.effect("clears reasoning metadata when an explicit same-model selection omits effort", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-4.6", + currentReasoningEffort: "high", + requestedModelId: "grok-4.6", + requestedReasoningEffort: undefined, + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([{ modelId: "grok-4.6" }]); + expect(result).toBe("grok-4.6"); + }), + ); + + it.effect("drops malformed effort metadata instead of sending it", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-4.6", + currentReasoningEffort: "high", + requestedModelId: "grok-4.6", + requestedReasoningEffort: "not a token", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([{ modelId: "grok-4.6" }]); + }), + ); + it.effect("skips set_model when requested matches current", () => 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 e93f05d0d10..c8882cd74eb 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -82,6 +82,17 @@ export function resolveGrokAcpBaseModelId(model: string | null | undefined): str return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? "grok-build"; } +const GROK_REASONING_EFFORT_TOKEN = /^[a-z0-9][a-z0-9._-]{0,31}$/i; + +export function isValidGrokReasoningEffortToken(value: string): boolean { + return GROK_REASONING_EFFORT_TOKEN.test(value); +} + +export function normalizeGrokReasoningEffort(value: string | undefined): string | undefined { + const effort = value?.trim(); + return effort && isValidGrokReasoningEffortToken(effort) ? effort : undefined; +} + export function currentGrokModelIdFromSessionSetup( sessionSetupResult: | EffectAcpSchema.LoadSessionResponse @@ -109,7 +120,9 @@ export function currentGrokReasoningEffortFromSessionSetup( (model) => model.modelId.trim() === currentModelId, ); const reasoningEffort = currentModel?._meta?.reasoningEffort; - return typeof reasoningEffort === "string" ? reasoningEffort.trim() || undefined : undefined; + return typeof reasoningEffort === "string" + ? normalizeGrokReasoningEffort(reasoningEffort) + : undefined; } export function applyGrokAcpModelSelection(input: { @@ -122,9 +135,9 @@ export function applyGrokAcpModelSelection(input: { }): Effect.Effect { const modelChanged = input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; - const reasoningEffort = input.requestedReasoningEffort?.trim() || undefined; + const reasoningEffort = normalizeGrokReasoningEffort(input.requestedReasoningEffort); const reasoningEffortChanged = - reasoningEffort !== undefined && reasoningEffort !== input.currentReasoningEffort; + input.requestedModelId !== undefined && reasoningEffort !== input.currentReasoningEffort; const targetModelId = input.requestedModelId ?? input.currentModelId; if ((!modelChanged && !reasoningEffortChanged) || targetModelId === undefined) { return Effect.succeed(input.currentModelId);