Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/ios/Zeron/Core/Generated/zeron_core.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
)
}
Expand Down Expand Up @@ -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)
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))?;
Expand Down
1 change: 1 addition & 0 deletions crates/client/src/demo/fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions crates/client/src/demo/transcripts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
}

Expand Down
98 changes: 98 additions & 0 deletions crates/client/src/workspace/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ pub struct SessionRow {
/// Oldest unadopted send from this device, if any.
pub send_state: Option<SendState>,
pub parent_chat_id: Option<String>,
/// 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<String>,
/// 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<String>,
/// Sync room generation (2 = chat2; 1 = legacy, not dialable).
pub room_gen: u32,
}
Expand Down Expand Up @@ -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()
}
Expand All @@ -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<SessionRow> {
Expand Down Expand Up @@ -507,6 +516,20 @@ fn build_row(chat: &Chat, rc: &RowContext<'_>, cx: &DeriveContext<'_>) -> Arc<Se
pull_request: change_request_for_chat(chat, cx.change_requests).cloned(),
send_state,
parent_chat_id: chat.parent_chat_id.clone(),
spawned_by_chat_id: chat.spawned_by_chat_id.clone(),
spawned_by_title: chat
.spawned_by_chat_id
.as_deref()
.and_then(|id| rc.chats.get(id))
.map(|spawner| {
spawner
.title
.as_deref()
.map(str::trim)
.filter(|t| !t.is_empty())
.unwrap_or("New session")
.to_owned()
}),
room_gen: chat.room_gen.unwrap_or(1),
};
row.revision = hash_row(&row);
Expand Down Expand Up @@ -536,6 +559,7 @@ pub(crate) fn derive(
}
}
let rc = RowContext {
chats: state.chats.iter().map(|c| (c.id.as_str(), c)).collect(),
spaces: state.spaces.iter().map(|s| (s.id.as_str(), s)).collect(),
devices: state.devices.iter().map(|d| (d.id.as_str(), d)).collect(),
sessions: state
Expand Down Expand Up @@ -793,4 +817,78 @@ mod tests {
assert_eq!(relative_time_label(now - 4 * 3_600_000 - 1, now), "4h");
assert_eq!(relative_time_label(now - 2 * 86_400_000, now), "2d");
}

fn chat(id: &str, title: &str, parent: Option<&str>, 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"]);
}
}
1 change: 1 addition & 0 deletions crates/client/tests/live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ fn host_rows(now: chrono::DateTime<Utc>) -> (Device, Space, Chat) {
last_seen_at: Some(now),
room_gen: Some(2),
parent_chat_id: None,
spawned_by_chat_id: None,
};
(device, space, chat)
}
Expand Down
71 changes: 71 additions & 0 deletions crates/doc/src/parts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,
},
#[serde(rename_all = "camelCase")]
Input {
Expand Down Expand Up @@ -357,6 +363,7 @@ pub fn fold_event_into_parts(out: &mut Vec<MessagePart>, event: &AgentEvent) {
subagent_ref: None,
subagent_status: None,
subagent_tail: None,
created_chat_ids: Vec::new(),
});
}
}
Expand All @@ -375,12 +382,25 @@ pub fn fold_event_into_parts(out: &mut Vec<MessagePart>, 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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions crates/doc/src/rebuild.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ mod tests {
subagent_ref: None,
subagent_status: None,
subagent_tail: None,
created_chat_ids: Vec::new(),
},
],
created_at: 5,
Expand Down
8 changes: 8 additions & 0 deletions crates/doc/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
Expand Down Expand Up @@ -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()),
),
]),
);
}
Expand Down
Loading
Loading