Support Codex subagent transcripts

This commit is contained in:
iris committed 2026-09-13 00:41:59 -04:00
1 parent 7d9df5d572
commit 83b113ef0f
6 files changed
+557 -38

No files matched your search

+469 -1
View File
@@ -4,9 +4,13 @@
//! therefore match only the records that have a useful common equivalent and
//! ignore the rest; an added Codex item must not make a live session go deaf.
use std::collections::HashMap;
use std::sync::Arc;
use serde_json::{Value, json};
use super::super::driver::{Event, SessionStatus, patch_start};
use super::super::subagent::Subagents;
#[derive(Default)]
pub(super) struct Translator {
@@ -14,6 +18,10 @@ pub(super) struct Translator {
completed: bool,
limited: bool,
pending_usage: Option<Usage>,
subagents: Option<Arc<Subagents>>,
children: HashMap<String, Translator>,
prompts: HashMap<String, String>,
in_turn: bool,
}
struct Usage {
@@ -22,10 +30,87 @@ struct Usage {
}
impl Translator {
pub(super) fn new(subagents: Arc<Subagents>, thread_id: Option<String>, in_turn: bool) -> Self {
Self {
thread_id,
subagents: Some(subagents),
in_turn,
..Self::default()
}
}
/// Whether a protocol notification belongs to the session's root thread.
/// App-server multiplexes every spawned thread onto the same stdout, so
/// the process-state bookkeeping in `codex.rs` must not mistake a child's
/// turn for the session's own.
pub(super) fn is_parent(&self, line: &Value) -> bool {
if line.get("id").is_some() && line.get("method").is_none() {
return true;
}
if line.get("method").and_then(Value::as_str) == Some("thread/started")
&& line
.pointer("/params/thread/parentThreadId")
.is_some_and(|parent| !parent.is_null())
{
return false;
}
match (self.thread_id.as_deref(), notification_thread(line)) {
(Some(parent), Some(thread)) => parent == thread,
_ => true,
}
}
#[cfg(test)]
pub(super) fn translate(&mut self, line: &Value) -> Vec<Event> {
self.translate_with_prefix(line, Vec::new())
}
/// Translates one multiplexed app-server record. `prefix` contains image
/// events extracted by the driver and must precede the item's ToolEnd in
/// whichever transcript owns the record.
pub(super) fn translate_with_prefix(
&mut self,
line: &Value,
mut prefix: Vec<Event>,
) -> Vec<Event> {
let method = line.get("method").and_then(Value::as_str);
let body = method.and_then(|_| line.get("params")).unwrap_or(line);
let kind = method.or_else(|| line.get("type").and_then(Value::as_str));
let item = body.get("item").unwrap_or(&Value::Null);
self.remember_collaboration(item);
if item.get("type").and_then(Value::as_str) == Some("subAgentActivity") {
return self.translate_activity(item);
}
self.remember_spawned_thread(kind, body);
let thread = notification_thread(line);
let child = thread.filter(|thread| {
self.thread_id
.as_deref()
.is_some_and(|parent| parent != *thread)
});
if let Some(id) = child {
self.ensure_child(id, "subagent", None);
if let Some(subagents) = &self.subagents {
subagents.reopen(id);
}
let translator = self.children.entry(id.to_string()).or_default();
prefix.extend(translator.translate_line(kind, body, line));
return self.record_child(id, prefix);
}
if matches!(kind, Some("turn.started" | "turn/started")) {
self.in_turn = true;
}
prefix.extend(self.translate_line(kind, body, line));
if matches!(kind, Some("turn.completed" | "turn/completed")) {
self.in_turn = false;
}
prefix
}
fn translate_line(&mut self, kind: Option<&str>, body: &Value, line: &Value) -> Vec<Event> {
match kind {
Some("thread.started") => {
self.thread_id = line
@@ -113,7 +198,15 @@ impl Translator {
events.extend(self.failure(error));
}
events.push(Event::Status {
state: SessionStatus::Idle,
state: if self
.subagents
.as_ref()
.is_some_and(|subagents| subagents.any_open(true))
{
SessionStatus::Waiting
} else {
SessionStatus::Idle
},
});
events
}
@@ -122,6 +215,181 @@ impl Translator {
}
}
/// Remembers prompts before the separate activity item announces which
/// child thread a spawn produced. Interactions with an existing child are
/// also real user turns in that child's transcript.
fn remember_collaboration(&mut self, item: &Value) {
if item.get("type").and_then(Value::as_str) != Some("collabAgentToolCall") {
return;
}
let prompt = item
.get("prompt")
.and_then(Value::as_str)
.filter(|prompt| !prompt.is_empty())
.map(str::to_string);
if let (Some(id), Some(prompt)) = (item.get("id").and_then(Value::as_str), &prompt) {
self.prompts.insert(id.to_string(), prompt.clone());
}
let Some(receivers) = item.get("receiverThreadIds").and_then(Value::as_array) else {
return;
};
for receiver in receivers.iter().filter_map(Value::as_str) {
if let Some(prompt) = &prompt {
self.prompts.insert(receiver.to_string(), prompt.clone());
}
}
if item.get("status").and_then(Value::as_str) != Some("completed")
|| item.get("tool").and_then(Value::as_str) == Some("spawnAgent")
{
return;
}
let Some(prompt) = prompt else {
return;
};
for receiver in receivers.iter().filter_map(Value::as_str) {
if self.thread_id.as_deref() == Some(receiver) {
continue;
}
self.ensure_child(receiver, "subagent", None);
if let Some(subagents) = &self.subagents {
subagents.reopen(receiver);
subagents.record(
receiver,
Event::UserMessage {
id: None,
text: prompt.clone(),
attachments: Vec::new(),
},
);
}
}
}
fn remember_spawned_thread(&mut self, kind: Option<&str>, body: &Value) {
if kind != Some("thread/started") {
return;
}
let thread = &body["thread"];
if thread.get("parentThreadId").is_none_or(Value::is_null) {
return;
}
let Some(id) = thread.get("id").and_then(Value::as_str) else {
return;
};
let title = thread
.get("name")
.and_then(Value::as_str)
.or_else(|| {
thread
.pointer("/source/subAgent/thread_spawn/agent_path")
.and_then(Value::as_str)
})
.map(subagent_title)
.unwrap_or_else(|| "subagent".to_string());
let prompt = self.prompts.get(id).cloned().or_else(|| {
thread
.get("preview")
.and_then(Value::as_str)
.map(str::to_string)
});
self.ensure_child(id, &title, prompt.as_deref());
if let Some(subagents) = &self.subagents {
subagents.reopen(id);
}
}
fn translate_activity(&mut self, item: &Value) -> Vec<Event> {
let Some(id) = item.get("agentThreadId").and_then(Value::as_str) else {
return Vec::new();
};
let title = item
.get("agentPath")
.and_then(Value::as_str)
.map(subagent_title)
.unwrap_or_else(|| "subagent".to_string());
match item.get("kind").and_then(Value::as_str) {
Some("started") => {
let prompt = item
.get("id")
.and_then(Value::as_str)
.and_then(|call| self.prompts.get(call))
.cloned()
.or_else(|| self.prompts.get(id).cloned());
self.ensure_child(id, &title, prompt.as_deref());
if let Some(subagents) = &self.subagents {
subagents.reopen(id);
}
Vec::new()
}
Some("interacted") => {
self.ensure_child(id, &title, None);
if let Some(subagents) = &self.subagents {
subagents.reopen(id);
}
Vec::new()
}
Some("interrupted" | "completed") => {
self.ensure_child(id, &title, None);
let was_open = self
.subagents
.as_ref()
.is_some_and(|subagents| subagents.is_open(id));
if let Some(subagents) = &self.subagents {
subagents.finish(id);
}
self.prompts.remove(id);
if was_open
&& !self.in_turn
&& self
.subagents
.as_ref()
.is_some_and(|subagents| !subagents.any_open(true))
{
vec![Event::Status {
state: SessionStatus::Idle,
}]
} else {
Vec::new()
}
}
_ => Vec::new(),
}
}
fn ensure_child(&mut self, id: &str, title: &str, prompt: Option<&str>) {
let Some(subagents) = &self.subagents else {
return;
};
if subagents.get(id).is_none() {
subagents.start(id, title, prompt);
}
self.children.entry(id.to_string()).or_default();
}
fn record_child(&self, id: &str, events: Vec<Event>) -> Vec<Event> {
let Some(subagents) = &self.subagents else {
return events;
};
let mut parent = Vec::new();
for event in events {
// A child remains running between its own turns and is closed by
// SubAgentActivity, not by its turn/completed notification.
if matches!(
event,
Event::Status {
state: SessionStatus::Idle | SessionStatus::Waiting | SessionStatus::Exited
}
) {
continue;
}
if matches!(event, Event::LimitReached { .. }) {
parent.push(event.clone());
}
subagents.record(id, event);
}
parent
}
#[cfg(test)]
pub(super) fn completed(&self) -> bool {
self.completed
@@ -148,6 +416,23 @@ impl Translator {
}
}
fn notification_thread(line: &Value) -> Option<&str> {
let method = line.get("method").and_then(Value::as_str)?;
let body = &line["params"];
if method == "thread/started" {
body.pointer("/thread/id").and_then(Value::as_str)
} else {
body.get("threadId").and_then(Value::as_str)
}
}
fn subagent_title(path: &str) -> String {
path.rsplit('/')
.find(|part| !part.is_empty())
.unwrap_or(path)
.replace('_', " ")
}
fn start_item(item: &Value) -> Vec<Event> {
if matches!(
item.get("type").and_then(Value::as_str),
@@ -185,6 +470,11 @@ fn update_item(item: &Value) -> Vec<Event> {
fn complete_item(item: &Value, include_agent_message: bool) -> Vec<Event> {
match item.get("type").and_then(Value::as_str) {
Some("agent_message" | "agentMessage")
if item.get("delivery").and_then(Value::as_str) == Some("async") =>
{
delivered_agent_message(item).into_iter().collect()
}
Some("agent_message" | "agentMessage") => item
.get("text")
.and_then(Value::as_str)
@@ -222,6 +512,9 @@ fn complete_item(item: &Value, include_agent_message: bool) -> Vec<Event> {
fn final_item(item: &Value) -> Vec<Event> {
match item.get("type").and_then(Value::as_str) {
Some("agentMessage") if item.get("delivery").and_then(Value::as_str) == Some("async") => {
delivered_agent_message(item).into_iter().collect()
}
Some("agentMessage") => item
.get("text")
.and_then(Value::as_str)
@@ -256,6 +549,30 @@ fn tool(item: &Value) -> Option<(String, String, Value)> {
.to_string(),
item.get("arguments").cloned().unwrap_or(Value::Null),
),
"collabAgentToolCall" => {
let name = match item.get("tool").and_then(Value::as_str) {
Some("spawnAgent") => "Task",
Some("sendInput" | "sendMessage" | "followupTask") => "SendMessage",
Some("wait") => "TaskOutput",
Some("closeAgent") => "CloseAgent",
Some("interruptAgent") => "InterruptAgent",
Some("listAgents") => "ListAgents",
Some("resumeAgent") => "ResumeAgent",
Some(tool) => tool,
None => "Agent",
};
let mut input = json!({});
if let Some(prompt) = item.get("prompt") {
input["prompt"] = prompt.clone();
}
if let Some(model) = item.get("model") {
input["model"] = model.clone();
}
if let Some(effort) = item.get("reasoningEffort") {
input["reasoningEffort"] = effort.clone();
}
(name.to_string(), input)
}
"functionCallOutput" => (
item.get("name")
.and_then(Value::as_str)
@@ -277,6 +594,29 @@ fn tool(item: &Value) -> Option<(String, String, Value)> {
Some((id, name, input))
}
fn delivered_agent_message(item: &Value) -> Option<Event> {
let text = item.get("text").and_then(Value::as_str)?;
if text.is_empty() {
return None;
}
let from = text
.lines()
.find_map(|line| line.strip_prefix("Sender: "))
.unwrap_or("another agent")
.to_string();
let text = text
.split_once("Payload:\n")
.map(|(_, payload)| payload.trim())
.filter(|payload| !payload.is_empty())
.unwrap_or(text)
.to_string();
Some(Event::PeerMessage {
from,
text,
turn_start: None,
})
}
/// Codex records the executor's argv, while Claude reports the script handed to its Bash tool.
/// Collapse Codex's standard wrapper to the same common shape so the transcript describes the
/// command a person wrote, not the implementation used to start it. An unfamiliar executable is
@@ -744,4 +1084,132 @@ mod tests {
}]
);
}
#[test]
fn codex_subagents_get_their_own_transcripts_and_hold_the_parent_waiting() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Arc::new(Subagents::new(dir.path().to_path_buf()));
let mut translator = Translator::new(
Arc::clone(&subagents),
Some("parent-thread".to_string()),
false,
);
assert_eq!(
translator.translate(&line(
r#"{"method":"item/started","params":{"threadId":"parent-thread","turnId":"turn-1","item":{"id":"spawn-1","type":"collabAgentToolCall","tool":"spawnAgent","status":"inProgress","senderThreadId":"parent-thread","receiverThreadIds":[],"agentsStates":{},"prompt":"audit the history","model":null,"reasoningEffort":null}}}"#
)),
vec![Event::ToolStart {
id: "spawn-1".to_string(),
tool: "Task".to_string(),
input: json!({"prompt": "audit the history", "model": null, "reasoningEffort": null})
}]
);
assert!(
translator
.translate(&line(
r#"{"method":"item/completed","params":{"threadId":"parent-thread","turnId":"turn-1","item":{"id":"spawn-1","type":"subAgentActivity","kind":"started","agentThreadId":"child-thread","agentPath":"/root/history_boundaries"}}}"#
))
.is_empty()
);
let rows = subagents.list(true);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].id, "child-thread");
assert_eq!(rows[0].title, "history boundaries");
assert!(
translator
.translate(&line(
r#"{"method":"turn/started","params":{"threadId":"child-thread","turn":{"id":"child-turn"}}}"#
))
.is_empty()
);
assert!(
translator
.translate(&line(
r#"{"method":"item/completed","params":{"threadId":"child-thread","turnId":"child-turn","item":{"id":"child-message","type":"agentMessage","text":"the audit result","phase":"final_answer","delivery":null}}}"#
))
.is_empty()
);
assert!(
translator
.translate(&line(
r#"{"method":"turn/completed","params":{"threadId":"child-thread","turn":{"id":"child-turn","status":"completed","error":null}}}"#
))
.is_empty()
);
assert_eq!(
translator.translate(&line(
r#"{"method":"turn/completed","params":{"threadId":"parent-thread","turn":{"id":"turn-1","status":"completed","error":null}}}"#
)),
vec![Event::Status {
state: SessionStatus::Waiting
}]
);
assert_eq!(
translator.translate(&line(
r#"{"method":"item/completed","params":{"threadId":"parent-thread","turnId":"turn-1","item":{"id":"subagent-completed-1","type":"subAgentActivity","kind":"completed","agentThreadId":"child-thread","agentPath":"/root/history_boundaries"}}}"#
)),
vec![Event::Status {
state: SessionStatus::Idle
}]
);
let child = subagents.get("child-thread").expect("child");
let events = crate::session::transcript::read_after(&child.transcript_path(), 0)
.expect("child transcript");
assert!(matches!(
&events[1].event,
Event::UserMessage { text, .. } if text == "audit the history"
));
assert!(events.iter().any(|event| matches!(
&event.event,
Event::AssistantTextFinal { text } if text == "the audit result"
)));
assert_eq!(
events.last().map(|event| &event.event),
Some(&Event::Status {
state: SessionStatus::Exited
})
);
}
#[test]
fn delivered_agent_messages_are_peer_messages_not_the_parents_reply() {
let event = final_item(&json!({
"id": "delivery-1",
"type": "agentMessage",
"delivery": "async",
"text": "Message Type: FINAL_ANSWER\nSender: /root/audit\nPayload:\nFound it."
}));
assert_eq!(
event,
vec![Event::PeerMessage {
from: "/root/audit".to_string(),
text: "Found it.".to_string(),
turn_start: None
}]
);
}
#[test]
fn an_adopted_turn_does_not_go_idle_when_its_last_child_finishes() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Arc::new(Subagents::new(dir.path().to_path_buf()));
subagents.start("child-thread", "child", Some("work"));
let mut translator = Translator::new(
Arc::clone(&subagents),
Some("parent-thread".to_string()),
true,
);
assert!(
translator
.translate(&line(
r#"{"method":"item/completed","params":{"threadId":"parent-thread","turnId":"turn-1","item":{"id":"subagent-completed-1","type":"subAgentActivity","kind":"completed","agentThreadId":"child-thread","agentPath":"/root/child"}}}"#
))
.is_empty()
);
assert_eq!(subagents.list(true)[0].status, SessionStatus::Exited);
}
}