11use anyhow:: Result ;
22use app_test_support:: McpProcess ;
3+ use app_test_support:: create_fake_rollout;
4+ use app_test_support:: rollout_path;
35use app_test_support:: to_response;
46use codex_app_server_protocol:: AddConversationListenerParams ;
57use codex_app_server_protocol:: AddConversationSubscriptionResponse ;
@@ -9,18 +11,25 @@ use codex_app_server_protocol::JSONRPCResponse;
911use codex_app_server_protocol:: NewConversationParams ;
1012use codex_app_server_protocol:: NewConversationResponse ;
1113use codex_app_server_protocol:: RequestId ;
14+ use codex_app_server_protocol:: ResumeConversationParams ;
15+ use codex_app_server_protocol:: ResumeConversationResponse ;
1216use codex_app_server_protocol:: SendUserMessageParams ;
1317use codex_app_server_protocol:: SendUserMessageResponse ;
1418use codex_execpolicy:: Policy ;
1519use codex_protocol:: ThreadId ;
20+ use codex_protocol:: config_types:: ReasoningSummary ;
1621use codex_protocol:: models:: ContentItem ;
1722use codex_protocol:: models:: DeveloperInstructions ;
1823use codex_protocol:: models:: ResponseItem ;
1924use codex_protocol:: protocol:: AskForApproval ;
2025use codex_protocol:: protocol:: RawResponseItemEvent ;
26+ use codex_protocol:: protocol:: RolloutItem ;
27+ use codex_protocol:: protocol:: RolloutLine ;
2128use codex_protocol:: protocol:: SandboxPolicy ;
29+ use codex_protocol:: protocol:: TurnContextItem ;
2230use core_test_support:: responses;
2331use pretty_assertions:: assert_eq;
32+ use std:: io:: Write ;
2433use std:: path:: Path ;
2534use std:: path:: PathBuf ;
2635use tempfile:: TempDir ;
@@ -263,6 +272,114 @@ async fn test_send_message_session_not_found() -> Result<()> {
263272 Ok ( ( ) )
264273}
265274
275+ #[ tokio:: test]
276+ async fn resume_with_model_mismatch_appends_model_switch_once ( ) -> Result < ( ) > {
277+ let server = responses:: start_mock_server ( ) . await ;
278+ let response_mock = responses:: mount_sse_sequence (
279+ & server,
280+ vec ! [
281+ responses:: sse( vec![
282+ responses:: ev_response_created( "resp-1" ) ,
283+ responses:: ev_assistant_message( "msg-1" , "Done" ) ,
284+ responses:: ev_completed( "resp-1" ) ,
285+ ] ) ,
286+ responses:: sse( vec![
287+ responses:: ev_response_created( "resp-2" ) ,
288+ responses:: ev_assistant_message( "msg-2" , "Done again" ) ,
289+ responses:: ev_completed( "resp-2" ) ,
290+ ] ) ,
291+ ] ,
292+ )
293+ . await ;
294+
295+ let codex_home = TempDir :: new ( ) ?;
296+ create_config_toml ( codex_home. path ( ) , & server. uri ( ) ) ?;
297+
298+ let filename_ts = "2025-01-02T12-00-00" ;
299+ let meta_rfc3339 = "2025-01-02T12:00:00Z" ;
300+ let preview = "Resume me" ;
301+ let conversation_id = create_fake_rollout (
302+ codex_home. path ( ) ,
303+ filename_ts,
304+ meta_rfc3339,
305+ preview,
306+ Some ( "mock_provider" ) ,
307+ None ,
308+ ) ?;
309+ let rollout_path = rollout_path ( codex_home. path ( ) , filename_ts, & conversation_id) ;
310+ append_rollout_turn_context ( & rollout_path, meta_rfc3339, "previous-model" ) ?;
311+
312+ let mut mcp = McpProcess :: new ( codex_home. path ( ) ) . await ?;
313+ timeout ( DEFAULT_READ_TIMEOUT , mcp. initialize ( ) ) . await ??;
314+
315+ let resume_id = mcp
316+ . send_resume_conversation_request ( ResumeConversationParams {
317+ path : Some ( rollout_path. clone ( ) ) ,
318+ conversation_id : None ,
319+ history : None ,
320+ overrides : Some ( NewConversationParams {
321+ model : Some ( "gpt-5.2-codex" . to_string ( ) ) ,
322+ ..Default :: default ( )
323+ } ) ,
324+ } )
325+ . await ?;
326+ timeout (
327+ DEFAULT_READ_TIMEOUT ,
328+ mcp. read_stream_until_notification_message ( "sessionConfigured" ) ,
329+ )
330+ . await ??;
331+ let resume_resp: JSONRPCResponse = timeout (
332+ DEFAULT_READ_TIMEOUT ,
333+ mcp. read_stream_until_response_message ( RequestId :: Integer ( resume_id) ) ,
334+ )
335+ . await ??;
336+ let ResumeConversationResponse {
337+ conversation_id, ..
338+ } = to_response :: < ResumeConversationResponse > ( resume_resp) ?;
339+
340+ let add_listener_id = mcp
341+ . send_add_conversation_listener_request ( AddConversationListenerParams {
342+ conversation_id,
343+ experimental_raw_events : false ,
344+ } )
345+ . await ?;
346+ let add_listener_resp: JSONRPCResponse = timeout (
347+ DEFAULT_READ_TIMEOUT ,
348+ mcp. read_stream_until_response_message ( RequestId :: Integer ( add_listener_id) ) ,
349+ )
350+ . await ??;
351+ let AddConversationSubscriptionResponse { subscription_id : _ } =
352+ to_response :: < _ > ( add_listener_resp) ?;
353+
354+ send_message ( "hello after resume" , conversation_id, & mut mcp) . await ?;
355+ send_message ( "second turn" , conversation_id, & mut mcp) . await ?;
356+
357+ let requests = response_mock. requests ( ) ;
358+ assert_eq ! ( requests. len( ) , 2 , "expected two model requests" ) ;
359+
360+ let first_developer_texts = requests[ 0 ] . message_input_texts ( "developer" ) ;
361+ let first_model_switch_count = first_developer_texts
362+ . iter ( )
363+ . filter ( |text| text. contains ( "<model_switch>" ) )
364+ . count ( ) ;
365+ assert ! (
366+ first_model_switch_count >= 1 ,
367+ "expected model switch message on first post-resume turn, got {first_developer_texts:?}"
368+ ) ;
369+
370+ let second_developer_texts = requests[ 1 ] . message_input_texts ( "developer" ) ;
371+ let second_model_switch_count = second_developer_texts
372+ . iter ( )
373+ . filter ( |text| text. contains ( "<model_switch>" ) )
374+ . count ( ) ;
375+ assert_eq ! (
376+ second_model_switch_count, 1 ,
377+ "did not expect duplicate model switch message on second post-resume turn, got {second_developer_texts:?}"
378+ ) ;
379+
380+ Ok ( ( ) )
381+ }
382+
266383// ---------------------------------------------------------------------------
267384// Helpers
268385// ---------------------------------------------------------------------------
@@ -438,3 +555,28 @@ fn content_texts(content: &[ContentItem]) -> Vec<&str> {
438555 } )
439556 . collect ( )
440557}
558+
559+ fn append_rollout_turn_context ( path : & Path , timestamp : & str , model : & str ) -> std:: io:: Result < ( ) > {
560+ let line = RolloutLine {
561+ timestamp : timestamp. to_string ( ) ,
562+ item : RolloutItem :: TurnContext ( TurnContextItem {
563+ cwd : PathBuf :: from ( "/" ) ,
564+ approval_policy : AskForApproval :: Never ,
565+ sandbox_policy : SandboxPolicy :: DangerFullAccess ,
566+ model : model. to_string ( ) ,
567+ personality : None ,
568+ collaboration_mode : None ,
569+ effort : None ,
570+ summary : ReasoningSummary :: Auto ,
571+ user_instructions : None ,
572+ developer_instructions : None ,
573+ final_output_json_schema : None ,
574+ truncation_policy : None ,
575+ } ) ,
576+ } ;
577+ let serialized = serde_json:: to_string ( & line) . map_err ( std:: io:: Error :: other) ?;
578+ std:: fs:: OpenOptions :: new ( )
579+ . append ( true )
580+ . open ( path) ?
581+ . write_all ( format ! ( "{serialized}\n " ) . as_bytes ( ) )
582+ }
0 commit comments