mirror of
https://github.com/openai/codex.git
synced 2026-05-19 10:43:38 +00:00
This PR add an URI-based system to reference agents within a tree. This comes from a sync between research and engineering. The main agent (the one manually spawned by a user) is always called `/root`. Any sub-agent spawned by it will be `/root/agent_1` for example where `agent_1` is chosen by the model. Any agent can contact any agents using the path. Paths can be used either in absolute or relative to the calling agents Resume is not supported for now on this new path
440 lines
16 KiB
Rust
440 lines
16 KiB
Rust
use crate::model::ThreadMetadata;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::RolloutItem;
|
|
use codex_protocol::protocol::SessionMetaLine;
|
|
use codex_protocol::protocol::TurnContextItem;
|
|
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
|
|
use codex_protocol::protocol::UserMessageEvent;
|
|
use serde::Serialize;
|
|
use serde_json::Value;
|
|
|
|
const IMAGE_ONLY_USER_MESSAGE_PLACEHOLDER: &str = "[Image]";
|
|
|
|
/// Apply a rollout item to the metadata structure.
|
|
pub fn apply_rollout_item(
|
|
metadata: &mut ThreadMetadata,
|
|
item: &RolloutItem,
|
|
default_provider: &str,
|
|
) {
|
|
match item {
|
|
RolloutItem::SessionMeta(meta_line) => apply_session_meta_from_item(metadata, meta_line),
|
|
RolloutItem::TurnContext(turn_ctx) => apply_turn_context(metadata, turn_ctx),
|
|
RolloutItem::EventMsg(event) => apply_event_msg(metadata, event),
|
|
RolloutItem::ResponseItem(item) => apply_response_item(metadata, item),
|
|
RolloutItem::Compacted(_) => {}
|
|
}
|
|
if metadata.model_provider.is_empty() {
|
|
metadata.model_provider = default_provider.to_string();
|
|
}
|
|
}
|
|
|
|
/// Return whether this rollout item can mutate thread metadata stored in SQLite.
|
|
pub fn rollout_item_affects_thread_metadata(item: &RolloutItem) -> bool {
|
|
match item {
|
|
RolloutItem::SessionMeta(_) | RolloutItem::TurnContext(_) => true,
|
|
RolloutItem::EventMsg(EventMsg::TokenCount(_) | EventMsg::UserMessage(_)) => true,
|
|
RolloutItem::EventMsg(_) | RolloutItem::ResponseItem(_) | RolloutItem::Compacted(_) => {
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
fn apply_session_meta_from_item(metadata: &mut ThreadMetadata, meta_line: &SessionMetaLine) {
|
|
if metadata.id != meta_line.meta.id {
|
|
// Ignore session_meta lines that don't match the canonical thread ID,
|
|
// e.g., forked rollouts that embed the source session metadata.
|
|
return;
|
|
}
|
|
metadata.id = meta_line.meta.id;
|
|
metadata.source = enum_to_string(&meta_line.meta.source);
|
|
metadata.agent_nickname = meta_line.meta.agent_nickname.clone();
|
|
metadata.agent_role = meta_line.meta.agent_role.clone();
|
|
metadata.agent_path = meta_line.meta.agent_path.clone();
|
|
if let Some(provider) = meta_line.meta.model_provider.as_deref() {
|
|
metadata.model_provider = provider.to_string();
|
|
}
|
|
if !meta_line.meta.cli_version.is_empty() {
|
|
metadata.cli_version = meta_line.meta.cli_version.clone();
|
|
}
|
|
if !meta_line.meta.cwd.as_os_str().is_empty() {
|
|
metadata.cwd = meta_line.meta.cwd.clone();
|
|
}
|
|
if let Some(git) = meta_line.git.as_ref() {
|
|
metadata.git_sha = git.commit_hash.clone();
|
|
metadata.git_branch = git.branch.clone();
|
|
metadata.git_origin_url = git.repository_url.clone();
|
|
}
|
|
}
|
|
|
|
fn apply_turn_context(metadata: &mut ThreadMetadata, turn_ctx: &TurnContextItem) {
|
|
if metadata.cwd.as_os_str().is_empty() {
|
|
metadata.cwd = turn_ctx.cwd.clone();
|
|
}
|
|
metadata.model = Some(turn_ctx.model.clone());
|
|
metadata.reasoning_effort = turn_ctx.effort;
|
|
metadata.sandbox_policy = enum_to_string(&turn_ctx.sandbox_policy);
|
|
metadata.approval_mode = enum_to_string(&turn_ctx.approval_policy);
|
|
}
|
|
|
|
fn apply_event_msg(metadata: &mut ThreadMetadata, event: &EventMsg) {
|
|
match event {
|
|
EventMsg::TokenCount(token_count) => {
|
|
if let Some(info) = token_count.info.as_ref() {
|
|
metadata.tokens_used = info.total_token_usage.total_tokens.max(0);
|
|
}
|
|
}
|
|
EventMsg::UserMessage(user) => {
|
|
if metadata.first_user_message.is_none() {
|
|
metadata.first_user_message = user_message_preview(user);
|
|
}
|
|
if metadata.title.is_empty() {
|
|
let title = strip_user_message_prefix(user.message.as_str());
|
|
if !title.is_empty() {
|
|
metadata.title = title.to_string();
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn apply_response_item(_metadata: &mut ThreadMetadata, _item: &ResponseItem) {
|
|
// Title and first_user_message are derived from EventMsg::UserMessage only.
|
|
}
|
|
|
|
fn strip_user_message_prefix(text: &str) -> &str {
|
|
match text.find(USER_MESSAGE_BEGIN) {
|
|
Some(idx) => text[idx + USER_MESSAGE_BEGIN.len()..].trim(),
|
|
None => text.trim(),
|
|
}
|
|
}
|
|
|
|
fn user_message_preview(user: &UserMessageEvent) -> Option<String> {
|
|
let message = strip_user_message_prefix(user.message.as_str());
|
|
if !message.is_empty() {
|
|
return Some(message.to_string());
|
|
}
|
|
if user
|
|
.images
|
|
.as_ref()
|
|
.is_some_and(|images| !images.is_empty())
|
|
|| !user.local_images.is_empty()
|
|
{
|
|
return Some(IMAGE_ONLY_USER_MESSAGE_PLACEHOLDER.to_string());
|
|
}
|
|
None
|
|
}
|
|
|
|
pub(crate) fn enum_to_string<T: Serialize>(value: &T) -> String {
|
|
match serde_json::to_value(value) {
|
|
Ok(Value::String(s)) => s,
|
|
Ok(other) => other.to_string(),
|
|
Err(_) => String::new(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::apply_rollout_item;
|
|
use crate::model::ThreadMetadata;
|
|
use chrono::DateTime;
|
|
use chrono::Utc;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::config_types::ReasoningSummary;
|
|
use codex_protocol::models::ContentItem;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::openai_models::ReasoningEffort;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::RolloutItem;
|
|
use codex_protocol::protocol::SandboxPolicy;
|
|
use codex_protocol::protocol::SessionMeta;
|
|
use codex_protocol::protocol::SessionMetaLine;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_protocol::protocol::TurnContextItem;
|
|
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
|
|
use codex_protocol::protocol::UserMessageEvent;
|
|
|
|
use pretty_assertions::assert_eq;
|
|
use std::path::PathBuf;
|
|
use uuid::Uuid;
|
|
|
|
#[test]
|
|
fn response_item_user_messages_do_not_set_title_or_first_user_message() {
|
|
let mut metadata = metadata_for_test();
|
|
let item = RolloutItem::ResponseItem(ResponseItem::Message {
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: "hello from response item".to_string(),
|
|
}],
|
|
end_turn: None,
|
|
phase: None,
|
|
});
|
|
|
|
apply_rollout_item(&mut metadata, &item, "test-provider");
|
|
|
|
assert_eq!(metadata.first_user_message, None);
|
|
assert_eq!(metadata.title, "");
|
|
}
|
|
|
|
#[test]
|
|
fn event_msg_user_messages_set_title_and_first_user_message() {
|
|
let mut metadata = metadata_for_test();
|
|
let item = RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
|
message: format!("{USER_MESSAGE_BEGIN} actual user request"),
|
|
images: Some(vec![]),
|
|
local_images: vec![],
|
|
text_elements: vec![],
|
|
}));
|
|
|
|
apply_rollout_item(&mut metadata, &item, "test-provider");
|
|
|
|
assert_eq!(
|
|
metadata.first_user_message.as_deref(),
|
|
Some("actual user request")
|
|
);
|
|
assert_eq!(metadata.title, "actual user request");
|
|
}
|
|
|
|
#[test]
|
|
fn event_msg_image_only_user_message_sets_image_placeholder_preview() {
|
|
let mut metadata = metadata_for_test();
|
|
let item = RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
|
message: String::new(),
|
|
images: Some(vec!["https://example.com/image.png".to_string()]),
|
|
local_images: vec![],
|
|
text_elements: vec![],
|
|
}));
|
|
|
|
apply_rollout_item(&mut metadata, &item, "test-provider");
|
|
|
|
assert_eq!(
|
|
metadata.first_user_message.as_deref(),
|
|
Some(super::IMAGE_ONLY_USER_MESSAGE_PLACEHOLDER)
|
|
);
|
|
assert_eq!(metadata.title, "");
|
|
}
|
|
|
|
#[test]
|
|
fn event_msg_blank_user_message_without_images_keeps_first_user_message_empty() {
|
|
let mut metadata = metadata_for_test();
|
|
let item = RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
|
message: " ".to_string(),
|
|
images: Some(vec![]),
|
|
local_images: vec![],
|
|
text_elements: vec![],
|
|
}));
|
|
|
|
apply_rollout_item(&mut metadata, &item, "test-provider");
|
|
|
|
assert_eq!(metadata.first_user_message, None);
|
|
assert_eq!(metadata.title, "");
|
|
}
|
|
|
|
#[test]
|
|
fn turn_context_does_not_override_session_cwd() {
|
|
let mut metadata = metadata_for_test();
|
|
metadata.cwd = PathBuf::new();
|
|
let thread_id = metadata.id;
|
|
|
|
apply_rollout_item(
|
|
&mut metadata,
|
|
&RolloutItem::SessionMeta(SessionMetaLine {
|
|
meta: SessionMeta {
|
|
id: thread_id,
|
|
forked_from_id: Some(
|
|
ThreadId::from_string(&Uuid::now_v7().to_string()).expect("thread id"),
|
|
),
|
|
timestamp: "2026-02-26T00:00:00.000Z".to_string(),
|
|
cwd: PathBuf::from("/child/worktree"),
|
|
originator: "codex_cli_rs".to_string(),
|
|
cli_version: "0.0.0".to_string(),
|
|
source: SessionSource::Cli,
|
|
agent_path: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
model_provider: Some("openai".to_string()),
|
|
base_instructions: None,
|
|
dynamic_tools: None,
|
|
memory_mode: None,
|
|
},
|
|
git: None,
|
|
}),
|
|
"test-provider",
|
|
);
|
|
apply_rollout_item(
|
|
&mut metadata,
|
|
&RolloutItem::TurnContext(TurnContextItem {
|
|
turn_id: Some("turn-1".to_string()),
|
|
trace_id: None,
|
|
cwd: PathBuf::from("/parent/workspace"),
|
|
current_date: None,
|
|
timezone: None,
|
|
approval_policy: AskForApproval::Never,
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
network: None,
|
|
model: "gpt-5".to_string(),
|
|
personality: None,
|
|
collaboration_mode: None,
|
|
realtime_active: None,
|
|
effort: None,
|
|
summary: ReasoningSummary::Auto,
|
|
user_instructions: None,
|
|
developer_instructions: None,
|
|
final_output_json_schema: None,
|
|
truncation_policy: None,
|
|
}),
|
|
"test-provider",
|
|
);
|
|
|
|
assert_eq!(metadata.cwd, PathBuf::from("/child/worktree"));
|
|
assert_eq!(
|
|
metadata.sandbox_policy,
|
|
super::enum_to_string(&SandboxPolicy::DangerFullAccess)
|
|
);
|
|
assert_eq!(metadata.approval_mode, "never");
|
|
}
|
|
|
|
#[test]
|
|
fn turn_context_sets_cwd_when_session_cwd_missing() {
|
|
let mut metadata = metadata_for_test();
|
|
metadata.cwd = PathBuf::new();
|
|
|
|
apply_rollout_item(
|
|
&mut metadata,
|
|
&RolloutItem::TurnContext(TurnContextItem {
|
|
turn_id: Some("turn-1".to_string()),
|
|
trace_id: None,
|
|
cwd: PathBuf::from("/fallback/workspace"),
|
|
current_date: None,
|
|
timezone: None,
|
|
approval_policy: AskForApproval::OnRequest,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
network: None,
|
|
model: "gpt-5".to_string(),
|
|
personality: None,
|
|
collaboration_mode: None,
|
|
realtime_active: None,
|
|
effort: Some(ReasoningEffort::High),
|
|
summary: ReasoningSummary::Auto,
|
|
user_instructions: None,
|
|
developer_instructions: None,
|
|
final_output_json_schema: None,
|
|
truncation_policy: None,
|
|
}),
|
|
"test-provider",
|
|
);
|
|
|
|
assert_eq!(metadata.cwd, PathBuf::from("/fallback/workspace"));
|
|
}
|
|
|
|
#[test]
|
|
fn turn_context_sets_model_and_reasoning_effort() {
|
|
let mut metadata = metadata_for_test();
|
|
|
|
apply_rollout_item(
|
|
&mut metadata,
|
|
&RolloutItem::TurnContext(TurnContextItem {
|
|
turn_id: Some("turn-1".to_string()),
|
|
trace_id: None,
|
|
cwd: PathBuf::from("/fallback/workspace"),
|
|
current_date: None,
|
|
timezone: None,
|
|
approval_policy: AskForApproval::OnRequest,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
network: None,
|
|
model: "gpt-5".to_string(),
|
|
personality: None,
|
|
collaboration_mode: None,
|
|
realtime_active: None,
|
|
effort: Some(ReasoningEffort::High),
|
|
summary: ReasoningSummary::Auto,
|
|
user_instructions: None,
|
|
developer_instructions: None,
|
|
final_output_json_schema: None,
|
|
truncation_policy: None,
|
|
}),
|
|
"test-provider",
|
|
);
|
|
|
|
assert_eq!(metadata.model.as_deref(), Some("gpt-5"));
|
|
assert_eq!(metadata.reasoning_effort, Some(ReasoningEffort::High));
|
|
}
|
|
|
|
#[test]
|
|
fn session_meta_does_not_set_model_or_reasoning_effort() {
|
|
let mut metadata = metadata_for_test();
|
|
let thread_id = metadata.id;
|
|
|
|
apply_rollout_item(
|
|
&mut metadata,
|
|
&RolloutItem::SessionMeta(SessionMetaLine {
|
|
meta: SessionMeta {
|
|
id: thread_id,
|
|
forked_from_id: None,
|
|
timestamp: "2026-02-26T00:00:00.000Z".to_string(),
|
|
cwd: PathBuf::from("/workspace"),
|
|
originator: "codex_cli_rs".to_string(),
|
|
cli_version: "0.0.0".to_string(),
|
|
source: SessionSource::Cli,
|
|
agent_path: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
model_provider: Some("openai".to_string()),
|
|
base_instructions: None,
|
|
dynamic_tools: None,
|
|
memory_mode: None,
|
|
},
|
|
git: None,
|
|
}),
|
|
"test-provider",
|
|
);
|
|
|
|
assert_eq!(metadata.model, None);
|
|
assert_eq!(metadata.reasoning_effort, None);
|
|
}
|
|
|
|
fn metadata_for_test() -> ThreadMetadata {
|
|
let id = ThreadId::from_string(&Uuid::from_u128(42).to_string()).expect("thread id");
|
|
let created_at = DateTime::<Utc>::from_timestamp(1_735_689_600, 0).expect("timestamp");
|
|
ThreadMetadata {
|
|
id,
|
|
rollout_path: PathBuf::from("/tmp/a.jsonl"),
|
|
created_at,
|
|
updated_at: created_at,
|
|
source: "cli".to_string(),
|
|
agent_path: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
model_provider: "openai".to_string(),
|
|
model: None,
|
|
reasoning_effort: None,
|
|
cwd: PathBuf::from("/tmp"),
|
|
cli_version: "0.0.0".to_string(),
|
|
title: String::new(),
|
|
sandbox_policy: "read-only".to_string(),
|
|
approval_mode: "on-request".to_string(),
|
|
tokens_used: 1,
|
|
first_user_message: None,
|
|
archived_at: None,
|
|
git_sha: None,
|
|
git_branch: None,
|
|
git_origin_url: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn diff_fields_detects_changes() {
|
|
let mut base = metadata_for_test();
|
|
base.id = ThreadId::from_string(&Uuid::now_v7().to_string()).expect("thread id");
|
|
base.title = "hello".to_string();
|
|
let mut other = base.clone();
|
|
other.tokens_used = 2;
|
|
other.title = "world".to_string();
|
|
let diffs = base.diff_fields(&other);
|
|
assert_eq!(diffs, vec!["title", "tokens_used"]);
|
|
}
|
|
}
|