diff --git a/src/local-agent-acp.test.ts b/src/local-agent-acp.test.ts index f20f02020..ab3328124 100644 --- a/src/local-agent-acp.test.ts +++ b/src/local-agent-acp.test.ts @@ -15,6 +15,7 @@ import { resolveAcpCommand, selectAcpPermissionOption, matchAcpSelectValue, + acpEmptyTurnDetail, } from "./local-agent-acp.js"; import { GrokPromptCompletionRegistry } from "./local-agent-grok.js"; @@ -832,3 +833,11 @@ test("an ACP select accepts a plain name when exactly one bracketed variant matc assert.equal(matchAcpSelectValue(available, "grok-4.6"), undefined, "ambiguous names match nothing"); assert.equal(matchAcpSelectValue(available, "composer"), undefined); }); + +test("a turn without assistant text reports the agent's reason, stop reason, updates, and stderr", () => { + assert.equal( + acpEmptyTurnDetail({ stopReason: "error" }, [{ sessionUpdate: "tool_call" }], "boom\n", "Unauthorized (401)"), + "Unauthorized (401); stop reason: error; updates: tool_call; stderr: boom", + ); + assert.equal(acpEmptyTurnDetail({}, [], ""), "stop reason: none; updates: none"); +}); diff --git a/src/local-agent-acp.ts b/src/local-agent-acp.ts index ec09ab03b..c04deb7a2 100644 --- a/src/local-agent-acp.ts +++ b/src/local-agent-acp.ts @@ -21,6 +21,7 @@ import { GROK_DEFAULT_MODEL, parseGrokPromptCompletion, readGrokSessionState, + readGrokTurnFailure, resolveGrokEffort, resolveGrokModelId, } from "./local-agent-grok.js"; @@ -102,6 +103,8 @@ export interface AcpRuntimeOptions { grokCompletionRegistry?: GrokPromptCompletionRegistry; promptCompletionTimeoutMs?: number; cancelGraceMs?: number; + /** The agent process's recent stderr, which often holds the real reason for a turn that produced nothing. */ + stderrTail?: () => string; } export class AcpRuntime implements LocalAgentRuntime { @@ -117,6 +120,8 @@ export class AcpRuntime implements LocalAgentRuntime { private readonly grokCompletionRegistry?: GrokPromptCompletionRegistry; private readonly promptCompletionTimeoutMs: number; private readonly cancelGraceMs: number; + private readonly stderrTail?: () => string; + private readonly env: NodeJS.ProcessEnv; private readonly activeSessions = new Set(); private promptSequence = 0; private alive = true; @@ -133,17 +138,19 @@ export class AcpRuntime implements LocalAgentRuntime { this.sessionMetadata = options.sessionMetadata ?? new Map(); this.grokCompletionRegistry = options.grokCompletionRegistry; this.promptCompletionTimeoutMs = options.promptCompletionTimeoutMs ?? ACP_GROK_PROMPT_COMPLETION_TIMEOUT_MS; + this.stderrTail = options.stderrTail; + this.env = options.env; this.cancelGraceMs = options.cancelGraceMs ?? ACP_CANCEL_GRACE_MS; void this.connection.closed.then(() => { if (!this.closed) this.alive = false; - this.grokCompletionRegistry?.rejectAll(new Error(`${this.provider} ACP connection closed.`)); + this.grokCompletionRegistry?.rejectAll(new Error(`${acpAgentName(this.flavor)} connection closed.`)); }).catch(() => { if (!this.closed) this.alive = false; - this.grokCompletionRegistry?.rejectAll(new Error(`${this.provider} ACP connection closed.`)); + this.grokCompletionRegistry?.rejectAll(new Error(`${acpAgentName(this.flavor)} connection closed.`)); }); this.child?.once("exit", () => { this.alive = false; - this.connection.close(new Error(`${this.provider} ACP process exited.`)); + this.connection.close(new Error(`${acpAgentName(this.flavor)} process exited.`)); }); this.child?.once("error", (error) => { this.alive = false; @@ -162,7 +169,7 @@ export class AcpRuntime implements LocalAgentRuntime { provider: this.provider, operation: "run", retryable: true, - message: `${this.provider} ACP runtime is not running.`, + message: `${acpAgentName(this.flavor)} runtime is not running.`, }); } @@ -171,7 +178,7 @@ export class AcpRuntime implements LocalAgentRuntime { if (input.signal?.aborted) throw localAgentCancelledError(this.provider, "run"); if (this.activeSessions.has(sessionId)) { - throw new TypeError(`${this.provider} ACP session ${sessionId} already has an active turn.`); + throw new TypeError(`${acpAgentName(this.flavor)} session ${sessionId} already has an active turn.`); } this.activeSessions.add(sessionId); @@ -263,8 +270,10 @@ export class AcpRuntime implements LocalAgentRuntime { provider: this.provider, operation: "run", retryable: false, - cause: response, - message: `${this.provider} ACP did not return a final assistant response.`, + cause: acpEmptyTurnDetail(response, updates, this.stderrTail?.() ?? "", this.flavor === "grok" + ? readGrokTurnFailure(sessionId, this.env) ?? "Grok wrote the cause to ~/.grok/logs/unified.jsonl; an expired login is common (run `grok` to sign in)." + : undefined), + message: `${acpAgentName(this.flavor)} did not return a final assistant response.`, }); } @@ -311,8 +320,8 @@ 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.`)); + this.grokCompletionRegistry?.rejectAll(new Error(`${acpAgentName(this.flavor)} runtime closed.`)); + this.connection.close(new Error(`${acpAgentName(this.flavor)} runtime closed.`)); if (this.child && this.child.exitCode === null) { const detached = process.platform !== "win32"; @@ -345,7 +354,7 @@ export class AcpRuntime implements LocalAgentRuntime { provider: this.provider, operation: "resume_session", retryable: false, - message: `${this.provider} ACP does not advertise session resume support.`, + message: `${acpAgentName(this.flavor)} does not advertise session resume support.`, }); } @@ -383,7 +392,7 @@ export class AcpRuntime implements LocalAgentRuntime { operation: "create_session", retryable: false, cause: response, - message: `${this.provider} ACP did not return a session id.`, + message: `${acpAgentName(this.flavor)} did not return a session id.`, }); } @@ -434,7 +443,7 @@ export class AcpRuntime implements LocalAgentRuntime { provider: this.provider, operation: "configure_session", retryable: false, - message: `${this.provider} ACP cannot apply the requested ${requested} override because the resumed session did not advertise configurable options.`, + message: `${acpAgentName(this.flavor)} cannot apply the requested ${requested} override because the resumed session did not advertise configurable options.`, }); } @@ -470,7 +479,7 @@ export class AcpRuntime implements LocalAgentRuntime { operation: "configure_session", retryable: false, cause, - message: `${this.provider} ACP could not enter its read-only mode '${modeId}'.`, + message: `${acpAgentName(this.flavor)} could not enter its read-only mode '${modeId}'.`, }); } } @@ -495,7 +504,7 @@ export class AcpRuntime implements LocalAgentRuntime { provider: this.provider, operation: "configure_session", retryable: false, - message: `${this.provider} ACP did not advertise typed model metadata required for the requested ${requested} override.`, + message: `${acpAgentName(this.flavor)} did not advertise typed model metadata required for the requested ${requested} override.`, }); } @@ -529,7 +538,7 @@ export class AcpRuntime implements LocalAgentRuntime { operation: "configure_session", retryable: false, cause, - message: `${this.provider} ACP could not select model '${requestedModel}'.`, + message: `${acpAgentName(this.flavor)} could not select model '${requestedModel}'.`, }); } } @@ -665,7 +674,7 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { agentId: context.agentId, operation: "create_runtime", retryable: false, - message: `${this.provider} ACP process did not expose stdio pipes.`, + message: `${acpAgentName(this.flavor)} process did not expose stdio pipes.`, }); } @@ -729,12 +738,13 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { startupError.then((error) => { throw error; }), ]), ACP_INITIALIZE_TIMEOUT_MS, - `${this.provider} ACP initialize timed out.`, + `${acpAgentName(this.flavor)} initialize timed out.`, ); const capabilities = readAcpCapabilities(init); const runtime = new AcpRuntime({ + stderrTail: () => stderrTail, flavor: this.flavor, command, args, @@ -778,7 +788,7 @@ export class AcpLocalAgentDriver implements LocalAgentDriver { operation: "create_runtime", retryable: true, cause: { error, stderr: stderrTail.trim() || undefined }, - message: `${this.provider} ACP initialization failed.`, + message: `${acpAgentName(this.flavor)} initialization failed.`, }); } }, @@ -968,6 +978,30 @@ function resolveAcpSelectConfigUpdate( * Accepts an exact option value, or a plain name such as `composer-2.5` when exactly one option is that name plus * bracketed settings (Cursor's `composer-2.5[fast=true]`). An ambiguous name matches nothing. */ +/** Names the agent in messages (`grok ACP`, `cursor ACP`) rather than the shared `acp` driver kind. */ +export function acpAgentName(flavor: AcpFlavor): string { + return flavor === "generic" ? "ACP agent" : `${flavor} ACP`; +} + +/** Summarizes a turn that ended without assistant text, so the agent's real reason (often on stderr) is shown. */ +export function acpEmptyTurnDetail( + response: WireInput, + updates: readonly WireInput[], + stderr: string, + agentReason?: string, +): string { + const stopReason = directString(asRecord(response)?.stopReason); + const kinds = [...new Set(updates.flatMap((update) => directString(asRecord(update)?.sessionUpdate) ?? []))]; + const trimmed = stderr.trim(); + + return [ + ...(agentReason ? [agentReason] : []), + `stop reason: ${stopReason ?? "none"}`, + `updates: ${kinds.length > 0 ? kinds.join(", ") : "none"}`, + ...(trimmed ? [`stderr: ${trimmed.slice(-500)}`] : []), + ].join("; "); +} + export function matchAcpSelectValue(available: readonly string[], requested: string): string | undefined { if (available.includes(requested)) return requested; diff --git a/src/local-agent-grok.test.ts b/src/local-agent-grok.test.ts index 9cbfd3323..f00a84b1f 100644 --- a/src/local-agent-grok.test.ts +++ b/src/local-agent-grok.test.ts @@ -1,3 +1,6 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import assert from "node:assert/strict"; import test from "node:test"; import { @@ -7,6 +10,7 @@ import { readGrokSessionState, resolveGrokEffort, resolveGrokModelId, + readGrokTurnFailure, } from "./local-agent-grok.js"; const sessionResponse = { @@ -93,3 +97,17 @@ test("a completion wait times out", async () => { await assert.rejects(timeout, /timed out/); await keepAlive; }); + +test("a Grok turn failure is read from Grok's log for the matching session", () => { + const home = mkdtempSync(join(tmpdir(), "devspace-grok-log-")); + mkdirSync(join(home, ".grok", "logs"), { recursive: true }); + writeFileSync(join(home, ".grok", "logs", "unified.jsonl"), [ + JSON.stringify({ sid: "other", msg: "turn.terminal_failure", ctx: { message: "Not this one" } }), + JSON.stringify({ sid: "s1", msg: "shell.turn.started", ctx: {} }), + JSON.stringify({ sid: "s1", msg: "turn.terminal_failure", ctx: { message: "Unauthorized (401): Invalid or expired credentials\n\n Model: grok-4.5" } }), + ].join("\n")); + + assert.equal(readGrokTurnFailure("s1", { HOME: home }), "Unauthorized (401): Invalid or expired credentials"); + assert.equal(readGrokTurnFailure("missing", { HOME: home }), undefined); + assert.equal(readGrokTurnFailure("s1", { HOME: join(home, "nowhere") }), undefined); +}); diff --git a/src/local-agent-grok.ts b/src/local-agent-grok.ts index 6698f3fa9..b9ed6dc29 100644 --- a/src/local-agent-grok.ts +++ b/src/local-agent-grok.ts @@ -1,3 +1,6 @@ +import { closeSync, openSync, readSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { isJsonString, isObjectValue, optionalFields } from "./json-value.js"; import { unsafeNarrow } from "./unsafe-narrow.js"; import type { WireInput, JsonObject } from "./json-value.js"; @@ -308,3 +311,46 @@ function asRecord(value: WireInput): JsonObject | undefined { ? unsafeNarrow(value) : undefined; } + +const GROK_LOG_TAIL_BYTES = 512 * 1024; + +/** + * Grok reports a failed turn over ACP only as `stopReason: "error"` and writes the cause (such as a 401 for an + * expired login) to its own log, so read that session's terminal failure from the end of the log. Best effort. + */ +export function readGrokTurnFailure(sessionId: string, env: NodeJS.ProcessEnv): string | undefined { + const path = join(env.HOME ?? homedir(), ".grok", "logs", "unified.jsonl"); + let text: string; + + try { + const size = statSync(path).size; + const length = Math.min(size, GROK_LOG_TAIL_BYTES); + const buffer = Buffer.alloc(length); + const fd = openSync(path, "r"); + + try { + readSync(fd, buffer, 0, length, size - length); + } finally { + closeSync(fd); + } + + text = buffer.toString("utf8"); + } catch { + return undefined; + } + + for (const line of text.split("\n").reverse()) { + if (!line.includes(sessionId) || !line.includes("turn.terminal_failure")) continue; + + try { + const entry = asRecord(JSON.parse(line)); + const message = directString(asRecord(entry?.ctx)?.message); + + if (entry?.sid === sessionId && message) return message.split("\n")[0]; + } catch { + continue; + } + } + + return undefined; +}