fix: close v0.7.2 issue cleanup

This commit is contained in:
Hunter Bown
2026-04-28 23:09:19 -05:00
parent 0f8c363012
commit 6d8ab4c2b8
12 changed files with 1462 additions and 1256 deletions
+14 -1
View File
@@ -64,6 +64,16 @@ deepseek --provider nvidia-nim
DEEPSEEK_PROVIDER=nvidia-nim NVIDIA_API_KEY="..." deepseek
```
### Other DeepSeek V4 providers
```bash
deepseek auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"
deepseek --provider fireworks --model deepseek-v4-pro
# SGLang is self-hosted; auth is optional for localhost deployments.
SGLANG_BASE_URL="http://localhost:30000/v1" deepseek --provider sglang --model deepseek-v4-flash
```
<details>
<summary>Install from source</summary>
@@ -181,9 +191,12 @@ Key environment overrides:
| `DEEPSEEK_API_KEY` | API key |
| `DEEPSEEK_BASE_URL` | API base URL |
| `DEEPSEEK_MODEL` | Default model |
| `DEEPSEEK_PROVIDER` | Provider: `deepseek` (default) or `nvidia-nim` |
| `DEEPSEEK_PROVIDER` | Provider: `deepseek` (default), `nvidia-nim`, `fireworks`, or `sglang` |
| `DEEPSEEK_PROFILE` | Config profile name |
| `NVIDIA_API_KEY` | NVIDIA NIM API key |
| `FIREWORKS_API_KEY` | Fireworks AI API key |
| `SGLANG_BASE_URL` | Self-hosted SGLang endpoint |
| `SGLANG_API_KEY` | Optional SGLang bearer token |
Quick diagnostics:
+22 -5
View File
@@ -10,12 +10,12 @@
# Active provider + DeepSeek defaults
# ─────────────────────────────────────────────────────────────────────────────────
# Choose which provider to use by default. Per-provider credentials live in the
# `[providers.deepseek]` and `[providers.nvidia_nim]` sections near the bottom of
# `[providers.*]` sections near the bottom of
# this file — keeping both stored at once means `/provider deepseek` and
# `/provider nvidia-nim` (or `--provider nvidia-nim`) toggle without having to
# `/provider nvidia-nim` (or `--provider fireworks`, `/provider sglang`) toggle without having to
# re-enter keys. Top-level `api_key` / `base_url` are still read as DeepSeek
# defaults when `[providers.deepseek]` is absent (backward compatibility).
provider = "deepseek" # deepseek | nvidia-nim
provider = "deepseek" # deepseek | nvidia-nim | openrouter | novita | fireworks | sglang
api_key = "YOUR_DEEPSEEK_API_KEY" # must be non-empty
base_url = "https://api.deepseek.com"
# base_url = "https://api.deepseeki.com" # China users
@@ -29,6 +29,9 @@ base_url = "https://api.deepseek.com"
# deepseek-v4-flash — fast, cost-efficient (legacy aliases: deepseek-chat, deepseek-reasoner)
# deepseek-ai/deepseek-v4-pro — NVIDIA NIM-hosted Pro model ID
# deepseek-ai/deepseek-v4-flash — NVIDIA NIM-hosted Flash model ID
# accounts/fireworks/models/deepseek-v4-pro — Fireworks AI Pro model ID
# deepseek-ai/DeepSeek-V4-Pro — SGLang self-hosted Pro model ID
# deepseek-ai/DeepSeek-V4-Flash — SGLang self-hosted Flash model ID
default_text_model = "deepseek-v4-pro"
# ─────────────────────────────────────────────────────────────────────────────────
@@ -81,12 +84,14 @@ max_subagents = 5 # optional (1-20)
# ─────────────────────────────────────────────────────────────────────────────────
# Per-provider credentials (peer providers — NIM is first-class, not a flag)
# ─────────────────────────────────────────────────────────────────────────────────
# Both providers can be stored at once; `provider = "..."` (top of file) or
# `/provider deepseek` / `/provider nvidia-nim` switches between them without
# Providers can be stored at once; `provider = "..."` (top of file) or
# `/provider deepseek` / `/provider nvidia-nim` / `/provider fireworks` switches between them without
# having to re-enter keys. Env vars override anything set here:
# DeepSeek: DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, DEEPSEEK_MODEL
# NIM: NVIDIA_API_KEY (or NVIDIA_NIM_API_KEY), NIM_BASE_URL
# (or NVIDIA_NIM_BASE_URL / NVIDIA_BASE_URL), NVIDIA_NIM_MODEL
# Fireworks: FIREWORKS_API_KEY, FIREWORKS_BASE_URL
# SGLang: SGLANG_BASE_URL, SGLANG_MODEL, optional SGLANG_API_KEY
# DeepSeek Platform (https://platform.deepseek.com)
[providers.deepseek]
@@ -100,6 +105,18 @@ max_subagents = 5 # optional (1-20)
# base_url = "https://integrate.api.nvidia.com/v1"
# model = "deepseek-ai/deepseek-v4-pro" # or deepseek-ai/deepseek-v4-flash
# Fireworks AI-hosted DeepSeek V4 (https://fireworks.ai)
[providers.fireworks]
# api_key = "YOUR_FIREWORKS_API_KEY"
# base_url = "https://api.fireworks.ai/inference/v1"
# model = "accounts/fireworks/models/deepseek-v4-pro"
# Self-hosted SGLang OpenAI-compatible server
[providers.sglang]
# api_key = "OPTIONAL_SGLANG_TOKEN"
# base_url = "http://localhost:30000/v1"
# model = "deepseek-ai/DeepSeek-V4-Pro" # or deepseek-ai/DeepSeek-V4-Flash
# ─────────────────────────────────────────────────────────────────────────────────
# Network Policy (#135)
# ─────────────────────────────────────────────────────────────────────────────────
+11
View File
@@ -447,6 +447,17 @@ mod tests {
));
}
#[test]
fn required_str_reports_provided_fields_on_missing_required_field() {
let input = json!({"path": "src/lib.rs", "content": "new body"});
let err = required_str(&input, "replace").expect_err("replace is missing");
let message = err.to_string();
assert!(message.contains("missing required field 'replace'"));
assert!(message.contains("Input provided:"));
assert!(message.contains("path"));
assert!(message.contains("content"));
}
#[test]
fn tool_error_display_matches_legacy_text() {
let err = ToolError::missing_field("path");
+6 -4
View File
@@ -448,10 +448,12 @@ impl DeepSeekClient {
fn build_http_client(api_key: &str) -> Result<reqwest::Client> {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {api_key}"))?,
);
if !api_key.trim().is_empty() {
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {api_key}"))?,
);
}
let mut builder = reqwest::Client::builder()
.default_headers(headers)
.connect_timeout(Duration::from_secs(30))
+29 -3
View File
@@ -1,5 +1,5 @@
//! Provider switching: flip between DeepSeek, NVIDIA NIM, OpenRouter, and
//! Novita AI at runtime.
//! Provider switching: flip between DeepSeek, hosted providers, and self-hosted
//! OpenAI-compatible DeepSeek V4 servers at runtime.
//!
//! `/provider` with no args opens the picker modal (#52). `/provider <name>`
//! keeps the v0.6.6 CLI form for muscle-memory + scripted use.
@@ -27,7 +27,7 @@ pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult {
let Some(target) = ApiProvider::parse(name) else {
return CommandResult::error(format!(
"Unknown provider '{name}'. Expected: deepseek, nvidia-nim, openrouter, novita."
"Unknown provider '{name}'. Expected: deepseek, nvidia-nim, openrouter, novita, fireworks, or sglang."
));
};
@@ -135,6 +135,32 @@ mod tests {
}
}
#[test]
fn switch_to_fireworks_emits_action() {
let mut app = create_test_app();
let result = provider(&mut app, Some("fireworks pro"));
match result.action {
Some(AppAction::SwitchProvider { provider, model }) => {
assert_eq!(provider, ApiProvider::Fireworks);
assert_eq!(model.as_deref(), Some("deepseek-v4-pro"));
}
other => panic!("expected SwitchProvider, got {other:?}"),
}
}
#[test]
fn switch_to_sglang_flash_emits_action() {
let mut app = create_test_app();
let result = provider(&mut app, Some("sglang flash"));
match result.action {
Some(AppAction::SwitchProvider { provider, model }) => {
assert_eq!(provider, ApiProvider::Sglang);
assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
}
other => panic!("expected SwitchProvider, got {other:?}"),
}
}
#[test]
fn switching_to_active_provider_without_model_is_a_noop() {
let mut app = create_test_app();
+82 -5
View File
@@ -1020,11 +1020,10 @@ impl Config {
"Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \
set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml."
),
ApiProvider::Sglang => anyhow::bail!(
"SGLang API key not found (optional for self-hosted). Run 'deepseek auth set --provider sglang', \
set SGLANG_API_KEY, or add [providers.sglang] api_key in ~/.deepseek/config.toml. \
If your SGLang deployment runs without authentication, set SGLANG_API_KEY to an empty string or any placeholder."
),
// Self-hosted SGLang deployments commonly run without auth on
// localhost. Return an empty key and let the client omit the
// Authorization header.
ApiProvider::Sglang => Ok(String::new()),
}
}
@@ -2715,6 +2714,60 @@ mod tests {
Ok(())
}
#[test]
fn fireworks_provider_uses_canonical_defaults() -> Result<()> {
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"deepseek-tui-fireworks-defaults-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root)?;
let _guard = EnvGuard::new(&temp_root);
let config = Config {
provider: Some("fireworks".to_string()),
..Default::default()
};
config.validate()?;
assert_eq!(config.api_provider(), ApiProvider::Fireworks);
assert_eq!(config.default_model(), DEFAULT_FIREWORKS_MODEL);
assert_eq!(config.deepseek_base_url(), DEFAULT_FIREWORKS_BASE_URL);
Ok(())
}
#[test]
fn sglang_provider_works_without_api_key() -> Result<()> {
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"deepseek-tui-sglang-defaults-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root)?;
let _guard = EnvGuard::new(&temp_root);
let config = Config {
provider: Some("sglang".to_string()),
..Default::default()
};
config.validate()?;
assert_eq!(config.api_provider(), ApiProvider::Sglang);
assert_eq!(config.default_model(), DEFAULT_SGLANG_MODEL);
assert_eq!(config.deepseek_base_url(), DEFAULT_SGLANG_BASE_URL);
assert_eq!(config.deepseek_api_key()?, "");
assert!(has_api_key_for(&config, ApiProvider::Sglang));
Ok(())
}
#[test]
fn openrouter_env_api_key_resolves_via_deepseek_api_key() -> Result<()> {
let _lock = lock_test_env();
@@ -2880,6 +2933,10 @@ api_key = "novita-table-key"
let mut config = Config::default();
assert!(!has_api_key_for(&config, ApiProvider::Openrouter));
assert!(
has_api_key_for(&config, ApiProvider::Sglang),
"SGLang is self-hosted and does not require a key by default"
);
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
@@ -2946,6 +3003,26 @@ api_key = "novita-table-key"
.and_then(toml::Value::as_str),
Some("novita-saved-key")
);
save_api_key_for(ApiProvider::Fireworks, "fireworks-saved-key")?;
save_api_key_for(ApiProvider::Sglang, "sglang-saved-key")?;
let contents = fs::read_to_string(&path)?;
let parsed: toml::Value = toml::from_str(&contents)?;
assert_eq!(
parsed
.get("providers")
.and_then(|p| p.get("fireworks"))
.and_then(|t| t.get("api_key"))
.and_then(toml::Value::as_str),
Some("fireworks-saved-key")
);
assert_eq!(
parsed
.get("providers")
.and_then(|p| p.get("sglang"))
.and_then(|t| t.get("api_key"))
.and_then(toml::Value::as_str),
Some("sglang-saved-key")
);
Ok(())
}
+2
View File
@@ -33,6 +33,8 @@ pub mod session_picker;
pub mod sidebar;
pub mod slash_menu;
pub mod streaming;
mod subagent_routing;
mod tool_routing;
pub mod transcript;
pub mod transcript_cache;
pub mod ui;
+313
View File
@@ -0,0 +1,313 @@
//! Sub-agent and background-task routing helpers for the TUI loop.
use std::time::Instant;
use crate::task_manager::{TaskRecord, TaskStatus, TaskSummary};
use crate::tools::subagent::{MailboxMessage, SubAgentResult, SubAgentStatus};
use crate::tui::app::{App, AppMode, TaskPanelEntry};
use crate::tui::history::{HistoryCell, SubAgentCell, summarize_tool_output};
use crate::tui::pager::PagerView;
use crate::tui::widgets::agent_card::{
AgentLifecycle, DelegateCard, FanoutCard, apply_to_delegate, apply_to_fanout,
};
pub(super) fn running_agent_count(app: &App) -> usize {
let mut ids: std::collections::HashSet<&str> =
app.agent_progress.keys().map(String::as_str).collect();
for agent in app
.subagent_cache
.iter()
.filter(|agent| matches!(agent.status, SubAgentStatus::Running))
{
ids.insert(agent.agent_id.as_str());
}
ids.len()
}
pub(super) fn reconcile_subagent_activity_state(app: &mut App) {
let running_agents: Vec<(String, String)> = app
.subagent_cache
.iter()
.filter(|agent| matches!(agent.status, SubAgentStatus::Running))
.map(|agent| {
(
agent.agent_id.clone(),
summarize_tool_output(&agent.assignment.objective),
)
})
.collect();
let running_ids: std::collections::HashSet<String> =
running_agents.iter().map(|(id, _)| id.clone()).collect();
app.agent_progress
.retain(|id, _| running_ids.contains(id.as_str()));
for (id, objective) in running_agents {
app.agent_progress.entry(id).or_insert(objective);
}
if running_ids.is_empty() {
app.agent_activity_started_at = None;
} else if app.agent_activity_started_at.is_none() {
app.agent_activity_started_at = Some(Instant::now());
}
}
fn subagent_status_rank(status: &SubAgentStatus) -> u8 {
match status {
SubAgentStatus::Running => 0,
SubAgentStatus::Interrupted(_) => 1,
SubAgentStatus::Failed(_) => 2,
SubAgentStatus::Completed => 3,
SubAgentStatus::Cancelled => 4,
}
}
pub(super) fn sort_subagents_in_place(agents: &mut [SubAgentResult]) {
agents.sort_by(|a, b| {
subagent_status_rank(&a.status)
.cmp(&subagent_status_rank(&b.status))
.then_with(|| a.agent_type.as_str().cmp(b.agent_type.as_str()))
.then_with(|| a.agent_id.cmp(&b.agent_id))
});
}
/// Route a `MailboxMessage` envelope to the matching in-transcript card,
/// allocating a `DelegateCard` or `FanoutCard` on first sight (issue #128).
pub(super) fn handle_subagent_mailbox(app: &mut App, _seq: u64, message: &MailboxMessage) {
// Accumulate sub-agent token costs for the real-time footer counter (#166).
if let MailboxMessage::TokenUsage { model, usage, .. } = message {
if let Some(cost) = crate::pricing::calculate_turn_cost_from_usage(model, usage) {
app.subagent_cost += cost;
}
return; // No card visual change needed; the footer handles display.
}
// Resolve (or allocate) the target cell for this envelope. ChildSpawned
// is special — it always belongs to the active fanout card if one
// exists; otherwise it seeds a new one.
let agent_id = message.agent_id().to_string();
if matches!(message, MailboxMessage::ChildSpawned { .. })
&& let Some(idx) = app.last_fanout_card_index
&& let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.get_mut(idx)
{
apply_to_fanout(card, message);
app.subagent_card_index.insert(agent_id, idx);
app.mark_history_updated();
return;
}
// Existing card for this agent_id? Mutate in place.
if let Some(&idx) = app.subagent_card_index.get(&agent_id) {
let updated = match app.history.get_mut(idx) {
Some(HistoryCell::SubAgent(SubAgentCell::Delegate(card))) => {
apply_to_delegate(card, message)
}
Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) => {
apply_to_fanout(card, message)
}
_ => false,
};
if updated {
app.mark_history_updated();
}
return;
}
// No existing card — only `Started` reasonably opens one. Anything else
// for an unknown agent_id is dropped (likely arrived after the cell was
// cleared, e.g. session-resume edge cases).
let MailboxMessage::Started { agent_type, .. } = message else {
return;
};
let dispatch_kind = app.pending_subagent_dispatch.as_deref();
let is_fanout = matches!(
dispatch_kind,
Some("agent_swarm" | "spawn_agents_on_csv" | "rlm")
);
if is_fanout {
// Reuse the active fanout card for sibling spawns; otherwise create
// one anchored at this position so subsequent siblings join it.
if let Some(idx) = app.last_fanout_card_index
&& let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) =
app.history.get_mut(idx)
{
card.upsert_worker(&agent_id, AgentLifecycle::Running);
app.subagent_card_index.insert(agent_id, idx);
} else {
let mut card = FanoutCard::new(dispatch_kind.unwrap_or("fanout").to_string());
card.upsert_worker(&agent_id, AgentLifecycle::Running);
app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card)));
let idx = app.history.len().saturating_sub(1);
app.last_fanout_card_index = Some(idx);
app.subagent_card_index.insert(agent_id, idx);
}
} else {
let card = DelegateCard::new(agent_id.clone(), agent_type.clone());
app.add_message(HistoryCell::SubAgent(SubAgentCell::Delegate(card)));
let idx = app.history.len().saturating_sub(1);
app.subagent_card_index.insert(agent_id, idx);
// Single delegate consumes the pending dispatch label so a follow-on
// tool call doesn't accidentally inherit it.
app.pending_subagent_dispatch = None;
}
app.mark_history_updated();
}
pub(super) fn task_mode_label(mode: AppMode) -> &'static str {
mode.as_setting()
}
pub(super) fn task_summary_to_panel_entry(summary: TaskSummary) -> TaskPanelEntry {
TaskPanelEntry {
id: summary.id,
status: task_status_label(summary.status).to_string(),
prompt_summary: summary.prompt_summary,
duration_ms: summary.duration_ms,
}
}
fn task_status_label(status: TaskStatus) -> &'static str {
match status {
TaskStatus::Queued => "queued",
TaskStatus::Running => "running",
TaskStatus::Completed => "completed",
TaskStatus::Failed => "failed",
TaskStatus::Canceled => "canceled",
}
}
pub(super) fn format_task_list(tasks: &[TaskSummary]) -> String {
if tasks.is_empty() {
return "No tasks found.".to_string();
}
let mut lines = vec![
format!("Tasks ({})", tasks.len()),
"----------------------------------------".to_string(),
];
for task in tasks {
let duration = task
.duration_ms
.map(|ms| format!("{:.2}s", ms as f64 / 1000.0))
.unwrap_or_else(|| "-".to_string());
lines.push(format!(
"{} {:9} {} {}",
task.id,
task_status_label(task.status),
duration,
task.prompt_summary
));
}
lines.push("Use /task show <id> for timeline details.".to_string());
lines.join("\n")
}
pub(super) fn open_task_pager(app: &mut App, task: &TaskRecord) {
let width = app
.last_transcript_area
.map(|area| area.width)
.unwrap_or(100)
.saturating_sub(4);
app.view_stack.push(PagerView::from_text(
format!("Task {}", task.id),
&format_task_detail(task),
width.max(60),
));
}
fn format_task_detail(task: &TaskRecord) -> String {
let mut lines = Vec::new();
lines.push(format!("Task: {}", task.id));
lines.push(format!("Status: {}", task_status_label(task.status)));
lines.push(format!("Mode: {}", task.mode));
lines.push(format!("Model: {}", task.model));
lines.push(format!("Workspace: {}", task.workspace.display()));
if let Some(thread_id) = task.thread_id.as_ref() {
lines.push(format!("Runtime Thread: {thread_id}"));
}
if let Some(turn_id) = task.turn_id.as_ref() {
lines.push(format!("Runtime Turn: {turn_id}"));
}
if task.runtime_event_count > 0 {
lines.push(format!("Runtime Events: {}", task.runtime_event_count));
}
lines.push(format!("Created: {}", task.created_at));
if let Some(started_at) = task.started_at {
lines.push(format!("Started: {}", started_at));
}
if let Some(ended_at) = task.ended_at {
lines.push(format!("Ended: {}", ended_at));
}
if let Some(duration) = task.duration_ms {
lines.push(format!("Duration: {:.2}s", duration as f64 / 1000.0));
}
lines.push(String::new());
lines.push("Prompt:".to_string());
lines.push(task.prompt.clone());
if let Some(summary) = task.result_summary.as_ref() {
lines.push(String::new());
lines.push("Result Summary:".to_string());
lines.push(summary.clone());
}
if let Some(path) = task.result_detail_path.as_ref() {
lines.push(format!("Result Artifact: {}", path.display()));
}
if let Some(error) = task.error.as_ref() {
lines.push(String::new());
lines.push(format!("Error: {error}"));
}
lines.push(String::new());
lines.push("Tool Calls:".to_string());
if task.tool_calls.is_empty() {
lines.push("- (none)".to_string());
} else {
for tool in &task.tool_calls {
let status = match tool.status {
crate::task_manager::TaskToolStatus::Running => "running",
crate::task_manager::TaskToolStatus::Success => "success",
crate::task_manager::TaskToolStatus::Failed => "failed",
crate::task_manager::TaskToolStatus::Canceled => "canceled",
};
let mut line = format!(
"- {} [{}] {}",
tool.name,
status,
tool.output_summary.as_deref().unwrap_or("(no summary)")
);
if let Some(duration) = tool.duration_ms {
line.push_str(&format!(" ({:.2}s)", duration as f64 / 1000.0));
}
lines.push(line);
if let Some(path) = tool.detail_path.as_ref() {
lines.push(format!(" detail: {}", path.display()));
}
if let Some(path) = tool.patch_ref.as_ref() {
lines.push(format!(" patch: {}", path.display()));
}
}
}
lines.push(String::new());
lines.push("Timeline:".to_string());
if task.timeline.is_empty() {
lines.push("- (none)".to_string());
} else {
for entry in &task.timeline {
lines.push(format!(
"- [{}] {}: {}",
entry.timestamp, entry.kind, entry.summary
));
if let Some(path) = entry.detail_path.as_ref() {
lines.push(format!(" detail: {}", path.display()));
}
}
}
lines.join("\n")
}
+922
View File
@@ -0,0 +1,922 @@
//! Active tool-card routing helpers for the TUI loop.
use std::path::PathBuf;
use std::time::Instant;
use crate::tools::ReviewOutput;
use crate::tools::spec::{ToolError, ToolResult};
use crate::tui::active_cell::ActiveCell;
use crate::tui::app::{App, ToolDetailRecord};
use crate::tui::history::{
DiffPreviewCell, ExecCell, ExecSource, ExploringEntry, GenericToolCell, HistoryCell,
McpToolCell, PatchSummaryCell, PlanStep, PlanUpdateCell, ReviewCell, ToolCell, ToolStatus,
ViewImageCell, WebSearchCell, summarize_mcp_output, summarize_tool_args, summarize_tool_output,
};
#[allow(clippy::too_many_lines)]
pub(super) fn handle_tool_call_started(
app: &mut App,
id: &str,
name: &str,
input: &serde_json::Value,
) {
let id = id.to_string();
// All in-flight tool work for the current turn lives in `app.active_cell`
// until the turn completes. This mirrors Codex's contract: ONE active cell
// mutates in place; finalized history isn't touched until flush. This
// keeps the transcript stable while parallel completions arrive in any
// order.
if app.active_cell.is_none() {
app.active_cell = Some(ActiveCell::new());
}
if is_exploring_tool(name) {
let label = exploring_label(name, input);
// ensure_exploring + append_to_exploring keeps all parallel exploring
// starts in a single ExploringCell entry.
let active = app.active_cell.as_mut().expect("active_cell just ensured");
let entry_idx = active.ensure_exploring();
let inner = active
.append_to_exploring(
id.clone(),
ExploringEntry {
label,
status: ToolStatus::Running,
},
)
.map_or(0, |(_, inner)| inner);
app.exploring_cell = Some(entry_idx);
let virtual_index = app.history.len() + entry_idx;
app.exploring_entries
.insert(id.clone(), (virtual_index, inner));
register_tool_cell(app, &id, name, input, virtual_index);
app.mark_history_updated();
return;
}
// Non-exploring tool: each is its own entry inside the active cell. We
// intentionally do NOT clear `exploring_cell` here — the active cell can
// hold both an exploring aggregate AND independent tool entries
// simultaneously, which is exactly the case CX#7 fixes.
if is_exec_tool(name) {
let command = exec_command_from_input(input).unwrap_or_else(|| "<command>".to_string());
let source = exec_source_from_input(input);
let interaction = exec_interaction_summary(name, input);
let mut is_wait = false;
if let Some((summary, wait)) = interaction.as_ref() {
is_wait = *wait;
if is_wait
&& app
.last_exec_wait_command
.as_ref()
.is_some_and(|last| last == &command)
{
app.ignored_tool_calls.insert(id);
return;
}
if is_wait {
app.last_exec_wait_command = Some(command.clone());
}
push_active_tool_cell(
app,
&id,
name,
input,
HistoryCell::Tool(ToolCell::Exec(ExecCell {
command,
status: ToolStatus::Running,
output: None,
started_at: Some(Instant::now()),
duration_ms: None,
source,
interaction: Some(summary.clone()),
})),
);
return;
}
if exec_is_background(input)
&& app
.last_exec_wait_command
.as_ref()
.is_some_and(|last| last == &command)
{
app.ignored_tool_calls.insert(id);
return;
}
if exec_is_background(input) && !is_wait {
app.last_exec_wait_command = Some(command.clone());
}
push_active_tool_cell(
app,
&id,
name,
input,
HistoryCell::Tool(ToolCell::Exec(ExecCell {
command,
status: ToolStatus::Running,
output: None,
started_at: Some(Instant::now()),
duration_ms: None,
source,
interaction: None,
})),
);
return;
}
if name == "update_plan" {
let (explanation, steps) = parse_plan_input(input);
push_active_tool_cell(
app,
&id,
name,
input,
HistoryCell::Tool(ToolCell::PlanUpdate(PlanUpdateCell {
explanation,
steps,
status: ToolStatus::Running,
})),
);
return;
}
if name == "apply_patch" {
let (path, summary) = parse_patch_summary(input);
push_active_tool_cell(
app,
&id,
name,
input,
HistoryCell::Tool(ToolCell::PatchSummary(PatchSummaryCell {
path,
summary,
status: ToolStatus::Running,
error: None,
})),
);
return;
}
if name == "review" {
let target = review_target_label(input);
push_active_tool_cell(
app,
&id,
name,
input,
HistoryCell::Tool(ToolCell::Review(ReviewCell {
target,
status: ToolStatus::Running,
output: None,
error: None,
})),
);
return;
}
if is_mcp_tool(name) {
push_active_tool_cell(
app,
&id,
name,
input,
HistoryCell::Tool(ToolCell::Mcp(McpToolCell {
tool: name.to_string(),
status: ToolStatus::Running,
content: None,
is_image: false,
})),
);
return;
}
if is_view_image_tool(name) {
if let Some(path) = input.get("path").and_then(|v| v.as_str()) {
let raw_path = PathBuf::from(path);
let display_path = raw_path
.strip_prefix(&app.workspace)
.unwrap_or(&raw_path)
.to_path_buf();
push_active_tool_cell(
app,
&id,
name,
input,
HistoryCell::Tool(ToolCell::ViewImage(ViewImageCell { path: display_path })),
);
}
return;
}
if is_web_search_tool(name) {
let query = web_search_query(input);
push_active_tool_cell(
app,
&id,
name,
input,
HistoryCell::Tool(ToolCell::WebSearch(WebSearchCell {
query,
status: ToolStatus::Running,
summary: None,
})),
);
return;
}
let input_summary = summarize_tool_args(input);
let prompts = extract_fanout_prompts(name, input);
push_active_tool_cell(
app,
&id,
name,
input,
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: name.to_string(),
status: ToolStatus::Running,
input_summary,
output: None,
prompts,
})),
);
}
/// Extract per-child prompts from a fan-out tool's input. Currently no
/// top-level tool exposes a prompt list — fan-out lives inside the RLM
/// REPL via `llm_query_batched`. Kept as a stable hook for any future
/// fan-out tool we add.
fn extract_fanout_prompts(_name: &str, _input: &serde_json::Value) -> Option<Vec<String>> {
None
}
/// Push a tool cell as a new entry in `active_cell`, register the tool id,
/// and write a stub detail record so the pager / Ctrl+O can find it.
fn push_active_tool_cell(
app: &mut App,
tool_id: &str,
tool_name: &str,
input: &serde_json::Value,
cell: HistoryCell,
) {
if app.active_cell.is_none() {
app.active_cell = Some(ActiveCell::new());
}
let active = app.active_cell.as_mut().expect("active_cell just ensured");
let entry_idx = active.push_tool(tool_id.to_string(), cell);
let virtual_index = app.history.len() + entry_idx;
register_tool_cell(app, tool_id, tool_name, input, virtual_index);
app.mark_history_updated();
}
fn register_tool_cell(
app: &mut App,
tool_id: &str,
tool_name: &str,
input: &serde_json::Value,
cell_index: usize,
) {
app.tool_cells.insert(tool_id.to_string(), cell_index);
let record = ToolDetailRecord {
tool_id: tool_id.to_string(),
tool_name: tool_name.to_string(),
input: input.clone(),
output: None,
};
if cell_index < app.history.len() {
app.tool_details_by_cell.insert(cell_index, record);
} else {
// Active-cell entry: keep the detail record in `active_tool_details`
// until the active cell flushes. `flush_active_cell` migrates these
// records into `tool_details_by_cell` keyed by the eventual real
// cell index.
app.active_tool_details.insert(tool_id.to_string(), record);
}
}
fn store_tool_detail_output(
app: &mut App,
tool_id: &str,
cell_index: usize,
result: &Result<ToolResult, ToolError>,
) {
let payload = Some(match result {
Ok(tool_result) => tool_result.content.clone(),
Err(err) => err.to_string(),
});
if cell_index < app.history.len()
&& let Some(detail) = app.tool_details_by_cell.get_mut(&cell_index)
{
detail.output = payload.clone();
}
// Also write to the active table while the entry might still live there;
// some callsites pre-rewrite cell_index but the active_tool_details map is
// the canonical source for in-flight outputs.
if let Some(detail) = app.active_tool_details.get_mut(tool_id) {
detail.output = payload;
}
}
#[allow(clippy::too_many_lines)]
pub(super) fn handle_tool_call_complete(
app: &mut App,
id: &str,
name: &str,
result: &Result<ToolResult, ToolError>,
) {
if app.ignored_tool_calls.remove(id) {
return;
}
// Exploring entries land in the per-tool map regardless of whether they
// live in the active cell or in finalized history; the path is the same.
if let Some((cell_index, entry_index)) = app.exploring_entries.remove(id) {
app.tool_cells.remove(id);
store_tool_detail_output(app, id, cell_index, result);
if let Some(HistoryCell::Tool(ToolCell::Exploring(cell))) =
app.cell_at_virtual_index_mut(cell_index)
&& let Some(entry) = cell.entries.get_mut(entry_index)
{
entry.status = match result.as_ref() {
Ok(tool_result) if tool_result.success => ToolStatus::Success,
Ok(_) | Err(_) => ToolStatus::Failed,
};
app.mark_history_updated();
// Mutating the in-flight exploring cell needs an active-cell
// revision bump so the transcript cache invalidates the synthetic
// tail row.
if cell_index >= app.history.len() {
app.active_cell_revision = app.active_cell_revision.wrapping_add(1);
if let Some(active) = app.active_cell.as_mut() {
active.bump_revision();
}
}
}
return;
}
// Look up the cell by tool id. If the id isn't registered, that's an
// orphan completion (race condition where the started event was lost or
// a tool result arrived after the active cell was already flushed). Build
// a finalized standalone cell from the result so the user can still see
// the output, but DO NOT touch the active cell.
let Some(cell_index) = app.tool_cells.remove(id) else {
push_orphan_tool_completion(app, id, name, result);
return;
};
store_tool_detail_output(app, id, cell_index, result);
let in_active = cell_index >= app.history.len();
let status = match result.as_ref() {
Ok(tool_result) => match tool_result.metadata.as_ref() {
Some(meta)
if meta
.get("status")
.and_then(|v| v.as_str())
.is_some_and(|s| s == "Running") =>
{
ToolStatus::Running
}
_ => {
if tool_result.success {
ToolStatus::Success
} else {
ToolStatus::Failed
}
}
},
Err(_) => ToolStatus::Failed,
};
if let Some(cell) = app.cell_at_virtual_index_mut(cell_index) {
match cell {
HistoryCell::Tool(ToolCell::Exec(exec)) => {
exec.status = status;
if let Ok(tool_result) = result.as_ref() {
exec.duration_ms = tool_result
.metadata
.as_ref()
.and_then(|m| m.get("duration_ms"))
.and_then(serde_json::Value::as_u64);
if status != ToolStatus::Running && exec.interaction.is_none() {
exec.output = Some(tool_result.content.clone());
}
} else if let Err(err) = result.as_ref()
&& exec.interaction.is_none()
{
exec.output = Some(err.to_string());
}
app.mark_history_updated();
}
HistoryCell::Tool(ToolCell::PlanUpdate(plan)) => {
plan.status = status;
app.mark_history_updated();
}
HistoryCell::Tool(ToolCell::PatchSummary(patch)) => {
patch.status = status;
match result.as_ref() {
Ok(tool_result) => {
if let Ok(json) =
serde_json::from_str::<serde_json::Value>(&tool_result.content)
&& let Some(message) = json.get("message").and_then(|v| v.as_str())
{
patch.summary = message.to_string();
}
}
Err(err) => {
patch.error = Some(err.to_string());
}
}
app.mark_history_updated();
}
HistoryCell::Tool(ToolCell::Review(review)) => {
review.status = status;
match result.as_ref() {
Ok(tool_result) => {
if tool_result.success {
review.output = Some(ReviewOutput::from_str(&tool_result.content));
} else {
review.error = Some(tool_result.content.clone());
}
}
Err(err) => {
review.error = Some(err.to_string());
}
}
app.mark_history_updated();
}
HistoryCell::Tool(ToolCell::Mcp(mcp)) => {
match result.as_ref() {
Ok(tool_result) => {
let summary = summarize_mcp_output(&tool_result.content);
if summary.is_error == Some(true) {
mcp.status = ToolStatus::Failed;
} else {
mcp.status = status;
}
mcp.is_image = summary.is_image;
mcp.content = summary.content;
}
Err(err) => {
mcp.status = status;
mcp.content = Some(err.to_string());
}
}
app.mark_history_updated();
}
HistoryCell::Tool(ToolCell::WebSearch(search)) => {
search.status = status;
match result.as_ref() {
Ok(tool_result) => {
search.summary = Some(summarize_tool_output(&tool_result.content));
}
Err(err) => {
search.summary = Some(err.to_string());
}
}
app.mark_history_updated();
}
HistoryCell::Tool(ToolCell::Generic(generic)) => {
generic.status = status;
match result.as_ref() {
Ok(tool_result) => {
generic.output = Some(summarize_tool_output(&tool_result.content));
}
Err(err) => {
generic.output = Some(err.to_string());
}
}
app.mark_history_updated();
}
_ => {}
}
}
// If the mutated cell lived inside the active group, bump the active-cell
// revision so the transcript cache re-renders the synthetic tail row.
if in_active {
app.active_cell_revision = app.active_cell_revision.wrapping_add(1);
if let Some(active) = app.active_cell.as_mut() {
active.bump_revision();
}
}
}
/// Build a finalized standalone history cell for a tool completion whose
/// start was never registered (orphan). This preserves the contract that
/// every tool result is visible somewhere; the alternative (silently
/// dropping it) hides errors and breaks debuggability.
///
/// Choice of cell type: we use `GenericToolCell` because we have no input
/// payload to reconstruct a more specific cell. The pager remains usable —
/// `tool_details_by_cell` is populated with the result text.
///
/// ## Index drift
///
/// If an active cell is in flight when the orphan arrives, pushing the
/// orphan into `app.history` shifts every active-cell virtual index forward
/// by 1. We must rewrite `tool_cells` / `exploring_entries` accordingly so
/// later completion lookups still find the right entries.
fn push_orphan_tool_completion(
app: &mut App,
tool_id: &str,
name: &str,
result: &Result<ToolResult, ToolError>,
) {
let status = match result.as_ref() {
Ok(tool_result) => {
if tool_result.success {
ToolStatus::Success
} else {
ToolStatus::Failed
}
}
Err(_) => ToolStatus::Failed,
};
let output = match result.as_ref() {
Ok(tool_result) => Some(summarize_tool_output(&tool_result.content)),
Err(err) => Some(err.to_string()),
};
let history_threshold_before_push = app.history.len();
let active_in_flight = app.active_cell.is_some();
app.add_message(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: name.to_string(),
status,
input_summary: None,
output,
prompts: None,
})));
let cell_index = app.history.len().saturating_sub(1);
app.tool_details_by_cell.insert(
cell_index,
ToolDetailRecord {
tool_id: tool_id.to_string(),
tool_name: name.to_string(),
input: serde_json::Value::Null,
output: match result.as_ref() {
Ok(tool_result) => Some(tool_result.content.clone()),
Err(err) => Some(err.to_string()),
},
},
);
// Shift active-cell virtual indices forward by 1 to absorb the new
// history cell. Without this, the next completion would address the
// wrong entry.
if active_in_flight {
let threshold = history_threshold_before_push;
for idx in app.tool_cells.values_mut() {
if *idx >= threshold {
*idx = idx.wrapping_add(1);
}
}
for (cell_idx, _) in app.exploring_entries.values_mut() {
if *cell_idx >= threshold {
*cell_idx = cell_idx.wrapping_add(1);
}
}
if let Some(idx) = app.exploring_cell.as_mut()
&& *idx >= threshold
{
*idx = idx.wrapping_add(1);
}
}
}
fn is_exploring_tool(name: &str) -> bool {
matches!(name, "read_file" | "list_dir" | "grep_files" | "list_files")
}
fn is_exec_tool(name: &str) -> bool {
matches!(
name,
"exec_shell" | "exec_shell_wait" | "exec_shell_interact" | "exec_wait" | "exec_interact"
)
}
pub(super) fn exploring_label(name: &str, input: &serde_json::Value) -> String {
let fallback = format!("{name} tool");
let obj = input.as_object();
match name {
"read_file" => obj
.and_then(|o| o.get("path"))
.and_then(|v| v.as_str())
.map_or(fallback, |path| format!("Reading {path}")),
"list_dir" => obj
.and_then(|o| o.get("path"))
.and_then(|v| v.as_str())
.map_or("Listing directory".to_string(), |path| {
format!("Listing {path}")
}),
"grep_files" => {
let pattern = obj
.and_then(|o| o.get("pattern"))
.and_then(|v| v.as_str())
.unwrap_or("pattern");
format!("Searching for `{pattern}`")
}
"list_files" => "Listing files".to_string(),
_ => fallback,
}
}
fn is_mcp_tool(name: &str) -> bool {
name.starts_with("mcp_")
}
fn is_view_image_tool(name: &str) -> bool {
matches!(name, "view_image" | "view_image_file" | "view_image_tool")
}
fn is_web_search_tool(name: &str) -> bool {
matches!(name, "web_search" | "search_web" | "search" | "web.run")
|| name.ends_with("_web_search")
}
fn web_search_query(input: &serde_json::Value) -> String {
if let Some(searches) = input.get("search_query").and_then(|v| v.as_array())
&& let Some(first) = searches.first()
&& let Some(q) = first.get("q").and_then(|v| v.as_str())
{
return q.to_string();
}
input
.get("query")
.or_else(|| input.get("q"))
.or_else(|| input.get("search"))
.and_then(|v| v.as_str())
.unwrap_or("Web search")
.to_string()
}
fn review_target_label(input: &serde_json::Value) -> String {
let target = input
.get("target")
.and_then(|v| v.as_str())
.unwrap_or("review")
.trim();
let kind = input
.get("kind")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_ascii_lowercase();
let staged = input
.get("staged")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let target_lower = target.to_ascii_lowercase();
if kind == "diff"
|| target_lower == "diff"
|| target_lower == "git diff"
|| target_lower == "staged"
|| target_lower == "cached"
{
if staged || target_lower == "staged" || target_lower == "cached" {
return "git diff --cached".to_string();
}
return "git diff".to_string();
}
target.to_string()
}
fn parse_plan_input(input: &serde_json::Value) -> (Option<String>, Vec<PlanStep>) {
let explanation = input
.get("explanation")
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string);
let mut steps = Vec::new();
if let Some(items) = input.get("plan").and_then(|v| v.as_array()) {
for item in items {
let step = item.get("step").and_then(|v| v.as_str()).unwrap_or("");
let status = item
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("pending");
if !step.is_empty() {
steps.push(PlanStep {
step: step.to_string(),
status: status.to_string(),
});
}
}
}
(explanation, steps)
}
fn parse_patch_summary(input: &serde_json::Value) -> (String, String) {
if let Some(changes) = input.get("changes").and_then(|v| v.as_array()) {
let count = changes.len();
let path = changes
.first()
.and_then(|c| c.get("path"))
.and_then(|v| v.as_str())
.map(str::to_string)
.unwrap_or_else(|| "<file>".to_string());
let label = if count <= 1 {
path
} else {
format!("{count} files")
};
let summary = format!("Changes: {count} file(s)");
return (label, summary);
}
let patch_text = input.get("patch").and_then(|v| v.as_str()).unwrap_or("");
let paths = extract_patch_paths(patch_text);
let path = input
.get("path")
.and_then(|v| v.as_str())
.map(str::to_string)
.or_else(|| {
if paths.len() == 1 {
paths.first().cloned()
} else if paths.is_empty() {
None
} else {
Some(format!("{} files", paths.len()))
}
})
.unwrap_or_else(|| "<file>".to_string());
let (adds, removes) = count_patch_changes(patch_text);
let summary = if adds == 0 && removes == 0 {
"Patch applied".to_string()
} else {
format!("Changes: +{adds} / -{removes}")
};
(path, summary)
}
fn extract_patch_paths(patch: &str) -> Vec<String> {
let mut paths = Vec::new();
for line in patch.lines() {
if let Some(rest) = line.strip_prefix("+++ ") {
let raw = rest.trim();
if raw == "/dev/null" || raw == "dev/null" {
continue;
}
let raw = raw.strip_prefix("b/").unwrap_or(raw);
if !paths.contains(&raw.to_string()) {
paths.push(raw.to_string());
}
} else if let Some(rest) = line.strip_prefix("diff --git ") {
let parts: Vec<&str> = rest.split_whitespace().collect();
if let Some(path) = parts.get(1).or_else(|| parts.first()) {
let raw = path.trim();
let raw = raw
.strip_prefix("b/")
.or_else(|| raw.strip_prefix("a/"))
.unwrap_or(raw);
if !paths.contains(&raw.to_string()) {
paths.push(raw.to_string());
}
}
}
}
paths
}
pub(super) fn maybe_add_patch_preview(app: &mut App, input: &serde_json::Value) {
if let Some(patch) = input.get("patch").and_then(|v| v.as_str()) {
app.add_message(HistoryCell::Tool(ToolCell::DiffPreview(DiffPreviewCell {
title: "Patch Preview".to_string(),
diff: patch.to_string(),
})));
app.mark_history_updated();
return;
}
if let Some(changes) = input.get("changes").and_then(|v| v.as_array()) {
let preview = format_changes_preview(changes);
if !preview.trim().is_empty() {
app.add_message(HistoryCell::Tool(ToolCell::DiffPreview(DiffPreviewCell {
title: "Changes Preview".to_string(),
diff: preview,
})));
app.mark_history_updated();
}
}
}
fn format_changes_preview(changes: &[serde_json::Value]) -> String {
let mut out = String::new();
for change in changes {
let path = change
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("<file>");
let content = change.get("content").and_then(|v| v.as_str()).unwrap_or("");
out.push_str(&format!("diff --git a/{path} b/{path}\n"));
out.push_str(&format!("--- a/{path}\n+++ b/{path}\n"));
out.push_str("@@ -0,0 +1,1 @@\n");
let mut count = 0usize;
for line in content.lines() {
out.push('+');
out.push_str(line);
out.push('\n');
count += 1;
if count >= 20 {
out.push_str("+... (truncated)\n");
break;
}
}
if content.is_empty() {
out.push_str("+\n");
}
}
out
}
fn count_patch_changes(patch: &str) -> (usize, usize) {
let mut adds = 0;
let mut removes = 0;
for line in patch.lines() {
if line.starts_with("+++") || line.starts_with("---") {
continue;
}
if line.starts_with('+') {
adds += 1;
} else if line.starts_with('-') {
removes += 1;
}
}
(adds, removes)
}
fn exec_command_from_input(input: &serde_json::Value) -> Option<String> {
input
.get("command")
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string)
}
fn exec_source_from_input(input: &serde_json::Value) -> ExecSource {
match input.get("source").and_then(|v| v.as_str()) {
Some(source) if source.eq_ignore_ascii_case("user") => ExecSource::User,
_ => ExecSource::Assistant,
}
}
fn exec_interaction_summary(name: &str, input: &serde_json::Value) -> Option<(String, bool)> {
let command = exec_command_from_input(input).unwrap_or_else(|| "<command>".to_string());
let command_display = format!("\"{command}\"");
let interaction_input = input
.get("input")
.or_else(|| input.get("stdin"))
.or_else(|| input.get("data"))
.and_then(|v| v.as_str());
let is_wait_tool = matches!(name, "exec_shell_wait" | "exec_wait");
let is_interact_tool = matches!(name, "exec_shell_interact" | "exec_interact");
if is_interact_tool || interaction_input.is_some() {
let preview = interaction_input.map(summarize_interaction_input);
let summary = if let Some(preview) = preview {
format!("Interacted with {command_display}, sent {preview}")
} else {
format!("Interacted with {command_display}")
};
return Some((summary, false));
}
if is_wait_tool || input.get("wait").and_then(serde_json::Value::as_bool) == Some(true) {
return Some((format!("Waited for {command_display}"), true));
}
None
}
fn summarize_interaction_input(input: &str) -> String {
let mut single_line = input.replace('\r', "");
single_line = single_line.replace('\n', "\\n");
single_line = single_line.replace('\"', "'");
let max_len = 80;
if single_line.chars().count() <= max_len {
return format!("\"{single_line}\"");
}
let mut out = String::new();
for ch in single_line.chars().take(max_len.saturating_sub(3)) {
out.push(ch);
}
out.push_str("...");
format!("\"{out}\"")
}
fn exec_is_background(input: &serde_json::Value) -> bool {
input
.get("background")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
+12 -1227
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -434,6 +434,30 @@ fn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {
assert!(app.agent_activity_started_at.is_none());
}
#[test]
fn subagent_token_usage_updates_live_cost_counter_without_card_change() {
let mut app = create_test_app();
handle_subagent_mailbox(
&mut app,
1,
&crate::tools::subagent::MailboxMessage::TokenUsage {
agent_id: "agent-a".to_string(),
model: "deepseek-v4-flash".to_string(),
usage: crate::models::Usage {
input_tokens: 10_000,
output_tokens: 1_000,
..Default::default()
},
},
);
assert!(app.subagent_cost > 0.0);
assert!(
app.history.is_empty(),
"usage-only mailbox messages should not allocate a sub-agent card"
);
}
#[test]
fn format_token_count_compact_formats_units() {
assert_eq!(format_token_count_compact(999), "999");
+25 -11
View File
@@ -23,14 +23,14 @@ DeepSeek auth and model defaults. `deepseek login --api-key ...` writes the
root `api_key` field that `deepseek-tui` reads directly, and `deepseek --model
deepseek-v4-flash` is forwarded to the TUI as `DEEPSEEK_MODEL`.
For NVIDIA NIM-hosted DeepSeek V4 Pro, set `provider = "nvidia-nim"` or pass
`deepseek --provider nvidia-nim`. The facade stores NIM credentials under
`[providers.nvidia_nim]` and forwards the resolved key, base URL, provider, and
model to the TUI process. Use
`deepseek auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"` to
save the NIM key through the facade. `DEEPSEEK_API_KEY` remains a compatibility
fallback when `DEEPSEEK_PROVIDER=nvidia-nim`, but `NVIDIA_API_KEY` or
`NVIDIA_NIM_API_KEY` is preferred.
For hosted or self-hosted DeepSeek V4 providers, set `provider = "nvidia-nim"`,
`"fireworks"`, or `"sglang"` or pass `deepseek --provider <name>`. The facade
stores provider credentials under `[providers.<name>]` and forwards the resolved
key, base URL, provider, and model to the TUI process. Use
`deepseek auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"` or
`deepseek auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"` to
save hosted-provider keys through the facade. SGLang is self-hosted and can run
without an API key by default.
To bootstrap MCP and skills directories at their resolved paths, run `deepseek-tui setup`.
To only scaffold MCP, run `deepseek-tui mcp init`.
@@ -58,6 +58,15 @@ provider = "nvidia-nim"
api_key = "NVIDIA_KEY"
base_url = "https://integrate.api.nvidia.com/v1"
default_text_model = "deepseek-ai/deepseek-v4-pro"
[profiles.fireworks]
provider = "fireworks"
default_text_model = "accounts/fireworks/models/deepseek-v4-pro"
[profiles.sglang]
provider = "sglang"
base_url = "http://localhost:30000/v1"
default_text_model = "deepseek-ai/DeepSeek-V4-Pro"
```
Select a profile with:
@@ -73,11 +82,16 @@ These override config values:
- `DEEPSEEK_API_KEY`
- `DEEPSEEK_BASE_URL`
- `DEEPSEEK_PROVIDER` (`deepseek|nvidia-nim`)
- `DEEPSEEK_PROVIDER` (`deepseek|nvidia-nim|openrouter|novita|fireworks|sglang`)
- `DEEPSEEK_MODEL` or `DEEPSEEK_DEFAULT_TEXT_MODEL`
- `NVIDIA_API_KEY` or `NVIDIA_NIM_API_KEY` (preferred when provider is `nvidia-nim`; falls back to `DEEPSEEK_API_KEY`)
- `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, or `NVIDIA_BASE_URL`
- `NVIDIA_NIM_MODEL`
- `FIREWORKS_API_KEY`
- `FIREWORKS_BASE_URL`
- `SGLANG_BASE_URL`
- `SGLANG_MODEL`
- `SGLANG_API_KEY` (optional; many localhost SGLang servers do not require auth)
- `DEEPSEEK_LOG_LEVEL` or `RUST_LOG` (`info`/`debug`/`trace` enables lightweight verbose logs)
- `DEEPSEEK_SKILLS_DIR`
- `DEEPSEEK_MCP_CONFIG`
@@ -155,10 +169,10 @@ If you are upgrading from older releases:
### Core keys (used by the TUI/engine)
- `provider` (string, optional): `deepseek` (default) or `nvidia-nim`. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`.
- `provider` (string, optional): `deepseek` (default), `nvidia-nim`, `openrouter`, `novita`, `fireworks`, or `sglang`. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`.
- `api_key` (string, required): must be non-empty (or set `DEEPSEEK_API_KEY`).
- `base_url` (string, optional): defaults to `https://api.deepseek.com` for DeepSeek's OpenAI-compatible Chat Completions API, or `https://integrate.api.nvidia.com/v1` for `provider = "nvidia-nim"`. `https://api.deepseek.com/v1` is also accepted for SDK compatibility; use `https://api.deepseek.com/beta` only for DeepSeek beta features such as strict tool mode, chat prefix completion, and FIM completion.
- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek or `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash`. With `provider = "nvidia-nim"`, `deepseek-v4-pro` maps to `deepseek-ai/deepseek-v4-pro` and `deepseek-v4-flash` maps to `deepseek-ai/deepseek-v4-flash`. Use `/models` or `deepseek models` to discover live IDs from your configured endpoint. `DEEPSEEK_MODEL` overrides this for a single process.
- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, and `deepseek-ai/DeepSeek-V4-Pro` for SGLang. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash`. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. Use `/models` or `deepseek models` to discover live IDs from your configured endpoint. `DEEPSEEK_MODEL` overrides this for a single process.
- `reasoning_effort` (string, optional): `off`, `low`, `medium`, `high`, or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`.
- `allow_shell` (bool, optional): defaults to `true` (sandboxed).
- `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases.