From c6f1f875be0aa4ae4924541cccacc37831a65d91 Mon Sep 17 00:00:00 2001 From: Enzo Tironi Date: Wed, 5 Aug 2026 12:10:03 -0300 Subject: [PATCH 1/5] 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/5] 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/5] 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 From 05f567265a3f0432083016a077665914dc996e13 Mon Sep 17 00:00:00 2001 From: Enzo Tironi Date: Wed, 5 Aug 2026 12:23:31 -0300 Subject: [PATCH 4/5] feat(grok): multi-agent visibility via task.* events --- .../server/src/provider/Layers/GrokAdapter.ts | 53 +++++++++++++++++++ .../src/provider/acp/GrokAcpSupport.test.ts | 33 ++++++++++++ .../server/src/provider/acp/GrokAcpSupport.ts | 30 ++++++++++- 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index e3d5c772952..9cea2798636 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -62,6 +62,7 @@ import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId, applyGrokPlanModeToPromptText, + isGrokSubagentToolCall, resolveGrokReasoningEffortSelection, type GrokAcpSpawnOptions, } from "../acp/GrokAcpSupport.ts"; @@ -1267,6 +1268,58 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte rawPayload: event.rawPayload, }), ); + // Surface Grok spawn_subagent (and similar) as T3 task rows + // so multi-agent work is visible like Claude Task tools. + if (isGrokSubagentToolCall(event.toolCall)) { + const taskId = RuntimeTaskId.make(event.toolCall.toolCallId); + const description = + event.toolCall.title?.trim() || + event.toolCall.detail?.trim() || + "Grok subagent"; + if ( + event.toolCall.status === "pending" || + event.toolCall.status === "inProgress" + ) { + yield* offerRuntimeEvent({ + type: "task.started", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + payload: { + taskId, + description, + taskType: "subagent", + }, + raw: { + source: "acp.jsonrpc", + method: "session/update", + payload: event.rawPayload, + }, + }); + } else if ( + event.toolCall.status === "completed" || + event.toolCall.status === "failed" + ) { + yield* offerRuntimeEvent({ + type: "task.completed", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + payload: { + taskId, + status: event.toolCall.status === "failed" ? "failed" : "completed", + ...(event.toolCall.detail ? { summary: event.toolCall.detail } : {}), + }, + raw: { + source: "acp.jsonrpc", + method: "session/update", + payload: event.rawPayload, + }, + }); + } + } return; } case "ContentDelta": diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index 4ca9ace69f1..43285050bb4 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -7,6 +7,7 @@ import { applyGrokAcpModelSelection, applyGrokPlanModeToPromptText, buildGrokAcpSpawnInput, + isGrokSubagentToolCall, resolveGrokAcpBaseModelId, resolveGrokReasoningEffortSelection, } from "./GrokAcpSupport.ts"; @@ -232,3 +233,35 @@ describe("applyGrokPlanModeToPromptText", () => { expect(applyGrokPlanModeToPromptText({ text: " ", interactionMode: "plan" })).toBe(""); }); }); + +describe("isGrokSubagentToolCall", () => { + it("matches spawn_subagent by tool name", () => { + expect( + isGrokSubagentToolCall({ + toolCallId: "tc_1", + data: { name: "spawn_subagent" }, + }), + ).toBe(true); + }); + + it("matches titles that mention subagent", () => { + expect( + isGrokSubagentToolCall({ + toolCallId: "tc_2", + title: "Spawn subagent: explore", + data: {}, + }), + ).toBe(true); + }); + + it("does not match ordinary tools", () => { + expect( + isGrokSubagentToolCall({ + toolCallId: "tc_3", + title: "Read file", + kind: "read", + data: { name: "read_file" }, + }), + ).toBe(false); + }); +}); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index 247edf005ef..ed1f0f2597f 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -82,8 +82,6 @@ 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 @@ -106,6 +104,34 @@ export function applyGrokPlanModeToPromptText(input: { return trimmed; } +/** + * Detect Grok in-process subagent tools (spawn_subagent and relatives) so the + * adapter can emit T3 task.* events for multi-agent visibility. + */ +export function isGrokSubagentToolCall(toolCall: { + readonly toolCallId: string; + readonly title?: string; + readonly kind?: string; + readonly detail?: string; + readonly data: Record; +}): boolean { + const haystack = [ + toolCall.toolCallId, + toolCall.title ?? "", + toolCall.kind ?? "", + toolCall.detail ?? "", + typeof toolCall.data.name === "string" ? toolCall.data.name : "", + typeof toolCall.data.toolName === "string" ? toolCall.data.toolName : "", + ] + .join(" ") + .toLowerCase(); + return ( + haystack.includes("spawn_subagent") || + haystack.includes("subagent") || + haystack.includes("spawn_agent") + ); +} + function resolveGrokAuthMethodId(environment: NodeJS.ProcessEnv | undefined): string { return environment?.[GROK_API_KEY_ENV]?.trim() ? GROK_AUTH_METHOD_API_KEY From 3929597e02b7332a11ba19340a38447bc536a671 Mon Sep 17 00:00:00 2001 From: Enzo Tironi Date: Wed, 5 Aug 2026 13:22:44 -0300 Subject: [PATCH 5/5] fix(grok): set_model effort, plan capture, compact, review findings --- apps/server/scripts/acp-mock-agent.ts | 26 + .../server/src/provider/Drivers/GrokDriver.ts | 27 +- .../src/provider/Layers/GrokAdapter.test.ts | 122 ++--- .../server/src/provider/Layers/GrokAdapter.ts | 474 ++++++++++++------ .../src/provider/Layers/GrokProvider.test.ts | 18 + .../src/provider/Layers/GrokProvider.ts | 70 ++- .../src/provider/acp/AcpSessionRuntime.ts | 7 +- .../src/provider/acp/GrokAcpSupport.test.ts | 86 +++- .../server/src/provider/acp/GrokAcpSupport.ts | 73 ++- .../src/provider/acp/XAiAcpExtension.test.ts | 110 ++++ .../src/provider/acp/XAiAcpExtension.ts | 149 +++++- .../src/textGeneration/GrokTextGeneration.ts | 1 + 12 files changed, 902 insertions(+), 261 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 8861bfb50fb..d7dcb0ada9e 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -69,6 +69,17 @@ function promptIdFromRequestMeta( return typeof promptId === "string" && promptId.length > 0 ? promptId : undefined; } +function promptTextFromRequest(request: AcpSchema.PromptRequest): string { + const parts = Array.isArray(request.prompt) ? request.prompt : []; + return parts + .flatMap((part) => + part && typeof part === "object" && "type" in part && part.type === "text" && "text" in part + ? [String(part.text)] + : [], + ) + .join(""); +} + function logExit(reason: string): void { if (!exitLogPath) { return; @@ -494,6 +505,21 @@ const program = Effect.gen(function* () { return yield* AcpError.AcpRequestError.internalError("Mock prompt failure"); } + // Mirror real Grok: `/compact` is handled as a prompt and emits auto_compact_completed. + const promptText = promptTextFromRequest(request).trim(); + if (/^\/compact(?:\s|$)/i.test(promptText)) { + writeJsonRpcNotification("_x.ai/session_notification", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "auto_compact_completed", + tokens_before: 12_000, + tokens_after: 4_000, + summary_preview: null, + }, + }); + return { stopReason: "end_turn" }; + } + if (emitStaleXAiPromptCompleteBeforeSecondHang && promptCount === 1) { return { stopReason: "end_turn", diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 5f95ed6bbc9..d4c985ecfc4 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -20,6 +20,7 @@ import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus, enrichGrokSnapshot, + ensureGrokStaticSlashCommands, mapAcpCommandsToCatalog, } from "../Layers/GrokProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; @@ -111,6 +112,7 @@ export const GrokDriver: ProviderDriver = { }); const commandCatalogRef = yield* Ref.make({ + received: false, slashCommands: [] as ServerProvider["slashCommands"], skills: [] as ServerProvider["skills"], }); @@ -123,12 +125,22 @@ export const GrokDriver: ProviderDriver = { 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, - })), + Effect.map((catalog) => { + // Until a live catalog arrives, keep discovery/probe catalogs on the snapshot. + if (!catalog.received) { + return { + ...snapshot, + slashCommands: ensureGrokStaticSlashCommands(snapshot.slashCommands), + }; + } + // Once received, apply even when empty so clients clear stale entries, + // but always re-attach static commands (e.g. compact) if missing. + return { + ...snapshot, + slashCommands: ensureGrokStaticSlashCommands(catalog.slashCommands), + skills: catalog.skills, + }; + }), ); const adapter = yield* makeGrokAdapter(effectiveConfig, { @@ -139,7 +151,8 @@ export const GrokDriver: ProviderDriver = { Effect.gen(function* () { const catalog = mapAcpCommandsToCatalog(commands); yield* Ref.set(commandCatalogRef, { - slashCommands: catalog.slashCommands, + received: true, + slashCommands: ensureGrokStaticSlashCommands(catalog.slashCommands), skills: catalog.skills, }); yield* PubSub.publish(commandCatalogChanges, undefined); diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index e260852b5a9..e9e1cd47ab9 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -1335,10 +1335,10 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { ); it.effect( - "restarts the process with --reasoning-effort and resumes the ACP session when effort changes", + "applies mid-thread effort via session/set_model _meta without process restart", () => Effect.gen(function* () { - const threadId = ThreadId.make("grok-effort-process-restart"); + const threadId = ThreadId.make("grok-effort-set-model-meta"); const tmpDir = yield* Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-effort-")), ); @@ -1348,18 +1348,22 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { 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 sessionExited = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (event.type === "turn.completed" && String(event.threadId) === String(threadId)) { + return Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore); + } + if (event.type === "session.exited" && String(event.threadId) === String(threadId)) { + return Deferred.succeed(sessionExited, undefined).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkChild); const instanceId = ProviderInstanceId.make("grok"); yield* adapter.startSession({ @@ -1391,38 +1395,52 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { yield* Deferred.await(turnCompleted); - const argvAfter = yield* waitForFileContent(argvLogPath, 40, "--reasoning-effort low"); + // Process must not restart: still a single argv line with the spawn effort. + const argvAfter = yield* waitForFileContent(argvLogPath, 10, "--reasoning-effort high"); const argvLines = argvAfter .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0); - assert.isAtLeast(argvLines.length, 2); + assert.equal(argvLines.length, 1); 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"); + const requestLog = yield* waitForFileContent(requestLogPath, 40, "session/set_model"); + assert.include(requestLog, "session/set_model"); + assert.include(requestLog, "reasoningEffort"); + assert.include(requestLog, "low"); + assert.notInclude(requestLog, "session/load"); + + const exitedRace = yield* Deferred.await(sessionExited).pipe( + Effect.timeoutOption("100 millis"), + ); + assert.isTrue(exitedRace._tag === "None"); yield* Fiber.interrupt(eventsFiber); yield* adapter.stopSession(threadId); }).pipe(TestClock.withLive), ); - it.effect("rejects effort change while a turn is still running", () => + it.effect("maps /compact session notifications to thread.state.changed compacted", () => Effect.gen(function* () { - const threadId = ThreadId.make("grok-effort-busy"); - const wrapperPath = yield* Effect.promise(() => - makeMockGrokWrapper({ T3_ACP_HANG_PROMPT_FOREVER: "1" }), - ); + const threadId = ThreadId.make("grok-compact-thread"); + const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); 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, + + const runtimeEvents: Array<{ type: string; payload?: unknown; raw?: unknown }> = []; + const compacted = yield* Deferred.make(); + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "thread.state.changed" && event.payload.state === "compacted" + ? Deferred.succeed(compacted, undefined) + : event.type === "turn.completed" + ? Deferred.succeed(turnCompleted, undefined) + : Effect.void, + ), + ), ).pipe(Effect.forkChild); yield* adapter.startSession({ @@ -1430,43 +1448,35 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { 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); + yield* adapter.sendTurn({ + threadId, + input: "/compact keep the auth details", + attachments: [], + }); - const turnId = yield* Deferred.await(turnStarted).pipe(Effect.timeout("2 seconds")); + yield* Deferred.await(compacted).pipe(Effect.timeout("3 seconds")); + yield* Deferred.await(turnCompleted).pipe(Effect.timeout("3 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" }], - }, - }), + const compactEvent = runtimeEvents.find( + (event) => + event.type === "thread.state.changed" && + (event.payload as { state?: string } | undefined)?.state === "compacted", ); - assert.equal(failure._tag, "ProviderAdapterValidationError"); - if (failure._tag === "ProviderAdapterValidationError") { - assert.include(failure.issue, "reasoning effort"); + assert.isDefined(compactEvent); + if (compactEvent?.type === "thread.state.changed") { + assert.deepEqual(compactEvent.payload.detail, { + tokensBefore: 12_000, + tokensAfter: 4_000, + }); + assert.equal( + (compactEvent as { raw?: { method?: string } }).raw?.method, + "_x.ai/session_notification", + ); } - yield* adapter.interruptTurn(threadId, turnId).pipe(Effect.timeout("2 seconds")); - yield* Fiber.join(hangingTurn).pipe(Effect.timeout("2 seconds")); - yield* Fiber.interrupt(eventsFiber); + yield* Fiber.interrupt(runtimeEventsFiber); 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 9cea2798636..36f9a2f5d9f 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -67,15 +67,17 @@ import { type GrokAcpSpawnOptions, } from "../acp/GrokAcpSupport.ts"; import { + extractGrokPlanMarkdownFromToolCallData, extractXAiAskUserQuestions, + extractXAiAutoCompactCompleted, + extractXAiExitPlanMarkdown, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, - makeXAiExitPlanModeApprovedResponse, - makeXAiExitPlanModeReviseResponse, + makeXAiExitPlanModeCapturedResponse, promptResponseHasMissingXAiStopReason, - unwrapExitPlanModeParams, XAiAskUserQuestionRequest, XAiExitPlanModeRequest, + XAiSessionNotification, } from "../acp/XAiAcpExtension.ts"; import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -132,14 +134,30 @@ 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). */ + /** + * Sticky effort for this process: CLI spawn value initially, then last + * successful `session/set_model` `_meta.reasoningEffort`. + */ processReasoningEffort: string | undefined; stopped: boolean; availableCommands: ReadonlyArray; configOptions: ReadonlyArray; sessionTitle: string | undefined; - /** Context window size from model meta when known. */ + /** Context window size for the active model when known. */ contextWindowTokens: number | undefined; + /** Per-model context windows from session/initialize model meta. */ + modelContextWindows: ReadonlyMap; + /** Last proposed plan markdown for this turn (exit_plan_mode fallback). */ + lastKnownProposedPlanMarkdown: string | undefined; + lastKnownProposedPlanTurnId: TurnId | undefined; + /** True after enter_plan_mode until the turn ends or exit_plan_mode resolves. */ + planModeActive: boolean; + /** toolCallIds already emitted as task.started (subagent dedupe). */ + startedSubagentTaskIds: Set; + /** toolCallIds already emitted as task.completed (subagent dedupe). */ + completedSubagentTaskIds: Set; + /** turnIds that already received a prompt token-usage event. */ + promptUsageOfferedTurnIds: Set; } function settlePendingApprovalsAsCancelled( @@ -285,10 +303,12 @@ function preferredModelMeta(input: { 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; + if (preferred) { + // Preferred id set: only that model's meta (may be undefined). Never fall back to models[0]. + const preferredMatch = models.find((model) => model.modelId === preferred); + return preferredMatch?._meta; + } + return models[0]?._meta; }; const fromSession = pick( input.sessionModels?.availableModels, @@ -297,11 +317,14 @@ function preferredModelMeta(input: { if (fromSession !== undefined) return fromSession; const initializeModelState = input.initializeMeta?.modelState; if (isRecord(initializeModelState) && Array.isArray(initializeModelState.availableModels)) { + const availableModels = initializeModelState.availableModels.flatMap((entry) => { + if (!isRecord(entry) || typeof entry.modelId !== "string") { + return [] as Array<{ modelId: string; _meta?: unknown }>; + } + return [{ modelId: entry.modelId, _meta: entry._meta }]; + }); return pick( - initializeModelState.availableModels as ReadonlyArray<{ - modelId: string; - _meta?: unknown; - }>, + availableModels, input.preferredModelId ?? (typeof initializeModelState.currentModelId === "string" ? initializeModelState.currentModelId @@ -340,11 +363,48 @@ function parseGrokAvailableCommandsFromMeta( }); } +function buildGrokModelContextWindows(input: { + readonly sessionModels: EffectAcpSchema.SessionModelState | null | undefined; + readonly initializeMeta: Record | undefined; +}): Map { + const windows = new Map(); + const ingest = (models: ReadonlyArray<{ modelId: string; _meta?: unknown }> | undefined) => { + if (!models) return; + for (const model of models) { + const tokens = totalContextTokensFromMeta(model._meta); + if (tokens !== undefined && model.modelId.trim()) { + windows.set(model.modelId.trim(), tokens); + } + } + }; + ingest(input.sessionModels?.availableModels); + const initializeModelState = input.initializeMeta?.modelState; + if (isRecord(initializeModelState) && Array.isArray(initializeModelState.availableModels)) { + const availableModels = initializeModelState.availableModels.flatMap((entry) => { + if (!isRecord(entry) || typeof entry.modelId !== "string") { + return [] as Array<{ modelId: string; _meta?: unknown }>; + } + return [{ modelId: entry.modelId, _meta: entry._meta }]; + }); + ingest(availableModels); + } + return windows; +} + function resolveGrokContextWindowTokens(input: { readonly sessionModels: EffectAcpSchema.SessionModelState | null | undefined; readonly initializeMeta: Record | undefined; readonly preferredModelId: string | undefined; + readonly modelContextWindows?: ReadonlyMap; }): number | undefined { + if (input.preferredModelId && input.modelContextWindows) { + const fromMap = input.modelContextWindows.get(input.preferredModelId); + if (fromMap !== undefined) { + return fromMap; + } + } + // Prefer the selected model's meta only — never steal another model's window + // when a preferred id is set but missing from the map. return totalContextTokensFromMeta(preferredModelMeta(input)); } @@ -357,6 +417,33 @@ function resolveProcessReasoningEffort(input: { return input.spawnEffort ?? reasoningEffortFromMeta(preferredModelMeta(input)); } +function clearProposedPlanFallback(ctx: GrokSessionContext): void { + ctx.lastKnownProposedPlanMarkdown = undefined; + ctx.lastKnownProposedPlanTurnId = undefined; + ctx.planModeActive = false; +} + +/** Detect Grok's enter_plan_mode tool call from ACP tool state. */ +export function isGrokEnterPlanModeToolCall(toolCall: { + readonly title?: string; + readonly data: Record; +}): boolean { + const title = toolCall.title?.trim().toLowerCase() ?? ""; + if ( + title === "enter_plan_mode" || + title === "plan: enter" || + title === "plan mode entered" || + title.includes("enter_plan_mode") + ) { + return true; + } + const rawInput = toolCall.data.rawInput; + if (isRecord(rawInput) && rawInput.variant === "EnterPlanMode") { + return true; + } + return false; +} + function selectPermissionOptionId( request: EffectAcpSchema.RequestPermissionRequest, decision: Exclude, @@ -461,14 +548,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte turnId: TurnId, meta: unknown, ): Effect.Effect => { + if (ctx.promptUsageOfferedTurnIds.has(turnId)) { + return Effect.void; + } const promptUsage = tokenUsageFromGrokPromptMeta(meta); if (!promptUsage) { return Effect.void; } - const maxTokens = - ctx.contextWindowTokens !== undefined && ctx.contextWindowTokens > 0 - ? ctx.contextWindowTokens - : undefined; + ctx.promptUsageOfferedTurnIds.add(turnId); + const maxTokens = resolveActiveContextWindowTokens(ctx); return makeEventStamp().pipe( Effect.flatMap((stamp) => offerRuntimeEvent( @@ -521,6 +609,19 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const withThreadLock = (threadId: string, effect: Effect.Effect) => Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + const resolveActiveContextWindowTokens = (ctx: GrokSessionContext): number | undefined => { + if (ctx.currentModelId) { + const fromMap = ctx.modelContextWindows.get(ctx.currentModelId); + if (fromMap !== undefined && fromMap > 0) { + return fromMap; + } + } + if (ctx.contextWindowTokens !== undefined && ctx.contextWindowTokens > 0) { + return ctx.contextWindowTokens; + } + return undefined; + }; + const settlePromptInFlight = ( threadId: ThreadId, turnId: TurnId, @@ -624,6 +725,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte options?.completedStopReason !== undefined && canEmitTurnCompletion; const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; liveCtx.activeTurnId = undefined; + // Drop turn-scoped plan fallback so a later empty exit_plan cannot + // resurrect this turn's markdown as a fresh proposal. + clearProposedPlanFallback(liveCtx); liveCtx.session = { ...readySession, status: "ready", @@ -722,6 +826,43 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ); }); + /** Surface Grok plan.md as T3's proposed-plan card (while writing + on exit). */ + const emitProposedPlanCompleted = ( + ctx: GrokSessionContext, + turnId: TurnId | undefined, + stamp: { readonly eventId: EventId; readonly createdAt: string }, + planMarkdown: string, + raw: { readonly method: string; readonly payload: unknown }, + ) => + Effect.gen(function* () { + const trimmedPlan = planMarkdown.trim(); + if (trimmedPlan.length === 0) { + return; + } + // Turn-scoped dedupe: identical text on a later turn must still emit. + if ( + ctx.lastKnownProposedPlanMarkdown === trimmedPlan && + ctx.lastKnownProposedPlanTurnId === turnId + ) { + return; + } + ctx.lastKnownProposedPlanMarkdown = trimmedPlan; + ctx.lastKnownProposedPlanTurnId = turnId; + yield* offerRuntimeEvent({ + type: "turn.proposed.completed", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload: { planMarkdown: trimmedPlan }, + raw: { + source: "acp.grok.extension", + method: raw.method, + payload: raw.payload, + }, + }); + }); + const requireSession = ( threadId: ThreadId, ): Effect.Effect => { @@ -900,6 +1041,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ), { discard: true }, ); + // Grok intercepts exit_plan_mode and reverse-requests client approval. + // Capture plan into T3 proposed-plan UI and abandon the native gate so + // the turn does not hang (Claude ExitPlanMode pattern). yield* Effect.forEach( ["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"] as const, (method) => @@ -907,13 +1051,22 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte 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) { + const ctx = sessions.get(input.threadId); + const planMarkdown = extractXAiExitPlanMarkdown( + params, + ctx?.lastKnownProposedPlanMarkdown, + ); + if (ctx) { + yield* emitProposedPlanCompleted( + ctx, + turnId, + yield* makeEventStamp(), + planMarkdown, + { method, payload: params }, + ); + ctx.planModeActive = false; + } else { yield* offerRuntimeEvent({ type: "turn.proposed.completed", ...(yield* makeEventStamp()), @@ -928,55 +1081,55 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }, }); } - 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", + return makeXAiExitPlanModeCapturedResponse(); + }), + ), + ), + { discard: true }, + ); + // Manual `/compact` and auto-compact complete as x.ai session notifications. + // Map them to thread.state.changed → UI work log "Context compacted". + yield* Effect.forEach( + ["x.ai/session_notification", "_x.ai/session_notification"] as const, + (method) => + acp.handleExtNotification(method, XAiSessionNotification, (notification) => + mapAcpCallbackFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, method, notification); + const compact = extractXAiAutoCompactCompleted(notification); + if (!compact) { + return; + } + const live = sessions.get(input.threadId); + if (!live || live.stopped) { + return; + } + const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); + if (turnId !== undefined && live.interruptedTurnIds.has(turnId)) { + return; + } + yield* offerRuntimeEvent({ + type: "thread.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: "compacted", + detail: { + tokensBefore: compact.tokensBefore, + tokensAfter: compact.tokensAfter, + ...(compact.summaryPreview + ? { summaryPreview: compact.summaryPreview } + : {}), }, - detail: - planMarkdown.length > 0 - ? "Grok is waiting for plan approval" - : "Grok is waiting for plan approval (empty plan content)", - args: exitParams, + }, + raw: { 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.", - ); + payload: notification, + }, + }); }), ), ), @@ -1061,6 +1214,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte runtime: acp, currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult), requestedModelId: requestedStartModelId, + selections: grokModelSelection?.options, mapError: (cause) => mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), }); @@ -1088,15 +1242,22 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte !Array.isArray(started.initializeResult._meta) ? (started.initializeResult._meta as Record) : undefined; + const modelContextWindows = buildGrokModelContextWindows({ + sessionModels: started.sessionSetupResult.models, + initializeMeta, + }); const metaSource = { sessionModels: started.sessionSetupResult.models, initializeMeta, preferredModelId: boundModelId, + modelContextWindows, }; const contextWindowTokens = resolveGrokContextWindowTokens(metaSource); const processReasoningEffort = resolveProcessReasoningEffort({ spawnEffort, - ...metaSource, + sessionModels: started.sessionSetupResult.models, + initializeMeta, + preferredModelId: boundModelId, }); const initializeCommands = parseGrokAvailableCommandsFromMeta(initializeMeta); if (initializeCommands.length > 0 && options?.onAvailableCommands) { @@ -1130,6 +1291,13 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte configOptions: started.sessionSetupResult.configOptions ?? [], sessionTitle: undefined, contextWindowTokens, + modelContextWindows, + lastKnownProposedPlanMarkdown: undefined, + lastKnownProposedPlanTurnId: undefined, + planModeActive: false, + startedSubagentTaskIds: new Set(), + completedSubagentTaskIds: new Set(), + promptUsageOfferedTurnIds: new Set(), }; const nf = yield* Stream.runDrain( @@ -1271,7 +1439,8 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte // Surface Grok spawn_subagent (and similar) as T3 task rows // so multi-agent work is visible like Claude Task tools. if (isGrokSubagentToolCall(event.toolCall)) { - const taskId = RuntimeTaskId.make(event.toolCall.toolCallId); + const toolCallId = event.toolCall.toolCallId; + const taskId = RuntimeTaskId.make(toolCallId); const description = event.toolCall.title?.trim() || event.toolCall.detail?.trim() || @@ -1280,44 +1449,75 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte event.toolCall.status === "pending" || event.toolCall.status === "inProgress" ) { - yield* offerRuntimeEvent({ - type: "task.started", - ...stamp, - provider: PROVIDER, - threadId: ctx.threadId, - turnId: notificationTurnId, - payload: { - taskId, - description, - taskType: "subagent", - }, - raw: { - source: "acp.jsonrpc", - method: "session/update", - payload: event.rawPayload, - }, - }); + if (!ctx.startedSubagentTaskIds.has(toolCallId)) { + ctx.startedSubagentTaskIds.add(toolCallId); + const taskStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "task.started", + ...taskStamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + payload: { + taskId, + description, + taskType: "subagent", + }, + raw: { + source: "acp.jsonrpc", + method: "session/update", + payload: event.rawPayload, + }, + }); + } } else if ( event.toolCall.status === "completed" || event.toolCall.status === "failed" ) { - yield* offerRuntimeEvent({ - type: "task.completed", - ...stamp, - provider: PROVIDER, - threadId: ctx.threadId, - turnId: notificationTurnId, - payload: { - taskId, - status: event.toolCall.status === "failed" ? "failed" : "completed", - ...(event.toolCall.detail ? { summary: event.toolCall.detail } : {}), - }, - raw: { - source: "acp.jsonrpc", + if (!ctx.completedSubagentTaskIds.has(toolCallId)) { + ctx.completedSubagentTaskIds.add(toolCallId); + const taskStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "task.completed", + ...taskStamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + payload: { + taskId, + status: event.toolCall.status === "failed" ? "failed" : "completed", + ...(event.toolCall.detail ? { summary: event.toolCall.detail } : {}), + }, + raw: { + source: "acp.jsonrpc", + method: "session/update", + payload: event.rawPayload, + }, + }); + } + } + } + if (isGrokEnterPlanModeToolCall(event.toolCall)) { + ctx.planModeActive = true; + } + // Only promote session plan.md writes while plan mode is + // active — avoids treating unrelated plan files as proposals. + // Fresh stamp: must not share eventId with the tool lifecycle event. + if (ctx.planModeActive) { + const planMarkdown = extractGrokPlanMarkdownFromToolCallData( + event.toolCall.data, + ); + if (planMarkdown) { + yield* emitProposedPlanCompleted( + ctx, + notificationTurnId, + yield* makeEventStamp(), + planMarkdown, + { method: "session/update", payload: event.rawPayload, }, - }); + ); } } return; @@ -1340,9 +1540,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const maxTokens = event.usage.size > 0 ? event.usage.size - : ctx.contextWindowTokens !== undefined && ctx.contextWindowTokens > 0 - ? ctx.contextWindowTokens - : undefined; + : resolveActiveContextWindowTokens(ctx); yield* offerRuntimeEvent( makeAcpTokenUsageEvent({ stamp, @@ -1398,49 +1596,8 @@ 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 } : {}), - }); - } - + // Effort is applied in-session via session/set_model _meta.reasoningEffort + // (Ahmed #5403). CLI --reasoning-effort is only used on initial spawn. const prepared = yield* withThreadLock( input.threadId, Effect.gen(function* () { @@ -1457,6 +1614,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte // Bind the turn id before cooperative yields so interruptTurn can // settle this prompt even if stop arrives during preparation. ctx.activeTurnId = turnId; + // New turn: do not fall back to a previous turn's plan.md body when + // exit_plan_mode omits planContent. + if (steeringTurnId === undefined) { + clearProposedPlanFallback(ctx); + } ctx.session = { ...ctx.session, status: steeringTurnId === undefined ? "connecting" : "running", @@ -1472,16 +1634,20 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const requestedTurnModelId = turnModelSelection?.model ? resolveGrokAcpBaseModelId(turnModelSelection.model) : undefined; + const appliedEffort = resolveGrokReasoningEffortSelection( + turnModelSelection?.options, + ); const currentModelId = yield* applyGrokAcpModelSelection({ runtime: ctx.acp, currentModelId: ctx.currentModelId, requestedModelId: requestedTurnModelId, + selections: turnModelSelection?.options, 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. + // primary effort contract is set_model _meta above. yield* applyGrokAcpConfigSelections({ runtime: ctx.acp, selections: turnModelSelection?.options, @@ -1493,6 +1659,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte cause, ), }); + if (appliedEffort !== undefined) { + ctx.processReasoningEffort = appliedEffort; + } const text = applyGrokPlanModeToPromptText({ text: input.input?.trim(), @@ -1545,6 +1714,12 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } ctx.currentModelId = currentModelId; + if (currentModelId) { + const windowForModel = ctx.modelContextWindows.get(currentModelId); + if (windowForModel !== undefined) { + ctx.contextWindowTokens = windowForModel; + } + } const displayModel = currentModelId ? resolveGrokAcpBaseModelId(currentModelId) : undefined; @@ -1722,6 +1897,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const completedAt = yield* nowIso; const { activeTurnId: _completedTurnId, ...readySession } = ctx.session; ctx.activeTurnId = undefined; + clearProposedPlanFallback(ctx); ctx.session = { ...readySession, status: "ready", diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index cda3181576c..0e67cd8eaf5 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -10,6 +10,8 @@ import { buildInitialGrokProviderSnapshot, capabilitiesFromGrokModelMeta, checkGrokProviderStatus, + ensureGrokStaticSlashCommands, + GROK_STATIC_SLASH_COMMANDS, mapAcpCommandsToCatalog, } from "./GrokProvider.ts"; @@ -38,6 +40,7 @@ describe("buildInitialGrokProviderSnapshot", () => { expect(snapshot.message).toContain("Checking Grok"); expect(snapshot.requiresNewThreadForModelChange).toBe(false); expect(snapshot.showInteractionModeToggle).toBe(true); + expect(snapshot.slashCommands).toEqual(GROK_STATIC_SLASH_COMMANDS); }), ); }); @@ -145,4 +148,19 @@ describe("Grok capability and command helpers", () => { expect(catalog.slashCommands).toHaveLength(2); expect(catalog.skills).toEqual([expect.objectContaining({ name: "review", enabled: true })]); }); + + it("re-adds compact when live catalog omits it", () => { + expect(ensureGrokStaticSlashCommands([])).toEqual(GROK_STATIC_SLASH_COMMANDS); + expect( + ensureGrokStaticSlashCommands([{ name: "review", description: "Review" }]).map( + (command) => command.name, + ), + ).toEqual(["review", "compact"]); + expect( + ensureGrokStaticSlashCommands([ + { name: "compact", description: "Live compact" }, + { name: "review" }, + ]).map((command) => command.name), + ).toEqual(["compact", "review"]); + }); }); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index a300afc8cbb..77e5ce92e1f 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -44,6 +44,27 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], }); +/** Grok ACP handles `/compact` as prompt text; surface it in the composer slash menu. */ +export const GROK_STATIC_SLASH_COMMANDS: ReadonlyArray = [ + { + name: "compact", + description: "Compress conversation history to reclaim context window", + input: { hint: "optional context about what to preserve" }, + }, +]; + +/** Ensure static commands (e.g. compact) remain present after live catalog merges. */ +export function ensureGrokStaticSlashCommands( + commands: ReadonlyArray | undefined, +): ReadonlyArray { + const existing = commands ?? []; + const names = new Set(existing.map((command) => command.name.trim().toLowerCase())); + const missing = GROK_STATIC_SLASH_COMMANDS.filter( + (command) => !names.has(command.name.trim().toLowerCase()), + ); + return missing.length === 0 ? existing : [...existing, ...missing]; +} + function reasoningEffortLabels(value: string): string { const normalized = value.trim().toLowerCase(); const labels: Record = { @@ -154,6 +175,7 @@ export function buildInitialGrokProviderSnapshot( enabled: false, checkedAt, models, + slashCommands: GROK_STATIC_SLASH_COMMANDS, probe: { installed: false, version: null, @@ -169,6 +191,7 @@ export function buildInitialGrokProviderSnapshot( enabled: true, checkedAt, models, + slashCommands: GROK_STATIC_SLASH_COMMANDS, probe: { installed: true, version: null, @@ -282,29 +305,38 @@ const discoverGrokModelsViaAcp = ( const modelsFromSession = buildGrokDiscoveredModelsFromSessionModelState( started.sessionSetupResult.models, ); - const modelsFromInitialize = buildGrokDiscoveredModelsFromSessionModelState( - initializeMeta?.modelState as EffectAcpSchema.SessionModelState | undefined, - ); + const rawInitializeModelState = initializeMeta?.modelState; + const initializeModelState = + rawInitializeModelState !== null && + typeof rawInitializeModelState === "object" && + !Array.isArray(rawInitializeModelState) && + Array.isArray((rawInitializeModelState as { availableModels?: unknown }).availableModels) + ? (rawInitializeModelState as EffectAcpSchema.SessionModelState) + : undefined; + const modelsFromInitialize = + buildGrokDiscoveredModelsFromSessionModelState(initializeModelState); const models = modelsFromSession.length > 0 ? modelsFromSession : modelsFromInitialize; const initializeCommands = Array.isArray(initializeMeta?.availableCommands) - ? ( - initializeMeta.availableCommands as ReadonlyArray<{ + ? (initializeMeta.availableCommands as ReadonlyArray).flatMap((command) => { + if (command === null || typeof command !== "object") { + return []; + } + const entry = command as { readonly name?: unknown; readonly description?: unknown; readonly input?: unknown; - }> - ).flatMap((command) => { - const name = typeof command.name === "string" ? command.name.trim() : ""; + }; + const name = typeof entry.name === "string" ? entry.name.trim() : ""; if (!name) return []; const description = - typeof command.description === "string" ? command.description.trim() : undefined; + typeof entry.description === "string" ? entry.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() + entry.input && + typeof entry.input === "object" && + entry.input !== null && + "hint" in entry.input && + typeof (entry.input as { hint: unknown }).hint === "string" + ? (entry.input as { hint: string }).hint.trim() : undefined; return [ { @@ -334,7 +366,7 @@ const discoverGrokModelsViaAcp = ( : undefined; return { models, - slashCommands: catalog.slashCommands, + slashCommands: ensureGrokStaticSlashCommands(catalog.slashCommands), skills: catalog.skills, ...(authEmail ? { authEmail } : {}), ...(authLabel ? { authLabel } : {}), @@ -376,6 +408,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: false, checkedAt, models: fallbackModels, + slashCommands: GROK_STATIC_SLASH_COMMANDS, probe: { installed: false, version: null, @@ -401,6 +434,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models: fallbackModels, + slashCommands: GROK_STATIC_SLASH_COMMANDS, probe: { installed: !isCommandMissingCause(error), version: null, @@ -419,6 +453,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models: fallbackModels, + slashCommands: GROK_STATIC_SLASH_COMMANDS, probe: { installed: true, version: null, @@ -442,6 +477,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models: fallbackModels, + slashCommands: GROK_STATIC_SLASH_COMMANDS, probe: { installed: true, version, @@ -469,6 +505,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models: fallbackModels, + slashCommands: GROK_STATIC_SLASH_COMMANDS, probe: { installed: true, version, @@ -489,6 +526,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models: fallbackModels, + slashCommands: GROK_STATIC_SLASH_COMMANDS, probe: { installed: true, version, diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 739f68091e2..9fb7f58bb13 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -224,10 +224,14 @@ export class AcpSessionRuntime extends Context.Service< readonly setModel: (model: string) => Effect.Effect; /** * Selects the active model through the unstable ACP `session/set_model` capability. + * Optional `_meta` is passed through for agent extensions (e.g. Grok `reasoningEffort`). * @see https://agentclientprotocol.com/protocol/schema#session/set_model */ readonly setSessionModel: ( modelId: string, + options?: { + readonly _meta?: { readonly [x: string]: unknown } | null; + }, ) => Effect.Effect; /** * Sends a generic ACP extension request and records it through the request logger. @@ -792,12 +796,13 @@ export const make = ( Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), Effect.asVoid, ), - setSessionModel: (modelId) => + setSessionModel: (modelId, options) => getStartedState.pipe( Effect.flatMap((started) => { const requestPayload = { sessionId: started.sessionId, modelId, + ...(options?._meta !== undefined ? { _meta: options._meta } : {}), } satisfies EffectAcpSchema.SetSessionModelRequest; return runLoggedRequest( "session/set_model", diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index 43285050bb4..b677bf15399 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -70,11 +70,17 @@ describe("resolveGrokReasoningEffortSelection", () => { describe("applyGrokAcpModelSelection", () => { const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => { - const modelCalls: Array = []; + const modelCalls: Array<{ + modelId: string; + options?: { readonly _meta?: { readonly [x: string]: unknown } | null }; + }> = []; const runtime = { - setSessionModel: (modelId: string) => + setSessionModel: ( + modelId: string, + options?: { readonly _meta?: { readonly [x: string]: unknown } | null }, + ) => Effect.gen(function* () { - modelCalls.push(modelId); + modelCalls.push({ modelId, ...(options ? { options } : {}) }); if (failure) return yield* failure; return {}; }), @@ -91,12 +97,12 @@ describe("applyGrokAcpModelSelection", () => { requestedModelId: "grok-mock-alt", mapError: (cause) => cause.message, }); - expect(modelCalls).toEqual(["grok-mock-alt"]); + expect(modelCalls).toEqual([{ modelId: "grok-mock-alt" }]); expect(result).toBe("grok-mock-alt"); }), ); - it.effect("skips set_model when requested matches current", () => + it.effect("skips set_model when requested matches current and no effort", () => Effect.gen(function* () { const { runtime, modelCalls } = makeRecordingRuntime(); const result = yield* applyGrokAcpModelSelection({ @@ -110,7 +116,7 @@ describe("applyGrokAcpModelSelection", () => { }), ); - it.effect("skips set_model when no model is requested", () => + it.effect("skips set_model when no model is requested and no effort", () => Effect.gen(function* () { const { runtime, modelCalls } = makeRecordingRuntime(); const result = yield* applyGrokAcpModelSelection({ @@ -124,6 +130,46 @@ describe("applyGrokAcpModelSelection", () => { }), ); + it.effect("calls set_model with reasoningEffort _meta for effort-only change", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-build", + requestedModelId: undefined, + selections: [{ id: "reasoningEffort", value: "high" }], + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([ + { + modelId: "grok-build", + options: { _meta: { reasoningEffort: "high" } }, + }, + ]); + expect(result).toBe("grok-build"); + }), + ); + + it.effect("calls set_model with model switch and effort _meta together", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-build", + requestedModelId: "grok-mock-alt", + selections: [{ id: "reasoningEffort", value: "low" }], + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([ + { + modelId: "grok-mock-alt", + options: { _meta: { reasoningEffort: "low" } }, + }, + ]); + expect(result).toBe("grok-mock-alt"); + }), + ); + it.effect("propagates session/set_model failures via mapError", () => Effect.gen(function* () { const failure = EffectAcpErrors.AcpRequestError.invalidParams("session id not known"); @@ -226,11 +272,21 @@ describe("applyGrokPlanModeToPromptText", () => { ); }); - it("returns undefined/empty for blank plan prompts", () => { + it("returns /plan for blank plan prompts", () => { expect(applyGrokPlanModeToPromptText({ text: undefined, interactionMode: "plan" })).toBe( + "/plan", + ); + expect(applyGrokPlanModeToPromptText({ text: " ", interactionMode: "plan" })).toBe("/plan"); + }); + + it("returns undefined/empty for blank non-plan prompts", () => { + expect(applyGrokPlanModeToPromptText({ text: undefined, interactionMode: "default" })).toBe( + undefined, + ); + expect(applyGrokPlanModeToPromptText({ text: " ", interactionMode: "default" })).toBe(""); + expect(applyGrokPlanModeToPromptText({ text: undefined, interactionMode: undefined })).toBe( undefined, ); - expect(applyGrokPlanModeToPromptText({ text: " ", interactionMode: "plan" })).toBe(""); }); }); @@ -244,7 +300,7 @@ describe("isGrokSubagentToolCall", () => { ).toBe(true); }); - it("matches titles that mention subagent", () => { + it("matches spawn-style titles after normalize", () => { expect( isGrokSubagentToolCall({ toolCallId: "tc_2", @@ -254,6 +310,18 @@ describe("isGrokSubagentToolCall", () => { ).toBe(true); }); + it("does not match ordinary tools whose detail mentions subagent", () => { + expect( + isGrokSubagentToolCall({ + toolCallId: "tc_detail", + title: "Read file", + kind: "read", + detail: "notes about a subagent workflow", + data: { name: "read_file" }, + }), + ).toBe(false); + }); + it("does not match ordinary tools", () => { expect( isGrokSubagentToolCall({ diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index ed1f0f2597f..dd009936439 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -16,6 +16,9 @@ import { normalizeModelSlug } from "@t3tools/shared/model"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; import { makeXAiPromptCompletionRuntime } from "./XAiAcpExtension.ts"; +/** Option id for Grok reasoning effort — matches ACP `session/set_model` `_meta.reasoningEffort`. */ +export const GROK_REASONING_EFFORT_OPTION_ID = "reasoningEffort"; + const GROK_API_KEY_ENV = "XAI_API_KEY"; const GROK_OAUTH2_REFERRER_ENV = "GROK_OAUTH2_REFERRER"; const T3_CODE_OAUTH_REFERRER = "t3code"; @@ -76,7 +79,7 @@ export function resolveGrokReasoningEffortSelection( selections: ReadonlyArray | null | undefined, ): string | undefined { return ( - getProviderOptionStringSelectionValue(selections, "reasoningEffort") ?? + getProviderOptionStringSelectionValue(selections, GROK_REASONING_EFFORT_OPTION_ID) ?? getProviderOptionStringSelectionValue(selections, "reasoning") ?? getProviderOptionStringSelectionValue(selections, "effort") ); @@ -93,7 +96,8 @@ export function applyGrokPlanModeToPromptText(input: { }): string | undefined { const trimmed = input.text?.trim(); if (!trimmed) { - return trimmed; + // Plan mode still needs the slash command so Grok enters plan mode. + return input.interactionMode === "plan" ? "/plan" : trimmed; } if (input.interactionMode === "plan") { if (/^\/plan(?:\s|$)/i.test(trimmed)) { @@ -104,9 +108,22 @@ export function applyGrokPlanModeToPromptText(input: { return trimmed; } +function normalizeGrokToolToken(value: string): string { + return value.toLowerCase().replace(/[\s-]+/g, "_"); +} + +function isGrokSpawnSubagentToken(normalized: string): boolean { + return ( + normalized === "spawn_subagent" || + normalized === "spawn_agent" || + normalized.startsWith("spawn_subagent") + ); +} + /** * Detect Grok in-process subagent tools (spawn_subagent and relatives) so the * adapter can emit T3 task.* events for multi-agent visibility. + * Matches only spawn-like tokens on name/toolName/title/kind — not detail/id. */ export function isGrokSubagentToolCall(toolCall: { readonly toolCallId: string; @@ -115,20 +132,17 @@ export function isGrokSubagentToolCall(toolCall: { readonly detail?: string; readonly data: Record; }): boolean { - const haystack = [ - toolCall.toolCallId, - toolCall.title ?? "", - toolCall.kind ?? "", - toolCall.detail ?? "", - typeof toolCall.data.name === "string" ? toolCall.data.name : "", - typeof toolCall.data.toolName === "string" ? toolCall.data.toolName : "", - ] - .join(" ") - .toLowerCase(); - return ( - haystack.includes("spawn_subagent") || - haystack.includes("subagent") || - haystack.includes("spawn_agent") + const candidates = [ + toolCall.title, + toolCall.kind, + typeof toolCall.data.name === "string" ? toolCall.data.name : undefined, + typeof toolCall.data.toolName === "string" ? toolCall.data.toolName : undefined, + ]; + return candidates.some( + (candidate) => + typeof candidate === "string" && + candidate.length > 0 && + isGrokSpawnSubagentToken(normalizeGrokToolToken(candidate)), ); } @@ -183,20 +197,37 @@ export function currentGrokModelIdFromSessionSetup( return sessionSetupResult.models?.currentModelId?.trim() || undefined; } +/** + * Apply model and/or reasoning effort via Grok ACP `session/set_model`. + * Effort is sent as `_meta.reasoningEffort` (Grok private extension). + * Calls set_model when the model changes or when an effort selection is present + * (effort can change without a model id change). Mid-thread effort no longer + * requires a process restart. + */ export function applyGrokAcpModelSelection(input: { readonly runtime: Pick; readonly currentModelId: string | undefined; readonly requestedModelId: string | undefined; + readonly selections?: ReadonlyArray | null; readonly mapError: (cause: EffectAcpErrors.AcpError) => E; }): Effect.Effect { + const targetModelId = input.requestedModelId ?? input.currentModelId; + const reasoningEffort = resolveGrokReasoningEffortSelection(input.selections); const shouldSwitchModel = input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; - if (!shouldSwitchModel) { + const shouldApplyEffort = reasoningEffort !== undefined; + + if (!targetModelId || (!shouldSwitchModel && !shouldApplyEffort)) { return Effect.succeed(input.currentModelId); } + + const setOptions = shouldApplyEffort + ? { _meta: { reasoningEffort } satisfies { readonly [x: string]: unknown } } + : undefined; + return input.runtime - .setSessionModel(input.requestedModelId) - .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId)); + .setSessionModel(targetModelId, setOptions) + .pipe(Effect.mapError(input.mapError), Effect.as(targetModelId)); } function isEffortConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { @@ -216,8 +247,8 @@ function isEffortConfigOption(option: EffectAcpSchema.SessionConfigOption): bool /** * 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. + * Live Grok 0.2.x has no session/set_config_option; primary effort path is + * `session/set_model` `_meta.reasoningEffort` (and CLI flag on initial spawn). */ export function applyGrokAcpConfigSelections(input: { readonly runtime: Pick< diff --git a/apps/server/src/provider/acp/XAiAcpExtension.test.ts b/apps/server/src/provider/acp/XAiAcpExtension.test.ts index c435269fd76..f8ee9c89def 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.test.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.test.ts @@ -9,11 +9,18 @@ import * as Schema from "effect/Schema"; import { describe, expect } from "vite-plus/test"; import { + extractGrokPlanMarkdownFromToolCallData, extractXAiAskUserQuestions, + extractXAiAutoCompactCompleted, + extractXAiExitPlanMarkdown, + isGrokPlanMarkdownPath, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, + makeXAiExitPlanModeCapturedResponse, makeXAiPromptCompletionRuntime, + XAI_EMPTY_PLAN_MARKDOWN, XAiAskUserQuestionRequest, + XAiSessionNotification, } from "./XAiAcpExtension.ts"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; @@ -239,6 +246,109 @@ describe("XAiAcpExtension", () => { }); }); + it("extracts exit_plan markdown with fallback and empty placeholder", () => { + const direct = { + sessionId: "session-1", + toolCallId: "tool-1", + planContent: " # Plan\n\n- do the thing ", + }; + expect(extractXAiExitPlanMarkdown(direct)).toBe("# Plan\n\n- do the thing"); + + const wrapped = { + method: "_x.ai/exit_plan_mode" as const, + params: { + sessionId: "session-1", + toolCallId: "tool-1", + planContent: null, + }, + }; + expect(extractXAiExitPlanMarkdown(wrapped, " # fallback plan ")).toBe("# fallback plan"); + expect(extractXAiExitPlanMarkdown(wrapped)).toBe(XAI_EMPTY_PLAN_MARKDOWN); + }); + + it("returns captured abandoned response for exit_plan_mode gate", () => { + expect(makeXAiExitPlanModeCapturedResponse()).toEqual({ + outcome: "abandoned", + feedback: + "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.", + }); + }); + + it("matches only Grok session plan.md paths", () => { + expect(isGrokPlanMarkdownPath("/home/x/.grok/sessions/abc/plan.md")).toBe(true); + expect( + isGrokPlanMarkdownPath( + "/Users/me/.grok/sessions/%2FUsers%2Fme%2Fproj/sess-123/plan.md", + ), + ).toBe(true); + expect(isGrokPlanMarkdownPath("plan.md")).toBe(false); + expect(isGrokPlanMarkdownPath("/repo/docs/plan.md")).toBe(false); + expect(isGrokPlanMarkdownPath("/tmp/other.md")).toBe(false); + }); + + it("extracts plan markdown from tool call rawInput and diff content", () => { + expect( + extractGrokPlanMarkdownFromToolCallData({ + rawInput: { + path: "/home/x/.grok/sessions/s1/plan.md", + content: "# Mid-plan draft\n", + }, + }), + ).toBe("# Mid-plan draft"); + + expect( + extractGrokPlanMarkdownFromToolCallData({ + content: [ + { + type: "diff", + path: "/home/x/.grok/sessions/s1/plan.md", + newText: "# Diff plan\n", + }, + ], + }), + ).toBe("# Diff plan"); + + expect( + extractGrokPlanMarkdownFromToolCallData({ + rawInput: { path: "/repo/docs/plan.md", content: "# Workspace plan" }, + }), + ).toBeUndefined(); + + expect( + extractGrokPlanMarkdownFromToolCallData({ + rawInput: { path: "/home/x/.grok/sessions/s1/plan.md", content: " " }, + }), + ).toBeUndefined(); + }); + + it("extracts auto_compact_completed session notifications", () => { + const notification = Schema.decodeUnknownSync(XAiSessionNotification)({ + sessionId: "session-1", + update: { + sessionUpdate: "auto_compact_completed", + tokens_before: 12_000, + tokens_after: 4_000, + summary_preview: "kept auth", + }, + }); + const compact = extractXAiAutoCompactCompleted(notification); + expect(compact).toEqual({ + sessionId: "session-1", + tokensBefore: 12_000, + tokensAfter: 4_000, + summaryPreview: "kept auth", + raw: notification, + }); + }); + + it("ignores non-compact session notifications", () => { + const notification = Schema.decodeUnknownSync(XAiSessionNotification)({ + sessionId: "session-1", + update: { sessionUpdate: "something_else" }, + }); + expect(extractXAiAutoCompactCompleted(notification)).toBeNull(); + }); + it("does not echo preview annotations for multi-select answers", () => { const response = makeXAiAskUserQuestionResponse( { diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts index 7bb9d1fceea..f43f44a442f 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -227,10 +227,155 @@ export function makeXAiExitPlanModeReviseResponse(feedback: string): { readonly outcome: "rejected"; readonly feedback: string; } { - const trimmed = feedback.trim(); + const feedbackText = feedback.trim(); return { outcome: "rejected", - feedback: trimmed.length > 0 ? trimmed : "Please revise the plan.", + feedback: feedbackText.length > 0 ? feedbackText : "Please revise the plan.", + }; +} + +export const XAI_EMPTY_PLAN_MARKDOWN = + "# No plan written yet\n\n(The agent exited plan mode without writing a plan.)"; + +export function extractXAiExitPlanMarkdown( + params: XAiExitPlanModeRequest, + fallback?: string | null, +): string { + const content = unwrapExitPlanModeParams(params).planContent; + const fromRequest = typeof content === "string" ? trimmed(content) : undefined; + if (fromRequest) { + return fromRequest; + } + const fromFallback = fallback?.trim(); + if (fromFallback && fromFallback.length > 0) { + return fromFallback; + } + return XAI_EMPTY_PLAN_MARKDOWN; +} + +export type XAiExitPlanModeOutcome = "approved" | "abandoned" | "request_changes" | "rejected"; + +export interface XAiExitPlanModeResponse { + readonly outcome: XAiExitPlanModeOutcome; + readonly feedback?: string; +} + +/** + * Client captured the plan for T3's proposed-plan card. Abandon the native + * Grok plan-approval gate so the turn unblocks; the user implements via T3 UI. + */ +export function makeXAiExitPlanModeCapturedResponse(feedback?: string): XAiExitPlanModeResponse { + return { + outcome: "abandoned", + feedback: + feedback ?? + "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.", + }; +} + +/** + * True when a path is Grok's session plan file under `~/.grok/sessions/.../plan.md`. + * Deliberately does not match workspace files named `plan.md` (e.g. docs/plan.md). + */ +export function isGrokPlanMarkdownPath(path: string | undefined | null): boolean { + if (typeof path !== "string") { + return false; + } + const normalized = path.trim().replace(/\\/g, "/"); + if (normalized.length === 0 || !normalized.endsWith("/plan.md")) { + return false; + } + // Session layout: ~/.grok/sessions///plan.md + return normalized.includes("/.grok/sessions/"); +} + +/** + * Extract plan markdown from a Grok write/edit tool call targeting plan.md. + * Used so T3 can show the plan while plan mode is still active (before exit). + */ +export function extractGrokPlanMarkdownFromToolCallData( + data: Record | undefined, +): string | undefined { + if (!data) { + return undefined; + } + + const rawInput = data.rawInput; + if (isRecord(rawInput)) { + const filePath = + (typeof rawInput.file_path === "string" ? rawInput.file_path : undefined) ?? + (typeof rawInput.path === "string" ? rawInput.path : undefined); + const content = typeof rawInput.content === "string" ? rawInput.content : undefined; + if (isGrokPlanMarkdownPath(filePath) && content && content.trim().length > 0) { + return content.trim(); + } + } + + const content = data.content; + if (Array.isArray(content)) { + for (const block of content) { + if (!isRecord(block) || block.type !== "diff") { + continue; + } + const path = typeof block.path === "string" ? block.path : undefined; + const newText = typeof block.newText === "string" ? block.newText : undefined; + if (isGrokPlanMarkdownPath(path) && newText && newText.trim().length > 0) { + return newText.trim(); + } + } + } + + return undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Manual `/compact` and auto-compact complete as `auto_compact_completed`. + */ +const XAiSessionNotificationUpdate = Schema.Struct({ + sessionUpdate: Schema.String, + tokens_before: Schema.optional(Schema.Number), + tokens_after: Schema.optional(Schema.Number), + summary_preview: Schema.optional(Schema.NullOr(Schema.String)), +}); + +export const XAiSessionNotification = Schema.Struct({ + sessionId: Schema.String, + update: XAiSessionNotificationUpdate, + _meta: Schema.optional(Schema.Unknown), +}); + +export type XAiSessionNotification = typeof XAiSessionNotification.Type; + +export interface XAiAutoCompactCompleted { + readonly sessionId: string; + readonly tokensBefore: number | undefined; + readonly tokensAfter: number | undefined; + readonly summaryPreview: string | undefined; + readonly raw: XAiSessionNotification; +} + +function finiteNonNegative(value: number | undefined): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; +} + +/** Returns compact details when the notification is a completed compaction; otherwise null. */ +export function extractXAiAutoCompactCompleted( + notification: XAiSessionNotification, +): XAiAutoCompactCompleted | null { + if (notification.update.sessionUpdate !== "auto_compact_completed") { + return null; + } + const summaryPreview = trimmed(notification.update.summary_preview ?? undefined); + return { + sessionId: notification.sessionId, + tokensBefore: finiteNonNegative(notification.update.tokens_before), + tokensAfter: finiteNonNegative(notification.update.tokens_after), + summaryPreview, + raw: notification, }; } diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index 1cf3d13e225..43a4538c6d6 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -87,6 +87,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi runtime, currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult), requestedModelId: resolvedModel, + selections: modelSelection.options, mapError: (cause) => new TextGenerationError({ operation,