test(host): end-to-end actor-channel round-trip for DesignSession
Task #28 prep. The orchestrator side has its own end-to-end tests under `crates/op-orchestrator/`, but the host's actor seam (worker → mpsc → UI thread → ack → mirror update + Progress → chat bubble) was only exercised piecewise by the three `RemoteDocSink` tests. This adds a single end-to-end smoke that drives both `pump_commands` + `pump_progress` from a fake worker thread against a real `WidgetHostNative`, then asserts: - Progress events rendered into the trailing assistant bubble (`• Planning…` line surfaces verbatim). - `RunSummary::Ok` rendered as the `Done — K subtask(s) succeeded` trailer with `streaming = false`. - `*current` cleared after `Done` so the event loop stops ticking at 33ms for this turn. Internal hook: `DesignSession::from_channels` (cfg(test)) wraps externally-supplied channels so a fake worker can drive the UI pumps without spinning up `Orchestrator::run` / an `agent::Provider` / a live API key. Production path still goes through `DesignSession::start`. The full LLM smoke is documented separately in openpencil-docs/superpowers/notes/2026-05-24-orchestrator-smoke-steps.md — it needs `OPENPENCIL_ANTHROPIC_API_KEY` and can't live in cargo test. cargo test -p op-host-desktop 106 passed (was 105). cargo fmt + clippy --workspace -D warnings clean.
This commit is contained in:
parent
37f1bb202c
commit
aef03e5541
|
|
@ -207,6 +207,22 @@ impl DesignSession {
|
|||
pub fn finished(&self) -> bool {
|
||||
self.finished
|
||||
}
|
||||
|
||||
/// Test-only ctor — wraps externally-supplied channels so a fake
|
||||
/// worker thread can drive the UI-side pumps end-to-end without
|
||||
/// spinning up a real LLM. Production code goes through
|
||||
/// [`DesignSession::start`].
|
||||
#[cfg(test)]
|
||||
pub fn from_channels(
|
||||
delta_rx: Receiver<DesignDelta>,
|
||||
cmd_rx: Receiver<DesignCmdReq>,
|
||||
) -> Self {
|
||||
Self {
|
||||
delta_rx,
|
||||
cmd_rx,
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Worker-side `DocSink` impl — forwards every mutation to the UI
|
||||
|
|
@ -407,6 +423,8 @@ fn progress_label(p: &Progress) -> String {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use op_editor_core::EditorCommand;
|
||||
use op_orchestrator::{RunSummary, SubtaskOutcome};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// `RemoteDocSink::apply` blocks until UI acks. When the UI side
|
||||
/// drops the receiver, `apply` returns false instead of hanging.
|
||||
|
|
@ -480,4 +498,94 @@ mod tests {
|
|||
let kinds = ui.join().expect("ui thread finishes");
|
||||
assert_eq!(kinds, vec!["begin", "apply", "end"]);
|
||||
}
|
||||
|
||||
/// End-to-end smoke through `pump_commands` + `pump_progress`:
|
||||
/// a fake worker thread drives a `RemoteDocSink` against
|
||||
/// real-looking channels, the UI loop drains both pumps, and we
|
||||
/// assert that the chat bubble carries the rendered progress +
|
||||
/// terminal summary line, and that the session clears itself
|
||||
/// after `Done`.
|
||||
///
|
||||
/// This is the host-side complement to the orchestrator's own
|
||||
/// end-to-end tests — it exercises the actor seam without
|
||||
/// requiring an `agent::Provider` / `ANTHROPIC_API_KEY`. Task #28
|
||||
/// covers the live LLM smoke separately.
|
||||
#[test]
|
||||
fn end_to_end_pump_round_trips_apply_and_progress_via_actor_channels() {
|
||||
let (delta_tx, delta_rx) = mpsc::channel::<DesignDelta>();
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel::<DesignCmdReq>();
|
||||
let mut current = Some(DesignSession::from_channels(delta_rx, cmd_rx));
|
||||
let mut host = WidgetHostNative::new();
|
||||
// Seed a streaming assistant bubble — `chat.begin_send`
|
||||
// creates one in production; the pumps fold the worker's
|
||||
// progress + summary into it.
|
||||
host.editor_state_mut()
|
||||
.chat
|
||||
.messages
|
||||
.push(op_editor_core::ChatMessage::assistant_streaming());
|
||||
|
||||
// Fake worker — emits one progress event, asks UI to apply
|
||||
// ClearSelection, then a successful `Done`.
|
||||
let fake_worker = thread::spawn(move || {
|
||||
// Progress first so the bubble starts streaming text
|
||||
// before the doc mutation.
|
||||
let _ = delta_tx.send(DesignDelta::Progress(Progress::Planning));
|
||||
let mut sink = RemoteDocSink::new(cmd_tx, EditorState::new());
|
||||
sink.apply(EditorCommand::ClearSelection);
|
||||
let _ = delta_tx.send(DesignDelta::Done(Ok(RunSummary {
|
||||
root_frame_id: "root".into(),
|
||||
subtasks: vec![SubtaskOutcome {
|
||||
id: "s1".into(),
|
||||
node_count: 3,
|
||||
error: None,
|
||||
}],
|
||||
total_nodes: 3,
|
||||
})));
|
||||
// Hold the sink so its channel survives until the UI has
|
||||
// had a chance to drain (the test polls until `Done`).
|
||||
sink
|
||||
});
|
||||
|
||||
// UI drives the pumps until the session clears (mirrors the
|
||||
// event-loop `RedrawRequested` block). Bound the loop with a
|
||||
// timeout so a hung worker fails the test instead of hanging.
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while current.is_some() && Instant::now() < deadline {
|
||||
let _ = pump_commands(&mut host, &mut current);
|
||||
let _ = pump_progress(&mut host, &mut current);
|
||||
if current.is_none() {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
// Worker can join now — the sink it returned (and thus its
|
||||
// cmd_tx) drops at this scope end.
|
||||
let _ = fake_worker.join().expect("fake worker exits cleanly");
|
||||
|
||||
assert!(
|
||||
current.is_none(),
|
||||
"session must clear after Done — leaving it set would keep the\
|
||||
event loop ticking and pump_progress retrying"
|
||||
);
|
||||
let bubble = host
|
||||
.editor_state()
|
||||
.chat
|
||||
.messages
|
||||
.last()
|
||||
.expect("seeded bubble survives");
|
||||
assert!(
|
||||
bubble.content.contains("Planning"),
|
||||
"progress line should render Planning, got: {:?}",
|
||||
bubble.content
|
||||
);
|
||||
assert!(
|
||||
bubble.content.contains("1 subtask"),
|
||||
"summary should report 1 subtask succeeded, got: {:?}",
|
||||
bubble.content
|
||||
);
|
||||
assert!(
|
||||
!bubble.streaming,
|
||||
"summary path must clear streaming so the chat panel stops the animation"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue