Agent chat: continue after creating or opening a design (#7526) (#9980)

* claim: #7526

* Agent chat: continue across agent-initiated design changes
This commit is contained in:
Colin Faulkingham
2026-10-31 11:12:00 +00:00
committed by GitHub
parent 60de59d5a9
commit 3eb76a8b3b
8 changed files with 335 additions and 9 deletions
+64
View File
@@ -320,6 +320,9 @@ impl Loading {
pub struct AgentChat {
/// The panel is shown (View menu, the application bar's chat button, Ctrl+Shift+A).
pub open: bool,
/// Only while dispatching this conversation's own admitted tool request. Window state:
/// creating/opening a design may carry the conversation, but a person's tab switch may not.
pub(crate) following_document: bool,
pub items: Vec<Item>,
pub input: String,
/// The viewer, open on one message's pictures.
@@ -444,6 +447,7 @@ impl AgentChat {
pub(crate) fn swap_document(&mut self, other: &mut Self) {
std::mem::swap(self, other);
std::mem::swap(&mut self.open, &mut other.open);
std::mem::swap(&mut self.following_document, &mut other.following_document);
std::mem::swap(&mut self.settings, &mut other.settings);
std::mem::swap(&mut self.control_starter, &mut other.control_starter);
std::mem::swap(&mut self.models_cache, &mut other.models_cache);
@@ -1052,6 +1056,66 @@ pub fn shutdown(app: &mut SolveApp) {
crate::documents::disconnect_background_chats(app, true);
}
/// Mark the origin only for this synchronous dispatch. Deferred work carries the same trusted
/// turn flag and enters here when it applies; JSON parameters cannot opt into continuation.
pub(crate) fn with_tool_request<T>(
app: &mut SolveApp,
active: Option<&Arc<std::sync::atomic::AtomicBool>>,
run: impl FnOnce(&mut SolveApp) -> T,
) -> T {
let own = active.is_some_and(|active| {
active.load(std::sync::atomic::Ordering::Relaxed) && app.agent_chat.turn.as_ref().is_some_and(|turn| Arc::ptr_eq(active, &turn.active))
});
// A batch may replace the design and then roll back to its checkpoint. Keep the turn's
// original undo boundary when that happens, including edits made before the batch.
let before = app.agent_chat.turn.as_ref().filter(|_| own).map(|t| (app.session.doc.clone(), t.start_doc.clone(), t.start_undo));
let previous = std::mem::replace(&mut app.agent_chat.following_document, own);
let result = run(app);
app.agent_chat.following_document = previous;
if let Some((doc, start_doc, start_undo)) = before
&& Arc::ptr_eq(&doc, &app.session.doc)
&& let Some(turn) = app.agent_chat.turn.as_mut()
&& active.is_some_and(|active| Arc::ptr_eq(active, &turn.active))
{
turn.start_doc = start_doc;
turn.start_undo = start_undo;
}
result
}
/// The agent's replacement succeeded. Keep its backend, transcript, queue, limits and validity
/// flag; subsequent edits undo in this design and picked geometry belongs to the old one.
pub(crate) fn continue_in_document(app: &mut SolveApp) {
if let Some(turn) = app.agent_chat.turn.as_mut() {
turn.start_doc = app.session.doc.clone();
turn.start_undo = app.session.undo.len();
}
app.agent_chat.picked.clear();
app.agent_chat.picked_context = Context::default();
app.agent_chat.selected_count = None;
}
/// A newly opened tab has no conversation to displace. Move the initiating chat into it,
/// grouping the previous design's completed edits before its session is parked.
pub(crate) fn follow_new_tab(app: &mut SolveApp, switch: impl FnOnce(&mut SolveApp)) {
if !app.agent_chat.following_document {
switch(app);
return;
}
if let Some(turn) = app.agent_chat.turn.as_ref() {
let steps =
merge_undo(&mut app.session.undo, &turn.start_doc, turn.start_undo, &trf("ui.agent_chat_undo_label", &[("message", &turn.label)]));
if steps > 0 {
app.agent_chat.push(Item::Changes { steps, doc: app.session.doc.clone(), undone: false });
}
}
let mut chat = AgentChat::default();
app.agent_chat.swap_document(&mut chat);
switch(app);
app.agent_chat.swap_document(&mut chat);
continue_in_document(app);
}
/// A tab switch stops this turn before its session leaves the app. Keep the backend's own
/// conversation, drafts and queue, but do not send that queue automatically on returning.
pub(crate) fn leave_document(app: &mut SolveApp) {
+197
View File
@@ -712,6 +712,203 @@ fn a_late_sign_in_retry_or_permission_cannot_restart_a_departed_turn() {
assert!(app.agent_chat.turn.is_none() && app.agent_chat.queued.is_none());
}
/// Exercise the chat endpoint's gate and the real dispatch paths, including the animated
/// pointer and asynchronous file loads. The scripted backend never needs a model account.
fn chat_tool(app: &mut SolveApp, tx: &Sender<ControlRequest>, ctx: &egui::Context, method: &str, params: Value) -> Value {
let reply = ask(tx, method, params);
pump_tools(app);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
app.drain_control(ctx);
app.now += 1.0;
crate::agent_cursor::step(app, ctx);
crate::file_load::tick(app, ctx);
app.poll_control_jobs(ctx);
match reply.try_recv() {
Ok(response) => return response,
Err(std::sync::mpsc::TryRecvError::Disconnected) => panic!("the chat's tool reply was dropped"),
Err(std::sync::mpsc::TryRecvError::Empty) => {}
}
assert!(std::time::Instant::now() < deadline, "the chat's tool did not finish");
std::thread::yield_now();
}
}
#[test]
fn an_agents_new_design_keeps_its_conversation_and_continues() {
// #7526: the old reset disconnected the backend inside its own file.new request.
for (method, params, animate) in [
("engine.execute", json!({"command": "file.new"}), false),
("engine.execute", json!({"command": "FILE.NEW"}), false),
("engine.execute", json!({"command": "file.new"}), true),
("engine.batch", json!({"commands": [{"command": "file.new"}, boxed(0.0)]}), false),
("engine.batch", json!({"commands": [{"command": "file.new"}, boxed(0.0)]}), true),
] {
let (mut app, script, ctx) = app_with_script();
let (tx, rx) = channel();
app.agent_chat.tools_rx = Some(rx);
app.preferences.ai_permissions = agents::Mode::All;
app.run("solid.box", boxed(0.0)["params"].clone()).unwrap();
send(&mut app, &ctx, "create another design".into());
let active = app.agent_chat.turn.as_ref().unwrap().active.clone();
app.ui.agent_cursor.show = animate;
crate::scenario::publish_handle("toolbar:file.new", egui::pos2(80.0, 100.0));
crate::scenario::publish_handle("toolbar:solid.box", egui::pos2(100.0, 100.0));
let response = chat_tool(&mut app, &tx, &ctx, method, params);
assert_eq!(response["ok"], true, "{response}");
assert!(app.agent_chat.backend.is_some(), "the agent must receive its own replacement's reply");
assert!(active.load(std::sync::atomic::Ordering::Relaxed));
assert!(app.agent_chat.items.iter().any(|i| matches!(i, Item::User { text, .. } if text == "create another design")));
assert!(!script.calls().iter().any(|call| matches!(call.as_str(), "cancel" | "new_session")));
let response = chat_tool(&mut app, &tx, &ctx, "engine.execute", boxed(30.0));
assert_eq!(response["ok"], true, "the same agent continues: {response}");
assert_eq!(app.agent_chat.turn.as_ref().unwrap().changes, 2, "a replacement does not reset the turn's change budget");
let count = if method == "engine.batch" { 2 } else { 1 };
assert_eq!(app.session.doc.features.len(), count);
ended(&script, "");
poll(&mut app, &ctx);
assert_eq!(app.session.undo.len(), 1, "only the new design's edits form this turn's undo step");
app.run("edit.undo", json!({})).unwrap();
assert!(app.session.doc.features.is_empty(), "undo must not resurrect the previous design");
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn an_agents_opened_design_keeps_its_conversation_and_undo_ownership() {
for blank in [true, false] {
let dir = std::env::temp_dir().join(format!("solidcraft-chat-open-{}-{blank}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("next.solidcraft");
let mut next = solidcraft_engine::Session::default();
next.execute("solid.box", &boxed(100.0)["params"]).unwrap();
next.execute("file.save_as", &json!({"path": path})).unwrap();
let (mut app, script, ctx) = app_with_script();
let (tx, rx) = channel();
app.agent_chat.tools_rx = Some(rx);
app.preferences.ai_permissions = agents::Mode::All;
if !blank {
app.run("solid.box", boxed(0.0)["params"].clone()).unwrap();
app.run("file.save_as", json!({"path": dir.join("first.solidcraft")})).unwrap();
}
send(&mut app, &ctx, "open another design and add a box".into());
let active = app.agent_chat.turn.as_ref().unwrap().active.clone();
if !blank {
assert_eq!(chat_tool(&mut app, &tx, &ctx, "engine.execute", boxed(20.0))["ok"], true);
assert_eq!(chat_tool(&mut app, &tx, &ctx, "engine.execute", boxed(40.0))["ok"], true);
}
let response =
chat_tool(&mut app, &tx, &ctx, "engine.execute", json!({"command": "doc.open", "params": {"path": path}, "allow_paths": [dir]}));
assert_eq!(response["ok"], true, "{response}");
assert!(app.agent_chat.backend.is_some(), "opening the file must carry the initiating chat");
assert!(active.load(std::sync::atomic::Ordering::Relaxed));
assert_eq!(app.docs.count(), if blank { 1 } else { 2 });
if !blank {
let response = chat_tool(
&mut app,
&tx,
&ctx,
"engine.execute",
json!({
"command": "doc.open", "params": {"path": dir.join("first.solidcraft")}, "allow_paths": [dir]
}),
);
assert_eq!(response["ok"], false, "another tab must keep its own conversation: {response}");
assert!(response["error"].as_str().unwrap().contains("already open in another tab"));
assert_eq!(app.docs.active, 1);
}
assert_eq!(chat_tool(&mut app, &tx, &ctx, "engine.execute", boxed(130.0))["ok"], true);
assert_eq!(chat_tool(&mut app, &tx, &ctx, "engine.execute", boxed(150.0))["ok"], true);
assert_eq!(app.session.doc.features.len(), 3);
ended(&script, "");
poll(&mut app, &ctx);
assert!(!script.calls().iter().any(|call| matches!(call.as_str(), "cancel" | "new_session")));
assert_eq!(app.session.undo.len(), 1);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc.features.len(), 1, "the opened design's existing body survives undo");
if !blank {
crate::documents::switch(&mut app, 0);
assert_eq!(app.session.doc.features.len(), 3);
assert_eq!(app.session.undo.len(), 2, "the first design keeps its own grouped agent edits");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc.features.len(), 1);
assert!(app.agent_chat.backend.is_none(), "the endpoint moved with the conversation");
crate::documents::switch(&mut app, 1);
assert!(app.agent_chat.backend.is_some());
}
let _ = std::fs::remove_dir_all(&dir);
}
}
#[test]
fn an_agents_failed_new_design_batch_restores_its_undo_boundary() {
let (mut app, script, ctx) = app_with_script();
let (tx, rx) = channel();
app.agent_chat.tools_rx = Some(rx);
app.preferences.ai_permissions = agents::Mode::All;
app.run("solid.box", boxed(0.0)["params"].clone()).unwrap();
send(&mut app, &ctx, "try another design".into());
assert_eq!(chat_tool(&mut app, &tx, &ctx, "engine.execute", boxed(20.0))["ok"], true);
let before = app.session.doc.clone();
let response = chat_tool(
&mut app,
&tx,
&ctx,
"engine.batch",
json!({"commands": [
{"command": "file.new"},
{"command": "sketch.line", "params": {"points": [[0, 0], [10, 10]]}}
]}),
);
assert_eq!(response["result"]["completed"], 1, "the new design ran before the failure: {response}");
assert_eq!(response["result"]["rolled_back"], true);
assert!(Arc::ptr_eq(&app.session.doc, &before));
assert!(app.agent_chat.backend.is_some());
assert_eq!(chat_tool(&mut app, &tx, &ctx, "engine.execute", boxed(40.0))["ok"], true);
ended(&script, "");
poll(&mut app, &ctx);
assert_eq!(app.session.undo.len(), 2, "edits before and after the failed batch share one turn");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc.features.len(), 1);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn an_agents_cancelled_open_cannot_replace_the_design_later() {
let dir = std::env::temp_dir().join(format!("solidcraft-chat-cancel-open-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("next.solidcraft");
solidcraft_engine::Session::default().execute("file.save_as", &json!({"path": path})).unwrap();
let (mut app, script, ctx) = app_with_script();
let (tx, rx) = channel();
app.agent_chat.tools_rx = Some(rx);
app.preferences.ai_permissions = agents::Mode::All;
app.run("solid.box", boxed(0.0)["params"].clone()).unwrap();
let original = app.session.doc.clone();
send(&mut app, &ctx, "open another design".into());
let (started_tx, started_rx) = channel();
let (release_tx, release_rx) = channel();
app.file_load.worker_gate = Some((started_tx, release_rx));
let reply = ask(&tx, "engine.execute", json!({"command": "doc.open", "params": {"path": path}, "allow_paths": [dir]}));
pump_tools(&mut app);
app.drain_control(&ctx);
started_rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
cancel(&mut app);
ended(&script, "cancelled");
poll(&mut app, &ctx);
release_tx.send(()).unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while !app.file_load.idle() && std::time::Instant::now() < deadline {
crate::file_load::tick(&mut app, &ctx);
std::thread::yield_now();
}
assert!(app.file_load.idle());
assert_eq!(reply.try_recv().unwrap()["ok"], false, "a stopped turn cannot finish its pending open");
assert!(Arc::ptr_eq(&app.session.doc, &original));
assert_eq!(app.docs.count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn programmatic_replacement_clears_the_conversation_and_invalidates_its_requests() {
for command in ["file.new", "FILE.NEW"] {
+2 -1
View File
@@ -452,7 +452,8 @@ fn finish(app: &mut SolveApp, ctx: &egui::Context) {
if follow && id == "sketch.create" {
app.agent.saved_view = Some(app.cam);
}
let (req, _rx) = crate::control::ControlRequest::new(job.method, params);
let (mut req, _rx) = crate::control::ControlRequest::new(job.method, params);
req.chat_turn = job.chat_turn;
// What was computed ahead while the pointer glided is found rather than computed again here.
let out = app.run_with_kept(job.ahead.map(|a| a.kept), |app| match crate::control::handle(app, ctx, &req) {
crate::control::Outcome::Done(v) => v,
+4
View File
@@ -465,6 +465,10 @@ pub fn view_png(app: &SolveApp, w: u32, h: u32) -> Result<Vec<u8>, String> {
}
pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
crate::agent_chat::with_tool_request(app, req.chat_turn.as_ref(), |app| handle_request(app, ctx, req))
}
fn handle_request(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
let level = match request_param_check(&req.method, &req.params) {
Ok(level) => level,
Err(e) => return err(e),
+10 -3
View File
@@ -315,6 +315,9 @@ pub(crate) fn reuse_path(app: &mut SolveApp, path: &str) -> Result<bool, String>
app.docs.slots.iter().position(|s| s.as_ref().is_some_and(|s| matches(&s.session)))
};
let Some(index) = index else { return Ok(false) };
if app.agent_chat.following_document && index != app.docs.active {
return Err("the design is already open in another tab; select that tab to continue in its own conversation".into());
}
if app.file_picker_busy() {
return Err("finish choosing a file before switching designs".into());
}
@@ -354,7 +357,7 @@ pub fn open_tab(app: &mut SolveApp, session: Session) -> Result<(), String> {
}
let at = app.docs.active + 1;
app.docs.slots.insert(at, Some(DocSlot::new(session)));
switch(app, at);
crate::agent_chat::follow_new_tab(app, |app| switch(app, at));
app.fit_view();
Ok(())
}
@@ -370,12 +373,12 @@ pub fn active_is_blank(app: &SolveApp) -> bool {
pub fn adopt(app: &mut SolveApp, session: Session) {
if active_is_blank(app) {
let rev = app.session.revision;
reset_chat(app);
crate::dialogs::cancel(app);
app.tool = None;
crate::design_properties::close_object(app);
crate::sketch_attachment_ui::clear(app);
app.session.replace_design(session);
reset_chat(app);
// The blank design's record of its links goes with it; the new one's start now.
app.docs.link_stamps.clear();
crate::render_workspace::reset_document(app);
@@ -493,8 +496,12 @@ pub fn close(app: &mut SolveApp, i: usize, force: bool) {
}
}
/// A replacement design starts a fresh conversation while retaining the window's chat services.
/// A replacement starts a fresh conversation, unless the current chat itself requested it.
pub(crate) fn reset_chat(app: &mut SolveApp) {
if app.agent_chat.following_document {
crate::agent_chat::continue_in_document(app);
return;
}
// A chat not asked anything yet (an agent connected at start, say) holds nothing of the old
// design: it starts over in place, and its agent stays connected.
if !app.agent_chat.items.iter().any(|i| matches!(i, crate::agent_chat::Item::User { .. })) {
+40 -4
View File
@@ -58,6 +58,8 @@ pub struct Request {
/// The saved design this request is for, when it must not run on another: one shown in its
/// place by the time the request starts drops it.
pub design: Option<String>,
/// The chat turn that requested the load. It must still be current when the file lands.
pub(crate) chat_turn: Option<Arc<std::sync::atomic::AtomicBool>>,
}
impl Request {
@@ -70,6 +72,7 @@ impl Request {
options: Default::default(),
allow_hosts: Vec::new(),
design: None,
chat_turn: None,
}
}
fn insert(&self) -> bool {
@@ -94,6 +97,10 @@ impl Request {
}
fn answer(&self, r: Result<Value, String>) {
if let Some(reply) = &self.reply {
if crate::control::chat_turn_cancelled(self.chat_turn.as_deref()) {
let _ = reply.send(crate::control::cancelled_chat_response());
return;
}
let _ = reply.send(match r {
Ok(v) => json!({"ok": true, "result": v}),
Err(e) => json!({"ok": false, "error": e}),
@@ -183,7 +190,7 @@ impl Running {
Arc::ptr_eq(&session.doc, &self.base) && session.revision == self.base_revision
}
fn cancelled(&self) -> bool {
self.progress.is_cancelled()
self.progress.is_cancelled() || crate::control::chat_turn_cancelled(self.request.chat_turn.as_deref())
}
}
@@ -241,6 +248,10 @@ pub fn begin(app: &mut SolveApp, path: &str, insert: bool) {
/// Queue a file operation; on the web (no threads) it runs now.
pub fn begin_request(app: &mut SolveApp, request: Request) -> Result<(), String> {
if crate::control::chat_turn_cancelled(request.chat_turn.as_deref()) {
request.answer(Err("the agent chat turn ended".into()));
return Ok(());
}
if app.file_load.idle() && reuse_open(app, &request) {
return Ok(());
}
@@ -320,7 +331,7 @@ fn reuse_open(app: &mut SolveApp, request: &Request) -> bool {
if !request.options.is_empty() {
return Ok(false);
}
crate::documents::reuse_path(app, &request.path)
crate::agent_chat::with_tool_request(app, request.chat_turn.as_ref(), |app| crate::documents::reuse_path(app, &request.path))
});
match reused {
Ok(false) => false,
@@ -371,7 +382,16 @@ pub fn control(app: &mut SolveApp, req: crate::control::ControlRequest) -> Optio
options.remove("path");
options.retain(|_, v| !v.is_null());
}
let request = Request { path: path.into(), kind, reply: Some(req.reply.clone()), scope, options, allow_hosts: Vec::new(), design: None };
let request = Request {
path: path.into(),
kind,
reply: Some(req.reply.clone()),
scope,
options,
allow_hosts: Vec::new(),
design: None,
chat_turn: req.chat_turn,
};
let _ = begin_request(app, request);
None
}
@@ -405,6 +425,10 @@ fn start(app: &mut SolveApp) {
return;
}
let Some(request) = app.file_load.queued.pop_front() else { return };
if crate::control::chat_turn_cancelled(request.chat_turn.as_deref()) {
request.answer(Err("the agent chat turn ended".into()));
return;
}
if reuse_open(app, &request) {
return;
}
@@ -865,6 +889,18 @@ fn compute(
}
pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
// Stop must release the waiting slot even if the worker has not reached its next check.
let chat_cancelled = app.file_load.running.as_ref().is_some_and(|r| crate::control::chat_turn_cancelled(r.request.chat_turn.as_deref()));
if chat_cancelled && let Some(running) = &app.file_load.running {
running.progress.cancel();
}
// As with FileLoad::cancel, a publishing export retains its worker until it reports back.
if chat_cancelled
&& app.file_load.running.as_ref().is_some_and(|r| r.request.kind != Kind::ExportDxf)
&& let Some(running) = app.file_load.running.take()
{
running.request.answer(Err("the agent chat turn ended".into()));
}
if let Some(running) = &mut app.file_load.running {
step_staged(running);
}
@@ -922,7 +958,7 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
app.session.accept_computed(made.session);
app.big_operation = true;
} else {
crate::documents::adopt(app, made.session);
crate::agent_chat::with_tool_request(app, request.chat_turn.as_ref(), |app| crate::documents::adopt(app, made.session));
if request.kind == Kind::Open {
app.home.add_recent(&request.path);
}
+7
View File
@@ -139,6 +139,13 @@ completed changes in that design's undo history. Returning restores the conversa
messages wait for **Send**. Closing a design closes its conversation. Conversations are kept
only while the tabs remain open.
When the agent itself creates a new design or opens a file, its conversation follows the new
design and the same turn continues. Its completed changes stay in each design's own undo
history. The original tab starts a fresh conversation if you return to it. Opening a file that
is already in another tab returns an error instead of displacing that tab's conversation;
select that tab yourself to continue there. Your own New, Open and tab switches still stop
the current turn. Stop also cancels a file the agent is still opening.
The input stays enabled during a turn. **Enter** queues the message: it shows in the conversation
as a **Queued** bubble that you can **Edit** or remove (×) until it is sent. Several queued
messages go as one prompt, in order, when the turn ends normally.
+11 -1
View File
@@ -187,9 +187,19 @@ parks that state alongside its `Session`; switching restores both together. Pane
placement, agent discovery, the latest sign-in evidence per agent, endpoint model cache,
settings dialogs and the desktop endpoint starter remain window state. A new tab gets a fresh conversation. Closing a tab drops its
backend; closing the last tab or replacing its design (including programmatic New, Open and
Recover commands or scripts) clears its conversation.
Recover commands or scripts from external callers) clears its conversation.
Conversation state is in memory only and does not change the design file format.
An admitted request from the current chat carries its turn's validity flag through direct
dispatch, cursor animation and asynchronous file loading. Only while that request applies,
`with_tool_request` marks its document changes as agent-initiated. New/Recover replacements
retain the chat and reset its undo boundary to the new design. Opening a new tab groups the
old design's completed changes, moves the conversation and its endpoint into the new tab,
and starts a new undo segment without resetting the turn's limits. A failed batch that
restores its original design also restores its original turn boundary. Opening an already
active file is unchanged; opening a file owned by another tab is refused, preserving both
conversations. The marker is never taken from JSON and does not persist between requests.
Leaving a tab cancels its running turn, declines outstanding approvals, and merges completed
changes into that design's undo history before swapping sessions. Its backend conversation
remains available when the tab is selected again. Pending messages stay with that tab and wait