mirror of
https://github.com/instructkr/claw-code.git
synced 2026-10-19 00:00:08 +08:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2e5f7c3b2 | ||
|
|
09f3b366ba | ||
|
|
d6b8202738 | ||
|
|
12d8177608 | ||
|
|
5eeaf3b261 | ||
|
|
ceba8f603d | ||
|
|
350e907e35 | ||
|
|
734558eb31 | ||
|
|
942805175a | ||
|
|
dfa1e4f71e | ||
|
|
8a19ecd7cc | ||
|
|
8938f53790 | ||
|
|
5433dac4a2 | ||
|
|
f3a23bf39f | ||
|
|
66006cca34 | ||
|
|
08cce855b5 | ||
|
|
fe71ead55f | ||
|
|
334768249f | ||
|
|
aab02464b2 | ||
|
|
7aa00a2798 | ||
|
|
76fe2e9f46 | ||
|
|
49e796051e | ||
|
|
d2f2b23dc4 | ||
|
|
cfa2f141d3 | ||
|
|
837ffd0f6e | ||
|
|
ab839bcfda | ||
|
|
18c0bead90 | ||
|
|
9fadb16204 | ||
|
|
6f1819a8ee | ||
|
|
c13e71851b | ||
|
|
d4578586eb | ||
|
|
3d8fd5f917 | ||
|
|
1efa72099d | ||
|
|
f58bf20aa3 |
No files matched your search
@@ -13,7 +13,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- GJC-compatible `:conflicts` selector for git merge conflict detection
|
||||
- SDK wire transport with WebSocket support
|
||||
- Nix flake development environment
|
||||
- `/status`, `/cost`, and `/version` slash commands
|
||||
- Persistent provider configuration loading
|
||||
- Pagination support for large read_file results
|
||||
- Comprehensive GJC compatibility documentation and analysis
|
||||
|
||||
Generated
+1
@@ -2268,6 +2268,7 @@ dependencies = [
|
||||
"crossterm",
|
||||
"log",
|
||||
"mock-anthropic-service",
|
||||
"nix",
|
||||
"plugins",
|
||||
"pulldown-cmark",
|
||||
"runtime",
|
||||
|
||||
@@ -15,13 +15,15 @@ pub enum ProviderClient {
|
||||
|
||||
/// Provider selected explicitly by persisted/runtime configuration.
|
||||
///
|
||||
/// This is intentionally separate from `ProviderKind`: DashScope speaks the
|
||||
/// OpenAI-compatible wire protocol but remains a distinct configured provider.
|
||||
/// This is intentionally separate from `ProviderKind`: Custom OpenAI and
|
||||
/// DashScope speak the OpenAI-compatible wire protocol but remain distinct
|
||||
/// configured providers.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderConfigKind {
|
||||
Anthropic,
|
||||
Xai,
|
||||
OpenAi,
|
||||
CustomOpenAi,
|
||||
DashScope,
|
||||
}
|
||||
|
||||
@@ -96,9 +98,12 @@ impl ProviderClient {
|
||||
|
||||
Ok(Self::Xai(client))
|
||||
}
|
||||
ProviderConfigKind::OpenAi | ProviderConfigKind::DashScope => {
|
||||
ProviderConfigKind::OpenAi
|
||||
| ProviderConfigKind::CustomOpenAi
|
||||
| ProviderConfigKind::DashScope => {
|
||||
let compat = match config.kind {
|
||||
ProviderConfigKind::OpenAi => OpenAiCompatConfig::openai(),
|
||||
ProviderConfigKind::CustomOpenAi => OpenAiCompatConfig::custom_openai(),
|
||||
ProviderConfigKind::DashScope => OpenAiCompatConfig::dashscope(),
|
||||
_ => unreachable!("non-OpenAI provider reached compatibility path"),
|
||||
};
|
||||
@@ -403,6 +408,56 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_custom_openai_uses_its_own_environment_namespace() {
|
||||
let _lock = env_lock();
|
||||
let _custom_key = EnvVarGuard::set("CLAWCUSTOMOPENAI_API_KEY", Some("custom-key"));
|
||||
let _custom_url = EnvVarGuard::set(
|
||||
"CLAWCUSTOMOPENAI_BASE_URL",
|
||||
Some("https://custom.example/v1"),
|
||||
);
|
||||
let _openai_key = EnvVarGuard::set("OPENAI_API_KEY", Some("openai-key"));
|
||||
let _openai_url = EnvVarGuard::set("OPENAI_BASE_URL", Some("https://openai.example/v1"));
|
||||
let _ollama = EnvVarGuard::set("OLLAMA_HOST", Some("http://localhost:11434"));
|
||||
|
||||
let config = ProviderConfig {
|
||||
kind: ProviderConfigKind::CustomOpenAi,
|
||||
model: "qwen-plus".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
};
|
||||
|
||||
match ProviderClient::from_config(&config).expect("custom OpenAI config should succeed") {
|
||||
ProviderClient::OpenAi(client) => {
|
||||
assert_eq!(client.base_url(), "https://custom.example/v1");
|
||||
}
|
||||
other => panic!("Expected custom OpenAI provider, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_custom_openai_does_not_reuse_openai_credentials() {
|
||||
let _lock = env_lock();
|
||||
let _custom_key = EnvVarGuard::set("CLAWCUSTOMOPENAI_API_KEY", None);
|
||||
let _custom_url = EnvVarGuard::set(
|
||||
"CLAWCUSTOMOPENAI_BASE_URL",
|
||||
Some("https://custom.example/v1"),
|
||||
);
|
||||
let _openai_key = EnvVarGuard::set("OPENAI_API_KEY", Some("openai-key"));
|
||||
let _ollama = EnvVarGuard::set("OLLAMA_HOST", Some("http://localhost:11434"));
|
||||
|
||||
let config = ProviderConfig {
|
||||
kind: ProviderConfigKind::CustomOpenAi,
|
||||
model: "gpt-5".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
};
|
||||
|
||||
let error = ProviderClient::from_config(&config)
|
||||
.expect_err("custom OpenAI must require its own credentials");
|
||||
assert!(error.to_string().contains("CLAWCUSTOMOPENAI_API_KEY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashscope_model_uses_dashscope_config_not_openai() {
|
||||
// Regression: qwen-plus was being routed to OpenAiCompatConfig::openai()
|
||||
|
||||
@@ -42,6 +42,7 @@ pub struct OpenAiCompatConfig {
|
||||
|
||||
const XAI_ENV_VARS: &[&str] = &["XAI_API_KEY"];
|
||||
const OPENAI_ENV_VARS: &[&str] = &["OPENAI_API_KEY"];
|
||||
const CUSTOM_OPENAI_ENV_VARS: &[&str] = &["CLAWCUSTOMOPENAI_API_KEY"];
|
||||
const DASHSCOPE_ENV_VARS: &[&str] = &["DASHSCOPE_API_KEY"];
|
||||
|
||||
// Provider-specific request body size limits in bytes
|
||||
@@ -80,6 +81,18 @@ impl OpenAiCompatConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenAI-compatible endpoint with its own environment namespace and no default URL.
|
||||
#[must_use]
|
||||
pub const fn custom_openai() -> Self {
|
||||
Self {
|
||||
provider_name: "Custom OpenAI",
|
||||
api_key_env: "CLAWCUSTOMOPENAI_API_KEY",
|
||||
base_url_env: "CLAWCUSTOMOPENAI_BASE_URL",
|
||||
default_base_url: "",
|
||||
max_request_body_bytes: OPENAI_MAX_REQUEST_BODY_BYTES,
|
||||
}
|
||||
}
|
||||
|
||||
/// Alibaba `DashScope` compatible-mode endpoint (Qwen family models).
|
||||
/// Uses the OpenAI-compatible REST shape at /compatible-mode/v1.
|
||||
/// Requested via Discord #clawcode-get-help: native Alibaba API for
|
||||
@@ -100,6 +113,7 @@ impl OpenAiCompatConfig {
|
||||
match self.provider_name {
|
||||
"xAI" => XAI_ENV_VARS,
|
||||
"OpenAI" => OPENAI_ENV_VARS,
|
||||
"Custom OpenAI" => CUSTOM_OPENAI_ENV_VARS,
|
||||
"DashScope" => DASHSCOPE_ENV_VARS,
|
||||
_ => &[],
|
||||
}
|
||||
|
||||
+36
-143
@@ -163,6 +163,20 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
||||
argument_hint: None,
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "tools",
|
||||
aliases: &[],
|
||||
summary: "List available tools and their descriptions",
|
||||
argument_hint: None,
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "info",
|
||||
aliases: &[],
|
||||
summary: "Show runtime information about claw-code",
|
||||
argument_hint: None,
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "bughunter",
|
||||
aliases: &[],
|
||||
@@ -1091,6 +1105,8 @@ pub enum SlashCommand {
|
||||
Init,
|
||||
Diff,
|
||||
Version,
|
||||
Tools,
|
||||
Info,
|
||||
Export {
|
||||
path: Option<String>,
|
||||
},
|
||||
@@ -1392,6 +1408,14 @@ pub fn validate_slash_command_input(
|
||||
validate_no_args(command, &args)?;
|
||||
SlashCommand::Version
|
||||
}
|
||||
"tools" => {
|
||||
validate_no_args(command, &args)?;
|
||||
SlashCommand::Tools
|
||||
}
|
||||
"info" => {
|
||||
validate_no_args(command, &args)?;
|
||||
SlashCommand::Info
|
||||
}
|
||||
"export" => SlashCommand::Export { path: remainder },
|
||||
"session" => parse_session_command(&args)?,
|
||||
"plugin" | "plugins" | "marketplace" => parse_plugin_command(&args)?,
|
||||
@@ -5297,60 +5321,6 @@ fn mcp_server_json(name: &str, server: &ScopedMcpServerConfig) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn render_session_status(session: &Session) -> String {
|
||||
let message_count = session.messages.len();
|
||||
let mut total_input = 0u32;
|
||||
let mut total_output = 0u32;
|
||||
for msg in &session.messages {
|
||||
if let Some(usage) = msg.usage {
|
||||
total_input = total_input.saturating_add(usage.input_tokens);
|
||||
total_output = total_output.saturating_add(usage.output_tokens);
|
||||
}
|
||||
}
|
||||
format!(
|
||||
"Session {}: {} messages, {} input tokens, {} output tokens",
|
||||
&session.session_id[0..8],
|
||||
message_count,
|
||||
total_input,
|
||||
total_output
|
||||
)
|
||||
}
|
||||
|
||||
fn render_token_cost(session: &Session) -> String {
|
||||
let mut total_input = 0u32;
|
||||
let mut total_output = 0u32;
|
||||
let mut cache_creation = 0u32;
|
||||
let mut cache_read = 0u32;
|
||||
for msg in &session.messages {
|
||||
if let Some(usage) = msg.usage {
|
||||
total_input = total_input.saturating_add(usage.input_tokens);
|
||||
total_output = total_output.saturating_add(usage.output_tokens);
|
||||
cache_creation = cache_creation.saturating_add(usage.cache_creation_input_tokens);
|
||||
cache_read = cache_read.saturating_add(usage.cache_read_input_tokens);
|
||||
}
|
||||
}
|
||||
format!(
|
||||
"Cumulative token usage for this session:
|
||||
Input tokens: {}
|
||||
Output tokens: {}
|
||||
Cache creation tokens: {}
|
||||
Cache read tokens: {}
|
||||
Total input: {}",
|
||||
total_input,
|
||||
total_output,
|
||||
cache_creation,
|
||||
cache_read,
|
||||
total_input
|
||||
.saturating_add(cache_creation)
|
||||
.saturating_add(cache_read)
|
||||
)
|
||||
}
|
||||
|
||||
fn render_version_info() -> String {
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
format!("claw-code version {}", version)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn handle_slash_command(
|
||||
input: &str,
|
||||
@@ -5388,19 +5358,8 @@ pub fn handle_slash_command(
|
||||
message: render_slash_command_help(),
|
||||
session: session.clone(),
|
||||
}),
|
||||
SlashCommand::Status => Some(SlashCommandResult {
|
||||
message: render_session_status(session),
|
||||
session: session.clone(),
|
||||
}),
|
||||
SlashCommand::Cost => Some(SlashCommandResult {
|
||||
message: render_token_cost(session),
|
||||
session: session.clone(),
|
||||
}),
|
||||
SlashCommand::Version => Some(SlashCommandResult {
|
||||
message: render_version_info(),
|
||||
session: session.clone(),
|
||||
}),
|
||||
SlashCommand::Bughunter { .. }
|
||||
SlashCommand::Status
|
||||
| SlashCommand::Bughunter { .. }
|
||||
| SlashCommand::Commit
|
||||
| SlashCommand::Pr { .. }
|
||||
| SlashCommand::Issue { .. }
|
||||
@@ -5411,6 +5370,7 @@ pub fn handle_slash_command(
|
||||
| SlashCommand::Model { .. }
|
||||
| SlashCommand::Permissions { .. }
|
||||
| SlashCommand::Clear { .. }
|
||||
| SlashCommand::Cost
|
||||
| SlashCommand::Resume { .. }
|
||||
| SlashCommand::Config { .. }
|
||||
| SlashCommand::Workspace { .. }
|
||||
@@ -5418,6 +5378,7 @@ pub fn handle_slash_command(
|
||||
| SlashCommand::Memory
|
||||
| SlashCommand::Init
|
||||
| SlashCommand::Diff
|
||||
| SlashCommand::Version
|
||||
| SlashCommand::Export { .. }
|
||||
| SlashCommand::Session { .. }
|
||||
| SlashCommand::Plugins { .. }
|
||||
@@ -5466,6 +5427,8 @@ pub fn handle_slash_command(
|
||||
| SlashCommand::History { .. }
|
||||
| SlashCommand::Team { .. }
|
||||
| SlashCommand::Setup
|
||||
| SlashCommand::Tools
|
||||
| SlashCommand::Info
|
||||
| SlashCommand::Unknown(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -6083,7 +6046,7 @@ mod tests {
|
||||
assert!(!help.contains("/login"));
|
||||
assert!(!help.contains("/logout"));
|
||||
assert!(help.contains("/setup"));
|
||||
assert_eq!(slash_command_specs().len(), 140);
|
||||
assert_eq!(slash_command_specs().len(), 142);
|
||||
assert!(resume_supported_slash_commands().len() >= 39);
|
||||
}
|
||||
|
||||
@@ -6256,6 +6219,7 @@ mod tests {
|
||||
fn ignores_unknown_or_runtime_bound_slash_commands() {
|
||||
let session = Session::new();
|
||||
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command("/sandbox", &session, CompactionConfig::default()).is_none());
|
||||
assert!(
|
||||
handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none()
|
||||
@@ -6287,6 +6251,7 @@ mod tests {
|
||||
handle_slash_command("/clear --confirm", &session, CompactionConfig::default())
|
||||
.is_none()
|
||||
);
|
||||
assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command(
|
||||
"/resume session.json",
|
||||
&session,
|
||||
@@ -6305,6 +6270,9 @@ mod tests {
|
||||
);
|
||||
assert!(handle_slash_command("/mcp list", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command("/tools", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command("/info", &session, CompactionConfig::default()).is_none());
|
||||
assert!(
|
||||
handle_slash_command("/export note.txt", &session, CompactionConfig::default())
|
||||
.is_none()
|
||||
@@ -7266,79 +7234,4 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
let _ = fs::remove_dir_all(bundled_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_command_shows_session_info() {
|
||||
use runtime::TokenUsage;
|
||||
|
||||
let mut session = Session::new();
|
||||
session.messages.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
blocks: vec![ContentBlock::Text {
|
||||
text: "hello".to_string(),
|
||||
}],
|
||||
usage: Some(TokenUsage {
|
||||
input_tokens: 10,
|
||||
output_tokens: 20,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
}),
|
||||
});
|
||||
session.messages.push(ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
blocks: vec![ContentBlock::Text {
|
||||
text: "response".to_string(),
|
||||
}],
|
||||
usage: Some(TokenUsage {
|
||||
input_tokens: 5,
|
||||
output_tokens: 15,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
let result = handle_slash_command("/status", &session, CompactionConfig::default())
|
||||
.expect("/status should be handled");
|
||||
assert!(result.message.contains("2 messages"));
|
||||
assert!(result.message.contains("15 input tokens"));
|
||||
assert!(result.message.contains("35 output tokens"));
|
||||
assert_eq!(result.session, session);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_shows_token_usage() {
|
||||
use runtime::TokenUsage;
|
||||
|
||||
let mut session = Session::new();
|
||||
session.messages.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
blocks: vec![ContentBlock::Text {
|
||||
text: "hello".to_string(),
|
||||
}],
|
||||
usage: Some(TokenUsage {
|
||||
input_tokens: 100,
|
||||
output_tokens: 200,
|
||||
cache_creation_input_tokens: 50,
|
||||
cache_read_input_tokens: 25,
|
||||
}),
|
||||
});
|
||||
|
||||
let result = handle_slash_command("/cost", &session, CompactionConfig::default())
|
||||
.expect("/cost should be handled");
|
||||
assert!(result.message.contains("100"));
|
||||
assert!(result.message.contains("200"));
|
||||
assert!(result.message.contains("50"));
|
||||
assert!(result.message.contains("25"));
|
||||
assert!(result.message.contains("Cumulative token usage"));
|
||||
assert_eq!(result.session, session);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_command_shows_version() {
|
||||
let session = Session::new();
|
||||
let result = handle_slash_command("/version", &session, CompactionConfig::default())
|
||||
.expect("/version should be handled");
|
||||
assert!(result.message.contains("claw-code version"));
|
||||
assert_eq!(result.session, session);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
use telemetry::SessionTracer;
|
||||
@@ -53,6 +57,41 @@ pub struct PromptCacheEvent {
|
||||
pub token_drop: u32,
|
||||
}
|
||||
|
||||
/// Shared flag used to request graceful interruption of a running turn.
|
||||
///
|
||||
/// Cloning shares the underlying flag, mirroring
|
||||
/// [`HookAbortSignal`](crate::hooks::HookAbortSignal). An input listener
|
||||
/// (e.g. Esc or Ctrl+C handling in the CLI) sets the flag while the
|
||||
/// conversation loop and the streaming API client poll it at safe points.
|
||||
/// When the flag is observed, the turn winds down without treating the
|
||||
/// stop as a failure: pending tool calls receive synthesized error
|
||||
/// results so the session stays consistent, and [`TurnSummary`] reports
|
||||
/// `interrupted: true`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TurnInterruptSignal {
|
||||
interrupted: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl TurnInterruptSignal {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn interrupt(&self) {
|
||||
self.interrupted.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_interrupted(&self) -> bool {
|
||||
self.interrupted.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn reset(&self) {
|
||||
self.interrupted.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal streaming API contract required by [`ConversationRuntime`].
|
||||
pub trait ApiClient {
|
||||
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError>;
|
||||
@@ -118,6 +157,7 @@ pub struct TurnSummary {
|
||||
pub iterations: usize,
|
||||
pub usage: TokenUsage,
|
||||
pub auto_compaction: Option<AutoCompactionEvent>,
|
||||
pub interrupted: bool,
|
||||
}
|
||||
|
||||
/// Details about automatic session compaction applied during a turn.
|
||||
@@ -138,6 +178,7 @@ pub struct ConversationRuntime<C, T> {
|
||||
hook_runner: HookRunner,
|
||||
auto_compaction_input_tokens_threshold: u32,
|
||||
hook_abort_signal: HookAbortSignal,
|
||||
turn_interrupt_signal: TurnInterruptSignal,
|
||||
hook_progress_reporter: Option<Box<dyn HookProgressReporter>>,
|
||||
session_tracer: Option<SessionTracer>,
|
||||
}
|
||||
@@ -192,6 +233,7 @@ where
|
||||
hook_runner,
|
||||
auto_compaction_input_tokens_threshold: auto_compaction_threshold_from_env(),
|
||||
hook_abort_signal: HookAbortSignal::default(),
|
||||
turn_interrupt_signal: TurnInterruptSignal::default(),
|
||||
hook_progress_reporter: None,
|
||||
session_tracer: None,
|
||||
}
|
||||
@@ -222,6 +264,15 @@ where
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_turn_interrupt_signal(
|
||||
mut self,
|
||||
turn_interrupt_signal: TurnInterruptSignal,
|
||||
) -> Self {
|
||||
self.turn_interrupt_signal = turn_interrupt_signal;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_hook_progress_reporter(
|
||||
mut self,
|
||||
@@ -355,8 +406,14 @@ where
|
||||
let mut prompt_cache_events = Vec::new();
|
||||
let mut iterations = 0;
|
||||
let mut auto_compaction = None;
|
||||
let mut interrupted = false;
|
||||
|
||||
loop {
|
||||
if self.turn_interrupt_signal.is_interrupted() {
|
||||
self.record_turn_interrupted(iterations, "before_request");
|
||||
interrupted = true;
|
||||
break;
|
||||
}
|
||||
iterations += 1;
|
||||
if iterations > self.max_iterations {
|
||||
let error = RuntimeError::new(
|
||||
@@ -373,6 +430,14 @@ where
|
||||
let events = match self.api_client.stream(request) {
|
||||
Ok(events) => events,
|
||||
Err(error) => {
|
||||
if self.turn_interrupt_signal.is_interrupted() {
|
||||
// The client aborted because the user interrupted the
|
||||
// turn; any partial response is discarded and the stop
|
||||
// is reported as an interruption rather than a failure.
|
||||
self.record_turn_interrupted(iterations, "during_request");
|
||||
interrupted = true;
|
||||
break;
|
||||
}
|
||||
self.record_turn_failed(iterations, &error);
|
||||
return Err(error);
|
||||
}
|
||||
@@ -421,6 +486,25 @@ where
|
||||
}
|
||||
|
||||
for (tool_use_id, tool_name, input) in pending_tool_uses {
|
||||
if interrupted || self.turn_interrupt_signal.is_interrupted() {
|
||||
// Every pending tool_use must still receive a tool_result
|
||||
// so the session stays valid for the next request.
|
||||
if !interrupted {
|
||||
self.record_turn_interrupted(iterations, "before_tool");
|
||||
interrupted = true;
|
||||
}
|
||||
let result_message = ConversationMessage::tool_result(
|
||||
tool_use_id,
|
||||
tool_name,
|
||||
"Interrupted by user before this tool could run.",
|
||||
true,
|
||||
);
|
||||
self.session
|
||||
.push_message(result_message.clone())
|
||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||
tool_results.push(result_message);
|
||||
continue;
|
||||
}
|
||||
let pre_hook_result = self.run_pre_tool_use_hook(&tool_name, &input);
|
||||
let effective_input = pre_hook_result
|
||||
.updated_input()
|
||||
@@ -520,6 +604,10 @@ where
|
||||
self.record_tool_finished(iterations, &result_message);
|
||||
tool_results.push(result_message);
|
||||
}
|
||||
|
||||
if interrupted {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let summary = TurnSummary {
|
||||
@@ -529,8 +617,11 @@ where
|
||||
iterations,
|
||||
usage: self.usage_tracker.cumulative_usage(),
|
||||
auto_compaction,
|
||||
interrupted,
|
||||
};
|
||||
self.record_turn_completed(&summary);
|
||||
if !interrupted {
|
||||
self.record_turn_completed(&summary);
|
||||
}
|
||||
|
||||
Ok(summary)
|
||||
}
|
||||
@@ -694,6 +785,17 @@ where
|
||||
session_tracer.record("turn_completed", attributes);
|
||||
}
|
||||
|
||||
fn record_turn_interrupted(&self, iteration: usize, phase: &str) {
|
||||
let Some(session_tracer) = &self.session_tracer else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut attributes = Map::new();
|
||||
attributes.insert("iteration".to_string(), Value::from(iteration as u64));
|
||||
attributes.insert("phase".to_string(), Value::String(phase.to_string()));
|
||||
session_tracer.record("turn_interrupted", attributes);
|
||||
}
|
||||
|
||||
fn record_turn_failed(&self, iteration: usize, error: &RuntimeError) {
|
||||
let Some(session_tracer) = &self.session_tracer else {
|
||||
return;
|
||||
@@ -855,7 +957,8 @@ mod tests {
|
||||
use super::{
|
||||
build_assistant_message, parse_auto_compaction_threshold, ApiClient, ApiRequest,
|
||||
AssistantEvent, AutoCompactionEvent, ConversationRuntime, PromptCacheEvent, RuntimeError,
|
||||
StaticToolExecutor, ToolExecutor, DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD,
|
||||
StaticToolExecutor, ToolExecutor, TurnInterruptSignal,
|
||||
DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD,
|
||||
};
|
||||
use crate::compact::CompactionConfig;
|
||||
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
|
||||
@@ -1956,4 +2059,165 @@ mod tests {
|
||||
// then
|
||||
assert_eq!(error.to_string(), "upstream failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupt_before_first_request_skips_the_api_call() {
|
||||
struct UnreachableApi;
|
||||
|
||||
impl ApiClient for UnreachableApi {
|
||||
fn stream(
|
||||
&mut self,
|
||||
_request: ApiRequest,
|
||||
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||
unreachable!("interrupted turn must not reach the API")
|
||||
}
|
||||
}
|
||||
|
||||
// given
|
||||
let interrupt = TurnInterruptSignal::new();
|
||||
interrupt.interrupt();
|
||||
let mut runtime = ConversationRuntime::new(
|
||||
Session::new(),
|
||||
UnreachableApi,
|
||||
StaticToolExecutor::new(),
|
||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
||||
vec!["system".to_string()],
|
||||
)
|
||||
.with_turn_interrupt_signal(interrupt);
|
||||
|
||||
// when
|
||||
let summary = runtime
|
||||
.run_turn("hello", None)
|
||||
.expect("interruption should not be reported as a failure");
|
||||
|
||||
// then
|
||||
assert!(summary.interrupted);
|
||||
assert_eq!(summary.iterations, 0);
|
||||
assert!(summary.assistant_messages.is_empty());
|
||||
assert!(summary.tool_results.is_empty());
|
||||
assert_eq!(summary.auto_compaction, None);
|
||||
assert_eq!(runtime.session().messages.len(), 1);
|
||||
assert_eq!(runtime.session().messages[0].role, MessageRole::User);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupt_after_stream_synthesizes_results_for_pending_tools() {
|
||||
struct ToolUseApi {
|
||||
interrupt: TurnInterruptSignal,
|
||||
}
|
||||
|
||||
impl ApiClient for ToolUseApi {
|
||||
fn stream(
|
||||
&mut self,
|
||||
_request: ApiRequest,
|
||||
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||
// Simulate the user pressing Esc while the response streams in.
|
||||
self.interrupt.interrupt();
|
||||
Ok(vec![
|
||||
AssistantEvent::TextDelta("Running the tool.".to_string()),
|
||||
AssistantEvent::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "add".to_string(),
|
||||
input: "2,2".to_string(),
|
||||
},
|
||||
AssistantEvent::MessageStop,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// given
|
||||
let interrupt = TurnInterruptSignal::new();
|
||||
let mut runtime = ConversationRuntime::new(
|
||||
Session::new(),
|
||||
ToolUseApi {
|
||||
interrupt: interrupt.clone(),
|
||||
},
|
||||
StaticToolExecutor::new()
|
||||
.register("add", |_input| panic!("interrupted tool must not run")),
|
||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
||||
vec!["system".to_string()],
|
||||
)
|
||||
.with_turn_interrupt_signal(interrupt);
|
||||
|
||||
// when
|
||||
let summary = runtime
|
||||
.run_turn("what is 2 + 2?", None)
|
||||
.expect("interruption should not be reported as a failure");
|
||||
|
||||
// then
|
||||
assert!(summary.interrupted);
|
||||
assert_eq!(summary.iterations, 1);
|
||||
assert_eq!(summary.assistant_messages.len(), 1);
|
||||
assert_eq!(summary.tool_results.len(), 1);
|
||||
assert!(matches!(
|
||||
&summary.tool_results[0].blocks[0],
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
is_error: true,
|
||||
output,
|
||||
..
|
||||
} if tool_use_id == "tool-1" && output.contains("Interrupted by user")
|
||||
));
|
||||
// user text, assistant tool_use, synthesized tool_result
|
||||
assert_eq!(runtime.session().messages.len(), 3);
|
||||
assert!(matches!(
|
||||
runtime.session().messages[2].blocks[0],
|
||||
ContentBlock::ToolResult { is_error: true, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_error_during_interrupt_is_reported_as_interruption() {
|
||||
struct AbortedApi {
|
||||
interrupt: TurnInterruptSignal,
|
||||
}
|
||||
|
||||
impl ApiClient for AbortedApi {
|
||||
fn stream(
|
||||
&mut self,
|
||||
_request: ApiRequest,
|
||||
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||
// Simulate the streaming client aborting the connection after
|
||||
// observing the interrupt flag mid-stream.
|
||||
self.interrupt.interrupt();
|
||||
Err(RuntimeError::new("request aborted"))
|
||||
}
|
||||
}
|
||||
|
||||
// given
|
||||
let sink = Arc::new(MemoryTelemetrySink::default());
|
||||
let tracer = SessionTracer::new("session-interrupt", sink.clone());
|
||||
let interrupt = TurnInterruptSignal::new();
|
||||
let mut runtime = ConversationRuntime::new(
|
||||
Session::new(),
|
||||
AbortedApi {
|
||||
interrupt: interrupt.clone(),
|
||||
},
|
||||
StaticToolExecutor::new(),
|
||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
||||
vec!["system".to_string()],
|
||||
)
|
||||
.with_turn_interrupt_signal(interrupt)
|
||||
.with_session_tracer(tracer);
|
||||
|
||||
// when
|
||||
let summary = runtime
|
||||
.run_turn("hello", None)
|
||||
.expect("interrupt-driven aborts should not surface as errors");
|
||||
|
||||
// then
|
||||
assert!(summary.interrupted);
|
||||
assert!(summary.assistant_messages.is_empty());
|
||||
let trace_names = sink
|
||||
.events()
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
TelemetryEvent::SessionTrace(trace) => Some(trace.name.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert!(trace_names.iter().any(|name| name == "turn_interrupted"));
|
||||
assert!(!trace_names.iter().any(|name| name == "turn_failed"));
|
||||
assert!(!trace_names.iter().any(|name| name == "turn_completed"));
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ mod report_schema;
|
||||
pub mod sandbox;
|
||||
mod session;
|
||||
pub mod session_control;
|
||||
pub mod session_settings;
|
||||
pub mod trident;
|
||||
pub use session_control::SessionStore;
|
||||
mod sse;
|
||||
@@ -84,7 +85,7 @@ pub use config_validate::{
|
||||
pub use conversation::{
|
||||
auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent,
|
||||
ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolError,
|
||||
ToolExecutor, TurnSummary,
|
||||
ToolExecutor, TurnInterruptSignal, TurnSummary,
|
||||
};
|
||||
pub use file_ops::{
|
||||
edit_file, edit_file_in_workspace, glob_search, glob_search_in_workspace, grep_search,
|
||||
@@ -179,6 +180,10 @@ pub use session::{
|
||||
ContentBlock, ConversationMessage, MessageRole, Session, SessionCompaction, SessionError,
|
||||
SessionFork, SessionHeartbeat, SessionLiveness, SessionPromptEntry,
|
||||
};
|
||||
pub use session_settings::{
|
||||
ConfigHotreloadMonitor, SettingApplicator, SettingCategory, SettingSchema, SettingSchemaRegistry,
|
||||
SettingType, SessionSettings,
|
||||
};
|
||||
pub use sse::{IncrementalSseParser, SseEvent};
|
||||
pub use stale_base::{
|
||||
check_base_commit, format_stale_base_warning, read_claw_base_file, resolve_expected_base,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -34,6 +34,9 @@ pub enum WorkerStatus {
|
||||
ToolPermissionRequired,
|
||||
ReadyForPrompt,
|
||||
Running,
|
||||
/// The user stopped the turn mid-run (e.g. Esc or Ctrl+C); the worker
|
||||
/// is back at the prompt with the session intact.
|
||||
Interrupted,
|
||||
Finished,
|
||||
Failed,
|
||||
}
|
||||
@@ -46,6 +49,7 @@ impl std::fmt::Display for WorkerStatus {
|
||||
Self::ToolPermissionRequired => write!(f, "tool_permission_required"),
|
||||
Self::ReadyForPrompt => write!(f, "ready_for_prompt"),
|
||||
Self::Running => write!(f, "running"),
|
||||
Self::Interrupted => write!(f, "interrupted"),
|
||||
Self::Finished => write!(f, "finished"),
|
||||
Self::Failed => write!(f, "failed"),
|
||||
}
|
||||
@@ -82,6 +86,7 @@ pub enum WorkerEventKind {
|
||||
PromptMisdelivery,
|
||||
PromptReplayArmed,
|
||||
Running,
|
||||
Interrupted,
|
||||
Restarted,
|
||||
Finished,
|
||||
Failed,
|
||||
@@ -1461,6 +1466,23 @@ mod tests {
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn interrupted_status_serializes_and_displays_as_snake_case() {
|
||||
assert_eq!(WorkerStatus::Interrupted.to_string(), "interrupted");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&WorkerStatus::Interrupted).expect("should serialize"),
|
||||
"\"interrupted\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<WorkerStatus>("\"interrupted\"").expect("should deserialize"),
|
||||
WorkerStatus::Interrupted
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&WorkerEventKind::Interrupted).expect("should serialize"),
|
||||
"\"interrupted\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlisted_trust_prompt_auto_resolves_then_reaches_ready_state() {
|
||||
let registry = WorkerRegistry::new();
|
||||
|
||||
@@ -24,6 +24,8 @@ tokio = { version = "1", features = ["rt-multi-thread", "signal", "time"] }
|
||||
tools = { path = "../tools" }
|
||||
log = "0.4"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
nix = { version = "0.29", features = ["term", "poll"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Esc-key interruption for running turns.
|
||||
//!
|
||||
//! While a turn runs, the main thread is blocked inside the conversation
|
||||
//! loop, so rustyline is not reading the keyboard. This module spawns a
|
||||
//! listener thread that switches stdin to a minimal non-canonical mode
|
||||
//! (line buffering and echo off; signal handling and output processing
|
||||
//! stay enabled, unlike full raw mode) and watches for a lone Escape
|
||||
//! byte. When one arrives it trips the shared [`TurnInterruptSignal`],
|
||||
//! which the conversation loop and the streaming API client poll to wind
|
||||
//! the turn down gracefully.
|
||||
//!
|
||||
//! Permission prompts read whole lines from stdin mid-turn, so stdin
|
||||
//! ownership is coordinated through [`StdinPromptGate`]: while a prompt
|
||||
//! holds a lease, the listener restores canonical mode and stops
|
||||
//! consuming bytes.
|
||||
|
||||
use std::io::IsTerminal;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use runtime::TurnInterruptSignal;
|
||||
|
||||
/// Terminal-mode bookkeeping guarded by the gate mutex. `saved` holds the
|
||||
/// canonical-mode termios while non-canonical mode is active.
|
||||
#[derive(Default)]
|
||||
struct TtyMode {
|
||||
#[cfg(unix)]
|
||||
saved: Option<nix::sys::termios::Termios>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct GateState {
|
||||
/// True while a permission prompt owns stdin.
|
||||
busy: AtomicBool,
|
||||
/// Tells the listener thread to exit at the end of the turn.
|
||||
stop: AtomicBool,
|
||||
/// Serializes stdin reads and terminal-mode flips.
|
||||
mode: Mutex<TtyMode>,
|
||||
}
|
||||
|
||||
/// Classification of one chunk of bytes read from the terminal.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum EscClassification {
|
||||
/// A bare ESC byte: the user pressed the Escape key.
|
||||
LoneEscape,
|
||||
/// ESC followed by more bytes: the prefix of a terminal escape
|
||||
/// sequence such as an arrow or function key.
|
||||
EscapeSequence,
|
||||
/// Anything else (regular type-ahead, control characters, ...).
|
||||
Other,
|
||||
}
|
||||
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
fn classify_escape_chunk(bytes: &[u8]) -> EscClassification {
|
||||
match bytes {
|
||||
[0x1b] => EscClassification::LoneEscape,
|
||||
[0x1b, ..] => EscClassification::EscapeSequence,
|
||||
_ => EscClassification::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands exclusive stdin ownership to interactive prompts while the
|
||||
/// Esc listener is running.
|
||||
#[derive(Clone)]
|
||||
pub struct StdinPromptGate {
|
||||
shared: Arc<GateState>,
|
||||
}
|
||||
|
||||
impl StdinPromptGate {
|
||||
/// Takes stdin away from the Esc listener for the lifetime of the
|
||||
/// returned lease: the terminal returns to canonical mode so a
|
||||
/// line-based prompt (e.g. the permission approval prompt) behaves
|
||||
/// normally. Listening resumes when the lease is dropped.
|
||||
pub fn lease(&self) -> StdinPromptLease<'_> {
|
||||
self.shared.busy.store(true, Ordering::SeqCst);
|
||||
// Wait for the listener's current poll cycle to release the
|
||||
// terminal, then restore canonical mode for line input.
|
||||
#[allow(unused_mut)]
|
||||
let mut mode = self
|
||||
.shared
|
||||
.mode
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner);
|
||||
#[cfg(unix)]
|
||||
tty::restore(&mut mode);
|
||||
drop(mode);
|
||||
StdinPromptLease {
|
||||
shared: &self.shared,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII lease returned by [`StdinPromptGate::lease`].
|
||||
pub struct StdinPromptLease<'a> {
|
||||
shared: &'a GateState,
|
||||
}
|
||||
|
||||
impl Drop for StdinPromptLease<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.shared.busy.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// Background listener that maps a lone Esc keypress to
|
||||
/// [`TurnInterruptSignal::interrupt`].
|
||||
pub struct EscapeInterruptMonitor {
|
||||
shared: Arc<GateState>,
|
||||
join_handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl EscapeInterruptMonitor {
|
||||
/// Spawns the listener for the duration of one turn. Returns `None`
|
||||
/// when stdin is not an interactive terminal or the platform does not
|
||||
/// support terminal-mode switching; Ctrl+C interruption still works
|
||||
/// in those cases.
|
||||
pub fn spawn(signal: TurnInterruptSignal) -> Option<(Self, StdinPromptGate)> {
|
||||
if !std::io::stdin().is_terminal() {
|
||||
return None;
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = signal;
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let shared = Arc::new(GateState::default());
|
||||
let thread_shared = Arc::clone(&shared);
|
||||
let join_handle = std::thread::spawn(move || listener_loop(&thread_shared, &signal));
|
||||
let gate = StdinPromptGate {
|
||||
shared: Arc::clone(&shared),
|
||||
};
|
||||
Some((
|
||||
Self {
|
||||
shared,
|
||||
join_handle: Some(join_handle),
|
||||
},
|
||||
gate,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops the listener and restores the terminal before control
|
||||
/// returns to the line editor.
|
||||
pub fn stop(mut self) {
|
||||
self.shared.stop.store(true, Ordering::SeqCst);
|
||||
if let Some(join_handle) = self.join_handle.take() {
|
||||
let _ = join_handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn listener_loop(shared: &GateState, signal: &TurnInterruptSignal) {
|
||||
let mut buffer = [0u8; 64];
|
||||
|
||||
while !shared.stop.load(Ordering::SeqCst) {
|
||||
if shared.busy.load(Ordering::SeqCst) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(25));
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut mode = shared.mode.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if shared.busy.load(Ordering::SeqCst) || shared.stop.load(Ordering::SeqCst) {
|
||||
continue;
|
||||
}
|
||||
if !tty::enable_noncanonical(&mut mode) {
|
||||
// Terminal refused the mode switch; Esc support is
|
||||
// unavailable for this turn but Ctrl+C still works.
|
||||
return;
|
||||
}
|
||||
if !tty::wait_readable(100) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let count = tty::read_pending(&mut buffer);
|
||||
if classify_escape_chunk(&buffer[..count]) == EscClassification::LoneEscape {
|
||||
// A slow terminal may split an escape sequence across reads;
|
||||
// only treat ESC as the Esc key when nothing follows it.
|
||||
if tty::wait_readable(50) {
|
||||
let _ = tty::read_pending(&mut buffer);
|
||||
continue;
|
||||
}
|
||||
signal.interrupt();
|
||||
}
|
||||
// Other bytes are intentionally swallowed: type-ahead is not
|
||||
// preserved while a turn is running.
|
||||
}
|
||||
|
||||
let mut mode = shared.mode.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
tty::restore(&mut mode);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
mod tty {
|
||||
use std::os::fd::{AsFd, AsRawFd};
|
||||
|
||||
use nix::poll::{poll, PollFd, PollFlags, PollTimeout};
|
||||
use nix::sys::termios::{self, LocalFlags, SetArg, SpecialCharacterIndices};
|
||||
|
||||
use super::TtyMode;
|
||||
|
||||
/// Disables line buffering and echo while leaving signal generation
|
||||
/// (ISIG) and output post-processing (OPOST) untouched, so Ctrl+C and
|
||||
/// streamed output keep working. No-op when already active.
|
||||
pub(super) fn enable_noncanonical(mode: &mut TtyMode) -> bool {
|
||||
if mode.saved.is_some() {
|
||||
return true;
|
||||
}
|
||||
let stdin = std::io::stdin();
|
||||
let Ok(saved) = termios::tcgetattr(&stdin) else {
|
||||
return false;
|
||||
};
|
||||
let mut noncanonical = saved.clone();
|
||||
noncanonical.local_flags &= !(LocalFlags::ICANON | LocalFlags::ECHO);
|
||||
noncanonical.control_chars[SpecialCharacterIndices::VMIN as usize] = 0;
|
||||
noncanonical.control_chars[SpecialCharacterIndices::VTIME as usize] = 0;
|
||||
if termios::tcsetattr(&stdin, SetArg::TCSANOW, &noncanonical).is_err() {
|
||||
return false;
|
||||
}
|
||||
mode.saved = Some(saved);
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn restore(mode: &mut TtyMode) {
|
||||
if let Some(saved) = mode.saved.take() {
|
||||
let _ = termios::tcsetattr(std::io::stdin(), SetArg::TCSANOW, &saved);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn wait_readable(timeout_ms: u16) -> bool {
|
||||
let stdin = std::io::stdin();
|
||||
let mut fds = [PollFd::new(stdin.as_fd(), PollFlags::POLLIN)];
|
||||
matches!(poll(&mut fds, PollTimeout::from(timeout_ms)), Ok(ready) if ready > 0)
|
||||
&& fds[0]
|
||||
.revents()
|
||||
.is_some_and(|revents| revents.contains(PollFlags::POLLIN))
|
||||
}
|
||||
|
||||
pub(super) fn read_pending(buffer: &mut [u8]) -> usize {
|
||||
nix::unistd::read(std::io::stdin().as_raw_fd(), buffer).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{classify_escape_chunk, EscClassification};
|
||||
|
||||
#[test]
|
||||
fn lone_escape_byte_is_the_esc_key() {
|
||||
assert_eq!(
|
||||
classify_escape_chunk(&[0x1b]),
|
||||
EscClassification::LoneEscape
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_followed_by_more_bytes_is_a_sequence() {
|
||||
// Up arrow: ESC [ A
|
||||
assert_eq!(
|
||||
classify_escape_chunk(&[0x1b, 0x5b, 0x41]),
|
||||
EscClassification::EscapeSequence
|
||||
);
|
||||
// Alt+f style: ESC f
|
||||
assert_eq!(
|
||||
classify_escape_chunk(&[0x1b, b'f']),
|
||||
EscClassification::EscapeSequence
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_bytes_are_ignored() {
|
||||
assert_eq!(classify_escape_chunk(b"hello"), EscClassification::Other);
|
||||
assert_eq!(classify_escape_chunk(&[]), EscClassification::Other);
|
||||
assert_eq!(classify_escape_chunk(&[0x03]), EscClassification::Other);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
)]
|
||||
mod init;
|
||||
mod input;
|
||||
mod interrupt;
|
||||
mod render;
|
||||
mod setup_wizard;
|
||||
|
||||
@@ -1035,6 +1036,8 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
output_format,
|
||||
} => print_system_prompt(cwd, date, &model, output_format)?,
|
||||
CliAction::Version { output_format } => print_version(output_format)?,
|
||||
CliAction::Tools { output_format } => print_tools(output_format)?,
|
||||
CliAction::Info { output_format } => print_info(output_format)?,
|
||||
CliAction::ResumeSession {
|
||||
session_path,
|
||||
commands,
|
||||
@@ -1196,6 +1199,12 @@ enum CliAction {
|
||||
Version {
|
||||
output_format: CliOutputFormat,
|
||||
},
|
||||
Tools {
|
||||
output_format: CliOutputFormat,
|
||||
},
|
||||
Info {
|
||||
output_format: CliOutputFormat,
|
||||
},
|
||||
SessionList {
|
||||
output_format: CliOutputFormat,
|
||||
},
|
||||
@@ -2598,6 +2607,8 @@ fn parse_direct_slash_cli_action(
|
||||
Ok(Some(SlashCommand::Sandbox)) => Ok(CliAction::Sandbox { output_format }),
|
||||
Ok(Some(SlashCommand::Diff)) => Ok(CliAction::Diff { output_format }),
|
||||
Ok(Some(SlashCommand::Version)) => Ok(CliAction::Version { output_format }),
|
||||
Ok(Some(SlashCommand::Tools)) => Ok(CliAction::Tools { output_format }),
|
||||
Ok(Some(SlashCommand::Info)) => Ok(CliAction::Info { output_format }),
|
||||
Ok(Some(SlashCommand::Doctor)) => Ok(CliAction::Doctor {
|
||||
output_format,
|
||||
permission_mode,
|
||||
@@ -5077,6 +5088,84 @@ fn version_json_value() -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn print_tools(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let specs = mvp_tool_specs();
|
||||
match output_format {
|
||||
CliOutputFormat::Text => {
|
||||
println!("Available tools ({}):", specs.len());
|
||||
for spec in &specs {
|
||||
println!(" {}\n {}", spec.name, spec.description);
|
||||
}
|
||||
}
|
||||
CliOutputFormat::Json => {
|
||||
let tools: Vec<_> = specs
|
||||
.into_iter()
|
||||
.map(|spec| {
|
||||
json!({
|
||||
"name": spec.name,
|
||||
"description": spec.description,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"kind": "tools",
|
||||
"action": "list",
|
||||
"status": "ok",
|
||||
"tools": tools,
|
||||
"count": tools.len(),
|
||||
}))?
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_info(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir().ok();
|
||||
let binary_provenance = binary_provenance_for(cwd.as_deref());
|
||||
match output_format {
|
||||
CliOutputFormat::Text => {
|
||||
println!("claw-code runtime information:");
|
||||
println!(" Version: {}", VERSION);
|
||||
if let Some(sha) = &binary_provenance.git_sha_short {
|
||||
println!(" Git SHA: {}", sha);
|
||||
}
|
||||
if let Some(branch) = &binary_provenance.branch {
|
||||
println!(" Branch: {}", branch);
|
||||
}
|
||||
println!(" Commit date: {}", binary_provenance.commit_date);
|
||||
println!(" Rustc version: {}", binary_provenance.rustc_version);
|
||||
if let Some(target) = &binary_provenance.target {
|
||||
println!(" Target: {}", target);
|
||||
}
|
||||
if let Some(path) = binary_provenance.executable_path {
|
||||
println!(" Executable: {}", path);
|
||||
}
|
||||
}
|
||||
CliOutputFormat::Json => {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"kind": "info",
|
||||
"action": "show",
|
||||
"status": "ok",
|
||||
"version": VERSION,
|
||||
"git_sha": binary_provenance.git_sha,
|
||||
"git_sha_short": binary_provenance.git_sha_short,
|
||||
"branch": binary_provenance.branch,
|
||||
"commit_date": binary_provenance.commit_date,
|
||||
"rustc_version": binary_provenance.rustc_version,
|
||||
"target": binary_provenance.target,
|
||||
"executable_path": binary_provenance.executable_path,
|
||||
}))?
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn resume_session(session_path: &Path, commands: &[String], output_format: CliOutputFormat) {
|
||||
let session_reference = session_path.display().to_string();
|
||||
@@ -6785,6 +6874,56 @@ fn run_resume_command(
|
||||
message: Some(render_version_report()),
|
||||
json: Some(version_json_value()),
|
||||
}),
|
||||
SlashCommand::Tools => {
|
||||
let specs = mvp_tool_specs();
|
||||
let tool_count = specs.len();
|
||||
let tools_text = specs
|
||||
.iter()
|
||||
.map(|spec| format!(" {}\n {}", spec.name, spec.description))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let message = format!("Available tools ({}):\n{}", tool_count, tools_text);
|
||||
let json_tools: Vec<_> = specs
|
||||
.into_iter()
|
||||
.map(|spec| serde_json::json!({ "name": spec.name, "description": spec.description }))
|
||||
.collect();
|
||||
Ok(ResumeCommandOutcome {
|
||||
session: session.clone(),
|
||||
message: Some(message),
|
||||
json: Some(serde_json::json!({
|
||||
"kind": "tools",
|
||||
"action": "list",
|
||||
"status": "ok",
|
||||
"tools": json_tools,
|
||||
"count": tool_count,
|
||||
})),
|
||||
})
|
||||
}
|
||||
SlashCommand::Info => {
|
||||
let cwd = env::current_dir().ok();
|
||||
let binary_provenance = binary_provenance_for(cwd.as_deref());
|
||||
let message = format!(
|
||||
"claw-code runtime information:\n Version: {}\n Git SHA: {}\n Rustc: {}\n Target: {}",
|
||||
VERSION,
|
||||
binary_provenance.git_sha_short.as_deref().unwrap_or("unknown"),
|
||||
binary_provenance.rustc_version,
|
||||
binary_provenance.target.as_deref().unwrap_or("unknown")
|
||||
);
|
||||
Ok(ResumeCommandOutcome {
|
||||
session: session.clone(),
|
||||
message: Some(message),
|
||||
json: Some(serde_json::json!({
|
||||
"kind": "info",
|
||||
"action": "show",
|
||||
"status": "ok",
|
||||
"version": VERSION,
|
||||
"git_sha": binary_provenance.git_sha,
|
||||
"git_sha_short": binary_provenance.git_sha_short,
|
||||
"rustc_version": binary_provenance.rustc_version,
|
||||
"target": binary_provenance.target,
|
||||
})),
|
||||
})
|
||||
}
|
||||
SlashCommand::Export { path } => {
|
||||
let export_path = resolve_export_path(path.as_deref(), session)?;
|
||||
fs::write(&export_path, render_export_text(session))?;
|
||||
@@ -7298,6 +7437,18 @@ impl BuiltRuntime {
|
||||
self
|
||||
}
|
||||
|
||||
fn with_turn_interrupt_signal(mut self, signal: runtime::TurnInterruptSignal) -> Self {
|
||||
let mut runtime = self
|
||||
.runtime
|
||||
.take()
|
||||
.expect("runtime should exist before installing turn interrupt signal");
|
||||
runtime
|
||||
.api_client_mut()
|
||||
.set_turn_interrupt_signal(signal.clone());
|
||||
self.runtime = Some(runtime.with_turn_interrupt_signal(signal));
|
||||
self
|
||||
}
|
||||
|
||||
fn shutdown_plugins(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if self.plugins_active {
|
||||
self.plugin_registry.shutdown()?;
|
||||
@@ -7680,7 +7831,10 @@ struct HookAbortMonitor {
|
||||
}
|
||||
|
||||
impl HookAbortMonitor {
|
||||
fn spawn(abort_signal: runtime::HookAbortSignal) -> Self {
|
||||
fn spawn(
|
||||
abort_signal: runtime::HookAbortSignal,
|
||||
turn_interrupt_signal: runtime::TurnInterruptSignal,
|
||||
) -> Self {
|
||||
Self::spawn_with_waiter(abort_signal, move |stop_rx, abort_signal| {
|
||||
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
@@ -7697,7 +7851,11 @@ impl HookAbortMonitor {
|
||||
tokio::select! {
|
||||
result = tokio::signal::ctrl_c() => {
|
||||
if result.is_ok() {
|
||||
// Ctrl+C stops the whole turn, not just running
|
||||
// hooks: the conversation loop and the streaming
|
||||
// client both poll the turn interrupt signal.
|
||||
abort_signal.abort();
|
||||
turn_interrupt_signal.interrupt();
|
||||
}
|
||||
}
|
||||
_ = wait_for_stop => {}
|
||||
@@ -7803,7 +7961,7 @@ impl LiveCli {
|
||||
\x1b[2mDirectory\x1b[0m {}\n\
|
||||
\x1b[2mSession\x1b[0m {}\n\
|
||||
\x1b[2mAuto-save\x1b[0m {}\n\n\
|
||||
Type \x1b[1m/help\x1b[0m for commands · \x1b[1m/status\x1b[0m for live context · \x1b[2m/resume latest\x1b[0m jumps back to the newest session · \x1b[1m/diff\x1b[0m then \x1b[1m/commit\x1b[0m to ship · \x1b[2mTab\x1b[0m for workflow completions · \x1b[2mShift+Enter\x1b[0m for newline",
|
||||
Type \x1b[1m/help\x1b[0m for commands · \x1b[1m/status\x1b[0m for live context · \x1b[2m/resume latest\x1b[0m jumps back to the newest session · \x1b[1m/diff\x1b[0m then \x1b[1m/commit\x1b[0m to ship · \x1b[2mTab\x1b[0m for workflow completions · \x1b[2mShift+Enter\x1b[0m for newline · \x1b[2mEsc\x1b[0m interrupts a running turn",
|
||||
self.model,
|
||||
self.permission_mode.as_str(),
|
||||
git_branch,
|
||||
@@ -7829,8 +7987,12 @@ impl LiveCli {
|
||||
&self,
|
||||
emit_output: bool,
|
||||
terminal_tail: TerminalTail,
|
||||
) -> Result<(BuiltRuntime, HookAbortMonitor), Box<dyn std::error::Error>> {
|
||||
) -> Result<
|
||||
(BuiltRuntime, HookAbortMonitor, runtime::TurnInterruptSignal),
|
||||
Box<dyn std::error::Error>,
|
||||
> {
|
||||
let hook_abort_signal = runtime::HookAbortSignal::new();
|
||||
let turn_interrupt_signal = runtime::TurnInterruptSignal::new();
|
||||
let runtime = build_runtime(
|
||||
self.runtime.session().clone(),
|
||||
&self.session.id,
|
||||
@@ -7843,10 +8005,12 @@ impl LiveCli {
|
||||
self.permission_mode,
|
||||
None,
|
||||
)?
|
||||
.with_hook_abort_signal(hook_abort_signal.clone());
|
||||
let hook_abort_monitor = HookAbortMonitor::spawn(hook_abort_signal);
|
||||
.with_hook_abort_signal(hook_abort_signal.clone())
|
||||
.with_turn_interrupt_signal(turn_interrupt_signal.clone());
|
||||
let hook_abort_monitor =
|
||||
HookAbortMonitor::spawn(hook_abort_signal, turn_interrupt_signal.clone());
|
||||
|
||||
Ok((runtime, hook_abort_monitor))
|
||||
Ok((runtime, hook_abort_monitor, turn_interrupt_signal))
|
||||
}
|
||||
|
||||
fn replace_runtime(&mut self, runtime: BuiltRuntime) -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -7857,8 +8021,16 @@ impl LiveCli {
|
||||
|
||||
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let terminal_tail = TerminalTail::new();
|
||||
let (mut runtime, hook_abort_monitor) =
|
||||
let (mut runtime, hook_abort_monitor, turn_interrupt_signal) =
|
||||
self.prepare_turn_runtime(true, terminal_tail.clone())?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let mut escape_monitor = None;
|
||||
if let Some((monitor, stdin_gate)) =
|
||||
interrupt::EscapeInterruptMonitor::spawn(turn_interrupt_signal)
|
||||
{
|
||||
permission_prompter = permission_prompter.with_stdin_gate(stdin_gate);
|
||||
escape_monitor = Some(monitor);
|
||||
}
|
||||
let mut spinner = Spinner::new();
|
||||
let mut stdout = io::stdout();
|
||||
// The animated frame is cursor control: it saves the cursor, rewrites the
|
||||
@@ -7869,13 +8041,19 @@ impl LiveCli {
|
||||
// emitted either way.
|
||||
if io::stdout().is_terminal() {
|
||||
spinner.tick(
|
||||
"🦀 Thinking...",
|
||||
if escape_monitor.is_some() {
|
||||
"🦀 Thinking... (esc to interrupt)"
|
||||
} else {
|
||||
"🦀 Thinking..."
|
||||
},
|
||||
TerminalRenderer::new().color_theme(),
|
||||
&mut stdout,
|
||||
)?;
|
||||
)?
|
||||
}
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||
if let Some(monitor) = escape_monitor {
|
||||
monitor.stop();
|
||||
}
|
||||
hook_abort_monitor.stop();
|
||||
match result {
|
||||
Ok(summary) => {
|
||||
@@ -7892,7 +8070,11 @@ impl LiveCli {
|
||||
println!();
|
||||
}
|
||||
spinner.finish(
|
||||
"✨ Done",
|
||||
if summary.interrupted {
|
||||
"⏹ Interrupted"
|
||||
} else {
|
||||
"✨ Done"
|
||||
},
|
||||
TerminalRenderer::new().color_theme(),
|
||||
&mut stdout,
|
||||
)?;
|
||||
@@ -8026,7 +8208,7 @@ impl LiveCli {
|
||||
*self.runtime.session_mut() = result.compacted_session.clone();
|
||||
|
||||
// Build a new runtime with the compacted session and retry
|
||||
let (mut new_runtime, hook_abort_monitor) =
|
||||
let (mut new_runtime, hook_abort_monitor, _turn_interrupt_signal) =
|
||||
self.prepare_turn_runtime(true, terminal_tail.clone())?;
|
||||
drop(hook_abort_monitor);
|
||||
|
||||
@@ -8119,7 +8301,7 @@ impl LiveCli {
|
||||
}
|
||||
|
||||
fn run_prompt_compact(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (mut runtime, hook_abort_monitor) =
|
||||
let (mut runtime, hook_abort_monitor, _turn_interrupt_signal) =
|
||||
self.prepare_turn_runtime(false, TerminalTail::new())?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||
@@ -8133,7 +8315,7 @@ impl LiveCli {
|
||||
}
|
||||
|
||||
fn run_prompt_compact_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (mut runtime, hook_abort_monitor) =
|
||||
let (mut runtime, hook_abort_monitor, _turn_interrupt_signal) =
|
||||
self.prepare_turn_runtime(false, TerminalTail::new())?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||
@@ -8159,7 +8341,7 @@ impl LiveCli {
|
||||
}
|
||||
|
||||
fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (mut runtime, hook_abort_monitor) =
|
||||
let (mut runtime, hook_abort_monitor, _turn_interrupt_signal) =
|
||||
self.prepare_turn_runtime(false, TerminalTail::new())?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||
@@ -8286,6 +8468,18 @@ impl LiveCli {
|
||||
Self::print_version(CliOutputFormat::Text);
|
||||
false
|
||||
}
|
||||
SlashCommand::Tools => {
|
||||
if let Err(error) = print_tools(CliOutputFormat::Text) {
|
||||
eprintln!("Tools: {error}");
|
||||
}
|
||||
false
|
||||
}
|
||||
SlashCommand::Info => {
|
||||
if let Err(error) = print_info(CliOutputFormat::Text) {
|
||||
eprintln!("Info: {error}");
|
||||
}
|
||||
false
|
||||
}
|
||||
SlashCommand::Export { path } => {
|
||||
self.export_session(path.as_deref())?;
|
||||
false
|
||||
@@ -9722,6 +9916,7 @@ fn render_repl_help() -> String {
|
||||
" Ctrl-R Reverse-search prompt history".to_string(),
|
||||
" Tab Complete commands, modes, and recent sessions".to_string(),
|
||||
" Ctrl-C Clear input (or exit on empty prompt)".to_string(),
|
||||
" Esc Interrupt the running turn".to_string(),
|
||||
" Shift+Enter/Ctrl+J Insert a newline".to_string(),
|
||||
" Auto-save .claw/sessions/<workspace-fingerprint>/<session-id>.jsonl"
|
||||
.to_string(),
|
||||
@@ -12774,11 +12969,20 @@ impl runtime::HookProgressReporter for CliHookProgressReporter {
|
||||
|
||||
struct CliPermissionPrompter {
|
||||
current_mode: PermissionMode,
|
||||
stdin_gate: Option<interrupt::StdinPromptGate>,
|
||||
}
|
||||
|
||||
impl CliPermissionPrompter {
|
||||
fn new(current_mode: PermissionMode) -> Self {
|
||||
Self { current_mode }
|
||||
Self {
|
||||
current_mode,
|
||||
stdin_gate: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_stdin_gate(mut self, stdin_gate: interrupt::StdinPromptGate) -> Self {
|
||||
self.stdin_gate = Some(stdin_gate);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12787,6 +12991,12 @@ impl runtime::PermissionPrompter for CliPermissionPrompter {
|
||||
&mut self,
|
||||
request: &runtime::PermissionRequest,
|
||||
) -> runtime::PermissionPromptDecision {
|
||||
// Take stdin back from the Esc listener (restoring canonical
|
||||
// mode) for the duration of this line-based prompt.
|
||||
let _stdin_lease = self
|
||||
.stdin_gate
|
||||
.as_ref()
|
||||
.map(interrupt::StdinPromptGate::lease);
|
||||
println!();
|
||||
println!("Permission approval required");
|
||||
println!(" Tool {}", request.tool_name);
|
||||
@@ -12910,6 +13120,7 @@ fn provider_config_from_runtime(
|
||||
"anthropic" => api::ProviderConfigKind::Anthropic,
|
||||
"xai" => api::ProviderConfigKind::Xai,
|
||||
"openai" => api::ProviderConfigKind::OpenAi,
|
||||
"custom-openai" => api::ProviderConfigKind::CustomOpenAi,
|
||||
"dashscope" => api::ProviderConfigKind::DashScope,
|
||||
other => {
|
||||
return Err(format!(
|
||||
@@ -12947,6 +13158,7 @@ fn explicit_provider_kind_for_model(
|
||||
Some("anthropic") => Some(api::ProviderConfigKind::Anthropic),
|
||||
Some("xai") => Some(api::ProviderConfigKind::Xai),
|
||||
Some("openai") | Some("local") => Some(api::ProviderConfigKind::OpenAi),
|
||||
Some("custom-openai") => Some(api::ProviderConfigKind::CustomOpenAi),
|
||||
Some("dashscope") | Some("qwen") | Some("kimi") => Some(api::ProviderConfigKind::DashScope),
|
||||
_ => {
|
||||
let lower = resolved_model.to_ascii_lowercase();
|
||||
@@ -12984,6 +13196,7 @@ struct AnthropicRuntimeClient {
|
||||
tool_registry: GlobalToolRegistry,
|
||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||
reasoning_effort: Option<String>,
|
||||
turn_interrupt_signal: Option<runtime::TurnInterruptSignal>,
|
||||
}
|
||||
|
||||
impl AnthropicRuntimeClient {
|
||||
@@ -13069,12 +13282,17 @@ impl AnthropicRuntimeClient {
|
||||
tool_registry,
|
||||
progress_reporter,
|
||||
reasoning_effort: None,
|
||||
turn_interrupt_signal: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn set_reasoning_effort(&mut self, effort: Option<String>) {
|
||||
self.reasoning_effort = effort;
|
||||
}
|
||||
|
||||
fn set_turn_interrupt_signal(&mut self, signal: runtime::TurnInterruptSignal) {
|
||||
self.turn_interrupt_signal = Some(signal);
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
|
||||
@@ -13118,30 +13336,50 @@ impl ApiClient for AnthropicRuntimeClient {
|
||||
};
|
||||
|
||||
self.runtime.block_on(async {
|
||||
// When resuming after tool execution, apply a stall timeout on the
|
||||
// first stream event. If the model does not respond within the
|
||||
// deadline we drop the stalled connection and re-send the request as
|
||||
// a continuation nudge (one retry only).
|
||||
let max_attempts: usize = if is_post_tool { 2 } else { 1 };
|
||||
let request_loop = async {
|
||||
// When resuming after tool execution, apply a stall timeout on the
|
||||
// first stream event. If the model does not respond within the
|
||||
// deadline we drop the stalled connection and re-send the request as
|
||||
// a continuation nudge (one retry only).
|
||||
let max_attempts: usize = if is_post_tool { 2 } else { 1 };
|
||||
|
||||
for attempt in 1..=max_attempts {
|
||||
let result = self
|
||||
.consume_stream(&message_request, is_post_tool && attempt == 1)
|
||||
.await;
|
||||
match result {
|
||||
Ok(events) => return Ok(events),
|
||||
Err(error)
|
||||
if error.to_string().contains("post-tool stall")
|
||||
&& attempt < max_attempts =>
|
||||
{
|
||||
// Stalled after tool completion — nudge the model by
|
||||
// re-sending the same request.
|
||||
for attempt in 1..=max_attempts {
|
||||
let result = self
|
||||
.consume_stream(&message_request, is_post_tool && attempt == 1)
|
||||
.await;
|
||||
match result {
|
||||
Ok(events) => return Ok(events),
|
||||
Err(error)
|
||||
if error.to_string().contains("post-tool stall")
|
||||
&& attempt < max_attempts =>
|
||||
{
|
||||
// Stalled after tool completion — nudge the model by
|
||||
// re-sending the same request.
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
|
||||
Err(RuntimeError::new("post-tool continuation nudge exhausted"))
|
||||
};
|
||||
|
||||
let Some(interrupt) = self.turn_interrupt_signal.clone() else {
|
||||
return request_loop.await;
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
result = request_loop => result,
|
||||
() = async {
|
||||
while !interrupt.is_interrupted() {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
} => {
|
||||
// Dropping the request future aborts the in-flight HTTP
|
||||
// stream. The conversation loop sees the interrupt flag and
|
||||
// reports this as an interruption rather than a failure.
|
||||
Err(RuntimeError::new("request interrupted by user"))
|
||||
}
|
||||
}
|
||||
|
||||
Err(RuntimeError::new("post-tool continuation nudge exhausted"))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -19664,6 +19902,22 @@ UU conflicted.rs",
|
||||
super::explicit_provider_kind_for_model("openai/gpt-5", &config.model),
|
||||
Some(api::ProviderConfigKind::OpenAi)
|
||||
);
|
||||
assert_eq!(
|
||||
super::explicit_provider_kind_for_model("custom-openai/local-model", &config.model),
|
||||
Some(api::ProviderConfigKind::CustomOpenAi)
|
||||
);
|
||||
assert_eq!(
|
||||
super::explicit_provider_kind_for_model("CUSTOM-OPENAI/local-model", &config.model),
|
||||
Some(api::ProviderConfigKind::CustomOpenAi)
|
||||
);
|
||||
assert_eq!(
|
||||
super::explicit_provider_kind_for_model(
|
||||
"custom-openai/local-model",
|
||||
"custom-openai/local-model"
|
||||
),
|
||||
None,
|
||||
"matching custom model must keep the persisted provider kind"
|
||||
);
|
||||
assert_eq!(
|
||||
super::explicit_provider_kind_for_model("qwen-plus", &config.model),
|
||||
None,
|
||||
@@ -19704,6 +19958,44 @@ UU conflicted.rs",
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_custom_openai_configuration_survives_runtime_boundary() {
|
||||
let config_home = temp_dir();
|
||||
let workspace = temp_dir();
|
||||
fs::create_dir_all(&config_home).expect("config home");
|
||||
fs::create_dir_all(&workspace).expect("workspace");
|
||||
fs::write(
|
||||
config_home.join("settings.json"),
|
||||
r#"{
|
||||
"provider": {
|
||||
"kind": "custom-openai",
|
||||
"apiKey": "custom-test-key",
|
||||
"baseUrl": "https://custom.example/v1",
|
||||
"model": "local-model"
|
||||
},
|
||||
"model": "local-model"
|
||||
}"#,
|
||||
)
|
||||
.expect("write provider settings");
|
||||
|
||||
let loader = ConfigLoader::new(&workspace, &config_home);
|
||||
let runtime_config = loader.load().expect("runtime config should load");
|
||||
let provider_config = super::provider_config_from_runtime(runtime_config.provider())
|
||||
.expect("custom provider configuration should adapt")
|
||||
.expect("persisted provider configuration should be present");
|
||||
|
||||
assert_eq!(provider_config.kind, api::ProviderConfigKind::CustomOpenAi);
|
||||
assert_eq!(provider_config.model, "local-model");
|
||||
assert_eq!(provider_config.api_key.as_deref(), Some("custom-test-key"));
|
||||
assert_eq!(
|
||||
provider_config.base_url.as_deref(),
|
||||
Some("https://custom.example/v1")
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_provider_configuration_controls_runtime_provider_dispatch() {
|
||||
let config_home = temp_dir();
|
||||
|
||||
@@ -249,16 +249,6 @@ impl TerminalRenderer {
|
||||
|
||||
#[must_use]
|
||||
pub fn render_markdown(&self, markdown: &str) -> String {
|
||||
self.render_markdown_raw(markdown).trim_end().to_string()
|
||||
}
|
||||
|
||||
/// Render markdown to ANSI without trimming trailing whitespace.
|
||||
///
|
||||
/// Block-level events (paragraphs, headings, lists, code blocks) emit their
|
||||
/// trailing newline separators here. `render_markdown` trims them for
|
||||
/// one-shot output, while streaming keeps them so that successive chunks
|
||||
/// stay on their own lines instead of running together.
|
||||
fn render_markdown_raw(&self, markdown: &str) -> String {
|
||||
let normalized = normalize_nested_fences(markdown);
|
||||
let mut output = String::new();
|
||||
let mut state = RenderState::default();
|
||||
@@ -277,7 +267,30 @@ impl TerminalRenderer {
|
||||
);
|
||||
}
|
||||
|
||||
output
|
||||
output.trim_end().to_string()
|
||||
}
|
||||
|
||||
/// Ensures the output ends with at least `required` newlines.
|
||||
/// This is idempotent: calling it multiple times with the same output
|
||||
/// will not add extra newlines beyond what is needed.
|
||||
/// The function is crucial for streaming rendering where chunks may
|
||||
/// split at block boundaries, causing the parser to lose newlines
|
||||
/// that normally separate blocks.
|
||||
fn ensure_newlines(output: &mut String, required: usize) {
|
||||
if output.is_empty() {
|
||||
// Don't start with blank lines
|
||||
return;
|
||||
}
|
||||
|
||||
// Count trailing newlines
|
||||
let trailing_newlines = output.chars().rev().take_while(|&c| c == '\n').count();
|
||||
|
||||
// Add newlines if we don't have enough
|
||||
if trailing_newlines < required {
|
||||
for _ in 0..(required - trailing_newlines) {
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -285,18 +298,6 @@ impl TerminalRenderer {
|
||||
self.render_markdown(markdown)
|
||||
}
|
||||
|
||||
/// Stream-friendly variant of `markdown_to_ansi` that preserves trailing
|
||||
/// block separators (such as a paragraph's closing newlines).
|
||||
///
|
||||
/// `MarkdownStreamState::push` writes each rendered chunk directly to the
|
||||
/// terminal with no separator of its own, so the trailing newlines must
|
||||
/// survive here to keep a paragraph from sticking to the block that follows
|
||||
/// it. The final `flush` still uses `markdown_to_ansi` to trim the tail.
|
||||
#[must_use]
|
||||
pub fn markdown_to_ansi_stream(&self, markdown: &str) -> String {
|
||||
self.render_markdown_raw(markdown)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn render_event(
|
||||
&self,
|
||||
@@ -309,10 +310,14 @@ impl TerminalRenderer {
|
||||
) {
|
||||
match event {
|
||||
Event::Start(Tag::Heading { level, .. }) => {
|
||||
Self::ensure_newlines(output, 1);
|
||||
Self::start_heading(state, level as u8, output);
|
||||
}
|
||||
Event::End(TagEnd::Paragraph) => output.push_str("\n\n"),
|
||||
Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
|
||||
Event::Start(Tag::BlockQuote(..)) => {
|
||||
Self::ensure_newlines(output, 1);
|
||||
self.start_quote(state, output);
|
||||
}
|
||||
Event::End(TagEnd::BlockQuote(..)) => {
|
||||
state.quote = state.quote.saturating_sub(1);
|
||||
output.push('\n');
|
||||
@@ -325,6 +330,7 @@ impl TerminalRenderer {
|
||||
state.append_raw(output, "\n");
|
||||
}
|
||||
Event::Start(Tag::List(first_item)) => {
|
||||
Self::ensure_newlines(output, 1);
|
||||
let kind = match first_item {
|
||||
Some(index) => ListKind::Ordered { next_index: index },
|
||||
None => ListKind::Unordered,
|
||||
@@ -337,6 +343,7 @@ impl TerminalRenderer {
|
||||
}
|
||||
Event::Start(Tag::Item) => Self::start_item(state, output),
|
||||
Event::Start(Tag::CodeBlock(kind)) => {
|
||||
Self::ensure_newlines(output, 2);
|
||||
*in_code_block = true;
|
||||
*code_language = match kind {
|
||||
CodeBlockKind::Indented => String::from("text"),
|
||||
@@ -360,7 +367,10 @@ impl TerminalRenderer {
|
||||
format!("{}", format!("`{code}`").with(self.color_theme.inline_code));
|
||||
state.append_raw(output, &rendered);
|
||||
}
|
||||
Event::Rule => output.push_str("---\n"),
|
||||
Event::Rule => {
|
||||
Self::ensure_newlines(output, 1);
|
||||
output.push_str("---\n");
|
||||
}
|
||||
Event::Text(text) => {
|
||||
self.push_text(text.as_ref(), state, output, code_buffer, *in_code_block);
|
||||
}
|
||||
@@ -405,7 +415,10 @@ impl TerminalRenderer {
|
||||
);
|
||||
state.append_raw(output, &rendered);
|
||||
}
|
||||
Event::Start(Tag::Table(..)) => state.table = Some(TableState::default()),
|
||||
Event::Start(Tag::Table(..)) => {
|
||||
Self::ensure_newlines(output, 1);
|
||||
state.table = Some(TableState::default());
|
||||
}
|
||||
Event::End(TagEnd::Table) => {
|
||||
if let Some(table) = state.table.take() {
|
||||
output.push_str(&self.render_table(&table));
|
||||
@@ -619,9 +632,21 @@ impl TerminalRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarkdownStreamState {
|
||||
pending: String,
|
||||
/// Track whether the last emitted output ended with a newline.
|
||||
/// Used to ensure proper spacing when resuming from a buffer.
|
||||
last_output_ends_with_newline: bool,
|
||||
}
|
||||
|
||||
impl Default for MarkdownStreamState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pending: String::new(),
|
||||
last_output_ends_with_newline: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MarkdownStreamState {
|
||||
@@ -631,7 +656,40 @@ impl MarkdownStreamState {
|
||||
let split = find_stream_safe_boundary(&self.pending)?;
|
||||
let ready = self.pending[..split].to_string();
|
||||
self.pending.drain(..split);
|
||||
Some(renderer.markdown_to_ansi_stream(&ready))
|
||||
|
||||
let mut rendered = renderer.markdown_to_ansi(&ready);
|
||||
let stripped = strip_ansi(&rendered);
|
||||
|
||||
// If the previous output didn't end with a newline and this chunk starts
|
||||
// with a block-level element, ensure proper spacing before it.
|
||||
if !self.last_output_ends_with_newline && !rendered.is_empty() {
|
||||
if stripped.starts_with("\u{256d}\u{2500}")
|
||||
|| stripped.starts_with("```")
|
||||
|| stripped.starts_with("|")
|
||||
|| stripped.starts_with("• ")
|
||||
|| stripped.starts_with("1. ")
|
||||
|| stripped.starts_with("-")
|
||||
{
|
||||
rendered = format!("\n\n{}", rendered);
|
||||
}
|
||||
}
|
||||
|
||||
// When markdown ends with a code block closing marker or other block-level element,
|
||||
// render_markdown() trims the trailing newlines that finish_code_block() added.
|
||||
// For streaming, we need to restore these newlines so proper spacing is preserved
|
||||
// when chunks are combined. This is critical for idempotence across re-renders.
|
||||
if stripped.ends_with("\u{2570}\u{2500}")
|
||||
|| stripped.ends_with("---")
|
||||
|| stripped.ends_with("|")
|
||||
{
|
||||
// Block element that should have trailing spacing
|
||||
if !rendered.ends_with('\n') {
|
||||
rendered.push_str("\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
self.last_output_ends_with_newline = rendered.ends_with('\n');
|
||||
Some(rendered)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -641,7 +699,26 @@ impl MarkdownStreamState {
|
||||
None
|
||||
} else {
|
||||
let pending = std::mem::take(&mut self.pending);
|
||||
Some(renderer.markdown_to_ansi(&pending))
|
||||
let mut rendered = renderer.markdown_to_ansi(&pending);
|
||||
|
||||
// If the previous output didn't end with a newline and this output
|
||||
// starts with a block-level element, ensure proper spacing.
|
||||
if !self.last_output_ends_with_newline && !rendered.is_empty() {
|
||||
// Check if output starts with a block element by looking for common markers
|
||||
let stripped = strip_ansi(&rendered);
|
||||
if stripped.starts_with("\u{256d}\u{2500}")
|
||||
|| stripped.starts_with("```")
|
||||
|| stripped.starts_with("|")
|
||||
|| stripped.starts_with("• ")
|
||||
|| stripped.starts_with("1. ")
|
||||
|| stripped.starts_with("-")
|
||||
{
|
||||
rendered = format!("\n\n{}", rendered);
|
||||
}
|
||||
}
|
||||
|
||||
self.last_output_ends_with_newline = rendered.ends_with('\n');
|
||||
Some(rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -835,6 +912,11 @@ fn normalize_nested_fences(markdown: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Find a safe boundary for streaming markdown rendering.
|
||||
/// Safe boundaries are:
|
||||
/// - After a closing fence line (when fence is closed)
|
||||
/// - After an empty line
|
||||
/// This function returns the position of the last safe boundary found.
|
||||
fn find_stream_safe_boundary(markdown: &str) -> Option<usize> {
|
||||
let mut open_fence: Option<FenceMarker> = None;
|
||||
let mut last_boundary = None;
|
||||
@@ -1074,34 +1156,6 @@ mod tests {
|
||||
assert!(plain_text.contains("fn nested()"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_rendering_preserves_block_separators() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
|
||||
// The stream variant keeps the paragraph-ending newlines that the
|
||||
// one-shot variant trims, so consecutive streamed chunks do not run
|
||||
// into each other when written back-to-back to the terminal.
|
||||
let streamed = renderer.markdown_to_ansi_stream("Intro paragraph.\n\n");
|
||||
assert!(streamed.ends_with('\n'));
|
||||
assert!(!renderer
|
||||
.markdown_to_ansi("Intro paragraph.\n\n")
|
||||
.ends_with('\n'));
|
||||
|
||||
let mut state = MarkdownStreamState::default();
|
||||
let first = state
|
||||
.push(&renderer, "Intro paragraph.\n\n")
|
||||
.expect("blank line closes the paragraph block");
|
||||
let second = state
|
||||
.push(&renderer, "1. first item\n\n")
|
||||
.expect("blank line closes the list block");
|
||||
|
||||
let combined = strip_ansi(&format!("{first}{second}"));
|
||||
assert!(combined.contains("Intro paragraph."));
|
||||
assert!(combined.contains("1. first item"));
|
||||
// The two blocks stay on separate lines instead of sticking together.
|
||||
assert!(!combined.contains("Intro paragraph.1."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spinner_advances_frames() {
|
||||
let terminal_renderer = TerminalRenderer::new();
|
||||
@@ -1117,4 +1171,189 @@ mod tests {
|
||||
let output = String::from_utf8_lossy(&out);
|
||||
assert!(output.contains("Working"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_text_before_code_block_preserves_newline_boundary() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
let mut state = MarkdownStreamState::default();
|
||||
|
||||
// Simulate streaming: paragraph text arrives in one chunk
|
||||
assert_eq!(
|
||||
state.push(&renderer, "This is some text."),
|
||||
None,
|
||||
"incomplete paragraph should not flush"
|
||||
);
|
||||
|
||||
// Then code block arrives in the next chunk
|
||||
let rendered = state
|
||||
.push(&renderer, "\n\n```rust\nfn main() {}\n```\n")
|
||||
.expect("fenced block should trigger flush");
|
||||
let plain_text = strip_ansi(&rendered);
|
||||
|
||||
// Verify the paragraph text and code block are present
|
||||
assert!(plain_text.contains("This is some text."));
|
||||
assert!(plain_text.contains("fn main()"));
|
||||
|
||||
// The critical check: there should be a blank line between the paragraph and code block
|
||||
// The paragraph ends, then there should be newlines before the code block marker
|
||||
let lines: Vec<&str> = plain_text.lines().collect();
|
||||
let text_line_idx = lines
|
||||
.iter()
|
||||
.position(|line| line.contains("This is some text."))
|
||||
.expect("find text line");
|
||||
// Skip past the text line and find the code block
|
||||
let code_marker_idx = lines[text_line_idx + 1..]
|
||||
.iter()
|
||||
.position(|line| line.contains("╭─ rust"))
|
||||
.map(|i| i + text_line_idx + 1);
|
||||
|
||||
// There should be at least one empty line between the text and the code block marker
|
||||
if let Some(code_idx) = code_marker_idx {
|
||||
assert!(
|
||||
code_idx > text_line_idx + 1,
|
||||
"there should be spacing between paragraph and code block"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendering_paragraph_immediately_before_code_block_has_blank_line() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
let markdown = "This is a paragraph.\n\n```rust\nfn main() {}\n```";
|
||||
let output = renderer.markdown_to_ansi(markdown);
|
||||
let plain_text = strip_ansi(&output);
|
||||
let lines: Vec<&str> = plain_text.lines().collect();
|
||||
|
||||
// Find the paragraph and code block
|
||||
let text_idx = lines
|
||||
.iter()
|
||||
.position(|l| l.contains("This is a paragraph."))
|
||||
.expect("find paragraph");
|
||||
let code_idx = lines[text_idx + 1..]
|
||||
.iter()
|
||||
.position(|l| l.contains("╭─ rust"))
|
||||
.map(|i| i + text_idx + 1)
|
||||
.expect("find code block");
|
||||
|
||||
// There should be at least one blank line between them
|
||||
assert!(
|
||||
code_idx > text_idx + 1,
|
||||
"code block should not immediately follow paragraph"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_block_ending_with_trailing_text_preserves_spacing() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
let markdown = "```rust\nfn main() {}\n```\n\nText after.";
|
||||
let output = renderer.markdown_to_ansi(markdown);
|
||||
let plain_text = strip_ansi(&output);
|
||||
|
||||
// The code block should end with "╰─" followed by blank lines, then text
|
||||
assert!(
|
||||
plain_text.contains("\u{256d}\u{2500}"),
|
||||
"should contain code block close marker"
|
||||
);
|
||||
assert!(
|
||||
plain_text.contains("\n\nText"),
|
||||
"should have blank line before text"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_code_block_preserves_trailing_newlines() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
let mut state = MarkdownStreamState::default();
|
||||
let markdown = "Text before.\n\n```rust\nfn test() {}\n```\n\nText after.";
|
||||
|
||||
// Debug: let's check what the code block part alone renders to
|
||||
let code_part = "```rust\nfn test() {}\n```\n\nText after.";
|
||||
let code_rendered = strip_ansi(&renderer.markdown_to_ansi(code_part));
|
||||
|
||||
// Simulate the streaming at split position 1
|
||||
let r1 = state.push(&renderer, "T").map(|s| strip_ansi(&s));
|
||||
let r2 = state
|
||||
.push(
|
||||
&renderer,
|
||||
"ext before.\n\n```rust\nfn test() {}\n```\n\nText after.",
|
||||
)
|
||||
.map(|s| strip_ansi(&s));
|
||||
|
||||
// After r2, the pending should contain the code block part
|
||||
let pending_before_r3 = state.pending.clone();
|
||||
|
||||
let r3 = state.flush(&renderer).map(|s| strip_ansi(&s));
|
||||
|
||||
// Combine the results
|
||||
let mut full_streamed = String::new();
|
||||
if let Some(s) = &r1 {
|
||||
full_streamed.push_str(s);
|
||||
}
|
||||
if let Some(s) = &r2 {
|
||||
full_streamed.push_str(s);
|
||||
}
|
||||
if let Some(s) = &r3 {
|
||||
full_streamed.push_str(s);
|
||||
}
|
||||
|
||||
let full_rendered = strip_ansi(&renderer.markdown_to_ansi(markdown));
|
||||
|
||||
assert_eq!(full_streamed, full_rendered,
|
||||
"Streaming mismatch.\n\nCode part alone renders to: {:?}\nPending before r3: {:?}\n\nBreakdown:\n r1: {:?}\n r2: {:?}\n r3: {:?}\n\nExpected:\n{:?}\n\nGot:\n{:?}",
|
||||
code_rendered, pending_before_r3, r1, r2, r3, full_rendered, full_streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_with_split_at_every_byte_position_produces_consistent_output() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
let markdown = "Text before.\n\n```rust\nfn test() {}\n```\n\nText after.";
|
||||
|
||||
// First, render the full document
|
||||
let full_output = renderer.markdown_to_ansi(markdown);
|
||||
let full_plain = strip_ansi(&full_output);
|
||||
|
||||
// Now simulate streaming by splitting at different positions
|
||||
for split_pos in 1..markdown.len() {
|
||||
let mut state = MarkdownStreamState::default();
|
||||
let part1 = &markdown[..split_pos];
|
||||
let part2 = &markdown[split_pos..];
|
||||
|
||||
// Try to push both parts
|
||||
let r1 = state.push(&renderer, part1);
|
||||
let r2 = state.push(&renderer, part2);
|
||||
let final_r = state.flush(&renderer);
|
||||
|
||||
// Reconstruct the streamed output
|
||||
let mut streamed_output = String::new();
|
||||
if let Some(out) = r1 {
|
||||
streamed_output.push_str(&out);
|
||||
}
|
||||
if let Some(out) = r2 {
|
||||
streamed_output.push_str(&out);
|
||||
}
|
||||
if let Some(out) = final_r {
|
||||
streamed_output.push_str(&out);
|
||||
}
|
||||
|
||||
let streamed_plain = strip_ansi(&streamed_output);
|
||||
|
||||
if streamed_plain != full_plain {
|
||||
eprintln!("\n=== MISMATCH at split position {} ===", split_pos);
|
||||
eprintln!("Part 1: {:?}", part1);
|
||||
eprintln!("Part 2: {:?}", part2);
|
||||
eprintln!("\nExpected (full):");
|
||||
eprintln!("{:?}", full_plain);
|
||||
eprintln!("\nGot (streamed):");
|
||||
eprintln!("{:?}", streamed_plain);
|
||||
eprintln!("\nStreamed rendered output:");
|
||||
eprintln!("{:?}", streamed_output);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
streamed_plain, full_plain,
|
||||
"streaming with split at position {} produces different output",
|
||||
split_pos
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ const PROVIDERS: &[(&str, &str, &str)] = &[
|
||||
("2", "xAI / Grok", "xai"),
|
||||
("3", "OpenAI", "openai"),
|
||||
("4", "DashScope (Qwen/Kimi)", "dashscope"),
|
||||
("5", "Custom (OpenAI-compat)", "openai"),
|
||||
("5", "Custom (OpenAI-compat)", "custom-openai"),
|
||||
];
|
||||
|
||||
const PROVIDER_MODELS: &[(&str, &[&str])] = &[
|
||||
@@ -27,6 +27,8 @@ const DEFAULT_BASE_URLS: &[(&str, &str)] = &[
|
||||
"dashscope",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
),
|
||||
// Custom OpenAI has no default; user must supply.
|
||||
("custom-openai", ""),
|
||||
];
|
||||
|
||||
const API_KEY_ENV_VARS: &[(&str, &str)] = &[
|
||||
@@ -34,6 +36,7 @@ const API_KEY_ENV_VARS: &[(&str, &str)] = &[
|
||||
("xai", "XAI_API_KEY"),
|
||||
("openai", "OPENAI_API_KEY"),
|
||||
("dashscope", "DASHSCOPE_API_KEY"),
|
||||
("custom-openai", "CLAWCUSTOMOPENAI_API_KEY"),
|
||||
];
|
||||
|
||||
pub fn run_setup_wizard() -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -177,6 +180,7 @@ fn prompt_base_url(
|
||||
"xai" => "XAI_BASE_URL",
|
||||
"openai" => "OPENAI_BASE_URL",
|
||||
"dashscope" => "DASHSCOPE_BASE_URL",
|
||||
"custom-openai" => "CLAWCUSTOMOPENAI_BASE_URL",
|
||||
_ => "BASE_URL",
|
||||
};
|
||||
let env_set = std::env::var(env_var).ok().is_some_and(|v| !v.is_empty());
|
||||
@@ -214,7 +218,11 @@ fn prompt_model(
|
||||
if !aliases.is_empty() {
|
||||
println!(" Common: {}", aliases.join(", "));
|
||||
}
|
||||
println!(" Or enter any model name (e.g. openai/gpt-4.1-mini for custom routing)");
|
||||
if kind == "custom-openai" {
|
||||
println!(" Or enter any model name (e.g. custom/gpt-4.1 for custom routing)");
|
||||
} else {
|
||||
println!(" Or enter any model name (e.g. openai/gpt-4.1-mini for custom routing)");
|
||||
}
|
||||
|
||||
let input = read_line(&format!(" Model [{current_model}]: "))?;
|
||||
if input.trim().is_empty() {
|
||||
|
||||
Reference in new issue
Block a user