mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-19 19:50:26 +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>
179 lines
6.0 KiB
Rust
179 lines
6.0 KiB
Rust
pub mod providers;
|
|
pub mod terminal;
|
|
|
|
use serde::Serialize;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use providers::{claude, codex, gemini, openclaw, opencode};
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SessionMeta {
|
|
pub provider_id: String,
|
|
pub session_id: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub title: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub summary: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub project_dir: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub created_at: Option<i64>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub last_active_at: Option<i64>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub source_path: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub resume_command: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SessionMessage {
|
|
pub role: String,
|
|
pub content: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub ts: Option<i64>,
|
|
}
|
|
|
|
pub fn scan_sessions() -> Vec<SessionMeta> {
|
|
let (r1, r2, r3, r4, r5) = std::thread::scope(|s| {
|
|
let h1 = s.spawn(codex::scan_sessions);
|
|
let h2 = s.spawn(claude::scan_sessions);
|
|
let h3 = s.spawn(opencode::scan_sessions);
|
|
let h4 = s.spawn(openclaw::scan_sessions);
|
|
let h5 = s.spawn(gemini::scan_sessions);
|
|
(
|
|
h1.join().unwrap_or_default(),
|
|
h2.join().unwrap_or_default(),
|
|
h3.join().unwrap_or_default(),
|
|
h4.join().unwrap_or_default(),
|
|
h5.join().unwrap_or_default(),
|
|
)
|
|
});
|
|
|
|
let mut sessions = Vec::new();
|
|
sessions.extend(r1);
|
|
sessions.extend(r2);
|
|
sessions.extend(r3);
|
|
sessions.extend(r4);
|
|
sessions.extend(r5);
|
|
|
|
sessions.sort_by(|a, b| {
|
|
let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0);
|
|
let b_ts = b.last_active_at.or(b.created_at).unwrap_or(0);
|
|
b_ts.cmp(&a_ts)
|
|
});
|
|
|
|
sessions
|
|
}
|
|
|
|
pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<SessionMessage>, String> {
|
|
// OpenCode SQLite sessions use a "sqlite:" prefixed source_path
|
|
if provider_id == "opencode" && source_path.starts_with("sqlite:") {
|
|
return opencode::load_messages_sqlite(source_path);
|
|
}
|
|
|
|
let path = Path::new(source_path);
|
|
match provider_id {
|
|
"codex" => codex::load_messages(path),
|
|
"claude" => claude::load_messages(path),
|
|
"opencode" => opencode::load_messages(path),
|
|
"openclaw" => openclaw::load_messages(path),
|
|
"gemini" => gemini::load_messages(path),
|
|
_ => Err(format!("Unsupported provider: {provider_id}")),
|
|
}
|
|
}
|
|
|
|
pub fn delete_session(
|
|
provider_id: &str,
|
|
session_id: &str,
|
|
source_path: &str,
|
|
) -> Result<bool, String> {
|
|
// OpenCode SQLite sessions bypass the file-based deletion path
|
|
if provider_id == "opencode" && source_path.starts_with("sqlite:") {
|
|
return opencode::delete_session_sqlite(session_id, source_path);
|
|
}
|
|
|
|
let root = provider_root(provider_id)?;
|
|
delete_session_with_root(provider_id, session_id, Path::new(source_path), &root)
|
|
}
|
|
|
|
fn delete_session_with_root(
|
|
provider_id: &str,
|
|
session_id: &str,
|
|
source_path: &Path,
|
|
root: &Path,
|
|
) -> Result<bool, String> {
|
|
let validated_root = canonicalize_existing_path(root, "session root")?;
|
|
let validated_source = canonicalize_existing_path(source_path, "session source")?;
|
|
|
|
if !validated_source.starts_with(&validated_root) {
|
|
return Err(format!(
|
|
"Session source path is outside provider root: {}",
|
|
source_path.display()
|
|
));
|
|
}
|
|
|
|
match provider_id {
|
|
"codex" => codex::delete_session(&validated_root, &validated_source, session_id),
|
|
"claude" => claude::delete_session(&validated_root, &validated_source, session_id),
|
|
"opencode" => opencode::delete_session(&validated_root, &validated_source, session_id),
|
|
"openclaw" => openclaw::delete_session(&validated_root, &validated_source, session_id),
|
|
"gemini" => gemini::delete_session(&validated_root, &validated_source, session_id),
|
|
_ => Err(format!("Unsupported provider: {provider_id}")),
|
|
}
|
|
}
|
|
|
|
fn provider_root(provider_id: &str) -> Result<PathBuf, String> {
|
|
let root = match provider_id {
|
|
"codex" => crate::codex_config::get_codex_config_dir().join("sessions"),
|
|
"claude" => crate::config::get_claude_config_dir().join("projects"),
|
|
"opencode" => opencode::get_opencode_data_dir(),
|
|
"openclaw" => crate::openclaw_config::get_openclaw_dir().join("agents"),
|
|
"gemini" => crate::gemini_config::get_gemini_dir().join("tmp"),
|
|
_ => return Err(format!("Unsupported provider: {provider_id}")),
|
|
};
|
|
|
|
Ok(root)
|
|
}
|
|
|
|
fn canonicalize_existing_path(path: &Path, label: &str) -> Result<PathBuf, String> {
|
|
if !path.exists() {
|
|
return Err(format!("{label} not found: {}", path.display()));
|
|
}
|
|
|
|
path.canonicalize()
|
|
.map_err(|e| format!("Failed to resolve {label} {}: {e}", path.display()))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn rejects_source_path_outside_provider_root() {
|
|
let root = tempdir().expect("tempdir");
|
|
let outside = tempdir().expect("tempdir");
|
|
let source = outside.path().join("session.jsonl");
|
|
std::fs::write(&source, "{}").expect("write source");
|
|
|
|
let err = delete_session_with_root("codex", "session-1", &source, root.path())
|
|
.expect_err("expected outside-root path to be rejected");
|
|
|
|
assert!(err.contains("outside provider root"));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_missing_source_path() {
|
|
let root = tempdir().expect("tempdir");
|
|
let missing = root.path().join("missing.jsonl");
|
|
|
|
let err = delete_session_with_root("codex", "session-1", &missing, root.path())
|
|
.expect_err("expected missing source path to fail");
|
|
|
|
assert!(err.contains("session source not found"));
|
|
}
|
|
}
|