Compare commits

..
Author SHA1 Message Date
clawdbot eb1537ca92 docs: add CHANGELOG for v0.1.4 release 2026-10-13 00:03:23 +00:00
Bellman 0508c2aa05 Merge pull request #3334 from ultraworkers/lane/gjc-s21-conflicts
Implement :conflicts selector for read_file tool (#3323)
2026-10-13 09:02:11 +09:00
clawdbot 2b64546b30 Apply rustfmt formatting 2026-10-12 23:59:04 +00:00
clawdbot 30f607e03a Implement :conflicts selector for read_file tool
- Add real git merge conflict extraction matching GJC behavior
- Extract unresolved conflict blocks (<<<<<<, =======, >>>>>>>)
- Support 3-way conflicts with diff3 markers (|||||||)
- Format conflicts as indexed summary with line ranges and labels
- Return clear 'No unresolved git merge conflicts' when clean
- Add comprehensive behavior tests for 2-way, 3-way, and no-conflicts cases
- Update documentation to mark :conflicts as implemented
- Closes #3323

Tests:
- read_with_selector_conflicts_no_conflicts: verifies clean file message
- read_with_selector_conflicts_two_way: verifies multiple conflict detection
- read_with_selector_conflicts_three_way: verifies diff3 marker support
2026-10-12 23:57:50 +00:00
14 changed files with 384 additions and 713 deletions
+38
View File
@@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.4] - 2026-10-13
### Added
- GJC-compatible line range selectors for read_file tool (`:50-200`, `:50+150` syntax)
- 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
### Fixed
- Windows path containment checks for security boundary enforcement
- Git bash detection on Windows with proper probe caching
- Unresolvable path denial to prevent escape attempts
- Auto-compaction context window measurement
- Tool result preservation on failed turns
- Model stall recovery without tool calls
- Streaming markdown formatting with preserved block separators
- Local model validation bypass generalization beyond Ollama
- Release artifact checksums on macOS and Linux
- Duplicate assistant text rendering
- Workspace slash command wire-up
### Changed
- Enhanced path scope validation for multiple Windows path spellings
- Improved error handling and configuration defaults
- Upgraded GitHub Actions to immutable commit SHAs for CI security
### Known Limitations
- ACP / Zed integration is not yet implemented (status stub available via `claw acp`)
- Python porting workspace is for reference only, not used in production
## [0.1.3] - 2026-10-13
This is the first public binary release of Claw Code.
+3 -2
View File
@@ -222,7 +222,7 @@ pub fn read_file_in_workspace(
**Missing**:
- ✗ Line range selectors (`:L-L+N` syntax)
- ✗ Raw mode (`:raw`)
- ✗ Git merge conflict extraction (`:conflicts`)
- ✓ Git merge conflict extraction (`:conflicts`)
- ✗ Truncation footers with recovery hints
- ✗ Structural summarization (collapse braces)
- ✗ Archive/SQLite support
@@ -272,7 +272,8 @@ pub fn read_file_in_workspace(
### Batch 1: READ Tool (Priority P0)
- Add line range selector parsing
- Implement `:raw` and `:conflicts` modes
- Implement `:raw` mode
- ✓ Implement `:conflicts` mode
- Add truncation footers with recovery hints
- Implement structural summarization
- Add tests for all selector combinations
+1 -1
View File
@@ -41,7 +41,7 @@ TUI rule: formatting fns take `&mut impl Write`, never stdout directly. Never mi
Library crates don't carry the `claw-` prefix. Binary crates do (except legacy `rusty-claude-cli`).
Workspace version is `0.1.3`, `publish = false`, MIT license.
Workspace version is `0.1.4`, `publish = false`, MIT license.
No rust-toolchain file, no MSRV. CI pins `dtolnay/rust-toolchain@stable`.
+12 -13
View File
@@ -93,7 +93,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "api"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"criterion",
"reqwest",
@@ -478,7 +478,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "claw-analog"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"api",
"clap",
@@ -498,7 +498,7 @@ dependencies = [
[[package]]
name = "claw-rag-service"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"axum 0.8.9",
"blake3",
@@ -531,7 +531,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "commands"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"plugins",
"runtime",
@@ -540,7 +540,7 @@ dependencies = [
[[package]]
name = "compat-harness"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"commands",
"runtime",
@@ -1568,7 +1568,7 @@ dependencies = [
[[package]]
name = "mock-anthropic-service"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"api",
"serde_json",
@@ -1761,7 +1761,7 @@ dependencies = [
[[package]]
name = "plugins"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"serde",
"serde_json",
@@ -2136,7 +2136,7 @@ dependencies = [
[[package]]
name = "runtime"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"glob",
"plugins",
@@ -2261,14 +2261,13 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "rusty-claude-cli"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"api",
"commands",
"crossterm",
"log",
"mock-anthropic-service",
"nix",
"plugins",
"pulldown-cmark",
"runtime",
@@ -2334,7 +2333,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sdk"
version = "0.1.0"
version = "0.1.4"
dependencies = [
"futures-util",
"serde",
@@ -2620,7 +2619,7 @@ dependencies = [
[[package]]
name = "telemetry"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"serde",
"serde_json",
@@ -2907,7 +2906,7 @@ dependencies = [
[[package]]
name = "tools"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"api",
"aspect-core",
+1 -1
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.1.3"
version = "0.1.4"
edition = "2021"
license = "MIT"
publish = false
+1 -1
View File
@@ -82,7 +82,7 @@ async fn send_message_posts_json_and_parses_response() {
);
assert_eq!(
request.headers.get("user-agent").map(String::as_str),
Some("claude-code/0.1.3")
Some("claude-code/0.1.4")
);
assert_eq!(
request.headers.get("anthropic-beta").map(String::as_str),
+2 -266
View File
@@ -1,9 +1,5 @@
use std::collections::BTreeMap;
use std::fmt::{Display, Formatter};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use serde_json::{Map, Value};
use telemetry::SessionTracer;
@@ -57,41 +53,6 @@ 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>;
@@ -157,7 +118,6 @@ 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.
@@ -178,7 +138,6 @@ 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>,
}
@@ -233,7 +192,6 @@ 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,
}
@@ -264,15 +222,6 @@ 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,
@@ -406,14 +355,8 @@ 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(
@@ -430,14 +373,6 @@ 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);
}
@@ -486,25 +421,6 @@ 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()
@@ -604,10 +520,6 @@ where
self.record_tool_finished(iterations, &result_message);
tool_results.push(result_message);
}
if interrupted {
break;
}
}
let summary = TurnSummary {
@@ -617,11 +529,8 @@ where
iterations,
usage: self.usage_tracker.cumulative_usage(),
auto_compaction,
interrupted,
};
if !interrupted {
self.record_turn_completed(&summary);
}
self.record_turn_completed(&summary);
Ok(summary)
}
@@ -785,17 +694,6 @@ 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;
@@ -957,8 +855,7 @@ mod tests {
use super::{
build_assistant_message, parse_auto_compaction_threshold, ApiClient, ApiRequest,
AssistantEvent, AutoCompactionEvent, ConversationRuntime, PromptCacheEvent, RuntimeError,
StaticToolExecutor, ToolExecutor, TurnInterruptSignal,
DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD,
StaticToolExecutor, ToolExecutor, DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD,
};
use crate::compact::CompactionConfig;
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
@@ -2059,165 +1956,4 @@ 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"));
}
}
+288 -1
View File
@@ -247,6 +247,34 @@ pub fn read_file_with_selector(
let lines: Vec<&str> = content.lines().collect();
let total_lines = lines.len();
// Special handling for conflicts selector
if selector == Some("conflicts") {
let conflicts = extract_merge_conflicts(&lines);
let (selected, num_lines) = if conflicts.is_empty() {
(format!("No unresolved git merge conflicts in {}.", path), 1)
} else {
let display_path = absolute_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path);
let summary = format_conflict_summary(&conflicts, display_path);
let summary_lines = summary.lines().count();
(summary, summary_lines)
};
return Ok(ReadFileOutput {
kind: String::from("text"),
file: TextFilePayload {
file_path: absolute_path.to_string_lossy().into_owned(),
content: selected,
num_lines,
start_line: 1,
total_lines,
},
truncation_footer: None,
});
}
// Determine which lines to select
let (start_index, end_index, truncation_footer) = if let Some(sel) = selector {
// Parse and apply selector
@@ -281,6 +309,195 @@ pub fn read_file_with_selector(
})
}
/// Represents a merge conflict block in a file.
#[derive(Debug, Clone, PartialEq, Eq)]
struct ConflictBlock {
start_line: usize, // 1-based line number where <<<<<<< starts
separator_line: usize, // 1-based line number where ======= is
end_line: usize, // 1-based line number where >>>>>>> is
base_line: Option<usize>, // 1-based line number where ||||||| is (for 3-way)
ours_label: Option<String>,
base_label: Option<String>,
theirs_label: Option<String>,
}
/// Scans lines for unresolved git merge conflict blocks.
fn extract_merge_conflicts(lines: &[&str]) -> Vec<ConflictBlock> {
let mut blocks = Vec::new();
let mut i = 0;
const OURS_PREFIX: &str = "<<<<<<<";
const BASE_PREFIX: &str = "|||||||";
const SEPARATOR: &str = "=======";
const THEIRS_PREFIX: &str = ">>>>>>>";
while i < lines.len() {
let line = lines[i];
// Check for start of conflict block
if let Some(label) = extract_marker_label(line, OURS_PREFIX) {
let ours_label = if label.is_empty() {
None
} else {
Some(label.to_string())
};
let start_line = i + 1; // Convert to 1-based
let mut separator_line = None;
let mut base_line = None;
let mut base_label = None;
let mut end_line = None;
let mut theirs_label = None;
let mut j = i + 1;
// Look for separator, optional base marker, and end marker
while j < lines.len() {
let current = lines[j];
// Check for base marker (3-way)
if let Some(label) = extract_marker_label(current, BASE_PREFIX) {
if base_line.is_none() {
base_line = Some(j + 1);
base_label = if label.is_empty() {
None
} else {
Some(label.to_string())
};
j += 1;
continue;
}
}
// Check for separator
if current.trim() == SEPARATOR {
if separator_line.is_none() {
separator_line = Some(j + 1);
j += 1;
continue;
}
}
// Check for end marker
if let Some(label) = extract_marker_label(current, THEIRS_PREFIX) {
if separator_line.is_some() {
end_line = Some(j + 1);
theirs_label = if label.is_empty() {
None
} else {
Some(label.to_string())
};
j += 1;
break;
}
}
j += 1;
}
// Only add if we found both separator and end marker
if let (Some(sep_line), Some(end)) = (separator_line, end_line) {
blocks.push(ConflictBlock {
start_line,
separator_line: sep_line,
end_line: end,
base_line,
ours_label,
base_label,
theirs_label,
});
i = j;
continue;
}
}
i += 1;
}
blocks
}
/// Extract the optional label from a marker line.
/// Returns the label text (stripped of leading/trailing spaces) if the line matches the prefix,
/// or None otherwise.
fn extract_marker_label<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
if line.starts_with(prefix) {
let rest = &line[prefix.len()..];
// Accept either just the prefix or prefix + single space + label
if rest.is_empty() {
Some("")
} else if rest.starts_with(' ') {
Some(rest[1..].trim())
} else {
None
}
} else {
None
}
}
/// Format conflict blocks as a summary index, like GJC's implementation.
fn format_conflict_summary(conflicts: &[ConflictBlock], display_path: &str) -> String {
let mut output = Vec::new();
let total = conflicts.len();
let word = if total == 1 { "conflict" } else { "conflicts" };
output.push(format!(
"⚠ {} unresolved {} in {}",
total, word, display_path
));
// Extract labels for header
let ours_label = conflicts.iter().find_map(|c| c.ours_label.as_ref());
let theirs_label = conflicts.iter().find_map(|c| c.theirs_label.as_ref());
let base_label = conflicts.iter().find_map(|c| c.base_label.as_ref());
let any_base = conflicts.iter().any(|c| c.base_line.is_some());
if let Some(label) = ours_label {
output.push(format!("- ours = {}", label));
}
if let Some(label) = theirs_label {
output.push(format!("- theirs = {}", label));
}
if any_base {
let base_name = base_label
.map(|l| l.to_string())
.unwrap_or_else(|| "(no label)".to_string());
output.push(format!("- base = {}", base_name));
}
output.push(
"NOTICE: Bulk-resolve with `write({ path: \"conflict://*\", content })`, or address a single block with `write({ path: \"conflict://<N>\", content })`. Inspect a block by reading `conflict://<N>` (add `/ours` / `/theirs` / `/base` for a single side).".to_string()
);
output.push(
"`content` shorthand: `@ours` / `@theirs` / `@base` / `@both` lines expand to the recorded sections; `@both` = ours-then-theirs. Non-token lines pass through verbatim.".to_string()
);
output.push(String::new());
// Format each conflict as a one-liner index
let id_width = total.to_string().len();
for (idx, conflict) in conflicts.iter().enumerate() {
let id = idx + 1;
let range = if conflict.start_line == conflict.end_line {
format!("L{}", conflict.start_line)
} else {
format!("L{}-{}", conflict.start_line, conflict.end_line)
};
let kind = if conflict.base_line.is_some() {
" (3-way)"
} else {
""
};
output.push(format!(
"#{:>width$} {}{}",
id,
range,
kind,
width = id_width
));
}
output.join("\n")
}
/// Apply a parsed line selector to file lines and return the selected range.
///
/// Returns (start_index, end_index, truncation_footer).
@@ -295,7 +512,7 @@ fn apply_line_selector(
(0, lines.len(), None)
}
LineRangeSelector::Conflicts => {
// For now, return entire file (git merge conflict extraction would go here)
// Conflicts are handled specially in read_file_with_selector, so this shouldn't be reached
(0, lines.len(), None)
}
LineRangeSelector::Lines(ranges) => {
@@ -1400,4 +1617,74 @@ mod tests {
);
assert!(result.is_err());
}
#[test]
fn read_with_selector_conflicts_no_conflicts() {
let path = temp_path("conflicts-none.txt");
write_file(
path.to_string_lossy().as_ref(),
"const a = 1;\nconst b = 2;\n",
)
.expect("write should succeed");
let output = super::read_file_with_selector(
path.to_string_lossy().as_ref(),
None,
None,
Some("conflicts"),
)
.expect("read with conflicts selector should succeed");
assert!(output
.file
.content
.contains("No unresolved git merge conflicts"));
}
#[test]
fn read_with_selector_conflicts_two_way() {
let path = temp_path("conflicts-two-way.txt");
let content = "<<<<<<< A\na-ours\n=======\na-theirs\n>>>>>>> A\nmiddle\n<<<<<<< B\nb-ours\n=======\nb-theirs\n>>>>>>> B\ntail";
write_file(path.to_string_lossy().as_ref(), content).expect("write should succeed");
let output = super::read_file_with_selector(
path.to_string_lossy().as_ref(),
None,
None,
Some("conflicts"),
)
.expect("read with conflicts selector should succeed");
let text = &output.file.content;
// Should report 2 conflicts
assert!(text.contains("2 unresolved conflicts"), "text: {}", text);
// Should have indices for both blocks
assert!(text.contains("#1"), "missing #1: {}", text);
assert!(text.contains("#2"), "missing #2: {}", text);
// Should have line ranges
assert!(text.contains("L1-5"), "missing L1-5: {}", text);
assert!(text.contains("L7-11"), "missing L7-11: {}", text);
}
#[test]
fn read_with_selector_conflicts_three_way() {
let path = temp_path("conflicts-three-way.txt");
let content = "head\n<<<<<<< HEAD\nours body\n||||||| common ancestor\nbase body\n=======\ntheirs body\n>>>>>>> feat\ntail";
write_file(path.to_string_lossy().as_ref(), content).expect("write should succeed");
let output = super::read_file_with_selector(
path.to_string_lossy().as_ref(),
None,
None,
Some("conflicts"),
)
.expect("read with conflicts selector should succeed");
let text = &output.file.content;
// Should report 1 conflict
assert!(text.contains("1 unresolved conflict"), "text: {}", text);
// Should mark as 3-way
assert!(text.contains("3-way"), "missing 3-way marker: {}", text);
// Should show line range
assert!(text.contains("L2-8"), "missing L2-8: {}", text);
}
}
+1 -1
View File
@@ -84,7 +84,7 @@ pub use config_validate::{
pub use conversation::{
auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent,
ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolError,
ToolExecutor, TurnInterruptSignal, TurnSummary,
ToolExecutor, TurnSummary,
};
pub use file_ops::{
edit_file, edit_file_in_workspace, glob_search, glob_search_in_workspace, grep_search,
-22
View File
@@ -34,9 +34,6 @@ 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,
}
@@ -49,7 +46,6 @@ 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"),
}
@@ -86,7 +82,6 @@ pub enum WorkerEventKind {
PromptMisdelivery,
PromptReplayArmed,
Running,
Interrupted,
Restarted,
Finished,
Failed,
@@ -1466,23 +1461,6 @@ 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();
-2
View File
@@ -24,8 +24,6 @@ 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
@@ -1,280 +0,0 @@
//! 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);
}
}
+36 -122
View File
@@ -16,7 +16,6 @@
)]
mod init;
mod input;
mod interrupt;
mod render;
mod setup_wizard;
@@ -7299,18 +7298,6 @@ 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()?;
@@ -7693,10 +7680,7 @@ struct HookAbortMonitor {
}
impl HookAbortMonitor {
fn spawn(
abort_signal: runtime::HookAbortSignal,
turn_interrupt_signal: runtime::TurnInterruptSignal,
) -> Self {
fn spawn(abort_signal: runtime::HookAbortSignal) -> Self {
Self::spawn_with_waiter(abort_signal, move |stop_rx, abort_signal| {
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
.enable_all()
@@ -7713,11 +7697,7 @@ 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 => {}
@@ -7823,7 +7803,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 · \x1b[2mEsc\x1b[0m interrupts a running turn",
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",
self.model,
self.permission_mode.as_str(),
git_branch,
@@ -7849,12 +7829,8 @@ impl LiveCli {
&self,
emit_output: bool,
terminal_tail: TerminalTail,
) -> Result<
(BuiltRuntime, HookAbortMonitor, runtime::TurnInterruptSignal),
Box<dyn std::error::Error>,
> {
) -> Result<(BuiltRuntime, HookAbortMonitor), 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,
@@ -7867,12 +7843,10 @@ impl LiveCli {
self.permission_mode,
None,
)?
.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());
.with_hook_abort_signal(hook_abort_signal.clone());
let hook_abort_monitor = HookAbortMonitor::spawn(hook_abort_signal);
Ok((runtime, hook_abort_monitor, turn_interrupt_signal))
Ok((runtime, hook_abort_monitor))
}
fn replace_runtime(&mut self, runtime: BuiltRuntime) -> Result<(), Box<dyn std::error::Error>> {
@@ -7883,16 +7857,8 @@ 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, turn_interrupt_signal) =
let (mut runtime, hook_abort_monitor) =
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
@@ -7903,19 +7869,13 @@ impl LiveCli {
// emitted either way.
if io::stdout().is_terminal() {
spinner.tick(
if escape_monitor.is_some() {
"🦀 Thinking... (esc to interrupt)"
} else {
"🦀 Thinking..."
},
"🦀 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) => {
@@ -7932,11 +7892,7 @@ impl LiveCli {
println!();
}
spinner.finish(
if summary.interrupted {
"⏹ Interrupted"
} else {
"✨ Done"
},
"✨ Done",
TerminalRenderer::new().color_theme(),
&mut stdout,
)?;
@@ -8070,7 +8026,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, _turn_interrupt_signal) =
let (mut new_runtime, hook_abort_monitor) =
self.prepare_turn_runtime(true, terminal_tail.clone())?;
drop(hook_abort_monitor);
@@ -8163,7 +8119,7 @@ impl LiveCli {
}
fn run_prompt_compact(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor, _turn_interrupt_signal) =
let (mut runtime, hook_abort_monitor) =
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));
@@ -8177,7 +8133,7 @@ impl LiveCli {
}
fn run_prompt_compact_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor, _turn_interrupt_signal) =
let (mut runtime, hook_abort_monitor) =
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));
@@ -8203,7 +8159,7 @@ impl LiveCli {
}
fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor, _turn_interrupt_signal) =
let (mut runtime, hook_abort_monitor) =
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));
@@ -9766,7 +9722,6 @@ 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(),
@@ -12819,20 +12774,11 @@ 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,
stdin_gate: None,
}
}
fn with_stdin_gate(mut self, stdin_gate: interrupt::StdinPromptGate) -> Self {
self.stdin_gate = Some(stdin_gate);
self
Self { current_mode }
}
}
@@ -12841,12 +12787,6 @@ 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);
@@ -13044,7 +12984,6 @@ struct AnthropicRuntimeClient {
tool_registry: GlobalToolRegistry,
progress_reporter: Option<InternalPromptProgressReporter>,
reasoning_effort: Option<String>,
turn_interrupt_signal: Option<runtime::TurnInterruptSignal>,
}
impl AnthropicRuntimeClient {
@@ -13130,17 +13069,12 @@ 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>> {
@@ -13184,50 +13118,30 @@ impl ApiClient for AnthropicRuntimeClient {
};
self.runtime.block_on(async {
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 };
// 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.
}
Err(error) => return Err(error),
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(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(error) => return Err(error),
}
}
Err(RuntimeError::new("post-tool continuation nudge exhausted"))
})
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "sdk"
version = "0.1.0"
version = "0.1.4"
edition = "2021"
publish = false