Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/acp_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ struct AcpProfileAgentsResponse {
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct DelegateModelOverrideInfo {
pub model_id: String,
#[allow(dead_code)] // Preserved from the ACP response for remote model identity.
#[serde(default)]
pub node_id: Option<String>,
}
Expand Down
147 changes: 65 additions & 82 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ pub struct AppLogEntry {
// ── Delegation tracking ───────────────────────────────────────────────────────

/// Update per-delegation stats from a single event arriving on a child session.
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn accumulate_delegate_stats(stats: &mut DelegateStats, kind: &EventKind) {
match kind {
EventKind::ToolCallStart { .. } => {
Expand Down Expand Up @@ -301,6 +302,7 @@ pub(crate) fn accumulate_delegate_stats(stats: &mut DelegateStats, kind: &EventK
}
}

#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn update_delegate_child_state(state: &mut DelegateChildState, kind: &EventKind) {
match kind {
EventKind::ElicitationRequested {
Expand Down Expand Up @@ -361,6 +363,7 @@ pub(crate) fn update_delegate_child_state(state: &mut DelegateChildState, kind:
}
}

#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn backfill_elicitation_outcomes(messages: &mut [ChatEntry], result_str: &str) {
let Ok(val) = serde_json::from_str::<serde_json::Value>(result_str) else {
return;
Expand Down Expand Up @@ -764,9 +767,6 @@ pub struct App {
pub parent_session_id: Option<String>,
/// Staging field: set by delegate popup before LoadSession, consumed by session_loaded.
pub pending_parent_session_id: Option<String>,
/// Set after DelegationCompleted/DelegationFailed; consumed by the next
/// UserMessageStored to suppress the noisy batch-result message.
pub suppress_delegation_result: bool,
/// Child-session state observed before a delegation entry can be linked.
pub pending_delegate_child_states: HashMap<String, DelegateChildState>,
pub pending_delegate_child_stats: HashMap<String, DelegateStats>,
Expand Down Expand Up @@ -982,7 +982,6 @@ impl App {
delegate_filter: String::new(),
parent_session_id: None,
pending_parent_session_id: None,
suppress_delegation_result: false,
pending_delegate_child_states: HashMap::new(),
pending_delegate_child_stats: HashMap::new(),
delegate_child_message_ids: HashMap::new(),
Expand Down Expand Up @@ -1063,9 +1062,6 @@ impl App {
self.reasoning_effort.as_deref().unwrap_or("auto")
}

/// Valid reasoning effort levels (excluding "auto" which maps to `None`).
pub const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "max"];

/// Cycle through `[auto, low, medium, high, max]` (wraps around).
/// Updates `self.reasoning_effort` optimistically, saves the new value as
/// the preference for the current `(mode, provider, model)` context, and
Expand Down Expand Up @@ -1183,23 +1179,12 @@ impl App {
.find(|profile| profile.id == profile_id)
}

pub fn active_profile(&self) -> Option<&ProfileInfo> {
self.active_profile_id
.as_deref()
.and_then(|profile_id| self.profile_by_id(profile_id))
}

pub fn current_session_profile_id(&self) -> Option<&str> {
self.session_id
.as_deref()
.and_then(|session_id| self.session_profiles.get(session_id).map(String::as_str))
}

pub fn current_session_profile(&self) -> Option<&ProfileInfo> {
self.current_session_profile_id()
.and_then(|profile_id| self.profile_by_id(profile_id))
}

pub fn profile_display_name(&self, profile_id: &str) -> String {
self.profile_by_id(profile_id)
.map(|profile| profile.name.clone())
Expand Down Expand Up @@ -1728,6 +1713,7 @@ impl App {
self.session_stats.open_llm_request_instant = None;
}

#[cfg_attr(not(test), allow(dead_code))]
pub fn apply_event_stats(&mut self, kind: &EventKind, timestamp: Option<i64>) {
match kind {
EventKind::ToolCallStart { .. } => {
Expand Down Expand Up @@ -1816,6 +1802,7 @@ impl App {
.unwrap_or(false)
}

#[cfg_attr(not(test), allow(dead_code))]
pub fn pending_session_label(&self) -> Option<&'static str> {
match self.activity {
ActivityState::SessionOp(SessionOp::Undo) => Some("undoing"),
Expand Down Expand Up @@ -1978,6 +1965,7 @@ impl App {
// ── delegate model preferences ───────────────────────────────────────────

/// Whether there are multiple agents (multi-agent / delegation mode).
#[cfg_attr(not(test), allow(dead_code))]
pub fn is_multi_agent(&self) -> bool {
self.agents.len() > 1
}
Expand Down Expand Up @@ -2875,7 +2863,16 @@ mod session_mode_tests {
mod tests {
use super::*;
use crate::domain::chat::OUTCOME_BULLET;
use crate::protocol::{AgentEvent, ProgressKind};
use crate::protocol::ProgressKind;
use serde::Deserialize;

#[derive(Deserialize)]
struct TestAgentEvent {
#[serde(default)]
timestamp: Option<String>,
#[serde(flatten)]
kind: EventKind,
}

fn make_turn(message_id: &str) -> UndoableTurn {
UndoableTurn {
Expand All @@ -2894,76 +2891,69 @@ mod tests {
#[test]
fn backend_next_protocol_events_deserialize() {
let session_queued = serde_json::json!({
"kind": {
"type": "session_queued",
"data": { "reason": "waiting for previous operation to complete" }
},
"type": "session_queued",
"data": { "reason": "waiting for previous operation to complete" },
"timestamp": null
});
let session_configured = serde_json::json!({
"kind": {
"type": "session_configured",
"data": {
"cwd": "/workspace/project",
"mcp_servers": [],
"limits": {
"max_steps": 200,
"max_turns": 50,
"max_cost_usd": null
}
"type": "session_configured",
"data": {
"cwd": "/workspace/project",
"mcp_servers": [],
"limits": {
"max_steps": 200,
"max_turns": 50,
"max_cost_usd": null
}
},
"timestamp": null
});
let tools_available = serde_json::json!({
"kind": {
"type": "tools_available",
"data": {
"tools": [{
"type": "function",
"function": {
"name": "search_text",
"description": "Search file contents",
"parameters": { "type": "object" }
}
}],
"tools_hash": "123456789"
}
"type": "tools_available",
"data": {
"tools": [{
"type": "function",
"function": {
"name": "search_text",
"description": "Search file contents",
"parameters": { "type": "object" }
}
}],
"tools_hash": "123456789"
},
"timestamp": null
});
let artifact_recorded = serde_json::json!({
"kind": {
"type": "artifact_recorded",
"data": {
"artifact": {
"kind": "file",
"uri": null,
"path": "src/generated.txt",
"summary": "Produced by write_file",
"created_at": "2026-04-29T14:25:09Z"
}
"type": "artifact_recorded",
"data": {
"artifact": {
"kind": "file",
"uri": null,
"path": "src/generated.txt",
"summary": "Produced by write_file",
"created_at": "2026-04-29T14:25:09Z"
}
},
"timestamp": null
});

let queued: AgentEvent = serde_json::from_value(session_queued).unwrap();
let queued: TestAgentEvent = serde_json::from_value(session_queued).unwrap();
assert!(queued.timestamp.is_none());
assert!(
matches!(queued.kind, EventKind::SessionQueued { reason } if reason == "waiting for previous operation to complete")
);

let configured: AgentEvent = serde_json::from_value(session_configured).unwrap();
let configured: TestAgentEvent = serde_json::from_value(session_configured).unwrap();
assert!(
matches!(configured.kind, EventKind::SessionConfigured { cwd, mcp_servers, limits } if cwd.as_deref() == Some("/workspace/project") && mcp_servers.is_empty() && limits.as_ref().and_then(|l| l.max_steps) == Some(200))
);

let available: AgentEvent = serde_json::from_value(tools_available).unwrap();
let available: TestAgentEvent = serde_json::from_value(tools_available).unwrap();
assert!(
matches!(available.kind, EventKind::ToolsAvailable { tools, tools_hash } if tools.first().and_then(|tool| tool.function.as_ref()).map(|function| function.name.as_str()) == Some("search_text") && tools_hash.is_some())
);

let artifact: AgentEvent = serde_json::from_value(artifact_recorded).unwrap();
let artifact: TestAgentEvent = serde_json::from_value(artifact_recorded).unwrap();
assert!(
matches!(artifact.kind, EventKind::ArtifactRecorded { artifact } if artifact.kind == "file" && artifact.path.as_deref() == Some("src/generated.txt") && artifact.summary.as_deref() == Some("Produced by write_file"))
);
Expand All @@ -2972,43 +2962,36 @@ mod tests {
#[test]
fn backend_snapshot_and_progress_events_deserialize() {
let snapshot_start = serde_json::json!({
"kind": {
"type": "snapshot_start",
"data": { "policy": "diff" }
},
"type": "snapshot_start",
"data": { "policy": "diff" },
"timestamp": null
});
let snapshot_end = serde_json::json!({
"kind": {
"type": "snapshot_end",
"data": { "summary": "1 modified" }
},
"type": "snapshot_end",
"data": { "summary": "1 modified" },
"timestamp": null
});
let progress_recorded = serde_json::json!({
"kind": {
"type": "progress_recorded",
"data": {
"progress_entry": {
"kind": "tool_call",
"content": "Calling tool: shell",
"metadata": "{\"tool\":\"shell\"}",
"created_at": "2026-04-13T00:00:00Z"
}
"type": "progress_recorded",
"data": {
"progress_entry": {
"kind": "tool_call",
"content": "Calling tool: shell",
"metadata": "{\"tool\":\"shell\"}",
"created_at": "2026-04-13T00:00:00Z"
}
},
"timestamp": null
});

let start: AgentEvent = serde_json::from_value(snapshot_start).unwrap();
assert!(matches!(start.kind, EventKind::SnapshotStart { policy } if policy == "diff"));
let start: TestAgentEvent = serde_json::from_value(snapshot_start).unwrap();
let end: TestAgentEvent = serde_json::from_value(snapshot_end).unwrap();
let progress: TestAgentEvent = serde_json::from_value(progress_recorded).unwrap();

let end: AgentEvent = serde_json::from_value(snapshot_end).unwrap();
assert!(matches!(start.kind, EventKind::SnapshotStart { policy } if policy == "diff"));
assert!(
matches!(end.kind, EventKind::SnapshotEnd { summary } if summary.as_deref() == Some("1 modified"))
);

let progress: AgentEvent = serde_json::from_value(progress_recorded).unwrap();
assert!(
matches!(progress.kind, EventKind::ProgressRecorded { progress_entry } if progress_entry.kind == ProgressKind::ToolCall && progress_entry.content == "Calling tool: shell")
);
Expand Down
11 changes: 9 additions & 2 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,21 @@ use crate::domain::model::DelegateModelPreference;
// ── path overrides for tests ─────────────────────────────────────────────────

static CONFIG_PATH_OVERRIDE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
#[cfg(test)]
static TEST_PERSISTENCE_LOCK: OnceLock<Mutex<()>> = OnceLock::new();

fn config_path_override() -> &'static Mutex<Option<PathBuf>> {
CONFIG_PATH_OVERRIDE.get_or_init(|| Mutex::new(None))
}

#[cfg(test)]
fn test_persistence_lock() -> &'static Mutex<()> {
TEST_PERSISTENCE_LOCK.get_or_init(|| Mutex::new(()))
}

/// Override the config path used by `TuiConfig::load()` / `save()`.
/// Intended for tests only; production code should not call this.
#[cfg(test)]
pub fn test_set_config_path_override(path: Option<PathBuf>) {
*config_path_override().lock().unwrap() = path;
}
Expand Down Expand Up @@ -196,11 +199,15 @@ mod tests {
dir
}

struct TestPathGuard(TestPersistenceGuard);
struct TestPathGuard {
_guard: TestPersistenceGuard,
}

impl TestPathGuard {
fn new(label: &str) -> Self {
Self(TestPersistenceGuard::new(label))
Self {
_guard: TestPersistenceGuard::new(label),
}
}
}

Expand Down
10 changes: 8 additions & 2 deletions src/domain/activity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ impl DelegateEntry {
)
}

#[cfg_attr(not(test), allow(dead_code))]
pub fn pending_elicitation(&self) -> Option<(&str, &str, &str)> {
match &self.child_state {
DelegateChildState::PendingElicitation {
Expand Down Expand Up @@ -123,8 +124,13 @@ pub enum ActivityState {
Idle,
Thinking,
Streaming,
RunningTool { name: String },
Compacting { token_estimate: u32 },
RunningTool {
name: String,
},
#[allow(dead_code)] // Retained for compaction rendering and replay state.
Compacting {
token_estimate: u32,
},
SessionOp(SessionOp),
}

Expand Down
1 change: 1 addition & 0 deletions src/domain/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ impl AuthProviderEntry {
}

/// Provider supports multiple auth methods (both OAuth and API key).
#[cfg_attr(not(test), allow(dead_code))]
pub fn has_multiple_auth_methods(&self) -> bool {
self.supports_oauth && self.env_var_name.is_some()
}
Expand Down
4 changes: 4 additions & 0 deletions src/domain/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,23 @@ pub enum ChatEntry {
is_error: bool,
detail: ToolDetail,
},
#[allow(dead_code)] // Retained for compaction event rendering.
CompactionStart {
token_estimate: u32,
},
#[allow(dead_code)] // Retained for compaction event rendering.
CompactionEnd {
token_estimate: Option<u32>,
summary: String,
summary_len: u32,
},
#[allow(dead_code)] // Retained for informational replay entries.
Info(String),
Error(String),
Elicitation {
elicitation_id: String,
message: String,
#[allow(dead_code)] // Retained to preserve elicitation origin metadata.
source: String,
/// None = pending; Some = responded with this outcome label.
outcome: Option<String>,
Expand Down
1 change: 1 addition & 0 deletions src/domain/elicitation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub struct ElicitationField {
pub struct ElicitationState {
pub elicitation_id: String,
pub message: String,
#[allow(dead_code)] // Retained so responses can preserve their ACP source metadata.
pub source: String,
pub fields: Vec<ElicitationField>,
/// Accumulated schema values (field name -> value).
Expand Down
Loading