Files
codex/codex-rs/app-server/tests/suite/v2/thread_read.rs
T
psachdeva-oai ddd1812a51 Carry targeted moderation decisions on final-answer messages (#59842)
## What changed

Read `final_answer_moderation` metadata and attach `moderation: { moderated: boolean }` to the completed final-answer message only when its item ID matches. Apply this in both normal and plan modes, and reset the pending decision when a new response starts.

Preserve the answer's native item ID and moderation flag through legacy events and history reconstruction. Combine text parts into one legacy event when a moderation decision is present.

Expose nullable moderation data on app-server agent messages and update the JSON schemas, TypeScript types, and Python models.

## Testing

Add an integration test for moderation on completed answers and their legacy events. Extend protocol conversion tests to cover populated moderation data and serialization as `null` when absent.

GitOrigin-RevId: f286b67731a84b3daf5d10b7644f2e31b62f0e53
2026-11-06 07:43:44 +00:00

3065 lines
105 KiB
Rust

use anyhow::Result;
use app_test_support::MockResponsesConfig;
use app_test_support::TestAppServer;
use app_test_support::create_fake_paginated_rollout;
use app_test_support::create_fake_rollout_with_text_elements;
use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::rollout_path;
use app_test_support::test_absolute_path;
use app_test_support::to_response;
use codex_app_server::in_process;
use codex_app_server::in_process::InProcessStartArgs;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::CodexErrorInfo;
use codex_app_server_protocol::DeprecationNoticeNotification;
use codex_app_server_protocol::ErrorNotification;
use codex_app_server_protocol::ImageReference;
use codex_app_server_protocol::InitializeCapabilities;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::SortDirection;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemEntry;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadItemsListAnchor;
use codex_app_server_protocol::ThreadItemsListCursor;
use codex_app_server_protocol::ThreadItemsListParams;
use codex_app_server_protocol::ThreadItemsListResponse;
use codex_app_server_protocol::ThreadItemsReadParams;
use codex_app_server_protocol::ThreadItemsReadResponse;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadNameUpdatedNotification;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadResumeInitialTurnsPageParams;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadSearchOccurrencesParams;
use codex_app_server_protocol::ThreadSearchOccurrencesResponse;
use codex_app_server_protocol::ThreadSetNameParams;
use codex_app_server_protocol::ThreadSetNameResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadTurnsListParams;
use codex_app_server_protocol::ThreadTurnsListResponse;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnItemsView;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
use codex_core::config::ConfigBuilder;
use codex_exec_server::EnvironmentManager;
use codex_feedback::CodexFeedback;
use codex_protocol::ContextWindowId;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::AgentMessageItem;
use codex_protocol::items::CommandExecutionItem;
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::MessagePhase;
use codex_protocol::protocol::AgentMessageEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecCommandSource;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::SessionSource as ProtocolSessionSource;
use codex_protocol::protocol::ThreadMemoryMode;
use codex_protocol::protocol::TurnCompleteEvent;
use codex_protocol::protocol::TurnStartedEvent;
use codex_protocol::protocol::UserMessageEvent;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
use codex_rollout::RolloutItem;
use codex_thread_store::AppendThreadItemsParams;
use codex_thread_store::CreateThreadParams;
use codex_thread_store::InMemoryThreadStore;
use codex_thread_store::LocalThreadStore;
use codex_thread_store::LocalThreadStoreConfig;
use codex_thread_store::PersistContext;
use codex_thread_store::ThreadMetadataPatch;
use codex_thread_store::ThreadPersistenceMetadata;
use codex_thread_store::ThreadStore;
use codex_thread_store::UpdateThreadMetadataParams;
use codex_utils_absolute_path::test_support::PathExt;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::io::Write;
use std::path::Path;
use std::sync::Arc;
use tempfile::TempDir;
use tokio::time::timeout;
use uuid::Uuid;
#[cfg(windows)]
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25);
#[cfg(not(windows))]
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test]
async fn thread_read_returns_summary_without_turns() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
let preview = "Saved user message";
let text_elements = [TextElement::new(
ByteRange { start: 0, end: 5 },
Some("<note>".into()),
)];
let conversation_id = create_fake_rollout_with_text_elements(
codex_home.path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
preview,
text_elements
.iter()
.map(|elem| serde_json::to_value(elem).expect("serialize text element"))
.collect(),
Some("mock_provider"),
/*git_info*/ None,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized()
.await?;
let read_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: conversation_id.clone(),
include_turns: false,
})
.await?;
let response: Value = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
assert_eq!(response["thread"].get("model"), Some(&Value::Null));
assert_eq!(
response["thread"].get("reasoningEffort"),
Some(&Value::Null)
);
let ThreadReadResponse { thread, .. } = serde_json::from_value(response)?;
assert_eq!(thread.id, conversation_id);
assert_eq!(thread.preview, preview);
assert_eq!(thread.model_provider, "mock_provider");
assert!(!thread.ephemeral, "stored rollouts should not be ephemeral");
assert!(thread.path.as_ref().expect("thread path").is_absolute());
assert_eq!(thread.cwd, test_absolute_path("/"));
assert_eq!(thread.cli_version, "0.0.0");
assert_eq!(thread.source, SessionSource::Cli);
assert_eq!(thread.git_info, None);
assert_eq!(thread.turns.len(), 0);
assert_eq!(thread.status, ThreadStatus::NotLoaded);
let list_id = mcp.send_raw_request("thread/list", Some(json!({}))).await?;
let response: Value = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(list_id)).await??;
let listed = response["data"]
.as_array()
.expect("thread list")
.iter()
.find(|listed| listed["id"] == conversation_id)
.expect("stored thread should be listed");
assert_eq!(listed.get("model"), Some(&Value::Null));
assert_eq!(listed.get("reasoningEffort"), Some(&Value::Null));
Ok(())
}
#[tokio::test]
async fn thread_read_can_include_turns() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
let preview = "Saved user message";
let text_elements = vec![TextElement::new(
ByteRange { start: 0, end: 5 },
Some("<note>".into()),
)];
let conversation_id = create_fake_rollout_with_text_elements(
codex_home.path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
preview,
text_elements
.iter()
.map(|elem| serde_json::to_value(elem).expect("serialize text element"))
.collect(),
Some("mock_provider"),
/*git_info*/ None,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized()
.await?;
let read_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: conversation_id.clone(),
include_turns: true,
})
.await?;
let ThreadReadResponse { thread, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
assert_eq!(thread.turns.len(), 1);
let turn = &thread.turns[0];
assert_eq!(turn.status, TurnStatus::Completed);
assert_eq!(turn.root_turn_id, None);
assert_eq!(turn.items_view, TurnItemsView::Full);
assert_eq!(turn.items.len(), 1, "expected user message item");
match &turn.items[0].payload {
ThreadItemPayload::UserMessage { content, .. } => {
assert_eq!(
content,
&vec![UserInput::Text {
text: preview.to_string(),
text_elements: text_elements.clone().into_iter().map(Into::into).collect(),
}]
);
}
other => panic!("expected user message item, got {other:?}"),
}
assert_eq!(thread.status, ThreadStatus::NotLoaded);
assert!(
!mcp.pending_notification_methods()
.contains(&"deprecationNotice".to_string())
);
Ok(())
}
/// Preserves a file-backed image and causal root across completion and a cold history read.
#[test_case::test_case(ThreadHistoryMode::Legacy; "legacy")]
#[test_case::test_case(ThreadHistoryMode::Paginated; "paginated")]
#[tokio::test]
async fn thread_read_preserves_file_id_and_root_from_completed_turn(
history_mode: ThreadHistoryMode,
) -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
let start_id = mcp
.send_thread_start_request_with_auto_env(ThreadStartParams {
model: Some("mock-model".to_string()),
history_mode: Some(history_mode),
..Default::default()
})
.await?;
let ThreadStartResponse { thread, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??;
let image = UserInput::Image {
image: ImageReference::File {
file_id: "file_123".to_string(),
},
detail: None,
};
let completed = timeout(
DEFAULT_READ_TIMEOUT,
mcp.start_turn_and_wait_for_completion(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![image.clone()],
root_turn_id: Some("causal-root".to_string()),
..Default::default()
}),
)
.await??;
let expected_root = "causal-root";
assert_eq!(completed.turn.root_turn_id.as_deref(), Some(expected_root));
let read_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: thread.id.clone(),
include_turns: true,
})
.await?;
let ThreadReadResponse {
thread: loaded_thread,
..
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
let ThreadItemPayload::UserMessage { content, .. } = &loaded_thread.turns[0].items[0].payload
else {
panic!("expected completed turn to start with a user message");
};
assert_eq!(content, &vec![image.clone()]);
assert_eq!(
loaded_thread.turns[0].root_turn_id.as_deref(),
Some(expected_root)
);
timeout(DEFAULT_READ_TIMEOUT, mcp.shutdown_gracefully()).await??;
drop(mcp);
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
let read_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: thread.id.clone(),
include_turns: true,
})
.await?;
let ThreadReadResponse {
thread: restarted_thread,
..
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
let ThreadItemPayload::UserMessage { content, .. } =
&restarted_thread.turns[0].items[0].payload
else {
panic!("expected persisted turn to start with a user message");
};
assert_eq!(content, &vec![image]);
assert_eq!(
restarted_thread.turns[0].root_turn_id.as_deref(),
Some(expected_root)
);
let page_id = mcp
.send_thread_turns_list_request(ThreadTurnsListParams {
thread_id: thread.id,
cursor: None,
limit: Some(1),
sort_direction: Some(SortDirection::Asc),
items_view: None,
})
.await?;
let page: ThreadTurnsListResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(page_id)).await??;
assert_eq!(page.data.len(), 1);
assert_eq!(page.data[0].id, completed.turn.id);
assert_eq!(page.data[0].root_turn_id.as_deref(), Some(expected_root));
Ok(())
}
#[tokio::test]
async fn paginated_stored_thread_routes_projected_turns() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
let conversation_id = create_fake_paginated_rollout(
codex_home.path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Saved user message",
Some("mock_provider"),
/*git_info*/ None,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized()
.await?;
let read_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: conversation_id.clone(),
include_turns: false,
})
.await?;
let ThreadReadResponse { thread, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
assert_eq!(thread.history_mode, ThreadHistoryMode::Paginated);
assert!(thread.turns.is_empty());
assert!(
!mcp.pending_notification_methods()
.contains(&"deprecationNotice".to_string())
);
let full_read_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: conversation_id.clone(),
include_turns: true,
})
.await?;
let notice: DeprecationNoticeNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_notification("deprecationNotice"),
)
.await??;
assert_eq!(
notice,
DeprecationNoticeNotification {
summary: "Full-history hydration is deprecated for paginated threads; omit `includeTurns` or set it to `false`, then page with `thread/turns/list` and `thread/items/list`.".to_string(),
details: None,
}
);
let _: ThreadReadResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(full_read_id)).await??;
let list_id = mcp
.send_thread_list_request(ThreadListParams {
current_account_only: false,
excluded_thread_ids: None,
attached_to_thread_id: None,
originators: None,
cursor: None,
limit: Some(50),
sort_key: None,
sort_direction: None,
model_providers: Some(vec!["mock_provider".to_string()]),
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: None,
})
.await?;
let ThreadListResponse { data, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(list_id)).await??;
let listed = data
.iter()
.find(|thread| thread.id == conversation_id)
.expect("thread/list should include paginated thread");
assert_eq!(listed.history_mode, ThreadHistoryMode::Paginated);
let turns_list_id = mcp
.send_thread_turns_list_request(ThreadTurnsListParams {
thread_id: conversation_id.clone(),
cursor: None,
limit: None,
sort_direction: None,
items_view: None,
})
.await?;
let turns_list_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turns_list_id)),
)
.await??;
assert_eq!(
to_response::<ThreadTurnsListResponse>(turns_list_resp)?,
ThreadTurnsListResponse {
data: Vec::new(),
next_cursor: None,
backwards_cursor: None,
}
);
Ok(())
}
#[tokio::test]
async fn thread_turns_list_can_page_backward_and_forward() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
let filename_ts = "2025-01-05T12-00-00";
let conversation_id = create_fake_rollout_with_text_elements(
codex_home.path(),
filename_ts,
"2025-01-05T12:00:00Z",
"first",
vec![],
Some("mock_provider"),
/*git_info*/ None,
)?;
let rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id);
append_user_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "second")?;
append_user_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "third")?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized()
.await?;
let read_id = mcp
.send_thread_turns_list_request(ThreadTurnsListParams {
thread_id: conversation_id.clone(),
cursor: None,
limit: Some(2),
sort_direction: Some(SortDirection::Desc),
items_view: None,
})
.await?;
let ThreadTurnsListResponse {
data,
next_cursor,
backwards_cursor,
..
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
assert_eq!(turn_user_texts(&data), vec!["third", "second"]);
assert!(
data.iter()
.all(|turn| turn.items_view == TurnItemsView::Summary)
);
let next_cursor = next_cursor.expect("expected nextCursor for older turns");
let backwards_cursor = backwards_cursor.expect("expected backwardsCursor for newest turn");
let read_id = mcp
.send_thread_turns_list_request(ThreadTurnsListParams {
thread_id: conversation_id.clone(),
cursor: Some(next_cursor),
limit: Some(10),
sort_direction: Some(SortDirection::Desc),
items_view: None,
})
.await?;
let ThreadTurnsListResponse { data, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
assert_eq!(turn_user_texts(&data), vec!["first"]);
append_user_message(rollout_path.as_path(), "2025-01-05T12:03:00Z", "fourth")?;
let read_id = mcp
.send_thread_turns_list_request(ThreadTurnsListParams {
thread_id: conversation_id,
cursor: Some(backwards_cursor),
limit: Some(10),
sort_direction: Some(SortDirection::Asc),
items_view: None,
})
.await?;
let ThreadTurnsListResponse { data, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
assert_eq!(turn_user_texts(&data), vec!["third", "fourth"]);
Ok(())
}
#[tokio::test]
async fn thread_turns_list_supports_requested_items_view() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
let filename_ts = "2025-01-05T12-00-00";
let conversation_id = create_fake_rollout_with_text_elements(
codex_home.path(),
filename_ts,
"2025-01-05T12:00:00Z",
"first",
vec![],
Some("mock_provider"),
/*git_info*/ None,
)?;
let rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id);
append_agent_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "draft")?;
append_agent_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "final")?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized()
.await?;
let full = read_single_turn_items_view(
&mut mcp,
conversation_id.as_str(),
Some(TurnItemsView::Full),
)
.await?;
assert_eq!(full.items_view, TurnItemsView::Full);
assert_eq!(
turn_agent_texts(std::slice::from_ref(&full)),
vec!["draft", "final"]
);
let summary = read_single_turn_items_view(
&mut mcp,
conversation_id.as_str(),
Some(TurnItemsView::Summary),
)
.await?;
assert_eq!(summary.items_view, TurnItemsView::Summary);
assert_eq!(
turn_user_texts(std::slice::from_ref(&summary)),
vec!["first"]
);
assert_eq!(
turn_agent_texts(std::slice::from_ref(&summary)),
vec!["final"]
);
let not_loaded = read_single_turn_items_view(
&mut mcp,
conversation_id.as_str(),
Some(TurnItemsView::NotLoaded),
)
.await?;
assert_eq!(not_loaded.items_view, TurnItemsView::NotLoaded);
assert!(not_loaded.items.is_empty());
assert_eq!(not_loaded.id, full.id);
assert_eq!(not_loaded.status, full.status);
assert_eq!(not_loaded.started_at, full.started_at);
assert_eq!(not_loaded.completed_at, full.completed_at);
assert_eq!(not_loaded.duration_ms, full.duration_ms);
Ok(())
}
#[tokio::test]
async fn thread_search_occurrences_reads_paginated_projection() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
let thread_id = codex_protocol::ThreadId::default();
let sqlite = codex_state::SqliteConfig::new_for_testing(codex_home.path().abs());
let state_db =
codex_state::StateRuntime::init(sqlite.clone(), "mock_provider".to_string()).await?;
let store = LocalThreadStore::new(
LocalThreadStoreConfig {
codex_home: codex_home.path().to_path_buf(),
sqlite,
default_model_provider_id: "mock_provider".to_string(),
},
Some(state_db),
);
store
.create_thread(CreateThreadParams {
attached_thread: false,
creator_user_id: None,
creator_account_id: None,
session_id: thread_id.into(),
thread_id,
extra_config: None,
forked_from_id: None,
parent_thread_id: None,
source: ProtocolSessionSource::Cli,
thread_source: None,
originator: "test_originator".to_string(),
base_instructions: BaseInstructions::default(),
dynamic_tools: Vec::new(),
selected_capability_roots: Vec::new(),
multi_agent_version: None,
history_mode: codex_protocol::protocol::ThreadHistoryMode::Paginated,
history_base: None,
subagent_history_start_ordinal: None,
initial_window_id: ContextWindowId::new_uuid_v7().to_string(),
runtime_workspace_roots: None,
metadata: ThreadPersistenceMetadata {
cwd: Some(codex_home.path().to_path_buf()),
model_provider: "mock_provider".to_string(),
memory_mode: ThreadMemoryMode::Enabled,
},
})
.await?;
store
.persist_thread(thread_id, PersistContext::Standard)
.await?;
store
.append_items(AppendThreadItemsParams {
thread_id,
items: vec![
paginated_turn_started("turn-1"),
paginated_completed_item(
thread_id,
"turn-1",
CoreTurnItem::UserMessage(UserMessageItem {
id: "user-1".to_string(),
client_id: None,
content: vec![
codex_protocol::user_input::UserInput::Text {
text: "Nee".to_string(),
text_elements: Vec::new(),
},
codex_protocol::user_input::UserInput::Text {
text: "dle needle needle needle".to_string(),
text_elements: Vec::new(),
},
],
}),
),
paginated_completed_item(
thread_id,
"turn-1",
CoreTurnItem::UserMessage(UserMessageItem {
id: "steer-1".to_string(),
client_id: None,
content: vec![codex_protocol::user_input::UserInput::Text {
text: "steer toward needle".to_string(),
text_elements: Vec::new(),
}],
}),
),
paginated_completed_item(
thread_id,
"turn-1",
CoreTurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: "commentary-1".to_string(),
content: vec![AgentMessageContent::Text {
text: "commentary needle".to_string(),
}],
phase: Some(MessagePhase::Commentary),
memory_citation: None,
delivery: None,
questions: None,
}),
),
paginated_completed_item(
thread_id,
"turn-1",
CoreTurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: "partial-1".to_string(),
content: vec![AgentMessageContent::Text {
text: "stable partial answer".to_string(),
}],
phase: Some(MessagePhase::PartialAnswer),
memory_citation: None,
delivery: None,
questions: None,
}),
),
paginated_completed_item(
thread_id,
"turn-1",
CoreTurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: "final-1".to_string(),
content: vec![AgentMessageContent::Text {
text: "😀 **Final** \nneedle".to_string(),
}],
phase: Some(MessagePhase::FinalAnswer),
memory_citation: None,
delivery: None,
questions: None,
}),
),
paginated_turn_completed("turn-1"),
],
})
.await?;
store.shutdown_thread(thread_id).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized()
.await?;
let request_id = mcp
.send_thread_search_occurrences_request(ThreadSearchOccurrencesParams {
thread_id: thread_id.to_string(),
search_term: "needle".to_string(),
cursor: None,
limit: Some(3),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadSearchOccurrencesResponse { data, next_cursor } = to_response(response)?;
assert_eq!(
data.iter()
.map(|occurrence| occurrence.item_id.as_str())
.collect::<Vec<_>>(),
vec!["user-1", "user-1", "user-1"]
);
assert_eq!(
data.iter()
.map(|occurrence| occurrence.turn_id.as_str())
.collect::<Vec<_>>(),
vec!["turn-1", "turn-1", "turn-1"]
);
assert_eq!(
data.iter()
.map(|occurrence| occurrence.snippet_match_range.start)
.collect::<Vec<_>>(),
vec![0, 7, 14]
);
let next_cursor = next_cursor.expect("first page should have another occurrence");
let request_id = mcp
.send_thread_search_occurrences_request(ThreadSearchOccurrencesParams {
thread_id: thread_id.to_string(),
search_term: "needle".to_string(),
cursor: Some(next_cursor),
limit: Some(3),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadSearchOccurrencesResponse { data, next_cursor } = to_response(response)?;
assert_eq!(
data.iter()
.map(|occurrence| occurrence.item_id.as_str())
.collect::<Vec<_>>(),
vec!["user-1", "steer-1", "final-1"]
);
assert_eq!(
data.iter()
.map(|occurrence| occurrence.turn_id.as_str())
.collect::<Vec<_>>(),
vec!["turn-1", "turn-1", "turn-1"]
);
assert_eq!(data[2].snippet, "😀 Final needle");
assert_eq!(data[2].snippet_match_range.start, 9);
assert_eq!(data[2].snippet_match_range.end, 15);
assert_eq!(next_cursor, None);
let request_id = mcp
.send_thread_search_occurrences_request(ThreadSearchOccurrencesParams {
thread_id: thread_id.to_string(),
search_term: "partial".to_string(),
cursor: None,
limit: Some(3),
})
.await?;
let ThreadSearchOccurrencesResponse { data, next_cursor } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(
data.iter()
.map(|occurrence| (occurrence.item_id.as_str(), occurrence.snippet.as_str()))
.collect::<Vec<_>>(),
vec![("partial-1", "stable partial answer")]
);
assert_eq!(next_cursor, None);
let fork_request_id = mcp
.send_thread_fork_request(ThreadForkParams {
thread_id: thread_id.to_string(),
..Default::default()
})
.await?;
let ThreadForkResponse { thread, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_request_id)).await??;
let forked_thread_id = thread.id;
let source_resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread_id.to_string(),
..Default::default()
})
.await?;
let _: ThreadResumeResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(source_resume_id)).await??;
for (target_thread_id, text) in [
(thread_id.to_string(), "excluded parent needle"),
(forked_thread_id.clone(), "child needle"),
] {
let turn_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: target_thread_id,
input: vec![UserInput::Text {
text: text.to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let _: TurnStartResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(turn_id)).await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
}
let request_id = mcp
.send_thread_search_occurrences_request(ThreadSearchOccurrencesParams {
thread_id: forked_thread_id.clone(),
search_term: "needle".to_string(),
cursor: None,
limit: Some(6),
})
.await?;
let ThreadSearchOccurrencesResponse { data, next_cursor } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(data.len(), 6);
assert!(
data.iter()
.all(|occurrence| !occurrence.snippet.contains("excluded parent needle"))
);
let next_cursor = next_cursor.expect("search should continue into child history");
let request_id = mcp
.send_thread_search_occurrences_request(ThreadSearchOccurrencesParams {
thread_id: forked_thread_id,
search_term: "needle".to_string(),
cursor: Some(next_cursor),
limit: Some(6),
})
.await?;
let ThreadSearchOccurrencesResponse { data, next_cursor } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(data.len(), 1);
assert!(data[0].snippet.contains("child needle"));
assert_eq!(next_cursor, None);
Ok(())
}
#[tokio::test]
async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result<()> {
let codex_home = TempDir::new()?;
let thread_id = codex_protocol::ThreadId::from_string("00000000-0000-4000-8000-000000000123")?;
let store_id = Uuid::new_v4().to_string();
MockResponsesConfig::new("http://127.0.0.1:1")
.with_root_config(&format!(
r#"experimental_thread_store = {{ type = "in_memory", id = "{store_id}" }}"#
))
.write(codex_home.path())?;
let store = InMemoryThreadStore::for_id(store_id.clone());
let _in_memory_store = InMemoryThreadStoreId { store_id };
seed_pathless_store_thread(&store, thread_id).await?;
let loader_overrides = LoaderOverrides::without_managed_config_for_tests();
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.loader_overrides(loader_overrides.clone())
.build()
.await?;
let client = in_process::start(InProcessStartArgs {
arg0_paths: Arg0DispatchPaths::default(),
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_config_bundle: CloudConfigBundleLoader::default(),
embedded_network_policy: Default::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
state_db: None,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
config_warnings: Vec::new(),
session_source: SessionSource::Cli.into(),
enable_codex_api_key_env: false,
initialize: InitializeParams {
client_info: ClientInfo {
name: "codex-app-server-tests".to_string(),
title: None,
version: "0.1.0".to_string(),
},
capabilities: Some(InitializeCapabilities {
experimental_api: true,
..Default::default()
}),
},
channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
})
.await?;
let result = client
.request(ClientRequest::ThreadTurnsList {
request_id: RequestId::Integer(1),
params: ThreadTurnsListParams {
thread_id: thread_id.to_string(),
cursor: None,
limit: Some(10),
sort_direction: Some(SortDirection::Asc),
items_view: None,
},
})
.await?
.expect("thread/turns/list should succeed");
let ThreadTurnsListResponse { data, .. } = serde_json::from_value(result)?;
assert_eq!(turn_user_texts(&data), vec!["history from store"]);
client.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_path() -> Result<()> {
let codex_home = TempDir::new()?;
let store_id = Uuid::new_v4().to_string();
MockResponsesConfig::new("http://127.0.0.1:1")
.with_root_config(&format!(
r#"experimental_thread_store = {{ type = "in_memory", id = "{store_id}" }}"#
))
.write(codex_home.path())?;
let store = InMemoryThreadStore::for_id(store_id.clone());
let _in_memory_store = InMemoryThreadStoreId { store_id };
let loader_overrides = LoaderOverrides::without_managed_config_for_tests();
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.loader_overrides(loader_overrides.clone())
.build()
.await?;
let client = in_process::start(InProcessStartArgs {
arg0_paths: Arg0DispatchPaths::default(),
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_config_bundle: CloudConfigBundleLoader::default(),
embedded_network_policy: Default::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
state_db: None,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
config_warnings: Vec::new(),
session_source: SessionSource::Cli.into(),
enable_codex_api_key_env: false,
initialize: InitializeParams {
client_info: ClientInfo {
name: "codex-app-server-tests".to_string(),
title: None,
version: "0.1.0".to_string(),
},
capabilities: Some(InitializeCapabilities {
experimental_api: true,
..Default::default()
}),
},
channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
})
.await?;
let result = client
.request(ClientRequest::ThreadStart {
request_id: RequestId::Integer(1),
params: ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
},
})
.await?
.expect("thread/start should succeed");
let ThreadStartResponse { thread, .. } = serde_json::from_value(result)?;
assert_eq!(thread.path, None);
let thread_id = codex_protocol::ThreadId::from_string(&thread.id)?;
store
.append_items(AppendThreadItemsParams {
thread_id,
items: store_history_items(),
})
.await?;
let result = client
.request(ClientRequest::ThreadRead {
request_id: RequestId::Integer(2),
params: ThreadReadParams {
thread_id: thread.id,
include_turns: true,
},
})
.await?
.expect("thread/read should succeed");
let ThreadReadResponse { thread, .. } = serde_json::from_value(result)?;
assert_eq!(turn_user_texts(&thread.turns), vec!["history from store"]);
let [
codex_app_server_protocol::ThreadItem {
payload: ThreadItemPayload::UserMessage { content, .. },
..
},
] = thread.turns[0].items.as_slice()
else {
panic!("expected one user message item");
};
assert_eq!(
content,
&vec![
UserInput::Text {
text: "history from store".to_string(),
text_elements: Vec::new(),
},
UserInput::Audio {
audio: codex_app_server_protocol::AudioReference::Inline {
url: "https://example.com/recording.mp3".to_string()
}
},
UserInput::LocalAudio {
path: "recording.wav".into(),
},
]
);
client.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()> {
let codex_home = TempDir::new()?;
let thread_id = codex_protocol::ThreadId::from_string("00000000-0000-4000-8000-000000000124")?;
let store_id = Uuid::new_v4().to_string();
MockResponsesConfig::new("http://127.0.0.1:1")
.with_root_config(&format!(
r#"experimental_thread_store = {{ type = "in_memory", id = "{store_id}" }}"#
))
.write(codex_home.path())?;
let store = InMemoryThreadStore::for_id(store_id.clone());
let _in_memory_store = InMemoryThreadStoreId { store_id };
seed_pathless_store_thread(&store, thread_id).await?;
let loader_overrides = LoaderOverrides::without_managed_config_for_tests();
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.loader_overrides(loader_overrides.clone())
.build()
.await?;
let client = in_process::start(InProcessStartArgs {
arg0_paths: Arg0DispatchPaths::default(),
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_config_bundle: CloudConfigBundleLoader::default(),
embedded_network_policy: Default::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
state_db: None,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
config_warnings: Vec::new(),
session_source: SessionSource::Cli.into(),
enable_codex_api_key_env: false,
initialize: InitializeParams {
client_info: ClientInfo {
name: "codex-app-server-tests".to_string(),
title: None,
version: "0.1.0".to_string(),
},
capabilities: Some(InitializeCapabilities {
experimental_api: true,
..Default::default()
}),
},
channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
})
.await?;
let result = client
.request(ClientRequest::ThreadList {
request_id: RequestId::Integer(1),
params: ThreadListParams {
current_account_only: false,
excluded_thread_ids: None,
attached_to_thread_id: None,
originators: None,
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
model_providers: Some(Vec::new()),
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: None,
},
})
.await?
.expect("thread/list should succeed");
let ThreadListResponse { data, .. } = serde_json::from_value(result)?;
assert_eq!(data.len(), 1);
let thread = &data[0];
assert_eq!(thread.id, thread_id.to_string());
assert_eq!(thread.path, None);
assert_eq!(thread.preview, "");
assert_eq!(thread.name.as_deref(), Some("named pathless thread"));
client.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn thread_read_can_return_archived_threads_by_id() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
let filename_ts = "2025-01-05T12-00-00";
let preview = "Archived saved user message";
let conversation_id = create_fake_rollout_with_text_elements(
codex_home.path(),
filename_ts,
"2025-01-05T12:00:00Z",
preview,
vec![],
Some("mock_provider"),
/*git_info*/ None,
)?;
let active_rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id);
let archived_dir = codex_home.path().join(ARCHIVED_SESSIONS_SUBDIR);
std::fs::create_dir_all(&archived_dir)?;
let archived_rollout_path =
archived_dir.join(active_rollout_path.file_name().expect("rollout file name"));
std::fs::rename(&active_rollout_path, &archived_rollout_path)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized()
.await?;
let read_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: conversation_id.clone(),
include_turns: false,
})
.await?;
let ThreadReadResponse { thread, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
assert_eq!(thread.id, conversation_id);
assert_eq!(thread.preview, preview);
let path = thread.path.expect("thread path");
assert_eq!(path.canonicalize()?, archived_rollout_path.canonicalize()?);
Ok(())
}
#[tokio::test]
async fn cold_resume_pages_inherited_history_when_active_ancestor_is_short() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
let filename_ts = "2025-01-05T12-00-00";
let parent_id = create_fake_paginated_rollout(
codex_home.path(),
filename_ts,
"2025-01-05T12:00:00Z",
"Saved user message",
Some("mock_provider"),
/*git_info*/ None,
)?;
let active = rollout_path(codex_home.path(), filename_ts, &parent_id);
for item in [
paginated_turn_started("turn-1"),
paginated_completed_item(
codex_protocol::ThreadId::from_string(&parent_id)?,
"turn-1",
CoreTurnItem::UserMessage(UserMessageItem {
id: "user-1".to_string(),
client_id: None,
content: Vec::new(),
}),
),
paginated_turn_completed("turn-1"),
] {
codex_rollout::append_rollout_item_to_path(&active, &item).await?;
}
let mut app = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized()
.await?;
let fork: ThreadForkResponse = app
.request(|request_id| ClientRequest::ThreadFork {
request_id,
params: ThreadForkParams {
thread_id: parent_id,
..Default::default()
},
})
.await?;
timeout(DEFAULT_READ_TIMEOUT, app.shutdown_gracefully()).await??;
// Leave the preferred copy readable, but shorter than the child's inherited bookmark.
let archive = codex_home.path().join(ARCHIVED_SESSIONS_SUBDIR);
std::fs::create_dir_all(&archive)?;
let filename = active.file_name().expect("rollout filename");
std::fs::copy(&active, archive.join(filename))?;
let contents = std::fs::read_to_string(&active)?;
let (header, _) = contents.split_once('\n').expect("rollout body");
std::fs::write(&active, format!("{header}\n"))?;
let mut app = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized()
.await?;
let child_id = codex_protocol::ThreadId::from_string(&fork.thread.id)?;
let resumed: ThreadResumeResponse = app
.request(|request_id| ClientRequest::ThreadResume {
request_id,
params: ThreadResumeParams {
thread_id: fork.thread.id,
exclude_turns: true,
..Default::default()
},
})
.await?;
assert!(resumed.thread.turns.is_empty());
let turn_cursor = resumed.turns_backwards_cursor.expect("turn cursor");
let item_cursor = resumed.items_backwards_cursor.expect("item cursor");
let turns = read_turns_page(
&mut app,
child_id,
Some(turn_cursor),
Some(1),
SortDirection::Desc,
Some(TurnItemsView::NotLoaded),
)
.await?;
let items = read_items_page(
&mut app,
child_id,
/*turn_id*/ None,
Some(item_cursor),
Some(1),
SortDirection::Desc,
)
.await?;
assert_eq!(turns.data[0].id, "turn-1");
assert_eq!(items.data[0].turn_id, "turn-1");
assert_eq!(items.data[0].item.id(), "user-1");
Ok(())
}
#[tokio::test]
async fn thread_resume_initial_turns_page_matches_requested_turns_list_page() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
let filename_ts = "2025-01-05T12-00-00";
let conversation_id = create_fake_rollout_with_text_elements(
codex_home.path(),
filename_ts,
"2025-01-05T12:00:00Z",
"first",
vec![],
Some("mock_provider"),
/*git_info*/ None,
)?;
let rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id);
append_user_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "second")?;
append_user_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "third")?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized()
.await?;
let turns_list_id = mcp
.send_thread_turns_list_request(ThreadTurnsListParams {
thread_id: conversation_id.clone(),
cursor: None,
limit: Some(2),
sort_direction: Some(SortDirection::Asc),
items_view: Some(TurnItemsView::NotLoaded),
})
.await?;
let turns_list_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turns_list_id)),
)
.await??;
let expected_page = to_response::<ThreadTurnsListResponse>(turns_list_resp)?;
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: conversation_id,
exclude_turns: true,
initial_turns_page: Some(ThreadResumeInitialTurnsPageParams {
limit: Some(2),
sort_direction: Some(SortDirection::Asc),
items_view: Some(TurnItemsView::NotLoaded),
}),
..Default::default()
})
.await?;
let ThreadResumeResponse {
thread,
initial_turns_page,
..
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
assert!(thread.turns.is_empty());
assert_eq!(
initial_turns_page,
Some(codex_app_server_protocol::TurnsPage::from(expected_page))
);
Ok(())
}
#[tokio::test]
async fn thread_turns_list_rejects_cursor_when_anchor_turn_is_rolled_back() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
let filename_ts = "2025-01-05T12-00-00";
let conversation_id = create_fake_rollout_with_text_elements(
codex_home.path(),
filename_ts,
"2025-01-05T12:00:00Z",
"first",
vec![],
Some("mock_provider"),
/*git_info*/ None,
)?;
let rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id);
append_user_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "second")?;
append_user_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "third")?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized()
.await?;
let read_id = mcp
.send_thread_turns_list_request(ThreadTurnsListParams {
thread_id: conversation_id.clone(),
cursor: None,
limit: Some(2),
sort_direction: Some(SortDirection::Desc),
items_view: None,
})
.await?;
let ThreadTurnsListResponse {
backwards_cursor, ..
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
let backwards_cursor = backwards_cursor.expect("expected backwardsCursor for newest turn");
append_thread_rollback(
rollout_path.as_path(),
"2025-01-05T12:03:00Z",
/*num_turns*/ 1,
)?;
let read_id = mcp
.send_thread_turns_list_request(ThreadTurnsListParams {
thread_id: conversation_id,
cursor: Some(backwards_cursor),
limit: Some(10),
sort_direction: Some(SortDirection::Asc),
items_view: None,
})
.await?;
let read_err: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(read_id)),
)
.await??;
assert_eq!(
read_err.error.message,
"invalid cursor: anchor turn is no longer present"
);
Ok(())
}
#[tokio::test]
async fn thread_read_returns_forked_from_id_for_forked_threads() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
let conversation_id = create_fake_rollout_with_text_elements(
codex_home.path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Saved user message",
vec![],
Some("mock_provider"),
/*git_info*/ None,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized()
.await?;
let fork_id = mcp
.send_thread_fork_request(ThreadForkParams {
thread_id: conversation_id.clone(),
..Default::default()
})
.await?;
let ThreadForkResponse { thread: forked, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??;
let read_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: forked.id,
include_turns: false,
})
.await?;
let ThreadReadResponse { thread, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
assert_eq!(thread.forked_from_id, Some(conversation_id));
Ok(())
}
#[tokio::test]
async fn thread_read_loaded_thread_returns_precomputed_path_before_materialization() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
let start_id = mcp
.send_thread_start_request_with_auto_env(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let ThreadStartResponse { thread, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??;
let thread_path = thread.path.clone().expect("thread path");
assert!(
!thread_path.exists(),
"fresh thread rollout should not be materialized yet"
);
let read_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: thread.id.clone(),
include_turns: false,
})
.await?;
let ThreadReadResponse { thread: read, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
assert_eq!(read.id, thread.id);
assert_eq!(read.path, Some(thread_path));
assert!(read.preview.is_empty());
assert_eq!(read.turns.len(), 0);
assert_eq!(read.status, ThreadStatus::Idle);
Ok(())
}
#[tokio::test]
async fn paginated_thread_name_set_is_reflected_in_read_list_and_metadata_resume() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
let conversation_id = create_fake_paginated_rollout(
codex_home.path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Saved user message",
Some("mock_provider"),
/*git_info*/ None,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized()
.await?;
// Set a user-facing thread title.
let new_name = "Custom saved name";
let set_id = mcp
.send_thread_set_name_request(ThreadSetNameParams {
thread_id: conversation_id.clone(),
name: new_name.to_string(),
})
.await?;
let _: ThreadSetNameResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(set_id)).await??;
let notification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("thread/name/updated"),
)
.await??;
let notification: ThreadNameUpdatedNotification =
serde_json::from_value(notification.params.expect("thread/name/updated params"))?;
assert_eq!(notification.thread_id, conversation_id);
assert_eq!(notification.thread_name.as_deref(), Some(new_name));
// Read should now surface `thread.name`, and the wire payload must include `name`.
let read_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: conversation_id.clone(),
include_turns: false,
})
.await?;
let read_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
)
.await??;
let read_result = read_resp.result.clone();
let ThreadReadResponse { thread, .. } = to_response::<ThreadReadResponse>(read_resp)?;
assert_eq!(thread.id, conversation_id);
assert_eq!(thread.name.as_deref(), Some(new_name));
assert_eq!(thread.history_mode, ThreadHistoryMode::Paginated);
let thread_json = read_result
.get("thread")
.and_then(Value::as_object)
.expect("thread/read result.thread must be an object");
assert_eq!(
thread_json.get("name").and_then(Value::as_str),
Some(new_name),
"thread/read must serialize `thread.name` on the wire"
);
assert_eq!(
thread_json.get("ephemeral").and_then(Value::as_bool),
Some(false),
"thread/read must serialize `thread.ephemeral` on the wire"
);
// List should also surface the name.
let list_id = mcp
.send_thread_list_request(ThreadListParams {
current_account_only: false,
excluded_thread_ids: None,
attached_to_thread_id: None,
originators: None,
cursor: None,
limit: Some(50),
sort_key: None,
sort_direction: None,
model_providers: Some(vec!["mock_provider".to_string()]),
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: true,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: None,
})
.await?;
let list_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(list_id)),
)
.await??;
let list_result = list_resp.result.clone();
let ThreadListResponse { data, .. } = to_response::<ThreadListResponse>(list_resp)?;
let listed = data
.iter()
.find(|t| t.id == conversation_id)
.expect("thread/list should include the created thread");
assert_eq!(listed.name.as_deref(), Some(new_name));
let listed_json = list_result
.get("data")
.and_then(Value::as_array)
.expect("thread/list result.data must be an array")
.iter()
.find(|t| t.get("id").and_then(Value::as_str) == Some(&conversation_id))
.and_then(Value::as_object)
.expect("thread/list should include the created thread as an object");
assert_eq!(
listed_json.get("name").and_then(Value::as_str),
Some(new_name),
"thread/list must serialize `thread.name` on the wire"
);
assert_eq!(
listed_json.get("ephemeral").and_then(Value::as_bool),
Some(false),
"thread/list must serialize `thread.ephemeral` on the wire"
);
// Resume should also surface the name.
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: conversation_id.clone(),
exclude_turns: true,
..Default::default()
})
.await?;
let resume_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(resume_id)),
)
.await??;
let resume_result = resume_resp.result.clone();
let ThreadResumeResponse {
thread: resumed, ..
} = to_response::<ThreadResumeResponse>(resume_resp)?;
assert_eq!(resumed.id, conversation_id);
assert_eq!(resumed.name.as_deref(), Some(new_name));
let resumed_json = resume_result
.get("thread")
.and_then(Value::as_object)
.expect("thread/resume result.thread must be an object");
assert_eq!(
resumed_json.get("name").and_then(Value::as_str),
Some(new_name),
"thread/resume must serialize `thread.name` on the wire"
);
assert_eq!(
resumed_json.get("ephemeral").and_then(Value::as_bool),
Some(false),
"thread/resume must serialize `thread.ephemeral` on the wire"
);
Ok(())
}
#[tokio::test]
async fn thread_read_include_turns_rejects_unmaterialized_loaded_thread() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
let start_id = mcp
.send_thread_start_request_with_auto_env(ThreadStartParams {
model: Some("mock-model".to_string()),
history_mode: Some(ThreadHistoryMode::Legacy),
..Default::default()
})
.await?;
let ThreadStartResponse { thread, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??;
let thread_path = thread.path.clone().expect("thread path");
assert!(
!thread_path.exists(),
"fresh thread rollout should not be materialized yet"
);
let read_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: thread.id.clone(),
include_turns: true,
})
.await?;
let read_err: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(read_id)),
)
.await??;
assert!(
read_err
.error
.message
.contains("includeTurns is unavailable before first user message"),
"unexpected error: {}",
read_err.error.message
);
Ok(())
}
#[tokio::test]
async fn thread_turns_list_rejects_unmaterialized_loaded_thread() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
let start_id = mcp
.send_thread_start_request_with_auto_env(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let ThreadStartResponse { thread, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??;
let thread_path = thread.path.clone().expect("thread path");
assert!(
!thread_path.exists(),
"fresh thread rollout should not be materialized yet"
);
let read_id = mcp
.send_thread_turns_list_request(ThreadTurnsListParams {
thread_id: thread.id,
cursor: None,
limit: None,
sort_direction: None,
items_view: None,
})
.await?;
let read_err: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(read_id)),
)
.await??;
assert!(
read_err
.error
.message
.contains("thread/turns/list is unavailable before first user message"),
"unexpected error: {}",
read_err.error.message
);
Ok(())
}
#[tokio::test]
async fn thread_items_list_defers_successful_command_output() -> Result<()> {
use codex_protocol::items::CommandExecutionStatus::Completed;
use codex_protocol::items::CommandExecutionStatus::Declined;
use codex_protocol::items::CommandExecutionStatus::Failed;
use codex_protocol::items::CommandExecutionStatus::InProgress;
async fn list(server: &mut TestAppServer, params: Value) -> Result<Value> {
let id = server
.send_request("thread/items/list", Some(params))
.await?;
timeout(DEFAULT_READ_TIMEOUT, server.read_response(id)).await?
}
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
let thread_id = codex_protocol::ThreadId::default();
let store = create_paginated_thread(codex_home.path(), thread_id).await?;
let large = "é".repeat(2048);
let small = "x".to_owned();
let empty = String::new();
let commands = [
("large", Completed, Some(0), Some(&large)),
("small", Completed, Some(0), Some(&small)),
("empty", Completed, Some(0), Some(&empty)),
("missing-output", Completed, Some(0), None),
("missing-exit", Completed, None, Some(&large)),
("nonzero", Completed, Some(1), Some(&large)),
("failed", Failed, Some(0), Some(&large)),
("declined", Declined, Some(0), Some(&large)),
("running", InProgress, Some(0), Some(&large)),
];
let mut items = vec![paginated_turn_started("turn")];
for (id, status, exit_code, output) in commands {
items.push(paginated_completed_item(
thread_id,
"turn",
CoreTurnItem::CommandExecution(CommandExecutionItem {
id: id.to_string(),
command: vec!["ls".to_string()],
cwd: codex_home.path().abs().into(),
parsed_cmd: Vec::new(),
source: ExecCommandSource::Agent,
status,
aggregated_output: output.cloned(),
exit_code,
sandbox_type: None,
model_context: None,
plugin_id: None,
script_path: None,
process_id: None,
interaction_input: None,
duration: None,
}),
));
}
store
.append_items(AppendThreadItemsParams { thread_id, items })
.await?;
store.shutdown_thread(thread_id).await?;
let mut stable = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
stable
.initialize_with_capabilities(
ClientInfo {
name: "stable-history-client".to_string(),
title: None,
version: "1".to_string(),
},
/*capabilities*/ None,
)
.await?;
let mut params = json!({"threadId": thread_id, "turnId": "turn"});
let full = list(&mut stable, params.clone()).await?;
let entries = full["data"].as_array().expect("list entries");
assert_eq!(entries.len(), commands.len());
for (entry, (id, _, _, output)) in entries.iter().zip(commands) {
assert_eq!(entry["item"]["id"], id);
assert_eq!(
entry["item"]["aggregatedOutput"],
json!(output.filter(|text| !text.is_empty()))
);
assert!(entry.get("outputDeferred").is_none());
}
for options in [
Value::Null,
json!({}),
json!({"commandExecution": false}),
json!({"unknownItemKind": true}),
] {
params["deferOutput"] = options;
assert_eq!(list(&mut stable, params.clone()).await?, full);
}
params["deferOutput"] = json!({"commandExecution": true, "unknownItemKind": true});
let id = stable
.send_request("thread/items/list", Some(params.clone()))
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
stable.read_stream_until_error_message(RequestId::Integer(id)),
)
.await??;
assert_eq!(error.error.code, -32600);
let mut experimental = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized()
.await?;
assert_eq!(
list(
&mut experimental,
json!({"threadId": thread_id, "turnId": "turn", "deferLargeCommandOutput": true}),
)
.await?,
full
);
let deferred = list(&mut experimental, params).await?;
let mut expected = full.clone();
for i in 0..2 {
expected["data"][i]["outputDeferred"] = json!(true);
expected["data"][i]["item"]["aggregatedOutput"] = Value::Null;
}
assert_eq!(deferred, expected);
let projected: ThreadItemsListResponse = serde_json::from_value(deferred)?;
let mut entries_to_fetch = projected
.data
.into_iter()
.filter_map(|entry| ThreadItem::try_from(entry).err());
let large_command = entries_to_fetch.next().expect("large command needs a read");
let small_command = entries_to_fetch.next().expect("small command needs a read");
assert!(entries_to_fetch.next().is_none());
let across_turns = list(
&mut experimental,
json!({"threadId": thread_id, "deferOutput": {"commandExecution": true}}),
)
.await?;
assert_eq!(across_turns["data"], full["data"]);
let exact: Value = experimental
.request(|request_id| ClientRequest::ThreadItemsRead {
request_id,
params: ThreadItemsReadParams {
thread_id: thread_id.to_string(),
turn_id: small_command.turn_id,
item_ids: vec![large_command.item_id, small_command.item_id],
},
})
.await?;
assert_eq!(exact, json!({"data": [entries[0], entries[1]]}));
Ok(())
}
#[tokio::test]
async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
let thread_id = codex_protocol::ThreadId::default();
let store = create_paginated_thread(codex_home.path(), thread_id).await?;
store
.append_items(AppendThreadItemsParams {
thread_id,
items: vec![
paginated_turn_started("turn-1"),
paginated_completed_item(
thread_id,
"turn-1",
CoreTurnItem::UserMessage(UserMessageItem {
id: "user-1".to_string(),
client_id: None,
content: Vec::new(),
}),
),
paginated_completed_item(
thread_id,
"turn-1",
CoreTurnItem::UserMessage(UserMessageItem {
id: "steer-1".to_string(),
client_id: None,
content: Vec::new(),
}),
),
paginated_completed_item(
thread_id,
"turn-1",
CoreTurnItem::AgentMessage(AgentMessageItem {
moderation: None,
id: "agent-1".to_string(),
content: vec![AgentMessageContent::Text {
text: "first".to_string(),
}],
phase: None,
memory_citation: None,
delivery: None,
questions: None,
}),
),
paginated_completed_item(
thread_id,
"turn-1",
CoreTurnItem::UserMessage(UserMessageItem {
id: "steer-1".to_string(),
client_id: Some("updated-steer".to_string()),
content: Vec::new(),
}),
),
paginated_turn_completed("turn-1"),
paginated_turn_started("turn-2"),
paginated_completed_item(
thread_id,
"turn-2",
CoreTurnItem::UserMessage(UserMessageItem {
id: "user-2".to_string(),
client_id: None,
content: Vec::new(),
}),
),
],
})
.await?;
store.shutdown_thread(thread_id).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized()
.await?;
let expected_turn_1_full = Turn {
id: "turn-1".to_string(),
root_turn_id: None,
items: vec![
ThreadItemPayload::UserMessage {
id: "user-1".to_string(),
client_id: None,
content: Vec::new(),
},
ThreadItemPayload::UserMessage {
id: "steer-1".to_string(),
client_id: Some("updated-steer".to_string()),
content: Vec::new(),
},
ThreadItemPayload::AgentMessage {
moderation: None,
id: "agent-1".to_string(),
text: "first".to_string(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
},
]
.into_iter()
.map(|payload| codex_app_server_protocol::ThreadItem {
payload,
started_at_ms: Some(0),
completed_at_ms: Some(1),
})
.collect(),
items_view: TurnItemsView::Full,
status: TurnStatus::Completed,
error: None,
started_at: Some(10),
completed_at: Some(20),
duration_ms: Some(10_000),
};
let expected_turn_2_full = Turn {
id: "turn-2".to_string(),
root_turn_id: None,
items: vec![ThreadItemPayload::UserMessage {
id: "user-2".to_string(),
client_id: None,
content: Vec::new(),
}]
.into_iter()
.map(|payload| codex_app_server_protocol::ThreadItem {
payload,
started_at_ms: Some(0),
completed_at_ms: Some(1),
})
.collect(),
items_view: TurnItemsView::Full,
status: TurnStatus::Interrupted,
error: None,
started_at: Some(10),
completed_at: None,
duration_ms: None,
};
let expected_full_turns = vec![expected_turn_1_full.clone(), expected_turn_2_full.clone()];
let read_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: thread_id.to_string(),
include_turns: true,
})
.await?;
let ThreadReadResponse {
thread: unloaded_thread,
..
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
assert_eq!(unloaded_thread.turns, expected_full_turns);
let legacy_resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread_id.to_string(),
..Default::default()
})
.await?;
let ThreadResumeResponse {
thread: legacy_thread,
..
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(legacy_resume_id)).await??;
assert_eq!(legacy_thread.turns, expected_full_turns);
let initial_page_resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread_id.to_string(),
exclude_turns: true,
initial_turns_page: Some(ThreadResumeInitialTurnsPageParams {
limit: Some(1),
sort_direction: Some(SortDirection::Desc),
items_view: Some(TurnItemsView::Full),
}),
..Default::default()
})
.await?;
let ThreadResumeResponse {
thread: initial_page_thread,
initial_turns_page,
..
} = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_response(initial_page_resume_id),
)
.await??;
assert!(initial_page_thread.turns.is_empty());
assert_eq!(
initial_turns_page.expect("initial turns page").data,
vec![expected_turn_2_full]
);
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread_id.to_string(),
exclude_turns: true,
..Default::default()
})
.await?;
let ThreadResumeResponse {
thread,
turns_backwards_cursor,
items_backwards_cursor,
..
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
assert!(thread.turns.is_empty());
let turns_backwards_cursor =
turns_backwards_cursor.expect("resume should return a turn head cursor");
let items_backwards_cursor =
items_backwards_cursor.expect("resume should return an item head cursor");
let rejoin_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread_id.to_string(),
exclude_turns: true,
..Default::default()
})
.await?;
let ThreadResumeResponse {
turns_backwards_cursor: rejoin_turns_backwards_cursor,
items_backwards_cursor: rejoin_items_backwards_cursor,
..
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(rejoin_id)).await??;
assert_eq!(
rejoin_turns_backwards_cursor.as_deref(),
Some(turns_backwards_cursor.as_str())
);
assert_eq!(
rejoin_items_backwards_cursor.as_deref(),
Some(items_backwards_cursor.as_str())
);
let ThreadTurnsListResponse { data, .. } = read_turns_page(
&mut mcp,
thread_id,
Some(turns_backwards_cursor),
Some(2),
SortDirection::Desc,
Some(TurnItemsView::NotLoaded),
)
.await?;
assert_eq!(
data.into_iter().map(|turn| turn.id).collect::<Vec<_>>(),
vec!["turn-2", "turn-1"]
);
let ThreadItemsListResponse { data, .. } = read_items_page(
&mut mcp,
thread_id,
/*turn_id*/ None,
Some(items_backwards_cursor.clone()),
Some(3),
SortDirection::Desc,
)
.await?;
assert_eq!(
data.into_iter()
.map(|entry| entry.item.id().to_string())
.collect::<Vec<_>>(),
vec!["user-2", "agent-1", "steer-1"]
);
let ThreadItemsListResponse { data, .. } = read_items_page(
&mut mcp,
thread_id,
Some("turn-1"),
Some(items_backwards_cursor),
Some(2),
SortDirection::Desc,
)
.await?;
assert_eq!(
data.into_iter()
.map(|entry| entry.item.id().to_string())
.collect::<Vec<_>>(),
vec!["agent-1", "steer-1"]
);
let first_page = read_turns_page(
&mut mcp,
thread_id,
/*cursor*/ None,
Some(1),
SortDirection::Asc,
Some(TurnItemsView::Summary),
)
.await?;
assert_eq!(
first_page.data,
vec![Turn {
id: "turn-1".to_string(),
root_turn_id: None,
items: vec![
ThreadItemPayload::UserMessage {
id: "user-1".to_string(),
client_id: None,
content: Vec::new(),
},
ThreadItemPayload::AgentMessage {
moderation: None,
id: "agent-1".to_string(),
text: "first".to_string(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
},
]
.into_iter()
.map(|payload| codex_app_server_protocol::ThreadItem {
payload,
started_at_ms: Some(0),
completed_at_ms: Some(1)
})
.collect(),
items_view: TurnItemsView::Summary,
status: TurnStatus::Completed,
error: None,
started_at: Some(10),
completed_at: Some(20),
duration_ms: Some(10_000),
}]
);
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread_id.to_string(),
exclude_turns: true,
initial_turns_page: Some(ThreadResumeInitialTurnsPageParams {
limit: Some(1),
sort_direction: Some(SortDirection::Asc),
items_view: Some(TurnItemsView::Summary),
}),
..Default::default()
})
.await?;
let resumed: ThreadResumeResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
assert_eq!(
resumed.initial_turns_page.expect("summary page").data,
first_page.data
);
let next_cursor = first_page.next_cursor.expect("next turn cursor");
let second_page = read_turns_page(
&mut mcp,
thread_id,
Some(next_cursor),
Some(1),
SortDirection::Asc,
Some(TurnItemsView::NotLoaded),
)
.await?;
assert_eq!(
second_page.data,
vec![Turn {
id: "turn-2".to_string(),
root_turn_id: None,
items: Vec::new(),
items_view: TurnItemsView::NotLoaded,
status: TurnStatus::Interrupted,
error: None,
started_at: Some(10),
completed_at: None,
duration_ms: None,
}]
);
let full_page = read_turns_page(
&mut mcp,
thread_id,
/*cursor*/ None,
Some(1),
SortDirection::Asc,
Some(TurnItemsView::Full),
)
.await?;
assert_eq!(full_page.data, vec![expected_turn_1_full]);
let first_items_page = read_items_page(
&mut mcp,
thread_id,
/*turn_id*/ None,
/*cursor*/ None,
Some(1),
SortDirection::Asc,
)
.await?;
assert_eq!(
first_items_page.data,
vec![ThreadItemEntry {
turn_id: "turn-1".to_string(),
output_deferred: false,
item: codex_app_server_protocol::ThreadItem {
payload: ThreadItemPayload::UserMessage {
id: "user-1".to_string(),
client_id: None,
content: Vec::new(),
},
started_at_ms: Some(0),
completed_at_ms: Some(1)
},
started_at_ms: Some(0),
completed_at_ms: Some(1),
}]
);
let second_items_page = read_items_page(
&mut mcp,
thread_id,
/*turn_id*/ None,
Some(first_items_page.next_cursor.expect("next item cursor")),
Some(1),
SortDirection::Asc,
)
.await?;
assert_eq!(second_items_page.data.len(), 1);
assert_eq!(second_items_page.data[0].turn_id, "turn-1");
assert_eq!(second_items_page.data[0].item.id(), "steer-1");
let third_items_page = read_items_page(
&mut mcp,
thread_id,
/*turn_id*/ None,
Some(second_items_page.next_cursor.expect("next item cursor")),
Some(2),
SortDirection::Asc,
)
.await?;
assert_eq!(third_items_page.data.len(), 2);
assert_eq!(third_items_page.data[0].turn_id, "turn-1");
assert_eq!(third_items_page.data[0].item.id(), "agent-1");
assert_eq!(third_items_page.data[1].turn_id, "turn-2");
assert_eq!(third_items_page.data[1].item.id(), "user-2");
let anchor_params = ThreadItemsListParams {
thread_id: thread_id.to_string(),
turn_id: Some("turn-1".to_string()),
cursor: None,
limit: Some(1),
sort_direction: None,
defer_output: None,
};
for direction in [None, Some(SortDirection::Desc)] {
let request = mcp
.send_thread_items_list_request(ThreadItemsListParams {
limit: Some(10),
sort_direction: direction,
..anchor_params.clone()
})
.await?;
let ordinary: ThreadItemsListResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request)).await??;
assert_eq!(ordinary.data.len(), 3);
let request = mcp
.send_thread_items_list_request(ThreadItemsListParams {
cursor: Some(ThreadItemsListCursor::Anchor(ThreadItemsListAnchor::Item {
item_id: ordinary.data[0].item.id().to_string(),
})),
sort_direction: direction,
..anchor_params.clone()
})
.await?;
let anchored: ThreadItemsListResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request)).await??;
assert_eq!(anchored.data, ordinary.data[1..2]);
let request = mcp
.send_thread_items_list_request(ThreadItemsListParams {
cursor: Some(ThreadItemsListCursor::Opaque(
anchored
.next_cursor
.expect("anchored page has a continuation"),
)),
sort_direction: direction,
..anchor_params.clone()
})
.await?;
let continued: ThreadItemsListResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request)).await??;
assert_eq!(continued.data, ordinary.data[2..]);
assert_eq!(continued.next_cursor, None);
}
for (turn_id, anchor, message) in [
(
None,
"steer-1",
"turnId is required when cursor is an item anchor",
),
(
Some("turn-1"),
"missing",
"cursor.itemId does not identify an item in the requested history scope",
),
] {
let request = mcp
.send_thread_items_list_request(ThreadItemsListParams {
turn_id: turn_id.map(str::to_string),
cursor: Some(ThreadItemsListCursor::Anchor(ThreadItemsListAnchor::Item {
item_id: anchor.to_string(),
})),
..anchor_params.clone()
})
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request)),
)
.await??;
assert_eq!(
(error.error.code, error.error.message.as_str()),
(-32602, message)
);
}
let read_id = mcp
.send_thread_items_read_request(ThreadItemsReadParams {
thread_id: thread_id.to_string(),
item_ids: vec![
"agent-1".to_string(),
"steer-1".to_string(),
"user-2".to_string(),
"missing".to_string(),
],
turn_id: "turn-1".to_string(),
})
.await?;
let read: ThreadItemsReadResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
assert_eq!(
read.data,
vec![
ThreadItemEntry {
turn_id: "turn-1".to_string(),
output_deferred: false,
item: codex_app_server_protocol::ThreadItem {
payload: ThreadItemPayload::UserMessage {
id: "steer-1".to_string(),
client_id: Some("updated-steer".to_string()),
content: Vec::new(),
},
started_at_ms: Some(0),
completed_at_ms: Some(1)
},
started_at_ms: Some(0),
completed_at_ms: Some(1),
},
ThreadItemEntry {
turn_id: "turn-1".to_string(),
output_deferred: false,
item: codex_app_server_protocol::ThreadItem {
payload: ThreadItemPayload::AgentMessage {
moderation: None,
id: "agent-1".to_string(),
text: "first".to_string(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
},
started_at_ms: Some(0),
completed_at_ms: Some(1)
},
started_at_ms: Some(0),
completed_at_ms: Some(1),
},
]
);
let turn_start_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread_id.to_string(),
client_user_message_id: None,
input: vec![UserInput::Text {
text: "continue after legacy resume".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let _: TurnStartResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(turn_start_id)).await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let read_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: thread_id.to_string(),
include_turns: true,
})
.await?;
let ThreadReadResponse {
thread: loaded_thread,
..
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
assert_eq!(&loaded_thread.turns[..2], expected_full_turns);
assert_eq!(
turn_user_texts(&loaded_thread.turns),
vec!["continue after legacy resume"]
);
Ok(())
}
#[tokio::test]
async fn thread_items_read_validates_ids_before_history_access() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized()
.await?;
let read_id = mcp
.send_thread_items_list_request(ThreadItemsListParams {
thread_id: "00000000-0000-4000-8000-000000000123".to_string(),
turn_id: None,
cursor: None,
limit: None,
sort_direction: None,
defer_output: None,
})
.await?;
let read_err: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(read_id)),
)
.await??;
assert_eq!(read_err.error.code, -32601);
assert_eq!(
read_err.error.message,
"thread/items/list is not supported yet"
);
let read = ThreadItemsReadParams {
thread_id: "00000000-0000-4000-8000-000000000123".to_string(),
item_ids: vec!["item".to_string()],
turn_id: "turn".to_string(),
};
for item_ids in [
Vec::new(),
(0..101).map(|index| index.to_string()).collect(),
] {
let id = mcp
.send_thread_items_read_request(ThreadItemsReadParams {
item_ids,
..read.clone()
})
.await?;
let error: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(id)),
)
.await??;
assert_eq!(error.error.code, -32600);
assert_eq!(error.error.message, "itemIds must contain 1 to 100 IDs");
}
let max_read_id = mcp
.send_thread_items_read_request(ThreadItemsReadParams {
item_ids: (0..100).map(|index| index.to_string()).collect(),
..read
})
.await?;
let max_read_error: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(max_read_id)),
)
.await??;
assert_eq!(max_read_error.error.code, -32601);
Ok(())
}
#[tokio::test]
async fn thread_read_preserves_model_not_found_after_failed_turn() -> Result<()> {
let server = responses::start_mock_server().await;
let message = "Access to this deployment ended.";
let _response_mock = responses::mount_sse_once(
&server,
responses::sse_failed("resp-1", "model_not_found", message),
)
.await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
let ThreadStartResponse { thread, .. } = mcp
.start_thread(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let turn_start_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
client_user_message_id: None,
input: vec![UserInput::Text {
text: "fail this turn".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let _: TurnStartResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(turn_start_id)).await??;
let live: ErrorNotification =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_notification("error")).await??;
assert!(!live.will_retry);
assert_eq!(
live.error.codex_error_info,
Some(CodexErrorInfo::ModelNotFound)
);
assert_eq!(live.error.message, message);
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let read_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: thread.id,
include_turns: true,
})
.await?;
let ThreadReadResponse { thread, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
assert_eq!(thread.status, ThreadStatus::SystemError);
assert_eq!(
thread.turns.last().and_then(|turn| turn.error.as_ref()),
Some(&live.error)
);
Ok(())
}
fn append_user_message(path: &Path, timestamp: &str, text: &str) -> std::io::Result<()> {
let mut file = std::fs::OpenOptions::new().append(true).open(path)?;
writeln!(
file,
"{}",
json!({
"timestamp": timestamp,
"type":"event_msg",
"payload": {
"type":"user_message",
"message": text,
"text_elements": [],
"local_images": []
}
})
)
}
fn append_agent_message(path: &Path, timestamp: &str, text: &str) -> anyhow::Result<()> {
let mut file = std::fs::OpenOptions::new().append(true).open(path)?;
writeln!(
file,
"{}",
json!({
"timestamp": timestamp,
"type": "event_msg",
"payload": serde_json::to_value(EventMsg::AgentMessage(AgentMessageEvent {
moderation: None,
message: text.to_string(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
}))?,
})
)?;
Ok(())
}
fn append_thread_rollback(path: &Path, timestamp: &str, num_turns: u32) -> std::io::Result<()> {
let mut file = std::fs::OpenOptions::new().append(true).open(path)?;
writeln!(
file,
"{}",
json!({
"timestamp": timestamp,
"type":"event_msg",
"payload": {
"type":"thread_rolled_back",
"num_turns": num_turns
}
})
)
}
async fn read_single_turn_items_view(
mcp: &mut TestAppServer,
thread_id: &str,
items_view: Option<TurnItemsView>,
) -> anyhow::Result<codex_app_server_protocol::Turn> {
let read_id = mcp
.send_thread_turns_list_request(ThreadTurnsListParams {
thread_id: thread_id.to_string(),
cursor: None,
limit: Some(10),
sort_direction: Some(SortDirection::Asc),
items_view,
})
.await?;
let read_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
)
.await??;
let ThreadTurnsListResponse { mut data, .. } =
to_response::<ThreadTurnsListResponse>(read_resp)?;
assert_eq!(data.len(), 1);
Ok(data.remove(0))
}
async fn read_turns_page(
mcp: &mut TestAppServer,
thread_id: codex_protocol::ThreadId,
cursor: Option<String>,
limit: Option<u32>,
sort_direction: SortDirection,
items_view: Option<TurnItemsView>,
) -> Result<ThreadTurnsListResponse> {
let request_id = mcp
.send_thread_turns_list_request(ThreadTurnsListParams {
thread_id: thread_id.to_string(),
cursor,
limit,
sort_direction: Some(sort_direction),
items_view,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
to_response(response)
}
async fn read_items_page(
mcp: &mut TestAppServer,
thread_id: codex_protocol::ThreadId,
turn_id: Option<&str>,
cursor: Option<String>,
limit: Option<u32>,
sort_direction: SortDirection,
) -> Result<ThreadItemsListResponse> {
let request_id = mcp
.send_thread_items_list_request(ThreadItemsListParams {
thread_id: thread_id.to_string(),
turn_id: turn_id.map(str::to_string),
cursor: cursor.map(ThreadItemsListCursor::Opaque),
limit,
sort_direction: Some(sort_direction),
defer_output: None,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
to_response(response)
}
async fn create_paginated_thread(
codex_home: &Path,
thread_id: codex_protocol::ThreadId,
) -> Result<LocalThreadStore> {
let sqlite = codex_state::SqliteConfig::new_for_testing(codex_home.abs());
let state_db =
codex_state::StateRuntime::init(sqlite.clone(), "mock_provider".to_string()).await?;
let store = LocalThreadStore::new(
LocalThreadStoreConfig {
codex_home: codex_home.to_path_buf(),
sqlite,
default_model_provider_id: "mock_provider".to_string(),
},
Some(state_db),
);
store
.create_thread(CreateThreadParams {
attached_thread: false,
creator_user_id: None,
creator_account_id: None,
session_id: thread_id.into(),
thread_id,
extra_config: None,
forked_from_id: None,
parent_thread_id: None,
source: ProtocolSessionSource::Cli,
thread_source: None,
originator: "test_originator".to_string(),
base_instructions: BaseInstructions::default(),
dynamic_tools: Vec::new(),
selected_capability_roots: Vec::new(),
multi_agent_version: None,
history_mode: codex_protocol::protocol::ThreadHistoryMode::Paginated,
history_base: None,
subagent_history_start_ordinal: None,
initial_window_id: ContextWindowId::new_uuid_v7().to_string(),
runtime_workspace_roots: None,
metadata: ThreadPersistenceMetadata {
cwd: Some(codex_home.to_path_buf()),
model_provider: "mock_provider".to_string(),
memory_mode: ThreadMemoryMode::Enabled,
},
})
.await?;
store
.persist_thread(thread_id, PersistContext::Standard)
.await?;
Ok(store)
}
fn paginated_turn_started(turn_id: &str) -> RolloutItem {
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: turn_id.to_string(),
root_turn_id: None,
trace_id: None,
started_at: Some(10),
model_context_window: None,
collaboration_mode_kind: Default::default(),
}))
}
fn paginated_turn_completed(turn_id: &str) -> RolloutItem {
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
root_turn_id: None,
turn_id: turn_id.to_string(),
last_agent_message: None,
error: None,
started_at: Some(10),
completed_at: Some(20),
duration_ms: Some(10_000),
time_to_first_token_ms: None,
}))
}
fn paginated_completed_item(
thread_id: codex_protocol::ThreadId,
turn_id: &str,
item: CoreTurnItem,
) -> RolloutItem {
RolloutItem::EventMsg(EventMsg::ItemCompleted(ItemCompletedEvent {
content_token_count: None,
thread_id,
turn_id: turn_id.to_string(),
item,
started_at_ms: Some(0),
completed_at_ms: 1,
}))
}
fn turn_user_texts(turns: &[codex_app_server_protocol::Turn]) -> Vec<&str> {
turns
.iter()
.filter_map(|turn| match &turn.items.first()?.payload {
ThreadItemPayload::UserMessage { content, .. } => match content.first()? {
UserInput::Text { text, .. } => Some(text.as_str()),
UserInput::Image { .. }
| UserInput::LocalImage { .. }
| UserInput::Audio { .. }
| UserInput::LocalAudio { .. }
| UserInput::Skill { .. }
| UserInput::Mention { .. } => None,
},
_ => None,
})
.collect()
}
fn turn_agent_texts(turns: &[codex_app_server_protocol::Turn]) -> Vec<&str> {
turns
.iter()
.flat_map(Turn::item_payloads)
.filter_map(|item| match item {
ThreadItemPayload::AgentMessage { text, .. } => Some(text.as_str()),
_ => None,
})
.collect()
}
struct InMemoryThreadStoreId {
store_id: String,
}
impl Drop for InMemoryThreadStoreId {
fn drop(&mut self) {
InMemoryThreadStore::remove_id(&self.store_id);
}
}
async fn seed_pathless_store_thread(
store: &InMemoryThreadStore,
thread_id: codex_protocol::ThreadId,
) -> Result<()> {
store
.create_thread(CreateThreadParams {
attached_thread: false,
creator_user_id: None,
creator_account_id: None,
session_id: thread_id.into(),
thread_id,
extra_config: None,
forked_from_id: None,
parent_thread_id: None,
source: ProtocolSessionSource::Cli,
thread_source: None,
originator: "test_originator".to_string(),
base_instructions: BaseInstructions::default(),
dynamic_tools: Vec::new(),
selected_capability_roots: Vec::new(),
multi_agent_version: None,
history_mode: Default::default(),
history_base: None,
subagent_history_start_ordinal: None,
initial_window_id: ContextWindowId::new_uuid_v7().to_string(),
runtime_workspace_roots: None,
metadata: ThreadPersistenceMetadata {
cwd: None,
model_provider: "test-provider".to_string(),
memory_mode: ThreadMemoryMode::Disabled,
},
})
.await?;
store
.append_items(AppendThreadItemsParams {
thread_id,
items: store_history_items(),
})
.await?;
store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
name: Some(Some("named pathless thread".to_string())),
..Default::default()
},
include_archived: true,
})
.await?;
Ok(())
}
fn store_history_items() -> Vec<RolloutItem> {
vec![RolloutItem::EventMsg(EventMsg::UserMessage(
UserMessageEvent {
client_id: None,
message: "history from store".to_string(),
images: None,
local_images: Vec::new(),
audio: Some(vec!["https://example.com/recording.mp3".to_string()]),
local_audio: vec!["recording.wav".into()],
text_elements: Vec::new(),
..Default::default()
},
))]
}