From 9aeaa52bdb33b63a5ae3f687337d42d7237236a1 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 19 Aug 2026 08:12:55 +0900 Subject: [PATCH 1/2] fix: retain worker run errors in console history --- crates/tui/src/app.rs | 86 +++++++++++++++++ .../src/lib/workspace/console/model.test.ts | 94 ++++++++++++++++++- .../src/lib/workspace/console/model.ts | 17 ++++ .../workers/[workerId]/console/+page.svelte | 17 ++-- 4 files changed, 204 insertions(+), 10 deletions(-) diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index a765ea71..615e29c4 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -2081,6 +2081,13 @@ impl App { } if domain == "yoi.compaction" => { self.apply_compaction_extension(&payload); } + session_store::LogEntry::RunErrored { message, .. } => { + self.blocks.push(Block::Alert { + level: AlertLevel::Error, + source: AlertSource::Worker, + message, + }); + } // Non-history-bearing variants don't affect the block view. _ => {} } @@ -3261,6 +3268,85 @@ mod completion_flow_tests { )); } + #[test] + fn snapshot_and_segment_rotation_retain_one_durable_run_error_block() { + let mut app = App::new("test".into()); + app.handle_worker_event(Event::Error { + code: ErrorCode::ProviderError, + message: "provider unavailable".into(), + }); + app.handle_worker_event(Event::Status { + status: WorkerStatus::Idle, + }); + + let live_errors = app + .blocks + .iter() + .filter_map(|block| match block { + Block::Alert { + level: AlertLevel::Error, + source: AlertSource::Worker, + message, + } => Some(message.as_str()), + _ => None, + }) + .collect::>(); + assert_eq!(live_errors, ["[ProviderError] provider unavailable"]); + + let run_errored = session_store::LogEntry::RunErrored { + ts: 3, + interrupted: false, + message: "provider unavailable".into(), + }; + app.handle_worker_event(Event::Snapshot { + greeting: test_greeting(), + entries: vec![serde_json::to_value(run_errored).unwrap()], + status: WorkerStatus::Idle, + in_flight: Default::default(), + }); + + let errors = app + .blocks + .iter() + .filter_map(|block| match block { + Block::Alert { + level: AlertLevel::Error, + source: AlertSource::Worker, + message, + } => Some(message.as_str()), + _ => None, + }) + .collect::>(); + assert_eq!(errors, ["provider unavailable"]); + + app.handle_worker_event(Event::Error { + code: ErrorCode::ProviderError, + message: "retry unavailable".into(), + }); + let rotated_run_error = session_store::LogEntry::RunErrored { + ts: 5, + interrupted: false, + message: "retry unavailable".into(), + }; + app.handle_worker_event(Event::SegmentRotated { + entry: serde_json::to_value(rotated_run_error).unwrap(), + }); + + let rotated_errors = app + .blocks + .iter() + .filter_map(|block| match block { + Block::Alert { + level: AlertLevel::Error, + source: AlertSource::Worker, + message, + } => Some(message.as_str()), + _ => None, + }) + .collect::>(); + assert_eq!(rotated_errors, ["retry unavailable"]); + } + #[test] fn snapshot_in_flight_blocks_continue_with_live_deltas() { let mut app = App::new("test".into()); diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index 6572c26c..fd0d5547 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -2,6 +2,7 @@ import type { Event } from "$lib/generated/protocol"; import { type ConsoleLine, createConsoleProjector, + isConsoleProjectionEvent, projectConsole, segmentsToText, selectConsoleTimelineLines, @@ -36,11 +37,11 @@ function consoleLine(id: string, kind: ConsoleLine["kind"]): ConsoleLine { }; } -function snapshotEvent(cwd: string): Event { +function snapshotEvent(cwd: string, entries: unknown[] = []): Event { return { event: "snapshot", data: { - entries: [], + entries, greeting: { worker_name: "Worker", cwd, @@ -57,6 +58,95 @@ function snapshotEvent(cwd: string): Event { }; } +Deno.test("console routing projects live errors but not completion replies", () => { + const errorEvent = { + event: "error", + data: { code: "provider_error", message: "provider unavailable" }, + } satisfies Event; + const completionEvent = { + event: "completions", + data: { kind: "file", entries: [] }, + } satisfies Event; + + assert( + isConsoleProjectionEvent(errorEvent), + "live errors must reach the timeline projector", + ); + assert( + !isConsoleProjectionEvent(completionEvent), + "completion replies should remain control-only events", + ); +}); + +Deno.test("snapshot and segment rotation retain one durable run_errored row", () => { + const projector = createConsoleProjector(); + let projection = projector.append([ + { + eventId: "live-error", + event: { + event: "error", + data: { code: "provider_error", message: "provider unavailable" }, + } satisfies Event, + }, + { + eventId: "idle-after-error", + event: { event: "status", data: { status: "idle" } } satisfies Event, + }, + ]); + + assertEquals(projection.status, "idle"); + const liveErrors = projection.lines.filter((line) => line.kind === "error"); + assertEquals(liveErrors.length, 1); + assertEquals(liveErrors[0].title, "error · provider_error"); + assertEquals(liveErrors[0].body, "provider unavailable"); + + projection = projector.append([{ + eventId: "reconnected-snapshot", + event: snapshotEvent("/repo", [{ + kind: "run_errored", + ts: 3, + interrupted: false, + message: "provider unavailable", + }]), + }]); + + const errors = projection.lines.filter((line) => line.kind === "error"); + assertEquals(errors.length, 1); + assertEquals(errors[0].title, "Run error"); + assertEquals(errors[0].body, "provider unavailable"); + assertEquals(errors[0].error, true); + + projection = projector.append([ + { + eventId: "second-live-error", + event: { + event: "error", + data: { code: "provider_error", message: "retry unavailable" }, + } satisfies Event, + }, + { + eventId: "segment-rotated", + event: { + event: "segment_rotated", + data: { + entry: { + kind: "run_errored", + ts: 5, + interrupted: false, + message: "retry unavailable", + }, + }, + } satisfies Event, + }, + ]); + + const rotatedErrors = projection.lines.filter((line) => + line.kind === "error" + ); + assertEquals(rotatedErrors.length, 1); + assertEquals(rotatedErrors[0].body, "retry unavailable"); +}); + Deno.test("workerConsoleHref encodes runtime and worker target authority", () => { assert( workerConsoleHref({ diff --git a/web/workspace/src/lib/workspace/console/model.ts b/web/workspace/src/lib/workspace/console/model.ts index 289905fc..a9a90300 100644 --- a/web/workspace/src/lib/workspace/console/model.ts +++ b/web/workspace/src/lib/workspace/console/model.ts @@ -142,6 +142,10 @@ export type ConsoleEventInput = { observedAtMs?: number; }; +export function isConsoleProjectionEvent(event: ProtocolEvent): boolean { + return event.event !== "completions"; +} + export function emptyConsoleProjection(): ConsoleProjection { return { lines: [], @@ -1216,6 +1220,19 @@ function applyLogEntry( case "tool_result": applyLoggedItem(projection, eventId, entry["item"]); break; + case "run_errored": + projection.lines.push( + line( + eventId, + "error", + "Run error", + stringField(entry, "message") ?? "Worker run failed.", + undefined, + false, + true, + ), + ); + break; case "extension": applyExtensionEntry(projection, eventId, entry); break; diff --git a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte index ae4def3e..f1b4b28d 100644 --- a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte @@ -18,6 +18,7 @@ import { fitTextarea } from "$lib/workspace/console/textarea-fit"; import { createConsoleProjector, + isConsoleProjectionEvent, selectConsoleTimelineLines, type ConsoleEventInput, type ConsoleLine, @@ -266,14 +267,14 @@ function handleIncomingProtocolEvent(payload: ProtocolEvent) { handleProtocolCommandEvent(payload); - if (payload.event === "completions" || payload.event === "error") { - if (payload.event === "error") { - queueObservationDiagnostic({ - code: payload.data.code, - severity: "error", - message: payload.data.message, - }); - } + if (payload.event === "error") { + queueObservationDiagnostic({ + code: payload.data.code, + severity: "error", + message: payload.data.message, + }); + } + if (!isConsoleProjectionEvent(payload)) { return; } From 73902b03b6ce357857ad282087b3bfcb5a02572f Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 19 Aug 2026 08:29:36 +0900 Subject: [PATCH 2/2] fix: preserve live errors across segment rotation --- crates/tui/src/app.rs | 54 ++++++++++++++----- .../src/lib/workspace/console/model.test.ts | 34 ++++++++---- .../src/lib/workspace/console/model.ts | 3 +- 3 files changed, 65 insertions(+), 26 deletions(-) diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 615e29c4..f19d2f8e 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -271,6 +271,9 @@ pub struct App { pub quit_confirm: Option, /// Full display history in render order. pub blocks: Vec, + /// Turn/protocol errors retained when a real `SegmentStart` replaces the + /// replayable conversation rows during segment rotation. + run_error_messages: Vec, pub scroll: Scroll, pub mode: Mode, pub cache: FileCache, @@ -347,6 +350,7 @@ impl App { quit: false, quit_confirm: None, blocks: Vec::new(), + run_error_messages: Vec::new(), scroll: Scroll::default(), mode: Mode::Normal, cache: FileCache::new(), @@ -812,6 +816,15 @@ impl App { }); } + fn push_run_error(&mut self, message: String) { + self.run_error_messages.push(message.clone()); + self.blocks.push(Block::Alert { + level: AlertLevel::Error, + source: AlertSource::Worker, + message, + }); + } + fn handle_error(&mut self, code: ErrorCode, message: String) { let text = format!("[{code:?}] {message}"); let was_applying = if let Some(picker) = self.rewind_picker.as_mut() { @@ -829,7 +842,7 @@ impl App { Duration::from_secs(6), ); } - self.push_error(text); + self.push_run_error(text); } fn rewind_submit_pending(&self) -> bool { @@ -981,8 +994,16 @@ impl App { self.assistant_streaming = false; } Event::SegmentRotated { entry } => { + let retained_run_errors = self.run_error_messages.clone(); self.reset_for_rotation(); self.apply_log_entry_raw(&entry); + for message in retained_run_errors { + self.blocks.push(Block::Alert { + level: AlertLevel::Error, + source: AlertSource::Worker, + message, + }); + } self.assistant_streaming = false; } Event::SystemItem { item } => { @@ -2005,6 +2026,7 @@ impl App { entries: &[serde_json::Value], greeting: Option, ) { + self.run_error_messages.clear(); self.turn_index = 0; self.blocks.clear(); self.cache = FileCache::new(); @@ -2082,11 +2104,7 @@ impl App { self.apply_compaction_extension(&payload); } session_store::LogEntry::RunErrored { message, .. } => { - self.blocks.push(Block::Alert { - level: AlertLevel::Error, - source: AlertSource::Worker, - message, - }); + self.push_run_error(message); } // Non-history-bearing variants don't affect the block view. _ => {} @@ -3269,7 +3287,7 @@ mod completion_flow_tests { } #[test] - fn snapshot_and_segment_rotation_retain_one_durable_run_error_block() { + fn snapshot_replaces_live_error_with_one_durable_run_error_block() { let mut app = App::new("test".into()); app.handle_worker_event(Event::Error { code: ErrorCode::ProviderError, @@ -3318,21 +3336,29 @@ mod completion_flow_tests { }) .collect::>(); assert_eq!(errors, ["provider unavailable"]); + } + #[test] + fn segment_rotation_retains_live_error_across_real_segment_start() { + let mut app = App::new("test".into()); app.handle_worker_event(Event::Error { code: ErrorCode::ProviderError, - message: "retry unavailable".into(), + message: "provider unavailable".into(), }); - let rotated_run_error = session_store::LogEntry::RunErrored { + let segment_start = session_store::LogEntry::SegmentStart { ts: 5, - interrupted: false, - message: "retry unavailable".into(), + session_id: uuid::Uuid::nil(), + system_prompt: None, + config: Default::default(), + history: Vec::new(), + forked_from: None, + compacted_from: None, }; app.handle_worker_event(Event::SegmentRotated { - entry: serde_json::to_value(rotated_run_error).unwrap(), + entry: serde_json::to_value(segment_start).unwrap(), }); - let rotated_errors = app + let errors = app .blocks .iter() .filter_map(|block| match block { @@ -3344,7 +3370,7 @@ mod completion_flow_tests { _ => None, }) .collect::>(); - assert_eq!(rotated_errors, ["retry unavailable"]); + assert_eq!(errors, ["[ProviderError] provider unavailable"]); } #[test] diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index fd0d5547..bbe9fa1c 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -78,7 +78,7 @@ Deno.test("console routing projects live errors but not completion replies", () ); }); -Deno.test("snapshot and segment rotation retain one durable run_errored row", () => { +Deno.test("snapshot replaces a live error with one durable run_errored row", () => { const projector = createConsoleProjector(); let projection = projector.append([ { @@ -115,13 +115,16 @@ Deno.test("snapshot and segment rotation retain one durable run_errored row", () assertEquals(errors[0].title, "Run error"); assertEquals(errors[0].body, "provider unavailable"); assertEquals(errors[0].error, true); +}); - projection = projector.append([ +Deno.test("segment rotation retains a live error beside the real SegmentStart history", () => { + const projector = createConsoleProjector(); + const projection = projector.append([ { - eventId: "second-live-error", + eventId: "live-error", event: { event: "error", - data: { code: "provider_error", message: "retry unavailable" }, + data: { code: "provider_error", message: "provider unavailable" }, } satisfies Event, }, { @@ -130,21 +133,30 @@ Deno.test("snapshot and segment rotation retain one durable run_errored row", () event: "segment_rotated", data: { entry: { - kind: "run_errored", + kind: "segment_start", ts: 5, - interrupted: false, - message: "retry unavailable", + session_id: "session-1", + system_prompt: null, + config: {}, + history: [{ + kind: "message", + role: "user", + content: [{ kind: "text", text: "retained conversation" }], + }], }, }, } satisfies Event, }, ]); - const rotatedErrors = projection.lines.filter((line) => - line.kind === "error" + const errors = projection.lines.filter((line) => line.kind === "error"); + assertEquals(errors.length, 1); + assertEquals(errors[0].title, "error · provider_error"); + assertEquals(errors[0].body, "provider unavailable"); + assert( + projection.lines.some((line) => line.body === "retained conversation"), + "SegmentStart history should still seed the rotated projection", ); - assertEquals(rotatedErrors.length, 1); - assertEquals(rotatedErrors[0].body, "retry unavailable"); }); Deno.test("workerConsoleHref encodes runtime and worker target authority", () => { diff --git a/web/workspace/src/lib/workspace/console/model.ts b/web/workspace/src/lib/workspace/console/model.ts index a9a90300..654a545d 100644 --- a/web/workspace/src/lib/workspace/console/model.ts +++ b/web/workspace/src/lib/workspace/console/model.ts @@ -328,12 +328,13 @@ export function applyProtocolEvent( next.status = event.data.status; break; case "segment_rotated": { + const retainedErrors = next.lines.filter((line) => line.kind === "error"); const segment = snapshotProjectionFromEntries( envelope.eventId, [event.data.entry], next.cwd, ); - next.lines = segment.lines; + next.lines = [...segment.lines, ...retainedErrors]; next.tasks = segment.tasks; next.taskNextId = segment.taskNextId; break;