feat(ai): stream per-batch reveals and progress in the design loop
Loop-applied batches now register node reveals against the live run epoch (no more whole-sections popping in silently), the checklist surfaces tool-call progress per turn, per-batch layout feedback keeps flowing, and the design loop gets a bounded turn budget sized for section-by-section batching plus repair turns.
This commit is contained in:
parent
974a1d1f2e
commit
a384a19fae
|
|
@ -203,6 +203,12 @@ pub fn begin() -> u64 {
|
|||
r.epoch
|
||||
}
|
||||
|
||||
/// Return the currently active run epoch, if a host-owned indicator run is live.
|
||||
pub fn active_epoch() -> Option<u64> {
|
||||
let r = REGISTRY.lock().unwrap();
|
||||
r.run_active.then_some(r.epoch)
|
||||
}
|
||||
|
||||
/// Clear indicators only if `epoch` is still the active run. A newer
|
||||
/// [`begin`] makes this a no-op, so a stale / cancelled run finishing
|
||||
/// late can't wipe the indicators of the run that replaced it. The
|
||||
|
|
@ -594,3 +600,19 @@ pub fn apply_remote(remote: &RemoteIndicators) -> bool {
|
|||
}
|
||||
has_active_indicators(&r) || r.needs_final_frame
|
||||
}
|
||||
|
||||
/// Latest scheduled reveal END (start + [`REVEAL_DURATION_MS`]) for `epoch`,
|
||||
/// or `None` when the epoch is stale or nothing is scheduled. The
|
||||
/// orchestrator's finalize gate polls this so tree-restructuring
|
||||
/// (`ReplaceSubtree` id churn) waits for the cursor sweep to finish instead
|
||||
/// of snapping the still-animating tail of the design in at once.
|
||||
pub fn latest_reveal_end_ms(epoch: u64) -> Option<u64> {
|
||||
let r = REGISTRY.lock().unwrap();
|
||||
if r.epoch != epoch {
|
||||
return None;
|
||||
}
|
||||
r.reveals
|
||||
.values()
|
||||
.max()
|
||||
.map(|s| s.saturating_add(REVEAL_DURATION_MS))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
//! Shared chat turn worker and transcript folding logic.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::mpsc::{self, Receiver, Sender, SyncSender, TryRecvError};
|
||||
use std::sync::Mutex;
|
||||
use std::thread;
|
||||
|
|
@ -92,6 +93,7 @@ pub fn chat_tool_channel() -> (UiChatToolExecutor, Receiver<ChatToolRequest>) {
|
|||
pub struct ChatSession {
|
||||
rx: Receiver<ChatDelta>,
|
||||
tool_rx: Option<Receiver<ChatToolRequest>>,
|
||||
deferred_tool_requests: VecDeque<ChatToolRequest>,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
|
|
@ -212,6 +214,7 @@ impl ChatSession {
|
|||
Self {
|
||||
rx,
|
||||
tool_rx,
|
||||
deferred_tool_requests: VecDeque::new(),
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
|
@ -225,6 +228,7 @@ impl ChatSession {
|
|||
Self {
|
||||
rx,
|
||||
tool_rx,
|
||||
deferred_tool_requests: VecDeque::new(),
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
|
@ -266,16 +270,20 @@ impl ChatSession {
|
|||
|
||||
/// Drain pending host tool requests without blocking.
|
||||
pub fn drain_tool_requests(&mut self) -> Vec<ChatToolRequest> {
|
||||
let mut requests: Vec<ChatToolRequest> = self.deferred_tool_requests.drain(..).collect();
|
||||
let Some(tool_rx) = self.tool_rx.as_ref() else {
|
||||
return Vec::new();
|
||||
return requests;
|
||||
};
|
||||
let mut requests = Vec::new();
|
||||
while let Ok(req) = tool_rx.try_recv() {
|
||||
requests.push(req);
|
||||
}
|
||||
requests
|
||||
}
|
||||
|
||||
pub fn defer_tool_request(&mut self, req: ChatToolRequest) {
|
||||
self.deferred_tool_requests.push_back(req);
|
||||
}
|
||||
|
||||
pub fn finished(&self) -> bool {
|
||||
self.finished
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use crate::theme::Theme;
|
|||
use crate::widgets::icons::{draw_icon, Icon};
|
||||
use crate::widgets::PaintCx;
|
||||
use crate::{Color, Point2D, Rect, TextLayout};
|
||||
use op_editor_core::chat::{ChatRole, ChatState};
|
||||
use op_editor_core::chat::{ChatMessage, ChatRole, ChatState};
|
||||
|
||||
pub(crate) const PROGRESS_H: f32 = 2.0;
|
||||
pub(crate) const HEADER_H: f32 = 32.0;
|
||||
|
|
@ -75,6 +75,10 @@ pub(crate) fn fixed_checklist_items(chat: &ChatState) -> Vec<ChecklistItem> {
|
|||
use_progress_position_fallback = !steps.is_empty();
|
||||
}
|
||||
if steps.is_empty() {
|
||||
let tool_items = loop_tool_call_items(chat, message);
|
||||
if !tool_items.is_empty() {
|
||||
return tool_items;
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
|
|
@ -121,6 +125,57 @@ pub(crate) fn fixed_checklist_items(chat: &ChatState) -> Vec<ChecklistItem> {
|
|||
}
|
||||
}
|
||||
|
||||
fn loop_tool_call_items(chat: &ChatState, message: &ChatMessage) -> Vec<ChecklistItem> {
|
||||
if chat.agents_running.1 == 0 || message.tool_calls.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
message
|
||||
.tool_calls
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, call)| {
|
||||
let status = tool_call_status(&call.args);
|
||||
let done = status.as_deref() == Some("done");
|
||||
let failed = status.as_deref() == Some("error");
|
||||
let active = status.as_deref() == Some("running")
|
||||
|| (status.is_none() && message.streaming && index + 1 == message.tool_calls.len());
|
||||
ChecklistItem {
|
||||
label: tool_call_label(&call.name),
|
||||
done,
|
||||
active,
|
||||
failed,
|
||||
details: Vec::new(),
|
||||
expanded: false,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn tool_call_status(args: &str) -> Option<String> {
|
||||
serde_json::from_str::<serde_json::Value>(args)
|
||||
.ok()?
|
||||
.get("status")?
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn tool_call_label(name: &str) -> String {
|
||||
match name {
|
||||
"get_editor_state" => "Read canvas state",
|
||||
"get_style_guide_tags" | "get_style_guide" => "Load style guide",
|
||||
"get_variables" => "Read variables",
|
||||
"batch_get" => "Read components",
|
||||
"snapshot_layout" => "Inspect layout",
|
||||
"find_empty_space" => "Find placement",
|
||||
"emit_elements" => "Emit elements",
|
||||
"batch_design" => "Apply design batch",
|
||||
"get_screenshot" => "Verify screenshot",
|
||||
"spawn_agents" => "Spawn section agents",
|
||||
other => other,
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn fixed_checklist_height(chat: &ChatState, collapsed: bool) -> f32 {
|
||||
let items = fixed_checklist_items(chat);
|
||||
let count = items.len();
|
||||
|
|
@ -569,6 +624,32 @@ mod tests {
|
|||
assert_eq!(items[1].label, "Subtask `hero` — Hero section");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_checklist_uses_loop_tool_call_sequence_when_no_plan_steps() {
|
||||
let mut message = ChatMessage::assistant_streaming();
|
||||
message.tool_calls = vec![
|
||||
op_editor_core::ChatToolCall {
|
||||
name: "get_editor_state".into(),
|
||||
args: r#"{"status":"done"}"#.into(),
|
||||
},
|
||||
op_editor_core::ChatToolCall {
|
||||
name: "batch_design".into(),
|
||||
args: r#"{"status":"running"}"#.into(),
|
||||
},
|
||||
];
|
||||
|
||||
let mut chat = ChatState::default();
|
||||
chat.agents_running = (1, 1);
|
||||
chat.messages = vec![message];
|
||||
let items = fixed_checklist_items(&chat);
|
||||
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].label, "Read canvas state");
|
||||
assert!(items[0].done);
|
||||
assert_eq!(items[1].label, "Apply design batch");
|
||||
assert!(items[1].active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checklist_summary_label_is_ellipsized_to_available_width() {
|
||||
let label = "Subtask `dumpling-card` · 11 skills · 7355/8000 tok · 27 dropped";
|
||||
|
|
|
|||
|
|
@ -94,6 +94,10 @@ fn drain_tool_requests(
|
|||
// passes the orchestrator runs per subtask. Runs on the UI thread (the
|
||||
// owner of the live `EditorState`), like every other tool mutation.
|
||||
if req.name == op_ai::chat_provider::LOOP_FINALIZE_OP {
|
||||
if crate::design_loop_indicator::reveal_drain_pending_for_active_epoch() {
|
||||
session.defer_tool_request(req);
|
||||
continue;
|
||||
}
|
||||
op_orchestrator::apply_loop_finalize(state);
|
||||
changed = true;
|
||||
let _ = req.ack.send(ChatToolResult {
|
||||
|
|
@ -243,3 +247,7 @@ fn attach_tool_result_to_transcript(
|
|||
#[cfg(test)]
|
||||
#[path = "chat_session_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "chat_session_reveal_tests.rs"]
|
||||
mod reveal_tests;
|
||||
|
|
|
|||
|
|
@ -418,6 +418,9 @@ pub fn drain_new_chat_request(
|
|||
// second one here (one "+" click == one new tab).
|
||||
*current_chat = None;
|
||||
*current_design = None;
|
||||
if let Some(epoch) = op_editor_core::agent_indicators::active_epoch() {
|
||||
op_editor_core::agent_indicators::end_if_epoch(epoch);
|
||||
}
|
||||
// A fresh tab must start a fresh provider conversation — forget any
|
||||
// resumable Claude Code / Copilot session so stale context cannot
|
||||
// leak into the new chat.
|
||||
|
|
@ -440,6 +443,9 @@ pub fn drain_stop_request(
|
|||
}
|
||||
*current_chat = None;
|
||||
*current_design = None;
|
||||
if let Some(epoch) = op_editor_core::agent_indicators::active_epoch() {
|
||||
op_editor_core::agent_indicators::end_if_epoch(epoch);
|
||||
}
|
||||
host.mark_editor_state_dirty();
|
||||
true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ use op_ai::chat_provider::{ChatProvider, ChatRequest, ThinkingMode};
|
|||
use op_editor_host_core::chat::ChatSession;
|
||||
use op_editor_host_core::design::DesignSession;
|
||||
use op_host_native::WidgetHostNative;
|
||||
use op_host_services::chat_builtin_http::ConfiguredBuiltinProvider;
|
||||
use op_host_services::chat_builtin_http::{
|
||||
ConfiguredBuiltinProvider, DESIGN_LOOP_MAX_OUTPUT_TOKENS,
|
||||
};
|
||||
use op_host_services::chat_canvas_tools::{chat_tool_channel, ChatToolRequest};
|
||||
use op_host_services::chat_system_prompt::chat_history_from_transcript;
|
||||
use op_host_services::design_agent_tools::design_tool_defs;
|
||||
|
|
@ -127,7 +129,7 @@ fn resolve_design_thinking(model: Option<&str>, chat_default: ThinkingMode) -> T
|
|||
/// falls through to the orchestrator path).
|
||||
///
|
||||
/// Mirrors `launch_if_pending`'s builtin chat branch but uses the
|
||||
/// design toolset and a 16384-token per-turn budget.
|
||||
/// design toolset and a section-batch-sized per-turn budget.
|
||||
pub(super) fn launch_design_loop_turn(
|
||||
host: &mut WidgetHostNative,
|
||||
user_text: String,
|
||||
|
|
@ -161,19 +163,18 @@ pub(super) fn launch_design_loop_turn(
|
|||
system_prompt: op_ai_skills::design_agent_system_prompt().to_string(),
|
||||
user_message: user_text,
|
||||
history,
|
||||
// Per-TURN output cap. The real budget-burner was hidden reasoning, not
|
||||
// the DSL: a glm-5.2 loop run streamed ~94k thinking chars vs ~4k of
|
||||
// actual batch_design, blowing the cap so a turn truncated mid-JSON, its
|
||||
// tool call failed to parse, and the loop stopped early with an
|
||||
// unfinished design. The root fix is at the wire (the loop now sends
|
||||
// thinking:{type:disabled} for glm/minimax); 16384 (up from 8192) is
|
||||
// headroom so a genuinely large section-batch still completes per turn.
|
||||
max_output_tokens: 16384,
|
||||
// Design-loop turns should emit one <=25-op section batch, then wait for
|
||||
// tool feedback. 6144 matches that 4-6k-token batch size and avoids
|
||||
// rewarding monolithic whole-screen tool calls.
|
||||
max_output_tokens: DESIGN_LOOP_MAX_OUTPUT_TOKENS,
|
||||
thinking,
|
||||
effort,
|
||||
attachments,
|
||||
model: None,
|
||||
};
|
||||
// The host starts the indicator epoch before the worker can apply its first
|
||||
// design batch; the indicator pump adopts this epoch for badges/teardown.
|
||||
op_editor_core::agent_indicators::begin();
|
||||
*current_chat = Some(ChatSession::start_with_tools(provider, req, Some(tool_rx)));
|
||||
// Signal one agent running so the chat header shows "1/1 designing…"
|
||||
// and the canvas indicator pump registers frame glows.
|
||||
|
|
|
|||
132
crates/op-host-desktop/src/chat_session_reveal_tests.rs
Normal file
132
crates/op-host-desktop/src/chat_session_reveal_tests.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
use super::*;
|
||||
use op_ai::chat_provider::{ChatDelta, ChatRequest, ChatToolExecutor, StopReason};
|
||||
use op_editor_core::{ChatMessage, PenNodeExt};
|
||||
|
||||
struct BatchThenFinalizeProvider {
|
||||
executor: std::sync::Arc<dyn ChatToolExecutor>,
|
||||
}
|
||||
|
||||
struct BatchThenFinalizeIter {
|
||||
executor: std::sync::Arc<dyn ChatToolExecutor>,
|
||||
step: u8,
|
||||
}
|
||||
|
||||
impl Iterator for BatchThenFinalizeIter {
|
||||
type Item = ChatDelta;
|
||||
|
||||
fn next(&mut self) -> Option<ChatDelta> {
|
||||
self.step += 1;
|
||||
match self.step {
|
||||
1 => {
|
||||
let args = r#"{"operations":"root=I(null,{type:'frame',name:'Header',width:1200,height:64})"}"#;
|
||||
let _ = self.executor.execute("batch_design", args);
|
||||
Some(ChatDelta::TextDelta("inserted".into()))
|
||||
}
|
||||
2 => {
|
||||
self.executor.finalize();
|
||||
Some(ChatDelta::Done {
|
||||
stop_reason: StopReason::EndTurn,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl op_ai::chat_provider::ChatProvider for BatchThenFinalizeProvider {
|
||||
fn provider_label(&self) -> &str {
|
||||
"batch-finalize"
|
||||
}
|
||||
|
||||
fn send(&self, _request: ChatRequest) -> Box<dyn Iterator<Item = ChatDelta> + Send> {
|
||||
Box::new(BatchThenFinalizeIter {
|
||||
executor: self.executor.clone(),
|
||||
step: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pump_defers_loop_finalize_until_registered_reveals_drain() {
|
||||
let _guard = crate::agent_indicator_test_lock::LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
op_editor_core::agent_indicators::clear();
|
||||
let epoch = op_editor_core::agent_indicators::begin();
|
||||
|
||||
let mut host = WidgetHostNative::new();
|
||||
host.editor_state_mut()
|
||||
.chat
|
||||
.messages
|
||||
.push(ChatMessage::assistant_streaming());
|
||||
let (executor, tool_rx) = op_host_services::chat_canvas_tools::chat_tool_channel();
|
||||
let provider = Box::new(BatchThenFinalizeProvider {
|
||||
executor: std::sync::Arc::new(executor),
|
||||
});
|
||||
let mut current = Some(ChatSession::start_with_tools(
|
||||
provider,
|
||||
ChatRequest {
|
||||
user_message: "build a header".into(),
|
||||
max_output_tokens: 64,
|
||||
..Default::default()
|
||||
},
|
||||
Some(tool_rx),
|
||||
));
|
||||
|
||||
for _ in 0..200 {
|
||||
pump(&mut host, &mut current, None);
|
||||
if op_editor_core::agent_indicators::latest_reveal_end_ms(epoch).is_some() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
let reveal_end = op_editor_core::agent_indicators::latest_reveal_end_ms(epoch)
|
||||
.expect("batch_design should register reveals before finalize");
|
||||
|
||||
for _ in 0..20 {
|
||||
pump(&mut host, &mut current, None);
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
let header = header_node(&host).expect("Header frame inserted");
|
||||
assert_eq!(
|
||||
header.base().role.as_deref(),
|
||||
None,
|
||||
"finalize must not run while the reveal queue is still draining"
|
||||
);
|
||||
|
||||
let now = reveal_now_millis();
|
||||
if reveal_end > now {
|
||||
std::thread::sleep(std::time::Duration::from_millis(reveal_end - now + 20));
|
||||
}
|
||||
for _ in 0..200 {
|
||||
pump(&mut host, &mut current, None);
|
||||
if current.is_none() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
assert!(current.is_none(), "turn must finish after reveal drain");
|
||||
let header = header_node(&host).expect("Header frame still present");
|
||||
assert_eq!(
|
||||
header.base().role.as_deref(),
|
||||
Some("navbar"),
|
||||
"loop-end finalize should run after the reveal queue drains"
|
||||
);
|
||||
|
||||
op_editor_core::agent_indicators::end_if_epoch(epoch);
|
||||
op_editor_core::agent_indicators::clear();
|
||||
}
|
||||
|
||||
fn header_node(host: &WidgetHostNative) -> Option<&jian_ops_schema::node::PenNode> {
|
||||
host.editor_state()
|
||||
.active_children()
|
||||
.iter()
|
||||
.find(|node| node.base().name.as_deref() == Some("Header"))
|
||||
}
|
||||
|
||||
fn reveal_now_millis() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis().min(u128::from(u64::MAX)) as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
//! `agents_running > 0`, so this module is a no-op for them.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use jian_ops_schema::node::PenNode;
|
||||
use op_editor_core::agent_indicators;
|
||||
|
|
@ -83,7 +84,7 @@ pub(super) fn pump_indicator(
|
|||
) {
|
||||
// Lazy creation when the design loop starts a turn.
|
||||
if state.chat.agents_running.0 > 0 && indicator.is_none() {
|
||||
let epoch = agent_indicators::begin();
|
||||
let epoch = agent_indicators::active_epoch().unwrap_or_else(agent_indicators::begin);
|
||||
let identities = assign_agent_identities(1);
|
||||
let id = identities
|
||||
.into_iter()
|
||||
|
|
@ -113,6 +114,25 @@ pub(super) fn pump_indicator(
|
|||
}
|
||||
}
|
||||
|
||||
/// True when the active design-loop epoch still has scheduled reveals that
|
||||
/// should finish before the loop-end structural finalizer rewrites the tree.
|
||||
pub(crate) fn reveal_drain_pending_for_active_epoch() -> bool {
|
||||
let Some(epoch) = agent_indicators::active_epoch() else {
|
||||
return false;
|
||||
};
|
||||
let Some(end) = agent_indicators::latest_reveal_end_ms(epoch) else {
|
||||
return false;
|
||||
};
|
||||
reveal_now_millis() < end
|
||||
}
|
||||
|
||||
fn reveal_now_millis() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis().min(u128::from(u64::MAX)) as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -8,16 +8,10 @@
|
|||
use super::*;
|
||||
use op_editor_core::{agent_indicators, EditorState};
|
||||
use op_host_services::design_agent_tools::execute_design_tool;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
/// A real styleguide name from the embedded corpus.
|
||||
const REAL_STYLEGUIDE: &str = "ai-product-dark";
|
||||
|
||||
/// Serialize tests that touch the process-global indicator registry +
|
||||
/// the module-level spawn stash so the default parallel runner doesn't
|
||||
/// race them against each other.
|
||||
static TEST_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
||||
|
||||
fn spec(prompt: &str, containers: &[&str], styleguide: &str, guidelines: &[&str]) -> SpawnSpec {
|
||||
SpawnSpec {
|
||||
prompt: prompt.to_string(),
|
||||
|
|
@ -183,7 +177,9 @@ fn parse_args_invalid_json_errors() {
|
|||
|
||||
#[test]
|
||||
fn stash_top_level_succeeds_nested_refused() {
|
||||
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _guard = crate::agent_indicator_test_lock::LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
// Clear any leftover stash.
|
||||
let _ = take_pending_spawn();
|
||||
|
||||
|
|
@ -214,7 +210,9 @@ fn stash_top_level_succeeds_nested_refused() {
|
|||
fn distinct_identities_and_epoch_scoped_frame_badges() {
|
||||
use op_orchestrator::agent_identity::assign_agent_identities;
|
||||
|
||||
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _guard = crate::agent_indicator_test_lock::LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
// Two specs → two distinct identities (colour + name).
|
||||
let identities = assign_agent_identities(2);
|
||||
|
|
@ -263,7 +261,9 @@ fn lazy_epoch_keeps_each_active_subs_badges_live_in_sequence() {
|
|||
// this. Simulate the pump's begin → badge → end cadence for two subs.
|
||||
use op_orchestrator::agent_identity::assign_agent_identities;
|
||||
|
||||
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _guard = crate::agent_indicator_test_lock::LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
agent_indicators::clear();
|
||||
|
||||
let ids = assign_agent_identities(2);
|
||||
|
|
@ -331,7 +331,9 @@ fn lazy_epoch_keeps_each_active_subs_badges_live_in_sequence() {
|
|||
fn pump_with_finished_subs_decrements_then_clears_agents_running() {
|
||||
use op_host_native::WidgetHostNative;
|
||||
|
||||
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _guard = crate::agent_indicator_test_lock::LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
let mut host = WidgetHostNative::new();
|
||||
// Seed the N/M header as `launch_sub_agents` would for two subs.
|
||||
|
|
|
|||
|
|
@ -21,6 +21,17 @@ use crate::chat_agent_loop::{run_anthropic_agent_loop, run_openai_agent_loop, Ag
|
|||
use crate::chat_canvas_tools::MAX_TOOL_TURNS;
|
||||
use crate::chat_runtime::{resolved_skill_preamble, shared_runtime, BlockingRecvIter};
|
||||
|
||||
/// Design turns build one <=25-op section batch per model turn, plus repair
|
||||
/// turns after layoutIssues feedback. 28 sits in the requested 24-32 window:
|
||||
/// enough for roughly 8-10 sections with fixes, without letting a bad loop run
|
||||
/// indefinitely. Plain chat keeps [`MAX_TOOL_TURNS`].
|
||||
pub const DESIGN_LOOP_MAX_TURNS: usize = 28;
|
||||
|
||||
/// A <=25-op batch plus one concise tool call fits comfortably in 4-6k output
|
||||
/// tokens. 6144 keeps section batches complete without encouraging the model to
|
||||
/// emit a whole screen in one turn.
|
||||
pub const DESIGN_LOOP_MAX_OUTPUT_TOKENS: u32 = 6_144;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ConfiguredBuiltinProvider {
|
||||
kind: BuiltinAgentKind,
|
||||
|
|
@ -178,7 +189,11 @@ impl ChatProvider for ConfiguredBuiltinProvider {
|
|||
max_output_tokens,
|
||||
tools: provider.tools.clone(),
|
||||
executor,
|
||||
max_turns: MAX_TOOL_TURNS,
|
||||
max_turns: if provider.finalize_on_exit {
|
||||
DESIGN_LOOP_MAX_TURNS
|
||||
} else {
|
||||
MAX_TOOL_TURNS
|
||||
},
|
||||
finalize_on_exit: provider.finalize_on_exit,
|
||||
disable_thinking,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,7 +10,12 @@
|
|||
//! DSL — giving the loop loops/data-driven emission without a separate
|
||||
//! element-builder tool.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use jian_ops_schema::node::{ContainerProps, PenNode, TextContent};
|
||||
use op_ai::chat_provider::{ChatToolDef, ChatToolResult};
|
||||
use op_editor_core::pen_node_ext::PenNodeExt;
|
||||
use op_editor_core::EditorState;
|
||||
use op_mcp::ToolRegistry;
|
||||
|
||||
|
|
@ -76,6 +81,22 @@ pub fn execute_design_tool(
|
|||
state: &mut EditorState,
|
||||
name: &str,
|
||||
args_json: &str,
|
||||
) -> (ChatToolResult, bool) {
|
||||
execute_design_tool_with_reveals(
|
||||
state,
|
||||
name,
|
||||
args_json,
|
||||
op_editor_core::agent_indicators::active_epoch(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Execute one design tool call and register entrance reveals for nodes inserted
|
||||
/// by write batches when the host has an active indicator epoch.
|
||||
pub fn execute_design_tool_with_reveals(
|
||||
state: &mut EditorState,
|
||||
name: &str,
|
||||
args_json: &str,
|
||||
indicator_epoch: Option<u64>,
|
||||
) -> (ChatToolResult, bool) {
|
||||
let Some(_level) = design_tool_level(name) else {
|
||||
let envelope = serde_json::json!({ "success": false, "error": format!("tool not available in design agent: {name}") });
|
||||
|
|
@ -87,8 +108,16 @@ pub fn execute_design_tool(
|
|||
false,
|
||||
);
|
||||
};
|
||||
let reveal_started_ms = reveal_now_millis();
|
||||
let ids_before = should_register_batch_reveals(name, indicator_epoch)
|
||||
.then(|| collect_active_node_ids(state));
|
||||
let registry = design_tool_registry(state, name);
|
||||
let (mut result, mutated) = execute_with_registry(state, name, args_json, registry);
|
||||
if mutated && !result.is_error {
|
||||
if let Some(ids_before) = ids_before.as_ref() {
|
||||
register_new_node_reveals(ids_before, state, indicator_epoch, reveal_started_ms);
|
||||
}
|
||||
}
|
||||
// Per-batch layout feedback: after every WRITE batch, attach what the real
|
||||
// layout proves wrong (collapses / table overflow / text overflow) so the
|
||||
// model sees each batch's geometric consequences immediately and repairs
|
||||
|
|
@ -127,9 +156,25 @@ pub fn execute_agent_tool(
|
|||
state: &mut EditorState,
|
||||
name: &str,
|
||||
args_json: &str,
|
||||
) -> (ChatToolResult, bool) {
|
||||
execute_agent_tool_with_reveals(
|
||||
state,
|
||||
name,
|
||||
args_json,
|
||||
op_editor_core::agent_indicators::active_epoch(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Host-facing tool router with an explicit indicator epoch for tests and
|
||||
/// desktop paths that already know the active design-loop epoch.
|
||||
pub fn execute_agent_tool_with_reveals(
|
||||
state: &mut EditorState,
|
||||
name: &str,
|
||||
args_json: &str,
|
||||
indicator_epoch: Option<u64>,
|
||||
) -> (ChatToolResult, bool) {
|
||||
if design_tool_level(name).is_some() {
|
||||
execute_design_tool(state, name, args_json)
|
||||
execute_design_tool_with_reveals(state, name, args_json, indicator_epoch)
|
||||
} else {
|
||||
execute_chat_tool(state, name, args_json)
|
||||
}
|
||||
|
|
@ -164,6 +209,148 @@ fn design_tool_registry(state: &EditorState, requested: &str) -> ToolRegistry {
|
|||
r
|
||||
}
|
||||
|
||||
fn should_register_batch_reveals(name: &str, indicator_epoch: Option<u64>) -> bool {
|
||||
indicator_epoch.is_some() && matches!(name, "batch_design" | EMIT_ELEMENTS_TOOL)
|
||||
}
|
||||
|
||||
fn collect_active_node_ids(state: &EditorState) -> HashSet<String> {
|
||||
let mut out = HashSet::new();
|
||||
for node in state.active_children() {
|
||||
collect_node_ids(node, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn collect_node_ids(node: &PenNode, out: &mut HashSet<String>) {
|
||||
out.insert(node.id_str().to_string());
|
||||
if let Some(children) = node.children() {
|
||||
for child in children {
|
||||
collect_node_ids(child, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn register_new_node_reveals(
|
||||
ids_before: &HashSet<String>,
|
||||
state: &EditorState,
|
||||
indicator_epoch: Option<u64>,
|
||||
reveal_started_ms: u64,
|
||||
) {
|
||||
let Some(epoch) = indicator_epoch else {
|
||||
return;
|
||||
};
|
||||
let mut stream = RevealStream {
|
||||
index: 0,
|
||||
next_start_ms: reveal_started_ms,
|
||||
};
|
||||
for node in state.active_children() {
|
||||
register_node_reveals(
|
||||
node,
|
||||
ids_before,
|
||||
epoch,
|
||||
reveal_started_ms,
|
||||
0,
|
||||
None,
|
||||
&mut stream,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
struct RevealStream {
|
||||
index: u64,
|
||||
next_start_ms: u64,
|
||||
}
|
||||
|
||||
fn register_node_reveals(
|
||||
node: &PenNode,
|
||||
ids_before: &HashSet<String>,
|
||||
epoch: u64,
|
||||
reveal_started_ms: u64,
|
||||
depth: u64,
|
||||
parent_reveal_start_ms: Option<u64>,
|
||||
stream: &mut RevealStream,
|
||||
) {
|
||||
let id = node.id_str();
|
||||
let mut own_reveal_start_ms = parent_reveal_start_ms;
|
||||
if !ids_before.contains(id) && should_reveal_node(node, depth) {
|
||||
let own_stream_index = stream.index;
|
||||
stream.index += 1;
|
||||
let base_start = reveal_started_ms
|
||||
+ op_editor_core::agent_indicators::reveal_offset_ms(depth, own_stream_index);
|
||||
let child_runway_start = parent_reveal_start_ms
|
||||
.map(|started_at| {
|
||||
started_at.saturating_add(op_editor_core::agent_indicators::REVEAL_CHILD_RUNWAY_MS)
|
||||
})
|
||||
.unwrap_or(reveal_started_ms);
|
||||
let started_at = base_start.max(child_runway_start).max(stream.next_start_ms);
|
||||
op_editor_core::agent_indicators::add_reveal(epoch, id, started_at);
|
||||
stream.next_start_ms =
|
||||
started_at.saturating_add(op_editor_core::agent_indicators::REVEAL_STAGGER_MS);
|
||||
own_reveal_start_ms = Some(started_at);
|
||||
}
|
||||
if let Some(children) = node.children() {
|
||||
for child in children {
|
||||
register_node_reveals(
|
||||
child,
|
||||
ids_before,
|
||||
epoch,
|
||||
reveal_started_ms,
|
||||
depth + 1,
|
||||
own_reveal_start_ms,
|
||||
stream,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_reveal_node(node: &PenNode, depth: u64) -> bool {
|
||||
depth == 0 || node_has_own_visual(node) || node_is_named_structure(node)
|
||||
}
|
||||
|
||||
fn node_has_own_visual(node: &PenNode) -> bool {
|
||||
match node {
|
||||
PenNode::Frame(n) => {
|
||||
container_has_own_visual(&n.container) || n.image_search_query.is_some()
|
||||
}
|
||||
PenNode::Group(n) => container_has_own_visual(&n.container),
|
||||
PenNode::Rectangle(n) => container_has_own_visual(&n.container),
|
||||
PenNode::Ref(_) => false,
|
||||
PenNode::Text(n) => match &n.content {
|
||||
TextContent::Plain(s) => !s.is_empty(),
|
||||
TextContent::Styled(segments) => !segments.is_empty(),
|
||||
},
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn container_has_own_visual(container: &ContainerProps) -> bool {
|
||||
container
|
||||
.fill
|
||||
.as_ref()
|
||||
.is_some_and(|fills| !fills.is_empty())
|
||||
|| container.stroke.is_some()
|
||||
|| container
|
||||
.effects
|
||||
.as_ref()
|
||||
.is_some_and(|effects| !effects.is_empty())
|
||||
}
|
||||
|
||||
fn node_is_named_structure(node: &PenNode) -> bool {
|
||||
if !node.is_container() {
|
||||
return false;
|
||||
}
|
||||
let base = node.base();
|
||||
base.role.as_deref().is_some_and(|role| !role.is_empty())
|
||||
|| base.name.as_deref().is_some_and(|name| !name.is_empty())
|
||||
}
|
||||
|
||||
fn reveal_now_millis() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis().min(u128::from(u64::MAX)) as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Extract `description` and `inputSchema` JSON string from `TOOL_SCHEMAS`
|
||||
/// for the given tool name. Returns `None` when no entry matches.
|
||||
///
|
||||
|
|
@ -285,6 +472,36 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_design_batch_design_registers_reveals_when_epoch_is_set() {
|
||||
use op_editor_core::agent_indicators;
|
||||
|
||||
agent_indicators::clear();
|
||||
let epoch = agent_indicators::begin();
|
||||
let mut state = EditorState::new();
|
||||
let (result, mutated) = execute_design_tool_with_reveals(
|
||||
&mut state,
|
||||
"batch_design",
|
||||
r#"{"operations":"root=I(null,{type:'frame',name:'Root',width:120,height:80})\ntitle=I(root,{type:'text',name:'Title',content:'Hello',width:80,height:20})"}"#,
|
||||
Some(epoch),
|
||||
);
|
||||
assert!(!result.is_error, "batch_design failed: {}", result.content);
|
||||
assert!(mutated, "batch_design must mutate the document");
|
||||
|
||||
let ids: Vec<String> = collect_active_node_ids(&state).into_iter().collect();
|
||||
assert!(ids.len() >= 2, "batch inserted a subtree, got {ids:?}");
|
||||
let snapshot = agent_indicators::snapshot();
|
||||
for id in ids {
|
||||
assert!(
|
||||
snapshot.reveals.contains_key(&id),
|
||||
"newly inserted node {id} should have a reveal: {:?}",
|
||||
snapshot.reveals
|
||||
);
|
||||
}
|
||||
agent_indicators::end_if_epoch(epoch);
|
||||
agent_indicators::clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_design_batch_design_attaches_per_batch_layout_feedback() {
|
||||
// A batch that lands an OVERFLOWING table (5×240 fixed columns in a
|
||||
|
|
|
|||
Loading…
Reference in a new issue