From baf640026de3f3bad720f7034b9f8f29cfb0a224 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:02:33 +0530 Subject: [PATCH 1/5] feat(agents): add Grok Build ACP provider --- src/local-agent-acp.ts | 165 +++++++++++++++++++- src/local-agent-adapters.ts | 2 + src/local-agent-availability.ts | 2 + src/local-agent-errors.ts | 1 + src/local-agent-grok.ts | 256 ++++++++++++++++++++++++++++++++ src/local-agent-profiles.ts | 5 +- 6 files changed, 423 insertions(+), 8 deletions(-) create mode 100644 src/local-agent-grok.ts diff --git a/src/local-agent-acp.ts b/src/local-agent-acp.ts index 9bb67536..a5d9c369 100644 --- a/src/local-agent-acp.ts +++ b/src/local-agent-acp.ts @@ -10,6 +10,14 @@ import { isProgrammerDefect, } from "./local-agent-errors.js"; import { terminateProcessTree } from "./process-platform.js"; +import { + GrokPromptCompletionRegistry, + GROK_DEFAULT_MODEL, + parseGrokPromptCompletion, + readGrokSessionState, + resolveGrokEffort, + resolveGrokModelId, +} from "./local-agent-grok.js"; import type { LocalAgentDriver, LocalAgentRunCallbacks, @@ -20,11 +28,12 @@ import type { LocalAgentWriteMode, } from "./local-agent-runtime.js"; -export type AcpProvider = "cursor" | "copilot"; +export type AcpProvider = "cursor" | "copilot" | "grok"; const MAX_ACP_QUEUE_ITEMS = 10_000; const MAX_ACP_STDERR_BYTES = 32 * 1024; const ACP_INITIALIZE_TIMEOUT_MS = 10_000; +const ACP_GROK_PROMPT_COMPLETION_TIMEOUT_MS = 10 * 60_000; const require = createRequire(import.meta.url); const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; const DEVSPACE_VERSION = readDevspaceVersion(); @@ -34,6 +43,7 @@ const observeChildError = (): void => {}; const ACP_COMMANDS: Record = { cursor: ["cursor-agent", "acp"], copilot: ["copilot", "--acp"], + grok: ["grok", "agent", "stdio"], }; interface AcpConnectionLike { @@ -65,6 +75,8 @@ export interface AcpRuntimeOptions { liveSessions?: Set; sessionWriteModes?: Map; sessionMetadata?: Map; + grokCompletionRegistry?: GrokPromptCompletionRegistry; + promptCompletionTimeoutMs?: number; } export class AcpRuntime implements LocalAgentRuntime { @@ -76,7 +88,10 @@ export class AcpRuntime implements LocalAgentRuntime { private readonly liveSessions: Set; private readonly sessionWriteModes: Map; private readonly sessionMetadata: Map; + private readonly grokCompletionRegistry?: GrokPromptCompletionRegistry; + private readonly promptCompletionTimeoutMs: number; private readonly activeSessions = new Set(); + private promptSequence = 0; private alive = true; private closed = false; @@ -89,10 +104,14 @@ export class AcpRuntime implements LocalAgentRuntime { this.liveSessions = options.liveSessions ?? new Set(); this.sessionWriteModes = options.sessionWriteModes ?? new Map(); this.sessionMetadata = options.sessionMetadata ?? new Map(); + this.grokCompletionRegistry = options.grokCompletionRegistry; + this.promptCompletionTimeoutMs = options.promptCompletionTimeoutMs ?? ACP_GROK_PROMPT_COMPLETION_TIMEOUT_MS; void this.connection.closed.then(() => { if (!this.closed) this.alive = false; + this.grokCompletionRegistry?.rejectAll(new Error(`${this.provider} ACP connection closed.`)); }).catch(() => { if (!this.closed) this.alive = false; + this.grokCompletionRegistry?.rejectAll(new Error(`${this.provider} ACP connection closed.`)); }); this.child?.once("exit", () => { this.alive = false; @@ -125,12 +144,36 @@ export class AcpRuntime implements LocalAgentRuntime { this.activeSessions.add(sessionId); const queue = this.queues.get(sessionId) ?? { values: [] }; this.queues.set(sessionId, queue); + const promptId = this.provider === "grok" ? this.nextPromptId() : undefined; + const completion = promptId && this.grokCompletionRegistry + ? this.grokCompletionRegistry.wait( + sessionId, + promptId, + this.promptCompletionTimeoutMs, + () => new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: this.provider, + operation: "run", + retryable: true, + message: "Grok ACP did not report completion for the prompt before the timeout.", + }), + ) + : undefined; try { queue.values.length = 0; - const response = await this.connection.agent.request("session/prompt", { + const standardResponse = this.connection.agent.request("session/prompt", { sessionId, prompt: [{ type: "text", text: input.prompt }], + ...(promptId ? { _meta: { promptId, requestId: promptId } } : {}), }); + const response = completion + ? await Promise.race([standardResponse, completion]) + : await standardResponse; + if (completion && isGrokPromptCompletion(response)) { + await yieldToAcpQueue(); + } else if (promptId) { + this.grokCompletionRegistry?.markCompleted(sessionId, promptId); + } const updates = queue.values.splice(0); const finalResponse = extractAcpText(updates); if (!finalResponse) { @@ -150,6 +193,7 @@ export class AcpRuntime implements LocalAgentRuntime { items: updates, }; } finally { + if (promptId) this.grokCompletionRegistry?.remove(sessionId, promptId); this.activeSessions.delete(sessionId); } }, @@ -178,6 +222,7 @@ export class AcpRuntime implements LocalAgentRuntime { this.sessionWriteModes.clear(); this.sessionMetadata.clear(); this.activeSessions.clear(); + this.grokCompletionRegistry?.rejectAll(new Error(`${this.provider} ACP runtime closed.`)); this.connection.close(new Error(`${this.provider} ACP runtime closed.`)); if (this.child && this.child.exitCode === null) { const detached = process.platform !== "win32"; @@ -251,7 +296,9 @@ export class AcpRuntime implements LocalAgentRuntime { } private cacheSessionMetadata(sessionId: string, response: unknown): void { - if (hasAcpConfigOptions(response)) this.sessionMetadata.set(sessionId, response); + if (hasAcpConfigOptions(response) || (this.provider === "grok" && readGrokSessionState(response))) { + this.sessionMetadata.set(sessionId, response); + } } private async configureSession( @@ -261,6 +308,10 @@ export class AcpRuntime implements LocalAgentRuntime { isNewSession = false, ): Promise { const metadata = response ?? this.sessionMetadata.get(sessionId); + if (this.provider === "grok") { + await this.configureGrokSession(sessionId, input, metadata, isNewSession); + return; + } const canConfigure = isNewSession || hasAcpConfigOptions(metadata); if (!canConfigure) { const requested = [ @@ -293,11 +344,68 @@ export class AcpRuntime implements LocalAgentRuntime { } } + private async configureGrokSession( + sessionId: string, + input: LocalAgentRunInput, + response: unknown, + isNewSession: boolean, + ): Promise { + const state = readGrokSessionState(response); + if (!state) { + const requested = [ + input.model && (isNewSession || input.modelOverrideRequested) ? "model" : undefined, + input.effort && (isNewSession || input.effortOverrideRequested) ? "effort" : undefined, + ].filter(Boolean).join(" and "); + if (requested) { + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: this.provider, + operation: "configure_session", + retryable: false, + message: `${this.provider} ACP did not advertise typed model metadata required for the requested ${requested} override.`, + }); + } + return; + } + + const currentModel = state.currentModelId; + const requestedModel = input.model + ? resolveGrokModelId(input.model, state) + : currentModel ?? state.availableModels[0]?.id ?? GROK_DEFAULT_MODEL; + const effort = input.effort + ? resolveGrokEffort(input.effort, state, requestedModel) + : undefined; + const shouldSetModel = Boolean(input.model && requestedModel !== currentModel) || effort !== undefined; + if (!shouldSetModel) return; + + try { + await this.connection.agent.request("session/set_model", { + sessionId, + modelId: requestedModel, + ...(effort ? { _meta: { reasoningEffort: effort } } : {}), + }); + } catch (cause) { + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: this.provider, + operation: "configure_session", + retryable: false, + cause, + message: `${this.provider} ACP could not select model '${requestedModel}'.`, + }); + } + } + private additionalDirectoryParams(): { additionalDirectories?: string[] } { // DevSpace currently authorizes exactly one workspace root per agent turn. // Do not advertise an empty additional-directory scope to ACP providers. return {}; } + + private nextPromptId(): string { + this.promptSequence += 1; + return `devspace-grok-prompt-${this.promptSequence}`; + } } export class AcpLocalAgentDriver implements LocalAgentDriver { @@ -339,7 +447,7 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { message: `${this.provider} executable was not found.`, }); } - const args = acpCommandArgs(this.provider, context); + const args = acpCommandArgs(this.provider, context, this.env); const child = spawn(command, args, { cwd: resolve(context.workspaceRoot), env: this.env, @@ -381,6 +489,9 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { const { client, methods, ndJsonStream } = await import("@agentclientprotocol/sdk"); const queues = new Map(); const sessionWriteModes = new Map(); + const grokCompletionRegistry = this.provider === "grok" + ? new GrokPromptCompletionRegistry() + : undefined; const app = client({ name: "DevSpace" }) .onRequest(methods.client.session.requestPermission, (context) => { const writeMode = sessionWriteModes.get(context.params.sessionId); @@ -394,6 +505,18 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { const queue = queues.get(sessionId); if (queue) appendAcpQueueValue(queue, context.params); }); + if (grokCompletionRegistry) { + for (const method of [ + "x.ai/session/prompt_complete", + "_x.ai/session/prompt_complete", + "x.ai/session/update", + "_x.ai/session/update", + ]) { + app.onNotification(method, parseGrokPromptCompletion, (context) => { + if (context.params) grokCompletionRegistry.resolve(context.params); + }); + } + } const stream = ndJsonStream( Writable.toWeb(child.stdin) as WritableStream, Readable.toWeb(child.stdout) as ReadableStream, @@ -420,6 +543,7 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { capabilities, queues, sessionWriteModes, + grokCompletionRegistry, }, connection); // AcpRuntime installs the long-lived child error listener before this // startup-only listener is removed, so there is no unobserved gap. @@ -487,7 +611,11 @@ export function resolveAcpCommand( provider: AcpProvider, env: NodeJS.ProcessEnv = process.env, ): string | undefined { - const configured = provider === "cursor" ? env.CURSOR_COMMAND : env.COPILOT_COMMAND; + const configured = provider === "cursor" + ? env.CURSOR_COMMAND + : provider === "copilot" + ? env.COPILOT_COMMAND + : env.GROK_COMMAND; const command = configured ?? ACP_COMMANDS[provider][0]; if (command.includes("/") || command.includes("\\")) return executableExists(command) ? command : undefined; const path = env.PATH; @@ -507,7 +635,11 @@ export function resolveAcpCommand( export type AcpCommandResolver = (provider: AcpProvider, env: NodeJS.ProcessEnv) => string | undefined; -export function acpCommandArgs(provider: AcpProvider, context: LocalAgentRuntimeContext): string[] { +export function acpCommandArgs( + provider: AcpProvider, + context: LocalAgentRuntimeContext, + env: NodeJS.ProcessEnv = process.env, +): string[] { const writeMode = context.writeMode ?? "allowed"; if (provider === "cursor") { return [ @@ -518,6 +650,18 @@ export function acpCommandArgs(provider: AcpProvider, context: LocalAgentRuntime ...(writeMode === "full_access" ? ["--force"] : []), ]; } + if (provider === "grok") { + const agentProfile = env.GROK_AGENT_PROFILE?.trim(); + const effort = context.effort + ? resolveGrokEffort(context.effort, undefined, undefined) + : undefined; + return [ + "agent", + ...(agentProfile ? ["--agent-profile", agentProfile] : []), + ...(effort ? ["--reasoning-effort", effort] : []), + "stdio", + ]; + } const sandboxArgs = writeMode === "full_access" ? ["--no-sandbox"] : ["--experimental", "--sandbox"]; @@ -659,6 +803,15 @@ function extractAcpText(updates: unknown[]): string { .trim(); } +function isGrokPromptCompletion(value: unknown): boolean { + const record = asRecord(value); + return typeof record?.sessionId === "string"; +} + +async function yieldToAcpQueue(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + function hasAcpConfigOptions(value: unknown): boolean { const record = asRecord(value); const response = asRecord(record?.newSessionResponse) ?? record; diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index c1b558ed..2b84c58b 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -43,6 +43,7 @@ export function createLocalAgentDrivers( new PiLocalAgentDriver(options.piSessionFactory), new AcpLocalAgentDriver("cursor", options.env), new AcpLocalAgentDriver("copilot", options.env), + new AcpLocalAgentDriver("grok", options.env), ]; } @@ -57,6 +58,7 @@ export function createLocalAgentAdapter( case "pi": return new PiLocalAgentDriver(options.piSessionFactory); case "cursor": case "copilot": + case "grok": return new AcpLocalAgentDriver(provider, options.env); } } diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index 2c8dd6e1..822ef690 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -35,6 +35,8 @@ export function checkLocalAgentProviderAvailability( return commandAvailability(provider, env.CURSOR_COMMAND ?? "cursor-agent", env); case "copilot": return commandAvailability(provider, env.COPILOT_COMMAND ?? "copilot", env); + case "grok": + return commandAvailability(provider, env.GROK_COMMAND ?? "grok", env); } } diff --git a/src/local-agent-errors.ts b/src/local-agent-errors.ts index e346a8a2..0df50b86 100644 --- a/src/local-agent-errors.ts +++ b/src/local-agent-errors.ts @@ -450,6 +450,7 @@ function displayProvider(provider: LocalAgentProvider): string { case "pi": return "Pi"; case "cursor": return "Cursor"; case "copilot": return "Copilot"; + case "grok": return "Grok"; } } diff --git a/src/local-agent-grok.ts b/src/local-agent-grok.ts new file mode 100644 index 00000000..8a8a85a4 --- /dev/null +++ b/src/local-agent-grok.ts @@ -0,0 +1,256 @@ +export const GROK_DEFAULT_MODEL = "grok-build"; +export const GROK_REASONING_EFFORTS = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", +] as const; +export type GrokReasoningEffort = (typeof GROK_REASONING_EFFORTS)[number]; + +const COMPLETED_PROMPT_ID_LIMIT = 128; + +export interface GrokModelInfo { + id: string; + reasoningEfforts: readonly string[]; +} + +export interface GrokSessionState { + currentModelId?: string; + availableModels: readonly GrokModelInfo[]; +} + +export interface GrokPromptCompletion { + sessionId: string; + promptId?: string; + stopReason?: string; +} + +interface PendingPromptCompletion { + sessionId: string; + promptId: string; + resolve: (completion: GrokPromptCompletion) => void; + reject: (error: unknown) => void; + timer: NodeJS.Timeout; +} + +/** + * Owns the one ordering-sensitive xAI completion bridge. Grok may resolve the + * standard session/prompt request or emit its private completion notification; + * whichever arrives first settles the turn, while duplicate and stale events + * are bounded and ignored. + */ +export class GrokPromptCompletionRegistry { + private readonly pending = new Map(); + private readonly completedPromptIds: string[] = []; + + wait( + sessionId: string, + promptId: string, + timeoutMs: number, + onTimeout: () => Error, + ): Promise { + const key = promptKey(sessionId, promptId); + if (this.pending.has(key)) { + throw new Error(`Grok prompt completion is already pending: ${promptId}`); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(key); + reject(onTimeout()); + }, timeoutMs); + timer.unref(); + this.pending.set(key, { sessionId, promptId, resolve, reject, timer }); + }); + } + + resolve(completion: GrokPromptCompletion): void { + if (completion.promptId && this.completedPromptIds.includes(completion.promptId)) return; + const pending = completion.promptId + ? this.pending.get(promptKey(completion.sessionId, completion.promptId)) + : findPendingForSession(this.pending, completion.sessionId); + if (!pending) return; + this.pending.delete(promptKey(pending.sessionId, pending.promptId)); + clearTimeout(pending.timer); + this.rememberCompletedPromptId(completion.promptId ?? pending.promptId); + pending.resolve({ ...completion, promptId: completion.promptId ?? pending.promptId }); + } + + remove(sessionId: string, promptId: string): void { + const key = promptKey(sessionId, promptId); + const pending = this.pending.get(key); + if (!pending) return; + this.pending.delete(key); + clearTimeout(pending.timer); + } + + rejectAll(error: unknown): void { + const pending = Array.from(this.pending.values()); + this.pending.clear(); + for (const entry of pending) { + clearTimeout(entry.timer); + entry.reject(error); + } + } + + markCompleted(sessionId: string, promptId: string): void { + const key = promptKey(sessionId, promptId); + const pending = this.pending.get(key); + if (pending) { + this.pending.delete(key); + clearTimeout(pending.timer); + } + this.rememberCompletedPromptId(promptId); + } + + get size(): number { + return this.pending.size; + } + + private rememberCompletedPromptId(promptId: string): void { + if (this.completedPromptIds.includes(promptId)) return; + this.completedPromptIds.push(promptId); + if (this.completedPromptIds.length > COMPLETED_PROMPT_ID_LIMIT) { + this.completedPromptIds.splice(0, this.completedPromptIds.length - COMPLETED_PROMPT_ID_LIMIT); + } + } +} + +export function parseGrokPromptCompletion(input: unknown): GrokPromptCompletion | undefined { + const record = asRecord(input); + const sessionId = directString(record?.sessionId); + if (!sessionId) return undefined; + + const update = asRecord(record?.update); + const sessionUpdate = directString(update?.sessionUpdate); + if (update && sessionUpdate !== "turn_completed") return undefined; + + const promptId = firstString( + record?.promptId, + record?.requestId, + update?.promptId, + update?.requestId, + asRecord(record?._meta)?.promptId, + asRecord(update?._meta)?.promptId, + ); + if (promptId && isBackgroundPromptId(promptId)) return undefined; + + return { + sessionId, + ...(promptId ? { promptId } : {}), + ...((firstString(record?.stopReason, update?.stopReason)) + ? { stopReason: firstString(record?.stopReason, update?.stopReason) } + : {}), + }; +} + +export function readGrokSessionState(value: unknown): GrokSessionState | undefined { + const record = asRecord(value); + const response = asRecord(record?.newSessionResponse) ?? record; + const models = asRecord(response?.models) + ?? asRecord(asRecord(response?._meta)?.modelState); + if (!models) return undefined; + + const availableModels = (readArray(models.availableModels) ?? []) + .map(readGrokModelInfo) + .filter((model): model is GrokModelInfo => model !== undefined); + return { + currentModelId: directString(models.currentModelId), + availableModels, + }; +} + +export function normalizeGrokModelId(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) return undefined; + return trimmed.replace(/^(?:grok|xai)\//i, ""); +} + +export function resolveGrokModelId( + requested: string, + state: GrokSessionState | undefined, +): string { + const modelId = normalizeGrokModelId(requested); + if (!modelId) throw new Error("Grok model must not be empty."); + const available = state?.availableModels ?? []; + if (available.length > 0 && !available.some((model) => model.id === modelId)) { + throw new Error(`Grok does not support '${modelId}'. Available models: ${available.map((model) => model.id).join(", ")}.`); + } + return modelId; +} + +export function resolveGrokEffort( + effort: string, + state: GrokSessionState | undefined, + modelId: string | undefined, +): GrokReasoningEffort { + const normalized = effort.trim().toLowerCase(); + if (!isGrokReasoningEffort(normalized)) { + throw new Error(`Grok reasoning effort must be one of: ${GROK_REASONING_EFFORTS.join(", ")}.`); + } + const selectedModel = state?.availableModels.find((model) => model.id === modelId); + const availableEfforts = selectedModel?.reasoningEfforts ?? []; + if (availableEfforts.length > 0 && !availableEfforts.includes(normalized)) { + throw new Error(`Grok model '${modelId ?? GROK_DEFAULT_MODEL}' does not support effort '${normalized}'. Available efforts: ${availableEfforts.join(", ")}.`); + } + return normalized; +} + +export function isGrokReasoningEffort(value: string): value is GrokReasoningEffort { + return (GROK_REASONING_EFFORTS as readonly string[]).includes(value); +} + +function readGrokModelInfo(value: unknown): GrokModelInfo | undefined { + const record = asRecord(value); + const id = directString(record?.modelId) ?? directString(record?.id); + if (!id) return undefined; + const modelMeta = asRecord(record?._meta); + const reasoningEfforts = (readArray(modelMeta?.reasoningEfforts) ?? []) + .flatMap((entry) => { + const effort = asRecord(entry); + return [directString(effort?.id), directString(effort?.value)].filter( + (value): value is string => value !== undefined, + ); + }) + .filter((effort, index, values) => values.indexOf(effort) === index); + return { id, reasoningEfforts }; +} + +function findPendingForSession( + pending: ReadonlyMap, + sessionId: string, +): PendingPromptCompletion | undefined { + return Array.from(pending.values()).find((entry) => entry.sessionId === sessionId); +} + +function promptKey(sessionId: string, promptId: string): string { + return `${sessionId}\u0000${promptId}`; +} + +function isBackgroundPromptId(promptId: string): boolean { + return /^(?:task|subagent|background)(?:-|_)/i.test(promptId) + || /^task-completed-/i.test(promptId); +} + +function firstString(...values: unknown[]): string | undefined { + for (const value of values) { + const result = directString(value); + if (result) return result; + } + return undefined; +} + +function readArray(value: unknown): unknown[] | undefined { + return Array.isArray(value) ? value : undefined; +} + +function directString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} diff --git a/src/local-agent-profiles.ts b/src/local-agent-profiles.ts index 00906d2d..af532e49 100644 --- a/src/local-agent-profiles.ts +++ b/src/local-agent-profiles.ts @@ -4,7 +4,7 @@ import { basename, join, resolve } from "node:path"; import { parse as parseYaml } from "yaml"; import type { ServerConfig } from "./config.js"; -export type LocalAgentProvider = "codex" | "claude" | "opencode" | "pi" | "cursor" | "copilot"; +export type LocalAgentProvider = "codex" | "claude" | "opencode" | "pi" | "cursor" | "copilot" | "grok"; export const LOCAL_AGENT_PROVIDERS: readonly LocalAgentProvider[] = [ "codex", @@ -13,6 +13,7 @@ export const LOCAL_AGENT_PROVIDERS: readonly LocalAgentProvider[] = [ "pi", "cursor", "copilot", + "grok", ]; export interface LocalAgentProfile { @@ -172,7 +173,7 @@ function readProvider(frontmatter: Record, filePath: string): L } if (!PROVIDERS.has(provider as LocalAgentProvider)) { throw new Error( - `Subagent profile provider must be codex, claude, opencode, pi, cursor, or copilot: ${filePath}`, + `Subagent profile provider must be codex, claude, opencode, pi, cursor, copilot, or grok: ${filePath}`, ); } return provider as LocalAgentProvider; From 68a9708ea50277f1bf318591d854415c64a17109 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:02:39 +0530 Subject: [PATCH 2/5] test(agents): cover Grok ACP runtime --- package.json | 2 +- src/config.test.ts | 2 +- src/local-agent-acp.test.ts | 81 +++++++++++++++++++++++ src/local-agent-adapters.test.ts | 1 + src/local-agent-availability.test.ts | 2 +- src/local-agent-config.test.ts | 2 +- src/local-agent-grok.test.ts | 96 ++++++++++++++++++++++++++++ src/local-agent-targets.test.ts | 2 +- 8 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 src/local-agent-grok.test.ts diff --git a/package.json b/package.json index b6e62669..f7e55951 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/onboarding.test.ts && tsx src/cli-workspace.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-config.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/onboarding.test.ts && tsx src/cli-workspace.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-config.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-grok.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/config.test.ts b/src/config.test.ts index dbf028d3..7b3eeeb6 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -177,7 +177,7 @@ assert.equal(fileConfig.port, 8787); assert.equal(fileConfig.oauth.ownerToken, "persisted-owner-token-long-enough"); assert.equal(fileConfig.publicBaseUrl, "https://devspace.example.com"); assert.equal(fileConfig.subagents.enabled, true); -assert.equal(fileConfig.subagents.providers.length, 6); +assert.equal(fileConfig.subagents.providers.length, 7); assert.equal(fileConfig.artifactsEnabled, true); assert.equal(fileConfig.artifactMaxFileBytes, 321); assert.deepEqual(fileConfig.allowedHosts, [ diff --git a/src/local-agent-acp.test.ts b/src/local-agent-acp.test.ts index 6b5fd9e8..76d7e5e5 100644 --- a/src/local-agent-acp.test.ts +++ b/src/local-agent-acp.test.ts @@ -10,6 +10,7 @@ import { resolveAcpCommand, selectAcpPermissionOption, } from "./local-agent-acp.js"; +import { GrokPromptCompletionRegistry } from "./local-agent-grok.js"; const requests: Array<{ method: string; params?: unknown }> = []; const queues = new Map(); @@ -278,6 +279,18 @@ assert.equal(resolverCalls, 1, "ACP executable identity is resolved once per dri assert.deepEqual(acpCommandArgs("cursor", cachedContext), [ "acp", "--sandbox", "enabled", "--workspace", resolvedProject, ]); +assert.deepEqual(acpCommandArgs("grok", { + ...cachedContext, + provider: "grok", + effort: "low", +}), ["agent", "--reasoning-effort", "low", "stdio"]); +assert.deepEqual(acpCommandArgs("grok", { + ...cachedContext, + provider: "grok", + effort: "low", +}, { GROK_AGENT_PROFILE: " /tmp/grok-coding-only.md " }), [ + "agent", "--agent-profile", "/tmp/grok-coding-only.md", "--reasoning-effort", "low", "stdio", +]); assert.deepEqual(acpCommandArgs("copilot", cachedContext), [ "--acp", "--experimental", "--sandbox", "--allow-all-tools", "--add-dir", resolvedProject, "-C", resolvedProject, ]); @@ -344,6 +357,74 @@ if (process.platform !== "win32") { } } +const grokRequests: Array<{ method: string; params?: unknown }> = []; +const grokQueues = new Map(); +const grokCompletionRegistry = new GrokPromptCompletionRegistry(); +const grokConnection = { + agent: { + async request(method: string, params?: unknown): Promise { + grokRequests.push({ method, params }); + const input = params as { sessionId?: string; _meta?: { promptId?: string } } | undefined; + if (method === "session/new") { + grokQueues.set("grok_session_1", { values: [] }); + return { + sessionId: "grok_session_1", + models: { + currentModelId: "grok-4.5", + availableModels: [{ + modelId: "grok-4.5", + _meta: { reasoningEfforts: [{ id: "low", value: "low" }] }, + }], + }, + }; + } + if (method === "session/set_model") return {}; + if (method === "session/prompt") { + grokQueues.get(input?.sessionId ?? "")?.values.push({ + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Grok response" }, + }, + }); + setImmediate(() => grokCompletionRegistry.resolve({ + sessionId: input?.sessionId ?? "", + promptId: input?._meta?.promptId, + stopReason: "end_turn", + })); + return new Promise(() => undefined); + } + return {}; + }, + }, + close() {}, + closed: new Promise(() => undefined), +}; +const grokRuntime = new AcpRuntime({ + provider: "grok", + command: "grok", + args: ["agent", "--reasoning-effort", "low", "stdio"], + env: {}, + capabilities: { resume: true, close: true }, + queues: grokQueues, + grokCompletionRegistry, + promptCompletionTimeoutMs: 100, +}, grokConnection); +const grokResult = await grokRuntime.run({ + prompt: "which model are you", + workspaceRoot: "/tmp/project", + model: "grok-4.5", + effort: "low", +}); +assert.equal(grokResult.isOk(), true); +if (grokResult.isErr()) throw grokResult.error; +assert.equal(grokResult.value.finalResponse, "Grok response"); +assert.deepEqual( + grokRequests.filter(({ method }) => method === "session/set_model").map(({ params }) => params), + [{ sessionId: "grok_session_1", modelId: "grok-4.5", _meta: { reasoningEffort: "low" } }], +); +assert.equal(grokCompletionRegistry.size, 0); +await grokRuntime.close(); + await resumedRuntime.close(); await resumedRuntime.close(); assert.equal(resumedRuntime.isAlive(), false); diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index c628ab68..4938bd0b 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -19,6 +19,7 @@ const providers: LocalAgentProvider[] = [ "pi", "cursor", "copilot", + "grok", ]; for (const provider of providers) { diff --git a/src/local-agent-availability.test.ts b/src/local-agent-availability.test.ts index 3dcf4ba6..49d88aad 100644 --- a/src/local-agent-availability.test.ts +++ b/src/local-agent-availability.test.ts @@ -34,7 +34,7 @@ import { }); assert.deepEqual( snapshot.map((provider) => provider.name), - ["codex", "claude", "opencode", "pi", "cursor", "copilot"], + ["codex", "claude", "opencode", "pi", "cursor", "copilot", "grok"], ); assert.equal(snapshot.find((provider) => provider.name === "pi")?.available, true); } diff --git a/src/local-agent-config.test.ts b/src/local-agent-config.test.ts index 8a4d8d26..713fed65 100644 --- a/src/local-agent-config.test.ts +++ b/src/local-agent-config.test.ts @@ -29,7 +29,7 @@ assert.equal(resolveSubagentsConfig({ ...config, enabled: false }, { DEVSPACE_SUBAGENTS: "1", }).enabled, true); assert.equal(resolveSubagentsConfig(undefined, {}).providers.length, 0); -assert.equal(resolveSubagentsConfig(true, {}).providers.length, 6); +assert.equal(resolveSubagentsConfig(true, {}).providers.length, 7); assert.throws( () => resolveSubagentsConfig({ diff --git a/src/local-agent-grok.test.ts b/src/local-agent-grok.test.ts new file mode 100644 index 00000000..34bee0e5 --- /dev/null +++ b/src/local-agent-grok.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { + GROK_DEFAULT_MODEL, + GrokPromptCompletionRegistry, + isGrokReasoningEffort, + normalizeGrokModelId, + parseGrokPromptCompletion, + readGrokSessionState, + resolveGrokEffort, + resolveGrokModelId, +} from "./local-agent-grok.js"; + +const sessionResponse = { + sessionId: "grok_session_1", + models: { + currentModelId: "grok-4.5", + availableModels: [ + { + modelId: "grok-4.5", + _meta: { + reasoningEfforts: [ + { id: "high", value: "high" }, + { id: "low", value: "low" }, + ], + }, + }, + ], + }, +}; + +const state = readGrokSessionState(sessionResponse); +assert.deepEqual(state, { + currentModelId: "grok-4.5", + availableModels: [{ id: "grok-4.5", reasoningEfforts: ["high", "low"] }], +}); +assert.equal(normalizeGrokModelId("grok/grok-4.5"), "grok-4.5"); +assert.equal(resolveGrokModelId("grok-4.5", state), "grok-4.5"); +assert.equal(resolveGrokEffort("low", state, "grok-4.5"), "low"); +assert.equal(isGrokReasoningEffort("low"), true); +assert.equal(GROK_DEFAULT_MODEL, "grok-build"); +assert.throws( + () => resolveGrokModelId("grok-unknown", state), + /Available models: grok-4\.5/, +); +assert.throws( + () => resolveGrokEffort("xhigh", state, "grok-4.5"), + /Available efforts: high, low/, +); + +assert.deepEqual( + parseGrokPromptCompletion({ + sessionId: "grok_session_1", + promptId: "prompt_1", + stopReason: "end_turn", + }), + { sessionId: "grok_session_1", promptId: "prompt_1", stopReason: "end_turn" }, +); +assert.deepEqual( + parseGrokPromptCompletion({ + sessionId: "grok_session_1", + update: { sessionUpdate: "turn_completed", requestId: "prompt_2", stopReason: "end_turn" }, + }), + { sessionId: "grok_session_1", promptId: "prompt_2", stopReason: "end_turn" }, +); +assert.equal( + parseGrokPromptCompletion({ + sessionId: "grok_session_1", + promptId: "task-completed-123", + }), + undefined, +); +assert.equal( + parseGrokPromptCompletion({ sessionId: "grok_session_1", update: { sessionUpdate: "agent_message_chunk" } }), + undefined, +); +assert.equal( + parseGrokPromptCompletion({ sessionId: "grok_session_1", update: { stopReason: "end_turn" } }), + undefined, +); + +const registry = new GrokPromptCompletionRegistry(); +const completion = registry.wait("grok_session_1", "prompt_3", 100, () => new Error("timed out")); +registry.resolve({ sessionId: "grok_session_1", promptId: "stale" }); +registry.resolve({ sessionId: "grok_session_1", promptId: "prompt_3", stopReason: "end_turn" }); +assert.deepEqual(await completion, { + sessionId: "grok_session_1", + promptId: "prompt_3", + stopReason: "end_turn", +}); +registry.resolve({ sessionId: "grok_session_1", promptId: "prompt_3", stopReason: "end_turn" }); +assert.equal(registry.size, 0); + +const timeout = registry.wait("grok_session_1", "prompt_timeout", 5, () => new Error("timed out")); +const keepAlive = new Promise((resolve) => setTimeout(resolve, 20)); +await assert.rejects(timeout, /timed out/); +await keepAlive; diff --git a/src/local-agent-targets.test.ts b/src/local-agent-targets.test.ts index 61a58f7e..a3a70160 100644 --- a/src/local-agent-targets.test.ts +++ b/src/local-agent-targets.test.ts @@ -148,4 +148,4 @@ assert.deepEqual(parseLocalAgentRunArgs(["codex", "--", "--json", "literal"]), { assert.equal(resolveLocalAgentTarget("missing", profiles), undefined); assert.match(formatAvailableLocalAgentTargets(profiles), /profiles: reviewer, claude/); -assert.match(formatAvailableLocalAgentTargets([]), /providers: codex, claude, opencode, pi, cursor, copilot/); +assert.match(formatAvailableLocalAgentTargets([]), /providers: codex, claude, opencode, pi, cursor, copilot, grok/); From bee5dc116b8554075be027725b4d6a84e25658f6 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:02:46 +0530 Subject: [PATCH 3/5] docs(agents): document Grok Build configuration --- docs/agent-profile-schema.md | 3 +++ docs/configuration.md | 14 ++++++++++++++ examples/agents/grok-builder.md | 10 ++++++++++ 3 files changed, 27 insertions(+) create mode 100644 examples/agents/grok-builder.md diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index 20b7b71b..b084eec1 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -73,6 +73,7 @@ provider: opencode provider: pi provider: cursor provider: copilot +provider: grok ``` Unsupported or custom providers are rejected. DevSpace maps providers to their @@ -84,6 +85,7 @@ native integration: - `pi`: the installed Pi coding-agent SDK, one in-process session per DevSpace agent - `cursor`: ACP - `copilot`: ACP +- `grok`: Grok Build ACP (`grok agent stdio`) Codex is resolved from the user's environment rather than bundled with DevSpace. Run `codex login` normally before using it; set `CODEX_COMMAND` when @@ -119,6 +121,7 @@ DevSpace passes this through to providers that expose a matching control: - `pi`: the AgentSession thinking-level control. - `opencode`: model variant. - `cursor` and `copilot`: ACP thought-level config when supported. +- `grok`: `--reasoning-effort` on startup and xAI's ACP model metadata for resumed sessions. ### `disabled` diff --git a/docs/configuration.md b/docs/configuration.md index 56e1b204..b96c1e05 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -163,6 +163,12 @@ Enable providers and set their defaults in `~/.devspace/config.json`: "id": "claude", "enabled": true, "model": "sonnet" + }, + { + "id": "grok", + "enabled": true, + "model": "grok-4.5", + "effort": "low" } ] } @@ -180,6 +186,14 @@ workspace. Add `--json` to inspect the availability and usability of enabled providers. Disabled and unconfigured providers are omitted. Provider availability is runtime state and never rewrites the configuration. +Grok Build is discovered from the `grok` executable. Authenticate it with +`grok login` or `XAI_API_KEY`; DevSpace does not read or store Grok credentials. +Grok supports `grok-build` by default and validates explicit model and effort +values against the ACP session metadata when available. Set `GROK_COMMAND` when +the executable is not on the normal PATH. If your Grok installation selects a +custom agent profile, set `GROK_AGENT_PROFILE` to that profile's path; DevSpace +passes it to `grok agent stdio` without writing to Grok's configuration. + `open_workspace` returns a compact catalog containing profile names, descriptions, providers, and optional models/effort levels so the host model can choose an agent without reading provider-specific launch details. Disabled or unavailable diff --git a/examples/agents/grok-builder.md b/examples/agents/grok-builder.md new file mode 100644 index 00000000..a37702ab --- /dev/null +++ b/examples/agents/grok-builder.md @@ -0,0 +1,10 @@ +--- +name: grok-builder +description: Implement a focused change with Grok Build. +provider: grok +model: grok-4.5 +effort: low +--- + +Implement the requested change in the current workspace. Inspect the existing +patterns first, keep the patch focused, and report the verification you ran. From 5a0b99404eb6d5c612fcb75759e150bafd03a647 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:15:49 +0530 Subject: [PATCH 4/5] fix(opencode): traverse paginated message history --- src/local-agent-opencode.test.ts | 73 ++++++++++++++++++++++++++++++++ src/local-agent-opencode.ts | 35 +++++++++++++-- 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts index cf5e24fd..2d54ee09 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -186,6 +186,79 @@ assert.equal(readinessActiveCalls >= 3, true); assert.equal(readinessMessageCalls >= 3, true); await readinessPool.close(); +const longSessionRequests: Array<{ cursor?: string; order?: string }> = []; +const longSessionClient = { + v2: { + session: { + async create() { + return { data: { data: { id: "session_long" } } }; + }, + async switchAgent() {}, + async prompt() { + return { data: { data: { id: "prompt_long" } } }; + }, + async active() { + return { data: { data: {} } }; + }, + async messages(input: unknown) { + const request = input as { cursor?: string; order?: string }; + longSessionRequests.push({ cursor: request.cursor, order: request.order }); + if (!request.cursor) { + return { + data: { + data: Array.from({ length: 100 }, (_, index) => ({ + type: "assistant", + id: `old-assistant-${index}`, + finish: "stop", + content: [{ type: "text", text: `old response ${index}` }], + })), + cursor: { next: "long-session-next" }, + }, + }; + } + return { + data: { + data: [ + { type: "user", id: "prompt_long" }, + { + type: "assistant", + id: "assistant_long", + finish: "stop", + content: [{ type: "text", text: "long response" }], + }, + ], + cursor: {}, + }, + }; + }, + }, + }, +} as unknown as OpencodeClientLike; +const longSessionPool = new LocalAgentRuntimePool(); +const longSessionDriver = new OpencodeLocalAgentDriver(async () => ({ + client: longSessionClient, + server: { close: () => undefined }, +})); +const longSessionResult = await longSessionPool.run(longSessionDriver, { + agentId: "agt_long_session", + provider: "opencode", + workspaceRoot: "/tmp/project", +}, { prompt: "long session", workspaceRoot: "/tmp/project" }); +assert.equal(longSessionResult.isOk(), true, "OpenCode should find completions past the first message page"); +if (longSessionResult.isOk()) { + assert.equal(longSessionResult.value.finalResponse, "long response"); +} +assert.equal(longSessionRequests.length, 4, "wait and final extraction should each traverse both pages"); +assert.deepEqual(longSessionRequests.filter((request) => request.cursor === undefined), [ + { cursor: undefined, order: "asc" }, + { cursor: undefined, order: "asc" }, +]); +assert.deepEqual(longSessionRequests.filter((request) => request.cursor !== undefined), [ + { cursor: "long-session-next", order: undefined }, + { cursor: "long-session-next", order: undefined }, +]); +await longSessionPool.close(); + assert.equal(opencodeAgentFor("read_only"), "devspace_read_only"); assert.equal(opencodeAgentFor("full_access"), "devspace_full_access"); assert.deepEqual(opencodePermissionFor("allowed"), { diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index f5514ab9..88cd6c05 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -75,7 +75,8 @@ export class OpencodeRuntime implements LocalAgentRuntime { } const promptResult = await promptOpencodeSession(this.client, sessionId, input); await waitForOpencodeSession(this.client, sessionId, promptResult); - const messages = await readOpencodeMessages(this.client, sessionId); + const promptId = extractOpenCodePromptId(promptResult); + const messages = await readOpencodeMessages(this.client, sessionId, promptId); const finalResponse = requireFinalResponse( extractOpenCodeFinalResponse(messages) || extractOpenCodeFinalResponse(promptResult), ); @@ -289,7 +290,7 @@ async function waitForOpencodeSession( const deadline = Date.now() + OPENCODE_SESSION_POLL_TIMEOUT_MS; let observedActive = false; while (true) { - const messages = await readOpencodeMessages(client, sessionId); + const messages = await readOpencodeMessages(client, sessionId, promptId); const activity = await active({ throwOnError: true }); const running = isOpenCodeSessionActive(activity, sessionId); if (running) observedActive = true; @@ -312,9 +313,35 @@ async function waitForOpencodeSession( async function readOpencodeMessages( client: OpencodeClientLike, sessionId: string, + promptId?: string, ): Promise { - const result = await client.v2.session.messages({ sessionID: sessionId, order: "asc", limit: 100 }, { throwOnError: true }); - return result.data; + const messages: SessionMessagesResponse["data"] = []; + const seenCursors = new Set(); + let cursor: string | undefined; + + while (true) { + const result = await client.v2.session.messages({ + sessionID: sessionId, + limit: 100, + ...(cursor ? { cursor } : { order: "asc" }), + }, { throwOnError: true }); + const page = result.data; + messages.push(...page.data); + + // A prompt-specific read can stop as soon as the submitted turn is + // complete. Reads without a prompt id still walk the full history because + // they are used to extract the final response after the wait fallback. + if (promptId !== undefined && hasCompletedOpenCodeTurn({ data: messages }, promptId)) { + break; + } + + const nextCursor = page.cursor?.next; + if (!nextCursor || seenCursors.has(nextCursor)) break; + seenCursors.add(nextCursor); + cursor = nextCursor; + } + + return { data: messages, cursor: {} }; } function extractOpenCodePromptId(value: unknown): string | undefined { From 73f9489b35ec0825531a08de778f440a9c2bba93 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:15:52 +0530 Subject: [PATCH 5/5] fix(agents): preserve Grok configuration diagnostics --- src/local-agent-acp.test.ts | 51 +++++++++++++++++++++++++++++++++++++ src/local-agent-grok.ts | 26 ++++++++++++++++--- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/local-agent-acp.test.ts b/src/local-agent-acp.test.ts index 76d7e5e5..c4f58d48 100644 --- a/src/local-agent-acp.test.ts +++ b/src/local-agent-acp.test.ts @@ -425,6 +425,57 @@ assert.deepEqual( assert.equal(grokCompletionRegistry.size, 0); await grokRuntime.close(); +const grokConfigurationConnection = { + agent: { + async request(method: string): Promise { + if (method === "session/new") { + return { + sessionId: "grok_configuration_session", + models: { + currentModelId: "grok-4.5", + availableModels: [{ + modelId: "grok-4.5", + _meta: { reasoningEfforts: [{ id: "low", value: "low" }] }, + }], + }, + }; + } + return {}; + }, + }, + close() {}, + closed: new Promise(() => undefined), +}; +const grokConfigurationRuntime = new AcpRuntime({ + provider: "grok", + command: "grok", + args: ["agent", "stdio"], + env: {}, +}, grokConfigurationConnection); +const invalidGrokModel = await grokConfigurationRuntime.run({ + prompt: "invalid model", + workspaceRoot: "/tmp/project", + model: "grok-unknown", +}); +assert.equal(invalidGrokModel.isErr(), true); +if (invalidGrokModel.isErr()) { + assert.equal(invalidGrokModel.error.code, "PROVIDER_PROTOCOL_ERROR"); + assert.equal(invalidGrokModel.error.retryable, false); + assert.match(invalidGrokModel.error.message, /Available models: grok-4\.5/); +} +const invalidGrokEffort = await grokConfigurationRuntime.run({ + prompt: "invalid effort", + workspaceRoot: "/tmp/project", + effort: "high", +}); +assert.equal(invalidGrokEffort.isErr(), true); +if (invalidGrokEffort.isErr()) { + assert.equal(invalidGrokEffort.error.code, "PROVIDER_PROTOCOL_ERROR"); + assert.equal(invalidGrokEffort.error.retryable, false); + assert.match(invalidGrokEffort.error.message, /Available efforts: low/); +} +await grokConfigurationRuntime.close(); + await resumedRuntime.close(); await resumedRuntime.close(); assert.equal(resumedRuntime.isAlive(), false); diff --git a/src/local-agent-grok.ts b/src/local-agent-grok.ts index 8a8a85a4..d4df607d 100644 --- a/src/local-agent-grok.ts +++ b/src/local-agent-grok.ts @@ -1,3 +1,5 @@ +import { AgentProviderProtocolError } from "./local-agent-errors.js"; + export const GROK_DEFAULT_MODEL = "grok-build"; export const GROK_REASONING_EFFORTS = [ "none", @@ -172,10 +174,12 @@ export function resolveGrokModelId( state: GrokSessionState | undefined, ): string { const modelId = normalizeGrokModelId(requested); - if (!modelId) throw new Error("Grok model must not be empty."); + if (!modelId) throw grokConfigurationError("Grok model must not be empty."); const available = state?.availableModels ?? []; if (available.length > 0 && !available.some((model) => model.id === modelId)) { - throw new Error(`Grok does not support '${modelId}'. Available models: ${available.map((model) => model.id).join(", ")}.`); + throw grokConfigurationError( + `Grok does not support '${modelId}'. Available models: ${available.map((model) => model.id).join(", ")}.`, + ); } return modelId; } @@ -187,16 +191,30 @@ export function resolveGrokEffort( ): GrokReasoningEffort { const normalized = effort.trim().toLowerCase(); if (!isGrokReasoningEffort(normalized)) { - throw new Error(`Grok reasoning effort must be one of: ${GROK_REASONING_EFFORTS.join(", ")}.`); + throw grokConfigurationError( + `Grok reasoning effort must be one of: ${GROK_REASONING_EFFORTS.join(", ")}.`, + ); } const selectedModel = state?.availableModels.find((model) => model.id === modelId); const availableEfforts = selectedModel?.reasoningEfforts ?? []; if (availableEfforts.length > 0 && !availableEfforts.includes(normalized)) { - throw new Error(`Grok model '${modelId ?? GROK_DEFAULT_MODEL}' does not support effort '${normalized}'. Available efforts: ${availableEfforts.join(", ")}.`); + throw grokConfigurationError( + `Grok model '${modelId ?? GROK_DEFAULT_MODEL}' does not support effort '${normalized}'. Available efforts: ${availableEfforts.join(", ")}.`, + ); } return normalized; } +function grokConfigurationError(message: string): AgentProviderProtocolError { + return new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "grok", + operation: "configure_session", + retryable: false, + message, + }); +} + export function isGrokReasoningEffort(value: string): value is GrokReasoningEffort { return (GROK_REASONING_EFFORTS as readonly string[]).includes(value); }