mirror of
https://github.com/openai/codex.git
synced 2026-04-24 22:54:54 +00:00
Currently there is no alternative way to know that thread is ephemeral, only client which did create it has the knowledge.
509 lines
17 KiB
Rust
509 lines
17 KiB
Rust
use anyhow::Result;
|
|
use app_test_support::McpProcess;
|
|
use app_test_support::create_fake_rollout_with_text_elements;
|
|
use app_test_support::create_mock_responses_server_repeating_assistant;
|
|
use app_test_support::to_response;
|
|
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::ThreadItem;
|
|
use codex_app_server_protocol::ThreadListParams;
|
|
use codex_app_server_protocol::ThreadListResponse;
|
|
use codex_app_server_protocol::ThreadReadParams;
|
|
use codex_app_server_protocol::ThreadReadResponse;
|
|
use codex_app_server_protocol::ThreadResumeParams;
|
|
use codex_app_server_protocol::ThreadResumeResponse;
|
|
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::TurnStartParams;
|
|
use codex_app_server_protocol::TurnStartResponse;
|
|
use codex_app_server_protocol::TurnStatus;
|
|
use codex_app_server_protocol::UserInput;
|
|
use codex_protocol::user_input::ByteRange;
|
|
use codex_protocol::user_input::TextElement;
|
|
use core_test_support::responses;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::Value;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use tempfile::TempDir;
|
|
use tokio::time::timeout;
|
|
|
|
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()?;
|
|
create_config_toml(codex_home.path(), &server.uri())?;
|
|
|
|
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"),
|
|
None,
|
|
)?;
|
|
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
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 ThreadReadResponse { thread } = to_response::<ThreadReadResponse>(read_resp)?;
|
|
|
|
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, PathBuf::from("/"));
|
|
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);
|
|
|
|
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()?;
|
|
create_config_toml(codex_home.path(), &server.uri())?;
|
|
|
|
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"),
|
|
None,
|
|
)?;
|
|
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let read_id = mcp
|
|
.send_thread_read_request(ThreadReadParams {
|
|
thread_id: conversation_id.clone(),
|
|
include_turns: true,
|
|
})
|
|
.await?;
|
|
let read_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
|
)
|
|
.await??;
|
|
let ThreadReadResponse { thread } = to_response::<ThreadReadResponse>(read_resp)?;
|
|
|
|
assert_eq!(thread.turns.len(), 1);
|
|
let turn = &thread.turns[0];
|
|
assert_eq!(turn.status, TurnStatus::Completed);
|
|
assert_eq!(turn.items.len(), 1, "expected user message item");
|
|
match &turn.items[0] {
|
|
ThreadItem::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);
|
|
|
|
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()?;
|
|
create_config_toml(codex_home.path(), &server.uri())?;
|
|
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let start_id = mcp
|
|
.send_thread_start_request(ThreadStartParams {
|
|
model: Some("mock-model".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let start_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
|
|
)
|
|
.await??;
|
|
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
|
|
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 read_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
|
)
|
|
.await??;
|
|
let ThreadReadResponse { thread: read } = to_response::<ThreadReadResponse>(read_resp)?;
|
|
|
|
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 thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
create_config_toml(codex_home.path(), &server.uri())?;
|
|
|
|
let preview = "Saved user message";
|
|
let conversation_id = create_fake_rollout_with_text_elements(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
preview,
|
|
vec![],
|
|
Some("mock_provider"),
|
|
None,
|
|
)?;
|
|
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
// `thread/name/set` operates on loaded threads (via ThreadManager). A rollout existing on disk
|
|
// is not enough; we must `thread/resume` first to load it into the running server.
|
|
let pre_resume_id = mcp
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: conversation_id.clone(),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let pre_resume_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(pre_resume_id)),
|
|
)
|
|
.await??;
|
|
let ThreadResumeResponse {
|
|
thread: pre_resumed,
|
|
..
|
|
} = to_response::<ThreadResumeResponse>(pre_resume_resp)?;
|
|
assert_eq!(pre_resumed.id, conversation_id);
|
|
|
|
// Set a user-facing thread title.
|
|
let new_name = "My renamed thread";
|
|
let set_id = mcp
|
|
.send_thread_set_name_request(ThreadSetNameParams {
|
|
thread_id: conversation_id.clone(),
|
|
name: new_name.to_string(),
|
|
})
|
|
.await?;
|
|
let set_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(set_id)),
|
|
)
|
|
.await??;
|
|
let _: ThreadSetNameResponse = to_response::<ThreadSetNameResponse>(set_resp)?;
|
|
|
|
// 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));
|
|
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 {
|
|
cursor: None,
|
|
limit: Some(50),
|
|
sort_key: None,
|
|
model_providers: Some(vec!["mock_provider".to_string()]),
|
|
source_kinds: None,
|
|
archived: None,
|
|
cwd: None,
|
|
search_term: 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(),
|
|
..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()?;
|
|
create_config_toml(codex_home.path(), &server.uri())?;
|
|
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let start_id = mcp
|
|
.send_thread_start_request(ThreadStartParams {
|
|
model: Some("mock-model".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let start_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
|
|
)
|
|
.await??;
|
|
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
|
|
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_read_reports_system_error_idle_flag_after_failed_turn() -> Result<()> {
|
|
let server = responses::start_mock_server().await;
|
|
let _response_mock = responses::mount_sse_once(
|
|
&server,
|
|
responses::sse_failed("resp-1", "server_error", "simulated failure"),
|
|
)
|
|
.await;
|
|
let codex_home = TempDir::new()?;
|
|
create_config_toml(codex_home.path(), &server.uri())?;
|
|
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let start_id = mcp
|
|
.send_thread_start_request(ThreadStartParams {
|
|
model: Some("mock-model".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let start_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
|
|
)
|
|
.await??;
|
|
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
|
|
|
|
let turn_start_id = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
input: vec![UserInput::Text {
|
|
text: "fail this turn".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let turn_start_response: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)),
|
|
)
|
|
.await??;
|
|
let _: TurnStartResponse = to_response::<TurnStartResponse>(turn_start_response)?;
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("error"),
|
|
)
|
|
.await??;
|
|
|
|
let read_id = mcp
|
|
.send_thread_read_request(ThreadReadParams {
|
|
thread_id: thread.id,
|
|
include_turns: false,
|
|
})
|
|
.await?;
|
|
let read_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
|
|
)
|
|
.await??;
|
|
let ThreadReadResponse { thread } = to_response::<ThreadReadResponse>(read_resp)?;
|
|
|
|
assert_eq!(thread.status, ThreadStatus::SystemError,);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// Helper to create a config.toml pointing at the mock model server.
|
|
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
|
|
let config_toml = codex_home.join("config.toml");
|
|
std::fs::write(
|
|
config_toml,
|
|
format!(
|
|
r#"
|
|
model = "mock-model"
|
|
approval_policy = "never"
|
|
sandbox_mode = "read-only"
|
|
|
|
model_provider = "mock_provider"
|
|
|
|
[model_providers.mock_provider]
|
|
name = "Mock provider for test"
|
|
base_url = "{server_uri}/v1"
|
|
wire_api = "responses"
|
|
request_max_retries = 0
|
|
stream_max_retries = 0
|
|
"#
|
|
),
|
|
)
|
|
}
|