Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/local-agent-acp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
resolveAcpCommand,
selectAcpPermissionOption,
matchAcpSelectValue,
acpEmptyTurnDetail,
} from "./local-agent-acp.js";
import { GrokPromptCompletionRegistry } from "./local-agent-grok.js";

Expand Down Expand Up @@ -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");
});
70 changes: 52 additions & 18 deletions src/local-agent-acp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
GROK_DEFAULT_MODEL,
parseGrokPromptCompletion,
readGrokSessionState,
readGrokTurnFailure,
resolveGrokEffort,
resolveGrokModelId,
} from "./local-agent-grok.js";
Expand Down Expand Up @@ -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 {
Expand All @@ -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<string>();
private promptSequence = 0;
private alive = true;
Expand All @@ -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;
Expand All @@ -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.`,
});
}

Expand All @@ -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);
Expand Down Expand Up @@ -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.`,
});
}

Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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.`,
});
}

Expand Down Expand Up @@ -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.`,
});
}

Expand Down Expand Up @@ -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.`,
});
}

Expand Down Expand Up @@ -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}'.`,
});
}
}
Expand All @@ -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.`,
});
}

Expand Down Expand Up @@ -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}'.`,
});
}
}
Expand Down Expand Up @@ -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.`,
});
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.`,
});
}
},
Expand Down Expand Up @@ -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;

Expand Down
18 changes: 18 additions & 0 deletions src/local-agent-grok.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -7,6 +10,7 @@ import {
readGrokSessionState,
resolveGrokEffort,
resolveGrokModelId,
readGrokTurnFailure,
} from "./local-agent-grok.js";

const sessionResponse = {
Expand Down Expand Up @@ -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);
});
46 changes: 46 additions & 0 deletions src/local-agent-grok.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -308,3 +311,46 @@ function asRecord(value: WireInput): JsonObject | undefined {
? unsafeNarrow<JsonObject>(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;
}
Loading