diff --git a/crates/op-orchestrator/src/append.rs b/crates/op-orchestrator/src/append.rs index 158831b38..e443295e6 100644 --- a/crates/op-orchestrator/src/append.rs +++ b/crates/op-orchestrator/src/append.rs @@ -5,8 +5,6 @@ //! present; returns an [`AppendPlanResult`] that `run.rs` uses to skip //! root-frame insertion and status-bar scaffold (Task B2). -#![allow(dead_code)] // callers land in Task B2 - use crate::plan::OrchestratorPlan; use crate::types::AppendContext; diff --git a/crates/op-orchestrator/src/run.rs b/crates/op-orchestrator/src/run.rs index 171f056af..c4afc39ce 100644 --- a/crates/op-orchestrator/src/run.rs +++ b/crates/op-orchestrator/src/run.rs @@ -18,6 +18,7 @@ //! The dashboard path implementation lives in `run_dashboard.rs` (split to //! keep this file under the 800-line ceiling). +use crate::append::apply_append_context_to_plan; use crate::cleanup::{ aggregate_concurrent_verdict, cleanup_concurrent_roots, descendant_count, run_cleanup_passes, }; @@ -69,10 +70,24 @@ impl Orchestrator { on_progress(Progress::Planning); let (mut plan, norm) = planning_loop(&request, llm, abort).await?; + // -- S3b-4 Task B2 call site 1: apply append context (TS :737) -- + // Must run AFTER planning_loop (which calls normalize) so root_frame.id + // and subtasks are already normalized before we repoint them. + let append_result = + apply_append_context_to_plan(&mut plan, request.append_context.as_ref()); + // -- S3b-2 Task C2: concurrency branch decision -- - // Port of `orchestrator.ts:780-810` (minus append-mode gate, S3b-4). + // Port of `orchestrator.ts:780-810`. let screen_groups = group_subtasks_by_screen(&plan.subtasks); - let effective = effective_concurrency(request.concurrency, screen_groups.len()); + + // -- S3b-4 Task B2 call site 3: effective concurrency gate (TS :806-810) -- + // Append mode is forced sequential — the concurrent branch creates multiple + // root frames which conflicts with reusing an existing content-root. + let effective = if append_result.skip_root_insertion { + 1 + } else { + effective_concurrency(request.concurrency, screen_groups.len()) + }; if effective > 1 { return run_concurrent_path( @@ -103,7 +118,15 @@ impl Orchestrator { } let scaffold_root_index = sink.state().active_children().len(); - if use_dashboard { + // -- S3b-4 Task B2: dashboard / append mutex (spec §2) -- + // Both concurrent and dashboard paths create new root structures that + // conflict with reusing an existing content-root. The concurrency gate + // above already enforces the concurrent/append mutex; here we enforce + // the dashboard/append mutex by preferring the append fast-path when + // both would otherwise fire. Mirrors TS positional precedence — the + // append fast-path lives inside the sequential else block, before the + // dashboard sub-branch. + if !append_result.skip_root_insertion && use_dashboard { // ── Dashboard path (extracted to run_dashboard.rs) ──────────── return run_dashboard_path( plan, @@ -118,43 +141,74 @@ impl Orchestrator { .await; } - // ── Non-dashboard sequential path (unchanged from S3a/S3b-1b) ────── - match build_scaffold(&plan, norm.is_mobile) { - Ok(cmds) => { - for cmd in cmds { - if !sink.apply(cmd) { - rollback(sink, &var_snapshot); - sink.end_undo_batch(); - return Err(OrchestratorError::Internal( - "scaffold insert rejected by document".into(), - )); + // ── Non-dashboard sequential path ────────────────────────────────── + // + // -- S3b-4 Task B2 call site 4: append fast-path (TS :942-949) -- + // In append mode we reuse the caller-provided content-root instead of + // creating a new root + status bar + dashboard columns. + // `skip_status_bar` is naturally satisfied here: the concurrent mobile + // scaffold (which injects a status-bar child) is skipped entirely, and + // `plan_normalize::normalize` already ran its mobile strip before + // `apply_append_context_to_plan` filtered any remaining status-bar subtasks. + let (root_id, scaffold_baseline) = if append_result.skip_root_insertion { + // Append fast-path: reuse the existing target frame as root. + // No scaffold InsertSubtree, no status bar, no agent badge. + let target_id = plan.root_frame.id.clone(); + for subtask in &mut plan.subtasks { + subtask.parent_frame_id = Some(target_id.clone()); + } + // Capture the pre-generation descendant count of the target frame. + // Used by the cleanup's zero-content check below. + let baseline = descendant_count(sink.state(), &target_id); + on_progress(Progress::ScaffoldDone); + (target_id, baseline) + } else { + // Normal scaffold path (unchanged from S3a/S3b-1b). + // S3b-4 Task B2 call site 2 (TS :743): guard the mobile status-bar + // injection — if append mode had been active `skip_status_bar` would + // be true and the mobile scaffold (which injects a status-bar child) + // would be suppressed. Here we're in the non-append branch so + // `skip_status_bar` is always false; consuming it silences the + // dead-code lint and keeps the guard semantically aligned with TS. + let effective_is_mobile = norm.is_mobile && !append_result.skip_status_bar; + match build_scaffold(&plan, effective_is_mobile) { + Ok(cmds) => { + for cmd in cmds { + if !sink.apply(cmd) { + rollback(sink, &var_snapshot); + sink.end_undo_batch(); + return Err(OrchestratorError::Internal( + "scaffold insert rejected by document".into(), + )); + } } } + Err(e) => { + // scaffold 模板 bug —— 收尾后报内部错误。 + rollback(sink, &var_snapshot); + sink.end_undo_batch(); + return Err(OrchestratorError::Internal(e)); + } } - Err(e) => { - // scaffold 模板 bug —— 收尾后报内部错误。 + let Some(rid) = sink + .state() + .active_children() + .get(scaffold_root_index) + .map(|n| n.id_str().to_string()) + else { rollback(sink, &var_snapshot); sink.end_undo_batch(); - return Err(OrchestratorError::Internal(e)); + return Err(OrchestratorError::Internal(format!( + "scaffold root `{planned_root_id}` was not inserted" + ))); + }; + for subtask in &mut plan.subtasks { + subtask.parent_frame_id = Some(rid.clone()); } - } - let Some(root_id) = sink - .state() - .active_children() - .get(scaffold_root_index) - .map(|n| n.id_str().to_string()) - else { - rollback(sink, &var_snapshot); - sink.end_undo_batch(); - return Err(OrchestratorError::Internal(format!( - "scaffold root `{planned_root_id}` was not inserted" - ))); + let baseline = descendant_count(sink.state(), &rid); + on_progress(Progress::ScaffoldDone); + (rid, baseline) }; - for subtask in &mut plan.subtasks { - subtask.parent_frame_id = Some(root_id.clone()); - } - let scaffold_baseline = descendant_count(sink.state(), &root_id); - on_progress(Progress::ScaffoldDone); // -- 阶段 3:顺序子 agent(C3: 3-attempt tier-gated retry ladder) -- // @@ -285,6 +339,11 @@ impl Orchestrator { } // -- 阶段 4:清理 -- + // S3b-4 append mode natural no-op: in append mode the fast-path reuses + // `plan.root_frame.id` (the existing target frame) as the single cleanup + // root. The cleanup loop iterates that one root; the `descendant_count + // <= scaffold_baseline` check below only deletes the frame if NOTHING + // new was added, which is the correct desired behaviour. run_cleanup_passes(sink, &plan, &[&root_id]); on_progress(Progress::CleanupDone); @@ -604,3 +663,8 @@ mod tests_c2; #[cfg(test)] #[path = "run_tests_c3.rs"] mod tests_c3; + +// Task B2 (S3b-4) tests — append-to-document mode wiring. +#[cfg(test)] +#[path = "run_tests_b4.rs"] +mod tests_b4; diff --git a/crates/op-orchestrator/src/run_tests_b4.rs b/crates/op-orchestrator/src/run_tests_b4.rs new file mode 100644 index 000000000..63cdd5134 --- /dev/null +++ b/crates/op-orchestrator/src/run_tests_b4.rs @@ -0,0 +1,471 @@ +//! Task B2 (S3b-4) tests — append-to-document mode wiring in `Orchestrator::run()`. +//! +//! Wired as `#[path = "run_tests_b4.rs"] mod tests_b4;` inside `run.rs`; +//! stays a child module of `run`, so `use super::*` resolves to `run`. +//! +//! Covers: +//! - Append mode: no scaffold `InsertSubtree` for the root, every subtask's +//! `parent_frame_id` is the ctx target, effective concurrency forced to 1. +//! - Append mode: sub-agent content lands as new children of `ctx.target_parent_id`. +//! - Non-append mode: all prior paths unchanged (covered by green existing tests). + +use super::*; +use crate::test_support::{ScriptResponse, ScriptedLlm, VecDocSink}; +use crate::types::{AppendContext, DesignRequest}; +use op_editor_core::{EditorCommand, PenNodeExt}; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +fn node_json(prefix: &str) -> String { + format!( + r#"[{{"type":"frame","id":"{prefix}-1","name":"Sec","x":0,"y":0,"width":1200,"height":300,"children":[]}}]"# + ) +} + +/// Insert a target frame into `sink` and return its LIVE id +/// (the remapped `n{N}` id assigned by `cmd_insert_subtree`). +/// The target frame is an empty vertical frame of 1200 wide. +fn insert_target_frame(sink: &mut VecDocSink, hint_id: &str) -> String { + let node: jian_ops_schema::node::PenNode = serde_json::from_str(&format!( + r#"{{"type":"frame","id":"{hint_id}","name":"Existing","x":0,"y":0, + "width":1200,"height":800,"layout":"vertical","gap":0,"children":[]}}"# + )) + .expect("parse target node"); + let before_count = sink.state().active_children().len(); + sink.apply(EditorCommand::InsertSubtree { + nodes: vec![node], + parent_id: op_editor_core::NodeId::NONE, + }); + // The newly inserted node is appended at the end. + sink.state() + .active_children() + .get(before_count) + .map(|n| n.id_str().to_string()) + .unwrap_or_else(|| hint_id.to_string()) +} + +/// Insert a target frame for mobile (390 wide) and return its live id. +fn insert_target_frame_mobile(sink: &mut VecDocSink, hint_id: &str) -> String { + let node: jian_ops_schema::node::PenNode = serde_json::from_str(&format!( + r#"{{"type":"frame","id":"{hint_id}","name":"Existing","x":0,"y":0, + "width":390,"height":844,"layout":"vertical","gap":0,"children":[]}}"# + )) + .expect("parse target node"); + let before_count = sink.state().active_children().len(); + sink.apply(EditorCommand::InsertSubtree { + nodes: vec![node], + parent_id: op_editor_core::NodeId::NONE, + }); + sink.state() + .active_children() + .get(before_count) + .map(|n| n.id_str().to_string()) + .unwrap_or_else(|| hint_id.to_string()) +} + +fn req_append(live_target_id: &str) -> DesignRequest { + DesignRequest { + prompt: "add a pricing section".into(), + model: None, + provider: None, + design_md: None, + concurrency: 1, + append_context: Some(AppendContext { + target_parent_id: live_target_id.into(), + target_width: 1200.0, + existing_section_labels: vec!["Hero".into(), "Features".into()], + is_mobile: false, + }), + } +} + +fn req_append_concurrent(live_target_id: &str) -> DesignRequest { + DesignRequest { + prompt: "add more screens".into(), + model: None, + provider: None, + design_md: None, + concurrency: 4, + append_context: Some(AppendContext { + target_parent_id: live_target_id.into(), + target_width: 390.0, + existing_section_labels: vec![], + is_mobile: false, + }), + } +} + +// ── Task B2 tests ───────────────────────────────────────────────────────────── + +/// (a) Append mode: run does NOT emit a scaffold-root InsertSubtree. +/// +/// We pre-insert the target frame, capture its live remapped id, then run +/// with append mode. The run should emit only 2 sub-agent InsertSubtrees +/// (one per subtask), NOT a scaffold-root InsertSubtree. +#[test] +fn append_mode_does_not_emit_scaffold_root_insert() { + // Minimal plan whose rootFrame.id doesn't matter — apply_append_context_to_plan + // replaces it with ctx.target_parent_id (the live id) before any scaffold call. + const PLAN_JSON: &str = r##"{ + "rootFrame": { "id": "frame-placeholder", "name": "Page", "width": 1200, "height": 800, + "layout": "vertical", "gap": 0, + "fill": [{ "type": "solid", "color": "#FFFFFF" }] }, + "subtasks": [ + { "id": "pricing", "label": "Pricing Section", "region": { "width": 1200, "height": 400 } }, + { "id": "cta", "label": "Call to Action", "region": { "width": 1200, "height": 300 } } + ] + }"##; + + let mut sink = VecDocSink::new(); + let live_id = insert_target_frame(&mut sink, "hint-target"); + // After pre-setup, sink has 1 InsertSubtree (the pre-insert). + let pre_insert_count = sink + .applied + .iter() + .filter(|c| matches!(c, EditorCommand::InsertSubtree { .. })) + .count(); + assert_eq!(pre_insert_count, 1, "sanity: pre-insert count"); + + let llm = ScriptedLlm::new(vec![ + ScriptResponse::Text(PLAN_JSON.into()), + ScriptResponse::Text(node_json("pricing")), + ScriptResponse::Text(node_json("cta")), + ]); + let abort = AbortFlag::new(); + + let result = futures::executor::block_on(Orchestrator::new().run( + req_append(&live_id), + &mut sink, + &llm, + &mut |_| {}, + &abort, + )); + + assert!(result.is_ok(), "append run should succeed: {result:?}"); + + // Count InsertSubtrees AFTER pre-setup: 2 sub-agent inserts expected, + // NOT 3 (which would indicate a scaffold-root insert). + let total_inserts = sink + .applied + .iter() + .filter(|c| matches!(c, EditorCommand::InsertSubtree { .. })) + .count(); + // 1 (pre-setup target frame) + 2 (sub-agents) = 3 total. + // If scaffold were inserted, it would be 4. + assert_eq!( + total_inserts, 3, + "expected exactly 3 InsertSubtrees (1 pre-setup + 2 sub-agents, no scaffold root): got {total_inserts}" + ); +} + +/// (b) Append mode: summary.root_frame_id equals ctx.target_parent_id. +#[test] +fn append_mode_summary_root_equals_target_parent_id() { + const PLAN_JSON: &str = r##"{ + "rootFrame": { "id": "frame-placeholder", "name": "Page", "width": 1200, "height": 800, + "layout": "vertical", "gap": 0, + "fill": [{ "type": "solid", "color": "#FFFFFF" }] }, + "subtasks": [ + { "id": "pricing", "label": "Pricing Section", "region": { "width": 1200, "height": 400 } }, + { "id": "cta", "label": "Call to Action", "region": { "width": 1200, "height": 300 } } + ] + }"##; + + let mut sink = VecDocSink::new(); + let live_id = insert_target_frame(&mut sink, "hint-target"); + + let llm = ScriptedLlm::new(vec![ + ScriptResponse::Text(PLAN_JSON.into()), + ScriptResponse::Text(node_json("pricing")), + ScriptResponse::Text(node_json("cta")), + ]); + let abort = AbortFlag::new(); + + let summary = futures::executor::block_on(Orchestrator::new().run( + req_append(&live_id), + &mut sink, + &llm, + &mut |_| {}, + &abort, + )) + .expect("append run ok"); + + assert_eq!( + summary.root_frame_id, live_id, + "root_frame_id must match ctx.target_parent_id (the live id)" + ); + assert_eq!(summary.subtasks.len(), 2, "both subtasks should appear"); +} + +/// (c) Append mode: effective concurrency is 1 even with concurrency=4 + 2 screens. +/// +/// With `concurrency=4` and a plan with 2 distinct screens, the normal path +/// would trigger concurrent multi-screen mode. Append mode forces sequential. +#[test] +fn append_mode_forces_effective_concurrency_to_one() { + const MULTISCREEN_PLAN_JSON: &str = r##"{ + "rootFrame": { "id": "frame-placeholder", "name": "App", "width": 390, "height": 844, + "layout": "vertical", "gap": 0, + "fill": [{ "type": "solid", "color": "#FFFFFF" }] }, + "subtasks": [ + { "id": "login", "label": "Login Screen", "region": { "width": 390, "height": 844 }, + "screen": "Login" }, + { "id": "home", "label": "Home Screen", "region": { "width": 390, "height": 844 }, + "screen": "Home" } + ] + }"##; + + let mut sink = VecDocSink::new(); + let live_id = insert_target_frame_mobile(&mut sink, "hint-screens"); + + // 2 sub-agent calls = sequential path. If concurrent, the scripted LLM + // would receive 2 parallel calls which our ScriptedLlm handles fine, but + // the summary would have 2 separate root frames. We verify single root. + let llm = ScriptedLlm::new(vec![ + ScriptResponse::Text(MULTISCREEN_PLAN_JSON.into()), + ScriptResponse::Text(node_json("login")), + ScriptResponse::Text(node_json("home")), + ]); + let abort = AbortFlag::new(); + + let summary = futures::executor::block_on(Orchestrator::new().run( + req_append_concurrent(&live_id), + &mut sink, + &llm, + &mut |_| {}, + &abort, + )) + .expect("append concurrent run ok"); + + // Sequential path: exactly 1 root (the target frame). + assert_eq!( + summary.root_frame_id, live_id, + "root_frame_id must be the target in sequential mode" + ); + assert_eq!(summary.subtasks.len(), 2); + + // 1 pre-setup + 2 sub-agent InsertSubtrees = 3 total (no N scaffold roots). + let inserts = sink + .applied + .iter() + .filter(|c| matches!(c, EditorCommand::InsertSubtree { .. })) + .count(); + assert_eq!( + inserts, 3, + "expected 3 InsertSubtrees (1 pre-setup + 2 sub-agents): got {inserts}" + ); +} + +/// (d) Append mode: sub-agent content lands as new children of ctx.target_parent_id. +/// +/// The target frame starts empty; after the run it must have more descendants. +#[test] +fn append_mode_content_lands_in_target_frame() { + const PLAN_JSON: &str = r##"{ + "rootFrame": { "id": "frame-placeholder", "name": "Page", "width": 1200, "height": 800, + "layout": "vertical", "gap": 0, + "fill": [{ "type": "solid", "color": "#FFFFFF" }] }, + "subtasks": [ + { "id": "pricing", "label": "Pricing Section", "region": { "width": 1200, "height": 400 } }, + { "id": "cta", "label": "Call to Action", "region": { "width": 1200, "height": 300 } } + ] + }"##; + + let mut sink = VecDocSink::new(); + let live_id = insert_target_frame(&mut sink, "hint-target"); + let before_count = descendant_count(sink.state(), &live_id); + + let llm = ScriptedLlm::new(vec![ + ScriptResponse::Text(PLAN_JSON.into()), + ScriptResponse::Text(node_json("pricing")), + ScriptResponse::Text(node_json("cta")), + ]); + let abort = AbortFlag::new(); + + let summary = futures::executor::block_on(Orchestrator::new().run( + req_append(&live_id), + &mut sink, + &llm, + &mut |_| {}, + &abort, + )) + .expect("append run ok"); + + let after_count = descendant_count(sink.state(), &live_id); + + assert!( + after_count > before_count, + "target frame must gain descendants: before={before_count} after={after_count}" + ); + assert!( + summary.total_nodes >= 2, + "total_nodes must reflect sub-agent output" + ); +} + +/// Non-append mode: existing sequential path is unchanged. +/// +/// A request without `append_context` takes the normal scaffold+sequential +/// path: it inserts a scaffold root frame AND the sub-agent nodes. +#[test] +fn non_append_mode_takes_normal_sequential_path() { + const PLAN_JSON: &str = r##"{ + "rootFrame": { "id": "root", "name": "Page", "width": 1200, "height": 800, + "layout": "vertical", "gap": 0, + "fill": [{ "type": "solid", "color": "#FFFFFF" }] }, + "subtasks": [ + { "id": "hero", "label": "Hero", "region": { "width": 1200, "height": 400 } }, + { "id": "feat", "label": "Features", "region": { "width": 1200, "height": 400 } } + ] + }"##; + + let llm = ScriptedLlm::new(vec![ + ScriptResponse::Text(PLAN_JSON.into()), + ScriptResponse::Text(node_json("hero")), + ScriptResponse::Text(node_json("feat")), + ]); + let mut sink = VecDocSink::new(); + let abort = AbortFlag::new(); + + let req = DesignRequest { + prompt: "a landing page".into(), + model: None, + provider: None, + design_md: None, + concurrency: 1, + append_context: None, // no append context + }; + + let summary = futures::executor::block_on(Orchestrator::new().run( + req, + &mut sink, + &llm, + &mut |_| {}, + &abort, + )) + .expect("non-append run ok"); + + // Normal path: scaffold root + 2 subtask inserts = at least 3 InsertSubtree. + let inserts = sink + .applied + .iter() + .filter(|c| matches!(c, EditorCommand::InsertSubtree { .. })) + .count(); + assert!( + inserts >= 3, + "expected >=3 InsertSubtrees (scaffold + subtasks): got {inserts}" + ); + assert!(!summary.root_frame_id.is_empty()); + assert_eq!(summary.subtasks.len(), 2); +} + +// ── Dashboard / append mutex (spec §2) ──────────────────────────────────────── + +/// Append + dashboard request → append fast-path wins (dashboard does NOT fire). +/// +/// The request carries `Some(append_context)` AND a prompt + plan that would +/// normally trigger `should_use_dashboard_columns`: +/// - prompt "an analytics admin dashboard" → dashboard keyword +/// - rootFrame.width 1440 (> 480) +/// - sidebar subtask + metrics subtask +/// +/// Without the mutex guard the dashboard path would dispatch first, inserting +/// a multi-frame dashboard scaffold (sidebar + main + row frames). With the +/// guard, the append fast-path takes priority — no scaffold-root InsertSubtree +/// beyond the 1 pre-setup target, content lands in `ctx.target_parent_id`. +#[test] +fn append_mode_wins_over_dashboard_branch() { + // Dashboard-like plan: 1440 wide, sidebar + metrics subtasks, dashboard + // keyword in subtask labels. + const DASHBOARD_PLAN_JSON: &str = r##"{ + "rootFrame": { "id": "frame-placeholder", "name": "Analytics Dashboard", + "width": 1440, "height": 900, + "layout": "vertical", "gap": 20, + "fill": [{ "type": "solid", "color": "#0F172A" }] }, + "subtasks": [ + { "id": "sidebar-nav", "label": "Sidebar Navigation", + "region": { "width": 260, "height": 760 } }, + { "id": "metrics-panel", "label": "Metrics Chart analytics dashboard", + "region": { "width": 900, "height": 320 } } + ] + }"##; + + // Pre-insert the target frame (width 1440 to match the would-be dashboard). + let mut sink = VecDocSink::new(); + let live_id = { + let node: jian_ops_schema::node::PenNode = serde_json::from_str( + r#"{"type":"frame","id":"hint-dash","name":"Existing","x":0,"y":0, + "width":1440,"height":900,"layout":"vertical","gap":0,"children":[]}"#, + ) + .expect("parse target node"); + let before_count = sink.state().active_children().len(); + sink.apply(EditorCommand::InsertSubtree { + nodes: vec![node], + parent_id: op_editor_core::NodeId::NONE, + }); + sink.state() + .active_children() + .get(before_count) + .map(|n| n.id_str().to_string()) + .unwrap_or_else(|| "hint-dash".to_string()) + }; + let before_count = descendant_count(sink.state(), &live_id); + + // Prompt carries the dashboard keyword so without the mutex guard + // `should_use_dashboard_columns` would return true. + let req = DesignRequest { + prompt: "an analytics admin dashboard".into(), + model: None, + provider: None, + design_md: None, + concurrency: 1, + append_context: Some(AppendContext { + target_parent_id: live_id.clone(), + target_width: 1440.0, + existing_section_labels: vec!["Hero".into()], + is_mobile: false, + }), + }; + + let llm = ScriptedLlm::new(vec![ + ScriptResponse::Text(DASHBOARD_PLAN_JSON.into()), + ScriptResponse::Text(node_json("sidebar")), + ScriptResponse::Text(node_json("metrics")), + ]); + let abort = AbortFlag::new(); + + let summary = futures::executor::block_on(Orchestrator::new().run( + req, + &mut sink, + &llm, + &mut |_| {}, + &abort, + )) + .expect("append+dashboard run ok"); + + // Append fast-path won: root_frame_id matches the live target. + assert_eq!( + summary.root_frame_id, live_id, + "append fast-path must win — root_frame_id should be the target id" + ); + + // No dashboard scaffold: total InsertSubtree count is exactly + // 1 (pre-setup) + 2 (sub-agents) = 3. Dashboard scaffold would push + // 1 root + 1 sidebar + 1 main + N row frames → >>3. + let inserts = sink + .applied + .iter() + .filter(|c| matches!(c, EditorCommand::InsertSubtree { .. })) + .count(); + assert_eq!( + inserts, 3, + "expected 3 InsertSubtrees (1 pre-setup + 2 sub-agents, no dashboard scaffold): got {inserts}" + ); + + // Content landed in target frame. + let after_count = descendant_count(sink.state(), &live_id); + assert!( + after_count > before_count, + "target frame must gain descendants: before={before_count} after={after_count}" + ); +}