diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index bc7828dd854..d7dcb0ada9e 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 = { @@ -68,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; @@ -79,6 +91,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 +316,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 } : {}), }; }), ); @@ -465,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", @@ -865,6 +920,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 +938,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/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 112f1101316..d4c985ecfc4 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,8 @@ import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus, enrichGrokSnapshot, + ensureGrokStaticSlashCommands, + mapAcpCommandsToCatalog, } from "../Layers/GrokProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; @@ -106,27 +111,75 @@ export const GrokDriver: ProviderDriver = { env: processEnv, }); + const commandCatalogRef = yield* Ref.make({ + received: false, + 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) => { + // 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, { environment: processEnv, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), instanceId, + onAvailableCommands: (commands) => + Effect.gen(function* () { + const catalog = mapAcpCommandsToCatalog(commands); + yield* Ref.set(commandCatalogRef, { + received: true, + slashCommands: ensureGrokStaticSlashCommands(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 +201,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/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/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 7b6f0972ae8..e9e1cd47ab9 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,222 @@ 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( + "applies mid-thread effort via session/set_model _meta without process restart", + () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-effort-set-model-meta"); + 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, + }, + { argvLogPath }, + ), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const turnCompleted = yield* Deferred.make(); + 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({ + 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); + + // 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.equal(argvLines.length, 1); + assert.include(argvLines[0] ?? "", "--reasoning-effort high"); + + 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("maps /compact session notifications to thread.state.changed compacted", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-compact-thread"); + const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + + 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({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId, + input: "/compact keep the auth details", + attachments: [], + }); + + yield* Deferred.await(compacted).pipe(Effect.timeout("3 seconds")); + yield* Deferred.await(turnCompleted).pipe(Effect.timeout("3 seconds")); + + const compactEvent = runtimeEvents.find( + (event) => + event.type === "thread.state.changed" && + (event.payload as { state?: string } | undefined)?.state === "compacted", + ); + 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* 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 977cc8caadd..a9f8421384e 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,37 @@ 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, + applyGrokPlanModeToPromptText, + isGrokSubagentToolCall, + resolveGrokReasoningEffortSelection, + type GrokAcpSpawnOptions, } from "../acp/GrokAcpSupport.ts"; import { + extractGrokPlanMarkdownFromToolCallData, extractXAiAskUserQuestions, + extractXAiAutoCompactCompleted, + extractXAiExitPlanMarkdown, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, + makeXAiExitPlanModeCapturedResponse, promptResponseHasMissingXAiStopReason, XAiAskUserQuestionRequest, + XAiExitPlanModeRequest, + XAiSessionNotification, } from "../acp/XAiAcpExtension.ts"; import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -84,6 +97,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 +134,30 @@ interface GrokSessionContext { * continues it, and only the last remaining prompt settles the turn. */ promptsInFlight: number; currentModelId: string | undefined; + /** + * 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 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( @@ -179,6 +219,245 @@ 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; + 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, + input.preferredModelId ?? input.sessionModels?.currentModelId, + ); + 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( + availableModels, + 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 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, + { overwrite }: { overwrite: boolean }, + ) => { + if (!models) return; + for (const model of models) { + const tokens = totalContextTokensFromMeta(model._meta); + if (tokens === undefined || !model.modelId.trim()) continue; + const id = model.modelId.trim(); + // Session models are ingested first and must win over initializeMeta. + if (overwrite || !windows.has(id)) { + windows.set(id, tokens); + } + } + }; + ingest(input.sessionModels?.availableModels, { overwrite: true }); + 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 }]; + }); + // Only fill gaps; never overwrite live session model windows. + ingest(availableModels, { overwrite: false }); + } + 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)); +} + +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)); +} + +/** + * Drop turn-scoped plan.md fallback content only. + * + * Do not clear `planModeActive` here — that flag is session-scoped and must + * survive turn settlement / the start of the next sendTurn so Build can emit + * `/default` after a Plan turn. Plan mode is cleared only on exit_plan_mode, + * an explicit Build send, or session recreate. + */ +function clearProposedPlanFallback(ctx: GrokSessionContext): void { + ctx.lastKnownProposedPlanMarkdown = undefined; + ctx.lastKnownProposedPlanTurnId = undefined; +} + +/** 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, @@ -273,6 +552,56 @@ 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 => { + if (ctx.promptUsageOfferedTurnIds.has(turnId)) { + return Effect.void; + } + const promptUsage = tokenUsageFromGrokPromptMeta(meta); + if (!promptUsage) { + return Effect.void; + } + ctx.promptUsageOfferedTurnIds.add(turnId); + const maxTokens = resolveActiveContextWindowTokens(ctx); + 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( @@ -294,6 +623,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, @@ -397,6 +739,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", @@ -495,6 +840,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 => { @@ -570,11 +952,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 +1055,100 @@ 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) => + acp.handleExtRequest(method, XAiExitPlanModeRequest, (params) => + mapAcpCallbackFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, method, params); + const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); + 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()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { planMarkdown }, + raw: { + source: "acp.grok.extension", + method, + payload: params, + }, + }); + } + 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 } + : {}), + }, + }, + raw: { + source: "acp.grok.extension", + method, + payload: notification, + }, + }); + }), + ), + ), + { discard: true }, + ); yield* acp.handleRequestPermission((params) => mapAcpCallbackFailure( Effect.gen(function* () { @@ -742,6 +1228,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), }); @@ -763,6 +1250,40 @@ 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 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, + sessionModels: started.sessionSetupResult.models, + initializeMeta, + preferredModelId: boundModelId, + }); + 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 +1299,19 @@ 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, + modelContextWindows, + lastKnownProposedPlanMarkdown: undefined, + lastKnownProposedPlanTurnId: undefined, + planModeActive: false, + startedSubagentTaskIds: new Set(), + completedSubagentTaskIds: new Set(), + promptUsageOfferedTurnIds: new Set(), }; const nf = yield* Stream.runDrain( @@ -791,7 +1324,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 +1338,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 +1439,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte "session/update", ); return; - case "ToolCallUpdated": + case "ToolCallUpdated": { yield* offerRuntimeEvent( makeAcpToolCallEvent({ stamp, @@ -855,7 +1450,92 @@ 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 toolCallId = event.toolCall.toolCallId; + const taskId = RuntimeTaskId.make(toolCallId); + const description = + event.toolCall.title?.trim() || + event.toolCall.detail?.trim() || + "Grok subagent"; + if ( + event.toolCall.status === "pending" || + event.toolCall.status === "inProgress" + ) { + 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" + ) { + 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; + } case "ContentDelta": yield* offerRuntimeEvent( makeAcpContentDeltaEvent({ @@ -865,10 +1545,29 @@ 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 + : resolveActiveContextWindowTokens(ctx); + yield* offerRuntimeEvent( + makeAcpTokenUsageEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + usedTokens: event.usage.used, + ...(maxTokens !== undefined ? { maxTokens } : {}), + rawPayload: event.rawPayload, + }), + ); + return; + } } }), ), @@ -911,6 +1610,8 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const sendTurn: GrokAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { + // 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* () { @@ -927,6 +1628,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", @@ -942,15 +1648,49 @@ 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, + currentEffort: ctx.processReasoningEffort, 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); + // primary effort contract is set_model _meta above. + yield* applyGrokAcpConfigSelections({ + runtime: ctx.acp, + selections: turnModelSelection?.options, + mapError: (cause) => + mapAcpToAdapterError( + PROVIDER, + input.threadId, + "session/set_config_option", + cause, + ), + }); + // Only sticky-update effort when set_model had a model target + // (effort-only with no model id is a no-op and must not claim apply). + if (appliedEffort !== undefined && currentModelId !== undefined) { + ctx.processReasoningEffort = appliedEffort; + } - const text = input.input?.trim(); + const text = applyGrokPlanModeToPromptText({ + text: input.input?.trim(), + interactionMode: input.interactionMode, + planModeActive: ctx.planModeActive, + }); + // Track local plan-mode state so Build can exit with /default. + if (input.interactionMode === "plan") { + ctx.planModeActive = true; + } else if (input.interactionMode === "default" && ctx.planModeActive) { + ctx.planModeActive = false; + } const imagePromptParts = yield* Effect.forEach( input.attachments ?? [], (attachment) => @@ -998,6 +1738,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; @@ -1096,6 +1842,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, @@ -1172,6 +1921,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", @@ -1179,6 +1929,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 +1957,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..0e67cd8eaf5 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -6,7 +6,14 @@ 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, + ensureGrokStaticSlashCommands, + GROK_STATIC_SLASH_COMMANDS, + mapAcpCommandsToCatalog, +} from "./GrokProvider.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); @@ -31,7 +38,9 @@ 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(true); + expect(snapshot.slashCommands).toEqual(GROK_STATIC_SLASH_COMMANDS); }), ); }); @@ -108,3 +117,50 @@ 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 })]); + }); + + 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 934eecdb5ae..77e5ce92e1f 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,121 @@ import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSup const GROK_PRESENTATION = { displayName: "Grok", - badgeLabel: "Early Access", - showInteractionModeToggle: false, - requiresNewThreadForModelChange: true, + // 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({ 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 = { + 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; @@ -66,6 +175,7 @@ export function buildInitialGrokProviderSnapshot( enabled: false, checkedAt, models, + slashCommands: GROK_STATIC_SLASH_COMMANDS, probe: { installed: false, version: null, @@ -81,6 +191,7 @@ export function buildInitialGrokProviderSnapshot( enabled: true, checkedAt, models, + slashCommands: GROK_STATIC_SLASH_COMMANDS, probe: { installed: true, version: null, @@ -113,16 +224,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 +295,82 @@ 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 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).flatMap((command) => { + if (command === null || typeof command !== "object") { + return []; + } + const entry = command as { + readonly name?: unknown; + readonly description?: unknown; + readonly input?: unknown; + }; + const name = typeof entry.name === "string" ? entry.name.trim() : ""; + if (!name) return []; + const description = + typeof entry.description === "string" ? entry.description.trim() : undefined; + const inputHint = + 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 [ + { + 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: ensureGrokStaticSlashCommands(catalog.slashCommands), + skills: catalog.skills, + ...(authEmail ? { authEmail } : {}), + ...(authLabel ? { authLabel } : {}), + } satisfies GrokAcpDiscoveryResult; }).pipe(Effect.scoped); const runGrokVersionCommand = ( @@ -175,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, @@ -200,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, @@ -218,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, @@ -241,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, @@ -256,20 +493,27 @@ 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, checkedAt, models: fallbackModels, + slashCommands: GROK_STATIC_SLASH_COMMANDS, probe: { 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.", }, }); } @@ -282,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, @@ -291,10 +536,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 +547,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/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..9fb7f58bb13 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< @@ -222,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. @@ -545,7 +551,7 @@ export const make = ( methodId: options.authMethodId, } satisfies EffectAcpSchema.AuthenticateRequest; - yield* runLoggedRequest( + const authenticateResult = yield* runLoggedRequest( "authenticate", authenticatePayload, acp.agent.authenticate(authenticatePayload), @@ -652,6 +658,7 @@ export const make = ( initializeResult, sessionSetupResult, modelConfigId: extractModelConfigId(sessionSetupResult), + authenticateResult, } satisfies AcpStartedState; return nextState; }); @@ -789,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/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..9a156aaae56 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -3,9 +3,13 @@ import * as Effect from "effect/Effect"; import * as EffectAcpErrors from "effect-acp/errors"; import { + applyGrokAcpConfigSelections, applyGrokAcpModelSelection, + applyGrokPlanModeToPromptText, buildGrokAcpSpawnInput, + isGrokSubagentToolCall, resolveGrokAcpBaseModelId, + resolveGrokReasoningEffortSelection, } from "./GrokAcpSupport.ts"; describe("resolveGrokAcpBaseModelId", () => { @@ -33,15 +37,50 @@ 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", () => { 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 {}; }), @@ -58,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({ @@ -77,13 +116,49 @@ 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({ + runtime, + currentModelId: "grok-build", + requestedModelId: undefined, + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([]); + expect(result).toBe("grok-build"); + }), + ); + + 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("skips set_model when effort matches currentEffort", () => Effect.gen(function* () { const { runtime, modelCalls } = makeRecordingRuntime(); const result = yield* applyGrokAcpModelSelection({ runtime, currentModelId: "grok-build", requestedModelId: undefined, + selections: [{ id: "reasoningEffort", value: "high" }], + currentEffort: "high", mapError: (cause) => cause.message, }); expect(modelCalls).toEqual([]); @@ -91,6 +166,62 @@ describe("applyGrokAcpModelSelection", () => { }), ); + it.effect("applies effort change when currentEffort differs", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-build", + requestedModelId: undefined, + selections: [{ id: "reasoningEffort", value: "low" }], + currentEffort: "high", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([ + { + modelId: "grok-build", + options: { _meta: { reasoningEffort: "low" } }, + }, + ]); + expect(result).toBe("grok-build"); + }), + ); + + it.effect("returns undefined without set_model when effort-only and no model id", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: undefined, + requestedModelId: undefined, + selections: [{ id: "reasoningEffort", value: "high" }], + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([]); + expect(result).toBeUndefined(); + }), + ); + + 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"); @@ -107,3 +238,180 @@ 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([]); + }), + ); +}); + +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 when not in plan mode", () => { + expect(applyGrokPlanModeToPromptText({ text: "hello", interactionMode: "default" })).toBe( + "hello", + ); + }); + + it("prefixes /default when leaving plan mode for Build", () => { + expect( + applyGrokPlanModeToPromptText({ + text: "implement it", + interactionMode: "default", + planModeActive: true, + }), + ).toBe("/default implement it"); + }); + + it("does not double-prefix /default", () => { + expect( + applyGrokPlanModeToPromptText({ + text: "/default already", + interactionMode: "default", + planModeActive: true, + }), + ).toBe("/default already"); + }); + + it("returns /default for blank Build prompts while plan mode is active", () => { + expect( + applyGrokPlanModeToPromptText({ + text: undefined, + interactionMode: "default", + planModeActive: true, + }), + ).toBe("/default"); + }); + + 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, + ); + }); +}); + +describe("isGrokSubagentToolCall", () => { + it("matches spawn_subagent by tool name", () => { + expect( + isGrokSubagentToolCall({ + toolCallId: "tc_1", + data: { name: "spawn_subagent" }, + }), + ).toBe(true); + }); + + it("matches spawn-style titles after normalize", () => { + expect( + isGrokSubagentToolCall({ + toolCallId: "tc_2", + title: "Spawn subagent: explore", + data: {}, + }), + ).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({ + 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 c928b3ed80e..2eb19dd85e7 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"; @@ -11,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"; @@ -20,6 +28,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 +42,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 +75,91 @@ export function buildGrokAcpSpawnInput( }; } +export function resolveGrokReasoningEffortSelection( + selections: ReadonlyArray | null | undefined, +): string | undefined { + return ( + getProviderOptionStringSelectionValue(selections, GROK_REASONING_EFFORT_OPTION_ID) ?? + getProviderOptionStringSelectionValue(selections, "reasoning") ?? + getProviderOptionStringSelectionValue(selections, "effort") + ); +} + +/** + * Grok has no ACP session modes on live 0.2.x. Plan mode is entered via the + * `/plan` slash command and left via `/default` (Build). Map T3 interactionMode + * onto those commands so Plan/Build in the composer does real work. + * + * When leaving plan mode (`interactionMode` default/build while `planModeActive`), + * prefix `/default` once so Grok exits plan mode for the subsequent Build turn. + */ +export function applyGrokPlanModeToPromptText(input: { + readonly text: string | undefined; + readonly interactionMode: "plan" | "default" | undefined; + /** True when the Grok session is currently in plan mode (enter_plan_mode or prior /plan). */ + readonly planModeActive?: boolean; +}): string | undefined { + const trimmed = input.text?.trim(); + if (input.interactionMode === "plan") { + if (!trimmed) { + return "/plan"; + } + if (/^\/plan(?:\s|$)/i.test(trimmed)) { + return trimmed; + } + return `/plan ${trimmed}`; + } + // Build (default): exit Grok plan mode when we still believe it is active. + if (input.planModeActive && input.interactionMode === "default") { + if (!trimmed) { + return "/default"; + } + if (/^\/default(?:\s|$)/i.test(trimmed)) { + return trimmed; + } + return `/default ${trimmed}`; + } + 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; + readonly title?: string; + readonly kind?: string; + readonly detail?: string; + readonly data: Record; +}): boolean { + 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)), + ); +} + function resolveGrokAuthMethodId(environment: NodeJS.ProcessEnv | undefined): string { return environment?.[GROK_API_KEY_ENV]?.trim() ? GROK_AUTH_METHOD_API_KEY @@ -62,7 +177,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( @@ -91,18 +211,111 @@ 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 effort differs from + * `currentEffort` (skip re-applying the same value every turn). Mid-thread + * effort no longer requires a process restart. + * + * Effort-only still needs a model id for set_model: prefer requested, else + * current. When both are missing, returns `undefined` without calling + * set_model (cannot claim effort was applied). + */ export function applyGrokAcpModelSelection(input: { readonly runtime: Pick; readonly currentModelId: string | undefined; readonly requestedModelId: string | undefined; + readonly selections?: ReadonlyArray | null; + /** Last known process effort; when equal to the selection, set_model is skipped. */ + readonly currentEffort?: string; 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 && reasoningEffort !== input.currentEffort; + + if (!targetModelId) { + // No model available for set_model (effort cannot be applied alone). + return Effect.succeed(undefined); + } + + if (!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 { + 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; primary effort path is + * `session/set_model` `_meta.reasoningEffort` (and CLI flag on initial spawn). + */ +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/XAiAcpExtension.test.ts b/apps/server/src/provider/acp/XAiAcpExtension.test.ts index c435269fd76..0a4f8926ef1 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,121 @@ 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", () => { + // Real layout: ~/.grok/sessions///plan.md + expect( + isGrokPlanMarkdownPath( + "/Users/me/.grok/sessions/%2FUsers%2Fme%2Fproj/sess-123/plan.md", + ), + ).toBe(true); + expect(isGrokPlanMarkdownPath("/home/x/.grok/sessions/encoded-cwd/sess-1/plan.md")).toBe( + true, + ); + // Too few segments after sessions/ (workspace false positive) + expect(isGrokPlanMarkdownPath("/home/x/.grok/sessions/abc/plan.md")).toBe(false); + expect(isGrokPlanMarkdownPath("/repo/.grok/sessions/demo/plan.md")).toBe(false); + 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/encoded/s1/plan.md", + content: "# Mid-plan draft\n", + }, + }), + ).toBe("# Mid-plan draft"); + + expect( + extractGrokPlanMarkdownFromToolCallData({ + content: [ + { + type: "diff", + path: "/home/x/.grok/sessions/encoded/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: "/repo/.grok/sessions/demo/plan.md", content: "# Fake session plan" }, + }), + ).toBeUndefined(); + + expect( + extractGrokPlanMarkdownFromToolCallData({ + rawInput: { path: "/home/x/.grok/sessions/encoded/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 d36a5fcfc89..550701715c1 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -196,6 +196,191 @@ 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 feedbackText = feedback.trim(); + return { + outcome: "rejected", + 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`. + * Requires at least two path segments after `.grok/sessions/` so workspace + * files like `/repo/.grok/sessions/demo/plan.md` are rejected. + */ +export function isGrokPlanMarkdownPath(path: string | undefined | null): boolean { + if (typeof path !== "string") { + return false; + } + const normalized = path.trim().replace(/\\/g, "/"); + if (normalized.length === 0) { + return false; + } + // Real layout: ~/.grok/sessions///plan.md + return /(?:^|\/)\.grok\/sessions\/[^/]+\/.+\/plan\.md$/i.test(normalized); +} + +/** + * 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, + }; +} + /** * Adds Grok's private prompt-completion fallback around a standards-only ACP runtime. * The underlying runtime remains unaware of xAI methods and metadata. 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}}} 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,