aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/assets/pi
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-11-05 19:21:43 +0200
committerIgor Soarez <igor@soarez.org>2026-11-05 19:21:43 +0200
commit5842b093cc502b46076423e4f8efdbbc568f86aa (patch)
tree35b5a2ab971a2b9abb3188e56e0416033b4f13ea /crates/dinggerd/assets/pi
parent3b7b7ed091bae3b0609c38b2d445201cc4b7a026 (diff)
pi: a failed run says so: a 'failed' alert with the error, and the error in red until seen
Diffstat (limited to 'crates/dinggerd/assets/pi')
-rw-r--r--crates/dinggerd/assets/pi/pi-agent-state.ts68
1 files changed, 57 insertions, 11 deletions
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<void> {
+/** 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<void> {
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<void> {
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();
});