From 44cae763d8779b4fc7c96cf948a72800a230f78e Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:23:19 +0530 Subject: [PATCH] fix(acp): show why an agent's turn produced no answer When an ACP agent ended a turn without assistant text, DevSpace said "acp ACP did not return a final assistant response" with no detail: the label was the driver kind, and the cause was only the bare prompt response. Grok in particular reports a failed turn only as stopReason "error" and writes the cause, such as a 401 for an expired login, to its own log. The error now names the agent (grok ACP, cursor ACP), and its detail carries the stop reason, the update kinds received, the agent's recent stderr, and for Grok the session's terminal failure read from the end of ~/.grok/logs/unified.jsonl, with a login hint when none is found. Co-Authored-By: Claude Opus 5.5 --- src/local-agent-acp.test.ts | 9 +++++ src/local-agent-acp.ts | 70 ++++++++++++++++++++++++++---------- src/local-agent-grok.test.ts | 18 ++++++++++ src/local-agent-grok.ts | 46 ++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 18 deletions(-) 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; +}