From 294af69a4f9fc78a9d2f7617820dc06b765cd0f0 Mon Sep 17 00:00:00 2001 From: Jaaneek Date: Mon, 25 May 2026 22:06:02 -0700 Subject: [PATCH 01/10] feat(grok): add Grok CLI provider via ACP Wires the Grok CLI (https://x.ai/cli) into the existing ACP runtime so X Premium / SuperGrok users get a first-class provider. - GrokDriver registered through builtInDrivers - GrokProvider / GrokAdapter handle session lifecycle, event streams, and model switching via session/set_model - GrokAcpSupport probes auth methods and drives the xai.api_key flow - GrokTextGeneration adds Grok as an option (not a default) for git commit/branch generation - grok-build registered as the only Grok model id (only one currently exposed by the CLI) - UI: Grok icon, provider selector entry, settings driver metadata, provider icon mapping Defaults are unchanged: Codex remains the default provider. Closes #2808 --- apps/server/scripts/acp-mock-agent.ts | 33 + .../server/src/provider/Drivers/GrokDriver.ts | 151 ++++ .../src/provider/Layers/GrokAdapter.test.ts | 215 +++++ .../server/src/provider/Layers/GrokAdapter.ts | 796 ++++++++++++++++++ .../src/provider/Layers/GrokProvider.test.ts | 114 +++ .../src/provider/Layers/GrokProvider.ts | 323 +++++++ .../ProviderInstanceRegistryLive.test.ts | 55 +- .../provider/Layers/ProviderRegistry.test.ts | 7 + .../src/provider/Services/GrokAdapter.ts | 16 + .../src/provider/acp/AcpSessionRuntime.ts | 17 + .../src/provider/acp/GrokAcpCliProbe.test.ts | 69 ++ .../src/provider/acp/GrokAcpSupport.test.ts | 109 +++ .../server/src/provider/acp/GrokAcpSupport.ts | 104 +++ apps/server/src/provider/builtInDrivers.ts | 3 + apps/server/src/serverSettings.ts | 2 + .../textGeneration/GrokTextGeneration.test.ts | 234 +++++ .../src/textGeneration/GrokTextGeneration.ts | 272 ++++++ .../src/textGeneration/TextGeneration.ts | 2 +- apps/web/src/components/Icons.tsx | 12 + .../components/KeybindingsToast.browser.tsx | 1 + .../src/components/chat/providerIconUtils.ts | 3 +- .../components/settings/providerDriverMeta.ts | 10 +- apps/web/src/modelSelection.test.ts | 21 + apps/web/src/session-logic.ts | 6 + packages/contracts/src/model.ts | 3 + packages/contracts/src/settings.ts | 32 + packages/shared/src/model.test.ts | 3 + 27 files changed, 2602 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/provider/Drivers/GrokDriver.ts create mode 100644 apps/server/src/provider/Layers/GrokAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/GrokAdapter.ts create mode 100644 apps/server/src/provider/Layers/GrokProvider.test.ts create mode 100644 apps/server/src/provider/Layers/GrokProvider.ts create mode 100644 apps/server/src/provider/Services/GrokAdapter.ts create mode 100644 apps/server/src/provider/acp/GrokAcpCliProbe.test.ts create mode 100644 apps/server/src/provider/acp/GrokAcpSupport.test.ts create mode 100644 apps/server/src/provider/acp/GrokAcpSupport.ts create mode 100644 apps/server/src/textGeneration/GrokTextGeneration.test.ts create mode 100644 apps/server/src/textGeneration/GrokTextGeneration.ts diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 20b12ef20dc..b241cc21205 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -237,6 +237,21 @@ function modeState(): AcpSchema.SessionModeState { }; } +const grokAcpModels: ReadonlyArray = [ + { modelId: "grok-build", name: "Grok Build" }, + { modelId: "grok-mock-alt", name: "Grok Mock Alt" }, +]; + +function modelState(): AcpSchema.SessionModelState { + const modelId = grokAcpModels.some((model) => model.modelId === currentModelId) + ? currentModelId + : "grok-build"; + return { + currentModelId: modelId, + availableModels: grokAcpModels, + }; +} + const program = Effect.gen(function* () { const agent = yield* EffectAcpAgent.AcpAgent; @@ -257,6 +272,7 @@ const program = Effect.gen(function* () { Effect.succeed({ sessionId, modes: modeState(), + models: modelState(), configOptions: configOptions(), }), ); @@ -273,11 +289,28 @@ const program = Effect.gen(function* () { .pipe( Effect.as({ modes: modeState(), + models: modelState(), configOptions: configOptions(), }), ), ); + yield* agent.handleSetSessionModel((request) => + Effect.gen(function* () { + if (!grokAcpModels.some((model) => model.modelId === request.modelId)) { + return yield* AcpError.AcpRequestError.invalidParams( + `Unknown mock model id: ${request.modelId}`, + { + method: "session/set_model", + params: request, + }, + ); + } + currentModelId = request.modelId; + return {}; + }), + ); + yield* agent.handleSetSessionConfigOption((request) => Effect.gen(function* () { if (exitOnSetConfigOption) { diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts new file mode 100644 index 00000000000..af25c348db1 --- /dev/null +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -0,0 +1,151 @@ +import { GrokSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { ServerConfig } from "../../config.ts"; +import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeGrokAdapter } from "../Layers/GrokAdapter.ts"; +import { + buildInitialGrokProviderSnapshot, + checkGrokProviderStatus, + enrichGrokSnapshot, +} from "../Layers/GrokProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +const decodeGrokSettings = Schema.decodeSync(GrokSettings); + +const DRIVER_KIND = ProviderDriverKind.make("grok"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +const UPDATE = makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), +); + +export type GrokDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const GrokDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Grok", + supportsMultipleInstances: true, + }, + configSchema: GrokSettings, + defaultConfig: (): GrokSettings => decodeGrokSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies GrokSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeGrokAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => + buildInitialGrokProviderSnapshot(settings).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ snapshot: currentSnapshot, publishSnapshot }) => + enrichGrokSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + publishSnapshot, + httpClient, + }), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Grok snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts new file mode 100644 index 00000000000..952c0f7f3c6 --- /dev/null +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -0,0 +1,215 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as path from "node:path"; +import * as os from "node:os"; +import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { + GrokSettings, + ProviderDriverKind, + ThreadId, + ProviderInstanceId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; + +import { ServerConfig } from "../../config.ts"; +import { makeGrokAdapter } from "./GrokAdapter.ts"; +const decodeGrokSettings = Schema.decodeSync(GrokSettings); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const bunExe = "bun"; + +async function makeMockGrokWrapper(extraEnv?: Record) { + const dir = await mkdtemp(path.join(os.tmpdir(), "grok-acp-mock-")); + const wrapperPath = path.join(dir, "fake-grok.sh"); + const envExports = Object.entries(extraEnv ?? {}) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .join("\n"); + const script = `#!/bin/sh +${envExports} +exec ${JSON.stringify(bunExe)} ${JSON.stringify(mockAgentPath)} "$@" +`; + await writeFile(wrapperPath, script, "utf8"); + await chmod(wrapperPath, 0o755); + return wrapperPath; +} + +async function waitForFileContent(filePath: string, attempts = 40): Promise { + const readAttempt = async (remainingAttempts: number): Promise => { + if (remainingAttempts <= 0) { + throw new Error(`Timed out waiting for file content at ${filePath}`); + } + try { + const raw = await readFile(filePath, "utf8"); + if (raw.trim().length > 0) { + return raw; + } + } catch {} + await Effect.runPromise(Effect.sleep("25 millis")); + return readAttempt(remainingAttempts - 1); + }; + return readAttempt(attempts); +} + +const grokAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-grok-adapter-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +const makeTestAdapter = (binaryPath: string) => + makeGrokAdapter(decodeGrokSettings({ binaryPath })).pipe(Effect.orDie); + +it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { + it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-mock-thread"); + const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" + ? Deferred.succeed(turnCompleted, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-mock-alt" }, + }); + + assert.equal(session.provider, "grok"); + assert.equal(session.model, "grok-mock-alt"); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + + yield* adapter.sendTurn({ + threadId, + input: "hello grok", + attachments: [], + }); + + yield* Deferred.await(turnCompleted); + yield* Fiber.interrupt(runtimeEventsFiber); + const types = runtimeEvents.map((e) => e.type); + + assert.includeMembers(types, [ + "session.started", + "session.state.changed", + "thread.started", + "turn.started", + "item.started", + "content.delta", + "turn.completed", + ] as const); + + const delta = runtimeEvents.find((e) => e.type === "content.delta"); + assert.isDefined(delta); + if (delta?.type === "content.delta") { + assert.equal(delta.payload.delta, "hello from mock"); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("closes the ACP child process when a session stops", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-stop-session-close"); + const tempDir = yield* Effect.promise(() => + mkdtemp(path.join(os.tmpdir(), "grok-adapter-exit-log-")), + ); + const exitLogPath = path.join(tempDir, "exit.log"); + + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EXIT_LOG_PATH: exitLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + + yield* adapter.stopSession(threadId); + + const exitLog = yield* Effect.promise(() => waitForFileContent(exitLogPath)); + assert.include(exitLog, "SIGTERM"); + }), + ); + + it.effect("rejects startSession when provider mismatches", () => + Effect.gen(function* () { + const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("grok-provider-mismatch"); + + const error = yield* Effect.flip( + adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }), + ); + + assert.equal(error._tag, "ProviderAdapterValidationError"); + }), + ); + + it.effect("rejects sendTurn with empty input and no attachments", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-empty-turn"); + + const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + + const error = yield* Effect.flip( + adapter.sendTurn({ + threadId, + input: " ", + attachments: [], + }), + ); + + assert.equal(error._tag, "ProviderAdapterValidationError"); + + yield* adapter.stopSession(threadId); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts new file mode 100644 index 00000000000..b9d42e91bcc --- /dev/null +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -0,0 +1,796 @@ +import { + ApprovalRequestId, + type GrokSettings, + EventId, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Random from "effect/Random"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import { type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggers } from "../acp/AcpNativeLogging.ts"; +import { + applyGrokAcpModelSelection, + currentGrokModelIdFromSessionSetup, + makeGrokAcpRuntime, + resolveGrokAcpBaseModelId, +} from "../acp/GrokAcpSupport.ts"; +import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); + +const PROVIDER = ProviderDriverKind.make("grok"); +const GROK_RESUME_VERSION = 1 as const; + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export interface GrokAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + readonly instanceId?: typeof ProviderInstanceId.Type; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; +} + +interface GrokSessionContext { + readonly threadId: ThreadId; + readonly acpSessionId: string; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntimeShape; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + currentModelId: string | undefined; + stopped: boolean; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + return Effect.forEach( + Array.from(pendingApprovals.values()), + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { discard: true }, + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseGrokResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== GROK_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + const allowAlwaysOption = request.options.find((option) => option.kind === "allow_always"); + if (typeof allowAlwaysOption?.optionId === "string" && allowAlwaysOption.optionId.trim()) { + return allowAlwaysOption.optionId.trim(); + } + + const allowOnceOption = request.options.find((option) => option.kind === "allow_once"); + if (typeof allowOnceOption?.optionId === "string" && allowOnceOption.optionId.trim()) { + return allowOnceOption.optionId.trim(); + } + + return undefined; +} + +export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapterLiveOptions) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("grok"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* Effect.service(ServerConfig); + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native" }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const logNative = (threadId: ThreadId, method: string, payload: unknown) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* Random.nextUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }); + + const emitPlanUpdate = ( + ctx: GrokSessionContext, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + method: string, + ) => + Effect.gen(function* () { + const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload, + source: "acp.jsonrpc", + method, + rawPayload, + }), + ); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: GrokSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: GrokAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + + const cwd = path.resolve(input.cwd.trim()); + const grokModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + const pendingApprovals = new Map(); + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + + const resumeSessionId = parseGrokResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + const acp = yield* makeGrokAcpRuntime({ + grokSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...acpNativeLoggers, + }).pipe( + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const started = yield* Effect.gen(function* () { + yield* acp.handleRequestPermission((params) => + Effect.gen(function* () { + yield* logNative(input.threadId, "session/request_permission", params); + if (input.runtimeMode === "full-access") { + const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId, + }, + }; + } + } + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(crypto.randomUUID()); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + pendingApprovals.set(requestId, { decision }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: sessions.get(input.threadId)?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? + "[unserializable params]", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: sessions.get(input.threadId)?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + return { + outcome: + resolved === "cancel" + ? ({ outcome: "cancelled" } as const) + : { + outcome: "selected" as const, + optionId: acpPermissionOutcome(resolved), + }, + }; + }), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + const requestedStartModelId = grokModelSelection?.model + ? resolveGrokAcpBaseModelId(grokModelSelection.model) + : undefined; + const boundModelId = yield* applyGrokAcpModelSelection({ + runtime: acp, + currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult), + requestedModelId: requestedStartModelId, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + }); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + ...(boundModelId ? { model: resolveGrokAcpBaseModelId(boundModelId) } : {}), + threadId: input.threadId, + resumeCursor: { + schemaVersion: GROK_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + const ctx: GrokSessionContext = { + threadId: input.threadId, + acpSessionId: started.sessionId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + currentModelId: boundModelId, + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + switch (event._tag) { + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + yield* emitPlanUpdate(ctx, event.payload, event.rawPayload, "session/update"); + return; + case "ToolCallUpdated": + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe(Effect.forkChild); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Grok ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: GrokAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const prepared = yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + const turnId = TurnId.make(crypto.randomUUID()); + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId + ? input.modelSelection + : undefined; + const requestedTurnModelId = turnModelSelection?.model + ? resolveGrokAcpBaseModelId(turnModelSelection.model) + : undefined; + const currentModelId = yield* applyGrokAcpModelSelection({ + runtime: ctx.acp, + currentModelId: ctx.currentModelId, + requestedModelId: requestedTurnModelId, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + }); + + const text = input.input?.trim(); + const imagePromptParts = yield* Effect.forEach(input.attachments ?? [], (attachment) => + Effect.gen(function* () { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: cause.message, + cause, + }), + ), + ); + return { + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + } satisfies EffectAcpSchema.ContentBlock; + }), + ); + const promptParts: Array = [ + ...(text ? [{ type: "text" as const, text }] : []), + ...imagePromptParts, + ]; + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + ctx.currentModelId = currentModelId; + const displayModel = currentModelId + ? resolveGrokAcpBaseModelId(currentModelId) + : undefined; + ctx.activeTurnId = turnId; + ctx.lastPlanFingerprint = undefined; + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + ...(displayModel ? { model: displayModel } : {}), + }; + + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: displayModel ? { model: displayModel } : {}, + }); + + return { + acp: ctx.acp, + acpSessionId: ctx.acpSessionId, + displayModel, + promptParts, + turnId, + }; + }), + ); + + const result = yield* prepared.acp + .prompt({ + prompt: prepared.promptParts, + }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + return yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.acpSessionId !== prepared.acpSessionId) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Grok session changed before the turn completed.", + }); + } + + ctx.turns = [ + ...ctx.turns, + { id: prepared.turnId, items: [{ prompt: prepared.promptParts, result }] }, + ]; + ctx.session = { + ...ctx.session, + activeTurnId: prepared.turnId, + updatedAt: yield* nowIso, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: prepared.turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.stopReason ?? null, + }, + }); + + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }), + ); + }); + + const interruptTurn: GrokAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + }); + + const respondToRequest: GrokAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: GrokAdapterShape["respondToUserInput"] = (threadId, requestId) => + Effect.gen(function* () { + yield* requireSession(threadId); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "user-input/respond", + detail: `Grok has no pending user-input request: ${requestId}`, + }); + }); + + const readThread: GrokAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: GrokAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: "Grok ACP sessions do not support provider-side rollback yet.", + }); + }); + + const stopSession: GrokAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: GrokAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: GrokAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: GrokAdapterShape["stopAll"] = () => + Effect.forEach(Array.from(sessions.values()), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.ignore(stopAll()).pipe( + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies GrokAdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts new file mode 100644 index 00000000000..8de684cdd00 --- /dev/null +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -0,0 +1,114 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vitest"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { GrokSettings } from "@t3tools/contracts"; + +import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus } from "./GrokProvider.ts"; + +const decodeGrokSettings = Schema.decodeSync(GrokSettings); + +const runNode = ( + effect: Effect.Effect< + A, + E, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + >, +): Promise => Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer))); + +describe("buildInitialGrokProviderSnapshot", () => { + it("returns a disabled snapshot when settings.enabled is false", async () => { + const snapshot = await Effect.runPromise( + buildInitialGrokProviderSnapshot(decodeGrokSettings({ enabled: false })), + ); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.installed).toBe(false); + expect(snapshot.message).toContain("disabled"); + }); + + it("returns a pending snapshot by default", async () => { + const snapshot = await Effect.runPromise( + buildInitialGrokProviderSnapshot(decodeGrokSettings({})), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.version).toBeNull(); + expect(snapshot.message).toContain("Checking Grok"); + }); +}); + +describe("checkGrokProviderStatus", () => { + it("reports the binary as missing when the binary path does not resolve", async () => { + const snapshot = await runNode( + checkGrokProviderStatus( + decodeGrokSettings({ + enabled: true, + binaryPath: "/definitely/not/installed/grok-binary", + }), + ), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/); + }); + + it("reports an installed CLI as unhealthy when --version exits non-zero", async () => { + const snapshot = await runNode( + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-version-" }); + const grokPath = path.join(dir, "grok"); + yield* fs.writeFileString( + grokPath, + ["#!/bin/sh", 'printf "%s\\n" "broken grok install" >&2', "exit 2", ""].join("\n"), + ); + yield* fs.chmod(grokPath, 0o755); + + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + ); + }), + ), + ); + + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toContain("broken grok install"); + }); + + it("reports an error when ACP model discovery is unavailable", async () => { + const snapshot = await runNode( + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-success-" }); + const grokPath = path.join(dir, "grok"); + yield* fs.writeFileString( + grokPath, + ["#!/bin/sh", 'printf "grok-cli 0.0.99\\n"', "exit 0", ""].join("\n"), + ); + yield* fs.chmod(grokPath, 0o755); + + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + ); + }), + ), + ); + + expect(snapshot.status).toBe("error"); + expect(snapshot.installed).toBe(true); + expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-build"]); + expect(snapshot.message).toContain("ACP startup failed"); + }); +}); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts new file mode 100644 index 00000000000..7348f9e7445 --- /dev/null +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -0,0 +1,323 @@ +import { + type GrokSettings, + type ModelCapabilities, + ProviderDriverKind, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { createModelCapabilities } from "@t3tools/shared/model"; + +import { + buildServerProvider, + detailFromResult, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSupport.ts"; + +const GROK_PRESENTATION = { + displayName: "Grok", + badgeLabel: "Early Access", + showInteractionModeToggle: false, +} as const; +const PROVIDER = ProviderDriverKind.make("grok"); +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; + +const GROK_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "grok-build", + name: "Grok Build", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, +]; + +export function buildInitialGrokProviderSnapshot( + grokSettings: GrokSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = grokModelsFromSettings(grokSettings.customModels); + + if (!grokSettings.enabled) { + return buildServerProvider({ + presentation: GROK_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Grok is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: GROK_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Grok CLI availability...", + }, + }); + }); +} + +function grokModelsFromSettings( + customModels: ReadonlyArray | undefined, + builtInModels: ReadonlyArray = GROK_BUILT_IN_MODELS, +): ReadonlyArray { + return providerModelsFromSettings( + builtInModels, + PROVIDER, + customModels ?? [], + EMPTY_CAPABILITIES, + ); +} + +function buildGrokDiscoveredModelsFromSessionModelState( + modelState: EffectAcpSchema.SessionModelState | null | undefined, +): ReadonlyArray { + if (!modelState || modelState.availableModels.length === 0) { + return []; + } + const seen = new Set(); + return modelState.availableModels + .map((model): ServerProviderModel | undefined => { + const slug = resolveGrokAcpBaseModelId(model.modelId); + if (!slug || seen.has(slug)) { + return undefined; + } + seen.add(slug); + return { + slug, + name: model.name.trim() || slug, + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }; + }) + .filter((model): model is ServerProviderModel => model !== undefined); +} + +const discoverGrokModelsViaAcp = ( + grokSettings: GrokSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const acp = yield* makeGrokAcpRuntime({ + grokSettings, + environment, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + }); + const started = yield* acp.start(); + return buildGrokDiscoveredModelsFromSessionModelState(started.sessionSetupResult.models); + }).pipe(Effect.scoped); + +const runGrokVersionCommand = ( + grokSettings: GrokSettings, + environment: NodeJS.ProcessEnv = process.env, +) => { + const command = grokSettings.binaryPath || "grok"; + return spawnAndCollect( + command, + ChildProcess.make(command, ["--version"], { + env: environment, + shell: process.platform === "win32", + }), + ); +}; + +export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( + grokSettings: GrokSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = grokModelsFromSettings(grokSettings.customModels); + + if (!grokSettings.enabled) { + return buildServerProvider({ + presentation: GROK_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Grok is disabled in T3 Code settings.", + }, + }); + } + + const versionResult = yield* runGrokVersionCommand(grokSettings, environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionResult)) { + const error = versionResult.failure; + return buildServerProvider({ + presentation: GROK_PRESENTATION, + enabled: grokSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Grok CLI (`grok`) is not installed or not on PATH." + : `Failed to execute Grok CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }, + }); + } + + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: GROK_PRESENTATION, + enabled: grokSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Grok CLI is installed but timed out while running `grok --version`.", + }, + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + const detail = detailFromResult(versionOutput); + return buildServerProvider({ + presentation: GROK_PRESENTATION, + enabled: grokSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: detail + ? `Grok CLI is installed but failed to run. ${detail}` + : "Grok CLI is installed but failed to run.", + }, + }); + } + + const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe( + Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS), + Effect.exit, + ); + if (Exit.isFailure(discoveryExit)) { + const detail = Cause.pretty(discoveryExit.cause); + yield* Effect.logWarning("Grok ACP model discovery failed", { cause: detail }); + return buildServerProvider({ + presentation: GROK_PRESENTATION, + enabled: grokSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: `Grok CLI is installed but ACP startup failed. ${detail}`, + }, + }); + } + if (Option.isNone(discoveryExit.value)) { + yield* Effect.logWarning( + `Grok ACP model discovery timed out after ${GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, + ); + return buildServerProvider({ + presentation: GROK_PRESENTATION, + enabled: grokSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: `Grok CLI is installed but ACP startup timed out after ${GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, + }, + }); + } + const discoveredModels = discoveryExit.value.value; + const models = + discoveredModels.length > 0 + ? grokModelsFromSettings(grokSettings.customModels, discoveredModels) + : fallbackModels; + + return buildServerProvider({ + presentation: GROK_PRESENTATION, + enabled: grokSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + }, + }); +}); + +export const enrichGrokSnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const { snapshot, publishSnapshot } = input; + + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + Effect.catchCause((cause) => + Effect.logWarning("Grok version advisory enrichment failed", { + cause: Cause.pretty(cause), + }), + ), + Effect.asVoid, + ); +}; diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 86f99c97326..f2c5892a2c6 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -10,7 +10,7 @@ * * 2. **Many drivers, one registry** — the "all drivers slice" describe * block below configures one instance of every shipped driver - * (`codex`, `claudeAgent`, `cursor`, `opencode`) in a single + * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single * `ProviderInstanceConfigMap` and asserts the registry boots them all * without cross-contamination. This proves the driver SPI is uniform * across every provider — any driver plugs into the registry through @@ -18,7 +18,7 @@ * * Every instance in these tests is configured with `enabled: false` so the * provider-status checks short-circuit to pending/disabled snapshots - * without trying to spawn real `codex` / `claude` / `agent` / `opencode` + * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `opencode` * binaries. That keeps the assertions focused on registry routing * behaviour rather than the runtime details of each provider. */ @@ -28,6 +28,7 @@ import { type ClaudeSettings, type CodexSettings, type CursorSettings, + type GrokSettings, type OpenCodeSettings, ProviderDriverKind, type ProviderInstanceConfigMap, @@ -41,6 +42,7 @@ import { ServerConfig } from "../../config.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; +import { GrokDriver } from "../Drivers/GrokDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; @@ -79,6 +81,13 @@ const makeCursorConfig = (overrides: Partial): CursorSettings => ...overrides, }); +const makeGrokConfig = (overrides: Partial): GrokSettings => ({ + enabled: false, + binaryPath: "grok", + customModels: [], + ...overrides, +}); + const makeOpenCodeConfig = (overrides: Partial): OpenCodeSettings => ({ enabled: false, binaryPath: "opencode", @@ -218,7 +227,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }); describe("ProviderInstanceRegistryLive — all drivers slice", () => { - // All four drivers need `NodeServices` (ChildProcessSpawner + FileSystem + + // All drivers need `NodeServices` (ChildProcessSpawner + FileSystem + // Path). `OpenCodeDriver.create` additionally yields `OpenCodeRuntime` // at construction time, so we wire `OpenCodeRuntimeLive` into the stack. // `OpenCodeRuntimeLive` bundles its own `NetService.layer` via @@ -244,11 +253,13 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const codexId = ProviderInstanceId.make("codex_default"); const claudeId = ProviderInstanceId.make("claude_default"); const cursorId = ProviderInstanceId.make("cursor_default"); + const grokId = ProviderInstanceId.make("grok_default"); const openCodeId = ProviderInstanceId.make("opencode_default"); const codexDriverKind = ProviderDriverKind.make("codex"); const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); const cursorDriverKind = ProviderDriverKind.make("cursor"); + const grokDriverKind = ProviderDriverKind.make("grok"); const openCodeDriverKind = ProviderDriverKind.make("opencode"); const configMap: ProviderInstanceConfigMap = { @@ -273,6 +284,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { enabled: false, config: makeCursorConfig({}), }, + [grokId]: { + driver: grokDriverKind, + displayName: "Grok", + enabled: false, + config: makeGrokConfig({}), + }, [openCodeId]: { driver: openCodeDriverKind, displayName: "OpenCode", @@ -282,7 +299,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }; const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver, ClaudeDriver, CursorDriver, OpenCodeDriver], + drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], configMap, }); @@ -292,9 +309,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(unavailable).toEqual([]); const instances = yield* registry.listInstances; - expect(instances).toHaveLength(4); + expect(instances).toHaveLength(5); expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [codexId, claudeId, cursorId, openCodeId].toSorted(), + [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(), ); // Instance lookup by id resolves each instance to its own bundle — @@ -303,14 +320,17 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const codex = yield* registry.getInstance(codexId); const claude = yield* registry.getInstance(claudeId); const cursor = yield* registry.getInstance(cursorId); + const grok = yield* registry.getInstance(grokId); const openCode = yield* registry.getInstance(openCodeId); expect(codex?.driverKind).toBe(codexDriverKind); expect(claude?.driverKind).toBe(claudeDriverKind); expect(cursor?.driverKind).toBe(cursorDriverKind); + expect(grok?.driverKind).toBe(grokDriverKind); expect(openCode?.driverKind).toBe(openCodeDriverKind); expect(codex?.displayName).toBe("Codex"); expect(claude?.displayName).toBe("Claude"); expect(cursor?.displayName).toBe("Cursor"); + expect(grok?.displayName).toBe("Grok"); expect(openCode?.displayName).toBe("OpenCode"); // Every instance owns its own set of closures — no sharing across @@ -318,16 +338,29 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { // distinct references even when two instances happen to share a // trait (e.g. Cursor + others all use a stub-or-real // `textGeneration`; they must still be different object values). - const adapters = [codex!.adapter, claude!.adapter, cursor!.adapter, openCode!.adapter]; + const adapters = [ + codex!.adapter, + claude!.adapter, + cursor!.adapter, + grok!.adapter, + openCode!.adapter, + ]; expect(new Set(adapters).size).toBe(adapters.length); const textGenerations = [ codex!.textGeneration, claude!.textGeneration, cursor!.textGeneration, + grok!.textGeneration, openCode!.textGeneration, ]; expect(new Set(textGenerations).size).toBe(textGenerations.length); - const snapshots = [codex!.snapshot, claude!.snapshot, cursor!.snapshot, openCode!.snapshot]; + const snapshots = [ + codex!.snapshot, + claude!.snapshot, + cursor!.snapshot, + grok!.snapshot, + openCode!.snapshot, + ]; expect(new Set(snapshots).size).toBe(snapshots.length); // Snapshots identify themselves by `instanceId` + `driver` so @@ -356,6 +389,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { `${cursorDriverKind}:instance:${cursorId}`, ); + const grokSnapshot = yield* grok!.snapshot.getSnapshot; + expect(grokSnapshot.instanceId).toBe(grokId); + expect(grokSnapshot.driver).toBe(grokDriverKind); + expect(grokSnapshot.enabled).toBe(false); + expect(grokSnapshot.continuation?.groupKey).toBe(`${grokDriverKind}:instance:${grokId}`); + const openCodeSnapshot = yield* openCode!.snapshot.getSnapshot; expect(openCodeSnapshot.instanceId).toBe(openCodeId); expect(openCodeSnapshot.driver).toBe(openCodeDriverKind); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 8b564dceaa6..8621ec06b50 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -992,6 +992,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T codex: { enabled: false }, claudeAgent: { enabled: false }, cursor: { enabled: false }, + grok: { enabled: false }, opencode: { enabled: false }, }, // `providerInstances` keys are branded `ProviderInstanceId`; @@ -1086,6 +1087,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T codex: { enabled: true, binaryPath: firstMissing }, claudeAgent: { enabled: false }, cursor: { enabled: false }, + grok: { enabled: false }, opencode: { enabled: false }, }, }), @@ -1186,6 +1188,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T codex: { enabled: false }, claudeAgent: { enabled: false }, cursor: { enabled: false }, + grok: { enabled: false }, opencode: { enabled: false }, }, providerInstances: { @@ -1245,6 +1248,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T cursor: { enabled: false, }, + grok: { + enabled: false, + }, }, }), ), @@ -1305,6 +1311,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T "claudeAgent", "codex", "cursor", + "grok", "opencode", ]); assert.strictEqual(cursorProvider?.enabled, false); diff --git a/apps/server/src/provider/Services/GrokAdapter.ts b/apps/server/src/provider/Services/GrokAdapter.ts new file mode 100644 index 00000000000..73254cefe39 --- /dev/null +++ b/apps/server/src/provider/Services/GrokAdapter.ts @@ -0,0 +1,16 @@ +/** + * GrokAdapter — shape type for the Grok provider adapter. + * + * The driver model ({@link ../Drivers/GrokDriver}) bundles one adapter per + * instance as a captured closure, so this module only retains the shape + * interface as a naming anchor for the driver bundle. + * + * @module GrokAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * GrokAdapterShape — per-instance Grok adapter contract. + */ +export interface GrokAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 8652b2cfeaf..4ed64890fc3 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -105,6 +105,9 @@ export interface AcpSessionRuntimeShape { value: string | boolean, ) => Effect.Effect; readonly setModel: (model: string) => Effect.Effect; + readonly setSessionModel: ( + modelId: string, + ) => Effect.Effect; readonly request: ( method: string, payload: unknown, @@ -546,6 +549,20 @@ const makeAcpSessionRuntime = ( Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), Effect.asVoid, ), + setSessionModel: (modelId) => + getStartedState.pipe( + Effect.flatMap((started) => { + const requestPayload = { + sessionId: started.sessionId, + modelId, + } satisfies EffectAcpSchema.SetSessionModelRequest; + return runLoggedRequest( + "session/set_model", + requestPayload, + acp.agent.setSessionModel(requestPayload), + ); + }), + ), request: (method, payload) => runLoggedRequest(method, payload, acp.raw.request(method, payload)), notify: acp.raw.notify, diff --git a/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts b/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts new file mode 100644 index 00000000000..74d85243fde --- /dev/null +++ b/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts @@ -0,0 +1,69 @@ +/** + * Optional integration check against a real `grok agent stdio` install. + * Enable with: T3_GROK_ACP_PROBE=1 bun run test GrokAcpCliProbe + * + * The probe assumes either `XAI_API_KEY` is set in the environment or + * the user has previously run `grok login`. Without credentials the + * agent's `authenticate` request will fail and the test will surface + * the error. + */ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { describe, expect } from "vitest"; + +import { makeGrokAcpRuntime } from "./GrokAcpSupport.ts"; + +const makeProbeRuntime = Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* makeGrokAcpRuntime({ + grokSettings: { binaryPath: "grok" }, + environment: process.env, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-grok-probe", version: "0.0.0" }, + }); +}); + +describe.runIf(process.env.T3_GROK_ACP_PROBE === "1")("Grok ACP CLI probe", () => { + it.effect("initialize and authenticate against real grok agent stdio", () => + Effect.gen(function* () { + const runtime = yield* makeProbeRuntime; + const started = yield* runtime.start(); + expect(started.initializeResult).toBeDefined(); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("session/new advertises typed SessionModelState with at least one model", () => + Effect.gen(function* () { + const runtime = yield* makeProbeRuntime; + const started = yield* runtime.start(); + const result = started.sessionSetupResult; + + expect(typeof started.sessionId).toBe("string"); + + // Modern grok-shell advertises models through the typed + // `SessionModelState` field, not via a `configOptions` entry. + // If this assertion fails the upstream surface has regressed. + const models = result.models; + expect(models).toBeDefined(); + expect(typeof models?.currentModelId).toBe("string"); + expect(models?.availableModels.length ?? 0).toBeGreaterThan(0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("session/set_model accepts a no-op switch to the current model", () => + 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; + + // No-op switch — selecting the model the session already runs on must + // succeed against every Grok build that implements `session/set_model`. + yield* runtime.setSessionModel(currentModelId); + }).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 new file mode 100644 index 00000000000..a8cd347fad5 --- /dev/null +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -0,0 +1,109 @@ +import * as Effect from "effect/Effect"; +import * as EffectAcpErrors from "effect-acp/errors"; +import { describe, expect, it } from "vitest"; + +import { + applyGrokAcpModelSelection, + buildGrokAcpSpawnInput, + resolveGrokAcpBaseModelId, +} from "./GrokAcpSupport.ts"; + +describe("resolveGrokAcpBaseModelId", () => { + it("normalizes empty and custom Grok model ids", () => { + expect(resolveGrokAcpBaseModelId(undefined)).toBe("grok-build"); + expect(resolveGrokAcpBaseModelId(" ")).toBe("grok-build"); + expect(resolveGrokAcpBaseModelId(" grok-test-custom-model ")).toBe("grok-test-custom-model"); + }); +}); + +describe("buildGrokAcpSpawnInput", () => { + it("passes the T3 Code referrer through Grok OAuth env", () => { + const spawn = buildGrokAcpSpawnInput({ binaryPath: "/usr/local/bin/grok" }, "/tmp/project", { + XAI_API_KEY: "secret", + GROK_OAUTH2_REFERRER: "other-client", + }); + + expect(spawn).toEqual({ + command: "/usr/local/bin/grok", + args: ["agent", "stdio"], + cwd: "/tmp/project", + env: { + XAI_API_KEY: "secret", + GROK_OAUTH2_REFERRER: "t3code", + }, + }); + }); +}); + +describe("applyGrokAcpModelSelection", () => { + const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => { + const modelCalls: Array = []; + const runtime = { + setSessionModel: (modelId: string) => + Effect.gen(function* () { + modelCalls.push(modelId); + if (failure) return yield* failure; + return {}; + }), + }; + return { runtime, modelCalls }; + }; + + it("calls session/set_model when the requested model differs from current", async () => { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = await Effect.runPromise( + applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-build", + requestedModelId: "grok-mock-alt", + mapError: (cause) => cause.message, + }), + ); + expect(modelCalls).toEqual(["grok-mock-alt"]); + expect(result).toBe("grok-mock-alt"); + }); + + it("skips set_model when requested matches current", async () => { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = await Effect.runPromise( + applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-build", + requestedModelId: "grok-build", + mapError: (cause) => cause.message, + }), + ); + expect(modelCalls).toEqual([]); + expect(result).toBe("grok-build"); + }); + + it("skips set_model when no model is requested", async () => { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = await Effect.runPromise( + applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-build", + requestedModelId: undefined, + mapError: (cause) => cause.message, + }), + ); + expect(modelCalls).toEqual([]); + expect(result).toBe("grok-build"); + }); + + it("propagates session/set_model failures via mapError", async () => { + const failure = EffectAcpErrors.AcpRequestError.invalidParams("session id not known"); + const { runtime } = makeRecordingRuntime(failure); + const error = await Effect.runPromise( + Effect.flip( + applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-build", + requestedModelId: "grok-mock-alt", + mapError: (cause) => cause.message, + }), + ), + ); + expect(error).toBe(failure.message); + }); +}); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts new file mode 100644 index 00000000000..642548832fa --- /dev/null +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -0,0 +1,104 @@ +import { type GrokSettings, ProviderDriverKind } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { normalizeModelSlug } from "@t3tools/shared/model"; + +import { + AcpSessionRuntime, + type AcpSessionRuntimeOptions, + type AcpSessionRuntimeShape, + type AcpSpawnInput, +} from "./AcpSessionRuntime.ts"; + +const GROK_API_KEY_ENV = "XAI_API_KEY"; +const GROK_OAUTH2_REFERRER_ENV = "GROK_OAUTH2_REFERRER"; +const T3_CODE_OAUTH_REFERRER = "t3code"; +const GROK_AUTH_METHOD_API_KEY = "xai.api_key"; +const GROK_AUTH_METHOD_CACHED_TOKEN = "cached_token"; +const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); + +type GrokAcpRuntimeGrokSettings = Pick; + +interface GrokAcpRuntimeInput extends Omit< + AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; +} + +export function buildGrokAcpSpawnInput( + grokSettings: GrokAcpRuntimeGrokSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSpawnInput { + return { + command: grokSettings?.binaryPath || "grok", + args: ["agent", "stdio"], + cwd, + env: { + ...environment, + [GROK_OAUTH2_REFERRER_ENV]: T3_CODE_OAUTH_REFERRER, + }, + }; +} + +function resolveGrokAuthMethodId(environment: NodeJS.ProcessEnv | undefined): string { + return environment?.[GROK_API_KEY_ENV]?.trim() + ? GROK_AUTH_METHOD_API_KEY + : GROK_AUTH_METHOD_CACHED_TOKEN; +} + +export const makeGrokAcpRuntime = ( + input: GrokAcpRuntimeInput, +): Effect.Effect => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildGrokAcpSpawnInput(input.grokSettings, input.cwd, input.environment), + authMethodId: resolveGrokAuthMethodId(input.environment), + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); + }); + +export function resolveGrokAcpBaseModelId(model: string | null | undefined): string { + const trimmed = model?.trim(); + const base = trimmed && trimmed.length > 0 ? trimmed : "grok-build"; + return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? "grok-build"; +} + +export function currentGrokModelIdFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): string | undefined { + return sessionSetupResult.models?.currentModelId?.trim() || undefined; +} + +export function applyGrokAcpModelSelection(input: { + readonly runtime: Pick; + readonly currentModelId: string | undefined; + readonly requestedModelId: string | undefined; + readonly mapError: (cause: EffectAcpErrors.AcpError) => E; +}): Effect.Effect { + const shouldSwitchModel = + input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; + if (!shouldSwitchModel) { + return Effect.succeed(input.currentModelId); + } + return input.runtime + .setSessionModel(input.requestedModelId) + .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId)); +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 5af56dc6b0e..791a96e1da3 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -23,6 +23,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; +import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -35,6 +36,7 @@ export type BuiltInDriversEnv = | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv + | GrokDriverEnv | OpenCodeDriverEnv; /** @@ -46,5 +48,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray): string { + const binDir = path.join(dir, "bin"); + const grokPath = path.join(binDir, "grok"); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + grokPath, + [ + "#!/bin/sh", + ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`), + 'if [ "$1" != "agent" ] || [ "$2" != "stdio" ]; then', + ' printf "%s\\n" "unexpected args: $*" >&2', + " exit 11", + "fi", + `exec bun ${JSON.stringify(mockAgentPath)}`, + "", + ].join("\n"), + "utf8", + ); + chmodSync(grokPath, 0o755); + return grokPath; +} + +function withFakeAcpGrok( + env: Record, + effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, +) { + return Effect.gen(function* () { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-grok-text-acp-")); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + rmSync(tempDir, { recursive: true, force: true }); + }), + ); + const binaryPath = makeAcpGrokWrapper(tempDir, env); + const config = decodeGrokSettings({ binaryPath }); + const textGeneration = yield* makeGrokTextGeneration(config); + return yield* effectFn(textGeneration); + }).pipe(Effect.scoped); +} + +function readJsonRpcRequests( + filePath: string, +): ReadonlyArray<{ readonly method?: string; readonly params?: Record }> { + return readFileSync(filePath, "utf8") + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as { method?: string; params?: Record }); +} + +it.layer(GrokTextGenerationTestLayer)("GrokTextGeneration", (it) => { + it.effect("uses ACP with disabled tool capabilities and forwards the requested model id", () => { + const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-grok-text-log-")); + const requestLogPath = path.join(requestLogDir, "requests.ndjson"); + + return withFakeAcpGrok( + { + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + subject: "Add Grok provider", + body: "Wire up the ACP runtime and headless text generation path.", + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/grok", + stagedSummary: "M apps/server/src/provider/Drivers/GrokDriver.ts", + stagedPatch: "diff --git a/.../GrokDriver.ts b/.../GrokDriver.ts", + modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-mock-alt"), + }); + + expect(generated.subject).toBe("Add Grok provider"); + expect(generated.body).toBe("Wire up the ACP runtime and headless text generation path."); + + const requests = readJsonRpcRequests(requestLogPath); + expect( + requests.find((request) => request.method === "initialize")?.params?.clientCapabilities, + ).toMatchObject({ + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }); + expect( + requests.some( + (request) => + request.method === "session/set_model" && + request.params?.modelId === "grok-mock-alt", + ), + ).toBe(true); + }), + ); + }); + + it.effect("extracts the JSON object when Grok wraps it in conversational text", () => + withFakeAcpGrok( + { + T3_ACP_PROMPT_RESPONSE_TEXT: + "Sure! Here's a thread title:\n\n" + + JSON.stringify({ title: "Investigate failing CI" }) + + "\n\nLet me know if you need anything else.", + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "the lint job is red", + modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-mock-alt"), + }); + expect(generated.title).toBe("Investigate failing CI"); + }), + ), + ); + + it.effect("surfaces ACP request failures as text generation errors", () => + withFakeAcpGrok( + { + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ branch: "unreachable" }), + }, + (textGeneration) => + Effect.gen(function* () { + const error = yield* Effect.flip( + textGeneration.generateBranchName({ + cwd: process.cwd(), + message: "wire up grok", + modelSelection: createModelSelection( + ProviderInstanceId.make("grok"), + "missing-grok-model", + ), + }), + ); + expect(error._tag).toBe("TextGenerationError"); + expect(error.detail).toContain("Grok ACP base model"); + }), + ), + ); + + it.effect("fails with TextGenerationError when output is empty", () => + withFakeAcpGrok( + { + T3_ACP_PROMPT_RESPONSE_TEXT: " \n ", + }, + (textGeneration) => + Effect.gen(function* () { + const error = yield* Effect.flip( + textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "anything", + modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-build"), + }), + ); + expect(error._tag).toBe("TextGenerationError"); + expect(error.detail).toMatch(/empty/i); + }), + ), + ); + + it.effect("decodes a structured PR title + body", () => + withFakeAcpGrok( + { + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + title: "feat(grok): wire up session/set_model", + body: "## Summary\n- Replace `-m` spawn flag with the typed ACP `session/set_model`.\n- Translate `MODEL_SWITCH_INCOMPATIBLE_AGENT` into a validation error.", + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generatePrContent({ + cwd: process.cwd(), + baseBranch: "main", + headBranch: "feat/grok-provider", + commitSummary: "feat: add grok provider", + diffSummary: "M apps/server/src/provider/Drivers/GrokDriver.ts", + diffPatch: "diff --git a/.../GrokDriver.ts b/.../GrokDriver.ts", + modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-build"), + }); + + expect(generated.title).toBe("feat(grok): wire up session/set_model"); + expect(generated.body).toContain("Translate `MODEL_SWITCH_INCOMPATIBLE_AGENT`"); + }), + ), + ); + + it.effect("fails with TextGenerationError when output is unparseable JSON", () => + withFakeAcpGrok( + { + T3_ACP_PROMPT_RESPONSE_TEXT: "totally not json output from a confused model", + }, + (textGeneration) => + Effect.gen(function* () { + const error = yield* Effect.flip( + textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "anything", + modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-build"), + }), + ); + expect(error._tag).toBe("TextGenerationError"); + expect(error.detail).toMatch(/invalid structured output/i); + }), + ), + ); +}); diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts new file mode 100644 index 00000000000..6d7ff8e872d --- /dev/null +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -0,0 +1,272 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import { type GrokSettings, type ModelSelection } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import { TextGenerationError } from "@t3tools/contracts"; +import { type ThreadTitleGenerationResult, type TextGenerationShape } from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; +import { + applyGrokAcpModelSelection, + currentGrokModelIdFromSessionSetup, + makeGrokAcpRuntime, + resolveGrokAcpBaseModelId, +} from "../provider/acp/GrokAcpSupport.ts"; + +const GROK_TIMEOUT_MS = 180_000; + +function mapGrokAcpError( + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle", + detail: string, + cause: unknown, +): TextGenerationError { + return new TextGenerationError({ + operation, + detail, + ...(cause !== undefined ? { cause } : {}), + }); +} + +function isTextGenerationError(error: unknown): error is TextGenerationError { + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "TextGenerationError" + ); +} + +export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(function* ( + grokSettings: GrokSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const runGrokJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const resolvedModel = resolveGrokAcpBaseModelId(modelSelection.model); + const outputRef = yield* Ref.make(""); + const runtime = yield* makeGrokAcpRuntime({ + grokSettings, + environment, + childProcessSpawner: commandSpawner, + cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") { + return Effect.void; + } + const content = update.content; + if (content.type !== "text") { + return Effect.void; + } + return Ref.update(outputRef, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + const started = yield* runtime.start(); + yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult), + requestedModelId: resolvedModel, + mapError: (cause) => + mapGrokAcpError( + operation, + "Failed to set Grok ACP base model for text generation.", + cause, + ), + }); + + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }], + }); + }).pipe( + Effect.timeoutOption(GROK_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ operation, detail: "Grok ACP request timed out." }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) => + isTextGenerationError(cause) + ? cause + : mapGrokAcpError(operation, "Grok ACP request failed.", cause), + ), + ); + + const trimmed = (yield* Ref.get(outputRef)).trim(); + if (!trimmed) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "Grok ACP request was cancelled." + : "Grok Agent returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(trimmed)).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Grok Agent returned invalid structured output.", + cause, + }), + ), + ), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : mapGrokAcpError(operation, "Grok ACP text generation failed.", cause), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + "GrokTextGeneration.generateCommitMessage", + )(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + + const generated = yield* runGrokJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( + "GrokTextGeneration.generatePrContent", + )(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + + const generated = yield* runGrokJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + "GrokTextGeneration.generateBranchName", + )(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runGrokJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "GrokTextGeneration.generateThreadTitle", + )(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runGrokJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGenerationShape; +}); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 2b149dabbb8..d5d28e638ed 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -10,7 +10,7 @@ import { } from "../provider/Services/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "opencode"; +export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index b3211e17753..8ea38c51958 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -199,6 +199,18 @@ export const CursorIcon: Icon = ({ className, ...props }) => ( ); +export const GrokIcon: Icon = ({ className, ...props }) => ( + + + + +); + export const TraeIcon: Icon = (props) => ( {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */} diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index b5e9d74764b..b7aa6d7a645 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -158,6 +158,7 @@ function createBaseServerConfig(): ServerConfig { launchArgs: "", }, cursor: { enabled: true, binaryPath: "", apiEndpoint: "", customModels: [] }, + grok: { enabled: true, binaryPath: "", customModels: [] }, opencode: { enabled: true, binaryPath: "", diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 88b56295f36..e85793b5530 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,5 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -7,6 +7,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, + [ProviderDriverKind.make("grok")]: GrokIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index 8d3d7482f62..bfee6a8d680 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -2,11 +2,12 @@ import { ClaudeSettings, CodexSettings, CursorSettings, + GrokSettings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -53,6 +54,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = badgeLabel: "Early Access", settingsSchema: CursorSettings, }, + { + value: ProviderDriverKind.make("grok"), + label: "Grok", + icon: GrokIcon, + badgeLabel: "Early Access", + settingsSchema: GrokSettings, + }, { value: ProviderDriverKind.make("opencode"), label: "OpenCode", diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 2602ee7eb60..3d973ccca74 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -101,6 +101,27 @@ describe("instance-scoped model selection", () => { ).toBe("openai/gpt-5.5"); }); + it("includes Grok custom models from the selected provider instance", () => { + const providers = [provider({ provider: ProviderDriverKind.make("grok"), instanceId: "grok" })]; + const settings: UnifiedSettings = { + ...settingsWithProviderInstances(), + providerInstances: { + ...settingsWithProviderInstances().providerInstances, + [ProviderInstanceId.make("grok")]: { + driver: ProviderDriverKind.make("grok"), + config: { customModels: ["grok-test-custom-model"] }, + }, + }, + }; + const grok = deriveProviderInstanceEntries(providers).find( + (entry) => entry.instanceId === "grok", + )!; + + expect(getAppModelOptionsForInstance(settings, grok).map((option) => option.slug)).toContain( + "grok-test-custom-model", + ); + }); + it("does not inject an unknown selected slug into the stock instance list", () => { const providers = [ provider({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5feffe15b09..4e72776bf19 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -45,6 +45,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("grok"), + label: "Grok", + available: true, + pickerSidebarBadge: "new", + }, ]; export interface WorkLogEntry { diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 5b1363f5544..7788eaace48 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -130,6 +130,7 @@ export type ModelCapabilities = typeof ModelCapabilities.Type; const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); +const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); export const DEFAULT_MODEL = "gpt-5.4"; @@ -139,6 +140,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CODEX_DRIVER_KIND]: "Codex", [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", + [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", }; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 66bb7631311..33781f56c94 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -281,6 +281,30 @@ export const CursorSettings = makeProviderSettingsSchema( ); export type CursorSettings = typeof CursorSettings.Type; +export const GrokSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("grok").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Grok CLI binary.", + providerSettingsForm: { placeholder: "grok", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath"], + }, +); +export type GrokSettings = typeof GrokSettings.Type; + export const OpenCodeSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( @@ -369,6 +393,7 @@ export const ServerSettings = Schema.Struct({ codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values @@ -437,6 +462,12 @@ const CursorSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const GrokSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -463,6 +494,7 @@ export const ServerSettingsPatch = Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), + grok: Schema.optionalKey(GrokSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ), diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index fc3c619d2c5..00c01616005 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -94,6 +94,9 @@ describe("resolveModelSlugForProvider", () => { expect(resolveModelSlugForProvider(ProviderDriverKind.make("ollama"), undefined)).toBe( DEFAULT_MODEL, ); + expect(resolveModelSlugForProvider(ProviderDriverKind.make("grok"), undefined)).toBe( + "grok-build", + ); }); it("preserves normalized unknown models", () => { From 7e9445b02688eb77d8c5fe1bc39c8ed2c2d5d7de Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 28 May 2026 12:02:40 -0700 Subject: [PATCH 02/10] Stabilize Grok provider and chat callbacks - Use crypto-backed UUIDs and normalize ACP callback failures - Fix chat composer refs and browser secret record updates - Tighten runtime catalog hydration test setup --- .../server/src/provider/Drivers/GrokDriver.ts | 2 + .../server/src/provider/Layers/GrokAdapter.ts | 157 +++++++++++------- apps/web/src/clientPersistenceStorage.ts | 2 +- apps/web/src/components/ChatView.tsx | 18 +- .../src/environments/runtime/catalog.test.ts | 6 +- 5 files changed, 114 insertions(+), 71 deletions(-) diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index af25c348db1..ab01439ffd3 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -1,5 +1,6 @@ import { GrokSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -44,6 +45,7 @@ const UPDATE = makeStaticProviderMaintenanceResolver( export type GrokDriverEnv = | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index b9d42e91bcc..31b3b9ac960 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -11,6 +11,7 @@ import { type ThreadId, TurnId, } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -20,13 +21,13 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; -import * as Random from "effect/Random"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; import { ChildProcessSpawner } from "effect/unstable/process"; +import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -48,7 +49,7 @@ import { makeAcpToolCallEvent, } from "../acp/AcpCoreRuntimeEvents.ts"; import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; -import { makeAcpNativeLoggers } from "../acp/AcpNativeLogging.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; import { applyGrokAcpModelSelection, currentGrokModelIdFromSessionSetup, @@ -138,6 +139,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const path = yield* Path.Path; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* Effect.service(ServerConfig); + const crypto = yield* Crypto.Crypto; const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -145,14 +147,36 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte : undefined); const managedNativeEventLogger = options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); const sessions = new Map(); const threadLocksRef = yield* SynchronizedRef.make(new Map()); const runtimeEventPubSub = yield* PubSub.unbounded(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); - const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.make(id)); + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate Grok runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const mapAcpCallbackFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process Grok ACP callback.", + cause, + }), + ), + ); const offerRuntimeEvent = (event: ProviderRuntimeEvent) => PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); @@ -186,7 +210,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte { observedAt, event: { - id: yield* Random.nextUUIDv4, + id: yield* randomUUIDv4, kind: "notification", provider: PROVIDER, createdAt: observedAt, @@ -325,65 +349,67 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ); const started = yield* Effect.gen(function* () { yield* acp.handleRequestPermission((params) => - Effect.gen(function* () { - yield* logNative(input.threadId, "session/request_permission", params); - if (input.runtimeMode === "full-access") { - const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); - if (autoApprovedOptionId !== undefined) { - return { - outcome: { - outcome: "selected" as const, - optionId: autoApprovedOptionId, - }, - }; - } - } - const permissionRequest = parsePermissionRequest(params); - const requestId = ApprovalRequestId.make(crypto.randomUUID()); - const runtimeRequestId = RuntimeRequestId.make(requestId); - const decision = yield* Deferred.make(); - pendingApprovals.set(requestId, { decision }); - yield* offerRuntimeEvent( - makeAcpRequestOpenedEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: input.threadId, - turnId: sessions.get(input.threadId)?.activeTurnId, - requestId: runtimeRequestId, - permissionRequest, - detail: - permissionRequest.detail ?? - encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? - "[unserializable params]", - args: params, - source: "acp.jsonrpc", - method: "session/request_permission", - rawPayload: params, - }), - ); - const resolved = yield* Deferred.await(decision); - pendingApprovals.delete(requestId); - yield* offerRuntimeEvent( - makeAcpRequestResolvedEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: input.threadId, - turnId: sessions.get(input.threadId)?.activeTurnId, - requestId: runtimeRequestId, - permissionRequest, - decision: resolved, - }), - ); - return { - outcome: - resolved === "cancel" - ? ({ outcome: "cancelled" } as const) - : { + mapAcpCallbackFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, "session/request_permission", params); + if (input.runtimeMode === "full-access") { + const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { outcome: "selected" as const, - optionId: acpPermissionOutcome(resolved), + optionId: autoApprovedOptionId, }, - }; - }), + }; + } + } + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + pendingApprovals.set(requestId, { decision }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: sessions.get(input.threadId)?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? + "[unserializable params]", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: sessions.get(input.threadId)?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + return { + outcome: + resolved === "cancel" + ? ({ outcome: "cancelled" } as const) + : { + outcome: "selected" as const, + optionId: acpPermissionOutcome(resolved), + }, + }; + }), + ), ); return yield* acp.start(); }).pipe( @@ -497,7 +523,12 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } }), ), - ).pipe(Effect.forkChild); + ).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process Grok runtime notification.", { cause }), + ), + Effect.forkChild, + ); ctx.notificationFiber = nf; sessions.set(input.threadId, ctx); @@ -535,7 +566,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte input.threadId, Effect.gen(function* () { const ctx = yield* requireSession(input.threadId); - const turnId = TurnId.make(crypto.randomUUID()); + const turnId = TurnId.make(yield* randomUUIDv4); const turnModelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index 2da74ae6c45..2838f502881 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -184,7 +184,7 @@ export function writeBrowserSavedEnvironmentSecret( return record; } found = true; - const nextRecord = { + const nextRecord: BrowserSavedEnvironmentRecord = { environmentId: record.environmentId, label: record.label, httpBaseUrl: record.httpBaseUrl, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7fc7669fe60..5f5a96647d3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1984,15 +1984,18 @@ export default function ChatView(props: ChatViewProps) { const focusComposer = useCallback(() => { composerRef.current?.focusAtEnd(); - }, []); + }, [composerRef]); const scheduleComposerFocus = useCallback(() => { window.requestAnimationFrame(() => { focusComposer(); }); }, [focusComposer]); - const addTerminalContextToDraft = useCallback((selection: TerminalContextSelection) => { - composerRef.current?.addTerminalContext(selection); - }, []); + const addTerminalContextToDraft = useCallback( + (selection: TerminalContextSelection) => { + composerRef.current?.addTerminalContext(selection); + }, + [composerRef], + ); const setTerminalOpen = useCallback( (open: boolean) => { if (!activeThreadRef) return; @@ -2771,6 +2774,7 @@ export default function ChatView(props: ChatViewProps) { keybindings, onToggleDiff, toggleTerminalVisibility, + composerRef, ]); const onRevertToTurnCount = useCallback( @@ -3245,7 +3249,7 @@ export default function ChatView(props: ChatViewProps) { promptRef.current = ""; composerRef.current?.resetCursorState({ cursor: 0 }); }, - [activePendingProgress?.activeQuestion, activePendingUserInput], + [activePendingProgress?.activeQuestion, activePendingUserInput, composerRef], ); const onChangeActivePendingUserInputCustomAnswer = useCallback( @@ -3279,7 +3283,7 @@ export default function ChatView(props: ChatViewProps) { composerRef.current?.focusAt(nextCursor); } }, - [activePendingUserInput], + [activePendingUserInput, composerRef], ); const onAdvanceActivePendingUserInput = useCallback(() => { @@ -3451,6 +3455,7 @@ export default function ChatView(props: ChatViewProps) { setThreadError, autoOpenPlanSidebar, environmentId, + composerRef, ], ); @@ -3588,6 +3593,7 @@ export default function ChatView(props: ChatViewProps) { runtimeMode, autoOpenPlanSidebar, environmentId, + composerRef, ]); const onProviderModelSelect = useCallback( diff --git a/apps/web/src/environments/runtime/catalog.test.ts b/apps/web/src/environments/runtime/catalog.test.ts index 1002ab08dd6..f6c5fc85277 100644 --- a/apps/web/src/environments/runtime/catalog.test.ts +++ b/apps/web/src/environments/runtime/catalog.test.ts @@ -15,6 +15,10 @@ import { writeSavedEnvironmentCredential, } from "./catalog"; +let resolveRegistryRead: () => void = () => { + throw new Error("Registry read resolver was not initialized."); +}; + describe("environment runtime catalog stores", () => { beforeEach(async () => { vi.stubGlobal("window", { @@ -138,7 +142,7 @@ describe("environment runtime catalog stores", () => { }); it("does not let stale hydration overwrite records added while hydration is in flight", async () => { - let resolveRegistryRead: () => void = () => { + resolveRegistryRead = () => { throw new Error("Registry read resolver was not initialized."); }; From 27c19b2a562d330a9e3f934e0255d63f26c99c14 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 28 May 2026 12:36:05 -0700 Subject: [PATCH 03/10] Honor provider approval IDs in Grok adapter - Use provider-supplied approval option IDs when responding - Keep streaming if native notification logging fails --- apps/server/scripts/acp-mock-agent.ts | 15 ++- .../src/provider/Layers/GrokAdapter.test.ts | 104 +++++++++++++++++- .../server/src/provider/Layers/GrokAdapter.ts | 56 ++++++---- 3 files changed, 150 insertions(+), 25 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index b241cc21205..1ee1b7eafce 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -21,6 +21,11 @@ const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; +const permissionOptionIds = { + allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once", + allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always", + rejectOnce: process.env.T3_ACP_REJECT_ONCE_OPTION_ID ?? "reject-once", +}; const sessionId = "mock-session-1"; let currentModeId = "ask"; @@ -452,9 +457,13 @@ const program = Effect.gen(function* () { ], }, options: [ - { optionId: "allow-once", name: "Allow once", kind: "allow_once" }, - { optionId: "allow-always", name: "Allow always", kind: "allow_always" }, - { optionId: "reject-once", name: "Reject", kind: "reject_once" }, + { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" }, + { + optionId: permissionOptionIds.allowAlways, + name: "Allow always", + kind: "allow_always", + }, + { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" }, ], }); diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 952c0f7f3c6..c8c8fadbb2c 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -14,6 +14,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { + ApprovalRequestId, GrokSettings, ProviderDriverKind, ThreadId, @@ -61,12 +62,21 @@ async function waitForFileContent(filePath: string, attempts = 40): Promise line.trim()) + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); +} + const grokAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3code-grok-adapter-test-", }).pipe(Layer.provideMerge(NodeServices.layer)); -const makeTestAdapter = (binaryPath: string) => - makeGrokAdapter(decodeGrokSettings({ binaryPath })).pipe(Effect.orDie); +const makeTestAdapter = (binaryPath: string, options?: Parameters[1]) => + makeGrokAdapter(decodeGrokSettings({ binaryPath }), options).pipe(Effect.orDie); it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => @@ -212,4 +222,94 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { yield* adapter.stopSession(threadId); }), ); + + it.effect("responds to ACP approvals using provider-supplied option ids", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-custom-approval-option-id"); + const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "grok-acp-"))); + const requestLogPath = path.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_ALLOW_ONCE_OPTION_ID: "agent-defined-approval-id", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "request.opened" + ? adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(event.requestId)), + "accept", + ) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + yield* adapter.sendTurn({ threadId, input: "approve this", attachments: [] }); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.isTrue( + requests.some( + (entry) => + !("method" in entry) && + typeof entry.result === "object" && + entry.result !== null && + "outcome" in entry.result && + typeof entry.result.outcome === "object" && + entry.result.outcome !== null && + "optionId" in entry.result.outcome && + entry.result.outcome.optionId === "agent-defined-approval-id", + ), + ); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("continues streaming events when native notification logging fails", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-native-log-failure"); + const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath, { + nativeEventLogger: { + filePath: "memory://grok-native-events", + write: (record: unknown) => + typeof record === "object" && + record !== null && + "event" in record && + typeof record.event === "object" && + record.event !== null && + "kind" in record.event && + record.event.kind === "notification" + ? Effect.die(new Error("native log write failed")) + : Effect.void, + close: () => Effect.void, + }, + }); + const contentDelta = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "content.delta" ? Deferred.succeed(contentDelta, 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: "keep streaming", attachments: [] }); + yield* Deferred.await(contentDelta); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); }); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 31b3b9ac960..753566e5987 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -38,7 +38,7 @@ import { ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError, } from "../Errors.ts"; -import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; import { type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts"; import { makeAcpAssistantItemEvent, @@ -116,20 +116,27 @@ function parseGrokResume(raw: unknown): { sessionId: string } | undefined { return { sessionId: raw.sessionId.trim() }; } -function selectAutoApprovedPermissionOption( +function selectPermissionOptionId( request: EffectAcpSchema.RequestPermissionRequest, + decision: Exclude, ): string | undefined { - const allowAlwaysOption = request.options.find((option) => option.kind === "allow_always"); - if (typeof allowAlwaysOption?.optionId === "string" && allowAlwaysOption.optionId.trim()) { - return allowAlwaysOption.optionId.trim(); - } - - const allowOnceOption = request.options.find((option) => option.kind === "allow_once"); - if (typeof allowOnceOption?.optionId === "string" && allowOnceOption.optionId.trim()) { - return allowOnceOption.optionId.trim(); - } + const kind = + decision === "acceptForSession" + ? "allow_always" + : decision === "accept" + ? "allow_once" + : "reject_once"; + const option = request.options.find((entry) => entry.kind === kind); + return option?.optionId.trim() || undefined; +} - return undefined; +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + return ( + selectPermissionOptionId(request, "acceptForSession") ?? + selectPermissionOptionId(request, "accept") + ); } export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapterLiveOptions) { @@ -221,7 +228,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }, threadId, ); - }); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to write native Grok notification log.", { + cause, + threadId, + method, + }), + ), + ); const emitPlanUpdate = ( ctx: GrokSessionContext, @@ -399,14 +414,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte decision: resolved, }), ); + const selectedOptionId = + resolved === "cancel" ? undefined : selectPermissionOptionId(params, resolved); return { - outcome: - resolved === "cancel" - ? ({ outcome: "cancelled" } as const) - : { - outcome: "selected" as const, - optionId: acpPermissionOutcome(resolved), - }, + outcome: selectedOptionId + ? { + outcome: "selected" as const, + optionId: selectedOptionId, + } + : ({ outcome: "cancelled" } as const), }; }), ), From f58cc366dc9565cd13ad5e5e0f87d80302524303 Mon Sep 17 00:00:00 2001 From: Jaaneek Date: Thu, 4 Jun 2026 17:01:40 +0100 Subject: [PATCH 04/10] Handle provider model-change restrictions. --- .../OrchestrationEngineHarness.integration.ts | 3 + .../Layers/ProviderCommandReactor.test.ts | 76 +++++++++++++++++++ .../Layers/ProviderCommandReactor.ts | 48 ++++++++++++ .../src/provider/Layers/GrokProvider.test.ts | 1 + .../src/provider/Layers/GrokProvider.ts | 1 + apps/server/src/provider/providerSnapshot.ts | 4 + .../testUtils/providerRegistryMock.ts | 21 +++++ .../web/src/components/ChatView.logic.test.ts | 68 +++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 39 ++++++++++ apps/web/src/components/ChatView.tsx | 17 +++++ packages/contracts/src/server.ts | 1 + 11 files changed, 279 insertions(+) create mode 100644 apps/server/src/provider/testUtils/providerRegistryMock.ts diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 563e5b3a8d3..19a4b56417c 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -35,6 +35,7 @@ import { ProjectionCheckpointRepository } from "../src/persistence/Services/Proj import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts"; import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts"; import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; +import { makeProviderRegistryLayer } from "../src/provider/testUtils/providerRegistryMock.ts"; import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; import { ServerSettingsService } from "../src/serverSettings.ts"; import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; @@ -293,6 +294,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provide(AnalyticsService.layerTest), Layer.provide(providerEventLoggersLayer), ); + const providerRegistryLayer = makeProviderRegistryLayer(); const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer)); const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive; @@ -375,6 +377,7 @@ export const makeOrchestrationIntegrationHarness = ( const layer = Layer.empty.pipe( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(orchestrationReactorLayer), + Layer.provideMerge(providerRegistryLayer), Layer.provide(persistenceLayer), Layer.provideMerge(RepositoryIdentityResolverLive), Layer.provideMerge(ServerSettingsService.layerTest()), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 16e68bf88bb..66e566c83db 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -40,6 +40,7 @@ import { ProviderService, type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; +import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts"; import { TextGeneration, type TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; @@ -142,6 +143,7 @@ describe("ProviderCommandReactor", () => { readonly baseDir?: string; readonly threadModelSelection?: ModelSelection; readonly sessionModelSwitch?: "unsupported" | "in-session"; + readonly requiresNewThreadForModelChange?: boolean; }) { const now = "2026-01-01T00:00:00.000Z"; const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); @@ -280,6 +282,14 @@ describe("ProviderCommandReactor", () => { }), ), ); + const providerSnapshots = [ + { + instanceId: modelSelection.instanceId, + ...(input?.requiresNewThreadForModelChange === true + ? { requiresNewThreadForModelChange: true } + : {}), + }, + ]; const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; const service: ProviderServiceShape = { @@ -335,6 +345,7 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(orchestrationLayer), Layer.provideMerge(projectionSnapshotLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), + Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)), Layer.provideMerge( Layer.mock(GitWorkflowService)({ renameBranch, @@ -879,6 +890,71 @@ describe("ProviderCommandReactor", () => { }); }); + it("rejects changing models after start when the provider requires a new thread", async () => { + const harness = await createHarness({ requiresNewThreadForModelChange: true }); + const now = "2026-01-01T00:00:00.000Z"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-restricted-1"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-restricted-1"), + role: "user", + text: "first", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-restricted-2"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-restricted-2"), + role: "user", + text: "second", + attachments: [], + }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.1-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + return ( + thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? + false + ); + }); + + expect(harness.sendTurn).toHaveBeenCalledTimes(1); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), + ).toMatchObject({ + payload: { + detail: expect.stringContaining("cannot switch models after the conversation has started"), + }, + }); + }); + it("starts a first turn on the requested provider instance even when it differs from the thread model", async () => { const harness = await createHarness({ threadModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index f63b873bc3d..e0db0fc320c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -31,6 +31,7 @@ import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import type { ProviderServiceError } from "../../provider/Errors.ts"; import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { ProviderRegistry } from "../../provider/Services/ProviderRegistry.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { @@ -180,6 +181,7 @@ const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const providerService = yield* ProviderService; + const providerRegistry = yield* ProviderRegistry; const gitWorkflow = yield* GitWorkflowService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; @@ -305,6 +307,38 @@ const make = Effect.gen(function* () { .pipe(Effect.map(Option.getOrUndefined)); }); + const rejectStartedThreadModelChangeIfRequired = Effect.fnUntraced(function* (input: { + readonly threadId: ThreadId; + readonly currentModelSelection: ModelSelection; + readonly requestedModelSelection: ModelSelection | undefined; + }) { + const requestedModelSelection = input.requestedModelSelection; + if ( + requestedModelSelection === undefined || + (input.currentModelSelection.instanceId === requestedModelSelection.instanceId && + input.currentModelSelection.model === requestedModelSelection.model) + ) { + return; + } + const providers = yield* providerRegistry.getProviders; + const requiresNewThread = + providers.find((snapshot) => snapshot.instanceId === input.currentModelSelection.instanceId) + ?.requiresNewThreadForModelChange === true || + providers.find((snapshot) => snapshot.instanceId === requestedModelSelection.instanceId) + ?.requiresNewThreadForModelChange === true; + if (!requiresNewThread) { + return; + } + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabelFromInstanceHint({ + instanceId: String(requestedModelSelection.instanceId), + modelSelectionInstanceId: String(input.currentModelSelection.instanceId), + }), + method: "thread.turn.start", + detail: `Thread '${input.threadId}' cannot switch models after the conversation has started. Start a new thread to use '${requestedModelSelection.model}'.`, + }); + }); + const ensureSessionForThread = Effect.fn("ensureSessionForThread")(function* ( threadId: ThreadId, createdAt: string, @@ -384,6 +418,20 @@ const make = Effect.gen(function* () { }); } const preferredProvider: ProviderDriverKind = desiredDriverKind; + if (thread.session !== null) { + yield* rejectStartedThreadModelChangeIfRequired({ + threadId, + currentModelSelection: + activeSession?.model !== undefined + ? { + ...thread.modelSelection, + instanceId: currentInstanceId, + model: activeSession.model, + } + : thread.modelSelection, + requestedModelSelection, + }); + } if ( thread.session !== null && requestedModelSelection !== undefined && diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 8de684cdd00..4fa1aa4b77d 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -39,6 +39,7 @@ describe("buildInitialGrokProviderSnapshot", () => { expect(snapshot.status).toBe("warning"); expect(snapshot.version).toBeNull(); expect(snapshot.message).toContain("Checking Grok"); + expect(snapshot.requiresNewThreadForModelChange).toBe(true); }); }); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 7348f9e7445..bead8b1a407 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -35,6 +35,7 @@ const GROK_PRESENTATION = { displayName: "Grok", badgeLabel: "Early Access", showInteractionModeToggle: false, + requiresNewThreadForModelChange: true, } as const; const PROVIDER = ProviderDriverKind.make("grok"); const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index c40903e1b45..ce43c5e6eab 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -45,6 +45,7 @@ export interface ServerProviderPresentation { readonly displayName: string; readonly badgeLabel?: string; readonly showInteractionModeToggle?: boolean; + readonly requiresNewThreadForModelChange?: boolean; } export type ServerProviderDraft = Omit; @@ -214,6 +215,9 @@ export function buildServerProvider(input: { ...(typeof input.presentation.showInteractionModeToggle === "boolean" ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle } : {}), + ...(typeof input.presentation.requiresNewThreadForModelChange === "boolean" + ? { requiresNewThreadForModelChange: input.presentation.requiresNewThreadForModelChange } + : {}), enabled: input.enabled, installed: input.probe.installed, version: input.probe.version, diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts new file mode 100644 index 00000000000..36598b05900 --- /dev/null +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -0,0 +1,21 @@ +import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; +import type { ServerProvider } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; + +export const makeProviderRegistryMock = ( + providers: ReadonlyArray = [], +): ProviderRegistryShape => ({ + getProviders: Effect.succeed(providers), + refresh: () => Effect.succeed(providers), + refreshInstance: () => Effect.succeed(providers), + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => + Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })), + setProviderMaintenanceActionState: () => Effect.succeed(providers), + streamChanges: Stream.empty, +}); + +export const makeProviderRegistryLayer = (providers: ReadonlyArray = []) => + Layer.succeed(ProviderRegistry, makeProviderRegistryMock(providers)); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 19c800ef139..13bd175e0c9 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -16,6 +16,7 @@ import { buildExpiredTerminalContextToastCopy, createLocalDispatchSnapshot, deriveComposerSendState, + getStartedThreadModelChangeBlockReason, hasServerAcknowledgedLocalDispatch, reconcileMountedTerminalThreadIds, resolveSendEnvMode, @@ -90,6 +91,73 @@ describe("buildExpiredTerminalContextToastCopy", () => { }); }); +describe("getStartedThreadModelChangeBlockReason", () => { + const providers = [ + { + instanceId: ProviderInstanceId.make("codex"), + }, + { + instanceId: ProviderInstanceId.make("grok"), + requiresNewThreadForModelChange: true, + }, + ]; + + it("allows model changes before a provider session has started", () => { + expect( + getStartedThreadModelChangeBlockReason({ + providers, + hasStartedSession: false, + currentModelSelection: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + }, + nextModelSelection: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-other", + }, + }), + ).toBeNull(); + }); + + it("allows unchanged model selections for restricted providers", () => { + expect( + getStartedThreadModelChangeBlockReason({ + providers, + hasStartedSession: true, + currentModelSelection: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + }, + nextModelSelection: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + }, + }), + ).toBeNull(); + }); + + it("blocks started-session model changes when either provider requires a new thread", () => { + expect( + getStartedThreadModelChangeBlockReason({ + providers, + hasStartedSession: true, + currentModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + nextModelSelection: { + instanceId: ProviderInstanceId.make("grok"), + model: "grok-build", + }, + }), + ).toEqual({ + title: "Start a new chat to change models", + description: + "This provider does not allow switching models after a conversation has started.", + }); + }); +}); + describe("resolveSendEnvMode", () => { it("keeps worktree mode for git repositories", () => { expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree"); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index bf87add28d9..de69c573046 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -4,6 +4,7 @@ import { ProjectId, type ModelSelection, type ProviderDriverKind, + type ServerProvider, type ScopedThreadRef, type ThreadId, type TurnId, @@ -262,6 +263,44 @@ export function deriveLockedProvider(input: { return narrowedThreadProvider ?? narrowedSelectedProvider ?? null; } +export function getStartedThreadModelChangeBlockReason(input: { + providers: ReadonlyArray>; + hasStartedSession: boolean; + currentModelSelection: ModelSelection; + currentProviderInstanceId?: ModelSelection["instanceId"] | null | undefined; + nextModelSelection: ModelSelection; +}): { title: string; description: string } | null { + if (!input.hasStartedSession) { + return null; + } + const currentModelSelection = { + ...input.currentModelSelection, + instanceId: input.currentProviderInstanceId ?? input.currentModelSelection.instanceId, + }; + if ( + currentModelSelection.instanceId === input.nextModelSelection.instanceId && + currentModelSelection.model === input.nextModelSelection.model + ) { + return null; + } + const currentProvider = input.providers.find( + (snapshot) => snapshot.instanceId === currentModelSelection.instanceId, + ); + const nextProvider = input.providers.find( + (snapshot) => snapshot.instanceId === input.nextModelSelection.instanceId, + ); + if ( + currentProvider?.requiresNewThreadForModelChange !== true && + nextProvider?.requiresNewThreadForModelChange !== true + ) { + return null; + } + return { + title: "Start a new chat to change models", + description: "This provider does not allow switching models after a conversation has started.", + }; +} + export async function waitForStartedServerThread( threadRef: ScopedThreadRef, timeoutMs = 1_000, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5f5a96647d3..df5d5e4a2ff 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -163,6 +163,7 @@ import { createLocalDispatchSnapshot, deriveComposerSendState, hasServerAcknowledgedLocalDispatch, + getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, type LocalDispatchSnapshot, @@ -3639,6 +3640,22 @@ export default function ChatView(props: ChatViewProps) { instanceId, model: resolvedModel, }; + const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({ + providers: providerStatuses, + hasStartedSession: activeThread.session !== null, + currentModelSelection: activeThread.modelSelection, + currentProviderInstanceId: activeThread.session?.providerInstanceId ?? null, + nextModelSelection, + }); + if (modelChangeBlockReason) { + toastManager.add({ + type: "warning", + title: modelChangeBlockReason.title, + description: modelChangeBlockReason.description, + }); + scheduleComposerFocus(); + return; + } setComposerDraftModelSelection( scopeThreadRef(activeThread.environmentId, activeThread.id), nextModelSelection, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 449ab733c37..1aa280ad63b 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -165,6 +165,7 @@ export const ServerProvider = Schema.Struct({ badgeLabel: Schema.optional(TrimmedNonEmptyString), continuation: Schema.optional(ServerProviderContinuation), showInteractionModeToggle: Schema.optional(Schema.Boolean), + requiresNewThreadForModelChange: Schema.optional(Schema.Boolean), enabled: Schema.Boolean, installed: Schema.Boolean, version: Schema.NullOr(TrimmedNonEmptyString), From f021def7a235be8880403691a5fe1ebc2510d0b4 Mon Sep 17 00:00:00 2001 From: Jaaneek Date: Thu, 4 Jun 2026 20:58:02 +0100 Subject: [PATCH 05/10] Handle Grok xAI interactive extensions. --- apps/server/scripts/acp-mock-agent.ts | 56 ++++++ .../src/provider/Layers/GrokAdapter.test.ts | 102 +++++++++++ .../server/src/provider/Layers/GrokAdapter.ts | 128 ++++++++++++- .../src/provider/acp/XAiAcpExtension.ts | 173 ++++++++++++++++++ 4 files changed, 451 insertions(+), 8 deletions(-) create mode 100644 apps/server/src/provider/acp/XAiAcpExtension.ts diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 1ee1b7eafce..0e25ccc9b30 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -18,6 +18,8 @@ const emitInterleavedAssistantToolCalls = process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; +const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; +const emitXAiExitPlanMode = process.env.T3_ACP_EMIT_XAI_EXIT_PLAN_MODE === "1"; const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; @@ -556,6 +558,60 @@ const program = Effect.gen(function* () { return { stopReason: "end_turn" }; } + if (emitXAiAskUserQuestion) { + const result = yield* agent.client.extRequest("_x.ai/ask_user_question", { + method: "x.ai/ask_user_question", + params: { + sessionId: requestedSessionId, + toolCallId: "ask-user-question-tool-call-1", + questions: [ + { + question: "Which scope should Grok use?", + options: [ + { label: "Workspace", description: "Use the current workspace" }, + { label: "Session", description: "Only use this session" }, + ], + }, + ], + mode: "default", + }, + }); + if ( + typeof result !== "object" || + result === null || + !("outcome" in result) || + result.outcome !== "accepted" || + !("answers" in result) || + typeof result.answers !== "object" || + result.answers === null + ) { + throw new Error("Expected _x.ai/ask_user_question response outcome."); + } + + return { stopReason: "end_turn" }; + } + + if (emitXAiExitPlanMode) { + const result = yield* agent.client.extRequest("_x.ai/exit_plan_mode", { + method: "x.ai/exit_plan_mode", + params: { + sessionId: requestedSessionId, + toolCallId: "exit-plan-mode-tool-call-1", + planContent: "# Grok plan\n\n- Inspect the workspace\n- Apply the fix", + }, + }); + if ( + typeof result !== "object" || + result === null || + !("outcome" in result) || + result.outcome !== "approved" + ) { + throw new Error("Expected _x.ai/exit_plan_mode approved outcome."); + } + + return { stopReason: "end_turn" }; + } + yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, update: { diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index c8c8fadbb2c..42c5fbf4526 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -274,6 +274,108 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("handles xAI ask_user_question extension requests", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-xai-ask-user-question"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_EMIT_XAI_ASK_USER_QUESTION: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const requested = + yield* Deferred.make>(); + const resolved = + yield* Deferred.make>(); + + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (String(event.threadId) !== String(threadId)) { + return Effect.void; + } + if (event.type === "user-input.requested") { + return Deferred.succeed(requested, event).pipe(Effect.ignore); + } + if (event.type === "user-input.resolved") { + return Deferred.succeed(resolved, event).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "ask before continuing", attachments: [] }) + .pipe(Effect.forkChild); + + const requestedEvent = yield* Deferred.await(requested); + assert.equal(requestedEvent.payload.questions.length, 1); + assert.equal(requestedEvent.payload.questions[0]?.id, "Which scope should Grok use?"); + assert.equal(requestedEvent.payload.questions[0]?.question, "Which scope should Grok use?"); + assert.equal(requestedEvent.raw?.method, "_x.ai/ask_user_question"); + + yield* adapter.respondToUserInput( + threadId, + ApprovalRequestId.make(String(requestedEvent.requestId)), + { + "Which scope should Grok use?": "Workspace", + }, + ); + + const resolvedEvent = yield* Deferred.await(resolved); + assert.deepEqual(resolvedEvent.payload.answers, { + "Which scope should Grok use?": "Workspace", + }); + yield* Fiber.join(sendTurnFiber); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("handles xAI exit_plan_mode extension requests", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-xai-exit-plan-mode"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_EMIT_XAI_EXIT_PLAN_MODE: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const proposedPlan = + yield* Deferred.make>(); + + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if ( + String(event.threadId) !== String(threadId) || + event.type !== "turn.proposed.completed" + ) { + return Effect.void; + } + return Deferred.succeed(proposedPlan, event).pipe(Effect.ignore); + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ threadId, input: "propose a plan", attachments: [] }); + + const event = yield* Deferred.await(proposedPlan); + assert.equal( + event.payload.planMarkdown, + "# Grok plan\n\n- Inspect the workspace\n- Apply the fix", + ); + assert.equal(event.raw?.method, "_x.ai/exit_plan_mode"); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("continues streaming events when native notification logging fails", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-native-log-failure"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 753566e5987..0e1207a06b2 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -5,6 +5,7 @@ import { type ProviderApprovalDecision, type ProviderRuntimeEvent, type ProviderSession, + type ProviderUserInputAnswers, ProviderDriverKind, ProviderInstanceId, RuntimeRequestId, @@ -56,6 +57,14 @@ import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId, } from "../acp/GrokAcpSupport.ts"; +import { + extractXAiAskUserQuestions, + extractXAiExitPlanMarkdown, + makeXAiAskUserQuestionResponse, + makeXAiExitPlanModeResponse, + XAiAskUserQuestionRequest, + XAiExitPlanModeRequest, +} from "../acp/XAiAcpExtension.ts"; import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -73,13 +82,17 @@ export interface GrokAdapterLiveOptions { readonly environment?: NodeJS.ProcessEnv; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; - readonly instanceId?: typeof ProviderInstanceId.Type; + readonly instanceId?: ProviderInstanceId; } interface PendingApproval { readonly decision: Deferred.Deferred; } +interface PendingUserInput { + readonly answers: Deferred.Deferred; +} + interface GrokSessionContext { readonly threadId: ThreadId; readonly acpSessionId: string; @@ -88,6 +101,7 @@ interface GrokSessionContext { readonly acp: AcpSessionRuntimeShape; notificationFiber: Fiber.Fiber | undefined; readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; turns: Array<{ id: TurnId; items: Array }>; lastPlanFingerprint: string | undefined; activeTurnId: TurnId | undefined; @@ -105,6 +119,16 @@ function settlePendingApprovalsAsCancelled( ); } +function settlePendingUserInputsAsEmptyAnswers( + pendingUserInputs: ReadonlyMap, +): Effect.Effect { + return Effect.forEach( + Array.from(pendingUserInputs.values()), + (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore), + { discard: true }, + ); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -287,6 +311,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte if (ctx.stopped) return; ctx.stopped = true; yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); if (ctx.notificationFiber) { yield* Fiber.interrupt(ctx.notificationFiber); } @@ -329,6 +354,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } const pendingApprovals = new Map(); + const pendingUserInputs = new Map(); const sessionScope = yield* Scope.make("sequential"); let sessionScopeTransferred = false; yield* Effect.addFinalizer(() => @@ -363,6 +389,82 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ), ); const started = yield* Effect.gen(function* () { + yield* Effect.forEach( + ["x.ai/ask_user_question", "_x.ai/ask_user_question"] as const, + (method) => + acp.handleExtRequest(method, XAiAskUserQuestionRequest, (params) => + mapAcpCallbackFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, method, params); + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const answers = yield* Deferred.make(); + pendingUserInputs.set(requestId, { answers }); + yield* offerRuntimeEvent({ + type: "user-input.requested", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: sessions.get(input.threadId)?.activeTurnId, + requestId: runtimeRequestId, + payload: { questions: extractXAiAskUserQuestions(params) }, + raw: { + source: "acp.grok.extension", + method, + payload: params, + }, + }); + const resolved = yield* Deferred.await(answers); + pendingUserInputs.delete(requestId); + yield* offerRuntimeEvent({ + type: "user-input.resolved", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: sessions.get(input.threadId)?.activeTurnId, + requestId: runtimeRequestId, + payload: { answers: resolved }, + raw: { + source: "acp.grok.extension", + method, + payload: params, + }, + }); + return makeXAiAskUserQuestionResponse(resolved); + }), + ), + ), + { discard: true }, + ); + yield* Effect.forEach( + ["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"] as const, + (method) => + acp.handleExtRequest(method, XAiExitPlanModeRequest, (params) => + mapAcpCallbackFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, method, params); + const planMarkdown = extractXAiExitPlanMarkdown(params)?.trim(); + if (planMarkdown) { + yield* offerRuntimeEvent({ + type: "turn.proposed.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: sessions.get(input.threadId)?.activeTurnId, + payload: { planMarkdown }, + raw: { + source: "acp.grok.extension", + method, + payload: params, + }, + }); + } + return makeXAiExitPlanModeResponse(); + }), + ), + ), + { discard: true }, + ); yield* acp.handleRequestPermission((params) => mapAcpCallbackFailure( Effect.gen(function* () { @@ -470,6 +572,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte acp, notificationFiber: undefined, pendingApprovals, + pendingUserInputs, turns: [], lastPlanFingerprint: undefined, activeTurnId: undefined, @@ -733,6 +836,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte Effect.gen(function* () { const ctx = yield* requireSession(threadId); yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); yield* Effect.ignore( ctx.acp.cancel.pipe( Effect.mapError((error) => @@ -760,14 +864,22 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte yield* Deferred.succeed(pending.decision, decision); }); - const respondToUserInput: GrokAdapterShape["respondToUserInput"] = (threadId, requestId) => + const respondToUserInput: GrokAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => Effect.gen(function* () { - yield* requireSession(threadId); - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "user-input/respond", - detail: `Grok has no pending user-input request: ${requestId}`, - }); + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingUserInputs.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "_x.ai/ask_user_question", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.answers, answers); }); const readThread: GrokAdapterShape["readThread"] = (threadId) => diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts new file mode 100644 index 00000000000..5177cae969a --- /dev/null +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -0,0 +1,173 @@ +import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export const XAiAskUserQuestionRequest = Schema.Unknown; +export const XAiExitPlanModeRequest = Schema.Unknown; + +type UnknownRecord = Record; + +function trimmed(value: string | undefined): string | undefined { + const text = value?.trim(); + return text && text.length > 0 ? text : undefined; +} + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringField(record: UnknownRecord, keys: ReadonlyArray): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string") { + const text = trimmed(value); + if (text) { + return text; + } + } + } + return undefined; +} + +function booleanField(record: UnknownRecord, keys: ReadonlyArray): boolean | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "boolean") { + return value; + } + } + return undefined; +} + +function arrayField(record: UnknownRecord, keys: ReadonlyArray): ReadonlyArray { + for (const key of keys) { + const value = record[key]; + if (Array.isArray(value)) { + return value; + } + } + return []; +} + +function nestedRecord( + record: UnknownRecord, + keys: ReadonlyArray, +): UnknownRecord | undefined { + for (const key of keys) { + const value = record[key]; + if (isRecord(value)) { + return value; + } + } + return undefined; +} + +function unwrapParams(params: unknown): UnknownRecord { + if (!isRecord(params)) { + return {}; + } + const request = nestedRecord(params, ["request"]); + const requestInput = request ? nestedRecord(request, ["input", "arguments", "args"]) : undefined; + return nestedRecord(params, ["input", "arguments", "args", "params"]) ?? requestInput ?? params; +} + +function extractOptionLabel(option: unknown): string | undefined { + return typeof option === "string" + ? trimmed(option) + : isRecord(option) + ? stringField(option, ["label", "value", "id", "text", "title", "name"]) + : undefined; +} + +function extractOptions(options: ReadonlyArray) { + const extracted = (options ?? []).flatMap((option) => { + const label = extractOptionLabel(option); + if (!label) { + return []; + } + const description = + typeof option === "string" + ? label + : isRecord(option) + ? (stringField(option, ["description", "detail", "subtitle"]) ?? label) + : label; + return [{ label, description }]; + }); + return extracted.length > 0 ? extracted : [{ label: "OK", description: "Continue" }]; +} + +function extractQuestion( + question: unknown, + fallbackTitle: string | undefined, + index: number, +): UserInputQuestion { + const record = isRecord(question) ? question : {}; + const nestedQuestion = nestedRecord(record, ["question"]); + const questionSource = nestedQuestion ?? record; + const questionText = + (typeof question === "string" ? trimmed(question) : undefined) ?? + stringField(questionSource, ["question", "prompt", "text", "content", "message"]) ?? + fallbackTitle ?? + `Question ${index + 1}`; + const id = stringField(questionSource, ["id", "questionId", "key"]) ?? questionText; + return { + id, + header: + stringField(questionSource, ["header", "title", "label"]) ?? fallbackTitle ?? "Question", + question: questionText, + multiSelect: + booleanField(questionSource, ["multiSelect", "allowMultiple", "allow_multiple"]) === true, + options: extractOptions(arrayField(questionSource, ["options", "choices", "answers"])), + }; +} + +export function extractXAiAskUserQuestions(params: unknown): ReadonlyArray { + const root = unwrapParams(params); + const title = stringField(root, ["title", "header", "toolTitle"]); + const questions = arrayField(root, ["questions", "items", "prompts"]); + if (questions.length > 0) { + return questions.map((question, index) => extractQuestion(question, title, index)); + } + const singleQuestion = nestedRecord(root, ["question"]) ?? root; + const singleQuestionOptions = arrayField(root, ["options", "choices", "answers"]); + const question = + singleQuestion === root || singleQuestionOptions.length === 0 + ? singleQuestion + : { ...singleQuestion, options: singleQuestionOptions }; + return [extractQuestion(question, title, 0)]; +} + +export function extractXAiExitPlanMarkdown(params: unknown): string | undefined { + const root = unwrapParams(params); + const nestedPlan = nestedRecord(root, ["plan"]); + return ( + stringField(root, ["planContent", "plan_content", "planMarkdown", "plan", "content"]) ?? + (nestedPlan ? stringField(nestedPlan, ["content", "markdown", "text"]) : undefined) + ); +} + +function answerValues(answer: unknown): ReadonlyArray { + if (Array.isArray(answer)) { + return answer.flatMap((entry) => { + const text = typeof entry === "string" ? trimmed(entry) : undefined; + return text ? [text] : []; + }); + } + const text = typeof answer === "string" ? trimmed(answer) : undefined; + return text ? [text] : []; +} + +export function makeXAiAskUserQuestionResponse(answers: ProviderUserInputAnswers): { + readonly outcome: "accepted"; + readonly answers: Record>; +} { + return { + outcome: "accepted", + answers: Object.fromEntries( + Object.entries(answers).map(([questionId, answer]) => [questionId, answerValues(answer)]), + ), + }; +} + +export function makeXAiExitPlanModeResponse(): { readonly outcome: "approved" } { + return { outcome: "approved" }; +} From f25bc66451612b8d5e7e3becb315b180dbab2723 Mon Sep 17 00:00:00 2001 From: Jaaneek Date: Thu, 4 Jun 2026 22:54:50 +0100 Subject: [PATCH 06/10] Stop bridging Grok exit plan requests. --- apps/server/scripts/acp-mock-agent.ts | 22 ---------- .../src/provider/Layers/GrokAdapter.test.ts | 41 ------------------- .../server/src/provider/Layers/GrokAdapter.ts | 32 --------------- .../src/provider/acp/XAiAcpExtension.ts | 14 ------- 4 files changed, 109 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 0e25ccc9b30..50e9c91061e 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -19,7 +19,6 @@ const emitInterleavedAssistantToolCalls = const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; -const emitXAiExitPlanMode = process.env.T3_ACP_EMIT_XAI_EXIT_PLAN_MODE === "1"; const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; @@ -591,27 +590,6 @@ const program = Effect.gen(function* () { return { stopReason: "end_turn" }; } - if (emitXAiExitPlanMode) { - const result = yield* agent.client.extRequest("_x.ai/exit_plan_mode", { - method: "x.ai/exit_plan_mode", - params: { - sessionId: requestedSessionId, - toolCallId: "exit-plan-mode-tool-call-1", - planContent: "# Grok plan\n\n- Inspect the workspace\n- Apply the fix", - }, - }); - if ( - typeof result !== "object" || - result === null || - !("outcome" in result) || - result.outcome !== "approved" - ) { - throw new Error("Expected _x.ai/exit_plan_mode approved outcome."); - } - - return { stopReason: "end_turn" }; - } - yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, update: { diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 42c5fbf4526..1d8d2da498d 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -335,47 +335,6 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); - it.effect("handles xAI exit_plan_mode extension requests", () => - Effect.gen(function* () { - const threadId = ThreadId.make("grok-xai-exit-plan-mode"); - const wrapperPath = yield* Effect.promise(() => - makeMockGrokWrapper({ T3_ACP_EMIT_XAI_EXIT_PLAN_MODE: "1" }), - ); - const adapter = yield* makeTestAdapter(wrapperPath); - const proposedPlan = - yield* Deferred.make>(); - - const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { - if ( - String(event.threadId) !== String(threadId) || - event.type !== "turn.proposed.completed" - ) { - return Effect.void; - } - return Deferred.succeed(proposedPlan, event).pipe(Effect.ignore); - }).pipe(Effect.forkChild); - - yield* adapter.startSession({ - threadId, - provider: ProviderDriverKind.make("grok"), - cwd: process.cwd(), - runtimeMode: "full-access", - }); - - yield* adapter.sendTurn({ threadId, input: "propose a plan", attachments: [] }); - - const event = yield* Deferred.await(proposedPlan); - assert.equal( - event.payload.planMarkdown, - "# Grok plan\n\n- Inspect the workspace\n- Apply the fix", - ); - assert.equal(event.raw?.method, "_x.ai/exit_plan_mode"); - - yield* Fiber.interrupt(eventsFiber); - yield* adapter.stopSession(threadId); - }), - ); - it.effect("continues streaming events when native notification logging fails", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-native-log-failure"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 0e1207a06b2..d788fdf6c73 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -59,11 +59,8 @@ import { } from "../acp/GrokAcpSupport.ts"; import { extractXAiAskUserQuestions, - extractXAiExitPlanMarkdown, makeXAiAskUserQuestionResponse, - makeXAiExitPlanModeResponse, XAiAskUserQuestionRequest, - XAiExitPlanModeRequest, } from "../acp/XAiAcpExtension.ts"; import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -436,35 +433,6 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ), { discard: true }, ); - yield* Effect.forEach( - ["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"] as const, - (method) => - acp.handleExtRequest(method, XAiExitPlanModeRequest, (params) => - mapAcpCallbackFailure( - Effect.gen(function* () { - yield* logNative(input.threadId, method, params); - const planMarkdown = extractXAiExitPlanMarkdown(params)?.trim(); - if (planMarkdown) { - yield* offerRuntimeEvent({ - type: "turn.proposed.completed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId: sessions.get(input.threadId)?.activeTurnId, - payload: { planMarkdown }, - raw: { - source: "acp.grok.extension", - method, - payload: params, - }, - }); - } - return makeXAiExitPlanModeResponse(); - }), - ), - ), - { discard: true }, - ); yield* acp.handleRequestPermission((params) => mapAcpCallbackFailure( Effect.gen(function* () { diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts index 5177cae969a..5cbf5327783 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -2,7 +2,6 @@ import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contr import * as Schema from "effect/Schema"; export const XAiAskUserQuestionRequest = Schema.Unknown; -export const XAiExitPlanModeRequest = Schema.Unknown; type UnknownRecord = Record; @@ -136,15 +135,6 @@ export function extractXAiAskUserQuestions(params: unknown): ReadonlyArray { if (Array.isArray(answer)) { return answer.flatMap((entry) => { @@ -167,7 +157,3 @@ export function makeXAiAskUserQuestionResponse(answers: ProviderUserInputAnswers ), }; } - -export function makeXAiExitPlanModeResponse(): { readonly outcome: "approved" } { - return { outcome: "approved" }; -} From a7ed07b35459896bfd944cb21f5f3f27fca808d5 Mon Sep 17 00:00:00 2001 From: Jaaneek Date: Fri, 5 Jun 2026 15:04:34 +0100 Subject: [PATCH 07/10] Tighten Grok xAI question payload handling. --- .../src/provider/acp/XAiAcpExtension.test.ts | 72 ++++++++ .../src/provider/acp/XAiAcpExtension.ts | 169 ++++++------------ 2 files changed, 122 insertions(+), 119 deletions(-) create mode 100644 apps/server/src/provider/acp/XAiAcpExtension.test.ts diff --git a/apps/server/src/provider/acp/XAiAcpExtension.test.ts b/apps/server/src/provider/acp/XAiAcpExtension.test.ts new file mode 100644 index 00000000000..4810b4b352d --- /dev/null +++ b/apps/server/src/provider/acp/XAiAcpExtension.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import * as Schema from "effect/Schema"; + +import { extractXAiAskUserQuestions, XAiAskUserQuestionRequest } from "./XAiAcpExtension.ts"; + +const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest); + +describe("XAiAcpExtension", () => { + it("extracts questions from the real xAI ask_user_question payload shape", () => { + const questions = extractXAiAskUserQuestions({ + sessionId: "session-1", + toolCallId: "tool-call-1", + mode: "default", + questions: [ + { + id: "scope", + question: "Which scope should Grok use?", + options: [ + { label: "Workspace", description: "Use the current workspace" }, + { label: "Session", description: "Only use this session" }, + ], + }, + ], + }); + + expect(questions).toEqual([ + { + id: "scope", + header: "Question", + question: "Which scope should Grok use?", + multiSelect: false, + options: [ + { label: "Workspace", description: "Use the current workspace" }, + { label: "Session", description: "Only use this session" }, + ], + }, + ]); + }); + + it("extracts questions from wrapped _x.ai extension payloads", () => { + const payload = { + method: "x.ai/ask_user_question", + params: { + sessionId: "session-1", + toolCallId: "tool-call-1", + mode: "plan", + questions: [ + { + question: "Which changes should be included?", + multiSelect: true, + options: [{ label: "Tests" }, { label: "Docs" }], + }, + ], + }, + }; + const decoded = decodeXAiAskUserQuestionRequest(payload); + const questions = extractXAiAskUserQuestions(decoded); + + expect(questions).toEqual([ + { + id: "Which changes should be included?", + header: "Question", + question: "Which changes should be included?", + multiSelect: true, + options: [ + { label: "Tests", description: "Tests" }, + { label: "Docs", description: "Docs" }, + ], + }, + ]); + }); +}); diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts index 5cbf5327783..00d617f3f13 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -1,138 +1,69 @@ import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; +import * as Exit from "effect/Exit"; import * as Schema from "effect/Schema"; -export const XAiAskUserQuestionRequest = Schema.Unknown; +const XAiAskUserQuestionOption = Schema.Struct({ + label: Schema.String, + description: Schema.optional(Schema.String), + preview: Schema.optional(Schema.String), + id: Schema.optional(Schema.String), +}); -type UnknownRecord = Record; +const XAiAskUserQuestion = Schema.Struct({ + id: Schema.optional(Schema.String), + question: Schema.String, + options: Schema.Array(XAiAskUserQuestionOption), + multiSelect: Schema.optional(Schema.Boolean), +}); -function trimmed(value: string | undefined): string | undefined { - const text = value?.trim(); - return text && text.length > 0 ? text : undefined; -} +const XAiAskUserQuestionParams = Schema.Struct({ + sessionId: Schema.String, + toolCallId: Schema.String, + questions: Schema.Array(XAiAskUserQuestion), + mode: Schema.Union([Schema.Literal("default"), Schema.Literal("plan")]), +}); -function isRecord(value: unknown): value is UnknownRecord { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +const XAiWrappedAskUserQuestionParams = Schema.Struct({ + method: Schema.Literal("x.ai/ask_user_question"), + params: XAiAskUserQuestionParams, +}); -function stringField(record: UnknownRecord, keys: ReadonlyArray): string | undefined { - for (const key of keys) { - const value = record[key]; - if (typeof value === "string") { - const text = trimmed(value); - if (text) { - return text; - } - } - } - return undefined; -} +export const XAiAskUserQuestionRequest = Schema.Unknown; -function booleanField(record: UnknownRecord, keys: ReadonlyArray): boolean | undefined { - for (const key of keys) { - const value = record[key]; - if (typeof value === "boolean") { - return value; - } - } - return undefined; -} +type XAiAskUserQuestionRequestParams = typeof XAiAskUserQuestionParams.Type; -function arrayField(record: UnknownRecord, keys: ReadonlyArray): ReadonlyArray { - for (const key of keys) { - const value = record[key]; - if (Array.isArray(value)) { - return value; - } - } - return []; -} +const decodeXAiAskUserQuestionParams = Schema.decodeUnknownSync(XAiAskUserQuestionParams); +const decodeXAiWrappedAskUserQuestionParamsExit = Schema.decodeUnknownExit( + XAiWrappedAskUserQuestionParams, +); -function nestedRecord( - record: UnknownRecord, - keys: ReadonlyArray, -): UnknownRecord | undefined { - for (const key of keys) { - const value = record[key]; - if (isRecord(value)) { - return value; - } - } - return undefined; +function trimmed(value: string | undefined): string | undefined { + const text = value?.trim(); + return text && text.length > 0 ? text : undefined; } -function unwrapParams(params: unknown): UnknownRecord { - if (!isRecord(params)) { - return {}; +function unwrapAskUserQuestionParams(params: unknown): XAiAskUserQuestionRequestParams { + const wrapped = decodeXAiWrappedAskUserQuestionParamsExit(params); + if (Exit.isSuccess(wrapped)) { + return wrapped.value.params; } - const request = nestedRecord(params, ["request"]); - const requestInput = request ? nestedRecord(request, ["input", "arguments", "args"]) : undefined; - return nestedRecord(params, ["input", "arguments", "args", "params"]) ?? requestInput ?? params; -} - -function extractOptionLabel(option: unknown): string | undefined { - return typeof option === "string" - ? trimmed(option) - : isRecord(option) - ? stringField(option, ["label", "value", "id", "text", "title", "name"]) - : undefined; -} - -function extractOptions(options: ReadonlyArray) { - const extracted = (options ?? []).flatMap((option) => { - const label = extractOptionLabel(option); - if (!label) { - return []; - } - const description = - typeof option === "string" - ? label - : isRecord(option) - ? (stringField(option, ["description", "detail", "subtitle"]) ?? label) - : label; - return [{ label, description }]; - }); - return extracted.length > 0 ? extracted : [{ label: "OK", description: "Continue" }]; -} - -function extractQuestion( - question: unknown, - fallbackTitle: string | undefined, - index: number, -): UserInputQuestion { - const record = isRecord(question) ? question : {}; - const nestedQuestion = nestedRecord(record, ["question"]); - const questionSource = nestedQuestion ?? record; - const questionText = - (typeof question === "string" ? trimmed(question) : undefined) ?? - stringField(questionSource, ["question", "prompt", "text", "content", "message"]) ?? - fallbackTitle ?? - `Question ${index + 1}`; - const id = stringField(questionSource, ["id", "questionId", "key"]) ?? questionText; - return { - id, - header: - stringField(questionSource, ["header", "title", "label"]) ?? fallbackTitle ?? "Question", - question: questionText, - multiSelect: - booleanField(questionSource, ["multiSelect", "allowMultiple", "allow_multiple"]) === true, - options: extractOptions(arrayField(questionSource, ["options", "choices", "answers"])), - }; + return decodeXAiAskUserQuestionParams(params); } export function extractXAiAskUserQuestions(params: unknown): ReadonlyArray { - const root = unwrapParams(params); - const title = stringField(root, ["title", "header", "toolTitle"]); - const questions = arrayField(root, ["questions", "items", "prompts"]); - if (questions.length > 0) { - return questions.map((question, index) => extractQuestion(question, title, index)); - } - const singleQuestion = nestedRecord(root, ["question"]) ?? root; - const singleQuestionOptions = arrayField(root, ["options", "choices", "answers"]); - const question = - singleQuestion === root || singleQuestionOptions.length === 0 - ? singleQuestion - : { ...singleQuestion, options: singleQuestionOptions }; - return [extractQuestion(question, title, 0)]; + return unwrapAskUserQuestionParams(params).questions.map((question) => ({ + id: question.id ?? question.question, + header: "Question", + question: question.question, + multiSelect: question.multiSelect === true, + options: + question.options.length > 0 + ? question.options.map((option) => ({ + label: option.label, + description: option.description ?? option.label, + })) + : [{ label: "OK", description: "Continue" }], + })); } function answerValues(answer: unknown): ReadonlyArray { From e0a6b4477514dfc65e7f59ad7df22d39c4db19ff Mon Sep 17 00:00:00 2001 From: Jaaneek Date: Fri, 5 Jun 2026 20:45:31 +0100 Subject: [PATCH 08/10] Handle Grok user question cancellation explicitly --- apps/server/scripts/acp-mock-agent.ts | 1 + .../server/src/provider/Layers/GrokAdapter.ts | 33 ++-- .../src/provider/acp/XAiAcpExtension.test.ts | 184 +++++++++++++++++- .../src/provider/acp/XAiAcpExtension.ts | 127 +++++++++--- 4 files changed, 309 insertions(+), 36 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 50e9c91061e..054be108e33 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -566,6 +566,7 @@ const program = Effect.gen(function* () { questions: [ { question: "Which scope should Grok use?", + multiSelect: null, options: [ { label: "Workspace", description: "Use the current workspace" }, { label: "Session", description: "Only use this session" }, diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index d788fdf6c73..ed4097ecda3 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -59,6 +59,7 @@ import { } from "../acp/GrokAcpSupport.ts"; import { extractXAiAskUserQuestions, + makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, XAiAskUserQuestionRequest, } from "../acp/XAiAcpExtension.ts"; @@ -86,8 +87,12 @@ interface PendingApproval { readonly decision: Deferred.Deferred; } +type PendingUserInputResolution = + | { readonly _tag: "answered"; readonly answers: ProviderUserInputAnswers } + | { readonly _tag: "cancelled" }; + interface PendingUserInput { - readonly answers: Deferred.Deferred; + readonly resolution: Deferred.Deferred; } interface GrokSessionContext { @@ -116,12 +121,12 @@ function settlePendingApprovalsAsCancelled( ); } -function settlePendingUserInputsAsEmptyAnswers( +function settlePendingUserInputsAsCancelled( pendingUserInputs: ReadonlyMap, ): Effect.Effect { return Effect.forEach( Array.from(pendingUserInputs.values()), - (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore), + (pending) => Deferred.succeed(pending.resolution, { _tag: "cancelled" }).pipe(Effect.ignore), { discard: true }, ); } @@ -308,7 +313,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte if (ctx.stopped) return; ctx.stopped = true; yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); - yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); if (ctx.notificationFiber) { yield* Fiber.interrupt(ctx.notificationFiber); } @@ -395,8 +400,8 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte yield* logNative(input.threadId, method, params); const requestId = ApprovalRequestId.make(yield* randomUUIDv4); const runtimeRequestId = RuntimeRequestId.make(requestId); - const answers = yield* Deferred.make(); - pendingUserInputs.set(requestId, { answers }); + const resolution = yield* Deferred.make(); + pendingUserInputs.set(requestId, { resolution }); yield* offerRuntimeEvent({ type: "user-input.requested", ...(yield* makeEventStamp()), @@ -411,8 +416,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte payload: params, }, }); - const resolved = yield* Deferred.await(answers); + const resolved = yield* Deferred.await(resolution); pendingUserInputs.delete(requestId); + const resolvedAnswers = resolved._tag === "answered" ? resolved.answers : {}; yield* offerRuntimeEvent({ type: "user-input.resolved", ...(yield* makeEventStamp()), @@ -420,14 +426,19 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte threadId: input.threadId, turnId: sessions.get(input.threadId)?.activeTurnId, requestId: runtimeRequestId, - payload: { answers: resolved }, + payload: { answers: resolvedAnswers }, raw: { source: "acp.grok.extension", method, payload: params, }, }); - return makeXAiAskUserQuestionResponse(resolved); + switch (resolved._tag) { + case "answered": + return makeXAiAskUserQuestionResponse(params, resolved.answers); + case "cancelled": + return makeXAiAskUserQuestionCancelledResponse(); + } }), ), ), @@ -804,7 +815,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte Effect.gen(function* () { const ctx = yield* requireSession(threadId); yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); - yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); yield* Effect.ignore( ctx.acp.cancel.pipe( Effect.mapError((error) => @@ -847,7 +858,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte detail: `Unknown pending user-input request: ${requestId}`, }); } - yield* Deferred.succeed(pending.answers, answers); + yield* Deferred.succeed(pending.resolution, { _tag: "answered", answers }); }); const readThread: GrokAdapterShape["readThread"] = (threadId) => diff --git a/apps/server/src/provider/acp/XAiAcpExtension.test.ts b/apps/server/src/provider/acp/XAiAcpExtension.test.ts index 4810b4b352d..a42561d9ae2 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.test.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.test.ts @@ -1,7 +1,12 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; -import { extractXAiAskUserQuestions, XAiAskUserQuestionRequest } from "./XAiAcpExtension.ts"; +import { + extractXAiAskUserQuestions, + makeXAiAskUserQuestionCancelledResponse, + makeXAiAskUserQuestionResponse, + XAiAskUserQuestionRequest, +} from "./XAiAcpExtension.ts"; const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest); @@ -39,7 +44,7 @@ describe("XAiAcpExtension", () => { it("extracts questions from wrapped _x.ai extension payloads", () => { const payload = { - method: "x.ai/ask_user_question", + method: "_x.ai/ask_user_question", params: { sessionId: "session-1", toolCallId: "tool-call-1", @@ -69,4 +74,177 @@ describe("XAiAcpExtension", () => { }, ]); }); + + it("treats nullable multiSelect from Grok as single-select", () => { + const questions = extractXAiAskUserQuestions({ + sessionId: "session-1", + toolCallId: "tool-call-1", + mode: "default", + questions: [ + { + question: "Which label should Grok use?", + multiSelect: null, + options: [ + { label: "Alpha", description: "Use the Alpha label" }, + { label: "Beta", description: "Use the Beta label" }, + { label: "Other", description: "Use the Other label" }, + ], + }, + ], + }); + + expect(questions).toEqual([ + { + id: "Which label should Grok use?", + header: "Question", + question: "Which label should Grok use?", + multiSelect: false, + options: [ + { label: "Alpha", description: "Use the Alpha label" }, + { label: "Beta", description: "Use the Beta label" }, + { label: "Other", description: "Use the Other label" }, + ], + }, + ]); + }); + + it("maps UI question ids back to xAI question text in accepted responses", () => { + const response = makeXAiAskUserQuestionResponse( + { + sessionId: "session-1", + toolCallId: "tool-call-1", + mode: "default", + questions: [ + { + id: "scope", + question: "Which scope should Grok use?", + options: [ + { label: "workspace", description: "Use the current workspace" }, + { label: "session", description: "Only use this session" }, + ], + }, + ], + }, + { scope: "workspace" }, + ); + + expect(response).toEqual({ + outcome: "accepted", + answers: { + "Which scope should Grok use?": ["workspace"], + }, + }); + }); + + it("orders accepted answers by the original xAI question order", () => { + const response = makeXAiAskUserQuestionResponse( + { + sessionId: "session-1", + toolCallId: "tool-call-1", + mode: "default", + questions: [ + { + id: "first", + question: "First question?", + options: [{ label: "A", description: "A" }], + }, + { + id: "second", + question: "Second question?", + options: [{ label: "B", description: "B" }], + }, + ], + }, + { + second: "B", + first: "A", + }, + ); + + expect(Object.keys(response.answers)).toEqual(["First question?", "Second question?"]); + expect(response).toMatchObject({ + outcome: "accepted", + answers: { + "First question?": ["A"], + "Second question?": ["B"], + }, + }); + }); + + it("encodes typed custom answers as xAI Other annotations", () => { + const response = makeXAiAskUserQuestionResponse( + { + method: "x.ai/ask_user_question", + params: { + sessionId: "session-1", + toolCallId: "tool-call-1", + mode: "default", + questions: [ + { + question: "Which ice cream flavor?", + options: [ + { label: "vanilla", description: "Vanilla flavor" }, + { label: "chocolate", description: "Chocolate flavor" }, + ], + }, + ], + }, + }, + { "Which ice cream flavor?": "pistachio" }, + ); + + expect(response).toEqual({ + outcome: "accepted", + answers: { + "Which ice cream flavor?": ["Other"], + }, + annotations: { + "Which ice cream flavor?": { + notes: "pistachio", + }, + }, + }); + }); + + it("encodes interrupted dialogs as xAI cancelled responses", () => { + expect(makeXAiAskUserQuestionCancelledResponse()).toEqual({ + outcome: "cancelled", + }); + }); + + it("does not echo preview annotations for multi-select answers", () => { + const response = makeXAiAskUserQuestionResponse( + { + sessionId: "session-1", + toolCallId: "tool-call-1", + mode: "default", + questions: [ + { + question: "Which files should Grok touch?", + multiSelect: true, + options: [ + { + label: "Tests", + description: "Update tests", + preview: "test preview", + }, + { + label: "Docs", + description: "Update docs", + preview: "docs preview", + }, + ], + }, + ], + }, + { "Which files should Grok touch?": ["Tests", "Docs"] }, + ); + + expect(response).toEqual({ + outcome: "accepted", + answers: { + "Which files should Grok touch?": ["Tests", "Docs"], + }, + }); + }); }); diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts index 00d617f3f13..6c774c7f8d5 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -1,5 +1,4 @@ import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; -import * as Exit from "effect/Exit"; import * as Schema from "effect/Schema"; const XAiAskUserQuestionOption = Schema.Struct({ @@ -13,44 +12,43 @@ const XAiAskUserQuestion = Schema.Struct({ id: Schema.optional(Schema.String), question: Schema.String, options: Schema.Array(XAiAskUserQuestionOption), - multiSelect: Schema.optional(Schema.Boolean), + multiSelect: Schema.optional(Schema.NullOr(Schema.Boolean)), }); const XAiAskUserQuestionParams = Schema.Struct({ sessionId: Schema.String, toolCallId: Schema.String, questions: Schema.Array(XAiAskUserQuestion), - mode: Schema.Union([Schema.Literal("default"), Schema.Literal("plan")]), + mode: Schema.Literals(["default", "plan"]), }); const XAiWrappedAskUserQuestionParams = Schema.Struct({ - method: Schema.Literal("x.ai/ask_user_question"), + method: Schema.Literals(["x.ai/ask_user_question", "_x.ai/ask_user_question"]), params: XAiAskUserQuestionParams, }); -export const XAiAskUserQuestionRequest = Schema.Unknown; +export const XAiAskUserQuestionRequest = Schema.Union([ + XAiAskUserQuestionParams, + XAiWrappedAskUserQuestionParams, +]); type XAiAskUserQuestionRequestParams = typeof XAiAskUserQuestionParams.Type; - -const decodeXAiAskUserQuestionParams = Schema.decodeUnknownSync(XAiAskUserQuestionParams); -const decodeXAiWrappedAskUserQuestionParamsExit = Schema.decodeUnknownExit( - XAiWrappedAskUserQuestionParams, -); +type XAiAskUserQuestionRequest = typeof XAiAskUserQuestionRequest.Type; function trimmed(value: string | undefined): string | undefined { const text = value?.trim(); return text && text.length > 0 ? text : undefined; } -function unwrapAskUserQuestionParams(params: unknown): XAiAskUserQuestionRequestParams { - const wrapped = decodeXAiWrappedAskUserQuestionParamsExit(params); - if (Exit.isSuccess(wrapped)) { - return wrapped.value.params; - } - return decodeXAiAskUserQuestionParams(params); +function unwrapAskUserQuestionParams( + params: XAiAskUserQuestionRequest, +): XAiAskUserQuestionRequestParams { + return "params" in params ? params.params : params; } -export function extractXAiAskUserQuestions(params: unknown): ReadonlyArray { +export function extractXAiAskUserQuestions( + params: XAiAskUserQuestionRequest, +): ReadonlyArray { return unwrapAskUserQuestionParams(params).questions.map((question) => ({ id: question.id ?? question.question, header: "Question", @@ -66,6 +64,31 @@ export function extractXAiAskUserQuestions(params: unknown): ReadonlyArray>; + readonly annotations?: Record; +} + +interface XAiAskUserQuestionCancelledResponse { + readonly outcome: "cancelled"; +} + +export type XAiAskUserQuestionResponse = + | XAiAskUserQuestionAcceptedResponse + | XAiAskUserQuestionCancelledResponse; + +interface NormalizedXAiAnswer { + readonly questionText: string; + readonly selectedLabels: ReadonlyArray; + readonly annotation?: XAiAskUserQuestionAnnotation; +} + function answerValues(answer: unknown): ReadonlyArray { if (Array.isArray(answer)) { return answer.flatMap((entry) => { @@ -77,14 +100,74 @@ function answerValues(answer: unknown): ReadonlyArray { return text ? [text] : []; } -export function makeXAiAskUserQuestionResponse(answers: ProviderUserInputAnswers): { - readonly outcome: "accepted"; - readonly answers: Record>; -} { +function normalizeAnswerForXAi( + question: XAiAskUserQuestionRequestParams["questions"][number], + answer: unknown, +): NormalizedXAiAnswer | undefined { + const values = answerValues(answer); + if (values.length === 0) { + return undefined; + } + + const optionByLabel = new Map(question.options.map((option) => [option.label, option])); + const resolvedValues = values.map((value) => ({ + value, + option: optionByLabel.get(value), + })); + const selectedLabels = resolvedValues.flatMap(({ option }) => (option ? [option.label] : [])); + const notes = resolvedValues.flatMap(({ option, value }) => (option ? [] : [value])); + const preview = + question.multiSelect === true + ? undefined + : resolvedValues.map(({ option }) => trimmed(option?.preview)).find((value) => value); + + const annotation = + preview || notes.length > 0 + ? { + ...(preview ? { preview } : {}), + ...(notes.length > 0 ? { notes: notes.join("\n") } : {}), + } + : undefined; + + return { + questionText: question.question, + selectedLabels: selectedLabels.length > 0 ? selectedLabels : ["Other"], + ...(annotation ? { annotation } : {}), + }; +} + +function findQuestionAnswer( + answers: ProviderUserInputAnswers, + question: XAiAskUserQuestionRequestParams["questions"][number], +): unknown { + const key = question.id ?? question.question; + return answers[key] ?? answers[question.question]; +} + +export function makeXAiAskUserQuestionResponse( + params: XAiAskUserQuestionRequest, + answers: ProviderUserInputAnswers, +): XAiAskUserQuestionAcceptedResponse { + const questions = unwrapAskUserQuestionParams(params).questions; + const normalized = questions.flatMap((question) => { + const entry = normalizeAnswerForXAi(question, findQuestionAnswer(answers, question)); + return entry ? [entry] : []; + }); + const annotations = Object.fromEntries( + normalized.flatMap((entry) => + entry.annotation ? [[entry.questionText, entry.annotation] as const] : [], + ), + ); + return { outcome: "accepted", answers: Object.fromEntries( - Object.entries(answers).map(([questionId, answer]) => [questionId, answerValues(answer)]), + normalized.map((entry) => [entry.questionText, entry.selectedLabels]), ), + ...(Object.keys(annotations).length > 0 ? { annotations } : {}), }; } + +export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCancelledResponse { + return { outcome: "cancelled" }; +} From ba88d9add643c9bf699a7824275ba82e3e427ea3 Mon Sep 17 00:00:00 2001 From: Jaaneek Date: Fri, 5 Jun 2026 22:39:43 +0100 Subject: [PATCH 09/10] Align mock xAI cancellation handling --- apps/server/scripts/acp-mock-agent.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 054be108e33..1a38e489fb2 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -576,16 +576,19 @@ const program = Effect.gen(function* () { mode: "default", }, }); + if (typeof result !== "object" || result === null || !("outcome" in result)) { + throw new Error("Expected _x.ai/ask_user_question response outcome."); + } + if (result.outcome === "cancelled") { + return { stopReason: "end_turn" }; + } if ( - typeof result !== "object" || - result === null || - !("outcome" in result) || result.outcome !== "accepted" || !("answers" in result) || typeof result.answers !== "object" || result.answers === null ) { - throw new Error("Expected _x.ai/ask_user_question response outcome."); + throw new Error("Expected accepted _x.ai/ask_user_question response answers."); } return { stopReason: "end_turn" }; From 1aa7fc261dc691ef51d6dba74e4631bdd5c2a85d Mon Sep 17 00:00:00 2001 From: Jaaneek Date: Sat, 6 Jun 2026 16:21:00 +0100 Subject: [PATCH 10/10] Fix Grok CI test portability Use the repo test harness imports and launch mock ACP agents with the current runtime so CI does not depend on undeclared vitest or bun PATH availability. --- apps/server/src/provider/Layers/GrokAdapter.test.ts | 4 ++-- apps/server/src/provider/Layers/GrokProvider.test.ts | 2 +- apps/server/src/provider/acp/GrokAcpCliProbe.test.ts | 2 +- apps/server/src/provider/acp/GrokAcpSupport.test.ts | 2 +- apps/server/src/textGeneration/GrokTextGeneration.test.ts | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 1d8d2da498d..b6e45b5026a 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -28,7 +28,7 @@ const decodeGrokSettings = Schema.decodeSync(GrokSettings); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); -const bunExe = "bun"; +const mockAgentCommand = process.execPath; async function makeMockGrokWrapper(extraEnv?: Record) { const dir = await mkdtemp(path.join(os.tmpdir(), "grok-acp-mock-")); @@ -38,7 +38,7 @@ async function makeMockGrokWrapper(extraEnv?: Record) { .join("\n"); const script = `#!/bin/sh ${envExports} -exec ${JSON.stringify(bunExe)} ${JSON.stringify(mockAgentPath)} "$@" +exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} "$@" `; await writeFile(wrapperPath, script, "utf8"); await chmod(wrapperPath, 0o755); diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 4fa1aa4b77d..d5453ef60cb 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "vite-plus/test"; import { ChildProcessSpawner } from "effect/unstable/process"; import { GrokSettings } from "@t3tools/contracts"; diff --git a/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts b/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts index 74d85243fde..222fc4a12d5 100644 --- a/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts +++ b/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts @@ -11,7 +11,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { describe, expect } from "vitest"; +import { describe, expect } from "vite-plus/test"; import { makeGrokAcpRuntime } from "./GrokAcpSupport.ts"; diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index a8cd347fad5..8102b6469c1 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -1,6 +1,6 @@ import * as Effect from "effect/Effect"; import * as EffectAcpErrors from "effect-acp/errors"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "vite-plus/test"; import { applyGrokAcpModelSelection, diff --git a/apps/server/src/textGeneration/GrokTextGeneration.test.ts b/apps/server/src/textGeneration/GrokTextGeneration.test.ts index f054e890ef9..58ce165752c 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.test.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.test.ts @@ -10,7 +10,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { createModelSelection } from "@t3tools/shared/model"; -import { expect } from "vitest"; +import { expect } from "vite-plus/test"; import { GrokSettings, ProviderInstanceId } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; @@ -42,7 +42,7 @@ function makeAcpGrokWrapper(dir: string, env: Record): string { ' printf "%s\\n" "unexpected args: $*" >&2', " exit 11", "fi", - `exec bun ${JSON.stringify(mockAgentPath)}`, + `exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)}`, "", ].join("\n"), "utf8",