diff --git a/apps/ios/Zeron/Core/Generated/zeron_core.swift b/apps/ios/Zeron/Core/Generated/zeron_core.swift index 2a750518a6..e2bfd0a2d9 100644 --- a/apps/ios/Zeron/Core/Generated/zeron_core.swift +++ b/apps/ios/Zeron/Core/Generated/zeron_core.swift @@ -7356,6 +7356,15 @@ public struct SessionRow: Equatable, Hashable { */ public var sendState: SendState? public var parentChatId: String? + /** + * The chat whose agent created this one (Zeron MCP): set on + * agent-spawned top-level chats, which list like any other session. + */ + public var spawnedByChatId: String? + /** + * The spawner's title for a "Spawned by …" line (`None`: unknown). + */ + public var spawnedByTitle: String? /** * 2 = chat2; 1 = legacy (not dialable). */ @@ -7394,6 +7403,13 @@ public struct SessionRow: Equatable, Hashable { /** * Oldest unadopted send from this device (open sessions only). */sendState: SendState?, parentChatId: String?, + /** + * The chat whose agent created this one (Zeron MCP): set on + * agent-spawned top-level chats, which list like any other session. + */spawnedByChatId: String?, + /** + * The spawner's title for a "Spawned by …" line (`None`: unknown). + */spawnedByTitle: String?, /** * 2 = chat2; 1 = legacy (not dialable). */roomGen: UInt32) { @@ -7426,6 +7442,8 @@ public struct SessionRow: Equatable, Hashable { self.pullRequest = pullRequest self.sendState = sendState self.parentChatId = parentChatId + self.spawnedByChatId = spawnedByChatId + self.spawnedByTitle = spawnedByTitle self.roomGen = roomGen } @@ -7474,6 +7492,8 @@ public struct FfiConverterTypeSessionRow: FfiConverterRustBuffer { pullRequest: FfiConverterOptionTypePullRequest.read(from: &buf), sendState: FfiConverterOptionTypeSendState.read(from: &buf), parentChatId: FfiConverterOptionString.read(from: &buf), + spawnedByChatId: FfiConverterOptionString.read(from: &buf), + spawnedByTitle: FfiConverterOptionString.read(from: &buf), roomGen: FfiConverterUInt32.read(from: &buf) ) } @@ -7508,6 +7528,8 @@ public struct FfiConverterTypeSessionRow: FfiConverterRustBuffer { FfiConverterOptionTypePullRequest.write(value.pullRequest, into: &buf) FfiConverterOptionTypeSendState.write(value.sendState, into: &buf) FfiConverterOptionString.write(value.parentChatId, into: &buf) + FfiConverterOptionString.write(value.spawnedByChatId, into: &buf) + FfiConverterOptionString.write(value.spawnedByTitle, into: &buf) FfiConverterUInt32.write(value.roomGen, into: &buf) } } diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 3cfdd9dfea..5f8918bfc9 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -759,6 +759,7 @@ impl Client { last_seen_at: None, room_gen: Some(2), parent_chat_id: None, + spawned_by_chat_id: None, }; let id = chat.id.clone(); self.inner.registry_write(|doc| doc.upsert_chat(&chat))?; diff --git a/crates/client/src/demo/fixtures.rs b/crates/client/src/demo/fixtures.rs index bb13e70152..2491f0af06 100644 --- a/crates/client/src/demo/fixtures.rs +++ b/crates/client/src/demo/fixtures.rs @@ -409,6 +409,7 @@ pub(crate) fn seed( last_seen_at: Some(ms(if demo.seen { last } else { last - MIN })), room_gen: Some(2), parent_chat_id: demo.parent.map(str::to_owned), + spawned_by_chat_id: None, }; doc.upsert_chat(&chat)?; if let Some(status) = demo.status { diff --git a/crates/client/src/demo/transcripts.rs b/crates/client/src/demo/transcripts.rs index e0f0d5d36e..cbd905f069 100644 --- a/crates/client/src/demo/transcripts.rs +++ b/crates/client/src/demo/transcripts.rs @@ -38,6 +38,7 @@ pub(crate) fn tool(id: &str, call: ToolCall, is_error: bool, output: Option<&str subagent_ref: None, subagent_status: None, subagent_tail: None, + created_chat_ids: Vec::new(), } } diff --git a/crates/client/src/workspace/view.rs b/crates/client/src/workspace/view.rs index e7c7117ca2..2c480d63d5 100644 --- a/crates/client/src/workspace/view.rs +++ b/crates/client/src/workspace/view.rs @@ -100,6 +100,12 @@ pub struct SessionRow { /// Oldest unadopted send from this device, if any. pub send_state: Option, pub parent_chat_id: Option, + /// Provenance: the chat whose agent created this one (Zeron MCP). Set on + /// agent-spawned top-level chats (listed normally) and side chats alike. + pub spawned_by_chat_id: Option, + /// The spawner's display title, for a "Spawned by …" line; `None` when + /// the spawner row is gone or not synced yet. + pub spawned_by_title: Option, /// Sync room generation (2 = chat2; 1 = legacy, not dialable). pub room_gen: u32, } @@ -406,6 +412,8 @@ fn hash_row(row: &SessionRow) -> u64 { } row.send_state.hash(&mut h); row.parent_chat_id.hash(&mut h); + row.spawned_by_chat_id.hash(&mut h); + row.spawned_by_title.hash(&mut h); row.room_gen.hash(&mut h); h.finish() } @@ -416,6 +424,7 @@ struct RowContext<'a> { sessions: HashMap<&'a str, &'a Session>, pinned: HashSet<&'a str>, section_of: HashMap<&'a str, &'a str>, + chats: HashMap<&'a str, &'a Chat>, } fn build_row(chat: &Chat, rc: &RowContext<'_>, cx: &DeriveContext<'_>) -> Arc { @@ -507,6 +516,20 @@ fn build_row(chat: &Chat, rc: &RowContext<'_>, cx: &DeriveContext<'_>) -> Arc, spawned_by: Option<&str>) -> Chat { + Chat { + id: id.into(), + device_id: "laptop".into(), + title: Some(title.into()), + archived: false, + cwd: None, + branch: None, + checkout_id: None, + source_context: None, + config: None, + last_message_preview: None, + last_message_at: None, + created_at: Utc::now(), + harness_session_id: None, + harness_session_cwd: None, + space_id: None, + last_seen_at: None, + room_gen: Some(2), + parent_chat_id: parent.map(str::to_owned), + spawned_by_chat_id: spawned_by.map(str::to_owned), + } + } + + /// Agent-spawned top-level chats list on the phone's front page like any + /// session, carrying who spawned them; spawned side chats stay children. + #[test] + fn spawned_top_level_chats_list_normally_with_their_spawner() { + let state = WorkspaceState { + devices: vec![], + spaces: vec![], + chats: vec![ + chat("coord", "Coordinator", None, None), + chat("worker", "Train on the GPU box", None, Some("coord")), + chat("side", "Quick check", Some("coord"), Some("coord")), + chat("orphan", "Spawner deleted", None, Some("gone")), + ], + sessions: vec![], + }; + let presence = HashMap::new(); + let send_states = HashMap::new(); + let snapshot = derive( + &state, + None, + &DeriveContext { + self_device_id: "laptop", + now: Utc::now(), + presence: &presence, + change_requests: &[], + send_states: &send_states, + synced: true, + previous: None, + }, + ); + let listed: HashSet<&str> = snapshot + .projectless + .iter() + .map(|row| row.id.as_str()) + .collect(); + assert_eq!(listed, HashSet::from(["coord", "worker", "orphan"])); + let worker = snapshot.session("worker").unwrap(); + assert_eq!(worker.spawned_by_chat_id.as_deref(), Some("coord")); + assert_eq!(worker.spawned_by_title.as_deref(), Some("Coordinator")); + let orphan = snapshot.session("orphan").unwrap(); + assert_eq!(orphan.spawned_by_title, None, "unknown spawner, no title"); + assert_eq!(snapshot.session("coord").unwrap().spawned_by_chat_id, None); + let children: Vec<&str> = snapshot + .children("coord") + .iter() + .map(|row| row.id.as_str()) + .collect(); + assert_eq!(children, ["side"]); + } } diff --git a/crates/client/tests/live.rs b/crates/client/tests/live.rs index 27fff96daa..93e0353782 100644 --- a/crates/client/tests/live.rs +++ b/crates/client/tests/live.rs @@ -92,6 +92,7 @@ fn host_rows(now: chrono::DateTime) -> (Device, Space, Chat) { last_seen_at: Some(now), room_gen: Some(2), parent_chat_id: None, + spawned_by_chat_id: None, }; (device, space, chat) } diff --git a/crates/doc/src/parts.rs b/crates/doc/src/parts.rs index 871ff83ac5..0fac2a049e 100644 --- a/crates/doc/src/parts.rs +++ b/crates/doc/src/parts.rs @@ -191,6 +191,12 @@ pub enum MessagePart { /// its tagged text deltas (capped; display-only). #[serde(default, skip_serializing_if = "Option::is_none")] subagent_tail: Option, + /// The chats a Zeron MCP `create_chat`/`create_chats` call created, + /// read from its result by the fold — the one slice of that output + /// the doc keeps, so every device can link the call to its chats + /// (additive; empty for every other tool and for older writers). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + created_chat_ids: Vec, }, #[serde(rename_all = "camelCase")] Input { @@ -357,6 +363,7 @@ pub fn fold_event_into_parts(out: &mut Vec, event: &AgentEvent) { subagent_ref: None, subagent_status: None, subagent_tail: None, + created_chat_ids: Vec::new(), }); } } @@ -375,12 +382,25 @@ pub fn fold_event_into_parts(out: &mut Vec, event: &AgentEvent) { diff: diff_slot, output_bytes, diff_stats, + call, + created_chat_ids, .. } = p && pid == id { *e = *is_error; *resolved = true; + // A Zeron create call's result names the chats it made: + // keep just their ids (the output itself stays out). + if !*is_error && zeron_proto::created_chats::create_chat_op(call).is_some() { + *created_chat_ids = output + .as_deref() + .map(zeron_proto::created_chats::parse_created_chats) + .unwrap_or_default() + .into_iter() + .map(|chat| chat.chat_id) + .collect(); + } // Tool OUTPUTS never enter the doc (2026-08-10 product // call: chips are one-liners — name + call info — like // pre-output builds; the R2 sidecar is parked with them, @@ -985,6 +1005,7 @@ mod tests { subagent_ref: None, subagent_status: None, subagent_tail: None, + created_chat_ids: Vec::new(), }, ]; let chunks = split_parts(&parts); @@ -1070,6 +1091,56 @@ mod tests { assert_eq!((stat.additions, stat.deletions), (2, 0)); } + #[test] + fn fold_keeps_only_the_chat_ids_a_zeron_create_call_made() { + let fold = |call: ToolCall, is_error: bool, output: &str| { + let mut parts = Vec::new(); + fold_event_into_parts(&mut parts, &AgentEvent::ToolCall { id: "t".into(), call }); + fold_event_into_parts( + &mut parts, + &AgentEvent::ToolResult { + id: "t".into(), + is_error, + output: Some(output.into()), + diff: None, + }, + ); + match parts.remove(0) { + MessagePart::Tool { + created_chat_ids, + output, + .. + } => { + assert_eq!(output, None); + created_chat_ids + } + other => panic!("{other:?}"), + } + }; + let zeron = |tool: &str| ToolCall::Mcp { + server: "zeron".into(), + tool: tool.into(), + input: None, + }; + let single = r#"{"chatId":"w1","kind":"chat","deviceId":"gpu"}"#; + assert_eq!(fold(zeron("create_chat"), false, single), ["w1"]); + // OpenCode's name for the same tool. + let opencode = ToolCall::Unknown { + name: "zeron_create_chat".into(), + input: None, + }; + assert_eq!(fold(opencode, false, single), ["w1"]); + // Failed calls, other tools and other servers keep nothing. + assert!(fold(zeron("create_chat"), true, single).is_empty()); + assert!(fold(zeron("list_chats"), false, single).is_empty()); + let github = ToolCall::Mcp { + server: "github".into(), + tool: "create_chat".into(), + input: None, + }; + assert!(fold(github, false, single).is_empty()); + } + #[test] fn fold_strips_output_to_summary_and_diff_to_stats() { let mut parts = Vec::new(); diff --git a/crates/doc/src/rebuild.rs b/crates/doc/src/rebuild.rs index 233b512e5a..3d94a2a1c2 100644 --- a/crates/doc/src/rebuild.rs +++ b/crates/doc/src/rebuild.rs @@ -183,6 +183,7 @@ mod tests { subagent_ref: None, subagent_status: None, subagent_tail: None, + created_chat_ids: Vec::new(), }, ], created_at: 5, diff --git a/crates/doc/src/registry.rs b/crates/doc/src/registry.rs index 255427e0ac..0903fcde0a 100644 --- a/crates/doc/src/registry.rs +++ b/crates/doc/src/registry.rs @@ -916,6 +916,10 @@ impl RegistryDoc { chat.room_gen.map(|g| json!(g)).unwrap_or(Value::Null), ), ("parentChatId", opt_str(chat.parent_chat_id.as_deref())), + ( + "spawnedByChatId", + opt_str(chat.spawned_by_chat_id.as_deref()), + ), ]); self.write(KIND_CHATS, &chat.id.clone(), OpKind::Upsert, set); Ok(()) @@ -1336,6 +1340,10 @@ impl RegistryDoc { ("parentChatId", opt_str(chat.parent_chat_id.as_deref())), ("lastSeenAt", opt_ms(chat.last_seen_at)), ("parentChatId", opt_str(chat.parent_chat_id.as_deref())), + ( + "spawnedByChatId", + opt_str(chat.spawned_by_chat_id.as_deref()), + ), ]), ); } diff --git a/crates/doc/src/registry/tests.rs b/crates/doc/src/registry/tests.rs index 2bef1ada73..7e33800eac 100644 --- a/crates/doc/src/registry/tests.rs +++ b/crates/doc/src/registry/tests.rs @@ -273,6 +273,7 @@ fn chat(id: &str, device_id: &str) -> Chat { harness_session_id: None, harness_session_cwd: None, parent_chat_id: Some("parent-chat".into()), + spawned_by_chat_id: None, space_id: None, last_seen_at: None, room_gen: None, @@ -1516,3 +1517,46 @@ fn side_chat_origin_syncs_and_survives_updates_and_restart() { Some("main") ); } + +#[test] +fn spawned_top_level_chat_keeps_provenance_across_devices_updates_and_restart() { + let mut a = RegistryDoc::new("dev-a"); + let mut b = RegistryDoc::new("dev-b"); + let mut worker = chat("worker", "dev-b"); + worker.parent_chat_id = None; + worker.spawned_by_chat_id = Some("coordinator".into()); + a.upsert_chat(&worker).unwrap(); + let mut server = HashMap::new(); + let mut seq = 0; + server_round(&mut server, &mut seq, &mut [&mut a, &mut b]); + assert_eq!(b.chat("worker").unwrap(), Some(worker.clone())); + // A rename and an archive from the other device leave provenance alone. + b.rename_chat("worker", "Train on the GPU box").unwrap(); + b.set_chat_archived("worker", true).unwrap(); + server_round(&mut server, &mut seq, &mut [&mut a, &mut b]); + let synced = a.chat("worker").unwrap().unwrap(); + assert_eq!(synced.spawned_by_chat_id.as_deref(), Some("coordinator")); + assert_eq!(synced.parent_chat_id, None, "top-level: no placement link"); + let restored = RegistryDoc::from_bytes(&a.to_bytes().unwrap(), "dev-a").unwrap(); + assert_eq!( + restored + .chat("worker") + .unwrap() + .unwrap() + .spawned_by_chat_id + .as_deref(), + Some("coordinator") + ); +} + +#[test] +fn rows_without_provenance_read_as_user_created() { + // An older engine's upsert never writes `spawnedByChatId`. + let mut a = RegistryDoc::new("dev-a"); + let mut plain = chat("plain", "dev-a"); + plain.parent_chat_id = None; + a.upsert_chat(&plain).unwrap(); + let read = a.chat("plain").unwrap().unwrap(); + assert_eq!(read.spawned_by_chat_id, None); + assert_eq!(read.parent_chat_id, None); +} diff --git a/crates/doc/src/schema.rs b/crates/doc/src/schema.rs index 8dc8845809..3f32a9555f 100644 --- a/crates/doc/src/schema.rs +++ b/crates/doc/src/schema.rs @@ -126,6 +126,9 @@ struct DocPartJson { /// One-line live tail of the subagent's output (additive). #[serde(default, skip_serializing_if = "Option::is_none")] subagent_tail: Option, + /// Chats a Zeron `create_chat(s)` call created (additive). + #[serde(default, skip_serializing_if = "Option::is_none")] + created_chat_ids: Option>, } /// App parts → doc part json (mirror of `toDocParts`). @@ -170,6 +173,7 @@ fn to_doc_part(part: &MessagePart) -> Result { subagent_ref, subagent_status, subagent_tail, + created_chat_ids, } => DocPartJson { id: id.clone(), kind: "tool".into(), @@ -193,6 +197,7 @@ fn to_doc_part(part: &MessagePart) -> Result { .to_owned() }), subagent_tail: subagent_tail.clone(), + created_chat_ids: (!created_chat_ids.is_empty()).then(|| created_chat_ids.clone()), ..Default::default() }, MessagePart::Input { @@ -251,6 +256,7 @@ fn from_doc_part(p: DocPartJson) -> MessagePart { _ => None, }), subagent_tail: p.subagent_tail, + created_chat_ids: p.created_chat_ids.unwrap_or_default(), }, None => MessagePart::Text { id: p.id, @@ -900,6 +906,9 @@ fn push_part(parts: &LoroList, part: &MessagePart) -> Result<(), DocError> { if let Some(subagent_tail) = &doc_part.subagent_tail { map.insert("subagentTail", subagent_tail.as_str())?; } + if let Some(ids) = &doc_part.created_chat_ids { + map.insert("createdChatIds", loro_value_from_json(&serde_json::json!(ids)))?; + } Ok(()) } @@ -1064,6 +1073,7 @@ fn salvage_part(part: &serde_json::Value, entry_id: &str, ix: usize) -> Option Result<(), DocError> if let Some(subagent_tail) = &doc_part.subagent_tail { map.insert("subagentTail", subagent_tail.as_str())?; } + if let Some(ids) = &doc_part.created_chat_ids { + map.insert("createdChatIds", loro_value_from_json(&serde_json::json!(ids)))?; + } if let Some(text) = &doc_part.text { // Defensive path only — the fold never rewrites earlier text. if let Some(loro::ValueOrContainer::Container(loro::Container::Text(t))) = map.get("text") { @@ -1576,6 +1589,7 @@ mod tests { subagent_ref: None, subagent_status: None, subagent_tail: None, + created_chat_ids: Vec::new(), }; w.sync(std::slice::from_ref(&part)).unwrap(); if let MessagePart::Tool { @@ -1606,6 +1620,61 @@ mod tests { } } + /// A Zeron create call's kept chat ids survive the streaming writer's + /// in-place resolve and a snapshot round trip, like the subagent keys. + #[test] + fn created_chat_ids_ride_the_segment_writer_and_snapshots() { + let doc = SessionDoc::init("c1").unwrap(); + let mut w = SegmentWriter::begin(&doc, "e1", "dev", 1).unwrap(); + let mut parts = Vec::new(); + crate::fold_event_into_parts( + &mut parts, + &zeron_proto::AgentEvent::ToolCall { + id: "t".into(), + call: zeron_proto::ToolCall::Mcp { + server: "zeron".into(), + tool: "create_chats".into(), + input: None, + }, + }, + ); + w.sync(&parts).unwrap(); + crate::fold_event_into_parts( + &mut parts, + &zeron_proto::AgentEvent::ToolResult { + id: "t".into(), + is_error: false, + output: Some( + serde_json::json!({ "results": [ + { "index": 0, "isError": false, "result": { "chatId": "w1", "kind": "chat" } }, + { "index": 1, "isError": false, "result": { "chatId": "s1", "kind": "side" } } + ]}) + .to_string(), + ), + diff: None, + }, + ); + w.sync(&parts).unwrap(); + let other = LoroDoc::new(); + other.import(&doc.export_snapshot().unwrap()).unwrap(); + let restored = SessionDoc::from_doc(other); + for doc in [&doc, &restored] { + match &doc.read_entries().unwrap()[0].parts[0] { + MessagePart::Tool { + created_chat_ids, + output, + resolved, + .. + } => { + assert!(*resolved); + assert_eq!(created_chat_ids, &["w1", "s1"]); + assert_eq!(output, &None, "the output itself stays out"); + } + other => panic!("{other:?}"), + } + } + } + #[test] fn update_subagent_chip_refuses_non_spawn_parts() { // The genus gate at the doc boundary: whatever id a driver keys its @@ -1628,6 +1697,7 @@ mod tests { subagent_ref: None, subagent_status: None, subagent_tail: None, + created_chat_ids: Vec::new(), }; let parts = vec![ tool( @@ -1963,6 +2033,7 @@ mod tests { subagent_ref: None, subagent_status: None, subagent_tail: None, + created_chat_ids: Vec::new(), }], created_at: 1, device_id: "dev-a".into(), diff --git a/crates/doc/src/workspace.rs b/crates/doc/src/workspace.rs index 59c89d2bd8..9bf0fe1542 100644 --- a/crates/doc/src/workspace.rs +++ b/crates/doc/src/workspace.rs @@ -286,6 +286,7 @@ impl WorkspaceDoc { set_opt_str(&row, "parentChatId", chat.parent_chat_id.as_deref())?; set_opt_ms(&row, "lastSeenAt", chat.last_seen_at)?; set_opt_str(&row, "parentChatId", chat.parent_chat_id.as_deref())?; + set_opt_str(&row, "spawnedByChatId", chat.spawned_by_chat_id.as_deref())?; self.doc.commit(); Ok(()) } @@ -716,6 +717,8 @@ pub(crate) struct RawChat { room_gen: Option, #[serde(default)] parent_chat_id: Option, + #[serde(default)] + spawned_by_chat_id: Option, } /// Decode a chat row's `config` leniently: unknown enum values (a newer @@ -757,6 +760,7 @@ impl From for Chat { last_seen_at: raw.last_seen_at.map(dt), room_gen: raw.room_gen, parent_chat_id: raw.parent_chat_id, + spawned_by_chat_id: raw.spawned_by_chat_id, } } } @@ -863,6 +867,7 @@ mod tests { harness_session_id: None, harness_session_cwd: None, parent_chat_id: Some("parent-chat".into()), + spawned_by_chat_id: Some("spawner-chat".into()), space_id: None, last_seen_at: None, room_gen: None, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2f4c74b653..03ada5b049 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -71,7 +71,8 @@ pub use titles::TitleGenerator; pub use uploads::{AttachmentChunk, Uploads}; pub use workspace_files::WorkspaceFiles; pub use workspace_host::{ - DEFAULT_ORG_ID, DEFAULT_USER_ID, WORKSPACE_DOC_ID, WorkspaceHost, WorkspaceHostConfig, + ChatLinks, DEFAULT_ORG_ID, DEFAULT_USER_ID, WORKSPACE_DOC_ID, WorkspaceHost, + WorkspaceHostConfig, }; pub(crate) const LEGACY_UNKNOWN_DEVICE_NAME: &str = "unknown-device"; diff --git a/crates/engine/src/rpc.rs b/crates/engine/src/rpc.rs index 2ca1fce168..7710ec37ac 100644 --- a/crates/engine/src/rpc.rs +++ b/crates/engine/src/rpc.rs @@ -526,10 +526,15 @@ enum MutateParams { /// Cwd override (isolated-worktree path); default = the space's folder. #[serde(default)] cwd: Option, - /// The chat whose agent is creating this one (Zeron MCP); recorded - /// on the row as `parentChatId` for orchestration trees. + /// Placement: the chat this one hangs off as a side chat (Zeron + /// MCP `kind: side`); recorded on the row as `parentChatId`. #[serde(default)] parent_chat_id: Option, + /// Provenance: the chat whose agent is creating this one (Zeron + /// MCP, either kind); recorded as `spawnedByChatId`. Additive — an + /// older engine ignores it and the chat simply reads as user-made. + #[serde(default)] + spawned_by_chat_id: Option, }, /// Create a space (device + folder pair). Idempotent by id; a live /// duplicate `(deviceId, path)` no-ops. `gitDetected` is seeded from the @@ -1099,15 +1104,19 @@ impl EngineRpc { branch, cwd, parent_chat_id, + spawned_by_chat_id, } => { self.workspace - .create_chat_with_parent( + .create_chat_linked( &chat_id, space_id.as_deref(), device_id.as_deref(), config, cwd, - parent_chat_id, + crate::ChatLinks { + parent_chat_id, + spawned_by_chat_id, + }, ) .map_err(failed)?; if let Some(branch) = branch.as_deref().filter(|b| !b.is_empty()) { @@ -1906,6 +1915,8 @@ impl RpcService for EngineRpc { let mut chat = source.clone(); chat.id = p.chat_id; chat.parent_chat_id = Some(parent_chat_id); + // A fork is the user's own side chat, whoever made the source. + chat.spawned_by_chat_id = None; chat.title = None; // First side-chat turn receives its own generated title. chat.archived = false; chat.created_at = chrono::Utc::now(); diff --git a/crates/engine/src/sessions.rs b/crates/engine/src/sessions.rs index fb342c8c3e..d04dbde359 100644 --- a/crates/engine/src/sessions.rs +++ b/crates/engine/src/sessions.rs @@ -1632,6 +1632,7 @@ fn render_parts(parts: &[MessagePart]) -> Vec { subagent_ref, subagent_status, subagent_tail, + created_chat_ids, } => MessagePart::Tool { id: id.clone(), call: sanitize_tool_call(call), @@ -1650,6 +1651,8 @@ fn render_parts(parts: &[MessagePart]) -> Vec { subagent_ref: subagent_ref.clone(), subagent_status: *subagent_status, subagent_tail: subagent_tail.clone(), + // The ids a Zeron create call made (never its output). + created_chat_ids: created_chat_ids.clone(), }, other => other.clone(), }) diff --git a/crates/engine/src/workspace_host.rs b/crates/engine/src/workspace_host.rs index df4329a3dd..442700f434 100644 --- a/crates/engine/src/workspace_host.rs +++ b/crates/engine/src/workspace_host.rs @@ -190,6 +190,17 @@ fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { mutex.lock().unwrap_or_else(PoisonError::into_inner) } +/// The two orchestration links a new chat can carry (see +/// [`WorkspaceHost::create_chat_linked`]). Both default to none: a chat the +/// user created. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ChatLinks { + /// Placement: a side chat under this chat (hidden from the sidebar). + pub parent_chat_id: Option, + /// Provenance: the chat whose agent created this one. + pub spawned_by_chat_id: Option, +} + #[derive(Clone)] pub struct WorkspaceHost { inner: Arc, @@ -906,9 +917,58 @@ impl WorkspaceHost { cwd: Option, parent_chat_id: Option, ) -> Result<(), EngineError> { + self.create_chat_linked( + chat_id, + space_id, + device_id, + config, + cwd, + ChatLinks { + parent_chat_id, + spawned_by_chat_id: None, + }, + ) + } + + /// [`create_chat`](Self::create_chat) with both orchestration links: + /// placement (`parentChatId`: a side chat under that chat) and provenance + /// (`spawnedByChatId`: whose agent created it). A top-level agent-spawned + /// chat carries only provenance. Provenance is depth-guarded here as a + /// backstop to the MCP server's own check ([`zeron_proto::MAX_SPAWN_DEPTH`]), + /// so no client can grow an unbounded spawn chain. + pub fn create_chat_linked( + &self, + chat_id: &str, + space_id: Option<&str>, + device_id: Option<&str>, + config: Option, + cwd: Option, + links: ChatLinks, + ) -> Result<(), EngineError> { + let ChatLinks { + parent_chat_id, + spawned_by_chat_id, + } = links; if self.read(|doc| doc.chat(chat_id))?.is_some() { return Ok(()); // idempotent: optimistic client retries never duplicate } + let spawned_by_chat_id = spawned_by_chat_id.filter(|p| !p.trim().is_empty()); + if let Some(spawner) = spawned_by_chat_id.as_deref() { + let chats = self.read(|doc| doc.read_chats())?; + let depth = zeron_proto::spawn_depth(spawner, |id| { + chats + .iter() + .find(|c| c.id == id) + .and_then(|c| c.spawned_by_chat_id.clone()) + }); + if depth.is_none_or(|d| d >= zeron_proto::MAX_SPAWN_DEPTH) { + return Err(EngineError::Other(format!( + "chat {spawner} is {} agent spawns deep; chats at depth {} cannot create chats", + depth.map_or_else(|| "too many".to_owned(), |d| d.to_string()), + zeron_proto::MAX_SPAWN_DEPTH + ))); + } + } let space = match space_id { Some(space_id) => match self.read(|doc| doc.space(space_id))? { Some(space) => Some(space), @@ -953,6 +1013,7 @@ impl WorkspaceHost { space_id: space.as_ref().map(|s| s.id.clone()), last_seen_at: None, parent_chat_id: parent_chat_id.filter(|p| !p.trim().is_empty()), + spawned_by_chat_id, }) })?; Ok(()) diff --git a/crates/engine/tests/side_chats.rs b/crates/engine/tests/side_chats.rs index 1d4db08b84..11091e2ccd 100644 --- a/crates/engine/tests/side_chats.rs +++ b/crates/engine/tests/side_chats.rs @@ -868,3 +868,87 @@ async fn orphaned_history_steer_still_owes_the_history() { ); core.shutdown().await; } + +/// `Mutate createChat` records both orchestration links independently: a +/// side chat carries placement + provenance, an agent-spawned top-level chat +/// provenance only (so it lists in the sidebar). Provenance is depth-guarded +/// as a backstop to the MCP server, and a fork (the user's own side chat) +/// never inherits its source's provenance. +#[tokio::test] +async fn create_chat_records_provenance_and_guards_spawn_depth() { + let dir = tempfile::tempdir().unwrap(); + let core = EngineCore::assemble( + dir.path(), + Arc::new(HarnessRegistry::new()), + HarnessId::Mock, + None, + ) + .unwrap(); + let client = zeron_rpc::memory_client(core.rpc_service()); + let create = |id: &str, parent: Option<&str>, by: Option<&str>| { + let mut params = serde_json::json!({ + "op": "createChat", "chatId": id, "deviceId": core.device_id, + }); + if let Some(parent) = parent { + params["parentChatId"] = parent.into(); + } + if let Some(by) = by { + params["spawnedByChatId"] = by.into(); + } + let client = &client; + async move { client.call(methods::MUTATE, params).await } + }; + create("coord", None, None).await.unwrap(); + create("side", Some("coord"), Some("coord")).await.unwrap(); + create("w1", None, Some("coord")).await.unwrap(); + create("w2", None, Some("w1")).await.unwrap(); + create("w3", None, Some("w2")).await.unwrap(); + + let chat = |id: &str| core.workspace.chat(id).unwrap().unwrap(); + assert_eq!(chat("coord").spawned_by_chat_id, None); + assert_eq!(chat("side").parent_chat_id.as_deref(), Some("coord")); + assert_eq!(chat("side").spawned_by_chat_id.as_deref(), Some("coord")); + assert_eq!(chat("w1").parent_chat_id, None, "top-level: no placement"); + assert_eq!(chat("w1").spawned_by_chat_id.as_deref(), Some("coord")); + + // w3 sits at MAX_SPAWN_DEPTH: nothing more may be spawned from it. + assert_eq!(zeron_proto::MAX_SPAWN_DEPTH, 3); + let err = create("w4", None, Some("w3")).await.unwrap_err().to_string(); + assert!(err.contains("cannot create chats"), "{err}"); + assert!(core.workspace.chat("w4").unwrap().is_none()); + + // Every device's chat list carries the field (serde-additive on the wire). + let listed = client + .subscribe_scoped(methods::WATCH_CHATS, serde_json::json!({})) + .await + .unwrap() + .recv() + .await + .unwrap(); + let w1 = listed + .as_array() + .unwrap() + .iter() + .find(|c| c["id"] == "w1") + .unwrap(); + assert_eq!(w1["spawnedByChatId"], "coord"); + assert!(w1.get("parentChatId").is_none()); + + // The user's own fork of a spawned chat is not agent-spawned. + let source = core.doc_host.open("w1").unwrap(); + for (id, role) in [("u1", MessageRole::User), ("a1", MessageRole::Assistant)] { + source + .doc() + .push_message(&message(id, role, "hi", MessageStatus::Complete)) + .unwrap(); + } + let fork = client + .call_as::( + methods::FORK_SIDE_CHAT, + serde_json::json!({ "chatId": "fork", "sourceChatId": "w1" }), + ) + .await + .unwrap(); + assert_eq!(fork.parent_chat_id.as_deref(), Some("w1")); + assert_eq!(fork.spawned_by_chat_id, None); +} diff --git a/crates/engine/tests/workspace_sync.rs b/crates/engine/tests/workspace_sync.rs index 56d3183b71..4f2876c27a 100644 --- a/crates/engine/tests/workspace_sync.rs +++ b/crates/engine/tests/workspace_sync.rs @@ -713,6 +713,7 @@ async fn legacy_workspace_doc_migrates_instantly_on_first_boot() { harness_session_id: Some("hs-9".into()), room_gen: None, parent_chat_id: None, + spawned_by_chat_id: None, harness_session_cwd: Some("/tmp/legacy".into()), space_id: Some("space-legacy".into()), last_seen_at: Some(now), diff --git a/crates/mcp/examples/agent_chats_e2e.rs b/crates/mcp/examples/agent_chats_e2e.rs new file mode 100644 index 0000000000..4ea1c20dee --- /dev/null +++ b/crates/mcp/examples/agent_chats_e2e.rs @@ -0,0 +1,500 @@ +//! Two-device e2e for agent-spawned chats (`scripts/e2e-agent-chats.sh` runs it). +//! +//! Drives real `zeron mcp` stdio servers — the process an agent's harness +//! spawns — against two running headless engines (A and B, one user, synced +//! through a real edge). The "agent" is the coordinator chat on A: its MCP +//! server carries `ZERON_CHAT_ID`, exactly as the engine injects it. +//! +//! 1. A and B see each other as online execution hosts (`list_devices`); +//! 2. the coordinator (on A) spawns a TOP-LEVEL chat on B with a prompt and +//! waits for B's mock harness to answer; +//! 3. it spawns a SIDE chat on B the same way; +//! 4. `list_chats { spawned_by }` and `read_chat` agree from BOTH engines; +//! 5. follow-up `send_message … wait`, `interrupt_chat`, validation errors; +//! 6. the top-level chat on B spawns further chats until the depth guard; +//! 7. optionally (`ZERON_E2E_AGENT_MODEL`), a real harness in a chat on A +//! makes the create_chat call itself through the engine-injected server. +//! +//! Usage: agent_chats_e2e [evidence.json] +//! Prints `PASS`/`FAIL` lines; exits nonzero on failure. + +use std::process::Stdio; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; + +const MOCK_TEXT: &str = "Mock harness reporting in."; +const STEP_TIMEOUT: Duration = Duration::from_secs(120); + +fn fail(message: &str) -> ! { + eprintln!("FAIL: {message}"); + std::process::exit(1); +} + +fn pass(message: &str) { + println!("PASS: {message}"); +} + +/// One `zeron mcp` child, spoken to over newline-delimited JSON-RPC. +struct Mcp { + label: String, + _child: Child, + stdin: ChildStdin, + lines: Lines>, + next_id: u64, +} + +impl Mcp { + async fn spawn(bin: &str, port: u16, chat: Option<&str>, label: &str) -> Self { + let mut command = Command::new(bin); + command + .arg("mcp") + .env("ZERON_IPC_PORT", port.to_string()) + .env_remove("ZERON_CHAT_ID") + .env_remove("ZERON_DEVICE_ID") + .env("RUST_LOG", "warn") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true); + if let Some(chat) = chat { + command.env("ZERON_CHAT_ID", chat); + } + let mut child = command + .spawn() + .unwrap_or_else(|e| fail(&format!("{label}: spawn {bin} mcp: {e}"))); + let stdin = child.stdin.take().unwrap(); + let lines = BufReader::new(child.stdout.take().unwrap()).lines(); + let mut mcp = Self { + label: label.to_owned(), + _child: child, + stdin, + lines, + next_id: 1, + }; + let init = mcp + .request( + "initialize", + json!({ "protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": { "name": "agent_chats_e2e", "version": "1" } }), + ) + .await; + if init["result"]["serverInfo"]["name"] != "zeron" { + fail(&format!("{label}: bad initialize reply {init}")); + } + mcp + } + + async fn request(&mut self, method: &str, params: Value) -> Value { + let id = self.next_id; + self.next_id += 1; + let line = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }); + self.stdin + .write_all(format!("{line}\n").as_bytes()) + .await + .unwrap_or_else(|e| fail(&format!("{}: write: {e}", self.label))); + self.stdin.flush().await.ok(); + loop { + let next = tokio::time::timeout(Duration::from_secs(900), self.lines.next_line()).await; + let line = match next { + Ok(Ok(Some(line))) => line, + other => fail(&format!("{}: no reply to {method}: {other:?}", self.label)), + }; + let Ok(reply) = serde_json::from_str::(&line) else { + continue; + }; + if reply["id"] == json!(id) { + return reply; + } + } + } + + /// `Ok(structuredContent)` or `Err(error text)` (tool-level `isError`). + async fn call(&mut self, tool: &str, args: Value) -> Result { + let reply = self + .request("tools/call", json!({ "name": tool, "arguments": args })) + .await; + let result = &reply["result"]; + if result["isError"] == true { + return Err(result["content"][0]["text"] + .as_str() + .unwrap_or_default() + .to_owned()); + } + if result.is_null() { + return Err(format!("protocol error: {reply}")); + } + Ok(result["structuredContent"].clone()) + } + + async fn ok(&mut self, tool: &str, args: Value) -> Value { + match self.call(tool, args.clone()).await { + Ok(value) => value, + Err(err) => fail(&format!("{}: {tool} {args}: {err}", self.label)), + } + } +} + +/// Call `tool` until `check` accepts the outcome, or fail after +/// [`STEP_TIMEOUT`] (rows and transcripts arrive through sync, not at once). +async fn poll( + mcp: &mut Mcp, + what: &str, + tool: &str, + args: Value, + check: impl Fn(Result) -> Option, +) -> T { + let deadline = Instant::now() + STEP_TIMEOUT; + loop { + let outcome = mcp.call(tool, args.clone()).await; + let last = format!("{outcome:?}"); + if let Some(value) = check(outcome) { + return value; + } + if Instant::now() > deadline { + fail(&format!("{}: timed out waiting for {what}; last: {last}", mcp.label)); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } +} + +fn replies_text(turn: &Value) -> String { + turn["replies"] + .as_array() + .map(|r| { + r.iter() + .filter_map(|m| m["text"].as_str()) + .collect::>() + .join("\n") + }) + .unwrap_or_default() +} + +fn chat_in<'a>(listed: &'a Value, id: &str) -> Option<&'a Value> { + listed["chats"].as_array()?.iter().find(|c| c["id"] == id) +} + +#[tokio::main] +async fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 4 { + fail("usage: agent_chats_e2e [evidence.json]"); + } + let bin = args[1].clone(); + let a_port: u16 = args[2].parse().unwrap_or_else(|_| fail("bad A port")); + let b_port: u16 = args[3].parse().unwrap_or_else(|_| fail("bad B port")); + let evidence_path = args.get(4).cloned(); + let mut evidence = serde_json::Map::new(); + + // ── 0. Plain (unattributed) servers on both engines ──────────────────────── + let mut a = Mcp::spawn(&bin, a_port, None, "A").await; + let mut b = Mcp::spawn(&bin, b_port, None, "B").await; + let a_dev = a.ok("whoami", json!({})).await["localDeviceId"] + .as_str() + .unwrap() + .to_owned(); + let b_dev = b.ok("whoami", json!({})).await["localDeviceId"] + .as_str() + .unwrap() + .to_owned(); + if a_dev == b_dev { + fail("A and B share a device id"); + } + + // ── 1. Each engine sees the other as an online execution host ───────────── + for (mcp, other) in [(&mut a, &b_dev), (&mut b, &a_dev)] { + let listed = poll(mcp, "peer online", "list_devices", json!({}), |r| { + let listed = r.ok()?; + let peer = listed["devices"] + .as_array()? + .iter() + .find(|d| d["id"] == other.as_str())? + .clone(); + (peer["online"] == true && peer["executionHost"] == true).then_some(listed) + }) + .await; + evidence.insert(format!("list_devices_from_{}", mcp.label), listed); + } + pass("both engines list the other as an online execution host"); + + // Chats below ask for the `mock` harness explicitly: deterministic, and + // honoured by the MCP although the test rig is hidden from pickers. + let remote = a + .ok("list_harnesses", json!({ "device": b_dev.as_str() })) + .await; + if remote["deviceId"] != b_dev.as_str() { + fail(&format!("list_harnesses did not route to B: {remote}")); + } + pass("list_harnesses { device: B } answers from B through targetDeviceId"); + + // ── 2. The coordinator: a user-style top-level chat on A ────────────────── + let coordinator = a + .ok( + "create_chat", + json!({ "kind": "chat", "harness": "mock", "title": "Coordinator" }), + ) + .await; + let coordinator_id = coordinator["chatId"].as_str().unwrap().to_owned(); + if !coordinator["spawnedByChatId"].is_null() { + fail("an unattributed create must not record provenance"); + } + // Everything below speaks AS the coordinator's agent. + let mut agent = Mcp::spawn(&bin, a_port, Some(&coordinator_id), "agent@A").await; + let who = poll(&mut agent, "coordinator row", "whoami", json!({}), |r| { + r.ok().filter(|w| w["spawning"]["spawnDepth"].is_number()) + }) + .await; + if who["spawning"]["spawnDepth"] != 0 || who["spawning"]["canCreateChats"] != true { + fail(&format!("coordinator spawning info: {who}")); + } + + // ── 3. A top-level chat on B, run to completion ─────────────────────────── + let worker = agent + .ok( + "create_chat", + json!({ "kind": "chat", "device": b_dev.as_str(), "harness": "mock", + "title": "Worker on B", "prompt": "Run the job on device B", + "wait": true, "timeout_secs": 120 }), + ) + .await; + let worker_id = worker["chatId"].as_str().unwrap().to_owned(); + if worker["kind"] != "chat" + || worker["deviceId"] != b_dev.as_str() + || !worker["parentChatId"].is_null() + || worker["spawnedByChatId"] != coordinator_id.as_str() + { + fail(&format!("top-level worker shape: {worker}")); + } + if worker["turn"]["outcome"] != "completed" || !replies_text(&worker["turn"]).contains(MOCK_TEXT) + { + fail(&format!("top-level worker turn: {}", worker["turn"])); + } + evidence.insert("create_chat_top_level_on_B".into(), worker.clone()); + pass("agent on A spawned a top-level chat on B; B's engine ran the prompt"); + + // ── 4. A side chat on B ──────────────────────────────────────────────────── + let side = agent + .ok( + "create_chat", + json!({ "kind": "side", "device": b_dev.as_str(), "harness": "mock", + "title": "Side check on B", "prompt": "Quick side check", + "wait": true, "timeout_secs": 120 }), + ) + .await; + let side_id = side["chatId"].as_str().unwrap().to_owned(); + if side["kind"] != "side" + || side["parentChatId"] != coordinator_id.as_str() + || side["spawnedByChatId"] != coordinator_id.as_str() + || side["turn"]["outcome"] != "completed" + { + fail(&format!("side chat shape: {side}")); + } + evidence.insert("create_chat_side_on_B".into(), side.clone()); + pass("agent on A spawned a side chat on B; B's engine ran the prompt"); + + // ── 5. Both engines agree: list_chats + read_chat ───────────────────────── + for mcp in [&mut a, &mut b] { + let label = mcp.label.clone(); + let args = json!({ "spawned_by": coordinator_id.as_str() }); + let listed = poll(mcp, "spawned chats synced", "list_chats", args, |r| { + let listed = r.ok()?; + let top = chat_in(&listed, &worker_id)?; + let side = chat_in(&listed, &side_id)?; + (listed["total"] == 2 + && top["kind"] == "chat" + && top["deviceId"] == b_dev.as_str() + && side["kind"] == "side" + && side["parentChatId"] == coordinator_id.as_str()) + .then_some(listed.clone()) + }) + .await; + evidence.insert(format!("list_chats_spawned_by_from_{label}"), listed); + for id in [&worker_id, &side_id] { + let args = json!({ "chat": id.as_str() }); + let read = poll(mcp, "transcript synced", "read_chat", args, |r| { + let read = r.ok()?; + read["messages"] + .to_string() + .contains(MOCK_TEXT) + .then_some(read) + }) + .await; + if id == &worker_id { + evidence.insert(format!("read_chat_worker_from_{label}"), read); + } + } + pass(&format!( + "engine {label}: list_chats {{spawned_by}} + read_chat see both spawned chats" + )); + } + + // ── 6. Running them afterwards: send/wait, interrupt, get_chat ──────────── + let follow = agent + .ok( + "send_message", + json!({ "chat": worker_id.as_str(), "text": "One more pass, please", + "wait": true, "timeout_secs": 120 }), + ) + .await; + if follow["turn"]["outcome"] != "completed" { + fail(&format!("follow-up turn: {follow}")); + } + let transcript = agent + .ok("read_chat", json!({ "chat": worker_id.as_str() })) + .await; + if !transcript["messages"] + .to_string() + .contains("[Message from Zeron chat Coordinator") + { + fail("follow-up was not attributed to the coordinator"); + } + evidence.insert("send_message_follow_up".into(), follow); + pass("send_message … wait round-trips to B, attributed to the coordinator"); + let interrupted = agent + .ok("interrupt_chat", json!({ "chat": worker_id.as_str() })) + .await; + if interrupted["commandId"].as_str().is_none_or(str::is_empty) { + fail(&format!("interrupt: {interrupted}")); + } + let waited = agent + .ok( + "wait_for_turn", + json!({ "chat": side_id.as_str(), "timeout_secs": 30 }), + ) + .await; + if waited["turn"]["outcome"] != "completed" { + fail(&format!("wait_for_turn on the idle side chat: {waited}")); + } + pass("interrupt_chat queues on the remote chat; wait_for_turn reads B's session row"); + + // ── 7. Validation: bad targets name the valid hosts ─────────────────────── + let err = agent + .call("create_chat", json!({ "kind": "chat", "device": "no-such-device" })) + .await + .expect_err("unknown device must fail"); + if !err.contains("no device matches") || !err.contains(&b_dev) { + fail(&format!("unknown-device error: {err}")); + } + let err = agent + .call( + "create_chat", + json!({ "kind": "chat", "parent": coordinator_id.as_str() }), + ) + .await + .expect_err("parent with kind chat must fail"); + evidence.insert("validation_errors".into(), json!([err])); + pass("invalid targets and kind/parent combinations are refused with guidance"); + + // ── 8. A top-level spawned chat on B orchestrates further, up to the limit ─ + let mut from_worker = Mcp::spawn(&bin, b_port, Some(&worker_id), "agent@B").await; + let grandchild = from_worker + .ok( + "create_chat", + json!({ "kind": "chat", "device": a_dev.as_str(), "harness": "mock", + "title": "Depth 2 on A" }), + ) + .await; + let depth2 = grandchild["chatId"].as_str().unwrap().to_owned(); + let side_from_b = from_worker + .ok( + "create_chat", + json!({ "harness": "mock", "title": "Side of the B worker" }), + ) + .await; + if side_from_b["kind"] != "side" || side_from_b["parentChatId"] != worker_id.as_str() { + fail(&format!("B worker's side chat: {side_from_b}")); + } + let mut from_depth2 = Mcp::spawn(&bin, a_port, Some(&depth2), "agent@A(depth2)").await; + let args = json!({ "kind": "chat", "harness": "mock", "title": "Depth 3" }); + let depth3 = poll(&mut from_depth2, "depth-2 spawn", "create_chat", args, |r| r.ok()).await; + let depth3_id = depth3["chatId"].as_str().unwrap().to_owned(); + let mut from_depth3 = Mcp::spawn(&bin, a_port, Some(&depth3_id), "agent@A(depth3)").await; + // The depth-3 row must have synced before its agent can be judged; a + // "no chat matches" is that race, a successful create would be the bug. + let args = json!({ "kind": "chat", "harness": "mock" }); + let err = poll(&mut from_depth3, "depth-3 refusal", "create_chat", args, |r| match r { + Err(err) if err.contains("agent spawns deep") => Some(err), + Ok(created) => fail(&format!("depth 3 created a chat: {created}")), + Err(_) => None, + }) + .await; + evidence.insert("depth_guard_error".into(), json!(err)); + pass("spawned top-level chats spawn further chats across devices until depth 3"); + + // ── 9. Optional: a real agent does the spawning ─────────────────────────── + // ZERON_E2E_AGENT_MODEL (e.g. opencode/big-pickle) runs a real harness in a + // chat on A; the engine injects its Zeron MCP server into that run, and the + // model itself must call create_chat to start a top-level chat on B. + if let Ok(model) = std::env::var("ZERON_E2E_AGENT_MODEL") { + let harness = + std::env::var("ZERON_E2E_AGENT_HARNESS").unwrap_or_else(|_| "opencode".into()); + let prompt = format!( + "You are testing Zeron orchestration. Call the Zeron MCP tool create_chat \ + (it may be exposed as zeron_create_chat) exactly once with these arguments: \ + kind \"chat\", device \"{b_dev}\", harness \"mock\", \ + title \"Spawned by a real agent\", prompt \"Say hello from device B\", \ + wait true. Then reply with only the chatId it returned." + ); + let real = a + .ok( + "create_chat", + json!({ "kind": "chat", "harness": harness, "model": model, + "title": "Real agent on A", "prompt": prompt, + "wait": true, "timeout_secs": 600 }), + ) + .await; + let real_id = real["chatId"].as_str().unwrap().to_owned(); + let args = json!({ "spawned_by": real_id.as_str() }); + let spawned = poll(&mut b, "the real agent's chat on B", "list_chats", args, |r| { + let listed = r.ok()?; + listed["chats"] + .as_array()? + .iter() + .find(|c| c["kind"] == "chat" && c["deviceId"] == b_dev.as_str()) + .cloned() + }) + .await; + let spawned_id = spawned["id"].as_str().unwrap().to_owned(); + let args = json!({ "chat": spawned_id.as_str() }); + poll(&mut b, "the spawned chat's reply on B", "read_chat", args, |r| { + r.ok()?.to_string().contains(MOCK_TEXT).then_some(()) + }) + .await; + // The spawner's doc keeps the ids its create call made (the fold's + // `created_chat_ids`), so its transcript links to the chat exactly. + let args = json!({ "chat": real_id.as_str(), "include_tools": true }); + let agent_transcript = poll(&mut a, "created ids in the transcript", "read_chat", args, |r| { + let read = r.ok()?; + read["messages"] + .to_string() + .contains(&format!("[created: {spawned_id}]")) + .then_some(read) + }) + .await; + evidence.insert("real_agent_turn".into(), real["turn"].clone()); + evidence.insert("real_agent_transcript".into(), agent_transcript); + evidence.insert("real_agent_spawned_chat_on_B".into(), spawned); + pass(&format!( + "a real {harness} agent ({model}) on A spawned a top-level chat on B that ran" + )); + for id in [&real_id, &spawned_id] { + a.call("archive_chat", json!({ "chat": id.as_str() })).await.ok(); + } + } + + // Tidy: archive what we spawned (the side chats go with the listing). + for id in [&worker_id, &side_id, &depth2, &depth3_id] { + agent + .call("archive_chat", json!({ "chat": id.as_str() })) + .await + .ok(); + } + if let Some(path) = evidence_path { + std::fs::write(&path, serde_json::to_string_pretty(&Value::Object(evidence)).unwrap()) + .unwrap_or_else(|e| fail(&format!("write {path}: {e}"))); + println!("evidence: {path}"); + } + println!("PASS: agent-spawned chats across two devices"); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 4cfbd8bf9a..98fed67243 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -13,12 +13,15 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use zeron_doc::SessionCommandPayload; use zeron_proto::{ - Chat, ChatConfig, HarnessId, ReasoningLevel, RunRequest, SandboxLevel, Session, SessionStatus, - Space, UserInputAnswer, + Chat, ChatConfig, Device, HarnessId, MAX_SPAWN_DEPTH, ReasoningLevel, RunRequest, SandboxLevel, + Session, SessionStatus, Space, UserInputAnswer, WorktreeSpec, spawn_depth, }; use crate::transcript::{RenderOptions, RenderedMessage, render_entries}; -use crate::zeron::{HarnessInfo, TurnOutcome, Zeron, session_for, short}; +use crate::zeron::{ + HarnessInfo, TurnOutcome, Zeron, is_execution_host, is_online, resolve_chat_in, + resolve_space_in, session_for, short, +}; /// Default and ceiling for the blocking waits. const MAX_BATCH: usize = 32; @@ -27,6 +30,15 @@ const MAX_WAIT: Duration = Duration::from_secs(3600); /// A session row older than this is not trusted to still be working /// (the UI's staleness window): a crashed host must not read as busy forever. const SESSION_STALE: chrono::Duration = chrono::Duration::seconds(45); +/// How long a completed wait keeps re-reading the transcript for the reply +/// when the session row outran the (separately synced) chat doc. +const TRANSCRIPT_GRACE: Duration = Duration::from_secs(20); +/// Fan-out cap: unarchived chats one chat may have spawned at once (side and +/// top-level together). Archiving finished workers frees slots. +pub const MAX_LIVE_SPAWNS: usize = 32; +/// Churn cap: chats one MCP server (one agent run) may create per minute, +/// so a create/archive loop cannot flood the registry. +pub const SPAWN_RATE: (usize, Duration) = (32, Duration::from_secs(60)); #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -38,6 +50,14 @@ pub struct ToolDef { pub struct Tools { zeron: Arc, + /// Serializes "count live spawns → write the row" so a concurrent + /// `create_chats` batch cannot overshoot [`MAX_LIVE_SPAWNS`]. + create_gate: tokio::sync::Mutex<()>, + /// Chats this server created (ids), for the live-spawn count while a + /// fresh row has not folded into `WatchChats` yet. + created: std::sync::Mutex>, + /// Recent creation instants: the per-server [`SPAWN_RATE`] window. + recent_creates: std::sync::Mutex>, } fn chat_key_schema(extra: Value) -> Value { @@ -64,26 +84,32 @@ fn catalog() -> Vec { }, ToolDef { name: "list_devices", - description: "Devices in this workspace (the local engine's device is flagged). Chats and projects are hosted on a device.", + description: "Devices in this workspace (the local engine's device is flagged). Chats and projects are hosted on a device. `executionHost` devices run agents; create_chat can target any one that is `online` (pass its id/name as device, or one of its `projects`).", input_schema: json!({ "type": "object", "properties": {} }), }, ToolDef { name: "list_projects", - description: "Projects: a folder on a device. Each chat belongs to one project, which fixes its host device and working directory.", + description: "Projects: a folder on a device. Each chat belongs to one project, which fixes its host device and working directory. Projects on other online devices are valid create_chat targets.", input_schema: json!({ "type": "object", "properties": {} }), }, ToolDef { name: "list_harnesses", - description: "Agent harnesses (claude-code, codex, cursor, …) and whether each is available on this device.", - input_schema: json!({ "type": "object", "properties": {} }), + description: "Agent harnesses (claude-code, codex, cursor, …) and whether each is available on a device (default: this one).", + input_schema: json!({ + "type": "object", + "properties": { + "device": { "type": "string", "description": "Device id or name to ask (it must be online). Defaults to this device." } + } + }), }, ToolDef { name: "list_models", - description: "Models a harness offers on this device. Model ids are harness-specific strings; pass one to create_chat.", + description: "Models a harness offers on a device (default: this one). Model ids are harness-specific strings; pass one to create_chat.", input_schema: json!({ "type": "object", "properties": { - "harness": { "type": "string", "description": "Harness id, e.g. claude-code or codex." } + "harness": { "type": "string", "description": "Harness id, e.g. claude-code or codex." }, + "device": { "type": "string", "description": "Device id or name to ask (it must be online). Defaults to this device." } }, "required": ["harness"] }), @@ -97,7 +123,8 @@ fn catalog() -> Vec { "project": { "type": "string", "description": "Only chats in this project (id, path, or name)." }, "device": { "type": "string", "description": "Only chats hosted on this device (id or name)." }, "include_archived": { "type": "boolean", "default": false }, - "parent": { "type": "string", "description": "Only chats created by this chat (id, prefix, or title) — e.g. your own id to list the chats you spawned." }, + "parent": { "type": "string", "description": "Only side chats under this chat (id, prefix, or title)." }, + "spawned_by": { "type": "string", "description": "Only chats (side or top-level) whose agent was this chat (id, prefix, or title) — e.g. your own id to list every chat you spawned." }, "limit": { "type": "integer", "minimum": 1, "maximum": 500, "default": 50 } } }), @@ -109,20 +136,22 @@ fn catalog() -> Vec { }, ToolDef { name: "create_chat", - description: "Create a chat in a project (or project-less on a device) with a harness and model. The new chat records your chat as its parent (parentChatId). Optionally send a first prompt and wait for the reply. Returns the new chat id. For parallel delegation use create_chats, or leave wait=false on every launch and wait only after all chats have been started.", + description: "Create a chat on any online device (see list_devices/list_projects) with a harness and model; that device's engine runs it. kind 'side' (default when you are in a chat) makes a side chat under your chat: hidden from the sidebar, listed under its parent. kind 'chat' makes a real top-level chat that appears in the Sessions sidebar on every device like one the user created, marked as spawned by your chat. Either way the new chat records you as spawnedByChatId. Optionally send a first prompt and wait for the reply. Returns the new chat id. For parallel delegation use create_chats, or leave wait=false on every launch and wait only after all chats have been started.", input_schema: json!({ "type": "object", "properties": { - "project": { "type": "string", "description": "Project id, path, or name. Required unless device is given." }, - "device": { "type": "string", "description": "Host device (id or name) for a project-less chat; defaults to this device." }, - "parent": { "type": "string", "description": "Parent chat to record (id, prefix, or title). Defaults to the chat you are speaking from." }, + "kind": { "type": "string", "enum": ["side", "chat"], "description": "side: a side chat under `parent` (default: your chat). chat: a top-level chat in the sidebar. Defaults to side when there is a parent, else chat." }, + "project": { "type": "string", "description": "Project id, path, or name; fixes the host device and working directory. Required unless device is given." }, + "device": { "type": "string", "description": "Host device (id or name). With project: only used to pick among same-named projects and must match. Without: a project-less chat on that device. Defaults to this device." }, + "parent": { "type": "string", "description": "kind side only: the chat to hang it under (id, prefix, or title). Defaults to the chat you are speaking from." }, "harness": { "type": "string", "description": "Harness id (see list_harnesses). Defaults to claude-code when available." }, "model": { "type": "string", "description": "Model id from list_models. Omit for the harness default." }, "reasoning": { "type": "string", "description": "Reasoning level the model supports (e.g. low, medium, high, max)." }, "sandbox": { "type": "string", "enum": ["read-only", "workspace-write", "danger-full-access"], "default": "workspace-write" }, "title": { "type": "string", "description": "Sidebar title. Otherwise the engine titles it from the first exchange." }, "branch": { "type": "string", "description": "Branch label to record on the chat." }, - "cwd": { "type": "string", "description": "Working directory override (an existing worktree path). Defaults to the project folder." }, + "cwd": { "type": "string", "description": "Working directory override on the host (an existing worktree path). Defaults to the project folder, or ~ for a project-less chat." }, + "worktree": { "type": "boolean", "default": false, "description": "Run in a fresh isolated git worktree the HOST creates off `branch` (default HEAD) on the first turn. Needs a git project and a prompt." }, "prompt": { "type": "string", "description": "First message to send right away." }, "wait": { "type": "boolean", "default": false, "description": "With prompt: block until the first turn finishes and return the reply." }, "timeout_secs": { "type": "integer", "minimum": 1, "maximum": 3600, "default": 600 } @@ -191,7 +220,7 @@ fn catalog() -> Vec { ( "create_chats", "create_chat", - "Create multiple independent side chats concurrently. Put each chat's prompt in its request to start all work together. Prefer this for parallel delegation, including harnesses that execute tool calls sequentially. Each request has create_chat arguments; wait defaults to false. Results preserve request order and include per-request errors; successful requests are not rolled back.", + "Create multiple independent chats concurrently (each request picks its own kind, device, and project). Put each chat's prompt in its request to start all work together. Prefer this for parallel delegation, including harnesses that execute tool calls sequentially. Each request has create_chat arguments; wait defaults to false. Results preserve request order and include per-request errors; successful requests are not rolled back.", ), ( "send_messages", @@ -232,6 +261,12 @@ struct ChatArgs { #[derive(Deserialize)] struct ListModelsArgs { harness: String, + device: Option, +} + +#[derive(Deserialize, Default)] +struct DeviceArgs { + device: Option, } #[derive(Deserialize, Default)] @@ -241,11 +276,13 @@ struct ListChatsArgs { #[serde(default)] include_archived: bool, parent: Option, + spawned_by: Option, limit: Option, } #[derive(Deserialize, Default)] struct CreateChatArgs { + kind: Option, project: Option, device: Option, parent: Option, @@ -256,12 +293,31 @@ struct CreateChatArgs { title: Option, branch: Option, cwd: Option, + #[serde(default)] + worktree: bool, prompt: Option, #[serde(default)] wait: bool, timeout_secs: Option, } +/// Where a created chat lives: a side chat under a parent, or a top-level +/// chat in the sidebar. Both record their spawner as provenance. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +enum ChatKind { + Side, + Chat, +} + +fn kind_of(chat: &Chat) -> ChatKind { + if chat.is_side_chat() { + ChatKind::Side + } else { + ChatKind::Chat + } +} + #[derive(Deserialize)] struct ReadChatArgs { chat: String, @@ -310,6 +366,7 @@ struct AnswerArg { } fn parse(args: Value) -> Result { + let args = if args.is_null() { json!({}) } else { args }; serde_json::from_value(args).map_err(|e| format!("invalid arguments: {e}")) } @@ -372,7 +429,9 @@ fn summarize_chat(chat: &Chat, spaces: &[Space], sessions: &[Session]) -> Value "model": chat.config.as_ref().and_then(|c| c.model.clone()), "reasoning": chat.config.as_ref().and_then(|c| c.reasoning), "archived": chat.archived, + "kind": kind_of(chat), "parentChatId": chat.parent_chat_id, + "spawnedByChatId": chat.spawned_by_chat_id, "status": status, "statusAgeSecs": status_age, "lastMessageAt": chat.last_message_at, @@ -389,7 +448,12 @@ fn last_pending_input(messages: &[RenderedMessage]) -> Option { impl Tools { pub fn new(zeron: Arc) -> Self { - Self { zeron } + Self { + zeron, + create_gate: tokio::sync::Mutex::new(()), + created: std::sync::Mutex::new(Vec::new()), + recent_creates: std::sync::Mutex::new(std::collections::VecDeque::new()), + } } pub fn list(&self) -> Vec { @@ -407,15 +471,18 @@ impl Tools { "whoami" => self.whoami().await, "list_devices" => self.list_devices().await, "list_projects" => self.list_projects().await, - "list_harnesses" => self.list_harnesses().await, + "list_harnesses" => self.list_harnesses(parse(args)?).await, "list_models" => self.list_models(parse(args)?).await, "list_chats" => self.list_chats(parse(args)?).await, "get_chat" => self.get_chat(parse(args)?).await, - "create_chat" => self.create_chat(parse(args)?).await, - "create_chats" => self.batch(parse(args)?, true).await, - "send_messages" => self.batch(parse(args)?, false).await, + // The create/send futures are large (resolution, host catalog + // reads, delivery and an optional wait inline): box them so a + // tool call never overflows a worker thread's stack. + "create_chat" => Box::pin(self.create_chat(parse(args)?)).await, + "create_chats" => Box::pin(self.batch(parse(args)?, true)).await, + "send_messages" => Box::pin(self.batch(parse(args)?, false)).await, "read_chat" => self.read_chat(parse(args)?).await, - "send_message" => self.send_message(parse(args)?).await, + "send_message" => Box::pin(self.send_message(parse(args)?)).await, "wait_for_turn" => self.wait_for_turn(parse(args)?).await, "interrupt_chat" => self.interrupt_chat(parse(args)?).await, "respond_to_input" => self.respond_to_input(parse(args)?).await, @@ -436,12 +503,12 @@ impl Tools { |(index, args)| async move { let result = if create { match serde_json::from_value(args) { - Ok(args) => self.create_chat(args).await, + Ok(args) => Box::pin(self.create_chat(args)).await, Err(error) => Err(error.into()), } } else { match serde_json::from_value(args) { - Ok(args) => self.send_message(args).await, + Ok(args) => Box::pin(self.send_message(args)).await, Err(error) => Err(error.into()), } }; @@ -461,11 +528,24 @@ impl Tools { let origin = self.zeron.origin().clone(); let local_device = self.zeron.local_device_id().await?; let engine = self.zeron.engine_info().await.unwrap_or(Value::Null); + let mut spawning = json!({ + "maxSpawnDepth": MAX_SPAWN_DEPTH, + "maxLiveSpawns": MAX_LIVE_SPAWNS, + "maxCreatesPerMinute": SPAWN_RATE.0, + }); let chat = match origin.chat_id.as_deref() { - Some(id) => match self.zeron.resolve_chat(id).await { - Ok(chat) => { + Some(id) => match self.zeron.chats().await.and_then(|c| { + resolve_chat_in(&c, id).map(|chat| (chat, c)) + }) { + Ok((chat, chats)) => { let (spaces, sessions) = tokio::try_join!(self.zeron.spaces(), self.zeron.sessions())?; + let depth = spawn_depth(&chat.id, spawner_lookup(&chats)); + spawning["spawnDepth"] = json!(depth); + spawning["liveSpawns"] = json!(live_spawns(&chats, &chat.id)); + spawning["canCreateChats"] = json!( + !chat.is_side_chat() && depth.is_some_and(|d| d < MAX_SPAWN_DEPTH) + ); summarize_chat(&chat, &spaces, &sessions) } Err(_) => json!({ "id": id }), @@ -477,6 +557,7 @@ impl Tools { "originDeviceId": origin.device_id, "localDeviceId": local_device, "workspaceScope": engine.get("workspaceScope").cloned().unwrap_or(Value::Null), + "spawning": spawning, "note": if origin.chat_id.is_some() { "Messages you send are attributed to this chat; it cannot message itself." } else { @@ -486,38 +567,77 @@ impl Tools { } async fn list_devices(&self) -> anyhow::Result { - let (devices, local) = - tokio::try_join!(self.zeron.devices(), self.zeron.local_device_id())?; + let (devices, spaces, local) = tokio::try_join!( + self.zeron.devices(), + self.zeron.spaces(), + self.zeron.local_device_id() + )?; + let now = chrono::Utc::now(); Ok(json!({ "devices": devices.iter().map(|d| json!({ "id": d.id, "name": d.name, "platform": d.platform, "local": d.id == local, + "online": is_online(d, &local, now), + "executionHost": is_execution_host(d), "lastSeenAt": d.last_seen_at, "version": d.version, + "projects": spaces.iter().filter(|s| s.device_id == d.id).map(|s| json!({ + "id": s.id, + "name": s.display_name(), + "path": s.path, + })).collect::>(), })).collect::>() })) } async fn list_projects(&self) -> anyhow::Result { - let (spaces, devices) = tokio::try_join!(self.zeron.spaces(), self.zeron.devices())?; - let device_name = |id: &str| devices.iter().find(|d| d.id == id).map(|d| d.name.clone()); + let (spaces, devices, local) = tokio::try_join!( + self.zeron.spaces(), + self.zeron.devices(), + self.zeron.local_device_id() + )?; + let now = chrono::Utc::now(); + let device = |id: &str| devices.iter().find(|d| d.id == id); Ok(json!({ "projects": spaces.iter().map(|s| json!({ "id": s.id, "name": s.display_name(), "path": s.path, "deviceId": s.device_id, - "deviceName": device_name(&s.device_id), + "deviceName": device(&s.device_id).map(|d| d.name.clone()), + "deviceOnline": s.device_id == local + || device(&s.device_id).is_some_and(|d| is_online(d, &local, now)), "git": s.git_detected, })).collect::>() })) } - async fn list_harnesses(&self) -> anyhow::Result { - let harnesses = self.zeron.harnesses().await?; + /// `Some(id)` when `key` names another device (so reads route there), + /// `None` for this engine's own. A remote target must be online. + async fn remote_target(&self, key: Option<&str>) -> anyhow::Result> { + if key.map(str::trim).is_none_or(str::is_empty) { + return Ok(None); + } + let (devices, local) = + tokio::try_join!(self.zeron.devices(), self.zeron.local_device_id())?; + let id = self.zeron.resolve_device_id(key).await?; + if id == local { + return Ok(None); + } + let device = devices.iter().find(|d| d.id == id); + if let Some(device) = device { + check_host(device, &devices, &local)?; + } + Ok(Some(id)) + } + + async fn list_harnesses(&self, args: DeviceArgs) -> anyhow::Result { + let target = self.remote_target(args.device.as_deref()).await?; + let harnesses = self.zeron.harnesses_on(target.as_deref()).await?; Ok(json!({ + "deviceId": target, "harnesses": harnesses.iter().map(|h| json!({ "id": h.id, "name": h.name, @@ -532,9 +652,11 @@ impl Tools { async fn list_models(&self, args: ListModelsArgs) -> anyhow::Result { let harness: HarnessId = parse_enum("harness", &args.harness).map_err(anyhow::Error::msg)?; - let models = self.zeron.models(harness).await?; + let target = self.remote_target(args.device.as_deref()).await?; + let models = self.zeron.models_on(harness, target.as_deref()).await?; Ok(json!({ "harness": harness, + "deviceId": target, "models": models.iter().map(|m| json!({ "id": m.id, "label": m.label, @@ -565,6 +687,10 @@ impl Tools { let parent = self.zeron.resolve_chat(parent).await?; chats.retain(|c| c.parent_chat_id.as_deref() == Some(parent.id.as_str())); } + if let Some(spawner) = args.spawned_by.as_deref() { + let spawner = self.zeron.resolve_chat(spawner).await?; + chats.retain(|c| c.spawned_by_chat_id.as_deref() == Some(spawner.id.as_str())); + } chats.sort_by(|a, b| { let a_at = a.last_message_at.unwrap_or(a.created_at); let b_at = b.last_message_at.unwrap_or(b.created_at); @@ -596,22 +722,99 @@ impl Tools { } async fn create_chat(&self, args: CreateChatArgs) -> anyhow::Result { - if let Some(origin) = self.zeron.origin().chat_id.as_deref() { - let chat = self.zeron.resolve_chat(origin).await?; + let (chats, spaces, devices, local) = tokio::try_join!( + self.zeron.chats(), + self.zeron.spaces(), + self.zeron.devices(), + self.zeron.local_device_id() + )?; + + // Provenance: the chat this server speaks for. It must be allowed to + // spawn at all — side chats never, spawned chats only while shallow. + let spawner = match self.zeron.origin().chat_id.as_deref() { + Some(origin) => Some(resolve_chat_in(&chats, origin)?), + None => None, + }; + if let Some(spawner) = &spawner { anyhow::ensure!( - chat.parent_chat_id.is_none(), + !spawner.is_side_chat(), "Side chats cannot create chats. Ask your parent chat to create another side chat." ); + check_spawn_depth(spawner, &chats)?; + } + + // Placement: `kind` decides whether the chat hangs off a parent. + let explicit_parent = args + .parent + .as_deref() + .map(str::trim) + .filter(|p| !p.is_empty()); + let kind = match args.kind.as_deref().map(str::trim).filter(|k| !k.is_empty()) { + Some(raw) => Some(match raw.to_ascii_lowercase().as_str() { + "side" | "side-chat" | "side_chat" => ChatKind::Side, + "chat" | "top-level" | "top_level" | "toplevel" => ChatKind::Chat, + _ => anyhow::bail!("unknown kind {raw:?}; expected \"side\" or \"chat\""), + }), + None => None, + }; + let parent = match (kind, explicit_parent) { + (Some(ChatKind::Chat), Some(_)) => anyhow::bail!( + "parent only applies to kind \"side\"; a kind \"chat\" chat is top-level (its spawner is recorded as spawnedByChatId)" + ), + (Some(ChatKind::Chat), None) => None, + (_, Some(key)) => Some(resolve_chat_in(&chats, key)?), + (_, None) => spawner.clone(), + }; + let kind = match (kind, &parent) { + (Some(ChatKind::Side), None) => anyhow::bail!( + "kind \"side\" needs a parent chat: run inside a Zeron chat or pass parent" + ), + (Some(kind), _) => kind, + (None, Some(_)) => ChatKind::Side, + (None, None) => ChatKind::Chat, + }; + if let Some(parent) = &parent { + anyhow::ensure!( + !parent.is_side_chat(), + "Cannot create a child of a side chat. Choose a top-level parent chat." + ); } - let harnesses = self.zeron.harnesses().await?; + + // Host: a project (fixes device + folder) or a device, on any online + // execution host in the workspace. That host's engine runs the chat. + let (space, device_id) = resolve_target( + args.project.as_deref(), + args.device.as_deref(), + &spaces, + &devices, + &local, + )?; + if let Some(device) = devices.iter().find(|d| d.id == device_id) { + check_host(device, &devices, &local)?; + } else if device_id != local { + anyhow::bail!( + "device {device_id} is not in this workspace; {}", + host_listing(&devices, &local) + ); + } + let remote = (device_id != local).then_some(device_id.as_str()); + + let harnesses = self.zeron.harnesses_on(remote).await.map_err(|e| { + anyhow::anyhow!("could not reach device {device_id} to list its harnesses: {e}") + })?; let harness = match args.harness.as_deref() { Some(raw) => { let id: HarnessId = parse_enum("harness", raw).map_err(anyhow::Error::msg)?; + // The mock test rig is never offered in pickers (always + // "disabled"), but an explicit request for it is honoured when + // the host has it, as the dev rig's `ZERON_HARNESS=mock` does. if let Some(info) = harnesses.iter().find(|h| h.id == id) && !info.available() + && !(id == HarnessId::Mock && info.installed) { anyhow::bail!( - "harness {raw} is not available on this device (see list_harnesses)" + "harness {raw} is not available on device {device_id}; available there: {}", + available_harnesses(&harnesses) ); } id @@ -619,7 +822,7 @@ impl Tools { None => default_harness(&harnesses)?, }; if let Some(model) = args.model.as_deref() - && let Ok(models) = self.zeron.models(harness).await + && let Ok(models) = self.zeron.models_on(harness, remote).await && !models.is_empty() && !models.iter().any(|m| m.id == model) { @@ -640,6 +843,37 @@ impl Tools { Some(raw) => parse_enum("sandbox", raw).map_err(anyhow::Error::msg)?, None => SandboxLevel::WorkspaceWrite, }; + let branch = args + .branch + .as_deref() + .map(str::trim) + .filter(|b| !b.is_empty()) + .map(str::to_owned); + let cwd = args + .cwd + .as_deref() + .map(str::trim) + .filter(|c| !c.is_empty()) + .map(str::to_owned); + let prompt = args.prompt.filter(|p| !p.trim().is_empty()); + let worktree = if args.worktree { + let space = space + .as_ref() + .filter(|s| s.git_detected) + .ok_or_else(|| anyhow::anyhow!("worktree needs a git project (see list_projects)"))?; + anyhow::ensure!( + prompt.is_some(), + "worktree needs a prompt: the host creates the worktree when the first turn starts" + ); + anyhow::ensure!(cwd.is_none(), "pass either worktree or cwd, not both"); + Some(WorktreeSpec { + repo_path: space.path.clone(), + base: branch.clone().unwrap_or_else(|| "HEAD".into()), + space_id: Some(space.id.clone()), + }) + } else { + None + }; let config = ChatConfig { harness, model: args.model.clone(), @@ -648,38 +882,9 @@ impl Tools { sandbox, }; - let (space, device_id) = match args.project.as_deref() { - Some(project) => { - let space = self.zeron.resolve_space(project).await?; - let device_id = space.device_id.clone(); - (Some(space), device_id) - } - None => ( - None, - self.zeron.resolve_device_id(args.device.as_deref()).await?, - ), - }; - - // Parent: the explicit `parent` argument, else the chat this server - // speaks for. Resolved so a prefix/title works and a typo fails loud. - let parent_chat_id = match args - .parent - .as_deref() - .map(str::trim) - .filter(|p| !p.is_empty()) - { - Some(key) => Some(self.zeron.resolve_chat(key).await?.id), - None => self.zeron.origin().chat_id.clone(), - }; - - if let Some(parent) = parent_chat_id.as_deref() { - let chat = self.zeron.resolve_chat(parent).await?; - anyhow::ensure!( - chat.parent_chat_id.is_none(), - "Cannot create a child of a side chat. Choose a top-level parent chat." - ); - } let chat_id = uuid::Uuid::new_v4().to_string(); + let spawned_by = spawner.as_ref().map(|c| c.id.clone()); + let parent_chat_id = parent.as_ref().map(|c| c.id.clone()); let mut mutate = json!({ "op": "createChat", "chatId": chat_id, @@ -692,18 +897,37 @@ impl Tools { if let Some(parent) = &parent_chat_id { mutate["parentChatId"] = json!(parent); } - if let Some(branch) = args - .branch - .as_deref() - .map(str::trim) - .filter(|b| !b.is_empty()) - { + if let Some(spawner) = &spawned_by { + mutate["spawnedByChatId"] = json!(spawner); + } + if let Some(branch) = &branch { mutate["branch"] = json!(branch); } - if let Some(cwd) = args.cwd.as_deref().map(str::trim).filter(|c| !c.is_empty()) { + if let Some(cwd) = &cwd { mutate["cwd"] = json!(cwd); } - self.zeron.mutate(mutate).await?; + { + // Count and write under one gate so a batch cannot overshoot. + let _gate = self.create_gate.lock().await; + if let Some(spawner) = &spawned_by { + let live = { + let created = self.created.lock().unwrap(); + live_spawns(&chats, spawner) + + created + .iter() + .filter(|id| !chats.iter().any(|c| &c.id == *id)) + .count() + }; + anyhow::ensure!( + live < MAX_LIVE_SPAWNS, + "chat {} already has {live} live spawned chats (limit {MAX_LIVE_SPAWNS}); archive finished ones with archive_chat first", + short(spawner) + ); + } + self.take_rate_slot()?; + self.zeron.mutate(mutate).await?; + self.created.lock().unwrap().push(chat_id.clone()); + } if let Some(title) = args .title .as_deref() @@ -715,17 +939,24 @@ impl Tools { .await?; } + let device_name = devices + .iter() + .find(|d| d.id == device_id) + .map(|d| d.name.clone()); let mut result = json!({ "chatId": chat_id, + "kind": kind, "deviceId": device_id, + "deviceName": device_name, "project": space.as_ref().map(|s| json!({ "id": s.id, "name": s.display_name(), "path": s.path })), "harness": harness, "model": args.model, "reasoning": reasoning, "title": args.title, "parentChatId": parent_chat_id, + "spawnedByChatId": spawned_by, }); - if let Some(prompt) = args.prompt.filter(|p| !p.trim().is_empty()) { + if let Some(prompt) = prompt { // The row may not have folded into WatchChats yet; build the // chat locally from what we just wrote rather than re-reading. let chat = Chat { @@ -733,8 +964,8 @@ impl Tools { device_id: device_id.clone(), title: args.title.clone(), archived: false, - cwd: args.cwd.clone(), - branch: args.branch.clone(), + cwd: cwd.clone(), + branch: branch.clone(), checkout_id: None, source_context: None, config: Some(config), @@ -744,12 +975,13 @@ impl Tools { harness_session_id: None, harness_session_cwd: None, parent_chat_id: parent_chat_id.clone(), + spawned_by_chat_id: spawned_by.clone(), space_id: space.as_ref().map(|s| s.id.clone()), last_seen_at: None, room_gen: None, }; let sent = self - .deliver(&chat, space.as_ref(), &harnesses, None, prompt, "run") + .deliver(&chat, space.as_ref(), &harnesses, None, prompt, "run", worktree) .await?; result["sent"] = sent; if args.wait { @@ -767,6 +999,32 @@ impl Tools { Ok(result) } + /// One slot of the per-server creation budget ([`SPAWN_RATE`]). + fn take_rate_slot(&self) -> anyhow::Result<()> { + let (max, window) = SPAWN_RATE; + let now = std::time::Instant::now(); + let mut recent = self.recent_creates.lock().unwrap(); + while recent + .front() + .is_some_and(|at| now.duration_since(*at) >= window) + { + recent.pop_front(); + } + if recent.len() >= max { + let wait = recent + .front() + .map(|at| window.saturating_sub(now.duration_since(*at))) + .unwrap_or(window); + anyhow::bail!( + "created {max} chats in the last {}s; retry in {}s", + window.as_secs(), + wait.as_secs().max(1) + ); + } + recent.push_back(now); + Ok(()) + } + async fn read_chat(&self, args: ReadChatArgs) -> anyhow::Result { let chat = self.zeron.resolve_chat(&args.chat).await?; let entries = self.zeron.transcript(&chat.id).await?; @@ -824,7 +1082,7 @@ impl Tools { "title": chat.title, }); result["sent"] = self - .deliver(&chat, space, &harnesses, baseline.as_ref(), body, mode) + .deliver(&chat, space, &harnesses, baseline.as_ref(), body, mode, None) .await?; if args.wait { result["turn"] = self @@ -936,7 +1194,9 @@ impl Tools { ) } - /// Pick and perform the delivery the composer would. + /// Pick and perform the delivery the composer would. `worktree` only + /// rides a first run (create_chat `worktree: true`). + #[allow(clippy::too_many_arguments)] async fn deliver( &self, chat: &Chat, @@ -945,6 +1205,7 @@ impl Tools { session: Option<&Session>, text: String, mode: &str, + worktree: Option, ) -> anyhow::Result { let harness = chat .config @@ -993,7 +1254,7 @@ impl Tools { auto_approve: false, resume: None, attachments: Vec::new(), - worktree: None, + worktree, }; self.zeron .queue_command( @@ -1039,8 +1300,31 @@ impl Tools { .zeron .wait_for_turn(chat, baseline, expect_turn, timeout) .await?; - let entries = self.zeron.transcript(&chat.id).await.unwrap_or_default(); - let rendered = render_entries(&entries, RenderOptions::default()); + // The session row and the transcript sync separately: for a chat + // hosted on another device the "idle" row can land here before the + // reply does. After a completed send, give the replica a moment to + // catch up instead of reporting an empty reply. + let grace = std::time::Instant::now() + TRANSCRIPT_GRACE; + let rendered = loop { + let entries = self.zeron.transcript(&chat.id).await.unwrap_or_default(); + let rendered = render_entries(&entries, RenderOptions::default()); + let settled = rendered + .iter() + .rev() + .find(|m| m.role == zeron_doc::MessageRole::Assistant) + .is_some_and(|m| { + m.created_at >= since_millis.saturating_sub(2_000) + && m.status != Some(zeron_doc::MessageStatus::Streaming) + }); + if outcome != TurnOutcome::Completed + || since_millis == 0 + || settled + || std::time::Instant::now() >= grace + { + break rendered; + } + tokio::time::sleep(Duration::from_millis(250)).await; + }; let replies: Vec<&RenderedMessage> = rendered .iter() .filter(|m| m.role == zeron_doc::MessageRole::Assistant) @@ -1086,6 +1370,190 @@ fn default_harness(harnesses: &[HarnessInfo]) -> anyhow::Result { }) } +fn available_harnesses(harnesses: &[HarnessInfo]) -> String { + let ids: Vec = harnesses + .iter() + .filter(|h| h.available()) + .map(|h| { + serde_json::to_value(h.id) + .ok() + .and_then(|v| v.as_str().map(str::to_owned)) + .unwrap_or_else(|| h.name.clone()) + }) + .collect(); + if ids.is_empty() { + "none".into() + } else { + ids.join(", ") + } +} + +/// `id → its spawner` over a registry snapshot, for [`spawn_depth`]. +fn spawner_lookup(chats: &[Chat]) -> impl Fn(&str) -> Option + '_ { + |id| { + chats + .iter() + .find(|c| c.id == id) + .and_then(|c| c.spawned_by_chat_id.clone()) + } +} + +/// Unarchived chats `spawner` created (side and top-level). +fn live_spawns(chats: &[Chat], spawner: &str) -> usize { + chats + .iter() + .filter(|c| !c.archived && c.spawned_by_chat_id.as_deref() == Some(spawner)) + .count() +} + +/// Refuse when `spawner` already sits at [`MAX_SPAWN_DEPTH`] (or its +/// provenance loops), so spawned top-level chats cannot recurse unbounded. +fn check_spawn_depth(spawner: &Chat, chats: &[Chat]) -> anyhow::Result<()> { + match spawn_depth(&spawner.id, spawner_lookup(chats)) { + Some(depth) if depth < MAX_SPAWN_DEPTH => Ok(()), + Some(depth) => anyhow::bail!( + "This chat is {depth} agent spawns deep (limit {MAX_SPAWN_DEPTH}); it can run and message chats but not create more. Ask the chat that spawned you to create it." + ), + None => anyhow::bail!("This chat's spawn provenance loops; it cannot create chats."), + } +} + +/// "Online execution hosts: …" for error messages. +fn host_listing(devices: &[Device], local: &str) -> String { + let now = chrono::Utc::now(); + let hosts: Vec = devices + .iter() + .filter(|d| is_execution_host(d) && is_online(d, local, now)) + .map(|d| { + let tag = if d.id == local { ", this device" } else { "" }; + format!("{} ({}{tag})", d.name, d.id) + }) + .collect(); + if hosts.is_empty() { + "no execution host is online".into() + } else { + format!("online execution hosts: {}", hosts.join(", ")) + } +} + +/// The chosen host must run an engine and be reachable right now: commands +/// to an offline host would sit in the doc until it returns, which an agent +/// waiting on a reply cannot tell from a hang. +fn check_host(device: &Device, devices: &[Device], local: &str) -> anyhow::Result<()> { + anyhow::ensure!( + is_execution_host(device), + "device {} ({}) is a {} viewer without an agent engine, so it cannot run chats; {}", + device.name, + device.id, + device.platform, + host_listing(devices, local) + ); + let now = chrono::Utc::now(); + if !is_online(device, local, now) { + let seen = device.last_seen_at.map_or_else( + || "never seen".to_owned(), + |at| format!("last seen {}s ago", (now - at).num_seconds().max(0)), + ); + anyhow::bail!( + "device {} ({}) is offline ({seen}); {}", + device.name, + device.id, + host_listing(devices, local) + ); + } + Ok(()) +} + +/// Project and/or device → (project, host device id). With both, the project +/// is looked up among that device's projects and must live there. +fn resolve_target( + project: Option<&str>, + device: Option<&str>, + spaces: &[Space], + devices: &[Device], + local: &str, +) -> anyhow::Result<(Option, String)> { + let project = project.map(str::trim).filter(|p| !p.is_empty()); + let device = device.map(str::trim).filter(|d| !d.is_empty()); + let device_id = match device { + Some(key) => Some(resolve_device_in(devices, key)?), + None => None, + }; + match (project, device_id) { + (Some(project), Some(device_id)) => { + let on_device: Vec = spaces + .iter() + .filter(|s| s.device_id == device_id) + .cloned() + .collect(); + match resolve_space_in(&on_device, project) { + Ok(space) => Ok((Some(space), device_id)), + Err(_) => { + let listed: Vec = on_device + .iter() + .map(|s| format!("{} ({})", s.path, s.id)) + .collect(); + anyhow::bail!( + "no project matches {project:?} on device {device_id}; its projects: {}", + if listed.is_empty() { + "none (omit project for a project-less chat)".into() + } else { + listed.join(", ") + } + ) + } + } + } + (Some(project), None) => { + // The same repo is often a project on several devices: an + // ambiguous name means this device's copy when it has exactly one. + let space = resolve_space_in(spaces, project).or_else(|err| { + let local_spaces: Vec = spaces + .iter() + .filter(|s| s.device_id == local) + .cloned() + .collect(); + resolve_space_in(&local_spaces, project) + .map_err(|_| anyhow::anyhow!("{err} (or pass device to pick the host)")) + })?; + let device_id = space.device_id.clone(); + Ok((Some(space), device_id)) + } + (None, Some(device_id)) => Ok((None, device_id)), + (None, None) => Ok((None, local.to_owned())), + } +} + +/// Device id or exact (case-insensitive) name over a snapshot. +fn resolve_device_in(devices: &[Device], key: &str) -> anyhow::Result { + if let Some(device) = devices.iter().find(|d| d.id == key) { + return Ok(device.id.clone()); + } + let by_name: Vec<&Device> = devices + .iter() + .filter(|d| d.name.trim().eq_ignore_ascii_case(key)) + .collect(); + match by_name.as_slice() { + [one] => Ok(one.id.clone()), + [] => anyhow::bail!( + "no device matches {key:?}; known: {}", + devices + .iter() + .map(|d| format!("{} ({})", d.name, d.id)) + .collect::>() + .join(", ") + ), + many => anyhow::bail!( + "{} devices are named {key:?}; use an id: {}", + many.len(), + many.iter() + .map(|d| d.id.as_str()) + .collect::>() + .join(", ") + ), + } +} + #[cfg(test)] mod tests { use super::*; @@ -1100,8 +1568,18 @@ mod tests { #[derive(Default)] struct World { writes: Mutex>, + /// Catalog reads (ListHarnesses/ListModels) with their params, to + /// assert device routing. + reads: Mutex>, dispatch_barrier: Option, beta_parent: Option, + /// Extra chat rows appended to the two fixed ones. + extra_chats: Vec, + /// Simulate a remote host: every created chat's session row reads as + /// a finished turn at once, while its reply reaches the transcript + /// only from the third read on (the doc syncing behind the row). + lagging_remote_reply: bool, + transcript_reads: std::sync::atomic::AtomicUsize, } fn stream(item: Value) -> RpcReply { @@ -1116,34 +1594,108 @@ mod tests { methods::ENGINE_INFO => RpcReply::Value(json!({ "deviceId": "dev-local", "workspaceScope": "local" })), - methods::WATCH_DEVICES => stream(json!([{ - "id": "dev-local", "name": "Laptop", "platform": "linux", - "lastSeenAt": null - }])), - methods::WATCH_SPACES => stream(json!([{ - "id": "space-1", "deviceId": "dev-local", "path": "/repo/comet", - "gitDetected": true, "createdAt": "2026-09-01T00:00:00Z" - }])), - methods::WATCH_CHATS => stream(json!([ + methods::WATCH_DEVICES => { + let now = chrono::Utc::now(); + stream(json!([ + { "id": "dev-local", "name": "Laptop", "platform": "linux", + "lastSeenAt": null }, + { "id": "dev-gpu", "name": "GPU box", "platform": "linux", + "lastSeenAt": now - chrono::Duration::seconds(10), + "capabilities": ["message-queue-v1"] }, + { "id": "dev-pixel", "name": "Pixel", "platform": "android", + "lastSeenAt": now, "capabilities": ["message-queue-v1"] }, + { "id": "dev-old", "name": "Old desktop", "platform": "macos", + "lastSeenAt": now - chrono::Duration::days(2) }, + { "id": "dev-phone", "name": "iPhone", "platform": "ios", + "lastSeenAt": now }, + ])) + } + methods::WATCH_SPACES => stream(json!([ { - "id": "chat-alpha-1", "deviceId": "dev-local", "title": "Alpha", - "archived": false, "spaceId": "space-1", - "config": { "harness": "claude-code", "model": "opus", "reasoning": null, "sandbox": "workspace-write" }, - "createdAt": "2026-09-01T00:00:00Z" + "id": "space-1", "deviceId": "dev-local", "path": "/repo/comet", + "gitDetected": true, "createdAt": "2026-09-01T00:00:00Z" }, { - "id": "chat-beta-2", "deviceId": "dev-local", "title": "Beta", - "parentChatId": self.beta_parent, - "archived": false, "spaceId": "space-1", - "createdAt": "2026-09-02T00:00:00Z" + "id": "space-gpu", "deviceId": "dev-gpu", "path": "/srv/train", + "gitDetected": true, "createdAt": "2026-09-01T00:00:00Z" + }, + { + "id": "space-gpu-comet", "deviceId": "dev-gpu", "path": "/home/gpu/comet", + "gitDetected": false, "createdAt": "2026-09-01T00:00:00Z" } ])), + methods::WATCH_CHATS => { + let mut chats = vec![ + json!({ + "id": "chat-alpha-1", "deviceId": "dev-local", "title": "Alpha", + "archived": false, "spaceId": "space-1", + "config": { "harness": "claude-code", "model": "opus", "reasoning": null, "sandbox": "workspace-write" }, + "createdAt": "2026-09-01T00:00:00Z" + }), + json!({ + "id": "chat-beta-2", "deviceId": "dev-local", "title": "Beta", + "parentChatId": self.beta_parent, + "archived": false, "spaceId": "space-1", + "createdAt": "2026-09-02T00:00:00Z" + }), + ]; + chats.extend(self.extra_chats.iter().cloned()); + stream(Value::Array(chats)) + } + methods::WATCH_SESSIONS if self.lagging_remote_reply => { + let rows: Vec = self + .writes + .lock() + .unwrap() + .iter() + .filter(|(_, p)| p["op"] == "createChat") + .map(|(_, p)| json!({ + "chatId": p["chatId"], "deviceId": p["deviceId"], "status": "idle", + "startedAt": null, "updatedAt": chrono::Utc::now(), + "lastCompletedTurn": "turn-1" + })) + .collect(); + stream(Value::Array(rows)) + } methods::WATCH_SESSIONS => stream(json!([])), + methods::WATCH_DOC_MESSAGES + if self.lagging_remote_reply + && self + .transcript_reads + .fetch_add(1, std::sync::atomic::Ordering::SeqCst) + >= 2 => + { + stream(json!({ "reset": [ + { "id": "u1", "role": "user", "createdAt": 1, "deviceId": "dev-gpu", + "parts": [{ "kind": "text", "id": "t", "text": "go" }] }, + { "id": "a9", "role": "assistant", "createdAt": now_millis(), + "deviceId": "dev-gpu", "status": "complete", + "parts": [{ "kind": "text", "id": "t", "text": "late reply" }] } + ]})) + } + methods::LIST_HARNESSES | methods::LIST_MODELS + if params.get("targetDeviceId").is_some() => + { + self.reads + .lock() + .unwrap() + .push((method.to_owned(), params.clone())); + if method == methods::LIST_MODELS { + RpcReply::Value(json!([{ "id": "gpu-model", "label": "GPU model" }])) + } else { + // The GPU box runs Codex only. + RpcReply::Value(json!([ + { "id": "claude-code", "name": "Claude Code", "installed": false }, + { "id": "codex", "name": "Codex", "installed": true, "enabled": true } + ])) + } + } methods::LIST_HARNESSES => RpcReply::Value(json!([ { "id": "claude-code", "name": "Claude Code", "supportsSteering": true, "steeringMode": "step-boundary", "reasoningLevels": [], "installed": true, "enabled": true }, { "id": "codex", "name": "Codex", "supportsSteering": true, - "steeringMode": "turn-boundary", "reasoningLevels": [], "installed": false, "enabled": true } + "steeringMode": "turn-boundary", "reasoningLevels": [], "installed": false, "enabled": true }, + { "id": "mock", "name": "Mock", "installed": true, "enabled": false } ])), methods::LIST_MODELS => RpcReply::Value(json!([ { "id": "opus", "label": "Opus" }, { "id": "sonnet", "label": "Sonnet" } @@ -1281,6 +1833,7 @@ mod tests { Some(&session), "follow up".into(), "auto", + None, ) .await .unwrap(); @@ -1514,6 +2067,436 @@ mod tests { assert!(world.writes.lock().unwrap().is_empty()); } + fn origin(chat: &str) -> Origin { + Origin { + chat_id: Some(chat.into()), + device_id: Some("dev-local".into()), + } + } + + fn creates(world: &World) -> Vec { + world + .writes + .lock() + .unwrap() + .iter() + .filter(|(m, p)| m == methods::MUTATE && p["op"] == "createChat") + .map(|(_, p)| p.clone()) + .collect() + } + + #[tokio::test] + async fn kinds_record_placement_and_provenance() { + let world = Arc::new(World::default()); + let alpha = tools(world.clone(), origin("chat-alpha-1")); + + // Default inside a chat: a side chat under it, spawned by it. + let side = alpha + .call("create_chat", json!({ "project": "comet" })) + .await + .unwrap(); + assert_eq!(side["kind"], "side"); + assert_eq!(side["parentChatId"], "chat-alpha-1"); + assert_eq!(side["spawnedByChatId"], "chat-alpha-1"); + + // kind chat: top-level (no parent) with provenance only. + let top = alpha + .call("create_chat", json!({ "project": "comet", "kind": "chat" })) + .await + .unwrap(); + assert_eq!(top["kind"], "chat"); + assert!(top["parentChatId"].is_null()); + assert_eq!(top["spawnedByChatId"], "chat-alpha-1"); + + let rows = creates(&world); + assert_eq!(rows[0]["parentChatId"], "chat-alpha-1"); + assert_eq!(rows[0]["spawnedByChatId"], "chat-alpha-1"); + assert!(rows[1].get("parentChatId").is_none(), "{}", rows[1]); + assert_eq!(rows[1]["spawnedByChatId"], "chat-alpha-1"); + + // A top-level chat has no placement parent to override. + let err = alpha + .call( + "create_chat", + json!({ "kind": "chat", "parent": "Alpha", "project": "comet" }), + ) + .await + .unwrap_err(); + assert!(err.contains("parent only applies"), "{err}"); + let err = alpha + .call("create_chat", json!({ "kind": "sidebar" })) + .await + .unwrap_err(); + assert!(err.contains("unknown kind"), "{err}"); + + // Outside a chat: nothing to hang a side chat under. + let terminal = tools(world.clone(), Origin::default()); + let err = terminal + .call("create_chat", json!({ "kind": "side" })) + .await + .unwrap_err(); + assert!(err.contains("needs a parent"), "{err}"); + let plain = terminal.call("create_chat", json!({})).await.unwrap(); + assert_eq!(plain["kind"], "chat"); + assert!(plain["spawnedByChatId"].is_null()); + assert_eq!(creates(&world).len(), 3); + } + + #[tokio::test] + async fn targets_any_online_execution_host() { + let world = Arc::new(World::default()); + let tools = tools(world.clone(), origin("chat-alpha-1")); + + // A project on the GPU box: that device hosts it, and harness and + // model checks ask the GPU box, not this engine. + let err = tools + .call( + "create_chat", + json!({ "project": "/srv/train", "kind": "chat", "harness": "claude-code" }), + ) + .await + .unwrap_err(); + assert!(err.contains("not available on device dev-gpu"), "{err}"); + assert!(err.contains("codex"), "{err}"); + let created = tools + .call( + "create_chat", + json!({ "project": "train", "kind": "chat", "harness": "codex", + "model": "gpu-model", "prompt": "train it" }), + ) + .await + .unwrap(); + assert_eq!(created["deviceId"], "dev-gpu"); + assert_eq!(created["deviceName"], "GPU box"); + assert_eq!(created["project"]["id"], "space-gpu"); + { + let reads = world.reads.lock().unwrap(); + assert!(reads.iter().all(|(_, p)| p["targetDeviceId"] == "dev-gpu")); + assert!(reads.iter().any(|(m, _)| m == methods::LIST_MODELS)); + } + let run = world + .writes + .lock() + .unwrap() + .iter() + .find(|(m, _)| m == methods::QUEUE_COMMAND) + .cloned() + .unwrap(); + assert_eq!(run.1["command"]["request"]["cwd"], "/srv/train"); + assert_eq!(run.1["command"]["request"]["harness"], "codex"); + + // Project-less on a device by name; a side chat can live there too. + let side = tools + .call("create_chat", json!({ "device": "gpu box", "harness": "codex" })) + .await + .unwrap(); + assert_eq!(side["deviceId"], "dev-gpu"); + assert_eq!(side["kind"], "side"); + assert!(side["project"].is_null()); + + // An on-device Android engine is a host like any other. + let phone = tools + .call("create_chat", json!({ "device": "Pixel", "harness": "codex" })) + .await + .unwrap(); + assert_eq!(phone["deviceId"], "dev-pixel"); + + // device + project: the project is looked up on that device. + let scoped = tools + .call( + "create_chat", + json!({ "device": "dev-gpu", "project": "comet", "harness": "codex" }), + ) + .await + .unwrap(); + assert_eq!(scoped["project"]["id"], "space-gpu-comet"); + let err = tools + .call( + "create_chat", + json!({ "device": "dev-gpu", "project": "/repo/comet" }), + ) + .await + .unwrap_err(); + assert!(err.contains("on device dev-gpu"), "{err}"); + assert!(err.contains("/srv/train (space-gpu)"), "{err}"); + + // Offline hosts, phone clients and unknown devices are refused with + // the valid choices listed. + for (device, want) in [ + ("Old desktop", "is offline"), + ("iPhone", "ios viewer without an agent engine"), + ("nope", "no device matches"), + ] { + let err = tools + .call("create_chat", json!({ "device": device })) + .await + .unwrap_err(); + assert!(err.contains(want), "{device}: {err}"); + if want != "no device matches" { + assert!(err.contains("GPU box (dev-gpu)"), "{err}"); + assert!(err.contains("Laptop (dev-local, this device)"), "{err}"); + let hosts = err.split("online execution hosts:").nth(1).unwrap(); + assert!(!hosts.contains("iPhone"), "{err}"); + assert!(!hosts.contains("Old desktop"), "{err}"); + } + } + let err = tools + .call("create_chat", json!({ "project": "comet" , "device": "Old desktop"})) + .await + .unwrap_err(); + assert!(err.contains("no project matches"), "{err}"); + // Without a device, a name shared across devices means this device's. + let local = tools + .call("create_chat", json!({ "project": "comet" })) + .await + .unwrap(); + assert_eq!(local["deviceId"], "dev-local"); + // No harness named: the host's own default (the GPU box has Codex). + let default = tools + .call("create_chat", json!({ "project": "train" })) + .await + .unwrap(); + assert_eq!(default["harness"], "codex"); + assert_eq!(creates(&world).len(), 6); + } + + #[tokio::test] + async fn a_remote_reply_that_syncs_after_the_session_row_is_still_returned() { + let world = Arc::new(World { + lagging_remote_reply: true, + ..Default::default() + }); + let tools = tools(world.clone(), origin("chat-alpha-1")); + let created = tools + .call( + "create_chat", + json!({ "kind": "chat", "device": "GPU box", "harness": "codex", + "prompt": "go", "wait": true, "timeout_secs": 10 }), + ) + .await + .unwrap(); + assert_eq!(created["turn"]["outcome"], "completed"); + assert_eq!(created["turn"]["replies"][0]["text"], "late reply", "{created}"); + assert!(world.transcript_reads.load(std::sync::atomic::Ordering::SeqCst) >= 3); + } + + #[tokio::test] + async fn the_mock_rig_is_explicit_only() { + let world = Arc::new(World::default()); + let tools = tools(world.clone(), Origin::default()); + let created = tools + .call("create_chat", json!({ "harness": "mock" })) + .await + .unwrap(); + assert_eq!(created["harness"], "mock"); + let err = tools + .call("create_chat", json!({ "harness": "codex" })) + .await + .unwrap_err(); + assert!(err.contains("available there: claude-code"), "{err}"); + let default = tools.call("create_chat", json!({})).await.unwrap(); + assert_eq!(default["harness"], "claude-code"); + } + + #[tokio::test] + async fn device_listings_expose_hosts_and_their_projects() { + let world = Arc::new(World::default()); + let tools = tools(world.clone(), Origin::default()); + let listed = tools.call("list_devices", json!({})).await.unwrap(); + let by_id = |id: &str| { + listed["devices"] + .as_array() + .unwrap() + .iter() + .find(|d| d["id"] == id) + .cloned() + .unwrap() + }; + assert_eq!(by_id("dev-local")["online"], true); + assert_eq!(by_id("dev-local")["local"], true); + assert_eq!(by_id("dev-gpu")["online"], true); + assert_eq!(by_id("dev-gpu")["executionHost"], true); + assert_eq!(by_id("dev-gpu")["projects"][0]["path"], "/srv/train"); + assert_eq!(by_id("dev-old")["online"], false); + assert_eq!(by_id("dev-phone")["executionHost"], false); + assert_eq!(by_id("dev-pixel")["executionHost"], true); + + let projects = tools.call("list_projects", json!({})).await.unwrap(); + assert_eq!(projects["projects"][1]["deviceName"], "GPU box"); + assert_eq!(projects["projects"][1]["deviceOnline"], true); + + let remote = tools + .call("list_harnesses", json!({ "device": "GPU box" })) + .await + .unwrap(); + assert_eq!(remote["deviceId"], "dev-gpu"); + assert_eq!(remote["harnesses"][1]["available"], true); + let local = tools.call("list_harnesses", Value::Null).await.unwrap(); + assert!(local["deviceId"].is_null()); + let models = tools + .call("list_models", json!({ "harness": "codex", "device": "dev-gpu" })) + .await + .unwrap(); + assert_eq!(models["models"][0]["id"], "gpu-model"); + let err = tools + .call("list_harnesses", json!({ "device": "Old desktop" })) + .await + .unwrap_err(); + assert!(err.contains("offline"), "{err}"); + } + + fn spawned(id: &str, by: &str, parent: Option<&str>, archived: bool) -> Value { + json!({ + "id": id, "deviceId": "dev-local", "title": id, "archived": archived, + "spaceId": "space-1", "spawnedByChatId": by, "parentChatId": parent, + "createdAt": "2026-09-03T00:00:00Z" + }) + } + + #[tokio::test] + async fn spawned_top_level_chats_spawn_until_the_depth_limit() { + // alpha (user) → w1 → w2 → w3: depth 1, 2, 3. + let world = Arc::new(World { + extra_chats: vec![ + spawned("w1", "chat-alpha-1", None, false), + spawned("w2", "w1", None, false), + spawned("w3", "w2", None, false), + spawned("w2-side", "w2", Some("w2"), false), + ], + ..Default::default() + }); + for (from, ok) in [("w1", true), ("w2", true), ("w3", false)] { + let result = tools(world.clone(), origin(from)) + .call("create_chat", json!({ "kind": "chat" })) + .await; + match (ok, result) { + (true, Ok(created)) => assert_eq!(created["spawnedByChatId"], from), + (false, Err(err)) => { + assert!(err.contains("3 agent spawns deep"), "{err}"); + assert!(err.contains("limit 3"), "{err}"); + } + (ok, other) => panic!("{from}: expected ok={ok}, got {other:?}"), + } + } + // A spawned side chat still cannot create anything. + let err = tools(world.clone(), origin("w2-side")) + .call("create_chat", json!({ "kind": "chat" })) + .await + .unwrap_err(); + assert!(err.contains("Side chats cannot"), "{err}"); + + let who = tools(world.clone(), origin("w3")) + .call("whoami", json!({})) + .await + .unwrap(); + assert_eq!(who["spawning"]["spawnDepth"], 3); + assert_eq!(who["spawning"]["canCreateChats"], false); + assert_eq!(who["chat"]["kind"], "chat"); + assert_eq!(who["chat"]["spawnedByChatId"], "w2"); + + // spawned_by lists both kinds; parent only the side chats. + let listed = tools(world.clone(), Origin::default()) + .call("list_chats", json!({ "spawned_by": "w2" })) + .await + .unwrap(); + let mut ids: Vec<&str> = listed["chats"] + .as_array() + .unwrap() + .iter() + .map(|c| c["id"].as_str().unwrap()) + .collect(); + ids.sort_unstable(); + assert_eq!(ids, ["w2-side", "w3"]); + let listed = tools(world.clone(), Origin::default()) + .call("list_chats", json!({ "parent": "w2" })) + .await + .unwrap(); + assert_eq!(listed["total"], 1); + assert_eq!(listed["chats"][0]["kind"], "side"); + } + + #[tokio::test] + async fn fan_out_is_capped_by_live_spawns_and_rate() { + // 31 live spawns (plus archived ones, which do not count): one more + // fits, the next is refused until something is archived. + let mut extra: Vec = (0..MAX_LIVE_SPAWNS - 1) + .map(|i| spawned(&format!("live-{i}"), "chat-alpha-1", None, false)) + .collect(); + extra.push(spawned("done", "chat-alpha-1", None, true)); + let world = Arc::new(World { + extra_chats: extra, + ..Default::default() + }); + let alpha = tools(world.clone(), origin("chat-alpha-1")); + let batch = alpha + .call( + "create_chats", + json!({ "requests": [{ "kind": "chat" }, { "kind": "chat" }, { "kind": "chat" }] }), + ) + .await + .unwrap(); + let errors: Vec<&Value> = batch["results"] + .as_array() + .unwrap() + .iter() + .filter(|r| r["isError"] == true) + .collect(); + assert_eq!(errors.len(), 2, "{batch}"); + assert!( + errors[0]["error"] + .as_str() + .unwrap() + .contains("archive finished ones") + ); + assert_eq!(creates(&world).len(), 1); + + // The per-server rate limit applies even without a spawner. + let world = Arc::new(World::default()); + let terminal = tools(world.clone(), Origin::default()); + for _ in 0..SPAWN_RATE.0 { + terminal.call("create_chat", json!({})).await.unwrap(); + } + let err = terminal.call("create_chat", json!({})).await.unwrap_err(); + assert!(err.contains("retry in"), "{err}"); + } + + #[tokio::test] + async fn worktree_runs_the_first_turn_in_a_host_created_checkout() { + let world = Arc::new(World::default()); + let tools = tools(world.clone(), origin("chat-alpha-1")); + for (args, want) in [ + (json!({ "project": "comet", "worktree": true }), "needs a prompt"), + ( + json!({ "device": "dev-gpu", "project": "comet", "worktree": true, "prompt": "x" }), + "git project", + ), + ( + json!({ "project": "comet", "worktree": true, "prompt": "x", "cwd": "/w" }), + "not both", + ), + ] { + let err = tools.call("create_chat", args).await.unwrap_err(); + assert!(err.contains(want), "{err}"); + } + tools + .call( + "create_chat", + json!({ "project": "comet", "kind": "chat", "worktree": true, + "branch": "main", "prompt": "fix it" }), + ) + .await + .unwrap(); + let writes = world.writes.lock().unwrap(); + let (_, run) = writes + .iter() + .find(|(m, _)| m == methods::QUEUE_COMMAND) + .unwrap(); + let spec = &run["command"]["request"]["worktree"]; + assert_eq!(spec["repoPath"], "/repo/comet"); + assert_eq!(spec["base"], "main"); + assert_eq!(spec["spaceId"], "space-1"); + } + #[tokio::test] async fn initialize_and_list_over_jsonrpc() { let world = Arc::new(World::default()); diff --git a/crates/mcp/src/transcript.rs b/crates/mcp/src/transcript.rs index 4ad48980a3..fc09c1d1b6 100644 --- a/crates/mcp/src/transcript.rs +++ b/crates/mcp/src/transcript.rs @@ -126,10 +126,17 @@ fn render_one(entry: &SessionMessageEntry, options: RenderOptions) -> RenderedMe is_error, resolved, output, + created_chat_ids, .. } => { if options.include_tools { - tools.push(tool_line(call, *is_error, *resolved, output.as_deref())); + let mut line = tool_line(call, *is_error, *resolved, output.as_deref()); + // A Zeron create call names the chats it made, so an + // orchestrator reading a transcript can follow them. + if !created_chat_ids.is_empty() { + line.push_str(&format!(" [created: {}]", created_chat_ids.join(", "))); + } + tools.push(line); } } MessagePart::Input { @@ -271,6 +278,27 @@ mod tests { subagent_ref: None, subagent_status: None, subagent_tail: None, + created_chat_ids: Vec::new(), + }, + MessagePart::Tool { + id: "z".into(), + call: ToolCall::Mcp { + server: "zeron".into(), + tool: "create_chats".into(), + input: None, + }, + is_error: false, + resolved: true, + output: None, + diff: None, + output_ref: None, + output_bytes: None, + diff_ref: None, + diff_stats: None, + subagent_ref: None, + subagent_status: None, + subagent_tail: None, + created_chat_ids: vec!["w1".into(), "s1".into()], }, MessagePart::Text { id: "t2".into(), @@ -289,7 +317,13 @@ mod tests { assert_eq!(rendered.len(), 2); assert_eq!(rendered[0].text, "hello"); assert_eq!(rendered[1].text, "done"); - assert_eq!(rendered[1].tools, vec!["exec: cargo test → ok"]); + assert_eq!( + rendered[1].tools, + vec![ + "exec: cargo test → ok", + "mcp: zeron/create_chats [created: w1, s1]" + ] + ); assert!(rendered[1].reasoning.is_none(), "reasoning is opt-in"); assert_eq!( rendered[1] diff --git a/crates/mcp/src/zeron.rs b/crates/mcp/src/zeron.rs index d31767d22c..1bd80f7a86 100644 --- a/crates/mcp/src/zeron.rs +++ b/crates/mcp/src/zeron.rs @@ -244,13 +244,34 @@ impl Zeron { } pub async fn harnesses(&self) -> anyhow::Result> { - let value = self.call(methods::LIST_HARNESSES, json!({})).await?; + self.harnesses_on(None).await + } + + /// Harnesses offered on `device` (`None`: this engine's own). A remote + /// device is asked through the engine's `targetDeviceId` relay forward, + /// so the answer reflects what that host can actually run. + pub async fn harnesses_on(&self, device: Option<&str>) -> anyhow::Result> { + let value = self + .call(methods::LIST_HARNESSES, targeted(json!({}), device)) + .await?; serde_json::from_value(value).context("ListHarnesses: unexpected shape") } pub async fn models(&self, harness: HarnessId) -> anyhow::Result> { + self.models_on(harness, None).await + } + + /// [`models`](Self::models) as offered on `device` (`None`: local). + pub async fn models_on( + &self, + harness: HarnessId, + device: Option<&str>, + ) -> anyhow::Result> { let value = self - .call(methods::LIST_MODELS, json!({ "harness": harness })) + .call( + methods::LIST_MODELS, + targeted(json!({ "harness": harness }), device), + ) .await?; serde_json::from_value(value).context("ListModels: unexpected shape") } @@ -473,6 +494,38 @@ impl Zeron { } } +/// `params` routed to `device` when it names another engine. +fn targeted(mut params: Value, device: Option<&str>) -> Value { + if let Some(device) = device.filter(|d| !d.is_empty()) { + params["targetDeviceId"] = json!(device); + } + params +} + +/// A device heartbeating within this window counts as online — the Devices +/// page's window (engines beat every 15s; 70s tolerates missed beats). +pub const HOST_ONLINE_WINDOW: chrono::Duration = chrono::Duration::seconds(70); + +/// Can this device run chats? Engines stamp `capabilities` on their own row +/// (desktop, headless, and on-device Android engines alike); a row without +/// any is either an older engine — judged by its OS — or a phone client that +/// only views chats hosted elsewhere. +pub fn is_execution_host(device: &Device) -> bool { + !device.capabilities.is_empty() + || !matches!( + device.platform.to_ascii_lowercase().as_str(), + "ios" | "ipados" | "android" + ) +} + +/// Online now: this engine's own device always is; others by heartbeat. +pub fn is_online(device: &Device, local_device_id: &str, now: chrono::DateTime) -> bool { + device.id == local_device_id + || device + .last_seen_at + .is_some_and(|at| now.signed_duration_since(at) <= HOST_ONLINE_WINDOW) +} + /// The session row that speaks for `chat`: the host device's, else the /// freshest one (a chat re-homed mid-flight can briefly have two). pub fn session_for(sessions: &[Session], chat: &Chat) -> Option { @@ -651,6 +704,7 @@ mod tests { harness_session_id: None, harness_session_cwd: None, parent_chat_id: None, + spawned_by_chat_id: None, space_id: None, last_seen_at: None, room_gen: None, diff --git a/crates/mobile/src/client_ffi/types.rs b/crates/mobile/src/client_ffi/types.rs index eb3d4d1752..db3cf64517 100644 --- a/crates/mobile/src/client_ffi/types.rs +++ b/crates/mobile/src/client_ffi/types.rs @@ -505,6 +505,11 @@ pub struct SessionRow { /// Oldest unadopted send from this device (open sessions only). pub send_state: Option, pub parent_chat_id: Option, + /// The chat whose agent created this one (Zeron MCP): set on + /// agent-spawned top-level chats, which list like any other session. + pub spawned_by_chat_id: Option, + /// The spawner's title for a "Spawned by …" line (`None`: unknown). + pub spawned_by_title: Option, /// 2 = chat2; 1 = legacy (not dialable). pub room_gen: u32, } @@ -545,6 +550,8 @@ impl From<&zc::SessionRow> for SessionRow { pull_request: r.pull_request.as_ref().map(PullRequest::from), send_state: r.send_state.map(Into::into), parent_chat_id: r.parent_chat_id.clone(), + spawned_by_chat_id: r.spawned_by_chat_id.clone(), + spawned_by_title: r.spawned_by_title.clone(), room_gen: r.room_gen, } } diff --git a/crates/mobile/src/layout/tests.rs b/crates/mobile/src/layout/tests.rs index 01ac1c72cb..c3ba18f97a 100644 --- a/crates/mobile/src/layout/tests.rs +++ b/crates/mobile/src/layout/tests.rs @@ -520,6 +520,7 @@ fn running_subagent_shows_a_spinner_after_its_spawn_resolves() { subagent_ref: Some("sub-1".into()), subagent_status: Some(status), subagent_tail: None, + created_chat_ids: Vec::new(), }; let frame_for = |status: SubagentStatus| { let mut w = worker(390.0); diff --git a/crates/proto/src/created_chats.rs b/crates/proto/src/created_chats.rs new file mode 100644 index 0000000000..634988bbbe --- /dev/null +++ b/crates/proto/src/created_chats.rs @@ -0,0 +1,294 @@ +//! Zeron MCP `create_chat` / `create_chats` calls, as any consumer of a +//! transcript sees them: which tool calls they are (every harness names MCP +//! tools its own way) and which chats their result says they created. +//! +//! Claude `mcp__zeron__create_chat` and Pi `zeron_create_chat` decode to +//! `ToolCall::Mcp { server: "zeron", .. }`, as do Codex and Cursor; OpenCode +//! `zeron_create_chat` and ACP titles such as `zeron/create_chat` or +//! `create_chat (zeron MCP Server)` stay `ToolCall::Unknown` — so detection +//! keys on the tool token plus a mention of the Zeron server, never on one +//! spelling. The session-doc fold uses [`parse_created_chats`] to keep the +//! created chat ids on the tool part (outputs themselves never enter the +//! doc); the desktop transcript uses both to draw links to those chats. + +use std::collections::HashSet; + +use crate::ToolCall; + +/// Which chat-creation tool a call is. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CreateChatOp { + /// `create_chat`: one chat. + Single, + /// `create_chats`: a batch, any number of chats. + Batch, +} + +/// `Some` when `call` is the Zeron MCP server's `create_chat`/`create_chats` +/// under any harness's naming scheme. +pub fn create_chat_op(call: &ToolCall) -> Option { + let (server, name) = match call { + ToolCall::Mcp { server, tool, .. } => (Some(server.as_str()), tool.as_str()), + ToolCall::Unknown { name, .. } => (None, name.as_str()), + _ => return None, + }; + let name = name.trim().to_ascii_lowercase().replace('-', "_"); + let (op, bare) = find_op(&name)?; + let zeron = name.contains("zeron") + || server.is_some_and(|server| server.to_ascii_lowercase().contains("zeron")); + // A bare `create_chat` with no server at all (an ACP title that dropped + // it) is still ours; another MCP server's `create_chat` is not. + (zeron || (bare && server.is_none())).then_some(op) +} + +/// The op token inside a normalized tool name, and whether the name is +/// nothing but the token. The token must stand alone: `create_chat` inside +/// `create_chats` or `create_chat_room` does not count. +fn find_op(name: &str) -> Option<(CreateChatOp, bool)> { + for (token, op) in [ + ("create_chats", CreateChatOp::Batch), + ("create_chat", CreateChatOp::Single), + ] { + let mut from = 0; + while let Some(pos) = name[from..].find(token) { + let start = from + pos; + let end = start + token.len(); + let before = name[..start] + .chars() + .next_back() + .is_none_or(|c| !c.is_ascii_alphanumeric()); + let after = name[end..] + .chars() + .next() + .is_none_or(|c| !(c.is_ascii_alphanumeric() || c == '_')); + if before && after { + return Some((op, name == token)); + } + from = start + 1; + } + } + None +} + +/// One chat a create call reported in its result. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct CreatedChat { + pub chat_id: String, + /// `kind: "side"` (or a parent id) → `Some(true)`; `kind: "chat"` → + /// `Some(false)`; unknown when the result predates `kind`. + pub side: Option, + pub device_id: Option, + pub device_name: Option, + pub title: Option, +} + +/// The chats a `create_chat`/`create_chats` result names, in result order. +/// Accepts the bare result object, the batch's `{results: [{isError, +/// result}]}`, an MCP `CallToolResult` wrapper (`structuredContent`, or +/// `content[].text` holding the JSON), and fenced or double-encoded text. +/// Failed batch entries are skipped; anything unparseable yields nothing. +pub fn parse_created_chats(output: &str) -> Vec { + let text: String = output + .lines() + .filter(|line| !line.trim_start().starts_with("```")) + .collect::>() + .join("\n"); + let mut out = Vec::new(); + if let Ok(value) = serde_json::from_str::(text.trim()) { + collect(&value, &mut out, 0); + } + let mut seen = HashSet::new(); + out.retain(|chat: &CreatedChat| seen.insert(chat.chat_id.clone())); + out +} + +fn collect(value: &serde_json::Value, out: &mut Vec, depth: usize) { + use serde_json::Value; + if depth > 6 { + return; + } + match value { + Value::Object(map) => { + if map.get("isError").and_then(Value::as_bool) == Some(true) { + return; + } + if let Some(chat_id) = map + .get("chatId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty()) + { + let text = |key: &str| { + map.get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + }; + let side = match map.get("kind").and_then(Value::as_str) { + Some("side") => Some(true), + Some("chat") => Some(false), + _ => map + .get("parentChatId") + .map(|parent| !parent.is_null()) + .filter(|side| *side), + }; + out.push(CreatedChat { + chat_id: chat_id.to_owned(), + side, + device_id: text("deviceId"), + device_name: text("deviceName"), + title: text("title"), + }); + return; + } + for key in ["structuredContent", "results", "result"] { + if let Some(inner) = map.get(key) { + let before = out.len(); + collect(inner, out, depth + 1); + if out.len() > before { + return; + } + } + } + if let Some(Value::Array(content)) = map.get("content") { + for part in content { + if let Some(text) = part.get("text").and_then(Value::as_str) { + collect(&Value::String(text.to_owned()), out, depth + 1); + } + } + } + } + Value::Array(items) => { + for item in items { + collect(item, out, depth + 1); + } + } + Value::String(text) => { + if let Ok(inner) = serde_json::from_str::(text.trim()) { + collect(&inner, out, depth + 1); + } + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mcp(server: &str, tool: &str) -> ToolCall { + ToolCall::Mcp { + server: server.into(), + tool: tool.into(), + input: None, + } + } + + fn unknown(name: &str) -> ToolCall { + ToolCall::Unknown { + name: name.into(), + input: None, + } + } + + #[test] + fn create_chat_calls_are_recognized_under_every_naming_scheme() { + use CreateChatOp::{Batch, Single}; + // Claude (`mcp__zeron__create_chat`), Pi (`zeron_create_chat`), + // Codex and Cursor all decode to `Mcp { server, tool }`. + assert_eq!(create_chat_op(&mcp("zeron", "create_chat")), Some(Single)); + assert_eq!(create_chat_op(&mcp("zeron", "create_chats")), Some(Batch)); + assert_eq!(create_chat_op(&mcp("Zeron", "create_chat")), Some(Single)); + // OpenCode and ACP titles stay `Unknown`. + for name in [ + "zeron_create_chat", + "mcp__zeron__create_chat", + "zeron/create_chat", + "zeron.create_chat", + "create_chat (zeron MCP Server)", + "Zeron: create-chat", + "create_chat", + ] { + assert_eq!(create_chat_op(&unknown(name)), Some(Single), "{name}"); + } + for name in ["zeron_create_chats", "mcp__zeron__create_chats", "create_chats"] { + assert_eq!(create_chat_op(&unknown(name)), Some(Batch), "{name}"); + } + // Not ours: other servers, other zeron tools, lookalike tokens, and + // ordinary tool kinds. + assert_eq!(create_chat_op(&mcp("github", "create_chat")), None); + assert_eq!(create_chat_op(&mcp("zeron", "list_chats")), None); + assert_eq!(create_chat_op(&mcp("zeron", "create_chat_room")), None); + assert_eq!(create_chat_op(&unknown("slack_create_chat")), None); + assert_eq!(create_chat_op(&unknown("zeron_recreate_chat")), None); + assert_eq!(create_chat_op(&unknown("Agent: create_chat")), None); + assert_eq!( + create_chat_op(&ToolCall::Exec { + command: "zeron create_chat".into() + }), + None + ); + } + + #[test] + fn single_results_name_their_chat() { + let output = serde_json::to_string_pretty(&serde_json::json!({ + "chatId": "c-1", + "kind": "chat", + "deviceId": "gpu", + "deviceName": "GPU box", + "title": "Train the tokenizer", + "parentChatId": null, + "spawnedByChatId": "coordinator", + })) + .unwrap(); + assert_eq!( + parse_created_chats(&output), + [CreatedChat { + chat_id: "c-1".into(), + side: Some(false), + device_id: Some("gpu".into()), + device_name: Some("GPU box".into()), + title: Some("Train the tokenizer".into()), + }] + ); + // A side chat without `kind` (older server) reads its parent. + let side = parse_created_chats(r#"{"chatId":"s-1","parentChatId":"coordinator"}"#); + assert_eq!(side[0].side, Some(true)); + // Fenced text and a CallToolResult wrapper resolve the same way. + let fenced = format!("```json\n{output}\n```"); + assert_eq!(parse_created_chats(&fenced)[0].chat_id, "c-1"); + let wrapped = serde_json::json!({ + "content": [{ "type": "text", "text": output }], + }) + .to_string(); + assert_eq!(parse_created_chats(&wrapped)[0].chat_id, "c-1"); + } + + #[test] + fn batch_results_name_every_created_chat_and_skip_failures() { + let output = serde_json::json!({ + "results": [ + { "index": 0, "isError": false, "result": { "chatId": "a", "kind": "chat", "deviceName": "GPU box" } }, + { "index": 1, "isError": true, "result": "device offline" }, + { "index": 2, "isError": false, "result": { "chatId": "b", "kind": "side" } }, + ] + }) + .to_string(); + let chats = parse_created_chats(&output); + assert_eq!( + chats.iter().map(|c| c.chat_id.as_str()).collect::>(), + ["a", "b"] + ); + assert_eq!(chats[0].side, Some(false)); + assert_eq!(chats[1].side, Some(true)); + // structuredContent carries the same batch. + let structured = serde_json::json!({ "structuredContent": serde_json::from_str::(&output).unwrap() }).to_string(); + assert_eq!(parse_created_chats(&structured).len(), 2); + // Error text and truncated summaries name nothing. + assert!(parse_created_chats("device gpu is offline").is_empty()); + assert!(parse_created_chats("{…").is_empty()); + assert!(parse_created_chats(r#"{"isError":true,"chatId":"x"}"#).is_empty()); + } +} diff --git a/crates/proto/src/entities.rs b/crates/proto/src/entities.rs index b2b3b7cfe3..d366cee45e 100644 --- a/crates/proto/src/entities.rs +++ b/crates/proto/src/entities.rs @@ -232,15 +232,59 @@ pub struct Chat { /// deleted) is tolerated rather than cascaded. #[serde(default, skip_serializing_if = "Option::is_none")] pub parent_chat_id: Option, + /// Provenance, independent of placement: the chat whose agent created + /// this one through the Zeron MCP server. A side chat carries both this + /// and `parent_chat_id`; an agent-spawned TOP-LEVEL chat carries only + /// this, so it lists in the sidebar like a user-created chat while still + /// saying who started it. Absent for user-created chats and on rows from + /// older engines; a dangling id (spawner deleted) is tolerated. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spawned_by_chat_id: Option, } impl Chat { + /// True when this chat is a side chat (hangs off a parent and stays out + /// of the main sidebar). + pub fn is_side_chat(&self) -> bool { + self.parent_chat_id.is_some() + } + /// True when this chat syncs over the chat2 dumb relay. pub fn on_chat2(&self) -> bool { self.room_gen.unwrap_or(1) >= 2 } } +/// Deepest agent-spawn chain allowed: a user-created chat is depth 0, a chat +/// its agent spawns is depth 1, and so on. A chat AT this depth can still run +/// but cannot create further chats (side or top-level). The guard exists so a +/// top-level spawned chat — which, unlike a side chat, may itself spawn — can +/// never recurse without bound. +pub const MAX_SPAWN_DEPTH: usize = 3; + +/// How many agent-spawn hops separate `chat_id` from a user-created chat, +/// following `spawned_by_chat_id` through `spawned_by` (id → its spawner, or +/// `None` for a user-created / unknown chat). A dangling spawner id ends the +/// chain there. `None` means the chain loops (or is absurdly long), which a +/// caller must treat as "over the limit". +pub fn spawn_depth(chat_id: &str, spawned_by: impl Fn(&str) -> Option) -> Option { + let mut seen = std::collections::HashSet::new(); + let mut current = chat_id.to_owned(); + let mut depth = 0usize; + loop { + if !seen.insert(current.clone()) || depth > 64 { + return None; + } + match spawned_by(¤t) { + Some(spawner) if !spawner.trim().is_empty() => { + depth += 1; + current = spawner; + } + _ => return Some(depth), + } + } +} + impl Chat { /// True when the chat has activity the user hasn't seen on any device. pub fn unseen(&self) -> bool { @@ -1393,6 +1437,51 @@ mod tests { ); } + #[test] + fn spawned_by_is_additive_and_independent_of_placement() { + // A row from an older engine: neither link, parses as top-level. + let legacy: Chat = serde_json::from_value(serde_json::json!({ + "id": "c", "deviceId": "d", "title": null, "archived": false, + "cwd": null, "branch": null, "checkoutId": null, "config": null, + "lastMessagePreview": null, "lastMessageAt": null, + "createdAt": "2026-09-01T00:00:00Z" + })) + .unwrap(); + assert_eq!(legacy.spawned_by_chat_id, None); + assert!(!legacy.is_side_chat()); + // Unset provenance is not written, so older peers see the same shape. + let value = serde_json::to_value(&legacy).unwrap(); + assert!(value.get("spawnedByChatId").is_none()); + + // An agent-spawned top-level chat: provenance without a parent. + let mut spawned = legacy.clone(); + spawned.spawned_by_chat_id = Some("coordinator".into()); + let value = serde_json::to_value(&spawned).unwrap(); + assert_eq!(value["spawnedByChatId"], "coordinator"); + assert!(value.get("parentChatId").is_none()); + let back: Chat = serde_json::from_value(value).unwrap(); + assert_eq!(back, spawned); + assert!(!back.is_side_chat()); + } + + #[test] + fn spawn_depth_follows_provenance_and_rejects_loops() { + use std::collections::HashMap; + let links: HashMap<&str, &str> = [("c", "b"), ("b", "a"), ("x", "y"), ("y", "x")] + .into_iter() + .collect(); + let lookup = |id: &str| links.get(id).map(|s| (*s).to_owned()); + assert_eq!(spawn_depth("a", lookup), Some(0), "user-created"); + assert_eq!(spawn_depth("b", lookup), Some(1)); + assert_eq!(spawn_depth("c", lookup), Some(2)); + // A dangling spawner (deleted) ends the chain rather than failing. + assert_eq!( + spawn_depth("d", |id| (id == "d").then(|| "gone".to_owned())), + Some(1) + ); + assert_eq!(spawn_depth("x", lookup), None, "a loop is over the limit"); + } + #[test] fn workspace_file_change_contract_is_camel_case() { let changes = WorkspaceFileChanges { diff --git a/crates/proto/src/lib.rs b/crates/proto/src/lib.rs index 92729dec72..ead783d5bc 100644 --- a/crates/proto/src/lib.rs +++ b/crates/proto/src/lib.rs @@ -4,6 +4,7 @@ //! Context occupancy is replicated per chat; billing `Usage` remains a harness passthrough. pub mod agent; +pub mod created_chats; pub mod entities; pub mod file_mentions; pub mod invocation; diff --git a/crates/sync/tests/registry_client.rs b/crates/sync/tests/registry_client.rs index 5c66d37df4..7d9db1a02e 100644 --- a/crates/sync/tests/registry_client.rs +++ b/crates/sync/tests/registry_client.rs @@ -46,6 +46,7 @@ fn chat(id: &str, device_id: &str) -> Chat { harness_session_id: None, harness_session_cwd: None, parent_chat_id: None, + spawned_by_chat_id: None, space_id: None, last_seen_at: None, room_gen: None, diff --git a/crates/sync/tests/registry_edge.rs b/crates/sync/tests/registry_edge.rs index a46953fa31..3dde8d21ae 100644 --- a/crates/sync/tests/registry_edge.rs +++ b/crates/sync/tests/registry_edge.rs @@ -39,6 +39,7 @@ fn chat(id: &str, device_id: &str) -> Chat { harness_session_id: None, harness_session_cwd: None, parent_chat_id: None, + spawned_by_chat_id: None, space_id: None, last_seen_at: None, room_gen: None, diff --git a/crates/ui/examples/agent-chats-fixture.rs b/crates/ui/examples/agent-chats-fixture.rs new file mode 100644 index 0000000000..f70b205623 --- /dev/null +++ b/crates/ui/examples/agent-chats-fixture.rs @@ -0,0 +1,284 @@ +//! Isolated native review fixture for agent-spawned chats: a coordinator +//! chat whose transcript holds a Zeron `create_chats` call that made a +//! top-level chat on another device ("GPU box") and a side chat, with the +//! spawned chat listed in the sidebar wearing its "spawned by" marker. No +//! engine, nothing is sent; the tool call carries no output, exactly like a +//! synced doc, so the cards resolve through the chats' provenance. +//! +//! ZERON_AGENT_CHATS_HOVER="x,y" hovers a window point (logical px) after +//! the first frame so a tooltip shows; ZERON_PALETTE_LIGHT selects the light +//! appearance. +use gpui::{AppContext, Bounds, WindowBounds, WindowOptions, px, size}; +use zeron_ui::*; + +fn main() -> anyhow::Result<()> { + let runtime = tokio::runtime::Runtime::new()?; + let _guard = runtime.enter(); + tracing_subscriber::fmt().with_env_filter("warn").init(); + let hover = std::env::var("ZERON_AGENT_CHATS_HOVER").ok().and_then(|at| { + let (x, y) = at.split_once(',')?; + Some(gpui::point(px(x.trim().parse().ok()?), px(y.trim().parse().ok()?))) + }); + let temp = tempfile::tempdir()?; + let data = temp.path().to_path_buf(); + gpui_platform::application() + .with_assets(icons::Assets) + .run(move |cx| { + gpui_tokio::init(cx); + gpui_base::init(cx); + let mut settings = settings::UiSettings::default(); + settings.sidebar_width = 290.0; + settings.sidebar_organization = settings::SidebarOrganization::InOneList; + settings.surface = zeron_theme::SurfacePreference::Frosted; + settings.save(&data).unwrap(); + settings::init(settings.clone(), data.clone(), cx); + let fonts = typography::register_fonts(cx); + typography::init( + settings.ui_font_family.clone(), + settings.ui_font_size, + settings.terminal_font_family.clone(), + settings.terminal_font_size, + settings.code_font_family.clone(), + settings.code_font_size, + fonts, + cx, + ); + theme_library::init(data.clone(), cx); + appearance::init( + if std::env::var_os("ZERON_PALETTE_LIGHT").is_some() { + appearance::AppearanceMode::Light + } else { + appearance::AppearanceMode::Dark + }, + settings.theme_selection, + settings.accent, + settings.surface, + cx, + ); + history::init( + settings.git_history_columns, + settings.git_history_column_widths, + settings.git_history_column_order, + settings.git_history_author_display, + cx, + ); + composer::init(cx, settings.composer_send_behavior); + terminal::panel::init(cx); + app_menus::init(cx); + + let project_path = data.join("tokenizer"); + std::fs::create_dir_all(&project_path).unwrap(); + let now = chrono::Utc::now(); + let ago = |minutes: i64| now - chrono::Duration::minutes(minutes); + // The coordinator's reply started 6 minutes ago; its create_chats + // call made both chats a few seconds later. + let turn_at = ago(6); + let spawned_at = turn_at + chrono::Duration::seconds(4); + let config = serde_json::json!({ + "harness": "claude-code", "model": "claude-sonnet-4-6", + "reasoning": null, "sandbox": "workspace-write", + }); + let chat = |id: &str, + device: &str, + title: &str, + created: chrono::DateTime, + last: chrono::DateTime, + parent: Option<&str>, + spawned_by: Option<&str>| { + serde_json::from_value::(serde_json::json!({ + "id": id, "deviceId": device, + "spaceId": if device == "local" { Some("project") } else { None }, + "cwd": if device == "local" { Some(project_path.clone()) } else { None }, + "title": title, "archived": false, + "createdAt": created, "lastMessageAt": last, "lastSeenAt": last, + "parentChatId": parent, "spawnedByChatId": spawned_by, + "config": config, + })) + .unwrap() + }; + let state = cx.new(|_| { + let mut s = state::AppState::new(); + s.connection = zeron_proto::view::ConnectionStatus::Ready; + s.workspace_scope = Some(zeron_proto::WorkspaceScope::Synced); + s.auth = Some(zeron_proto::AuthState::SignedIn { + user: zeron_proto::UserProfile { + id: "fixture-user".into(), + email: "alex@example.test".into(), + name: Some("Alex".into()), + }, + org_id: Some("fixture-org".into()), + }); + s.local_device_id = Some("local".into()); + s.devices = [ + ("local", "Workstation", "linux"), + ("gpu", "GPU box", "linux"), + ] + .into_iter() + .map(|(id, name, platform)| { + serde_json::from_value(serde_json::json!({ + "id": id, "name": name, "platform": platform, + "lastSeenAt": now, "capabilities": [], + })) + .unwrap() + }) + .collect(); + s.selected_chat = Some("coordinator".into()); + s.selected_space = Some("project".into()); + s.auto_selected = true; + s.chats_synced = true; + s.spaces_synced = true; + s.spaces = vec![ + serde_json::from_value(serde_json::json!({ + "id": "project", "deviceId": "local", "path": project_path, + "createdAt": ago(600), + })) + .unwrap(), + ]; + let mut review = chat( + "side-review", + "local", + "Review the tokenizer API surface", + spawned_at, + ago(1), + Some("coordinator"), + Some("coordinator"), + ); + // Finished and not yet looked at: the sidebar's "Done". + review.last_seen_at = Some(ago(3)); + s.chats = vec![ + chat( + "coordinator", + "local", + "Coordinate the tokenizer rewrite", + ago(40), + ago(2), + None, + None, + ), + chat( + "worker-gpu", + "gpu", + "Benchmark the tokenizer on CUDA", + // The batch's first entry, created first. + spawned_at - chrono::Duration::seconds(1), + ago(0), + None, + Some("coordinator"), + ), + review, + chat( + "docs", + "local", + "Update the contributor guide", + ago(180), + ago(90), + None, + None, + ), + chat( + "flaky", + "local", + "Fix the flaky sync test", + ago(300), + ago(200), + None, + None, + ), + ]; + s.sessions = vec![ + serde_json::from_value(serde_json::json!({ + "chatId": "worker-gpu", "deviceId": "gpu", "status": "working", + "startedAt": spawned_at, "updatedAt": now, + })) + .unwrap(), + ]; + s + }); + let boot = EngineBootConfig { + data_dir: data, + ipc_port: 0, + edge_url: String::new(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: HarnessId::ClaudeCode, + }; + let window = cx + .open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds::new( + gpui::point(px(0.), px(0.)), + size(px(1100.), px(720.)), + ))), + app_owns_titlebar_drag: true, + ..Default::default() + }, + |_, cx| cx.new(|cx| shell::Shell::new(state.clone(), boot, cx)), + ) + .unwrap(); + state.update(cx, |_, cx| cx.notify()); + let transcript = serde_json::json!([ + { + "id": "user", "role": "user", "deviceId": "local", + "createdAt": (turn_at - chrono::Duration::seconds(20)).timestamp_millis(), + "parts": [{ "id": "text", "kind": "text", "text": + "Split the tokenizer rewrite: benchmark it on the GPU box, and have a side chat review the public API while you wire it in." }], + }, + { + "id": "assistant", "role": "assistant", "deviceId": "local", + "status": "complete", "durationMs": 48_000, + "createdAt": turn_at.timestamp_millis(), + "parts": [ + { "id": "t0", "kind": "text", "text": + "I'll fan this out so the benchmark and the review run while I wire the new tokenizer in." }, + // As the doc fold keeps it: the call, resolved, and + // the ids its result named (never the output). + { "id": "tool-create", "kind": "tool", "resolved": true, "isError": false, + "call": { "kind": "mcp", "server": "zeron", "tool": "create_chats" }, + "createdChatIds": ["worker-gpu", "side-review"] }, + { "id": "t1", "kind": "text", "text": + "Both are running: the benchmark is a top-level chat on the GPU box, and the API review is a side chat under this one. I'll fold their results in as they report back." }, + ], + }, + ]); + cx.spawn(async move |cx| { + cx.background_executor() + .timer(std::time::Duration::from_millis(900)) + .await; + state + .update(cx, |s, cx| { + s.receive_transcript_frame( + zeron_doc::TranscriptFrame::Reset { + reset: serde_json::from_value(transcript).unwrap(), + }, + cx, + ) + .unwrap(); + cx.notify(); + }); + let Some(position) = hover else { return }; + // Through the untyped handle: the typed one leases the Shell, + // which the dispatched event must update. + let window: gpui::AnyWindowHandle = window.into(); + for _ in 0..3 { + cx.background_executor() + .timer(std::time::Duration::from_millis(400)) + .await; + window + .update(cx, |_, window, cx| { + window.dispatch_event( + gpui::PlatformInput::MouseMove(gpui::MouseMoveEvent { + position, + pressed_button: None, + modifiers: gpui::Modifiers::default(), + }), + cx, + ); + }) + .ok(); + } + }) + .detach(); + }); + Ok(()) +} diff --git a/crates/ui/src/change_requests.rs b/crates/ui/src/change_requests.rs index fb51b0e562..20f3822ff7 100644 --- a/crates/ui/src/change_requests.rs +++ b/crates/ui/src/change_requests.rs @@ -378,6 +378,7 @@ mod tests { harness_session_id: None, harness_session_cwd: None, parent_chat_id: None, + spawned_by_chat_id: None, space_id: Some("space".into()), last_seen_at: None, room_gen: None, diff --git a/crates/ui/src/changes.rs b/crates/ui/src/changes.rs index e9d0672dc9..4311d53a94 100644 --- a/crates/ui/src/changes.rs +++ b/crates/ui/src/changes.rs @@ -6035,6 +6035,7 @@ rename to new_name.rs harness_session_id: None, harness_session_cwd: None, parent_chat_id: None, + spawned_by_chat_id: None, space_id: None, last_seen_at: None, room_gen: None, diff --git a/crates/ui/src/files/sections.rs b/crates/ui/src/files/sections.rs index cba70073ff..55836099b7 100644 --- a/crates/ui/src/files/sections.rs +++ b/crates/ui/src/files/sections.rs @@ -277,7 +277,8 @@ pub(super) fn subagent_rows(state: &AppState, chat_id: &str) -> Vec running } -/// A side chat of the active chat, as the footer lists it. +/// A side chat of the active chat, or a top-level chat its agent spawned, +/// as the footer lists it. #[derive(Debug, Clone, PartialEq)] pub(super) struct ChildChatRow { pub chat_id: String, @@ -286,11 +287,17 @@ pub(super) struct ChildChatRow { pub time_ago: SharedString, /// The chat's linked pull request, drawn as the sidebar's badge. pub change_request: Option, + /// A top-level chat this chat's agent spawned (`create_chat { kind: + /// "chat" }`): it has its own sidebar row, so opening it selects it + /// rather than docking it beside the parent. + pub top_level: bool, activity: DateTime, } -/// The live (unarchived) children of `chat_id`, most recent activity first — -/// the same order the sidebar's Sessions list keeps. +/// The live (unarchived) children of `chat_id` — its side chats plus the +/// top-level chats its agent spawned, so a coordinator can find its workers +/// in one place — most recent activity first, the same order the sidebar's +/// Sessions list keeps. pub(super) fn child_chat_rows( state: &AppState, chat_id: &str, @@ -299,11 +306,18 @@ pub(super) fn child_chat_rows( let mut rows: Vec = state .chats .iter() - .filter(|chat| !chat.archived && chat.parent_chat_id.as_deref() == Some(chat_id)) + .filter(|chat| { + !chat.archived + && match chat.parent_chat_id.as_deref() { + Some(parent) => parent == chat_id, + None => chat.spawned_by_chat_id.as_deref() == Some(chat_id), + } + }) .map(|chat| { let activity = chat.last_message_at.unwrap_or(chat.created_at); ChildChatRow { chat_id: chat.id.clone(), + top_level: chat.parent_chat_id.is_none(), title: child_chat_title(chat).into(), status: state.display_status_for(chat, now), time_ago: zeron_proto::view::format_time_ago(activity, now).into(), @@ -322,7 +336,13 @@ pub(super) fn child_chat_title(chat: &Chat) -> String { chat.title .clone() .or_else(|| chat.last_message_preview.clone()) - .unwrap_or_else(|| "New side chat".into()) + .unwrap_or_else(|| { + if chat.parent_chat_id.is_some() { + "New side chat".into() + } else { + "New chat".into() + } + }) } /// What the footer would draw for `chat_id`, hashed. Cheap enough to run on @@ -337,6 +357,7 @@ pub(super) fn fingerprint(state: &AppState, chat_id: &str, now: DateTime) - 0xC0FFEEu64.hash(&mut hasher); for row in child_chat_rows(state, chat_id, now) { row.chat_id.hash(&mut hasher); + row.top_level.hash(&mut hasher); row.title.as_ref().hash(&mut hasher); (row.status as u8).hash(&mut hasher); row.time_ago.as_ref().hash(&mut hasher); @@ -813,7 +834,11 @@ impl FilesSurface { let menu_id = row.chat_id.clone(); list = list.child( compact_row(format!("files-chat-{}", row.chat_id), theme) - .aria_label(SharedString::from(format!("Open side chat {}", row.title))) + .aria_label(SharedString::from(if row.top_level { + format!("Open chat {}", row.title) + } else { + format!("Open side chat {}", row.title) + })) .on_click(cx.listener(move |_, _, _, cx| { cx.stop_propagation(); cx.emit(FilesEvent::OpenChildChat(open_id.clone())); @@ -1084,6 +1109,7 @@ mod tests { subagent_ref: doc.map(str::to_owned), subagent_status: status, subagent_tail: None, + created_chat_ids: Vec::new(), } } @@ -1268,6 +1294,48 @@ mod tests { assert_eq!(rows[0].status, ChatIndicator::Idle); } + #[test] + fn child_chat_rows_include_top_level_chats_this_chat_spawned() { + let spawned = |id: &str, parent: Option<&str>, by: &str, minutes_ago: i64| { + let mut chat = chat(id, parent, minutes_ago); + chat.spawned_by_chat_id = Some(by.into()); + chat + }; + let mut archived_worker = spawned("gone", None, "main", 1); + archived_worker.archived = true; + let mut state = AppState::new(); + state.apply_chats(vec![ + chat("main", None, 60), + // A user-made side chat and an agent-made one: both placed here. + chat("side", Some("main"), 20), + spawned("agent-side", Some("main"), "main", 15), + // Top-level workers this chat's agent spawned. + spawned("worker", None, "main", 5), + archived_worker, + // Spawned by `main` but placed under another chat: that chat's + // footer owns it. + spawned("placed-elsewhere", Some("other"), "main", 3), + // Another chat's worker. + spawned("foreign", None, "other", 2), + chat("other", None, 40), + ]); + let rows = child_chat_rows(&state, "main", Utc::now()); + assert_eq!( + rows.iter().map(|r| r.chat_id.as_str()).collect::>(), + ["worker", "agent-side", "side"] + ); + assert!(rows[0].top_level); + assert_eq!(rows[0].title.as_ref(), "New chat"); + assert!(!rows[1].top_level && !rows[2].top_level); + assert_eq!(rows[1].title.as_ref(), "New side chat"); + // The footer fingerprint moves when a worker is spawned. + let before = fingerprint(&state, "main", Utc::now()); + let mut chats = state.chats.clone(); + chats.push(spawned("worker-2", None, "main", 0)); + state.apply_chats(chats); + assert_ne!(before, fingerprint(&state, "main", Utc::now())); + } + #[test] fn fingerprint_tracks_membership_and_status() { let mut state = AppState::new(); diff --git a/crates/ui/src/links.rs b/crates/ui/src/links.rs index 29406ca32b..cf285bf186 100644 --- a/crates/ui/src/links.rs +++ b/crates/ui/src/links.rs @@ -141,6 +141,7 @@ mod tests { harness_session_id: Some("thread/one".into()), harness_session_cwd: None, parent_chat_id: None, + spawned_by_chat_id: None, space_id: None, last_seen_at: None, room_gen: None, diff --git a/crates/ui/src/settings/archived.rs b/crates/ui/src/settings/archived.rs index 16fad80f4c..e8c1819f04 100644 --- a/crates/ui/src/settings/archived.rs +++ b/crates/ui/src/settings/archived.rs @@ -433,6 +433,7 @@ mod tests { harness_session_id: None, harness_session_cwd: None, parent_chat_id: None, + spawned_by_chat_id: None, space_id: None, last_seen_at: None, room_gen: None, diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index c763fb3e77..f273258acc 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -3793,6 +3793,12 @@ impl Shell { cx, ); } + // The live row decides placement (a side chat docks beside its + // parent, a top-level one is selected); `side` is the card's + // reading of the same row. + TranscriptEvent::OpenChat { chat_id, side: _ } => { + self.open_child_chat_tab(chat_id, cx); + } } } @@ -6929,6 +6935,19 @@ impl Shell { .is_some_and(|chat| { self.state.read(cx).local_device_id.as_deref() != Some(chat.device_id.as_str()) }); + // Agent-spawned top-level chats name their spawner (sidebar rows + // only — the palette row is a search hit, not a place to navigate + // sideways from). + let spawned_by = (search_query.is_none() && !preview) + .then(|| { + let state = self.state.read(cx); + state + .chats + .iter() + .find(|chat| chat.id == id) + .and_then(|chat| spaces::spawned_by_affordance(chat, &state.chats)) + }) + .flatten(); let project_icon = (search_query.is_none() && self.settings.sidebar_show_project_icon) .then(|| self.render_project_icon(&id, SIDEBAR_ACTIVE_HARNESS_ICON_SIZE, selected, cx)); let corner_hovered = !preview && self.chat_status_hover.as_deref() == Some(row_id.as_str()); @@ -7386,6 +7405,50 @@ impl Shell { .line_height(px(17.0)) .child(popover::search_highlight(title, search_query, theme)), )) + .when_some(spawned_by, |el, spawned_by| { + // Provenance, not status: the transcript's agent + // glyph at the remote icon's size and tone, with the + // spawner's name on hover. Clicking it opens the + // spawner; the row's own click still opens this chat. + let label = SharedString::from(spawned_by.label); + el.child( + div() + .id(SharedString::from(format!("{row_id}-spawned-by"))) + .debug_selector({ + let id = id.clone(); + move || format!("chat-spawned-by-{id}") + }) + .flex_none() + .size(px(SIDEBAR_ACTIVE_HARNESS_ICON_SIZE + 4.0)) + .mx(px(-2.0)) + .flex() + .items_center() + .justify_center() + .rounded(px(4.0)) + .aria_label(label.clone()) + .tooltip(crate::settings::widgets::text_tooltip(label)) + .child( + icon(icons::BOT) + .size(px(SIDEBAR_ACTIVE_HARNESS_ICON_SIZE)) + .text_color(subline.opacity(if archived_muted { + 0.4 + } else { + 1.0 + })), + ) + .when_some(spawned_by.spawner_id, |el, spawner| { + el.cursor_pointer() + .hover(|s| s.bg(crate::theme::wash(0.10))) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation() + }) + .on_click(cx.listener(move |this, _, _, cx| { + cx.stop_propagation(); + this.open_chat(spawner.clone(), cx); + })) + }), + ) + }) .when(!compact && !show_label && remote, |el| { el.child( icon(icons::REMOTE_SERVER) @@ -12828,6 +12891,7 @@ mod tests { last_seen_at: None, room_gen: None, parent_chat_id: None, + spawned_by_chat_id: None, } } diff --git a/crates/ui/src/shell/side_chats.rs b/crates/ui/src/shell/side_chats.rs index 65ac66d15b..19a8459039 100644 --- a/crates/ui/src/shell/side_chats.rs +++ b/crates/ui/src/shell/side_chats.rs @@ -126,7 +126,9 @@ impl Shell { self.open_side_chat_tab(chat, key, true, cx); } - /// Open an existing side chat (a footer row) in the right pane. + /// Open an existing side chat (a footer row) in the right pane. A + /// top-level chat an agent spawned has its own sidebar row, so it opens + /// the way that row does: selected in the main view. pub(super) fn open_child_chat_tab(&mut self, chat_id: &str, cx: &mut Context) { let Some(chat) = self .state @@ -138,6 +140,10 @@ impl Shell { else { return; }; + if chat.parent_chat_id.is_none() { + self.open_chat(chat.id, cx); + return; + } let key = self.panel_key(cx); self.open_side_chat(chat, key, cx); } diff --git a/crates/ui/src/shell/spaces.rs b/crates/ui/src/shell/spaces.rs index 4614f4dd42..9941a9fd00 100644 --- a/crates/ui/src/shell/spaces.rs +++ b/crates/ui/src/shell/spaces.rs @@ -2261,6 +2261,48 @@ pub(crate) fn status_dot_color(status: ChatIndicator, theme: &Theme) -> gpui::Hs } } +/// The sidebar's "spawned by" marker on an agent-spawned top-level chat: +/// the tooltip text and, when the spawner row still exists, the chat a +/// click opens. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SpawnedBy { + pub label: String, + pub spawner_id: Option, +} + +/// `None` for chats a user started, and for side chats — those already sit +/// under their parent, so provenance would only repeat the placement. An +/// agent's `create_chat { kind: "chat" }` row names its spawner by title; +/// a spawner that was deleted (or has not synced yet) reads generically. +pub(crate) fn spawned_by_affordance( + chat: &zeron_proto::Chat, + chats: &[zeron_proto::Chat], +) -> Option { + if chat.parent_chat_id.is_some() { + return None; + } + let spawner_id = chat.spawned_by_chat_id.as_deref()?; + Some(match chats.iter().find(|c| c.id == spawner_id) { + Some(spawner) => SpawnedBy { + label: format!( + "Spawned by {}", + spawner + .title + .as_deref() + .map(str::trim) + .filter(|title| !title.is_empty()) + .map(crate::transcript::single_line) + .unwrap_or_else(|| "New session".into()) + ), + spawner_id: Some(spawner.id.clone()), + }, + None => SpawnedBy { + label: "Spawned by an agent".into(), + spawner_id: None, + }, + }) +} + // Handle-based rail host for the spaces dropdown: its list is a plain // tracked scroller, so the trait's default metrics/press/drag (off the live // ScrollHandle) apply unchanged. @@ -6683,6 +6725,7 @@ mod tests { harness_session_id: None, harness_session_cwd: None, parent_chat_id: None, + spawned_by_chat_id: None, space_id: None, last_seen_at: None, room_gen: None, @@ -6723,6 +6766,45 @@ mod tests { assert_eq!(groups, before); } + + #[test] + fn spawned_by_affordance_names_the_spawner_and_targets_it() { + use super::{SpawnedBy, spawned_by_affordance}; + let mut spawner = chat("spawner"); + spawner.title = Some("Coordinate the\nrelease".into()); + let mut worker = chat("worker"); + worker.spawned_by_chat_id = Some("spawner".into()); + let chats = vec![spawner.clone(), worker.clone()]; + assert_eq!( + spawned_by_affordance(&worker, &chats), + Some(SpawnedBy { + label: "Spawned by Coordinate the release".into(), + spawner_id: Some("spawner".into()), + }) + ); + + // An untitled spawner reads like its own sidebar row. + let untitled = vec![chat("spawner"), worker.clone()]; + assert_eq!( + spawned_by_affordance(&worker, &untitled).unwrap().label, + "Spawned by New session" + ); + + // Deleted (or not yet synced) spawner: generic label, nothing to open. + assert_eq!( + spawned_by_affordance(&worker, std::slice::from_ref(&worker)), + Some(SpawnedBy { + label: "Spawned by an agent".into(), + spawner_id: None, + }) + ); + + // User-started chats and side chats carry no marker. + assert_eq!(spawned_by_affordance(&spawner, &chats), None); + let mut side = worker.clone(); + side.parent_chat_id = Some("spawner".into()); + assert_eq!(spawned_by_affordance(&side, &chats), None); + } } /// Synthetic responses for the isolated native screenshot fixture only. diff --git a/crates/ui/src/state.rs b/crates/ui/src/state.rs index a8618b4017..1eca61e4c7 100644 --- a/crates/ui/src/state.rs +++ b/crates/ui/src/state.rs @@ -1762,10 +1762,11 @@ impl AppState { // ---- queries ---- - /// Non-archived, top-level chats in sidebar order. Chats spawned by - /// another chat (`parent_chat_id`, the Zeron MCP's orchestration link) - /// are the parent's workers, not sessions the user started: they stay - /// reachable by id/deep link but never take a sidebar row or jump slot. + /// Non-archived, top-level chats in sidebar order. Side chats + /// (`parent_chat_id`, placement under another chat) stay reachable by + /// id/deep link but never take a sidebar row or jump slot. Provenance + /// alone (`spawned_by_chat_id`, an agent's `create_chat { kind: "chat" }`) + /// does not hide a chat: those are real top-level sessions. pub fn visible_chats(&self) -> impl Iterator { self.chats .iter() @@ -3647,6 +3648,7 @@ mod tests { harness_session_id: None, harness_session_cwd: None, parent_chat_id: None, + spawned_by_chat_id: None, space_id: None, last_seen_at: None, room_gen: None, @@ -4610,6 +4612,26 @@ mod tests { assert!(state.chats.iter().any(|c| c.id == "child")); } + #[test] + fn visible_chats_list_agent_spawned_top_level_chats() { + let mut state = AppState::new(); + let spawner = chat("spawner", 0, Some(1)); + // `create_chat { kind: "chat" }`: provenance only, no placement. + let mut worker = chat("worker", 1, Some(2)); + worker.spawned_by_chat_id = Some("spawner".into()); + // `create_chat { kind: "side" }`: placement and provenance. + let mut side = chat("side", 2, Some(3)); + side.parent_chat_id = Some("spawner".into()); + side.spawned_by_chat_id = Some("spawner".into()); + let mut archived_worker = chat("archived", 3, Some(4)); + archived_worker.spawned_by_chat_id = Some("spawner".into()); + archived_worker.archived = true; + state.apply_chats(vec![spawner, worker, side, archived_worker]); + let mut visible: Vec<&str> = state.visible_chats().map(|c| c.id.as_str()).collect(); + visible.sort_unstable(); + assert_eq!(visible, ["spawner", "worker"]); + } + #[test] fn side_chats_remain_addressable_but_do_not_appear_in_sidebar() { let mut state = AppState::new(); diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index ef584e9b8e..5487dc9a9e 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -54,6 +54,8 @@ use crate::syntax_cache::{DocumentHighlightKey, SyntaxHighlightCache}; use crate::theme::Theme; use zeron_syntax::LanguageId as Lang; +mod created_chats; + // --------------------------------------------------------------------------- // Constants (mugen ports) // --------------------------------------------------------------------------- @@ -371,6 +373,10 @@ pub struct ToolItem { /// per-delta header rewrites read as noise). Never rendered; still /// fingerprinted so an old doc's chips re-splice correctly. pub subagent_tail: Option, + /// The chats a Zeron `create_chat`/`create_chats` call's result names + /// ([`created_chats::parse_created_chats`]); `None` for other tools and + /// for results the doc does not carry. + pub(crate) created_chats: Option>>, /// `Call` is a real doc tool invocation; `Thought` (a reasoning part /// riding the tool group — the thought process belongs inside the /// combined "Ran N commands" accordion, opening/closing with the same @@ -384,9 +390,11 @@ pub struct ToolItem { /// Subagent spawn chips — [`ToolCall::is_subagent_spawn`], the shared genus /// every driver decodes its spawn tool into. These stay out of the /// collapsible "Called N tools" wrap so a running subagent is visible -/// without opening the fold. +/// without opening the fold. Zeron `create_chat`/`create_chats` calls share +/// the genus: they spawn agents too, and their chip is the link to the +/// chats they made. fn is_agent_call(call: &ToolCall) -> bool { - call.is_subagent_spawn() + call.is_subagent_spawn() || created_chats::create_chat_op(call).is_some() } /// The chip's GENUS is the call itself, never the ref: docs written before @@ -727,6 +735,7 @@ fn thought_item(part_id: &str, tree: &BlockTree, live: bool) -> ToolItem { subagent_ref: None, subagent_status: None, subagent_tail: None, + created_chats: None, kind: ToolItemKind::Thought, } } @@ -1219,6 +1228,10 @@ fn tool_fingerprint(tools: &[ToolItem], auto_open: bool) -> u64 { if let Some(tail) = &t.subagent_tail { acc.extend_from_slice(tail.as_bytes()); } + for chat in t.created_chats.iter().flat_map(|chats| chats.iter()) { + acc.extend_from_slice(chat.chat_id.as_bytes()); + acc.push(0); + } } acc.push(auto_open as u8); fnv1a(&acc) @@ -1417,6 +1430,7 @@ pub fn rows_for_entry( subagent_ref, subagent_status, subagent_tail, + created_chat_ids, .. } => { let item = ToolItem { @@ -1433,6 +1447,10 @@ pub fn rows_for_entry( subagent_ref: subagent_ref.clone().map(SharedString::from), subagent_status: *subagent_status, subagent_tail: subagent_tail.clone().map(SharedString::from), + created_chats: created_chats::create_chat_op(call) + .map(|_| created_chats::named_chats(created_chat_ids, output.as_deref())) + .filter(|chats| !chats.is_empty()) + .map(Arc::new), kind: ToolItemKind::Call, }; if compact { @@ -3143,6 +3161,14 @@ pub struct Transcript { /// frames reuse settled blocks' text+runs; the incremental parser's stable /// boundary invalidates only the live tail per commit. render_cache: Rc>, + /// The transcript's settled Zeron create calls whose chats must be + /// recovered from provenance, keyed by (chat, transcript revision) — + /// rescanned only when the transcript changes, not per paint. + create_call_sites: Option<( + (Option, u64), + Arc>, + Arc>, + )>, workspace_link: Option, /// File-link roots per linking chat, valid for one /// `AppState::link_roots_revision`: every rendered row asks for them. @@ -3294,6 +3320,10 @@ pub enum TranscriptEvent { title: String, frozen: bool, }, + /// A created-chat card (a Zeron `create_chat`/`create_chats` call): + /// open that chat — a side chat docks beside its parent like a footer + /// row, a top-level chat is selected like its sidebar row. + OpenChat { chat_id: String, side: bool }, } impl gpui::EventEmitter for Transcript {} @@ -3486,6 +3516,7 @@ impl Transcript { veil_baseline: std::collections::HashSet::new(), veil_attach_pending: true, render_cache: Rc::new(RefCell::new(RenderCache::default())), + create_call_sites: None, workspace_link: None, file_link_roots: Default::default(), inline_code_links: Default::default(), @@ -6987,6 +7018,120 @@ impl Transcript { Some(Arc::new(crate::changes::DiffHighlights { old, new })) } + /// For each chip in `tools`: the chats a settled Zeron create call made, + /// as cards — `None` for every other chip, for running or failed calls, + /// and for calls whose chats are unknown (those keep the plain chip). + /// + /// Ids come from the call's own result when the doc carries it, else + /// from the chats' provenance: every chat whose `spawnedByChatId` is + /// this transcript's chat, matched back to the calls by creation time + /// ([`created_chats::attribute_spawned_chats`]). Titles, kind, host and + /// status are read live from the chat rows, like the sidebar. + fn created_chat_cards( + &mut self, + tools: &[ToolItem], + cx: &mut Context, + ) -> Vec>> { + let settled = |tool: &ToolItem| { + tool.kind == ToolItemKind::Call + && tool.resolved + && !tool.is_error + && created_chats::create_chat_op(&tool.call).is_some() + }; + if !tools.iter().any(settled) { + return vec![None; tools.len()]; + } + // Provenance names the spawner's chat, so only a chat's own + // transcript (not a subagent doc) can recover ids from it. + let attributed = match (&self.doc_override, self.chat_id.clone()) { + (None, Some(chat_id)) => { + let state = self.state.read(cx); + let key = (Some(chat_id.clone()), state.transcript_revision); + if self + .create_call_sites + .as_ref() + .is_none_or(|(cached, _, _)| cached != &key) + { + let (sites, claimed) = created_chats::create_call_sites(&state.transcript); + self.create_call_sites = Some((key, Arc::new(sites), Arc::new(claimed))); + } + let (_, sites, claimed) = self.create_call_sites.as_ref().unwrap(); + let spawned: Vec<(String, i64)> = state + .chats + .iter() + .filter(|chat| { + chat.spawned_by_chat_id.as_deref() == Some(chat_id.as_str()) + && !claimed.contains(&chat.id) + }) + .map(|chat| (chat.id.clone(), chat.created_at.timestamp_millis())) + .collect(); + created_chats::attribute_spawned_chats(sites, &spawned) + } + _ => HashMap::new(), + }; + let state = self.state.read(cx); + let now = chrono::Utc::now(); + tools + .iter() + .map(|tool| { + if !settled(tool) { + return None; + } + let named: Vec = match &tool.created_chats { + Some(chats) => chats.as_ref().clone(), + None => attributed + .get(&tool.part_id)? + .iter() + .map(|id| created_chats::CreatedChat { + chat_id: id.clone(), + ..Default::default() + }) + .collect(), + }; + let cards: Vec = named + .into_iter() + .map(|created| { + let live = state.chats.iter().find(|c| c.id == created.chat_id); + let side = live + .map(|chat| chat.parent_chat_id.is_some()) + .or(created.side) + .unwrap_or(false); + let title = live + .and_then(|chat| chat.title.as_deref()) + .or(created.title.as_deref()) + .map(str::trim) + .filter(|title| !title.is_empty()) + .map(single_line) + .unwrap_or_else(|| { + if side { "New side chat" } else { "New chat" }.into() + }); + let device_id = live + .map(|chat| chat.device_id.as_str()) + .or(created.device_id.as_deref()); + let remote = device_id + .is_some_and(|id| state.local_device_id.as_deref() != Some(id)); + let device = remote + .then(|| { + device_id + .and_then(|id| state.device_name(id)) + .or(created.device_name.as_deref()) + .map(|name| SharedString::from(name.to_owned())) + }) + .flatten(); + CreatedChatCard { + chat_id: created.chat_id, + title: title.into(), + side, + device, + status: live.map(|chat| state.display_status_for(chat, now)), + } + }) + .collect(); + (!cards.is_empty()).then_some(cards) + }) + .collect() + } + fn render_tool_group( &mut self, row_id: &SharedString, @@ -7075,17 +7220,22 @@ impl Transcript { .toggled_at .is_some_and(|at| at.elapsed() < TOOL_FOLD.total())); let tools = if body_visible { tools.as_slice() } else { &[] }; + // Zeron create_chat(s) calls that settled into real chats render as + // one link card per chat (live title/status), resolved per paint. + let created_cards = self.created_chat_cards(tools, cx); // Chips render their EFFECTIVE detail: the precomputed doc-resident // one, upgraded in place by a fetched sidecar blob (chat2-sync A3). // Resolved per paint (a HashMap probe per chip) so fetched content // needs no row rebuild — arrival is a cx.notify, like a fold toggle. let details: Vec>> = tools .iter() - .map(|tool| { + .zip(&created_cards) + .map(|(tool, cards)| { // Spawn chips never expand — the subagent doc is the record // of what the tool did, and an inline body would only repeat // it. The whole chip is the "open that doc" click instead. - if is_spawn_link(tool) { + // Created-chat cards likewise open the chats they name. + if is_spawn_link(tool) || cards.is_some() { return None; } // Among fetched blobs, the most recently REQUESTED one wins — @@ -7107,7 +7257,12 @@ impl Transcript { // always answers "what exactly was this call?", output or not. let invocations: Vec>> = tools .iter() - .map(|tool| tool.invocation.clone().filter(|_| !is_spawn_link(tool))) + .zip(&created_cards) + .map(|(tool, cards)| { + tool.invocation + .clone() + .filter(|_| !is_spawn_link(tool) && cards.is_none()) + }) .collect(); // Fetch affordance under each open detail whose full payload is still // sidecar-only: `(ref, label)`. Diff offered first (the richer @@ -7246,6 +7401,15 @@ impl Transcript { target }) .collect(); + // A card per created chat, stacked in the chip's own slot. + let row_heights: Vec = row_heights + .into_iter() + .zip(&created_cards) + .map(|(height, cards)| match cards { + Some(cards) => base_row_height * cards.len() as f32, + None => height, + }) + .collect(); let reduce_motion = cx.reduce_motion(); let now = Instant::now(); let reveal_progress: Vec = (0..tools.len()) @@ -7442,6 +7606,16 @@ impl Transcript { cx, ); } + if let Some(cards) = created_cards[ix].as_ref() { + return created_chat_chips( + cards, + &format!("{row_id}#c{ix}"), + collapses, + base_row_height, + theme, + cx, + ); + } let detail = details[ix].clone(); let invocation = invocations[ix].clone(); if detail.is_none() && invocation.is_none() { @@ -8715,9 +8889,29 @@ fn subagent_chip( theme: &Theme, view: gpui::EntityId, cx: &mut gpui::App, +) -> AnyElement { + link_chip( + id, + chip_header_row(tool, Some(ChipTrail::OpenArrow), theme, view, cx), + Some(Box::new(on_open)), + rail, + CHIP_HEIGHT, + ) +} + +type LinkChipClick = Box; + +/// The spawn chip's card around `header`: the whole card is the click when +/// `on_open` is set (a card with nothing to open stays inert). +fn link_chip( + id: SharedString, + header: gpui::Div, + on_open: Option, + rail: bool, + height: f32, ) -> AnyElement { div() - .h(px(CHIP_HEIGHT)) + .h(px(height)) .w_full() .flex_none() .flex() @@ -8747,18 +8941,217 @@ fn subagent_chip( .border_1() .border_color(crate::theme::hairline(0.07)) .bg(crate::theme::ink(0.03)) - .cursor_pointer() - .hover(|s| s.bg(crate::theme::ink(0.05))) - .on_click(on_open) - .child(chip_header_row( - tool, - Some(ChipTrail::OpenArrow), - theme, + .when_some(on_open, |card, on_open| { + card.cursor_pointer() + .hover(|s| s.bg(crate::theme::ink(0.05))) + .on_click(on_open) + }) + .child(header), + ) + .into_any_element() +} + +/// One chat a settled Zeron create call made, as its card shows it. +#[derive(Debug, Clone, PartialEq)] +struct CreatedChatCard { + chat_id: String, + title: SharedString, + /// Placed under the spawner (`kind: "side"`) rather than top-level. + side: bool, + /// The host's name when it is not this device. + device: Option, + /// Live display status; `None` when the chat row is gone (deleted, or + /// not synced yet) — the card then has nothing to open. + status: Option, +} + +/// The cards a Zeron `create_chat`/`create_chats` chip becomes once its +/// chats exist: the spawn chip's card, one per created chat, each the link +/// to its chat — "Chat"/"Side chat" where the spawn chip says "Agent", the +/// live title as the detail, the host when remote, and the sidebar's status +/// glyph and word. The whole card opens the chat +/// ([`TranscriptEvent::OpenChat`]). +fn created_chat_chips( + cards: &[CreatedChatCard], + id_prefix: &str, + rail: bool, + height: f32, + theme: &Theme, + cx: &mut Context, +) -> AnyElement { + let view = cx.entity_id(); + let mut column = div().w_full().flex_none().flex().flex_col(); + for (ix, card) in cards.iter().enumerate() { + let opens = card.status.is_some(); + let chat_id = card.chat_id.clone(); + let side = card.side; + let header = created_chat_header(card, opens, theme, view, cx); + let on_open: Option = opens.then(|| { + Box::new(cx.listener(move |_, _: &gpui::ClickEvent, _, cx| { + cx.emit(TranscriptEvent::OpenChat { + chat_id: chat_id.clone(), + side, + }); + })) as LinkChipClick + }); + column = column.child(link_chip( + SharedString::from(format!("{id_prefix}.{ix}")), + header, + on_open, + rail, + height, + )); + } + column.into_any_element() +} + +/// A created chat's card header — [`chip_header_row`]'s spawn-chip layout: +/// icon tile, medium label, truncating detail, faint trailing meta, the +/// status glyph, and the open-arrow tile. +fn created_chat_header( + card: &CreatedChatCard, + opens: bool, + theme: &Theme, + view: gpui::EntityId, + cx: &mut gpui::App, +) -> gpui::Div { + let status = card + .status + .filter(|status| *status != zeron_proto::ChatIndicator::Idle); + let mut row = div() + .h(px(CHIP_HEADER_HEIGHT)) + .w_full() + .min_w_0() + .flex() + .flex_row() + .items_center() + .gap(px(8.0)) + .px(px(8.0)) + .text_size(px(TOOL_LABEL_SIZE)) + .line_height(px(TOOL_LABEL_LINE_HEIGHT)) + .child( + div() + .size(px(18.0)) + .flex_none() + .rounded(px(5.0)) + .bg(crate::theme::ink(0.08)) + .flex() + .items_center() + .justify_center() + .child( + crate::icons::icon(crate::icons::CHAT_ROUND_LINE) + .size(px(12.0)) + .text_color(theme.text_muted), + ), + ) + .child( + div() + .flex_none() + .h(px(TOOL_LABEL_LINE_HEIGHT)) + .flex() + .items_center() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(theme.text_muted) + .child(SharedString::from(if card.side { "Side chat" } else { "Chat" })), + ) + .child( + div() + .flex_1() + .min_w_0() + .h(px(TOOL_LABEL_LINE_HEIGHT)) + .flex() + .items_center() + .truncate() + .text_color(theme.text.opacity(0.85)) + .child(div().min_w_0().truncate().child(card.title.clone())), + ) + .when_some(card.device.clone(), |row, device| { + // The host, like the spawn chip's model: bare faint text, since + // the trailing tiles are the affordances. + row.child( + div() + .flex_none() + .h(px(18.0)) + .flex() + .items_center() + .gap(px(4.0)) + .text_size(px(11.0)) + .text_color(theme.text_faint) + .child( + crate::icons::icon(crate::icons::REMOTE_SERVER) + .size(px(11.0)) + .text_color(theme.text_faint), + ) + .child(device), + ) + }); + if let Some(status) = status { + // The sidebar row's status corner: glyph + word in its tone. + let color = crate::shell::spaces::status_dot_color(status, theme); + let glyph = match status { + zeron_proto::ChatIndicator::Working => div() + .flex_none() + .child(crate::loaders::mini_glyph_spinner( + format!("created-chat-{}", card.chat_id), + 2.0, + theme.glyph, view, cx, - )), + )) + .into_any_element(), + zeron_proto::ChatIndicator::Completed => crate::icons::icon(crate::icons::CHECK) + .size(px(11.0)) + .flex_none() + .text_color(color) + .into_any_element(), + _ => div() + .size(px(6.0)) + .flex_none() + .rounded_full() + .bg(color) + .into_any_element(), + }; + let label = match status { + zeron_proto::ChatIndicator::Working => "Working", + zeron_proto::ChatIndicator::AwaitingInput => "Input", + zeron_proto::ChatIndicator::Errored => "Failed", + zeron_proto::ChatIndicator::Completed => "Done", + zeron_proto::ChatIndicator::Idle => "", + }; + row = row.child( + div() + .flex_none() + .flex() + .flex_row() + .items_center() + .gap(px(4.0)) + .child(glyph) + .child( + div() + .text_size(crate::typography::ui_rems(10.0)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(color) + .child(SharedString::from(label)), + ), + ); + } + row.when(opens, |row| { + row.child( + div() + .size(px(18.0)) + .flex_none() + .rounded(px(5.0)) + .bg(crate::theme::ink(0.06)) + .flex() + .items_center() + .justify_center() + .child( + crate::icons::icon(crate::icons::ARROW_UP_RIGHT) + .size(px(11.0)) + .text_color(theme.text_muted.opacity(0.8)), + ), ) - .into_any_element() + }) } fn entry_fingerprint(entry: &SessionMessageEntry, pending: bool) -> u64 { @@ -11358,6 +11751,7 @@ mod tests { subagent_ref: None, subagent_status: None, subagent_tail: None, + created_chat_ids: Vec::new(), } } @@ -11480,9 +11874,69 @@ mod tests { subagent_ref: Some(format!("chat--sub--{id}")), subagent_status: Some(SubagentStatus::Running), subagent_tail: None, + created_chat_ids: Vec::new(), } } + #[test] + fn zeron_create_calls_are_spawn_chips_that_carry_their_created_chats() { + let create = |id: &str, tool: &str, output: Option<&str>| MessagePart::Tool { + id: id.into(), + call: ToolCall::Mcp { + server: "zeron".into(), + tool: tool.into(), + input: None, + }, + is_error: false, + resolved: true, + output: output.map(str::to_owned), + diff: None, + output_ref: None, + output_bytes: None, + diff_ref: None, + diff_stats: None, + subagent_ref: None, + subagent_status: None, + subagent_tail: None, + created_chat_ids: Vec::new(), + }; + let batch = r#"{"results":[{"index":0,"isError":false,"result":{"chatId":"w1","kind":"chat"}},{"index":1,"isError":false,"result":{"chatId":"s1","kind":"side"}}]}"#; + let entry = assistant( + "m-create", + MessageStatus::Complete, + vec![ + tool_part("a", "ls"), + create("c1", "create_chats", Some(batch)), + // Synced docs carry no output: the chip still splits out, + // with its chats recovered from provenance at paint. + create("c2", "create_chat", None), + tool_part("b", "pwd"), + ], + ); + let rows = rows_for_entry(&entry, false, false, &mut parse); + let groups: Vec<&Arc>> = rows + .iter() + .filter_map(|row| match &row.kind { + RowKind::ToolGroup { tools, .. } => Some(tools), + _ => None, + }) + .collect(); + assert_eq!(groups.len(), 3); + assert!(!tool_group_collapses(groups[1])); + assert_eq!(groups[1].len(), 2); + let named: Vec<&str> = groups[1][0] + .created_chats + .as_deref() + .unwrap() + .iter() + .map(|chat| chat.chat_id.as_str()) + .collect(); + assert_eq!(named, ["w1", "s1"]); + assert!(groups[1][1].created_chats.is_none()); + // Not a subagent link: nothing binds a subagent doc to it. + assert!(!is_spawn_link(&groups[1][0])); + } + #[test] fn agent_calls_split_out_of_ordinary_tool_groups() { // Agent/spawn chips must not share a collapse with Reads/Runs: a @@ -13651,6 +14105,7 @@ mod tests { subagent_ref: None, subagent_status: None, subagent_tail: None, + created_chats: None, kind: ToolItemKind::Call, }; let edit = |p: &str| ToolItem { @@ -13670,6 +14125,7 @@ mod tests { subagent_ref: None, subagent_status: None, subagent_tail: None, + created_chats: None, kind: ToolItemKind::Call, }; let tools = vec![ @@ -13705,6 +14161,7 @@ mod tests { subagent_ref: None, subagent_status: None, subagent_tail: None, + created_chats: None, kind: ToolItemKind::Call, }, ToolItem { @@ -13722,6 +14179,7 @@ mod tests { subagent_ref: None, subagent_status: None, subagent_tail: None, + created_chats: None, kind: ToolItemKind::Call, }, ToolItem { @@ -13737,6 +14195,7 @@ mod tests { subagent_ref: None, subagent_status: None, subagent_tail: None, + created_chats: None, kind: ToolItemKind::Call, }, ]; diff --git a/crates/ui/src/transcript/created_chats.rs b/crates/ui/src/transcript/created_chats.rs new file mode 100644 index 0000000000..9bc12b2a39 --- /dev/null +++ b/crates/ui/src/transcript/created_chats.rs @@ -0,0 +1,331 @@ +//! Zeron MCP `create_chat` / `create_chats` calls in the spawner's +//! transcript: which tool calls they are, and which chats they created. +//! +//! Every harness names MCP tools its own way — Claude `mcp__zeron__create_chat` +//! (decoded to `ToolCall::Mcp { server: "zeron", .. }`), Pi `zeron_create_chat` +//! (also decoded to `Mcp`), Codex/Cursor `Mcp { server, tool }`, OpenCode +//! `zeron_create_chat` and ACP titles such as `zeron/create_chat` or +//! `create_chat (zeron MCP Server)` (both left as `ToolCall::Unknown`) — so +//! detection keys on the tool token plus a mention of the Zeron server, +//! never on one spelling. +//! +//! The created chat ids come from the tool part itself: the doc fold keeps +//! a create call's `created_chat_ids` (read from its result, whose text +//! never enters the doc). A result still on the part (pre-strip docs, local +//! journals) is parsed as well. Transcripts written before the fold kept ids +//! fall back to the chats' own provenance (`spawnedByChatId`), matched back +//! to the calls by time: [`attribute_spawned_chats`]. + +use std::collections::{HashMap, HashSet}; + +use zeron_doc::{MessagePart, SessionMessageEntry}; +use zeron_proto::ToolCall; + +pub(crate) use zeron_proto::created_chats::{ + CreateChatOp, CreatedChat, create_chat_op, parse_created_chats, +}; + +/// The chats a create call's part names: the fold's `created_chat_ids`, +/// else whatever an inline result says (richer: kind, host, title). +pub(crate) fn named_chats(created_chat_ids: &[String], output: Option<&str>) -> Vec { + let parsed = output.map(parse_created_chats).unwrap_or_default(); + if created_chat_ids.is_empty() { + return parsed; + } + created_chat_ids + .iter() + .map(|id| { + parsed + .iter() + .find(|chat| &chat.chat_id == id) + .cloned() + .unwrap_or_else(|| CreatedChat { + chat_id: id.clone(), + ..Default::default() + }) + }) + .collect() +} + +/// A settled, successful create call in a transcript. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CreateCallSite { + pub part_id: String, + pub entry_id: String, + /// The carrying message's start (epoch ms). + pub at_ms: i64, + pub op: CreateChatOp, +} + +/// The create calls in `entries` whose chats must be recovered from +/// provenance, and the chat ids other calls' results already name (those +/// are spoken for). Running and failed calls create nothing to link. +pub(crate) fn create_call_sites( + entries: &[SessionMessageEntry], +) -> (Vec, HashSet) { + let mut sites = Vec::new(); + let mut claimed = HashSet::new(); + for entry in entries { + for part in &entry.parts { + let MessagePart::Tool { + id, + call, + is_error, + resolved, + output, + created_chat_ids, + .. + } = part + else { + continue; + }; + let Some(op) = create_chat_op(call) else { + continue; + }; + if !*resolved || *is_error { + continue; + } + let named = named_chats(created_chat_ids, output.as_deref()); + if named.is_empty() { + sites.push(CreateCallSite { + part_id: id.clone(), + entry_id: entry.id.clone(), + at_ms: entry.created_at, + op, + }); + } else { + claimed.extend(named.into_iter().map(|chat| chat.chat_id)); + } + } + } + (sites, claimed) +} + +/// Match the chats a spawner's agent created (`spawned`: `(chat id, +/// created_at ms)` of every chat whose `spawnedByChatId` is the spawner) to +/// the create calls in its transcript, keyed by the call's part id. +/// +/// A chat belongs to the last message that started at or before it was +/// created. Within one message, `create_chat` calls take one chat each in +/// order; a single `create_chats` batch takes whatever the `create_chat`s +/// around it leave. Anything ambiguous (two batches in one message) stays +/// unlinked rather than guessed — the card then falls back to the plain +/// chip. Deleted chats simply leave their call unlinked. +pub(crate) fn attribute_spawned_chats( + sites: &[CreateCallSite], + spawned: &[(String, i64)], +) -> HashMap> { + // Consecutive calls of one message form a group. + let mut groups: Vec<(i64, Vec<&CreateCallSite>)> = Vec::new(); + let mut last_entry: Option<&str> = None; + for site in sites { + if last_entry == Some(site.entry_id.as_str()) + && let Some((_, calls)) = groups.last_mut() + { + calls.push(site); + } else { + groups.push((site.at_ms, vec![site])); + } + last_entry = Some(site.entry_id.as_str()); + } + let mut spawned: Vec<&(String, i64)> = spawned.iter().collect(); + spawned.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0))); + let mut buckets: Vec> = vec![Vec::new(); groups.len()]; + for (chat_id, created_ms) in spawned { + if let Some(group) = groups.iter().rposition(|(at, _)| *at <= *created_ms) { + buckets[group].push(chat_id); + } + } + let mut out: HashMap> = HashMap::new(); + for ((_, calls), chats) in groups.iter().zip(buckets) { + let batches: Vec = calls + .iter() + .enumerate() + .filter(|(_, call)| call.op == CreateChatOp::Batch) + .map(|(ix, _)| ix) + .collect(); + let mut chats = chats.into_iter(); + let mut give = |call: &CreateCallSite, ids: Vec<&str>| { + if !ids.is_empty() { + out.entry(call.part_id.clone()) + .or_default() + .extend(ids.into_iter().map(str::to_owned)); + } + }; + match batches.as_slice() { + [] => { + for call in calls { + give(call, chats.next().into_iter().collect()); + } + } + [batch] => { + let (before, rest) = calls.split_at(*batch); + let after = &rest[1..]; + for call in before { + give(call, chats.next().into_iter().collect()); + } + let remaining: Vec<&str> = chats.collect(); + let keep = remaining.len().saturating_sub(after.len()); + give(rest[0], remaining[..keep].to_vec()); + for (call, chat) in after.iter().zip(&remaining[keep..]) { + give(call, vec![chat]); + } + } + [first, ..] => { + for call in &calls[..*first] { + give(call, chats.next().into_iter().collect()); + } + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mcp(server: &str, tool: &str) -> ToolCall { + ToolCall::Mcp { + server: server.into(), + tool: tool.into(), + input: None, + } + } + + fn site(part: &str, entry: &str, at: i64, op: CreateChatOp) -> CreateCallSite { + CreateCallSite { + part_id: part.into(), + entry_id: entry.into(), + at_ms: at, + op, + } + } + + fn spawned(chats: &[(&str, i64)]) -> Vec<(String, i64)> { + chats.iter().map(|(id, at)| ((*id).into(), *at)).collect() + } + + fn ids(map: &HashMap>, part: &str) -> Vec { + map.get(part).cloned().unwrap_or_default() + } + + #[test] + fn spawned_chats_attribute_to_the_message_that_created_them() { + use CreateChatOp::{Batch, Single}; + let sites = [ + site("t1", "e1", 100, Single), + site("t2", "e2", 200, Batch), + site("t3", "e3", 300, Single), + ]; + // `late` predates nothing it could belong to: before any call. + let map = attribute_spawned_chats( + &sites, + &spawned(&[ + ("early", 50), + ("one", 110), + ("batch-a", 210), + ("batch-b", 215), + ("three", 320), + ]), + ); + assert_eq!(ids(&map, "t1"), ["one"]); + assert_eq!(ids(&map, "t2"), ["batch-a", "batch-b"]); + assert_eq!(ids(&map, "t3"), ["three"]); + assert!(!map.values().flatten().any(|id| id == "early")); + + // A deleted chat leaves its own call empty without shifting others. + let map = attribute_spawned_chats(&sites, &spawned(&[("batch-a", 210), ("three", 320)])); + assert!(ids(&map, "t1").is_empty()); + assert_eq!(ids(&map, "t2"), ["batch-a"]); + assert_eq!(ids(&map, "t3"), ["three"]); + } + + #[test] + fn calls_sharing_one_message_split_its_chats_in_order() { + use CreateChatOp::{Batch, Single}; + // create_chat, create_chats, create_chat in one message. + let sites = [ + site("s1", "e", 100, Single), + site("b", "e", 100, Batch), + site("s2", "e", 100, Single), + ]; + let map = attribute_spawned_chats( + &sites, + &spawned(&[("c1", 101), ("c2", 102), ("c3", 103), ("c4", 104)]), + ); + assert_eq!(ids(&map, "s1"), ["c1"]); + assert_eq!(ids(&map, "b"), ["c2", "c3"]); + assert_eq!(ids(&map, "s2"), ["c4"]); + + // Two batches in one message are ambiguous: only the leading + // create_chat links. + let sites = [ + site("s1", "e", 100, Single), + site("b1", "e", 100, Batch), + site("b2", "e", 100, Batch), + ]; + let map = attribute_spawned_chats(&sites, &spawned(&[("c1", 101), ("c2", 102)])); + assert_eq!(ids(&map, "s1"), ["c1"]); + assert!(ids(&map, "b1").is_empty() && ids(&map, "b2").is_empty()); + } + + #[test] + fn call_sites_skip_running_failed_and_self_describing_calls() { + let tool = |id: &str, tool: &str, resolved: bool, is_error: bool, output: Option<&str>| { + MessagePart::Tool { + id: id.into(), + call: mcp("zeron", tool), + is_error, + resolved, + output: output.map(str::to_owned), + diff: None, + output_ref: None, + output_bytes: None, + diff_ref: None, + diff_stats: None, + subagent_ref: None, + subagent_status: None, + subagent_tail: None, + created_chat_ids: Vec::new(), + } + }; + let entry = SessionMessageEntry { + id: "e".into(), + role: zeron_doc::MessageRole::Assistant, + parts: vec![ + tool("running", "create_chat", false, false, None), + tool("failed", "create_chat", true, true, None), + tool("named", "create_chat", true, false, Some(r#"{"chatId":"known"}"#)), + tool("bare", "create_chats", true, false, None), + tool("other", "list_chats", true, false, None), + ], + created_at: 42, + device_id: "dev".into(), + status: None, + continuation_of: None, + duration_ms: None, + }; + let (sites, claimed) = create_call_sites(std::slice::from_ref(&entry)); + assert_eq!(sites, [site("bare", "e", 42, CreateChatOp::Batch)]); + assert_eq!(claimed, HashSet::from(["known".to_owned()])); + + // The doc fold's ids name the call's chats exactly: no timing guess. + let mut kept = tool("kept", "create_chats", true, false, None); + if let MessagePart::Tool { + created_chat_ids, .. + } = &mut kept + { + *created_chat_ids = vec!["w1".into(), "w2".into()]; + } + let entry = SessionMessageEntry { + parts: vec![kept], + ..entry + }; + let (sites, claimed) = create_call_sites(std::slice::from_ref(&entry)); + assert!(sites.is_empty()); + assert_eq!(claimed, HashSet::from(["w1".to_owned(), "w2".to_owned()])); + let named = named_chats(&["w1".into()], Some(r#"{"chatId":"w1","kind":"side"}"#)); + assert_eq!(named[0].side, Some(true), "inline detail enriches kept ids"); + } +} diff --git a/docs/mcp.md b/docs/mcp.md index 12369c9b28..431c3248c1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -29,26 +29,120 @@ one, and the server refuses to message its own chat. The transcript renders this routing header as “Message from **chat name**”, keeping the full routing instructions in the stored prompt for agents. -### Parent links - -A chat created through `create_chat` records the creating chat as its parent: -`Chat.parent_chat_id` (proto) ⇄ `parentChatId` on the registry/workspace chat -row (`Mutate createChat { parentChatId? }` → `WorkspaceHost::create_chat_with_parent`). -Chats with a parent cannot create chats through MCP, including batch creation -or an explicit parent override. A side chat cannot be selected as a parent; -only one level of side chats is supported. - -The default is the origin chat (`ZERON_CHAT_ID`); an explicit `parent` argument -(id, prefix, or title) overrides it. `list_chats { parent }` returns a chat's -children, and every chat summary carries `parentChatId`. The field is additive -and serde-defaulted: rows written by older engines read as parentless, and a -dangling id (parent deleted) is tolerated rather than cascaded. - -The left sidebar hides chats that have a parent: `AppState::visible_chats` -(the Sessions list, project tabs, jump slots) and the Archived section both -require `parent_chat_id == None`. Children remain addressable by id, deep -link, and every MCP tool; `list_chats { parent }` is how an orchestrator -finds them. +### Chat kinds, parent links and provenance + +`create_chat` (and each `create_chats` request) takes a `kind`: + +| `kind` | Placement | Sidebar | `parentChatId` | `spawnedByChatId` | +| ------ | --------- | ------- | -------------- | ----------------- | +| `side` | Under its parent chat (the right-hand side chats / explorer **Chats**) | Hidden | parent (default: the calling chat) | calling chat | +| `chat` | Top level, exactly like a chat the user created | Shown in Sessions on every device, with a "spawned by" mark | — | calling chat | + +When `kind` is omitted it is `side` whenever there is a parent (the calling +chat, or an explicit `parent`), and `chat` otherwise (a human running +`zeron mcp` from a terminal). Existing callers therefore keep today's side-chat +behaviour; asking for a real chat is explicit. `parent` only applies to +`side`: combining it with `kind: "chat"` is an error, and `kind: "side"` with +no parent to hang it under is an error. + +Two independent fields carry the links: + +- **Placement** — `Chat.parent_chat_id` (proto) ⇄ `parentChatId` on the + registry/workspace chat row. A chat with a parent is a side chat: + `AppState::visible_chats` (Sessions list, project tabs, jump slots), the + Archived section and the mobile front page all require + `parent_chat_id == None`. Side chats stay addressable by id, deep link and + every MCP tool; `list_chats { parent }` lists them. +- **Provenance** — `Chat.spawned_by_chat_id` ⇄ `spawnedByChatId`: whose agent + created the chat, for both kinds. It never hides anything. The desktop + sidebar marks such rows ("Spawned by ", click opens the spawner), the + spawner's transcript links the chats it created, and mobile session rows + carry `spawnedByChatId`/`spawnedByTitle`. +- **Created ids on the call** — tool outputs never enter the session doc, so + the fold keeps only the chat ids a successful Zeron `create_chat` / + `create_chats` result named, on that tool part (`createdChatIds`, + additive; detection and parsing live in `zeron_proto::created_chats` and + tolerate every harness's naming: `mcp__zeron__create_chat`, + `zeron_create_chat`, `Mcp { server: "zeron" }`, ACP titles). The desktop + card links exactly those chats (older transcripts fall back to matching + `spawnedByChatId` by creation time), and `read_chat` prints them as + `mcp: zeron/create_chats [created: , …]`. `list_chats { spawned_by }` returns + every chat a chat spawned, side or top-level. + +`Mutate createChat { parentChatId?, spawnedByChatId? }` → +`WorkspaceHost::create_chat_linked`. Both fields are additive and +serde-defaulted: rows written by older engines read as user-created +top-level chats, an older engine receiving `spawnedByChatId` ignores it (the +chat simply shows as user-made there), and a dangling id (spawner deleted) is +tolerated rather than cascaded. Chats created before this field existed keep +only `parentChatId`, so `spawned_by` does not list them. A fork +(`ForkSideChat`) is the user's own side chat and never inherits provenance. + +Every chat summary carries `kind`, `parentChatId` and `spawnedByChatId`. + +### Who may create chats + +- A **side chat** cannot create chats (one level of side chats), and cannot be + chosen as a `parent`. +- A **top-level** chat may create chats of either kind — including one an + agent spawned. Unbounded recursion is prevented by provenance depth: a + user-created chat is depth 0, a chat it spawns depth 1, and a chat at + `MAX_SPAWN_DEPTH` (3) can run and message other chats but not create more. + The MCP server checks this before writing, and the engine re-checks the + `spawnedByChatId` chain on `createChat` as a backstop (a looping chain counts + as over the limit). +- **Fan-out**: a chat may have at most `MAX_LIVE_SPAWNS` (32) unarchived chats + it spawned at once; `archive_chat` finished workers to free slots. The count + and the row write happen under one lock, so a `create_chats` batch cannot + overshoot. +- **Rate**: one MCP server (one agent run) may create at most 32 chats per + 60 s, so a create/archive loop cannot flood the registry. + +`whoami` reports the calling chat's `spawnDepth`, `liveSpawns`, +`canCreateChats` and the limits. + +### Running chats on any device + +A chat runs on its **host device**: the project's device, or `device` for a +project-less chat (cwd `~` on that host). `create_chat` targets any execution +host in the workspace, not just the local one: + +- `project` — id, path, name, or unique path suffix. A name shared by several + devices (the same repo cloned on each) resolves to this device's copy; pass + `device` to pick another. +- `device` — id or name. Alone: a project-less chat there. With `project`: + the project is looked up among that device's projects (and must live + there). +- `cwd` — an existing directory on the host; `branch` — the ref label (the + base for `worktree`); `worktree: true` — the host creates a fresh isolated + `zeron/` worktree off `branch` (default `HEAD`) when the first turn + starts (`RunRequest.worktree`, the composer's "New worktree"). It needs a git + project and a `prompt`. + +The target is validated before anything is written. It must be an +**execution host** — a device whose engine stamped `capabilities` on its row +(desktop, headless and on-device Android engines), or, for rows from older +engines, a non-phone OS; iOS/iPadOS viewers are refused — and **online**: this +device, or a heartbeat within 70 s (the Devices page window). Errors name the +valid choices (`online execution hosts: GPU box (dev-gpu), …`, or the +device's projects). The harness and model are checked against the **host's** +catalog: `ListHarnesses`/`ListModels` go out with `targetDeviceId`, which the +local engine forwards over the device relay; the default harness is the +host's. The `mock` test rig is never a default and is hidden from pickers, +but an explicit `harness: "mock"` is honoured when the host has it (e2e). `list_harnesses { device }` and `list_models { harness, device }` expose +the same catalogs, and `list_devices` reports each device's `online`, +`executionHost` and `projects`. + +Everything after creation is device-agnostic, exactly as for the desktop +composer: the row is a registry write that syncs everywhere; `send_message`, +the first `prompt`, `interrupt_chat` and `respond_to_input` queue durable +commands into the chat doc, which the local engine syncs to the host (and +nudges a cold host's device room); `read_chat` reads the synced transcript and +`wait_for_turn` the synced session row. The session row and the chat doc +sync separately, so a completed wait keeps re-reading the transcript for up +to 20 s until the new reply has reached this engine's replica. The host engine injects its own Zeron +MCP server into the run, so a top-level chat spawned on another device can +itself orchestrate from there (within the depth limit). ### Injection @@ -88,8 +182,9 @@ a `fork` part (a system entry: `sourceChatId`, `sourceTitle`) as the seam; the transcript draws it as "This chat was forked from ". `parentChatId` defaults to the source; a side chat's own fork button passes its parent so the copy lists as a sibling. The file explorer's footer lists a chat's -**Subagents** (its spawn chips) and **Chats** (its children: forks and -`create_chat` spawns) and opens either in the right pane. +**Subagents** (its spawn chips) and **Chats** (its side chats — forks and +`kind: side` spawns — plus the top-level chats it spawned) and opens either +in the right pane. ## Tools @@ -106,7 +201,7 @@ name (default: the local engine's device). | `list_models` | `ListModels {harness}` | | `list_chats` | `WatchChats` + `WatchSessions` snapshots (status merged) | | `get_chat` | above + `WatchDocMessages` opening frame (pending input) | -| `create_chat` | `Mutate createChat` (+ `renameChat`; optional first send) | +| `create_chat` | `ListHarnesses`/`ListModels` on the host, `Mutate createChat` (+ `renameChat`; optional first send) | | `create_chats` | Concurrent `create_chat` requests with per-request results | | `send_messages` | Concurrent `send_message` requests with per-request results | | `read_chat` | `WatchDocMessages` opening `reset` frame, rendered | @@ -156,6 +251,52 @@ edge. A brand-new chat has no session row until the host picks the run up, so the wait keeps waiting in that case rather than reporting the unstarted run as done (this was the one bug the first live run found). +## Orchestration examples + +A coordinator chat on the laptop fans work out to other devices: long training +runs as real top-level chats on a GPU box (the user can open, read and steer +them from any device like any other chat), and a quick review as a side chat +that stays under the coordinator. + +```mermaid +sequenceDiagram + participant C as Coordinator chat<br/>(laptop engine) + participant L as Laptop engine + participant R as Registry + chat docs<br/>(edge sync) + participant G as GPU box engine + participant P as Phone engine<br/>(on-device) + C->>L: create_chats [{kind:chat, project:"train", device:"GPU box", prompt},<br/>{kind:side, device:"Pixel", prompt}] + L->>G: ListHarnesses/ListModels (targetDeviceId) + L->>R: createChat {spawnedByChatId} and {parentChatId, spawnedByChatId} + L->>R: QueueCommand Run (chat docs) + R-->>G: row + Run for the top-level chat + R-->>P: row + Run for the side chat + G-->>R: transcript + session status + P-->>R: transcript + session status + C->>L: wait_for_turn / read_chat / send_message / interrupt_chat + L-->>C: replies (synced transcripts) + Note over R: The top-level chat lists in every device's sidebar,<br/>marked "Spawned by Coordinator" +``` + +Start both workers in one call (per-request `kind`, `device`, `project`): + +```json +{ + "requests": [ + {"kind": "chat", "device": "GPU box", "project": "train", "title": "Sweep lr", + "harness": "codex", "prompt": "Run the lr sweep in scripts/sweep.sh and report the best config"}, + {"kind": "side", "device": "Pixel", "title": "Check the phone build", + "prompt": "Build the Android app and report any warnings"} + ] +} +``` + +Then `wait_for_turn` each returned `chatId` (or `send_message ... wait: true` +for follow-ups), `read_chat` for detail, `interrupt_chat` to stop one, and +`archive_chat` when done. `list_chats { spawned_by: "<your id>" }` finds them +again later, from any device. A top-level worker on the GPU box may split its +own work the same way (it is depth 1; its workers are depth 2). + ## Parallel side chats Use `create_chats` with a `requests` array to launch independent workers in @@ -191,6 +332,17 @@ BIN=target/debug/zeron sleep 5; } | ZERON_IPC_PORT=27655 $BIN mcp ``` +`scripts/e2e-agent-chats.sh` is the two-device version: a `wrangler dev` edge +(AUTH_MODE=dev), two headless engines, and the `zeron-mcp` example +`agent_chats_e2e` driving real `zeron mcp` servers. The coordinator's agent +on engine A spawns a top-level chat and a side chat on engine B (mock +harness), both engines agree through `list_chats { spawned_by }` and +`read_chat`, and follow-ups, interrupts, target validation and the depth +guard are checked across the devices. With +`ZERON_E2E_AGENT_MODEL=opencode/big-pickle` (a free OpenCode Zen model) a real +agent on A makes the `create_chat` call itself through the engine-injected +server. + `create_chat` with `"prompt": "Reply with exactly the word pong", "wait": true` against a live daemon returns the assistant's `pong` in a few seconds; archive the chat afterwards with `archive_chat`. Unit tests (`cargo test -p zeron-mcp`) diff --git a/docs/media/agent-chats/overview.png b/docs/media/agent-chats/overview.png new file mode 100644 index 0000000000..fe7b4eaee3 Binary files /dev/null and b/docs/media/agent-chats/overview.png differ diff --git a/docs/media/agent-chats/sidebar-spawned-chat-light.png b/docs/media/agent-chats/sidebar-spawned-chat-light.png new file mode 100644 index 0000000000..0fe4fd0d6b Binary files /dev/null and b/docs/media/agent-chats/sidebar-spawned-chat-light.png differ diff --git a/docs/media/agent-chats/sidebar-spawned-chat.png b/docs/media/agent-chats/sidebar-spawned-chat.png new file mode 100644 index 0000000000..0ea96a9e54 Binary files /dev/null and b/docs/media/agent-chats/sidebar-spawned-chat.png differ diff --git a/docs/media/agent-chats/spawner-transcript-card-light.png b/docs/media/agent-chats/spawner-transcript-card-light.png new file mode 100644 index 0000000000..e3bcc87066 Binary files /dev/null and b/docs/media/agent-chats/spawner-transcript-card-light.png differ diff --git a/docs/media/agent-chats/spawner-transcript-card.png b/docs/media/agent-chats/spawner-transcript-card.png new file mode 100644 index 0000000000..4fc3f6f182 Binary files /dev/null and b/docs/media/agent-chats/spawner-transcript-card.png differ diff --git a/scripts/e2e-agent-chats.sh b/scripts/e2e-agent-chats.sh new file mode 100755 index 0000000000..9ee98d384a --- /dev/null +++ b/scripts/e2e-agent-chats.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Two-device e2e for agent-spawned chats: real edge (wrangler dev, dev auth), +# two headless engines (one user, two devices), and the zeron-mcp +# agent_chats_e2e example driving real `zeron mcp` stdio servers: +# +# the coordinator chat's agent on engine A spawns a top-level chat and a +# side chat on engine B; B's engine runs both (mock harness); list_chats / +# read_chat agree from both engines; follow-ups, interrupts, validation and +# the spawn-depth guard are exercised across the two devices. +# +# Usage: scripts/e2e-agent-chats.sh [evidence.json] +# Env: ZERON_E2E_EDGE_PORT (default 27740), ZERON_E2E_KEEP_LOGS=1 to keep logs, +# ZERON_E2E_AGENT_MODEL=opencode/<model> (+ ZERON_E2E_AGENT_HARNESS, default +# opencode) to also have a real agent on A make the create_chat call. +# Ports/dirs differ from e2e-smoke.sh so both can run side by side. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +command -v cargo >/dev/null 2>&1 || PATH="$HOME/.cargo/bin:$PATH" +EDGE_PORT="${ZERON_E2E_EDGE_PORT:-27740}" +EDGE_URL="http://localhost:${EDGE_PORT}" +TOKEN="alice@org1" +ORG="org1" +A_PORT=27851 +B_PORT=27852 +A_DIR=/tmp/e2e-agent-chats-a +B_DIR=/tmp/e2e-agent-chats-b +EVIDENCE="${1:-}" +LOG_DIR="$(mktemp -d /tmp/zeron-e2e-agent-chats-logs.XXXXXX)" +TARGET="${CARGO_TARGET_DIR:-$ROOT/target}" + +EDGE_PID="" +A_PID="" +B_PID="" +STATUS=1 + +cleanup() { + for pid in "$A_PID" "$B_PID"; do + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + fi + done + [[ -n "$EDGE_PID" ]] && kill -- -"$EDGE_PID" 2>/dev/null || true + sleep 1 + for pid in "$A_PID" "$B_PID"; do + [[ -n "$pid" ]] && kill -9 "$pid" 2>/dev/null || true + done + [[ -n "$EDGE_PID" ]] && kill -9 -- -"$EDGE_PID" 2>/dev/null || true + rm -rf "$A_DIR" "$B_DIR" + if [[ "$STATUS" -ne 0 ]]; then + echo "--- engine A log (tail) ---"; tail -n 40 "$LOG_DIR/engine-a.log" 2>/dev/null || true + echo "--- engine B log (tail) ---"; tail -n 40 "$LOG_DIR/engine-b.log" 2>/dev/null || true + echo "--- edge log (tail) ---"; tail -n 40 "$LOG_DIR/edge.log" 2>/dev/null || true + fi + if [[ "${ZERON_E2E_KEEP_LOGS:-0}" != "1" ]]; then + rm -rf "$LOG_DIR" + else + echo "logs kept in $LOG_DIR" + fi +} +trap cleanup EXIT + +wait_for() { # wait_for <description> <timeout_s> <command...> + local what="$1" timeout="$2"; shift 2 + local waited=0 + until "$@" >/dev/null 2>&1; do + sleep 1 + waited=$((waited + 1)) + if [[ "$waited" -ge "$timeout" ]]; then + echo "FAIL: timed out waiting for $what" >&2 + exit 1 + fi + done +} + +# ── 1. Edge worker (wrangler dev, dev auth: bearer == user@org) ──────────────── +if curl -sf -m 3 "$EDGE_URL/health" | grep -q '"auth":"dev"'; then + echo "edge: reusing healthy dev-mode worker on :$EDGE_PORT" +else + echo "edge: starting wrangler dev on :$EDGE_PORT" + set -m + bash -c "cd '$ROOT/edge' && exec npx wrangler dev --port '$EDGE_PORT' --var AUTH_MODE:dev" \ + >"$LOG_DIR/edge.log" 2>&1 & + EDGE_PID=$! + set +m + wait_for "edge /health" 90 curl -sf -m 3 "$EDGE_URL/health" +fi + +# ── 2. Build the binaries ────────────────────────────────────────────────────── +echo "build: zeron + agent_chats_e2e" +(cd "$ROOT" && cargo build -q -p zeron) +(cd "$ROOT" && cargo build -q -p zeron-mcp --example agent_chats_e2e) +ZERON="$TARGET/debug/zeron" +DRIVER="$TARGET/debug/examples/agent_chats_e2e" + +# ── 3. Two headless engines, one user, two devices ───────────────────────────── +rm -rf "$A_DIR" "$B_DIR" +mkdir -p "$A_DIR" "$B_DIR" + +start_engine() { # start_engine <data_dir> <ipc_port> <name> <log> + ZERON_DATA_DIR="$1" ZERON_IPC_PORT="$2" ZERON_DEVICE_NAME="$3" \ + ZERON_EDGE_URL="$EDGE_URL" ZERON_EDGE_TOKEN="$TOKEN" ZERON_ORG_ID="$ORG" \ + ZERON_HARNESS=mock RUST_LOG=info \ + "$ZERON" headless >"$4" 2>&1 & +} + +start_engine "$A_DIR" "$A_PORT" "laptop-a" "$LOG_DIR/engine-a.log"; A_PID=$! +start_engine "$B_DIR" "$B_PORT" "gpu-box-b" "$LOG_DIR/engine-b.log"; B_PID=$! + +wait_for "engine A ipc :$A_PORT" 60 bash -c "exec 3<>/dev/tcp/127.0.0.1/$A_PORT" +wait_for "engine B ipc :$B_PORT" 60 bash -c "exec 3<>/dev/tcp/127.0.0.1/$B_PORT" +echo "engines: A pid=$A_PID ipc=:$A_PORT B pid=$B_PID ipc=:$B_PORT" + +# ── 4. Drive the agent through real `zeron mcp` servers on both engines ─────── +if "$DRIVER" "$ZERON" "$A_PORT" "$B_PORT" $EVIDENCE; then + STATUS=0 +else + STATUS=$? +fi +exit "$STATUS"