Use native Codex steering and transcript deletion
This commit is contained in:
1 parent
00538cc19b
commit
8c88a7e991
12 files changed
+1004
-390
No files matched your search
@@ -1,9 +1,11 @@
|
||||
//! `codex exec --json` lines into the common event model.
|
||||
//! 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::HashSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::driver::{Event, SessionStatus};
|
||||
@@ -13,11 +15,16 @@ pub(super) struct Translator {
|
||||
pub(super) thread_id: Option<String>,
|
||||
completed: bool,
|
||||
limited: bool,
|
||||
streamed_messages: HashSet<String>,
|
||||
pending_usage: Option<u64>,
|
||||
}
|
||||
|
||||
impl Translator {
|
||||
pub(super) fn translate(&mut self, line: &Value) -> Vec<Event> {
|
||||
match line.get("type").and_then(Value::as_str) {
|
||||
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));
|
||||
match kind {
|
||||
Some("thread.started") => {
|
||||
self.thread_id = line
|
||||
.get("thread_id")
|
||||
@@ -25,16 +32,65 @@ impl Translator {
|
||||
.map(str::to_string);
|
||||
Vec::new()
|
||||
}
|
||||
Some("turn.started") => vec![Event::Status {
|
||||
state: SessionStatus::Running,
|
||||
}],
|
||||
Some("item.started") => start_item(&line["item"]),
|
||||
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;
|
||||
self.pending_usage = None;
|
||||
self.streamed_messages.clear();
|
||||
vec![Event::Status {
|
||||
state: SessionStatus::Running,
|
||||
}]
|
||||
}
|
||||
Some("item.started") | Some("item/started") => start_item(&body["item"]),
|
||||
Some("item.updated") => update_item(&line["item"]),
|
||||
Some("item.completed") => complete_item(&line["item"]),
|
||||
Some("turn.completed") => {
|
||||
Some("item.completed") | Some("item/completed") => {
|
||||
complete_item(&body["item"], &self.streamed_messages)
|
||||
}
|
||||
Some("item/agentMessage/delta") => {
|
||||
let Some(delta) = body.get("delta").and_then(Value::as_str) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if let Some(id) = body.get("itemId").and_then(Value::as_str) {
|
||||
self.streamed_messages.insert(id.to_string());
|
||||
}
|
||||
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(),
|
||||
}]
|
||||
}
|
||||
Some("thread/tokenUsage/updated") => {
|
||||
self.pending_usage = body
|
||||
.pointer("/tokenUsage/last/totalTokens")
|
||||
.and_then(Value::as_u64);
|
||||
Vec::new()
|
||||
}
|
||||
Some("turn.completed") | Some("turn/completed") => {
|
||||
self.completed = true;
|
||||
let mut events = Vec::new();
|
||||
if let Some(usage) = line.get("usage") {
|
||||
if let Some(tokens) = self.pending_usage.take() {
|
||||
events.push(Event::UsageDelta {
|
||||
tokens,
|
||||
context: None,
|
||||
});
|
||||
} else 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() {
|
||||
@@ -46,20 +102,27 @@ impl Translator {
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(error) = body.pointer("/turn/error")
|
||||
&& !error.is_null()
|
||||
{
|
||||
events.extend(self.failure(error));
|
||||
}
|
||||
events.push(Event::Status {
|
||||
state: SessionStatus::Idle,
|
||||
});
|
||||
events
|
||||
}
|
||||
Some("turn.failed") | Some("error") => self.failure(line),
|
||||
Some("turn.failed") | Some("error") => self.failure(body),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn completed(&self) -> bool {
|
||||
self.completed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn limited(&self) -> bool {
|
||||
self.limited
|
||||
}
|
||||
@@ -106,19 +169,24 @@ fn update_item(item: &Value) -> Vec<Event> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn complete_item(item: &Value) -> Vec<Event> {
|
||||
fn complete_item(item: &Value, streamed_messages: &HashSet<String>) -> Vec<Event> {
|
||||
match item.get("type").and_then(Value::as_str) {
|
||||
Some("agent_message") => item
|
||||
Some("agent_message" | "agentMessage") => item
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|text| !text.is_empty())
|
||||
.filter(|_| {
|
||||
item.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.is_none_or(|id| !streamed_messages.contains(id))
|
||||
})
|
||||
.map(|delta| {
|
||||
vec![Event::AssistantText {
|
||||
delta: delta.to_string(),
|
||||
}]
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
Some("reasoning") => Vec::new(),
|
||||
Some("reasoning" | "userMessage") => Vec::new(),
|
||||
_ => {
|
||||
let Some((id, _, _)) = tool(item) else {
|
||||
return Vec::new();
|
||||
@@ -133,15 +201,15 @@ 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" => (
|
||||
"command_execution" | "commandExecution" => (
|
||||
"exec_command".to_string(),
|
||||
json!({"command": item.get("command").cloned().unwrap_or(Value::Null)}),
|
||||
),
|
||||
"file_change" => (
|
||||
"file_change" | "fileChange" => (
|
||||
"apply_patch".to_string(),
|
||||
item.get("changes").cloned().unwrap_or(Value::Null),
|
||||
),
|
||||
"mcp_tool_call" => (
|
||||
"mcp_tool_call" | "mcpToolCall" => (
|
||||
item.get("tool")
|
||||
.or_else(|| item.get("name"))
|
||||
.and_then(Value::as_str)
|
||||
@@ -149,18 +217,24 @@ fn tool(item: &Value) -> Option<(String, String, Value)> {
|
||||
.unwrap_or_else(|| "mcp".to_string()),
|
||||
item.get("arguments").cloned().unwrap_or(Value::Null),
|
||||
),
|
||||
"web_search" => (
|
||||
"web_search" | "webSearch" => (
|
||||
"web_search".to_string(),
|
||||
json!({"query": item.get("query").cloned().unwrap_or(Value::Null)}),
|
||||
),
|
||||
"todo_list" => ("update_plan".to_string(), item.clone()),
|
||||
"todo_list" | "todoList" | "plan" => ("update_plan".to_string(), item.clone()),
|
||||
_ => return None,
|
||||
};
|
||||
Some((id, name, input))
|
||||
}
|
||||
|
||||
fn tool_output(item: &Value) -> String {
|
||||
for key in ["aggregated_output", "output", "result", "error"] {
|
||||
for key in [
|
||||
"aggregated_output",
|
||||
"aggregatedOutput",
|
||||
"output",
|
||||
"result",
|
||||
"error",
|
||||
] {
|
||||
if let Some(text) = item.get(key).and_then(value_text)
|
||||
&& !text.is_empty()
|
||||
{
|
||||
@@ -276,4 +350,53 @@ mod tests {
|
||||
);
|
||||
assert!(translator.limited());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translates_native_app_server_streaming_without_repeating_the_final_item() {
|
||||
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!(
|
||||
translator
|
||||
.translate(&line(
|
||||
r#"{"method":"item/completed","params":{"item":{"id":"message-1","type":"agentMessage","text":"hello"}}}"#
|
||||
))
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
translator
|
||||
.translate(&line(
|
||||
r#"{"method":"thread/tokenUsage/updated","params":{"tokenUsage":{"last":{"totalTokens":42}}}}"#
|
||||
))
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
translator.translate(&line(
|
||||
r#"{"method":"turn/completed","params":{"turn":{"id":"turn-1","status":"completed","error":null}}}"#
|
||||
)),
|
||||
vec![
|
||||
Event::UsageDelta {
|
||||
tokens: 42,
|
||||
context: None
|
||||
},
|
||||
Event::Status {
|
||||
state: SessionStatus::Idle
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user