diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 46d248e22..13b7b8888 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -15,6 +15,7 @@ use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; +use codex_protocol::protocol::SessionSource as CoreSessionSource; use codex_protocol::user_input::UserInput as CoreUserInput; use mcp_types::ContentBlock as McpContentBlock; use schemars::JsonSchema; @@ -259,6 +260,56 @@ pub enum CommandAction { }, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +#[derive(Default)] +pub enum SessionSource { + Cli, + #[serde(rename = "vscode")] + #[ts(rename = "vscode")] + #[default] + VsCode, + Exec, + AppServer, + #[serde(other)] + Unknown, +} + +impl From for SessionSource { + fn from(value: CoreSessionSource) -> Self { + match value { + CoreSessionSource::Cli => SessionSource::Cli, + CoreSessionSource::VSCode => SessionSource::VsCode, + CoreSessionSource::Exec => SessionSource::Exec, + CoreSessionSource::Mcp => SessionSource::AppServer, + CoreSessionSource::SubAgent(_) => SessionSource::Unknown, + CoreSessionSource::Unknown => SessionSource::Unknown, + } + } +} + +impl From for CoreSessionSource { + fn from(value: SessionSource) -> Self { + match value { + SessionSource::Cli => CoreSessionSource::Cli, + SessionSource::VsCode => CoreSessionSource::VSCode, + SessionSource::Exec => CoreSessionSource::Exec, + SessionSource::AppServer => CoreSessionSource::Mcp, + SessionSource::Unknown => CoreSessionSource::Unknown, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GitInfo { + pub sha: Option, + pub branch: Option, + pub origin_url: Option, +} + impl CommandAction { pub fn into_core(self) -> CoreParsedCommand { match self { @@ -581,11 +632,20 @@ pub struct Thread { pub id: String, /// Usually the first user message in the thread, if available. pub preview: String, + /// Model provider used for this thread (for example, 'openai'). pub model_provider: String, /// Unix timestamp (in seconds) when the thread was created. pub created_at: i64, /// [UNSTABLE] Path to the thread on disk. pub path: PathBuf, + /// Working directory captured for the thread. + pub cwd: PathBuf, + /// Version of the CLI that created the thread. + pub cli_version: String, + /// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). + pub source: SessionSource, + /// Optional Git metadata captured when the thread was created. + pub git_info: Option, /// Only populated on a `thread/resume` response. /// For all other responses and notifications returning a Thread, /// the turns field will be an empty list. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index ae1bed31c..3ab944893 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -39,6 +39,7 @@ use codex_app_server_protocol::GetConversationSummaryResponse; use codex_app_server_protocol::GetUserAgentResponse; use codex_app_server_protocol::GetUserSavedConfigResponse; use codex_app_server_protocol::GitDiffToRemoteResponse; +use codex_app_server_protocol::GitInfo as ApiGitInfo; use codex_app_server_protocol::InputItem as WireInputItem; use codex_app_server_protocol::InterruptConversationParams; use codex_app_server_protocol::JSONRPCErrorError; @@ -131,7 +132,7 @@ use codex_protocol::ConversationId; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; -use codex_protocol::protocol::GitInfo; +use codex_protocol::protocol::GitInfo as CoreGitInfo; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionMetaLine; @@ -2931,7 +2932,7 @@ fn extract_conversation_summary( path: PathBuf, head: &[serde_json::Value], session_meta: &SessionMeta, - git: Option<&GitInfo>, + git: Option<&CoreGitInfo>, fallback_provider: &str, ) -> Option { let preview = head @@ -2972,7 +2973,7 @@ fn extract_conversation_summary( }) } -fn map_git_info(git_info: &GitInfo) -> ConversationGitInfo { +fn map_git_info(git_info: &CoreGitInfo) -> ConversationGitInfo { ConversationGitInfo { sha: git_info.commit_hash.clone(), branch: git_info.branch.clone(), @@ -2995,10 +2996,18 @@ fn summary_to_thread(summary: ConversationSummary) -> Thread { preview, timestamp, model_provider, - .. + cwd, + cli_version, + source, + git_info, } = summary; let created_at = parse_datetime(timestamp.as_deref()); + let git_info = git_info.map(|info| ApiGitInfo { + sha: info.sha, + branch: info.branch, + origin_url: info.origin_url, + }); Thread { id: conversation_id.to_string(), @@ -3006,6 +3015,10 @@ fn summary_to_thread(summary: ConversationSummary) -> Thread { model_provider, created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0), path, + cwd, + cli_version, + source: source.into(), + git_info, turns: Vec::new(), } } diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index c8197a046..52035e4ed 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -1,6 +1,8 @@ use anyhow::Result; use codex_protocol::ConversationId; +use codex_protocol::protocol::GitInfo; use codex_protocol::protocol::SessionMeta; +use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; use serde_json::json; use std::fs; @@ -22,6 +24,7 @@ pub fn create_fake_rollout( meta_rfc3339: &str, preview: &str, model_provider: Option<&str>, + git_info: Option, ) -> Result { let uuid = Uuid::new_v4(); let uuid_str = uuid.to_string(); @@ -37,7 +40,7 @@ pub fn create_fake_rollout( let file_path = dir.join(format!("rollout-{filename_ts}-{uuid}.jsonl")); // Build JSONL lines - let payload = serde_json::to_value(SessionMeta { + let meta = SessionMeta { id: conversation_id, timestamp: meta_rfc3339.to_string(), cwd: PathBuf::from("/"), @@ -46,6 +49,10 @@ pub fn create_fake_rollout( instructions: None, source: SessionSource::Cli, model_provider: model_provider.map(str::to_string), + }; + let payload = serde_json::to_value(SessionMetaLine { + meta, + git: git_info, })?; let lines = [ diff --git a/codex-rs/app-server/tests/suite/list_resume.rs b/codex-rs/app-server/tests/suite/list_resume.rs index 30be93a2e..1e89c0684 100644 --- a/codex-rs/app-server/tests/suite/list_resume.rs +++ b/codex-rs/app-server/tests/suite/list_resume.rs @@ -31,6 +31,7 @@ async fn test_list_and_resume_conversations() -> Result<()> { "2025-01-02T12:00:00Z", "Hello A", Some("openai"), + None, )?; create_fake_rollout( codex_home.path(), @@ -38,6 +39,7 @@ async fn test_list_and_resume_conversations() -> Result<()> { "2025-01-01T13:00:00Z", "Hello B", Some("openai"), + None, )?; create_fake_rollout( codex_home.path(), @@ -45,6 +47,7 @@ async fn test_list_and_resume_conversations() -> Result<()> { "2025-01-01T12:00:00Z", "Hello C", None, + None, )?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -105,6 +108,7 @@ async fn test_list_and_resume_conversations() -> Result<()> { "2025-01-01T11:30:00Z", "Hello TP", Some("test-provider"), + None, )?; // Filtering by model provider should return only matching sessions. diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 464fb4eee..57299ef97 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -2,10 +2,14 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_fake_rollout; use app_test_support::to_response; +use codex_app_server_protocol::GitInfo as ApiGitInfo; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; +use codex_protocol::protocol::GitInfo as CoreGitInfo; +use std::path::PathBuf; use tempfile::TempDir; use tokio::time::timeout; @@ -24,7 +28,7 @@ async fn thread_list_basic_empty() -> Result<()> { .send_thread_list_request(ThreadListParams { cursor: None, limit: Some(10), - model_providers: None, + model_providers: Some(vec!["mock_provider".to_string()]), }) .await?; let list_resp: JSONRPCResponse = timeout( @@ -63,6 +67,7 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { "2025-01-02T12:00:00Z", "Hello", Some("mock_provider"), + None, )?; let _b = create_fake_rollout( codex_home.path(), @@ -70,6 +75,7 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { "2025-01-01T13:00:00Z", "Hello", Some("mock_provider"), + None, )?; let _c = create_fake_rollout( codex_home.path(), @@ -77,6 +83,7 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { "2025-01-01T12:00:00Z", "Hello", Some("mock_provider"), + None, )?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -104,6 +111,10 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { assert_eq!(thread.preview, "Hello"); assert_eq!(thread.model_provider, "mock_provider"); assert!(thread.created_at > 0); + 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); } let cursor1 = cursor1.expect("expected nextCursor on first page"); @@ -129,6 +140,10 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { assert_eq!(thread.preview, "Hello"); assert_eq!(thread.model_provider, "mock_provider"); assert!(thread.created_at > 0); + 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!(cursor2, None, "expected nextCursor to be null on last page"); @@ -147,6 +162,7 @@ async fn thread_list_respects_provider_filter() -> Result<()> { "2025-01-02T10:00:00Z", "X", Some("mock_provider"), + None, )?; // mock_provider let _b = create_fake_rollout( codex_home.path(), @@ -154,6 +170,7 @@ async fn thread_list_respects_provider_filter() -> Result<()> { "2025-01-02T11:00:00Z", "X", Some("other_provider"), + None, )?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -180,6 +197,63 @@ async fn thread_list_respects_provider_filter() -> Result<()> { assert_eq!(thread.model_provider, "other_provider"); let expected_ts = chrono::DateTime::parse_from_rfc3339("2025-01-02T11:00:00Z")?.timestamp(); assert_eq!(thread.created_at, expected_ts); + 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); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_includes_git_info() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let git_info = CoreGitInfo { + commit_hash: Some("abc123".to_string()), + branch: Some("main".to_string()), + repository_url: Some("https://example.com/repo.git".to_string()), + }; + let conversation_id = create_fake_rollout( + codex_home.path(), + "2025-02-01T09-00-00", + "2025-02-01T09:00:00Z", + "Git info preview", + Some("mock_provider"), + Some(git_info), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let list_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: Some(10), + model_providers: Some(vec!["mock_provider".to_string()]), + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + let ThreadListResponse { data, .. } = to_response::(resp)?; + let thread = data + .iter() + .find(|t| t.id == conversation_id) + .expect("expected thread for created rollout"); + + let expected_git = ApiGitInfo { + sha: Some("abc123".to_string()), + branch: Some("main".to_string()), + origin_url: Some("https://example.com/repo.git".to_string()), + }; + assert_eq!(thread.git_info, Some(expected_git)); + assert_eq!(thread.source, SessionSource::Cli); + assert_eq!(thread.cwd, PathBuf::from("/")); + assert_eq!(thread.cli_version, "0.0.0"); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index e22b711ea..be8562e2f 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -5,6 +5,7 @@ use app_test_support::create_mock_chat_completions_server; use app_test_support::to_response; 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::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; @@ -14,6 +15,7 @@ use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; +use std::path::PathBuf; use tempfile::TempDir; use tokio::time::timeout; @@ -75,6 +77,7 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { "2025-01-05T12:00:00Z", preview, Some("mock_provider"), + None, )?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -97,6 +100,10 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { assert_eq!(thread.preview, preview); assert_eq!(thread.model_provider, "mock_provider"); assert!(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(),