Files
CC-Switch/src-tauri/src/session_manager/providers/claude.rs
T
BlueOcean bd3cfb7741 fix: parse tool_use/tool_result messages and add OpenCode SQLite backend (#1401)
* fix: parse tool_use/tool_result messages and add OpenCode SQLite backend

  - Claude: reclassify user messages containing tool_result as "tool" role
  - Codex: handle function_call and function_call_output payload types
  - Gemini: support array content and toolCalls extraction, filter info/error types
  - OpenCode: add SQLite session scan, load and delete alongside legacy JSON
  - utils: extend parse_timestamp_to_ms for integer timestamps, extract tool_use/tool_result in shared extract_text

* fix: address remaining issues from tool_use/tool_result parsing commit
  - Claude: fix role misclassification for mixed user+tool_result messages (any → all)
  - OpenCode: extract duplicate part text logic into extract_part_text()
  - OpenCode: add path validation for SQLite delete to prevent foreign DB access
  - OpenCode: wrap SQLite deletion in transaction for atomicity
  - openclaw_config: remove redundant as_deref() on Option<&str>
2026-03-22 22:02:35 +08:00

340 lines
11 KiB
Rust

use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use serde_json::Value;
use crate::config::get_claude_config_dir;
use crate::session_manager::{SessionMessage, SessionMeta};
use super::utils::{
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
};
const PROVIDER_ID: &str = "claude";
pub fn scan_sessions() -> Vec<SessionMeta> {
let root = get_claude_config_dir().join("projects");
let mut files = Vec::new();
collect_jsonl_files(&root, &mut files);
let mut sessions = Vec::new();
for path in files {
if let Some(meta) = parse_session(&path) {
sessions.push(meta);
}
}
sessions
}
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
let file = File::open(path).map_err(|e| format!("Failed to open session file: {e}"))?;
let reader = BufReader::new(file);
let mut messages = Vec::new();
for line in reader.lines() {
let line = match line {
Ok(value) => value,
Err(_) => continue,
};
let value: Value = match serde_json::from_str(&line) {
Ok(parsed) => parsed,
Err(_) => continue,
};
if value.get("isMeta").and_then(Value::as_bool) == Some(true) {
continue;
}
let message = match value.get("message") {
Some(message) => message,
None => continue,
};
let mut role = message
.get("role")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
// Claude wraps tool_result inside user messages; reclassify as "tool" role
if role == "user" {
if let Some(Value::Array(items)) = message.get("content") {
let all_tool_results = !items.is_empty()
&& items.iter().all(|item| {
item.get("type").and_then(Value::as_str) == Some("tool_result")
});
if all_tool_results {
role = "tool".to_string();
}
}
}
let content = message.get("content").map(extract_text).unwrap_or_default();
if content.trim().is_empty() {
continue;
}
let ts = value.get("timestamp").and_then(parse_timestamp_to_ms);
messages.push(SessionMessage { role, content, ts });
}
Ok(messages)
}
pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
let meta = parse_session(path).ok_or_else(|| {
format!(
"Failed to parse Claude session metadata: {}",
path.display()
)
})?;
if meta.session_id != session_id {
return Err(format!(
"Claude session ID mismatch: expected {session_id}, found {}",
meta.session_id
));
}
if let Some(stem) = path.file_stem() {
let sibling = path.parent().unwrap_or_else(|| Path::new("")).join(stem);
remove_path_if_exists(&sibling).map_err(|e| {
format!(
"Failed to delete Claude session sidecar {}: {e}",
sibling.display()
)
})?;
}
std::fs::remove_file(path).map_err(|e| {
format!(
"Failed to delete Claude session file {}: {e}",
path.display()
)
})?;
Ok(true)
}
fn parse_session(path: &Path) -> Option<SessionMeta> {
if is_agent_session(path) {
return None;
}
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
let mut session_id: Option<String> = None;
let mut project_dir: Option<String> = None;
let mut created_at: Option<i64> = None;
// Extract metadata from head lines
for line in &head {
let value: Value = match serde_json::from_str(line) {
Ok(parsed) => parsed,
Err(_) => continue,
};
if session_id.is_none() {
session_id = value
.get("sessionId")
.and_then(Value::as_str)
.map(|s| s.to_string());
}
if project_dir.is_none() {
project_dir = value
.get("cwd")
.and_then(Value::as_str)
.map(|s| s.to_string());
}
if created_at.is_none() {
created_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
}
}
// Extract last_active_at and summary from tail lines (reverse order)
let mut last_active_at: Option<i64> = None;
let mut summary: Option<String> = None;
for line in tail.iter().rev() {
let value: Value = match serde_json::from_str(line) {
Ok(parsed) => parsed,
Err(_) => continue,
};
if last_active_at.is_none() {
last_active_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
}
if summary.is_none() {
if value.get("isMeta").and_then(Value::as_bool) == Some(true) {
continue;
}
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 last_active_at.is_some() && summary.is_some() {
break;
}
}
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 summary = summary.map(|text| truncate_summary(&text, 160));
Some(SessionMeta {
provider_id: PROVIDER_ID.to_string(),
session_id: session_id.clone(),
title,
summary,
project_dir,
created_at,
last_active_at,
source_path: Some(path.to_string_lossy().to_string()),
resume_command: Some(format!("claude --resume {session_id}")),
})
}
fn is_agent_session(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.map(|name| name.starts_with("agent-"))
.unwrap_or(false)
}
fn infer_session_id_from_filename(path: &Path) -> Option<String> {
path.file_stem()
.and_then(|stem| stem.to_str())
.map(|stem| stem.to_string())
}
fn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {
if !root.exists() {
return;
}
let entries = match std::fs::read_dir(root) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_jsonl_files(&path, files);
} else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") {
files.push(path);
}
}
}
fn remove_path_if_exists(path: &Path) -> std::io::Result<()> {
match std::fs::metadata(path) {
Ok(meta) => {
if meta.is_dir() {
std::fs::remove_dir_all(path)
} else {
std::fs::remove_file(path)
}
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn delete_session_removes_main_file_and_sidecar_directory() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("abc123-session.jsonl");
let sidecar = temp.path().join("abc123-session");
let subagents = sidecar.join("subagents");
let tool_results = sidecar.join("tool-results");
std::fs::create_dir_all(&subagents).expect("create subagents");
std::fs::create_dir_all(&tool_results).expect("create tool-results");
std::fs::write(subagents.join("agent-1.jsonl"), "{}").expect("write subagent");
std::fs::write(tool_results.join("tool-1.txt"), "result").expect("write tool result");
std::fs::write(
&path,
concat!(
"{\"sessionId\":\"session-123\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
"{\"message\":{\"role\":\"user\",\"content\":\"hello\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n"
),
)
.expect("write session");
delete_session(temp.path(), &path, "session-123").expect("delete session");
assert!(!path.exists());
assert!(!sidecar.exists());
}
#[test]
fn load_messages_tool_use_shows_as_assistant() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session.jsonl");
std::fs::write(
&path,
concat!(
"{\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"Write\",\"input\":{\"file_path\":\"a.txt\"}}]},\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
"{\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_1\",\"content\":\"File written\"}]},\"timestamp\":\"2026-03-06T10:00:01Z\"}\n",
),
)
.expect("write");
let msgs = load_messages(&path).expect("load");
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].role, "assistant");
assert!(msgs[0].content.contains("[Tool: Write]"));
assert_eq!(msgs[1].role, "tool");
assert_eq!(msgs[1].content, "File written");
}
#[test]
fn load_messages_mixed_text_and_tool_use() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session.jsonl");
std::fs::write(
&path,
"{\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Let me help.\"},{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"Read\",\"input\":{}}]},\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
)
.expect("write");
let msgs = load_messages(&path).expect("load");
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].role, "assistant");
assert!(msgs[0].content.contains("Let me help."));
assert!(msgs[0].content.contains("[Tool: Read]"));
}
#[test]
fn load_messages_mixed_user_tool_result_and_text_stays_user() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session.jsonl");
std::fs::write(
&path,
"{\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_1\",\"content\":\"result\"},{\"type\":\"text\",\"text\":\"Please continue\"}]},\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
)
.expect("write");
let msgs = load_messages(&path).expect("load");
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].role, "user");
assert!(msgs[0].content.contains("Please continue"));
}
}