mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(sessions): extract meaningful titles for Codex and OpenClaw sessions
Previously Codex and OpenClaw sessions only showed the working directory basename as the title, making it hard to distinguish sessions in the same project. Now both providers extract the first real user message as the session title, matching the existing Claude Code behavior. - Codex: first user message → dir basename (skips AGENTS.md injection) - OpenClaw: displayName (sessions.json) → first user message → dir basename - Move TITLE_MAX_CHARS constant to shared utils.rs - Use Option<&HashMap> for OpenClaw parse_session to avoid leaky abstraction
This commit is contained in:
@@ -9,10 +9,10 @@ use crate::session_manager::{SessionMessage, SessionMeta};
|
||||
|
||||
use super::utils::{
|
||||
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
|
||||
TITLE_MAX_CHARS,
|
||||
};
|
||||
|
||||
const PROVIDER_ID: &str = "claude";
|
||||
const TITLE_MAX_CHARS: usize = 80;
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let root = get_claude_config_dir().join("projects");
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::session_manager::{SessionMessage, SessionMeta};
|
||||
|
||||
use super::utils::{
|
||||
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
|
||||
TITLE_MAX_CHARS,
|
||||
};
|
||||
|
||||
const PROVIDER_ID: &str = "codex";
|
||||
@@ -129,8 +130,9 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let mut session_id: Option<String> = None;
|
||||
let mut project_dir: Option<String> = None;
|
||||
let mut created_at: Option<i64> = None;
|
||||
let mut first_user_message: Option<String> = None;
|
||||
|
||||
// Extract metadata from head lines
|
||||
// Extract metadata and first user message from head lines
|
||||
for line in &head {
|
||||
let value: Value = match serde_json::from_str(line) {
|
||||
Ok(parsed) => parsed,
|
||||
@@ -158,6 +160,29 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Extract first user message as title candidate
|
||||
if first_user_message.is_none()
|
||||
&& value.get("type").and_then(Value::as_str) == Some("response_item")
|
||||
{
|
||||
if let Some(payload) = value.get("payload") {
|
||||
if payload.get("type").and_then(Value::as_str) == Some("message")
|
||||
&& payload.get("role").and_then(Value::as_str) == Some("user")
|
||||
{
|
||||
let text = payload.get("content").map(extract_text).unwrap_or_default();
|
||||
let trimmed = text.trim();
|
||||
if !trimmed.is_empty() && !trimmed.starts_with("# AGENTS.md") {
|
||||
first_user_message = Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if session_id.is_some()
|
||||
&& project_dir.is_some()
|
||||
&& created_at.is_some()
|
||||
&& first_user_message.is_some()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract last_active_at and summary from tail lines (reverse order)
|
||||
@@ -190,10 +215,14 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
|
||||
let session_id = session_id?;
|
||||
|
||||
let title = project_dir
|
||||
.as_deref()
|
||||
.and_then(path_basename)
|
||||
.map(|value| value.to_string());
|
||||
let title = first_user_message
|
||||
.map(|t| truncate_summary(&t, TITLE_MAX_CHARS))
|
||||
.or_else(|| {
|
||||
project_dir
|
||||
.as_deref()
|
||||
.and_then(path_basename)
|
||||
.map(|v| v.to_string())
|
||||
});
|
||||
|
||||
let summary = summary.map(|text| truncate_summary(&text, 160));
|
||||
|
||||
@@ -261,6 +290,82 @@ mod tests {
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_uses_first_user_message_as_title() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"How do I deploy?\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"Here is how...\"}}\n"
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path).unwrap();
|
||||
assert_eq!(meta.title.as_deref(), Some("How do I deploy?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_skips_agents_md_injection() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"developer\",\"content\":\"<permissions>\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# AGENTS.md instructions for /tmp/project\\n<INSTRUCTIONS>Do stuff</INSTRUCTIONS>\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"Fix the login bug\"}}\n"
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path).unwrap();
|
||||
// Should skip AGENTS.md injection and use the real user message
|
||||
assert_eq!(meta.title.as_deref(), Some("Fix the login bug"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_falls_back_to_dir_basename() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/my-project\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"Hello\"}}\n"
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path).unwrap();
|
||||
// No user message → falls back to dir basename
|
||||
assert_eq!(meta.title.as_deref(), Some("my-project"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_truncates_long_title() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session.jsonl");
|
||||
let long_msg = "a".repeat(200);
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"{{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"test-id\",\"cwd\":\"/tmp/p\"}}}}\n\
|
||||
{{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"user\",\"content\":\"{long_msg}\"}}}}\n",
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path).unwrap();
|
||||
let title = meta.title.unwrap();
|
||||
assert!(title.len() <= TITLE_MAX_CHARS + 3); // +3 for "..."
|
||||
assert!(title.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_messages_includes_function_call_and_output() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::Path;
|
||||
@@ -12,6 +13,7 @@ use crate::{
|
||||
|
||||
use super::utils::{
|
||||
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
|
||||
TITLE_MAX_CHARS,
|
||||
};
|
||||
|
||||
const PROVIDER_ID: &str = "openclaw";
|
||||
@@ -46,22 +48,15 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let display_names = load_display_names(&sessions_dir);
|
||||
|
||||
for entry in session_entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
|
||||
continue;
|
||||
}
|
||||
// Skip sessions.json index file
|
||||
if path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n == "sessions.json")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(meta) = parse_session(&path) {
|
||||
if let Some(meta) = parse_session(&path, Some(&display_names)) {
|
||||
sessions.push(meta);
|
||||
}
|
||||
}
|
||||
@@ -119,7 +114,7 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
}
|
||||
|
||||
pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
|
||||
let meta = parse_session(path).ok_or_else(|| {
|
||||
let meta = parse_session(path, None).ok_or_else(|| {
|
||||
format!(
|
||||
"Failed to parse OpenClaw session metadata: {}",
|
||||
path.display()
|
||||
@@ -149,15 +144,43 @@ pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<boo
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
/// Read `sessions.json` index and build a sessionId → displayName lookup map.
|
||||
/// Returns an empty map if the file does not exist or cannot be parsed.
|
||||
fn load_display_names(sessions_dir: &Path) -> HashMap<String, String> {
|
||||
let index_path = sessions_dir.join("sessions.json");
|
||||
let content = match std::fs::read_to_string(&index_path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return HashMap::new(),
|
||||
};
|
||||
let index: serde_json::Map<String, Value> = match serde_json::from_str(&content) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return HashMap::new(),
|
||||
};
|
||||
|
||||
let mut map = HashMap::new();
|
||||
for (_key, entry) in &index {
|
||||
if let (Some(id), Some(name)) = (
|
||||
entry.get("sessionId").and_then(Value::as_str),
|
||||
entry.get("displayName").and_then(Value::as_str),
|
||||
) {
|
||||
if !name.is_empty() {
|
||||
map.insert(id.to_string(), name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path, display_names: Option<&HashMap<String, String>>) -> Option<SessionMeta> {
|
||||
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
|
||||
|
||||
let mut session_id: Option<String> = None;
|
||||
let mut cwd: Option<String> = None;
|
||||
let mut created_at: Option<i64> = None;
|
||||
let mut summary: Option<String> = None;
|
||||
let mut first_user_message: Option<String> = None;
|
||||
|
||||
// Extract metadata and first message summary from head lines
|
||||
// Extract metadata, summary, and first user message from head lines
|
||||
for line in &head {
|
||||
let value: Value = match serde_json::from_str(line) {
|
||||
Ok(parsed) => parsed,
|
||||
@@ -189,15 +212,30 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// OpenClaw summary is the first message content
|
||||
if event_type == "message" && summary.is_none() {
|
||||
if event_type == "message" {
|
||||
if let Some(message) = value.get("message") {
|
||||
let text = message.get("content").map(extract_text).unwrap_or_default();
|
||||
if !text.trim().is_empty() {
|
||||
summary = Some(text);
|
||||
if first_user_message.is_none()
|
||||
&& message.get("role").and_then(Value::as_str) == Some("user")
|
||||
{
|
||||
first_user_message = Some(text.trim().to_string());
|
||||
}
|
||||
if summary.is_none() {
|
||||
summary = Some(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if session_id.is_some()
|
||||
&& cwd.is_some()
|
||||
&& created_at.is_some()
|
||||
&& summary.is_some()
|
||||
&& first_user_message.is_some()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract last_active_at from tail lines (reverse order)
|
||||
@@ -221,10 +259,17 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
});
|
||||
let session_id = session_id?;
|
||||
|
||||
let title = cwd
|
||||
.as_deref()
|
||||
.and_then(path_basename)
|
||||
.map(|s| s.to_string());
|
||||
// Title priority: displayName (from sessions.json) > first user message > dir basename
|
||||
let title = display_names
|
||||
.and_then(|m| m.get(&session_id))
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|t| truncate_summary(t, TITLE_MAX_CHARS))
|
||||
.or_else(|| first_user_message.map(|t| truncate_summary(&t, TITLE_MAX_CHARS)))
|
||||
.or_else(|| {
|
||||
cwd.as_deref()
|
||||
.and_then(path_basename)
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
let summary = summary.map(|text| truncate_summary(&text, 160));
|
||||
|
||||
@@ -284,6 +329,93 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn parse_session_uses_first_user_message_as_title() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session-abc.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"type\":\"session\",\"id\":\"session-abc\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
|
||||
"{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"How do I deploy?\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n",
|
||||
"{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":\"Here is how...\"},\"timestamp\":\"2026-03-06T10:02:00Z\"}\n"
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path, None).unwrap();
|
||||
assert_eq!(meta.title.as_deref(), Some("How do I deploy?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_display_name_overrides_user_message() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let sessions_dir = temp.path();
|
||||
|
||||
let path = sessions_dir.join("session-abc.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"type\":\"session\",\"id\":\"session-abc\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
|
||||
"{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"fix something\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n"
|
||||
),
|
||||
)
|
||||
.expect("write session");
|
||||
|
||||
std::fs::write(
|
||||
sessions_dir.join("sessions.json"),
|
||||
r#"{
|
||||
"agent:main:main": {
|
||||
"sessionId": "session-abc",
|
||||
"displayName": "重构登录模块"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write index");
|
||||
|
||||
let display_names = load_display_names(sessions_dir);
|
||||
let meta = parse_session(&path, Some(&display_names)).unwrap();
|
||||
assert_eq!(meta.title.as_deref(), Some("重构登录模块"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_falls_back_to_dir_basename() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session-def.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"type\":\"session\",\"id\":\"session-def\",\"cwd\":\"/tmp/my-project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
|
||||
"{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":\"Hello\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n"
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path, None).unwrap();
|
||||
// No user message and no displayName → falls back to dir basename
|
||||
assert_eq!(meta.title.as_deref(), Some("my-project"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_truncates_long_title() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session-trunc.jsonl");
|
||||
let long_msg = "a".repeat(200);
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"{{\"type\":\"session\",\"id\":\"session-trunc\",\"cwd\":\"/tmp/p\",\"timestamp\":\"2026-03-06T10:00:00Z\"}}\n\
|
||||
{{\"type\":\"message\",\"message\":{{\"role\":\"user\",\"content\":\"{long_msg}\"}},\"timestamp\":\"2026-03-06T10:01:00Z\"}}\n",
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path, None).unwrap();
|
||||
let title = meta.title.unwrap();
|
||||
assert!(title.len() <= TITLE_MAX_CHARS + 3); // +3 for "..."
|
||||
assert!(title.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_session_updates_index_and_removes_jsonl() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
|
||||
@@ -5,6 +5,9 @@ use std::path::Path;
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Maximum number of characters for session titles (shared across providers).
|
||||
pub const TITLE_MAX_CHARS: usize = 80;
|
||||
|
||||
/// Read the first `head_n` lines and last `tail_n` lines from a file.
|
||||
/// For small files (< 16 KB), reads all lines once to avoid unnecessary seeking.
|
||||
pub fn read_head_tail_lines(
|
||||
|
||||
Reference in New Issue
Block a user