Skip to content

Commit 89b7b4a

Browse files
committed
feat: annotate conversations with model_provider for filtering
1 parent a4be4d7 commit 89b7b4a

File tree

14 files changed

+486
-59
lines changed

14 files changed

+486
-59
lines changed

codex-rs/app-server-protocol/src/protocol.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,9 @@ pub struct ListConversationsParams {
313313
/// Opaque pagination cursor returned by a previous call.
314314
#[serde(skip_serializing_if = "Option::is_none")]
315315
pub cursor: Option<String>,
316+
/// Optional model provider filter (matches against session metadata).
317+
#[serde(skip_serializing_if = "Option::is_none")]
318+
pub model_providers: Option<Vec<String>>,
316319
}
317320

318321
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -324,6 +327,8 @@ pub struct ConversationSummary {
324327
/// RFC3339 timestamp string for the session start, if available.
325328
#[serde(skip_serializing_if = "Option::is_none")]
326329
pub timestamp: Option<String>,
330+
/// Model provider recorded for the session (resolved when absent in metadata).
331+
pub model_provider: String,
327332
}
328333

329334
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]

codex-rs/app-server/src/codex_message_processor.rs

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -818,19 +818,51 @@ impl CodexMessageProcessor {
818818
request_id: RequestId,
819819
params: ListConversationsParams,
820820
) {
821-
let page_size = params.page_size.unwrap_or(25);
821+
let ListConversationsParams {
822+
page_size,
823+
cursor,
824+
model_providers: model_provider,
825+
} = params;
826+
let page_size = page_size.unwrap_or(25);
822827
// Decode the optional cursor string to a Cursor via serde (Cursor implements Deserialize from string)
823-
let cursor_obj: Option<RolloutCursor> = match params.cursor {
828+
let cursor_obj: Option<RolloutCursor> = match cursor {
824829
Some(s) => serde_json::from_str::<RolloutCursor>(&format!("\"{s}\"")).ok(),
825830
None => None,
826831
};
827832
let cursor_ref = cursor_obj.as_ref();
833+
let model_provider_filter = match model_provider {
834+
Some(providers) => {
835+
if providers.is_empty() {
836+
None
837+
} else {
838+
Some(providers)
839+
}
840+
}
841+
None => Some(vec![self.config.model_provider_id.clone()]),
842+
};
843+
let model_provider_slice = model_provider_filter.as_deref();
844+
let fallback_provider = model_provider_slice
845+
.and_then(|providers| {
846+
if providers.is_empty() {
847+
return None;
848+
}
849+
if providers.len() == 1 {
850+
return Some(providers[0].as_str());
851+
}
852+
if providers.iter().any(|provider| provider == "openai") {
853+
return Some("openai");
854+
}
855+
providers.first().map(std::string::String::as_str)
856+
})
857+
.unwrap_or("openai");
858+
let fallback_provider = fallback_provider.to_string();
828859

829860
let page = match RolloutRecorder::list_conversations(
830861
&self.config.codex_home,
831862
page_size,
832863
cursor_ref,
833864
INTERACTIVE_SESSION_SOURCES,
865+
model_provider_slice,
834866
)
835867
.await
836868
{
@@ -849,7 +881,7 @@ impl CodexMessageProcessor {
849881
let items = page
850882
.items
851883
.into_iter()
852-
.filter_map(|it| extract_conversation_summary(it.path, &it.head))
884+
.filter_map(|it| extract_conversation_summary(it.path, &it.head, &fallback_provider))
853885
.collect();
854886

855887
// Encode next_cursor as a plain string
@@ -1616,6 +1648,7 @@ async fn on_exec_approval_response(
16161648
fn extract_conversation_summary(
16171649
path: PathBuf,
16181650
head: &[serde_json::Value],
1651+
fallback_provider: &str,
16191652
) -> Option<ConversationSummary> {
16201653
let session_meta = match head.first() {
16211654
Some(first_line) => serde_json::from_value::<SessionMeta>(first_line.clone()).ok()?,
@@ -1640,12 +1673,17 @@ fn extract_conversation_summary(
16401673
} else {
16411674
Some(session_meta.timestamp.clone())
16421675
};
1676+
let conversation_id = session_meta.id;
1677+
let model_provider = session_meta
1678+
.model_provider
1679+
.unwrap_or_else(|| fallback_provider.to_string());
16431680

16441681
Some(ConversationSummary {
1645-
conversation_id: session_meta.id,
1682+
conversation_id,
16461683
timestamp,
16471684
path,
16481685
preview: preview.to_string(),
1686+
model_provider,
16491687
})
16501688
}
16511689

@@ -1669,7 +1707,8 @@ mod tests {
16691707
"cwd": "/",
16701708
"originator": "codex",
16711709
"cli_version": "0.0.0",
1672-
"instructions": null
1710+
"instructions": null,
1711+
"model_provider": "test-provider"
16731712
}),
16741713
json!({
16751714
"type": "message",
@@ -1689,7 +1728,8 @@ mod tests {
16891728
}),
16901729
];
16911730

1692-
let summary = extract_conversation_summary(path.clone(), &head).expect("summary");
1731+
let summary =
1732+
extract_conversation_summary(path.clone(), &head, "test-provider").expect("summary");
16931733

16941734
assert_eq!(summary.conversation_id, conversation_id);
16951735
assert_eq!(
@@ -1698,6 +1738,7 @@ mod tests {
16981738
);
16991739
assert_eq!(summary.path, path);
17001740
assert_eq!(summary.preview, "Count to 5");
1741+
assert_eq!(summary.model_provider, "test-provider");
17011742
Ok(())
17021743
}
17031744
}

codex-rs/app-server/tests/suite/list_resume.rs

Lines changed: 108 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,21 @@ async fn test_list_and_resume_conversations() {
3030
"2025-01-02T12-00-00",
3131
"2025-01-02T12:00:00Z",
3232
"Hello A",
33+
Some("openai"),
3334
);
3435
create_fake_rollout(
3536
codex_home.path(),
3637
"2025-01-01T13-00-00",
3738
"2025-01-01T13:00:00Z",
3839
"Hello B",
40+
Some("openai"),
3941
);
4042
create_fake_rollout(
4143
codex_home.path(),
4244
"2025-01-01T12-00-00",
4345
"2025-01-01T12:00:00Z",
4446
"Hello C",
47+
None,
4548
);
4649

4750
let mut mcp = McpProcess::new(codex_home.path())
@@ -57,6 +60,7 @@ async fn test_list_and_resume_conversations() {
5760
.send_list_conversations_request(ListConversationsParams {
5861
page_size: Some(2),
5962
cursor: None,
63+
model_providers: None,
6064
})
6165
.await
6266
.expect("send listConversations");
@@ -74,6 +78,8 @@ async fn test_list_and_resume_conversations() {
7478
// Newest first; preview text should match
7579
assert_eq!(items[0].preview, "Hello A");
7680
assert_eq!(items[1].preview, "Hello B");
81+
assert_eq!(items[0].model_provider, "openai");
82+
assert_eq!(items[1].model_provider, "openai");
7783
assert!(items[0].path.is_absolute());
7884
assert!(next_cursor.is_some());
7985

@@ -82,6 +88,7 @@ async fn test_list_and_resume_conversations() {
8288
.send_list_conversations_request(ListConversationsParams {
8389
page_size: Some(2),
8490
cursor: next_cursor,
91+
model_providers: None,
8592
})
8693
.await
8794
.expect("send listConversations page 2");
@@ -99,7 +106,88 @@ async fn test_list_and_resume_conversations() {
99106
} = to_response::<ListConversationsResponse>(resp2).expect("deserialize response");
100107
assert_eq!(items2.len(), 1);
101108
assert_eq!(items2[0].preview, "Hello C");
102-
assert!(next2.is_some());
109+
assert_eq!(items2[0].model_provider, "openai");
110+
assert_eq!(next2, None);
111+
112+
// Add a conversation with an explicit non-OpenAI provider for filter tests.
113+
create_fake_rollout(
114+
codex_home.path(),
115+
"2025-01-01T11-30-00",
116+
"2025-01-01T11:30:00Z",
117+
"Hello TP",
118+
Some("test-provider"),
119+
);
120+
121+
// Filtering by model provider should return only matching sessions.
122+
let filter_req_id = mcp
123+
.send_list_conversations_request(ListConversationsParams {
124+
page_size: Some(10),
125+
cursor: None,
126+
model_providers: Some(vec!["test-provider".to_string()]),
127+
})
128+
.await
129+
.expect("send listConversations filtered");
130+
let filter_resp: JSONRPCResponse = timeout(
131+
DEFAULT_READ_TIMEOUT,
132+
mcp.read_stream_until_response_message(RequestId::Integer(filter_req_id)),
133+
)
134+
.await
135+
.expect("listConversations filtered timeout")
136+
.expect("listConversations filtered resp");
137+
let ListConversationsResponse {
138+
items: filtered_items,
139+
next_cursor: filtered_next,
140+
} = to_response::<ListConversationsResponse>(filter_resp).expect("deserialize filtered");
141+
assert_eq!(filtered_items.len(), 1);
142+
assert_eq!(filtered_next, None);
143+
assert_eq!(filtered_items[0].preview, "Hello TP");
144+
assert_eq!(filtered_items[0].model_provider, "test-provider");
145+
146+
// Empty filter should include every session regardless of provider metadata.
147+
let unfiltered_req_id = mcp
148+
.send_list_conversations_request(ListConversationsParams {
149+
page_size: Some(10),
150+
cursor: None,
151+
model_providers: Some(Vec::new()),
152+
})
153+
.await
154+
.expect("send listConversations unfiltered");
155+
let unfiltered_resp: JSONRPCResponse = timeout(
156+
DEFAULT_READ_TIMEOUT,
157+
mcp.read_stream_until_response_message(RequestId::Integer(unfiltered_req_id)),
158+
)
159+
.await
160+
.expect("listConversations unfiltered timeout")
161+
.expect("listConversations unfiltered resp");
162+
let ListConversationsResponse {
163+
items: unfiltered_items,
164+
next_cursor: unfiltered_next,
165+
} = to_response::<ListConversationsResponse>(unfiltered_resp)
166+
.expect("deserialize unfiltered response");
167+
assert_eq!(unfiltered_items.len(), 4);
168+
assert!(unfiltered_next.is_none());
169+
170+
let empty_req_id = mcp
171+
.send_list_conversations_request(ListConversationsParams {
172+
page_size: Some(10),
173+
cursor: None,
174+
model_providers: Some(vec!["other".to_string()]),
175+
})
176+
.await
177+
.expect("send listConversations filtered empty");
178+
let empty_resp: JSONRPCResponse = timeout(
179+
DEFAULT_READ_TIMEOUT,
180+
mcp.read_stream_until_response_message(RequestId::Integer(empty_req_id)),
181+
)
182+
.await
183+
.expect("listConversations filtered empty timeout")
184+
.expect("listConversations filtered empty resp");
185+
let ListConversationsResponse {
186+
items: empty_items,
187+
next_cursor: empty_next,
188+
} = to_response::<ListConversationsResponse>(empty_resp).expect("deserialize filtered empty");
189+
assert!(empty_items.is_empty());
190+
assert!(empty_next.is_none());
103191

104192
// Now resume one of the sessions and expect a SessionConfigured notification and response.
105193
let resume_req_id = mcp
@@ -152,7 +240,13 @@ async fn test_list_and_resume_conversations() {
152240
assert!(!conversation_id.to_string().is_empty());
153241
}
154242

155-
fn create_fake_rollout(codex_home: &Path, filename_ts: &str, meta_rfc3339: &str, preview: &str) {
243+
fn create_fake_rollout(
244+
codex_home: &Path,
245+
filename_ts: &str,
246+
meta_rfc3339: &str,
247+
preview: &str,
248+
model_provider: Option<&str>,
249+
) {
156250
let uuid = Uuid::new_v4();
157251
// sessions/YYYY/MM/DD/ derived from filename_ts (YYYY-MM-DDThh-mm-ss)
158252
let year = &filename_ts[0..4];
@@ -164,18 +258,22 @@ fn create_fake_rollout(codex_home: &Path, filename_ts: &str, meta_rfc3339: &str,
164258
let file_path = dir.join(format!("rollout-{filename_ts}-{uuid}.jsonl"));
165259
let mut lines = Vec::new();
166260
// Meta line with timestamp (flattened meta in payload for new schema)
261+
let mut payload = json!({
262+
"id": uuid,
263+
"timestamp": meta_rfc3339,
264+
"cwd": "/",
265+
"originator": "codex",
266+
"cli_version": "0.0.0",
267+
"instructions": null,
268+
});
269+
if let Some(provider) = model_provider {
270+
payload["model_provider"] = json!(provider);
271+
}
167272
lines.push(
168273
json!({
169274
"timestamp": meta_rfc3339,
170275
"type": "session_meta",
171-
"payload": {
172-
"id": uuid,
173-
"timestamp": meta_rfc3339,
174-
"cwd": "/",
175-
"originator": "codex",
176-
"cli_version": "0.0.0",
177-
"instructions": null
178-
}
276+
"payload": payload
179277
})
180278
.to_string(),
181279
);

0 commit comments

Comments
 (0)