refactor: massive codebase restructuring — naming, splitting, DRY

Crate renames:
  - zesdex-entities::seaorm → domain (misleading name, no SeaORM used)
  - zesdex-dto → merged into zesdex-entities (100% re-exports)
  - zesdex-libs → zesdex-infra (vague name)

Module renames:
  - app/harness → guard (misleading: safety gatekeeper, not test harness)
  - runtime/commands → action_dispatch (name clashed with controller/command)
  - resources → prompts (embedded prompt text, not general resources)
  - tool/seqthink → sequential_think (unreadable abbreviation)
  - msglog/query → insert (module only inserts, never queries)

Dead code removal:
  - app/mode/help.rs (orphaned — not declared in mod.rs)
  - app/mode/loading.rs (orphaned — not declared in mod.rs)

File splitting (71 new files, avg ~115 lines/file):
  - app/runtime/actions/: 1→8 files (was 2030 lines)
  - view/overlays/: 1→16 files (was 1167 lines)
  - tool/lsp/: 1→8 per-tool files (was 909 lines)
  - main.rs: 1→5 files (session, daemon, attach, event_loop)
  - workflow/engine + hive_mind: 2→10 files
  - subagent/engine + auto: 2→9 files
  - lsp/provisioner: 1→5 files
  - review/: 1→6 files
  - guard/: 1→2 files (extracted patterns)
  - state/misc: 1→3 files (input, scroll)
  - mcp/: 1→3 files (transport, adapter)
  - stream/json_repair extracted from turn.rs

DRY:
  - Pattern constants (STUB_PATTERNS etc) in guard/patterns shared with subagent
  - 3 near-identical background spawners → 1 generic + thin wrappers
  - Shared spawn_subagent_with_drain() extracted
  - Shared create_session() in main
  - write_osc52 deduplicated

Bug fixes:
  - archive_message(): sess.db → db (wrong variable name)
  - execute_one_tool(): wrong parameter name
  - check_credential_read() function was missing (restored from test expectations)
