refactor(host): move chat_intent to op-web-daemon; extract host-coupled test (Phase 5, Task 5.3a)

The CLI standard-mode intent router moves to op_web_daemon::chat_intent; its 23
headless tests (which reach chat_intent's #[cfg(test)] internals) move with it as the
#[path] sibling. The 1 host-coupled test (cli_new_design_clears_agent_frame_indicators_after_done
— drives the GUI design-session pumps via WidgetHostNative) is extracted to
op-host-desktop/src/chat_intent_host_tests.rs against the pub run_cli_turn/CliTurnPlan API +
crate::design_session pumps. chat_session/chat_session_launch/web_chat_standard refs repointed.
op-web-daemon 276 + host test 1 green; no dep/lock change.
This commit is contained in:
Kayshen-X 2026-06-19 21:55:33 +08:00
parent d5727d322d
commit a172b74023
11 changed files with 239 additions and 149 deletions

View file

@ -499,7 +499,6 @@ bun run cargo:deny # cargo-deny (native + wasm32 bans; CI uses cargo-deny
| op-host-native | Native host lib — WidgetHostNative + skia-safe GL backend (desktop + mobile) | ❌ (native only) |
| op-host-desktop | Desktop binary `openpencil-desktop` (winit + skia-safe GL) — also the `--serve-web` daemon that hosts the web bundle | ❌ (native only) |
| op-cli | `op` command-line tool | ❌ (native only) |
| op-app | Thin composition root — re-exports the per-platform host (target-gated) | ✅ / ❌ per target |
**Shared library crates:** `op-editor-core` (canonical `.op` state), `op-editor-ui` (platform-free widgets + `RenderBackend`), `op-editor-host-core` (transport-free host state machines), `op-mcp`, `op-ai`, `op-ai-skills`, `op-codegen`, `op-orchestrator`, `op-figma`, `op-git`, `op-opmerge`, `op-pen-loader`, `op-design-lint`, `op-config-store`, `op-process-io`, `op-acp`, `op-i18n`, `op-rpc-transport` — plus `op-smoke` (headless design-turn test runner). The browser bundle renders through the official CanvasKit skia WASM (loaded separately), so the retired `skia-safe-op` wasm fork + `wasm-libc-shim` no longer exist.

View file

@ -24,7 +24,6 @@ crates/
├── op-host-native/ Native host lib: WidgetHostNative + skia-safe GL backend (desktop + mobile)
├── op-host-web/ Browser bundle entry: wasm32-unknown-unknown cdylib, CanvasKit renderer
├── op-host-desktop/ Desktop binary `openpencil-desktop` (winit + skia-safe GL); also the `--serve-web` daemon
├── op-app/ Thin composition root re-exporting the per-platform host (target-gated)
├── op-cli/ `op` command-line tool
└── … op-mcp / op-ai / op-ai-skills / op-codegen / op-orchestrator / op-figma /
op-git / op-opmerge / op-pen-loader / op-design-lint / op-config-store /

View file

