Merge branch 'main' into feat/proxy-full-url

This commit is contained in:
YoVinchen
2026-03-22 22:30:07 +08:00
27 changed files with 1377 additions and 150 deletions
+8
View File
@@ -1059,6 +1059,14 @@ impl Database {
"0.50",
"6.25",
),
(
"claude-sonnet-4-6-20260217",
"Claude Sonnet 4.6",
"3",
"15",
"0.30",
"3.75",
),
// Claude 4.5 系列
(
"claude-opus-4-5-20251101",
+1 -1
View File
@@ -294,7 +294,7 @@ impl OpenClawConfigDocument {
if let Some(existing) = key_value_pairs
.iter_mut()
.find(|pair| json5_key_name(&pair.key).as_deref() == Some(key))
.find(|pair| json5_key_name(&pair.key) == Some(key))
{
existing.value = new_value;
return Ok(());
+14 -1
View File
@@ -860,9 +860,22 @@ impl RequestForwarder {
// 过滤黑名单 Headers,保护隐私并避免冲突
for (key, value) in headers {
let key_str = key.as_str();
if HEADER_BLACKLIST
.iter()
.any(|h| key.as_str().eq_ignore_ascii_case(h))
.any(|h| key_str.eq_ignore_ascii_case(h))
{
continue;
}
// Copilot 请求:过滤会由 add_auth_headers 注入的固定指纹头,
// 防止客户端原始头与注入头重复(reqwest header() 是追加语义)
if is_copilot
&& (key_str.eq_ignore_ascii_case("user-agent")
|| key_str.eq_ignore_ascii_case("editor-version")
|| key_str.eq_ignore_ascii_case("editor-plugin-version")
|| key_str.eq_ignore_ascii_case("copilot-integration-id")
|| key_str.eq_ignore_ascii_case("x-github-api-version")
|| key_str.eq_ignore_ascii_case("openai-intent"))
{
continue;
}
+7 -4
View File
@@ -329,12 +329,15 @@ impl ProviderAdapter for ClaudeAdapter {
AuthStrategy::Bearer => {
request.header("Authorization", format!("Bearer {}", auth.api_key))
}
// GitHub Copilot: Bearer + 特定的 Editor headers
// GitHub Copilot: Bearer + 统一指纹头
AuthStrategy::GitHubCopilot => request
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("Editor-Version", "vscode/1.85.0")
.header("Editor-Plugin-Version", "copilot/1.150.0")
.header("Copilot-Integration-Id", "vscode-chat"),
.header("editor-version", super::copilot_auth::COPILOT_EDITOR_VERSION)
.header("editor-plugin-version", super::copilot_auth::COPILOT_PLUGIN_VERSION)
.header("copilot-integration-id", super::copilot_auth::COPILOT_INTEGRATION_ID)
.header("user-agent", super::copilot_auth::COPILOT_USER_AGENT)
.header("x-github-api-version", super::copilot_auth::COPILOT_API_VERSION)
.header("openai-intent", "conversation-panel"),
_ => request,
}
}
+13 -8
View File
@@ -46,10 +46,11 @@ const TOKEN_REFRESH_BUFFER_SECONDS: i64 = 60;
const COPILOT_MODELS_URL: &str = "https://api.githubcopilot.com/models";
/// Copilot API Header 常量
const COPILOT_EDITOR_VERSION: &str = "vscode/1.96.0";
const COPILOT_PLUGIN_VERSION: &str = "copilot-chat/0.26.7";
const COPILOT_USER_AGENT: &str = "GitHubCopilotChat/0.26.7";
const COPILOT_API_VERSION: &str = "2025-04-01";
pub const COPILOT_EDITOR_VERSION: &str = "vscode/1.96.0";
pub const COPILOT_PLUGIN_VERSION: &str = "copilot-chat/0.26.7";
pub const COPILOT_USER_AGENT: &str = "GitHubCopilotChat/0.26.7";
pub const COPILOT_API_VERSION: &str = "2025-04-01";
pub const COPILOT_INTEGRATION_ID: &str = "vscode-chat";
/// Copilot 使用量 API URL
const COPILOT_USAGE_URL: &str = "https://api.github.com/copilot_internal/user";
@@ -465,6 +466,7 @@ impl CopilotAuthManager {
.http_client
.post(GITHUB_DEVICE_CODE_URL)
.header("Accept", "application/json")
.header("User-Agent", COPILOT_USER_AGENT)
.form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "read:user")])
.send()
.await?;
@@ -502,6 +504,7 @@ impl CopilotAuthManager {
.http_client
.post(GITHUB_OAUTH_TOKEN_URL)
.header("Accept", "application/json")
.header("User-Agent", COPILOT_USER_AGENT)
.form(&[
("client_id", GITHUB_CLIENT_ID),
("device_code", device_code),
@@ -948,7 +951,9 @@ impl CopilotAuthManager {
.http_client
.get(GITHUB_USER_URL)
.header("Authorization", format!("token {}", github_token))
.header("User-Agent", "CC-Switch")
.header("User-Agent", COPILOT_USER_AGENT)
.header("Editor-Version", COPILOT_EDITOR_VERSION)
.header("Editor-Plugin-Version", COPILOT_PLUGIN_VERSION)
.send()
.await?;
@@ -978,9 +983,9 @@ impl CopilotAuthManager {
.http_client
.get(COPILOT_TOKEN_URL)
.header("Authorization", format!("token {}", github_token))
.header("User-Agent", "CC-Switch")
.header("Editor-Version", "vscode/1.85.0")
.header("Editor-Plugin-Version", "copilot/1.150.0")
.header("User-Agent", COPILOT_USER_AGENT)
.header("Editor-Version", COPILOT_EDITOR_VERSION)
.header("Editor-Plugin-Version", COPILOT_PLUGIN_VERSION)
.send()
.await?;
+238
View File
@@ -14,6 +14,63 @@ pub fn is_openai_o_series(model: &str) -> bool {
&& model.as_bytes().get(1).is_some_and(|b| b.is_ascii_digit())
}
/// Detect OpenAI models that support reasoning_effort.
///
/// Supported families:
/// - o-series: o1, o3, o4-mini, etc.
/// - GPT-5+: gpt-5, gpt-5.1, gpt-5.4, gpt-5-codex, etc.
pub fn supports_reasoning_effort(model: &str) -> bool {
is_openai_o_series(model)
|| model
.to_lowercase()
.strip_prefix("gpt-")
.and_then(|rest| rest.chars().next())
.is_some_and(|c| c.is_ascii_digit() && c >= '5')
}
/// Resolve the appropriate OpenAI `reasoning_effort` from an Anthropic request body.
///
/// Priority:
/// 1. Explicit `output_config.effort` — preserves the user's intent directly.
/// `low`/`medium`/`high` map 1:1; `max` maps to `xhigh`
/// (supported by mainstream GPT models). Unknown values are ignored.
/// 2. Fallback: `thinking.type` + `budget_tokens`:
/// - `adaptive` → `high` (mirrors optimizer semantics where adaptive ≈ max effort)
/// - `enabled` with budget → `low` (<4 000) / `medium` (4 00015 999) / `high` (≥16 000)
/// - `enabled` without budget → `high` (conservative default)
/// - `disabled` / absent → `None`
pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
// --- Priority 1: explicit output_config.effort ---
if let Some(effort) = body
.pointer("/output_config/effort")
.and_then(|v| v.as_str())
{
return match effort {
"low" => Some("low"),
"medium" => Some("medium"),
"high" => Some("high"),
"max" => Some("xhigh"), // OpenAI xhigh = maximum reasoning effort
_ => None, // unknown value — do not inject
};
}
// --- Priority 2: thinking.type + budget_tokens fallback ---
let thinking = body.get("thinking")?;
match thinking.get("type").and_then(|t| t.as_str()) {
Some("adaptive") => Some("high"),
Some("enabled") => {
let budget = thinking.get("budget_tokens").and_then(|b| b.as_u64());
match budget {
Some(b) if b < 4_000 => Some("low"),
Some(b) if b < 16_000 => Some("medium"),
Some(_) => Some("high"),
None => Some("high"), // enabled but no budget — assume strong reasoning
}
}
_ => None, // disabled or missing
}
}
/// Anthropic 请求 → OpenAI 请求
///
/// `cache_key`: optional prompt_cache_key to inject for improved cache routing
@@ -80,6 +137,13 @@ pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value
result["stream"] = v.clone();
}
// Map Anthropic thinking → OpenAI reasoning_effort
if supports_reasoning_effort(model) {
if let Some(effort) = resolve_reasoning_effort(&body) {
result["reasoning_effort"] = json!(effort);
}
}
// 转换 tools (过滤 BatchTool)
if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) {
let openai_tools: Vec<Value> = tools
@@ -800,6 +864,180 @@ mod tests {
assert!(!is_openai_o_series(""));
}
#[test]
fn test_supports_reasoning_effort() {
assert!(supports_reasoning_effort("o1"));
assert!(supports_reasoning_effort("o3-mini"));
assert!(supports_reasoning_effort("gpt-5"));
assert!(supports_reasoning_effort("gpt-5.4"));
assert!(supports_reasoning_effort("gpt-5-codex"));
assert!(!supports_reasoning_effort("gpt-4o"));
assert!(!supports_reasoning_effort("claude-sonnet-4-6"));
}
// ── resolve_reasoning_effort unit tests ──
#[test]
fn test_output_config_low_maps_to_reasoning_effort_low() {
let body = json!({"output_config": {"effort": "low"}});
assert_eq!(resolve_reasoning_effort(&body), Some("low"));
}
#[test]
fn test_output_config_medium_maps_to_reasoning_effort_medium() {
let body = json!({"output_config": {"effort": "medium"}});
assert_eq!(resolve_reasoning_effort(&body), Some("medium"));
}
#[test]
fn test_output_config_high_maps_to_reasoning_effort_high() {
let body = json!({"output_config": {"effort": "high"}});
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
}
#[test]
fn test_output_config_max_maps_to_reasoning_effort_xhigh() {
let body = json!({"output_config": {"effort": "max"}});
assert_eq!(resolve_reasoning_effort(&body), Some("xhigh"));
}
#[test]
fn test_output_config_takes_priority_over_thinking() {
// Even with thinking.adaptive present, explicit effort wins
let body = json!({
"output_config": {"effort": "low"},
"thinking": {"type": "adaptive"}
});
assert_eq!(resolve_reasoning_effort(&body), Some("low"));
}
#[test]
fn test_output_config_unknown_value_no_reasoning_effort() {
let body = json!({"output_config": {"effort": "turbo"}});
assert_eq!(resolve_reasoning_effort(&body), None);
}
#[test]
fn test_thinking_enabled_small_budget_maps_low() {
let body = json!({"thinking": {"type": "enabled", "budget_tokens": 1024}});
assert_eq!(resolve_reasoning_effort(&body), Some("low"));
}
#[test]
fn test_thinking_enabled_medium_budget_maps_medium() {
let body = json!({"thinking": {"type": "enabled", "budget_tokens": 8000}});
assert_eq!(resolve_reasoning_effort(&body), Some("medium"));
}
#[test]
fn test_thinking_enabled_large_budget_maps_high() {
let body = json!({"thinking": {"type": "enabled", "budget_tokens": 32000}});
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
}
#[test]
fn test_thinking_enabled_without_budget_maps_high() {
let body = json!({"thinking": {"type": "enabled"}});
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
}
#[test]
fn test_thinking_adaptive_maps_high() {
let body = json!({"thinking": {"type": "adaptive"}});
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
}
#[test]
fn test_thinking_disabled_no_reasoning_effort() {
let body = json!({"thinking": {"type": "disabled"}});
assert_eq!(resolve_reasoning_effort(&body), None);
}
#[test]
fn test_no_thinking_field_no_reasoning_effort() {
let body = json!({"messages": [{"role": "user", "content": "Hello"}]});
assert_eq!(resolve_reasoning_effort(&body), None);
}
// ── Integration: anthropic_to_openai with resolve_reasoning_effort ──
#[test]
fn test_non_reasoning_model_no_reasoning_effort() {
let input = json!({
"model": "gpt-4o",
"max_tokens": 1024,
"thinking": {"type": "enabled", "budget_tokens": 2048},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert!(result.get("reasoning_effort").is_none());
}
#[test]
fn test_reasoning_model_with_output_config_effort() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"output_config": {"effort": "medium"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["reasoning_effort"], "medium");
}
#[test]
fn test_reasoning_model_with_output_config_max() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"output_config": {"effort": "max"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["reasoning_effort"], "xhigh");
}
#[test]
fn test_reasoning_model_thinking_enabled_small_budget() {
let input = json!({
"model": "o3",
"max_tokens": 1024,
"thinking": {"type": "enabled", "budget_tokens": 2048},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["reasoning_effort"], "low");
}
#[test]
fn test_reasoning_model_thinking_adaptive() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"thinking": {"type": "adaptive"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["reasoning_effort"], "high");
}
#[test]
fn test_reasoning_model_no_thinking_no_effort() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert!(result.get("reasoning_effort").is_none());
}
#[test]
fn test_anthropic_to_openai_o_series_max_completion_tokens() {
for model in &["o1", "o3-mini", "o4-mini"] {
@@ -61,6 +61,15 @@ pub fn anthropic_to_responses(body: Value, cache_key: Option<&str>) -> Result<Va
result["stream"] = v.clone();
}
// Map Anthropic thinking → OpenAI Responses reasoning.effort
if let Some(model_name) = body.get("model").and_then(|m| m.as_str()) {
if super::transform::supports_reasoning_effort(model_name) {
if let Some(effort) = super::transform::resolve_reasoning_effort(&body) {
result["reasoning"] = json!({ "effort": effort });
}
}
}
// stop_sequences → 丢弃 (Responses API 不支持)
// 转换 tools (过滤 BatchTool)
@@ -910,4 +919,96 @@ mod tests {
assert_eq!(result["max_output_tokens"], 4096);
assert!(result.get("max_completion_tokens").is_none());
}
#[test]
fn test_responses_output_config_max_sets_reasoning_xhigh() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"output_config": {"effort": "max"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
assert_eq!(result["reasoning"]["effort"], "xhigh");
}
#[test]
fn test_responses_output_config_takes_priority_over_thinking() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"output_config": {"effort": "low"},
"thinking": {"type": "adaptive"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
assert_eq!(result["reasoning"]["effort"], "low");
}
#[test]
fn test_responses_thinking_enabled_small_budget_sets_reasoning_low() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"thinking": {"type": "enabled", "budget_tokens": 2048},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
assert_eq!(result["reasoning"]["effort"], "low");
}
#[test]
fn test_responses_thinking_enabled_medium_budget_sets_reasoning_medium() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"thinking": {"type": "enabled", "budget_tokens": 8000},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
assert_eq!(result["reasoning"]["effort"], "medium");
}
#[test]
fn test_responses_thinking_enabled_large_budget_sets_reasoning_high() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"thinking": {"type": "enabled", "budget_tokens": 32000},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
assert_eq!(result["reasoning"]["effort"], "high");
}
#[test]
fn test_responses_thinking_adaptive_sets_reasoning_high() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"thinking": {"type": "adaptive"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
assert_eq!(result["reasoning"]["effort"], "high");
}
#[test]
fn test_responses_non_reasoning_model_no_reasoning() {
let input = json!({
"model": "gpt-4o",
"max_tokens": 1024,
"thinking": {"type": "enabled", "budget_tokens": 2048},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
assert!(result.get("reasoning").is_none());
}
}
+7 -3
View File
@@ -13,6 +13,7 @@ use crate::app_config::AppType;
use crate::error::AppError;
use crate::provider::Provider;
use crate::proxy::providers::transform::anthropic_to_openai;
use crate::proxy::providers::copilot_auth;
use crate::proxy::providers::transform_responses::anthropic_to_responses;
use crate::proxy::providers::{get_adapter, AuthInfo, AuthStrategy};
@@ -353,9 +354,12 @@ impl StreamCheckService {
.header("content-type", "application/json")
.header("accept", "text/event-stream")
.header("accept-encoding", "identity")
.header("editor-version", "vscode/1.85.0")
.header("editor-plugin-version", "copilot/1.150.0")
.header("copilot-integration-id", "vscode-chat");
.header("user-agent", copilot_auth::COPILOT_USER_AGENT)
.header("editor-version", copilot_auth::COPILOT_EDITOR_VERSION)
.header("editor-plugin-version", copilot_auth::COPILOT_PLUGIN_VERSION)
.header("copilot-integration-id", copilot_auth::COPILOT_INTEGRATION_ID)
.header("x-github-api-version", copilot_auth::COPILOT_API_VERSION)
.header("openai-intent", "conversation-panel");
} else if is_openai_chat || is_openai_responses {
// OpenAI-compatible targets: Bearer auth + SSE headers only
request_builder = request_builder
+10
View File
@@ -69,6 +69,11 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
}
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),
@@ -85,6 +90,11 @@ pub fn delete_session(
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)
}
@@ -52,11 +52,25 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
None => continue,
};
let role = message
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;
@@ -268,4 +282,58 @@ mod tests {
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"));
}
}
@@ -59,16 +59,37 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
None => continue,
};
if payload.get("type").and_then(Value::as_str) != Some("message") {
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,
};
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();
if content.trim().is_empty() {
continue;
}
@@ -239,4 +260,36 @@ mod tests {
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.");
}
}
@@ -60,21 +60,47 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
let mut result = Vec::new();
for msg in messages {
let content = match msg.get("content").and_then(Value::as_str) {
Some(c) if !c.trim().is_empty() => c.to_string(),
_ => continue,
let role = match msg.get("type").and_then(Value::as_str) {
Some("gemini") => "assistant",
Some("user") => "user",
Some("info") | Some("error") => continue,
Some(_) | None => continue,
};
let role = match msg.get("type").and_then(Value::as_str) {
Some("gemini") => "assistant".to_string(),
Some("user") => "user".to_string(),
Some(other) => other.to_string(),
None => continue,
// Gemini content may be a plain string or an array of {text: ...} objects
let mut content = match msg.get("content") {
Some(Value::String(s)) => s.to_string(),
Some(Value::Array(items)) => items
.iter()
.filter_map(|item| item.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
};
// Append tool call names from the optional toolCalls array
if let Some(Value::Array(calls)) = msg.get("toolCalls") {
for call in calls {
if let Some(name) = call.get("name").and_then(Value::as_str) {
if !content.is_empty() {
content.push('\n');
}
content.push_str(&format!("[Tool: {name}]"));
}
}
}
if content.trim().is_empty() {
continue;
}
let ts = msg.get("timestamp").and_then(parse_timestamp_to_ms);
result.push(SessionMessage { role, content, ts });
result.push(SessionMessage {
role: role.to_string(),
content,
ts,
});
}
Ok(result)
@@ -172,4 +198,55 @@ mod tests {
assert!(!path.exists());
}
#[test]
fn load_messages_handles_array_content() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session.json");
std::fs::write(
&path,
r#"{
"sessionId": "test",
"messages": [
{"id":"1","timestamp":"2026-03-06T10:00:00Z","type":"user","content":[{"text":"hello"}]},
{"id":"2","timestamp":"2026-03-06T10:00:01Z","type":"gemini","content":"world"},
{"id":"3","timestamp":"2026-03-06T10:00:02Z","type":"info","content":"system info"},
{"id":"4","timestamp":"2026-03-06T10:00:03Z","type":"error","content":"MCP ERROR"}
]
}"#,
)
.expect("write");
let msgs = load_messages(&path).expect("load");
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].role, "user");
assert_eq!(msgs[0].content, "hello");
assert_eq!(msgs[1].role, "assistant");
assert_eq!(msgs[1].content, "world");
}
#[test]
fn load_messages_includes_tool_calls() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session.json");
std::fs::write(
&path,
r#"{
"sessionId": "test",
"messages": [
{"id":"1","timestamp":"2026-03-10T08:24:50Z","type":"gemini","content":"","toolCalls":[{"id":"call_1","name":"web_search","args":{"query":"test"}}]},
{"id":"2","timestamp":"2026-03-10T08:25:00Z","type":"gemini","content":"Here are the results.","toolCalls":[{"id":"call_2","name":"web_fetch","args":{"url":"http://example.com"}}]}
]
}"#,
)
.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: web_search]"));
assert_eq!(msgs[1].role, "assistant");
assert!(msgs[1].content.contains("Here are the results."));
assert!(msgs[1].content.contains("[Tool: web_fetch]"));
}
}
@@ -1,5 +1,6 @@
use std::path::{Path, PathBuf};
use rusqlite::Connection;
use serde_json::Value;
use crate::session_manager::{SessionMessage, SessionMeta};
@@ -8,22 +9,59 @@ use super::utils::{parse_timestamp_to_ms, path_basename, truncate_summary};
const PROVIDER_ID: &str = "opencode";
/// Return the OpenCode data directory.
/// Return the OpenCode base directory (`$XDG_DATA_HOME/opencode`).
///
/// Respects `XDG_DATA_HOME` on all platforms; falls back to
/// `~/.local/share/opencode/storage/`.
pub(crate) fn get_opencode_data_dir() -> PathBuf {
/// `~/.local/share/opencode/`.
pub(crate) fn get_opencode_base_dir() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
if !xdg.is_empty() {
return PathBuf::from(xdg).join("opencode").join("storage");
return PathBuf::from(xdg).join("opencode");
}
}
dirs::home_dir()
.map(|h| h.join(".local/share/opencode/storage"))
.unwrap_or_else(|| PathBuf::from(".local/share/opencode/storage"))
.map(|h| h.join(".local/share/opencode"))
.unwrap_or_else(|| PathBuf::from(".local/share/opencode"))
}
/// Return the OpenCode JSON storage directory (legacy flat-file layout).
pub(crate) fn get_opencode_data_dir() -> PathBuf {
get_opencode_base_dir().join("storage")
}
fn get_opencode_db_path() -> PathBuf {
get_opencode_base_dir().join("opencode.db")
}
/// Scan sessions from both the legacy JSON files and the newer SQLite database,
/// merging results with SQLite taking precedence on ID conflicts.
pub fn scan_sessions() -> Vec<SessionMeta> {
let json_sessions = scan_sessions_json();
let sqlite_sessions = scan_sessions_sqlite();
if sqlite_sessions.is_empty() {
return json_sessions;
}
if json_sessions.is_empty() {
return sqlite_sessions;
}
// Deduplicate: keep SQLite version when the same session_id exists in both
let sqlite_ids: std::collections::HashSet<String> = sqlite_sessions
.iter()
.map(|s| s.session_id.clone())
.collect();
let mut merged = sqlite_sessions;
for s in json_sessions {
if !sqlite_ids.contains(&s.session_id) {
merged.push(s);
}
}
merged
}
fn scan_sessions_json() -> Vec<SessionMeta> {
let storage = get_opencode_data_dir();
let session_dir = storage.join("session");
if !session_dir.exists() {
@@ -42,6 +80,81 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
sessions
}
/// Parse a SQLite source reference in the format `sqlite:<db_path>:<session_id>`.
///
/// Uses `rfind(":ses_")` to split the path from the session ID because the
/// db path itself may contain colons (e.g. `C:\Users\...` on Windows).
/// This relies on the OpenCode convention that session IDs start with `ses_`.
fn parse_sqlite_source(source: &str) -> Option<(PathBuf, String)> {
let rest = source.strip_prefix("sqlite:")?;
let sep = rest.rfind(":ses_")?;
let db_path = PathBuf::from(&rest[..sep]);
let session_id = rest[sep + 1..].to_string();
Some((db_path, session_id))
}
fn scan_sessions_sqlite() -> Vec<SessionMeta> {
let db_path = get_opencode_db_path();
if !db_path.exists() {
return Vec::new();
}
let conn = match Connection::open_with_flags(
&db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let mut stmt = match conn.prepare(
"SELECT id, title, directory, time_created, time_updated FROM session ORDER BY time_updated DESC",
) {
Ok(s) => s,
Err(_) => return Vec::new(),
};
let db_display = db_path.display().to_string();
let iter = match stmt.query_map([], |row| {
let session_id: String = row.get(0)?;
let title: String = row.get(1)?;
let directory: String = row.get(2)?;
let created: i64 = row.get(3)?;
let updated: i64 = row.get(4)?;
Ok((session_id, title, directory, created, updated))
}) {
Ok(rows) => rows,
Err(_) => return Vec::new(),
};
let mut sessions = Vec::new();
for row in iter.flatten() {
let (session_id, title, directory, created, updated) = row;
let display_title = if title.is_empty() {
path_basename(&directory)
} else {
Some(title)
};
sessions.push(SessionMeta {
provider_id: PROVIDER_ID.to_string(),
session_id: session_id.clone(),
title: display_title.clone(),
summary: display_title,
project_dir: if directory.is_empty() {
None
} else {
Some(directory)
},
created_at: Some(created),
last_active_at: Some(updated),
source_path: Some(format!("sqlite:{}:{}", db_display, session_id)),
resume_command: Some(format!("opencode session resume {session_id}")),
});
}
sessions
}
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
// `path` is the message directory: storage/message/{sessionID}/
if !path.is_dir() {
@@ -111,6 +224,95 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
Ok(messages)
}
/// Load messages from the OpenCode SQLite database for a given source reference.
/// Joins the `message` and `part` tables in memory to reconstruct full messages.
pub fn load_messages_sqlite(source: &str) -> Result<Vec<SessionMessage>, String> {
let (db_path, session_id) = parse_sqlite_source(source)
.ok_or_else(|| format!("Invalid SQLite source reference: {source}"))?;
let conn = Connection::open_with_flags(
&db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|e| format!("Failed to open OpenCode database: {e}"))?;
let mut msg_stmt = conn
.prepare(
"SELECT id, time_created, data FROM message WHERE session_id = ?1 ORDER BY time_created ASC",
)
.map_err(|e| format!("Failed to prepare message query: {e}"))?;
let msg_rows = msg_stmt
.query_map([session_id.as_str()], |row| {
let id: String = row.get(0)?;
let ts: i64 = row.get(1)?;
let data: String = row.get(2)?;
Ok((id, ts, data))
})
.map_err(|e| format!("Failed to query messages: {e}"))?;
let mut part_stmt = conn
.prepare(
"SELECT message_id, data FROM part WHERE session_id = ?1 ORDER BY time_created ASC",
)
.map_err(|e| format!("Failed to prepare part query: {e}"))?;
let part_rows = part_stmt
.query_map([session_id.as_str()], |row| {
let message_id: String = row.get(0)?;
let data: String = row.get(1)?;
Ok((message_id, data))
})
.map_err(|e| format!("Failed to query parts: {e}"))?;
let mut parts_map: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for part in part_rows.flatten() {
let (message_id, data) = part;
parts_map.entry(message_id).or_default().push(data);
}
let mut messages = Vec::new();
for row in msg_rows.flatten() {
let (msg_id, ts, data) = row;
let msg_value: Value = match serde_json::from_str(&data) {
Ok(v) => v,
Err(_) => continue,
};
let role = msg_value
.get("role")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
let mut texts = Vec::new();
if let Some(parts) = parts_map.get(&msg_id) {
for part_data in parts {
let part_value: Value = match serde_json::from_str(part_data) {
Ok(v) => v,
Err(_) => continue,
};
if let Some(text) = extract_part_text(&part_value) {
texts.push(text);
}
}
}
let content = texts.join("\n");
if content.trim().is_empty() {
continue;
}
messages.push(SessionMessage {
role,
content,
ts: Some(ts),
});
}
Ok(messages)
}
pub fn delete_session(storage: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
if path.file_name().and_then(|name| name.to_str()) != Some(session_id) {
return Err(format!(
@@ -176,6 +378,48 @@ pub fn delete_session(storage: &Path, path: &Path, session_id: &str) -> Result<b
Ok(true)
}
/// Delete a session from the OpenCode SQLite database.
pub fn delete_session_sqlite(session_id: &str, source: &str) -> Result<bool, String> {
let (db_path, ref_session_id) = parse_sqlite_source(source)
.ok_or_else(|| format!("Invalid SQLite source reference: {source}"))?;
let db_path = db_path
.canonicalize()
.map_err(|e| format!("Failed to canonicalize SQLite database path: {e}"))?;
let expected_db_path = get_opencode_db_path()
.canonicalize()
.map_err(|e| format!("Failed to canonicalize expected OpenCode database path: {e}"))?;
if ref_session_id != session_id {
return Err(format!(
"OpenCode SQLite session ID mismatch: expected {session_id}, found {ref_session_id}"
));
}
if db_path != expected_db_path {
return Err("SQLite path does not match expected OpenCode database".to_string());
}
let conn =
Connection::open(&db_path).map_err(|e| format!("Failed to open OpenCode database: {e}"))?;
let tx = conn
.unchecked_transaction()
.map_err(|e| format!("Failed to begin transaction: {e}"))?;
tx.execute("DELETE FROM part WHERE session_id = ?1", [session_id])
.map_err(|e| format!("Failed to delete OpenCode parts: {e}"))?;
tx.execute("DELETE FROM message WHERE session_id = ?1", [session_id])
.map_err(|e| format!("Failed to delete OpenCode messages: {e}"))?;
let deleted = tx
.execute("DELETE FROM session WHERE id = ?1", [session_id])
.map_err(|e| format!("Failed to delete OpenCode session: {e}"))?;
tx.commit()
.map_err(|e| format!("Failed to commit session deletion: {e}"))?;
Ok(deleted > 0)
}
fn parse_session(storage: &Path, path: &Path) -> Option<SessionMeta> {
let data = std::fs::read_to_string(path).ok()?;
let value: Value = serde_json::from_str(&data).ok()?;
@@ -286,6 +530,24 @@ fn get_first_user_summary(storage: &Path, session_id: &str) -> Option<String> {
}
/// Collect text content from all parts in a part directory.
fn extract_part_text(part_value: &Value) -> Option<String> {
match part_value.get("type").and_then(Value::as_str) {
Some("text") => part_value
.get("text")
.and_then(Value::as_str)
.filter(|t| !t.trim().is_empty())
.map(|t| t.to_string()),
Some("tool") => {
let tool = part_value
.get("tool")
.and_then(Value::as_str)
.unwrap_or("unknown");
Some(format!("[Tool: {tool}]"))
}
_ => None,
}
}
fn collect_parts_text(part_dir: &Path) -> String {
if !part_dir.is_dir() {
return String::new();
@@ -305,15 +567,8 @@ fn collect_parts_text(part_dir: &Path) -> String {
Err(_) => continue,
};
// Only include text-type parts
if value.get("type").and_then(Value::as_str) != Some("text") {
continue;
}
if let Some(text) = value.get("text").and_then(Value::as_str) {
if !text.trim().is_empty() {
texts.push(text.to_string());
}
if let Some(text) = extract_part_text(&value) {
texts.push(text);
}
}
@@ -370,8 +625,47 @@ fn remove_dir_all_if_exists(path: &Path) -> std::io::Result<()> {
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
use std::sync::{Mutex, OnceLock};
use tempfile::tempdir;
fn opencode_env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn create_sqlite_schema(conn: &Connection) {
conn.execute_batch(
"
PRAGMA foreign_keys = ON;
CREATE TABLE session (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
directory TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL
);
CREATE TABLE message (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
data TEXT NOT NULL,
FOREIGN KEY(session_id) REFERENCES session(id) ON DELETE CASCADE
);
CREATE TABLE part (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
message_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
data TEXT NOT NULL,
FOREIGN KEY(session_id) REFERENCES session(id) ON DELETE CASCADE,
FOREIGN KEY(message_id) REFERENCES message(id) ON DELETE CASCADE
);
",
)
.expect("create sqlite schema");
}
#[test]
fn delete_session_removes_session_diff_messages_and_parts() {
let temp = tempdir().expect("tempdir");
@@ -432,4 +726,272 @@ mod tests {
.join(format!("{project_id}.json"))
.exists());
}
#[test]
fn load_messages_includes_tool_parts() {
let temp = tempdir().expect("tempdir");
let storage = temp.path();
let session_id = "ses_test";
let msg_id = "msg_1";
let msg_dir = storage.join("message").join(session_id);
let part_dir = storage.join("part").join(msg_id);
std::fs::create_dir_all(&msg_dir).expect("create msg dir");
std::fs::create_dir_all(&part_dir).expect("create part dir");
std::fs::write(
msg_dir.join(format!("{msg_id}.json")),
r#"{"id":"msg_1","role":"assistant","time":{"created":"2026-03-06T10:00:00Z"}}"#,
)
.expect("write msg");
std::fs::write(
part_dir.join("prt_1.json"),
r#"{"id":"prt_1","type":"tool","tool":"bash","state":{"status":"completed","input":{"command":"ls"},"output":"file.txt"}}"#,
)
.expect("write tool part");
std::fs::write(
part_dir.join("prt_2.json"),
r#"{"id":"prt_2","type":"text","text":"Here are the files."}"#,
)
.expect("write text part");
let msgs = load_messages(&msg_dir).expect("load");
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].role, "assistant");
assert!(msgs[0].content.contains("[Tool: bash]"));
assert!(msgs[0].content.contains("Here are the files."));
}
#[test]
fn parse_sqlite_source_accepts_valid_references() {
let parsed = parse_sqlite_source("sqlite:/tmp/opencode.db:ses_123").expect("valid source");
assert_eq!(parsed.0, PathBuf::from("/tmp/opencode.db"));
assert_eq!(parsed.1, "ses_123");
}
#[test]
fn parse_sqlite_source_rejects_invalid_references() {
assert!(parse_sqlite_source("/tmp/opencode.db:ses_123").is_none());
assert!(parse_sqlite_source("sqlite:/tmp/opencode.db:msg_123").is_none());
assert!(parse_sqlite_source("sqlite:/tmp/opencode.db").is_none());
}
#[test]
#[allow(deprecated)] // set_var/remove_var deprecated since Rust 1.81; safe here under mutex
fn scan_sessions_sqlite_reads_temp_database() {
let _guard = opencode_env_lock().lock().expect("lock");
let temp = tempdir().expect("tempdir");
let original_xdg = std::env::var_os("XDG_DATA_HOME");
std::env::set_var("XDG_DATA_HOME", temp.path());
let base_dir = temp.path().join("opencode");
std::fs::create_dir_all(&base_dir).expect("create base dir");
let db_path = base_dir.join("opencode.db");
let conn = Connection::open(&db_path).expect("open sqlite db");
create_sqlite_schema(&conn);
conn.execute(
"INSERT INTO session (id, title, directory, time_created, time_updated) VALUES (?1, ?2, ?3, ?4, ?5)",
("ses_1", "", "/tmp/project-a", 1_771_061_953_033_i64, 1_771_061_954_033_i64),
)
.expect("insert session 1");
conn.execute(
"INSERT INTO session (id, title, directory, time_created, time_updated) VALUES (?1, ?2, ?3, ?4, ?5)",
("ses_2", "Named Session", "/tmp/project-b", 1_771_061_950_000_i64, 1_771_061_955_000_i64),
)
.expect("insert session 2");
drop(conn);
let sessions = scan_sessions_sqlite();
#[allow(deprecated)]
if let Some(value) = original_xdg {
std::env::set_var("XDG_DATA_HOME", value);
} else {
std::env::remove_var("XDG_DATA_HOME");
}
assert_eq!(sessions.len(), 2);
assert_eq!(sessions[0].session_id, "ses_2");
assert_eq!(sessions[0].title.as_deref(), Some("Named Session"));
assert_eq!(sessions[1].session_id, "ses_1");
assert_eq!(sessions[1].title.as_deref(), Some("project-a"));
assert_eq!(sessions[1].project_dir.as_deref(), Some("/tmp/project-a"));
let expected_source = format!("sqlite:{}:ses_1", db_path.display());
assert_eq!(
sessions[1].source_path.as_deref(),
Some(expected_source.as_str())
);
}
#[test]
fn load_messages_sqlite_reads_messages_and_parts() {
let temp = tempdir().expect("tempdir");
let db_path = temp.path().join("opencode.db");
let conn = Connection::open(&db_path).expect("open sqlite db");
create_sqlite_schema(&conn);
conn.execute(
"INSERT INTO session (id, title, directory, time_created, time_updated) VALUES (?1, ?2, ?3, ?4, ?5)",
("ses_1", "Session", "/tmp/project-a", 1000_i64, 3000_i64),
)
.expect("insert session");
conn.execute(
"INSERT INTO message (id, session_id, time_created, data) VALUES (?1, ?2, ?3, ?4)",
("msg_1", "ses_1", 1000_i64, r#"{"role":"user"}"#),
)
.expect("insert message 1");
conn.execute(
"INSERT INTO message (id, session_id, time_created, data) VALUES (?1, ?2, ?3, ?4)",
("msg_2", "ses_1", 2000_i64, r#"{"role":"assistant"}"#),
)
.expect("insert message 2");
conn.execute(
"INSERT INTO part (id, session_id, message_id, time_created, data) VALUES (?1, ?2, ?3, ?4, ?5)",
("prt_1", "ses_1", "msg_1", 1000_i64, r#"{"type":"text","text":"Hello"}"#),
)
.expect("insert part 1");
conn.execute(
"INSERT INTO part (id, session_id, message_id, time_created, data) VALUES (?1, ?2, ?3, ?4, ?5)",
(
"prt_2",
"ses_1",
"msg_2",
2000_i64,
r#"{"type":"tool","tool":"bash"}"#,
),
)
.expect("insert part 2");
conn.execute(
"INSERT INTO part (id, session_id, message_id, time_created, data) VALUES (?1, ?2, ?3, ?4, ?5)",
(
"prt_3",
"ses_1",
"msg_2",
2001_i64,
r#"{"type":"text","text":"Done"}"#,
),
)
.expect("insert part 3");
drop(conn);
let source = format!("sqlite:{}:ses_1", db_path.display());
let messages = load_messages_sqlite(&source).expect("load sqlite messages");
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].role, "user");
assert_eq!(messages[0].content, "Hello");
assert_eq!(messages[0].ts, Some(1000));
assert_eq!(messages[1].role, "assistant");
assert_eq!(messages[1].content, "[Tool: bash]\nDone");
assert_eq!(messages[1].ts, Some(2000));
}
#[test]
fn delete_session_sqlite_removes_session() {
let _guard = opencode_env_lock().lock().expect("lock");
let temp = tempdir().expect("tempdir");
let original_xdg = std::env::var_os("XDG_DATA_HOME");
#[allow(deprecated)]
std::env::set_var("XDG_DATA_HOME", temp.path());
let base_dir = temp.path().join("opencode");
std::fs::create_dir_all(&base_dir).expect("create base dir");
let db_path = base_dir.join("opencode.db");
let conn = Connection::open(&db_path).expect("open sqlite db");
create_sqlite_schema(&conn);
conn.execute(
"INSERT INTO session (id, title, directory, time_created, time_updated) VALUES (?1, ?2, ?3, ?4, ?5)",
("ses_1", "Session", "/tmp/project-a", 1000_i64, 3000_i64),
)
.expect("insert session");
conn.execute(
"INSERT INTO message (id, session_id, time_created, data) VALUES (?1, ?2, ?3, ?4)",
("msg_1", "ses_1", 1000_i64, r#"{"role":"user"}"#),
)
.expect("insert message");
conn.execute(
"INSERT INTO part (id, session_id, message_id, time_created, data) VALUES (?1, ?2, ?3, ?4, ?5)",
("prt_1", "ses_1", "msg_1", 1000_i64, r#"{"type":"text","text":"Hello"}"#),
)
.expect("insert part");
drop(conn);
let source = format!("sqlite:{}:ses_1", db_path.display());
let deleted = delete_session_sqlite("ses_1", &source).expect("delete sqlite session");
assert!(deleted);
let conn = Connection::open(&db_path).expect("re-open sqlite db");
let remaining_sessions: i64 = conn
.query_row(
"SELECT COUNT(*) FROM session WHERE id = 'ses_1'",
[],
|row| row.get(0),
)
.expect("count sessions");
let remaining_messages: i64 = conn
.query_row(
"SELECT COUNT(*) FROM message WHERE session_id = 'ses_1'",
[],
|row| row.get(0),
)
.expect("count messages");
let remaining_parts: i64 = conn
.query_row(
"SELECT COUNT(*) FROM part WHERE session_id = 'ses_1'",
[],
|row| row.get(0),
)
.expect("count parts");
assert_eq!(remaining_sessions, 0);
assert_eq!(remaining_messages, 0);
assert_eq!(remaining_parts, 0);
#[allow(deprecated)]
if let Some(value) = original_xdg {
std::env::set_var("XDG_DATA_HOME", value);
} else {
std::env::remove_var("XDG_DATA_HOME");
}
}
#[test]
fn delete_session_sqlite_rejects_foreign_db_path() {
let _guard = opencode_env_lock().lock().expect("lock");
let temp = tempdir().expect("tempdir");
let original_xdg = std::env::var_os("XDG_DATA_HOME");
#[allow(deprecated)]
std::env::set_var("XDG_DATA_HOME", temp.path());
let expected_base_dir = temp.path().join("opencode");
std::fs::create_dir_all(&expected_base_dir).expect("create expected base dir");
let expected_db_path = expected_base_dir.join("opencode.db");
Connection::open(&expected_db_path).expect("create expected sqlite db");
let db_path = temp.path().join("foreign.db");
let conn = Connection::open(&db_path).expect("open sqlite db");
create_sqlite_schema(&conn);
conn.execute(
"INSERT INTO session (id, title, directory, time_created, time_updated) VALUES (?1, ?2, ?3, ?4, ?5)",
("ses_1", "Session", "/tmp/project", 1000_i64, 3000_i64),
)
.expect("insert session");
drop(conn);
let source = format!("sqlite:{}:ses_1", db_path.display());
let err = delete_session_sqlite("ses_1", &source).expect_err("should reject foreign db");
assert!(err.contains("expected OpenCode database"));
#[allow(deprecated)]
if let Some(value) = original_xdg {
std::env::set_var("XDG_DATA_HOME", value);
} else {
std::env::remove_var("XDG_DATA_HOME");
}
}
}
@@ -46,6 +46,15 @@ pub fn read_head_tail_lines(
}
pub fn parse_timestamp_to_ms(value: &Value) -> Option<i64> {
// Integer: milliseconds (>1e12) or seconds
if let Some(n) = value.as_i64() {
return Some(if n > 1_000_000_000_000 { n } else { n * 1000 });
}
if let Some(n) = value.as_f64() {
let n = n as i64;
return Some(if n > 1_000_000_000_000 { n } else { n * 1000 });
}
// RFC3339 string
let raw = value.as_str()?;
DateTime::parse_from_rfc3339(raw)
.ok()
@@ -71,6 +80,28 @@ pub fn extract_text(content: &Value) -> String {
}
fn extract_text_from_item(item: &Value) -> Option<String> {
let item_type = item.get("type").and_then(Value::as_str).unwrap_or("");
// tool_use: show tool name
if item_type == "tool_use" {
let name = item
.get("name")
.and_then(Value::as_str)
.unwrap_or("unknown");
return Some(format!("[Tool: {name}]"));
}
// tool_result: extract nested content
if item_type == "tool_result" {
if let Some(content) = item.get("content") {
let text = extract_text(content);
if !text.is_empty() {
return Some(text);
}
}
return None;
}
if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
return Some(text.to_string());
}
@@ -119,3 +150,25 @@ pub fn path_basename(value: &str) -> Option<String> {
.filter(|segment| !segment.is_empty())?;
Some(last.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parse_timestamp_to_ms_supports_integers_and_rfc3339() {
assert_eq!(
parse_timestamp_to_ms(&json!(1_771_061_953_033_i64)),
Some(1_771_061_953_033)
);
assert_eq!(
parse_timestamp_to_ms(&json!(1_771_061_953_i64)),
Some(1_771_061_953_000)
);
assert_eq!(
parse_timestamp_to_ms(&json!("1970-01-01T00:00:01Z")),
Some(1_000)
);
}
}