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:
parent
d5727d322d
commit
a172b74023
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 /
|
||||
|
|
|
|||
188
crates/op-host-desktop/src/chat_intent_host_tests.rs
Normal file
188
crates/op-host-desktop/src/chat_intent_host_tests.rs
Normal 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();
|
||||
}
|
||||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue