chore: merge current develop into ticket source

This commit is contained in:
2026-08-19 11:54:35 +09:00
4 changed files with 245 additions and 12 deletions
+113 -1
View File
@@ -271,6 +271,9 @@ pub struct App {
pub quit_confirm: Option<std::time::Instant>,
/// Full display history in render order.
pub blocks: Vec<Block>,
/// Turn/protocol errors retained when a real `SegmentStart` replaces the
/// replayable conversation rows during segment rotation.
run_error_messages: Vec<String>,
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<protocol::Greeting>,
) {
self.run_error_messages.clear();
self.turn_index = 0;
self.blocks.clear();
self.cache = FileCache::new();
@@ -2081,6 +2103,9 @@ impl App {
} if domain == "yoi.compaction" => {
self.apply_compaction_extension(&payload);
}
session_store::LogEntry::RunErrored { message, .. } => {
self.push_run_error(message);
}
// Non-history-bearing variants don't affect the block view.
_ => {}
}
@@ -3261,6 +3286,93 @@ mod completion_flow_tests {
));
}
#[test]
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,
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::<Vec<_>>();
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::<Vec<_>>();
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: "provider unavailable".into(),
});
let segment_start = session_store::LogEntry::SegmentStart {
ts: 5,
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(segment_start).unwrap(),
});
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::<Vec<_>>();
assert_eq!(errors, ["[ProviderError] provider unavailable"]);
}
#[test]
fn snapshot_in_flight_blocks_continue_with_live_deltas() {
let mut app = App::new("test".into());
@@ -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,107 @@ 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 replaces a live error with 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);
});
Deno.test("segment rotation retains a live error beside the real SegmentStart history", () => {
const projector = createConsoleProjector();
const projection = projector.append([
{
eventId: "live-error",
event: {
event: "error",
data: { code: "provider_error", message: "provider unavailable" },
} satisfies Event,
},
{
eventId: "segment-rotated",
event: {
event: "segment_rotated",
data: {
entry: {
kind: "segment_start",
ts: 5,
session_id: "session-1",
system_prompt: null,
config: {},
history: [{
kind: "message",
role: "user",
content: [{ kind: "text", text: "retained conversation" }],
}],
},
},
} satisfies Event,
},
]);
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",
);
});
Deno.test("workerConsoleHref encodes runtime and worker target authority", () => {
assert(
workerConsoleHref({
@@ -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: [],
@@ -324,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;
@@ -1216,6 +1221,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;
@@ -18,6 +18,7 @@
import { fitTextarea } from "$lib/workspace/console/textarea-fit";
import {
createConsoleProjector,
isConsoleProjectionEvent,
selectConsoleTimelineLines,
type ConsoleEventInput,
type ConsoleLine,
@@ -266,7 +267,6 @@
function handleIncomingProtocolEvent(payload: ProtocolEvent) {
handleProtocolCommandEvent(payload);
if (payload.event === "completions" || payload.event === "error") {
if (payload.event === "error") {
queueObservationDiagnostic({
code: payload.data.code,
@@ -274,6 +274,7 @@
message: payload.data.message,
});
}
if (!isConsoleProjectionEvent(payload)) {
return;
}