feat(tui): expose stream chunk timeout config
Harvested from PR #2507 by @cyq1017. Reported by @mserrano11 in #2365. Co-authored-by: cyq1017 <61975706+cyq1017@users.noreply.github.com>
This commit is contained in:
@@ -49,6 +49,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Added `completion_sound = "file"` with `[notifications].sound_file` so
|
- Added `completion_sound = "file"` with `[notifications].sound_file` so
|
||||||
Windows users can play a custom WAV file for turn-completion sounds without
|
Windows users can play a custom WAV file for turn-completion sounds without
|
||||||
changing the global Windows sound scheme (#2484, #2512).
|
changing the global Windows sound scheme (#2484, #2512).
|
||||||
|
- Added `[tui].stream_chunk_timeout_secs` and `/config stream_chunk_timeout_secs`
|
||||||
|
so slow local or OpenAI-compatible model servers can extend the SSE idle
|
||||||
|
timeout without mutating process environment. The legacy
|
||||||
|
`DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var remains a fallback (#2365, #2507).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -498,7 +498,7 @@ Key environment variables:
|
|||||||
| `DEEPSEEK_BASE_URL` | API base URL |
|
| `DEEPSEEK_BASE_URL` | API base URL |
|
||||||
| `DEEPSEEK_HTTP_HEADERS` | Optional custom model request headers, e.g. `X-Model-Provider-Id=your-model-provider` |
|
| `DEEPSEEK_HTTP_HEADERS` | Optional custom model request headers, e.g. `X-Model-Provider-Id=your-model-provider` |
|
||||||
| `DEEPSEEK_MODEL` | Default model |
|
| `DEEPSEEK_MODEL` | Default model |
|
||||||
| `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | Stream idle timeout in seconds, default `300`, clamped to `1..=3600` |
|
| `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | Legacy stream idle timeout env override, default `300`, clamped to `1..=3600`; `[tui].stream_chunk_timeout_secs` takes precedence when configured |
|
||||||
| `CODEWHALE_PROVIDER` / `DEEPSEEK_PROVIDER` | `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `siliconflow-CN`, `arcee`, `moonshot`, `sglang`, `vllm`, `ollama`, `huggingface` |
|
| `CODEWHALE_PROVIDER` / `DEEPSEEK_PROVIDER` | `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `siliconflow-CN`, `arcee`, `moonshot`, `sglang`, `vllm`, `ollama`, `huggingface` |
|
||||||
| `DEEPSEEK_PROFILE` | Config profile name |
|
| `DEEPSEEK_PROFILE` | Config profile name |
|
||||||
| `DEEPSEEK_MEMORY` | Set to `on` to enable user memory |
|
| `DEEPSEEK_MEMORY` | Set to `on` to enable user memory |
|
||||||
|
|||||||
@@ -472,6 +472,7 @@ max_subagents = 10 # optional (1-20)
|
|||||||
alternate_screen = "auto" # auto/always use the TUI screen; never uses terminal scrollback
|
alternate_screen = "auto" # auto/always use the TUI screen; never uses terminal scrollback
|
||||||
mouse_capture = true # true copies only transcript user/assistant text; false uses raw terminal selection/copy
|
mouse_capture = true # true copies only transcript user/assistant text; false uses raw terminal selection/copy
|
||||||
terminal_probe_timeout_ms = 500 # optional startup terminal-mode timeout (100-5000ms)
|
terminal_probe_timeout_ms = 500 # optional startup terminal-mode timeout (100-5000ms)
|
||||||
|
stream_chunk_timeout_secs = 300 # optional SSE idle timeout per chunk (0 = default, 1-3600)
|
||||||
osc8_links = true # emit OSC 8 escapes around URLs (Cmd+click in iTerm2/Ghostty/Kitty/WezTerm/Terminal.app 13+); set false for terminals that misrender
|
osc8_links = true # emit OSC 8 escapes around URLs (Cmd+click in iTerm2/Ghostty/Kitty/WezTerm/Terminal.app 13+); set false for terminals that misrender
|
||||||
# Ordered footer chips shown in the TUI status line. Omit the key to use the
|
# Ordered footer chips shown in the TUI status line. Omit the key to use the
|
||||||
# built-in default; set [] to hide all configurable chips. You can also edit
|
# built-in default; set [] to hide all configurable chips. You can also edit
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Added `completion_sound = "file"` with `[notifications].sound_file` so
|
- Added `completion_sound = "file"` with `[notifications].sound_file` so
|
||||||
Windows users can play a custom WAV file for turn-completion sounds without
|
Windows users can play a custom WAV file for turn-completion sounds without
|
||||||
changing the global Windows sound scheme (#2484, #2512).
|
changing the global Windows sound scheme (#2484, #2512).
|
||||||
|
- Added `[tui].stream_chunk_timeout_secs` and `/config stream_chunk_timeout_secs`
|
||||||
|
so slow local or OpenAI-compatible model servers can extend the SSE idle
|
||||||
|
timeout without mutating process environment. The legacy
|
||||||
|
`DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var remains a fallback (#2365, #2507).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ pub struct DeepSeekClient {
|
|||||||
connection_health: Arc<AsyncMutex<ConnectionHealth>>,
|
connection_health: Arc<AsyncMutex<ConnectionHealth>>,
|
||||||
rate_limiter: Arc<AsyncMutex<TokenBucket>>,
|
rate_limiter: Arc<AsyncMutex<TokenBucket>>,
|
||||||
path_suffix: Option<String>,
|
path_suffix: Option<String>,
|
||||||
|
pub(super) stream_idle_timeout: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONNECTION_FAILURE_THRESHOLD: u32 = 2;
|
const CONNECTION_FAILURE_THRESHOLD: u32 = 2;
|
||||||
@@ -325,6 +326,7 @@ impl Clone for DeepSeekClient {
|
|||||||
connection_health: self.connection_health.clone(),
|
connection_health: self.connection_health.clone(),
|
||||||
rate_limiter: self.rate_limiter.clone(),
|
rate_limiter: self.rate_limiter.clone(),
|
||||||
path_suffix: self.path_suffix.clone(),
|
path_suffix: self.path_suffix.clone(),
|
||||||
|
stream_idle_timeout: self.stream_idle_timeout,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -581,6 +583,7 @@ impl DeepSeekClient {
|
|||||||
validate_base_url_security(&base_url)?;
|
validate_base_url_security(&base_url)?;
|
||||||
let retry = config.retry_policy();
|
let retry = config.retry_policy();
|
||||||
let default_model = config.default_model();
|
let default_model = config.default_model();
|
||||||
|
let stream_idle_timeout = Duration::from_secs(config.stream_chunk_timeout_secs());
|
||||||
let http_headers = config.http_headers();
|
let http_headers = config.http_headers();
|
||||||
let path_suffix = config
|
let path_suffix = config
|
||||||
.provider_config_for(api_provider)
|
.provider_config_for(api_provider)
|
||||||
@@ -615,6 +618,7 @@ impl DeepSeekClient {
|
|||||||
connection_health: Arc::new(AsyncMutex::new(ConnectionHealth::default())),
|
connection_health: Arc::new(AsyncMutex::new(ConnectionHealth::default())),
|
||||||
rate_limiter: Arc::new(AsyncMutex::new(TokenBucket::from_env())),
|
rate_limiter: Arc::new(AsyncMutex::new(TokenBucket::from_env())),
|
||||||
path_suffix,
|
path_suffix,
|
||||||
|
stream_idle_timeout,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1683,6 +1687,21 @@ mod tests {
|
|||||||
assert!(headers.get("x-blank").is_none());
|
assert!(headers.get("x-blank").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_stream_idle_timeout_uses_tui_config() {
|
||||||
|
let client = DeepSeekClient::new(&Config {
|
||||||
|
api_key: Some("sk-test".to_string()),
|
||||||
|
tui: Some(crate::config::TuiConfig {
|
||||||
|
stream_chunk_timeout_secs: Some(777),
|
||||||
|
..crate::config::TuiConfig::default()
|
||||||
|
}),
|
||||||
|
..Config::default()
|
||||||
|
})
|
||||||
|
.expect("client");
|
||||||
|
|
||||||
|
assert_eq!(client.stream_idle_timeout, Duration::from_secs(777));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn xiaomi_mimo_token_plan_endpoint_uses_api_key_header() {
|
fn xiaomi_mimo_token_plan_endpoint_uses_api_key_header() {
|
||||||
let headers = DeepSeekClient::default_headers_for_provider(
|
let headers = DeepSeekClient::default_headers_for_provider(
|
||||||
|
|||||||
@@ -16,11 +16,6 @@ use tokio::time::timeout as tokio_timeout;
|
|||||||
|
|
||||||
use crate::config::wire_model_for_provider;
|
use crate::config::wire_model_for_provider;
|
||||||
|
|
||||||
/// Default idle timeout for SSE stream reads (300 seconds = 5 minutes).
|
|
||||||
/// After this period with no data, the stream is considered stalled and
|
|
||||||
/// yields a recoverable error so the caller can retry.
|
|
||||||
const DEFAULT_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(300);
|
|
||||||
|
|
||||||
/// Default timeout for the initial streaming response headers.
|
/// Default timeout for the initial streaming response headers.
|
||||||
///
|
///
|
||||||
/// `doctor` uses a bounded non-streaming request, but normal TUI turns first
|
/// `doctor` uses a bounded non-streaming request, but normal TUI turns first
|
||||||
@@ -48,17 +43,6 @@ fn stream_open_timeout_from_env(value: Option<&str>) -> Duration {
|
|||||||
Duration::from_secs(secs)
|
Duration::from_secs(secs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads the `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var, falling back to
|
|
||||||
/// the default 300s. The parsed value is clamped to [1, 3600] seconds.
|
|
||||||
fn stream_idle_timeout() -> Duration {
|
|
||||||
let secs = std::env::var("DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse::<u64>().ok())
|
|
||||||
.unwrap_or(DEFAULT_STREAM_IDLE_TIMEOUT.as_secs())
|
|
||||||
.clamp(1, 3600);
|
|
||||||
Duration::from_secs(secs)
|
|
||||||
}
|
|
||||||
|
|
||||||
use crate::config::ApiProvider;
|
use crate::config::ApiProvider;
|
||||||
use crate::llm_client::StreamEventBox;
|
use crate::llm_client::StreamEventBox;
|
||||||
use crate::llm_client::sanitize_http_error_body;
|
use crate::llm_client::sanitize_http_error_body;
|
||||||
@@ -283,6 +267,7 @@ impl DeepSeekClient {
|
|||||||
// gzip-compressor failure when investigating #103.
|
// gzip-compressor failure when investigating #103.
|
||||||
let response_headers = format_stream_headers(response.headers());
|
let response_headers = format_stream_headers(response.headers());
|
||||||
let byte_stream = response.bytes_stream();
|
let byte_stream = response.bytes_stream();
|
||||||
|
let stream_idle_timeout = self.stream_idle_timeout;
|
||||||
|
|
||||||
let stream = async_stream::stream! {
|
let stream = async_stream::stream! {
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
@@ -315,7 +300,7 @@ impl DeepSeekClient {
|
|||||||
let is_reasoning_model = is_reasoning_model_for_stream(api_provider, &model);
|
let is_reasoning_model = is_reasoning_model_for_stream(api_provider, &model);
|
||||||
|
|
||||||
let mut byte_stream = std::pin::pin!(byte_stream);
|
let mut byte_stream = std::pin::pin!(byte_stream);
|
||||||
let idle = stream_idle_timeout();
|
let idle = stream_idle_timeout;
|
||||||
|
|
||||||
// Telemetry for #103 stream-decode diagnostics: bytes received
|
// Telemetry for #103 stream-decode diagnostics: bytes received
|
||||||
// since the start of this stream and last successful event time.
|
// since the start of this stream and last successful event time.
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ use std::time::Duration;
|
|||||||
use super::CommandResult;
|
use super::CommandResult;
|
||||||
use crate::client::DeepSeekClient;
|
use crate::client::DeepSeekClient;
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
ApiProvider, COMMON_DEEPSEEK_MODELS, Config, DEFAULT_XIAOMI_MIMO_BASE_URL,
|
ApiProvider, COMMON_DEEPSEEK_MODELS, Config, DEFAULT_STREAM_CHUNK_TIMEOUT_SECS,
|
||||||
|
DEFAULT_XIAOMI_MIMO_BASE_URL, MAX_STREAM_CHUNK_TIMEOUT_SECS, MIN_STREAM_CHUNK_TIMEOUT_SECS,
|
||||||
XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL, clear_active_provider_api_key, effective_home_dir,
|
XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL, clear_active_provider_api_key, effective_home_dir,
|
||||||
expand_path, normalize_model_name_for_provider,
|
expand_path, normalize_model_name_for_provider,
|
||||||
};
|
};
|
||||||
@@ -152,6 +153,7 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult {
|
|||||||
};
|
};
|
||||||
Some(config.deepseek_base_url())
|
Some(config.deepseek_base_url())
|
||||||
}
|
}
|
||||||
|
"stream_chunk_timeout_secs" => Some(app.stream_chunk_timeout_secs.to_string()),
|
||||||
"locale" | "language" => Some(locale_display(app.ui_locale).to_string()),
|
"locale" | "language" => Some(locale_display(app.ui_locale).to_string()),
|
||||||
"theme" | "ui_theme" => {
|
"theme" | "ui_theme" => {
|
||||||
Some(crate::palette::theme_label_for_mode(app.ui_theme.mode).to_string())
|
Some(crate::palette::theme_label_for_mode(app.ui_theme.mode).to_string())
|
||||||
@@ -417,6 +419,45 @@ fn persist_root_bool_key(
|
|||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn persist_tui_integer_key(
|
||||||
|
config_path: Option<&Path>,
|
||||||
|
key: &str,
|
||||||
|
value: u64,
|
||||||
|
) -> anyhow::Result<PathBuf> {
|
||||||
|
use anyhow::Context;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let path = config_toml_path(config_path)?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("failed to create config directory {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut doc: toml::Value = if path.exists() {
|
||||||
|
let raw = fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("failed to read config at {}", path.display()))?;
|
||||||
|
toml::from_str(&raw)
|
||||||
|
.with_context(|| format!("failed to parse config at {}", path.display()))?
|
||||||
|
} else {
|
||||||
|
toml::Value::Table(toml::value::Table::new())
|
||||||
|
};
|
||||||
|
let table = doc
|
||||||
|
.as_table_mut()
|
||||||
|
.context("config.toml root must be a table")?;
|
||||||
|
let tui_entry = table
|
||||||
|
.entry("tui".to_string())
|
||||||
|
.or_insert_with(|| toml::Value::Table(toml::value::Table::new()));
|
||||||
|
let tui_table = tui_entry
|
||||||
|
.as_table_mut()
|
||||||
|
.context("`tui` section in config.toml must be a table")?;
|
||||||
|
let value = i64::try_from(value).context("integer value is too large for TOML")?;
|
||||||
|
tui_table.insert(key.to_string(), toml::Value::Integer(value));
|
||||||
|
let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?;
|
||||||
|
fs::write(&path, body)
|
||||||
|
.with_context(|| format!("failed to write config at {}", path.display()))?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
fn persist_provider_base_url_key(
|
fn persist_provider_base_url_key(
|
||||||
config_path: Option<&Path>,
|
config_path: Option<&Path>,
|
||||||
provider: ApiProvider,
|
provider: ApiProvider,
|
||||||
@@ -525,6 +566,14 @@ fn parse_config_bool(value: &str) -> Result<bool, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn stream_chunk_timeout_value_label(raw: u64, resolved: u64) -> String {
|
||||||
|
if raw == 0 {
|
||||||
|
format!("0 (default {resolved})")
|
||||||
|
} else {
|
||||||
|
resolved.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve the path to `~/.codewhale/config.toml` (or
|
/// Resolve the path to `~/.codewhale/config.toml` (or
|
||||||
/// `$CODEWHALE_CONFIG_PATH` / `$DEEPSEEK_CONFIG_PATH`). Mirrors what `Config::load` accepts so we
|
/// `$CODEWHALE_CONFIG_PATH` / `$DEEPSEEK_CONFIG_PATH`). Mirrors what `Config::load` accepts so we
|
||||||
/// never write to a different file than the one we read.
|
/// never write to a different file than the one we read.
|
||||||
@@ -729,6 +778,55 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
|
|||||||
"provider_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving.",
|
"provider_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
"stream_chunk_timeout_secs" => {
|
||||||
|
let raw = match value.trim().parse::<u64>() {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => {
|
||||||
|
return CommandResult::error(
|
||||||
|
"stream_chunk_timeout_secs must be a whole number",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if raw != 0
|
||||||
|
&& !(MIN_STREAM_CHUNK_TIMEOUT_SECS..=MAX_STREAM_CHUNK_TIMEOUT_SECS).contains(&raw)
|
||||||
|
{
|
||||||
|
return CommandResult::error(format!(
|
||||||
|
"stream_chunk_timeout_secs must be 0 or {}..={}",
|
||||||
|
MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let resolved = if raw == 0 {
|
||||||
|
DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
|
||||||
|
} else {
|
||||||
|
raw
|
||||||
|
};
|
||||||
|
app.stream_chunk_timeout_secs = resolved;
|
||||||
|
let value_label = stream_chunk_timeout_value_label(raw, resolved);
|
||||||
|
if persist {
|
||||||
|
match persist_tui_integer_key(
|
||||||
|
app.config_path.as_deref(),
|
||||||
|
"stream_chunk_timeout_secs",
|
||||||
|
raw,
|
||||||
|
) {
|
||||||
|
Ok(path) => {
|
||||||
|
return CommandResult::with_message_and_action(
|
||||||
|
format!(
|
||||||
|
"stream_chunk_timeout_secs = {value_label} (saved to {}; affects subsequent turns in this session)",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
AppAction::UpdateStreamChunkTimeout(resolved),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CommandResult::with_message_and_action(
|
||||||
|
format!(
|
||||||
|
"stream_chunk_timeout_secs = {value_label} (session only; affects subsequent turns in this session)"
|
||||||
|
),
|
||||||
|
AppAction::UpdateStreamChunkTimeout(resolved),
|
||||||
|
);
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2371,6 +2469,112 @@ mod tests {
|
|||||||
assert!(saved.contains("base_url = \"https://example.session.local/v1\""));
|
assert!(saved.contains("base_url = \"https://example.session.local/v1\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_command_stream_chunk_timeout_session_query_uses_live_value() {
|
||||||
|
let _lock = lock_test_env();
|
||||||
|
let mut app = create_test_app();
|
||||||
|
|
||||||
|
let result = config_command(&mut app, Some("stream_chunk_timeout_secs 90"));
|
||||||
|
assert!(!result.is_error);
|
||||||
|
assert_eq!(app.stream_chunk_timeout_secs, 90);
|
||||||
|
assert!(matches!(
|
||||||
|
result.action,
|
||||||
|
Some(AppAction::UpdateStreamChunkTimeout(90))
|
||||||
|
));
|
||||||
|
|
||||||
|
let query = config_command(&mut app, Some("stream_chunk_timeout_secs"));
|
||||||
|
assert_eq!(
|
||||||
|
query.message.as_deref(),
|
||||||
|
Some("stream_chunk_timeout_secs = 90")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_command_stream_chunk_timeout_save_persists_tui_key() {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let temp_root = env::temp_dir().join(format!(
|
||||||
|
"codewhale-tui-stream-timeout-test-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
nanos
|
||||||
|
));
|
||||||
|
fs::create_dir_all(&temp_root).unwrap();
|
||||||
|
let _guard = EnvGuard::new(&temp_root);
|
||||||
|
|
||||||
|
let config_path = temp_root.join("custom-config.toml");
|
||||||
|
let mut app = create_test_app();
|
||||||
|
app.config_path = Some(config_path.clone());
|
||||||
|
|
||||||
|
let result = config_command(&mut app, Some("stream_chunk_timeout_secs 120 --save"));
|
||||||
|
let msg = result.message.unwrap();
|
||||||
|
let saved = fs::read_to_string(&config_path).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
msg,
|
||||||
|
format!(
|
||||||
|
"stream_chunk_timeout_secs = 120 (saved to {}; affects subsequent turns in this session)",
|
||||||
|
config_path.display()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert!(saved.contains("[tui]"));
|
||||||
|
assert!(saved.contains("stream_chunk_timeout_secs = 120"));
|
||||||
|
assert_eq!(app.stream_chunk_timeout_secs, 120);
|
||||||
|
assert!(matches!(
|
||||||
|
result.action,
|
||||||
|
Some(AppAction::UpdateStreamChunkTimeout(120))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_command_stream_chunk_timeout_rejects_invalid_input() {
|
||||||
|
let _lock = lock_test_env();
|
||||||
|
let mut app = create_test_app();
|
||||||
|
|
||||||
|
let text = config_command(&mut app, Some("stream_chunk_timeout_secs abc"));
|
||||||
|
assert!(text.is_error);
|
||||||
|
assert!(
|
||||||
|
text.message
|
||||||
|
.unwrap()
|
||||||
|
.contains("stream_chunk_timeout_secs must be a whole number")
|
||||||
|
);
|
||||||
|
|
||||||
|
let high = config_command(&mut app, Some("stream_chunk_timeout_secs 3601"));
|
||||||
|
assert!(high.is_error);
|
||||||
|
assert!(
|
||||||
|
high.message
|
||||||
|
.unwrap()
|
||||||
|
.contains("stream_chunk_timeout_secs must be 0 or 1..=3600")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_command_stream_chunk_timeout_zero_reports_effective_default() {
|
||||||
|
let _lock = lock_test_env();
|
||||||
|
let mut app = create_test_app();
|
||||||
|
|
||||||
|
let result = config_command(&mut app, Some("stream_chunk_timeout_secs 0"));
|
||||||
|
|
||||||
|
assert!(!result.is_error);
|
||||||
|
assert_eq!(
|
||||||
|
app.stream_chunk_timeout_secs,
|
||||||
|
DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result.message.as_deref(),
|
||||||
|
Some(
|
||||||
|
"stream_chunk_timeout_secs = 0 (default 300) (session only; affects subsequent turns in this session)"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
result.action,
|
||||||
|
Some(AppAction::UpdateStreamChunkTimeout(
|
||||||
|
DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
|
||||||
|
))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn config_command_provider_url_token_plan_persists_provider_base_url() {
|
fn config_command_provider_url_token_plan_persists_provider_base_url() {
|
||||||
let temp_root = env::temp_dir().join(format!(
|
let temp_root = env::temp_dir().join(format!(
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ pub const DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS: u64 = 300;
|
|||||||
pub const MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS: u64 = 30;
|
pub const MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS: u64 = 30;
|
||||||
/// Maximum accepted `[subagents] heartbeat_timeout_secs` (1 hour).
|
/// Maximum accepted `[subagents] heartbeat_timeout_secs` (1 hour).
|
||||||
pub const MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS: u64 = 3600;
|
pub const MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS: u64 = 3600;
|
||||||
|
/// Default per-SSE-chunk idle timeout, in seconds.
|
||||||
|
pub const DEFAULT_STREAM_CHUNK_TIMEOUT_SECS: u64 = 300;
|
||||||
|
/// Minimum accepted stream chunk timeout.
|
||||||
|
pub const MIN_STREAM_CHUNK_TIMEOUT_SECS: u64 = 1;
|
||||||
|
/// Maximum accepted stream chunk timeout.
|
||||||
|
pub const MAX_STREAM_CHUNK_TIMEOUT_SECS: u64 = 3600;
|
||||||
|
pub(crate) const STREAM_CHUNK_TIMEOUT_ENV: &str = "DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS";
|
||||||
pub const DEFAULT_TEXT_MODEL: &str = "deepseek-v4-pro";
|
pub const DEFAULT_TEXT_MODEL: &str = "deepseek-v4-pro";
|
||||||
pub const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta";
|
pub const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta";
|
||||||
pub const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro";
|
pub const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro";
|
||||||
@@ -835,6 +842,9 @@ pub struct TuiConfig {
|
|||||||
/// Timeout for startup terminal mode/probe calls in milliseconds.
|
/// Timeout for startup terminal mode/probe calls in milliseconds.
|
||||||
/// Defaults to 500ms when omitted.
|
/// Defaults to 500ms when omitted.
|
||||||
pub terminal_probe_timeout_ms: Option<u64>,
|
pub terminal_probe_timeout_ms: Option<u64>,
|
||||||
|
/// Per-SSE-chunk idle timeout in seconds. Defaults to 300 seconds when
|
||||||
|
/// omitted. `0` maps to the default; values clamp to `1..=3600`.
|
||||||
|
pub stream_chunk_timeout_secs: Option<u64>,
|
||||||
/// Ordered list of footer items the user wants visible. `None` (the field
|
/// Ordered list of footer items the user wants visible. `None` (the field
|
||||||
/// missing from `config.toml`) means "use the built-in default order"; an
|
/// missing from `config.toml`) means "use the built-in default order"; an
|
||||||
/// empty `Some(vec![])` means "show nothing in the footer".
|
/// empty `Some(vec![])` means "show nothing in the footer".
|
||||||
@@ -2784,6 +2794,30 @@ impl Config {
|
|||||||
configured.max(min_for_api)
|
configured.max(min_for_api)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolved per-SSE-chunk idle timeout in seconds.
|
||||||
|
///
|
||||||
|
/// Reads `[tui].stream_chunk_timeout_secs`, falling back to the legacy
|
||||||
|
/// `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var when the config key is
|
||||||
|
/// omitted. `None` or `0` resolve to the default 300 seconds; explicit
|
||||||
|
/// values are clamped to `1..=3600`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn stream_chunk_timeout_secs(&self) -> u64 {
|
||||||
|
let raw = self
|
||||||
|
.tui
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|cfg| cfg.stream_chunk_timeout_secs)
|
||||||
|
.or_else(|| {
|
||||||
|
std::env::var(STREAM_CHUNK_TIMEOUT_ENV)
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.parse::<u64>().ok())
|
||||||
|
})
|
||||||
|
.unwrap_or(DEFAULT_STREAM_CHUNK_TIMEOUT_SECS);
|
||||||
|
if raw == 0 {
|
||||||
|
return DEFAULT_STREAM_CHUNK_TIMEOUT_SECS;
|
||||||
|
}
|
||||||
|
raw.clamp(MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS)
|
||||||
|
}
|
||||||
|
|
||||||
/// Raw sub-agent model override map. Values are validated at spawn time
|
/// Raw sub-agent model override map. Values are validated at spawn time
|
||||||
/// so an invalid role/type model fails before any partial agent spawn.
|
/// so an invalid role/type model fails before any partial agent spawn.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -6417,6 +6451,76 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tui_stream_chunk_timeout_defaults_env_and_clamps() {
|
||||||
|
let _lock = lock_test_env();
|
||||||
|
let previous = env::var_os(STREAM_CHUNK_TIMEOUT_ENV);
|
||||||
|
unsafe {
|
||||||
|
env::remove_var(STREAM_CHUNK_TIMEOUT_ENV);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Config::default().stream_chunk_timeout_secs(),
|
||||||
|
DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
|
||||||
|
);
|
||||||
|
|
||||||
|
let zero = Config {
|
||||||
|
tui: Some(TuiConfig {
|
||||||
|
stream_chunk_timeout_secs: Some(0),
|
||||||
|
..TuiConfig::default()
|
||||||
|
}),
|
||||||
|
..Config::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
zero.stream_chunk_timeout_secs(),
|
||||||
|
DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
|
||||||
|
);
|
||||||
|
|
||||||
|
let explicit_min = Config {
|
||||||
|
tui: Some(TuiConfig {
|
||||||
|
stream_chunk_timeout_secs: Some(MIN_STREAM_CHUNK_TIMEOUT_SECS),
|
||||||
|
..TuiConfig::default()
|
||||||
|
}),
|
||||||
|
..Config::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
explicit_min.stream_chunk_timeout_secs(),
|
||||||
|
MIN_STREAM_CHUNK_TIMEOUT_SECS
|
||||||
|
);
|
||||||
|
|
||||||
|
let high = Config {
|
||||||
|
tui: Some(TuiConfig {
|
||||||
|
stream_chunk_timeout_secs: Some(MAX_STREAM_CHUNK_TIMEOUT_SECS + 1),
|
||||||
|
..TuiConfig::default()
|
||||||
|
}),
|
||||||
|
..Config::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
high.stream_chunk_timeout_secs(),
|
||||||
|
MAX_STREAM_CHUNK_TIMEOUT_SECS
|
||||||
|
);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
env::set_var(STREAM_CHUNK_TIMEOUT_ENV, "123");
|
||||||
|
}
|
||||||
|
assert_eq!(Config::default().stream_chunk_timeout_secs(), 123);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
env::set_var(STREAM_CHUNK_TIMEOUT_ENV, "0");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
Config::default().stream_chunk_timeout_secs(),
|
||||||
|
DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
|
||||||
|
);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
match previous {
|
||||||
|
Some(value) => env::set_var(STREAM_CHUNK_TIMEOUT_ENV, value),
|
||||||
|
None => env::remove_var(STREAM_CHUNK_TIMEOUT_ENV),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {
|
fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> {
|
||||||
// `save_api_key` writes to the shared user config file. This
|
// `save_api_key` writes to the shared user config file. This
|
||||||
|
|||||||
@@ -351,6 +351,10 @@ pub struct EngineConfig {
|
|||||||
/// once at engine construction, then threaded onto every
|
/// once at engine construction, then threaded onto every
|
||||||
/// `SubAgentRuntime` the engine builds (#1806, #1808).
|
/// `SubAgentRuntime` the engine builds (#1806, #1808).
|
||||||
pub subagent_api_timeout: Duration,
|
pub subagent_api_timeout: Duration,
|
||||||
|
/// Per-SSE-chunk idle timeout for streamed model responses.
|
||||||
|
/// Resolved from `[tui].stream_chunk_timeout_secs` (or the legacy
|
||||||
|
/// `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS`) and updated live by `/config`.
|
||||||
|
pub stream_chunk_timeout: Duration,
|
||||||
/// No-progress heartbeat timeout for live sub-agents. Used by the manager
|
/// No-progress heartbeat timeout for live sub-agents. Used by the manager
|
||||||
/// and parent wait loop to auto-cancel stuck children before they exhaust
|
/// and parent wait loop to auto-cancel stuck children before they exhaust
|
||||||
/// the sub-agent slot pool indefinitely (#2614).
|
/// the sub-agent slot pool indefinitely (#2614).
|
||||||
@@ -414,6 +418,9 @@ impl Default for EngineConfig {
|
|||||||
subagent_api_timeout: Duration::from_secs(
|
subagent_api_timeout: Duration::from_secs(
|
||||||
crate::config::DEFAULT_SUBAGENT_API_TIMEOUT_SECS,
|
crate::config::DEFAULT_SUBAGENT_API_TIMEOUT_SECS,
|
||||||
),
|
),
|
||||||
|
stream_chunk_timeout: Duration::from_secs(
|
||||||
|
crate::config::DEFAULT_STREAM_CHUNK_TIMEOUT_SECS,
|
||||||
|
),
|
||||||
subagent_heartbeat_timeout: Duration::from_secs(
|
subagent_heartbeat_timeout: Duration::from_secs(
|
||||||
crate::config::DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS,
|
crate::config::DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS,
|
||||||
),
|
),
|
||||||
@@ -1271,6 +1278,15 @@ impl Engine {
|
|||||||
)))
|
)))
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
Op::SetStreamChunkTimeout { timeout_secs } => {
|
||||||
|
self.config.stream_chunk_timeout = Duration::from_secs(timeout_secs);
|
||||||
|
let _ = self
|
||||||
|
.tx_event
|
||||||
|
.send(Event::status(format!(
|
||||||
|
"Stream chunk timeout set to {timeout_secs}s"
|
||||||
|
)))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
Op::SyncSession {
|
Op::SyncSession {
|
||||||
session_id,
|
session_id,
|
||||||
messages,
|
messages,
|
||||||
@@ -2745,7 +2761,7 @@ use self::streaming::{
|
|||||||
ContentBlockKind, FAKE_WRAPPER_NOTICE, MAX_STREAM_ERRORS_BEFORE_FAIL,
|
ContentBlockKind, FAKE_WRAPPER_NOTICE, MAX_STREAM_ERRORS_BEFORE_FAIL,
|
||||||
MAX_TRANSPARENT_STREAM_RETRIES, STREAM_MAX_CONTENT_BYTES, STREAM_MAX_DURATION_SECS,
|
MAX_TRANSPARENT_STREAM_RETRIES, STREAM_MAX_CONTENT_BYTES, STREAM_MAX_DURATION_SECS,
|
||||||
ToolUseState, contains_fake_tool_wrapper, filter_tool_call_delta,
|
ToolUseState, contains_fake_tool_wrapper, filter_tool_call_delta,
|
||||||
should_transparently_retry_stream, stream_chunk_timeout_secs,
|
should_transparently_retry_stream,
|
||||||
};
|
};
|
||||||
use self::tool_catalog::{
|
use self::tool_catalog::{
|
||||||
CODE_EXECUTION_TOOL_NAME, JS_EXECUTION_TOOL_NAME, MULTI_TOOL_PARALLEL_NAME,
|
CODE_EXECUTION_TOOL_NAME, JS_EXECUTION_TOOL_NAME, MULTI_TOOL_PARALLEL_NAME,
|
||||||
|
|||||||
@@ -22,26 +22,6 @@ pub(super) struct ToolUseState {
|
|||||||
pub(super) input_buffer: String,
|
pub(super) input_buffer: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default maximum time to wait for a single stream chunk before assuming a stall.
|
|
||||||
/// **This is the idle timeout** — it resets on every SSE chunk, so long
|
|
||||||
/// thinking turns that ARE producing reasoning_content stay alive. Only a
|
|
||||||
/// genuine `chunk_timeout` window of silence kills the stream.
|
|
||||||
const DEFAULT_STREAM_CHUNK_TIMEOUT_SECS: u64 = 300;
|
|
||||||
const MIN_STREAM_CHUNK_TIMEOUT_SECS: u64 = 1;
|
|
||||||
const MAX_STREAM_CHUNK_TIMEOUT_SECS: u64 = 3600;
|
|
||||||
const STREAM_IDLE_TIMEOUT_ENV: &str = "DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS";
|
|
||||||
|
|
||||||
/// Reads the shared stream idle-timeout override used by the SSE client.
|
|
||||||
pub(super) fn stream_chunk_timeout_secs() -> u64 {
|
|
||||||
stream_chunk_timeout_secs_from_env(std::env::var(STREAM_IDLE_TIMEOUT_ENV).ok().as_deref())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stream_chunk_timeout_secs_from_env(value: Option<&str>) -> u64 {
|
|
||||||
value
|
|
||||||
.and_then(|v| v.parse::<u64>().ok())
|
|
||||||
.unwrap_or(DEFAULT_STREAM_CHUNK_TIMEOUT_SECS)
|
|
||||||
.clamp(MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS)
|
|
||||||
}
|
|
||||||
/// Maximum total bytes of text/thinking content before aborting the stream.
|
/// Maximum total bytes of text/thinking content before aborting the stream.
|
||||||
pub(super) const STREAM_MAX_CONTENT_BYTES: usize = 10 * 1024 * 1024; // 10 MB
|
pub(super) const STREAM_MAX_CONTENT_BYTES: usize = 10 * 1024 * 1024; // 10 MB
|
||||||
/// Sanity backstop for total stream wall-clock duration. **Not** a routine
|
/// Sanity backstop for total stream wall-clock duration. **Not** a routine
|
||||||
@@ -150,20 +130,3 @@ pub(crate) fn filter_tool_call_delta(delta: &str, in_tool_call: &mut bool) -> St
|
|||||||
|
|
||||||
output
|
output
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stream_chunk_timeout_defaults_and_clamps_env_values() {
|
|
||||||
assert_eq!(stream_chunk_timeout_secs_from_env(None), 300);
|
|
||||||
assert_eq!(
|
|
||||||
stream_chunk_timeout_secs_from_env(Some("not-a-number")),
|
|
||||||
300
|
|
||||||
);
|
|
||||||
assert_eq!(stream_chunk_timeout_secs_from_env(Some("0")), 1);
|
|
||||||
assert_eq!(stream_chunk_timeout_secs_from_env(Some("90")), 90);
|
|
||||||
assert_eq!(stream_chunk_timeout_secs_from_env(Some("99999")), 3600);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -469,8 +469,7 @@ impl Engine {
|
|||||||
// budget restarts with the fresh stream.
|
// budget restarts with the fresh stream.
|
||||||
let mut stream_start = Instant::now();
|
let mut stream_start = Instant::now();
|
||||||
let mut stream_content_bytes: usize = 0;
|
let mut stream_content_bytes: usize = 0;
|
||||||
let chunk_timeout_secs = stream_chunk_timeout_secs();
|
let (chunk_timeout_secs, chunk_timeout) = stream_chunk_timeout_budget(&self.config);
|
||||||
let chunk_timeout = Duration::from_secs(chunk_timeout_secs);
|
|
||||||
let max_duration = Duration::from_secs(STREAM_MAX_DURATION_SECS);
|
let max_duration = Duration::from_secs(STREAM_MAX_DURATION_SECS);
|
||||||
|
|
||||||
// Process stream events
|
// Process stream events
|
||||||
@@ -2293,6 +2292,29 @@ fn should_hold_turn_for_subagents(queued_completions: usize, running_children: u
|
|||||||
queued_completions > 0 || running_children > 0
|
queued_completions > 0 || running_children > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn stream_chunk_timeout_budget(config: &EngineConfig) -> (u64, Duration) {
|
||||||
|
let secs = config.stream_chunk_timeout.as_secs();
|
||||||
|
(secs, Duration::from_secs(secs))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod stream_timeout_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_chunk_timeout_budget_uses_engine_config() {
|
||||||
|
let config = EngineConfig {
|
||||||
|
stream_chunk_timeout: Duration::from_secs(42),
|
||||||
|
..EngineConfig::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
stream_chunk_timeout_budget(&config),
|
||||||
|
(42, Duration::from_secs(42))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn command_allows_tool(allowed_tools: Option<&[String]>, tool_name: &str) -> bool {
|
fn command_allows_tool(allowed_tools: Option<&[String]>, tool_name: &str) -> bool {
|
||||||
let Some(allowed_tools) = allowed_tools else {
|
let Some(allowed_tools) = allowed_tools else {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ pub enum Op {
|
|||||||
/// Update auto-compaction settings
|
/// Update auto-compaction settings
|
||||||
SetCompaction { config: CompactionConfig },
|
SetCompaction { config: CompactionConfig },
|
||||||
|
|
||||||
|
/// Update the SSE idle timeout used for subsequent streamed turns.
|
||||||
|
SetStreamChunkTimeout { timeout_secs: u64 },
|
||||||
|
|
||||||
/// Sync engine session state (used for resume/load)
|
/// Sync engine session state (used for resume/load)
|
||||||
SyncSession {
|
SyncSession {
|
||||||
session_id: Option<String>,
|
session_id: Option<String>,
|
||||||
|
|||||||
@@ -5765,6 +5765,7 @@ async fn run_exec_agent(
|
|||||||
runtime_services: crate::tools::spec::RuntimeToolServices::default(),
|
runtime_services: crate::tools::spec::RuntimeToolServices::default(),
|
||||||
subagent_model_overrides: config.subagent_model_overrides(),
|
subagent_model_overrides: config.subagent_model_overrides(),
|
||||||
subagent_api_timeout: std::time::Duration::from_secs(config.subagent_api_timeout_secs()),
|
subagent_api_timeout: std::time::Duration::from_secs(config.subagent_api_timeout_secs()),
|
||||||
|
stream_chunk_timeout: std::time::Duration::from_secs(config.stream_chunk_timeout_secs()),
|
||||||
subagent_heartbeat_timeout: std::time::Duration::from_secs(
|
subagent_heartbeat_timeout: std::time::Duration::from_secs(
|
||||||
config.subagent_heartbeat_timeout_secs(),
|
config.subagent_heartbeat_timeout_secs(),
|
||||||
),
|
),
|
||||||
@@ -6743,6 +6744,7 @@ mod terminal_mode_tests {
|
|||||||
alternate_screen: Some("never".to_string()),
|
alternate_screen: Some("never".to_string()),
|
||||||
mouse_capture: None,
|
mouse_capture: None,
|
||||||
terminal_probe_timeout_ms: None,
|
terminal_probe_timeout_ms: None,
|
||||||
|
stream_chunk_timeout_secs: None,
|
||||||
status_items: None,
|
status_items: None,
|
||||||
osc8_links: None,
|
osc8_links: None,
|
||||||
composer_arrows_scroll: None,
|
composer_arrows_scroll: None,
|
||||||
@@ -6836,6 +6838,7 @@ mod terminal_mode_tests {
|
|||||||
alternate_screen: None,
|
alternate_screen: None,
|
||||||
mouse_capture: Some(false),
|
mouse_capture: Some(false),
|
||||||
terminal_probe_timeout_ms: None,
|
terminal_probe_timeout_ms: None,
|
||||||
|
stream_chunk_timeout_secs: None,
|
||||||
status_items: None,
|
status_items: None,
|
||||||
osc8_links: None,
|
osc8_links: None,
|
||||||
composer_arrows_scroll: None,
|
composer_arrows_scroll: None,
|
||||||
@@ -6867,6 +6870,7 @@ mod terminal_mode_tests {
|
|||||||
alternate_screen: None,
|
alternate_screen: None,
|
||||||
mouse_capture: Some(true),
|
mouse_capture: Some(true),
|
||||||
terminal_probe_timeout_ms: None,
|
terminal_probe_timeout_ms: None,
|
||||||
|
stream_chunk_timeout_secs: None,
|
||||||
status_items: None,
|
status_items: None,
|
||||||
osc8_links: None,
|
osc8_links: None,
|
||||||
composer_arrows_scroll: None,
|
composer_arrows_scroll: None,
|
||||||
@@ -6952,6 +6956,7 @@ mod terminal_mode_tests {
|
|||||||
alternate_screen: None,
|
alternate_screen: None,
|
||||||
mouse_capture: Some(true),
|
mouse_capture: Some(true),
|
||||||
terminal_probe_timeout_ms: None,
|
terminal_probe_timeout_ms: None,
|
||||||
|
stream_chunk_timeout_secs: None,
|
||||||
status_items: None,
|
status_items: None,
|
||||||
osc8_links: None,
|
osc8_links: None,
|
||||||
composer_arrows_scroll: None,
|
composer_arrows_scroll: None,
|
||||||
|
|||||||
@@ -2065,6 +2065,9 @@ impl RuntimeThreadManager {
|
|||||||
subagent_api_timeout: std::time::Duration::from_secs(
|
subagent_api_timeout: std::time::Duration::from_secs(
|
||||||
self.config.subagent_api_timeout_secs(),
|
self.config.subagent_api_timeout_secs(),
|
||||||
),
|
),
|
||||||
|
stream_chunk_timeout: std::time::Duration::from_secs(
|
||||||
|
self.config.stream_chunk_timeout_secs(),
|
||||||
|
),
|
||||||
subagent_heartbeat_timeout: std::time::Duration::from_secs(
|
subagent_heartbeat_timeout: std::time::Duration::from_secs(
|
||||||
self.config.subagent_heartbeat_timeout_secs(),
|
self.config.subagent_heartbeat_timeout_secs(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1407,6 +1407,8 @@ pub struct App {
|
|||||||
pub max_input_history: usize,
|
pub max_input_history: usize,
|
||||||
pub allow_shell: bool,
|
pub allow_shell: bool,
|
||||||
pub max_subagents: usize,
|
pub max_subagents: usize,
|
||||||
|
/// Per-SSE-chunk idle timeout for streamed turns, in seconds.
|
||||||
|
pub stream_chunk_timeout_secs: u64,
|
||||||
/// Cached sub-agent snapshots for UI views.
|
/// Cached sub-agent snapshots for UI views.
|
||||||
pub subagent_cache: Vec<SubAgentResult>,
|
pub subagent_cache: Vec<SubAgentResult>,
|
||||||
/// Last known per-agent progress text for running sub-agents.
|
/// Last known per-agent progress text for running sub-agents.
|
||||||
@@ -2136,6 +2138,7 @@ impl App {
|
|||||||
max_input_history,
|
max_input_history,
|
||||||
allow_shell,
|
allow_shell,
|
||||||
max_subagents,
|
max_subagents,
|
||||||
|
stream_chunk_timeout_secs: config.stream_chunk_timeout_secs(),
|
||||||
subagent_cache: Vec::new(),
|
subagent_cache: Vec::new(),
|
||||||
agent_progress: HashMap::new(),
|
agent_progress: HashMap::new(),
|
||||||
subagent_card_index: HashMap::new(),
|
subagent_card_index: HashMap::new(),
|
||||||
@@ -5047,6 +5050,7 @@ pub enum AppAction {
|
|||||||
model: Option<String>,
|
model: Option<String>,
|
||||||
},
|
},
|
||||||
UpdateCompaction(CompactionConfig),
|
UpdateCompaction(CompactionConfig),
|
||||||
|
UpdateStreamChunkTimeout(u64),
|
||||||
OpenContextInspector,
|
OpenContextInspector,
|
||||||
CompactContext,
|
CompactContext,
|
||||||
PurgeContext,
|
PurgeContext,
|
||||||
|
|||||||
@@ -908,6 +908,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
|
|||||||
runtime_services: app.runtime_services.clone(),
|
runtime_services: app.runtime_services.clone(),
|
||||||
subagent_model_overrides: config.subagent_model_overrides(),
|
subagent_model_overrides: config.subagent_model_overrides(),
|
||||||
subagent_api_timeout: Duration::from_secs(config.subagent_api_timeout_secs()),
|
subagent_api_timeout: Duration::from_secs(config.subagent_api_timeout_secs()),
|
||||||
|
stream_chunk_timeout: Duration::from_secs(app.stream_chunk_timeout_secs),
|
||||||
subagent_heartbeat_timeout: Duration::from_secs(config.subagent_heartbeat_timeout_secs()),
|
subagent_heartbeat_timeout: Duration::from_secs(config.subagent_heartbeat_timeout_secs()),
|
||||||
prefer_bwrap: config.prefer_bwrap.unwrap_or(false),
|
prefer_bwrap: config.prefer_bwrap.unwrap_or(false),
|
||||||
memory_enabled: config.memory_enabled(),
|
memory_enabled: config.memory_enabled(),
|
||||||
@@ -5804,6 +5805,11 @@ async fn apply_command_result(
|
|||||||
AppAction::UpdateCompaction(compaction) => {
|
AppAction::UpdateCompaction(compaction) => {
|
||||||
apply_model_and_compaction_update(engine_handle, compaction, app.mode).await;
|
apply_model_and_compaction_update(engine_handle, compaction, app.mode).await;
|
||||||
}
|
}
|
||||||
|
AppAction::UpdateStreamChunkTimeout(timeout_secs) => {
|
||||||
|
let _ = engine_handle
|
||||||
|
.send(Op::SetStreamChunkTimeout { timeout_secs })
|
||||||
|
.await;
|
||||||
|
}
|
||||||
AppAction::OpenConfigEditor(mode) => match mode {
|
AppAction::OpenConfigEditor(mode) => match mode {
|
||||||
ConfigUiMode::Native => {
|
ConfigUiMode::Native => {
|
||||||
if app.view_stack.top_kind() != Some(ModalKind::Config) {
|
if app.view_stack.top_kind() != Some(ModalKind::Config) {
|
||||||
@@ -7407,6 +7413,11 @@ async fn handle_view_events(
|
|||||||
apply_model_and_compaction_update(engine_handle, compaction, app.mode)
|
apply_model_and_compaction_update(engine_handle, compaction, app.mode)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
AppAction::UpdateStreamChunkTimeout(timeout_secs) => {
|
||||||
|
let _ = engine_handle
|
||||||
|
.send(Op::SetStreamChunkTimeout { timeout_secs })
|
||||||
|
.await;
|
||||||
|
}
|
||||||
AppAction::OpenConfigView => {}
|
AppAction::OpenConfigView => {}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1937,6 +1937,7 @@ fn terminal_probe_timeout_uses_tui_config_and_clamps() {
|
|||||||
alternate_screen: None,
|
alternate_screen: None,
|
||||||
mouse_capture: None,
|
mouse_capture: None,
|
||||||
terminal_probe_timeout_ms: Some(750),
|
terminal_probe_timeout_ms: Some(750),
|
||||||
|
stream_chunk_timeout_secs: None,
|
||||||
status_items: None,
|
status_items: None,
|
||||||
osc8_links: None,
|
osc8_links: None,
|
||||||
notification_condition: None,
|
notification_condition: None,
|
||||||
|
|||||||
@@ -532,6 +532,7 @@ enum ConfigSection {
|
|||||||
Provider,
|
Provider,
|
||||||
Model,
|
Model,
|
||||||
Permissions,
|
Permissions,
|
||||||
|
Network,
|
||||||
Display,
|
Display,
|
||||||
Composer,
|
Composer,
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@@ -545,6 +546,7 @@ impl ConfigSection {
|
|||||||
ConfigSection::Provider => "Provider",
|
ConfigSection::Provider => "Provider",
|
||||||
ConfigSection::Model => "Model",
|
ConfigSection::Model => "Model",
|
||||||
ConfigSection::Permissions => "Permissions",
|
ConfigSection::Permissions => "Permissions",
|
||||||
|
ConfigSection::Network => "Network",
|
||||||
ConfigSection::Display => "Display",
|
ConfigSection::Display => "Display",
|
||||||
ConfigSection::Composer => "Composer",
|
ConfigSection::Composer => "Composer",
|
||||||
ConfigSection::Sidebar => "Sidebar",
|
ConfigSection::Sidebar => "Sidebar",
|
||||||
@@ -658,6 +660,13 @@ impl ConfigView {
|
|||||||
editable: true,
|
editable: true,
|
||||||
scope: ConfigScope::Saved,
|
scope: ConfigScope::Saved,
|
||||||
},
|
},
|
||||||
|
ConfigRow {
|
||||||
|
section: ConfigSection::Network,
|
||||||
|
key: "stream_chunk_timeout_secs".to_string(),
|
||||||
|
value: app.stream_chunk_timeout_secs.to_string(),
|
||||||
|
editable: true,
|
||||||
|
scope: ConfigScope::Session,
|
||||||
|
},
|
||||||
ConfigRow {
|
ConfigRow {
|
||||||
section: ConfigSection::Display,
|
section: ConfigSection::Display,
|
||||||
key: "theme".to_string(),
|
key: "theme".to_string(),
|
||||||
@@ -2371,6 +2380,7 @@ mod tests {
|
|||||||
ConfigSection::Provider.label(),
|
ConfigSection::Provider.label(),
|
||||||
ConfigSection::Model.label(),
|
ConfigSection::Model.label(),
|
||||||
ConfigSection::Permissions.label(),
|
ConfigSection::Permissions.label(),
|
||||||
|
ConfigSection::Network.label(),
|
||||||
ConfigSection::Display.label(),
|
ConfigSection::Display.label(),
|
||||||
ConfigSection::Composer.label(),
|
ConfigSection::Composer.label(),
|
||||||
ConfigSection::Sidebar.label(),
|
ConfigSection::Sidebar.label(),
|
||||||
@@ -2395,6 +2405,7 @@ mod tests {
|
|||||||
assert!(keys.contains(&"base_url"));
|
assert!(keys.contains(&"base_url"));
|
||||||
assert!(keys.contains(&"approval_mode"));
|
assert!(keys.contains(&"approval_mode"));
|
||||||
assert!(keys.contains(&"allow_shell"));
|
assert!(keys.contains(&"allow_shell"));
|
||||||
|
assert!(keys.contains(&"stream_chunk_timeout_secs"));
|
||||||
assert!(keys.contains(&"theme"));
|
assert!(keys.contains(&"theme"));
|
||||||
assert!(keys.contains(&"locale"));
|
assert!(keys.contains(&"locale"));
|
||||||
assert!(keys.contains(&"background_color"));
|
assert!(keys.contains(&"background_color"));
|
||||||
|
|||||||
@@ -966,6 +966,7 @@ If you are upgrading from older releases:
|
|||||||
- `tui.alternate_screen` (string, optional): `auto`, `always`, or `never`. This is retained for config compatibility, but interactive sessions now always use the TUI-owned alternate screen so host terminal scrollback cannot hijack the viewport.
|
- `tui.alternate_screen` (string, optional): `auto`, `always`, or `never`. This is retained for config compatibility, but interactive sessions now always use the TUI-owned alternate screen so host terminal scrollback cannot hijack the viewport.
|
||||||
- `tui.mouse_capture` (bool, optional, default `true` on non-Windows terminals and on Windows Terminal/ConEmu/Cmder when the alternate screen is active; `false` on legacy Windows console and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where mouse-event escapes leak into the input stream as garbled text, see #878 / #898): enable internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. TUI-owned drag selection copies only transcript text, removes visual wrap-column line breaks from paragraphs, and keeps selection scoped to the transcript pane. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection; set it to `true` or run with `--mouse-capture` to opt in anywhere it's defaulted off. On raw terminal selection, especially on legacy Windows console or when mouse capture is disabled, selection may cross the right sidebar and include visual wraps because the terminal, not the TUI, owns the selection.
|
- `tui.mouse_capture` (bool, optional, default `true` on non-Windows terminals and on Windows Terminal/ConEmu/Cmder when the alternate screen is active; `false` on legacy Windows console and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where mouse-event escapes leak into the input stream as garbled text, see #878 / #898): enable internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. TUI-owned drag selection copies only transcript text, removes visual wrap-column line breaks from paragraphs, and keeps selection scoped to the transcript pane. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection; set it to `true` or run with `--mouse-capture` to opt in anywhere it's defaulted off. On raw terminal selection, especially on legacy Windows console or when mouse capture is disabled, selection may cross the right sidebar and include visual wraps because the terminal, not the TUI, owns the selection.
|
||||||
- `tui.terminal_probe_timeout_ms` (int, optional, default `500`): startup terminal-mode probe timeout in milliseconds. Values are clamped to `100..=5000`; timeout emits a warning and aborts startup instead of hanging indefinitely.
|
- `tui.terminal_probe_timeout_ms` (int, optional, default `500`): startup terminal-mode probe timeout in milliseconds. Values are clamped to `100..=5000`; timeout emits a warning and aborts startup instead of hanging indefinitely.
|
||||||
|
- `tui.stream_chunk_timeout_secs` (int, optional, default `300`): per-SSE-chunk idle timeout for streamed model responses. Slow local or compatible servers can raise this with `/config stream_chunk_timeout_secs <seconds>`; `0` maps to the default and explicit values must be `1..=3600`. The legacy `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var is still honored when this key is omitted.
|
||||||
- `tui.osc8_links` (bool, optional, default `true`): emit OSC 8 escape sequences around URLs in transcript output so terminals that support them (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) render them as Cmd+click hyperlinks. Terminals without OSC 8 support render the plain URL and ignore the escape. Set `false` for terminals that misrender the sequence; selection/clipboard output always strips the escapes.
|
- `tui.osc8_links` (bool, optional, default `true`): emit OSC 8 escape sequences around URLs in transcript output so terminals that support them (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) render them as Cmd+click hyperlinks. Terminals without OSC 8 support render the plain URL and ignore the escape. Set `false` for terminals that misrender the sequence; selection/clipboard output always strips the escapes.
|
||||||
- `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`).
|
- `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`).
|
||||||
- `features.*` (optional): feature flag overrides (see below).
|
- `features.*` (optional): feature flag overrides (see below).
|
||||||
|
|||||||
Reference in New Issue
Block a user