Refactor view modules for improved readability and consistency

- Updated markdown rendering logic to use more concise methods for obtaining vector lengths.
- Changed review status display to use the correct flag from settings.
- Cleaned up sidebar rendering code for better formatting and readability.
- Enhanced status bar rendering with improved string formatting and consistent style application.
- Refined workflow panel rendering, ensuring consistent style usage and improved readability.
- Added architecture overview and detailed documentation for backend, data, dependencies, and frontend structures.
This commit is contained in:
asepharyana
2026-07-16 07:56:11 +07:00
parent 7d99cd6618
commit a00aa9bec8
141 changed files with 3420 additions and 2172 deletions
+68 -133
View File
@@ -545,44 +545,21 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
state.push_toast(Toast::new(ToastKind::Warning, "Aborting generation...".to_string()));
}
Action::Compact => {
let Some(messages) = state.session_runtime.as_ref().map(|rt| rt.messages.clone()) else {
return;
};
if messages.is_empty() {
return;
}
let (api_key, model, base_url) = match resolve_llm_client_config(state) {
Ok(v) => v,
Err(msg) => {
state.push_toast(Toast::new(ToastKind::Error, msg));
return;
}
};
let max_wire_tokens = crate::app::runtime::context::window::resolve(&state.app_config, &state.settings);
let turn_events = state.turn_events.clone();
state.push_toast(Toast::new(ToastKind::Info, "Compacting conversation history...".to_string()));
// Manual /compact previously ran synchronously and always
// passed `client: None` to shape_messages, so it never got
// LLM summarization — only automatic mid-turn compaction did.
// Running this on a background thread (same pattern as
// spawn_turn) fixes that asymmetry: both paths now summarize
// dropped history with the LLM instead of one silently
// falling back to a bare placeholder.
std::thread::spawn(move || {
let client = crate::service::provider::LlmClient::new(api_key, model, base_url);
let (deduped, _) = crate::app::runtime::context::dedup::collapse(&messages);
let token_count: usize = deduped.iter()
.map(crate::app::runtime::context::tokens::count_message_tokens)
let max_wire_tokens = state.app_config.model_roles.values()
.find(|role| role.provider == state.settings.provider && role.model == state.settings.model)
.and_then(|role| role.context_window)
.unwrap_or(state.app_config.default_context_window) as usize;
if let Some(ref mut rt) = state.session_runtime {
let total_chars: usize = rt.messages.iter()
.filter_map(|m| m.content.as_deref())
.map(str::len)
.sum();
let compacted = crate::app::runtime::context::shaping::shape_messages(
&deduped, token_count, max_wire_tokens, true, Some(&client),
);
if let Ok(mut q) = turn_events.lock() {
q.push_back(TurnEvent::Compacted(compacted));
}
});
let token_estimate = total_chars / 3;
rt.messages = crate::app::runtime::shortsend::shape_messages(&rt.messages, token_estimate, max_wire_tokens, true, None);
state.push_toast(Toast::new(ToastKind::Success, "Conversation history compacted.".to_string()));
state.dirty = true;
}
}
Action::LessonAccept { name } => {
if let Some(ref rt) = state.session_runtime {
@@ -623,45 +600,6 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
}
}
/// Resolve the API key, model name, and base URL for the currently
/// configured provider.
///
/// Flow: look up the provider's `ProviderConfig` for its `api_base` ->
/// resolve the API key from `Settings.api_keys`, falling back to the
/// provider's `api_key_env` environment variable, then its
/// `default_api_key`, then the crate-wide empty-string default.
///
/// Why: this exact resolution was duplicated between `spawn_turn` and
/// needed again for `Action::Compact`'s background-thread LLM call —
/// factored out so both stay in sync.
///
/// Return: `Ok((api_key, model, base_url))`, or `Err(message)` — a
/// user-facing string — if the configured provider has no entry in
/// `AppConfig.providers` at all.
fn resolve_llm_client_config(state: &AppStateRest) -> Result<(String, String, Option<String>), String> {
let base_url = state.app_config.providers.get(&state.settings.provider).map(|p| p.api_base.clone());
let Some(base_url) = base_url else {
return Err(format!(
"Provider '{}' is not configured — no matching entry found. \
Pick a different provider in Settings, or configure it.",
state.settings.provider
));
};
let mut api_key = state.settings.api_keys.get(&state.settings.provider).cloned().unwrap_or_default();
if api_key.is_empty() {
if let Some(provider_cfg) = state.app_config.providers.get(&state.settings.provider) {
api_key = provider_cfg.api_key_env.as_ref()
.and_then(|env| std::env::var(env).ok())
.or_else(|| provider_cfg.default_api_key.clone())
.unwrap_or_default();
}
}
if api_key.is_empty() {
api_key = crate::service::provider::DEFAULT_API_KEY.to_string();
}
Ok((api_key, state.settings.model.clone(), Some(base_url)))
}
/// Spawn a background thread that runs one full LLM turn.
///
/// Flow: check that no turn is currently in-flight → bail if so →
@@ -692,17 +630,42 @@ fn spawn_turn(state: &AppStateRest) {
if messages.is_empty() {
return;
}
let (api_key, model, base_url) = match resolve_llm_client_config(state) {
Ok(v) => v,
Err(msg) => {
if let Ok(mut q) = state.turn_events.lock() {
q.push_back(TurnEvent::Error(msg));
}
return;
let mut api_key = state.settings.api_keys.get(&state.settings.provider).cloned().unwrap_or_default();
let model = state.settings.model.clone();
let base_url = state.app_config.providers.get(&state.settings.provider)
.map(|p| p.api_base.clone());
let context_window = state.app_config.model_roles.values()
.find(|role| role.provider == state.settings.provider && role.model == state.settings.model)
.and_then(|role| role.context_window)
.unwrap_or(state.app_config.default_context_window) as usize;
// The selected provider has no entry in app_config at all (e.g. the
// Claude-settings auto-detection that registers "claude" found nothing
// this run). Without this check, LlmClient::new silently falls back to
// the zen default base URL while keeping this provider's model name —
// a mismatched request that reaches a real server and comes back as a
// confusing "Missing API key" 401 from an unrelated provider, instead
// of the actual problem: the configured provider doesn't exist.
if base_url.is_none() {
if let Ok(mut q) = state.turn_events.lock() {
q.push_back(TurnEvent::Error(format!(
"Provider '{}' is not configured — no matching entry found. \
Pick a different provider in Settings, or configure it.",
state.settings.provider
)));
}
};
let context_window = crate::app::runtime::context::window::resolve(&state.app_config, &state.settings);
let concise_output = state.settings.concise_output;
return;
}
if api_key.is_empty() {
if let Some(provider_cfg) = state.app_config.providers.get(&state.settings.provider) {
api_key = provider_cfg.api_key_env.as_ref()
.and_then(|env| std::env::var(env).ok())
.or_else(|| provider_cfg.default_api_key.clone())
.unwrap_or_default();
}
}
if api_key.is_empty() {
api_key = crate::service::provider::DEFAULT_API_KEY.to_string();
}
let (temperature, max_tokens) = crate::app::mode::effort::generation_params(
state.misc.effort_level,
state.settings.max_tokens,
@@ -747,7 +710,6 @@ fn spawn_turn(state: &AppStateRest) {
max_tokens,
abort_flag,
hive_mind_converged,
concise_output,
};
let result = run_agent_turn(&tc, &messages, &events_q);
if let Err(e) = result {
@@ -780,10 +742,6 @@ struct TurnCtx {
/// of this turn — whether a hive-mind convergence already completed
/// earlier in this session.
hive_mind_converged: bool,
/// Snapshot of `Settings.concise_output` taken at the start of this
/// turn, so the system-prompt assembly above can read it without
/// `TurnCtx` needing a `Settings` reference.
concise_output: bool,
}
/// Build an ASCII tree of the workspace directory structure for the
@@ -933,9 +891,8 @@ const HIVE_MIND_KICKOFF_NOTE: &str = "The Hive is stirring — Core Intelligence
/// handle tool calls, and loop until the LLM produces a non-tool response
/// or runs out of unfinished todo items.
///
/// Flow: build system prompt with workspace tree → deduplicate messages
/// via `context::dedup::collapse` → optionally shape (compact) messages via
/// `context::shaping::{should_shape, shape_messages}` → call `chat_with_tools_streaming`
/// Flow: build system prompt with workspace tree → optionally shape
/// (compact) messages via `shortsend` → call `chat_with_tools_streaming`
/// with a callback that pushes `StreamStart`, `StreamToken`, `Reasoning`,
/// and `Usage` events → on streaming success, handle tool calls (gated
/// through `Harness::gate_tool_call`) or unwrap the final assistant
@@ -970,23 +927,12 @@ fn run_agent_turn(
// workspace tree and reads all memory files each time).
let tree_info = generate_workspace_tree(&tc.workspace_roots);
let memory_section = build_memory_section(&tc.ctx.memory_dir);
let concise_section = if tc.concise_output {
"\n\nWrite tersely: drop articles (a/an/the), filler words (just/really/basically/\
actually/simply), pleasantries (sure/certainly/of course/happy to), and hedging. \
Fragments are fine. Code, commands, file paths, and error text must stay byte-exact \
— never abbreviate or paraphrase those. Exception: for destructive-operation \
confirmations and security-relevant warnings, always give full detail regardless of \
this instruction — clarity matters more than brevity when something risky is at stake."
} else {
""
};
let system_text = format!(
"{}\n\n{}\n\n{}{}{}",
"{}\n\n{}\n\n{}{}",
crate::resources::SYSTEM_PROMPT,
crate::resources::SYSTEM_TOOLS,
tree_info,
memory_section,
concise_section,
);
if !msgs.iter().any(|m| matches!(m.role, crate::dto::chat::message::Role::System)) {
let sys = ChatMessage::system(system_text);
@@ -1198,43 +1144,33 @@ fn run_agent_turn(
let mut todo_retry_count = 0usize;
loop {
// Dedup runs every iteration, unconditionally — repeated
// read-only tool calls (same tool + same arguments) are
// collapsed to their latest result before anything else, so
// context stays minimal from turn 1 instead of only shrinking
// once shaping's budget threshold trips.
let (deduped, dedup_changed) = crate::app::runtime::context::dedup::collapse(&msgs);
let token_count: usize = deduped.iter()
.map(crate::app::runtime::context::tokens::count_message_tokens)
let total_chars: usize = msgs.iter()
.filter_map(|m| m.content.as_deref())
.map(str::len)
.sum();
let token_estimate = total_chars / 4;
let max_wire_tokens = tc.context_window;
// Skip shaping if abort was requested — the non-streaming LLM
// call for summarization would block without checking abort_flag.
// Skip message compaction if abort was requested — the non-streaming
// LLM call for summarization would block without checking abort_flag.
let wire_msgs = if !tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst)
&& crate::app::runtime::context::shaping::should_shape(token_count, max_wire_tokens, prev_shaped)
&& crate::app::runtime::shortsend::should_shape(token_estimate, max_wire_tokens, prev_shaped)
{
prev_shaped = true;
let compacted = crate::app::runtime::context::shaping::shape_messages(&deduped, token_count, max_wire_tokens, false, Some(&tc.client));
// Dispatch to the main thread so the local session history is
// permanently updated and doesn't re-trigger shaping immediately
// on the next turn.
let compacted = crate::app::runtime::shortsend::shape_messages(&msgs, token_estimate, max_wire_tokens, false, Some(&tc.client));
// Dispatch the compacted messages to the main thread so the local session history
// is permanently compacted and doesn't trigger shaping again immediately on next turn.
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::Compacted(compacted.clone()));
}
// Also update our local `msgs` variable so the rest of the loop operates on the compacted version
msgs.clone_from(&compacted);
compacted
} else {
prev_shaped = false;
if dedup_changed {
if let Ok(mut q) = events_q.lock() {
q.push_back(TurnEvent::Compacted(deduped.clone()));
}
msgs.clone_from(&deduped);
}
deduped
msgs.clone()
};
let mut stream_started = false;
@@ -1481,8 +1417,7 @@ fn run_agent_turn(
}
}
let squashed_output = crate::app::runtime::context::squash::apply(&tool_name, &output);
let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), squashed_output);
let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), output);
archive_message(tc.db.as_ref(), &tc.session_id, &tool_msg);
msgs.push(tool_msg);
}
@@ -1677,7 +1612,7 @@ fn execute_one_tool(
/// `should_trigger_review` on `Tick`), only informs the user that
/// a review has material to examine.
fn maybe_trigger_review(state: &mut AppStateRest) {
if !state.settings.review_enabled {
if !state.settings.flags.review_enabled {
return;
}
let edit_count = state
+1 -1
View File
@@ -1,8 +1,8 @@
//! Maps parsed `/` slash commands into one or more `Action` variants
//! that `apply_action` can process.
use crate::controller::command::Command;
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.
///
+37 -24
View File
@@ -15,11 +15,10 @@
//! `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 std::collections::HashMap;
use sha2::Digest;
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]";
@@ -50,7 +49,9 @@ pub fn collapse(messages: &[ChatMessage]) -> (Vec<ChatMessage>, bool) {
continue;
}
let Some(id) = &m.tool_call_id else { continue };
let Some((name, args)) = call_info.get(id) else { continue };
let Some((name, args)) = call_info.get(id) else {
continue;
};
if !READ_TOOLS.contains(&name.as_str()) {
continue;
}
@@ -58,22 +59,30 @@ pub fn collapse(messages: &[ChatMessage]) -> (Vec<ChatMessage>, bool) {
}
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();
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)
}
@@ -102,7 +111,10 @@ mod tests {
m.tool_calls = Some(vec![ToolCall {
id: id.to_string(),
type_: "function".to_string(),
function: ToolFunction { name: name.to_string(), arguments: args },
function: ToolFunction {
name: name.to_string(),
arguments: args,
},
}]);
m
}
@@ -172,9 +184,10 @@ mod tests {
#[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 messages = vec![ChatMessage::tool_result(
"orphan-id".to_string(),
"some result".to_string(),
)];
let (result, changed) = collapse(&messages);
-1
View File
@@ -9,7 +9,6 @@
//! 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;
+13 -7
View File
@@ -3,7 +3,6 @@
//! 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;
@@ -101,7 +100,8 @@ pub fn shape_messages(
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]");
summary_text =
format!("[Summary of compacted prior conversation:\n{content}\n]");
}
}
Err(e) => {
@@ -136,7 +136,10 @@ mod tests {
#[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(900, 1000, true),
"below 95% and already shaped: no re-trigger yet"
);
assert!(should_shape(950, 1000, true));
}
@@ -186,9 +189,9 @@ mod tests {
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]")
});
let has_placeholder = result
.iter()
.any(|m| m.content.as_deref() == Some("[prior conversation compacted]"));
assert!(has_placeholder);
}
@@ -200,6 +203,9 @@ mod tests {
}
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");
assert!(
result.iter().any(|m| m.content == last_content),
"most recent message must survive shaping"
);
}
}
+46 -14
View File
@@ -10,7 +10,6 @@
//! 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;
@@ -220,12 +219,24 @@ fn squash_log(text: &str) -> String {
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));
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));
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();
@@ -257,7 +268,10 @@ fn squash_generic(text: &str, budget: usize) -> String {
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>();
+ 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;
@@ -324,8 +338,8 @@ mod tests {
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");
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");
@@ -357,7 +371,11 @@ mod tests {
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_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");
}
@@ -412,20 +430,34 @@ mod tests {
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");
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 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("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());
}
+3 -2
View File
@@ -10,7 +10,6 @@
//! `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`.
@@ -21,7 +20,9 @@ use crate::dto::chat::message::ChatMessage;
/// (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()
tiktoken_rs::o200k_base_singleton()
.encode_ordinary(text)
.len()
}
/// Count tokens in a `ChatMessage`'s text content.
+31 -18
View File
@@ -5,7 +5,6 @@
//! 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;
@@ -18,7 +17,9 @@ use crate::model::settings::Settings;
///
/// Return: always a concrete token count, never "unknown".
pub fn resolve(app_config: &AppConfig, settings: &Settings) -> usize {
app_config.model_roles.values()
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
@@ -32,13 +33,16 @@ mod tests {
#[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,
});
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();
@@ -53,23 +57,32 @@ mod tests {
settings.provider = "nonexistent".to_string();
settings.model = "nonexistent-model".to_string();
assert_eq!(resolve(&app_config, &settings), app_config.default_context_window as usize);
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,
});
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);
assert_eq!(
resolve(&app_config, &settings),
app_config.default_context_window as usize
);
}
}
+61 -92
View File
@@ -1,8 +1,6 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! SSE stream parser: converts SSE- or JSON-chunked LLM responses into
//! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done).
pub mod turn;
pub mod tools;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -89,7 +87,7 @@ impl SseParser {
/// provider-specific parsing layer.
///
/// Return: 0, 1, or more `StreamEvent`s from the flushed frame.
#[allow(clippy::too_many_lines)]
fn flush_event(&mut self) -> Vec<StreamEvent> {
let data = self.data_lines.join("\n");
self.data_lines.clear();
@@ -112,20 +110,32 @@ impl SseParser {
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)
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 });
events.push(StreamEvent::Usage {
prompt_tokens,
completion_tokens,
total_tokens,
});
}
}
@@ -143,23 +153,32 @@ impl SseParser {
}
// Reasoning token
if let Some(reasoning) = d.get("reasoning_content").and_then(|r| r.as_str()) {
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()) {
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")
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")
let args_delta = tc
.get("function")
.and_then(|f| f.get("arguments"))
.and_then(|a| a.as_str())
.unwrap_or("")
@@ -174,7 +193,9 @@ impl SseParser {
}
// Finish reason
if let Some(reason) = choice.get("finish_reason").and_then(|r| r.as_str()) {
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);
}
@@ -193,72 +214,6 @@ impl SseParser {
events.append(&mut other_events);
events
}
/// Clears any partially-buffered SSE frame. Reserved for reconnect/retry flows that
/// reuse a parser instance across requests rather than constructing a fresh one.
#[allow(dead_code)]
pub fn reset(&mut self) {
self.buffer.clear();
self.event_type = None;
self.data_lines.clear();
}
}
/// Fallback parser for providers that send bare JSON chunks instead of SSE-framed
/// `data: ...` lines. Not used by the `SseParser` streaming path (which handles
/// standard SSE framing directly), kept for providers/tests that feed raw chunks.
///
/// Flow: parse `data` as JSON → extract first `choices[0].delta` →
/// return a `Token`, `Reasoning`, `Done`, or `ToolCallDelta` event based
/// on the fields present.
///
/// Return: `Some(StreamEvent)` if the chunk contained recognisable
/// content, `None` otherwise.
#[allow(dead_code)]
pub fn parse_stream_chunk(data: &str) -> Option<StreamEvent> {
let value: Value = serde_json::from_str(data).ok()?;
if value == Value::Null {
return None;
}
let choices = value.get("choices")?.as_array()?;
let choice = choices.first()?;
let delta = choice.get("delta")?;
if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
return Some(StreamEvent::Token(content.to_string()));
}
if let Some(reasoning) = delta.get("reasoning_content").and_then(|r| r.as_str()) {
return Some(StreamEvent::Reasoning(reasoning.to_string()));
}
if let Some(finish) = choice.get("finish_reason").and_then(|r| r.as_str()) {
if finish == "stop" || finish == "tool_calls" {
return Some(StreamEvent::Done);
}
}
if let Some(tool_calls) = delta.get("tool_calls").and_then(|tc| tc.as_array()) {
if let Some(tc) = tool_calls.first() {
let index = tc.get("index").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
tracing::warn!("[stream] fallback parser: tool call 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 = tc.get("function")
.and_then(|f| f.get("arguments"))
.and_then(|a| a.as_str())
.unwrap_or("")
.to_string();
return Some(StreamEvent::ToolCallDelta {
index,
id,
name,
arguments_delta: args,
});
}
}
None
}
#[cfg(test)]
@@ -280,7 +235,10 @@ mod tests {
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");
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] {
@@ -300,9 +258,7 @@ mod tests {
#[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",
);
let events = p.feed("data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n");
assert_eq!(events.len(), 1);
assert!(matches!(events[0], StreamEvent::Done));
}
@@ -315,7 +271,12 @@ mod tests {
);
assert_eq!(events.len(), 1);
match &events[0] {
StreamEvent::ToolCallDelta { index, id, name, arguments_delta } => {
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"));
@@ -333,7 +294,11 @@ mod tests {
);
assert_eq!(events.len(), 1);
match &events[0] {
StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens } => {
StreamEvent::Usage {
prompt_tokens,
completion_tokens,
total_tokens,
} => {
assert_eq!(*prompt_tokens, 10);
assert_eq!(*completion_tokens, 5);
assert_eq!(*total_tokens, 15);
@@ -351,7 +316,11 @@ mod tests {
assert_eq!(events.len(), 2);
match (&events[0], &events[1]) {
(
StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens },
StreamEvent::Usage {
prompt_tokens,
completion_tokens,
total_tokens,
},
StreamEvent::Token(t),
) => {
assert_eq!(*prompt_tokens, 10);
-101
View File
@@ -1,101 +0,0 @@
//! Standalone accumulator for streamed tool-call deltas.
//!
//! Flow: `ToolCallAccumulator::add_delta` is fed incremental `(index, id,
//! name, arguments_delta)` chunks as they arrive over SSE → grows its
//! internal `Vec<ParsedToolCall>` as needed → `is_complete` reports once
//! every accumulated call has both a name and arguments.
//!
//! Why: mirrors the accumulation logic built into `StreamedTurn::apply_event`
//! but as an independent, reusable type for callers that want to track
//! tool-call deltas without a full `StreamedTurn` (e.g. a lighter-weight
//! preview). Currently unused (`#[allow(dead_code)]`), kept for that future
//! use case.
use super::turn::ParsedToolCall;
use serde_json::{json, Value};
/// Standalone tool-call delta accumulator, functionally equivalent to the accumulation
/// logic built into `StreamedTurn::apply_event`. Reserved for callers that want to track
/// tool-call deltas independently of a full `StreamedTurn` (e.g. a lighter-weight preview).
#[allow(dead_code)]
pub struct ToolCallAccumulator {
calls: Vec<ParsedToolCall>,
}
#[allow(dead_code)]
impl ToolCallAccumulator {
/// Construct an empty accumulator with no tool calls tracked yet.
///
/// Return: a fresh `ToolCallAccumulator`.
pub fn new() -> Self {
ToolCallAccumulator { calls: Vec::new() }
}
/// Append a delta to the tool call at the given index, growing the
/// calls vector if needed.
pub fn add_delta(
&mut self,
index: usize,
id: Option<&str>,
name: Option<&str>,
arguments_delta: &str,
) {
while self.calls.len() <= index {
self.calls.push(ParsedToolCall {
id: String::new(),
name: String::new(),
arguments: String::new(),
is_complete: false,
});
}
let tc = &mut self.calls[index];
if let Some(new_id) = id {
if !new_id.is_empty() {
tc.id = new_id.to_string();
}
}
if let Some(new_name) = name {
if !new_name.is_empty() {
tc.name = new_name.to_string();
}
}
tc.arguments.push_str(arguments_delta);
}
/// Borrow the accumulated tool calls.
pub fn calls(&self) -> &[ParsedToolCall] {
&self.calls
}
/// Return true once all tool calls have both a name and arguments.
pub fn is_complete(&self) -> bool {
!self.calls.is_empty() && self.calls.iter().all(|tc| !tc.name.is_empty() && !tc.arguments.is_empty())
}
/// Clear all accumulated calls (starting a fresh turn).
pub fn reset(&mut self) {
self.calls.clear();
}
/// Build a JSON-serialisable `Vec<Value>` of pending (non-empty-name)
/// tool calls, suitable for downstream inspection or replay.
pub fn pending_args(&self) -> Vec<Value> {
self.calls
.iter()
.filter(|tc| !tc.name.is_empty())
.map(|tc| {
json!({
"tool_call_id": tc.id,
"name": tc.name,
"arguments": tc.arguments,
})
})
.collect()
}
}
impl Default for ToolCallAccumulator {
fn default() -> Self {
Self::new()
}
}
+31 -35
View File
@@ -101,17 +101,7 @@ pub struct ParsedToolCall {
pub is_complete: bool,
}
impl ParsedToolCall {
/// Attempt to parse the accumulated argument string as JSON before
/// the tool call is marked complete — useful for a speculative preview.
///
/// Return: `Some(Value)` if the arguments are parsable JSON, `None`
/// if still partial.
#[allow(dead_code)]
pub fn try_parse(&self) -> Option<Value> {
serde_json::from_str(&self.arguments).ok()
}
}
impl ParsedToolCall {}
impl StreamedTurn {
/// Create an empty turn accumulator.
@@ -186,12 +176,12 @@ impl StreamedTurn {
let mut msg = if self.tool_calls.is_empty() {
ChatMessage::assistant(None)
} else {
let tool_dtos: Vec<ToolCall> = self.tool_calls
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)
{
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);
@@ -200,7 +190,8 @@ impl StreamedTurn {
tracing::warn!(
"[stream] tool call '{}' had truncated JSON \
arguments repaired successfully: {}",
tc.name, e,
tc.name,
e,
);
v
}
@@ -209,7 +200,9 @@ impl StreamedTurn {
"[stream] tool call '{}' has invalid JSON \
arguments: {} (after repair: {}) falling \
back to raw string",
tc.name, e, e2,
tc.name,
e,
e2,
);
serde_json::Value::String(tc.arguments.clone())
}
@@ -235,7 +228,10 @@ impl StreamedTurn {
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)
format!(
"<think>\n{}\n</think>\n\n{}",
self.accumulated_reasoning, self.accumulated_content
)
};
let content = if full_content.is_empty() {
None
@@ -259,7 +255,8 @@ impl StreamedTurn {
/// 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()
self.tool_calls
.iter()
.filter(|tc| !tc.name.is_empty())
.find_map(|tc| {
serde_json::from_str::<Value>(&tc.arguments)
@@ -267,19 +264,6 @@ impl StreamedTurn {
.map(|e| (tc.name.as_str(), e.to_string()))
})
}
/// Reserved accessor for callers that want to branch mid-stream before the turn
/// completes; the current wiring only inspects the final `build_assistant_message()`.
#[allow(dead_code)]
pub fn has_tool_calls(&self) -> bool {
self.tool_calls.iter().any(|tc| !tc.name.is_empty())
}
/// Reserved accessor mirroring `has_tool_calls` for mid-stream content peeks.
#[allow(dead_code)]
pub fn content(&self) -> &str {
&self.accumulated_content
}
}
impl Default for StreamedTurn {
@@ -353,7 +337,10 @@ mod tests {
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!(
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"));
}
@@ -361,7 +348,10 @@ mod tests {
#[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"));
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"));
}
@@ -369,7 +359,10 @@ mod tests {
#[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\"}"));
turn.tool_calls.push(tool_call(
"write",
"{\"path\": \"a.txt\", \"content\": \"done\"}",
));
assert!(turn.incomplete_tool_call().is_none());
}
@@ -386,7 +379,10 @@ mod tests {
// 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"));
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());
}