Compare commits

..
Author SHA1 Message Date
clawdbot 2a1c4b29c7 fix(test): sandbox status can be 'ok' or 'warn' depending on kernel namespaces
The /sandbox status check returns 'ok' on systems with working user
namespaces and 'warn' on systems without them (#731). The test was
only accepting 'warn', which caused failures on systems with namespaces.
This changes the test to accept both values, matching the kernel's
actual sandbox support.

Fixes the failure: direct_resume_safe_slash_commands_route_to_local_json_actions_831
2026-10-12 23:04:29 +00:00
clawdbot 0663c80ba1 build: add Nix flake dev shell and claw package (#3247) 2026-10-12 23:04:29 +00:00
Bellman e97ddc767f Merge pull request #3239 from AbbyJL/workspace-slash-command
fix(commands): wire workspace slash command
2026-10-13 07:59:47 +09:00
Bellman 95f797f5e2 Merge pull request #3316 from ultraworkers/fix/release-sha256-macos
fix(ci): use cross-platform sha256 for macOS and Linux release artifacts
2026-10-13 07:32:17 +09:00
clawdbot ff515c9aeb fix(ci): use cross-platform sha256 for macOS and Linux release artifacts 2026-10-12 22:29:43 +00:00
Bellmanandclawdbot 0f1a69659c chore: remove accidentally committed .gjc session state and ignore .gjc/ (#3315)
Co-authored-by: clawdbot <clawdbot@ultraworkers.dev>
2026-10-13 07:22:41 +09:00
990cc33c19 test(runtime): expand json module unit tests [formatting fix] (#3308)
* test(runtime): expand json module unit tests

The hand-rolled JsonValue parser/renderer in runtime had only two
tests covering a self-contained JSON parser, renderer, accessor set,
and error type. Add focused unit tests covering primitive rendering,
sorted object keys, escape handling (quotes, backslashes, control
chars), signed-integer and whitespace-tolerant parsing, nested
structures, render->parse round-tripping, malformed-input rejection,
i64 overflow rejection, accessor variant mismatches, and JsonError
Display.

* style: format json.rs tests to match rustfmt standards

---------

Co-authored-by: 黄云龙 <76432572+nankingjing@users.noreply.github.com>
Co-authored-by: gaebal-gajae <clawdbot@users.noreply.github.com>
2026-10-13 07:22:31 +09:00
黄云龙 8d2c7b78b2 refactor(cli): let the emitter report the output tail it leaves behind (#3304)
refactor(cli): let the emitter report the output tail it leaves behind

Two places believed they owned terminal output: consume_stream streams
assistant deltas live while emit_output = true, and run_turn decided
whether to emit a separating newline before spinner.finish by guessing
from final_assistant_text(&summary).

That guess agrees with reality only because every *other* writer happens to
terminate 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! — so the assistant
text path is the only one that can leave a partial line. That is a property
of the current renderers rather than of the streaming protocol, and it is
not enforced anywhere.

TerminalTail is state the emitter records into as it writes: both live
writers (AnthropicRuntimeClient::consume_stream and the tool-result blocks
in CliToolExecutor::execute) now push bytes through a TailTrackingWriter
that reports the last byte written, and the caller that ends a turn asks
where the emitter stopped instead of inferring it.

The heuristic also left the failure arm unguarded. A turn that streamed text
and then errored called spinner.fail on a partial line, which the line
clear then erased; that arm now carries the same guard.

Also gate the animated spinner.tick frame on is_terminal(). That frame is
pure cursor control (save, rewrite line, restore) and emits no newline, so
down a pipe it leaves an unterminated banner that the first streamed delta
then continues on the same physical line.

—
*[repo owner's gaebal-gajae (clawdbot) 🦞]*
2026-10-13 07:18:12 +09:00
User a6c32edcf6 fix(commands): wire workspace slash command 2026-06-08 23:21:53 +08:00
18 changed files with 989 additions and 52 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
use flake
+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
+4
View File
@@ -25,6 +25,8 @@ on:
- tests/test_roadmap_helpers.py
- docs/**
- rust/**
- flake.nix
- .envrc
pull_request:
branches:
- main
@@ -47,6 +49,8 @@ on:
- tests/test_roadmap_helpers.py
- docs/**
- rust/**
- flake.nix
- .envrc
workflow_dispatch:
concurrency:
+6
View File
@@ -13,3 +13,9 @@ archive/
status-help.txt
# Legacy Python port session scratch artifacts
.port_sessions/
# Local agent session state
.gjc/
# Nix build outputs and direnv
.envrc.local
result
result-*
@@ -1 +0,0 @@
{"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"}]}}
@@ -1 +0,0 @@
{"pid":1,"start_time":"unknown","token":"1a2e8a33-76f2-4175-ac23-1521458f12a2","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
@@ -1 +0,0 @@
{"pid":1,"start_time":"unknown","token":"a1342032-af80-4a49-a783-291bf73bf027","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
Whitespace-only changes.
@@ -1,12 +0,0 @@
{
"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
}
@@ -1 +0,0 @@
{"effectMarker":"f441857a-f591-474b-b34a-6d89b2bf9feb","incarnation":"linux:159099652","pid":487360}
@@ -1 +0,0 @@
{"effectMarker":"f441857a-f591-474b-b34a-6d89b2bf9feb","incarnation":"linux:159099652","pid":487360}
+203
View File
@@ -0,0 +1,203 @@
# Nix flake development environment
This repository includes a **`flake.nix`** for reproducible development environments on NixOS and systems with Nix installed.
## Prerequisites
- **Nix** (version 2.4 or later with flakes support enabled)
- **direnv** (optional, for automatic environment loading via `.envrc`)
To verify Nix is installed and flakes are enabled:
```bash
nix --version
nix flake --help
```
If flakes are not enabled, add this to your `~/.config/nix/nix.conf`:
```
experimental-features = nix-command flakes
```
## Quick start
### Enter the development environment
**Option 1: Using `nix flake`** (without direnv)
```bash
nix flake update # Update inputs (optional, pins nixpkgs)
nix develop # Enter default dev shell
nix develop .#full # Enter full dev shell with extra tools
exit # Leave the shell
```
**Option 2: Using direnv** (automatic on directory entry)
```bash
direnv allow # Trust the .envrc file (one-time setup)
cd . # Direnv reloads the flake automatically
```
When you leave the repository directory, the environment is automatically unloaded.
## Development shells
The flake provides two development shells:
### `default` (minimal)
```bash
nix develop
```
Includes:
- Rust (stable, latest)
- Rust source code (`rust-src`)
- `cargo-edit` (dependency management helpers)
- `cargo-expand` (macro expansion tool)
- `cargo-watch` (file watcher for incremental builds)
- Build tools: `pkg-config`, `openssl`, `git`, `ca-certificates`
### `full` (with extra tooling)
```bash
nix develop .#full
```
Includes everything from `default` plus:
- `clippy` (linter)
- `rustfmt` (code formatter)
- `mold` (fast linker)
- `lldb` (debugger)
## Building the claw binary
### With the dev environment
```bash
nix develop
cd rust
cargo build -p rusty-claude-cli
```
### Directly with `nix build`
```bash
nix build .#claw # Build the claw binary
nix build # Same as .#claw (default package)
```
The compiled binary is symlinked to `./result/bin/claw`.
## Running common tasks
Once in the dev shell:
```bash
# Format code
cd rust
cargo fmt
# Run clippy linter
cargo clippy --workspace --all-targets -- -D warnings
# Run all tests
cargo test --workspace
# Run tests for a specific crate
cargo test -p rusty-claude-cli
# Build with optimizations
cargo build --release -p rusty-claude-cli
```
## Environment variables
The flake automatically sets:
- `RUSTUP_TOOLCHAIN=stable` — uses stable Rust from nixpkgs
- Standard Rust build flags are inherited from the environment
For development, the minimal dev shell includes a helpful message on entry:
```
Claw Code development environment
Run 'cargo build -p rusty-claude-cli' to build the claw binary
Run 'cargo test --workspace' to run tests
Run 'scripts/fmt.sh' to format code
```
## Troubleshooting
### `command not found: nix`
Nix is not installed. Install it from [nixos.org/download.html](https://nixos.org/download.html).
### `error: experimental Nix feature 'flakes' is disabled`
Enable flakes in your Nix configuration (`~/.config/nix/nix.conf` or `/etc/nix/nix.conf`):
```
experimental-features = nix-command flakes
```
### `error: ... does not provide attribute 'claw'`
The flake structure may have changed. Try updating:
```bash
nix flake update
nix flake check
```
### Rebuilds are slow after updating dependencies
Cargo will recompile dependencies if `Cargo.lock` changes. This is expected:
```bash
nix build --rebuild
```
### `cache lock file needs to be updated`
The Cargo.lock in `rust/` needs to be committed. Run:
```bash
cd rust
cargo generate-lockfile
git add Cargo.lock
git commit -m "update: Cargo.lock"
```
## Integration with other tools
### IDE integration (VS Code, IntelliJ, etc.)
Many IDEs can detect and use Nix environments. Direnv integration is recommended:
1. Install the direnv extension
2. Enable `direnv` shell integration in your IDE
3. Run `direnv allow` in the repository
Your IDE will automatically use the correct Rust toolchain and dependencies from the flake.
### CI/CD
For GitHub Actions, consider using [cachix/install-nix-action](https://github.com/cachix/install-nix-action) to set up Nix on the runner:
```yaml
- uses: cachix/install-nix-action@v18
with:
nix_path: nixpkgs=channel:nixos-unstable
extra_nix_config: "experimental-features = nix-command flakes"
- run: nix develop -c cargo test --workspace
```
## More information
- [NixOS flakes documentation](https://nixos.wiki/wiki/Flakes)
- [Direnv documentation](https://direnv.net/)
- [Rust on Nix](https://github.com/oxalica/rust-overlay)
+115
View File
@@ -0,0 +1,115 @@
{
description = "claw-code: Claude Code CLI agent harness in Rust";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, flake-utils, rust-overlay }:
flake-utils.lib.eachDefaultSystem (system:
let
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs {
inherit system overlays;
};
# Build inputs needed for Rust and the project
buildInputs = with pkgs; [
pkg-config
openssl
ca-certificates
git
];
# Runtime inputs for the dev environment
nativeBuildInputs = with pkgs; [
(rust-bin.stable.latest.default.override {
extensions = [ "rust-src" ];
targets = [ ];
})
cargo-edit
cargo-expand
cargo-watch
];
# Derivation to build the claw binary
clawBinary = pkgs.rustPlatform.buildRustPackage {
pname = "claw";
version = (builtins.fromTOML (builtins.readFile ./rust/Cargo.toml)).workspace.package.version;
src = ./rust;
# Hash of all the dependencies in Cargo.lock
# This needs to be updated when dependencies change
# Run: nix flake update
cargoLock = {
lockFile = ./rust/Cargo.lock;
};
cargoBuildFlags = [ "-p" "rusty-claude-cli" ];
buildInputs = buildInputs ++ (with pkgs; [
openssl
]);
nativeBuildInputs = with pkgs; [
pkg-config
];
meta = with pkgs.lib; {
description = "Claude Code CLI: agent harness with Rust implementation";
homepage = "https://github.com/ultraworkers/claw-code";
license = licenses.mit;
platforms = platforms.unix;
};
};
in
{
# Provide the claw binary as a package
packages.claw = clawBinary;
packages.default = clawBinary;
# Development shell for contributors
devShells.default = pkgs.mkShell {
name = "claw-dev";
inherit buildInputs nativeBuildInputs;
shellHook = ''
echo "Claw Code development environment"
echo "Run 'cargo build -p rusty-claude-cli' to build the claw binary"
echo "Run 'cargo test --workspace' to run tests"
echo "Run 'scripts/fmt.sh' to format code"
'';
# Make sure we have the right Rust toolchain
RUSTUP_TOOLCHAIN = "stable";
};
# Alternative shell with additional development tools
devShells.full = pkgs.mkShell {
name = "claw-dev-full";
inherit buildInputs nativeBuildInputs;
nativeBuildInputs = nativeBuildInputs ++ (with pkgs; [
clippy
rustfmt
mold
lldb
]);
shellHook = ''
echo "Claw Code full development environment (with extra tools)"
echo "Available tools: clippy, rustfmt, mold (linker), lldb (debugger)"
'';
RUSTUP_TOOLCHAIN = "stable";
};
}
);
}
+26
View File
@@ -1131,6 +1131,9 @@ pub enum SlashCommand {
SecurityReview,
Keybindings,
PrivacySettings,
Workspace {
path: Option<String>,
},
Plan {
mode: Option<String>,
},
@@ -1272,6 +1275,7 @@ impl SlashCommand {
Self::SecurityReview => "/security-review",
Self::Keybindings => "/keybindings",
Self::PrivacySettings => "/privacy-settings",
Self::Workspace { .. } => "/workspace",
Self::Plan { .. } => "/plan",
Self::Review { .. } => "/review",
Self::Tasks { .. } => "/tasks",
@@ -1405,6 +1409,9 @@ pub fn validate_slash_command_input(
validate_no_args(command, &args)?;
SlashCommand::Setup
}
"workspace" | "cwd" => SlashCommand::Workspace {
path: optional_single_arg(command, &args, "[path]")?,
},
"login" | "logout" => {
return Err(command_error(
"This auth flow was removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead.",
@@ -5342,6 +5349,7 @@ pub fn handle_slash_command(
| SlashCommand::Cost
| SlashCommand::Resume { .. }
| SlashCommand::Config { .. }
| SlashCommand::Workspace { .. }
| SlashCommand::Mcp { .. }
| SlashCommand::Memory
| SlashCommand::Init
@@ -6119,6 +6127,24 @@ mod tests {
assert_eq!(suggest_slash_commands("zzz", 3), Vec::<String>::new());
}
#[test]
fn parses_workspace_slash_command_and_alias() {
let workspace = validate_slash_command_input("/workspace")
.expect("workspace should parse")
.expect("workspace should be a slash command");
assert_eq!(workspace, SlashCommand::Workspace { path: None });
let cwd = validate_slash_command_input("/cwd src")
.expect("cwd alias should parse")
.expect("cwd alias should be a slash command");
assert_eq!(
cwd,
SlashCommand::Workspace {
path: Some("src".to_string()),
}
);
}
#[test]
fn compacts_sessions_via_slash_command() {
let mut session = Session::new();
+153 -2
View File
@@ -13,6 +13,16 @@ 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,
@@ -99,6 +109,8 @@ enum Scenario {
BashPermissionPromptDenied,
PluginToolRoundtrip,
AutoCompactTriggered,
ContextWindowRetry,
StreamThenError,
TokenCostReporting,
}
@@ -116,6 +128,8 @@ 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,
}
@@ -134,6 +148,8 @@ 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",
}
}
@@ -149,6 +165,18 @@ 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,
@@ -158,7 +186,7 @@ async fn handle_connection(
raw_body,
});
let response = build_http_response(&request, scenario);
let response = build_http_response(&request, scenario, attempt);
socket.write_all(response.as_bytes()).await?;
Ok(())
}
@@ -308,7 +336,11 @@ fn flatten_tool_result_content(content: &[api::ToolResultContentBlock]) -> Strin
}
#[allow(clippy::too_many_lines)]
fn build_http_response(request: &MessageRequest, scenario: Scenario) -> String {
fn build_http_response(request: &MessageRequest, scenario: Scenario, attempt: usize) -> String {
if let Some(rejection) = context_window_rejection(scenario, attempt) {
return rejection;
}
let response = if request.stream {
let body = build_stream_body(request, scenario);
return http_response(
@@ -329,6 +361,37 @@ fn build_http_response(request: &MessageRequest, scenario: Scenario) -> String {
)
}
/// 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 {
@@ -461,6 +524,8 @@ 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)
}
@@ -628,6 +693,12 @@ 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.",
@@ -650,6 +721,8 @@ 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",
}
}
@@ -971,6 +1044,84 @@ 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(
+273 -23
View File
@@ -27,6 +27,7 @@ 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};
@@ -6714,6 +6715,49 @@ fn run_resume_command(
message: Some(render_memory_report()?),
json: Some(render_memory_json()?),
}),
SlashCommand::Workspace { path } => {
let cwd_before = env::current_dir()?;
let workspace_root = session
.workspace_root()
.map(Path::to_path_buf)
.unwrap_or_else(|| cwd_before.clone());
let workspace_root = canonicalize_or_clone(&workspace_root);
let changed = if let Some(path) = path.as_deref() {
let requested_path = Path::new(path);
let resolved_path = if requested_path.is_absolute() {
requested_path.to_path_buf()
} else {
cwd_before.join(requested_path)
};
let resolved_path = canonicalize_or_clone(&resolved_path);
if !resolved_path.starts_with(&workspace_root) {
return Err(format!(
"workspace_change_outside_root: `{}` is outside the current workspace root `{}`.\nUse `claw --cwd {}` to start a new session there.",
resolved_path.display(),
workspace_root.display(),
resolved_path.display(),
)
.into());
}
env::set_current_dir(&resolved_path)?;
true
} else {
false
};
let cwd_after = env::current_dir()?;
let message =
render_workspace_report(&cwd_after, &workspace_root, &session.session_id, changed);
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(message),
json: Some(workspace_report_json(
&cwd_after,
&workspace_root,
&session.session_id,
changed,
)),
})
}
SlashCommand::Init => {
// #142: run the init once, then render both text + structured JSON
// from the same InitReport so both surfaces stay in sync.
@@ -7702,6 +7746,7 @@ impl LiveCli {
system_prompt.clone(),
enable_tools,
true,
TerminalTail::new(),
allowed_tools.clone(),
permission_mode,
None,
@@ -7783,6 +7828,7 @@ 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(
@@ -7792,6 +7838,7 @@ impl LiveCli {
self.system_prompt.clone(),
true,
emit_output,
terminal_tail,
self.allowed_tools.clone(),
self.permission_mode,
None,
@@ -7809,27 +7856,39 @@ impl LiveCli {
}
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?;
let terminal_tail = TerminalTail::new();
let (mut runtime, hook_abort_monitor) =
self.prepare_turn_runtime(true, terminal_tail.clone())?;
let mut spinner = Spinner::new();
let mut stdout = io::stdout();
spinner.tick(
"🦀 Thinking...",
TerminalRenderer::new().color_theme(),
&mut 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,
)?;
}
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 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() {
// 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() {
println!();
}
spinner.finish(
@@ -7849,6 +7908,13 @@ 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(),
@@ -7961,14 +8027,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)?;
self.prepare_turn_runtime(true, terminal_tail.clone())?;
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 !final_assistant_text(&summary).is_empty() {
if !terminal_tail.at_line_start() {
println!();
}
spinner.finish(
@@ -8053,7 +8119,8 @@ 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)?;
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));
hook_abort_monitor.stop();
@@ -8066,7 +8133,8 @@ 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)?;
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));
hook_abort_monitor.stop();
@@ -8091,7 +8159,8 @@ 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)?;
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));
hook_abort_monitor.stop();
@@ -8190,6 +8259,7 @@ impl LiveCli {
Self::print_config(section.as_deref())?;
false
}
SlashCommand::Workspace { path } => self.handle_workspace_command(path.as_deref())?,
SlashCommand::Mcp { action, target } => {
let args = match (action.as_deref(), target.as_deref()) {
(None, None) => None,
@@ -8348,6 +8418,51 @@ impl LiveCli {
);
}
fn handle_workspace_command(
&mut self,
target: Option<&str>,
) -> Result<bool, Box<dyn std::error::Error>> {
let current_dir = env::current_dir()?;
let workspace_root = self
.runtime
.session()
.workspace_root()
.map(Path::to_path_buf)
.unwrap_or_else(|| current_dir.clone());
let workspace_root = canonicalize_or_clone(&workspace_root);
if let Some(target) = target {
let requested_path = Path::new(target);
let resolved_path = if requested_path.is_absolute() {
requested_path.to_path_buf()
} else {
current_dir.join(requested_path)
};
let resolved_path = canonicalize_or_clone(&resolved_path);
if !resolved_path.starts_with(&workspace_root) {
return Err(format!(
"workspace_change_outside_root: `{}` is outside the current workspace root `{}`.\nUse `claw --cwd {}` to start a new session there.",
resolved_path.display(),
workspace_root.display(),
resolved_path.display(),
)
.into());
}
env::set_current_dir(&resolved_path)?;
}
println!(
"{}",
render_workspace_report(
&env::current_dir()?,
&workspace_root,
&self.session.id,
target.is_some(),
)
);
Ok(target.is_some())
}
fn record_prompt_history(&mut self, prompt: &str) {
let timestamp_ms = std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -8447,6 +8562,7 @@ impl LiveCli {
self.system_prompt.clone(),
true,
true,
TerminalTail::new(),
self.allowed_tools.clone(),
self.permission_mode,
None,
@@ -8493,6 +8609,7 @@ impl LiveCli {
self.system_prompt.clone(),
true,
true,
TerminalTail::new(),
self.allowed_tools.clone(),
self.permission_mode,
None,
@@ -8523,6 +8640,7 @@ impl LiveCli {
self.system_prompt.clone(),
true,
true,
TerminalTail::new(),
self.allowed_tools.clone(),
self.permission_mode,
None,
@@ -8565,6 +8683,7 @@ impl LiveCli {
self.system_prompt.clone(),
true,
true,
TerminalTail::new(),
self.allowed_tools.clone(),
self.permission_mode,
None,
@@ -8918,6 +9037,7 @@ impl LiveCli {
self.system_prompt.clone(),
true,
true,
TerminalTail::new(),
self.allowed_tools.clone(),
self.permission_mode,
None,
@@ -8953,6 +9073,7 @@ impl LiveCli {
self.system_prompt.clone(),
true,
true,
TerminalTail::new(),
self.allowed_tools.clone(),
self.permission_mode,
None,
@@ -9047,6 +9168,7 @@ impl LiveCli {
self.system_prompt.clone(),
true,
true,
TerminalTail::new(),
self.allowed_tools.clone(),
self.permission_mode,
None,
@@ -9067,6 +9189,7 @@ impl LiveCli {
self.system_prompt.clone(),
true,
true,
TerminalTail::new(),
self.allowed_tools.clone(),
self.permission_mode,
None,
@@ -9091,6 +9214,7 @@ impl LiveCli {
self.system_prompt.clone(),
enable_tools,
false,
TerminalTail::new(),
self.allowed_tools.clone(),
self.permission_mode,
progress,
@@ -9179,6 +9303,41 @@ fn new_cli_session() -> Result<Session, Box<dyn std::error::Error>> {
Ok(Session::new().with_workspace_root(env::current_dir()?))
}
fn canonicalize_or_clone(path: &Path) -> PathBuf {
fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
fn render_workspace_report(
cwd: &Path,
workspace_root: &Path,
session_id: &str,
changed: bool,
) -> String {
let action = if changed { "change" } else { "show" };
format!(
"Workspace\n Action {action}\n Session {session_id}\n Workspace root {}\n Current directory {}",
workspace_root.display(),
cwd.display(),
)
}
fn workspace_report_json(
cwd: &Path,
workspace_root: &Path,
session_id: &str,
changed: bool,
) -> Value {
serde_json::json!({
"kind": "workspace",
"action": if changed { "change" } else { "show" },
"status": "ok",
"session_id": session_id,
"workspace_root": workspace_root.display().to_string(),
"current_directory": cwd.display().to_string(),
"changed": changed,
})
}
fn create_managed_session_handle(
session_id: &str,
) -> Result<SessionHandle, Box<dyn std::error::Error>> {
@@ -12500,6 +12659,7 @@ 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>,
@@ -12512,6 +12672,7 @@ fn build_runtime(
system_prompt,
enable_tools,
emit_output,
terminal_tail,
allowed_tools,
permission_mode,
progress_reporter,
@@ -12528,6 +12689,7 @@ 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>,
@@ -12553,6 +12715,7 @@ fn build_runtime_with_plugin_state(
model,
enable_tools,
emit_output,
terminal_tail.clone(),
allowed_tools.clone(),
tool_registry.clone(),
progress_reporter,
@@ -12560,6 +12723,7 @@ fn build_runtime_with_plugin_state(
CliToolExecutor::new(
allowed_tools.clone(),
emit_output,
terminal_tail,
tool_registry.clone(),
mcp_state.clone(),
),
@@ -12655,6 +12819,76 @@ 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
@@ -12668,6 +12902,7 @@ struct AnthropicRuntimeClient {
model: String,
enable_tools: bool,
emit_output: bool,
terminal_tail: TerminalTail,
allowed_tools: Option<AllowedToolSet>,
tool_registry: GlobalToolRegistry,
progress_reporter: Option<InternalPromptProgressReporter>,
@@ -12680,6 +12915,7 @@ impl AnthropicRuntimeClient {
model: String,
enable_tools: bool,
emit_output: bool,
terminal_tail: TerminalTail,
allowed_tools: Option<AllowedToolSet>,
tool_registry: GlobalToolRegistry,
progress_reporter: Option<InternalPromptProgressReporter>,
@@ -12733,6 +12969,7 @@ impl AnthropicRuntimeClient {
model,
enable_tools,
emit_output,
terminal_tail,
allowed_tools,
tool_registry,
progress_reporter,
@@ -12832,8 +13069,9 @@ 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 stdout
&mut tracked
} else {
&mut sink
};
@@ -13997,6 +14235,7 @@ 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>>>,
@@ -14006,12 +14245,14 @@ 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,
@@ -14105,8 +14346,11 @@ 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 io::stdout())
.stream_markdown(&markdown, &mut tracked)
.map_err(|error| ToolError::new(error.to_string()))?;
}
Ok(output)
@@ -14114,8 +14358,11 @@ 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 io::stdout())
.stream_markdown(&markdown, &mut tracked)
.map_err(|stream_error| ToolError::new(stream_error.to_string()))?;
}
Err(error)
@@ -14430,8 +14677,8 @@ mod tests {
CliOutputFormat, CliToolExecutor, GitOperation, GitWorkspaceSummary,
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic,
PermissionModeProvenance, PromptHistoryEntry, SessionLifecycleKind,
SessionLifecycleSummary, SlashCommand, StatusUsage, TmuxPaneSnapshot, DEFAULT_MODEL,
LATEST_SESSION_REFERENCE, STUB_COMMANDS,
SessionLifecycleSummary, SlashCommand, StatusUsage, TerminalTail, TmuxPaneSnapshot,
DEFAULT_MODEL, LATEST_SESSION_REFERENCE, STUB_COMMANDS,
};
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
use plugins::{
@@ -19354,6 +19601,7 @@ UU conflicted.rs",
let mut executor = CliToolExecutor::new(
None,
false,
TerminalTail::new(),
state.tool_registry.clone(),
state.mcp_state.clone(),
);
@@ -19452,6 +19700,7 @@ UU conflicted.rs",
let mut executor = CliToolExecutor::new(
None,
false,
TerminalTail::new(),
state.tool_registry.clone(),
state.mcp_state.clone(),
);
@@ -19512,6 +19761,7 @@ UU conflicted.rs",
vec!["test system prompt".to_string()],
true,
false,
TerminalTail::new(),
None,
PermissionMode::DangerFullAccess,
None,
@@ -7,7 +7,9 @@ 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, SCENARIO_PREFIX};
use mock_anthropic_service::{
MockAnthropicService, CONTEXT_WINDOW_RETRY_TEXT, SCENARIO_PREFIX, STREAM_THEN_ERROR_TEXT,
};
use serde_json::Value;
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -189,6 +191,199 @@ 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");
@@ -441,6 +441,9 @@ fn status_and_sandbox_emit_json_when_requested() {
// #831: direct resume-safe slash commands should use the same local CliAction
// JSON surfaces as their bare subcommands, not interactive_only guidance.
// Note: /sandbox status depends on kernel support for user namespaces.
// On systems with working user namespaces the status is "ok".
// On systems without user namespaces the status is "warn" (#731).
#[test]
fn direct_resume_safe_slash_commands_route_to_local_json_actions_831() {
let root = unique_temp_dir("direct-resume-safe-slash-831");
@@ -451,11 +454,11 @@ fn direct_resume_safe_slash_commands_route_to_local_json_actions_831() {
.output()
.expect("git init should launch");
for (command, expected_kind, expected_status) in [
("/version", "version", "ok"),
("/sandbox", "sandbox", "warn"),
("/diff", "diff", "ok"),
("/status", "status", "ok"),
for (command, expected_kind, expected_statuses) in [
("/version", "version", &["ok"][..]),
("/sandbox", "sandbox", &["ok", "warn"][..]),
("/diff", "diff", &["ok"][..]),
("/status", "status", &["ok"][..]),
] {
let output = run_claw(&root, &["--output-format", "json", command], &[]);
assert!(
@@ -470,9 +473,9 @@ fn direct_resume_safe_slash_commands_route_to_local_json_actions_831() {
.unwrap_or_else(|_| panic!("{command} must emit JSON (#831), got: {stdout:?}"));
assert_eq!(parsed["kind"], expected_kind, "{command} kind: {parsed}");
assert_eq!(
parsed["status"], expected_status,
"{command} status: {parsed}"
assert!(
expected_statuses.contains(&parsed["status"].as_str().unwrap_or("")),
"{command} status must be one of {expected_statuses:?}: {parsed}"
);
assert_ne!(
parsed["error_kind"], "interactive_only",