mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 23:56:02 +08:00
bd3cfb7741
* 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>
296 lines
9.9 KiB
Rust
296 lines
9.9 KiB
Rust
use std::fs::File;
|
|
use std::io::{BufRead, BufReader};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::LazyLock;
|
|
|
|
use regex::Regex;
|
|
use serde_json::Value;
|
|
|
|
use crate::codex_config::get_codex_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 = "codex";
|
|
|
|
static UUID_RE: LazyLock<Regex> = LazyLock::new(|| {
|
|
Regex::new(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
|
|
.unwrap()
|
|
});
|
|
|
|
pub fn scan_sessions() -> Vec<SessionMeta> {
|
|
let root = get_codex_config_dir().join("sessions");
|
|
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("type").and_then(Value::as_str) != Some("response_item") {
|
|
continue;
|
|
}
|
|
|
|
let payload = match value.get("payload") {
|
|
Some(payload) => payload,
|
|
None => continue,
|
|
};
|
|
|
|
let payload_type = payload.get("type").and_then(Value::as_str).unwrap_or("");
|
|
|
|
// Codex uses separate payload types for tool interactions
|
|
let (role, content) = match payload_type {
|
|
"message" => {
|
|
let role = payload
|
|
.get("role")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("unknown")
|
|
.to_string();
|
|
let content = payload.get("content").map(extract_text).unwrap_or_default();
|
|
(role, content)
|
|
}
|
|
"function_call" => {
|
|
let name = payload
|
|
.get("name")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("unknown");
|
|
("assistant".to_string(), format!("[Tool: {name}]"))
|
|
}
|
|
"function_call_output" => {
|
|
let output = payload
|
|
.get("output")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_string();
|
|
("tool".to_string(), output)
|
|
}
|
|
_ => continue,
|
|
};
|
|
|
|
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 Codex session metadata: {}", path.display()))?;
|
|
|
|
if meta.session_id != session_id {
|
|
return Err(format!(
|
|
"Codex session ID mismatch: expected {session_id}, found {}",
|
|
meta.session_id
|
|
));
|
|
}
|
|
|
|
std::fs::remove_file(path).map_err(|e| {
|
|
format!(
|
|
"Failed to delete Codex session file {}: {e}",
|
|
path.display()
|
|
)
|
|
})?;
|
|
|
|
Ok(true)
|
|
}
|
|
|
|
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
|
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 created_at.is_none() {
|
|
created_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
|
}
|
|
if value.get("type").and_then(Value::as_str) == Some("session_meta") {
|
|
if let Some(payload) = value.get("payload") {
|
|
if session_id.is_none() {
|
|
session_id = payload
|
|
.get("id")
|
|
.and_then(Value::as_str)
|
|
.map(|s| s.to_string());
|
|
}
|
|
if project_dir.is_none() {
|
|
project_dir = payload
|
|
.get("cwd")
|
|
.and_then(Value::as_str)
|
|
.map(|s| s.to_string());
|
|
}
|
|
if let Some(ts) = payload.get("timestamp").and_then(parse_timestamp_to_ms) {
|
|
created_at.get_or_insert(ts);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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() && 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") {
|
|
let text = payload.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!("codex resume {session_id}")),
|
|
})
|
|
}
|
|
|
|
fn infer_session_id_from_filename(path: &Path) -> Option<String> {
|
|
let file_name = path.file_name()?.to_string_lossy();
|
|
UUID_RE.find(&file_name).map(|mat| mat.as_str().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);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn delete_session_removes_jsonl_file() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let path = temp
|
|
.path()
|
|
.join("rollout-2026-03-06T21-50-12-019cc369-bd7c-7891-b371-7b20b4fe0b18.jsonl");
|
|
std::fs::write(
|
|
&path,
|
|
concat!(
|
|
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"019cc369-bd7c-7891-b371-7b20b4fe0b18\",\"cwd\":\"/tmp/project\"}}\n",
|
|
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"hello\"}}\n"
|
|
),
|
|
)
|
|
.expect("write session");
|
|
|
|
delete_session(temp.path(), &path, "019cc369-bd7c-7891-b371-7b20b4fe0b18")
|
|
.expect("delete session");
|
|
|
|
assert!(!path.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn load_messages_includes_function_call_and_output() {
|
|
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\"}}\n",
|
|
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"list files\"}}\n",
|
|
"{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"function_call\",\"name\":\"shell\",\"arguments\":\"{\\\"cmd\\\":[\\\"ls\\\"]}\",\"call_id\":\"call_1\"}}\n",
|
|
"{\"timestamp\":\"2026-03-06T21:50:15Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"function_call_output\",\"call_id\":\"call_1\",\"output\":\"file1.txt\\nfile2.txt\"}}\n",
|
|
"{\"timestamp\":\"2026-03-06T21:50:16Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Done.\"}]}}\n",
|
|
),
|
|
)
|
|
.expect("write");
|
|
|
|
let msgs = load_messages(&path).expect("load");
|
|
assert_eq!(msgs.len(), 4);
|
|
|
|
assert_eq!(msgs[0].role, "user");
|
|
assert_eq!(msgs[0].content, "list files");
|
|
|
|
assert_eq!(msgs[1].role, "assistant");
|
|
assert!(msgs[1].content.contains("[Tool: shell]"));
|
|
|
|
assert_eq!(msgs[2].role, "tool");
|
|
assert!(msgs[2].content.contains("file1.txt"));
|
|
|
|
assert_eq!(msgs[3].role, "assistant");
|
|
assert_eq!(msgs[3].content, "Done.");
|
|
}
|
|
}
|