Merge f42951233a into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2025-10-24 16:55:15 -07:00
committed by GitHub
10 changed files with 211 additions and 18 deletions
@@ -314,6 +314,9 @@ pub struct ListConversationsParams {
/// Opaque pagination cursor returned by a previous call.
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
/// Optional model provider filter (matches against session metadata).
#[serde(skip_serializing_if = "Option::is_none")]
pub model_provider: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -325,6 +328,9 @@ pub struct ConversationSummary {
/// RFC3339 timestamp string for the session start, if available.
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
/// Optional model provider recorded for the session.
#[serde(skip_serializing_if = "Option::is_none")]
pub model_provider: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -818,9 +818,14 @@ impl CodexMessageProcessor {
request_id: RequestId,
params: ListConversationsParams,
) {
let page_size = params.page_size.unwrap_or(25);
let ListConversationsParams {
page_size,
cursor,
model_provider,
} = params;
let page_size = page_size.unwrap_or(25);
// Decode the optional cursor string to a Cursor via serde (Cursor implements Deserialize from string)
let cursor_obj: Option<RolloutCursor> = match params.cursor {
let cursor_obj: Option<RolloutCursor> = match cursor {
Some(s) => serde_json::from_str::<RolloutCursor>(&format!("\"{s}\"")).ok(),
None => None,
};
@@ -831,6 +836,7 @@ impl CodexMessageProcessor {
page_size,
cursor_ref,
INTERACTIVE_SESSION_SOURCES,
model_provider.as_deref(),
)
.await
{
@@ -1643,12 +1649,14 @@ fn extract_conversation_summary(
} else {
Some(session_meta.timestamp.clone())
};
let model_provider = session_meta.model_provider.clone();
Some(ConversationSummary {
conversation_id: session_meta.id,
timestamp,
path,
preview: preview.to_string(),
model_provider,
})
}
@@ -1672,7 +1680,8 @@ mod tests {
"cwd": "/",
"originator": "codex",
"cli_version": "0.0.0",
"instructions": null
"instructions": null,
"model_provider": "test-provider"
}),
json!({
"type": "message",
@@ -1701,6 +1710,7 @@ mod tests {
);
assert_eq!(summary.path, path);
assert_eq!(summary.preview, "Count to 5");
assert_eq!(summary.model_provider, Some("test-provider".to_string()));
Ok(())
}
}
@@ -57,6 +57,7 @@ async fn test_list_and_resume_conversations() {
.send_list_conversations_request(ListConversationsParams {
page_size: Some(2),
cursor: None,
model_provider: None,
})
.await
.expect("send listConversations");
@@ -74,6 +75,8 @@ async fn test_list_and_resume_conversations() {
// Newest first; preview text should match
assert_eq!(items[0].preview, "Hello A");
assert_eq!(items[1].preview, "Hello B");
assert_eq!(items[0].model_provider.as_deref(), Some("test-provider"));
assert_eq!(items[1].model_provider.as_deref(), Some("test-provider"));
assert!(items[0].path.is_absolute());
assert!(next_cursor.is_some());
@@ -82,6 +85,7 @@ async fn test_list_and_resume_conversations() {
.send_list_conversations_request(ListConversationsParams {
page_size: Some(2),
cursor: next_cursor,
model_provider: None,
})
.await
.expect("send listConversations page 2");
@@ -99,8 +103,59 @@ async fn test_list_and_resume_conversations() {
} = to_response::<ListConversationsResponse>(resp2).expect("deserialize response");
assert_eq!(items2.len(), 1);
assert_eq!(items2[0].preview, "Hello C");
assert_eq!(items2[0].model_provider.as_deref(), Some("test-provider"));
assert!(next2.is_some());
// Filtering by model provider should return only matching sessions.
let filter_req_id = mcp
.send_list_conversations_request(ListConversationsParams {
page_size: Some(10),
cursor: None,
model_provider: Some("test-provider".to_string()),
})
.await
.expect("send listConversations filtered");
let filter_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(filter_req_id)),
)
.await
.expect("listConversations filtered timeout")
.expect("listConversations filtered resp");
let ListConversationsResponse {
items: filtered_items,
next_cursor: filtered_next,
} = to_response::<ListConversationsResponse>(filter_resp).expect("deserialize filtered");
assert_eq!(filtered_items.len(), 3);
assert!(filtered_next.is_none());
assert!(
filtered_items
.iter()
.all(|item| item.model_provider.as_deref() == Some("test-provider"))
);
let empty_req_id = mcp
.send_list_conversations_request(ListConversationsParams {
page_size: Some(10),
cursor: None,
model_provider: Some("other".to_string()),
})
.await
.expect("send listConversations filtered empty");
let empty_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(empty_req_id)),
)
.await
.expect("listConversations filtered empty timeout")
.expect("listConversations filtered empty resp");
let ListConversationsResponse {
items: empty_items,
next_cursor: empty_next,
} = to_response::<ListConversationsResponse>(empty_resp).expect("deserialize filtered empty");
assert!(empty_items.is_empty());
assert!(empty_next.is_none());
// Now resume one of the sessions and expect a SessionConfigured notification and response.
let resume_req_id = mcp
.send_resume_conversation_request(ResumeConversationParams {
+18 -2
View File
@@ -54,6 +54,7 @@ struct HeadTailSummary {
saw_session_meta: bool,
saw_user_event: bool,
source: Option<SessionSource>,
model_provider: Option<String>,
created_at: Option<String>,
updated_at: Option<String>,
}
@@ -109,6 +110,7 @@ pub(crate) async fn get_conversations(
page_size: usize,
cursor: Option<&Cursor>,
allowed_sources: &[SessionSource],
model_provider: Option<&str>,
) -> io::Result<ConversationsPage> {
let mut root = codex_home.to_path_buf();
root.push(SESSIONS_SUBDIR);
@@ -124,8 +126,14 @@ pub(crate) async fn get_conversations(
let anchor = cursor.cloned();
let result =
traverse_directories_for_paths(root.clone(), page_size, anchor, allowed_sources).await?;
let result = traverse_directories_for_paths(
root.clone(),
page_size,
anchor,
allowed_sources,
model_provider,
)
.await?;
Ok(result)
}
@@ -145,6 +153,7 @@ async fn traverse_directories_for_paths(
page_size: usize,
anchor: Option<Cursor>,
allowed_sources: &[SessionSource],
model_provider: Option<&str>,
) -> io::Result<ConversationsPage> {
let mut items: Vec<ConversationItem> = Vec::with_capacity(page_size);
let mut scanned_files = 0usize;
@@ -208,6 +217,12 @@ async fn traverse_directories_for_paths(
{
continue;
}
if let Some(filter) = model_provider {
match summary.model_provider.as_deref() {
Some(provider) if provider == filter => {}
_ => continue,
}
}
// Apply filters: must have session meta and at least one user message event
if summary.saw_session_meta && summary.saw_user_event {
let HeadTailSummary {
@@ -354,6 +369,7 @@ async fn read_head_and_tail(
match rollout_line.item {
RolloutItem::SessionMeta(session_meta_line) => {
summary.source = Some(session_meta_line.meta.source);
summary.model_provider = session_meta_line.meta.model_provider.clone();
summary.created_at = summary
.created_at
.clone()
+9 -1
View File
@@ -97,8 +97,16 @@ impl RolloutRecorder {
page_size: usize,
cursor: Option<&Cursor>,
allowed_sources: &[SessionSource],
model_provider: Option<&str>,
) -> std::io::Result<ConversationsPage> {
get_conversations(codex_home, page_size, cursor, allowed_sources).await
get_conversations(
codex_home,
page_size,
cursor,
allowed_sources,
model_provider,
)
.await
}
/// Attempt to create a new [`RolloutRecorder`]. If the sessions directory
+98 -9
View File
@@ -39,6 +39,24 @@ fn write_session_file(
uuid: Uuid,
num_records: usize,
source: Option<SessionSource>,
) -> std::io::Result<(OffsetDateTime, Uuid)> {
write_session_file_with_provider(
root,
ts_str,
uuid,
num_records,
source,
Some("test-provider"),
)
}
fn write_session_file_with_provider(
root: &Path,
ts_str: &str,
uuid: Uuid,
num_records: usize,
source: Option<SessionSource>,
model_provider: Option<&str>,
) -> std::io::Result<(OffsetDateTime, Uuid)> {
let format: &[FormatItem] =
format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]");
@@ -69,6 +87,9 @@ fn write_session_file(
if let Some(source) = source {
payload["source"] = serde_json::to_value(source).unwrap();
}
if let Some(provider) = model_provider {
payload["model_provider"] = serde_json::Value::String(provider.to_string());
}
let meta = serde_json::json!({
"timestamp": ts_str,
@@ -135,7 +156,7 @@ async fn test_list_conversations_latest_first() {
)
.unwrap();
let page = get_conversations(home, 10, None, INTERACTIVE_SESSION_SOURCES)
let page = get_conversations(home, 10, None, INTERACTIVE_SESSION_SOURCES, None)
.await
.unwrap();
@@ -279,7 +300,7 @@ async fn test_pagination_cursor() {
)
.unwrap();
let page1 = get_conversations(home, 2, None, INTERACTIVE_SESSION_SOURCES)
let page1 = get_conversations(home, 2, None, INTERACTIVE_SESSION_SOURCES, None)
.await
.unwrap();
let p5 = home
@@ -344,6 +365,7 @@ async fn test_pagination_cursor() {
2,
page1.next_cursor.as_ref(),
INTERACTIVE_SESSION_SOURCES,
None,
)
.await
.unwrap();
@@ -409,6 +431,7 @@ async fn test_pagination_cursor() {
2,
page2.next_cursor.as_ref(),
INTERACTIVE_SESSION_SOURCES,
None,
)
.await
.unwrap();
@@ -454,7 +477,7 @@ async fn test_get_conversation_contents() {
let ts = "2025-04-01T10-30-00";
write_session_file(home, ts, uuid, 2, Some(SessionSource::VSCode)).unwrap();
let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES)
let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES, None)
.await
.unwrap();
let path = &page.items[0].path;
@@ -575,7 +598,7 @@ async fn test_tail_includes_last_response_items() -> Result<()> {
}
drop(file);
let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES).await?;
let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES, None).await?;
let item = page.items.first().expect("conversation item");
let tail_len = item.tail.len();
assert_eq!(tail_len, 10usize.min(total_messages));
@@ -658,7 +681,7 @@ async fn test_tail_handles_short_sessions() -> Result<()> {
}
drop(file);
let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES).await?;
let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES, None).await?;
let tail = &page.items.first().expect("conversation item").tail;
assert_eq!(tail.len(), 3);
@@ -757,7 +780,7 @@ async fn test_tail_skips_trailing_non_responses() -> Result<()> {
writeln!(file, "{}", serde_json::to_string(&shutdown_event)?)?;
drop(file);
let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES).await?;
let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES, None).await?;
let tail = &page.items.first().expect("conversation item").tail;
let expected: Vec<serde_json::Value> = (0..4)
@@ -799,7 +822,7 @@ async fn test_stable_ordering_same_second_pagination() {
write_session_file(home, ts, u2, 0, Some(SessionSource::VSCode)).unwrap();
write_session_file(home, ts, u3, 0, Some(SessionSource::VSCode)).unwrap();
let page1 = get_conversations(home, 2, None, INTERACTIVE_SESSION_SOURCES)
let page1 = get_conversations(home, 2, None, INTERACTIVE_SESSION_SOURCES, None)
.await
.unwrap();
@@ -856,6 +879,7 @@ async fn test_stable_ordering_same_second_pagination() {
2,
page1.next_cursor.as_ref(),
INTERACTIVE_SESSION_SOURCES,
None,
)
.await
.unwrap();
@@ -906,7 +930,7 @@ async fn test_source_filter_excludes_non_matching_sessions() {
)
.unwrap();
let interactive_only = get_conversations(home, 10, None, INTERACTIVE_SESSION_SOURCES)
let interactive_only = get_conversations(home, 10, None, INTERACTIVE_SESSION_SOURCES, None)
.await
.unwrap();
let paths: Vec<_> = interactive_only
@@ -920,7 +944,7 @@ async fn test_source_filter_excludes_non_matching_sessions() {
path.ends_with("rollout-2025-08-02T10-00-00-00000000-0000-0000-0000-00000000002a.jsonl")
}));
let all_sessions = get_conversations(home, 10, None, NO_SOURCE_FILTER)
let all_sessions = get_conversations(home, 10, None, NO_SOURCE_FILTER, None)
.await
.unwrap();
let all_paths: Vec<_> = all_sessions
@@ -936,3 +960,68 @@ async fn test_source_filter_excludes_non_matching_sessions() {
path.ends_with("rollout-2025-08-01T10-00-00-00000000-0000-0000-0000-00000000004d.jsonl")
}));
}
#[tokio::test]
async fn test_model_provider_filter_selects_only_matching_sessions() -> Result<()> {
let temp = TempDir::new().unwrap();
let home = temp.path();
let alpha_id = Uuid::from_u128(1);
let beta_id = Uuid::from_u128(2);
let none_id = Uuid::from_u128(3);
write_session_file_with_provider(
home,
"2025-09-01T12-00-00",
alpha_id,
1,
Some(SessionSource::VSCode),
Some("alpha"),
)?;
write_session_file_with_provider(
home,
"2025-09-01T11-00-00",
beta_id,
1,
Some(SessionSource::VSCode),
Some("beta"),
)?;
write_session_file_with_provider(
home,
"2025-09-01T10-00-00",
none_id,
1,
Some(SessionSource::VSCode),
None,
)?;
let alpha_sessions = get_conversations(home, 10, None, NO_SOURCE_FILTER, Some("alpha")).await?;
assert_eq!(alpha_sessions.items.len(), 1);
let alpha_id_str = alpha_id.to_string();
let alpha_head = alpha_sessions.items[0]
.head
.first()
.and_then(|value| value.get("id"))
.and_then(serde_json::Value::as_str);
assert_eq!(alpha_head, Some(alpha_id_str.as_str()));
let beta_sessions = get_conversations(home, 10, None, NO_SOURCE_FILTER, Some("beta")).await?;
assert_eq!(beta_sessions.items.len(), 1);
let beta_id_str = beta_id.to_string();
let beta_head = beta_sessions
.items
.first()
.and_then(|item| item.head.first())
.and_then(|value| value.get("id"))
.and_then(serde_json::Value::as_str);
assert_eq!(beta_head, Some(beta_id_str.as_str()));
let unknown_sessions =
get_conversations(home, 10, None, NO_SOURCE_FILTER, Some("unknown")).await?;
assert!(unknown_sessions.items.is_empty());
let all_sessions = get_conversations(home, 10, None, NO_SOURCE_FILTER, None).await?;
assert_eq!(all_sessions.items.len(), 3);
Ok(())
}
+1 -1
View File
@@ -75,7 +75,7 @@ async fn chat_mode_stream_cli() {
server.verify().await;
// Verify a new session rollout was created and is discoverable via list_conversations
let page = RolloutRecorder::list_conversations(home.path(), 10, None, &[])
let page = RolloutRecorder::list_conversations(home.path(), 10, None, &[], None)
.await
.expect("list conversations");
assert!(
+8 -2
View File
@@ -389,8 +389,14 @@ async fn resolve_resume_path(
args: &crate::cli::ResumeArgs,
) -> anyhow::Result<Option<PathBuf>> {
if args.last {
match codex_core::RolloutRecorder::list_conversations(&config.codex_home, 1, None, &[])
.await
match codex_core::RolloutRecorder::list_conversations(
&config.codex_home,
1,
None,
&[],
None,
)
.await
{
Ok(page) => Ok(page.items.first().map(|it| it.path.clone())),
Err(e) => {
+1
View File
@@ -396,6 +396,7 @@ async fn run_ratatui_app(
1,
None,
INTERACTIVE_SESSION_SOURCES,
None,
)
.await
{
+2
View File
@@ -77,6 +77,7 @@ pub async fn run_resume_picker(tui: &mut Tui, codex_home: &Path) -> Result<Resum
PAGE_SIZE,
request.cursor.as_ref(),
INTERACTIVE_SESSION_SOURCES,
None,
)
.await;
let _ = tx.send(BackgroundEvent::PageLoaded {
@@ -329,6 +330,7 @@ impl PickerState {
PAGE_SIZE,
None,
INTERACTIVE_SESSION_SOURCES,
None,
)
.await?;
self.reset_pagination();