Compare commits

..
15 changed files with 54 additions and 663 deletions

No files matched your search

+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
mkdir -p dist
cp "target/release/${{ matrix.bin }}" "dist/${{ matrix.artifact_name }}"
chmod +x "dist/${{ matrix.artifact_name }}"
(cd dist && sha256sum "${{ matrix.artifact_name }}" > "${{ matrix.artifact_name }}.sha256")
(cd dist && (sha256sum "${{ matrix.artifact_name }}" 2>/dev/null || shasum -a 256 "${{ matrix.artifact_name }}") > "${{ matrix.artifact_name }}.sha256")
- name: Upload workflow artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
-2
View File
@@ -13,5 +13,3 @@ archive/
status-help.txt
# Legacy Python port session scratch artifacts
.port_sessions/
# Local agent session state
.gjc/
@@ -0,0 +1 @@
{"schema_version":1,"session_id":"5e2dd1e8-ea4b-4af1-b56b-89ab7cca4299","state":"running","ready_for_input":false,"updated_at":"2026-10-12T22:09:30.659Z","current_turn_id":null,"last_turn_id":null,"live":true,"reason":null,"source":"agent_session_event","event":"turn_start","cwd":"/home/bellman/Data/Workspace/gc-task-cc-issue-3257-lane","workdir":"/home/bellman/Data/Workspace/gc-task-cc-issue-3257-lane","branch":null,"session_file":"/home/bellman/.gajaeway/broker/agent/sessions/v2-6fji37qf7ld523ivvoiohlcpmz3nu45m2apxyrkusn5nxczjmouq/2026-10-12T22-04-33-362Z_5e2dd1e8-ea4b-4af1-b56b-89ab7cca4299.jsonl","activity":{"seq":115,"last_activity_at":"2026-10-12T22:09:34.144Z","tool":"bash","phase":"started","outcome":null,"elapsed_ms":null,"active_tool_count":1,"active_tools":[{"tool":"bash","started_at":"2026-10-12T22:09:34.144Z"}],"in_flight":[{"digest":"e603a70efb03b6b2b78c94b704197d26fc8a35113543291a21f68b3b08ca11a9","tool":"bash","started_at":"2026-10-12T22:09:34.144Z"}]}}
@@ -0,0 +1 @@
{"pid":1,"start_time":"unknown","token":"1a2e8a33-76f2-4175-ac23-1521458f12a2","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
@@ -0,0 +1 @@
{"pid":1,"start_time":"unknown","token":"a1342032-af80-4a49-a783-291bf73bf027","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
Whitespace-only changes.
@@ -0,0 +1,12 @@
{
"version": 1,
"sessionId": "5e2dd1e8-ea4b-4af1-b56b-89ab7cca4299",
"pid": 487360,
"host": "127.0.0.1",
"port": 37541,
"url": "ws://127.0.0.1:37541",
"token": "LlIfu_aHH57jfys2Ii0ORMrnu56NLAa1",
"startedAt": 1791842673967,
"updatedAt": 1791842673967,
"stale": false
}
@@ -0,0 +1 @@
{"effectMarker":"f441857a-f591-474b-b34a-6d89b2bf9feb","incarnation":"linux:159099652","pid":487360}
@@ -0,0 +1 @@
{"effectMarker":"f441857a-f591-474b-b34a-6d89b2bf9feb","incarnation":"linux:159099652","pid":487360}
+4 -10
View File
@@ -8,16 +8,10 @@ unless a maintainer explicitly marks them as supported.
## Reporting a vulnerability
Please do **not** open a public issue for a suspected vulnerability. Use [GitHub
Security Advisories](https://docs.github.com/en/code-security/security-advisories)
for `ultraworkers/claw-code`:
1. Go to the **Security** tab of this repository
2. Click **"Report a vulnerability"**
3. Describe the issue with reproduction steps and impact
Alternatively, contact a maintainer through the repository's published support
channel with a minimal, non-destructive reproduction.
Please do **not** open a public issue for a suspected vulnerability. Use GitHub
private vulnerability reporting for `ultraworkers/claw-code` when available, or
contact a maintainer through the repository's published support channel with a
minimal, non-destructive reproduction.
Include:
+6 -23
View File
@@ -110,30 +110,13 @@ Primary artifacts:
## Model Aliases
Short model aliases resolve to canonical model IDs. Because the router selects a
provider from the resolved model, an alias also determines which backend and
credentials are used — choose the alias for the provider you have an API key for.
Alias resolution is case-insensitive (`OPUS` behaves the same as `opus`). Aliases
are the bare short names listed below: a prefixed form such as `anthropic/opus`
is not an alias, and is passed through as-is rather than expanded to
`claude-opus-4-7`.
Short names resolve to the latest model versions:
| Alias | Resolves To | Provider | Auth env var |
|-------|-------------|----------|--------------|
| `opus` | `claude-opus-4-7` | Anthropic | `ANTHROPIC_API_KEY` |
| `sonnet` | `claude-sonnet-4-6` | Anthropic | `ANTHROPIC_API_KEY` |
| `haiku` | `claude-haiku-4-5-20251213` | Anthropic | `ANTHROPIC_API_KEY` |
| `grok`, `grok-3` | `grok-3` | xAI | `XAI_API_KEY` |
| `grok-mini`, `grok-3-mini` | `grok-3-mini` | xAI | `XAI_API_KEY` |
| `grok-2` | `grok-2` | xAI | `XAI_API_KEY` |
| `kimi` | `kimi-k2.5` | DashScope (OpenAI-compatible) | `DASHSCOPE_API_KEY` |
Anthropic aliases track the latest published model versions. xAI models honor
`XAI_BASE_URL` and Kimi honors `DASHSCOPE_BASE_URL` for base-URL overrides. Alias
resolution is implemented in `resolve_model_alias`
([`crates/api/src/providers/mod.rs`](./crates/api/src/providers/mod.rs)); for
provider-specific request handling see
[`../docs/MODEL_COMPATIBILITY.md`](../docs/MODEL_COMPATIBILITY.md).
| Alias | Resolves To |
|-------|------------|
| `opus` | `claude-opus-4-7` |
| `sonnet` | `claude-sonnet-4-6` |
| `haiku` | `claude-haiku-4-5-20251213` |
## CLI Flags and Commands
-129
View File
@@ -927,135 +927,6 @@ mod tests {
assert_eq!(resolve_model_alias("grok-2"), "grok-2");
}
/// Collects every non-empty backtick-delimited span in a markdown cell.
fn backticked(cell: &str) -> Vec<&str> {
let mut spans = Vec::new();
let mut rest = cell;
while let Some(start) = rest.find('`') {
let after_open = &rest[start + 1..];
let Some(end) = after_open.find('`') else {
break;
};
spans.push(&after_open[..end]);
rest = &after_open[end + 1..];
}
spans
}
// Locks the `## Model Aliases` table in `rust/README.md` to the resolver.
//
// The table and the alias set must agree in *both* directions: no alias may
// resolve without being documented, and none may be documented without
// resolving. The table previously listed only the three Anthropic aliases
// while the resolver had grown xAI and Kimi entries, so an alias could work
// undocumented — exactly the drift a parity test catches and a hand-written
// `assert_eq!` per alias does not.
#[test]
fn readme_alias_table_matches_resolver() {
// `rust/crates/api/src/providers/mod.rs` -> `rust/README.md`.
const README: &str = include_str!("../../../../README.md");
let section = README
.split("## Model Aliases")
.nth(1)
.expect("rust/README.md must keep a `## Model Aliases` section");
let section = section.split("\n## ").next().unwrap_or(section);
// (alias, documented canonical id, documented auth env var)
let mut documented: Vec<(String, String, String)> = Vec::new();
for line in section.lines() {
let line = line.trim();
let Some(row) = line.strip_prefix('|') else {
continue;
};
let cells: Vec<&str> = row
.trim_end_matches('|')
.split('|')
.map(str::trim)
.collect();
if cells.len() < 4 {
continue;
}
let aliases = backticked(cells[0]);
if aliases.is_empty() {
// Header and `|---|---|` separator rows carry no aliases.
continue;
}
let canonicals = backticked(cells[1]);
assert_eq!(
canonicals.len(),
1,
"`{line}` must document exactly one canonical model id"
);
let auth_envs = backticked(cells[3]);
assert_eq!(
auth_envs.len(),
1,
"`{line}` must document exactly one auth env var"
);
for alias in aliases {
documented.push((
alias.to_string(),
canonicals[0].to_string(),
auth_envs[0].to_string(),
));
}
}
assert!(
!documented.is_empty(),
"parsed no alias rows out of the `## Model Aliases` table in rust/README.md"
);
for (alias, canonical, auth_env) in &documented {
let resolved = resolve_model_alias(alias);
assert_eq!(
resolved.as_str(),
canonical.as_str(),
"rust/README.md documents `{alias}` -> `{canonical}`, but resolve_model_alias returns `{resolved}`"
);
assert_eq!(
resolve_model_alias(&alias.to_ascii_uppercase()).as_str(),
resolved.as_str(),
"rust/README.md claims alias resolution is case-insensitive, but `{}` disagrees with `{alias}`",
alias.to_ascii_uppercase()
);
let metadata = super::metadata_for_model(&resolved).unwrap_or_else(|| {
panic!("no provider metadata for documented model `{canonical}`")
});
assert_eq!(
metadata.auth_env,
auth_env.as_str(),
"rust/README.md documents `{alias}` as needing `{auth_env}`, but `{canonical}` resolves to provider metadata with auth env `{}`",
metadata.auth_env
);
}
let documented_aliases: std::collections::BTreeSet<&str> = documented
.iter()
.map(|(alias, _, _)| alias.as_str())
.collect();
let resolver_aliases: std::collections::BTreeSet<&str> = super::MODEL_REGISTRY
.iter()
.map(|(alias, _)| *alias)
.collect();
assert_eq!(
documented_aliases, resolver_aliases,
"rust/README.md's `## Model Aliases` table and MODEL_REGISTRY have drifted apart"
);
}
// The table documents bare short names only, so the provider-prefixed form
// is deliberately *not* an alias. This pins that distinction, since the
// prefix is handled later (at request-build time) and a future refactor
// could easily fold it into alias resolution.
#[test]
fn provider_prefixed_alias_is_not_an_alias() {
assert_eq!(resolve_model_alias("opus"), "claude-opus-4-7");
assert_eq!(resolve_model_alias("anthropic/opus"), "anthropic/opus");
assert_eq!(resolve_model_alias("xai/grok"), "xai/grok");
}
#[test]
fn detects_provider_from_model_name_first() {
assert_eq!(detect_provider_kind("grok"), ProviderKind::Xai);
+2 -153
View File
@@ -13,16 +13,6 @@ use tokio::task::JoinHandle;
pub const SCENARIO_PREFIX: &str = "PARITY_SCENARIO:";
pub const DEFAULT_MODEL: &str = "claude-sonnet-4-6";
/// The assistant text the `context_window_retry` scenario finally streams, after
/// its first request was refused with a context-overflow error. Exposed so a
/// test can assert on the exact body instead of restating the literal.
pub const CONTEXT_WINDOW_RETRY_TEXT: &str = "auto compact retry parity complete.";
/// The partial assistant text the `stream_then_error` scenario streams before it
/// fails the response mid-stream. Exposed so a test can assert on the exact body
/// instead of restating the literal.
pub const STREAM_THEN_ERROR_TEXT: &str = "partial response before the stream failed";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CapturedRequest {
pub method: String,
@@ -109,8 +99,6 @@ enum Scenario {
BashPermissionPromptDenied,
PluginToolRoundtrip,
AutoCompactTriggered,
ContextWindowRetry,
StreamThenError,
TokenCostReporting,
}
@@ -128,8 +116,6 @@ impl Scenario {
"bash_permission_prompt_denied" => Some(Self::BashPermissionPromptDenied),
"plugin_tool_roundtrip" => Some(Self::PluginToolRoundtrip),
"auto_compact_triggered" => Some(Self::AutoCompactTriggered),
"context_window_retry" => Some(Self::ContextWindowRetry),
"stream_then_error" => Some(Self::StreamThenError),
"token_cost_reporting" => Some(Self::TokenCostReporting),
_ => None,
}
@@ -148,8 +134,6 @@ impl Scenario {
Self::BashPermissionPromptDenied => "bash_permission_prompt_denied",
Self::PluginToolRoundtrip => "plugin_tool_roundtrip",
Self::AutoCompactTriggered => "auto_compact_triggered",
Self::ContextWindowRetry => "context_window_retry",
Self::StreamThenError => "stream_then_error",
Self::TokenCostReporting => "token_cost_reporting",
}
}
@@ -165,18 +149,6 @@ async fn handle_connection(
let scenario = detect_scenario(&request)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing parity scenario"))?;
// How many times this scenario has already been served *on this endpoint*.
// Scenarios that need to behave differently on a retry (reject once, then
// succeed) key off this; counting per endpoint keeps the token-counting
// preflight on `/v1/messages/count_tokens` from consuming the first attempt.
let attempt = {
let served = requests.lock().await;
served
.iter()
.filter(|captured| captured.scenario == scenario.name() && captured.path == path)
.count()
};
requests.lock().await.push(CapturedRequest {
method,
path,
@@ -186,7 +158,7 @@ async fn handle_connection(
raw_body,
});
let response = build_http_response(&request, scenario, attempt);
let response = build_http_response(&request, scenario);
socket.write_all(response.as_bytes()).await?;
Ok(())
}
@@ -336,11 +308,7 @@ fn flatten_tool_result_content(content: &[api::ToolResultContentBlock]) -> Strin
}
#[allow(clippy::too_many_lines)]
fn build_http_response(request: &MessageRequest, scenario: Scenario, attempt: usize) -> String {
if let Some(rejection) = context_window_rejection(scenario, attempt) {
return rejection;
}
fn build_http_response(request: &MessageRequest, scenario: Scenario) -> String {
let response = if request.stream {
let body = build_stream_body(request, scenario);
return http_response(
@@ -361,37 +329,6 @@ fn build_http_response(request: &MessageRequest, scenario: Scenario, attempt: us
)
}
/// The first request of [`Scenario::ContextWindowRetry`] is refused with a
/// context-overflow error instead of a streamed answer, so the client's
/// auto-compact recovery path runs and the *retry* is what produces the
/// response text. Every later request against the same endpoint succeeds.
///
/// HTTP 400 is deliberately chosen: it is not in the client's retryable status
/// set, so the client surfaces the error rather than silently re-sending it —
/// which is exactly the situation a real context-overflow rejection creates.
/// The body carries the `context_window` marker the client greps for.
fn context_window_rejection(scenario: Scenario, attempt: usize) -> Option<String> {
if scenario != Scenario::ContextWindowRetry || attempt > 0 {
return None;
}
let body = json!({
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "prompt is too long: context_window exceeded for this model",
},
})
.to_string();
Some(http_response(
"400 Bad Request",
"application/json",
&body,
&[("request-id", request_id_for(scenario))],
))
}
#[allow(clippy::too_many_lines)]
fn build_stream_body(request: &MessageRequest, scenario: Scenario) -> String {
match scenario {
@@ -524,8 +461,6 @@ fn build_stream_body(request: &MessageRequest, scenario: Scenario) -> String {
Scenario::AutoCompactTriggered => {
final_text_sse_with_usage("auto compact parity complete.", 50_000, 200)
}
Scenario::ContextWindowRetry => final_text_sse(CONTEXT_WINDOW_RETRY_TEXT),
Scenario::StreamThenError => stream_then_error_sse(STREAM_THEN_ERROR_TEXT),
Scenario::TokenCostReporting => {
final_text_sse_with_usage("token cost reporting parity complete.", 1_000, 500)
}
@@ -693,12 +628,6 @@ fn build_message_response(request: &MessageRequest, scenario: Scenario) -> Messa
50_000,
200,
),
Scenario::ContextWindowRetry => {
text_message_response("msg_context_window_retry", CONTEXT_WINDOW_RETRY_TEXT)
}
Scenario::StreamThenError => {
text_message_response("msg_stream_then_error", STREAM_THEN_ERROR_TEXT)
}
Scenario::TokenCostReporting => text_message_response_with_usage(
"msg_token_cost_reporting",
"token cost reporting parity complete.",
@@ -721,8 +650,6 @@ fn request_id_for(scenario: Scenario) -> &'static str {
Scenario::BashPermissionPromptDenied => "req_bash_permission_prompt_denied",
Scenario::PluginToolRoundtrip => "req_plugin_tool_roundtrip",
Scenario::AutoCompactTriggered => "req_auto_compact_triggered",
Scenario::ContextWindowRetry => "req_context_window_retry",
Scenario::StreamThenError => "req_stream_then_error",
Scenario::TokenCostReporting => "req_token_cost_reporting",
}
}
@@ -1044,84 +971,6 @@ fn final_text_sse(text: &str) -> String {
body
}
/// Streams a text block to completion and then fails the response, which is
/// what a provider does when it dies partway through an answer.
///
/// The block is closed before the failure on purpose: the client only flushes
/// its buffered markdown to the terminal on `content_block_stop`, so without
/// that frame the partial text would still be sitting in the renderer and the
/// scenario would not reproduce a turn that streamed text and *then* errored.
///
/// The response is cut off in the middle of the failing frame rather than
/// carrying a complete one. Two things fall out of that, both deliberate.
///
/// It is what a provider that dies on the socket actually looks like, and it
/// keeps the failure in a *later* parser call than the answer text. The whole
/// response is one `write_all` on the mock's side, so a complete trailing frame
/// would land in the same read chunk as the deltas — and `SseParser::push`
/// returns `Err` from inside its frame loop, which discards the events it had
/// already parsed from that same chunk. The client would then never render the
/// text at all, and the scenario would be exercising that parser behaviour
/// instead of the terminal state it is meant to reproduce. A truncated tail
/// stays in the parser's buffer, so `push` returns the deltas normally and the
/// failure only surfaces from `finish()`, once the client has consumed every
/// event ahead of it.
///
/// The failure is a frame that does not deserialize — `api::types::StreamEvent`
/// is `#[serde(tag = "type")]` with no fallback arm, so an unknown frame type or
/// a truncated payload is an `ApiError::Json`. That is non-retryable, and it is
/// raised while reading the body rather than while acquiring the response, so the
/// client reports it on the first attempt rather than retrying.
fn stream_then_error_sse(text: &str) -> String {
let mut body = String::new();
append_sse(
&mut body,
"message_start",
json!({
"type": "message_start",
"message": {
"id": unique_message_id(),
"type": "message",
"role": "assistant",
"content": [],
"model": DEFAULT_MODEL,
"stop_reason": null,
"stop_sequence": null,
"usage": usage_json(14, 0)
}
}),
);
append_sse(
&mut body,
"content_block_start",
json!({
"type": "content_block_start",
"index": 0,
"content_block": {"type": "text", "text": ""}
}),
);
append_sse(
&mut body,
"content_block_delta",
json!({
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": text}
}),
);
append_sse(
&mut body,
"content_block_stop",
json!({"type": "content_block_stop", "index": 0}),
);
// No terminating blank line: the frame is still arriving when the provider
// drops the connection, so it stays in the client's parser buffer until the
// body ends. See the note above for why the failure has to trail the deltas
// by a parser call rather than sharing their chunk.
body.push_str("event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_");
body
}
fn final_text_sse_with_usage(text: &str, input_tokens: u32, output_tokens: u32) -> String {
let mut body = String::new();
append_sse(
+23 -149
View File
@@ -27,7 +27,6 @@ use std::net::TcpListener;
use std::ops::{Deref, DerefMut};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread::{self, JoinHandle};
@@ -7703,7 +7702,6 @@ impl LiveCli {
system_prompt.clone(),
enable_tools,
true,
TerminalTail::new(),
allowed_tools.clone(),
permission_mode,
None,
@@ -7785,7 +7783,6 @@ impl LiveCli {
fn prepare_turn_runtime(
&self,
emit_output: bool,
terminal_tail: TerminalTail,
) -> Result<(BuiltRuntime, HookAbortMonitor), Box<dyn std::error::Error>> {
let hook_abort_signal = runtime::HookAbortSignal::new();
let runtime = build_runtime(
@@ -7795,7 +7792,6 @@ impl LiveCli {
self.system_prompt.clone(),
true,
emit_output,
terminal_tail,
self.allowed_tools.clone(),
self.permission_mode,
None,
@@ -7813,39 +7809,27 @@ 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) =
self.prepare_turn_runtime(true, terminal_tail.clone())?;
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?;
let mut spinner = Spinner::new();
let mut stdout = io::stdout();
// The animated frame is cursor control: it saves the cursor, rewrites the
// current line, and restores the cursor. None of that describes anything
// when stdout is a pipe — the escapes survive as literal bytes and the
// banner ends up glued to whatever the emitter writes next. Only animate
// on a terminal; the one-shot completion line below is plain text and is
// emitted either way.
if io::stdout().is_terminal() {
spinner.tick(
"🦀 Thinking...",
TerminalRenderer::new().color_theme(),
&mut stdout,
)?;
}
spinner.tick(
"🦀 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));
hook_abort_monitor.stop();
match result {
Ok(summary) => {
self.replace_runtime(runtime)?;
// The emitter owns the terminal during a turn: `emit_output = true`
// streams text deltas and echoes tool results live, so the response
// must not be printed again here — that was the duplicate output
// this PR's predecessor fixed.
//
// What the emitter may have left behind is a partial line, so ask it
// rather than guessing from `final_assistant_text(&summary)`: that
// guess is empty for tool-only turns, which still render.
if !terminal_tail.at_line_start() {
// The assistant response was already streamed to the terminal live
// during the turn (emit_output = true), so reprinting the full text
// here would duplicate the entire message. When text was streamed,
// emit a newline first so `spinner.finish`'s line-clear lands on a
// fresh line instead of erasing the last streamed line.
let final_text = final_assistant_text(&summary);
if !final_text.is_empty() {
println!();
}
spinner.finish(
@@ -7865,13 +7849,6 @@ impl LiveCli {
}
Err(error) => {
runtime.shutdown_plugins()?;
// Same ownership rule as the success arm: `fail` clears the current
// line, so it may only do so when the emitter stopped on a line
// boundary. A turn that streamed text and then failed would
// otherwise lose its last partial line to the clear.
if !terminal_tail.at_line_start() {
println!();
}
spinner.fail(
"❌ Request failed",
TerminalRenderer::new().color_theme(),
@@ -7984,14 +7961,14 @@ impl LiveCli {
// Build a new runtime with the compacted session and retry
let (mut new_runtime, hook_abort_monitor) =
self.prepare_turn_runtime(true, terminal_tail.clone())?;
self.prepare_turn_runtime(true)?;
drop(hook_abort_monitor);
let mut rp = CliPermissionPrompter::new(self.permission_mode);
match new_runtime.run_turn(input, Some(&mut rp)) {
Ok(summary) => {
self.replace_runtime(new_runtime)?;
if !terminal_tail.at_line_start() {
if !final_assistant_text(&summary).is_empty() {
println!();
}
spinner.finish(
@@ -8076,8 +8053,7 @@ impl LiveCli {
}
fn run_prompt_compact(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) =
self.prepare_turn_runtime(false, TerminalTail::new())?;
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = runtime.run_turn(input, Some(&mut permission_prompter));
hook_abort_monitor.stop();
@@ -8090,8 +8066,7 @@ impl LiveCli {
}
fn run_prompt_compact_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) =
self.prepare_turn_runtime(false, TerminalTail::new())?;
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = runtime.run_turn(input, Some(&mut permission_prompter));
hook_abort_monitor.stop();
@@ -8116,8 +8091,7 @@ impl LiveCli {
}
fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) =
self.prepare_turn_runtime(false, TerminalTail::new())?;
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = runtime.run_turn(input, Some(&mut permission_prompter));
hook_abort_monitor.stop();
@@ -8473,7 +8447,6 @@ impl LiveCli {
self.system_prompt.clone(),
true,
true,
TerminalTail::new(),
self.allowed_tools.clone(),
self.permission_mode,
None,
@@ -8520,7 +8493,6 @@ impl LiveCli {
self.system_prompt.clone(),
true,
true,
TerminalTail::new(),
self.allowed_tools.clone(),
self.permission_mode,
None,
@@ -8551,7 +8523,6 @@ impl LiveCli {
self.system_prompt.clone(),
true,
true,
TerminalTail::new(),
self.allowed_tools.clone(),
self.permission_mode,
None,
@@ -8594,7 +8565,6 @@ impl LiveCli {
self.system_prompt.clone(),
true,
true,
TerminalTail::new(),
self.allowed_tools.clone(),
self.permission_mode,
None,
@@ -8948,7 +8918,6 @@ impl LiveCli {
self.system_prompt.clone(),
true,
true,
TerminalTail::new(),
self.allowed_tools.clone(),
self.permission_mode,
None,
@@ -8984,7 +8953,6 @@ impl LiveCli {
self.system_prompt.clone(),
true,
true,
TerminalTail::new(),
self.allowed_tools.clone(),
self.permission_mode,
None,
@@ -9079,7 +9047,6 @@ impl LiveCli {
self.system_prompt.clone(),
true,
true,
TerminalTail::new(),
self.allowed_tools.clone(),
self.permission_mode,
None,
@@ -9100,7 +9067,6 @@ impl LiveCli {
self.system_prompt.clone(),
true,
true,
TerminalTail::new(),
self.allowed_tools.clone(),
self.permission_mode,
None,
@@ -9125,7 +9091,6 @@ impl LiveCli {
self.system_prompt.clone(),
enable_tools,
false,
TerminalTail::new(),
self.allowed_tools.clone(),
self.permission_mode,
progress,
@@ -12535,7 +12500,6 @@ fn build_runtime(
system_prompt: Vec<String>,
enable_tools: bool,
emit_output: bool,
terminal_tail: TerminalTail,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
progress_reporter: Option<InternalPromptProgressReporter>,
@@ -12548,7 +12512,6 @@ fn build_runtime(
system_prompt,
enable_tools,
emit_output,
terminal_tail,
allowed_tools,
permission_mode,
progress_reporter,
@@ -12565,7 +12528,6 @@ fn build_runtime_with_plugin_state(
system_prompt: Vec<String>,
enable_tools: bool,
emit_output: bool,
terminal_tail: TerminalTail,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
progress_reporter: Option<InternalPromptProgressReporter>,
@@ -12591,7 +12553,6 @@ fn build_runtime_with_plugin_state(
model,
enable_tools,
emit_output,
terminal_tail.clone(),
allowed_tools.clone(),
tool_registry.clone(),
progress_reporter,
@@ -12599,7 +12560,6 @@ fn build_runtime_with_plugin_state(
CliToolExecutor::new(
allowed_tools.clone(),
emit_output,
terminal_tail,
tool_registry.clone(),
mcp_state.clone(),
),
@@ -12695,76 +12655,6 @@ impl runtime::PermissionPrompter for CliPermissionPrompter {
}
}
/// Tracks whether the shared terminal stream currently sits at the start of a
/// fresh line.
///
/// A turn's streamed assistant text has no trailing newline, and
/// `Spinner::finish` moves to column 0 and clears the current line — so without
/// a guard it erases the last thing the emitter rendered. Both writers that
/// render live during a turn (the assistant/reasoning deltas in
/// `AnthropicRuntimeClient::consume_stream` and the tool-result blocks in
/// `CliToolExecutor::execute`) push their bytes
/// through a `TailTrackingWriter` sharing one of these, which lets the caller
/// that ends the turn ask the emitter what it actually left on the terminal.
///
/// The previous approach inferred this from `final_assistant_text(&summary)`,
/// which agrees today only because every *other* writer terminates its output
/// with a newline — tool results go through `stream_markdown` (which appends
/// one), thinking summaries are written with a trailing newline, and tool-call
/// headers use `writeln!`. That is a property of the current renderers rather
/// than of the streaming protocol, and the text path already violates it, so
/// recording the fact is cheaper than depending on it. The heuristic also left
/// the failure arm unguarded: a turn that streamed text and then errored still
/// had that line cleared.
#[derive(Clone, Default)]
struct TerminalTail {
at_line_start: Arc<AtomicBool>,
}
impl TerminalTail {
fn new() -> Self {
Self {
at_line_start: Arc::new(AtomicBool::new(true)),
}
}
/// True when nothing has been written since the last newline, so clearing
/// the current terminal line cannot destroy rendered output.
fn at_line_start(&self) -> bool {
self.at_line_start.load(Ordering::Relaxed)
}
fn record(&self, written: &[u8]) {
if let Some(last) = written.last() {
self.at_line_start.store(*last == b'\n', Ordering::Relaxed);
}
}
}
/// Forwards writes to `inner` while reporting the tail byte to a `TerminalTail`.
struct TailTrackingWriter<'a, W: Write> {
inner: &'a mut W,
tail: TerminalTail,
}
impl<'a, W: Write> TailTrackingWriter<'a, W> {
fn new(inner: &'a mut W, tail: TerminalTail) -> Self {
Self { inner, tail }
}
}
impl<W: Write> Write for TailTrackingWriter<'_, W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let written = self.inner.write(buf)?;
self.tail.record(&buf[..written]);
Ok(written)
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct
// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI,
// OpenAI, or DashScope at construction time based on
@@ -12778,7 +12668,6 @@ struct AnthropicRuntimeClient {
model: String,
enable_tools: bool,
emit_output: bool,
terminal_tail: TerminalTail,
allowed_tools: Option<AllowedToolSet>,
tool_registry: GlobalToolRegistry,
progress_reporter: Option<InternalPromptProgressReporter>,
@@ -12791,7 +12680,6 @@ impl AnthropicRuntimeClient {
model: String,
enable_tools: bool,
emit_output: bool,
terminal_tail: TerminalTail,
allowed_tools: Option<AllowedToolSet>,
tool_registry: GlobalToolRegistry,
progress_reporter: Option<InternalPromptProgressReporter>,
@@ -12845,7 +12733,6 @@ impl AnthropicRuntimeClient {
model,
enable_tools,
emit_output,
terminal_tail,
allowed_tools,
tool_registry,
progress_reporter,
@@ -12945,9 +12832,8 @@ impl AnthropicRuntimeClient {
})?;
let mut stdout = io::stdout();
let mut sink = io::sink();
let mut tracked = TailTrackingWriter::new(&mut stdout, self.terminal_tail.clone());
let out: &mut dyn Write = if self.emit_output {
&mut tracked
&mut stdout
} else {
&mut sink
};
@@ -14111,7 +13997,6 @@ fn prompt_cache_record_to_runtime_event(
struct CliToolExecutor {
renderer: TerminalRenderer,
emit_output: bool,
terminal_tail: TerminalTail,
allowed_tools: Option<AllowedToolSet>,
tool_registry: GlobalToolRegistry,
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
@@ -14121,14 +14006,12 @@ impl CliToolExecutor {
fn new(
allowed_tools: Option<AllowedToolSet>,
emit_output: bool,
terminal_tail: TerminalTail,
tool_registry: GlobalToolRegistry,
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
) -> Self {
Self {
renderer: TerminalRenderer::new(),
emit_output,
terminal_tail,
allowed_tools,
tool_registry,
mcp_state,
@@ -14222,11 +14105,8 @@ impl ToolExecutor for CliToolExecutor {
Ok(output) => {
if self.emit_output {
let markdown = format_tool_result(tool_name, &output, false);
let mut stdout = io::stdout();
let mut tracked =
TailTrackingWriter::new(&mut stdout, self.terminal_tail.clone());
self.renderer
.stream_markdown(&markdown, &mut tracked)
.stream_markdown(&markdown, &mut io::stdout())
.map_err(|error| ToolError::new(error.to_string()))?;
}
Ok(output)
@@ -14234,11 +14114,8 @@ impl ToolExecutor for CliToolExecutor {
Err(error) => {
if self.emit_output {
let markdown = format_tool_result(tool_name, &error.to_string(), true);
let mut stdout = io::stdout();
let mut tracked =
TailTrackingWriter::new(&mut stdout, self.terminal_tail.clone());
self.renderer
.stream_markdown(&markdown, &mut tracked)
.stream_markdown(&markdown, &mut io::stdout())
.map_err(|stream_error| ToolError::new(stream_error.to_string()))?;
}
Err(error)
@@ -14553,8 +14430,8 @@ mod tests {
CliOutputFormat, CliToolExecutor, GitOperation, GitWorkspaceSummary,
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic,
PermissionModeProvenance, PromptHistoryEntry, SessionLifecycleKind,
SessionLifecycleSummary, SlashCommand, StatusUsage, TerminalTail, TmuxPaneSnapshot,
DEFAULT_MODEL, LATEST_SESSION_REFERENCE, STUB_COMMANDS,
SessionLifecycleSummary, SlashCommand, StatusUsage, TmuxPaneSnapshot, DEFAULT_MODEL,
LATEST_SESSION_REFERENCE, STUB_COMMANDS,
};
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
use plugins::{
@@ -19477,7 +19354,6 @@ UU conflicted.rs",
let mut executor = CliToolExecutor::new(
None,
false,
TerminalTail::new(),
state.tool_registry.clone(),
state.mcp_state.clone(),
);
@@ -19576,7 +19452,6 @@ UU conflicted.rs",
let mut executor = CliToolExecutor::new(
None,
false,
TerminalTail::new(),
state.tool_registry.clone(),
state.mcp_state.clone(),
);
@@ -19637,7 +19512,6 @@ UU conflicted.rs",
vec!["test system prompt".to_string()],
true,
false,
TerminalTail::new(),
None,
PermissionMode::DangerFullAccess,
None,
@@ -7,9 +7,7 @@ use std::process::{Command, Output, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use mock_anthropic_service::{
MockAnthropicService, CONTEXT_WINDOW_RETRY_TEXT, SCENARIO_PREFIX, STREAM_THEN_ERROR_TEXT,
};
use mock_anthropic_service::{MockAnthropicService, SCENARIO_PREFIX};
use serde_json::Value;
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -191,199 +189,6 @@ fn text_prompt_mode_streams_assistant_text_once_before_spinner_completion() {
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
}
#[test]
fn text_prompt_mode_emits_the_response_body_exactly_once() {
// given a workspace pointed at the mock Anthropic service running the
// streaming_text scenario, which emits a single assistant text block
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
let server = runtime
.block_on(MockAnthropicService::spawn())
.expect("mock service should start");
let base_url = server.base_url();
let workspace = unique_temp_dir("text-prompt-once");
let config_home = workspace.join("config-home");
let home = workspace.join("home");
fs::create_dir_all(&workspace).expect("workspace should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
// when we invoke claw in normal text prompt mode
let prompt = format!("{SCENARIO_PREFIX}streaming_text");
let output = run_claw(
&workspace,
&config_home,
&home,
&base_url,
&[
"--model",
"sonnet",
"--permission-mode",
"read-only",
&prompt,
],
);
// then the response body reaches stdout exactly once: the emitter owns the
// terminal while the turn runs, so the caller must not print the assistant
// text a second time
assert!(
output.status.success(),
"text prompt run should succeed\nstdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
let plain_stdout = strip_ansi_codes(&stdout);
assert_eq!(
plain_stdout
.matches("Mock streaming says hello from the parity harness.")
.count(),
1,
"text prompt stdout should carry the response body exactly once ({stdout:?})"
);
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
}
#[test]
fn text_prompt_mode_emits_retried_body_once_after_auto_compact() {
// given a mock service whose first answer to this scenario is a context
// window rejection and whose answer to the retry is a streamed response
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
let server = runtime
.block_on(MockAnthropicService::spawn())
.expect("mock service should start");
let base_url = server.base_url();
let workspace = unique_temp_dir("text-prompt-auto-compact-retry");
let config_home = workspace.join("config-home");
let home = workspace.join("home");
fs::create_dir_all(&workspace).expect("workspace should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
// when claw is rejected for context window overflow and recovers by
// compacting the session and retrying the same prompt
let prompt = format!("{SCENARIO_PREFIX}context_window_retry");
let output = run_claw(
&workspace,
&config_home,
&home,
&base_url,
&[
"--model",
"sonnet",
"--permission-mode",
"read-only",
&prompt,
],
);
// then the retry succeeds and its response body is on stdout exactly once.
// The recovery path builds a fresh runtime with its own terminal tail, so
// this is where a tail that only tracked the first attempt would leave the
// streamed line unterminated and let `spinner.finish` clear it.
assert!(
output.status.success(),
"auto-compact retry should succeed\nstdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
let plain_stdout = strip_ansi_codes(&stdout);
assert_eq!(
plain_stdout.matches(CONTEXT_WINDOW_RETRY_TEXT).count(),
1,
"auto-compact retry stdout should carry the response body exactly once ({stdout:?})"
);
assert!(
plain_stdout
.lines()
.any(|line| line == CONTEXT_WINDOW_RETRY_TEXT),
"the retried response body should land on its own line ({stdout:?})"
);
assert!(
plain_stdout.contains("✨ Done (after auto-compact)"),
"the retry should report the auto-compact completion ({stdout:?})"
);
// and the scenario really was requested twice, so the retry path ran
let captured = runtime.block_on(server.captured_requests());
let messages: Vec<_> = captured
.iter()
.filter(|request| request.path == "/v1/messages")
.collect();
assert!(
messages.len() >= 2,
"the scenario should be requested twice so the retry is exercised ({captured:?})"
);
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
}
#[test]
fn text_prompt_mode_keeps_streamed_text_when_the_turn_fails() {
// given a mock service that streams part of an answer and then fails the
// response, so the turn ends with text already rendered on the terminal
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
let server = runtime
.block_on(MockAnthropicService::spawn())
.expect("mock service should start");
let base_url = server.base_url();
let workspace = unique_temp_dir("text-prompt-stream-then-error");
let config_home = workspace.join("config-home");
let home = workspace.join("home");
fs::create_dir_all(&workspace).expect("workspace should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
// when the turn fails after the emitter already streamed a partial line
let prompt = format!("{SCENARIO_PREFIX}stream_then_error");
let output = run_claw(
&workspace,
&config_home,
&home,
&base_url,
&[
"--model",
"sonnet",
"--permission-mode",
"read-only",
&prompt,
],
);
// then the failed turn is reported...
assert!(
!output.status.success(),
"a mid-stream failure should fail the run\nstdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
let plain_stdout = strip_ansi_codes(&stdout);
// ...and the text the emitter already streamed survives the failure banner.
// `spinner.fail` moves to column 0 and clears the current line, so a caller
// that does not know a partial line is pending erases that text instead of
// printing the banner underneath it. This is the failure arm of the same
// ownership rule the success arm follows.
assert!(
plain_stdout
.lines()
.any(|line| line == STREAM_THEN_ERROR_TEXT),
"the streamed text should keep its own line despite the failure ({stdout:?})"
);
assert!(
plain_stdout.contains("Request failed"),
"the failure banner should still be reported ({stdout:?})"
);
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
}
#[test]
fn compact_flag_with_json_output_emits_structured_json() {
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");