Skip to content

Commit 472a9bf

Browse files
CodeWhale BotSparkofSpike
andcommitted
fix(session): derive titles from user prompts across save, import and live UI
Harvested from PR #6012 by @SparkofSpike Preserve the contributor's shared save/import title derivation and four regression tests from eeee8b9 and 14147f6. Resolve the test-helper insertion against current source without removing the existing session tests. Use the existing user-turn classifier for prompt eligibility and share raw prompt selection with the live title fallback. This also excludes generic runtime and legacy non-authoritative metadata while retaining the first image-only user boundary. Existing formatting, sanitization and rename priority remain in place; previously persisted bad titles are not migrated. Local validation: - Exact source-fragment probe: 21 passed, 0 failed; rustc -D warnings exit 0. - Baseline runtime-first preservation invariant: expected failure, exit 101. - cargo fmt --all -- --check and git diff --check: exit 0. - npm test and npm run check:web: each exit 1, missing package scripts. - Production crate compilation/tests: not run in this lane; integrator owns the coordinated full-link window. Four maintainer regressions added. Hosted PR-head CI is separate evidence: three OS test jobs passed; wrapper binary build ended with exit 143 and runner shutdown, smoke skipped. That failed gate is not a pass for this integrated donor. Co-authored-by: Sh1Zuku <125943630+SparkofSpike@users.noreply.github.com>
1 parent 5f3078d commit 472a9bf

3 files changed

Lines changed: 233 additions & 37 deletions

File tree

crates/tui/src/session_manager.rs

Lines changed: 217 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -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
33493370
pub 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>\nInput 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>\nSession 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";

crates/tui/src/tui/ui.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,6 @@ pub(crate) const UI_GHOSTTY_UNDERWATER_ANIMATION_MS: u64 = 34;
228228
// this constant once described no longer gates on it.)
229229
pub(crate) const FILE_TREE_MIN_HOST_WIDTH: u16 = 60;
230230
const DEFAULT_TERMINAL_PROBE_TIMEOUT_MS: u64 = 500;
231-
const TURN_META_PREFIX: &str = "<turn_meta>";
232231
const SESSION_TITLE_MAX_CHARS: usize = 32;
233232
const VERSION_HINT_TOAST_TTL_MS: u64 = 12_000;
234233

crates/tui/src/tui/ui/session_state.rs

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,14 +1054,7 @@ pub(crate) fn resolve_loaded_session_route(app: &mut App, config: &Config) {
10541054
///
10551055
/// Never leaks raw prompt text — the result is always a concise label.
10561056
pub(crate) fn derive_session_title(messages: &[Message]) -> Option<String> {
1057-
let text = messages.iter().find(|m| m.role == "user").and_then(|m| {
1058-
m.content.iter().find_map(|block| match block {
1059-
ContentBlock::Text { text, .. } if !text.starts_with(TURN_META_PREFIX) => {
1060-
Some(text.trim().to_string())
1061-
}
1062-
_ => None,
1063-
})
1064-
})?;
1057+
let text = crate::session_manager::conversation_title_prompt(messages)?;
10651058

10661059
let first_line =
10671060
crate::session_manager::sanitize_session_title(text.lines().next().unwrap_or("").trim());
@@ -1113,6 +1106,21 @@ mod derived_title_tests {
11131106
// Controls alone leave no title to derive.
11141107
assert_eq!(derive_session_title(&[user("\u{1b}\u{7}\u{200b}")]), None);
11151108
}
1109+
1110+
#[test]
1111+
fn live_title_uses_the_same_user_prompt_after_runtime_handoffs() {
1112+
let handoff = crate::runtime_handoff::operate_contract_runtime_message();
1113+
assert_eq!(derive_session_title(std::slice::from_ref(&handoff)), None);
1114+
let messages = [handoff, user("/goal Fix the diagnostic display")];
1115+
assert_eq!(
1116+
derive_session_title(&messages).as_deref(),
1117+
Some("/goal Fix")
1118+
);
1119+
assert_eq!(
1120+
crate::session_manager::conversation_title_prompt(&messages),
1121+
Some("/goal Fix the diagnostic display")
1122+
);
1123+
}
11161124
}
11171125

11181126
#[cfg(test)]

0 commit comments

Comments
 (0)