From c6f1f875be0aa4ae4924541cccacc37831a65d91 Mon Sep 17 00:00:00 2001 From: Enzo Tironi Date: Wed, 5 Aug 2026 12:10:03 -0300 Subject: [PATCH 1/3] feat(grok): ACP sessionUpdate parity core Shared AcpRuntimeModel/CoreRuntimeEvents/SessionRuntime and xAI extension hooks used by the Grok adapter (and related fixtures). --- apps/server/scripts/acp-mock-agent.ts | 51 +++++- .../src/provider/Layers/CursorAdapter.ts | 1 + .../src/provider/acp/AcpCoreRuntimeEvents.ts | 58 ++++++- .../src/provider/acp/AcpRuntimeModel.test.ts | 85 ++++++++++ .../src/provider/acp/AcpRuntimeModel.ts | 151 +++++++++++++++++- .../src/provider/acp/AcpSessionRuntime.ts | 5 +- .../src/provider/acp/XAiAcpExtension.ts | 38 +++++ 7 files changed, 385 insertions(+), 4 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index bc7828dd854..8861bfb50fb 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -38,6 +38,7 @@ const emitOverlappingXAiPromptCompleteOutOfOrder = 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 emitSessionInfoUpdate = process.env.T3_ACP_EMIT_SESSION_INFO === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0"); const permissionOptionIds = { @@ -79,6 +80,10 @@ function writeJsonRpcNotification(method: string, params: unknown): void { process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`); } +function writeJsonRpcResponse(id: string | number, result: unknown): void { + process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`); +} + process.once("SIGTERM", () => { logExit("SIGTERM"); process.exit(0); @@ -300,9 +305,33 @@ const program = Effect.gen(function* () { Effect.sync(() => { parameterizedModelPicker = request.clientCapabilities?._meta?.parameterizedModelPicker === true; + // #4109-class: unsolicited response with non-numeric id must not crash the client. + if (process.env.T3_ACP_EMIT_SKILLS_RELOAD_ID === "1") { + queueMicrotask(() => { + writeJsonRpcResponse("skills-reload", { ok: true }); + }); + } + const initMeta = + process.env.T3_ACP_EMIT_INIT_AVAILABLE_COMMANDS === "1" + ? { + availableCommands: [ + { + name: "compact", + description: "Compress conversation history", + input: { hint: "optional context" }, + }, + { + name: "session-info", + description: "Show session details", + input: null, + }, + ], + } + : undefined; return { protocolVersion: 1, agentCapabilities: { loadSession: true }, + ...(initMeta ? { _meta: initMeta } : {}), }; }), ); @@ -865,6 +894,16 @@ const program = Effect.gen(function* () { }, }); + if (emitSessionInfoUpdate) { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "session_info_update", + title: "Mock Grok session title", + }, + }); + } + yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, update: { @@ -873,7 +912,17 @@ const program = Effect.gen(function* () { }, }); - return { stopReason: "end_turn" }; + // Live Grok stamps usage on prompt result `_meta` (not only usage_update). + return { + stopReason: "end_turn", + _meta: { + totalTokens: 12_345, + inputTokens: 10_000, + outputTokens: 2_000, + cachedReadTokens: 8_000, + reasoningTokens: 345, + }, + }; }), ); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 80475a5c269..dc61c90cd2d 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -863,6 +863,7 @@ export function makeCursorAdapter( turnId: ctx.activeTurnId, ...(event.itemId ? { itemId: event.itemId } : {}), text: event.text, + streamKind: event.streamKind, rawPayload: event.rawPayload, }), ); diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts index c93e61dc37b..2f703430e3e 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts @@ -220,6 +220,7 @@ export function makeAcpContentDeltaEvent(input: { readonly turnId: TurnId | undefined; readonly itemId?: string; readonly text: string; + readonly streamKind?: "assistant_text" | "reasoning_text"; readonly rawPayload: unknown; }): ProviderRuntimeEvent { return { @@ -230,7 +231,7 @@ export function makeAcpContentDeltaEvent(input: { turnId: input.turnId, ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), payload: { - streamKind: "assistant_text", + streamKind: input.streamKind ?? "assistant_text", delta: input.text, }, raw: { @@ -240,3 +241,58 @@ export function makeAcpContentDeltaEvent(input: { }, }; } + +export function makeAcpTokenUsageEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderDriverKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly usedTokens: number; + readonly maxTokens?: number; + readonly inputTokens?: number; + readonly outputTokens?: number; + readonly cachedInputTokens?: number; + readonly reasoningOutputTokens?: number; + readonly rawPayload: unknown; + readonly source?: AcpAdapterRawSource; + readonly method?: string; +}): ProviderRuntimeEvent { + return { + type: "thread.token-usage.updated", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + turnId: input.turnId, + payload: { + usage: { + usedTokens: input.usedTokens, + lastUsedTokens: input.usedTokens, + ...(input.maxTokens !== undefined && input.maxTokens > 0 + ? { maxTokens: input.maxTokens } + : {}), + ...(input.inputTokens !== undefined ? { inputTokens: input.inputTokens } : {}), + ...(input.outputTokens !== undefined ? { outputTokens: input.outputTokens } : {}), + ...(input.cachedInputTokens !== undefined + ? { cachedInputTokens: input.cachedInputTokens } + : {}), + ...(input.reasoningOutputTokens !== undefined + ? { reasoningOutputTokens: input.reasoningOutputTokens } + : {}), + ...(input.inputTokens !== undefined ? { lastInputTokens: input.inputTokens } : {}), + ...(input.outputTokens !== undefined ? { lastOutputTokens: input.outputTokens } : {}), + ...(input.cachedInputTokens !== undefined + ? { lastCachedInputTokens: input.cachedInputTokens } + : {}), + ...(input.reasoningOutputTokens !== undefined + ? { lastReasoningOutputTokens: input.reasoningOutputTokens } + : {}), + compactsAutomatically: true, + }, + }, + raw: { + source: input.source ?? "acp.jsonrpc", + method: input.method ?? "session/update", + payload: input.rawPayload, + }, + }; +} diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index 7682c5f5f9c..d1546be2cbd 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -322,6 +322,7 @@ describe("AcpRuntimeModel", () => { { _tag: "ContentDelta", text: "hello from acp", + streamKind: "assistant_text", rawPayload: { sessionId: "session-1", update: { @@ -336,6 +337,90 @@ describe("AcpRuntimeModel", () => { ]); }); + it("parses thought, usage, commands, config, session info, and user chunks", () => { + const thought = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "thinking" }, + }, + } satisfies EffectAcpSchema.SessionNotification); + expect(thought.events[0]).toMatchObject({ + _tag: "ContentDelta", + text: "thinking", + streamKind: "reasoning_text", + }); + + const usage = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "usage_update", + used: 1200, + size: 256000, + cost: { amount: 0.01, currency: "USD" }, + }, + } satisfies EffectAcpSchema.SessionNotification); + expect(usage.events[0]).toMatchObject({ + _tag: "UsageUpdated", + usage: { used: 1200, size: 256000, costAmount: 0.01, costCurrency: "USD" }, + }); + + const commands = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "available_commands_update", + availableCommands: [ + { name: " review ", description: " Review code ", input: { hint: " path " } }, + { name: "", description: "skip" }, + ], + }, + } satisfies EffectAcpSchema.SessionNotification); + expect(commands.events[0]).toMatchObject({ + _tag: "AvailableCommandsUpdated", + commands: [{ name: "review", description: "Review code", inputHint: "path" }], + }); + + const config = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "config_option_update", + configOptions: [ + { + id: "effort", + name: "Reasoning", + category: "thought_level", + type: "select", + currentValue: "high", + options: [{ value: "high", name: "High" }], + }, + ], + }, + } satisfies EffectAcpSchema.SessionNotification); + expect(config.events[0]?._tag).toBe("ConfigOptionsUpdated"); + + const info = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "session_info_update", + title: "My session", + updatedAt: "2026-08-05T00:00:00Z", + }, + } satisfies EffectAcpSchema.SessionNotification); + expect(info.events[0]).toMatchObject({ + _tag: "SessionInfoUpdated", + info: { title: "My session", updatedAt: "2026-08-05T00:00:00Z" }, + }); + + const userChunk = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "echo" }, + }, + } satisfies EffectAcpSchema.SessionNotification); + expect(userChunk.events[0]?._tag).toBe("UserMessageChunk"); + }); + it("keeps permission request parsing compatible with loose extension payloads", () => { const request = parsePermissionRequest({ sessionId: "session-1", diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index e6bfc127e6e..e7c0c98b11f 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -80,6 +80,26 @@ export interface AcpPermissionRequest { readonly toolCall?: AcpToolCallState; } +export type AcpContentStreamKind = "assistant_text" | "reasoning_text"; + +export interface AcpAvailableCommand { + readonly name: string; + readonly description?: string; + readonly inputHint?: string; +} + +export interface AcpUsageUpdate { + readonly used: number; + readonly size: number; + readonly costAmount?: number; + readonly costCurrency?: string; +} + +export interface AcpSessionInfoUpdate { + readonly title?: string | null; + readonly updatedAt?: string | null; +} + export type AcpParsedSessionEvent = | { readonly _tag: "ModeChanged"; @@ -107,6 +127,37 @@ export type AcpParsedSessionEvent = readonly _tag: "ContentDelta"; readonly itemId?: string; readonly text: string; + readonly streamKind: AcpContentStreamKind; + readonly rawPayload: unknown; + } + | { + readonly _tag: "AvailableCommandsUpdated"; + readonly commands: ReadonlyArray; + readonly rawPayload: unknown; + } + | { + readonly _tag: "UsageUpdated"; + readonly usage: AcpUsageUpdate; + readonly rawPayload: unknown; + } + | { + readonly _tag: "ConfigOptionsUpdated"; + readonly configOptions: ReadonlyArray; + readonly rawPayload: unknown; + } + | { + readonly _tag: "SessionInfoUpdated"; + readonly info: AcpSessionInfoUpdate; + readonly rawPayload: unknown; + } + | { + readonly _tag: "UserMessageChunk"; + readonly text: string; + readonly rawPayload: unknown; + } + | { + readonly _tag: "UnknownSessionUpdate"; + readonly sessionUpdate: string; readonly rawPayload: unknown; }; @@ -569,13 +620,111 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat events.push({ _tag: "ContentDelta", text: upd.content.text, + streamKind: "assistant_text", rawPayload: params, }); } break; } - default: + case "agent_thought_chunk": { + if (upd.content.type === "text" && upd.content.text.length > 0) { + events.push({ + _tag: "ContentDelta", + text: upd.content.text, + streamKind: "reasoning_text", + rawPayload: params, + }); + } + break; + } + case "user_message_chunk": { + if (upd.content.type === "text" && upd.content.text.length > 0) { + events.push({ + _tag: "UserMessageChunk", + text: upd.content.text, + rawPayload: params, + }); + } + break; + } + case "available_commands_update": { + const commands = upd.availableCommands.flatMap((command): AcpAvailableCommand[] => { + const name = command.name.trim(); + if (!name) { + return []; + } + const description = command.description.trim(); + const inputHint = + command.input && "hint" in command.input && typeof command.input.hint === "string" + ? command.input.hint.trim() + : undefined; + return [ + { + name, + ...(description.length > 0 ? { description } : {}), + ...(inputHint && inputHint.length > 0 ? { inputHint } : {}), + }, + ]; + }); + events.push({ + _tag: "AvailableCommandsUpdated", + commands, + rawPayload: params, + }); + break; + } + case "usage_update": { + const used = Number.isFinite(upd.used) && upd.used >= 0 ? Math.trunc(upd.used) : undefined; + if (used === undefined) { + break; + } + // Live Grok may omit size; consumers fall back to model totalContextTokens. + const size = Number.isFinite(upd.size) && upd.size >= 0 ? Math.trunc(upd.size) : 0; + const cost = upd.cost; + events.push({ + _tag: "UsageUpdated", + usage: { + used, + size, + ...(cost && typeof cost.amount === "number" && typeof cost.currency === "string" + ? { costAmount: cost.amount, costCurrency: cost.currency } + : {}), + }, + rawPayload: params, + }); + break; + } + case "config_option_update": { + events.push({ + _tag: "ConfigOptionsUpdated", + configOptions: upd.configOptions, + rawPayload: params, + }); + break; + } + case "session_info_update": { + events.push({ + _tag: "SessionInfoUpdated", + info: { + ...(upd.title !== undefined ? { title: upd.title } : {}), + ...(upd.updatedAt !== undefined ? { updatedAt: upd.updatedAt } : {}), + }, + rawPayload: params, + }); break; + } + default: { + // Exhaustive against current ACP schema; keep a fallback for forward compatibility. + const unknownUpdate = upd as { readonly sessionUpdate?: unknown }; + const sessionUpdate = + typeof unknownUpdate.sessionUpdate === "string" ? unknownUpdate.sessionUpdate : "unknown"; + events.push({ + _tag: "UnknownSessionUpdate", + sessionUpdate, + rawPayload: params, + }); + break; + } } return { ...(modeId !== undefined ? { modeId } : {}), events }; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 09fce6d56f9..739f68091e2 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -94,6 +94,8 @@ export interface AcpSessionRuntimeStartResult { | EffectAcpSchema.NewSessionResponse | EffectAcpSchema.ResumeSessionResponse; readonly modelConfigId: string | undefined; + /** Authenticate response payload when the agent returned one (process-observed). */ + readonly authenticateResult?: EffectAcpSchema.AuthenticateResponse; } export class AcpSessionRuntime extends Context.Service< @@ -545,7 +547,7 @@ export const make = ( methodId: options.authMethodId, } satisfies EffectAcpSchema.AuthenticateRequest; - yield* runLoggedRequest( + const authenticateResult = yield* runLoggedRequest( "authenticate", authenticatePayload, acp.agent.authenticate(authenticatePayload), @@ -652,6 +654,7 @@ export const make = ( initializeResult, sessionSetupResult, modelConfigId: extractModelConfigId(sessionSetupResult), + authenticateResult, } satisfies AcpStartedState; return nextState; }); diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts index d36a5fcfc89..7bb9d1fceea 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -196,6 +196,44 @@ export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCan return { outcome: "cancelled" }; } +const XAiExitPlanModeParams = Schema.Struct({ + sessionId: Schema.String, + toolCallId: Schema.String, + planContent: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const XAiWrappedExitPlanModeParams = Schema.Struct({ + method: Schema.Literals(["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"]), + params: XAiExitPlanModeParams, +}); + +export const XAiExitPlanModeRequest = Schema.Union([ + XAiExitPlanModeParams, + XAiWrappedExitPlanModeParams, +]); + +export type XAiExitPlanModeRequest = typeof XAiExitPlanModeRequest.Type; +export type XAiExitPlanModeParams = typeof XAiExitPlanModeParams.Type; + +export function unwrapExitPlanModeParams(params: XAiExitPlanModeRequest): XAiExitPlanModeParams { + return "method" in params ? params.params : params; +} + +export function makeXAiExitPlanModeApprovedResponse(): { readonly outcome: "approved" } { + return { outcome: "approved" }; +} + +export function makeXAiExitPlanModeReviseResponse(feedback: string): { + readonly outcome: "rejected"; + readonly feedback: string; +} { + const trimmed = feedback.trim(); + return { + outcome: "rejected", + feedback: trimmed.length > 0 ? trimmed : "Please revise the plan.", + }; +} + /** * Adds Grok's private prompt-completion fallback around a standards-only ACP runtime. * The underlying runtime remains unaware of xAI methods and metadata. From 6c766ad5e6d7cedaa869af71f61fe8744077dee8 Mon Sep 17 00:00:00 2001 From: Enzo Tironi Date: Wed, 5 Aug 2026 12:21:37 -0300 Subject: [PATCH 2/3] feat(grok): native ACP provider core (catalog, effort, usage) Grok driver/adapter/provider core: slash catalog, process-scoped effort, usage meter, auth surface, set_model. Plan toggle and multi-agent task mapping land in later stack layers. --- .../server/src/provider/Drivers/GrokDriver.ts | 60 +- .../src/provider/Layers/GrokAdapter.test.ts | 275 +++++++++- .../server/src/provider/Layers/GrokAdapter.ts | 517 +++++++++++++++++- .../src/provider/Layers/GrokProvider.test.ts | 42 +- .../src/provider/Layers/GrokProvider.ts | 235 +++++++- .../src/provider/acp/GrokAcpCliProbe.test.ts | 142 +++++ .../src/provider/acp/GrokAcpSupport.test.ts | 93 ++++ .../server/src/provider/acp/GrokAcpSupport.ts | 114 +++- .../acp/fixtures/grok-initialize-stdio.jsonl | 1 + 9 files changed, 1457 insertions(+), 22 deletions(-) create mode 100644 apps/server/src/provider/acp/fixtures/grok-initialize-stdio.jsonl diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 112f1101316..5f95ed6bbc9 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -3,7 +3,10 @@ import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -17,6 +20,7 @@ import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus, enrichGrokSnapshot, + mapAcpCommandsToCatalog, } from "../Layers/GrokProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; @@ -106,27 +110,63 @@ export const GrokDriver: ProviderDriver = { env: processEnv, }); + const commandCatalogRef = yield* Ref.make({ + slashCommands: [] as ServerProvider["slashCommands"], + skills: [] as ServerProvider["skills"], + }); + // Live available_commands_update (and initialize meta seed) must push a + // snapshot change so clients do not wait for the next health probe. + const commandCatalogChanges = yield* Effect.acquireRelease( + PubSub.unbounded(), + PubSub.shutdown, + ); + + const mergeCommandCatalog = (snapshot: ServerProvider): Effect.Effect => + Ref.get(commandCatalogRef).pipe( + Effect.map((catalog) => ({ + ...snapshot, + slashCommands: + catalog.slashCommands.length > 0 ? catalog.slashCommands : snapshot.slashCommands, + skills: catalog.skills.length > 0 ? catalog.skills : snapshot.skills, + })), + ); + const adapter = yield* makeGrokAdapter(effectiveConfig, { environment: processEnv, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), instanceId, + onAvailableCommands: (commands) => + Effect.gen(function* () { + const catalog = mapAcpCommandsToCatalog(commands); + yield* Ref.set(commandCatalogRef, { + slashCommands: catalog.slashCommands, + skills: catalog.skills, + }); + yield* PubSub.publish(commandCatalogChanges, undefined); + }), }); const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe( Effect.map(stampIdentity), + Effect.flatMap(mergeCommandCatalog), Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); - const snapshot = yield* makeManagedServerProvider>({ + const managedSnapshot = yield* makeManagedServerProvider< + ProviderSnapshotSettings + >({ maintenanceCapabilities, getSettings: snapshotSettings.getSettings, streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - buildInitialGrokProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + buildInitialGrokProviderSnapshot(settings.provider).pipe( + Effect.map(stampIdentity), + Effect.flatMap(mergeCommandCatalog), + ), checkProvider, enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => enrichGrokSnapshot({ @@ -148,6 +188,22 @@ export const GrokDriver: ProviderDriver = { ), ); + const snapshot = { + ...managedSnapshot, + getSnapshot: managedSnapshot.getSnapshot.pipe(Effect.flatMap(mergeCommandCatalog)), + get streamChanges() { + const managedChanges = Stream.mapEffect( + managedSnapshot.streamChanges, + mergeCommandCatalog, + ); + const catalogDrivenChanges = Stream.mapEffect( + Stream.fromPubSub(commandCatalogChanges), + () => managedSnapshot.getSnapshot.pipe(Effect.flatMap(mergeCommandCatalog)), + ); + return Stream.merge(managedChanges, catalogDrivenChanges); + }, + }; + return { instanceId, driverKind: DRIVER_KIND, diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 7b6f0972ae8..e260852b5a9 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -33,14 +33,21 @@ const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); const mockAgentCommand = process.execPath; -async function makeMockGrokWrapper(extraEnv?: Record) { +async function makeMockGrokWrapper( + extraEnv?: Record, + options?: { readonly argvLogPath?: string }, +) { const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-mock-")); const wrapperPath = NodePath.join(dir, "fake-grok.sh"); const envExports = Object.entries(extraEnv ?? {}) .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) .join("\n"); + const argvLogLine = options?.argvLogPath + ? `printf '%s\\n' "$*" >> ${JSON.stringify(options.argvLogPath)}` + : ""; const script = `#!/bin/sh ${envExports} +${argvLogLine} exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} "$@" `; await NodeFSP.writeFile(wrapperPath, script, "utf8"); @@ -175,6 +182,7 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { "turn.started", "item.started", "content.delta", + "thread.token-usage.updated", "turn.completed", ] as const); @@ -184,6 +192,32 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { assert.equal(delta.payload.delta, "hello from mock"); } + const usage = runtimeEvents.find((e) => e.type === "thread.token-usage.updated"); + assert.isDefined(usage); + if (usage?.type === "thread.token-usage.updated") { + assert.equal(usage.payload.usage.usedTokens, 12_345); + assert.equal(usage.payload.usage.inputTokens, 10_000); + assert.equal(usage.payload.usage.outputTokens, 2_000); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("survives unsolicited skills-reload response ids after initialize", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-skills-reload-id"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_EMIT_SKILLS_RELOAD_ID: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + assert.equal(session.status, "ready"); yield* adapter.stopSession(threadId); }), ); @@ -327,6 +361,37 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("emits token usage from prompt meta for the context meter", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-usage-context-meter"); + const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + const usageSeen = + yield* Deferred.make< + Extract + >(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "thread.token-usage.updated" && String(event.threadId) === String(threadId) + ? Deferred.succeed(usageSeen, event).pipe(Effect.asVoid, Effect.ignore) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "usage please", attachments: [] }); + const usage = yield* Deferred.await(usageSeen).pipe(Effect.timeout("3 seconds")); + assert.equal(usage.payload.usage.usedTokens, 12_345); + assert.equal(usage.payload.usage.inputTokens, 10_000); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + it.effect("completes a Grok turn from xAI prompt completion when the prompt RPC hangs", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-xai-prompt-complete-fallback"); @@ -1197,4 +1262,212 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { yield* adapter.stopSession(threadId); }), ); + + it.effect("maps session_info_update titles to thread.metadata.updated", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-session-info-title"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_EMIT_SESSION_INFO: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const metadataUpdated = + yield* Deferred.make>(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "thread.metadata.updated" && String(event.threadId) === String(threadId) + ? Deferred.succeed(metadataUpdated, event).pipe(Effect.asVoid, Effect.ignore) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "title me", + attachments: [], + }); + + const event = yield* Deferred.await(metadataUpdated).pipe(Effect.timeout("2 seconds")); + assert.equal(event.payload.name, "Mock Grok session title"); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + + it.effect("publishes initialize _meta availableCommands via onAvailableCommands", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-init-commands"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_EMIT_INIT_AVAILABLE_COMMANDS: "1" }), + ); + const catalog = + yield* Deferred.make< + ReadonlyArray<{ readonly name: string; readonly description?: string }> + >(); + const adapter = yield* makeTestAdapter(wrapperPath, { + onAvailableCommands: (commands) => + Deferred.succeed(catalog, commands).pipe(Effect.asVoid, Effect.ignore), + }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const published = yield* Deferred.await(catalog).pipe(Effect.timeout("2 seconds")); + assert.include( + published.map((command) => command.name), + "compact", + ); + assert.include( + published.map((command) => command.name), + "session-info", + ); + + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + + it.effect( + "restarts the process with --reasoning-effort and resumes the ACP session when effort changes", + () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-effort-process-restart"); + const tmpDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-effort-")), + ); + const argvLogPath = NodePath.join(tmpDir, "argv.log"); + const requestLogPath = NodePath.join(tmpDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper( + { + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_EMIT_LOAD_REPLAY: "1", + }, + { argvLogPath }, + ), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const turnCompleted = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "turn.completed" && String(event.threadId) === String(threadId) + ? Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkChild); + + const instanceId = ProviderInstanceId.make("grok"); + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + instanceId, + model: "grok-mock-alt", + options: [{ id: "reasoningEffort", value: "high" }], + }, + }); + + const firstArgv = yield* waitForFileContent(argvLogPath, 40, "--reasoning-effort high"); + assert.include(firstArgv, "--reasoning-effort high"); + assert.include(firstArgv, "--model grok-mock-alt"); + + yield* adapter.sendTurn({ + threadId, + input: "switch effort mid-thread", + attachments: [], + modelSelection: { + instanceId, + model: "grok-mock-alt", + options: [{ id: "reasoningEffort", value: "low" }], + }, + }); + + yield* Deferred.await(turnCompleted); + + const argvAfter = yield* waitForFileContent(argvLogPath, 40, "--reasoning-effort low"); + const argvLines = argvAfter + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + assert.isAtLeast(argvLines.length, 2); + assert.include(argvLines[0] ?? "", "--reasoning-effort high"); + assert.include(argvLines[argvLines.length - 1] ?? "", "--reasoning-effort low"); + + const requestLog = yield* waitForFileContent(requestLogPath, 40, "session/load"); + assert.include(requestLog, "session/load"); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + + it.effect("rejects effort change while a turn is still running", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-effort-busy"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_HANG_PROMPT_FOREVER: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const instanceId = ProviderInstanceId.make("grok"); + const turnStarted = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "turn.started" && + event.turnId !== undefined && + String(event.threadId) === String(threadId) + ? Deferred.succeed(turnStarted, event.turnId).pipe(Effect.asVoid) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + instanceId, + model: "grok-mock-alt", + options: [{ id: "reasoningEffort", value: "high" }], + }, + }); + + const hangingTurn = yield* adapter + .sendTurn({ + threadId, + input: "hang please", + attachments: [], + }) + .pipe(Effect.forkChild); + + const turnId = yield* Deferred.await(turnStarted).pipe(Effect.timeout("2 seconds")); + + const failure = yield* Effect.flip( + adapter.sendTurn({ + threadId, + input: "change effort while busy", + attachments: [], + modelSelection: { + instanceId, + model: "grok-mock-alt", + options: [{ id: "reasoningEffort", value: "low" }], + }, + }), + ); + assert.equal(failure._tag, "ProviderAdapterValidationError"); + if (failure._tag === "ProviderAdapterValidationError") { + assert.include(failure.issue, "reasoning effort"); + } + + yield* adapter.interruptTurn(threadId, turnId).pipe(Effect.timeout("2 seconds")); + yield* Fiber.join(hangingTurn).pipe(Effect.timeout("2 seconds")); + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); }); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 977cc8caadd..93fd8dc05b0 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -9,6 +9,7 @@ import { ProviderDriverKind, ProviderInstanceId, RuntimeRequestId, + RuntimeTaskId, type ThreadId, TurnId, } from "@t3tools/contracts"; @@ -46,25 +47,33 @@ import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; import { makeAcpAssistantItemEvent, makeAcpContentDeltaEvent, + makeAcpTokenUsageEvent, makeAcpPlanUpdatedEvent, makeAcpRequestOpenedEvent, makeAcpRequestResolvedEvent, makeAcpToolCallEvent, } from "../acp/AcpCoreRuntimeEvents.ts"; -import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; +import { parsePermissionRequest, type AcpAvailableCommand } from "../acp/AcpRuntimeModel.ts"; import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; import { + applyGrokAcpConfigSelections, applyGrokAcpModelSelection, currentGrokModelIdFromSessionSetup, makeGrokAcpRuntime, resolveGrokAcpBaseModelId, + resolveGrokReasoningEffortSelection, + type GrokAcpSpawnOptions, } from "../acp/GrokAcpSupport.ts"; import { extractXAiAskUserQuestions, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, + makeXAiExitPlanModeApprovedResponse, + makeXAiExitPlanModeReviseResponse, promptResponseHasMissingXAiStopReason, + unwrapExitPlanModeParams, XAiAskUserQuestionRequest, + XAiExitPlanModeRequest, } from "../acp/XAiAcpExtension.ts"; import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -84,6 +93,10 @@ export interface GrokAdapterLiveOptions { readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; readonly instanceId?: ProviderInstanceId; + /** Fired when ACP available_commands_update lands (skills/slash catalog). */ + readonly onAvailableCommands?: ( + commands: ReadonlyArray, + ) => Effect.Effect; } interface PendingApproval { @@ -117,7 +130,14 @@ interface GrokSessionContext { * continues it, and only the last remaining prompt settles the turn. */ promptsInFlight: number; currentModelId: string | undefined; + /** Process-level effort from CLI --reasoning-effort (not ACP config). */ + processReasoningEffort: string | undefined; stopped: boolean; + availableCommands: ReadonlyArray; + configOptions: ReadonlyArray; + sessionTitle: string | undefined; + /** Context window size from model meta when known. */ + contextWindowTokens: number | undefined; } function settlePendingApprovalsAsCancelled( @@ -179,6 +199,162 @@ function parseGrokResume(raw: unknown): { sessionId: string } | undefined { return { sessionId: raw.sessionId.trim() }; } +/** Live Grok model `_meta.totalContextTokens` (fixture + 0.2.118 wire). */ +function totalContextTokensFromMeta(meta: unknown): number | undefined { + if (!isRecord(meta)) return undefined; + const raw = meta.totalContextTokens ?? meta.total_context_tokens; + if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) { + return Math.floor(raw); + } + if (typeof raw === "string" && raw.trim()) { + const parsed = Number(raw.trim()); + if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed); + } + return undefined; +} + +function finiteNonNegInt(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value) && value >= 0) { + return Math.floor(value); + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value.trim()); + if (Number.isFinite(parsed) && parsed >= 0) return Math.floor(parsed); + } + return undefined; +} + +/** Live Grok stamps usage on prompt result `_meta` (and nested `usage`). */ +function tokenUsageFromGrokPromptMeta(meta: unknown): { + readonly usedTokens: number; + readonly inputTokens?: number; + readonly outputTokens?: number; + readonly cachedInputTokens?: number; + readonly reasoningOutputTokens?: number; +} | null { + if (!isRecord(meta)) return null; + const nested = isRecord(meta.usage) ? meta.usage : undefined; + const usedTokens = + finiteNonNegInt(meta.totalTokens) ?? + finiteNonNegInt(nested?.totalTokens) ?? + (() => { + const input = finiteNonNegInt(meta.inputTokens) ?? finiteNonNegInt(nested?.inputTokens) ?? 0; + const output = + finiteNonNegInt(meta.outputTokens) ?? finiteNonNegInt(nested?.outputTokens) ?? 0; + const total = input + output; + return total > 0 ? total : undefined; + })(); + if (usedTokens === undefined) return null; + const inputTokens = finiteNonNegInt(meta.inputTokens) ?? finiteNonNegInt(nested?.inputTokens); + const outputTokens = finiteNonNegInt(meta.outputTokens) ?? finiteNonNegInt(nested?.outputTokens); + const cachedInputTokens = + finiteNonNegInt(meta.cachedReadTokens) ?? + finiteNonNegInt(meta.cachedInputTokens) ?? + finiteNonNegInt(nested?.cachedReadTokens) ?? + finiteNonNegInt(nested?.cachedInputTokens); + const reasoningOutputTokens = + finiteNonNegInt(meta.reasoningTokens) ?? + finiteNonNegInt(meta.reasoningOutputTokens) ?? + finiteNonNegInt(nested?.reasoningTokens) ?? + finiteNonNegInt(nested?.reasoningOutputTokens); + return { + usedTokens, + ...(inputTokens !== undefined ? { inputTokens } : {}), + ...(outputTokens !== undefined ? { outputTokens } : {}), + ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}), + ...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}), + }; +} + +/** Live Grok model `_meta.reasoningEffort` after process spawn. */ +function reasoningEffortFromMeta(meta: unknown): string | undefined { + if (!isRecord(meta)) return undefined; + const raw = meta.reasoningEffort ?? meta.reasoning_effort; + return typeof raw === "string" && raw.trim() ? raw.trim() : undefined; +} + +function preferredModelMeta(input: { + readonly sessionModels: EffectAcpSchema.SessionModelState | null | undefined; + readonly initializeMeta: Record | undefined; + readonly preferredModelId: string | undefined; +}): unknown { + const pick = ( + models: ReadonlyArray<{ modelId: string; _meta?: unknown }> | undefined, + preferred: string | undefined, + ): unknown => { + if (!models || models.length === 0) return undefined; + const preferredMatch = preferred + ? models.find((model) => model.modelId === preferred) + : undefined; + return preferredMatch?._meta ?? models[0]?._meta; + }; + const fromSession = pick( + input.sessionModels?.availableModels, + input.preferredModelId ?? input.sessionModels?.currentModelId, + ); + if (fromSession !== undefined) return fromSession; + const initializeModelState = input.initializeMeta?.modelState; + if (isRecord(initializeModelState) && Array.isArray(initializeModelState.availableModels)) { + return pick( + initializeModelState.availableModels as ReadonlyArray<{ + modelId: string; + _meta?: unknown; + }>, + input.preferredModelId ?? + (typeof initializeModelState.currentModelId === "string" + ? initializeModelState.currentModelId + : undefined), + ); + } + return undefined; +} + +function parseGrokAvailableCommandsFromMeta( + meta: Record | undefined, +): ReadonlyArray { + if (!meta || !Array.isArray(meta.availableCommands)) { + return []; + } + return meta.availableCommands.flatMap((entry): AcpAvailableCommand[] => { + if (!isRecord(entry) || typeof entry.name !== "string" || !entry.name.trim()) { + return []; + } + const name = entry.name.trim(); + const description = + typeof entry.description === "string" && entry.description.trim() + ? entry.description.trim() + : undefined; + const inputHint = + isRecord(entry.input) && typeof entry.input.hint === "string" && entry.input.hint.trim() + ? entry.input.hint.trim() + : undefined; + return [ + { + name, + ...(description ? { description } : {}), + ...(inputHint ? { inputHint } : {}), + }, + ]; + }); +} + +function resolveGrokContextWindowTokens(input: { + readonly sessionModels: EffectAcpSchema.SessionModelState | null | undefined; + readonly initializeMeta: Record | undefined; + readonly preferredModelId: string | undefined; +}): number | undefined { + return totalContextTokensFromMeta(preferredModelMeta(input)); +} + +function resolveProcessReasoningEffort(input: { + readonly spawnEffort: string | undefined; + readonly sessionModels: EffectAcpSchema.SessionModelState | null | undefined; + readonly initializeMeta: Record | undefined; + readonly preferredModelId: string | undefined; +}): string | undefined { + return input.spawnEffort ?? reasoningEffortFromMeta(preferredModelMeta(input)); +} + function selectPermissionOptionId( request: EffectAcpSchema.RequestPermissionRequest, decision: Exclude, @@ -273,6 +449,55 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const offerRuntimeEvent = (event: ProviderRuntimeEvent) => PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + /** + * Live Grok often settles the turn via `_x.ai/session/prompt_complete` before + * the prompt RPC returns. Usage still arrives on the RPC result `_meta` — emit + * it whenever that meta is available so the context meter is not starved. + */ + const offerGrokPromptTokenUsage = ( + ctx: GrokSessionContext, + turnId: TurnId, + meta: unknown, + ): Effect.Effect => { + const promptUsage = tokenUsageFromGrokPromptMeta(meta); + if (!promptUsage) { + return Effect.void; + } + const maxTokens = + ctx.contextWindowTokens !== undefined && ctx.contextWindowTokens > 0 + ? ctx.contextWindowTokens + : undefined; + return makeEventStamp().pipe( + Effect.flatMap((stamp) => + offerRuntimeEvent( + makeAcpTokenUsageEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + usedTokens: promptUsage.usedTokens, + ...(maxTokens !== undefined ? { maxTokens } : {}), + ...(promptUsage.inputTokens !== undefined + ? { inputTokens: promptUsage.inputTokens } + : {}), + ...(promptUsage.outputTokens !== undefined + ? { outputTokens: promptUsage.outputTokens } + : {}), + ...(promptUsage.cachedInputTokens !== undefined + ? { cachedInputTokens: promptUsage.cachedInputTokens } + : {}), + ...(promptUsage.reasoningOutputTokens !== undefined + ? { reasoningOutputTokens: promptUsage.reasoningOutputTokens } + : {}), + rawPayload: meta, + source: "acp.grok.extension", + method: "session/prompt", + }), + ), + ), + ); + }; + const getThreadSemaphore = (threadId: string) => SynchronizedRef.modifyEffect(threadLocksRef, (current) => { const existing: Option.Option = Option.fromNullishOr( @@ -570,11 +795,21 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }); const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const spawnModel = grokModelSelection?.model + ? resolveGrokAcpBaseModelId(grokModelSelection.model) + : undefined; + const spawnEffort = resolveGrokReasoningEffortSelection(grokModelSelection?.options); + const spawnOptions: GrokAcpSpawnOptions = { + ...(spawnModel ? { model: spawnModel } : {}), + ...(spawnEffort ? { reasoningEffort: spawnEffort } : {}), + ...(input.runtimeMode === "full-access" ? { alwaysApprove: true } : {}), + }; const acp = yield* makeGrokAcpRuntime({ grokSettings, ...(options?.environment ? { environment: options.environment } : {}), childProcessSpawner, cwd, + spawnOptions, ...(resumeSessionId ? { resumeSessionId } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, ...(mcpSession @@ -663,6 +898,88 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ), { discard: true }, ); + yield* Effect.forEach( + ["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"] as const, + (method) => + acp.handleExtRequest(method, XAiExitPlanModeRequest, (params) => + mapAcpCallbackFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, method, params); + const exitParams = unwrapExitPlanModeParams(params); + const planMarkdown = + typeof exitParams.planContent === "string" + ? exitParams.planContent.trim() + : ""; + const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); + if (planMarkdown.length > 0) { + yield* offerRuntimeEvent({ + type: "turn.proposed.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { planMarkdown }, + raw: { + source: "acp.grok.extension", + method, + payload: params, + }, + }); + } + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + pendingApprovals.set(requestId, { decision }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + permissionRequest: { + kind: "unknown", + detail: planMarkdown.length > 0 ? planMarkdown : "Approve Grok plan", + }, + detail: + planMarkdown.length > 0 + ? "Grok is waiting for plan approval" + : "Grok is waiting for plan approval (empty plan content)", + args: exitParams, + source: "acp.grok.extension", + method, + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + permissionRequest: { + kind: "unknown", + detail: "Grok plan approval", + }, + decision: resolved, + }), + ); + if (resolved === "accept" || resolved === "acceptForSession") { + return makeXAiExitPlanModeApprovedResponse(); + } + return makeXAiExitPlanModeReviseResponse( + resolved === "decline" + ? "User rejected the plan." + : "Plan approval cancelled.", + ); + }), + ), + ), + { discard: true }, + ); yield* acp.handleRequestPermission((params) => mapAcpCallbackFailure( Effect.gen(function* () { @@ -763,6 +1080,33 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte updatedAt: now, }; + const initializeMeta = + started.initializeResult._meta && + typeof started.initializeResult._meta === "object" && + !Array.isArray(started.initializeResult._meta) + ? (started.initializeResult._meta as Record) + : undefined; + const metaSource = { + sessionModels: started.sessionSetupResult.models, + initializeMeta, + preferredModelId: boundModelId, + }; + const contextWindowTokens = resolveGrokContextWindowTokens(metaSource); + const processReasoningEffort = resolveProcessReasoningEffort({ + spawnEffort, + ...metaSource, + }); + const initializeCommands = parseGrokAvailableCommandsFromMeta(initializeMeta); + if (initializeCommands.length > 0 && options?.onAvailableCommands) { + yield* options + .onAvailableCommands(initializeCommands) + .pipe( + Effect.catch((cause) => + Effect.logWarning("Grok initialize command catalog publish failed", { cause }), + ), + ); + } + const ctx: GrokSessionContext = { threadId: input.threadId, acpSessionId: started.sessionId, @@ -778,7 +1122,12 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte interruptedTurnIds: new Set(), promptsInFlight: 0, currentModelId: boundModelId, + processReasoningEffort, stopped: false, + availableCommands: initializeCommands, + configOptions: started.sessionSetupResult.configOptions ?? [], + sessionTitle: undefined, + contextWindowTokens, }; const nf = yield* Stream.runDrain( @@ -791,7 +1140,13 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte if ( event._tag === "PlanUpdated" || event._tag === "ToolCallUpdated" || - event._tag === "ContentDelta" + event._tag === "ContentDelta" || + event._tag === "UsageUpdated" || + event._tag === "AvailableCommandsUpdated" || + event._tag === "ConfigOptionsUpdated" || + event._tag === "SessionInfoUpdated" || + event._tag === "UserMessageChunk" || + event._tag === "UnknownSessionUpdate" ) { yield* logNative(ctx.threadId, "session/update", event.rawPayload); } @@ -799,6 +1154,62 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte if (event._tag === "ModeChanged") { return; } + if (event._tag === "UserMessageChunk") { + // T3 already owns the user bubble; avoid echo duplicates. + return; + } + if (event._tag === "UnknownSessionUpdate") { + yield* Effect.logWarning("Grok ACP unknown sessionUpdate", { + sessionUpdate: event.sessionUpdate, + threadId: ctx.threadId, + }); + return; + } + if (event._tag === "AvailableCommandsUpdated") { + ctx.availableCommands = event.commands; + if (options?.onAvailableCommands) { + yield* options.onAvailableCommands(event.commands).pipe( + Effect.catch((cause) => + Effect.logWarning("Grok available commands catalog update failed", { + cause, + }), + ), + ); + } + return; + } + if (event._tag === "ConfigOptionsUpdated") { + ctx.configOptions = event.configOptions; + return; + } + if (event._tag === "SessionInfoUpdated") { + const nextTitle = + typeof event.info.title === "string" && event.info.title.trim().length > 0 + ? event.info.title.trim() + : undefined; + if (nextTitle && nextTitle !== ctx.sessionTitle) { + ctx.sessionTitle = nextTitle; + yield* offerRuntimeEvent({ + type: "thread.metadata.updated", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { + name: nextTitle, + metadata: { + sessionId: ctx.acpSessionId, + source: "session_info_update", + }, + }, + raw: { + source: "acp.jsonrpc", + method: "session/update", + payload: event.rawPayload, + }, + }); + } + return; + } const notificationTurnId = resolveNotificationTurnId(ctx); if ( @@ -844,7 +1255,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte "session/update", ); return; - case "ToolCallUpdated": + case "ToolCallUpdated": { yield* offerRuntimeEvent( makeAcpToolCallEvent({ stamp, @@ -856,6 +1267,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }), ); return; + } case "ContentDelta": yield* offerRuntimeEvent( makeAcpContentDeltaEvent({ @@ -865,10 +1277,31 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte turnId: notificationTurnId, ...(event.itemId ? { itemId: event.itemId } : {}), text: event.text, + streamKind: event.streamKind, + rawPayload: event.rawPayload, + }), + ); + return; + case "UsageUpdated": { + const maxTokens = + event.usage.size > 0 + ? event.usage.size + : ctx.contextWindowTokens !== undefined && ctx.contextWindowTokens > 0 + ? ctx.contextWindowTokens + : undefined; + yield* offerRuntimeEvent( + makeAcpTokenUsageEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + usedTokens: event.usage.used, + ...(maxTokens !== undefined ? { maxTokens } : {}), rawPayload: event.rawPayload, }), ); return; + } } }), ), @@ -911,6 +1344,49 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const sendTurn: GrokAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { + // Grok effort is process-scoped (--reasoning-effort). Changing it requires + // restarting the agent process while holding no nested thread lock. + // Resume the same ACP session so transcript continuity is preserved. + const restartDecision = yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId + ? input.modelSelection + : undefined; + const nextEffort = resolveGrokReasoningEffortSelection(turnModelSelection?.options); + if (nextEffort !== undefined && nextEffort !== ctx.processReasoningEffort) { + if (ctx.promptsInFlight > 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: + "Cannot change Grok reasoning effort while a turn is running. Wait for the turn to finish, then try again.", + }); + } + const restart = { + cwd: ctx.session.cwd, + runtimeMode: ctx.session.runtimeMode, + resumeCursor: ctx.session.resumeCursor, + }; + yield* stopSessionInternal(ctx); + return { _tag: "restart" as const, ...restart }; + } + return { _tag: "continue" as const }; + }), + ); + if (restartDecision._tag === "restart") { + yield* startSession({ + threadId: input.threadId, + provider: PROVIDER, + cwd: restartDecision.cwd, + runtimeMode: restartDecision.runtimeMode, + ...(restartDecision.resumeCursor ? { resumeCursor: restartDecision.resumeCursor } : {}), + ...(input.modelSelection ? { modelSelection: input.modelSelection } : {}), + }); + } + const prepared = yield* withThreadLock( input.threadId, Effect.gen(function* () { @@ -949,6 +1425,20 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte mapError: (cause) => mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), }); + // Secondary path only when Grok advertises effort as ACP config + // options. Live 0.2.x returns empty configOptions (no-op here); + // process-scoped restart above is the real effort contract. + yield* applyGrokAcpConfigSelections({ + runtime: ctx.acp, + selections: turnModelSelection?.options, + mapError: (cause) => + mapAcpToAdapterError( + PROVIDER, + input.threadId, + "session/set_config_option", + cause, + ), + }); const text = input.input?.trim(); const imagePromptParts = yield* Effect.forEach( @@ -1096,6 +1586,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte input.threadId, Effect.gen(function* () { const ctx = yield* requireSession(input.threadId); + // Emit usage as soon as the prompt RPC returns, even if xAI already + // settled the turn (common live path). + yield* offerGrokPromptTokenUsage(ctx, prepared.turnId, result._meta); if (ctx.acpSessionId !== prepared.acpSessionId) { yield* settlePromptInFlight( input.threadId, @@ -1179,6 +1672,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ...(prepared.displayModel ? { model: prepared.displayModel } : {}), }; const completedStopReason = completedStopReasonFromPromptResponse(result); + // Usage already offered above on RPC return; complete the turn. yield* offerRuntimeEvent({ type: "turn.completed", ...(yield* makeEventStamp()), @@ -1206,6 +1700,23 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }).pipe( Effect.ensuring( Effect.gen(function* () { + // Usage must surface even when xAI settled the turn before the prompt RPC. + if (yield* Ref.get(promptRpcSucceeded)) { + const promptResult = yield* Ref.get(promptResultRef); + if (promptResult !== undefined) { + yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const live = sessions.get(input.threadId); + if (!live || live.stopped || live.acpSessionId !== prepared.acpSessionId) { + return; + } + yield* offerGrokPromptTokenUsage(live, prepared.turnId, promptResult._meta); + }), + ).pipe(Effect.catch(() => Effect.void)); + } + } + if (yield* Ref.get(promptSettled)) { return; } diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 000243869c9..6ca6c89b8f0 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -6,7 +6,12 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { GrokSettings } from "@t3tools/contracts"; -import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus } from "./GrokProvider.ts"; +import { + buildInitialGrokProviderSnapshot, + capabilitiesFromGrokModelMeta, + checkGrokProviderStatus, + mapAcpCommandsToCatalog, +} from "./GrokProvider.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); @@ -31,7 +36,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.showInteractionModeToggle).toBe(false); }), ); }); @@ -108,3 +114,35 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { }), ); }); + +describe("Grok capability and command helpers", () => { + it("maps reasoningEfforts meta into optionDescriptors", () => { + const caps = capabilitiesFromGrokModelMeta({ + reasoningEfforts: [ + { id: "high", value: "high", label: "High Effort", default: true }, + { id: "low", value: "low", label: "Low Effort", default: false }, + ], + }); + expect(caps.optionDescriptors?.[0]).toMatchObject({ + id: "reasoningEffort", + type: "select", + }); + expect( + caps.optionDescriptors?.[0]?.type === "select" && caps.optionDescriptors[0].options, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "high", label: "High Effort", isDefault: true }), + expect.objectContaining({ id: "low", label: "Low Effort" }), + ]), + ); + }); + + it("maps ACP commands into slash and skills catalogs", () => { + const catalog = mapAcpCommandsToCatalog([ + { name: "review", description: "Review code", inputHint: "path" }, + { name: "skip-me" }, + ]); + expect(catalog.slashCommands).toHaveLength(2); + expect(catalog.skills).toEqual([expect.objectContaining({ name: "review", enabled: true })]); + }); +}); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 934eecdb5ae..f867e27bf4a 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -3,6 +3,8 @@ import { type ModelCapabilities, type ServerProvider, type ServerProviderModel, + type ServerProviderSkill, + type ServerProviderSlashCommand, } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; import { causeErrorTag } from "@t3tools/shared/observability"; @@ -33,14 +35,99 @@ import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSup const GROK_PRESENTATION = { displayName: "Grok", - badgeLabel: "Early Access", + // Live 0.2.x: plan toggle lands in later stack layer; set_model is in-session. showInteractionModeToggle: false, - requiresNewThreadForModelChange: true, + requiresNewThreadForModelChange: false, } as const; const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], }); +function reasoningEffortLabels(value: string): string { + const normalized = value.trim().toLowerCase(); + const labels: Record = { + none: "None", + minimal: "Minimal", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max", + }; + return labels[normalized] ?? value; +} + +/** Map Grok/ACP model `_meta.reasoningEfforts` into composer optionDescriptors when present. */ +export function capabilitiesFromGrokModelMeta( + meta: Record | null | undefined, +): ModelCapabilities { + if (!meta) { + return EMPTY_CAPABILITIES; + } + const rawEfforts = meta.reasoningEfforts ?? meta.reasoning_efforts; + if (!Array.isArray(rawEfforts) || rawEfforts.length === 0) { + return EMPTY_CAPABILITIES; + } + const defaultEffort = + typeof meta.defaultReasoningEffort === "string" + ? meta.defaultReasoningEffort.trim() + : typeof meta.default_reasoning_effort === "string" + ? meta.default_reasoning_effort.trim() + : undefined; + const options = rawEfforts.flatMap((entry) => { + if (typeof entry === "string") { + const id = entry.trim(); + if (!id) return []; + return [ + { + id, + label: reasoningEffortLabels(id), + ...(defaultEffort === id ? { isDefault: true as const } : {}), + }, + ]; + } + if (!entry || typeof entry !== "object") { + return []; + } + const record = entry as Record; + const id = + typeof record.value === "string" && record.value.trim() + ? record.value.trim() + : typeof record.id === "string" && record.id.trim() + ? record.id.trim() + : ""; + if (!id) { + return []; + } + const label = + typeof record.label === "string" && record.label.trim() + ? record.label.trim() + : reasoningEffortLabels(id); + const isDefault = + record.default === true || defaultEffort === id || defaultEffort === record.id; + return [ + { + id, + label, + ...(isDefault ? { isDefault: true as const } : {}), + }, + ]; + }); + if (options.length === 0) { + return EMPTY_CAPABILITIES; + } + return createModelCapabilities({ + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options, + }, + ], + }); +} + const VERSION_PROBE_TIMEOUT_MS = 4_000; const GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; @@ -113,16 +200,63 @@ function buildGrokDiscoveredModelsFromSessionModelState( return undefined; } seen.add(slug); + const meta = + model._meta && typeof model._meta === "object" && !Array.isArray(model._meta) + ? (model._meta as Record) + : undefined; return { slug, name: model.name.trim() || slug, isCustom: false, - capabilities: EMPTY_CAPABILITIES, + capabilities: capabilitiesFromGrokModelMeta(meta), }; }) .filter((model): model is ServerProviderModel => model !== undefined); } +export interface GrokAcpDiscoveryResult { + readonly models: ReadonlyArray; + readonly slashCommands: ReadonlyArray; + readonly skills: ReadonlyArray; + readonly authEmail?: string; + readonly authLabel?: string; +} + +export function mapAcpCommandsToCatalog( + commands: ReadonlyArray<{ + readonly name: string; + readonly description?: string; + readonly inputHint?: string; + }>, +): { + readonly slashCommands: ReadonlyArray; + readonly skills: ReadonlyArray; +} { + const slashCommands: ServerProviderSlashCommand[] = []; + const skills: ServerProviderSkill[] = []; + for (const command of commands) { + const name = command.name.trim(); + if (!name) continue; + const description = command.description?.trim(); + slashCommands.push({ + name, + ...(description ? { description } : {}), + ...(command.inputHint ? { input: { hint: command.inputHint } } : {}), + }); + // Grok advertises skills as slash-style commands; mirror into skills when + // description is present so the $ picker is non-empty (#4109 class). + if (description) { + skills.push({ + name, + description, + path: `acp://${name}`, + enabled: true, + }); + } + } + return { slashCommands, skills }; +} + const discoverGrokModelsViaAcp = ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, @@ -137,7 +271,73 @@ const discoverGrokModelsViaAcp = ( clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, }); const started = yield* acp.start(); - return buildGrokDiscoveredModelsFromSessionModelState(started.sessionSetupResult.models); + // Prefer modelState from session setup; fall back to initialize _meta (live wire). + const initializeMeta = + started.initializeResult._meta && + typeof started.initializeResult._meta === "object" && + !Array.isArray(started.initializeResult._meta) + ? (started.initializeResult._meta as Record) + : undefined; + const modelsFromSession = buildGrokDiscoveredModelsFromSessionModelState( + started.sessionSetupResult.models, + ); + const modelsFromInitialize = buildGrokDiscoveredModelsFromSessionModelState( + initializeMeta?.modelState as EffectAcpSchema.SessionModelState | undefined, + ); + const models = modelsFromSession.length > 0 ? modelsFromSession : modelsFromInitialize; + const initializeCommands = Array.isArray(initializeMeta?.availableCommands) + ? ( + initializeMeta.availableCommands as ReadonlyArray<{ + readonly name?: unknown; + readonly description?: unknown; + readonly input?: unknown; + }> + ).flatMap((command) => { + const name = typeof command.name === "string" ? command.name.trim() : ""; + if (!name) return []; + const description = + typeof command.description === "string" ? command.description.trim() : undefined; + const inputHint = + command.input && + typeof command.input === "object" && + command.input !== null && + "hint" in command.input && + typeof (command.input as { hint: unknown }).hint === "string" + ? (command.input as { hint: string }).hint.trim() + : undefined; + return [ + { + name, + ...(description ? { description } : {}), + ...(inputHint ? { inputHint } : {}), + }, + ]; + }) + : []; + const catalog = mapAcpCommandsToCatalog(initializeCommands); + const authMeta = + started.authenticateResult?._meta && + typeof started.authenticateResult._meta === "object" && + !Array.isArray(started.authenticateResult._meta) + ? (started.authenticateResult._meta as Record) + : undefined; + const authEmail = + typeof authMeta?.email === "string" && authMeta.email.trim() + ? authMeta.email.trim() + : undefined; + const authLabel = + typeof authMeta?.subscription_tier === "string" && authMeta.subscription_tier.trim() + ? `Grok ${authMeta.subscription_tier.trim()}` + : typeof authMeta?.auth_mode === "string" && authMeta.auth_mode.trim() + ? authMeta.auth_mode.trim() + : undefined; + return { + models, + slashCommands: catalog.slashCommands, + skills: catalog.skills, + ...(authEmail ? { authEmail } : {}), + ...(authLabel ? { authLabel } : {}), + } satisfies GrokAcpDiscoveryResult; }).pipe(Effect.scoped); const runGrokVersionCommand = ( @@ -256,9 +456,13 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func Effect.exit, ); if (Exit.isFailure(discoveryExit)) { + const errorTag = causeErrorTag(discoveryExit.cause); yield* Effect.logWarning("Grok ACP model discovery failed", { - errorTag: causeErrorTag(discoveryExit.cause), + errorTag, }); + const authFailure = + /auth|unauth|login|token|credential|oidc|forbidden|unauthorized/i.test(errorTag) || + /auth|unauth|login|token|credential/i.test(String(discoveryExit.cause)); return buildServerProvider({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, @@ -268,8 +472,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: authFailure ? "unauthenticated" : "unknown" }, + message: authFailure + ? "Grok CLI is installed but authentication failed. Run `grok login` or set XAI_API_KEY." + : "Grok CLI is installed but ACP startup failed. Check server logs for details.", }, }); } @@ -291,10 +497,10 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func }, }); } - const discoveredModels = discoveryExit.value.value; + const discovery = discoveryExit.value.value; const models = - discoveredModels.length > 0 - ? grokModelsFromSettings(grokSettings.customModels, discoveredModels) + discovery.models.length > 0 + ? grokModelsFromSettings(grokSettings.customModels, discovery.models) : fallbackModels; return buildServerProvider({ @@ -302,11 +508,18 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models, + slashCommands: discovery.slashCommands, + skills: discovery.skills, probe: { installed: true, version, status: "ready", - auth: { status: "unknown" }, + // Process-observed: authenticate + session start succeeded on this probe. + auth: { + status: "authenticated", + ...(discovery.authLabel ? { label: discovery.authLabel } : {}), + ...(discovery.authEmail ? { email: discovery.authEmail } : {}), + }, }, }); }); diff --git a/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts b/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts index 222fc4a12d5..f692098c728 100644 --- a/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts +++ b/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts @@ -9,11 +9,18 @@ */ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import { ChildProcessSpawner } from "effect/unstable/process"; import { describe, expect } from "vite-plus/test"; import { makeGrokAcpRuntime } from "./GrokAcpSupport.ts"; +import { + makeXAiExitPlanModeApprovedResponse, + unwrapExitPlanModeParams, + XAiExitPlanModeRequest, +} from "./XAiAcpExtension.ts"; const makeProbeRuntime = Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -66,4 +73,139 @@ 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 remains promptable after session/set_model (in-session continuity)", () => + Effect.gen(function* () { + const runtime = yield* makeProbeRuntime; + const started = yield* runtime.start(); + const currentModelId = started.sessionSetupResult.models?.currentModelId?.trim(); + expect(currentModelId).toBeDefined(); + if (!currentModelId) return; + + yield* runtime.setSessionModel(currentModelId); + const result = yield* runtime.prompt({ + prompt: [ + { + type: "text", + text: "Reply with exactly one word: ok. Do not use tools.", + }, + ], + }); + expect(result.stopReason).toBeDefined(); + // Live wire currently advertises a single model; when more appear, the + // same set_model path is used by applyGrokAcpModelSelection. + const available = started.sessionSetupResult.models?.availableModels ?? []; + expect(available.length).toBeGreaterThan(0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("initialize _meta exposes reasoningEfforts, totalContextTokens, and commands", () => + Effect.gen(function* () { + const runtime = yield* makeProbeRuntime; + const started = yield* runtime.start(); + const meta = started.initializeResult._meta as Record | undefined; + expect(meta).toBeDefined(); + const modelState = meta?.modelState as + | { + readonly availableModels?: ReadonlyArray<{ + readonly _meta?: { + readonly totalContextTokens?: number; + readonly reasoningEfforts?: unknown; + }; + }>; + } + | undefined; + const firstMeta = modelState?.availableModels?.[0]?._meta; + expect(firstMeta?.totalContextTokens).toBeGreaterThan(0); + expect(Array.isArray(firstMeta?.reasoningEfforts)).toBe(true); + expect((firstMeta?.reasoningEfforts as unknown[]).length).toBeGreaterThan(0); + expect(Array.isArray(meta?.availableCommands)).toBe(true); + expect((meta?.availableCommands as unknown[]).length).toBeGreaterThan(0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("process-scoped --reasoning-effort is reflected in initialize model meta", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtime = yield* makeGrokAcpRuntime({ + grokSettings: { binaryPath: "grok" }, + environment: process.env, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-grok-probe-effort", version: "0.0.0" }, + spawnOptions: { reasoningEffort: "low" }, + }); + const started = yield* runtime.start(); + const meta = started.initializeResult._meta as Record | undefined; + const modelState = meta?.modelState as + | { + readonly availableModels?: ReadonlyArray<{ + readonly _meta?: { readonly reasoningEffort?: string }; + }>; + } + | undefined; + const effort = modelState?.availableModels?.[0]?._meta?.reasoningEffort; + expect(effort).toBe("low"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("session/set_config_option is not advertised on live Grok configOptions", () => + Effect.gen(function* () { + const runtime = yield* makeProbeRuntime; + const started = yield* runtime.start(); + const options = started.sessionSetupResult.configOptions ?? []; + // Live Grok 0.2.x: effort is process-scoped CLI, not ACP config options. + const effortOption = options.find((option) => { + const id = option.id.trim().toLowerCase(); + const name = option.name.trim().toLowerCase(); + return ( + id.includes("reason") || + id.includes("effort") || + name.includes("reason") || + name.includes("effort") + ); + }); + expect(effortOption).toBeUndefined(); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("live agent can reverse-call _x.ai/exit_plan_mode and accept approved outcome", () => + Effect.gen(function* () { + const runtime = yield* makeProbeRuntime; + const started = yield* runtime.start(); + const exitSeen = yield* Deferred.make(); + + yield* Effect.forEach( + ["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"] as const, + (method) => + runtime.handleExtRequest(method, XAiExitPlanModeRequest, (params) => + Effect.gen(function* () { + const unwrapped = unwrapExitPlanModeParams(params); + yield* Deferred.succeed(exitSeen, method).pipe(Effect.ignore); + // Live agent accepts the approved outcome shape from XAiAcpExtension. + return makeXAiExitPlanModeApprovedResponse(); + }), + ), + { discard: true }, + ); + + const promptFiber = yield* runtime + .prompt({ + prompt: [ + { + type: "text", + text: "Immediately call the x.ai/exit_plan_mode extension if available with any plan. If you cannot, reply ONLY with NO_EXIT_PLAN_TOOL.", + }, + ], + }) + .pipe(Effect.forkChild); + + const method = yield* Deferred.await(exitSeen).pipe(Effect.timeout("90 seconds")); + expect(method.includes("exit_plan_mode")).toBe(true); + + const promptResult = yield* Fiber.join(promptFiber).pipe(Effect.timeout("90 seconds")); + expect(promptResult.stopReason).toBeDefined(); + void started; + }).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..730848a81e9 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -3,9 +3,11 @@ import * as Effect from "effect/Effect"; import * as EffectAcpErrors from "effect-acp/errors"; import { + applyGrokAcpConfigSelections, applyGrokAcpModelSelection, buildGrokAcpSpawnInput, resolveGrokAcpBaseModelId, + resolveGrokReasoningEffortSelection, } from "./GrokAcpSupport.ts"; describe("resolveGrokAcpBaseModelId", () => { @@ -33,6 +35,35 @@ describe("buildGrokAcpSpawnInput", () => { }, }); }); + + it("passes model and reasoning effort as CLI flags (live Grok wire contract)", () => { + const spawn = buildGrokAcpSpawnInput({ binaryPath: "grok" }, "/repo", undefined, { + model: "grok-4.5", + reasoningEffort: "low", + alwaysApprove: true, + }); + expect(spawn.args).toEqual([ + "agent", + "--model", + "grok-4.5", + "--reasoning-effort", + "low", + "--always-approve", + "stdio", + ]); + }); +}); + +describe("resolveGrokReasoningEffortSelection", () => { + it("reads reasoningEffort, reasoning, or effort option ids", () => { + expect(resolveGrokReasoningEffortSelection([{ id: "reasoningEffort", value: "low" }])).toBe( + "low", + ); + expect(resolveGrokReasoningEffortSelection([{ id: "reasoning", value: "high" }])).toBe("high"); + expect(resolveGrokReasoningEffortSelection([{ id: "effort", value: "medium" }])).toBe("medium"); + expect(resolveGrokReasoningEffortSelection([{ id: "other", value: "x" }])).toBeUndefined(); + expect(resolveGrokReasoningEffortSelection(undefined)).toBeUndefined(); + }); }); describe("applyGrokAcpModelSelection", () => { @@ -107,3 +138,65 @@ describe("applyGrokAcpModelSelection", () => { }), ); }); + +describe("applyGrokAcpConfigSelections", () => { + it.effect("sets effort config option when advertised and selected", () => + Effect.gen(function* () { + const calls: Array<{ id: string; value: string | boolean }> = []; + yield* applyGrokAcpConfigSelections({ + runtime: { + getConfigOptions: Effect.succeed([ + { + id: "effort", + name: "Reasoning", + category: "thought_level", + type: "select", + currentValue: "low", + options: [ + { value: "low", name: "Low" }, + { value: "high", name: "High" }, + ], + }, + ]), + setConfigOption: ((id: string, value: string | boolean) => + Effect.sync(() => { + calls.push({ id, value }); + return {}; + })) as never, + }, + selections: [{ id: "reasoningEffort", value: "high" }], + mapError: (cause) => cause.message, + }); + expect(calls).toEqual([{ id: "effort", value: "high" }]); + }), + ); + + it.effect("skips when selection already matches current", () => + Effect.gen(function* () { + const calls: Array<{ id: string; value: string | boolean }> = []; + yield* applyGrokAcpConfigSelections({ + runtime: { + getConfigOptions: Effect.succeed([ + { + id: "effort", + name: "Reasoning", + type: "select", + currentValue: "high", + options: [{ value: "high", name: "High" }], + }, + ]), + setConfigOption: ((id: string, value: string | boolean) => + Effect.sync(() => { + calls.push({ id, value }); + return {}; + })) as never, + }, + selections: [{ id: "reasoningEffort", value: "high" }], + mapError: (cause) => cause.message, + }); + expect(calls).toEqual([]); + }), + ); +}); + + diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index c928b3ed80e..535b2c7c7fe 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 ProviderOptionSelection, + ProviderDriverKind, +} from "@t3tools/contracts"; +import { getProviderOptionStringSelectionValue } from "@t3tools/shared/model"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -20,6 +25,13 @@ const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); type GrokAcpRuntimeGrokSettings = Pick; +/** Process-level agent options. Live Grok exposes effort via CLI flags, not ACP config options. */ +export interface GrokAcpSpawnOptions { + readonly model?: string; + readonly reasoningEffort?: string; + readonly alwaysApprove?: boolean; +} + interface GrokAcpRuntimeInput extends Omit< AcpSessionRuntime.AcpSessionRuntimeOptions, "authMethodId" | "clientCapabilities" | "spawn" @@ -27,16 +39,31 @@ interface GrokAcpRuntimeInput extends Omit< readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined; readonly environment?: NodeJS.ProcessEnv; + readonly spawnOptions?: GrokAcpSpawnOptions; } export function buildGrokAcpSpawnInput( grokSettings: GrokAcpRuntimeGrokSettings | null | undefined, cwd: string, environment?: NodeJS.ProcessEnv, + spawnOptions?: GrokAcpSpawnOptions, ): AcpSessionRuntime.AcpSpawnInput { + const args: string[] = ["agent"]; + const model = spawnOptions?.model?.trim(); + if (model) { + args.push("--model", model); + } + const effort = spawnOptions?.reasoningEffort?.trim(); + if (effort) { + args.push("--reasoning-effort", effort); + } + if (spawnOptions?.alwaysApprove) { + args.push("--always-approve"); + } + args.push("stdio"); return { command: grokSettings?.binaryPath || "grok", - args: ["agent", "stdio"], + args, cwd, env: { ...environment, @@ -45,6 +72,18 @@ export function buildGrokAcpSpawnInput( }; } +export function resolveGrokReasoningEffortSelection( + selections: ReadonlyArray | null | undefined, +): string | undefined { + return ( + getProviderOptionStringSelectionValue(selections, "reasoningEffort") ?? + getProviderOptionStringSelectionValue(selections, "reasoning") ?? + getProviderOptionStringSelectionValue(selections, "effort") + ); +} + + + function resolveGrokAuthMethodId(environment: NodeJS.ProcessEnv | undefined): string { return environment?.[GROK_API_KEY_ENV]?.trim() ? GROK_AUTH_METHOD_API_KEY @@ -62,7 +101,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.spawnOptions, + ), authMethodId: resolveGrokAuthMethodId(input.environment), }).pipe( Layer.provide( @@ -106,3 +150,67 @@ export function applyGrokAcpModelSelection(input: { .setSessionModel(input.requestedModelId) .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId)); } + +function isEffortConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { + const id = option.id.trim().toLowerCase(); + const name = option.name.trim().toLowerCase(); + const category = option.category?.trim().toLowerCase() ?? ""; + return ( + id === "reasoning" || + id === "reasoningeffort" || + id === "reasoning_effort" || + id === "effort" || + name.includes("reasoning") || + name.includes("effort") || + category === "thought_level" + ); +} + +/** + * Secondary path: only when Grok advertises effort as ACP config options. + * Live Grok 0.2.x has no session/set_config_option; effort is CLI + * `--reasoning-effort` via buildGrokAcpSpawnInput / process restart. + */ +export function applyGrokAcpConfigSelections(input: { + readonly runtime: Pick< + AcpSessionRuntime.AcpSessionRuntime["Service"], + "getConfigOptions" | "setConfigOption" + >; + readonly selections: ReadonlyArray | null | undefined; + readonly mapError: (cause: EffectAcpErrors.AcpError) => E; +}): Effect.Effect { + return Effect.gen(function* () { + if (!input.selections || input.selections.length === 0) { + return; + } + const configOptions = yield* input.runtime.getConfigOptions.pipe( + Effect.mapError(input.mapError), + ); + if (!configOptions || configOptions.length === 0) { + return; + } + const requestedEffort = + getProviderOptionStringSelectionValue(input.selections, "reasoningEffort") ?? + getProviderOptionStringSelectionValue(input.selections, "reasoning") ?? + getProviderOptionStringSelectionValue(input.selections, "effort"); + if (!requestedEffort) { + return; + } + const effortOption = configOptions.find(isEffortConfigOption); + if (!effortOption || effortOption.type !== "select") { + return; + } + const values = effortOption.options.flatMap((entry) => + "value" in entry ? [entry.value] : entry.options.map((option) => option.value), + ); + const match = values.find( + (value) => value.trim().toLowerCase() === requestedEffort.trim().toLowerCase(), + ); + if (!match || match === effortOption.currentValue) { + return; + } + yield* input.runtime + .setConfigOption(effortOption.id, match) + .pipe(Effect.mapError(input.mapError)); + }); +} diff --git a/apps/server/src/provider/acp/fixtures/grok-initialize-stdio.jsonl b/apps/server/src/provider/acp/fixtures/grok-initialize-stdio.jsonl new file mode 100644 index 00000000000..43555a70d82 --- /dev/null +++ b/apps/server/src/provider/acp/fixtures/grok-initialize-stdio.jsonl @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true},"authMethods":[{"id":"cached_token","name":"cached_token","description":"Cached token from ~/.grok/auth.json"},{"id":"grok.com","name":"Grok","description":"Sign in with Grok"}],"_meta":{"modelState":{"currentModelId":"grok-4.5","availableModels":[{"modelId":"grok-4.5","name":"Grok 4.5","description":"SpaceXAI's new frontier model","_meta":{"totalContextTokens":500000,"agentType":"grok-build-plan","supportsReasoningEffort":true,"reasoningEffort":"high","reasoningEfforts":[{"id":"high","value":"high","label":"High Effort","description":"Highest implementation quality with extensive reasoning","default":true},{"id":"medium","value":"medium","label":"Medium Effort","description":"Balanced effort with standard implementation and testing","default":false},{"id":"low","value":"low","label":"Low Effort","description":"Quick, fast implementations","default":false}]}}]},"availableCommands":[{"name":"compact","description":"Compress conversation history to save context window","input":{"hint":"optional context about what to preserve"}},{"name":"always-approve","description":"Toggle always-approve mode (skip all permission prompts)","input":{"hint":"on|off"}},{"name":"context","description":"Show context window usage and session stats","input":null},{"name":"session-info","description":"Show session details (model, turns, context usage)","input":null},{"name":"deep-research","description":"Research with bounded parallel agents, cross-check evidence, and write a cited report","input":{"hint":""}},{"name":"workflow","description":"Launch a saved workflow, or manage a run (pause, resume, stop, save)","input":{"hint":" [args] | pause|resume|stop|save [name]"}},{"name":"goal","description":"Set, manage, or check an autonomous goal","input":{"hint":" [--budget ] | status | pause | resume | clear"}}],"agentVersion":"0.2.118","defaultAuthMethodId":"cached_token","grokShell":true}}} From 2447738ad2782145555cf39037261e223628f019 Mon Sep 17 00:00:00 2001 From: Enzo Tironi Date: Wed, 5 Aug 2026 12:23:30 -0300 Subject: [PATCH 3/3] feat(grok): plan mode via /plan text mapping --- .../server/src/provider/Layers/GrokAdapter.ts | 6 +++- .../src/provider/Layers/GrokProvider.test.ts | 2 +- .../src/provider/Layers/GrokProvider.ts | 5 +-- .../src/provider/acp/GrokAcpSupport.test.ts | 32 +++++++++++++++++++ .../server/src/provider/acp/GrokAcpSupport.ts | 22 +++++++++++++ 5 files changed, 63 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 93fd8dc05b0..e3d5c772952 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -61,6 +61,7 @@ import { currentGrokModelIdFromSessionSetup, makeGrokAcpRuntime, resolveGrokAcpBaseModelId, + applyGrokPlanModeToPromptText, resolveGrokReasoningEffortSelection, type GrokAcpSpawnOptions, } from "../acp/GrokAcpSupport.ts"; @@ -1440,7 +1441,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ), }); - const text = input.input?.trim(); + const text = applyGrokPlanModeToPromptText({ + text: input.input?.trim(), + interactionMode: input.interactionMode, + }); const imagePromptParts = yield* Effect.forEach( input.attachments ?? [], (attachment) => diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 6ca6c89b8f0..cda3181576c 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -37,7 +37,7 @@ describe("buildInitialGrokProviderSnapshot", () => { expect(snapshot.version).toBeNull(); expect(snapshot.message).toContain("Checking Grok"); expect(snapshot.requiresNewThreadForModelChange).toBe(false); - expect(snapshot.showInteractionModeToggle).toBe(false); + expect(snapshot.showInteractionModeToggle).toBe(true); }), ); }); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index f867e27bf4a..a300afc8cbb 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -35,8 +35,9 @@ import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSup const GROK_PRESENTATION = { displayName: "Grok", - // Live 0.2.x: plan toggle lands in later stack layer; set_model is in-session. - showInteractionModeToggle: false, + // Plan/Build maps to Grok's `/plan` command on send (no ACP session modes). + // Live 0.2.x: session/set_model works in-session; no forced new thread. + showInteractionModeToggle: true, requiresNewThreadForModelChange: false, } as const; const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index 730848a81e9..4ca9ace69f1 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 { applyGrokAcpConfigSelections, applyGrokAcpModelSelection, + applyGrokPlanModeToPromptText, buildGrokAcpSpawnInput, resolveGrokAcpBaseModelId, resolveGrokReasoningEffortSelection, @@ -199,4 +200,35 @@ describe("applyGrokAcpConfigSelections", () => { ); }); +describe("applyGrokPlanModeToPromptText", () => { + it("prefixes /plan when interactionMode is plan", () => { + expect(applyGrokPlanModeToPromptText({ text: "design auth", interactionMode: "plan" })).toBe( + "/plan design auth", + ); + }); + + it("does not double-prefix /plan", () => { + expect(applyGrokPlanModeToPromptText({ text: "/plan already", interactionMode: "plan" })).toBe( + "/plan already", + ); + }); + + it("accepts case-insensitive existing /Plan prefix", () => { + expect(applyGrokPlanModeToPromptText({ text: "/Plan design", interactionMode: "plan" })).toBe( + "/Plan design", + ); + }); + + it("leaves default mode text unchanged", () => { + expect(applyGrokPlanModeToPromptText({ text: "hello", interactionMode: "default" })).toBe( + "hello", + ); + }); + it("returns undefined/empty for blank plan prompts", () => { + expect(applyGrokPlanModeToPromptText({ text: undefined, interactionMode: "plan" })).toBe( + undefined, + ); + expect(applyGrokPlanModeToPromptText({ text: " ", interactionMode: "plan" })).toBe(""); + }); +}); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index 535b2c7c7fe..247edf005ef 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -84,6 +84,28 @@ export function resolveGrokReasoningEffortSelection( +/** + * Grok has no ACP session modes on live 0.2.x. Plan mode is entered via the + * `/plan` slash command (see Grok Build user guide). Map T3 interactionMode + * onto that command so Plan/Build in the composer does real work. + */ +export function applyGrokPlanModeToPromptText(input: { + readonly text: string | undefined; + readonly interactionMode: "plan" | "default" | undefined; +}): string | undefined { + const trimmed = input.text?.trim(); + if (!trimmed) { + return trimmed; + } + if (input.interactionMode === "plan") { + if (/^\/plan(?:\s|$)/i.test(trimmed)) { + return trimmed; + } + return `/plan ${trimmed}`; + } + return trimmed; +} + function resolveGrokAuthMethodId(environment: NodeJS.ProcessEnv | undefined): string { return environment?.[GROK_API_KEY_ENV]?.trim() ? GROK_AUTH_METHOD_API_KEY