This commit is contained in:
asepharyana
2026-07-17 09:08:41 +07:00
parent 1f0ae9f551
commit 9a67137954
139 changed files with 9704 additions and 8858 deletions
@@ -0,0 +1,7 @@
//! Authentication entities: session metadata and PID-file lock.
pub mod session;
pub mod session_lock;
pub use session::Session;
pub use session_lock::SessionLock;
@@ -0,0 +1,128 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Session metadata: id, title, workspace roots, and message/token counts,
//! persisted as `session.json` per session directory.
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// Metadata for one conversation session (distinct from the message
/// history itself, which lives in `Conversation`/the msglog).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: String,
pub created_at: i64,
pub updated_at: i64,
pub title: String,
pub model: String,
pub workspace_roots: Vec<PathBuf>,
pub message_count: u32,
pub token_count: u32,
pub archived: bool,
pub summary: Option<String>,
}
impl Session {
/// Create a new session with the given id/title, defaulting the
/// model, workspace root (current dir), and counters.
pub fn new(id: String, title: String) -> Self {
let now = Utc::now().timestamp_millis();
Session {
id,
created_at: now,
updated_at: now,
title,
model: "anthropic/claude-opus-4-8".to_string(),
workspace_roots: vec![std::env::current_dir().unwrap_or_default()],
message_count: 0,
token_count: 0,
archived: false,
summary: None,
}
}
/// Compute this session's directory under `<base_dir>/sessions/<id>`.
pub fn session_dir(&self, base_dir: &Path) -> PathBuf {
base_dir.join("sessions").join(&self.id)
}
/// Compute this session's `conversation.json` path.
pub fn conversation_path(&self, base_dir: &Path) -> PathBuf {
self.session_dir(base_dir).join("conversation.json")
}
/// Persist this session's metadata to `session.json`, atomically
/// with fsync for crash safety.
///
/// Flow: ensure the session directory exists → serialize to pretty
/// JSON → write to `session.json.tmp` → fsync → rename over
/// `session.json` → fsync parent directory.
///
/// Why: write-then-rename avoids a torn/partial `session.json` if
/// interrupted mid-write; fsync before rename ensures the data is
/// on disk before the rename makes it visible.
///
/// Return: `Ok(())` on success, or an `io::Error` from any step.
pub fn save(&self, base_dir: &Path) -> std::io::Result<()> {
let dir = self.session_dir(base_dir);
std::fs::create_dir_all(&dir)?;
let path = dir.join("session.json");
let data = serde_json::to_string_pretty(self)?;
let tmp = dir.join("session.json.tmp");
std::fs::write(&tmp, data)?;
let f = std::fs::File::open(&tmp)?;
f.sync_all()?;
std::fs::rename(&tmp, path)?;
let _ = std::fs::File::open(&dir).and_then(|d| d.sync_all());
Ok(())
}
/// Load a session's metadata by id from `<base_dir>/sessions/<id>/session.json`.
///
/// Security: the session id is validated to prevent directory traversal
/// (e.g. `../../etc/passwd`). Only alphanumeric, hyphens, underscores,
/// and dots are allowed — no path separators.
///
/// Return: the parsed `Session`, or an `io::Error` if the file is
/// missing or malformed.
pub fn load(id: &str, base_dir: &Path) -> std::io::Result<Self> {
// Reject session ids that contain path separators or parent dir refs
if id.contains('/') || id.contains('\\') || id.contains("..") {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("invalid session id '{id}': must not contain path separators"),
));
}
let path = base_dir.join("sessions").join(id).join("session.json");
let data = std::fs::read_to_string(path)?;
let session: Session = serde_json::from_str(&data)?;
Ok(session)
}
/// List all loadable sessions under `<base_dir>/sessions/`.
///
/// Flow: read the sessions directory → keep subdirectories → attempt
/// `Session::load` for each by its directory name, discarding any
/// that fail to load.
///
/// Return: a `Vec<Session>`, empty if the directory can't be read or
/// contains no valid sessions.
pub fn list(base_dir: &Path) -> Vec<Self> {
let sessions_dir = base_dir.join("sessions");
let Ok(entries) = std::fs::read_dir(&sessions_dir) else {
return Vec::new();
};
entries
.filter_map(std::result::Result::ok)
.filter(|e| e.path().is_dir())
.filter_map(|e| {
let id = e.file_name().to_string_lossy().to_string();
Session::load(&id, base_dir).ok()
})
.collect()
}
}
@@ -0,0 +1,130 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! PID-file based advisory lock preventing two processes from operating on
//! the same session directory concurrently.
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
/// A PID-file lock (`<session_dir>/.lock`) tied to the current process,
/// auto-removed on drop.
#[derive(Debug)]
pub struct SessionLock {
path: PathBuf,
pid: u32,
}
impl SessionLock {
/// Construct a lock handle for a session directory (does not acquire
/// the lock yet — call `try_lock`).
pub fn new(session_dir: &Path) -> Self {
SessionLock {
path: session_dir.join(".lock"),
pid: std::process::id(),
}
}
/// Attempt to acquire the session lock using an atomic file creation.
///
/// Flow: try `O_CREAT | O_EXCL` via `create_new(true)` → if that
/// succeeds, the lock is ours — write our PID and return ok. If the
/// file already exists, read the PID inside it and check `is_alive`:
/// if that process is still running, fail to acquire; otherwise the
/// lock is stale — overwrite it with our own PID and succeed.
///
/// Why: `create_new(true)` is atomic on POSIX (unlike the previous
/// read-then-write pattern which had a TOCTOU race between checking
/// `path.exists()` and writing). The stale-lock recovery path reads
/// the stale PID and verifies liveness via `kill(pid, 0)`.
///
/// Return: `Ok(true)` if acquired, `Ok(false)` if another live
/// process holds it, `Err` on I/O failure.
pub fn try_lock(&self) -> std::io::Result<bool> {
// Phase 1: try atomic create. If it succeeds, the lock is ours.
match fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&self.path)
{
Ok(mut file) => {
write!(file, "{}", self.pid)?;
file.sync_all()?;
return Ok(true);
}
Err(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
// Lock file exists — check if it's stale.
}
Err(e) => return Err(e),
}
// Phase 2: lock file exists — check liveness of the owning process.
let content = fs::read_to_string(&self.path).unwrap_or_default();
if let Ok(pid) = content.trim().parse::<u32>() {
if Self::is_alive(pid) {
return Ok(false);
}
}
// Phase 3: stale lock — overwrite it atomically (best-effort).
// Use a temp file + rename to avoid partial writes corrupting the lock.
let tmp = self.path.with_extension("lock.tmp");
{
let mut tmp_file = fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&tmp)?;
write!(tmp_file, "{}", self.pid)?;
tmp_file.sync_all()?;
}
fs::rename(&tmp, &self.path)?;
// Sync the parent directory so the rename survives a crash.
if let Some(parent) = self.path.parent() {
let _ = fs::File::open(parent).and_then(|d| d.sync_all());
}
Ok(true)
}
/// Explicitly release the lock by removing the lock file.
pub fn unlock(&self) {
let _ = fs::remove_file(&self.path);
}
/// Check whether a process with the given PID is currently alive and
/// is actually a zesdex process (not a recycled PID from a different
/// program).
fn is_alive(pid: u32) -> bool {
// SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks
// whether the process exists and the caller has permission to signal
// it. The integer argument is a PID already validated by `try_lock`.
if unsafe { libc::kill(pid as i32, 0) != 0 } {
return false;
}
// Extra check: verify the PID belongs to a zesdex process via
// /proc/<pid>/exe to mitigate the PID-reuse race (a recycled PID
// from a different program would answer kill but shouldn't hold
// our lock). This is best-effort — /proc may not be available
// on all platforms.
let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe"));
if let Ok(target) = std::fs::read_link(&proc_exe) {
if let Ok(exe) = std::env::current_exe() {
if target != exe {
return false;
}
}
}
true
}
}
impl Drop for SessionLock {
/// Release the lock automatically when the guard goes out of scope,
/// so an ungracefully-exited process doesn't leave a dangling lock.
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
@@ -0,0 +1,116 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! In-memory conversation state: message history plus the system prompt and
//! model parameters used to drive the LLM.
use serde::{Deserialize, Serialize};
use super::message::{ChatMessage, Role};
/// A single conversation's message history and generation settings.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Conversation {
pub messages: Vec<ChatMessage>,
pub system_prompt: String,
pub session_id: String,
pub model: String,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
}
impl Conversation {
/// Create an empty conversation with the given system prompt and
/// session id, using default model/token/temperature settings.
pub fn new(system_prompt: String, session_id: String) -> Self {
Conversation {
messages: Vec::new(),
system_prompt,
session_id,
model: "anthropic/claude-opus-4-8".to_string(),
max_tokens: None,
temperature: None,
}
}
/// Append a message to the conversation history.
pub fn push(&mut self, msg: ChatMessage) {
self.messages.push(msg);
}
/// Replace the system prompt and strip any prior `System`-role
/// messages from history.
///
/// Why: the system prompt is re-injected fresh at request time via
/// `to_api_messages`, so stale `System` messages in `self.messages`
/// would be redundant/conflicting if left in place.
pub fn rebuild_system(&mut self, new_prompt: String) {
self.system_prompt = new_prompt;
self.messages.retain(|m| !matches!(m.role, Role::System));
}
/// Build the message list to send to the LLM API, with the system
/// prompt prepended.
///
/// Return: a new `Vec` (clone of history) with a synthesized system
/// message at index 0.
pub fn to_api_messages(&self) -> Vec<ChatMessage> {
let mut msgs = Vec::with_capacity(self.messages.len() + 1);
msgs.push(ChatMessage::system(&self.system_prompt));
msgs.extend(self.messages.iter().cloned());
msgs
}
/// Number of messages in the conversation history (excluding the
/// synthesized system message).
pub fn len(&self) -> usize {
self.messages.len()
}
/// Returns `true` if the conversation has no messages.
pub fn is_empty(&self) -> bool {
self.messages.is_empty()
}
/// Persist the conversation to a JSON file at the given base directory.
///
/// Flow: compute path from `session_id` → ensure directory exists →
/// serialize to pretty JSON → write-then-rename with fsync.
///
/// Return: `Ok(())` on success, or an `io::Error` from any step.
pub fn save_conversation(&self, base_dir: &std::path::Path) -> std::io::Result<()> {
let dir = base_dir.join("sessions").join(&self.session_id);
std::fs::create_dir_all(&dir)?;
let path = dir.join("conversation.json");
let data = serde_json::to_string_pretty(self)?;
let tmp = dir.join("conversation.json.tmp");
std::fs::write(&tmp, data)?;
let f = std::fs::File::open(&tmp)?;
f.sync_all()?;
std::fs::rename(&tmp, path)?;
let _ = std::fs::File::open(&dir).and_then(|d| d.sync_all());
Ok(())
}
/// Load a conversation from a JSON file for the given session id.
///
/// Flow: read `<base_dir>/sessions/<session_id>/conversation.json` →
/// JSON-parse.
///
/// Return: the parsed `Conversation`, or an `io::Error` if the file is
/// missing or malformed.
pub fn load_conversation(
session_id: &str,
base_dir: &std::path::Path,
) -> std::io::Result<Self> {
let path = base_dir
.join("sessions")
.join(session_id)
.join("conversation.json");
let data = std::fs::read_to_string(path)?;
let conv: Conversation = serde_json::from_str(&data)?;
Ok(conv)
}
}
@@ -0,0 +1,111 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Chat message types shared across the entity layer: `Role` and `ChatMessage`
//! with convenience constructors.
use serde::{Deserialize, Serialize};
/// The conversation participant who authored a message.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Role {
#[serde(rename = "user")]
User,
#[serde(rename = "assistant")]
Assistant,
#[serde(rename = "system")]
System,
#[serde(rename = "tool")]
Tool,
}
impl Role {
/// Return the role as a lowercase string.
pub fn as_str(&self) -> &'static str {
match self {
Role::User => "user",
Role::Assistant => "assistant",
Role::System => "system",
Role::Tool => "tool",
}
}
}
impl std::fmt::Display for Role {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// A single message in a conversation, compatible with the OpenAI/Anthropic
/// chat-completion API structures.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: Role,
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<super::tool_call::ToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
impl ChatMessage {
/// Build a user-role message with the given text content.
pub fn user(content: impl Into<String>) -> Self {
ChatMessage {
role: Role::User,
content: Some(content.into()),
tool_calls: None,
tool_call_id: None,
name: None,
}
}
/// Build an assistant-role message with an optional text response.
pub fn assistant(content: Option<String>) -> Self {
ChatMessage {
role: Role::Assistant,
content,
tool_calls: None,
tool_call_id: None,
name: None,
}
}
/// Build a system-role message with the given instruction text.
pub fn system(content: impl Into<String>) -> Self {
ChatMessage {
role: Role::System,
content: Some(content.into()),
tool_calls: None,
tool_call_id: None,
name: None,
}
}
/// Build a tool-role result message referencing a prior tool call.
pub fn tool(tool_call_id: String, content: String) -> Self {
ChatMessage {
role: Role::Tool,
content: Some(content),
tool_calls: None,
tool_call_id: Some(tool_call_id),
name: None,
}
}
/// Alias for `tool`, used throughout the codebase for tool results.
pub fn tool_result(tool_call_id: String, content: String) -> Self {
ChatMessage {
role: Role::Tool,
content: Some(content),
tool_calls: None,
tool_call_id: Some(tool_call_id),
name: None,
}
}
}
@@ -0,0 +1,22 @@
//! Common entity types shared across the Zesdex application: application
//! configuration, settings, store paths, conversations, messages, tool
//! calls, usage stats, and SSE streaming types.
pub mod conversation;
pub mod message;
pub mod provider;
pub mod store;
pub mod tool_call;
pub mod tool_result;
pub mod usage;
pub use conversation::Conversation;
pub use message::{ChatMessage, Role};
pub use provider::{
ChatRequest, ChatResponse, Choice, SseParser, StreamEvent, StreamOptions, ToolDef,
ToolFunctionDef,
};
pub use store::Store;
pub use tool_call::{ToolCall, ToolFunction};
pub use tool_result::ToolCallResult;
pub use usage::UsageStats;
@@ -0,0 +1,329 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Provider-facing DTOs: chat completion request, response, streaming types,
//! and the SSE stream parser.
use serde::{Deserialize, Serialize};
use serde_json::Value;
// ---------------------------------------------------------------------------
// Chat request / response
// ---------------------------------------------------------------------------
/// Outbound chat completion request body sent to an OpenAI/Anthropic-compatible
/// provider.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatRequest {
pub model: String,
pub messages: Vec<super::message::ChatMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<ToolDef>>,
/// Controls which (if any) function is called by the model.
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_options: Option<StreamOptions>,
}
/// Streaming options for the request; `include_usage` asks the provider to
/// emit a final usage chunk in the SSE stream.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamOptions {
pub include_usage: bool,
}
/// Wire format for a single tool definition sent to the provider.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDef {
#[serde(rename = "type")]
pub type_: String,
pub function: ToolFunctionDef,
}
/// Name, description, and JSON schema parameters for a tool definition.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolFunctionDef {
pub name: String,
pub description: String,
pub parameters: Value,
}
/// Non-streaming chat completion response returned by the provider.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatResponse {
pub id: String,
pub object: Option<String>,
pub model: String,
pub choices: Vec<Choice>,
pub usage: Option<TokenUsage>,
pub created: Option<i64>,
}
/// One completion candidate within a `ChatResponse.choices` list.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Choice {
pub index: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<super::message::ChatMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delta: Option<Delta>,
#[serde(skip_serializing_if = "Option::is_none")]
pub finish_reason: Option<String>,
}
/// Incremental delta emitted in a streaming SSE chunk.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Delta {
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<super::message::Role>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<super::tool_call::ToolCall>>,
}
/// Token counts and optional cost breakdown for a single completion request.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TokenUsage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_tokens_cost: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub completion_tokens_cost: Option<f64>,
}
// ---------------------------------------------------------------------------
// SSE streaming
// ---------------------------------------------------------------------------
/// One atomic event extracted from an LLM streaming response stream.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum StreamEvent {
Token(String),
Reasoning(String),
ToolCallDelta {
index: usize,
id: Option<String>,
name: Option<String>,
arguments_delta: String,
},
Usage {
prompt_tokens: u64,
completion_tokens: u64,
total_tokens: u64,
},
Done,
Error(String),
}
/// Buffered SSE frame parser that accumulates raw `data:` lines and
/// flushes a `StreamEvent` on each blank-line boundary.
pub struct SseParser {
buffer: String,
event_type: Option<String>,
data_lines: Vec<String>,
}
impl SseParser {
/// Create a new parser with an empty buffer.
pub fn new() -> Self {
SseParser {
buffer: String::new(),
event_type: None,
data_lines: Vec::new(),
}
}
/// Feed a raw SSE chunk and produce any completed events.
///
/// Flow: append chunk to buffer → scan for '\n' → strip '\r' → on
/// blank line, call `flush_event` to parse the accumulated data →
/// on `event:` line, store the event type → on `data:` line, append
/// to data accumulator → continue until buffer exhausted.
///
/// Edge case: a chunk may split mid-line; the remainder stays in the
/// buffer for the next `feed()` call.
///
/// Return: all `StreamEvent`s completed by this chunk.
pub fn feed(&mut self, chunk: &str) -> Vec<StreamEvent> {
self.buffer.push_str(chunk);
let mut events = Vec::new();
while let Some(line_end) = self.buffer.find('\n') {
let line = self.buffer[..line_end].trim_end_matches('\r').to_string();
self.buffer = self.buffer[line_end + 1..].to_string();
if line.is_empty() {
events.extend(self.flush_event());
} else if let Some(ty) = line.strip_prefix("event: ") {
self.event_type = Some(ty.trim().to_string());
} else if let Some(data) = line.strip_prefix("data:") {
let data = data.trim_start().to_string();
self.data_lines.push(data);
}
}
events
}
/// Flush the current buffered `data:` lines as one or more `StreamEvent`s.
///
/// Flow: join data lines → handle `[DONE]` sentinel → JSON-parse →
/// emit `Usage` if a usage object is present → else match `event_type`
/// ("message.stop", "message.delta", etc.) → extract content,
/// reasoning, tool-call deltas, or finish-reason from the delta
/// structure (supporting both Anthropic-style top-level delta and
/// OpenAI-style `choices` array).
///
/// Return: 0, 1, or more `StreamEvent`s from the flushed frame.
fn flush_event(&mut self) -> Vec<StreamEvent> {
let data = self.data_lines.join("\n");
self.data_lines.clear();
let event_type = self.event_type.take().unwrap_or_default();
if data.is_empty() || data == "[DONE]" {
if data == "[DONE]" {
return vec![StreamEvent::Done];
}
return vec![];
}
let value: Value = match serde_json::from_str(&data) {
Ok(v) => v,
Err(e) => {
tracing::warn!("[stream] failed to parse chunk: {}", e);
return vec![];
}
};
let mut events = Vec::new();
if let Some(usage) = value.get("usage") {
if !usage.is_null() {
let prompt_tokens = usage
.get("prompt_tokens")
.and_then(Value::as_u64)
.unwrap_or_else(|| {
tracing::warn!("[stream] prompt_tokens missing in usage chunk");
0
});
let completion_tokens = usage
.get("completion_tokens")
.and_then(Value::as_u64)
.unwrap_or_else(|| {
tracing::warn!("[stream] completion_tokens missing in usage chunk");
0
});
let total_tokens = usage
.get("total_tokens")
.and_then(Value::as_u64)
.unwrap_or_else(|| {
tracing::warn!("[stream] total_tokens missing in usage chunk");
prompt_tokens + completion_tokens
});
events.push(StreamEvent::Usage {
prompt_tokens,
completion_tokens,
total_tokens,
});
}
}
let mut other_events = match event_type.as_str() {
"message.stop" => vec![StreamEvent::Done],
"message.delta" | "" => {
let mut d_events = Vec::new();
if let Some(delta) = value.get("delta").or_else(|| value.get("choices")) {
if let Some(choices) = delta.as_array() {
if let Some(choice) = choices.first() {
if let Some(d) = choice.get("delta") {
// Content token
if let Some(content) = d.get("content").and_then(|c| c.as_str()) {
d_events.push(StreamEvent::Token(content.to_string()));
}
// Reasoning token
if let Some(reasoning) =
d.get("reasoning_content").and_then(|r| r.as_str())
{
d_events.push(StreamEvent::Reasoning(reasoning.to_string()));
}
// Tool calls — iterate ALL entries, not just first()
if let Some(tool_calls) =
d.get("tool_calls").and_then(|tc| tc.as_array())
{
for tc in tool_calls {
let index =
tc.get("index").and_then(Value::as_u64).unwrap_or_else(
|| {
tracing::warn!(
"[stream] tool call delta missing index, \
defaulting to 0"
);
0
},
) as usize;
let id = tc
.get("id")
.and_then(|i| i.as_str())
.map(std::string::ToString::to_string);
let name = tc
.get("function")
.and_then(|f| f.get("name"))
.and_then(|n| n.as_str())
.map(std::string::ToString::to_string);
let args_delta = tc
.get("function")
.and_then(|f| f.get("arguments"))
.and_then(|a| a.as_str())
.unwrap_or("")
.to_string();
d_events.push(StreamEvent::ToolCallDelta {
index,
id,
name,
arguments_delta: args_delta,
});
}
}
// Finish reason
if let Some(reason) =
choice.get("finish_reason").and_then(|r| r.as_str())
{
if reason == "stop" || reason == "tool_calls" {
d_events.push(StreamEvent::Done);
}
}
}
}
} else if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
d_events.push(StreamEvent::Token(content.to_string()));
}
}
d_events
}
_ => vec![],
};
events.append(&mut other_events);
events
}
}
impl Default for SseParser {
fn default() -> Self {
Self::new()
}
}
@@ -0,0 +1,63 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Filesystem layout for zesdex's persistent and scratch data directories.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Resolved paths for all data directories zesdex reads from and writes to.
///
/// Why: centralizing path computation here means every consumer agrees on
/// where memory, scratch, session images, and downloads live.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Store {
pub base_dir: PathBuf,
pub scratch_root: PathBuf,
pub memory_dir: PathBuf,
pub session_images_dir: PathBuf,
pub download_dir: PathBuf,
}
impl Store {
/// Compute the standard set of zesdex data directory paths.
///
/// Flow: OS data dir (or `.local/share` fallback) + "zesdex" → base dir;
/// scratch root comes from the OS temp dir instead, since it's disposable.
///
/// Why: paths are computed, not created — call `ensure_dirs` before use.
pub fn new() -> Self {
let base = dirs::data_dir()
.unwrap_or_else(|| PathBuf::from(".local/share"))
.join("zesdex");
let scratch = std::env::temp_dir().join("zesdex-scratch");
Store {
memory_dir: base.join("memory"),
scratch_root: scratch,
session_images_dir: base.join("session-images"),
download_dir: base.join("downloads"),
base_dir: base,
}
}
/// Create all store directories (base, memory, scratch, session images,
/// downloads) if missing.
///
/// Return: `Err` on the first directory that fails to create.
pub fn ensure_dirs(&self) -> std::io::Result<()> {
std::fs::create_dir_all(&self.base_dir)?;
std::fs::create_dir_all(&self.memory_dir)?;
std::fs::create_dir_all(&self.scratch_root)?;
std::fs::create_dir_all(&self.session_images_dir)?;
std::fs::create_dir_all(&self.download_dir)?;
Ok(())
}
}
impl Default for Store {
fn default() -> Self {
Self::new()
}
}
@@ -0,0 +1,147 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Tool-call DTOs embedded in assistant chat messages.
//!
//! Flow: provider response/stream carries `tool_calls` on an assistant
//! message → deserialized into `ToolCall`/`ToolFunction` → harness resolves
//! `function.name` against `all_tools()` and runs it with
//! `sanitize_tool_arguments(function.arguments)`.
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// A single tool-call request emitted by the model in an assistant message.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
#[serde(rename = "type")]
pub type_: String,
pub function: ToolFunction,
}
/// The function name and raw arguments payload for a `ToolCall`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolFunction {
pub name: String,
pub arguments: Value,
}
/// Normalize tool-call arguments into a JSON object/value.
///
/// Flow: some providers send `arguments` as a JSON-encoded string rather
/// than a nested object; if `args` is a string, attempt to parse it as
/// JSON. Objects and other value types pass through unchanged.
///
/// Security: on parse failure we wrap the raw string in `{ "_raw": "..." }`
/// instead of passing it through as a raw string, so tools that expect a
/// JSON object (via `args.get("key")`) get `None` rather than unexpectedly
/// receiving a plain string value.
///
/// Attempt to fix truncated JSON by closing open strings, braces and brackets.
pub fn sanitize_tool_arguments(args: &Value) -> Value {
match args {
Value::String(s) => {
// Attempt 1: direct parse.
if let Ok(v) = serde_json::from_str::<Value>(s) {
return v;
}
// Attempt 2: strip control chars (0x00-0x1F except \t, \n)
let cleaned: String = s
.chars()
.filter(|&c| !c.is_control() || c == '\t' || c == '\n' || c == '\r')
.collect();
if cleaned.len() != s.len() {
if let Ok(v) = serde_json::from_str::<Value>(&cleaned) {
tracing::warn!(
"tool argument contained control characters — stripped \
and reparsed successfully",
);
return v;
}
}
// Attempt 3: repair truncated JSON and retry.
let input = if cleaned.len() == s.len() {
s
} else {
&cleaned
};
let repaired = repair_json(input);
match serde_json::from_str::<Value>(&repaired) {
Ok(v) => {
tracing::warn!("tool argument string was truncated — repaired successfully",);
v
}
Err(e2) => {
tracing::error!(
"tool argument is a JSON string but failed to parse. \
Wrapping in object. Error: {}. Raw (first 200): {}",
e2,
s.chars().take(200).collect::<String>(),
);
serde_json::json!({"_raw": s, "_parse_error": e2.to_string()})
}
}
}
obj @ Value::Object(_) => obj.clone(),
other => other.clone(),
}
}
/// Repair truncated JSON by closing open strings, braces and brackets.
///
/// Flow: single-pass character scan tracking string/escape state with a
/// LIFO stack for `{`/`[` → append missing `"`, `]`, `}` in the right
/// (reverse nesting) order.
pub fn repair_json(s: &str) -> String {
let mut stack: Vec<char> = Vec::new();
let mut in_string = false;
let mut prev_was_backslash = false;
let mut ends_with_unclosed_escape = false;
for c in s.chars() {
if prev_was_backslash {
prev_was_backslash = false;
ends_with_unclosed_escape = false;
continue;
}
if c == '\\' && in_string {
prev_was_backslash = true;
ends_with_unclosed_escape = true;
continue;
}
ends_with_unclosed_escape = false;
if c == '"' {
in_string = !in_string;
continue;
}
if in_string {
continue;
}
match c {
'{' | '[' => stack.push(c),
'}' | ']' => {
stack.pop();
}
_ => {}
}
}
let mut result = s.to_string();
if ends_with_unclosed_escape {
result.pop();
}
if in_string {
result.push('"');
}
for &opener in stack.iter().rev() {
match opener {
'{' => result.push('}'),
'[' => result.push(']'),
_ => {}
}
}
result
}
@@ -0,0 +1,37 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Record of one completed tool invocation, kept for transcript/history.
use serde::{Deserialize, Serialize};
/// Record of a completed tool invocation, kept for transcript/history.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallResult {
pub tool_call_id: String,
pub tool_name: String,
pub output: String,
pub is_error: bool,
pub duration_ms: u64,
}
impl ToolCallResult {
/// Create a new tool call result.
pub fn new(
tool_call_id: String,
tool_name: String,
output: String,
is_error: bool,
duration_ms: u64,
) -> Self {
ToolCallResult {
tool_call_id,
tool_name,
output,
is_error,
duration_ms,
}
}
}
@@ -0,0 +1,29 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Token usage accounting shared by streaming and non-streaming responses.
use serde::{Deserialize, Serialize};
/// Cumulative token/latency counters for a session, persisted alongside it.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
pub struct UsageStats {
pub tokens_in: u64,
pub tokens_out: u64,
#[serde(default)]
pub last_tokens_in: u64,
#[serde(default)]
pub last_tokens_out: u64,
pub api_calls: u64,
pub review_tokens: u64,
pub total_ms: u64,
}
impl UsageStats {
/// Create a new `UsageStats` with all counters zeroed.
pub fn new() -> Self {
Self::default()
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Domain entity modules organised by concern — pure data structures with
//! serde serialisation and filesystem persistence (serde JSON + std::fs).
pub mod auth;
pub mod common;