From a172b7402318907c4a382c51722ccabd41f8d38e Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Fri, 19 Jun 2026 21:55:33 +0800 Subject: [PATCH] refactor(host): move chat_intent to op-web-daemon; extract host-coupled test (Phase 5, Task 5.3a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 1 - crates/CLAUDE.md | 1 - .../src/chat_intent_host_tests.rs | 188 ++++++++++++++++++ crates/op-host-desktop/src/chat_session.rs | 2 +- .../src/chat_session_launch.rs | 10 +- crates/op-host-desktop/src/main.rs | 10 +- .../op-host-desktop/src/web_chat_standard.rs | 26 +-- .../src/chat_intent.rs | 29 +-- .../src/chat_intent_tests.rs | 113 +---------- crates/op-web-daemon/src/lib.rs | 1 + tools/check-jian-boundaries.sh | 7 +- 11 files changed, 239 insertions(+), 149 deletions(-) create mode 100644 crates/op-host-desktop/src/chat_intent_host_tests.rs rename crates/{op-host-desktop => op-web-daemon}/src/chat_intent.rs (96%) rename crates/{op-host-desktop => op-web-daemon}/src/chat_intent_tests.rs (85%) diff --git a/README.md b/README.md index c35541e25..e68f516c6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/crates/CLAUDE.md b/crates/CLAUDE.md index eb522fefd..9e8d8819a 100644 --- a/crates/CLAUDE.md +++ b/crates/CLAUDE.md @@ -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 / diff --git a/crates/op-host-desktop/src/chat_intent_host_tests.rs b/crates/op-host-desktop/src/chat_intent_host_tests.rs new file mode 100644 index 000000000..8b0270cb5 --- /dev/null +++ b/crates/op-host-desktop/src/chat_intent_host_tests.rs @@ -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, + 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 + 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>, +} + +impl ScriptedCalls { + fn new(calls: Vec) -> 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 + 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(); +} diff --git a/crates/op-host-desktop/src/chat_session.rs b/crates/op-host-desktop/src/chat_session.rs index c29e5d700..b8c8cd9ad 100644 --- a/crates/op-host-desktop/src/chat_session.rs +++ b/crates/op-host-desktop/src/chat_session.rs @@ -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::(&req.args_json) .ok() .and_then(|v| v.get("nodes").and_then(|n| n.as_array().cloned())) diff --git a/crates/op-host-desktop/src/chat_session_launch.rs b/crates/op-host-desktop/src/chat_session_launch.rs index 3d4fcaa6d..426338768 100644 --- a/crates/op-host-desktop/src/chat_session_launch.rs +++ b/crates/op-host-desktop/src/chat_session_launch.rs @@ -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 diff --git a/crates/op-host-desktop/src/main.rs b/crates/op-host-desktop/src/main.rs index bed2d9b14..1e220d914 100644 --- a/crates/op-host-desktop/src/main.rs +++ b/crates/op-host-desktop/src/main.rs @@ -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; diff --git a/crates/op-host-desktop/src/web_chat_standard.rs b/crates/op-host-desktop/src/web_chat_standard.rs index c9fe1f05c..ec77ae3fb 100644 --- a/crates/op-host-desktop/src/web_chat_standard.rs +++ b/crates/op-host-desktop/src/web_chat_standard.rs @@ -188,24 +188,24 @@ pub(crate) fn stream_standard_turn( 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( fn stream_modify_route( out: &mut W, - plan: crate::chat_intent::ModifyPlan, + plan: op_web_daemon::chat_intent::ModifyPlan, provider: &dyn ChatProvider, state: &Mutex, hub: &SseHub, @@ -404,7 +404,7 @@ fn stream_new_design_route( hub: &SseHub, model: Option, ) -> 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(), diff --git a/crates/op-host-desktop/src/chat_intent.rs b/crates/op-web-daemon/src/chat_intent.rs similarity index 96% rename from crates/op-host-desktop/src/chat_intent.rs rename to crates/op-web-daemon/src/chat_intent.rs index e1a46be17..91a4ff2cf 100644 --- a/crates/op-host-desktop/src/chat_intent.rs +++ b/crates/op-web-daemon/src/chat_intent.rs @@ -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, @@ -360,7 +360,7 @@ fn pick_content_root(page: &PenNode) -> (&PenNode, Vec) { /// (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 { +pub fn detect_append_intent(state: &EditorState, prompt: &str) -> Option { 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 { +pub fn build_variable_context(state: &EditorState) -> Option { let vars = state.doc.variables.as_ref().filter(|v| !v.is_empty())?; let mut lines: Vec = 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 { +pub fn build_modify_plan(state: &EditorState, instruction: &str) -> Option { 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, executor: UiChatToolExecutor, @@ -740,3 +740,4 @@ fn run_modify_turn( #[cfg(test)] #[path = "chat_intent_tests.rs"] mod tests; + diff --git a/crates/op-host-desktop/src/chat_intent_tests.rs b/crates/op-web-daemon/src/chat_intent_tests.rs similarity index 85% rename from crates/op-host-desktop/src/chat_intent_tests.rs rename to crates/op-web-daemon/src/chat_intent_tests.rs index bd96b2c9a..aa1386b36 100644 --- a/crates/op-host-desktop/src/chat_intent_tests.rs +++ b/crates/op-web-daemon/src/chat_intent_tests.rs @@ -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>, -} - -impl ScriptedCalls { - fn new(calls: Vec) -> Self { - Self { - calls: Mutex::new(calls.into()), - } - } -} - -impl ChatProvider for ScriptedCalls { - fn provider_label(&self) -> &str { - "scripted-calls" - } - - fn send(&self, _request: ChatRequest) -> Box + 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) -> Vec { 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) -> Vec { 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 { diff --git a/crates/op-web-daemon/src/lib.rs b/crates/op-web-daemon/src/lib.rs index 371c93849..aaf60aec8 100644 --- a/crates/op-web-daemon/src/lib.rs +++ b/crates/op-web-daemon/src/lib.rs @@ -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; diff --git a/tools/check-jian-boundaries.sh b/tools/check-jian-boundaries.sh index 5591ec573..2574e1105 100755 --- a/tools/check-jian-boundaries.sh +++ b/tools/check-jian-boundaries.sh @@ -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