mirror of
https://github.com/instructkr/claw-code.git
synced 2026-10-30 00:00:22 +08:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a1c4b29c7 | ||
|
|
0663c80ba1 | ||
|
|
e97ddc767f | ||
|
|
95f797f5e2 | ||
|
|
ff515c9aeb | ||
|
|
0f1a69659c | ||
|
|
990cc33c19 | ||
|
|
8d2c7b78b2 | ||
|
|
a6c32edcf6 |
No files matched your search
@@ -57,7 +57,7 @@ jobs:
|
|||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
cp "target/release/${{ matrix.bin }}" "dist/${{ matrix.artifact_name }}"
|
cp "target/release/${{ matrix.bin }}" "dist/${{ matrix.artifact_name }}"
|
||||||
chmod +x "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
|
- name: Upload workflow artifact
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ on:
|
|||||||
- tests/test_roadmap_helpers.py
|
- tests/test_roadmap_helpers.py
|
||||||
- docs/**
|
- docs/**
|
||||||
- rust/**
|
- rust/**
|
||||||
|
- flake.nix
|
||||||
|
- .envrc
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
@@ -47,6 +49,8 @@ on:
|
|||||||
- tests/test_roadmap_helpers.py
|
- tests/test_roadmap_helpers.py
|
||||||
- docs/**
|
- docs/**
|
||||||
- rust/**
|
- rust/**
|
||||||
|
- flake.nix
|
||||||
|
- .envrc
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
|
|||||||
@@ -13,3 +13,9 @@ archive/
|
|||||||
status-help.txt
|
status-help.txt
|
||||||
# Legacy Python port session scratch artifacts
|
# Legacy Python port session scratch artifacts
|
||||||
.port_sessions/
|
.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
@@ -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}
|
|
||||||
@@ -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)
|
||||||
@@ -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";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1131,6 +1131,9 @@ pub enum SlashCommand {
|
|||||||
SecurityReview,
|
SecurityReview,
|
||||||
Keybindings,
|
Keybindings,
|
||||||
PrivacySettings,
|
PrivacySettings,
|
||||||
|
Workspace {
|
||||||
|
path: Option<String>,
|
||||||
|
},
|
||||||
Plan {
|
Plan {
|
||||||
mode: Option<String>,
|
mode: Option<String>,
|
||||||
},
|
},
|
||||||
@@ -1272,6 +1275,7 @@ impl SlashCommand {
|
|||||||
Self::SecurityReview => "/security-review",
|
Self::SecurityReview => "/security-review",
|
||||||
Self::Keybindings => "/keybindings",
|
Self::Keybindings => "/keybindings",
|
||||||
Self::PrivacySettings => "/privacy-settings",
|
Self::PrivacySettings => "/privacy-settings",
|
||||||
|
Self::Workspace { .. } => "/workspace",
|
||||||
Self::Plan { .. } => "/plan",
|
Self::Plan { .. } => "/plan",
|
||||||
Self::Review { .. } => "/review",
|
Self::Review { .. } => "/review",
|
||||||
Self::Tasks { .. } => "/tasks",
|
Self::Tasks { .. } => "/tasks",
|
||||||
@@ -1405,6 +1409,9 @@ pub fn validate_slash_command_input(
|
|||||||
validate_no_args(command, &args)?;
|
validate_no_args(command, &args)?;
|
||||||
SlashCommand::Setup
|
SlashCommand::Setup
|
||||||
}
|
}
|
||||||
|
"workspace" | "cwd" => SlashCommand::Workspace {
|
||||||
|
path: optional_single_arg(command, &args, "[path]")?,
|
||||||
|
},
|
||||||
"login" | "logout" => {
|
"login" | "logout" => {
|
||||||
return Err(command_error(
|
return Err(command_error(
|
||||||
"This auth flow was removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead.",
|
"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::Cost
|
||||||
| SlashCommand::Resume { .. }
|
| SlashCommand::Resume { .. }
|
||||||
| SlashCommand::Config { .. }
|
| SlashCommand::Config { .. }
|
||||||
|
| SlashCommand::Workspace { .. }
|
||||||
| SlashCommand::Mcp { .. }
|
| SlashCommand::Mcp { .. }
|
||||||
| SlashCommand::Memory
|
| SlashCommand::Memory
|
||||||
| SlashCommand::Init
|
| SlashCommand::Init
|
||||||
@@ -6119,6 +6127,24 @@ mod tests {
|
|||||||
assert_eq!(suggest_slash_commands("zzz", 3), Vec::<String>::new());
|
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]
|
#[test]
|
||||||
fn compacts_sessions_via_slash_command() {
|
fn compacts_sessions_via_slash_command() {
|
||||||
let mut session = Session::new();
|
let mut session = Session::new();
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ use tokio::task::JoinHandle;
|
|||||||
pub const SCENARIO_PREFIX: &str = "PARITY_SCENARIO:";
|
pub const SCENARIO_PREFIX: &str = "PARITY_SCENARIO:";
|
||||||
pub const DEFAULT_MODEL: &str = "claude-sonnet-4-6";
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct CapturedRequest {
|
pub struct CapturedRequest {
|
||||||
pub method: String,
|
pub method: String,
|
||||||
@@ -99,6 +109,8 @@ enum Scenario {
|
|||||||
BashPermissionPromptDenied,
|
BashPermissionPromptDenied,
|
||||||
PluginToolRoundtrip,
|
PluginToolRoundtrip,
|
||||||
AutoCompactTriggered,
|
AutoCompactTriggered,
|
||||||
|
ContextWindowRetry,
|
||||||
|
StreamThenError,
|
||||||
TokenCostReporting,
|
TokenCostReporting,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +128,8 @@ impl Scenario {
|
|||||||
"bash_permission_prompt_denied" => Some(Self::BashPermissionPromptDenied),
|
"bash_permission_prompt_denied" => Some(Self::BashPermissionPromptDenied),
|
||||||
"plugin_tool_roundtrip" => Some(Self::PluginToolRoundtrip),
|
"plugin_tool_roundtrip" => Some(Self::PluginToolRoundtrip),
|
||||||
"auto_compact_triggered" => Some(Self::AutoCompactTriggered),
|
"auto_compact_triggered" => Some(Self::AutoCompactTriggered),
|
||||||
|
"context_window_retry" => Some(Self::ContextWindowRetry),
|
||||||
|
"stream_then_error" => Some(Self::StreamThenError),
|
||||||
"token_cost_reporting" => Some(Self::TokenCostReporting),
|
"token_cost_reporting" => Some(Self::TokenCostReporting),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -134,6 +148,8 @@ impl Scenario {
|
|||||||
Self::BashPermissionPromptDenied => "bash_permission_prompt_denied",
|
Self::BashPermissionPromptDenied => "bash_permission_prompt_denied",
|
||||||
Self::PluginToolRoundtrip => "plugin_tool_roundtrip",
|
Self::PluginToolRoundtrip => "plugin_tool_roundtrip",
|
||||||
Self::AutoCompactTriggered => "auto_compact_triggered",
|
Self::AutoCompactTriggered => "auto_compact_triggered",
|
||||||
|
Self::ContextWindowRetry => "context_window_retry",
|
||||||
|
Self::StreamThenError => "stream_then_error",
|
||||||
Self::TokenCostReporting => "token_cost_reporting",
|
Self::TokenCostReporting => "token_cost_reporting",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,6 +165,18 @@ async fn handle_connection(
|
|||||||
let scenario = detect_scenario(&request)
|
let scenario = detect_scenario(&request)
|
||||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing parity scenario"))?;
|
.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 {
|
requests.lock().await.push(CapturedRequest {
|
||||||
method,
|
method,
|
||||||
path,
|
path,
|
||||||
@@ -158,7 +186,7 @@ async fn handle_connection(
|
|||||||
raw_body,
|
raw_body,
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = build_http_response(&request, scenario);
|
let response = build_http_response(&request, scenario, attempt);
|
||||||
socket.write_all(response.as_bytes()).await?;
|
socket.write_all(response.as_bytes()).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -308,7 +336,11 @@ fn flatten_tool_result_content(content: &[api::ToolResultContentBlock]) -> Strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[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 response = if request.stream {
|
||||||
let body = build_stream_body(request, scenario);
|
let body = build_stream_body(request, scenario);
|
||||||
return http_response(
|
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)]
|
#[allow(clippy::too_many_lines)]
|
||||||
fn build_stream_body(request: &MessageRequest, scenario: Scenario) -> String {
|
fn build_stream_body(request: &MessageRequest, scenario: Scenario) -> String {
|
||||||
match scenario {
|
match scenario {
|
||||||
@@ -461,6 +524,8 @@ fn build_stream_body(request: &MessageRequest, scenario: Scenario) -> String {
|
|||||||
Scenario::AutoCompactTriggered => {
|
Scenario::AutoCompactTriggered => {
|
||||||
final_text_sse_with_usage("auto compact parity complete.", 50_000, 200)
|
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 => {
|
Scenario::TokenCostReporting => {
|
||||||
final_text_sse_with_usage("token cost reporting parity complete.", 1_000, 500)
|
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,
|
50_000,
|
||||||
200,
|
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(
|
Scenario::TokenCostReporting => text_message_response_with_usage(
|
||||||
"msg_token_cost_reporting",
|
"msg_token_cost_reporting",
|
||||||
"token cost reporting parity complete.",
|
"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::BashPermissionPromptDenied => "req_bash_permission_prompt_denied",
|
||||||
Scenario::PluginToolRoundtrip => "req_plugin_tool_roundtrip",
|
Scenario::PluginToolRoundtrip => "req_plugin_tool_roundtrip",
|
||||||
Scenario::AutoCompactTriggered => "req_auto_compact_triggered",
|
Scenario::AutoCompactTriggered => "req_auto_compact_triggered",
|
||||||
|
Scenario::ContextWindowRetry => "req_context_window_retry",
|
||||||
|
Scenario::StreamThenError => "req_stream_then_error",
|
||||||
Scenario::TokenCostReporting => "req_token_cost_reporting",
|
Scenario::TokenCostReporting => "req_token_cost_reporting",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -971,6 +1044,84 @@ fn final_text_sse(text: &str) -> String {
|
|||||||
body
|
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 {
|
fn final_text_sse_with_usage(text: &str, input_tokens: u32, output_tokens: u32) -> String {
|
||||||
let mut body = String::new();
|
let mut body = String::new();
|
||||||
append_sse(
|
append_sse(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ use std::net::TcpListener;
|
|||||||
use std::ops::{Deref, DerefMut};
|
use std::ops::{Deref, DerefMut};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
|
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
use std::thread::{self, JoinHandle};
|
use std::thread::{self, JoinHandle};
|
||||||
@@ -6714,6 +6715,49 @@ fn run_resume_command(
|
|||||||
message: Some(render_memory_report()?),
|
message: Some(render_memory_report()?),
|
||||||
json: Some(render_memory_json()?),
|
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 => {
|
SlashCommand::Init => {
|
||||||
// #142: run the init once, then render both text + structured JSON
|
// #142: run the init once, then render both text + structured JSON
|
||||||
// from the same InitReport so both surfaces stay in sync.
|
// from the same InitReport so both surfaces stay in sync.
|
||||||
@@ -7702,6 +7746,7 @@ impl LiveCli {
|
|||||||
system_prompt.clone(),
|
system_prompt.clone(),
|
||||||
enable_tools,
|
enable_tools,
|
||||||
true,
|
true,
|
||||||
|
TerminalTail::new(),
|
||||||
allowed_tools.clone(),
|
allowed_tools.clone(),
|
||||||
permission_mode,
|
permission_mode,
|
||||||
None,
|
None,
|
||||||
@@ -7783,6 +7828,7 @@ impl LiveCli {
|
|||||||
fn prepare_turn_runtime(
|
fn prepare_turn_runtime(
|
||||||
&self,
|
&self,
|
||||||
emit_output: bool,
|
emit_output: bool,
|
||||||
|
terminal_tail: TerminalTail,
|
||||||
) -> Result<(BuiltRuntime, HookAbortMonitor), Box<dyn std::error::Error>> {
|
) -> Result<(BuiltRuntime, HookAbortMonitor), Box<dyn std::error::Error>> {
|
||||||
let hook_abort_signal = runtime::HookAbortSignal::new();
|
let hook_abort_signal = runtime::HookAbortSignal::new();
|
||||||
let runtime = build_runtime(
|
let runtime = build_runtime(
|
||||||
@@ -7792,6 +7838,7 @@ impl LiveCli {
|
|||||||
self.system_prompt.clone(),
|
self.system_prompt.clone(),
|
||||||
true,
|
true,
|
||||||
emit_output,
|
emit_output,
|
||||||
|
terminal_tail,
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
None,
|
None,
|
||||||
@@ -7809,27 +7856,39 @@ impl LiveCli {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
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 spinner = Spinner::new();
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
spinner.tick(
|
// The animated frame is cursor control: it saves the cursor, rewrites the
|
||||||
"🦀 Thinking...",
|
// current line, and restores the cursor. None of that describes anything
|
||||||
TerminalRenderer::new().color_theme(),
|
// when stdout is a pipe — the escapes survive as literal bytes and the
|
||||||
&mut stdout,
|
// 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 mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||||
hook_abort_monitor.stop();
|
hook_abort_monitor.stop();
|
||||||
match result {
|
match result {
|
||||||
Ok(summary) => {
|
Ok(summary) => {
|
||||||
self.replace_runtime(runtime)?;
|
self.replace_runtime(runtime)?;
|
||||||
// The assistant response was already streamed to the terminal live
|
// The emitter owns the terminal during a turn: `emit_output = true`
|
||||||
// during the turn (emit_output = true), so reprinting the full text
|
// streams text deltas and echoes tool results live, so the response
|
||||||
// here would duplicate the entire message. When text was streamed,
|
// must not be printed again here — that was the duplicate output
|
||||||
// emit a newline first so `spinner.finish`'s line-clear lands on a
|
// this PR's predecessor fixed.
|
||||||
// fresh line instead of erasing the last streamed line.
|
//
|
||||||
let final_text = final_assistant_text(&summary);
|
// What the emitter may have left behind is a partial line, so ask it
|
||||||
if !final_text.is_empty() {
|
// 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!();
|
println!();
|
||||||
}
|
}
|
||||||
spinner.finish(
|
spinner.finish(
|
||||||
@@ -7849,6 +7908,13 @@ impl LiveCli {
|
|||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
runtime.shutdown_plugins()?;
|
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(
|
spinner.fail(
|
||||||
"❌ Request failed",
|
"❌ Request failed",
|
||||||
TerminalRenderer::new().color_theme(),
|
TerminalRenderer::new().color_theme(),
|
||||||
@@ -7961,14 +8027,14 @@ impl LiveCli {
|
|||||||
|
|
||||||
// Build a new runtime with the compacted session and retry
|
// Build a new runtime with the compacted session and retry
|
||||||
let (mut new_runtime, hook_abort_monitor) =
|
let (mut new_runtime, hook_abort_monitor) =
|
||||||
self.prepare_turn_runtime(true)?;
|
self.prepare_turn_runtime(true, terminal_tail.clone())?;
|
||||||
drop(hook_abort_monitor);
|
drop(hook_abort_monitor);
|
||||||
|
|
||||||
let mut rp = CliPermissionPrompter::new(self.permission_mode);
|
let mut rp = CliPermissionPrompter::new(self.permission_mode);
|
||||||
match new_runtime.run_turn(input, Some(&mut rp)) {
|
match new_runtime.run_turn(input, Some(&mut rp)) {
|
||||||
Ok(summary) => {
|
Ok(summary) => {
|
||||||
self.replace_runtime(new_runtime)?;
|
self.replace_runtime(new_runtime)?;
|
||||||
if !final_assistant_text(&summary).is_empty() {
|
if !terminal_tail.at_line_start() {
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
spinner.finish(
|
spinner.finish(
|
||||||
@@ -8053,7 +8119,8 @@ impl LiveCli {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run_prompt_compact(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
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 mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||||
hook_abort_monitor.stop();
|
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>> {
|
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 mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||||
hook_abort_monitor.stop();
|
hook_abort_monitor.stop();
|
||||||
@@ -8091,7 +8159,8 @@ impl LiveCli {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
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 mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||||
hook_abort_monitor.stop();
|
hook_abort_monitor.stop();
|
||||||
@@ -8190,6 +8259,7 @@ impl LiveCli {
|
|||||||
Self::print_config(section.as_deref())?;
|
Self::print_config(section.as_deref())?;
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
SlashCommand::Workspace { path } => self.handle_workspace_command(path.as_deref())?,
|
||||||
SlashCommand::Mcp { action, target } => {
|
SlashCommand::Mcp { action, target } => {
|
||||||
let args = match (action.as_deref(), target.as_deref()) {
|
let args = match (action.as_deref(), target.as_deref()) {
|
||||||
(None, None) => None,
|
(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) {
|
fn record_prompt_history(&mut self, prompt: &str) {
|
||||||
let timestamp_ms = std::time::SystemTime::now()
|
let timestamp_ms = std::time::SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
@@ -8447,6 +8562,7 @@ impl LiveCli {
|
|||||||
self.system_prompt.clone(),
|
self.system_prompt.clone(),
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
TerminalTail::new(),
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
None,
|
None,
|
||||||
@@ -8493,6 +8609,7 @@ impl LiveCli {
|
|||||||
self.system_prompt.clone(),
|
self.system_prompt.clone(),
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
TerminalTail::new(),
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
None,
|
None,
|
||||||
@@ -8523,6 +8640,7 @@ impl LiveCli {
|
|||||||
self.system_prompt.clone(),
|
self.system_prompt.clone(),
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
TerminalTail::new(),
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
None,
|
None,
|
||||||
@@ -8565,6 +8683,7 @@ impl LiveCli {
|
|||||||
self.system_prompt.clone(),
|
self.system_prompt.clone(),
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
TerminalTail::new(),
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
None,
|
None,
|
||||||
@@ -8918,6 +9037,7 @@ impl LiveCli {
|
|||||||
self.system_prompt.clone(),
|
self.system_prompt.clone(),
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
TerminalTail::new(),
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
None,
|
None,
|
||||||
@@ -8953,6 +9073,7 @@ impl LiveCli {
|
|||||||
self.system_prompt.clone(),
|
self.system_prompt.clone(),
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
TerminalTail::new(),
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
None,
|
None,
|
||||||
@@ -9047,6 +9168,7 @@ impl LiveCli {
|
|||||||
self.system_prompt.clone(),
|
self.system_prompt.clone(),
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
TerminalTail::new(),
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
None,
|
None,
|
||||||
@@ -9067,6 +9189,7 @@ impl LiveCli {
|
|||||||
self.system_prompt.clone(),
|
self.system_prompt.clone(),
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
TerminalTail::new(),
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
None,
|
None,
|
||||||
@@ -9091,6 +9214,7 @@ impl LiveCli {
|
|||||||
self.system_prompt.clone(),
|
self.system_prompt.clone(),
|
||||||
enable_tools,
|
enable_tools,
|
||||||
false,
|
false,
|
||||||
|
TerminalTail::new(),
|
||||||
self.allowed_tools.clone(),
|
self.allowed_tools.clone(),
|
||||||
self.permission_mode,
|
self.permission_mode,
|
||||||
progress,
|
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()?))
|
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(
|
fn create_managed_session_handle(
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
) -> Result<SessionHandle, Box<dyn std::error::Error>> {
|
) -> Result<SessionHandle, Box<dyn std::error::Error>> {
|
||||||
@@ -12500,6 +12659,7 @@ fn build_runtime(
|
|||||||
system_prompt: Vec<String>,
|
system_prompt: Vec<String>,
|
||||||
enable_tools: bool,
|
enable_tools: bool,
|
||||||
emit_output: bool,
|
emit_output: bool,
|
||||||
|
terminal_tail: TerminalTail,
|
||||||
allowed_tools: Option<AllowedToolSet>,
|
allowed_tools: Option<AllowedToolSet>,
|
||||||
permission_mode: PermissionMode,
|
permission_mode: PermissionMode,
|
||||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||||
@@ -12512,6 +12672,7 @@ fn build_runtime(
|
|||||||
system_prompt,
|
system_prompt,
|
||||||
enable_tools,
|
enable_tools,
|
||||||
emit_output,
|
emit_output,
|
||||||
|
terminal_tail,
|
||||||
allowed_tools,
|
allowed_tools,
|
||||||
permission_mode,
|
permission_mode,
|
||||||
progress_reporter,
|
progress_reporter,
|
||||||
@@ -12528,6 +12689,7 @@ fn build_runtime_with_plugin_state(
|
|||||||
system_prompt: Vec<String>,
|
system_prompt: Vec<String>,
|
||||||
enable_tools: bool,
|
enable_tools: bool,
|
||||||
emit_output: bool,
|
emit_output: bool,
|
||||||
|
terminal_tail: TerminalTail,
|
||||||
allowed_tools: Option<AllowedToolSet>,
|
allowed_tools: Option<AllowedToolSet>,
|
||||||
permission_mode: PermissionMode,
|
permission_mode: PermissionMode,
|
||||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||||
@@ -12553,6 +12715,7 @@ fn build_runtime_with_plugin_state(
|
|||||||
model,
|
model,
|
||||||
enable_tools,
|
enable_tools,
|
||||||
emit_output,
|
emit_output,
|
||||||
|
terminal_tail.clone(),
|
||||||
allowed_tools.clone(),
|
allowed_tools.clone(),
|
||||||
tool_registry.clone(),
|
tool_registry.clone(),
|
||||||
progress_reporter,
|
progress_reporter,
|
||||||
@@ -12560,6 +12723,7 @@ fn build_runtime_with_plugin_state(
|
|||||||
CliToolExecutor::new(
|
CliToolExecutor::new(
|
||||||
allowed_tools.clone(),
|
allowed_tools.clone(),
|
||||||
emit_output,
|
emit_output,
|
||||||
|
terminal_tail,
|
||||||
tool_registry.clone(),
|
tool_registry.clone(),
|
||||||
mcp_state.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
|
// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct
|
||||||
// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI,
|
// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI,
|
||||||
// OpenAI, or DashScope at construction time based on
|
// OpenAI, or DashScope at construction time based on
|
||||||
@@ -12668,6 +12902,7 @@ struct AnthropicRuntimeClient {
|
|||||||
model: String,
|
model: String,
|
||||||
enable_tools: bool,
|
enable_tools: bool,
|
||||||
emit_output: bool,
|
emit_output: bool,
|
||||||
|
terminal_tail: TerminalTail,
|
||||||
allowed_tools: Option<AllowedToolSet>,
|
allowed_tools: Option<AllowedToolSet>,
|
||||||
tool_registry: GlobalToolRegistry,
|
tool_registry: GlobalToolRegistry,
|
||||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||||
@@ -12680,6 +12915,7 @@ impl AnthropicRuntimeClient {
|
|||||||
model: String,
|
model: String,
|
||||||
enable_tools: bool,
|
enable_tools: bool,
|
||||||
emit_output: bool,
|
emit_output: bool,
|
||||||
|
terminal_tail: TerminalTail,
|
||||||
allowed_tools: Option<AllowedToolSet>,
|
allowed_tools: Option<AllowedToolSet>,
|
||||||
tool_registry: GlobalToolRegistry,
|
tool_registry: GlobalToolRegistry,
|
||||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||||
@@ -12733,6 +12969,7 @@ impl AnthropicRuntimeClient {
|
|||||||
model,
|
model,
|
||||||
enable_tools,
|
enable_tools,
|
||||||
emit_output,
|
emit_output,
|
||||||
|
terminal_tail,
|
||||||
allowed_tools,
|
allowed_tools,
|
||||||
tool_registry,
|
tool_registry,
|
||||||
progress_reporter,
|
progress_reporter,
|
||||||
@@ -12832,8 +13069,9 @@ impl AnthropicRuntimeClient {
|
|||||||
})?;
|
})?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
let mut sink = io::sink();
|
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 {
|
let out: &mut dyn Write = if self.emit_output {
|
||||||
&mut stdout
|
&mut tracked
|
||||||
} else {
|
} else {
|
||||||
&mut sink
|
&mut sink
|
||||||
};
|
};
|
||||||
@@ -13997,6 +14235,7 @@ fn prompt_cache_record_to_runtime_event(
|
|||||||
struct CliToolExecutor {
|
struct CliToolExecutor {
|
||||||
renderer: TerminalRenderer,
|
renderer: TerminalRenderer,
|
||||||
emit_output: bool,
|
emit_output: bool,
|
||||||
|
terminal_tail: TerminalTail,
|
||||||
allowed_tools: Option<AllowedToolSet>,
|
allowed_tools: Option<AllowedToolSet>,
|
||||||
tool_registry: GlobalToolRegistry,
|
tool_registry: GlobalToolRegistry,
|
||||||
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
|
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||||
@@ -14006,12 +14245,14 @@ impl CliToolExecutor {
|
|||||||
fn new(
|
fn new(
|
||||||
allowed_tools: Option<AllowedToolSet>,
|
allowed_tools: Option<AllowedToolSet>,
|
||||||
emit_output: bool,
|
emit_output: bool,
|
||||||
|
terminal_tail: TerminalTail,
|
||||||
tool_registry: GlobalToolRegistry,
|
tool_registry: GlobalToolRegistry,
|
||||||
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
|
mcp_state: Option<Arc<Mutex<RuntimeMcpState>>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
renderer: TerminalRenderer::new(),
|
renderer: TerminalRenderer::new(),
|
||||||
emit_output,
|
emit_output,
|
||||||
|
terminal_tail,
|
||||||
allowed_tools,
|
allowed_tools,
|
||||||
tool_registry,
|
tool_registry,
|
||||||
mcp_state,
|
mcp_state,
|
||||||
@@ -14105,8 +14346,11 @@ impl ToolExecutor for CliToolExecutor {
|
|||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
if self.emit_output {
|
if self.emit_output {
|
||||||
let markdown = format_tool_result(tool_name, &output, false);
|
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
|
self.renderer
|
||||||
.stream_markdown(&markdown, &mut io::stdout())
|
.stream_markdown(&markdown, &mut tracked)
|
||||||
.map_err(|error| ToolError::new(error.to_string()))?;
|
.map_err(|error| ToolError::new(error.to_string()))?;
|
||||||
}
|
}
|
||||||
Ok(output)
|
Ok(output)
|
||||||
@@ -14114,8 +14358,11 @@ impl ToolExecutor for CliToolExecutor {
|
|||||||
Err(error) => {
|
Err(error) => {
|
||||||
if self.emit_output {
|
if self.emit_output {
|
||||||
let markdown = format_tool_result(tool_name, &error.to_string(), true);
|
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
|
self.renderer
|
||||||
.stream_markdown(&markdown, &mut io::stdout())
|
.stream_markdown(&markdown, &mut tracked)
|
||||||
.map_err(|stream_error| ToolError::new(stream_error.to_string()))?;
|
.map_err(|stream_error| ToolError::new(stream_error.to_string()))?;
|
||||||
}
|
}
|
||||||
Err(error)
|
Err(error)
|
||||||
@@ -14430,8 +14677,8 @@ mod tests {
|
|||||||
CliOutputFormat, CliToolExecutor, GitOperation, GitWorkspaceSummary,
|
CliOutputFormat, CliToolExecutor, GitOperation, GitWorkspaceSummary,
|
||||||
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic,
|
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic,
|
||||||
PermissionModeProvenance, PromptHistoryEntry, SessionLifecycleKind,
|
PermissionModeProvenance, PromptHistoryEntry, SessionLifecycleKind,
|
||||||
SessionLifecycleSummary, SlashCommand, StatusUsage, TmuxPaneSnapshot, DEFAULT_MODEL,
|
SessionLifecycleSummary, SlashCommand, StatusUsage, TerminalTail, TmuxPaneSnapshot,
|
||||||
LATEST_SESSION_REFERENCE, STUB_COMMANDS,
|
DEFAULT_MODEL, LATEST_SESSION_REFERENCE, STUB_COMMANDS,
|
||||||
};
|
};
|
||||||
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
|
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
|
||||||
use plugins::{
|
use plugins::{
|
||||||
@@ -19354,6 +19601,7 @@ UU conflicted.rs",
|
|||||||
let mut executor = CliToolExecutor::new(
|
let mut executor = CliToolExecutor::new(
|
||||||
None,
|
None,
|
||||||
false,
|
false,
|
||||||
|
TerminalTail::new(),
|
||||||
state.tool_registry.clone(),
|
state.tool_registry.clone(),
|
||||||
state.mcp_state.clone(),
|
state.mcp_state.clone(),
|
||||||
);
|
);
|
||||||
@@ -19452,6 +19700,7 @@ UU conflicted.rs",
|
|||||||
let mut executor = CliToolExecutor::new(
|
let mut executor = CliToolExecutor::new(
|
||||||
None,
|
None,
|
||||||
false,
|
false,
|
||||||
|
TerminalTail::new(),
|
||||||
state.tool_registry.clone(),
|
state.tool_registry.clone(),
|
||||||
state.mcp_state.clone(),
|
state.mcp_state.clone(),
|
||||||
);
|
);
|
||||||
@@ -19512,6 +19761,7 @@ UU conflicted.rs",
|
|||||||
vec!["test system prompt".to_string()],
|
vec!["test system prompt".to_string()],
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
|
TerminalTail::new(),
|
||||||
None,
|
None,
|
||||||
PermissionMode::DangerFullAccess,
|
PermissionMode::DangerFullAccess,
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ use std::process::{Command, Output, Stdio};
|
|||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
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;
|
use serde_json::Value;
|
||||||
|
|
||||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
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");
|
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]
|
#[test]
|
||||||
fn compact_flag_with_json_output_emits_structured_json() {
|
fn compact_flag_with_json_output_emits_structured_json() {
|
||||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
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
|
// #831: direct resume-safe slash commands should use the same local CliAction
|
||||||
// JSON surfaces as their bare subcommands, not interactive_only guidance.
|
// 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]
|
#[test]
|
||||||
fn direct_resume_safe_slash_commands_route_to_local_json_actions_831() {
|
fn direct_resume_safe_slash_commands_route_to_local_json_actions_831() {
|
||||||
let root = unique_temp_dir("direct-resume-safe-slash-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()
|
.output()
|
||||||
.expect("git init should launch");
|
.expect("git init should launch");
|
||||||
|
|
||||||
for (command, expected_kind, expected_status) in [
|
for (command, expected_kind, expected_statuses) in [
|
||||||
("/version", "version", "ok"),
|
("/version", "version", &["ok"][..]),
|
||||||
("/sandbox", "sandbox", "warn"),
|
("/sandbox", "sandbox", &["ok", "warn"][..]),
|
||||||
("/diff", "diff", "ok"),
|
("/diff", "diff", &["ok"][..]),
|
||||||
("/status", "status", "ok"),
|
("/status", "status", &["ok"][..]),
|
||||||
] {
|
] {
|
||||||
let output = run_claw(&root, &["--output-format", "json", command], &[]);
|
let output = run_claw(&root, &["--output-format", "json", command], &[]);
|
||||||
assert!(
|
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:?}"));
|
.unwrap_or_else(|_| panic!("{command} must emit JSON (#831), got: {stdout:?}"));
|
||||||
|
|
||||||
assert_eq!(parsed["kind"], expected_kind, "{command} kind: {parsed}");
|
assert_eq!(parsed["kind"], expected_kind, "{command} kind: {parsed}");
|
||||||
assert_eq!(
|
assert!(
|
||||||
parsed["status"], expected_status,
|
expected_statuses.contains(&parsed["status"].as_str().unwrap_or("")),
|
||||||
"{command} status: {parsed}"
|
"{command} status must be one of {expected_statuses:?}: {parsed}"
|
||||||
);
|
);
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
parsed["error_kind"], "interactive_only",
|
parsed["error_kind"], "interactive_only",
|
||||||
|
|||||||
Reference in new issue
Block a user