feat: annotate conversations with model_provider for filtering

This commit is contained in:
Michael Bolin
2025-10-24 16:55:10 -07:00
parent a4be4d78b9
commit f42951233a
14 changed files with 236 additions and 22 deletions
@@ -313,6 +313,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)]
@@ -324,6 +327,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
{
@@ -1640,12 +1646,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,
})
}
@@ -1669,7 +1677,8 @@ mod tests {
"cwd": "/",
"originator": "codex",
"cli_version": "0.0.0",
"instructions": null
"instructions": null,
"model_provider": "test-provider"
}),
json!({
"type": "message",
@@ -1698,6 +1707,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 -1
View File
@@ -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 {
@@ -174,7 +229,8 @@ fn create_fake_rollout(codex_home: &Path, filename_ts: &str, meta_rfc3339: &str,
"cwd": "/",
"originator": "codex",
"cli_version": "0.0.0",
"instructions": null
"instructions": null,
"model_provider": "test-provider"
}
})
.to_string(),
+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()
+10 -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
@@ -137,6 +145,7 @@ impl RolloutRecorder {
cli_version: env!("CARGO_PKG_VERSION").to_string(),
instructions,
source,
model_provider: Some(config.model_provider_id.clone()),
}),
)
}
+112 -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]");
@@ -68,6 +86,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,
@@ -134,7 +155,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();
@@ -166,6 +187,7 @@ async fn test_list_conversations_latest_first() {
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"model_provider": "test-provider",
})];
let head_2 = vec![serde_json::json!({
"id": u2,
@@ -175,6 +197,7 @@ async fn test_list_conversations_latest_first() {
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"model_provider": "test-provider",
})];
let head_1 = vec![serde_json::json!({
"id": u1,
@@ -184,6 +207,7 @@ async fn test_list_conversations_latest_first() {
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"model_provider": "test-provider",
})];
let expected_cursor: Cursor =
@@ -275,7 +299,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
@@ -298,6 +322,7 @@ async fn test_pagination_cursor() {
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"model_provider": "test-provider",
})];
let head_4 = vec![serde_json::json!({
"id": u4,
@@ -307,6 +332,7 @@ async fn test_pagination_cursor() {
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"model_provider": "test-provider",
})];
let expected_cursor1: Cursor =
serde_json::from_str(&format!("\"2025-03-04T09-00-00|{u4}\"")).unwrap();
@@ -338,6 +364,7 @@ async fn test_pagination_cursor() {
2,
page1.next_cursor.as_ref(),
INTERACTIVE_SESSION_SOURCES,
None,
)
.await
.unwrap();
@@ -361,6 +388,7 @@ async fn test_pagination_cursor() {
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"model_provider": "test-provider",
})];
let head_2 = vec![serde_json::json!({
"id": u2,
@@ -370,6 +398,7 @@ async fn test_pagination_cursor() {
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"model_provider": "test-provider",
})];
let expected_cursor2: Cursor =
serde_json::from_str(&format!("\"2025-03-02T09-00-00|{u2}\"")).unwrap();
@@ -401,6 +430,7 @@ async fn test_pagination_cursor() {
2,
page2.next_cursor.as_ref(),
INTERACTIVE_SESSION_SOURCES,
None,
)
.await
.unwrap();
@@ -418,6 +448,7 @@ async fn test_pagination_cursor() {
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"model_provider": "test-provider",
})];
let expected_cursor3: Cursor =
serde_json::from_str(&format!("\"2025-03-01T09-00-00|{u1}\"")).unwrap();
@@ -445,7 +476,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;
@@ -467,6 +498,7 @@ async fn test_get_conversation_contents() {
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"model_provider": "test-provider",
})];
let expected_cursor: Cursor = serde_json::from_str(&format!("\"{ts}|{uuid}\"")).unwrap();
let expected_page = ConversationsPage {
@@ -495,6 +527,7 @@ async fn test_get_conversation_contents() {
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"model_provider": "test-provider",
}
});
let user_event = serde_json::json!({
@@ -532,6 +565,7 @@ async fn test_tail_includes_last_response_items() -> Result<()> {
originator: "test_originator".into(),
cli_version: "test_version".into(),
source: SessionSource::VSCode,
model_provider: Some("test-provider".into()),
},
git: None,
}),
@@ -563,7 +597,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));
@@ -615,6 +649,7 @@ async fn test_tail_handles_short_sessions() -> Result<()> {
originator: "test_originator".into(),
cli_version: "test_version".into(),
source: SessionSource::VSCode,
model_provider: Some("test-provider".into()),
},
git: None,
}),
@@ -645,7 +680,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);
@@ -699,6 +734,7 @@ async fn test_tail_skips_trailing_non_responses() -> Result<()> {
originator: "test_originator".into(),
cli_version: "test_version".into(),
source: SessionSource::VSCode,
model_provider: Some("test-provider".into()),
},
git: None,
}),
@@ -743,7 +779,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)
@@ -785,7 +821,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();
@@ -810,6 +846,7 @@ async fn test_stable_ordering_same_second_pagination() {
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"model_provider": "test-provider",
})]
};
let expected_cursor1: Cursor = serde_json::from_str(&format!("\"{ts}|{u2}\"")).unwrap();
@@ -841,6 +878,7 @@ async fn test_stable_ordering_same_second_pagination() {
2,
page1.next_cursor.as_ref(),
INTERACTIVE_SESSION_SOURCES,
None,
)
.await
.unwrap();
@@ -891,7 +929,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
@@ -905,7 +943,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
@@ -921,3 +959,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!(
+2 -1
View File
@@ -154,7 +154,8 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
"instructions": "be nice",
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version"
"cli_version": "test_version",
"model_provider": "test-provider"
}
})
)
+2 -1
View File
@@ -375,7 +375,8 @@ async fn review_input_isolated_from_parent_history() {
"instructions": null,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version"
"cli_version": "test_version",
"model_provider": "test-provider"
}
});
f.write_all(format!("{meta_line}\n").as_bytes())
@@ -28,7 +28,8 @@ fn write_minimal_rollout_with_id(codex_home: &Path, id: Uuid) -> PathBuf {
"instructions": null,
"cwd": ".",
"originator": "test",
"cli_version": "test"
"cli_version": "test",
"model_provider": "test-provider"
}
})
)
+8 -2
View File
@@ -388,8 +388,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) => {
+2
View File
@@ -933,6 +933,7 @@ pub struct SessionMeta {
pub instructions: Option<String>,
#[serde(default)]
pub source: SessionSource,
pub model_provider: Option<String>,
}
impl Default for SessionMeta {
@@ -945,6 +946,7 @@ impl Default for SessionMeta {
cli_version: String::new(),
instructions: None,
source: SessionSource::default(),
model_provider: None,
}
}
}
+1
View File
@@ -395,6 +395,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();