@ -0,0 +1,188 @@
//! Host-coupled test for the CLI standard-mode design route (GAP #33).
//!
//! The bulk of `chat_intent`'s tests are headless and live beside the
//! module in `op_web_daemon::chat_intent` (accessing its `#[cfg(test)]`
//! internals via `super`). This one test is the exception: it drives the
//! desktop GUI design-session pumps (`design_session::{pump_commands,
//! pump_progress}`, which take `&mut WidgetHostNative` — orphan rule) to
//! prove the new-design route clears the agent frame indicators after the
//! turn finishes. `op-web-daemon` links `op-host-native` with
//! default-features off, so `WidgetHostNative` (gl-host gated) is absent
//! there; the test therefore lives host-side.
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
use op_ai::chat_provider::{ChatDelta, ChatProvider, ChatRequest, StopReason};
use op_editor_core::EditorState;
use op_orchestrator::DesignRequest;
use op_editor_host_core::design::DesignSession;
use op_host_native::WidgetHostNative;
use op_web_daemon::chat_canvas_tools::chat_tool_channel;
use op_web_daemon::chat_intent::{run_cli_turn, CliTurnPlan};
use crate::design_session::{pump_commands, pump_progress};
// ---------------------------------------------------------------------------
// Scripted providers (copied from the headless chat_intent test sibling —
// `op-web-daemon` keeps its own copies for the cross-crate tests there).
// ---------------------------------------------------------------------------
struct Scripted {
deltas: Vec<ChatDelta>,
delay: Duration,
}
impl Scripted {
fn text(s: &str) -> Self {
Self {
deltas: vec![
ChatDelta::TextDelta(s.to_string()),
ChatDelta::Done {
stop_reason: StopReason::EndTurn,
},
],
delay: Duration::ZERO,
}
}
}
impl ChatProvider for Scripted {
fn provider_label(&self) -> &str {
"scripted"
}
fn send(&self, _request: ChatRequest) -> Box<dyn Iterator<Item = ChatDelta> + Send> {
let deltas = self.deltas.clone();
let delay = self.delay;
Box::new(deltas.into_iter().inspect(move |_| {
std::thread::sleep(delay);
}))
}
}
struct ScriptedCalls {
calls: std::sync::Mutex<std::collections::VecDeque<String>>,
}
impl ScriptedCalls {
fn new(calls: Vec<String>) -> Self {
Self {
calls: std::sync::Mutex::new(calls.into()),
}
}
}
impl ChatProvider for ScriptedCalls {
fn provider_label(&self) -> &str {
"scripted-calls"
}
fn send(&self, _request: ChatRequest) -> Box<dyn Iterator<Item = ChatDelta> + Send> {
let text = self
.calls
.lock()
.unwrap()
.pop_front()
.expect("scripted call exhausted");
Box::new(
vec![
ChatDelta::TextDelta(text),
ChatDelta::Done {
stop_reason: StopReason::EndTurn,
},
]
.into_iter(),
)
}
}
// ---------------------------------------------------------------------------
// Fixtures (copied verbatim from the headless chat_intent test sibling).
// ---------------------------------------------------------------------------
fn test_design_request() -> DesignRequest {
DesignRequest {
prompt: "p".into(),
model: None,
provider: None,
design_md: None,
concurrency: 1,
append_context: None,
validation_enabled: false,
visual_ref_enabled: false,
}
}
const ONE_SUBTASK_PLAN_JSON: &str = r##"{
"rootFrame": { "id": "root", "name": "Login", "width": 390, "height": 844,
"layout": "vertical", "gap": 0,
"fill": [{ "type": "solid", "color": "#FFFFFF" }] },
"subtasks": [
{ "id": "form", "label": "Form", "region": { "width": 390, "height": 300 } }
]
}"##;
fn one_node_json() -> String {
r#"[{"type":"frame","id":"form-1","name":"Form","width":390,"height":120,"children":[{"type":"text","id":"form-title","content":"Welcome","fontSize":24}]}]"#.into()
}
// ---------------------------------------------------------------------------
// The host-coupled new-design route test.
// ---------------------------------------------------------------------------
#[test]
fn cli_new_design_clears_agent_frame_indicators_after_done() {
op_editor_core::agent_indicators::clear();
let indicator_epoch = op_editor_core::agent_indicators::begin();
let plan = CliTurnPlan {
user_text: "design a login page".into(),
page_children_empty: true,
classify_provider: Box::new(Scripted::text("DESIGN_NEW")),
chat_provider: Box::new(Scripted::text("unused")),
design_provider: Box::new(ScriptedCalls::new(vec![
ONE_SUBTASK_PLAN_JSON.into(),
one_node_json(),
])),
chat_request: ChatRequest::default(),
modify_request: None,
design_request: test_design_request(),
initial_state: EditorState::new(),
indicator_epoch,
model: None,
};
let (chat_tx, _chat_rx) = mpsc::channel();
let (executor, _tool_rx) = chat_tool_channel();
let (delta_tx, delta_rx) = mpsc::channel();
let (cmd_tx, cmd_rx) = mpsc::channel();
let mut current = Some(DesignSession::from_channels_with_epoch(
delta_rx,
cmd_rx,
indicator_epoch,
));
let mut host = WidgetHostNative::new();
host.editor_state_mut()
.chat
.messages
.push(op_editor_core::ChatMessage::assistant_streaming());
let worker = thread::spawn(move || run_cli_turn(plan, chat_tx, executor, delta_tx, cmd_tx));
let deadline = Instant::now() + Duration::from_secs(10);
while current.is_some() && Instant::now() < deadline {
let _ = pump_commands(&mut host, &mut current, 1440.0, 900.0);
let _ = pump_progress(&mut host, &mut current);
std::thread::sleep(Duration::from_millis(2));
}
worker.join().expect("worker exits");
assert!(current.is_none(), "design session should finish");
let snapshot = op_editor_core::agent_indicators::snapshot();
assert!(
snapshot.frames.is_empty(),
"agent frame badges/borders must clear after Done, got {:?}",
snapshot.frames
);
op_editor_core::agent_indicators::clear();
}

