[app-server] feat: expose gitInfo/cwd/etc. on Thread (#7060)

Port the new additions from https://github.com/openai/codex/pull/6337 on
the legacy API to v2. Mainly need `gitInfo` and `cwd` for VSCE.
This commit is contained in:
Owen Lin
2025-11-21 10:37:12 -08:00
committed by GitHub
parent 0e051644a9
commit aa4e0d823e
6 changed files with 171 additions and 6 deletions

View File

@@ -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<CoreSessionSource> 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<SessionSource> 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<String>,
pub branch: Option<String>,
pub origin_url: Option<String>,
}
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<GitInfo>,
/// Only populated on a `thread/resume` response.
/// For all other responses and notifications returning a Thread,
/// the turns field will be an empty list.

View File

@@ -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<ConversationSummary> {
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(),
}
}

View File

@@ -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<GitInfo>,
) -> Result<String> {
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 = [

View File

@@ -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.

View File

@@ -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::<ThreadListResponse>(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(())
}

View File

@@ -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(),