chore: merge current develop into ticket source
This commit is contained in:
+113
-1
@@ -271,6 +271,9 @@ pub struct App {
|
|||||||
pub quit_confirm: Option<std::time::Instant>,
|
pub quit_confirm: Option<std::time::Instant>,
|
||||||
/// Full display history in render order.
|
/// Full display history in render order.
|
||||||
pub blocks: Vec<Block>,
|
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 scroll: Scroll,
|
||||||
pub mode: Mode,
|
pub mode: Mode,
|
||||||
pub cache: FileCache,
|
pub cache: FileCache,
|
||||||
@@ -347,6 +350,7 @@ impl App {
|
|||||||
quit: false,
|
quit: false,
|
||||||
quit_confirm: None,
|
quit_confirm: None,
|
||||||
blocks: Vec::new(),
|
blocks: Vec::new(),
|
||||||
|
run_error_messages: Vec::new(),
|
||||||
scroll: Scroll::default(),
|
scroll: Scroll::default(),
|
||||||
mode: Mode::Normal,
|
mode: Mode::Normal,
|
||||||
cache: FileCache::new(),
|
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) {
|
fn handle_error(&mut self, code: ErrorCode, message: String) {
|
||||||
let text = format!("[{code:?}] {message}");
|
let text = format!("[{code:?}] {message}");
|
||||||
let was_applying = if let Some(picker) = self.rewind_picker.as_mut() {
|
let was_applying = if let Some(picker) = self.rewind_picker.as_mut() {
|
||||||
@@ -829,7 +842,7 @@ impl App {
|
|||||||
Duration::from_secs(6),
|
Duration::from_secs(6),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
self.push_error(text);
|
self.push_run_error(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rewind_submit_pending(&self) -> bool {
|
fn rewind_submit_pending(&self) -> bool {
|
||||||
@@ -981,8 +994,16 @@ impl App {
|
|||||||
self.assistant_streaming = false;
|
self.assistant_streaming = false;
|
||||||
}
|
}
|
||||||
Event::SegmentRotated { entry } => {
|
Event::SegmentRotated { entry } => {
|
||||||
|
let retained_run_errors = self.run_error_messages.clone();
|
||||||
self.reset_for_rotation();
|
self.reset_for_rotation();
|
||||||
self.apply_log_entry_raw(&entry);
|
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;
|
self.assistant_streaming = false;
|
||||||
}
|
}
|
||||||
Event::SystemItem { item } => {
|
Event::SystemItem { item } => {
|
||||||
@@ -2005,6 +2026,7 @@ impl App {
|
|||||||
entries: &[serde_json::Value],
|
entries: &[serde_json::Value],
|
||||||
greeting: Option<protocol::Greeting>,
|
greeting: Option<protocol::Greeting>,
|
||||||
) {
|
) {
|
||||||
|
self.run_error_messages.clear();
|
||||||
self.turn_index = 0;
|
self.turn_index = 0;
|
||||||
self.blocks.clear();
|
self.blocks.clear();
|
||||||
self.cache = FileCache::new();
|
self.cache = FileCache::new();
|
||||||
@@ -2081,6 +2103,9 @@ impl App {
|
|||||||
} if domain == "yoi.compaction" => {
|
} if domain == "yoi.compaction" => {
|
||||||
self.apply_compaction_extension(&payload);
|
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.
|
// 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]
|
#[test]
|
||||||
fn snapshot_in_flight_blocks_continue_with_live_deltas() {
|
fn snapshot_in_flight_blocks_continue_with_live_deltas() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Event } from "$lib/generated/protocol";
|
|||||||
import {
|
import {
|
||||||
type ConsoleLine,
|
type ConsoleLine,
|
||||||
createConsoleProjector,
|
createConsoleProjector,
|
||||||
|
isConsoleProjectionEvent,
|
||||||
projectConsole,
|
projectConsole,
|
||||||
segmentsToText,
|
segmentsToText,
|
||||||
selectConsoleTimelineLines,
|
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 {
|
return {
|
||||||
event: "snapshot",
|
event: "snapshot",
|
||||||
data: {
|
data: {
|
||||||
entries: [],
|
entries,
|
||||||
greeting: {
|
greeting: {
|
||||||
worker_name: "Worker",
|
worker_name: "Worker",
|
||||||
cwd,
|
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", () => {
|
Deno.test("workerConsoleHref encodes runtime and worker target authority", () => {
|
||||||
assert(
|
assert(
|
||||||
workerConsoleHref({
|
workerConsoleHref({
|
||||||
|
|||||||
@@ -142,6 +142,10 @@ export type ConsoleEventInput = {
|
|||||||
observedAtMs?: number;
|
observedAtMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function isConsoleProjectionEvent(event: ProtocolEvent): boolean {
|
||||||
|
return event.event !== "completions";
|
||||||
|
}
|
||||||
|
|
||||||
export function emptyConsoleProjection(): ConsoleProjection {
|
export function emptyConsoleProjection(): ConsoleProjection {
|
||||||
return {
|
return {
|
||||||
lines: [],
|
lines: [],
|
||||||
@@ -324,12 +328,13 @@ export function applyProtocolEvent(
|
|||||||
next.status = event.data.status;
|
next.status = event.data.status;
|
||||||
break;
|
break;
|
||||||
case "segment_rotated": {
|
case "segment_rotated": {
|
||||||
|
const retainedErrors = next.lines.filter((line) => line.kind === "error");
|
||||||
const segment = snapshotProjectionFromEntries(
|
const segment = snapshotProjectionFromEntries(
|
||||||
envelope.eventId,
|
envelope.eventId,
|
||||||
[event.data.entry],
|
[event.data.entry],
|
||||||
next.cwd,
|
next.cwd,
|
||||||
);
|
);
|
||||||
next.lines = segment.lines;
|
next.lines = [...segment.lines, ...retainedErrors];
|
||||||
next.tasks = segment.tasks;
|
next.tasks = segment.tasks;
|
||||||
next.taskNextId = segment.taskNextId;
|
next.taskNextId = segment.taskNextId;
|
||||||
break;
|
break;
|
||||||
@@ -1216,6 +1221,19 @@ function applyLogEntry(
|
|||||||
case "tool_result":
|
case "tool_result":
|
||||||
applyLoggedItem(projection, eventId, entry["item"]);
|
applyLoggedItem(projection, eventId, entry["item"]);
|
||||||
break;
|
break;
|
||||||
|
case "run_errored":
|
||||||
|
projection.lines.push(
|
||||||
|
line(
|
||||||
|
eventId,
|
||||||
|
"error",
|
||||||
|
"Run error",
|
||||||
|
stringField(entry, "message") ?? "Worker run failed.",
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
case "extension":
|
case "extension":
|
||||||
applyExtensionEntry(projection, eventId, entry);
|
applyExtensionEntry(projection, eventId, entry);
|
||||||
break;
|
break;
|
||||||
|
|||||||
+9
-8
@@ -18,6 +18,7 @@
|
|||||||
import { fitTextarea } from "$lib/workspace/console/textarea-fit";
|
import { fitTextarea } from "$lib/workspace/console/textarea-fit";
|
||||||
import {
|
import {
|
||||||
createConsoleProjector,
|
createConsoleProjector,
|
||||||
|
isConsoleProjectionEvent,
|
||||||
selectConsoleTimelineLines,
|
selectConsoleTimelineLines,
|
||||||
type ConsoleEventInput,
|
type ConsoleEventInput,
|
||||||
type ConsoleLine,
|
type ConsoleLine,
|
||||||
@@ -266,14 +267,14 @@
|
|||||||
|
|
||||||
function handleIncomingProtocolEvent(payload: ProtocolEvent) {
|
function handleIncomingProtocolEvent(payload: ProtocolEvent) {
|
||||||
handleProtocolCommandEvent(payload);
|
handleProtocolCommandEvent(payload);
|
||||||
if (payload.event === "completions" || payload.event === "error") {
|
if (payload.event === "error") {
|
||||||
if (payload.event === "error") {
|
queueObservationDiagnostic({
|
||||||
queueObservationDiagnostic({
|
code: payload.data.code,
|
||||||
code: payload.data.code,
|
severity: "error",
|
||||||
severity: "error",
|
message: payload.data.message,
|
||||||
message: payload.data.message,
|
});
|
||||||
});
|
}
|
||||||
}
|
if (!isConsoleProjectionEvent(payload)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user