Say when a session is working, and what it was told
Three things a phone could not see, all of them the same shape: the session was doing something and nothing on screen said so. A turn nobody here started never reported itself. `Running` was sent where a message was *sent*, so a session picked up mid-turn, one compacting on its own, or one another agent wrote to sat there reading as idle until it finished. The driver now says it from what it observes -- output that could only come from a turn in flight -- which is the same set of events that already announced a steer, with the ends swapped. An imported session had it worse: nothing but replayed lines ever reaches it, and a status was not among them, so it was permanently whatever it was when it was adopted. Its file does not record a turn ending, but it does record why each assistant message stopped, and `tool_use` versus anything else answers it. A record that says nothing leaves the status alone rather than voting for idle. Messages from other agents were dropped outright: the CLI marks them meta, and this replayed everything except meta. They are now a row of their own, closed by default like a tool call, named for the session that sent it -- not the reader's own bubble, because they did not say it, and a session working on something this phone never asked for is exactly what one of these explains. Measured against a real session file rather than guessed: the peer record carries the sender's name and the message body in `origin`, beside a copy wrapped for the model to read.
This commit is contained in:
1 parent
f18639e4b1
commit
404066fa7d
11 files changed
+577
-29
No files matched your search
@@ -410,13 +410,28 @@ pub async fn read_tail(transport: &Transport, path: &str) -> Result<String> {
|
||||
/// which reads its own session file.
|
||||
pub fn events_from(text: &str, session_dir: &std::path::Path) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
// What the newest record that had an opinion says the session is
|
||||
// doing. Kept to the end rather than pushed as it is found, because
|
||||
// the answer is the last one and everything before it is history.
|
||||
let mut state = None;
|
||||
for line in text.lines() {
|
||||
let Ok(record) = serde_json::from_str::<Value>(line) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(peer) = peer_message(&record) {
|
||||
// Before `is_hidden`, which these records are: the CLI marks
|
||||
// them meta because they are not the user's own words, and
|
||||
// that is the reason to draw them differently rather than the
|
||||
// reason to drop them. A session working on something a phone
|
||||
// never asked for is otherwise unexplainable from the phone.
|
||||
state = turn_state(&record).or(state);
|
||||
events.push(peer);
|
||||
continue;
|
||||
}
|
||||
if is_hidden(&record) {
|
||||
continue;
|
||||
}
|
||||
state = turn_state(&record).or(state);
|
||||
let Some(message) = record.get("message") else {
|
||||
continue;
|
||||
};
|
||||
@@ -429,9 +444,72 @@ pub fn events_from(text: &str, session_dir: &std::path::Path) -> Vec<Event> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(state) = state {
|
||||
events.push(Event::Status { state });
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
/// A message from another agent, as the CLI records one.
|
||||
///
|
||||
/// Measured from a real session file (2026-08-29): the record is a `user`
|
||||
/// one marked `isMeta`, and its `origin` carries `kind: "peer"`, the
|
||||
/// sending session's `name`, and the message itself as `body`. The
|
||||
/// message content beside it is the same text wrapped in an explanatory
|
||||
/// preamble and a `<cross-session-message>` tag, which is written for the
|
||||
/// model that has to read it rather than for a person -- so the body is
|
||||
/// what a reader is shown, and the name is who they are told sent it.
|
||||
fn peer_message(record: &Value) -> Option<Event> {
|
||||
let origin = record.get("origin")?;
|
||||
if origin.get("kind").and_then(Value::as_str) != Some("peer") {
|
||||
return None;
|
||||
}
|
||||
Some(Event::PeerMessage {
|
||||
from: origin
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("another session")
|
||||
.to_string(),
|
||||
text: origin.get("body").and_then(Value::as_str)?.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether this record means the session is working, as far as it can be
|
||||
/// told from the file.
|
||||
///
|
||||
/// The one thing a session file does not contain is the CLI saying "this
|
||||
/// turn is over": there is no `result` record, only the messages. What
|
||||
/// there is instead is why the last assistant message stopped, and that
|
||||
/// answers it -- `tool_use` means a call is being made and more is coming,
|
||||
/// anything else means the model has finished talking. Anything on the
|
||||
/// user's side of the conversation -- a person, a tool's result, another
|
||||
/// agent -- means the session has something to answer and is answering it.
|
||||
///
|
||||
/// `None` is the third answer and it matters: a record that says nothing
|
||||
/// about the turn leaves the status alone rather than voting for idle. The
|
||||
/// same goes for a record whose reason for stopping is missing, which is
|
||||
/// what a future CLI adding a shape we do not know looks like.
|
||||
///
|
||||
/// What this cannot see is a session that stopped existing mid-turn -- its
|
||||
/// file's last record still says `tool_use`, so it reads as working
|
||||
/// forever. Nothing in the file distinguishes that from a model thinking,
|
||||
/// and inventing a timeout here would replace a stale reading with a
|
||||
/// confident wrong one.
|
||||
fn turn_state(record: &Value) -> Option<super::driver::SessionStatus> {
|
||||
use super::driver::SessionStatus;
|
||||
match record.get("type").and_then(Value::as_str)? {
|
||||
"user" => Some(SessionStatus::Running),
|
||||
"assistant" => match record["message"]
|
||||
.get("stop_reason")
|
||||
.and_then(Value::as_str)?
|
||||
{
|
||||
"tool_use" => Some(SessionStatus::Running),
|
||||
_ => Some(SessionStatus::Idle),
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_user(events: &mut Vec<Event>, content: &Value, session_dir: &std::path::Path) {
|
||||
// A tool result arrives as a user record, because that is how the API
|
||||
// models it -- but it is the other half of a tool call, not something
|
||||
@@ -678,8 +756,83 @@ mod tests {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let line = r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"plain output"}]}}"#;
|
||||
let events = events_from(line, dir.path());
|
||||
assert_eq!(events.len(), 1, "{events:?}");
|
||||
// The result, and the turn state it implies: a tool has answered,
|
||||
// so the model is about to be asked again.
|
||||
assert_eq!(events.len(), 2, "{events:?}");
|
||||
assert_eq!(
|
||||
events[1],
|
||||
Event::Status {
|
||||
state: super::super::driver::SessionStatus::Running
|
||||
}
|
||||
);
|
||||
// No stray directory for a session that never produced one.
|
||||
assert!(!dir.path().join("files").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_from_another_agent_is_kept_and_named() {
|
||||
// The real shape, from a session file: the CLI marks these meta,
|
||||
// and everything a reader needs is in `origin`.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let line = r#"{"type":"user","isMeta":true,"origin":{"kind":"peer","from":"uds:/run/user/1000/cc-socks/605.sock","verifiedPeerPid":605,"name":"dev-updater-f5","fromMode":"prompting","body":"Pull before you touch AGENTS.md."},"message":{"role":"user","content":"Another Claude session sent a message:\n<cross-session-message from-name=\"dev-updater-f5\">\nPull before you touch AGENTS.md.\n</cross-session-message>"}}"#;
|
||||
let events = events_from(line, dir.path());
|
||||
assert_eq!(
|
||||
events[0],
|
||||
Event::PeerMessage {
|
||||
from: "dev-updater-f5".to_string(),
|
||||
// The body, not the wrapper the model is given.
|
||||
text: "Pull before you touch AGENTS.md.".to_string(),
|
||||
},
|
||||
"{events:?}"
|
||||
);
|
||||
// And it counts as the session having been given something.
|
||||
assert_eq!(
|
||||
events[1],
|
||||
Event::Status {
|
||||
state: super::super::driver::SessionStatus::Running
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_record_says_whether_the_session_is_working() {
|
||||
use super::super::driver::SessionStatus;
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let asked = r#"{"type":"user","message":{"role":"user","content":"do the thing"}}"#;
|
||||
let calling = r#"{"type":"assistant","message":{"role":"assistant","stop_reason":"tool_use","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}}"#;
|
||||
let done = r#"{"type":"assistant","message":{"role":"assistant","stop_reason":"end_turn","content":[{"type":"text","text":"done"}]}}"#;
|
||||
|
||||
let state = |text: &str| {
|
||||
events_from(text, dir.path())
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find_map(|event| match event {
|
||||
Event::Status { state } => Some(state),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
assert_eq!(state(asked), Some(SessionStatus::Running));
|
||||
assert_eq!(
|
||||
state(&[asked, calling].join("\n")),
|
||||
Some(SessionStatus::Running)
|
||||
);
|
||||
assert_eq!(
|
||||
state(&[asked, calling, done].join("\n")),
|
||||
Some(SessionStatus::Idle),
|
||||
"a turn that has finished talking is over"
|
||||
);
|
||||
|
||||
// A subagent's own messages are not the session's turn, and a
|
||||
// record with no stop reason is not an answer -- neither may
|
||||
// overrule what the conversation itself last said.
|
||||
let sidechain = r#"{"type":"assistant","isSidechain":true,"message":{"role":"assistant","stop_reason":"end_turn","content":[{"type":"text","text":"sub"}]}}"#;
|
||||
let unknown = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"?"}]}}"#;
|
||||
assert_eq!(
|
||||
state(&[asked, calling, sidechain, unknown].join("\n")),
|
||||
Some(SessionStatus::Running)
|
||||
);
|
||||
|
||||
// And nothing at all to go on says nothing, rather than idle.
|
||||
assert_eq!(state(r#"{"type":"summary","summary":"x"}"#), None);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user