@@ -1105,16 +1105,10 @@ impl SavedSession {
11051105 let messages = journal. to_messages ( ) ;
11061106 let now = Utc :: now ( ) ;
11071107 let spawn_depth = journal. spawn_depth . saturating_add ( 1 ) ;
1108- let title = messages
1109- . iter ( )
1110- . find ( |m| m. role == "user" )
1111- . and_then ( |m| {
1112- m. content . iter ( ) . find_map ( |b| match b {
1113- ContentBlock :: Text { text, .. } => Some ( text. as_str ( ) ) ,
1114- _ => None ,
1115- } )
1116- } )
1117- . map ( |s| crate :: session_manager:: truncate_title ( s, 50 ) )
1108+ // Reuse the conversation-derived title so an imported session that
1109+ // opens with runtime-owned control traffic (Operate contract, restore
1110+ // checkpoint) is named after the real prompt, not the envelope.
1111+ let title = conversation_derived_title ( & messages)
11181112 . unwrap_or_else ( || crate :: session_manager:: DEFAULT_SESSION_TITLE . to_string ( ) ) ;
11191113 let metadata = SessionMetadata {
11201114 id : Uuid :: new_v4 ( ) . to_string ( ) ,
@@ -3027,24 +3021,11 @@ pub fn create_saved_session_with_id_and_mode(
30273021) -> SavedSession {
30283022 let now = Utc :: now ( ) ;
30293023
3030- // Generate title from first user message
3031- let title = messages
3032- . iter ( )
3033- . find ( |m| m. role == "user" )
3034- . and_then ( |m| {
3035- m. content . iter ( ) . find_map ( |block| match block {
3036- ContentBlock :: Text { text, .. } => {
3037- let prompt = extract_user_prompt ( text) ;
3038- if prompt. is_empty ( ) {
3039- None
3040- } else {
3041- Some ( truncate_title ( prompt, 50 ) )
3042- }
3043- }
3044- _ => None ,
3045- } )
3046- } )
3047- . unwrap_or_else ( || DEFAULT_SESSION_TITLE . to_string ( ) ) ;
3024+ // Generate title from the first real user message (runtime-owned control
3025+ // traffic is skipped by `conversation_derived_title`). Fall back to the
3026+ // placeholder when no user-authored prompt exists yet.
3027+ let title =
3028+ conversation_derived_title ( messages) . unwrap_or_else ( || DEFAULT_SESSION_TITLE . to_string ( ) ) ;
30483029
30493030 let journal = SessionJournal :: from_messages ( messages. to_vec ( ) , 0 ) ;
30503031 let leaf_id = journal. leaf_id . clone ( ) ;
@@ -3345,6 +3326,46 @@ fn truncate_title(s: &str, max_len: usize) -> String {
33453326 }
33463327}
33473328
3329+ /// Derive the auto-title from the first real user message of a conversation.
3330+ ///
3331+ /// Returns `None` when no user-authored message exists to name the session
3332+ /// after (an empty transcript, or one holding only runtime-owned control
3333+ /// traffic); callers fall back to [`DEFAULT_SESSION_TITLE`].
3334+ ///
3335+ /// Chat-template compatibility forces runtime-owned control traffic
3336+ /// (sub-agent handoffs, the Operate contract, restore checkpoints) through
3337+ /// `role = "user"`, but an internal envelope is not what the person typed.
3338+ /// Prompt eligibility comes from the existing user-turn classifier; the live
3339+ /// title fallback shares the same selection through `conversation_title_prompt`.
3340+ fn conversation_derived_title ( messages : & [ Message ] ) -> Option < String > {
3341+ conversation_title_prompt ( messages) . map ( |prompt| truncate_title ( prompt, 50 ) )
3342+ }
3343+
3344+ /// Select the first real user turn's text for persisted and live titles.
3345+ /// Keep an image-only turn as the first user boundary, and strip historical
3346+ /// leading turn metadata without introducing another provenance classifier.
3347+ pub ( crate ) fn conversation_title_prompt ( messages : & [ Message ] ) -> Option < & str > {
3348+ messages
3349+ . iter ( )
3350+ . find ( |message| {
3351+ crate :: runtime_handoff:: classify_user_turn_prompt ( message)
3352+ != crate :: runtime_handoff:: UserTurnPromptKind :: NotPrompt
3353+ } )
3354+ . and_then ( |m| {
3355+ m. content . iter ( ) . find_map ( |block| match block {
3356+ ContentBlock :: Text { text, .. } => {
3357+ let prompt = extract_user_prompt ( text) ;
3358+ if prompt. is_empty ( ) {
3359+ None
3360+ } else {
3361+ Some ( prompt)
3362+ }
3363+ }
3364+ _ => None ,
3365+ } )
3366+ } )
3367+ }
3368+
33483369/// Format a session for display in a picker
33493370pub fn format_session_line ( meta : & SessionMetadata ) -> String {
33503371 let age = format_age ( & meta. updated_at ) ;
@@ -4334,6 +4355,11 @@ mod tests {
43344355 ) ;
43354356 }
43364357
4358+ fn container_with ( messages : Vec < Message > , dir : & std:: path:: Path ) -> SessionImportContainer {
4359+ let session = create_saved_session ( & messages, "test-model" , dir, 100 , None ) ;
4360+ session. export_container ( "test-session.json" )
4361+ }
4362+
43374363 #[ test]
43384364 fn session_goal_sidecar_round_trips_control_state_without_model_output ( ) {
43394365 let tmp = tempdir ( ) . expect ( "tempdir" ) ;
@@ -5698,6 +5724,169 @@ mod tests {
56985724 ) ;
56995725 }
57005726
5727+ #[ test]
5728+ fn create_saved_session_skips_runtime_handoffs_when_deriving_title ( ) {
5729+ let tmp = tempdir ( ) . expect ( "tempdir" ) ;
5730+ // Operate/automation sessions start with runtime-owned control traffic
5731+ // as the first `user` message. The auto-title must come from the real
5732+ // prompt that follows, never from the internal envelope.
5733+ let messages = vec ! [
5734+ crate :: runtime_handoff:: operate_contract_runtime_message( ) ,
5735+ make_test_message( "user" , "Ship the session-title fix" ) ,
5736+ ] ;
5737+ let session = create_saved_session ( & messages, "test-model" , tmp. path ( ) , 100 , None ) ;
5738+ assert_eq ! ( session. metadata. title, "Ship the session-title fix" ) ;
5739+ assert ! (
5740+ !session. metadata. title. contains( "codewhale:runtime" ) ,
5741+ "internal envelope leaked into the session title: {}" ,
5742+ session. metadata. title
5743+ ) ;
5744+ }
5745+
5746+ #[ test]
5747+ fn create_saved_session_with_only_runtime_traffic_keeps_placeholder_title ( ) {
5748+ let tmp = tempdir ( ) . expect ( "tempdir" ) ;
5749+ let waiting = crate :: runtime_handoff:: waiting_for_subagents_runtime_message ( 2 ) ;
5750+ let restored =
5751+ crate :: runtime_handoff:: project_messages_for_restore ( std:: slice:: from_ref ( & waiting) )
5752+ . into_iter ( )
5753+ . next ( )
5754+ . expect ( "restore projection yields one message" ) ;
5755+ // Runtime handoffs must stay out of the auto-title. Exercise the
5756+ // Operate contract, a waiting/restored
5757+ // sub-agent checkpoint, and a background shell completion.
5758+ let messages = vec ! [
5759+ crate :: runtime_handoff:: operate_contract_runtime_message( ) ,
5760+ waiting,
5761+ restored,
5762+ crate :: runtime_handoff:: shell_completion_runtime_message( & [ ] ) ,
5763+ ] ;
5764+ let session = create_saved_session ( & messages, "test-model" , tmp. path ( ) , 100 , None ) ;
5765+ assert_eq ! ( session. metadata. title, DEFAULT_SESSION_TITLE ) ;
5766+ assert ! (
5767+ !session. metadata. title. contains( "codewhale:runtime" ) ,
5768+ "internal envelope leaked into the session title: {}" ,
5769+ session. metadata. title
5770+ ) ;
5771+ }
5772+
5773+ #[ test]
5774+ fn import_foreign_derives_title_from_the_first_real_user_message ( ) {
5775+ let tmp = tempdir ( ) . expect ( "tempdir" ) ;
5776+ // Importing a session whose transcript opens with the Operate contract
5777+ // (the shape this bug produced on export) must not re-derive the
5778+ // envelope as the imported title.
5779+ let container = container_with (
5780+ vec ! [
5781+ crate :: runtime_handoff:: operate_contract_runtime_message( ) ,
5782+ make_test_message( "user" , "Fix the session picker" ) ,
5783+ ] ,
5784+ tmp. path ( ) ,
5785+ ) ;
5786+ let imported = crate :: session_manager:: SavedSession :: import_foreign (
5787+ container,
5788+ tmp. path ( ) . to_path_buf ( ) ,
5789+ "test-model" . to_string ( ) ,
5790+ )
5791+ . expect ( "import succeeds" ) ;
5792+ assert_eq ! ( imported. metadata. title, "Fix the session picker" ) ;
5793+ assert ! (
5794+ !imported. metadata. title. contains( "codewhale:runtime" ) ,
5795+ "internal envelope leaked into the imported session title: {}" ,
5796+ imported. metadata. title
5797+ ) ;
5798+ }
5799+
5800+ #[ test]
5801+ fn import_foreign_keeps_placeholder_when_only_runtime_traffic ( ) {
5802+ let tmp = tempdir ( ) . expect ( "tempdir" ) ;
5803+ let container = container_with (
5804+ vec ! [ crate :: runtime_handoff:: operate_contract_runtime_message( ) ] ,
5805+ tmp. path ( ) ,
5806+ ) ;
5807+ let imported = crate :: session_manager:: SavedSession :: import_foreign (
5808+ container,
5809+ tmp. path ( ) . to_path_buf ( ) ,
5810+ "test-model" . to_string ( ) ,
5811+ )
5812+ . expect ( "import succeeds" ) ;
5813+ assert_eq ! ( imported. metadata. title, DEFAULT_SESSION_TITLE ) ;
5814+ }
5815+
5816+ #[ test]
5817+ fn title_derivation_skips_current_and_legacy_runtime_provenance ( ) {
5818+ let tmp = tempdir ( ) . expect ( "tempdir" ) ;
5819+ for leading_metadata in [ false , true ] {
5820+ let mut runtime = make_test_message ( "user" , "Internal diagnostic update" ) ;
5821+ let metadata = ContentBlock :: Text {
5822+ text : "<turn_meta>\n Input provenance: runtime (non-authoritative)\n </turn_meta>"
5823+ . to_string ( ) ,
5824+ cache_control : None ,
5825+ } ;
5826+ if leading_metadata {
5827+ runtime. content . insert ( 0 , metadata) ;
5828+ } else {
5829+ runtime. content . push ( metadata) ;
5830+ }
5831+ let messages = vec ! [
5832+ runtime,
5833+ make_test_message( "user" , "Fix the diagnostic display" ) ,
5834+ ] ;
5835+ let session = create_saved_session ( & messages, "test-model" , tmp. path ( ) , 0 , None ) ;
5836+ assert_eq ! ( session. metadata. title, "Fix the diagnostic display" ) ;
5837+ let imported = SavedSession :: import_foreign (
5838+ container_with ( messages, tmp. path ( ) ) ,
5839+ tmp. path ( ) . to_path_buf ( ) ,
5840+ "test-model" . to_string ( ) ,
5841+ )
5842+ . expect ( "import succeeds" ) ;
5843+ assert_eq ! ( imported. metadata. title, "Fix the diagnostic display" ) ;
5844+ }
5845+ }
5846+
5847+ #[ test]
5848+ fn title_derivation_keeps_user_authored_runtime_example ( ) {
5849+ let tmp = tempdir ( ) . expect ( "tempdir" ) ;
5850+ let messages = vec ! [ make_test_message(
5851+ "user" ,
5852+ "<codewhale:runtime_event> example" ,
5853+ ) ] ;
5854+ let session = create_saved_session ( & messages, "test-model" , tmp. path ( ) , 0 , None ) ;
5855+ assert_eq ! ( session. metadata. title, "<codewhale:runtime_event> example" ) ;
5856+ }
5857+
5858+ #[ test]
5859+ fn title_derivation_keeps_the_first_image_only_user_boundary ( ) {
5860+ let tmp = tempdir ( ) . expect ( "tempdir" ) ;
5861+ for with_metadata in [ false , true ] {
5862+ let mut first = Message {
5863+ role : Role :: User ,
5864+ content : vec ! [ ContentBlock :: ImageUrl {
5865+ image_url: crate :: models:: ImageUrlContent {
5866+ url: "data:image/png;base64,AAAA" . to_string( ) ,
5867+ } ,
5868+ } ] ,
5869+ } ;
5870+ if with_metadata {
5871+ first. content . push ( ContentBlock :: Text {
5872+ text : "<turn_meta>\n Session mode: Work\n </turn_meta>" . to_string ( ) ,
5873+ cache_control : None ,
5874+ } ) ;
5875+ }
5876+ let messages = vec ! [ first, make_test_message( "user" , "A later request" ) ] ;
5877+ assert_eq ! ( conversation_title_prompt( & messages) , None ) ;
5878+ let session = create_saved_session ( & messages, "test-model" , tmp. path ( ) , 0 , None ) ;
5879+ assert_eq ! ( session. metadata. title, DEFAULT_SESSION_TITLE ) ;
5880+ let imported = SavedSession :: import_foreign (
5881+ container_with ( messages, tmp. path ( ) ) ,
5882+ tmp. path ( ) . to_path_buf ( ) ,
5883+ "test-model" . to_string ( ) ,
5884+ )
5885+ . expect ( "import succeeds" ) ;
5886+ assert_eq ! ( imported. metadata. title, DEFAULT_SESSION_TITLE ) ;
5887+ }
5888+ }
5889+
57015890 #[ test]
57025891 fn strip_thinking_tags_removes_common_inline_blocks ( ) {
57035892 let text = "Before <think>private</think> middle <reasoning>hidden</reasoning> after" ;
0 commit comments