refactor: migrate monolithic crate to Cargo Workspace with Clean Architecture

Transform the single binary crate into a 9-crate workspace monorepo:

- Root Cargo.toml as [workspace] manager with resolver = "2"
- zesdex-entities: Domain entity types (session, settings, store, message, etc.)
- zesdex-utils: Pure utility functions (error, logger, pagination, slug, clipboard)
- zesdex-dto: Data Transfer Objects for LLM provider API communication
- zesdex-ipc: Unix-socket IPC layer (client/server/framing/protocol)
- zesdex-iam: Identity & Access Management (Clean Architecture: domain/application/infrastructure)
- zesdex-cms: Content Management (Clean Architecture: domain/application/infrastructure)
- zesdex-middleware: HTTP middleware (Auth, CORS, Rate Limiting)
- zesdex-libs: Composition root (AppContext, DB init, JWT, Argon2)
- zesdex-backend: Main binary entry point + seed/migrate binaries
- DevOps: Dockerfile, docker-compose, Nix (flake/shell/default), CI/CD updates
- Remove dead root src/ and src-misc/ directories

All crate re-exports maintain backward compatibility with original
crate::model::*, crate::dto::*, crate::ipc::* module paths.
Feature crates enforce strict layer separation: domain -> application
-> infrastructure with generic trait-based dependency injection.
This commit is contained in:
asepharyana
2026-07-17 09:08:41 +07:00
parent 86cc412395
commit be0a9582bb
248 changed files with 7901 additions and 1505 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,77 @@
//! Maps parsed `/` slash commands into one or more `Action` variants
//! that `apply_action` can process.
use crate::app::runtime::actions::Action;
use crate::app::state::types::Overlay;
use crate::controller::command::Command;
/// Convert a parsed `Command` into the corresponding sequence of `Action`s.
///
/// Flow: match each `Command` variant to its handler — most produce a
/// single `Action` (open an overlay, dispatch an OAuth flow, open the
/// editor, etc.); some produce an `Action::SystemNote` for errors or
/// informational responses.
///
/// Return: a `Vec<Action>` (always non-empty) to be applied sequentially
/// by `apply_action`.
pub fn apply_command(command: Command) -> Vec<Action> {
match command {
Command::Help => {
vec![Action::OpenOverlay(Overlay::Help)]
}
Command::Quit => {
vec![Action::QuitConfirm]
}
Command::McpOpen => {
vec![Action::OpenOverlay(Overlay::Mcp)]
}
Command::ClearConfirm => {
vec![Action::OpenOverlay(Overlay::ClearConfirm)]
}
Command::Clear => {
vec![Action::SystemNote {
kind: "clear".to_string(),
message: "transcript cleared".to_string(),
}]
}
Command::Login { provider } if provider.is_empty() => {
vec![Action::SystemNote {
kind: "error".to_string(),
message: "Usage: /login <provider>".to_string(),
}]
}
Command::Login { provider } => {
vec![Action::StartOAuth { provider }]
}
Command::Edit(path) if path == "." || path.is_empty() => {
vec![Action::SystemNote {
kind: "info".to_string(),
message: "Usage: /edit <path>\nOpens a file for inline editing.\nExample: /edit src/main.rs".to_string(),
}]
}
Command::Edit(path) => {
vec![Action::OpenEditor { path }]
}
Command::McpAdd { name, command } => {
vec![Action::McpAdd { name, command }]
}
Command::ModelList => {
vec![Action::ModelList]
}
Command::Compact => {
vec![Action::Compact]
}
Command::TodoOpen => {
vec![Action::OpenOverlay(Overlay::Todo)]
}
Command::UsageOpen => {
vec![Action::OpenOverlay(Overlay::Usage)]
}
Command::Unknown(cmd) => {
vec![Action::SystemNote {
kind: "error".to_string(),
message: format!("unknown command: {cmd}"),
}]
}
}
}
@@ -0,0 +1,197 @@
#![allow(dead_code)]
//! Cross-call tool-result deduplication: when a read-only tool is called
//! again with identical arguments, the earlier result is replaced with a
//! placeholder so only the latest copy occupies context.
//!
//! Flow: pair each `Role::Tool` message to its originating `ToolCall` via
//! `tool_call_id` -> key on `(function.name, sha256(canonical_json(args)))`
//! -> for read-only tools, keep only the last occurrence of each key in
//! full, placeholder the rest.
//!
//! Why: reading the same file (or re-running the same grep) twice in a
//! session otherwise keeps both full copies in context until compaction
//! eventually drops the older one wholesale, along with everything else
//! from that period. Mutating tools (`write`, `edit`, `bash`, `delete`,
//! `git_operator`, ...) are never touched, even with identical
//! arguments, because call order and repetition can be semantically
//! meaningful (e.g. retrying a flaky `bash` command until it passes).
use crate::app::subagent::division::tool_scope::READ_TOOLS;
use crate::dto::chat::message::{ChatMessage, Role};
use sha2::Digest;
use std::collections::HashMap;
const DUPLICATE_PLACEHOLDER: &str =
"[duplicate result — superseded by a later identical call, see below]";
/// Replace superseded read-only tool results with a placeholder.
///
/// Return: a `Vec<ChatMessage>` the same length as `messages`, and
/// `true` iff at least one entry was replaced. The caller uses the
/// `bool` to decide whether the result is worth persisting/announcing,
/// without `ChatMessage` needing to implement `PartialEq`.
pub fn collapse(messages: &[ChatMessage]) -> (Vec<ChatMessage>, bool) {
// tool_call_id -> (tool name, canonical JSON of its arguments)
let mut call_info: HashMap<String, (String, String)> = HashMap::new();
for m in messages {
if let Some(calls) = &m.tool_calls {
for call in calls {
let canonical = serde_json::to_string(&call.function.arguments).unwrap_or_default();
call_info.insert(call.id.clone(), (call.function.name.clone(), canonical));
}
}
}
// For each (tool, args-hash) key among read-only tools, find the
// index of its LAST occurrence — that's the one kept in full.
let mut last_index_for_key: HashMap<String, usize> = HashMap::new();
for (idx, m) in messages.iter().enumerate() {
if m.role != Role::Tool {
continue;
}
let Some(id) = &m.tool_call_id else { continue };
let Some((name, args)) = call_info.get(id) else {
continue;
};
if !READ_TOOLS.contains(&name.as_str()) {
continue;
}
last_index_for_key.insert(dedup_key(name, args), idx);
}
let mut changed = false;
let result = messages
.iter()
.enumerate()
.map(|(idx, m)| {
if m.role != Role::Tool {
return m.clone();
}
let Some(id) = &m.tool_call_id else {
return m.clone();
};
let Some((name, args)) = call_info.get(id) else {
return m.clone();
};
if !READ_TOOLS.contains(&name.as_str()) {
return m.clone();
}
let key = dedup_key(name, args);
if last_index_for_key.get(&key) == Some(&idx) {
return m.clone();
}
changed = true;
ChatMessage::tool_result(id.clone(), DUPLICATE_PLACEHOLDER.to_string())
})
.collect();
(result, changed)
}
/// Build the dedup key for a tool call.
///
/// Why hash the arguments: keeps the key a fixed, short size regardless
/// of argument payload size. `serde_json::to_string` is already
/// canonical here — this codebase doesn't enable `serde_json`'s
/// `preserve_order` feature, so `Value::Object` is backed by a
/// `BTreeMap` and always serializes keys in sorted order.
fn dedup_key(tool_name: &str, canonical_args: &str) -> String {
let hash = hex::encode(sha2::Sha256::digest(canonical_args.as_bytes()));
format!("{tool_name}:{hash}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dto::chat::message::ChatMessage;
use crate::dto::chat::tool::{ToolCall, ToolFunction};
use serde_json::json;
fn assistant_with_call(id: &str, name: &str, args: serde_json::Value) -> ChatMessage {
let mut m = ChatMessage::assistant(None);
m.tool_calls = Some(vec![ToolCall {
id: id.to_string(),
type_: "function".to_string(),
function: ToolFunction {
name: name.to_string(),
arguments: args,
},
}]);
m
}
#[test]
fn older_result_of_same_read_tool_and_args_is_replaced() {
let messages = vec![
assistant_with_call("call-1", "read", json!({"path": "a.rs"})),
ChatMessage::tool_result("call-1".to_string(), "first read of a.rs".to_string()),
assistant_with_call("call-2", "read", json!({"path": "a.rs"})),
ChatMessage::tool_result("call-2".to_string(), "second read of a.rs".to_string()),
];
let (result, changed) = collapse(&messages);
assert!(changed);
assert_eq!(result[1].content.as_deref(), Some(DUPLICATE_PLACEHOLDER));
assert_eq!(result[3].content.as_deref(), Some("second read of a.rs"));
}
#[test]
fn different_arguments_are_not_deduplicated() {
let messages = vec![
assistant_with_call("call-1", "read", json!({"path": "a.rs"})),
ChatMessage::tool_result("call-1".to_string(), "read of a.rs".to_string()),
assistant_with_call("call-2", "read", json!({"path": "b.rs"})),
ChatMessage::tool_result("call-2".to_string(), "read of b.rs".to_string()),
];
let (result, changed) = collapse(&messages);
assert!(!changed);
assert_eq!(result[1].content.as_deref(), Some("read of a.rs"));
assert_eq!(result[3].content.as_deref(), Some("read of b.rs"));
}
#[test]
fn key_order_in_arguments_does_not_prevent_dedup() {
let messages = vec![
assistant_with_call("call-1", "grep", json!({"pattern": "foo", "path": "."})),
ChatMessage::tool_result("call-1".to_string(), "first grep".to_string()),
assistant_with_call("call-2", "grep", json!({"path": ".", "pattern": "foo"})),
ChatMessage::tool_result("call-2".to_string(), "second grep".to_string()),
];
let (result, changed) = collapse(&messages);
assert!(changed);
assert_eq!(result[1].content.as_deref(), Some(DUPLICATE_PLACEHOLDER));
}
#[test]
fn mutating_tool_with_identical_args_is_never_deduplicated() {
let messages = vec![
assistant_with_call("call-1", "bash", json!({"command": "cargo test"})),
ChatMessage::tool_result("call-1".to_string(), "first run: 3 failed".to_string()),
assistant_with_call("call-2", "bash", json!({"command": "cargo test"})),
ChatMessage::tool_result("call-2".to_string(), "second run: 0 failed".to_string()),
];
let (result, changed) = collapse(&messages);
assert!(!changed);
assert_eq!(result[1].content.as_deref(), Some("first run: 3 failed"));
assert_eq!(result[3].content.as_deref(), Some("second run: 0 failed"));
}
#[test]
fn tool_result_with_no_matching_call_is_left_untouched() {
let messages = vec![ChatMessage::tool_result(
"orphan-id".to_string(),
"some result".to_string(),
)];
let (result, changed) = collapse(&messages);
assert!(!changed);
assert_eq!(result[0].content.as_deref(), Some("some result"));
}
}
@@ -0,0 +1,16 @@
//! Context management: token counting, cross-call tool-result dedup,
//! per-result compression, budget-based shaping, and shared
//! context-window resolution — replaces `runtime::shortsend`.
//!
//! No facade function here: `dedup`, `shaping`, and `tokens` are called
//! directly from each call site (the per-turn auto-compaction loop in
//! `actions::run_agent_turn`, and `Action::Compact`), matching this
//! codebase's "no DI, call modules directly" convention. An orchestration
//! layer would only serve one of the two callers generically — the
//! auto-loop already needs per-stage control to decide when to emit
//! `TurnEvent::Compacted`.
pub mod dedup;
pub mod shaping;
pub mod squash;
pub mod tokens;
pub mod window;
@@ -0,0 +1,211 @@
//! Budget-based message shaping: compacts long conversation histories so
//! they fit within the provider's context window before being sent to
//! the LLM API. Ported from the former `runtime::shortsend` — behavior
//! is unchanged, only its token-counting now goes through
//! `context::tokens` instead of an inline heuristic.
use super::tokens::count_tokens;
use crate::dto::chat::message::ChatMessage;
/// Decide whether the message list should be shaped (compacted) before
/// sending to the LLM.
///
/// Flow: trigger based on token estimate. If `token_estimate` exceeds
/// the threshold, we shape. When `prev_shaped` is true, the threshold is
/// raised (95%) to avoid fluttering — compaction only re-triggers when
/// the context is genuinely full again. When `prev_shaped` is false, the
/// threshold is lower (85%) so compaction starts proactively.
///
/// Why: hysteresis prevents repeated compaction on every turn when the
/// token count hovers near the boundary.
///
/// Return: `true` if shaping should be applied.
pub fn should_shape(token_estimate: usize, max_wire_tokens: usize, prev_shaped: bool) -> bool {
let threshold = if prev_shaped {
(max_wire_tokens as f32 * 0.95) as usize
} else {
(max_wire_tokens as f32 * 0.85) as usize
};
token_estimate >= threshold
}
/// Compact a long message list by dropping middle messages and inserting
/// a summary placeholder.
///
/// Flow: if the estimated token count is within budget and not forced,
/// return messages unchanged -> otherwise keep the system message and
/// the most recent messages that fit a 70%-of-budget target, with a
/// `[prior conversation compacted]` (or LLM-generated summary, if
/// `client` is `Some`) system message in between.
///
/// Why: keeps context-size overhead roughly constant regardless of
/// session length.
///
/// Return: the shaped message list, or `messages` unchanged if shaping
/// wasn't needed.
pub fn shape_messages(
messages: &[ChatMessage],
token_count: usize,
max_wire_tokens: usize,
force: bool,
client: Option<&crate::service::provider::LlmClient>,
) -> Vec<ChatMessage> {
if !force && (token_count <= max_wire_tokens || messages.len() < 5) {
return messages.to_vec();
}
let target_tokens = (max_wire_tokens as f32 * 0.70) as usize;
let mut current_tokens = 0;
let mut keep_recent = Vec::new();
let mut dropped_msgs = Vec::new();
let mut msgs_to_eval = messages.to_vec();
let first = if msgs_to_eval.is_empty() {
None
} else {
Some(msgs_to_eval.remove(0))
};
for m in msgs_to_eval.into_iter().rev() {
let text = m.content.as_deref().unwrap_or("");
let msg_tokens = count_tokens(text);
if current_tokens + msg_tokens <= target_tokens {
current_tokens += msg_tokens;
keep_recent.push(m);
} else {
dropped_msgs.push(m);
}
}
dropped_msgs.reverse();
let mut result = Vec::new();
if let Some(f) = first {
result.push(f);
}
if !dropped_msgs.is_empty() {
let mut summary_text = "[prior conversation compacted]".to_string();
if let Some(llm) = client {
let prompt = format!(
"Summarize the following dropped conversation history briefly. Focus on main goals, decisions made, and files modified, so the context is preserved for future turns. Keep it concise.\n\nHistory:\n{}",
dropped_msgs.iter()
.map(|m| format!("[{}]: {}", if m.role == crate::dto::chat::message::Role::User { "User" } else { "Assistant" }, m.content.as_deref().unwrap_or("")))
.collect::<Vec<_>>()
.join("\n\n")
);
let req_msgs = vec![ChatMessage::user(prompt)];
match llm.chat_with_tools_non_streaming(&req_msgs, None) {
Ok(resp) => {
if let Some(content) = resp.0.content {
summary_text =
format!("[Summary of compacted prior conversation:\n{content}\n]");
}
}
Err(e) => {
tracing::warn!(
"[context::shaping] LLM summarization failed: {}. \
Prior conversation history is lost — no summary available. \
This means the model will lose context about earlier parts of \
the conversation.",
e,
);
}
}
}
result.push(ChatMessage::system(summary_text));
}
result.extend(keep_recent.into_iter().rev());
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dto::chat::message::ChatMessage;
#[test]
fn should_shape_triggers_at_85_percent_when_not_previously_shaped() {
assert!(should_shape(850, 1000, false));
assert!(!should_shape(849, 1000, false));
}
#[test]
fn should_shape_uses_95_percent_threshold_once_already_shaped() {
assert!(
!should_shape(900, 1000, true),
"below 95% and already shaped: no re-trigger yet"
);
assert!(should_shape(950, 1000, true));
}
#[test]
fn shape_messages_is_a_noop_under_budget_and_not_forced() {
let messages = vec![
ChatMessage::system("sys"),
ChatMessage::user("hi"),
ChatMessage::assistant(Some("hello".to_string())),
];
let result = shape_messages(&messages, 10, 1000, false, None);
assert_eq!(result.len(), messages.len());
}
/// Build a message whose real BPE token count is large enough that 20
/// of them (~49 tokens each, ~980 total — verified empirically with
/// `context::tokens::count_tokens`) comfortably exceed
/// `shape_messages`'s 70%-of-1000 = 700 token target, guaranteeing
/// several get dropped. A short fixture like `format!("message {i}")`
/// (~8 tokens each, ~160 total for 20) stays entirely under budget
/// with real BPE counting and would make these tests pass vacuously
/// (nothing ever gets dropped, so "must survive shaping" and "falls
/// back to placeholder" hold trivially without exercising the actual
/// drop logic) — this was a real bug caught during Task 5's first
/// implementation attempt.
fn padded_message(i: usize) -> String {
format!(
"message number {i} with some padding text {}",
"additional padding content to increase token count substantially ".repeat(5),
)
}
#[test]
fn shape_messages_always_preserves_the_first_system_message() {
let mut messages = vec![ChatMessage::system("system prompt")];
for i in 0..20 {
messages.push(ChatMessage::user(padded_message(i)));
}
let result = shape_messages(&messages, 100_000, 1000, true, None);
assert_eq!(result[0].content.as_deref(), Some("system prompt"));
}
#[test]
fn shape_messages_without_a_client_falls_back_to_placeholder_summary() {
let mut messages = vec![ChatMessage::system("system prompt")];
for i in 0..20 {
messages.push(ChatMessage::user(padded_message(i)));
}
let result = shape_messages(&messages, 100_000, 1000, true, None);
let has_placeholder = result
.iter()
.any(|m| m.content.as_deref() == Some("[prior conversation compacted]"));
assert!(has_placeholder);
}
#[test]
fn shape_messages_keeps_most_recent_messages_over_older_ones() {
let mut messages = vec![ChatMessage::system("system prompt")];
for i in 0..20 {
messages.push(ChatMessage::user(padded_message(i)));
}
let result = shape_messages(&messages, 100_000, 1000, true, None);
let last_content = messages.last().unwrap().content.clone();
assert!(
result.iter().any(|m| m.content == last_content),
"most recent message must survive shaping"
);
}
}
@@ -0,0 +1,464 @@
#![allow(dead_code)]
//! Per-tool-result compression: shrink large tool outputs before they
//! ever enter conversation history, dispatching by content shape.
//!
//! Flow: `apply(tool_name, output)` -> `read` tool or under the size
//! floor? pass through unchanged : valid JSON? `squash_json` : tool is
//! `bash` and looks log-shaped? `squash_log` : `squash_generic`.
//!
//! Why: a single large `bash`/`grep` result can dominate a
//! conversation's token budget even on its first occurrence, long
//! before `dedup`/`shaping` ever get a chance to act on repeats or
//! overall budget.
use std::collections::HashSet;
use std::fmt::Write;
/// Below this size, compression isn't worth the risk of losing detail —
/// pass the output through unchanged.
const SQUASH_FLOOR_BYTES: usize = 1500;
/// Byte budget for the generic fallback compressor — double the squash
/// floor, so the fallback path still yields a real reduction on
/// anything that triggered it.
const GENERIC_BUDGET_BYTES: usize = SQUASH_FLOOR_BYTES * 2;
/// Tools whose output must never be altered. `read` is exempted because
/// its output must stay byte-exact — the agent relies on it for
/// exact-match edits afterward, and squashing a file that happens to
/// parse as JSON (e.g. `package.json`) would silently corrupt the
/// agent's view of real file content.
const NEVER_SQUASH: &[&str] = &["read"];
/// Tools whose output the log classifier is allowed to run on.
/// `looks_log_shaped` keys purely on content (>=3 error/warn/fail-shaped
/// lines), which a `grep`/`search` result full of matches against
/// error-handling code would trip just as easily as a real build log —
/// but `squash_log` caps at 20 error + 10 warning lines with no byte
/// budget, silently dropping legitimate matches past that cap. Only
/// `bash` (the actual log-producing tool) is allowed to route through
/// it; everything else that looks log-shaped falls through to the
/// gentler, byte-budgeted `squash_generic` instead.
const LOG_SHAPED_TOOLS: &[&str] = &["bash"];
/// Compress a tool's raw output before it's stored in conversation
/// history.
///
/// Return: `output` unchanged if `tool_name` is in `NEVER_SQUASH` or at
/// or under `SQUASH_FLOOR_BYTES`; otherwise the compressed form from
/// whichever detector matches its content shape.
pub fn apply(tool_name: &str, output: &str) -> String {
if NEVER_SQUASH.contains(&tool_name) || output.len() <= SQUASH_FLOOR_BYTES {
return output.to_string();
}
if serde_json::from_str::<serde_json::Value>(output).is_ok() {
return squash_json(output);
}
if LOG_SHAPED_TOOLS.contains(&tool_name) && looks_log_shaped(output) {
return squash_log(output);
}
squash_generic(output, GENERIC_BUDGET_BYTES)
}
/// Compress a JSON tool result by keeping all structural content (keys,
/// array/object shape) and eliding long, low-entropy string *values*,
/// while keeping short values (<=20 chars) and high-entropy single-token
/// ones (UUIDs, hashes, paths) intact. Array elements past the first 3
/// are elided regardless of length/entropy.
///
/// Why walk a parsed `Value` instead of hand-rolling a JSON tokenizer:
/// `serde_json` already handles escaping/nesting correctly (this
/// codebase's own `dto::chat::tool::repair_json` exists specifically to
/// work around how easy it is to get that wrong by hand) — reusing it
/// is both simpler and more robust.
///
/// Return: re-serialized JSON with the same shape as the input.
fn squash_json(text: &str) -> String {
let Ok(mut value) = serde_json::from_str::<serde_json::Value>(text) else {
return text.to_string();
};
squash_json_value(&mut value, false);
serde_json::to_string(&value).unwrap_or_else(|_| text.to_string())
}
/// Recursively elide long, low-entropy string values in place.
/// `in_late_array` is true once past the first 3 elements of an
/// enclosing array, tightening the elision rule for the rest of it.
///
/// Why the `!s.contains(' ')` gate before the entropy check: raw
/// per-character Shannon entropy alone does NOT separate "meaningful
/// prose" from "random-looking identifier" — verified empirically,
/// repeated English prose scores ~3.89 bits/char, *higher* than a UUID's
/// ~3.39 or a SHA-256 hex digest's ~3.66, because prose draws from a
/// wide, fairly-balanced character set too. What actually distinguishes
/// identifiers from prose is that identifiers are a single unbroken
/// token — this mirrors headroom's own approach (its entropy check is
/// "cheaply pre-filtered by 'no spaces'" before scoring). Multi-word
/// values never reach the entropy branch at all; only whitespace-free
/// tokens do, where entropy correctly separates "abc123" or "aaaaaaaa"
/// (low, elided if long) from a UUID/hash/API-key-shaped string (high,
/// kept).
fn squash_json_value(value: &mut serde_json::Value, in_late_array: bool) {
match value {
serde_json::Value::String(s) => {
let looks_like_identifier = !s.contains(' ') && shannon_entropy(s) >= 3.0;
let keep = !in_late_array && (s.len() <= 20 || looks_like_identifier);
if !keep {
*s = "".to_string();
}
}
serde_json::Value::Array(items) => {
for (i, item) in items.iter_mut().enumerate() {
squash_json_value(item, i >= 3);
}
}
serde_json::Value::Object(map) => {
for v in map.values_mut() {
squash_json_value(v, false);
}
}
_ => {}
}
}
/// Shannon entropy in bits per character — used, after the `squash_json`
/// caller's own "no internal whitespace" pre-filter, to distinguish
/// high-entropy single-token strings (UUIDs, hashes, random IDs, worth
/// keeping) from low-entropy ones (e.g. `"aaaaaaaaaa"`, safe to elide).
/// 3.0 sits comfortably below a UUID's ~3.39 and a SHA-256 hex digest's
/// ~3.66 (both empirically measured with this exact formula) while
/// staying well above a degenerate repeated-character string's 0.0.
fn shannon_entropy(s: &str) -> f64 {
if s.is_empty() {
return 0.0;
}
let mut counts: std::collections::HashMap<char, usize> = std::collections::HashMap::new();
for c in s.chars() {
*counts.entry(c).or_insert(0) += 1;
}
let len = s.chars().count() as f64;
counts
.values()
.map(|&count| {
let p = f64::from(u32::try_from(count).unwrap_or(u32::MAX)) / len;
-p * p.log2()
})
.sum()
}
/// Coarse severity classification for a single log line, used by
/// `squash_log` to rank which lines are most worth keeping.
#[derive(Clone, Copy, PartialEq, Eq)]
enum LogLevel {
Error,
Warn,
Info,
Debug,
}
/// Classify a single log line by scanning for level keywords.
///
/// Why substring matching on a lowercased copy instead of a real log
/// parser: tool output comes from arbitrary external processes with no
/// consistent log format, so keyword sniffing is the only detector that
/// generalizes across all of them.
fn classify_line(line: &str) -> LogLevel {
let lower = line.to_lowercase();
if lower.contains("error") || lower.contains("fail") || lower.contains("panic") {
LogLevel::Error
} else if lower.contains("warn") {
LogLevel::Warn
} else if lower.contains("debug") || lower.contains("trace") {
LogLevel::Debug
} else {
LogLevel::Info
}
}
/// Heuristic gate for routing to `squash_log` vs `squash_generic`: at
/// least 3 lines that look like error/warning/stack-trace output.
fn looks_log_shaped(text: &str) -> bool {
let hits = text
.lines()
.filter(|l| {
let lower = l.to_lowercase();
lower.contains("error")
|| lower.contains("warn")
|| lower.contains("fail")
|| lower.contains("panic")
|| l.trim_start().starts_with("at ")
})
.count();
hits >= 3
}
/// Compress log-shaped output: keep up to 20 highest-scored error lines
/// and up to 10 highest-scored warning lines (score = level weight +
/// 0.3 if the line looks like a stack-trace frame), each with a
/// +/-2-line context window, replacing every gap with a `[N lines
/// omitted]` marker.
///
/// Why not a comment-shaped marker (e.g. `// N lines omitted`): the
/// `rtk` project's own regression tests found that shape gets parsed by
/// the LLM as code and triggers a retry loop.
fn squash_log(text: &str) -> String {
let lines: Vec<&str> = text.lines().collect();
let levels: Vec<LogLevel> = lines.iter().map(|l| classify_line(l)).collect();
let score = |i: usize| -> f32 {
let level_score = match levels[i] {
LogLevel::Error => 1.0,
LogLevel::Warn => 0.5,
LogLevel::Info => 0.1,
LogLevel::Debug => 0.05,
};
let stack_boost = if lines[i].trim_start().starts_with("at ") {
0.3
} else {
0.0
};
level_score + stack_boost
};
let mut error_idxs: Vec<usize> = (0..lines.len())
.filter(|&i| levels[i] == LogLevel::Error)
.collect();
error_idxs.sort_by(|&a, &b| {
score(b)
.partial_cmp(&score(a))
.unwrap_or(std::cmp::Ordering::Equal)
});
error_idxs.truncate(20);
let mut warn_idxs: Vec<usize> = (0..lines.len())
.filter(|&i| levels[i] == LogLevel::Warn)
.collect();
warn_idxs.sort_by(|&a, &b| {
score(b)
.partial_cmp(&score(a))
.unwrap_or(std::cmp::Ordering::Equal)
});
warn_idxs.truncate(10);
let mut keep: HashSet<usize> = HashSet::new();
for &i in error_idxs.iter().chain(warn_idxs.iter()) {
let lo = i.saturating_sub(2);
let hi = (i + 2).min(lines.len().saturating_sub(1));
keep.extend(lo..=hi);
}
if keep.is_empty() {
return squash_generic(text, GENERIC_BUDGET_BYTES);
}
render_kept_lines(&lines, &keep)
}
/// Importance-ranked truncation for content that isn't JSON or
/// log-shaped: keep the first 10 and last 10 lines, plus any
/// non-blank line that isn't a repeat of the one before it, until
/// `budget` bytes are used.
fn squash_generic(text: &str, budget: usize) -> String {
let lines: Vec<&str> = text.lines().collect();
if lines.len() <= 20 {
return text.chars().take(budget).collect();
}
let head_end = 10;
let tail_start = lines.len() - 10;
let mut keep: HashSet<usize> = (0..head_end).chain(tail_start..lines.len()).collect();
let mut used: usize = lines[..head_end].iter().map(|l| l.len() + 1).sum::<usize>()
+ lines[tail_start..]
.iter()
.map(|l| l.len() + 1)
.sum::<usize>();
let mut prev = "";
for (i, &line) in lines.iter().enumerate().take(tail_start).skip(head_end) {
let non_trivial = !line.trim().is_empty() && line != prev;
if non_trivial && used + line.len() < budget {
keep.insert(i);
used += line.len() + 1;
}
prev = line;
}
render_kept_lines(&lines, &keep)
}
/// Render a subset of `lines` in order, inserting a `[N lines omitted]`
/// marker at every gap between kept lines.
fn render_kept_lines(lines: &[&str], keep: &HashSet<usize>) -> String {
let mut kept_sorted: Vec<usize> = keep.iter().copied().collect();
kept_sorted.sort_unstable();
let mut out = String::new();
let mut cursor = 0usize;
for &i in &kept_sorted {
if i > cursor {
let _ = writeln!(out, "[{} lines omitted]", i - cursor);
}
out.push_str(lines[i]);
out.push('\n');
cursor = i + 1;
}
if cursor < lines.len() {
let _ = writeln!(out, "[{} lines omitted]", lines.len() - cursor);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn output_under_the_floor_passes_through_unchanged() {
let small = "short output";
assert_eq!(apply("bash", small), small);
}
#[test]
fn read_tool_output_is_never_squashed_even_when_huge_json() {
let big_json = format!(
"{{\"description\": \"{}\"}}",
"a very long description value that repeats ".repeat(100),
);
assert!(big_json.len() > SQUASH_FLOOR_BYTES);
assert_eq!(apply("read", &big_json), big_json);
}
#[test]
fn json_output_over_floor_keeps_structure_and_short_values() {
let value = serde_json::json!({
"id": "abc123",
"note": "hi",
"description": "a very long description value that repeats ".repeat(100),
});
let text = serde_json::to_string(&value).unwrap();
assert!(text.len() > SQUASH_FLOOR_BYTES);
let result = apply("some_mcp_tool", &text);
let parsed: serde_json::Value =
serde_json::from_str(&result).expect("squashed JSON must still be valid JSON");
assert_eq!(parsed["id"], "abc123", "short values must survive");
assert_eq!(parsed["note"], "hi", "short values must survive");
assert_ne!(
parsed["description"].as_str().unwrap().len(),
value["description"].as_str().unwrap().len(),
"long low-entropy value must be shrunk",
);
}
#[test]
fn json_array_elements_past_third_are_squashed_harder() {
// A UUID-shaped value has no internal whitespace and clears the
// entropy threshold, so under the *normal* per-value rule (which
// still applies to array indices 0-2) it survives untouched.
// Padding elsewhere in the object pushes total size over the
// squash floor without affecting which array elements get kept.
let identifier = "550e8400-e29b-41d4-a716-446655440000";
let padding = "padding text to push this payload past the squash floor so apply() actually dispatches to squash_json ".repeat(20);
let value = serde_json::json!({
"padding": padding,
"items": [identifier, identifier, identifier, identifier],
});
let text = serde_json::to_string(&value).unwrap();
assert!(text.len() > SQUASH_FLOOR_BYTES);
let result = apply("some_mcp_tool", &text);
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
let items = parsed["items"].as_array().unwrap();
assert_eq!(items[0].as_str().unwrap(), identifier, "index 0 is under the array cutoff and identifier-shaped, so it's kept under the normal rule");
assert_eq!(
items[2].as_str().unwrap(),
identifier,
"index 2 is still under the cutoff (past-third means index >= 3)"
);
assert_ne!(items[3].as_str().unwrap(), identifier, "index 3 must be force-elided even though it's identifier-shaped and would survive at any earlier index");
}
#[test]
fn log_like_output_keeps_error_lines_and_marks_omissions() {
// `looks_log_shaped` requires >= 3 lines matching error/warn/fail/
// panic/stack-frame patterns before routing to `squash_log` at
// all — a single error line isn't enough and would silently fall
// through to `squash_generic` instead, so this fixture needs at
// least 3 such lines, spread apart, to actually exercise
// squash_log's scoring/windowing logic (not just its fallback).
let mut lines = vec!["build started".to_string()];
for i in 0..200 {
lines.push(format!("info: compiling module {i}"));
}
lines.push("error: something failed early in the build".to_string());
for i in 0..200 {
lines.push(format!("info: compiling module {}", i + 200));
}
lines.push("warning: deprecated api used somewhere".to_string());
lines.push("error: something failed at the end".to_string());
let text = lines.join("\n");
assert!(text.len() > SQUASH_FLOOR_BYTES);
let result = apply("bash", &text);
assert!(result.contains("error: something failed early in the build"));
assert!(result.contains("error: something failed at the end"));
assert!(result.contains("lines omitted"));
assert!(result.len() < text.len());
}
#[test]
fn non_bash_tool_with_log_shaped_content_is_not_log_compressed() {
// A grep result whose matched lines all mention "error" would
// trip `looks_log_shaped`'s >=3-line keyword threshold just like
// a real build log — but `squash_log` caps at 20 highest-scored
// error lines with no guaranteed tail retention, silently
// dropping legitimate matches past that cap. Only `bash` is
// treated as log-shaped; `grep` must fall through to
// `squash_generic`, which always keeps the first and last 10
// lines regardless of score. With every line tied at the same
// score, a `squash_log` route would keep indices 0-19 (stable
// sort preserves original order on ties) and drop index 49 —
// so asserting the tail survives is a route-distinguishing
// check, not just a content check.
let lines: Vec<String> = (0..50)
.map(|i| format!("src/file{i}.rs:{i}: error handling for case {i}"))
.collect();
let text = lines.join("\n");
assert!(text.len() > SQUASH_FLOOR_BYTES);
let result = apply("grep", &text);
assert!(
result.contains("src/file0.rs:0: error handling for case 0"),
"generic keeps head"
);
assert!(
result.contains("src/file49.rs:49: error handling for case 49"),
"generic keeps tail — squash_log would have dropped this"
);
}
#[test]
fn generic_large_text_is_truncated_with_omission_marker() {
let lines: Vec<String> = (0..500)
.map(|i| format!("line number {i} of plain output"))
.collect();
let text = lines.join("\n");
assert!(text.len() > SQUASH_FLOOR_BYTES);
let result = apply("bash", &text);
assert!(
result.contains("line number 0 of plain output"),
"keeps head"
);
assert!(
result.contains("line number 499 of plain output"),
"keeps tail"
);
assert!(result.contains("lines omitted"));
assert!(result.len() < text.len());
}
}
@@ -0,0 +1,71 @@
//! Unified token-count estimation for context-window budgeting.
//!
//! Flow: text -> `tiktoken_rs::o200k_base_singleton()` (BPE vocab embedded
//! in the binary via `include_str!`, no network access) -> `encode_ordinary`
//! -> token count.
//!
//! Why: replaces three independent char-count heuristics that disagreed
//! with each other (`/3` in the old `shortsend.rs`, `/4` in the turn
//! loop, `/4` again in the status bar) with one real BPE tokenizer.
//! `o200k_base` is an approximation for non-OpenAI providers but is far
//! closer than a flat byte-per-token guess; it's only used for the
//! 85%/95% budget thresholds, not for billing-accurate counts.
use crate::dto::chat::message::ChatMessage;
/// Count tokens in a single string under `o200k_base`.
///
/// Return: the BPE token count for `text`. `encode_ordinary` (not
/// `encode`/`encode_with_special_tokens`) is used deliberately — message
/// content that happens to contain a special-token-shaped substring
/// (e.g. literal text `<|endoftext|>` pasted by a user) must be counted
/// as ordinary text, not interpreted as a control token.
pub fn count_tokens(text: &str) -> usize {
tiktoken_rs::o200k_base_singleton()
.encode_ordinary(text)
.len()
}
/// Count tokens in a `ChatMessage`'s text content.
///
/// Return: 0 for a message with no `content` (e.g. an assistant message
/// that only carries `tool_calls`).
#[allow(dead_code)]
pub fn count_message_tokens(msg: &ChatMessage) -> usize {
msg.content.as_deref().map_or(0, count_tokens)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dto::chat::message::ChatMessage;
#[test]
fn empty_string_has_zero_tokens() {
assert_eq!(count_tokens(""), 0);
}
#[test]
fn known_short_phrase_has_expected_token_count() {
// Verified empirically against tiktoken-rs 0.12's o200k_base:
// "hello world" -> [24912, 2375], i.e. 2 tokens.
assert_eq!(count_tokens("hello world"), 2);
}
#[test]
fn known_code_snippet_has_expected_token_count() {
// Verified empirically: 9 tokens under o200k_base.
assert_eq!(count_tokens("fn main() { println!(\"hi\"); }"), 9);
}
#[test]
fn message_with_no_content_counts_zero() {
let msg = ChatMessage::assistant(None);
assert_eq!(count_message_tokens(&msg), 0);
}
#[test]
fn message_token_count_matches_count_tokens_on_its_content() {
let msg = ChatMessage::user("hello world");
assert_eq!(count_message_tokens(&msg), count_tokens("hello world"));
}
}
@@ -0,0 +1,88 @@
#![allow(dead_code)]
//! Single source of truth for resolving the active model's context
//! window size, replacing three copies of the same lookup that had
//! drifted (`Action::Compact`, `spawn_turn`, and `view/status.rs` each
//! had their own inline version — the status bar's copy additionally
//! displayed "?" on no match instead of falling back like the other two,
//! an inconsistency this unifies away).
use crate::model::app_config::AppConfig;
use crate::model::settings::Settings;
/// Resolve the context-window size (in tokens) for the currently
/// configured provider/model.
///
/// Flow: find the `ModelRole` whose `provider`+`model` match
/// `settings` -> use its `context_window` if set -> otherwise fall back
/// to `app_config.default_context_window`.
///
/// Return: always a concrete token count, never "unknown".
pub fn resolve(app_config: &AppConfig, settings: &Settings) -> usize {
app_config
.model_roles
.values()
.find(|role| role.provider == settings.provider && role.model == settings.model)
.and_then(|role| role.context_window)
.unwrap_or(app_config.default_context_window) as usize
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::app_config::ModelRole;
#[test]
fn resolves_context_window_from_matching_model_role() {
let mut app_config = AppConfig::default();
app_config.model_roles.insert(
"default".to_string(),
ModelRole {
provider: "zen".to_string(),
model: "deepseek-v4-flash-free".to_string(),
max_tokens: None,
context_window: Some(128_000),
temperature: None,
},
);
let mut settings = Settings::default();
settings.provider = "zen".to_string();
settings.model = "deepseek-v4-flash-free".to_string();
assert_eq!(resolve(&app_config, &settings), 128_000);
}
#[test]
fn falls_back_to_default_context_window_when_no_role_matches() {
let app_config = AppConfig::default();
let mut settings = Settings::default();
settings.provider = "nonexistent".to_string();
settings.model = "nonexistent-model".to_string();
assert_eq!(
resolve(&app_config, &settings),
app_config.default_context_window as usize
);
}
#[test]
fn falls_back_to_default_when_matching_role_has_no_context_window_set() {
let mut app_config = AppConfig::default();
app_config.model_roles.insert(
"default".to_string(),
ModelRole {
provider: "zen".to_string(),
model: "deepseek-v4-flash-free".to_string(),
max_tokens: None,
context_window: None,
temperature: None,
},
);
let mut settings = Settings::default();
settings.provider = "zen".to_string();
settings.model = "deepseek-v4-flash-free".to_string();
assert_eq!(
resolve(&app_config, &settings),
app_config.default_context_window as usize
);
}
}
@@ -0,0 +1,67 @@
//! Adaptive poll-rate event loop: polls faster for IDLE_THRESHOLD_MS
//! after any activity, then slows down to conserve CPU.
use std::collections::VecDeque;
use std::time::{Duration, Instant};
use crate::app::state::runtime::TurnEvent;
const FAST_POLL_MS: u64 = 8;
const SLOW_POLL_MS: u64 = 100;
const IDLE_THRESHOLD_MS: u64 = 500;
/// Tracks whether the app has been active vs idle to adjust the TUI poll
/// rate, balancing responsiveness against CPU usage.
pub struct EventLoop {
last_activity: Instant,
fast_poll_until: Option<Instant>,
}
impl EventLoop {
/// Create an `EventLoop` with the current instant as the last activity.
pub fn new() -> Self {
EventLoop {
last_activity: Instant::now(),
fast_poll_until: None,
}
}
/// Return the appropriate polling delay based on activity state.
///
/// Flow: if `fast_poll_until` is set and the deadline hasn't expired,
/// return `FAST_POLL_MS`; otherwise return `SLOW_POLL_MS`.
pub fn poll_interval(&self) -> Duration {
if let Some(fast_until) = self.fast_poll_until {
if Instant::now() < fast_until {
return Duration::from_millis(FAST_POLL_MS);
}
}
Duration::from_millis(SLOW_POLL_MS)
}
/// Mark the current time as the last activity and arm the fast-poll
/// window for the next `IDLE_THRESHOLD_MS`.
pub fn mark_active(&mut self) {
self.last_activity = Instant::now();
self.fast_poll_until = Some(Instant::now() + Duration::from_millis(IDLE_THRESHOLD_MS));
}
/// Return `true` if the app has been idle for more than `IDLE_THRESHOLD_MS`.
pub fn is_idle(&self) -> bool {
self.last_activity.elapsed().as_millis() as u64 > IDLE_THRESHOLD_MS
}
/// Drain all pending `TurnEvent`s from the shared mutex queue.
///
/// Return: a `Vec` of all events that were in the queue (may be empty).
pub fn drain_events(
events: &std::sync::Mutex<VecDeque<TurnEvent>>,
) -> Vec<TurnEvent> {
events.lock().map(|mut q| q.drain(..).collect()).unwrap_or_default()
}
}
impl Default for EventLoop {
fn default() -> Self {
Self::new()
}
}
@@ -0,0 +1,6 @@
//! Runtime layer: action dispatch, slash commands, short-send handling,
//! and the LLM streaming pipeline.
pub mod actions;
pub mod commands;
pub mod context;
pub mod stream;
@@ -0,0 +1,356 @@
//! SSE stream parser: converts SSE- or JSON-chunked LLM responses into
//! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done).
pub mod turn;
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// 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:") {
// Handle both "data: {...}" (with space) and "data:{...}"
// (without space). Some providers omit the trailing space.
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).
///
/// Why: dual-format support in one method avoids a separate
/// provider-specific parsing layer.
///
/// 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(serde_json::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(serde_json::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(serde_json::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(serde_json::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
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn feed_parses_single_token_chunk() {
let mut p = SseParser::new();
let events = p.feed("data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n");
assert_eq!(events.len(), 1);
match &events[0] {
StreamEvent::Token(t) => assert_eq!(t, "hello"),
other => panic!("expected Token, got {other:?}"),
}
}
#[test]
fn feed_handles_chunk_split_mid_line() {
let mut p = SseParser::new();
let e1 = p.feed("data: {\"choices\":[{\"delta\":{\"content\":\"partial");
assert!(
e1.is_empty(),
"no event until the line and blank separator complete"
);
let e2 = p.feed("\"}}]}\n\n");
assert_eq!(e2.len(), 1);
match &e2[0] {
StreamEvent::Token(t) => assert_eq!(t, "partial"),
other => panic!("expected Token, got {other:?}"),
}
}
#[test]
fn feed_emits_done_on_done_sentinel() {
let mut p = SseParser::new();
let events = p.feed("data: [DONE]\n\n");
assert_eq!(events.len(), 1);
assert!(matches!(events[0], StreamEvent::Done));
}
#[test]
fn feed_emits_done_on_finish_reason_stop() {
let mut p = SseParser::new();
let events = p.feed("data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n");
assert_eq!(events.len(), 1);
assert!(matches!(events[0], StreamEvent::Done));
}
#[test]
fn feed_parses_tool_call_delta() {
let mut p = SseParser::new();
let events = p.feed(
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"bash\",\"arguments\":\"{\\\"cmd\\\"\"}}]}}]}\n\n",
);
assert_eq!(events.len(), 1);
match &events[0] {
StreamEvent::ToolCallDelta {
index,
id,
name,
arguments_delta,
} => {
assert_eq!(*index, 0);
assert_eq!(id.as_deref(), Some("call_1"));
assert_eq!(name.as_deref(), Some("bash"));
assert_eq!(arguments_delta, "{\"cmd\"");
}
other => panic!("expected ToolCallDelta, got {other:?}"),
}
}
#[test]
fn feed_parses_usage_chunk() {
let mut p = SseParser::new();
let events = p.feed(
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}\n\n",
);
assert_eq!(events.len(), 1);
match &events[0] {
StreamEvent::Usage {
prompt_tokens,
completion_tokens,
total_tokens,
} => {
assert_eq!(*prompt_tokens, 10);
assert_eq!(*completion_tokens, 5);
assert_eq!(*total_tokens, 15);
}
other => panic!("expected Usage, got {other:?}"),
}
}
#[test]
fn feed_parses_usage_and_content_bundled_chunk() {
let mut p = SseParser::new();
let events = p.feed(
"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}\n\n",
);
assert_eq!(events.len(), 2);
match (&events[0], &events[1]) {
(
StreamEvent::Usage {
prompt_tokens,
completion_tokens,
total_tokens,
},
StreamEvent::Token(t),
) => {
assert_eq!(*prompt_tokens, 10);
assert_eq!(*completion_tokens, 5);
assert_eq!(*total_tokens, 15);
assert_eq!(t, "hello");
}
other => panic!("expected [Usage, Token], got {other:?}"),
}
}
#[test]
fn feed_ignores_empty_data_lines() {
let mut p = SseParser::new();
let events = p.feed(": comment\n\n");
assert!(events.is_empty());
}
#[test]
fn feed_multiple_events_across_one_chunk() {
let mut p = SseParser::new();
let chunk = "data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"b\"}}]}\n\n";
let events = p.feed(chunk);
assert_eq!(events.len(), 2);
match (&events[0], &events[1]) {
(StreamEvent::Token(a), StreamEvent::Token(b)) => {
assert_eq!(a, "a");
assert_eq!(b, "b");
}
other => panic!("expected two Tokens, got {other:?}"),
}
}
}
@@ -0,0 +1,389 @@
//! Accumulates streaming LLM responses into complete message/tool-call
//! representation via `StreamedTurn`, and provides a standalone tool-call
//! accumulator in `tools::ToolCallAccumulator`.
use super::StreamEvent;
use crate::dto::chat::message::ChatMessage;
use crate::dto::chat::tool::{ToolCall, ToolFunction};
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Try to repair truncated JSON by closing open strings, braces, and brackets.
///
/// Flow: scan character-by-character tracking string/escape state. For
/// every `{` or `[` seen outside a string, push onto a LIFO stack; on
/// `}`/`]` pop the matching opener (tracking remaining depth only).
/// At the end, if the last char was a backslash (start of an escape
/// sequence), remove it; if inside a string, append `"`; then close
/// every unclosed opener in reverse (LIFO) order.
///
/// Why: LLM responses can be cut off (`max_tokens`, network) midJSON
/// string, but we want tools to receive whatever arguments were already
/// emitted so the partial work can proceed.
///
/// Why LIFO vs. depth counters: `{` inside `[` must be closed with `}`
/// *before* the `]`, not after it. Simple depth counters get the order
/// wrong for nested heterogenous structures.
fn repair_incomplete_json(s: &str) -> String {
let mut stack: Vec<char> = Vec::new();
let mut in_string = false;
let mut prev_was_backslash = false;
// `true` only when the very last character consumed was a bare `\`
// inside a string (i.e. the start of an escape that was never completed).
let mut ends_with_unclosed_escape = false;
for c in s.chars() {
if prev_was_backslash {
// Consume the character that was being escaped — the escape is
// complete, so clear the unclosed-escape flag.
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 {
// The last character is a dangling backslash that started an escape
// but got cut off before the escaped char — remove it.
result.pop();
}
if in_string {
result.push('"');
}
for &opener in stack.iter().rev() {
match opener {
'{' => result.push('}'),
'[' => result.push(']'),
_ => {}
}
}
result
}
/// Accumulates a single streaming assistant turn into its final
/// `ChatMessage` form, including tool-call deltas and content/reasoning.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamedTurn {
pub messages: Vec<ChatMessage>,
pub tool_calls: Vec<ParsedToolCall>,
pub is_complete: bool,
pub done_received: bool,
pub accumulated_content: String,
pub accumulated_reasoning: String,
}
/// A single tool call being built up from streaming deltas.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParsedToolCall {
pub id: String,
pub name: String,
pub arguments: String,
pub is_complete: bool,
}
impl ParsedToolCall {}
impl StreamedTurn {
/// Create an empty turn accumulator.
pub fn new() -> Self {
StreamedTurn {
messages: Vec::new(),
tool_calls: Vec::new(),
is_complete: false,
done_received: false,
accumulated_content: String::new(),
accumulated_reasoning: String::new(),
}
}
/// Apply a `StreamEvent` to the turn, updating accumulated content,
/// reasoning, and tool-call deltas.
///
/// Flow: match on variant — `Token` appends to `accumulated_content`,
/// `Reasoning` to `accumulated_reasoning`, `ToolCallDelta` fills or
/// grows the `tool_calls` vector, `Done` sets `is_complete = true`.
pub fn apply_event(&mut self, event: &StreamEvent) {
match event {
StreamEvent::Token(token) => {
self.accumulated_content.push_str(token);
}
StreamEvent::Reasoning(reasoning) => {
self.accumulated_reasoning.push_str(reasoning);
}
StreamEvent::ToolCallDelta {
index,
id,
name,
arguments_delta,
} => {
while self.tool_calls.len() <= *index {
self.tool_calls.push(ParsedToolCall {
id: String::new(),
name: String::new(),
arguments: String::new(),
is_complete: false,
});
}
let tc = &mut self.tool_calls[*index];
if let Some(new_id) = id {
if !new_id.is_empty() {
tc.id.clone_from(new_id);
}
}
if let Some(new_name) = name {
if !new_name.is_empty() {
tc.name.clone_from(new_name);
}
}
tc.arguments.push_str(arguments_delta);
}
StreamEvent::Done => {
self.is_complete = true;
}
_ => {}
}
}
/// Finalise the turn into a `ChatMessage`, combining accumulated
/// reasoning (wrapped in `<think>` tags) with content and tool calls.
///
/// Flow: if tool calls exist, build a `ChatMessage` with `tool_calls`
/// set; otherwise build a plain assistant message → set `content` to
/// the combined reasoning+content string (or `None` if empty).
///
/// Return: a complete `ChatMessage` with role `Assistant`.
pub fn build_assistant_message(&self) -> ChatMessage {
let mut msg = if self.tool_calls.is_empty() {
ChatMessage::assistant(None)
} else {
let tool_dtos: Vec<ToolCall> = self
.tool_calls
.iter()
.filter(|tc| !tc.name.is_empty())
.map(|tc| {
let args_value: serde_json::Value = match serde_json::from_str(&tc.arguments) {
Ok(v) => v,
Err(e) => {
let repaired = repair_incomplete_json(&tc.arguments);
match serde_json::from_str(&repaired) {
Ok(v) => {
tracing::warn!(
"[stream] tool call '{}' had truncated JSON \
arguments repaired successfully: {}",
tc.name,
e,
);
v
}
Err(e2) => {
tracing::warn!(
"[stream] tool call '{}' has invalid JSON \
arguments: {} (after repair: {}) falling \
back to raw string",
tc.name,
e,
e2,
);
serde_json::Value::String(tc.arguments.clone())
}
}
}
};
ToolCall {
id: tc.id.clone(),
type_: "function".to_string(),
function: ToolFunction {
name: tc.name.clone(),
arguments: args_value,
},
}
})
.collect();
let mut msg = ChatMessage::assistant(None);
if !tool_dtos.is_empty() {
msg.tool_calls = Some(tool_dtos);
}
msg
};
let full_content = if self.accumulated_reasoning.is_empty() {
self.accumulated_content.clone()
} else {
format!(
"<think>\n{}\n</think>\n\n{}",
self.accumulated_reasoning, self.accumulated_content
)
};
let content = if full_content.is_empty() {
None
} else {
Some(full_content)
};
msg.content = content;
msg
}
/// Find the first named tool call whose accumulated `arguments` do not
/// parse as valid JSON.
///
/// Why: a connection that closes mid-stream (no `[DONE]` event) still
/// leaves partial argument text in the accumulator — e.g. a `write`
/// tool call cut off mid-string. Parsing that fragment always fails,
/// so a parse failure at end-of-stream is a reliable signal that the
/// response was truncated, not that the model legitimately finished
/// without sending `[DONE]`.
///
/// Return: `Some((name, parse_error))` for the first bad tool call, or
/// `None` if every tool call's arguments are complete, parsable JSON.
pub fn incomplete_tool_call(&self) -> Option<(&str, String)> {
self.tool_calls
.iter()
.filter(|tc| !tc.name.is_empty())
.find_map(|tc| {
serde_json::from_str::<Value>(&tc.arguments)
.err()
.map(|e| (tc.name.as_str(), e.to_string()))
})
}
}
impl Default for StreamedTurn {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tool_call(name: &str, arguments: &str) -> ParsedToolCall {
ParsedToolCall {
id: "call_1".to_string(),
name: name.to_string(),
arguments: arguments.to_string(),
is_complete: false,
}
}
#[test]
fn repair_closes_unclosed_string() {
let result = repair_incomplete_json("{\"key\": \"value");
assert_eq!(result, "{\"key\": \"value\"}");
}
#[test]
fn repair_closes_unclosed_object() {
let result = repair_incomplete_json("{\"key\": \"value\"");
assert_eq!(result, "{\"key\": \"value\"}");
}
#[test]
fn repair_closes_nested_structures() {
let result = repair_incomplete_json("{\"a\": [1, 2, {\"b\": 3");
assert_eq!(result, "{\"a\": [1, 2, {\"b\": 3}]}");
}
#[test]
fn repair_leaves_complete_json_unchanged() {
let s = "{\"a\": 1, \"b\": \"hello\"}";
assert_eq!(repair_incomplete_json(s), s);
}
#[test]
fn repair_handles_trailing_backslash_before_cut() {
// Truncated inside an escape sequence like "hello\"
let result = repair_incomplete_json("{\"text\": \"hello\\");
assert_eq!(result, "{\"text\": \"hello\"}");
}
#[test]
fn repair_handles_escaped_quotes_inside_string() {
// Input ends with `\"` where the `"` is the escaped character
// (consumed by the backslash handler), so the string is still
// unterminated. Repair adds `"` to close the string and `}` to
// close the object.
let result = repair_incomplete_json("{\"msg\": \"he said \\\"hello\\\"");
assert_eq!(result, "{\"msg\": \"he said \\\"hello\\\"\"}");
}
#[test]
fn build_assistant_message_repairs_truncated_tool_call() {
let mut turn = StreamedTurn::new();
turn.tool_calls.push(tool_call(
"write",
"{\"path\": \"a.txt\", \"content\": \"short\", \"reason\": \"trunc",
));
let msg = turn.build_assistant_message();
let tcs = msg.tool_calls.expect("should produce tool calls");
assert_eq!(tcs.len(), 1);
let args = &tcs[0].function.arguments;
assert!(
args.is_object(),
"args should be an object after repair: {args:?}"
);
assert_eq!(args.get("path").and_then(|v| v.as_str()), Some("a.txt"));
assert_eq!(args.get("content").and_then(|v| v.as_str()), Some("short"));
}
#[test]
fn incomplete_tool_call_flags_truncated_json() {
let mut turn = StreamedTurn::new();
turn.tool_calls.push(tool_call(
"write",
"{\"path\": \"a.txt\", \"content\": \"unterm",
));
let bad = turn.incomplete_tool_call();
assert_eq!(bad.map(|(name, _)| name), Some("write"));
}
#[test]
fn incomplete_tool_call_accepts_complete_json() {
let mut turn = StreamedTurn::new();
turn.tool_calls.push(tool_call(
"write",
"{\"path\": \"a.txt\", \"content\": \"done\"}",
));
assert!(turn.incomplete_tool_call().is_none());
}
#[test]
fn incomplete_tool_call_ignores_calls_without_a_name() {
let mut turn = StreamedTurn::new();
turn.tool_calls.push(tool_call("", "not json at all"));
assert!(turn.incomplete_tool_call().is_none());
}
#[test]
fn incomplete_tool_call_accepts_repaired_json() {
// `incomplete_tool_call` uses raw `serde_json::from_str` (no repair)
// so it should still flag truncated JSON even though
// `build_assistant_message` will later repair it.
let mut turn = StreamedTurn::new();
turn.tool_calls.push(tool_call(
"write",
"{\"path\": \"a.txt\", \"content\": \"unterm",
));
// Even though it's repairable, raw parse should still fail
assert!(serde_json::from_str::<Value>(&turn.tool_calls[0].arguments).is_err());
}
}