diff options
| author | Igor Soarez <igor@soarez.org> | 2026-11-05 19:21:43 +0200 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-11-05 19:21:43 +0200 |
| commit | 5842b093cc502b46076423e4f8efdbbc568f86aa (patch) | |
| tree | 35b5a2ab971a2b9abb3188e56e0416033b4f13ea | |
| parent | 3b7b7ed091bae3b0609c38b2d445201cc4b7a026 (diff) | |
pi: a failed run says so: a 'failed' alert with the error, and the error in red until seen
| -rw-r--r-- | README.md | 13 | ||||
| -rw-r--r-- | ROADMAP.md | 10 | ||||
| -rw-r--r-- | crates/dinggerd/assets/pi/pi-agent-state.ts | 68 | ||||
| -rw-r--r-- | crates/dinggerd/src/hooks.rs | 44 | ||||
| -rw-r--r-- | crates/dinggerd/src/main.rs | 8 | ||||
| -rw-r--r-- | crates/dinggerd/src/notify.rs | 7 | ||||
| -rw-r--r-- | crates/dinggerd/src/tmux.rs | 8 | ||||
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 41 | ||||
| -rw-r--r-- | menubar/model.swift | 27 | ||||
| -rw-r--r-- | menubar/panel.swift | 25 | ||||
| -rwxr-xr-x | tests/e2e.sh | 30 | ||||
| -rwxr-xr-x | tests/fake-model.py | 11 | ||||
| -rwxr-xr-x | tests/pi-live.sh | 25 |
13 files changed, 272 insertions, 45 deletions
@@ -96,7 +96,12 @@ than a poll is still a completion. the clock. Clicking it jumps to the top waiting agent (the menu's top row), skipping the window you already look at. - **Notifications**: "needs attention" (blocked) and "finished" (done), with - herdr's sounds; silent for completions you are already watching. + herdr's sounds; silent for completions you are already watching. A pi + run that ends in an error (after pi's own retries) is still done, but + says so: "failed" with the error as one readable line (`401: invalid + x-api-key`), with the "needs attention" sound, and `failed 3m · <why>` + in red where the wait shows (plain text in the menu; `dingger status` + too), until you have seen it or it runs again. - **Importance**: mark an agent high, normal (the default), or low. Within a status, more important agents are listed first, so a jump goes to them first; status still comes first (a blocked low agent before a done high @@ -179,7 +184,8 @@ than a poll is still a completion. All per-pane state lives in tmux pane options (`@agent_state`, `@dingger_since`, `@dingger_hook`, `@dingger_importance`, `@dingger_extension`, `@dingger_control`, `@dingger_draft`, -`@dingger_cost`, `@dingger_context`), so restarting dingger loses nothing. +`@dingger_cost`, `@dingger_context`, `@dingger_error`), so restarting +dingger loses nothing. ### Keeping pi agents on the current extension @@ -285,7 +291,8 @@ daemon of its own, with a fake local model (`tests/fake-model.py`) and its own pi agent directory: no network, no credentials, your pi untouched. It covers identification, the extension's state and session reports, a turn, `/reload`, an extension's blocked event, dialogs (blocked during a run, -nothing while idle), a cancelled run (idle, not done), screen detection +nothing while idle), a cancelled run (idle, not done), a failed run (done, +with why; cleared when seen or run again), screen detection without the extension, restore, reloading an agent on an old extension (and refusing while busy or with a draft), drafts (reported, cleared, kept across a @@ -32,11 +32,11 @@ In priority order. 5. ~~**Cancelled runs are not done.**~~ Done (see the README): the extension reports `aborted` with the idle that ends a cancelled run, and the daemon keeps it idle, with no "finished" alert. -6. **Failed runs.** pi 1.1 tells a run that ended with an error from one - that finished, and gives the error's first line. dingger shows both as - done. Design question: herdr has no such status; should a failure get - its own mark, its own sound, and the error in the panel and - notification? +6. ~~**Failed runs.**~~ Done for pi (see the README): still done, but + the alert says "failed" and why, with the "needs attention" sound, + and the second line shows it in red until seen. In 30 days, 88 of + 3,959 runs (2.2%) failed: network errors, timeouts, and keys or + gateway policies. 7. ~~**The extended-keys warning.**~~ Gone: the tmux config turns on `extended-keys` (format `csi-u`), which is all pi checks for. dingger's snippet leaves it to the tmux config. 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(); }); diff --git a/crates/dinggerd/src/hooks.rs b/crates/dinggerd/src/hooks.rs index 709f62b..aeb3285 100644 --- a/crates/dinggerd/src/hooks.rs +++ b/crates/dinggerd/src/hooks.rs @@ -29,6 +29,8 @@ //! //! An idle report may say the run was `aborted` (cancelled, as with //! Escape): the agent is idle, not done, and nobody is told it finished. +//! Or that it failed, with the `error` as one readable line: the agent is +//! done, and the alert says it failed and why. //! //! It also carries the `draft` in the agent's input box ("" for none), for //! marking a draft you left behind; never logged. And `context`: how full @@ -81,6 +83,8 @@ struct PaneReports { draft: Option<String>, /// How full its context window is: whole percent ("" when unknown). context: Option<String>, + /// The error that ended its last failed run, as one line. + error: Option<String>, /// When the last report arrived. at: Option<Instant>, } @@ -183,6 +187,8 @@ struct HookParams { context: Option<String>, #[serde(default)] aborted: Option<bool>, + #[serde(default)] + error: Option<String>, } fn handle_stream(stream: UnixStream) { @@ -252,6 +258,7 @@ fn handle_stream(stream: UnixStream) { draft: params.draft, context: params.context, aborted: params.aborted == Some(true), + error: params.error, }; record(params.pane_id, state, reported); wake(); @@ -269,6 +276,8 @@ struct Reported { context: Option<String>, /// The run this report ends was cancelled. aborted: bool, + /// The error the run this report ends failed with. + error: Option<String>, } /// A reported state, and whether it ends a run the user cancelled. @@ -277,11 +286,13 @@ pub struct Observation { pub state: AgentState, /// Idle after a cancelled run: not done, and no "finished" alert. pub cancelled: bool, + /// Idle after a failed run: done, with a "failed" alert (see [`error`]). + pub failed: bool, } impl Observation { pub fn plain(state: AgentState) -> Self { - Observation { state, cancelled: false } + Observation { state, cancelled: false, failed: false } } } @@ -311,7 +322,12 @@ fn record(pane_id: String, state: Option<AgentState>, reported: Reported) { pane.at = Some(Instant::now()); if let Some(state) = state { pane.last = Some(state); - pane.pending.push(Observation { state, cancelled: reported.aborted && state == AgentState::Idle }); + let idle = state == AgentState::Idle; + let error = reported.error.as_deref().map(draft_line).filter(|error| idle && !error.is_empty()); + pane.pending.push(Observation { state, cancelled: reported.aborted && idle, failed: error.is_some() }); + if error.is_some() { + pane.error = error; + } } if let Some(session) = option_value(reported.session) { pane.session = Some(session); @@ -342,6 +358,11 @@ fn context_percent(value: &str) -> String { } } +/// The error the pane's agent's last failed run ended with. +pub fn error(pane_id: &str) -> Option<String> { + reports().get(pane_id).and_then(|pane| pane.error.clone()) +} + /// How full the pane's agent's context window is ("" when unknown), if its /// extension reports it. pub fn context(pane_id: &str) -> Option<String> { @@ -497,7 +518,7 @@ mod tests { take_observations("%t7"), Some(vec![ Observation::plain(AgentState::Working), - Observation { state: AgentState::Idle, cancelled: true }, + Observation { state: AgentState::Idle, cancelled: true, failed: false }, ]), "only an idle report ends a run" ); @@ -507,6 +528,23 @@ mod tests { } #[test] + fn a_failed_run_ends_in_a_failed_idle_observation_with_its_error() { + let failed = |error: &str| Reported { error: Some(error.into()), ..Default::default() }; + record("%t8".into(), Some(AgentState::Working), failed("not the end of a run")); + record("%t8".into(), Some(AgentState::Idle), failed("401: invalid\nx-api-key")); + assert_eq!( + take_observations("%t8"), + Some(vec![ + Observation::plain(AgentState::Working), + Observation { state: AgentState::Idle, cancelled: false, failed: true }, + ]) + ); + assert_eq!(super::error("%t8").as_deref(), Some("401: invalid x-api-key"), "one line"); + record("%t8".into(), Some(AgentState::Idle), failed("")); + assert_eq!(take_observations("%t8"), Some(vec![Observation::plain(AgentState::Idle)]), "no error, no failure"); + } + + #[test] fn context_use_is_kept_as_whole_percent_until_reported_again() { let context = |text: &str| Reported { context: Some(text.into()), ..Default::default() }; assert_eq!(super::context("%t6"), None, "an extension that does not report it"); diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs index 935da7d..0ace3a9 100644 --- a/crates/dinggerd/src/main.rs +++ b/crates/dinggerd/src/main.rs @@ -480,7 +480,13 @@ fn status() -> Result<(), String> { }; let cost = if pane.written_cost.is_empty() { String::new() } else { format!("${}", pane.written_cost) }; let context = if pane.written_context.is_empty() { String::new() } else { format!("{}%", pane.written_context) }; - println!("{state:<8} {:<width$} {agent:<7} {cost:>8} {context:>4} {draft}{}", place(pane), name.trim()); + // A failed run: done, and why. + let (state, why) = if pane.written_error.is_empty() || state != "done" { + (state, String::new()) + } else { + ("failed", format!(" · {}", pane.written_error)) + }; + println!("{state:<8} {:<width$} {agent:<7} {cost:>8} {context:>4} {draft}{}{why}", place(pane), name.trim()); } let global = |name: &str| tmux::show_global(name).unwrap_or_default(); let spent = [("today", "@dingger_cost_today"), ("this week", "@dingger_cost_week"), ("this month", "@dingger_cost_month")] diff --git a/crates/dinggerd/src/notify.rs b/crates/dinggerd/src/notify.rs index 11b37b0..ef2c721 100644 --- a/crates/dinggerd/src/notify.rs +++ b/crates/dinggerd/src/notify.rs @@ -8,6 +8,8 @@ use tracing::{info, warn}; pub enum NotificationKind { NeedsAttention, Finished, + /// A run that ended in an error: done, but you have to act. + Failed, } impl NotificationKind { @@ -15,6 +17,7 @@ impl NotificationKind { match self { Self::NeedsAttention => "needs attention", Self::Finished => "finished", + Self::Failed => "failed", } } @@ -22,7 +25,7 @@ impl NotificationKind { /// for completions. fn sound_bytes(&self) -> &'static [u8] { match self { - Self::NeedsAttention => include_bytes!("../assets/request.mp3"), + Self::NeedsAttention | Self::Failed => include_bytes!("../assets/request.mp3"), Self::Finished => include_bytes!("../assets/done.mp3"), } } @@ -34,7 +37,7 @@ pub fn play_sound(kind: &NotificationKind) { // Test seam: play through a command instead (tests/e2e.sh). if let Ok(command) = std::env::var("DINGGER_SOUND") { let name = match kind { - NotificationKind::NeedsAttention => "request", + NotificationKind::NeedsAttention | NotificationKind::Failed => "request", NotificationKind::Finished => "done", }; let _ = Command::new("sh").args(["-c", &command, name]).stdin(Stdio::null()).spawn(); diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs index 80adbb5..7845381 100644 --- a/crates/dinggerd/src/tmux.rs +++ b/crates/dinggerd/src/tmux.rs @@ -38,6 +38,8 @@ pub struct Pane { pub written_cost: String, /// How full the agent's context window is, in whole percent. pub written_context: String, + /// Why its last run failed, while it is done and unseen. + pub written_error: String, } @@ -124,14 +126,15 @@ pub fn list_panes() -> Result<Vec<Pane>, String> { "#{@dingger_control}\t", "#{@dingger_draft}\t", "#{@dingger_cost}\t", - "#{@dingger_context}", + "#{@dingger_context}\t", + "#{@dingger_error}", ); let out = run_tmux(&["list-panes", "-a", "-F", format])?; let mut panes: Vec<Pane> = Vec::new(); let mut process_tree = None; for line in out.lines() { let fields: Vec<&str> = line.split('\t').collect(); - if fields.len() < 25 { + if fields.len() < 26 { continue; } let attended = fields[4] == "1" && fields[5] != "0"; @@ -179,6 +182,7 @@ pub fn list_panes() -> Result<Vec<Pane>, String> { written_draft: fields[22].to_string(), written_cost: fields[23].to_string(), written_context: fields[24].to_string(), + written_error: fields[25].to_string(), }); } Ok(panes) diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs index aee8d5e..9bad1fe 100644 --- a/crates/dinggerd/src/watcher.rs +++ b/crates/dinggerd/src/watcher.rs @@ -29,6 +29,7 @@ //! - `@dingger_draft` the draft in the agent's input box, as one line //! - `@dingger_cost` what the agent's session spent today (see `cost`) //! - `@dingger_context` how full its context window is, in whole percent +//! - `@dingger_error` why its run failed, while it is done (unseen) use std::collections::HashMap; @@ -375,7 +376,15 @@ impl Watcher { // Apply every observation in order: a turn that started and // finished between two ticks is still a completion. let mut alerts = Vec::new(); + // Why its last run failed: kept while it is done. + let mut error = pane.written_error.clone(); for observed in observations { + if observed.state != AgentState::Idle { + error.clear(); + } + if observed.failed { + error = hooks::error(&pane.pane_id).unwrap_or_else(|| "error".into()); + } let mut next = advance(track, observed.state, pane.attended, acked, now); // A cancelled run did not finish: idle, as if seen. if observed.cancelled && next.state == AgentState::Idle { @@ -392,7 +401,12 @@ impl Watcher { // Notify on transitions observed by this process, not // when resuming from options written before a restart. if !restored && !observed.cancelled { - alerts.extend(notification_kind_for_transition(track.state, next.state)); + alerts.extend(notification_kind_for_transition(track.state, next.state).map(|kind| { + match kind { + NotificationKind::Finished if observed.failed => NotificationKind::Failed, + kind => kind, + } + })); } } track = next; @@ -408,14 +422,18 @@ impl Watcher { track = next; } self.tracks.insert(pane.pane_id.clone(), track); + // Seen, a new run, or gone: the failure is no longer news. + if track.status() != Status::Done { + error.clear(); + } // Update what the user sees first, then alert. if acked { let _ = tmux::unset_pane_option(&pane.pane_id, "@dingger_ack"); } - self.write_pane_options(pane, track); + self.write_pane_options(pane, track, &error); for kind in alerts { - self.notify(pane, kind); + self.notify(pane, kind, &error); } } @@ -465,7 +483,7 @@ impl Watcher { /// Write the pane's options that changed, in one tmux command list: /// tmux applies it as a unit, so a daemon killed mid-write never leaves /// a display state that disagrees with the hook state it came from. - fn write_pane_options(&self, pane: &tmux::Pane, track: PaneTrack) { + fn write_pane_options(&self, pane: &tmux::Pane, track: PaneTrack, error: &str) { let mut changes: Vec<(&str, Option<String>)> = Vec::new(); let mut change = |name: &'static str, written: &str, value: String| { if written != value { @@ -483,6 +501,7 @@ impl Watcher { } change("@agent_state", &pane.written_state, track.status().label().to_string()); change("@dingger_since", &pane.written_since, track.since.map(|s| s.to_string()).unwrap_or_default()); + change("@dingger_error", &pane.written_error, error.to_string()); change("@agent", &pane.written_agent, pane.agent.map(detect_core::agent_label).unwrap_or("").to_string()); let name = crate::names::display_name(pane.agent, &pane.pane_title, &pane.cwd, host_name()) .unwrap_or_default(); @@ -544,16 +563,22 @@ impl Watcher { } } - fn notify(&self, pane: &tmux::Pane, kind: NotificationKind) { + fn notify(&self, pane: &tmux::Pane, kind: NotificationKind, error: &str) { // herdr: a blocked pane plays the alert even when the user is looking - // at it; a finished pane stays silent when they already are. + // at it; a finished (or failed) pane stays silent when they already + // are. let watching = pane.attended; - if matches!(kind, NotificationKind::Finished) && watching { + if matches!(kind, NotificationKind::Finished | NotificationKind::Failed) && watching { return; } let agent = pane.agent.map(detect_core::agent_label).unwrap_or("agent"); if self.notifications && !watching { - send_notification(agent, &kind, &format!("{} ({agent})", pane.display_target)); + let mut context = format!("{} ({agent})", pane.display_target); + if matches!(kind, NotificationKind::Failed) && !error.is_empty() { + context.push_str(": "); + context.push_str(error); + } + send_notification(agent, &kind, &context); } // A low agent makes no sound; it still notifies and waits in line. if self.sounds && importance_of(pane) != Importance::Low { diff --git a/menubar/model.swift b/menubar/model.swift index 4117ba5..fb83105 100644 --- a/menubar/model.swift +++ b/menubar/model.swift @@ -105,6 +105,11 @@ struct Row: Equatable { /// How full its context window is, in percent (`@dingger_context`), as /// pi's footer shows it; nil when unknown. var context: Double? = nil + /// Why its run failed (`@dingger_error`), while it is done; "" if not. + var error: String = "" + + /// Done, but its run failed. + var failed: Bool { status == .done && !error.isEmpty } var location: String { "\(session) · \(window)" } /// A draft left behind: where the user looks, they may be typing it. @@ -134,15 +139,25 @@ struct Row: Equatable { } /// The panel's second line, before its cost and context columns. - var place: String { + var place: String { [placeBase, failure].compactMap { $0 }.joined(separator: " · ") } + + /// Where it runs, which harness, whether it is low, and how long it + /// waits, unless its run failed (see `failure`). + var placeBase: String { var parts = [location, agent] if importance == .low { parts.append("low") } - if waiting, since != Int.max { + if waiting, since != Int.max, !failed { parts.append("\(status.rawValue) \(elapsed(sinceMs: since))") } return parts.joined(separator: " · ") } + /// "failed 3m · 401: invalid x-api-key", for a failed run. + var failure: String? { + guard failed else { return nil } + return (since != Int.max ? "failed \(elapsed(sinceMs: since))" : "failed") + " · " + error + } + /// The panel's row as text: its columns, then cost and context. var panelText: String { let right = [cost >= 0.005 ? dollars(cost) : nil, context.map(contextText)].compactMap { $0 } @@ -356,19 +371,19 @@ func fetchRows() -> [Row] { let format = [ "#{pane_id}", "#{@agent}", "#{@agent_state}", "#{@dingger_since}", "#{@agent_name}", "#{session_name}", "#{window_name}", "#{window_panes}", "#{pane_index}", "#{pane_current_path}", - "#{@dingger_importance}", "#{window_active}", "#{session_attached}", "#{@dingger_draft}", "#{@dingger_cost}", "#{@dingger_context}", + "#{@dingger_importance}", "#{window_active}", "#{session_attached}", "#{@dingger_draft}", "#{@dingger_cost}", "#{@dingger_context}", "#{@dingger_error}", ].joined(separator: "\t") guard let out = tmux(["list-panes", "-a", "-F", format]) else { return [] } let lines = out.split(separator: "\n").map { $0.split(separator: "\t", omittingEmptySubsequences: false).map(String.init) } // Looked at in any of its sessions, as the daemon counts it. - let attended = Set(lines.filter { $0.count >= 16 && $0[11] == "1" && $0[12] != "0" }.map { $0[0] }) + let attended = Set(lines.filter { $0.count >= 17 && $0[11] == "1" && $0[12] != "0" }.map { $0[0] }) // A pane in several sessions (a session group, a linked window) is // listed once, under its first session, as the daemon counts it. var listed = Set<String>() let rows: [Row] = lines.compactMap { f in - guard f.count >= 16, !f[1].isEmpty, listed.insert(f[0]).inserted else { return nil } + guard f.count >= 17, !f[1].isEmpty, listed.insert(f[0]).inserted else { return nil } let status = Status(rawValue: f[2]) ?? .unknown guard status != .unknown else { return nil } let window = (Int(f[7]) ?? 1) > 1 ? "\(f[6]).\(f[8])" : f[6] @@ -376,7 +391,7 @@ func fetchRows() -> [Row] { session: f[5], window: window, since: Int(f[3]) ?? Int.max, path: f[9].hasPrefix(home) ? "~" + f[9].dropFirst(home.count) : f[9], importance: Importance(option: f[10]), draft: f[13], attended: attended.contains(f[0]), - cost: Double(f[14]) ?? 0, context: Double(f[15])) + cost: Double(f[14]) ?? 0, context: Double(f[15]), error: f[16]) } return rows.sorted(by: listedBefore) } diff --git a/menubar/panel.swift b/menubar/panel.swift index 5347ceb..174d30e 100644 --- a/menubar/panel.swift +++ b/menubar/panel.swift @@ -313,9 +313,17 @@ final class PanelRowView: ClickView { self.onClick = { onClick(row.paneId) } let title = singleLine(NSTextField( labelWithAttributedString: titleText ?? rowTitle(row, primary: mochaText, secondary: mochaSubtext))) - let placeLabel = NSTextField(labelWithString: row.place) - placeLabel.font = .systemFont(ofSize: 11) - placeLabel.textColor = mochaSubtext + // A failed run's "failed 3m · why" in red. + let small = NSFont.systemFont(ofSize: 11) + let placeText = NSMutableAttributedString( + string: row.placeBase, attributes: [.font: small, .foregroundColor: mochaSubtext]) + if let failure = row.failure { + placeText.append(NSAttributedString(string: " · ", attributes: [.font: small, .foregroundColor: mochaSubtext])) + placeText.append(NSAttributedString(string: failure, attributes: [.font: small, .foregroundColor: mochaRed])) + } + let placeLabel = NSTextField(labelWithAttributedString: placeText) + // Its own font too, or it lays out as 13 pt (the colors stay). + placeLabel.font = small let place = singleLine(placeLabel) let cost = NSTextField(labelWithString: row.cost >= 0.005 ? dollars(row.cost) : "") cost.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) @@ -777,6 +785,17 @@ func panelSelftest() -> [String] { check(full.plainText.hasSuffix("[Show less ▴]") && !compact.plainText.contains("Show"), "toggle offered only when allowed, labelled by layout") + // A failed run. + var broken = row("b", .done) + broken.error = "401: invalid x-api-key" + broken.cost = 1.5 + check(broken.failed && broken.place.hasPrefix("s · b · pi · failed ") && broken.place.hasSuffix(" · 401: invalid x-api-key"), + "failed: in place of the wait, with why") + check(broken.subtitle.hasSuffix("401: invalid x-api-key · $1.50"), "failed: the menu's text, then the cost") + var seen = row("b", .idle) + seen.error = broken.error + check(!seen.failed && seen.place == "s · b · pi", "failed: once seen, plain idle") + // Context use. var filling = row("f", .idle) filling.cost = 3.12 diff --git a/tests/e2e.sh b/tests/e2e.sh index 52367d2..d9b4381 100755 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -1207,6 +1207,36 @@ expect "one sound, the finished one's" "cat $SOUNDS" done expect "the menu lists it idle" "mb --print | grep -F 'can · c2' | cut -f1" "○ idle" t kill-session -t can +# --- 28. a failed run --------------------------------------------------------- +echo "28. failed runs" +error_report() { # pane error + python3 - "$HOOK" "$1" "$2" <<'PY' +import json, socket, sys +params = {"pane_id": sys.argv[2], "agent": "pi", "state": "idle", "error": sys.argv[3]} +s = socket.socket(socket.AF_UNIX) +s.connect(sys.argv[1]) +s.sendall((json.dumps({"method": "pane.report_agent", "params": params}) + "\n").encode()) +s.close() +PY +} +t new-session -d -s fl -n f1 -c "$DIR" "$DIR/bin/pi" +F1=$(t display -p -t fl:f1 '#{pane_id}') +report "$F1" working "" +expect "working" "opt $F1 @agent_state" working 50 +sleep 3.5; : >"$SOUNDS"; : >"$NOTES" +error_report "$F1" "401: invalid x-api-key" +expect "failed: done" "opt $F1 @agent_state" done 50 +expect "with why" "opt $F1 @dingger_error" "401: invalid x-api-key" +expect "a 'failed' notification, with why" "grep -c '^pi failed|fl:f1 (pi): 401: invalid x-api-key$' $NOTES" 1 60 +expect "the 'needs attention' sound" "cat $SOUNDS" request +expect "dingger status: failed, and why" "$DINGGER status | grep -F 'fl · f1' | grep -oE '^failed .* · 401: invalid x-api-key$' | wc -l | tr -d ' '" 1 +report "$F1" working "" +expect "a new run: the failure goes" "opt $F1 @dingger_error" "" 50 +report "$F1" idle "" +expect "finished: done" "opt $F1 @agent_state" done 50 +expect "with no failure" "opt $F1 @dingger_error" "" +t kill-session -t fl + # Every daemon this run started must stop with it. kill "$DAEMON_PID" "$DAEMON2_PID" 2>/dev/null expect "the test daemons stop" "ps -o pid= -p $DAEMON_PID,$DAEMON2_PID | wc -l | tr -d ' '" 0 diff --git a/tests/fake-model.py b/tests/fake-model.py index a02b98c..98a61db 100755 --- a/tests/fake-model.py +++ b/tests/fake-model.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """A fake OpenAI-compatible model for tests/pi-live.sh: streams a short reply to /v1/chat/completions, after a delay taken from the last user message -("slow 3" waits 3 seconds). Prints its port, then serves until killed.""" +("slow 3" waits 3 seconds), or fails as a provider does ("fail 401": an +authentication error). Prints its port, then serves until killed.""" import json import re import sys @@ -34,6 +35,14 @@ class Handler(BaseHTTPRequestHandler): text = last_user_text(body) match = re.search(r"slow (\d+(?:\.\d+)?)", text) time.sleep(float(match.group(1)) if match else 0.2) + failure = re.search(r"fail (\d{3})", text) + if failure: + error = json.dumps({"type": "error", "error": {"type": "authentication_error", "message": "invalid x-api-key"}}) + self.send_response(int(failure.group(1))) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(error.encode()) + return self.send_response(200) self.send_header("Content-Type", "text/event-stream") self.send_header("Cache-Control", "no-cache") diff --git a/tests/pi-live.sh b/tests/pi-live.sh index 53115ec..04ef04a 100755 --- a/tests/pi-live.sh +++ b/tests/pi-live.sh @@ -277,6 +277,31 @@ same_cost() { python3 -c "import sys; a,b=sys.argv[1:]; print('same' if a and b expect "cost: compaction counted, as pi's footer counts" same_cost same 30 t kill-window -t main:ctx +# A failed run (the provider refuses the key): done, and why. +t new-window -d -t main: -n fail -c "$D/work3" +t send-keys -t main:fail "pi $NO_MCP" Enter +expect "failures: an agent of its own" "st fail" idle 150 +type_line fail "fail 401 now" +expect "a failed run: done" "st fail" done 80 +expect "with why, as one readable line" "t show -pv -t main:fail @dingger_error 2>/dev/null" "401: invalid x-api-key" 30 +expect "the menu says it failed" "\"$MENU\" --print | grep -F 'main · fail' | cut -f3 | grep -oE 'failed [^·]*· 401: invalid x-api-key'" "failed now · 401: invalid x-api-key" +t set -p -t main:fail @dingger_ack 1; "$DINGGER" poke +expect "seen: plain idle" "st fail" idle 50 +expect "and the failure is no longer shown" "t show -pv -t main:fail @dingger_error 2>/dev/null" "" 30 +type_line fail "fail 401 again" +expect "failed again" "t show -pv -t main:fail @dingger_error 2>/dev/null" "401: invalid x-api-key" 80 +# A run cancelled after a failure is not a failure. +type_line fail "slow 8 cancel me" +expect "a new run: working" "st fail" working 50 +expect "the failure goes with it" "t show -pv -t main:fail @dingger_error 2>/dev/null" "" 30 +t send-keys -t main:fail Escape +expect "cancelled: idle" "st fail" idle 50 +expect "with no failure" "t show -pv -t main:fail @dingger_error 2>/dev/null" "" +type_line fail "slow 1 fine" +expect "a run that works: done" "st fail" done 80 +expect "with no failure" "t show -pv -t main:fail @dingger_error 2>/dev/null" "" +t kill-window -t main:fail + echo "2. screen detection (pi without the extension)" t send-keys -t main:plain "PI_CODING_AGENT_DIR=$D/agent-plain pi $NO_MCP" Enter expect "identified as pi" "t show -pv -t main:plain @agent 2>/dev/null" pi 150 |
