From 5842b093cc502b46076423e4f8efdbbc568f86aa Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Thu, 5 Nov 2026 19:21:43 +0200 Subject: pi: a failed run says so: a 'failed' alert with the error, and the error in red until seen --- crates/dinggerd/assets/pi/pi-agent-state.ts | 68 ++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 11 deletions(-) (limited to 'crates/dinggerd/assets/pi') diff --git a/crates/dinggerd/assets/pi/pi-agent-state.ts b/crates/dinggerd/assets/pi/pi-agent-state.ts index 62f29db..ceac45b 100644 --- a/crates/dinggerd/assets/pi/pi-agent-state.ts +++ b/crates/dinggerd/assets/pi/pi-agent-state.ts @@ -167,7 +167,34 @@ function requestId(): string { return `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`; } -async function report(state: AgentState | undefined, message?: string, aborted = false): Promise { +/** How a run ended, sent with the idle report that ends it. */ +type RunEnd = { aborted?: boolean; error?: string }; + +/** + * A provider error as one short readable line: the message inside a JSON + * body ("401: invalid x-api-key"), else the first line. + */ +export function readableError(text: string): string { + const first = text.split("\n").find((line) => line.trim()) ?? ""; + let line = first; + const json = first.indexOf("{"); + if (json >= 0) { + try { + const body = JSON.parse(first.slice(json)); + const message = body?.error?.message ?? body?.message ?? body?.detail ?? body?.error; + if (typeof message === "string" && message.trim()) { + const status = first.slice(0, json).replace(/[\s:]+$/, ""); + line = status ? `${status}: ${message}` : message; + } + } catch { + // Not JSON after all. + } + } + line = line.replace(/\s+/g, " ").trim(); + return line.length > 100 ? `${line.slice(0, 99)}…` : line || "error"; +} + +async function report(state: AgentState | undefined, message?: string, end: RunEnd = {}): Promise { await sendRequest({ id: requestId(), method: "pane.report_agent", @@ -177,8 +204,9 @@ async function report(state: AgentState | undefined, message?: string, aborted = agent: "pi", ...(state ? { state } : {}), message, - // The run this idle report ends was cancelled: not done. - ...(aborted ? { aborted: true } : {}), + // The run this idle report ends was cancelled (not done), or failed. + ...(end.aborted ? { aborted: true } : {}), + ...(end.error ? { error: end.error } : {}), importance, extension: extensionVersion, ...(controlListening ? { control: controlPath } : {}), @@ -191,10 +219,10 @@ async function report(state: AgentState | undefined, message?: string, aborted = } let sendInFlight = false; -let queuedState: { state: AgentState; message?: string; aborted: boolean } | undefined; +let queuedState: { state: AgentState; message?: string; end: RunEnd } | undefined; -function queueState(state: AgentState, message?: string, aborted = false): void { - queuedState = { state, message, aborted }; +function queueState(state: AgentState, message?: string, end: RunEnd = {}): void { + queuedState = { state, message, end }; if (!sendInFlight) { void drainStateQueue(); } @@ -210,7 +238,7 @@ async function drainStateQueue(): Promise { while (queuedState) { const next = queuedState; queuedState = undefined; - await report(next.state, next.message, next.aborted); + await report(next.state, next.message, next.end); } } finally { sendInFlight = false; @@ -241,16 +269,30 @@ export default function (pi) { return { state: "idle" as const, message: undefined }; } - function publishState(force = false, aborted = false) { + function publishState(force = false, end: RunEnd = {}) { const next = desiredState(); if (!force && next.state === lastState && next.message === lastMessage) { return; } lastState = next.state; lastMessage = next.message; - queueState(next.state, next.message, aborted && next.state === "idle"); + queueState(next.state, next.message, next.state === "idle" ? end : {}); } + // How the current run is ending: pi says before it settles (not for a + // cancelled run), and the last reply carries the error. + let runOutcome: string | undefined; + let replyError: string | undefined; + pi.on("agent_before_settle", (event) => { + runOutcome = event?.outcome; + }); + pi.on("message_end", (event) => { + const reply = event?.message; + if (reply?.role === "assistant") { + replyError = reply.stopReason === "error" ? (reply.errorMessage ?? "") : undefined; + } + }); + // A dialog that opens during a run (an extension asking before it goes // on): the agent waits on you, so it is blocked until the dialog closes. // One opened while idle comes from what you just did, and changes nothing. @@ -529,6 +571,8 @@ export default function (pi) { pi.on("agent_start", (_event, ctx) => { latestCtx = ctx; agentActive = true; + runOutcome = undefined; + replyError = undefined; updateSessionFile(ctx); publishState(); // The prompt left the input box. @@ -553,8 +597,10 @@ export default function (pi) { agentActive = false; // The last reply's usage: message_end comes before pi counts it. checkContext(ctx); - // A cancelled run (Escape) is idle, not done. - publishState(false, event?.aborted === true); + // A cancelled run (Escape) is idle, not done; a failed one says why. + const aborted = event?.aborted === true; + const error = !aborted && runOutcome === "error" ? readableError(replyError ?? "") : undefined; + publishState(false, { aborted, error }); // Escape puts queued messages back into the input box. checkDraftSoon(); }); -- cgit v1.2.3