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
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
Bellman 972ba85c3b Merge pull request #3268 from nankingjing/docs-model-aliases
docs(rust/README): document xAI and Kimi model aliases
2026-10-13 07:16:43 +09:00
Bellmanandclawdbot 6d6c83be01 docs(security): enable GitHub Security Advisories instructions (Fixes #3276) (#3311)
- Updated SECURITY.md with explicit instructions for reporting vulnerabilities via GitHub Security Advisories
- Replaced 'when available' qualifier since Private Vulnerability Reporting is now enabled
- Provided step-by-step guidance for security researchers to submit vulnerability reports

Co-authored-by: clawdbot <clawdbot@ultraworkers.dev>
2026-10-13 07:15:26 +09:00
nankingjing 072594dfee test(api): lock the README model-alias table to the resolver
The `## Model Aliases` table and `MODEL_REGISTRY` had drifted: the table
listed only the three Anthropic aliases while the resolver had grown the
xAI and Kimi entries, so an alias could resolve without being documented.

Assert the two agree in both directions — every registry alias appears in
the table with the canonical id and auth env var it actually resolves to,
and the table introduces no alias the resolver does not know. Each row is
also checked case-insensitively, since the README claims `OPUS` behaves
the same as `opus`.

Also pin that a provider-prefixed form such as `anthropic/opus` is not an
alias: it is passed through as-is rather than expanded. The README now
says so, which is what made the distinction worth a test rather than a
comment.
2026-09-24 12:19:39 +08:00
黄云龙 b89ddda387 docs(rust/README): document xAI and Kimi model aliases 2026-07-09 23:27:48 +08:00
User a6c32edcf6 fix(commands): wire workspace slash command 2026-06-08 23:21:53 +08:00
21 changed files with 1510 additions and 301 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
use flake
+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}
+10 -4
View File
@@ -8,10 +8,16 @@ unless a maintainer explicitly marks them as supported.
## Reporting a vulnerability
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.
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.
Include:
+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";
};
}
);
}
+23 -6
View File
@@ -110,13 +110,30 @@ Primary artifacts:
## Model Aliases
Short names resolve to the latest model versions:
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`.
| Alias | Resolves To |
|-------|------------|
| `opus` | `claude-opus-4-7` |
| `sonnet` | `claude-sonnet-4-6` |
| `haiku` | `claude-haiku-4-5-20251213` |
| 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).
## CLI Flags and Commands
+129
View File
@@ -927,6 +927,135 @@ 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);
+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(
+360 -240
View File
@@ -49,10 +49,10 @@ impl JsonValue {
.join(",");
format!("[{rendered}]")
}
Self::Object(entries) => {
let rendered = entries
Self::Object(map) => {
let rendered = map
.iter()
.map(|(key, value)| format!("{}:{}", render_string(key), value.render()))
.map(|(k, v)| format!("{}:{}", render_string(k), v.render()))
.collect::<Vec<_>>()
.join(",");
format!("{{{rendered}}}")
@@ -60,45 +60,10 @@ impl JsonValue {
}
}
pub fn parse(source: &str) -> Result<Self, JsonError> {
let mut parser = Parser::new(source);
let value = parser.parse_value()?;
parser.skip_whitespace();
if parser.is_eof() {
Ok(value)
} else {
Err(JsonError::new("unexpected trailing content"))
}
}
#[must_use]
pub fn as_object(&self) -> Option<&BTreeMap<String, JsonValue>> {
match self {
Self::Object(value) => Some(value),
_ => None,
}
}
#[must_use]
pub fn as_array(&self) -> Option<&[JsonValue]> {
match self {
Self::Array(value) => Some(value),
_ => None,
}
}
#[must_use]
pub fn as_str(&self) -> Option<&str> {
match self {
Self::String(value) => Some(value),
_ => None,
}
}
#[must_use]
pub fn as_bool(&self) -> Option<bool> {
match self {
Self::Bool(value) => Some(*value),
Self::Bool(b) => Some(*b),
_ => None,
}
}
@@ -106,249 +71,370 @@ impl JsonValue {
#[must_use]
pub fn as_i64(&self) -> Option<i64> {
match self {
Self::Number(value) => Some(*value),
Self::Number(n) => Some(*n),
_ => None,
}
}
}
fn render_string(value: &str) -> String {
let mut rendered = String::with_capacity(value.len() + 2);
rendered.push('"');
for ch in value.chars() {
match ch {
'"' => rendered.push_str("\\\""),
'\\' => rendered.push_str("\\\\"),
'\n' => rendered.push_str("\\n"),
'\r' => rendered.push_str("\\r"),
'\t' => rendered.push_str("\\t"),
'\u{08}' => rendered.push_str("\\b"),
'\u{0C}' => rendered.push_str("\\f"),
control if control.is_control() => push_unicode_escape(&mut rendered, control),
plain => rendered.push(plain),
}
}
rendered.push('"');
rendered
}
fn push_unicode_escape(rendered: &mut String, control: char) {
const HEX: &[u8; 16] = b"0123456789abcdef";
rendered.push_str("\\u");
let value = u32::from(control);
for shift in [12_u32, 8, 4, 0] {
let nibble = ((value >> shift) & 0xF) as usize;
rendered.push(char::from(HEX[nibble]));
}
}
struct Parser<'a> {
chars: Vec<char>,
index: usize,
_source: &'a str,
}
impl<'a> Parser<'a> {
fn new(source: &'a str) -> Self {
Self {
chars: source.chars().collect(),
index: 0,
_source: source,
#[must_use]
pub fn as_str(&self) -> Option<&str> {
match self {
Self::String(s) => Some(s),
_ => None,
}
}
fn parse_value(&mut self) -> Result<JsonValue, JsonError> {
self.skip_whitespace();
match self.peek() {
Some('n') => self.parse_literal("null", JsonValue::Null),
Some('t') => self.parse_literal("true", JsonValue::Bool(true)),
Some('f') => self.parse_literal("false", JsonValue::Bool(false)),
Some('"') => self.parse_string().map(JsonValue::String),
Some('[') => self.parse_array(),
Some('{') => self.parse_object(),
Some('-' | '0'..='9') => self.parse_number().map(JsonValue::Number),
Some(other) => Err(JsonError::new(format!("unexpected character: {other}"))),
None => Err(JsonError::new("unexpected end of input")),
#[must_use]
pub fn as_array(&self) -> Option<&[JsonValue]> {
match self {
Self::Array(a) => Some(a),
_ => None,
}
}
fn parse_literal(&mut self, expected: &str, value: JsonValue) -> Result<JsonValue, JsonError> {
for expected_char in expected.chars() {
if self.next() != Some(expected_char) {
return Err(JsonError::new(format!(
"invalid literal: expected {expected}"
)));
}
#[must_use]
pub fn as_object(&self) -> Option<&BTreeMap<String, JsonValue>> {
match self {
Self::Object(o) => Some(o),
_ => None,
}
}
pub fn parse(input: &str) -> Result<Self, JsonError> {
let mut chars = input.trim().chars().peekable();
let value = parse_value(&mut chars)?;
if chars.peek().is_some() {
return Err(JsonError::new("trailing content"));
}
Ok(value)
}
}
fn parse_string(&mut self) -> Result<String, JsonError> {
self.expect('"')?;
let mut value = String::new();
while let Some(ch) = self.next() {
match ch {
'"' => return Ok(value),
'\\' => value.push(self.parse_escape()?),
plain => value.push(plain),
}
}
Err(JsonError::new("unterminated string"))
fn parse_value(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
match chars.peek() {
Some('n') => parse_null(chars),
Some('t') | Some('f') => parse_bool(chars),
Some('"') => parse_string(chars).map(JsonValue::String),
Some('[') => parse_array(chars),
Some('{') => parse_object(chars),
Some('-') | Some('0'..='9') => parse_number(chars),
Some(c) => Err(JsonError::new(format!("unexpected char: {c}"))),
None => Err(JsonError::new("empty input")),
}
}
fn parse_null(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
if consume_literal(chars, "null") {
Ok(JsonValue::Null)
} else {
Err(JsonError::new("invalid null"))
}
}
fn parse_bool(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
if consume_literal(chars, "true") {
return Ok(JsonValue::Bool(true));
}
if consume_literal(chars, "false") {
return Ok(JsonValue::Bool(false));
}
Err(JsonError::new("invalid bool"))
}
fn parse_string(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<String, JsonError> {
if chars.next() != Some('"') {
return Err(JsonError::new("expected opening quote"));
}
fn parse_escape(&mut self) -> Result<char, JsonError> {
match self.next() {
Some('"') => Ok('"'),
Some('\\') => Ok('\\'),
Some('/') => Ok('/'),
Some('b') => Ok('\u{08}'),
Some('f') => Ok('\u{0C}'),
Some('n') => Ok('\n'),
Some('r') => Ok('\r'),
Some('t') => Ok('\t'),
Some('u') => self.parse_unicode_escape(),
Some(other) => Err(JsonError::new(format!("invalid escape sequence: {other}"))),
None => Err(JsonError::new("unexpected end of input in escape sequence")),
let mut result = String::new();
loop {
match chars.next() {
None => return Err(JsonError::new("unterminated string")),
Some('"') => return Ok(result),
Some('\\') => match chars.next() {
Some('"') => result.push('"'),
Some('\\') => result.push('\\'),
Some('/') => result.push('/'),
Some('b') => result.push('\u{08}'),
Some('f') => result.push('\u{0C}'),
Some('n') => result.push('\n'),
Some('r') => result.push('\r'),
Some('t') => result.push('\t'),
Some('u') => {
let mut hex = String::new();
for _ in 0..4 {
match chars.next() {
Some(c @ '0'..='9') | Some(c @ 'a'..='f') | Some(c @ 'A'..='F') => {
hex.push(c)
}
_ => return Err(JsonError::new("invalid unicode escape")),
}
}
let code = u32::from_str_radix(&hex, 16)
.map_err(|_| JsonError::new("invalid hex code"))?;
if (0xD800..=0xDFFF).contains(&code) {
return Err(JsonError::new("surrogate not allowed"));
}
result.push(
char::from_u32(code).ok_or_else(|| JsonError::new("invalid codepoint"))?,
);
}
_ => return Err(JsonError::new("invalid escape")),
},
Some(c) => result.push(c),
}
}
}
fn parse_unicode_escape(&mut self) -> Result<char, JsonError> {
let mut value = 0_u32;
for _ in 0..4 {
let Some(ch) = self.next() else {
return Err(JsonError::new("unexpected end of input in unicode escape"));
};
value = (value << 4)
| ch.to_digit(16)
.ok_or_else(|| JsonError::new("invalid unicode escape"))?;
}
char::from_u32(value).ok_or_else(|| JsonError::new("invalid unicode scalar value"))
fn parse_number(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
let mut num_str = String::new();
if chars.peek() == Some(&'-') {
num_str.push(chars.next().unwrap());
}
fn parse_array(&mut self) -> Result<JsonValue, JsonError> {
self.expect('[')?;
let mut values = Vec::new();
loop {
self.skip_whitespace();
if self.try_consume(']') {
break;
}
values.push(self.parse_value()?);
self.skip_whitespace();
if self.try_consume(']') {
break;
}
self.expect(',')?;
}
Ok(JsonValue::Array(values))
}
fn parse_object(&mut self) -> Result<JsonValue, JsonError> {
self.expect('{')?;
let mut entries = BTreeMap::new();
loop {
self.skip_whitespace();
if self.try_consume('}') {
break;
}
let key = self.parse_string()?;
self.skip_whitespace();
self.expect(':')?;
let value = self.parse_value()?;
entries.insert(key, value);
self.skip_whitespace();
if self.try_consume('}') {
break;
}
self.expect(',')?;
}
Ok(JsonValue::Object(entries))
}
fn parse_number(&mut self) -> Result<i64, JsonError> {
let mut value = String::new();
if self.try_consume('-') {
value.push('-');
}
while let Some(ch @ '0'..='9') = self.peek() {
value.push(ch);
self.index += 1;
}
if value.is_empty() || value == "-" {
return Err(JsonError::new("invalid number"));
}
value
.parse::<i64>()
.map_err(|_| JsonError::new("number out of range"))
}
fn expect(&mut self, expected: char) -> Result<(), JsonError> {
match self.next() {
Some(actual) if actual == expected => Ok(()),
Some(actual) => Err(JsonError::new(format!(
"expected '{expected}', found '{actual}'"
))),
None => Err(JsonError::new(format!(
"expected '{expected}', found end of input"
))),
}
}
fn try_consume(&mut self, expected: char) -> bool {
if self.peek() == Some(expected) {
self.index += 1;
true
while let Some(&c) = chars.peek() {
if c.is_ascii_digit() {
num_str.push(chars.next().unwrap());
} else {
false
break;
}
}
fn skip_whitespace(&mut self) {
while matches!(self.peek(), Some(' ' | '\n' | '\r' | '\t')) {
self.index += 1;
if num_str.is_empty() || num_str == "-" {
return Err(JsonError::new("invalid number"));
}
num_str
.parse::<i64>()
.map(JsonValue::Number)
.map_err(|_| JsonError::new("number out of range"))
}
fn parse_array(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
if chars.next() != Some('[') {
return Err(JsonError::new("expected ["));
}
let mut values = Vec::new();
skip_whitespace(chars);
if chars.peek() == Some(&']') {
chars.next();
return Ok(JsonValue::Array(values));
}
loop {
skip_whitespace(chars);
values.push(parse_value(chars)?);
skip_whitespace(chars);
match chars.peek() {
Some(',') => {
chars.next();
}
Some(']') => {
chars.next();
return Ok(JsonValue::Array(values));
}
_ => return Err(JsonError::new("expected , or ]")),
}
}
}
fn peek(&self) -> Option<char> {
self.chars.get(self.index).copied()
fn parse_object(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<JsonValue, JsonError> {
if chars.next() != Some('{') {
return Err(JsonError::new("expected {"));
}
fn next(&mut self) -> Option<char> {
let ch = self.peek()?;
self.index += 1;
Some(ch)
let mut map = BTreeMap::new();
skip_whitespace(chars);
if chars.peek() == Some(&'}') {
chars.next();
return Ok(JsonValue::Object(map));
}
fn is_eof(&self) -> bool {
self.index >= self.chars.len()
loop {
skip_whitespace(chars);
if chars.peek() != Some(&'"') {
return Err(JsonError::new("expected key string"));
}
let key = parse_string(chars)?;
skip_whitespace(chars);
if chars.next() != Some(':') {
return Err(JsonError::new("expected :"));
}
skip_whitespace(chars);
let value = parse_value(chars)?;
map.insert(key, value);
skip_whitespace(chars);
match chars.peek() {
Some(',') => {
chars.next();
}
Some('}') => {
chars.next();
return Ok(JsonValue::Object(map));
}
_ => return Err(JsonError::new("expected , or }")),
}
}
}
fn consume_literal(chars: &mut std::iter::Peekable<std::str::Chars>, literal: &str) -> bool {
let remaining: String = chars.clone().collect();
if remaining.starts_with(literal) {
for _ in literal.chars() {
chars.next();
}
true
} else {
false
}
}
fn skip_whitespace(chars: &mut std::iter::Peekable<std::str::Chars>) {
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
chars.next();
} else {
break;
}
}
}
fn render_string(s: &str) -> String {
let mut result = String::from('"');
for c in s.chars() {
match c {
'"' => result.push_str("\\\""),
'\\' => result.push_str("\\\\"),
'\n' => result.push_str("\\n"),
'\r' => result.push_str("\\r"),
'\t' => result.push_str("\\t"),
'\u{08}' => result.push_str("\\b"),
'\u{0C}' => result.push_str("\\f"),
c if c.is_control() => {
let code = c as u32;
result.push_str(&format!("\\u{code:04x}"));
}
c => result.push(c),
}
}
result.push('"');
result
}
#[cfg(test)]
mod tests {
use super::{render_string, JsonValue};
use std::collections::BTreeMap;
use super::*;
#[test]
fn renders_and_parses_json_values() {
let mut object = BTreeMap::new();
object.insert("flag".to_string(), JsonValue::Bool(true));
object.insert(
"items".to_string(),
JsonValue::Array(vec![
JsonValue::Number(4),
JsonValue::String("ok".to_string()),
]),
fn renders_primitive_values() {
assert_eq!(JsonValue::Null.render(), "null");
assert_eq!(JsonValue::Bool(true).render(), "true");
assert_eq!(JsonValue::Bool(false).render(), "false");
assert_eq!(JsonValue::Number(42).render(), "42");
assert_eq!(JsonValue::Number(-7).render(), "-7");
assert_eq!(JsonValue::String("hi".to_string()).render(), "\"hi\"");
}
#[test]
fn renders_arrays_and_objects_with_sorted_keys() {
assert_eq!(JsonValue::Array(Vec::new()).render(), "[]");
assert_eq!(
JsonValue::Array(vec![JsonValue::Number(1), JsonValue::Number(2)]).render(),
"[1,2]"
);
assert_eq!(JsonValue::Object(BTreeMap::new()).render(), "{}");
let rendered = JsonValue::Object(object).render();
let parsed = JsonValue::parse(&rendered).expect("json should parse");
let mut object = BTreeMap::new();
object.insert("b".to_string(), JsonValue::Bool(false));
object.insert("a".to_string(), JsonValue::Number(1));
// A BTreeMap iterates keys in sorted order, so object rendering is
// deterministic regardless of insertion order.
assert_eq!(JsonValue::Object(object).render(), "{\"a\":1,\"b\":false}");
}
assert_eq!(parsed.as_object().expect("object").len(), 2);
#[test]
fn render_string_escapes_quotes_backslashes_and_other_controls() {
// Backslash and double-quote are backslash-escaped.
assert_eq!(render_string("\"\\"), "\"\\\"\\\\\"");
// Carriage return has a dedicated short escape.
assert_eq!(render_string("\r"), "\"\\r\"");
// Other control characters fall back to the \uXXXX form (U+0001 here).
assert_eq!(render_string("\u{1}"), "\"\\u0001\"");
}
#[test]
fn parses_primitive_values() {
assert_eq!(
JsonValue::parse("null").expect("null parses"),
JsonValue::Null
);
assert_eq!(
JsonValue::parse("true").expect("true parses"),
JsonValue::Bool(true)
);
assert_eq!(
JsonValue::parse("false").expect("false parses"),
JsonValue::Bool(false)
);
assert_eq!(
JsonValue::parse("\"hello\"").expect("string parses"),
JsonValue::String("hello".to_string())
);
}
#[test]
fn parses_signed_integers_and_ignores_surrounding_whitespace() {
assert_eq!(
JsonValue::parse("-42").expect("negative parses").as_i64(),
Some(-42)
);
assert_eq!(
JsonValue::parse(" 123 ").expect("padded parses").as_i64(),
Some(123)
);
}
#[test]
fn parses_nested_arrays_and_objects() {
let value =
JsonValue::parse("{\"nums\": [1, 2], \"flag\": true}").expect("nested json parses");
let object = value.as_object().expect("top level is an object");
assert_eq!(object.len(), 2);
let nums = object
.get("nums")
.and_then(JsonValue::as_array)
.expect("nums is an array");
assert_eq!(nums.len(), 2);
assert_eq!(nums[0].as_i64(), Some(1));
assert_eq!(object.get("flag").and_then(JsonValue::as_bool), Some(true));
}
#[test]
fn round_trips_rendered_values_through_the_parser() {
let mut object = BTreeMap::new();
object.insert("name".to_string(), JsonValue::String("clawi".to_string()));
object.insert("count".to_string(), JsonValue::Number(-3));
object.insert(
"tags".to_string(),
JsonValue::Array(vec![JsonValue::Bool(true), JsonValue::Null]),
);
let original = JsonValue::Object(object);
let reparsed = JsonValue::parse(&original.render()).expect("rendered json should re-parse");
assert_eq!(reparsed, original);
}
#[test]
@@ -433,6 +519,7 @@ mod tests {
object.insert("a\nb".to_string(), JsonValue::String("c\td".to_string()));
let original = JsonValue::Object(object);
let reparsed = JsonValue::parse(&original.render()).expect("escaped key re-parses");
assert_eq!(reparsed, original);
}
@@ -471,4 +558,37 @@ mod tests {
"i64::MIN - 1"
);
}
#[test]
fn parse_rejects_malformed_input() {
assert!(JsonValue::parse("").is_err(), "empty input");
assert!(JsonValue::parse("nul").is_err(), "truncated literal");
assert!(JsonValue::parse("true false").is_err(), "trailing content");
assert!(
JsonValue::parse("\"unterminated").is_err(),
"unterminated string"
);
assert!(JsonValue::parse("[1, 2").is_err(), "unterminated array");
}
#[test]
fn parse_rejects_integers_outside_the_i64_range() {
// Far beyond i64::MAX (~9.2e18): the parser must reject rather than
// silently truncate the value.
assert!(JsonValue::parse("9999999999999999999999999").is_err());
}
#[test]
fn accessors_return_none_for_mismatched_variants() {
assert_eq!(JsonValue::Null.as_bool(), None);
assert_eq!(JsonValue::Bool(true).as_i64(), None);
assert!(JsonValue::Number(1).as_str().is_none());
assert!(JsonValue::Number(1).as_array().is_none());
assert!(JsonValue::String("x".to_string()).as_object().is_none());
}
#[test]
fn json_error_displays_its_message() {
assert_eq!(JsonError::new("boom").to_string(), "boom");
}
}
+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",