View file

@ -58,7 +58,7 @@ fn drain_tool_requests(state: &mut EditorState, session: &mut ChatSession) -> bo
}
let mut changed = false;
for req in requests {
if req.name == crate::chat_intent::APPLY_MODIFICATION_OP {
if req.name == op_web_daemon::chat_intent::APPLY_MODIFICATION_OP {
let nodes = serde_json::from_str::<serde_json::Value>(&req.args_json)
.ok()
.and_then(|v| v.get("nodes").and_then(|n| n.as_array().cloned()))

View file

@ -91,7 +91,7 @@ pub fn launch_if_pending(
host.mark_editor_state_dirty();
}
let append_context =
crate::chat_intent::detect_append_intent(host.editor_state(), &user_text);
op_web_daemon::chat_intent::detect_append_intent(host.editor_state(), &user_text);
let initial_state = host.editor_state().clone();
let request = build_design_request(user_text, &initial_state, append_context);
*current_design = Some(op_web_daemon::design_session::start(llm, request, initial_state));
@ -248,8 +248,8 @@ fn launch_cli_standard_turn(
DEFAULT_MAX_CHARS,
);
let system_prompt = build_chat_system_prompt(state, user_text);
let modify_plan = crate::chat_intent::build_modify_plan(state, user_text);
let append_context = crate::chat_intent::detect_append_intent(state, user_text);
let modify_plan = op_web_daemon::chat_intent::build_modify_plan(state, user_text);
let append_context = op_web_daemon::chat_intent::detect_append_intent(state, user_text);
let initial_state = state.clone();
let design_request =
build_design_request(user_text.to_string(), &initial_state, append_context);
@ -292,7 +292,7 @@ fn launch_cli_standard_turn(
indicator_epoch,
));
let plan = crate::chat_intent::CliTurnPlan {
let plan = op_web_daemon::chat_intent::CliTurnPlan {
user_text: user_text.to_string(),
page_children_empty,
classify_provider,
@ -308,7 +308,7 @@ fn launch_cli_standard_turn(
thread::Builder::new()
.name("op-chat-intent".into())
.spawn(move || {
crate::chat_intent::run_cli_turn(plan, chat_tx, executor, delta_tx, cmd_tx);
op_web_daemon::chat_intent::run_cli_turn(plan, chat_tx, executor, delta_tx, cmd_tx);
})
.expect("spawn op-chat-intent thread");
true

View file

@ -8,7 +8,6 @@ mod acp_agent_probe_host;
mod app_handler;
mod chat_acp;
mod chat_attachment;
mod chat_intent;
mod chat_session;
mod clipboard;
mod codegen_export;
@ -988,5 +987,14 @@ fn main() {
}
}
// chat_intent moved to op_web_daemon::chat_intent (its headless tests
// moved alongside it as a `#[path]` sibling). Only the one host-coupled
// test stayed here — it drives the GUI design-session pumps, which need
// `WidgetHostNative` (absent from op-web-daemon's default-features-off
// op-host-native dependency).
#[cfg(test)]
#[path = "chat_intent_host_tests.rs"]
mod chat_intent_host_tests;
#[cfg(test)]
mod main_tests;

View file

@ -188,24 +188,24 @@ pub(crate) fn stream_standard_turn<W: Write>(
return write_error_event(out, "no model configured");
};
let classified = crate::chat_intent::classify_intent_llm(
let classified = op_web_daemon::chat_intent::classify_intent_llm(
classify_provider.as_ref(),
&req.ai.user,
model.clone(),
);
let modify_plan = crate::chat_intent::build_modify_plan(&snapshot, &req.ai.user);
let modify_plan = op_web_daemon::chat_intent::build_modify_plan(&snapshot, &req.ai.user);
let page_children_empty = snapshot.active_children().is_empty();
let intent = resolve_standard_route(classified, page_children_empty, modify_plan.is_some());
match intent {
crate::chat_intent::DesignIntent::Chat => {
op_web_daemon::chat_intent::DesignIntent::Chat => {
stream_chat_route(out, &req, &snapshot, chat_provider.as_ref(), model)
}
crate::chat_intent::DesignIntent::Modify => {
op_web_daemon::chat_intent::DesignIntent::Modify => {
let plan = modify_plan.expect("route checked has_modify_plan");
stream_modify_route(out, plan, design_provider.as_ref(), state, hub)
}
crate::chat_intent::DesignIntent::New => {
op_web_daemon::chat_intent::DesignIntent::New => {
stream_new_design_route(out, req, snapshot, design_provider, state, hub, model)
}
}
@ -275,16 +275,16 @@ fn clear_fresh_starter_frame_for_design(state: &mut EditorState) -> bool {
}
fn resolve_standard_route(
classified: crate::chat_intent::DesignIntent,
classified: op_web_daemon::chat_intent::DesignIntent,
page_children_empty: bool,
has_modify_plan: bool,
) -> crate::chat_intent::DesignIntent {
) -> op_web_daemon::chat_intent::DesignIntent {
match classified {
crate::chat_intent::DesignIntent::Modify if page_children_empty => {
crate::chat_intent::DesignIntent::New
op_web_daemon::chat_intent::DesignIntent::Modify if page_children_empty => {
op_web_daemon::chat_intent::DesignIntent::New
}
crate::chat_intent::DesignIntent::Modify if !has_modify_plan => {
crate::chat_intent::DesignIntent::New
op_web_daemon::chat_intent::DesignIntent::Modify if !has_modify_plan => {
op_web_daemon::chat_intent::DesignIntent::New
}
other => other,
}
@ -319,7 +319,7 @@ fn stream_chat_route<W: Write>(
fn stream_modify_route<W: Write>(
out: &mut W,
plan: crate::chat_intent::ModifyPlan,
plan: op_web_daemon::chat_intent::ModifyPlan,
provider: &dyn ChatProvider,
state: &Mutex<WebCanvasState>,
hub: &SseHub,
@ -404,7 +404,7 @@ fn stream_new_design_route<W: Write>(
hub: &SseHub,
model: Option<String>,
) -> std::io::Result<()> {
let append_context = crate::chat_intent::detect_append_intent(&snapshot, &req.ai.user);
let append_context = op_web_daemon::chat_intent::detect_append_intent(&snapshot, &req.ai.user);
let request = DesignRequest {
prompt: req.ai.user,
model: model.clone(),

View file

@ -49,21 +49,21 @@ use op_editor_core::pen_node_ext::PenNodeExt;
use op_editor_core::EditorState;
use op_orchestrator::{AppendContext, DesignRequest};
use op_web_daemon::chat_canvas_tools::UiChatToolExecutor;
use op_web_daemon::chat_provider_llm::ChatProviderLlmClient;
use op_web_daemon::design_session::{run_design_worker, DesignCmdReq, DesignDelta};
use crate::chat_canvas_tools::UiChatToolExecutor;
use crate::chat_provider_llm::ChatProviderLlmClient;
use crate::design_session::{run_design_worker, DesignCmdReq, DesignDelta};
/// Internal host-op name the modify worker sends over the chat tool
/// channel; intercepted by `chat_session::drain_tool_requests` (never
/// advertised to any model).
pub(crate) const APPLY_MODIFICATION_OP: &str = "__apply_design_modification";
pub const APPLY_MODIFICATION_OP: &str = "__apply_design_modification";
/// TS `classifyIntent` abort budget (`ai-chat-intent-classifier.ts:26`).
const CLASSIFY_TIMEOUT: Duration = Duration::from_secs(8);
/// TS `DesignIntent = 'new' | 'modify' | 'chat'`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DesignIntent {
pub enum DesignIntent {
New,
Modify,
Chat,
@ -125,7 +125,7 @@ fn matches_any_word_phrase(text_lower: &str, phrases: &[&str]) -> bool {
/// TS `classifyByKeywords` — verbatim rule order.
#[cfg(test)]
pub(crate) fn classify_by_keywords(text: &str) -> DesignIntent {
pub fn classify_by_keywords(text: &str) -> DesignIntent {
let lower = text.to_lowercase();
let chat = matches_any_word_phrase(&lower, CHAT_KEYWORDS);
let modify = matches_any_word_phrase(&lower, MODIFY_KEYWORDS);
@ -139,7 +139,7 @@ pub(crate) fn classify_by_keywords(text: &str) -> DesignIntent {
}
/// TS classification-tag parsing (`ai-chat-intent-classifier.ts:46-51`).
pub(crate) fn parse_classified(text: &str) -> DesignIntent {
pub fn parse_classified(text: &str) -> DesignIntent {
let upper = text.trim().to_uppercase();
if upper.contains("DESIGN_MODIFY") {
return DesignIntent::Modify;
@ -156,7 +156,7 @@ pub(crate) fn parse_classified(text: &str) -> DesignIntent {
/// TS `classifyIntent` — one lightweight LLM call through the (chat-
/// session-untracked) provider, with the TS 8s abort and the TS
/// fallback to `new` on any failure / timeout.
pub(crate) fn classify_intent_llm(
pub fn classify_intent_llm(
provider: &dyn ChatProvider,
text: &str,
model: Option<String>,
@ -360,7 +360,7 @@ fn pick_content_root(page: &PenNode) -> (&PenNode, Vec<String>) {
/// (TS `pickActivePageFrame` fallback branch — the Rust shell's
/// active page is always a real page entry, never a frame-as-page
/// alias).
pub(crate) fn detect_append_intent(state: &EditorState, prompt: &str) -> Option<AppendContext> {
pub fn detect_append_intent(state: &EditorState, prompt: &str) -> Option<AppendContext> {
if prompt.trim().is_empty() {
return None;
}
@ -403,7 +403,7 @@ pub(crate) fn detect_append_intent(state: &EditorState, prompt: &str) -> Option<
/// TS `buildVariableContext` (design-generator.ts:43-72). `None` when
/// the document has no variables. BTreeMap iteration is sorted where
/// TS uses insertion order — content is identical, ordering may not be.
pub(crate) fn build_variable_context(state: &EditorState) -> Option<String> {
pub fn build_variable_context(state: &EditorState) -> Option<String> {
let vars = state.doc.variables.as_ref().filter(|v| !v.is_empty())?;
let mut lines: Vec<String> = vec![
"DOCUMENT VARIABLES (use \"$name\" to reference, e.g. fill color \"$color-1\"):".into(),
@ -461,7 +461,7 @@ fn scalar_display(value: &jian_ops_schema::variable::VariableScalar) -> String {
}
/// Pre-built `generateDesignModification` request inputs.
pub(crate) struct ModifyPlan {
pub struct ModifyPlan {
/// `CONTEXT NODES + INSTRUCTION (+ variable context)` user message.
pub user_message: String,
/// Maintenance skills (+ design-md style policy) system prompt.
@ -473,7 +473,7 @@ pub(crate) struct ModifyPlan {
/// the page, else last page child), then the
/// `generateDesignModification` message/prompt assembly. `None` when
/// the page has no usable target (the caller degrades to `new`).
pub(crate) fn build_modify_plan(state: &EditorState, instruction: &str) -> Option<ModifyPlan> {
pub fn build_modify_plan(state: &EditorState, instruction: &str) -> Option<ModifyPlan> {
let children = state.active_children();
let mut targets: Vec<&PenNode> = Vec::new();
if !state.selection.set.is_empty() {
@ -537,7 +537,7 @@ pub(crate) fn build_modify_plan(state: &EditorState, instruction: &str) -> Optio
// ---------------------------------------------------------------------------
/// Everything the router worker needs, pre-computed on the UI thread.
pub(crate) struct CliTurnPlan {
pub struct CliTurnPlan {
pub user_text: String,
/// TS `pageChildren.length === 0` (modify degrades to new).
pub page_children_empty: bool,
@ -593,7 +593,7 @@ fn resolve_route(
/// `executor`, and a `DesignSession` on `delta_tx` / `cmd_tx`; the
/// routes not taken drop their senders so the matching pump retires
/// its session.
pub(crate) fn run_cli_turn(
pub fn run_cli_turn(
plan: CliTurnPlan,
chat_tx: Sender<ChatDelta>,
executor: UiChatToolExecutor,
@ -740,3 +740,4 @@ fn run_modify_turn(
#[cfg(test)]
#[path = "chat_intent_tests.rs"]
mod tests;

View file

@ -1,17 +1,14 @@
//! Tests for the CLI standard-mode intent router (GAP #33).
use std::collections::VecDeque;
use std::sync::{mpsc, Mutex};
use std::time::{Duration, Instant};
use std::sync::mpsc;
use std::time::Duration;
use op_ai::chat_provider::{ChatDelta, ChatProvider, ChatRequest, StopReason};
use op_editor_core::EditorState;
use op_host_native::WidgetHostNative;
use op_orchestrator::DesignRequest;
use super::*;
use op_web_daemon::chat_canvas_tools::{apply_design_modification, chat_tool_channel};
use crate::design_session::{pump_commands, pump_progress, DesignSession};
use crate::chat_canvas_tools::{apply_design_modification, chat_tool_channel};
// ---------------------------------------------------------------------------
// Keyword + tag classification
@ -133,42 +130,6 @@ impl ChatProvider for Scripted {
}
}
struct ScriptedCalls {
calls: Mutex<VecDeque<String>>,
}
impl ScriptedCalls {
fn new(calls: Vec<String>) -> Self {
Self {
calls: Mutex::new(calls.into()),
}
}
}
impl ChatProvider for ScriptedCalls {
fn provider_label(&self) -> &str {
"scripted-calls"
}
fn send(&self, _request: ChatRequest) -> Box<dyn Iterator<Item = ChatDelta> + Send> {
let text = self
.calls
.lock()
.unwrap()
.pop_front()
.expect("scripted call exhausted");
Box::new(
vec![
ChatDelta::TextDelta(text),
ChatDelta::Done {
stop_reason: StopReason::EndTurn,
},
]
.into_iter(),
)
}
}
// ---------------------------------------------------------------------------
// LLM classification
// ---------------------------------------------------------------------------
@ -522,19 +483,6 @@ fn test_design_request() -> DesignRequest {
}
}
const ONE_SUBTASK_PLAN_JSON: &str = r##"{
"rootFrame": { "id": "root", "name": "Login", "width": 390, "height": 844,
"layout": "vertical", "gap": 0,
"fill": [{ "type": "solid", "color": "#FFFFFF" }] },
"subtasks": [
{ "id": "form", "label": "Form", "region": { "width": 390, "height": 300 } }
]
}"##;
fn one_node_json() -> String {
r#"[{"type":"frame","id":"form-1","name":"Form","width":390,"height":120,"children":[{"type":"text","id":"form-title","content":"Welcome","fontSize":24}]}]"#.into()
}
fn drain_chat(rx: &mpsc::Receiver<ChatDelta>) -> Vec<ChatDelta> {
let mut out = Vec::new();
while let Ok(delta) = rx.recv_timeout(Duration::from_secs(10)) {
@ -547,61 +495,6 @@ fn drain_chat(rx: &mpsc::Receiver<ChatDelta>) -> Vec<ChatDelta> {
out
}
#[test]
fn cli_new_design_clears_agent_frame_indicators_after_done() {
op_editor_core::agent_indicators::clear();
let indicator_epoch = op_editor_core::agent_indicators::begin();
let plan = CliTurnPlan {
user_text: "design a login page".into(),
page_children_empty: true,
classify_provider: Box::new(Scripted::text("DESIGN_NEW")),
chat_provider: Box::new(Scripted::text("unused")),
design_provider: Box::new(ScriptedCalls::new(vec![
ONE_SUBTASK_PLAN_JSON.into(),
one_node_json(),
])),
chat_request: ChatRequest::default(),
modify_request: None,
design_request: test_design_request(),
initial_state: EditorState::new(),
indicator_epoch,
model: None,
};
let (chat_tx, _chat_rx) = mpsc::channel();
let (executor, _tool_rx) = chat_tool_channel();
let (delta_tx, delta_rx) = mpsc::channel();
let (cmd_tx, cmd_rx) = mpsc::channel();
let mut current = Some(DesignSession::from_channels_with_epoch(
delta_rx,
cmd_rx,
indicator_epoch,
));
let mut host = WidgetHostNative::new();
host.editor_state_mut()
.chat
.messages
.push(op_editor_core::ChatMessage::assistant_streaming());
let worker =
std::thread::spawn(move || run_cli_turn(plan, chat_tx, executor, delta_tx, cmd_tx));
let deadline = Instant::now() + Duration::from_secs(10);
while current.is_some() && Instant::now() < deadline {
let _ = pump_commands(&mut host, &mut current, 1440.0, 900.0);
let _ = pump_progress(&mut host, &mut current);
std::thread::sleep(Duration::from_millis(2));
}
worker.join().expect("worker exits");
assert!(current.is_none(), "design session should finish");
let snapshot = op_editor_core::agent_indicators::snapshot();
assert!(
snapshot.frames.is_empty(),
"agent frame badges/borders must clear after Done, got {:?}",
snapshot.frames
);
op_editor_core::agent_indicators::clear();
}
#[test]
fn cli_turn_chat_route_streams_provider_deltas() {
let plan = CliTurnPlan {

View file

@ -23,6 +23,7 @@ pub mod chat_canvas_tools;
pub mod chat_claude;
pub mod chat_copilot;
pub mod chat_http_server;
pub mod chat_intent;
pub mod chat_provider_llm;
pub mod chat_runtime;
pub mod chat_spawn;

View file

@ -6,9 +6,10 @@
#
# (The former Invariant 1 — "the app crate must not depend directly on
# any jian-* crate" — was dropped in Phase 1 Task 1.2 along with the
# old placeholder crate. The Phase 7.3 reorg reintroduced a real
# composition-root crate, `op-app`; reinstate an equivalent facade
# check against it if/when op-app grows beyond a thin re-export.)
# old placeholder crate. The Phase 7.3 reorg's `op-app` composition-root
# crate was removed 2026-06-19 as an orphan — nothing depended on it and
# the editor-UI composition already lives in op-editor-ui. Reinstate an
# equivalent facade check if a real app crate is reintroduced.)
#
# Invariant 2 (§11.1, §12.3 — REVISED 2026-05-10): mobile targets
# (`aarch64-linux-android`, `aarch64-apple-ios`) must NOT pull