feat: stream internal subworker output through parent

This commit is contained in:
2026-08-19 09:24:14 +09:00
parent fe74d7c4b8
commit ce62e09919
18 changed files with 990 additions and 19 deletions
+155 -2
View File
@@ -4,8 +4,8 @@ use std::time::{Duration, Instant};
use protocol::{
AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, InFlightBlock,
InFlightSnapshot, InFlightToolCallState, Method, RewindTarget, RunResult, Segment,
WorkerStatus,
InFlightSnapshot, InFlightToolCallState, InternalWorkerRef, InternalWorkerSnapshot, Method,
RewindTarget, RunResult, Segment, WorkerStatus,
};
use crate::block::{
@@ -227,6 +227,12 @@ impl ActionbarNotice {
}
}
pub struct InternalWorkerView {
pub worker: InternalWorkerRef,
pub revision: u64,
pub app: Box<App>,
}
pub struct App {
pub worker_name: String,
pub connected: bool,
@@ -274,6 +280,9 @@ pub struct App {
/// Turn/protocol errors retained when a real `SegmentStart` replaces the
/// replayable conversation rows during segment rotation.
run_error_messages: Vec<String>,
/// Presentation-only Internal Worker projections keyed by session identity.
/// They are rendered in separate sub-panes and never mixed into `blocks`.
pub internal_workers: Vec<InternalWorkerView>,
pub scroll: Scroll,
pub mode: Mode,
pub cache: FileCache,
@@ -351,6 +360,7 @@ impl App {
quit_confirm: None,
blocks: Vec::new(),
run_error_messages: Vec::new(),
internal_workers: Vec::new(),
scroll: Scroll::default(),
mode: Mode::Normal,
cache: FileCache::new(),
@@ -1296,11 +1306,18 @@ impl App {
greeting,
status,
in_flight,
internal_workers,
} => {
self.rewind_refresh_fence = false;
self.restore_snapshot(&entries, greeting, in_flight);
self.replace_internal_worker_snapshots(internal_workers);
self.set_worker_status(status);
}
Event::InternalWorker {
worker,
revision,
event,
} => self.apply_internal_worker_event(worker, revision, *event),
Event::Status { status } => {
self.rewind_refresh_fence = false;
self.set_worker_status(status);
@@ -1980,6 +1997,60 @@ impl App {
/// LogEntry variant into the same blocks live events would have
/// produced. Followed by `Event::Entry` updates for anything
/// committed after the snapshot.
fn replace_internal_worker_snapshots(&mut self, snapshots: Vec<InternalWorkerSnapshot>) {
self.internal_workers = snapshots
.into_iter()
.map(Self::internal_worker_view_from_snapshot)
.collect();
}
fn internal_worker_view_from_snapshot(snapshot: InternalWorkerSnapshot) -> InternalWorkerView {
let mut app = App::new(snapshot.worker.name.clone());
app.restore_entries(&snapshot.entries, None);
app.apply_in_flight_snapshot(snapshot.in_flight);
app.set_worker_status(snapshot.status);
if let Some(error) = snapshot.error {
let _ = app.handle_worker_event(Event::Error {
code: protocol::ErrorCode::Internal,
message: error,
});
}
app.replace_internal_worker_snapshots(snapshot.internal_workers);
InternalWorkerView {
worker: snapshot.worker,
revision: snapshot.revision,
app: Box::new(app),
}
}
fn apply_internal_worker_event(
&mut self,
worker: InternalWorkerRef,
revision: u64,
event: Event,
) {
let index = self
.internal_workers
.iter()
.position(|candidate| candidate.worker.session_id == worker.session_id);
let target = if let Some(index) = index {
&mut self.internal_workers[index]
} else {
self.internal_workers.push(InternalWorkerView {
worker: worker.clone(),
revision: 0,
app: Box::new(App::new(worker.name.clone())),
});
self.internal_workers.last_mut().unwrap()
};
if revision <= target.revision {
return;
}
target.worker = worker;
target.revision = revision;
let _ = target.app.handle_worker_event(event);
}
fn restore_snapshot(
&mut self,
entries: &[serde_json::Value],
@@ -3276,6 +3347,7 @@ mod completion_flow_tests {
entries: vec![session_start_value],
status: WorkerStatus::Running,
in_flight: Default::default(),
internal_workers: Vec::new(),
});
assert!(matches!(app.worker_status, WorkerStatus::Running));
@@ -3321,6 +3393,7 @@ mod completion_flow_tests {
entries: vec![serde_json::to_value(run_errored).unwrap()],
status: WorkerStatus::Idle,
in_flight: Default::default(),
internal_workers: Vec::new(),
});
let errors = app
@@ -3398,6 +3471,7 @@ mod completion_flow_tests {
},
],
},
internal_workers: Vec::new(),
});
app.handle_worker_event(Event::TextDelta { text: "lo".into() });
@@ -3434,6 +3508,83 @@ mod completion_flow_tests {
assert!(app.blocks.is_empty());
}
#[test]
fn internal_worker_events_project_by_session_without_mixing_parent_blocks() {
let mut app = App::new("parent".into());
let worker = InternalWorkerRef {
session_id: "child-session".into(),
name: "research".into(),
parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker,
};
app.handle_worker_event(Event::InternalWorker {
worker: worker.clone(),
revision: 2,
event: Box::new(Event::TextDelta {
text: "child output".into(),
}),
});
app.handle_worker_event(Event::InternalWorker {
worker,
revision: 1,
event: Box::new(Event::TextDelta {
text: "stale".into(),
}),
});
assert!(app.blocks.is_empty());
assert_eq!(app.internal_workers.len(), 1);
assert_eq!(app.internal_workers[0].revision, 2);
assert!(
app.internal_workers[0].app.blocks.iter().any(
|block| matches!(block, Block::AssistantText { text } if text == "child output")
)
);
}
#[test]
fn snapshot_authoritatively_replaces_internal_worker_views() {
let mut app = App::new("parent".into());
app.internal_workers.push(InternalWorkerView {
worker: InternalWorkerRef {
session_id: "old".into(),
name: "old".into(),
parent_session_id: None,
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
app: Box::new(App::new("old".into())),
});
app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(),
entries: Vec::new(),
status: WorkerStatus::Idle,
in_flight: Default::default(),
internal_workers: vec![InternalWorkerSnapshot {
worker: InternalWorkerRef {
session_id: "replacement".into(),
name: "replacement".into(),
parent_session_id: Some("parent-session".into()),
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 4,
entries: Vec::new(),
status: WorkerStatus::Running,
error: None,
in_flight: Default::default(),
internal_workers: Vec::new(),
}],
});
assert_eq!(app.internal_workers.len(), 1);
assert_eq!(app.internal_workers[0].worker.session_id, "replacement");
assert_eq!(app.internal_workers[0].revision, 4);
assert_eq!(
app.internal_workers[0].app.worker_status,
WorkerStatus::Running
);
}
#[test]
fn live_system_item_notification_appends_notify_block() {
let mut app = App::new("test".into());
@@ -3552,6 +3703,7 @@ mod completion_flow_tests {
greeting,
status: WorkerStatus::Idle,
in_flight: Default::default(),
internal_workers: Vec::new(),
});
assert_eq!(app.context_window, 123_000);
@@ -3749,6 +3901,7 @@ mod completion_flow_tests {
entries: assistant_item_entries,
status: WorkerStatus::Running,
in_flight: Default::default(),
internal_workers: Vec::new(),
});
let tasks = app.task_store.tasks();
+2
View File
@@ -2019,6 +2019,7 @@ mod tests {
entries: vec![],
status: WorkerStatus::Idle,
in_flight: Default::default(),
internal_workers: Vec::new(),
});
app.handle_worker_event(Event::RewindApplied {
entries: vec![],
@@ -2045,6 +2046,7 @@ mod tests {
entries: vec![],
status: WorkerStatus::Idle,
in_flight: Default::default(),
internal_workers: Vec::new(),
});
type_keys(&mut app, "draft");
+2
View File
@@ -859,6 +859,7 @@ async fn ticket_queue_notification_sends_notify_when_socket_available() {
},
status: WorkerStatus::Idle,
in_flight: Default::default(),
internal_workers: Vec::new(),
})
.await
.unwrap();
@@ -900,6 +901,7 @@ async fn send_notify_only_can_deliver_weak_notification_without_auto_run() {
},
status: WorkerStatus::Idle,
in_flight: Default::default(),
internal_workers: Vec::new(),
})
.await
.unwrap();
+22
View File
@@ -381,6 +381,28 @@ pub fn compute_history(app: &App, width: u16) -> HistoryLayout {
i += 1;
}
for internal in &app.internal_workers {
logical.push((Line::from(""), false));
logical.push((
Line::from(vec![
Span::styled("SubWorker ", Style::default().bold()),
Span::raw(internal.worker.name.clone()),
Span::styled(
format!(" {:?}", internal.app.worker_status),
Style::default().fg(Color::DarkGray),
),
]),
false,
));
let child_width = width.saturating_sub(2).max(1);
let child_history = compute_history(&internal.app, child_width);
logical.extend(child_history.rows.into_iter().map(|row| {
let mut spans = vec![Span::raw(" ")];
spans.extend(row.line.spans);
(Line::from(spans), row.selectable)
}));
}
// Step 2: pre-wrap every logical line to char-based terminal rows so
// scroll math is exact. Track the logical → wrapped mapping so
// turn-start indices get translated into wrapped-row coordinates.
+1
View File
@@ -914,6 +914,7 @@ mod tests {
greeting: test_greeting(),
status: WorkerStatus::Idle,
in_flight: Default::default(),
internal_workers: Vec::new(),
},
];