From c7b7a541071b59db2d9244034a0e04d361ab5aeb Mon Sep 17 00:00:00 2001 From: torarinvik Date: Sat, 15 Aug 2026 11:47:01 +0200 Subject: [PATCH 1/2] feat(server): add a reasoning effort picker for Grok Grok advertises per-model reasoning levels over ACP in `ModelInfo._meta.reasoningEfforts`, and applies one through `session/set_model` with `_meta.reasoningEffort`. T3 ignored both, so Grok models shipped with empty capabilities and no picker, while Codex, Claude, and Cursor all have one. Read the levels off the discovered models so the existing composer traits menu renders them, and carry the selection through session start and each turn. Levels are per model (Grok 4.6 offers xhigh/high/medium/ low, Grok 4.5 offers high/medium/low), so nothing is hardcoded. Grok flags more than one level as `default`, so the level currently applied to the model wins and the `default` flags are a fallback. An effort-only change resends the model Grok already has selected, since `session/set_model` is what carries the effort. Models that fail ACP discovery keep empty capabilities rather than being given an invented level list. Co-Authored-By: Claude Opus 5 --- .../server/src/provider/Layers/GrokAdapter.ts | 34 ++++- .../src/provider/Layers/GrokProvider.test.ts | 35 ++++- .../src/provider/Layers/GrokProvider.ts | 30 +++- .../src/provider/acp/AcpSessionRuntime.ts | 8 +- .../src/provider/acp/GrokAcpSupport.test.ts | 137 +++++++++++++++++- .../server/src/provider/acp/GrokAcpSupport.ts | 137 +++++++++++++++++- 6 files changed, 363 insertions(+), 18 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 977cc8caaddb..c360d4e92c71 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -3,6 +3,7 @@ import { type GrokSettings, EventId, type ProviderApprovalDecision, + type ProviderOptionSelection, type ProviderRuntimeEvent, type ProviderSession, type ProviderUserInputAnswers, @@ -32,6 +33,8 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; +import { getProviderOptionStringSelectionValue } from "@t3tools/shared/model"; + import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; @@ -56,6 +59,8 @@ import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; import { applyGrokAcpModelSelection, currentGrokModelIdFromSessionSetup, + currentGrokReasoningEffortFromSessionSetup, + GROK_REASONING_EFFORT_OPTION_ID, makeGrokAcpRuntime, resolveGrokAcpBaseModelId, } from "../acp/GrokAcpSupport.ts"; @@ -117,9 +122,24 @@ interface GrokSessionContext { * continues it, and only the last remaining prompt settles the turn. */ promptsInFlight: number; currentModelId: string | undefined; + /** Reasoning effort last sent through `session/set_model`. */ + currentReasoningEffort: string | undefined; stopped: boolean; } +function resolveRequestedGrokReasoningEffort( + modelSelection: + | { readonly options?: ReadonlyArray | null | undefined } + | undefined, +): string | undefined { + return ( + getProviderOptionStringSelectionValue( + modelSelection?.options, + GROK_REASONING_EFFORT_OPTION_ID, + ) ?? undefined + ); +} + function settlePendingApprovalsAsCancelled( pendingApprovals: ReadonlyMap, ): Effect.Effect { @@ -738,13 +758,18 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const requestedStartModelId = grokModelSelection?.model ? resolveGrokAcpBaseModelId(grokModelSelection.model) : undefined; - const boundModelId = yield* applyGrokAcpModelSelection({ + const bound = yield* applyGrokAcpModelSelection({ runtime: acp, currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult), requestedModelId: requestedStartModelId, + currentReasoningEffort: currentGrokReasoningEffortFromSessionSetup( + started.sessionSetupResult, + ), + requestedReasoningEffort: resolveRequestedGrokReasoningEffort(grokModelSelection), mapError: (cause) => mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), }); + const boundModelId = bound.modelId; const now = yield* nowIso; const session: ProviderSession = { @@ -778,6 +803,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte interruptedTurnIds: new Set(), promptsInFlight: 0, currentModelId: boundModelId, + currentReasoningEffort: bound.reasoningEffort, stopped: false, }; @@ -942,13 +968,16 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const requestedTurnModelId = turnModelSelection?.model ? resolveGrokAcpBaseModelId(turnModelSelection.model) : undefined; - const currentModelId = yield* applyGrokAcpModelSelection({ + const turnSelection = yield* applyGrokAcpModelSelection({ runtime: ctx.acp, currentModelId: ctx.currentModelId, requestedModelId: requestedTurnModelId, + currentReasoningEffort: ctx.currentReasoningEffort, + requestedReasoningEffort: resolveRequestedGrokReasoningEffort(turnModelSelection), mapError: (cause) => mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), }); + const currentModelId = turnSelection.modelId; const text = input.input?.trim(); const imagePromptParts = yield* Effect.forEach( @@ -998,6 +1027,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } ctx.currentModelId = currentModelId; + ctx.currentReasoningEffort = turnSelection.reasoningEffort; 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 000243869c9e..bd3ec9535493 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -6,10 +6,43 @@ 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("exposes the reasoning picker advertised by the model metadata", () => { + const capabilities = buildGrokModelCapabilities({ + supportsReasoningEffort: true, + reasoningEffort: "high", + reasoningEfforts: [ + { id: "high", value: "high", label: "High Effort", default: true }, + { id: "low", value: "low", label: "Low Effort", default: false }, + ], + }); + expect(capabilities.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "high", label: "High Effort", isDefault: true }, + { id: "low", label: "Low Effort" }, + ], + currentValue: "high", + }, + ]); + }); + + it("exposes no options for models without reasoning metadata", () => { + expect(buildGrokModelCapabilities(null).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 934eecdb5ae6..7b09e1156214 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, @@ -29,7 +30,12 @@ import { enrichProviderSnapshotWithVersionAdvisory, type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; -import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSupport.ts"; +import { + GROK_REASONING_EFFORT_OPTION_ID, + grokReasoningEffortLevelsFromModelMeta, + makeGrokAcpRuntime, + resolveGrokAcpBaseModelId, +} from "../acp/GrokAcpSupport.ts"; const GROK_PRESENTATION = { displayName: "Grok", @@ -99,6 +105,26 @@ function grokModelsFromSettings( return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); } +export function buildGrokModelCapabilities(meta: unknown | null | undefined): ModelCapabilities { + const reasoningEffortLevels = grokReasoningEffortLevelsFromModelMeta(meta); + if (reasoningEffortLevels.length === 0) { + return EMPTY_CAPABILITIES; + } + return createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: GROK_REASONING_EFFORT_OPTION_ID, + label: "Reasoning", + options: reasoningEffortLevels.map((level) => ({ + value: level.value, + label: level.label, + ...(level.isDefault ? { isDefault: true } : {}), + })), + }), + ], + }); +} + function buildGrokDiscoveredModelsFromSessionModelState( modelState: EffectAcpSchema.SessionModelState | null | undefined, ): ReadonlyArray { @@ -117,7 +143,7 @@ function buildGrokDiscoveredModelsFromSessionModelState( slug, name: model.name.trim() || slug, isCustom: false, - capabilities: EMPTY_CAPABILITIES, + capabilities: buildGrokModelCapabilities(model._meta), }; }) .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 09fce6d56f9d..ab1d448e0277 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -226,6 +226,10 @@ export class AcpSessionRuntime extends Context.Service< */ readonly setSessionModel: ( modelId: string, + options?: { + /** Agent-specific request metadata, merged into the request `_meta`. */ + readonly meta?: Readonly>; + }, ) => Effect.Effect; /** * Sends a generic ACP extension request and records it through the request logger. @@ -789,12 +793,14 @@ export const make = ( Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), Effect.asVoid, ), - setSessionModel: (modelId) => + setSessionModel: (modelId, setModelOptions) => getStartedState.pipe( Effect.flatMap((started) => { + const meta = setModelOptions?.meta; const requestPayload = { sessionId: started.sessionId, modelId, + ...(meta && Object.keys(meta).length > 0 ? { _meta: meta } : {}), } satisfies EffectAcpSchema.SetSessionModelRequest; return runLoggedRequest( "session/set_model", diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index 02d60976b24c..49472935ce21 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -5,6 +5,8 @@ import * as EffectAcpErrors from "effect-acp/errors"; import { applyGrokAcpModelSelection, buildGrokAcpSpawnInput, + currentGrokReasoningEffortFromSessionSetup, + grokReasoningEffortLevelsFromModelMeta, resolveGrokAcpBaseModelId, } from "./GrokAcpSupport.ts"; @@ -35,13 +37,68 @@ describe("buildGrokAcpSpawnInput", () => { }); }); +describe("grokReasoningEffortLevelsFromModelMeta", () => { + const meta = { + supportsReasoningEffort: true, + reasoningEffort: "medium", + 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", default: false }, + { id: "low", value: "low", label: "Low Effort", default: false }, + ], + }; + + it("marks the model's applied effort as the default level", () => { + expect(grokReasoningEffortLevelsFromModelMeta(meta)).toEqual([ + { value: "xhigh", label: "Extra High Effort", isDefault: false }, + { value: "high", label: "High Effort", isDefault: false }, + { value: "medium", label: "Medium Effort", isDefault: true }, + { value: "low", label: "Low Effort", isDefault: false }, + ]); + }); + + it("falls back to the first level flagged default when no effort is applied", () => { + expect( + grokReasoningEffortLevelsFromModelMeta({ ...meta, reasoningEffort: undefined }).find( + (level) => level.isDefault, + )?.value, + ).toBe("xhigh"); + }); + + it("returns no levels for models without reasoning metadata", () => { + expect(grokReasoningEffortLevelsFromModelMeta(undefined)).toEqual([]); + expect(grokReasoningEffortLevelsFromModelMeta({ totalContextTokens: 500_000 })).toEqual([]); + }); +}); + +describe("currentGrokReasoningEffortFromSessionSetup", () => { + it("reads the effort applied to the session's current model", () => { + expect( + currentGrokReasoningEffortFromSessionSetup({ + sessionId: "session-1", + models: { + currentModelId: "grok-4.6", + availableModels: [ + { modelId: "grok-4.5", name: "Grok 4.5", _meta: { reasoningEffort: "high" } }, + { modelId: "grok-4.6", name: "Grok 4.6", _meta: { reasoningEffort: "medium" } }, + ], + }, + } as never), + ).toBe("medium"); + }); +}); + describe("applyGrokAcpModelSelection", () => { const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => { - const modelCalls: Array = []; + const modelCalls: Array<{ modelId: string; meta?: Readonly> }> = []; const runtime = { - setSessionModel: (modelId: string) => + setSessionModel: ( + modelId: string, + options?: { readonly meta?: Readonly> }, + ) => Effect.gen(function* () { - modelCalls.push(modelId); + modelCalls.push(options?.meta ? { modelId, meta: options.meta } : { modelId }); if (failure) return yield* failure; return {}; }), @@ -58,8 +115,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 +130,7 @@ describe("applyGrokAcpModelSelection", () => { mapError: (cause) => cause.message, }); expect(modelCalls).toEqual([]); - expect(result).toBe("grok-build"); + expect(result).toEqual({ modelId: "grok-build", reasoningEffort: undefined }); }), ); @@ -87,7 +144,73 @@ describe("applyGrokAcpModelSelection", () => { mapError: (cause) => cause.message, }); expect(modelCalls).toEqual([]); - expect(result).toBe("grok-build"); + expect(result).toEqual({ modelId: "grok-build", reasoningEffort: undefined }); + }), + ); + + it.effect("carries the requested reasoning effort in the set_model metadata", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-build", + requestedModelId: "grok-mock-alt", + currentReasoningEffort: "medium", + requestedReasoningEffort: "xhigh", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([ + { modelId: "grok-mock-alt", meta: { reasoningEffort: "xhigh" } }, + ]); + expect(result).toEqual({ modelId: "grok-mock-alt", reasoningEffort: "xhigh" }); + }), + ); + + it.effect("resends the current model when only the reasoning effort changes", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-build", + requestedModelId: "grok-build", + currentReasoningEffort: "medium", + requestedReasoningEffort: "low", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([{ modelId: "grok-build", meta: { reasoningEffort: "low" } }]); + expect(result).toEqual({ modelId: "grok-build", reasoningEffort: "low" }); + }), + ); + + it.effect("keeps the applied effort when a model switch leaves it unchanged", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-build", + requestedModelId: "grok-mock-alt", + currentReasoningEffort: "high", + requestedReasoningEffort: "high", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([{ modelId: "grok-mock-alt", meta: { reasoningEffort: "high" } }]); + expect(result).toEqual({ modelId: "grok-mock-alt", reasoningEffort: "high" }); + }), + ); + + it.effect("skips set_model when neither the model nor the effort changes", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-build", + requestedModelId: "grok-build", + currentReasoningEffort: "high", + requestedReasoningEffort: "high", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([]); + expect(result).toEqual({ modelId: "grok-build", reasoningEffort: "high" }); }), ); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index c928b3ed80e0..e1c345df0078 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -91,18 +91,145 @@ export function currentGrokModelIdFromSessionSetup( return sessionSetupResult.models?.currentModelId?.trim() || undefined; } +/** + * Provider option id for the Grok reasoning picker. Grok advertises the levels + * per model through `ModelInfo._meta.reasoningEfforts` and applies them through + * `session/set_model` with `_meta.reasoningEffort`. + */ +export const GROK_REASONING_EFFORT_OPTION_ID = "reasoningEffort"; +const GROK_REASONING_EFFORT_META_KEY = "reasoningEffort"; +const GROK_REASONING_EFFORTS_META_KEY = "reasoningEfforts"; + +export interface GrokReasoningEffortLevel { + readonly value: string; + readonly label: string; + readonly description?: string; + readonly isDefault?: boolean; +} + +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; +} + +/** Reads the effort currently applied to a model from its ACP `_meta`. */ +export function grokCurrentReasoningEffortFromModelMeta( + meta: unknown | null | undefined, +): string | undefined { + return isRecord(meta) ? trimmedString(meta[GROK_REASONING_EFFORT_META_KEY]) : undefined; +} + +/** + * Reads the selectable reasoning levels from a model's ACP `_meta`. Grok marks + * more than one entry as `default`, so the level currently applied to the model + * wins and the `default` flags are only a fallback. + */ +export function grokReasoningEffortLevelsFromModelMeta( + meta: unknown | null | undefined, +): ReadonlyArray { + if (!isRecord(meta)) { + return []; + } + const rawLevels = meta[GROK_REASONING_EFFORTS_META_KEY]; + if (!Array.isArray(rawLevels)) { + return []; + } + const levels = rawLevels.flatMap((entry): ReadonlyArray => { + if (!isRecord(entry)) { + return []; + } + const value = trimmedString(entry.value) ?? trimmedString(entry.id); + if (!value) { + return []; + } + const description = trimmedString(entry.description); + return [ + { + value, + label: trimmedString(entry.label) ?? value, + ...(description ? { description } : {}), + ...(entry.default === true ? { isDefault: true } : {}), + }, + ]; + }); + if (levels.length === 0) { + return []; + } + const currentEffort = grokCurrentReasoningEffortFromModelMeta(meta); + const defaultValue = + levels.find((level) => level.value === currentEffort)?.value ?? + levels.find((level) => level.isDefault)?.value ?? + levels[0]?.value; + return levels.map((level) => + level.value === defaultValue ? { ...level, isDefault: true } : { ...level, isDefault: false }, + ); +} + +/** Reads the effort Grok already applies to the session's current model. */ +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(); + const currentModel = modelState.availableModels.find( + (model) => model.modelId.trim() === currentModelId, + ); + return grokCurrentReasoningEffortFromModelMeta(currentModel?._meta); +} + 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< + { readonly modelId: string | undefined; readonly reasoningEffort: string | undefined }, + E +> { const shouldSwitchModel = input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; - if (!shouldSwitchModel) { - return Effect.succeed(input.currentModelId); + const shouldSwitchReasoningEffort = + input.requestedReasoningEffort !== undefined && + input.requestedReasoningEffort !== input.currentReasoningEffort; + if (!shouldSwitchModel && !shouldSwitchReasoningEffort) { + return Effect.succeed({ + modelId: input.currentModelId, + reasoningEffort: input.currentReasoningEffort, + }); + } + // `session/set_model` carries the effort, so an effort-only change still + // resends the model Grok already has selected. + const modelId = input.requestedModelId ?? input.currentModelId; + if (modelId === undefined) { + return Effect.succeed({ + modelId: input.currentModelId, + reasoningEffort: input.currentReasoningEffort, + }); } + const reasoningEffort = shouldSwitchReasoningEffort + ? input.requestedReasoningEffort + : input.currentReasoningEffort; return input.runtime - .setSessionModel(input.requestedModelId) - .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId)); + .setSessionModel( + modelId, + reasoningEffort === undefined + ? {} + : { meta: { [GROK_REASONING_EFFORT_META_KEY]: reasoningEffort } }, + ) + .pipe(Effect.mapError(input.mapError), Effect.as({ modelId, reasoningEffort })); } From 11f2f17fbb494487bd500ae90f88308635eb4562 Mon Sep 17 00:00:00 2001 From: torarinvik Date: Sat, 15 Aug 2026 11:55:08 +0200 Subject: [PATCH 2/2] fix(server): keep Grok reasoning levels model-scoped on model switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reasoning levels differ per Grok model, so carrying the current effort onto a different model can send a level the target never advertised. Probing `grok 1.0.4`, `session/set_model` accepts `grok-4.5` with `xhigh` without error, applies it, and leaves the session config with no effort selected at all. Fall back to the target model's own default when the model changes and no effort was explicitly requested. An explicit request is still always sent, including when it matches the effort already applied — deciding this on `shouldSwitchReasoningEffort` alone would silently drop a requested level that happened to equal the previous model's. Co-Authored-By: Claude Opus 5 --- .../src/provider/acp/GrokAcpSupport.test.ts | 36 +++++++++++++++++++ .../server/src/provider/acp/GrokAcpSupport.ts | 11 ++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index 49472935ce21..7bec1e220b99 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -198,6 +198,42 @@ describe("applyGrokAcpModelSelection", () => { }), ); + it.effect("does not carry the previous model's effort onto a different model", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-mock-4.6", + requestedModelId: "grok-mock-4.5", + // `xhigh` exists on 4.6 but not on 4.5; sending it applies a level that + // model never advertised, so the switch must leave the effort unset. + currentReasoningEffort: "xhigh", + requestedReasoningEffort: undefined, + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([{ modelId: "grok-mock-4.5" }]); + expect(result).toEqual({ modelId: "grok-mock-4.5", reasoningEffort: undefined }); + }), + ); + + it.effect("still sends an explicit effort that matches the previous model's effort", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-mock-4.6", + requestedModelId: "grok-mock-4.5", + currentReasoningEffort: "medium", + requestedReasoningEffort: "medium", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([ + { modelId: "grok-mock-4.5", meta: { reasoningEffort: "medium" } }, + ]); + expect(result).toEqual({ modelId: "grok-mock-4.5", reasoningEffort: "medium" }); + }), + ); + it.effect("skips set_model when neither the model nor the effort changes", () => Effect.gen(function* () { const { runtime, modelCalls } = makeRecordingRuntime(); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index e1c345df0078..c27a865ee375 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -221,9 +221,14 @@ export function applyGrokAcpModelSelection(input: { reasoningEffort: input.currentReasoningEffort, }); } - const reasoningEffort = shouldSwitchReasoningEffort - ? input.requestedReasoningEffort - : input.currentReasoningEffort; + // An explicit request always wins. Without one, the effort carries over only + // when the model does not change: levels are per model, so carrying (say) + // `xhigh` from Grok 4.6 onto Grok 4.5 applies a level that model never + // advertised and leaves its session config with nothing selected. Sending no + // effort lets Grok apply the target model's own default instead. + const reasoningEffort = + input.requestedReasoningEffort ?? + (shouldSwitchModel ? undefined : input.currentReasoningEffort); return input.runtime .setSessionModel( modelId,