From 9616a4391b7bc3461c407478ed336d54fcfc42a4 Mon Sep 17 00:00:00 2001 From: AresNing <49557311+AresNing@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:01:30 +0800 Subject: [PATCH 1/8] feat: add turn_end observer hook --- crates/tui/src/commands/hooks.rs | 7 + crates/tui/src/hooks.rs | 238 +++++++++++++ crates/tui/src/tui/ui.rs | 52 ++- docs/CONFIGURATION.md | 53 +++ docs/rfcs/1364-hooks-lifecycle.md | 553 ++++++++++++++++++++---------- web/app/[locale]/docs/page.tsx | 6 +- 6 files changed, 722 insertions(+), 187 deletions(-) diff --git a/crates/tui/src/commands/hooks.rs b/crates/tui/src/commands/hooks.rs index 526500299..554a4eb98 100644 --- a/crates/tui/src/commands/hooks.rs +++ b/crates/tui/src/commands/hooks.rs @@ -47,6 +47,10 @@ fn events() -> CommandResult { HookEvent::MessageSubmit, "fires before model dispatch; can transform or block submitted text", ), + ( + HookEvent::TurnEnd, + "fires after a turn ends; observer-only and receives JSON on stdin", + ), ( HookEvent::ToolCallBefore, "fires before each tool call (read-only observer for now)", @@ -134,6 +138,7 @@ fn event_label(event: HookEvent) -> &'static str { HookEvent::SessionStart => "session_start", HookEvent::SessionEnd => "session_end", HookEvent::MessageSubmit => "message_submit", + HookEvent::TurnEnd => "turn_end", HookEvent::ToolCallBefore => "tool_call_before", HookEvent::ToolCallAfter => "tool_call_after", HookEvent::ModeChange => "mode_change", @@ -257,6 +262,7 @@ mod tests { "session_start", "session_end", "message_submit", + "turn_end", "tool_call_before", "tool_call_after", "mode_change", @@ -296,6 +302,7 @@ mod tests { assert_eq!(event_label(HookEvent::ToolCallBefore), "tool_call_before"); assert_eq!(event_label(HookEvent::ToolCallAfter), "tool_call_after"); assert_eq!(event_label(HookEvent::MessageSubmit), "message_submit"); + assert_eq!(event_label(HookEvent::TurnEnd), "turn_end"); assert_eq!(event_label(HookEvent::ModeChange), "mode_change"); assert_eq!(event_label(HookEvent::OnError), "on_error"); } diff --git a/crates/tui/src/hooks.rs b/crates/tui/src/hooks.rs index 6450d9e6a..ae9f546bb 100644 --- a/crates/tui/src/hooks.rs +++ b/crates/tui/src/hooks.rs @@ -33,6 +33,8 @@ pub enum HookEvent { SessionEnd, /// Triggered before a user message is sent to the LLM MessageSubmit, + /// Triggered after a model turn reaches a terminal outcome + TurnEnd, /// Triggered before a tool is executed ToolCallBefore, /// Triggered after a tool completes (success or failure) @@ -58,6 +60,7 @@ impl HookEvent { HookEvent::SessionStart => "session_start", HookEvent::SessionEnd => "session_end", HookEvent::MessageSubmit => "message_submit", + HookEvent::TurnEnd => "turn_end", HookEvent::ToolCallBefore => "tool_call_before", HookEvent::ToolCallAfter => "tool_call_after", HookEvent::ModeChange => "mode_change", @@ -467,6 +470,28 @@ impl MessageSubmitOutcome { } } +/// Post-update token totals included in `turn_end` payloads. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TurnEndTotals { + pub session_tokens: u32, + pub conversation_tokens: u32, + pub input_tokens: u32, + pub output_tokens: u32, +} + +/// Input used to build the structured JSON payload for `turn_end` hooks. +pub struct TurnEndPayloadInput<'a> { + pub context: &'a HookContext, + pub turn_id: Option<&'a str>, + pub status: &'a str, + pub error: Option<&'a str>, + pub duration_ms: u64, + pub usage: &'a crate::models::Usage, + pub totals: TurnEndTotals, + pub tool_count: usize, + pub queued_message_count: usize, +} + #[derive(Debug, Clone, PartialEq, Eq)] enum MessageSubmitStdout { Unchanged, @@ -664,6 +689,55 @@ impl HookExecutor { } } + /// Run observer-only hooks with a structured JSON stdin payload. + /// + /// The caller is responsible for invoking this off the UI-critical path. + /// Unlike `message_submit`, observer hooks ignore stdout and can never + /// mutate or block the caller's state. + pub fn execute_structured_observer( + &self, + event: HookEvent, + context: &HookContext, + payload: serde_json::Value, + ) -> Vec { + if !self.config.enabled { + return Vec::new(); + } + + let hooks = self.config.hooks_for_event(event); + if hooks.is_empty() { + return Vec::new(); + } + + let env_vars = context.to_env_vars(); + let mut results = Vec::new(); + + for hook in hooks { + if !self.matches_condition(hook, context) { + continue; + } + + let result = self.execute_sync_with_stdin(hook, &env_vars, &payload); + if !result.success { + let label = result.name.as_deref().unwrap_or("(unnamed)"); + tracing::warn!( + target: "hooks", + hook = label, + event = event.as_str(), + exit_code = ?result.exit_code, + duration_ms = result.duration.as_millis() as u64, + error = result.error.as_deref().unwrap_or(""), + stderr_head = %result.stderr.lines().next().unwrap_or(""), + "structured observer hook failed" + ); + } + + results.push(result); + } + + results + } + /// Run every `ShellEnv` hook for this context and merge their stdout /// (`KEY=VALUE\n` lines) into a single env-var map. Used by the /// `exec_shell` tool to inject ephemeral credentials, per-skill PATH @@ -1015,6 +1089,30 @@ fn message_submit_payload(context: &HookContext, text: &str) -> serde_json::Valu }) } +pub fn turn_end_payload(input: TurnEndPayloadInput<'_>) -> serde_json::Value { + json!({ + "event": HookEvent::TurnEnd.as_str(), + "session_id": input.context.session_id, + "workspace": input.context.workspace.as_ref().map(|p| p.display().to_string()), + "mode": input.context.mode, + "model": input.context.model, + "turn_id": input.turn_id, + "status": input.status, + "error": input.error, + "duration_ms": input.duration_ms, + "usage": input.usage, + "totals": { + "session_tokens": input.totals.session_tokens, + "conversation_tokens": input.totals.conversation_tokens, + "input_tokens": input.totals.input_tokens, + "output_tokens": input.totals.output_tokens, + }, + "tool_count": input.tool_count, + "queued_message_count": input.queued_message_count, + "stop_hook_active": false, + }) +} + fn parse_message_submit_stdout(stdout: &str) -> MessageSubmitStdout { let trimmed = stdout.trim(); if trimmed.is_empty() { @@ -1130,6 +1228,7 @@ fn parse_env_lines(stdout: &str) -> HashMap { mod tests { use super::*; use std::collections::HashMap; + use std::fs; use std::path::PathBuf; /// #456 — `parse_env_lines` covers the formats users actually emit from @@ -1235,10 +1334,63 @@ NOEQUAL line dropped #[test] fn test_hook_event_as_str() { assert_eq!(HookEvent::SessionStart.as_str(), "session_start"); + assert_eq!(HookEvent::TurnEnd.as_str(), "turn_end"); assert_eq!(HookEvent::ToolCallAfter.as_str(), "tool_call_after"); assert_eq!(HookEvent::ModeChange.as_str(), "mode_change"); } + #[test] + fn turn_end_payload_contains_post_turn_observer_fields() { + let context = HookContext::new() + .with_session_id("sess_test") + .with_workspace(PathBuf::from("/tmp/workspace")) + .with_mode("agent") + .with_model("deepseek-test"); + let usage = crate::models::Usage { + input_tokens: 100, + output_tokens: 25, + prompt_cache_hit_tokens: Some(10), + prompt_cache_miss_tokens: Some(90), + reasoning_replay_tokens: Some(12), + ..Default::default() + }; + + let payload = turn_end_payload(TurnEndPayloadInput { + context: &context, + turn_id: Some("turn_test"), + status: "completed", + error: None, + duration_ms: 42, + usage: &usage, + totals: TurnEndTotals { + session_tokens: 125, + conversation_tokens: 125, + input_tokens: 100, + output_tokens: 25, + }, + tool_count: 2, + queued_message_count: 1, + }); + + assert_eq!(payload["event"], "turn_end"); + assert_eq!(payload["session_id"], "sess_test"); + assert_eq!(payload["workspace"], "/tmp/workspace"); + assert_eq!(payload["mode"], "agent"); + assert_eq!(payload["model"], "deepseek-test"); + assert_eq!(payload["turn_id"], "turn_test"); + assert_eq!(payload["status"], "completed"); + assert!(payload["error"].is_null()); + assert_eq!(payload["duration_ms"], 42); + assert_eq!(payload["usage"]["input_tokens"], 100); + assert_eq!(payload["usage"]["output_tokens"], 25); + assert_eq!(payload["usage"]["reasoning_replay_tokens"], 12); + assert_eq!(payload["totals"]["session_tokens"], 125); + assert_eq!(payload["totals"]["conversation_tokens"], 125); + assert_eq!(payload["tool_count"], 2); + assert_eq!(payload["queued_message_count"], 1); + assert_eq!(payload["stop_hook_active"], false); + } + #[test] fn test_hook_context_to_env_vars() { let ctx = HookContext::new() @@ -1398,6 +1550,89 @@ printf '\ndone:%s\n' "${#payload}" assert!(result.stderr.len() >= 256 * 1024, "stderr was drained"); } + #[cfg(not(windows))] + #[test] + fn structured_observer_receives_stdin_json_and_ignores_stdout() { + let dir = tempfile::tempdir().expect("tempdir"); + let command = write_hook_script( + &dir, + "turn_end_observer.sh", + r#"#!/bin/sh +payload=$(cat) +printf '%s' "$payload" > observed.json +printf '{"text":"must be ignored"}' +"#, + ); + let executor = HookExecutor::new( + HooksConfig { + enabled: true, + hooks: vec![Hook::new(HookEvent::TurnEnd, &command)], + ..Default::default() + }, + dir.path().to_path_buf(), + ); + let context = HookContext::new().with_session_id("sess_test"); + let payload = json!({ + "event": "turn_end", + "status": "completed", + }); + + let results = executor.execute_structured_observer(HookEvent::TurnEnd, &context, payload); + + assert_eq!(results.len(), 1); + assert!(results[0].success, "observer should succeed: {results:?}"); + assert!( + results[0].stdout.contains("must be ignored"), + "stdout is captured for diagnostics only" + ); + let observed = fs::read_to_string(dir.path().join("observed.json")).expect("payload file"); + let observed: serde_json::Value = serde_json::from_str(&observed).expect("json payload"); + assert_eq!(observed["event"], "turn_end"); + assert_eq!(observed["status"], "completed"); + } + + #[cfg(not(windows))] + #[test] + fn structured_observer_failure_does_not_stop_later_hooks() { + let dir = tempfile::tempdir().expect("tempdir"); + let failing = write_hook_script( + &dir, + "failing_observer.sh", + r#"#!/bin/sh +printf 'failed\n' >&2 +exit 1 +"#, + ); + let later = write_hook_script( + &dir, + "later_observer.sh", + r#"#!/bin/sh +cat > later.json +"#, + ); + let mut fail_hook = Hook::new(HookEvent::TurnEnd, &failing); + fail_hook.continue_on_error = false; + let executor = HookExecutor::new( + HooksConfig { + enabled: true, + hooks: vec![fail_hook, Hook::new(HookEvent::TurnEnd, &later)], + ..Default::default() + }, + dir.path().to_path_buf(), + ); + + let results = executor.execute_structured_observer( + HookEvent::TurnEnd, + &HookContext::new(), + json!({"event": "turn_end"}), + ); + + assert_eq!(results.len(), 2, "observer failures are warn-only"); + assert!(!results[0].success); + assert!(results[1].success); + assert!(dir.path().join("later.json").exists()); + } + #[test] fn test_executor_session_id() { let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from(".")); @@ -1699,6 +1934,7 @@ exit 7 HookEvent::SessionStart, HookEvent::SessionEnd, HookEvent::MessageSubmit, + HookEvent::TurnEnd, HookEvent::ToolCallBefore, HookEvent::ToolCallAfter, HookEvent::ModeChange, @@ -1731,6 +1967,7 @@ exit 7 enabled: true, hooks: vec![ Hook::new(HookEvent::SessionStart, "echo start"), + Hook::new(HookEvent::TurnEnd, "echo turn"), Hook::new(HookEvent::ToolCallBefore, "echo before"), ], ..HooksConfig::default() @@ -1738,6 +1975,7 @@ exit 7 let executor = HookExecutor::new(config, PathBuf::from(".")); // Configured events return true. assert!(executor.has_hooks_for_event(HookEvent::SessionStart)); + assert!(executor.has_hooks_for_event(HookEvent::TurnEnd)); assert!(executor.has_hooks_for_event(HookEvent::ToolCallBefore)); // Unconfigured events return false even when other events are present. assert!(!executor.has_hooks_for_event(HookEvent::ToolCallAfter)); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 9aa56b05a..ab037c21c 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -49,7 +49,7 @@ use crate::config_ui::{self, ConfigUiMode, WebConfigSession, WebConfigSessionEve use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine}; use crate::core::events::Event as EngineEvent; use crate::core::ops::{Op, USER_SHELL_TOOL_ID_PREFIX}; -use crate::hooks::{HookEvent, HookExecutor}; +use crate::hooks::{HookEvent, HookExecutor, TurnEndPayloadInput, TurnEndTotals, turn_end_payload}; use crate::llm_client::LlmClient; use crate::models::{ ContentBlock, Message, MessageRequest, SystemPrompt, Usage, context_window_for_model, @@ -1572,15 +1572,8 @@ async fn run_event_loop( // turn's chunks pull the view down again until the // user opts out by scrolling up. app.user_scrolled_during_stream = false; - app.runtime_turn_status = Some(match status { - crate::core::events::TurnOutcomeStatus::Completed => { - "completed".to_string() - } - crate::core::events::TurnOutcomeStatus::Interrupted => { - "interrupted".to_string() - } - crate::core::events::TurnOutcomeStatus::Failed => "failed".to_string(), - }); + app.runtime_turn_status = + Some(turn_outcome_status_label(status).to_string()); if matches!( status, crate::core::events::TurnOutcomeStatus::Interrupted @@ -1635,7 +1628,7 @@ async fn run_event_loop( reasoning_replay_tokens: usage.reasoning_replay_tokens, recorded_at: Instant::now(), }); - if let Some(error) = error { + if let Some(error) = error.as_deref() { // Only show "Turn failed:" in the composer status // area when an EngineEvent::Error has NOT already // posted the same message into the transcript. @@ -1806,6 +1799,35 @@ async fn run_event_loop( } } + if app.hooks.has_hooks_for_event(HookEvent::TurnEnd) { + let context = app.base_hook_context(); + let payload = turn_end_payload(TurnEndPayloadInput { + context: &context, + turn_id: app.runtime_turn_id.as_deref(), + status: turn_outcome_status_label(status), + error: error.as_deref(), + duration_ms: u64::try_from(turn_elapsed.as_millis()) + .unwrap_or(u64::MAX), + usage: &usage, + totals: TurnEndTotals { + session_tokens: app.session.total_tokens, + conversation_tokens: app.session.total_conversation_tokens, + input_tokens: app.session.total_input_tokens, + output_tokens: app.session.total_output_tokens, + }, + tool_count: app.tool_evidence.len(), + queued_message_count: app.queued_message_count(), + }); + let hooks = app.hooks.clone(); + tokio::task::spawn_blocking(move || { + let _ = hooks.execute_structured_observer( + HookEvent::TurnEnd, + &context, + payload, + ); + }); + } + if queued_to_send.is_none() { queued_to_send = app.pop_queued_message(); } @@ -4120,6 +4142,14 @@ fn queued_session_to_ui(msg: QueuedSessionMessage) -> QueuedMessage { } } +fn turn_outcome_status_label(status: crate::core::events::TurnOutcomeStatus) -> &'static str { + match status { + crate::core::events::TurnOutcomeStatus::Completed => "completed", + crate::core::events::TurnOutcomeStatus::Interrupted => "interrupted", + crate::core::events::TurnOutcomeStatus::Failed => "failed", + } +} + fn reconcile_turn_liveness(app: &mut App, now: Instant, has_running_agents: bool) -> bool { if app.is_loading && app.runtime_turn_status.is_none() diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 230d077b2..0a37d5955 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -484,6 +484,59 @@ the message. Existing environment variables remain available. `shell_env` hooks keep their existing `KEY=VALUE` stdout contract; the JSON stdout contract applies only to `message_submit`. +### Observer `turn_end` hooks + +`turn_end` hooks run after a model turn reaches a terminal outcome and +the TUI has updated visible state, token counters, cost estimates, +receipts, and session persistence scheduling. They are observer-only: +stdout is ignored, failures are logged as warnings, and hook execution +does not block the next input or queued-message dispatch. + +```toml +[[hooks.hooks]] +event = "turn_end" +command = "~/.codewhale/hooks/turn-end.sh" +timeout_secs = 5 +continue_on_error = true +``` + +The hook receives JSON on stdin: + +```json +{ + "event": "turn_end", + "session_id": "sess_12345678", + "workspace": "/path/to/workspace", + "mode": "agent", + "model": "deepseek-chat", + "turn_id": "turn_123", + "status": "completed", + "error": null, + "duration_ms": 4200, + "usage": { + "input_tokens": 1000, + "output_tokens": 250, + "prompt_cache_hit_tokens": 128, + "prompt_cache_miss_tokens": 872, + "reasoning_replay_tokens": null + }, + "totals": { + "session_tokens": 1250, + "conversation_tokens": 1250, + "input_tokens": 1000, + "output_tokens": 250 + }, + "tool_count": 2, + "queued_message_count": 0, + "stop_hook_active": false +} +``` + +`status` is `completed`, `interrupted`, or `failed`. `error` is set +only when the same error is already visible to the user. Payloads do +not include full transcript text, assistant response text, tool +arguments, tool outputs, provider headers, or secrets. + ### Composer stash (`/stash`, Ctrl+S) Press **Ctrl+S** in the composer to park the current draft to diff --git a/docs/rfcs/1364-hooks-lifecycle.md b/docs/rfcs/1364-hooks-lifecycle.md index f7f759c11..fda99a957 100644 --- a/docs/rfcs/1364-hooks-lifecycle.md +++ b/docs/rfcs/1364-hooks-lifecycle.md @@ -1,276 +1,481 @@ # RFC: Hook Lifecycle Data Flow **Issue:** #1364 -**Status:** Draft -**Date:** 2026-05-28 +**Status:** Phase 1 landed, Phase 2/3 spec +**Date:** 2026-06-02 +**Baseline:** `main` at `31f34c5df2363316f23a23398a81cf2d363cd19a` -## 1. Problem +## 1. Current State -CodeWhale already has lifecycle hooks and MCP support, but the current hook -surface is mostly observer-only. This blocks portable extensions that need to -participate in the agent data flow: +CodeWhale has MCP support and configurable hooks. Phase 1 of #1364 landed via +#2434, which harvested the mutable `message_submit` slice from #2318. -- memory/context injection before a user message reaches the model -- post-turn background analysis that prepares context for the next turn -- sub-agent lifecycle visibility for orchestration and audit extensions +Implemented in Phase 1: -The current `message_submit` event fires before dispatch, but its output is -ignored. `TurnComplete`, `AgentSpawned`, and `AgentComplete` exist internally, -but they are not exposed as configurable hook events. +- non-background `message_submit` hooks receive JSON on stdin +- stdout JSON with a non-empty string `text` field replaces submitted text +- exit code `2` blocks submission before a model turn starts +- multiple `message_submit` hooks run serially in config order +- background `message_submit` hooks remain observer-only +- transformed text is used by history, file mention expansion, API messages, + and engine dispatch +- `continue_on_error = true` surfaces stderr/stdout/internal errors as a + transient TUI warning instead of silently swallowing continued failures -## 2. PR split +Remaining #1364 work: -This issue should be implemented as three PRs. Each PR should be independently -reviewable and should leave the hook system in a useful state. +- Phase 2: expose a post-turn `turn_end` lifecycle hook +- Phase 3: expose observer-only subagent lifecycle hooks -### PR 1: Mutable `message_submit` +Future work should start from current `main`; #2318 is now a reference branch, +not the Phase 1 merge target. -Add a structured hook execution path for `message_submit` that can transform or -block the user's submitted text before it is sent to the engine. +## 2. Shared Design Rules -Scope: +All remaining hooks should preserve the existing `[[hooks.hooks]]` config +shape, existing env vars, and existing condition matching. -- keep the existing `[[hooks.hooks]]` config shape -- pass a JSON payload to the hook on stdin -- interpret stdout JSON containing `text` as the replacement user text -- treat exit code `2` as an intentional block -- run multiple submit hooks serially in config order -- keep existing env vars for compatibility -- keep `shell_env` stdout parsing unchanged +Structured payloads should be written to hook stdin as JSON and should include +the stable shared fields: -Non-goals: +- `event` +- `session_id` +- `workspace` +- `mode` +- `model` + +Observer lifecycle hooks must not: + +- mutate transcript content +- mutate submitted user text +- mutate tool arguments or tool results +- block user input or subagent scheduling +- expose secrets, full tool outputs, or unbounded transcript content -- no tool argument mutation -- no global stdout JSON semantics for all hook events -- no transcript or model response mutation +For Phase 2 and Phase 3, stdout is ignored. The stdout JSON mutation contract +remains specific to `message_submit`. + +## 3. PR Split + +### PR 1: Mutable `message_submit` + +Status: landed in #2434. + +No further Phase 1 development should be based on #2318 unless the maintainer +explicitly asks for it. Follow-up behavior should build on `main`. ### PR 2: `turn_end` -Expose the existing turn completion lifecycle as a hook event. +Expose turn completion as a post-turn observer hook. This is the next focused +slice. Scope: - add `HookEvent::TurnEnd` with event name `turn_end` -- fire from the UI's `EngineEvent::TurnComplete` branch after core app state, - usage, cost, notifications, and receipt state have been updated -- pass turn metadata on stdin as JSON -- make failures non-blocking and warn-only -- include a `stop_hook_active` field in the payload, initially `false`, so the - contract can support re-entry protection later +- add a structured observer hook execution path that accepts a JSON payload +- fire from the `EngineEvent::TurnComplete` UI branch after core user-visible + state has been updated +- run hooks without blocking the next user action +- treat hook failures as warn/log only +- include `stop_hook_active`, initially `false`, to reserve the re-entry guard + contract +- document the payload and non-blocking semantics +- update `/hooks events`, `docs/CONFIGURATION.md`, and web docs Non-goals: -- no change to turn status -- no blocking of user input -- no transcript mutation from `turn_end` +- no blocking or replacement behavior +- no transcript mutation +- no model response mutation +- no tool output or full transcript payload +- no subagent lifecycle hook in this PR -### PR 3: Subagent lifecycle observer hooks +### PR 3: Subagent Lifecycle Observer Hooks -Expose subagent start and completion as observer-only hook events. +Expose subagent start and completion as observer-only lifecycle hooks. Scope: - add `HookEvent::SubagentSpawn` with event name `subagent_spawn` - add `HookEvent::SubagentComplete` with event name `subagent_complete` -- fire from the existing `AgentSpawned` and `AgentComplete` UI branches -- pass subagent metadata on stdin as JSON -- make failures non-blocking and warn-only +- reuse the structured observer execution path from Phase 2 +- fire from the existing `EngineEvent::AgentSpawned` and + `EngineEvent::AgentComplete` UI branches +- pass bounded subagent metadata on stdin as JSON +- run hooks without blocking subagent scheduling or parent-turn progress +- treat hook failures as warn/log only +- document the payload and observer-only semantics Non-goals: - no subagent spawn gating in the first version -- no subagent prompt/result mutation +- no subagent prompt or result mutation +- no full prompt/result payload by default - no changes to subagent scheduling +- no new subagent type matcher unless a later review asks for it -## 3. PR 1 detailed plan - -### 3.1 Contract +## 4. Phase 2 Technical Spec: `turn_end` -Configuration: +### 4.1 Hook Configuration ```toml [[hooks.hooks]] -event = "message_submit" -command = "~/.deepseek/hooks/inject-memory.sh" -timeout_secs = 2 +event = "turn_end" +command = "~/.codewhale/hooks/turn-end.sh" +timeout_secs = 5 continue_on_error = true ``` -Input payload on stdin: +The hook should be treated as observer-only even when `background = false`. +The UI must not wait for it before accepting input, dispatching queued +messages, or repainting the idle state. + +### 4.2 Trigger Point + +The trigger point is `crates/tui/src/tui/ui.rs`, inside the +`EngineEvent::TurnComplete` branch. + +The hook should fire after these state updates have happened: + +- loading and streaming state cleared +- turn duration recorded +- runtime turn status set +- session token counters updated +- cache telemetry updated +- turn cost accrued +- user-facing notification/receipt state updated +- session snapshot persistence has been scheduled + +The hook should fire before any automatically queued message starts a new turn, +so the completed turn is observable even when the queue immediately continues. +The hook itself must be fire-and-forget; it must not delay the queued message. + +`turn_end` should fire for all terminal turn outcomes: + +- `completed` +- `interrupted` +- `failed` + +It should not fire on app startup, session startup, or session shutdown without +a completed engine turn. + +### 4.3 Payload + +Example payload: ```json { - "event": "message_submit", - "text": "original user text", - "session_id": "sess_xxxx", + "event": "turn_end", + "session_id": "sess_12345678", "workspace": "/path/to/workspace", "mode": "agent", "model": "deepseek-chat", - "total_tokens": 1234 + "turn_id": "turn_123", + "status": "completed", + "error": null, + "duration_ms": 4200, + "usage": { + "input_tokens": 1000, + "output_tokens": 250, + "prompt_cache_hit_tokens": 128, + "prompt_cache_miss_tokens": 872, + "reasoning_replay_tokens": null + }, + "totals": { + "session_tokens": 1250, + "conversation_tokens": 1250, + "input_tokens": 1000, + "output_tokens": 250 + }, + "tool_count": 2, + "queued_message_count": 0, + "stop_hook_active": false } ``` -Output payload on stdout: +Payload rules: -```json -{ "text": "replacement user text" } -``` +- `status` is one of `completed`, `interrupted`, or `failed` +- `error` is the turn error string only when already visible to the user +- `duration_ms` is wall-clock turn duration from `app.turn_started_at` +- `usage` mirrors `EngineEvent::TurnComplete.usage` +- `totals` are the post-update app/session counters +- `tool_count` is the number of tool evidence entries, not full tool output +- `queued_message_count` is informational and must not change queue behavior +- `stop_hook_active` is always `false` in this PR -Rules: +Do not include: -- exit `0` with stdout JSON containing `text: string` replaces the current text -- exit `0` with empty stdout leaves the current text unchanged -- exit `0` with JSON that does not contain `text` leaves the current text - unchanged -- exit `2` blocks submission before the message is appended to history or sent - to the engine -- other non-zero exits follow `continue_on_error` - - `true`: warn, keep the current text, continue later hooks - - `false`: stop later hooks and block submission with an error message -- `background = true` on `message_submit` remains observer-only and cannot - transform or block submission +- full transcript text +- full tool arguments or outputs +- API keys or provider headers +- raw assistant response text -Multiple hooks: +### 4.4 Implementation Plan + +1. Extend `HookEvent` in `crates/tui/src/hooks.rs`. + +```rust +TurnEnd, +``` -- hooks run in config order -- each hook receives the latest transformed text -- the final transformed text is the only text used by file mention expansion, - skill wrapping, auto routing, history, and `api_messages` +Map it in `HookEvent::as_str()` as `"turn_end"`. -### 3.2 Implementation steps +2. Add a structured observer execution helper in `crates/tui/src/hooks.rs`. -1. Add structured submit outcome types in `crates/tui/src/hooks.rs`: +Suggested shape: ```rust -pub enum MessageSubmitOutcome { - Unchanged, - Replaced(String), - Blocked { reason: String }, +pub fn execute_structured_observer( + &self, + event: HookEvent, + context: &HookContext, + payload: serde_json::Value, +) +``` + +This helper should: + +- filter hooks with `hooks_for_event(event)` +- apply existing condition matching +- pass existing env vars +- write `payload` to stdin using the existing stdin-capable executor path +- ignore stdout +- log failures with `tracing::warn!` +- never return a blocking outcome to the caller + +The caller should invoke this helper from a background task/thread so UI event +handling remains non-blocking. If that requires cloning `HookExecutor`, keep the +payload owned and avoid borrowing `App`. + +3. Add a `turn_end_payload(...)` builder. + +Keep it private to `hooks.rs` if all fields can be passed through +`HookContext`, or place it near the UI call site if it needs direct `App` +access. Prefer a small explicit payload builder over ad hoc JSON construction +inside the event branch. + +4. Wire the UI event branch. + +In `crates/tui/src/tui/ui.rs`, after the `TurnComplete` branch finishes the +core app-state updates, build the payload and dispatch the observer hook. + +5. Update command and docs. + +- `crates/tui/src/commands/hooks.rs` +- `docs/CONFIGURATION.md` +- `web/app/[locale]/docs/page.tsx` + +### 4.5 Tests + +Hook unit tests: + +- `HookEvent::TurnEnd` serializes/deserializes as `turn_end` +- configured `turn_end` hooks receive stdin JSON +- stdout is ignored +- non-zero exit logs/warns but does not produce a blocking result +- timeout does not block the caller path + +TUI tests: + +- completed turn fires one `turn_end` hook +- failed turn fires one `turn_end` hook with `status = "failed"` and `error` +- interrupted turn fires one `turn_end` hook with `status = "interrupted"` +- payload contains post-update token totals +- queued messages still dispatch after `TurnComplete` + +Manual smoke test: + +1. Configure a `turn_end` hook that appends stdin JSON to a temp file. +2. Run one successful turn. +3. Confirm the file contains `event = "turn_end"` and `status = "completed"`. +4. Configure a slow hook with `timeout_secs = 1`. +5. Confirm the TUI returns to idle and accepts input without waiting. + +## 5. Phase 3 Technical Spec: Subagent Lifecycle Hooks + +### 5.1 Hook Configuration + +```toml +[[hooks.hooks]] +event = "subagent_spawn" +command = "~/.codewhale/hooks/subagent-spawn.sh" +timeout_secs = 3 +continue_on_error = true + +[[hooks.hooks]] +event = "subagent_complete" +command = "~/.codewhale/hooks/subagent-complete.sh" +timeout_secs = 3 +continue_on_error = true +``` + +Both events are observer-only. Their hooks must not gate spawn, cancel +subagents, or rewrite prompts/results. + +### 5.2 Trigger Points + +Trigger from `crates/tui/src/tui/ui.rs`: + +- `EngineEvent::AgentSpawned { id, prompt }` -> `subagent_spawn` +- `EngineEvent::AgentComplete { id, result }` -> `subagent_complete` + +Fire after the existing UI state/status updates for each branch have been +applied. Hook failures must not affect: + +- `app.agent_progress` +- `app.status_message` +- `Op::ListSubAgents` +- subagent cards or mailbox routing +- parent turn completion + +### 5.3 Payloads + +Spawn payload: + +```json +{ + "event": "subagent_spawn", + "session_id": "sess_12345678", + "workspace": "/path/to/workspace", + "mode": "agent", + "model": "deepseek-chat", + "agent_id": "agent_abc", + "prompt_preview": "Investigate failing tests", + "prompt_truncated": false } ``` -2. Add a stdin-capable sync executor: +Complete payload: -```rust -fn execute_sync_with_stdin( - &self, - hook: &Hook, - env_vars: &HashMap, - stdin_json: &serde_json::Value, -) -> HookResult +```json +{ + "event": "subagent_complete", + "session_id": "sess_12345678", + "workspace": "/path/to/workspace", + "mode": "agent", + "model": "deepseek-chat", + "agent_id": "agent_abc", + "status": "completed", + "result_preview": "Found the failing assertion in parser tests", + "result_truncated": false +} ``` -This should reuse the existing timeout, working directory, stdout, stderr, and -error handling behavior from `execute_sync`. +Payload rules: + +- use bounded previews, not full prompt/result text +- use the same truncation helper for prompt and result previews +- if agent type or assignment metadata is available without extra blocking + lookups, include it as optional `agent_type` / `assignment` fields +- do not add blocking lookups only to enrich hook payloads +- do not include full subagent transcript -3. Add a `message_submit` transform entrypoint: +### 5.4 Implementation Plan + +1. Extend `HookEvent` in `crates/tui/src/hooks.rs`. ```rust -pub fn execute_message_submit_transform( - &self, - context: &HookContext, - original_text: &str, -) -> MessageSubmitOutcome +SubagentSpawn, +SubagentComplete, ``` -This method should: +Map them as: -- filter configured `MessageSubmit` hooks through existing condition matching -- build a JSON payload for each hook using the current text -- run non-background hooks through `execute_sync_with_stdin` -- run background hooks with the existing observer-only path -- parse stdout JSON only for non-background hooks -- return the final text or a block result +- `"subagent_spawn"` +- `"subagent_complete"` -4. Apply the transformed message in `dispatch_user_message`: +2. Reuse the Phase 2 structured observer helper. -- run the transform before `last_submitted_prompt`, file mentions, history, and - `api_messages` -- create a local mutable `QueuedMessage` or replacement display text -- if blocked, show a status message or toast and return without dispatch +Subagent lifecycle hooks should not create a second observer execution path. +Reuse the `execute_structured_observer` helper and payload writing behavior. -5. Update `/hooks events`: +3. Add bounded preview helpers. -- keep `message_submit` listed -- update description to say it can transform or block user text +Keep prompt/result payloads bounded. Reuse an existing truncation helper if one +is already available in the TUI layer; otherwise add a small private helper +that truncates by chars and returns both preview text and a boolean +`*_truncated` flag. -6. Update user-facing docs: +4. Wire the UI branches. -- document the stdin/stdout contract -- document exit code `2` -- document that `shell_env` still uses `KEY=VALUE` stdout +In the `AgentSpawned` branch, build the spawn payload from `id` and the +existing `prompt_summary`/bounded prompt preview. -### 3.3 Test plan +In the `AgentComplete` branch, build the complete payload from `id` and a +bounded result preview. -Unit tests in `crates/tui/src/hooks.rs`: +5. Update docs and `/hooks events`. -- parses stdout `{"text":"changed"}` as replacement -- empty stdout means unchanged -- JSON without `text` means unchanged -- malformed stdout means unchanged with warning semantics -- exit `2` maps to blocked -- multiple hooks apply transforms in order -- background `message_submit` hook cannot transform -- `continue_on_error = false` blocks on non-zero failure +- `crates/tui/src/commands/hooks.rs` +- `docs/CONFIGURATION.md` +- `web/app/[locale]/docs/page.tsx` -TUI integration or focused dispatch tests: +### 5.5 Tests -- transformed text is written to `api_messages` -- transformed text is written to visible history -- transformed text is used by file mention expansion -- blocked submit does not append user history -- blocked submit does not push an API message -- blocked submit leaves loading state false +Hook unit tests: -Manual smoke test: +- `HookEvent::SubagentSpawn` serializes/deserializes as `subagent_spawn` +- `HookEvent::SubagentComplete` serializes/deserializes as + `subagent_complete` +- subagent observer hooks receive stdin JSON +- stdout is ignored +- non-zero exits do not affect caller state -1. Add a config hook that prepends `[hooked] ` to every submitted message. -2. Submit `hello`. -3. Verify the transcript and model input use `[hooked] hello`. -4. Replace the hook with one that exits `2`. -5. Submit `hello`. -6. Verify no turn starts and the TUI shows the block reason. +TUI tests: -## 4. Shared payload conventions +- `AgentSpawned` fires one `subagent_spawn` hook +- `AgentComplete` fires one `subagent_complete` hook +- payload previews are truncated and marked when input is long +- hook failure does not prevent `Op::ListSubAgents` +- hook failure does not alter `app.agent_progress` +- hook failure does not alter `app.status_message` -All new structured hook payloads should include: +Manual smoke test: -- `event` -- `session_id` -- `workspace` -- `mode` -- `model` +1. Configure both subagent hooks to append stdin JSON to a temp file. +2. Trigger a subagent. +3. Confirm `subagent_spawn` appears before `subagent_complete`. +4. Confirm prompts/results are bounded previews, not full transcripts. -Event-specific payloads should add only fields that are stable and useful for -extension authors. Avoid leaking secrets, full tool outputs, or unbounded -transcript content in the first version. +## 6. Contribution Workflow -## 5. Compatibility +For Phase 2 and Phase 3: -- Existing hook config remains valid. -- Existing observer-only hooks keep working. -- Existing env vars remain available. -- `shell_env` keeps its existing stdout `KEY=VALUE` contract. -- Structured stdout is interpreted only by `message_submit` in PR 1. +- create a fresh branch from current `main` +- keep each PR focused on one behavior boundary +- use `Refs #1364 (partial)` unless a maintainer reopens or re-scopes the issue +- do not use `Closes #1364` for either follow-up slice unless the maintainer + confirms the issue should be fully closed by that PR +- include local validation evidence in the PR body +- keep PRs ready for the direct-merge path: rebased, non-draft, green CI, and + backed by focused tests -## 6. Review checkpoints +Suggested PR body shape: -PR 1 should be accepted only if: +```text +Summary: +Scope: +Not in this slice: +Builds on: #2434 +Issues: Refs #1364 (partial) +Validation: +``` -- submit mutation is covered by tests -- submit blocking is covered by tests -- the unchanged path preserves current behavior -- `shell_env` tests still prove the old stdout contract -- the docs clearly mark `message_submit` as the only mutable hook +## 7. Review Checkpoints -PR 2 should be accepted only if: +Phase 2 is ready for review only if: -- `turn_end` fires after `TurnComplete` app state updates -- failure is warn-only -- payload contains status and usage +- `turn_end` fires after post-turn app state is updated +- all terminal turn statuses are covered by tests +- hook execution is non-blocking from the UI perspective +- payload excludes transcript/tool-output content +- docs specify that stdout is ignored -PR 3 should be accepted only if: +Phase 3 is ready for review only if: - subagent hooks are observer-only -- failures do not affect subagent lifecycle -- payloads do not include unbounded or secret data +- spawn and completion are both covered by tests +- failures do not affect subagent state or scheduling +- payload previews are bounded and tested +- docs clearly state that gating/mutation are out of scope diff --git a/web/app/[locale]/docs/page.tsx b/web/app/[locale]/docs/page.tsx index f141001a0..db4619203 100644 --- a/web/app/[locale]/docs/page.tsx +++ b/web/app/[locale]/docs/page.tsx @@ -179,7 +179,7 @@ default_timeout_secs = 30 [[hooks.hooks]] event = "session_start" # 也支持: tool_call_before / tool_call_after -command = "~/.codewhale/hooks/pre.sh" # / message_submit / mode_change / on_error / shell_env`} +command = "~/.codewhale/hooks/pre.sh" # / message_submit / turn_end / mode_change / on_error / shell_env`}

完整参考:config.example.toml。 @@ -187,6 +187,7 @@ command = "~/.codewhale/hooks/pre.sh" # / message_submit / mode_change /

message_submit hooks run before a user message is sent to the model. A non-background hook can print {'{"text":"replacement"}'} on stdout to replace the message; text must be non-empty. Exit with code 2 to block the submission. + turn_end hooks run after a turn finishes, receive JSON on stdin, ignore stdout, and never block the next input. shell_env keeps its existing KEY=VALUE stdout contract.

@@ -434,7 +435,7 @@ default_timeout_secs = 30 [[hooks.hooks]] event = "session_start" # or: tool_call_before / tool_call_after -command = "~/.codewhale/hooks/pre.sh" # / message_submit / mode_change / on_error / shell_env`} +command = "~/.codewhale/hooks/pre.sh" # / message_submit / turn_end / mode_change / on_error / shell_env`}

Full reference: config.example.toml. @@ -442,6 +443,7 @@ command = "~/.codewhale/hooks/pre.sh" # / message_submit / mode_change /

message_submit hooks run before a user message is sent to the model. A non-background hook can print {'{"text":"replacement"}'} on stdout to replace the message; text must be non-empty. Exit with code 2 to block the submission. + turn_end hooks run after a turn finishes, receive JSON on stdin, ignore stdout, and never block the next input. shell_env keeps its existing KEY=VALUE stdout contract.

From 74d69c3663a9f3211713f33e16639ad2612f82b7 Mon Sep 17 00:00:00 2001 From: AresNing <49557311+AresNing@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:29:34 +0800 Subject: [PATCH 2/8] docs: update hook lifecycle baseline --- docs/rfcs/1364-hooks-lifecycle.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rfcs/1364-hooks-lifecycle.md b/docs/rfcs/1364-hooks-lifecycle.md index fda99a957..e3ef39167 100644 --- a/docs/rfcs/1364-hooks-lifecycle.md +++ b/docs/rfcs/1364-hooks-lifecycle.md @@ -3,7 +3,7 @@ **Issue:** #1364 **Status:** Phase 1 landed, Phase 2/3 spec **Date:** 2026-06-02 -**Baseline:** `main` at `31f34c5df2363316f23a23398a81cf2d363cd19a` +**Baseline:** `main` at `0072209d0096c0b583efeb3da6c6f2ac452bd572` ## 1. Current State From 1caf490bb7f4df109f09302e2188fd691d77323c Mon Sep 17 00:00:00 2001 From: AresNing <49557311+AresNing@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:31:10 +0800 Subject: [PATCH 3/8] docs: clarify observer hook error behavior --- docs/CONFIGURATION.md | 3 +++ docs/rfcs/1364-hooks-lifecycle.md | 1 + 2 files changed, 4 insertions(+) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 0a37d5955..c17bc634d 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -491,6 +491,9 @@ the TUI has updated visible state, token counters, cost estimates, receipts, and session persistence scheduling. They are observer-only: stdout is ignored, failures are logged as warnings, and hook execution does not block the next input or queued-message dispatch. +Because observer hooks cannot block caller state, `continue_on_error` +does not stop later `turn_end` hooks; all matching hooks are attempted +even when an earlier hook fails. ```toml [[hooks.hooks]] diff --git a/docs/rfcs/1364-hooks-lifecycle.md b/docs/rfcs/1364-hooks-lifecycle.md index e3ef39167..c213c7077 100644 --- a/docs/rfcs/1364-hooks-lifecycle.md +++ b/docs/rfcs/1364-hooks-lifecycle.md @@ -242,6 +242,7 @@ This helper should: - write `payload` to stdin using the existing stdin-capable executor path - ignore stdout - log failures with `tracing::warn!` +- ignore `continue_on_error` for observer sequencing; attempt all matching hooks - never return a blocking outcome to the caller The caller should invoke this helper from a background task/thread so UI event From eabbd621ada2d509ca0aef4863f2e3460d17d26c Mon Sep 17 00:00:00 2001 From: AresNing <49557311+AresNing@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:46:30 +0800 Subject: [PATCH 4/8] fix: sync web package lock --- web/package-lock.json | 537 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 537 insertions(+) diff --git a/web/package-lock.json b/web/package-lock.json index 1958d3fa7..33806a683 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1669,6 +1669,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2434,6 +2435,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2456,6 +2458,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2478,6 +2481,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2494,6 +2498,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2510,6 +2515,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2526,6 +2532,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2542,6 +2549,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2558,6 +2566,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2574,6 +2583,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2590,6 +2600,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2606,6 +2617,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2622,6 +2634,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2638,6 +2651,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2660,6 +2674,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2682,6 +2697,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2704,6 +2720,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2726,6 +2743,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2748,6 +2766,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2770,6 +2789,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2792,6 +2812,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2814,6 +2835,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -2833,6 +2855,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2852,6 +2875,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2871,6 +2895,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -13082,6 +13107,474 @@ } } }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/vitest/node_modules/@vitest/mocker": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", @@ -13109,6 +13602,50 @@ } } }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, "node_modules/vitest/node_modules/vite": { "version": "8.0.14", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", From 91ce71b65f64bd481b8619c29213ae331c01a697 Mon Sep 17 00:00:00 2001 From: AresNing <49557311+AresNing@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:53:54 +0800 Subject: [PATCH 5/8] fix: gate hook test fs import --- crates/tui/src/hooks.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tui/src/hooks.rs b/crates/tui/src/hooks.rs index ae9f546bb..e44b14140 100644 --- a/crates/tui/src/hooks.rs +++ b/crates/tui/src/hooks.rs @@ -1228,6 +1228,7 @@ fn parse_env_lines(stdout: &str) -> HashMap { mod tests { use super::*; use std::collections::HashMap; + #[cfg(not(windows))] use std::fs; use std::path::PathBuf; From 836c71e5928aac4286d254535b7a9229bd82be9e Mon Sep 17 00:00:00 2001 From: AresNing <49557311+AresNing@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:16:12 +0800 Subject: [PATCH 6/8] fix: sync with latest main checks --- crates/config/src/lib.rs | 8 ++++-- crates/tui/CHANGELOG.md | 33 +++++++++++++++++++--- crates/tui/src/client.rs | 9 ++++-- crates/tui/src/config.rs | 42 ++++++++++++++++------------ crates/tui/src/main.rs | 6 ++-- crates/tui/src/tui/notifications.rs | 2 +- crates/tui/src/tui/session_picker.rs | 4 +-- crates/tui/src/tui/ui.rs | 4 ++- 8 files changed, 74 insertions(+), 34 deletions(-) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 84d822efe..1c79e62f5 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -2465,7 +2465,9 @@ impl EnvRuntimeOverrides { ProviderKind::XiaomiMimo => self.xiaomi_mimo_base_url.clone(), ProviderKind::Novita => self.novita_base_url.clone(), ProviderKind::Fireworks => self.fireworks_base_url.clone(), - ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => self.siliconflow_base_url.clone(), + ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => { + self.siliconflow_base_url.clone() + } ProviderKind::Arcee => self.arcee_base_url.clone(), ProviderKind::Moonshot => self.moonshot_base_url.clone(), ProviderKind::Sglang => self.sglang_base_url.clone(), @@ -2478,7 +2480,9 @@ impl EnvRuntimeOverrides { let model = match provider { ProviderKind::WanjieArk => self.wanjie_ark_model.clone(), ProviderKind::Volcengine => self.volcengine_model.clone(), - ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => self.siliconflow_model.clone(), + ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => { + self.siliconflow_model.clone() + } ProviderKind::Arcee => self.arcee_model.clone(), ProviderKind::Moonshot => self.moonshot_model.clone(), ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(), diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index efb14a598..acb5ec7bf 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -66,10 +66,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Assistant turns no longer leave an orphaned role glyph (the stray "blue dot") when a turn streams only whitespace between reasoning and a tool call. -- Scrolling the mouse wheel over the right-hand sidebar no longer leaks into - the transcript scroll. -- The sidebar hover tooltip now appears only for truncated lines, sits below - the cursor, and uses a neutral surface color instead of the warning-orange +- Scrolling the mouse wheel over the right-hand sidebar no longer leaks into the + transcript scroll. +- The sidebar hover tooltip now appears only for truncated lines, sits below the + cursor, and uses a neutral surface color instead of the warning-orange highlight that overlapped neighbouring rows. - Corrected the README's description of the Constitution (Article VII is the hierarchy itself; Article II's truth duty overrides even a user request) to @@ -77,6 +77,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Repaired release-blocking unit and integration tests left failing by the cycle-removal and compaction-threshold refactors (relay instruction, model-reject message, compaction budget, mock-LLM threshold helper). +- Fixed DEC private-mode CSI fragment leakage into composer text after + terminal resets, restoring clean prompt editing (#2592). +- The engine now recovers from turn-level panics instead of killing the + main event loop, keeping the session alive through transient failures + (#2583, #1269). +- Deeply nested files are now discoverable via @-mention and Ctrl+P file + picker; the default walk depth was relaxed to handle monorepo layouts (#2488). +- Command-palette selection stays visible when scrolling through long lists + instead of scrolling off-screen (#2590). +- exec_shell child processes now inherit .NET/NuGet and Windows app-data + environment variables, fixing toolchain resolution on Windows (#1857). +- A warning is emitted when shell/sandbox config keys are nested under + unknown top-level sections instead of being silently ignored (#2589). +- Diff-render now preserves leading whitespace in patch content lines, + fixing an extra-space regression in PR previews (#2591). Thanks @zlh124. +- Model selection from the /model command now persists per-provider across + restarts, with a warning when persistence fails. + +### Community + +Thanks to **@zlh124** (#2591) and **@reidliu41** (#2601) for the fixes +harvested into this release. Thanks also to **@idling11** (#2602), +**@gordonlu** (#2585), **@cyq1017** (#2593), **@xyuai** (#2587, #2584), +and **@IcedOranges** (#2584) for reports, drafts, and investigations +that shaped this release cycle. ## [0.8.50] - 2026-06-02 diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 5925e2dd4..2ca4fd28b 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -1092,7 +1092,8 @@ pub(super) fn apply_reasoning_effort( | ApiProvider::Openrouter | ApiProvider::XiaomiMimo | ApiProvider::Novita - | ApiProvider::Siliconflow | ApiProvider::SiliconflowCn + | ApiProvider::Siliconflow + | ApiProvider::SiliconflowCn | ApiProvider::Sglang | ApiProvider::Volcengine => { body["thinking"] = json!({ "type": "disabled" }); @@ -1128,7 +1129,8 @@ pub(super) fn apply_reasoning_effort( // DeepSeek compatibility: low/medium both map to high ApiProvider::Deepseek | ApiProvider::DeepseekCN - | ApiProvider::Siliconflow | ApiProvider::SiliconflowCn + | ApiProvider::Siliconflow + | ApiProvider::SiliconflowCn | ApiProvider::Sglang | ApiProvider::Volcengine => { body["reasoning_effort"] = json!("high"); @@ -1189,7 +1191,8 @@ pub(super) fn apply_reasoning_effort( "xhigh" | "max" | "highest" => match provider { ApiProvider::Deepseek | ApiProvider::DeepseekCN - | ApiProvider::Siliconflow | ApiProvider::SiliconflowCn + | ApiProvider::Siliconflow + | ApiProvider::SiliconflowCn | ApiProvider::Sglang | ApiProvider::Volcengine => { body["reasoning_effort"] = json!("max"); diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 7955aed91..6b7712072 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -178,10 +178,10 @@ impl ApiProvider { "novita" => Some(Self::Novita), "fireworks" | "fireworks-ai" => Some(Self::Fireworks), "siliconflow" | "silicon-flow" | "silicon_flow" => Some(Self::Siliconflow), - "siliconflow-cn" | "siliconflow-CN" - | "silicon-flow-cn" | "silicon-flow-CN" - | "silicon_flow_cn" | "silicon_flow_CN" - | "siliconflow-china" => Some(Self::SiliconflowCn), + "siliconflow-cn" | "siliconflow-CN" | "silicon-flow-cn" | "silicon-flow-CN" + | "silicon_flow_cn" | "silicon_flow_CN" | "siliconflow-china" => { + Some(Self::SiliconflowCn) + } "arcee" | "arcee-ai" | "arcee_ai" => Some(Self::Arcee), "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => Some(Self::Moonshot), "sglang" | "sg-lang" => Some(Self::Sglang), @@ -697,7 +697,10 @@ pub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> } return Some(canonical.to_string()); } - if matches!(provider, ApiProvider::Siliconflow | ApiProvider::SiliconflowCn) { + if matches!( + provider, + ApiProvider::Siliconflow | ApiProvider::SiliconflowCn + ) { let provider_model = model_for_provider(provider, normalized.clone()); if provider_model != normalized { return Some(provider_model); @@ -2311,7 +2314,8 @@ impl Config { | ApiProvider::XiaomiMimo | ApiProvider::Novita | ApiProvider::Fireworks - | ApiProvider::Siliconflow | ApiProvider::SiliconflowCn + | ApiProvider::Siliconflow + | ApiProvider::SiliconflowCn | ApiProvider::Arcee | ApiProvider::Moonshot | ApiProvider::Sglang @@ -2332,7 +2336,6 @@ impl Config { ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL, ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL, ApiProvider::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL, - ApiProvider::SiliconflowCn => DEFAULT_SILICONFLOW_CN_BASE_URL, ApiProvider::SiliconflowCn => DEFAULT_SILICONFLOW_CN_BASE_URL, ApiProvider::Arcee => DEFAULT_ARCEE_BASE_URL, ApiProvider::Moonshot => { @@ -2388,7 +2391,6 @@ impl Config { ApiProvider::Fireworks => "fireworks", ApiProvider::Siliconflow => "siliconflow", ApiProvider::SiliconflowCn => "siliconflow-CN", - ApiProvider::SiliconflowCn => "siliconflow-CN", ApiProvider::Arcee => "arcee", ApiProvider::Moonshot => "moonshot", ApiProvider::Sglang => "sglang", @@ -3303,8 +3305,10 @@ fn apply_env_overrides(config: &mut Config) { .fireworks .base_url = Some(value); } - if matches!(config.api_provider(), ApiProvider::Siliconflow | ApiProvider::SiliconflowCn) - && let Ok(value) = std::env::var("SILICONFLOW_BASE_URL") + if matches!( + config.api_provider(), + ApiProvider::Siliconflow | ApiProvider::SiliconflowCn + ) && let Ok(value) = std::env::var("SILICONFLOW_BASE_URL") && !value.trim().is_empty() { config @@ -3460,8 +3464,10 @@ fn apply_env_overrides(config: &mut Config) { .moonshot .model = Some(value); } - if matches!(config.api_provider(), ApiProvider::Siliconflow | ApiProvider::SiliconflowCn) - && let Ok(value) = std::env::var("SILICONFLOW_MODEL") + if matches!( + config.api_provider(), + ApiProvider::Siliconflow | ApiProvider::SiliconflowCn + ) && let Ok(value) = std::env::var("SILICONFLOW_MODEL") && !value.trim().is_empty() { config @@ -3829,8 +3835,7 @@ fn default_base_url_for_provider(provider: ApiProvider) -> &'static str { ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL, ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL, ApiProvider::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL, - ApiProvider::SiliconflowCn => DEFAULT_SILICONFLOW_CN_BASE_URL, - ApiProvider::SiliconflowCn => DEFAULT_SILICONFLOW_CN_BASE_URL, + ApiProvider::SiliconflowCn => DEFAULT_SILICONFLOW_CN_BASE_URL, ApiProvider::Arcee => DEFAULT_ARCEE_BASE_URL, ApiProvider::Moonshot => DEFAULT_MOONSHOT_BASE_URL, ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL, @@ -3841,7 +3846,9 @@ fn default_base_url_for_provider(provider: ApiProvider) -> &'static str { } fn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool { - if (provider == ApiProvider::Siliconflow || provider == ApiProvider::SiliconflowCn) && siliconflow_base_url_is_official(base_url) { + if (provider == ApiProvider::Siliconflow || provider == ApiProvider::SiliconflowCn) + && siliconflow_base_url_is_official(base_url) + { return false; } normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider)) @@ -4758,8 +4765,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result ApiProvider::Novita => "novita", ApiProvider::Fireworks => "fireworks", ApiProvider::Siliconflow => "siliconflow", - ApiProvider::SiliconflowCn => "siliconflow-CN", - ApiProvider::SiliconflowCn => "siliconflow-CN", + ApiProvider::SiliconflowCn => "siliconflow-CN", ApiProvider::Arcee => "arcee", ApiProvider::Moonshot => "moonshot", ApiProvider::Sglang => "sglang", @@ -4854,7 +4860,7 @@ fn provider_config_key(provider: ApiProvider) -> Result<&'static str> { ApiProvider::Novita => Ok("novita"), ApiProvider::Fireworks => Ok("fireworks"), ApiProvider::Siliconflow => Ok("siliconflow"), - ApiProvider::SiliconflowCn => Ok("siliconflow-CN"), + ApiProvider::SiliconflowCn => Ok("siliconflow-CN"), ApiProvider::Arcee => Ok("arcee"), ApiProvider::Moonshot => Ok("moonshot"), ApiProvider::Sglang => Ok("sglang"), diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 6df2b9f07..5df20247c 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -2000,7 +2000,8 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { "FIREWORKS_API_KEY", "codewhale auth set --provider fireworks --api-key \"...\"", ), - crate::config::ApiProvider::Siliconflow | crate::config::ApiProvider::SiliconflowCn => ( + crate::config::ApiProvider::Siliconflow + | crate::config::ApiProvider::SiliconflowCn => ( "SILICONFLOW_API_KEY", "codewhale auth set --provider siliconflow --api-key \"...\"", ), @@ -2044,7 +2045,8 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { crate::config::ApiProvider::XiaomiMimo => "xiaomi_mimo", crate::config::ApiProvider::Novita => "novita", crate::config::ApiProvider::Fireworks => "fireworks", - crate::config::ApiProvider::Siliconflow | crate::config::ApiProvider::SiliconflowCn => "siliconflow", + crate::config::ApiProvider::Siliconflow + | crate::config::ApiProvider::SiliconflowCn => "siliconflow", crate::config::ApiProvider::Arcee => "arcee", crate::config::ApiProvider::Moonshot => "moonshot", crate::config::ApiProvider::Sglang => "sglang", diff --git a/crates/tui/src/tui/notifications.rs b/crates/tui/src/tui/notifications.rs index b624de0c0..84f43191c 100644 --- a/crates/tui/src/tui/notifications.rs +++ b/crates/tui/src/tui/notifications.rs @@ -699,7 +699,7 @@ pub fn latest_assistant_text(messages: &[Message]) -> Option { | ContentBlock::ServerToolUse { .. } | ContentBlock::ToolSearchToolResult { .. } | ContentBlock::CodeExecutionToolResult { .. } => None, - | ContentBlock::ImageUrl { .. } => None, + ContentBlock::ImageUrl { .. } => None, }) .collect::>() .join("\n"); diff --git a/crates/tui/src/tui/session_picker.rs b/crates/tui/src/tui/session_picker.rs index 64a7225ef..489438a2b 100644 --- a/crates/tui/src/tui/session_picker.rs +++ b/crates/tui/src/tui/session_picker.rs @@ -790,9 +790,7 @@ fn message_text_for_history(message: &crate::models::Message) -> String { | crate::models::ContentBlock::CodeExecutionToolResult { content, .. } => { format!("tool result: {}", truncate(&content.to_string(), 220)) } - crate::models::ContentBlock::ImageUrl { .. } => { - String::from("[image]") - } + crate::models::ContentBlock::ImageUrl { .. } => String::from("[image]"), }; let part = part.trim(); if !part.is_empty() { diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 48617bc01..380c2cd2d 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -6634,7 +6634,9 @@ fn render(f: &mut Frame, app: &mut App) { crate::config::ApiProvider::XiaomiMimo => Some("MiMo"), crate::config::ApiProvider::Novita => Some("Novita"), crate::config::ApiProvider::Fireworks => Some("Fireworks"), - crate::config::ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => Some("SiliconFlow"), + crate::config::ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => { + Some("SiliconFlow") + } crate::config::ApiProvider::Arcee => Some("Arcee"), crate::config::ApiProvider::Moonshot => Some("Kimi"), crate::config::ApiProvider::Sglang => Some("SGLang"), From de73954e897652286406a39b0bec4d12855f1dbb Mon Sep 17 00:00:00 2001 From: AresNing <49557311+AresNing@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:51:32 +0800 Subject: [PATCH 7/8] fix: cover siliconflow cn in cli auth --- crates/cli/src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 85901a060..13b53076a 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -744,6 +744,7 @@ fn provider_slot(provider: ProviderKind) -> &'static str { ProviderKind::Novita => "novita", ProviderKind::Fireworks => "fireworks", ProviderKind::Siliconflow => "siliconflow", + ProviderKind::SiliconflowCN => "siliconflow-CN", ProviderKind::Arcee => "arcee", ProviderKind::Moonshot => "moonshot", ProviderKind::Sglang => "sglang", @@ -753,7 +754,7 @@ fn provider_slot(provider: ProviderKind) -> &'static str { } /// Provider order used by the `auth list` and `auth status` outputs. -const PROVIDER_LIST: [ProviderKind; 16] = [ +const PROVIDER_LIST: [ProviderKind; 17] = [ ProviderKind::Deepseek, ProviderKind::NvidiaNim, ProviderKind::Openai, @@ -765,6 +766,7 @@ const PROVIDER_LIST: [ProviderKind; 16] = [ ProviderKind::Novita, ProviderKind::Fireworks, ProviderKind::Siliconflow, + ProviderKind::SiliconflowCN, ProviderKind::Arcee, ProviderKind::Moonshot, ProviderKind::Sglang, @@ -822,6 +824,7 @@ fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] { ProviderKind::NvidiaNim => &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"], ProviderKind::Fireworks => &["FIREWORKS_API_KEY"], ProviderKind::Siliconflow => &["SILICONFLOW_API_KEY"], + ProviderKind::SiliconflowCN => &["SILICONFLOW_API_KEY"], ProviderKind::Arcee => &["ARCEE_API_KEY"], ProviderKind::Moonshot => &["MOONSHOT_API_KEY", "KIMI_API_KEY"], ProviderKind::Sglang => &["SGLANG_API_KEY"], From f5a28c1eb2f1aee6add7e3d19924b3bc61998d24 Mon Sep 17 00:00:00 2001 From: AresNing <49557311+AresNing@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:16:21 +0800 Subject: [PATCH 8/8] fix: document siliconflow cn registry --- config.example.toml | 4 ++-- docs/CONFIGURATION.md | 15 +++++++++------ docs/PROVIDERS.md | 5 +++-- scripts/check-provider-registry.py | 10 +++++++++- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/config.example.toml b/config.example.toml index ac8d22c0c..ff839e7ff 100644 --- a/config.example.toml +++ b/config.example.toml @@ -14,12 +14,12 @@ # this file — keeping both stored at once means `/provider deepseek` and # `/provider nvidia-nim` (or `--provider openai`, `--provider wanjie-ark`, # `--provider volcengine`, `--provider xiaomi-mimo`, `--provider fireworks`, -# `--provider siliconflow`, `/provider arcee`, `/provider moonshot`, `/provider sglang`, +# `--provider siliconflow`, `--provider siliconflow-CN`, `/provider arcee`, `/provider moonshot`, `/provider sglang`, # `/provider vllm`, `/provider ollama`) 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 | deepseek-cn | nvidia-nim | openai | atlascloud | wanjie-ark | volcengine | openrouter | xiaomi-mimo | novita | fireworks | siliconflow | arcee | moonshot | sglang | vllm | ollama +provider = "deepseek" # deepseek | deepseek-cn | nvidia-nim | openai | atlascloud | wanjie-ark | volcengine | openrouter | xiaomi-mimo | novita | fireworks | siliconflow | siliconflow-CN | arcee | moonshot | sglang | vllm | ollama api_key = "YOUR_DEEPSEEK_API_KEY" # must be non-empty base_url = "https://api.deepseek.com/beta" # provider = "deepseek-cn" # legacy alias (official host is still https://api.deepseek.com) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 878ee5ebb..f2926dc5d 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -87,7 +87,8 @@ provider's keyring entry. For hosted, generic OpenAI-compatible, or self-hosted providers, set `provider = "nvidia-nim"`, `"openai"`, `"atlascloud"`, `"wanjie-ark"`, `"volcengine"`, `"openrouter"`, `"xiaomi-mimo"`, `"novita"`, `"fireworks"`, -`"siliconflow"`, `"arcee"`, `"moonshot"`, `"sglang"`, `"vllm"`, or `"ollama"` or pass +`"siliconflow"`, `"siliconflow-CN"`, `"arcee"`, `"moonshot"`, `"sglang"`, +`"vllm"`, or `"ollama"` or pass `codewhale --provider `. For the provider-by-provider registry, including auth variables, default base URLs, model IDs, and capability metadata, see [PROVIDERS.md](PROVIDERS.md). @@ -118,8 +119,10 @@ unless API-key auth is explicitly requested; use an env var or config-file key when a local server does require bearer auth. SiliconFlow defaults to `https://api.siliconflow.com/v1`, accepts `SILICONFLOW_BASE_URL`, and uses `deepseek-ai/DeepSeek-V4-Pro` by default. -`https://api.siliconflow.cn/v1` can still be configured explicitly when a user -needs the regional endpoint. +Use `provider = "siliconflow-CN"` for the regional +`https://api.siliconflow.cn/v1` default without manually overriding the base +URL. Both SiliconFlow provider IDs share `SILICONFLOW_API_KEY` and +`[providers.siliconflow]`. Arcee AI defaults to `https://api.arcee.ai/api/v1`, accepts `ARCEE_BASE_URL`, and uses `trinity-large-thinking` by default for CodeWhale agent work. `trinity-large-preview` is also listed as a direct Arcee API model; OpenRouter's @@ -302,7 +305,7 @@ aliases. When both forms are set the `CODEWHALE_*` value wins; the `DEEPSEEK_*` form is kept for older shells: - `CODEWHALE_PROVIDER` (preferred) / `DEEPSEEK_PROVIDER` (legacy alias) — - `deepseek|nvidia-nim|openai|atlascloud|wanjie-ark|volcengine|openrouter|xiaomi-mimo|novita|fireworks|siliconflow|arcee|moonshot|sglang|vllm|ollama` + `deepseek|nvidia-nim|openai|atlascloud|wanjie-ark|volcengine|openrouter|xiaomi-mimo|novita|fireworks|siliconflow|siliconflow-CN|arcee|moonshot|sglang|vllm|ollama` - `CODEWHALE_MODEL` (preferred) / `DEEPSEEK_MODEL` (legacy alias) — default model for the active provider - `CODEWHALE_BASE_URL` (preferred) / `DEEPSEEK_BASE_URL` (legacy alias) — base URL for the active provider @@ -737,9 +740,9 @@ If you are upgrading from older releases: ### Core keys (used by the TUI/engine) -- `provider` (string, optional): `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`, `moonshot`, `sglang`, `vllm`, or `ollama`. Legacy `deepseek-cn` configs are still accepted as an alias for `deepseek`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `volcengine` targets Volcengine Ark's OpenAI-compatible coding endpoint at `https://ark.cn-beijing.volces.com/api/coding/v3`; `openrouter` targets `https://openrouter.ai/api/v1`; `xiaomi-mimo` targets Xiaomi MiMo's OpenAI-compatible endpoint at `https://api.xiaomimimo.com/v1`; `novita` targets `https://api.novita.ai/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `siliconflow` targets SiliconFlow, defaulting to `https://api.siliconflow.com/v1`; `arcee` targets Arcee AI's OpenAI-compatible endpoint at `https://api.arcee.ai/api/v1`; `moonshot` targets Moonshot/Kimi, defaulting to `https://api.moonshot.ai/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`. +- `provider` (string, optional): `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `siliconflow-CN`, `arcee`, `moonshot`, `sglang`, `vllm`, or `ollama`. Legacy `deepseek-cn` configs are still accepted as an alias for `deepseek`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `volcengine` targets Volcengine Ark's OpenAI-compatible coding endpoint at `https://ark.cn-beijing.volces.com/api/coding/v3`; `openrouter` targets `https://openrouter.ai/api/v1`; `xiaomi-mimo` targets Xiaomi MiMo's OpenAI-compatible endpoint at `https://api.xiaomimimo.com/v1`; `novita` targets `https://api.novita.ai/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `siliconflow` targets SiliconFlow, defaulting to `https://api.siliconflow.com/v1`; `siliconflow-CN` targets the regional SiliconFlow endpoint, defaulting to `https://api.siliconflow.cn/v1`; `arcee` targets Arcee AI's OpenAI-compatible endpoint at `https://api.arcee.ai/api/v1`; `moonshot` targets Moonshot/Kimi, defaulting to `https://api.moonshot.ai/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`. - `api_key` (string, required for hosted providers): must be non-empty for DeepSeek/hosted providers (or set the provider API key env var). Self-hosted SGLang, vLLM, and Ollama can omit it. -- `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs. Other defaults are `https://integrate.api.nvidia.com/v1` for `nvidia-nim`, `https://api.openai.com/v1` for `openai`, `https://api.atlascloud.ai/v1` for `atlascloud`, `https://maas-openapi.wanjiedata.com/api/v1` for `wanjie-ark`, `https://ark.cn-beijing.volces.com/api/coding/v3` for `volcengine`, `https://openrouter.ai/api/v1` for `openrouter`, `https://api.xiaomimimo.com/v1` for `xiaomi-mimo`, `https://api.novita.ai/v1` for `novita`, `https://api.fireworks.ai/inference/v1` for `fireworks`, `https://api.siliconflow.com/v1` for `siliconflow`, `https://api.arcee.ai/api/v1` for `arcee`, `https://api.moonshot.ai/v1` for `moonshot`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, and `http://localhost:11434/v1` for `ollama`. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. +- `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs. Other defaults are `https://integrate.api.nvidia.com/v1` for `nvidia-nim`, `https://api.openai.com/v1` for `openai`, `https://api.atlascloud.ai/v1` for `atlascloud`, `https://maas-openapi.wanjiedata.com/api/v1` for `wanjie-ark`, `https://ark.cn-beijing.volces.com/api/coding/v3` for `volcengine`, `https://openrouter.ai/api/v1` for `openrouter`, `https://api.xiaomimimo.com/v1` for `xiaomi-mimo`, `https://api.novita.ai/v1` for `novita`, `https://api.fireworks.ai/inference/v1` for `fireworks`, `https://api.siliconflow.com/v1` for `siliconflow`, `https://api.siliconflow.cn/v1` for `siliconflow-CN`, `https://api.arcee.ai/api/v1` for `arcee`, `https://api.moonshot.ai/v1` for `moonshot`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, and `http://localhost:11434/v1` for `ollama`. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. - `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `DeepSeek-V4-Pro` for Volcengine Ark, `deepseek/deepseek-v4-pro` for OpenRouter and Novita, `mimo-v2.5-pro` for Xiaomi MiMo, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SiliconFlow, `trinity-large-thinking` for Arcee AI, `kimi-k2.6` for Moonshot, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026, except SiliconFlow maps `deepseek-reasoner` and `deepseek-r1` to its Pro model while `deepseek-chat` and `deepseek-v3` map to Flash. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. OpenRouter also recognizes recent large IDs such as `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `google/gemma-4-31b-it`, and `moonshotai/kimi-k2.6`; direct Arcee uses bare IDs such as `trinity-large-thinking` and `trinity-large-preview`; direct Xiaomi MiMo recognizes chat IDs `mimo-v2.5-pro` and `mimo-v2.5`, while TTS IDs are selected through `codewhale speech` / `tts`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, `arcee`, and Ollama model IDs are passed through unchanged after known aliases are normalized. OpenRouter and SiliconFlow provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `CODEWHALE_MODEL` overrides this for a single process; `DEEPSEEK_MODEL` is the legacy alias. - `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 `false`; shell tools must be explicitly enabled. diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 94c2f4754..23e11fdb0 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -30,8 +30,8 @@ Sources to keep in sync: The canonical provider IDs are: `deepseek`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, -`openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`, -`moonshot`, `sglang`, `vllm`, and `ollama`. +`openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, +`siliconflow-CN`, `arcee`, `moonshot`, `sglang`, `vllm`, and `ollama`. Use any of these surfaces to select a provider: @@ -122,6 +122,7 @@ endpoint. | `novita` | `[providers.novita]` | `NOVITA_API_KEY` | `NOVITA_BASE_URL`; default `https://api.novita.ai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | OpenAI-compatible hosted route for DeepSeek model IDs. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. | | `fireworks` | `[providers.fireworks]` | `FIREWORKS_API_KEY` | `FIREWORKS_BASE_URL`; default `https://api.fireworks.ai/inference/v1` | `accounts/fireworks/models/deepseek-v4-pro` | OpenAI-compatible hosted route. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. | | `siliconflow` | `[providers.siliconflow]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.com/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | OpenAI-compatible hosted route. Official docs use the `.com` endpoint; users who need the regional endpoint can set `https://api.siliconflow.cn/v1` explicitly. `SILICONFLOW_MODEL` is accepted. Reasoning aliases `deepseek-reasoner` and `deepseek-r1` map to Pro; `deepseek-chat` and `deepseek-v3` map to Flash. | +| `siliconflow-CN` | `[providers.siliconflow]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.cn/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Regional SiliconFlow endpoint with the same auth, model aliases, and config table as `siliconflow`. Use this provider ID when you want the `.cn` default without overriding the base URL manually. | | `arcee` | `[providers.arcee]` | `ARCEE_API_KEY` | `ARCEE_BASE_URL`; default `https://api.arcee.ai/api/v1` | `trinity-large-thinking`, `trinity-large-preview` | Arcee AI direct OpenAI-compatible route, tracked as 256K-context BF16 serving. `ARCEE_MODEL` is accepted. OpenRouter's `arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced model ID; direct Arcee uses the bare `trinity-large-thinking` ID. | | `moonshot` | `[providers.moonshot]` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` | `MOONSHOT_BASE_URL`, `KIMI_BASE_URL`; default `https://api.moonshot.ai/v1` | `kimi-k2.6`; Kimi Code path uses `kimi-for-coding` at `https://api.kimi.com/coding/v1` | Moonshot/Kimi route. `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, and `KIMI_MODEL` are accepted. `[providers.moonshot] auth_mode = "kimi_oauth"` reads Kimi CLI OAuth credentials when present. | | `sglang` | `[providers.sglang]` | Optional `SGLANG_API_KEY` | `SGLANG_BASE_URL`; default `http://localhost:30000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted OpenAI-compatible route. Localhost deployments commonly omit auth. `SGLANG_MODEL` is accepted. | diff --git a/scripts/check-provider-registry.py b/scripts/check-provider-registry.py index 2b8050675..0788f77ef 100644 --- a/scripts/check-provider-registry.py +++ b/scripts/check-provider-registry.py @@ -27,6 +27,11 @@ API_PROVIDER_ONLY_IDS = {"deepseek-cn"} +PROVIDER_TABLE_ALIASES = { + # `siliconflow-CN` is a separate provider ID for regional endpoint defaults, + # but it intentionally reuses the SiliconFlow credential/config table. + "siliconflow-CN": "siliconflow", +} def read(path: Path) -> str: @@ -205,7 +210,10 @@ def main() -> int: variant_to_id = provider_kind_ids(config_rs) canonical_ids = set(variant_to_id.values()) live_api_provider_ids = set(api_provider_ids(tui_config_rs).values()) - expected_tables = {provider_id.replace("-", "_") for provider_id in canonical_ids} + expected_tables = { + PROVIDER_TABLE_ALIASES.get(provider_id, provider_id.replace("-", "_")) + for provider_id in canonical_ids + } errors: list[str] = [] errors += report_provider_enum_drift(canonical_ids, live_api_provider_ids)