`UsageDelta` gains `prefillMs`, llama-server's own `timings.prompt_ms`, so the footer under a finished reply is "read 9.5s · 50.3 tok/s · 3:00 PM". Prefill is the half of a turn that was invisible and is often the larger: measured on the 0.6B here, 1m 4s for the first turn after a model loads against 22ms for the next, whose prompt the server still had cached. The clock moves to the end of the line. Everything in front of it is a provider's own measurement, so a session on another provider has fewer of them or none, and a reader who has learned where the time is should not have to find it again because the model changed. The costs grow leftwards into the space instead, and a test asserts every shape of the line ends with the same thing. Verified on the emulator against a real llama session: three replies reading "read 1m 4s · 193 tok/s · 3:54 PM", "read 25ms · 308 tok/s · 3:54 PM" and "read 22ms · 194 tok/s · 3:54 PM", with the clock in one column. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1535 lines
58 KiB
Rust
1535 lines
58 KiB
Rust
//! Codex app-server notifications into the common event model.
|
|
//!
|
|
//! The CLI promises JSONL but deliberately leaves room for new item kinds. We
|
|
//! 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, HashSet};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use super::super::driver::{Event, SessionStatus, patch_start, prefixed_lines};
|
|
use super::super::subagent::Subagents;
|
|
|
|
#[derive(Default)]
|
|
pub(super) struct Translator {
|
|
pub(super) thread_id: Option<String>,
|
|
completed: bool,
|
|
limited: bool,
|
|
subagents: Option<Arc<Subagents>>,
|
|
background_processes: Option<Arc<Mutex<HashSet<String>>>>,
|
|
children: HashMap<String, Translator>,
|
|
prompts: HashMap<String, String>,
|
|
async_messages: HashSet<String>,
|
|
in_turn: bool,
|
|
}
|
|
|
|
impl Translator {
|
|
pub(super) fn new(
|
|
subagents: Arc<Subagents>,
|
|
background_processes: Arc<Mutex<HashSet<String>>>,
|
|
thread_id: Option<String>,
|
|
in_turn: bool,
|
|
) -> Self {
|
|
Self {
|
|
thread_id,
|
|
subagents: Some(subagents),
|
|
background_processes: Some(background_processes),
|
|
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;
|
|
}
|
|
let kind = line.get("method").and_then(Value::as_str);
|
|
let body = kind.and_then(|_| line.get("params")).unwrap_or(line);
|
|
if let Some(root) = started_thread_is_root(kind, body) {
|
|
// A clear or missing-rollout recovery replaces the root id inside this same
|
|
// app-server. The new root cannot match the id this translator still holds; its null
|
|
// parent is the authoritative distinction from a newly spawned subagent.
|
|
return root;
|
|
}
|
|
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, prefix: Vec<Event>) -> Vec<Event> {
|
|
let before = self.background_task_count();
|
|
let mut events = self.translate_inner(line, prefix);
|
|
let after = self.background_task_count();
|
|
if after != before
|
|
&& let Some(count) = after
|
|
{
|
|
events.push(Event::BackgroundTasks { count });
|
|
}
|
|
events
|
|
}
|
|
|
|
fn translate_inner(&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 root_started = started_thread_is_root(kind, body) == Some(true);
|
|
let child = thread.filter(|thread| {
|
|
!root_started
|
|
&& 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 background_task_count(&self) -> Option<usize> {
|
|
self.subagents.as_ref().map(|subagents| {
|
|
subagents.open_count()
|
|
+ self
|
|
.background_processes
|
|
.as_ref()
|
|
.map(|processes| processes.lock().unwrap().len())
|
|
.unwrap_or(0)
|
|
})
|
|
}
|
|
|
|
fn translate_line(&mut self, kind: Option<&str>, body: &Value, line: &Value) -> Vec<Event> {
|
|
match kind {
|
|
Some("thread.started") => {
|
|
self.thread_id = line
|
|
.get("thread_id")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string);
|
|
Vec::new()
|
|
}
|
|
Some("thread/started") => {
|
|
self.thread_id = body
|
|
.pointer("/thread/id")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string);
|
|
Vec::new()
|
|
}
|
|
Some("turn.started") | Some("turn/started") => {
|
|
self.completed = false;
|
|
self.limited = false;
|
|
vec![Event::Status {
|
|
state: SessionStatus::Running,
|
|
}]
|
|
}
|
|
Some("item.started") | Some("item/started") => {
|
|
let item = &body["item"];
|
|
if let Some(id) = item.get("id").and_then(Value::as_str)
|
|
&& item.get("delivery").and_then(Value::as_str) == Some("async")
|
|
{
|
|
self.async_messages.insert(id.to_string());
|
|
}
|
|
start_item(item)
|
|
}
|
|
Some("item.updated") => update_item(&line["item"]),
|
|
// The old `codex exec --json` dialect reports only the completed message. App-server
|
|
// also reports deltas, but they are provisional: safety buffering can revise their
|
|
// text before completion. Keep its completed copy as an append-only correction rather
|
|
// than guessing that the two representations concatenate to the same answer.
|
|
Some("item.completed") => complete_item(&body["item"], true),
|
|
Some("item/completed") => {
|
|
let item = &body["item"];
|
|
let events = final_item(item);
|
|
if let Some(id) = item.get("id").and_then(Value::as_str) {
|
|
self.async_messages.remove(id);
|
|
}
|
|
events
|
|
}
|
|
Some("item/agentMessage/delta") => {
|
|
if body
|
|
.get("itemId")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|id| self.async_messages.contains(id))
|
|
{
|
|
// The delta notification does not repeat `delivery`.
|
|
// Appending an asynchronously delivered peer report as
|
|
// assistant text merges it into the recipient's current
|
|
// reply; the next completed assistant item then replaces
|
|
// that whole row. The item/started record did identify it,
|
|
// so leave its text to the PeerMessage emitted at completion.
|
|
return Vec::new();
|
|
}
|
|
let Some(delta) = body.get("delta").and_then(Value::as_str) else {
|
|
return Vec::new();
|
|
};
|
|
vec![Event::AssistantText {
|
|
delta: delta.to_string(),
|
|
}]
|
|
}
|
|
Some("item/commandExecution/outputDelta") => {
|
|
let (Some(id), Some(output)) = (
|
|
body.get("itemId").and_then(Value::as_str),
|
|
body.get("delta").and_then(Value::as_str),
|
|
) else {
|
|
return Vec::new();
|
|
};
|
|
vec![Event::ToolUpdate {
|
|
id: id.to_string(),
|
|
output: output.to_string(),
|
|
}]
|
|
}
|
|
// One of these per *model request*, not per turn: app-server's `last` is the
|
|
// request that just finished, and a turn is as many requests as it made tool
|
|
// calls. Reported as each arrives, so `tokens` adds up to what the turn cost --
|
|
// held to the end of the turn it was the last request's cost alone, which on a
|
|
// two-request turn measured 28,878 against the 51,399 actually spent -- and so
|
|
// the context figure moves while a long turn is still running rather than
|
|
// standing at what it was before the turn began.
|
|
Some("thread/tokenUsage/updated") => {
|
|
let last = &body["tokenUsage"]["last"];
|
|
last.get("totalTokens")
|
|
.and_then(Value::as_u64)
|
|
.map(|tokens| Event::UsageDelta {
|
|
tokens,
|
|
// Cached input is a subset of this figure, not an additional count.
|
|
context: last.get("inputTokens").and_then(Value::as_u64),
|
|
tokens_per_second: None,
|
|
prefill_ms: None,
|
|
})
|
|
.into_iter()
|
|
.collect()
|
|
}
|
|
Some("turn.completed") | Some("turn/completed") => {
|
|
self.completed = true;
|
|
let mut events = Vec::new();
|
|
// The old `codex exec --json` dialect reports the turn's usage here and
|
|
// sends no `thread/tokenUsage/updated` at all.
|
|
if let Some(usage) = line.get("usage") {
|
|
let input = number(usage, "input_tokens");
|
|
let output = number(usage, "output_tokens");
|
|
if input.is_some() || output.is_some() {
|
|
events.push(Event::UsageDelta {
|
|
tokens: input.unwrap_or(0) + output.unwrap_or(0),
|
|
context: input,
|
|
tokens_per_second: None,
|
|
prefill_ms: None,
|
|
});
|
|
}
|
|
}
|
|
if let Some(error) = body.pointer("/turn/error")
|
|
&& !error.is_null()
|
|
{
|
|
events.extend(self.failure(error));
|
|
}
|
|
events.push(Event::Status {
|
|
state: if self.background_task_count().is_some_and(|count| count > 0) {
|
|
SessionStatus::Waiting
|
|
} else {
|
|
SessionStatus::Idle
|
|
},
|
|
});
|
|
events
|
|
}
|
|
Some("turn.failed") | Some("error") => self.failure(body),
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
item.get("id")
|
|
.and_then(Value::as_str)
|
|
.map(|call| {
|
|
vec![Event::ToolEnd {
|
|
id: call.to_string(),
|
|
output: String::new(),
|
|
}]
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
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.background_task_count() == Some(0) {
|
|
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
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(super) fn limited(&self) -> bool {
|
|
self.limited
|
|
}
|
|
|
|
fn failure(&mut self, line: &Value) -> Vec<Event> {
|
|
let detail = error_text(line);
|
|
if is_limit(&detail) {
|
|
if self.limited {
|
|
return Vec::new();
|
|
}
|
|
self.limited = true;
|
|
vec![Event::LimitReached {
|
|
resets_at: find_reset(line),
|
|
}]
|
|
} else {
|
|
vec![Event::Error { message: detail }]
|
|
}
|
|
}
|
|
}
|
|
|
|
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 started_thread_is_root(kind: Option<&str>, body: &Value) -> Option<bool> {
|
|
(kind == Some("thread/started")).then(|| {
|
|
body.pointer("/thread/parentThreadId")
|
|
.is_none_or(Value::is_null)
|
|
})
|
|
}
|
|
|
|
fn start_item(item: &Value) -> Vec<Event> {
|
|
if matches!(
|
|
item.get("type").and_then(Value::as_str),
|
|
Some("file_change" | "fileChange")
|
|
) {
|
|
// Unlike command execution, a file change's start notification has no payload. Its
|
|
// completed copy carries the diff, so that is where both common events are made; recording
|
|
// this empty shell produced a Patch card containing only `diff:`.
|
|
return Vec::new();
|
|
}
|
|
let Some((id, tool, input)) = tool(item) else {
|
|
return Vec::new();
|
|
};
|
|
vec![Event::ToolStart { id, tool, input }]
|
|
}
|
|
|
|
fn update_item(item: &Value) -> Vec<Event> {
|
|
let Some(id) = item.get("id").and_then(Value::as_str) else {
|
|
return Vec::new();
|
|
};
|
|
let output = item
|
|
.get("aggregated_output")
|
|
.or_else(|| item.get("output"))
|
|
.and_then(value_text);
|
|
output
|
|
.filter(|text| !text.is_empty())
|
|
.map(|output| {
|
|
vec![Event::ToolUpdate {
|
|
id: id.to_string(),
|
|
output,
|
|
}]
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
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)
|
|
.filter(|text| !text.is_empty())
|
|
.filter(|_| include_agent_message)
|
|
.map(|delta| {
|
|
vec![Event::AssistantText {
|
|
delta: delta.to_string(),
|
|
}]
|
|
})
|
|
.unwrap_or_default(),
|
|
Some("reasoning" | "userMessage") => Vec::new(),
|
|
Some("file_change" | "fileChange") => item
|
|
.get("id")
|
|
.and_then(Value::as_str)
|
|
.map(|id| {
|
|
vec![
|
|
patch_start(id.to_string(), file_change_diff(item)),
|
|
Event::ToolEnd {
|
|
id: id.to_string(),
|
|
output: tool_output(item),
|
|
},
|
|
]
|
|
})
|
|
.unwrap_or_default(),
|
|
_ => {
|
|
let Some((id, _, _)) = tool(item) else {
|
|
return Vec::new();
|
|
};
|
|
let output = tool_output(item);
|
|
vec![Event::ToolEnd { id, output }]
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
.map(|text| {
|
|
vec![Event::AssistantTextFinal {
|
|
text: text.to_string(),
|
|
}]
|
|
})
|
|
.unwrap_or_default(),
|
|
_ => complete_item(item, false),
|
|
}
|
|
}
|
|
|
|
fn tool(item: &Value) -> Option<(String, String, Value)> {
|
|
let id = item.get("id")?.as_str()?.to_string();
|
|
let kind = item.get("type")?.as_str()?;
|
|
let (name, input) = match kind {
|
|
"command_execution" | "commandExecution" => command_tool(item),
|
|
"file_change" | "fileChange" => return None,
|
|
"mcp_tool_call" | "mcpToolCall" => (
|
|
item.get("tool")
|
|
.or_else(|| item.get("name"))
|
|
.and_then(Value::as_str)
|
|
.map(|tool| format!("mcp:{tool}"))
|
|
.unwrap_or_else(|| "mcp".to_string()),
|
|
item.get("arguments").cloned().unwrap_or(Value::Null),
|
|
),
|
|
"dynamicToolCall" => (
|
|
item.get("tool")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("tool")
|
|
.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").and_then(Value::as_str) {
|
|
input["prompt"] = Value::String(prompt.to_string());
|
|
}
|
|
if let Some(model) = item.get("model").and_then(Value::as_str) {
|
|
input["model"] = Value::String(model.to_string());
|
|
}
|
|
if let Some(effort) = item.get("reasoningEffort").and_then(Value::as_str) {
|
|
input["reasoningEffort"] = Value::String(effort.to_string());
|
|
}
|
|
(name.to_string(), input)
|
|
}
|
|
"functionCallOutput" => (
|
|
item.get("name")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("tool")
|
|
.to_string(),
|
|
Value::Null,
|
|
),
|
|
"imageView" => (
|
|
"view_image".to_string(),
|
|
json!({"path": item.get("path").cloned().unwrap_or(Value::Null)}),
|
|
),
|
|
"web_search" | "webSearch" => (
|
|
"WebSearch".to_string(),
|
|
json!({"query": item.get("query").cloned().unwrap_or(Value::Null)}),
|
|
),
|
|
"todo_list" | "todoList" | "plan" => ("update_plan".to_string(), item.clone()),
|
|
_ => return None,
|
|
};
|
|
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
|
|
/// left intact: hiding that would make a deliberately selected shell look like Bash.
|
|
fn command_tool(item: &Value) -> (String, Value) {
|
|
let command = item.get("command").cloned().unwrap_or(Value::Null);
|
|
if let Some(script) = command.as_str() {
|
|
let script = rendered_bash_script(script).unwrap_or_else(|| script.to_string());
|
|
return ("Bash".to_string(), json!({"command": script}));
|
|
}
|
|
if let Some(argv) = command.as_array()
|
|
&& let [program, option, script] = argv.as_slice()
|
|
&& program
|
|
.as_str()
|
|
.and_then(|program| program.rsplit('/').next())
|
|
== Some("bash")
|
|
&& matches!(option.as_str(), Some("-c" | "-lc"))
|
|
&& let Some(script) = script.as_str()
|
|
{
|
|
return ("Bash".to_string(), json!({"command": script}));
|
|
}
|
|
let command = command
|
|
.as_array()
|
|
.map(|argv| argv.iter().map(shell_word).collect::<Vec<_>>().join(" "))
|
|
.unwrap_or_else(|| command.to_string());
|
|
("Shell".to_string(), json!({"command": command}))
|
|
}
|
|
|
|
/// App-server renders the executor argv into one shell-quoted string. Unwrap only an exact Bash
|
|
/// invocation whose script has matching outer quotes; extra arguments stay visible rather than
|
|
/// being mistaken for part of the script.
|
|
fn rendered_bash_script(command: &str) -> Option<String> {
|
|
let quoted = ["/usr/bin/bash -lc ", "/bin/bash -lc ", "bash -lc "]
|
|
.iter()
|
|
.find_map(|prefix| command.strip_prefix(prefix))?;
|
|
if quoted.len() >= 2
|
|
&& ((quoted.starts_with('\'') && quoted.ends_with('\''))
|
|
|| (quoted.starts_with('"') && quoted.ends_with('"')))
|
|
{
|
|
return Some(quoted[1..quoted.len() - 1].to_string());
|
|
}
|
|
(!quoted.is_empty() && !quoted.chars().any(char::is_whitespace)).then(|| quoted.to_string())
|
|
}
|
|
|
|
fn shell_word(word: &Value) -> String {
|
|
let Some(word) = word.as_str() else {
|
|
return word.to_string();
|
|
};
|
|
if !word.is_empty()
|
|
&& word
|
|
.chars()
|
|
.all(|character| character.is_ascii_alphanumeric() || "/_-.=:,@+".contains(character))
|
|
{
|
|
return word.to_string();
|
|
}
|
|
format!("'{}'", word.replace('\'', "'\\''"))
|
|
}
|
|
|
|
fn file_change_diff(item: &Value) -> String {
|
|
match item.get("changes") {
|
|
// Current app-server protocol: [{path, kind: {type, move_path?}, diff}].
|
|
Some(Value::Array(changes)) => changes
|
|
.iter()
|
|
.filter_map(|change| {
|
|
let path = change.get("path")?.as_str()?;
|
|
let kind = change.pointer("/kind/type").and_then(Value::as_str);
|
|
let moved = change.pointer("/kind/move_path").and_then(Value::as_str);
|
|
let body = change
|
|
.get("diff")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default();
|
|
Some(render_file_diff(path, kind, moved, body))
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n"),
|
|
// Older exec protocol and durable Codex rollouts: {path: {type, unified_diff, ...}}.
|
|
Some(Value::Object(changes)) => changes
|
|
.iter()
|
|
.map(|(path, change)| {
|
|
let kind = change.get("type").and_then(Value::as_str);
|
|
render_file_diff(
|
|
path,
|
|
kind,
|
|
change.get("move_path").and_then(Value::as_str),
|
|
change
|
|
.get(if matches!(kind, Some("add" | "delete")) {
|
|
"content"
|
|
} else {
|
|
"unified_diff"
|
|
})
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default(),
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n"),
|
|
_ => String::new(),
|
|
}
|
|
}
|
|
|
|
fn render_file_diff(path: &str, kind: Option<&str>, moved: Option<&str>, body: &str) -> String {
|
|
// Some protocol revisions carry complete unified diffs and some carry only their hunks.
|
|
if !matches!(kind, Some("add" | "delete"))
|
|
&& (body.starts_with("--- ") || body.starts_with("diff --git "))
|
|
{
|
|
return body.to_string();
|
|
}
|
|
let from = if kind == Some("add") {
|
|
"/dev/null"
|
|
} else {
|
|
path
|
|
};
|
|
let to = if kind == Some("delete") {
|
|
"/dev/null"
|
|
} else {
|
|
moved.unwrap_or(path)
|
|
};
|
|
let body = match kind {
|
|
// Add and delete carry file content, not a unified diff. Prefixing it here makes the
|
|
// protocol's change kind authoritative: a '-' that belongs to a Markdown bullet can never
|
|
// be mistaken for diff syntax.
|
|
Some("add") => prefixed_lines('+', body),
|
|
Some("delete") => prefixed_lines('-', body),
|
|
_ => body.to_string(),
|
|
};
|
|
format!("--- {from}\n+++ {to}\n{body}")
|
|
}
|
|
|
|
fn tool_output(item: &Value) -> String {
|
|
match item.get("type").and_then(Value::as_str) {
|
|
Some("file_change" | "fileChange")
|
|
if item.get("status").and_then(Value::as_str) == Some("completed") =>
|
|
{
|
|
return String::new();
|
|
}
|
|
Some("collabAgentToolCall")
|
|
if item.get("status").and_then(Value::as_str) == Some("completed") =>
|
|
{
|
|
return String::new();
|
|
}
|
|
Some("dynamicToolCall") => {
|
|
return content_text(item.get("contentItems"), "inputText");
|
|
}
|
|
Some("mcpToolCall") => {
|
|
if let Some(message) = item.pointer("/error/message").and_then(Value::as_str) {
|
|
return message.to_string();
|
|
}
|
|
return content_text(item.pointer("/result/content"), "text");
|
|
}
|
|
Some("functionCallOutput") => return content_text(item.get("output"), "input_text"),
|
|
Some("imageView") => return String::new(),
|
|
_ => {}
|
|
}
|
|
for key in [
|
|
"aggregated_output",
|
|
"aggregatedOutput",
|
|
"output",
|
|
"result",
|
|
"error",
|
|
] {
|
|
if let Some(text) = item.get(key).and_then(value_text)
|
|
&& !text.is_empty()
|
|
{
|
|
return text;
|
|
}
|
|
}
|
|
match item.get("status").and_then(Value::as_str) {
|
|
Some(status) => status.to_string(),
|
|
None => String::new(),
|
|
}
|
|
}
|
|
|
|
/// Text from a structured result, deliberately excluding its image data. The driver saves images
|
|
/// beside the transcript; serializing a data URL here makes a screenshot a megabytes-long line and
|
|
/// still cannot draw it.
|
|
fn content_text(value: Option<&Value>, text_kind: &str) -> String {
|
|
match value {
|
|
Some(Value::String(text)) => text.clone(),
|
|
Some(Value::Array(parts)) => parts
|
|
.iter()
|
|
.filter(|part| part.get("type").and_then(Value::as_str) == Some(text_kind))
|
|
.filter_map(|part| part.get("text").and_then(Value::as_str))
|
|
.collect::<Vec<_>>()
|
|
.join("\n"),
|
|
_ => String::new(),
|
|
}
|
|
}
|
|
|
|
fn value_text(value: &Value) -> Option<String> {
|
|
value
|
|
.as_str()
|
|
.map(str::to_string)
|
|
.or_else(|| (!value.is_null()).then(|| value.to_string()))
|
|
}
|
|
|
|
fn number(value: &Value, key: &str) -> Option<u64> {
|
|
value.get(key).and_then(Value::as_u64)
|
|
}
|
|
|
|
fn error_text(line: &Value) -> String {
|
|
line.get("message")
|
|
.and_then(Value::as_str)
|
|
.or_else(|| line.pointer("/error/message").and_then(Value::as_str))
|
|
.or_else(|| line.get("error").and_then(Value::as_str))
|
|
.unwrap_or("Codex ended the turn with an unknown error")
|
|
.to_string()
|
|
}
|
|
|
|
fn is_limit(detail: &str) -> bool {
|
|
let lower = detail.to_ascii_lowercase();
|
|
lower.contains("usage limit")
|
|
|| lower.contains("rate limit")
|
|
|| lower.contains("quota exceeded")
|
|
|| lower.contains("credits depleted")
|
|
}
|
|
|
|
fn find_reset(value: &Value) -> Option<f64> {
|
|
for key in ["resets_at", "resetsAt", "reset_at", "resetAt"] {
|
|
if let Some(at) = value.get(key).and_then(Value::as_f64) {
|
|
return Some(at);
|
|
}
|
|
}
|
|
value.as_object()?.values().find_map(find_reset)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn line(text: &str) -> Value {
|
|
serde_json::from_str(text).expect("fixture")
|
|
}
|
|
|
|
#[test]
|
|
fn translates_the_observed_minimal_stream() {
|
|
let mut translator = Translator::default();
|
|
assert!(
|
|
translator
|
|
.translate(&line(r#"{"type":"thread.started","thread_id":"thread-1"}"#))
|
|
.is_empty()
|
|
);
|
|
assert_eq!(translator.thread_id.as_deref(), Some("thread-1"));
|
|
assert_eq!(
|
|
translator.translate(&line(
|
|
r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"hello"}}"#
|
|
)),
|
|
vec![Event::AssistantText {
|
|
delta: "hello".to_string()
|
|
}]
|
|
);
|
|
let events = translator.translate(&line(
|
|
r#"{"type":"turn.completed","usage":{"input_tokens":13,"cached_input_tokens":8,"output_tokens":5}}"#,
|
|
));
|
|
assert_eq!(
|
|
events[0],
|
|
Event::UsageDelta {
|
|
tokens: 18,
|
|
context: Some(13),
|
|
tokens_per_second: None,
|
|
prefill_ms: None,
|
|
}
|
|
);
|
|
assert!(translator.completed());
|
|
}
|
|
|
|
#[test]
|
|
fn translates_tools_and_limits_without_matching_whole_records() {
|
|
let mut translator = Translator::default();
|
|
let started = translator.translate(&line(
|
|
r#"{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":["/usr/bin/bash","-lc","pwd"],"status":"in_progress"}}"#,
|
|
));
|
|
assert_eq!(
|
|
started,
|
|
vec![Event::ToolStart {
|
|
id: "item_1".to_string(),
|
|
tool: "Bash".to_string(),
|
|
input: json!({"command": "pwd"})
|
|
}]
|
|
);
|
|
let ended = translator.translate(&line(
|
|
r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"pwd","aggregated_output":"/tmp\n","exit_code":0,"status":"completed"}}"#,
|
|
));
|
|
assert_eq!(
|
|
ended,
|
|
vec![Event::ToolEnd {
|
|
id: "item_1".to_string(),
|
|
output: "/tmp\n".to_string()
|
|
}]
|
|
);
|
|
let limit = translator.translate(&line(
|
|
r#"{"type":"turn.failed","error":{"message":"usage limit reached","resetsAt":1234}}"#,
|
|
));
|
|
assert_eq!(
|
|
limit,
|
|
vec![Event::LimitReached {
|
|
resets_at: Some(1234.0)
|
|
}]
|
|
);
|
|
assert!(translator.limited());
|
|
}
|
|
|
|
#[test]
|
|
fn a_replacement_root_is_not_mistaken_for_a_subagent() {
|
|
let mut translator = Translator {
|
|
thread_id: Some("old-root".to_string()),
|
|
..Translator::default()
|
|
};
|
|
let replacement = line(
|
|
r#"{"method":"thread/started","params":{"thread":{"id":"new-root","parentThreadId":null}}}"#,
|
|
);
|
|
assert!(translator.is_parent(&replacement));
|
|
assert!(translator.translate(&replacement).is_empty());
|
|
assert_eq!(translator.thread_id.as_deref(), Some("new-root"));
|
|
|
|
let child = line(
|
|
r#"{"method":"thread/started","params":{"thread":{"id":"child","parentThreadId":"new-root"}}}"#,
|
|
);
|
|
assert!(!translator.is_parent(&child));
|
|
}
|
|
|
|
#[test]
|
|
fn command_translation_only_hides_the_known_bash_wrapper() {
|
|
let legacy = tool(&line(
|
|
r#"{"id":"old","type":"command_execution","command":"pwd"}"#,
|
|
));
|
|
assert_eq!(
|
|
legacy,
|
|
Some((
|
|
"old".to_string(),
|
|
"Bash".to_string(),
|
|
json!({"command": "pwd"})
|
|
))
|
|
);
|
|
|
|
let rendered = tool(&json!({
|
|
"id": "rendered",
|
|
"type": "commandExecution",
|
|
"command": "/usr/bin/bash -lc 'printf \"%s\\n\" hello'"
|
|
}));
|
|
assert_eq!(
|
|
rendered,
|
|
Some((
|
|
"rendered".to_string(),
|
|
"Bash".to_string(),
|
|
json!({"command": "printf \"%s\\n\" hello"})
|
|
))
|
|
);
|
|
|
|
let double_quoted = tool(&json!({
|
|
"id": "double-quoted",
|
|
"type": "commandExecution",
|
|
"command": r#"/usr/bin/bash -lc "printf '%s\n' "$HOME" \path""#
|
|
}));
|
|
assert_eq!(
|
|
double_quoted,
|
|
Some((
|
|
"double-quoted".to_string(),
|
|
"Bash".to_string(),
|
|
json!({"command": "printf '%s\\n' \"$HOME\" \\path"})
|
|
))
|
|
);
|
|
|
|
let fish = tool(&line(
|
|
r#"{"id":"fish","type":"commandExecution","command":["/usr/bin/fish","-c","pwd"]}"#,
|
|
));
|
|
assert_eq!(
|
|
fish,
|
|
Some((
|
|
"fish".to_string(),
|
|
"Shell".to_string(),
|
|
json!({"command": "/usr/bin/fish -c pwd"})
|
|
))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn web_search_uses_the_common_tool_shape() {
|
|
assert_eq!(
|
|
tool(&line(
|
|
r#"{"id":"search-1","type":"webSearch","query":"Codex app-server protocol"}"#,
|
|
)),
|
|
Some((
|
|
"search-1".to_string(),
|
|
"WebSearch".to_string(),
|
|
json!({"query": "Codex app-server protocol"})
|
|
))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_file_change_becomes_the_common_patch_shape() {
|
|
let mut translator = Translator::default();
|
|
let started = translator.translate(&line(
|
|
r#"{"method":"item/started","params":{"item":{"id":"patch-1","type":"fileChange","changes":{},"status":"inProgress"}}}"#,
|
|
));
|
|
assert!(started.is_empty());
|
|
assert_eq!(
|
|
translator.translate(&line(
|
|
r#"{"method":"item/completed","params":{"item":{"id":"patch-1","type":"fileChange","changes":[{"path":"src/main.rs","kind":{"type":"update","move_path":null},"diff":"@@ -1 +1 @@\n-old\n+new\n"}],"status":"completed","stdout":"Success"}}}"#
|
|
)),
|
|
vec![
|
|
patch_start(
|
|
"patch-1".to_string(),
|
|
"--- src/main.rs\n+++ src/main.rs\n@@ -1 +1 @@\n-old\n+new\n".to_string()
|
|
),
|
|
Event::ToolEnd {
|
|
id: "patch-1".to_string(),
|
|
output: String::new()
|
|
}
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_legacy_file_change_shape_remains_readable() {
|
|
let item = line(
|
|
r#"{"changes":{"old.rs":{"type":"update","unified_diff":"@@ -1 +1 @@\n-a\n+b\n","move_path":"new.rs"}}}"#,
|
|
);
|
|
assert_eq!(
|
|
file_change_diff(&item),
|
|
"--- old.rs\n+++ new.rs\n@@ -1 +1 @@\n-a\n+b\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn whole_file_changes_get_markers_from_their_kind_not_their_content() {
|
|
let item = line(
|
|
r#"{"changes":[{"path":"added.md","kind":{"type":"add"},"diff":"--- a rule\n- a bullet\nplain\n"},{"path":"deleted.md","kind":{"type":"delete"},"diff":"- another bullet\nplain\n"}]}"#,
|
|
);
|
|
assert_eq!(
|
|
file_change_diff(&item),
|
|
"--- /dev/null\n+++ added.md\n+--- a rule\n+- a bullet\n+plain\n\n--- deleted.md\n+++ /dev/null\n-- another bullet\n-plain\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_whole_file_changes_show_the_content_they_changed() {
|
|
let item = line(
|
|
r#"{"changes":{"added.md":{"type":"add","content":"new\n"},"deleted.md":{"type":"delete","content":"old\n"}}}"#,
|
|
);
|
|
assert_eq!(
|
|
file_change_diff(&item),
|
|
"--- /dev/null\n+++ added.md\n+new\n\n--- deleted.md\n+++ /dev/null\n-old\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn native_app_server_completion_corrects_provisional_streaming() {
|
|
let mut translator = Translator::default();
|
|
assert_eq!(
|
|
translator.translate(&line(
|
|
r#"{"method":"turn/started","params":{"threadId":"thread-1","turn":{"id":"turn-1"}}}"#
|
|
)),
|
|
vec![Event::Status {
|
|
state: SessionStatus::Running
|
|
}]
|
|
);
|
|
assert_eq!(
|
|
translator.translate(&line(
|
|
r#"{"method":"item/agentMessage/delta","params":{"itemId":"message-1","delta":"hello"}}"#
|
|
)),
|
|
vec![Event::AssistantText {
|
|
delta: "hello".to_string()
|
|
}]
|
|
);
|
|
assert_eq!(
|
|
translator.translate(&line(
|
|
r#"{"method":"item/completed","params":{"item":{"id":"message-1","type":"agentMessage","text":"hello, revised"}}}"#
|
|
)),
|
|
vec![Event::AssistantTextFinal {
|
|
text: "hello, revised".to_string()
|
|
}]
|
|
);
|
|
// One per model request, as it arrives. A turn that made two of them costs both,
|
|
// and the context figure moves while the turn is still running.
|
|
assert_eq!(
|
|
translator.translate(&line(
|
|
r#"{"method":"thread/tokenUsage/updated","params":{"tokenUsage":{"last":{"inputTokens":39,"cachedInputTokens":30,"outputTokens":3,"reasoningOutputTokens":1,"totalTokens":42},"total":{"inputTokens":100,"cachedInputTokens":80,"outputTokens":9,"reasoningOutputTokens":2,"totalTokens":109},"modelContextWindow":258400}}}"#
|
|
)),
|
|
vec![Event::UsageDelta {
|
|
tokens: 42,
|
|
context: Some(39),
|
|
tokens_per_second: None,
|
|
prefill_ms: None,
|
|
}]
|
|
);
|
|
assert_eq!(
|
|
translator.translate(&line(
|
|
r#"{"method":"thread/tokenUsage/updated","params":{"tokenUsage":{"last":{"inputTokens":61,"cachedInputTokens":39,"outputTokens":4,"reasoningOutputTokens":1,"totalTokens":65},"total":{"inputTokens":161,"cachedInputTokens":119,"outputTokens":13,"reasoningOutputTokens":3,"totalTokens":174},"modelContextWindow":258400}}}"#
|
|
)),
|
|
vec![Event::UsageDelta {
|
|
tokens: 65,
|
|
context: Some(61),
|
|
tokens_per_second: None,
|
|
prefill_ms: None,
|
|
}]
|
|
);
|
|
assert_eq!(
|
|
translator.translate(&line(
|
|
r#"{"method":"turn/completed","params":{"turn":{"id":"turn-1","status":"completed","error":null}}}"#
|
|
)),
|
|
vec![Event::Status {
|
|
state: SessionStatus::Idle
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn structured_tool_results_keep_text_but_not_image_data() {
|
|
let mut translator = Translator::default();
|
|
let started = translator.translate(&line(
|
|
r#"{"method":"item/started","params":{"item":{"id":"tool-1","type":"dynamicToolCall","tool":"view_image","arguments":{"path":"shot.png"},"status":"inProgress"}}}"#,
|
|
));
|
|
assert!(matches!(&started[0], Event::ToolStart { tool, .. } if tool == "view_image"));
|
|
|
|
let ended = translator.translate(&line(
|
|
r#"{"method":"item/completed","params":{"item":{"id":"tool-1","type":"dynamicToolCall","tool":"view_image","arguments":{},"status":"completed","contentItems":[{"type":"inputText","text":"looked"},{"type":"inputImage","imageUrl":"data:image/png;base64,aGVsbG8="}]}}}"#,
|
|
));
|
|
assert_eq!(
|
|
ended,
|
|
vec![Event::ToolEnd {
|
|
id: "tool-1".to_string(),
|
|
output: "looked".to_string()
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_app_server_completion_corrects_streamed_text_after_adoption() {
|
|
// A newly adopted translator has not seen the deltas already recorded by the previous
|
|
// backend. The dialect, rather than process-local memory, decides that this is a copy.
|
|
let mut adopted = Translator::default();
|
|
assert_eq!(
|
|
adopted.translate(&line(
|
|
r#"{"method":"item/completed","params":{"item":{"id":"message-1","type":"agentMessage","text":"the complete message"}}}"#
|
|
)),
|
|
vec![Event::AssistantTextFinal {
|
|
text: "the complete message".to_string()
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[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 background_processes = Arc::new(Mutex::new(HashSet::new()));
|
|
let mut translator = Translator::new(
|
|
Arc::clone(&subagents),
|
|
Arc::clone(&background_processes),
|
|
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"})
|
|
}]
|
|
);
|
|
assert_eq!(
|
|
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"}}}"#
|
|
)),
|
|
vec![
|
|
Event::ToolEnd {
|
|
id: "spawn-1".to_string(),
|
|
output: String::new()
|
|
},
|
|
Event::BackgroundTasks { count: 1 }
|
|
]
|
|
);
|
|
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
|
|
},
|
|
Event::BackgroundTasks { count: 0 }
|
|
]
|
|
);
|
|
|
|
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 codex_reports_each_change_to_its_live_background_count() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let subagents = Arc::new(Subagents::new(dir.path().to_path_buf()));
|
|
let background_processes = Arc::new(Mutex::new(HashSet::new()));
|
|
let mut translator = Translator::new(
|
|
Arc::clone(&subagents),
|
|
Arc::clone(&background_processes),
|
|
Some("parent-thread".to_string()),
|
|
false,
|
|
);
|
|
|
|
for (call, child, count) in [("spawn-a", "child-a", 1), ("spawn-b", "child-b", 2)] {
|
|
let events = translator.translate(&json!({
|
|
"method": "item/completed",
|
|
"params": {
|
|
"threadId": "parent-thread",
|
|
"item": {
|
|
"id": call,
|
|
"type": "subAgentActivity",
|
|
"kind": "started",
|
|
"agentThreadId": child,
|
|
"agentPath": format!("/root/{child}")
|
|
}
|
|
}
|
|
}));
|
|
assert_eq!(events.last(), Some(&Event::BackgroundTasks { count }));
|
|
}
|
|
background_processes
|
|
.lock()
|
|
.unwrap()
|
|
.insert("command-a".to_string());
|
|
for (child, count) in [("child-a", 2), ("child-b", 1)] {
|
|
let events = translator.translate(&json!({
|
|
"method": "item/completed",
|
|
"params": {
|
|
"threadId": "parent-thread",
|
|
"item": {
|
|
"id": format!("completed-{child}"),
|
|
"type": "subAgentActivity",
|
|
"kind": "completed",
|
|
"agentThreadId": child,
|
|
"agentPath": format!("/root/{child}")
|
|
}
|
|
}
|
|
}));
|
|
assert_eq!(events.last(), Some(&Event::BackgroundTasks { count }));
|
|
assert!(!events.iter().any(|event| {
|
|
matches!(
|
|
event,
|
|
Event::Status {
|
|
state: SessionStatus::Idle
|
|
}
|
|
)
|
|
}));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn codex_collaboration_coordination_has_clean_parent_tool_cards() {
|
|
let mut translator = Translator::default();
|
|
for (tool, name) in [
|
|
("wait", "TaskOutput"),
|
|
("sendInput", "SendMessage"),
|
|
("sendMessage", "SendMessage"),
|
|
("followupTask", "SendMessage"),
|
|
("closeAgent", "CloseAgent"),
|
|
("interruptAgent", "InterruptAgent"),
|
|
("listAgents", "ListAgents"),
|
|
("resumeAgent", "ResumeAgent"),
|
|
] {
|
|
let started = json!({
|
|
"method": "item/started",
|
|
"params": {
|
|
"threadId": "parent-thread",
|
|
"turnId": "turn-1",
|
|
"item": {
|
|
"id": format!("{tool}-1"),
|
|
"type": "collabAgentToolCall",
|
|
"tool": tool,
|
|
"status": "inProgress",
|
|
"senderThreadId": "parent-thread",
|
|
"receiverThreadIds": [],
|
|
"agentsStates": {},
|
|
"prompt": null,
|
|
"model": null,
|
|
"reasoningEffort": null
|
|
}
|
|
}
|
|
});
|
|
let completed = json!({
|
|
"method": "item/completed",
|
|
"params": {
|
|
"threadId": "parent-thread",
|
|
"turnId": "turn-1",
|
|
"item": {
|
|
"id": format!("{tool}-1"),
|
|
"type": "collabAgentToolCall",
|
|
"tool": tool,
|
|
"status": "completed",
|
|
"senderThreadId": "parent-thread",
|
|
"receiverThreadIds": [],
|
|
"agentsStates": {},
|
|
"prompt": null,
|
|
"model": null,
|
|
"reasoningEffort": null
|
|
}
|
|
}
|
|
});
|
|
assert_eq!(
|
|
translator.translate(&started),
|
|
vec![Event::ToolStart {
|
|
id: format!("{tool}-1"),
|
|
tool: name.to_string(),
|
|
input: json!({})
|
|
}],
|
|
"{tool} start"
|
|
);
|
|
assert_eq!(
|
|
translator.translate(&completed),
|
|
vec![Event::ToolEnd {
|
|
id: format!("{tool}-1"),
|
|
output: String::new()
|
|
}],
|
|
"{tool} completion"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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 delivered_agent_message_deltas_do_not_replace_the_recipients_text() {
|
|
let mut translator = Translator::default();
|
|
|
|
assert!(
|
|
translator
|
|
.translate(&line(
|
|
r#"{"method":"item/started","params":{"threadId":"parent-thread","turnId":"turn-1","item":{"id":"delivery-1","type":"agentMessage","text":"","phase":null,"delivery":"async"}}}"#
|
|
))
|
|
.is_empty()
|
|
);
|
|
assert!(
|
|
translator
|
|
.translate(&line(
|
|
r#"{"method":"item/agentMessage/delta","params":{"threadId":"parent-thread","turnId":"turn-1","itemId":"delivery-1","delta":"a child report"}}"#
|
|
))
|
|
.is_empty()
|
|
);
|
|
assert_eq!(
|
|
translator.translate(&line(
|
|
r#"{"method":"item/completed","params":{"threadId":"parent-thread","turnId":"turn-1","item":{"id":"delivery-1","type":"agentMessage","text":"a child report","phase":null,"delivery":"async"}}}"#
|
|
)),
|
|
vec![Event::PeerMessage {
|
|
from: "another agent".to_string(),
|
|
text: "a child report".to_string(),
|
|
turn_start: None
|
|
}]
|
|
);
|
|
|
|
// Removing the completed id is the path out for this state: a later
|
|
// item reusing it is not silently suppressed.
|
|
assert_eq!(
|
|
translator.translate(&line(
|
|
r#"{"method":"item/agentMessage/delta","params":{"threadId":"parent-thread","turnId":"turn-2","itemId":"delivery-1","delta":"ordinary text"}}"#
|
|
)),
|
|
vec![Event::AssistantText {
|
|
delta: "ordinary text".to_string()
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[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),
|
|
Arc::new(Mutex::new(HashSet::new())),
|
|
Some("parent-thread".to_string()),
|
|
true,
|
|
);
|
|
|
|
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/child"}}}"#
|
|
)),
|
|
vec![Event::BackgroundTasks { count: 0 }]
|
|
);
|
|
assert_eq!(subagents.list(true)[0].status, SessionStatus::Exited);
|
|
}
|
|
}
|