Files
CC-Switch/src-tauri/src/session_manager/providers/opencode.rs
T
Dex Miller 67e074c0a7 refactor(proxy): transparent header forwarding via hyper client (#1714)
* style(frontend): reformat provider forms, constants and hooks

Apply prettier formatting across 5 frontend files. No logic changes.

Changed files:
- AddProviderDialog.tsx: reformat generic type annotation and callback
- ClaudeFormFields.tsx: consolidate multi-line useState and Collapsible props
- CodexConfigSections.tsx: expand single-line React imports to multi-line,
  collapse removeCodexTopLevelField() call
- constants.ts: merge TemplateType into single line
- useSkills.ts: expand single-line TanStack Query imports to multi-line,
  reformat uninstallSkill mutationFn chain

* deps(proxy): add hyper ecosystem crates and manual decompression libs

reqwest internally normalizes all header names to lowercase and does not
preserve insertion order, causing proxied requests to differ from the
original client requests. To achieve transparent header forwarding with
original casing and order, introduce lower-level hyper HTTP client libs.

New dependencies:
- hyper-util 0.1: TokioExecutor + legacy Client with
  preserve_header_case support for HTTP/1.1
- hyper-rustls 0.27: rustls-based TLS connector for hyper
- http 1 / http-body 1 / http-body-util 0.1: HTTP type crates for
  hyper 1.x request/response construction
- flate2 1: manual gzip/deflate decompression (replaces reqwest auto)
- brotli 7: manual brotli decompression

Changed dependencies:
- serde_json: enable preserve_order feature to keep JSON field order
- reqwest: drop gzip feature to prevent reqwest from overriding the
  client's original accept-encoding header

* refactor(proxy): use hyper client for header-case preserving forwarding

Previously the proxy used reqwest for all upstream requests. reqwest
normalizes header names to lowercase and reorders them internally,
making proxied requests distinguishable from direct CLI requests.
Some upstream providers are sensitive to these differences.

This commit replaces reqwest with a hyper-based HTTP client on the
default (non-proxy) path, achieving wire-level header fidelity:

Server layer (server.rs):
- Replace axum::serve with a manual hyper HTTP/1.1 accept loop
- Enable preserve_header_case(true) so incoming header casing is
  captured in a HeaderCaseMap extension on each request
- Bridge hyper requests to axum Router via tower::Service

New hyper client module (hyper_client.rs):
- Lazy-initialized hyper-util Client with preserve_header_case
- ProxyResponse enum wrapping both hyper::Response and reqwest::Response
  behind a unified interface (status, headers, bytes, bytes_stream)
- send_request() builds requests with ordered HeaderMap + case map

Request handlers (handlers.rs):
- Switch from (HeaderMap, Json<Value>) extractors to raw
  axum::extract::Request to preserve Extensions (containing the
  HeaderCaseMap from the accept loop)
- Pass extensions through the forwarding chain

Forwarder (forwarder.rs):
- Remove HEADER_BLACKLIST array; replace with ordered header iteration
  that preserves original header sequence and casing
- Build ordered_headers by iterating client headers, skipping only
  auth/host/content-length, and inserting auth headers at the original
  authorization position to maintain order
- Handle anthropic-beta (ensure claude-code-20250219 tag) and
  anthropic-version (passthrough or default) inline during iteration
- Remove should_force_identity_encoding() — accept-encoding is now
  transparently forwarded to upstream
- Use hyper client by default; fall back to reqwest only when an
  HTTP/SOCKS5 proxy tunnel is configured

Provider adapters (adapter.rs, claude.rs, codex.rs, gemini.rs):
- Replace add_auth_headers(RequestBuilder) -> RequestBuilder with
  get_auth_headers(AuthInfo) -> Vec<(HeaderName, HeaderValue)>
- Adapters now return header pairs instead of mutating a reqwest builder
- Claude adapter: merge Anthropic/ClaudeAuth/Bearer into single branch;
  move Copilot fingerprint headers into get_auth_headers

Response processing (response_processor.rs):
- Add manual decompression (gzip/deflate/brotli via flate2 + brotli)
  for non-streaming responses, since reqwest auto-decompression is now
  disabled to allow accept-encoding passthrough
- Add compressed-SSE warning log for streaming responses
- Accept ProxyResponse instead of reqwest::Response

HTTP client (http_client.rs):
- Disable reqwest auto-decompression (.no_gzip/.no_brotli/.no_deflate)
  on both global and per-provider clients

Streaming adapters (streaming.rs, streaming_responses.rs):
- Generalize stream error type from reqwest::Error to generic E: Error

Misc:
- log_codes.rs: add SRV-005 (ACCEPT_ERR) and SRV-006 (CONN_ERR)
- stream_check.rs: reformat copilot header lines
- transform.rs: fix trailing whitespace alignment

* fix(lint): resolve 35 clippy warnings across Rust codebase

Fix all clippy warnings reported by `cargo clippy --lib`:

- codex_config.rs: fix doc_overindented_list_items (3 spaces -> 2)
- commands/copilot.rs: inline format args in 2 log::error! calls
- commands/provider.rs: inline format args in 3 map_err closures
- proxy/hyper_client.rs: inline format arg in log::debug! call
- proxy/providers/copilot_auth.rs: inline format args in 16 locations
  (log macros, format! in headers, error constructors)
- proxy/thinking_optimizer.rs: inline format args in 2 log::info! calls
- services/skill.rs: inline format args in log::debug! call
- services/webdav_sync.rs: inline format args in 6 format! calls
  (version compat messages, download limit messages)
- services/webdav_sync/archive.rs: inline format args in 2 format! calls
- session_manager/providers/opencode.rs: inline format args in
  source_path format!

All fixes use the clippy::uninlined_format_args suggestion pattern:
  format!("msg: {}", var)  ->  format!("msg: {var}")

* deps(proxy): add raw HTTP write and native TLS cert dependencies

Add crates required for the raw TCP/TLS write path that bypasses
hyper's header encoder to preserve original header name casing:

- httparse: parse raw TCP peek bytes to capture header casings
- tokio-rustls + rustls: direct TLS connections for raw write path
- webpki-roots: Mozilla CA bundle baseline
- rustls-native-certs: load system keychain CAs (trusts proxy MITM
  certificates from Clash, mitmproxy, etc.)

* fix(proxy): address code review feedback on response handling

Fixes from PR #1714 code review:

- Extract `read_decoded_body()` and `strip_entity_headers_for_rebuilt_body()`
  in response_processor to properly clean content-encoding/content-length
  headers after decompression
- Reuse `read_decoded_body()` in handlers.rs for Claude transform path,
  ensuring compressed responses are decoded before format conversion
- Make `build_proxy_url_from_config()` public so forwarder can pass proxy
  URL to the hyper raw write path
- Add `has_system_proxy_env()` utility with test coverage
- Add 50ms backoff after accept() failures in server.rs to prevent
  tight-loop CPU spin on transient socket errors

* feat(proxy): implement raw TCP/TLS write with HTTP CONNECT tunnel

Rewrite hyper_client with a two-tier strategy for header case preservation:

Primary path (raw write):
- Peek raw TCP bytes in server.rs to capture OriginalHeaderCases before
  hyper lowercases them
- Build raw HTTP/1.1 request bytes with exact original header name casing
- Write directly to TLS stream, then use WriteFilter to let hyper parse
  the response while discarding its duplicate request writes
- Support HTTP CONNECT tunneling through upstream proxies, so header case
  is preserved even when a proxy (Clash, V2Ray) is configured

Fallback path (hyper-util Client):
- Used when OriginalHeaderCases is empty or raw write fails
- Configured with title_case_headers(true) for best-effort casing

TLS improvements:
- Load native system certificates alongside webpki roots so proxy MITM
  CAs (installed in system keychain) are trusted through CONNECT tunnels

Key types added:
- OriginalHeaderCases: maps lowercase name → original wire-casing bytes
- WriteFilter<S>: AsyncRead+AsyncWrite wrapper that discards writes
- connect_via_proxy(): HTTP CONNECT tunnel establishment
- ExtensionDebugMarker: diagnostic marker for extension chain debugging

* refactor(proxy): route requests through hyper with proxy-aware forwarding

Rework forwarder request dispatch to always prefer the hyper raw write
path (header case preservation) over reqwest:

Request routing:
- HTTP/HTTPS proxy: hyper raw write through CONNECT tunnel (case preserved)
- SOCKS5 proxy: reqwest fallback (CONNECT not supported for SOCKS5)
- No proxy: hyper raw write direct connection

Header handling improvements:
- Replace host header in-place at original position instead of
  skip-and-append, preserving client's header ordering
- Preserve client's original accept-encoding for transparent passthrough;
  only force identity encoding when transform path needs decompression
- Add should_force_identity_encoding() to centralize the decision
- Remove hardcoded 'br, gzip, deflate' override that masked client values

Proxy URL resolution (priority order):
1. Provider-specific proxy config (if enabled)
2. Global proxy URL configured in CC Switch
3. Direct connection (no proxy)

* chore(proxy): remove dead code, redundant tests and debug scaffolding

- Inline should_force_identity_encoding() (was just `needs_transform`)
  and delete its 5 test cases
- Remove ExtensionDebugMarker diagnostic type
- Remove unused has_system_proxy_env() and its test
- Remove strip_entity_headers test
- Simplify hyper path: remove redundant is_socks_proxy ternary
- Update hyper_client module doc to reflect CONNECT tunnel support

* fix(proxy): block direct-connect fallback and complete CONNECT tunnel support

* feat(hooks): improve proxy requirement warnings with specific reasons

- Remove redundant OpenAI format hint toast messages
- Add detailed reason detection for proxy requirements (OpenAI Chat, OpenAI Responses, full URL mode)
- Update i18n files with new reason-specific keys

* style(*): format code with prettier

- Remove extra whitespace in http_client.rs
- Fix formatting issues in useProviderActions.ts

* fix(proxy): post-merge fixes for forward return type and clippy warnings

- Restore forward() return type to (ProxyResponse, Option<String>)
  to pass claude_api_format through to callers
- Inline format args in log::warn! macro (clippy::uninlined_format_args)
- Suppress too_many_arguments for check_claude_stream

* refactor(proxy): preserve original header wire order and add non-streaming body timeout

- Rewrite build_raw_request to emit headers in original
  client-sent sequence instead of hash-map order
- Remove unused OriginalHeaderCases::get_all method
- Add body_timeout to read_decoded_body to prevent
  requests hanging when upstream stalls after headers
2026-03-29 20:26:15 +08:00

998 lines
34 KiB
Rust

use std::path::{Path, PathBuf};
use rusqlite::Connection;
use serde_json::Value;
use crate::session_manager::{SessionMessage, SessionMeta};
use super::utils::{parse_timestamp_to_ms, path_basename, truncate_summary};
const PROVIDER_ID: &str = "opencode";
/// Return the OpenCode base directory (`$XDG_DATA_HOME/opencode`).
///
/// Respects `XDG_DATA_HOME` on all platforms; falls back to
/// `~/.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");
}
}
dirs::home_dir()
.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() {
return Vec::new();
}
let mut json_files = Vec::new();
collect_json_files(&session_dir, &mut json_files);
let mut sessions = Vec::new();
for path in json_files {
if let Some(meta) = parse_session(&storage, &path) {
sessions.push(meta);
}
}
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() {
return Err(format!("Message directory not found: {}", path.display()));
}
let storage = path
.parent()
.and_then(|p| p.parent())
.ok_or_else(|| "Cannot determine storage root from message path".to_string())?;
let mut msg_files = Vec::new();
collect_json_files(path, &mut msg_files);
// Parse all messages and collect (created_ts, message_id, role, parts_text)
let mut entries: Vec<(i64, String, String, String)> = Vec::new();
for msg_path in &msg_files {
let data = match std::fs::read_to_string(msg_path) {
Ok(d) => d,
Err(_) => continue,
};
let value: Value = match serde_json::from_str(&data) {
Ok(v) => v,
Err(_) => continue,
};
let msg_id = match value.get("id").and_then(Value::as_str) {
Some(id) => id.to_string(),
None => continue,
};
let role = value
.get("role")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
let created_ts = value
.get("time")
.and_then(|t| t.get("created"))
.and_then(parse_timestamp_to_ms)
.unwrap_or(0);
// Collect text parts from storage/part/{messageID}/
let part_dir = storage.join("part").join(&msg_id);
let text = collect_parts_text(&part_dir);
if text.trim().is_empty() {
continue;
}
entries.push((created_ts, msg_id, role, text));
}
// Sort by created timestamp
entries.sort_by_key(|(ts, _, _, _)| *ts);
let messages = entries
.into_iter()
.map(|(ts, _, role, content)| SessionMessage {
role,
content,
ts: if ts > 0 { Some(ts) } else { None },
})
.collect();
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!(
"OpenCode session path does not match session ID: expected {session_id}, found {}",
path.display()
));
}
let mut message_files = Vec::new();
collect_json_files(path, &mut message_files);
let mut message_ids = Vec::new();
for message_path in &message_files {
let data = match std::fs::read_to_string(message_path) {
Ok(data) => data,
Err(_) => continue,
};
let value: Value = match serde_json::from_str(&data) {
Ok(value) => value,
Err(_) => continue,
};
if let Some(message_id) = value.get("id").and_then(Value::as_str) {
message_ids.push(message_id.to_string());
}
}
for message_id in &message_ids {
let part_dir = storage.join("part").join(message_id);
remove_dir_all_if_exists(&part_dir).map_err(|e| {
format!(
"Failed to delete OpenCode part directory {}: {e}",
part_dir.display()
)
})?;
}
let session_diff_path = storage
.join("session_diff")
.join(format!("{session_id}.json"));
remove_file_if_exists(&session_diff_path).map_err(|e| {
format!(
"Failed to delete OpenCode session diff {}: {e}",
session_diff_path.display()
)
})?;
remove_dir_all_if_exists(path).map_err(|e| {
format!(
"Failed to delete OpenCode message directory {}: {e}",
path.display()
)
})?;
if let Some(session_file) = find_session_file(storage, session_id) {
remove_file_if_exists(&session_file).map_err(|e| {
format!(
"Failed to delete OpenCode session file {}: {e}",
session_file.display()
)
})?;
}
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()?;
let session_id = value.get("id").and_then(Value::as_str)?.to_string();
let title = value
.get("title")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let directory = value
.get("directory")
.and_then(Value::as_str)
.map(|s| s.to_string());
let created_at = value
.get("time")
.and_then(|t| t.get("created"))
.and_then(parse_timestamp_to_ms);
let updated_at = value
.get("time")
.and_then(|t| t.get("updated"))
.and_then(parse_timestamp_to_ms);
// Derive title from directory basename if no explicit title
let has_title = title.is_some();
let display_title = title.or_else(|| {
directory
.as_deref()
.and_then(path_basename)
.map(|s| s.to_string())
});
// Build source_path = message directory for this session
let msg_dir = storage.join("message").join(&session_id);
let source_path = msg_dir.to_string_lossy().to_string();
// Skip expensive I/O if title already available from session JSON
let summary = if has_title {
display_title.clone()
} else {
get_first_user_summary(storage, &session_id)
};
Some(SessionMeta {
provider_id: PROVIDER_ID.to_string(),
session_id: session_id.clone(),
title: display_title,
summary,
project_dir: directory,
created_at,
last_active_at: updated_at.or(created_at),
source_path: Some(source_path),
resume_command: Some(format!("opencode session resume {session_id}")),
})
}
/// Read the first user message's first text part to use as summary.
fn get_first_user_summary(storage: &Path, session_id: &str) -> Option<String> {
let msg_dir = storage.join("message").join(session_id);
if !msg_dir.is_dir() {
return None;
}
let mut msg_files = Vec::new();
collect_json_files(&msg_dir, &mut msg_files);
// Collect user messages with timestamps for ordering
let mut user_msgs: Vec<(i64, String)> = Vec::new();
for msg_path in &msg_files {
let data = match std::fs::read_to_string(msg_path) {
Ok(d) => d,
Err(_) => continue,
};
let value: Value = match serde_json::from_str(&data) {
Ok(v) => v,
Err(_) => continue,
};
if value.get("role").and_then(Value::as_str) != Some("user") {
continue;
}
let msg_id = match value.get("id").and_then(Value::as_str) {
Some(id) => id.to_string(),
None => continue,
};
let ts = value
.get("time")
.and_then(|t| t.get("created"))
.and_then(parse_timestamp_to_ms)
.unwrap_or(0);
user_msgs.push((ts, msg_id));
}
user_msgs.sort_by_key(|(ts, _)| *ts);
// Take first user message and get its parts
let (_, first_id) = user_msgs.first()?;
let part_dir = storage.join("part").join(first_id);
let text = collect_parts_text(&part_dir);
if text.trim().is_empty() {
return None;
}
Some(truncate_summary(&text, 160))
}
/// 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();
}
let mut parts = Vec::new();
collect_json_files(part_dir, &mut parts);
let mut texts = Vec::new();
for part_path in &parts {
let data = match std::fs::read_to_string(part_path) {
Ok(d) => d,
Err(_) => continue,
};
let value: Value = match serde_json::from_str(&data) {
Ok(v) => v,
Err(_) => continue,
};
if let Some(text) = extract_part_text(&value) {
texts.push(text);
}
}
texts.join("\n")
}
fn collect_json_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_json_files(&path, files);
} else if path.extension().and_then(|ext| ext.to_str()) == Some("json") {
files.push(path);
}
}
}
fn find_session_file(storage: &Path, session_id: &str) -> Option<PathBuf> {
let session_root = storage.join("session");
let mut files = Vec::new();
collect_json_files(&session_root, &mut files);
let expected = format!("{session_id}.json");
files
.into_iter()
.find(|path| path.file_name().and_then(|name| name.to_str()) == Some(expected.as_str()))
}
fn remove_file_if_exists(path: &Path) -> std::io::Result<()> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}
fn remove_dir_all_if_exists(path: &Path) -> std::io::Result<()> {
match std::fs::remove_dir_all(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}
#[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");
let storage = temp.path();
let project_id = "project-123";
let session_id = "ses_123";
let session_dir = storage.join("session").join(project_id);
let message_dir = storage.join("message").join(session_id);
let session_diff = storage
.join("session_diff")
.join(format!("{session_id}.json"));
let part_dir = storage.join("part").join("msg_1");
let session_file = session_dir.join(format!("{session_id}.json"));
std::fs::create_dir_all(&session_dir).expect("create session dir");
std::fs::create_dir_all(&message_dir).expect("create message dir");
std::fs::create_dir_all(&part_dir).expect("create part dir");
std::fs::create_dir_all(storage.join("project")).expect("create project dir");
std::fs::create_dir_all(storage.join("session_diff")).expect("create session diff dir");
std::fs::write(
&session_file,
format!(
r#"{{
"id": "{session_id}",
"projectID": "{project_id}",
"directory": "/tmp/project",
"time": {{ "created": 1, "updated": 2 }}
}}"#
),
)
.expect("write session file");
std::fs::write(
message_dir.join("msg_1.json"),
format!(r#"{{"id":"msg_1","sessionID":"{session_id}","role":"user"}}"#),
)
.expect("write message file");
std::fs::write(
part_dir.join("prt_1.json"),
r#"{"id":"prt_1","messageID":"msg_1"}"#,
)
.expect("write part file");
std::fs::write(&session_diff, "[]").expect("write session diff");
std::fs::write(
storage.join("project").join(format!("{project_id}.json")),
r#"{"id":"project-123"}"#,
)
.expect("write project file");
delete_session(storage, &message_dir, session_id).expect("delete session");
assert!(!session_file.exists());
assert!(!message_dir.exists());
assert!(!session_diff.exists());
assert!(!part_dir.exists());
assert!(storage
.join("project")
.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");
}
}
}