Compare commits

...

5 Commits

Author SHA1 Message Date
Yeachan-Heo
cc8da08ad3 Restore Rust formatting compliance for origin/main
Run cargo fmt across the Rust workspace after rebasing onto origin/main so the branch matches rustfmt's current output and the CI formatter gate can pass again. This commit intentionally contains formatter output only and keeps it separate from the README brand refresh.

Constraint: origin/main was failing cargo fmt --all --check
Rejected: Manually format only the initially reported files | cargo fmt --all updated additional workspace files that are part of the formatter gate
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Do not mix behavior edits into formatter-only commits
Tested: cargo fmt --all --check
Tested: cargo test --all
Tested: git diff --check
2026-04-23 00:24:18 +00:00
Yeachan-Heo
e43c6b81db Celebrate the 188K-star milestone in the project brand surface
Refresh the root README hero so the repo acknowledges the 188K GitHub star milestone without turning the page into a campaign landing page. The change keeps the existing structure, sharpens the positioning copy, and adds a compact milestone section that fits the current documentation-first surface.

Constraint: Keep the redesign confined to the repo's primary marketing/docs entrypoint
Constraint: Avoid new assets, dependencies, or multi-page brand churn
Rejected: Broader multi-file docs refresh | exceeded the requested reviewable scope
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep milestone copy lightweight so future README updates can age it out without restructuring the page
Tested: git diff --check
Not-tested: Rendered GitHub HTML preview outside local markdown diff
2026-04-23 00:24:09 +00:00
YeonGyu-Kim
a389f8dff1 file: #160 — session_store missing list_sessions, delete_session, session_exists — claw cannot enumerate or clean up sessions without filesystem hacks 2026-04-22 08:47:52 +09:00
YeonGyu-Kim
7a014170ba file: #159 — run_turn_loop hardcodes empty denied_tools, permission denials absent from multi-turn sessions 2026-04-22 06:48:03 +09:00
YeonGyu-Kim
986f8e89fd file: #158 — compact_messages_if_needed drops turns silently, no structured compaction event 2026-04-22 06:37:54 +09:00
10 changed files with 319 additions and 112 deletions

View File

@@ -1,5 +1,13 @@
# Claw Code
<p align="center">
<strong>188K GitHub stars and climbing.</strong>
</p>
<p align="center">
<strong>Rust-native agent execution for people who want speed, control, and a real terminal.</strong>
</p>
<p align="center">
<a href="https://github.com/ultraworkers/claw-code">ultraworkers/claw-code</a>
·
@@ -28,8 +36,21 @@
<img src="assets/claw-hero.jpeg" alt="Claw Code" width="300" />
</p>
Claw Code is the public Rust implementation of the `claw` CLI agent harness.
The canonical implementation lives in [`rust/`](./rust), and the current source of truth for this repository is **ultraworkers/claw-code**.
<p align="center">
Claw Code just crossed <strong>188,000 GitHub stars</strong>. This repo is the public Rust implementation of the <code>claw</code> CLI agent harness, built in the open with the UltraWorkers community.
</p>
<p align="center">
The canonical implementation lives in <a href="./rust/">rust/</a>, and the current source of truth for this repository is <strong>ultraworkers/claw-code</strong>.
</p>
## 188K and climbing
Thanks to everyone who starred, tested, reviewed, and pushed the project forward. Claw Code is focused on a straightforward promise: a fast local-first CLI agent runtime with native tools, inspectable behavior, and a Rust workspace that stays close to the metal.
- Native Rust workspace and CLI binary under [`rust/`](./rust)
- Local-first workflows for prompts, sessions, tooling, and parity validation
- Open development across the broader UltraWorkers ecosystem
> [!IMPORTANT]
> Start with [`USAGE.md`](./USAGE.md) for build, auth, CLI, session, and parity-harness workflows. Make `claw doctor` your first health check after building, use [`rust/README.md`](./rust/README.md) for crate-level details, read [`PARITY.md`](./PARITY.md) for the current Rust-port checkpoint, and see [`docs/container.md`](./docs/container.md) for the container-first workflow.

View File

@@ -6043,3 +6043,103 @@ New users see these commands in the help output but have no explanation of:
**Blocker.** None. Natural Phase 3 progression from #77 P1 (JSON kind) → #156 (text kind) → #157 (structured remediation).
**Source.** gaebal-gajae dogfood sweep 2026-04-22 05:30 KST — identified that `kind` is structured but `hint` remains prose-derived, leaving downstream claws with half an error contract.
## Pinpoint #158. `compact_messages_if_needed` drops turns silently — no structured compaction event emitted
**Gap.** `QueryEnginePort.compact_messages_if_needed()` (`src/query_engine.py:129`) silently truncates `mutable_messages` and `transcript_store` whenever turn count exceeds `compact_after_turns` (default 12). The truncation is invisible to any consumer — `TurnResult` carries no compaction indicator, the streaming path emits no `compaction_occurred` event, and `persist_session()` persists only the post-compaction slice. A claw polling session state after compaction sees the same `session_id` but a different (shorter) context window with no structured signal that turns were dropped.
**Repro.**
```python
import sys; sys.path.insert(0, 'src')
from query_engine import QueryEnginePort, QueryEngineConfig
engine = QueryEnginePort.from_workspace()
engine.config = QueryEngineConfig(compact_after_turns=3)
for i in range(5):
r = engine.submit_message(f'turn {i}')
# TurnResult has no compaction field
assert not hasattr(r, 'compaction_occurred') # passes every time
print(len(engine.mutable_messages)) # 3 — silently truncated from 5
```
**Root cause.** `compact_messages_if_needed` is called inside `submit_message` with no return value and no side-channel notification. `stream_submit_message` yields a `message_stop` event that includes `transcript_size` but not a `compaction_occurred` flag or `turns_dropped` count.
**Fix shape (~15 lines).**
1. Add `compaction_occurred: bool` and `turns_dropped: int` to `TurnResult`.
2. In `compact_messages_if_needed`, return `(bool, int)` — whether compaction ran and how many turns were dropped.
3. Propagate into `TurnResult` in `submit_message`.
4. In `stream_submit_message`, include `compaction_occurred` and `turns_dropped` in the `message_stop` event.
**Acceptance.** A claw watching the stream can detect that compaction occurred and how many turns were silently dropped, without polling `transcript_size` across two consecutive turns.
**Blocker.** None.
**Source.** Jobdori dogfood sweep 2026-04-22 06:36 KST — probed `query_engine.py` compact path, confirmed no structured compaction signal in `TurnResult` or stream output.
## Pinpoint #159. `run_turn_loop` hardcodes empty denied_tools — permission denials silently absent from multi-turn sessions
**Gap.** `PortRuntime.run_turn_loop` (`src/runtime.py:163`) calls `engine.submit_message(turn_prompt, command_names, tool_names, ())` with a hardcoded empty tuple for `denied_tools`. By contrast, `bootstrap_session` calls `_infer_permission_denials(matches)` and passes the result. Result: any tool that would be denied (e.g., bash-family tools gated as "destructive") silently appears unblocked across all turns in `turn-loop` mode. The `TurnResult.permission_denials` tuple is always empty for multi-turn runs, giving a false "clean" permission picture to any claw consuming those results.
**Repro.**
```python
import sys; sys.path.insert(0, 'src')
from runtime import PortRuntime
results = PortRuntime().run_turn_loop('run bash ls', max_turns=2)
for r in results:
assert r.permission_denials == () # passes — denials never surfaced
```
Compare `bootstrap_session` for the same prompt — it produces a `PermissionDenial` for bash-family tools.
**Root cause.** `src/runtime.py:163` — `engine.submit_message(turn_prompt, command_names, tool_names, ())`. The `()` is a hardcoded literal; `_infer_permission_denials` is never called in the turn-loop path.
**Fix shape (~5 lines).** Before the turn loop, compute:
```python
denials = tuple(self._infer_permission_denials(matches))
```
Then pass `denied_tools=denials` to every `submit_message` call inside the loop. Mirrors the existing pattern in `bootstrap_session`.
**Acceptance.** `run_turn_loop('run bash ls').permission_denials` is non-empty and matches what `bootstrap_session` returns for the same prompt. Multi-turn session security posture is symmetric with single-turn bootstrap.
**Blocker.** None.
**Source.** Jobdori dogfood sweep 2026-04-22 06:46 KST — diffed `bootstrap_session` vs `run_turn_loop` in `src/runtime.py`, confirmed asymmetric permission denial propagation.
## Pinpoint #160. `session_store` has no `list_sessions`, `delete_session`, or `session_exists` — claw cannot enumerate or clean up sessions without filesystem hacks
**Gap.** `src/session_store.py` exposes exactly two public functions: `save_session` and `load_session`. There is no `list_sessions`, `delete_session`, or `session_exists`. Any claw that needs to enumerate stored sessions, verify a session exists before loading (to avoid `FileNotFoundError`), or clean up stale sessions must reach past the module and glob `DEFAULT_SESSION_DIR` directly. This couples callers to the on-disk layout (`<dir>/<session_id>.json`) and makes it impossible to swap storage backends (e.g., sqlite, remote store) without touching every call site.
**Repro.**
```python
import sys; sys.path.insert(0, 'src')
import session_store, inspect
print([n for n, _ in inspect.getmembers(session_store, inspect.isfunction)
if not n.startswith('_')])
# ['asdict', 'dataclass', 'load_session', 'save_session']
# list_sessions, delete_session, session_exists — all absent
```
Try to enumerate sessions without the module:
```python
from pathlib import Path
sessions = list((Path('.port_sessions')).glob('*.json'))
# Works today, breaks if the dir layout ever changes — no abstraction layer
```
Try to load a session that doesn't exist:
```python
load_session('nonexistent') # raises FileNotFoundError with no structured error type
```
**Root cause.** `src/session_store.py` was scaffolded with the minimum needed to save/load a single session and was never extended with the CRUD surface a claw actually needs to manage session lifecycle.
**Fix shape (~25 lines).**
1. `list_sessions(directory: Path | None = None) -> list[str]` — glob `*.json` in target dir, return sorted session ids (filename stems). Claws can call this to discover all stored sessions without touching the filesystem directly.
2. `session_exists(session_id: str, directory: Path | None = None) -> bool` — `(target_dir / f'{session_id}.json').exists()`. Use before `load_session` to get a bool check instead of catching `FileNotFoundError`.
3. `delete_session(session_id: str, directory: Path | None = None) -> bool` — unlink the file if present, return True on success, False if not found. Claws can use this for cleanup without knowing the path scheme.
**Acceptance.** A claw can call `list_sessions()`, `session_exists(id)`, and `delete_session(id)` without importing `Path` or knowing the `.port_sessions/<id>.json` layout. `load_session` on a missing id raises a typed `SessionNotFoundError` subclass of `KeyError` (not `FileNotFoundError`) so callers can distinguish "not found" from IO errors.
**Blocker.** None.
**Source.** Jobdori dogfood sweep 2026-04-22 08:46 KST — inspected `src/session_store.py` public API, confirmed only `save_session` + `load_session` present, no list/delete/exists surface.

View File

@@ -753,14 +753,14 @@ mod tests {
#[test]
fn returns_context_window_metadata_for_kimi_models() {
// kimi-k2.5
let k25_limit = model_token_limit("kimi-k2.5")
.expect("kimi-k2.5 should have token limit metadata");
let k25_limit =
model_token_limit("kimi-k2.5").expect("kimi-k2.5 should have token limit metadata");
assert_eq!(k25_limit.max_output_tokens, 16_384);
assert_eq!(k25_limit.context_window_tokens, 256_000);
// kimi-k1.5
let k15_limit = model_token_limit("kimi-k1.5")
.expect("kimi-k1.5 should have token limit metadata");
let k15_limit =
model_token_limit("kimi-k1.5").expect("kimi-k1.5 should have token limit metadata");
assert_eq!(k15_limit.max_output_tokens, 16_384);
assert_eq!(k15_limit.context_window_tokens, 256_000);
}
@@ -768,11 +768,13 @@ mod tests {
#[test]
fn kimi_alias_resolves_to_kimi_k25_token_limits() {
// The "kimi" alias resolves to "kimi-k2.5" via resolve_model_alias()
let alias_limit = model_token_limit("kimi")
.expect("kimi alias should resolve to kimi-k2.5 limits");
let direct_limit = model_token_limit("kimi-k2.5")
.expect("kimi-k2.5 should have limits");
assert_eq!(alias_limit.max_output_tokens, direct_limit.max_output_tokens);
let alias_limit =
model_token_limit("kimi").expect("kimi alias should resolve to kimi-k2.5 limits");
let direct_limit = model_token_limit("kimi-k2.5").expect("kimi-k2.5 should have limits");
assert_eq!(
alias_limit.max_output_tokens,
direct_limit.max_output_tokens
);
assert_eq!(
alias_limit.context_window_tokens,
direct_limit.context_window_tokens

View File

@@ -2195,9 +2195,16 @@ mod tests {
#[test]
fn provider_specific_size_limits_are_correct() {
assert_eq!(OpenAiCompatConfig::dashscope().max_request_body_bytes, 6_291_456); // 6MB
assert_eq!(OpenAiCompatConfig::openai().max_request_body_bytes, 104_857_600); // 100MB
assert_eq!(OpenAiCompatConfig::xai().max_request_body_bytes, 52_428_800); // 50MB
assert_eq!(
OpenAiCompatConfig::dashscope().max_request_body_bytes,
6_291_456
); // 6MB
assert_eq!(
OpenAiCompatConfig::openai().max_request_body_bytes,
104_857_600
); // 100MB
assert_eq!(OpenAiCompatConfig::xai().max_request_body_bytes, 52_428_800);
// 50MB
}
#[test]

View File

@@ -2623,10 +2623,8 @@ fn render_mcp_report_json_for(
// runs, the existing serializer adds `status: "ok"` below.
match loader.load() {
Ok(runtime_config) => {
let mut value = render_mcp_summary_report_json(
cwd,
runtime_config.mcp().servers(),
);
let mut value =
render_mcp_summary_report_json(cwd, runtime_config.mcp().servers());
if let Some(map) = value.as_object_mut() {
map.insert("status".to_string(), Value::String("ok".to_string()));
map.insert("config_load_error".to_string(), Value::Null);

View File

@@ -172,7 +172,7 @@ async fn execute_bash_async(
) -> io::Result<BashCommandOutput> {
// Detect and emit ship provenance for git push operations
detect_and_emit_ship_prepared(&input.command);
let mut command = prepare_tokio_command(&input.command, &cwd, &sandbox_status, true);
let output_result = if let Some(timeout_ms) = input.timeout {

View File

@@ -405,7 +405,10 @@ pub enum BlockedSubphase {
#[serde(rename = "blocked.branch_freshness")]
BranchFreshness { behind_main: u32 },
#[serde(rename = "blocked.test_hang")]
TestHang { elapsed_secs: u32, test_name: Option<String> },
TestHang {
elapsed_secs: u32,
test_name: Option<String>,
},
#[serde(rename = "blocked.report_pending")]
ReportPending { since_secs: u32 },
}
@@ -543,7 +546,8 @@ impl LaneEvent {
.with_failure_class(blocker.failure_class)
.with_detail(blocker.detail.clone());
if let Some(ref subphase) = blocker.subphase {
event = event.with_data(serde_json::to_value(subphase).expect("subphase should serialize"));
event =
event.with_data(serde_json::to_value(subphase).expect("subphase should serialize"));
}
event
}
@@ -554,7 +558,8 @@ impl LaneEvent {
.with_failure_class(blocker.failure_class)
.with_detail(blocker.detail.clone());
if let Some(ref subphase) = blocker.subphase {
event = event.with_data(serde_json::to_value(subphase).expect("subphase should serialize"));
event =
event.with_data(serde_json::to_value(subphase).expect("subphase should serialize"));
}
event
}
@@ -562,8 +567,12 @@ impl LaneEvent {
/// Ship prepared — §4.44.5
#[must_use]
pub fn ship_prepared(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
Self::new(LaneEventName::ShipPrepared, LaneEventStatus::Ready, emitted_at)
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
Self::new(
LaneEventName::ShipPrepared,
LaneEventStatus::Ready,
emitted_at,
)
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
}
/// Ship commits selected — §4.44.5
@@ -573,22 +582,34 @@ impl LaneEvent {
commit_count: u32,
commit_range: impl Into<String>,
) -> Self {
Self::new(LaneEventName::ShipCommitsSelected, LaneEventStatus::Ready, emitted_at)
.with_detail(format!("{} commits: {}", commit_count, commit_range.into()))
Self::new(
LaneEventName::ShipCommitsSelected,
LaneEventStatus::Ready,
emitted_at,
)
.with_detail(format!("{} commits: {}", commit_count, commit_range.into()))
}
/// Ship merged — §4.44.5
#[must_use]
pub fn ship_merged(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
Self::new(LaneEventName::ShipMerged, LaneEventStatus::Completed, emitted_at)
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
Self::new(
LaneEventName::ShipMerged,
LaneEventStatus::Completed,
emitted_at,
)
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
}
/// Ship pushed to main — §4.44.5
#[must_use]
pub fn ship_pushed_main(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
Self::new(LaneEventName::ShipPushedMain, LaneEventStatus::Completed, emitted_at)
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
Self::new(
LaneEventName::ShipPushedMain,
LaneEventStatus::Completed,
emitted_at,
)
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
}
#[must_use]

View File

@@ -58,8 +58,8 @@ impl SessionStore {
let workspace_root = workspace_root.as_ref();
// #151: canonicalize workspace_root for consistent fingerprinting
// across equivalent path representations.
let canonical_workspace = fs::canonicalize(workspace_root)
.unwrap_or_else(|_| workspace_root.to_path_buf());
let canonical_workspace =
fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
let sessions_root = data_dir
.as_ref()
.join("sessions")
@@ -158,10 +158,9 @@ impl SessionStore {
}
pub fn latest_session(&self) -> Result<ManagedSessionSummary, SessionControlError> {
self.list_sessions()?
.into_iter()
.next()
.ok_or_else(|| SessionControlError::Format(format_no_managed_sessions(&self.sessions_root)))
self.list_sessions()?.into_iter().next().ok_or_else(|| {
SessionControlError::Format(format_no_managed_sessions(&self.sessions_root))
})
}
pub fn load_session(

View File

@@ -228,8 +228,10 @@ fn main() {
// don't need to regex-scrape the prose.
let kind = classify_error_kind(&message);
if message.contains("`claw --help`") {
eprintln!("[error-kind: {kind}]
error: {message}");
eprintln!(
"[error-kind: {kind}]
error: {message}"
);
} else {
eprintln!(
"[error-kind: {kind}]
@@ -372,7 +374,12 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
model_flag_raw,
permission_mode,
output_format,
} => print_status_snapshot(&model, model_flag_raw.as_deref(), permission_mode, output_format)?,
} => print_status_snapshot(
&model,
model_flag_raw.as_deref(),
permission_mode,
output_format,
)?,
CliAction::Sandbox { output_format } => print_sandbox_status_snapshot(output_format)?,
CliAction::Prompt {
prompt,
@@ -412,19 +419,17 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
CliAction::Config {
section,
output_format,
} => {
match output_format {
CliOutputFormat::Text => {
println!("{}", render_config_report(section.as_deref())?);
}
CliOutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(&render_config_json(section.as_deref())?)?
);
}
} => match output_format {
CliOutputFormat::Text => {
println!("{}", render_config_report(section.as_deref())?);
}
}
CliOutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(&render_config_json(section.as_deref())?)?
);
}
},
CliAction::Diff { output_format } => match output_format {
CliOutputFormat::Text => {
println!("{}", render_diff_report()?);
@@ -627,13 +632,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
}
"--help" | "-h"
if !rest.is_empty()
&& matches!(
rest[0].as_str(),
"prompt"
| "commit"
| "pr"
| "issue"
) =>
&& matches!(rest[0].as_str(), "prompt" | "commit" | "pr" | "issue") =>
{
// `--help` following a subcommand that would otherwise forward
// the arg to the API (e.g. `claw prompt --help`) should show
@@ -844,9 +843,13 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
if let Some(action) = parse_local_help_action(&rest) {
return action;
}
if let Some(action) =
parse_single_word_command_alias(&rest, &model, model_flag_raw.as_deref(), permission_mode_override, output_format)
{
if let Some(action) = parse_single_word_command_alias(
&rest,
&model,
model_flag_raw.as_deref(),
permission_mode_override,
output_format,
) {
return action;
}
@@ -1312,7 +1315,6 @@ fn suggest_closest_term<'a>(input: &str, candidates: &'a [&'a str]) -> Option<&'
ranked_suggestions(input, candidates).into_iter().next()
}
fn suggest_similar_subcommand(input: &str) -> Option<Vec<String>> {
const KNOWN_SUBCOMMANDS: &[&str] = &[
"help",
@@ -1342,8 +1344,7 @@ fn suggest_similar_subcommand(input: &str) -> Option<Vec<String>> {
let prefix_match = common_prefix_len(&normalized_input, &normalized_candidate) >= 4;
let substring_match = normalized_candidate.contains(&normalized_input)
|| normalized_input.contains(&normalized_candidate);
((distance <= 2) || prefix_match || substring_match)
.then_some((distance, *candidate))
((distance <= 2) || prefix_match || substring_match).then_some((distance, *candidate))
})
.collect::<Vec<_>>();
ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1)));
@@ -1363,7 +1364,6 @@ fn common_prefix_len(left: &str, right: &str) -> usize {
.count()
}
fn looks_like_subcommand_typo(input: &str) -> bool {
!input.is_empty()
&& input
@@ -1472,13 +1472,11 @@ fn validate_model_syntax(model: &str) -> Result<(), String> {
err_msg.push_str("\nDid you mean `openai/");
err_msg.push_str(trimmed);
err_msg.push_str("`? (Requires OPENAI_API_KEY env var)");
}
else if trimmed.starts_with("qwen") {
} else if trimmed.starts_with("qwen") {
err_msg.push_str("\nDid you mean `qwen/");
err_msg.push_str(trimmed);
err_msg.push_str("`? (Requires DASHSCOPE_API_KEY env var)");
}
else if trimmed.starts_with("grok") {
} else if trimmed.starts_with("grok") {
err_msg.push_str("\nDid you mean `xai/");
err_msg.push_str(trimmed);
err_msg.push_str("`? (Requires XAI_API_KEY env var)");
@@ -4322,7 +4320,6 @@ impl LiveCli {
Ok(())
}
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 permission_prompter = CliPermissionPrompter::new(self.permission_mode);
@@ -5440,7 +5437,13 @@ fn print_status_snapshot(
match output_format {
CliOutputFormat::Text => println!(
"{}",
format_status_report(&provenance.resolved, usage, permission_mode.as_str(), &context, Some(&provenance))
format_status_report(
&provenance.resolved,
usage,
permission_mode.as_str(),
&context,
Some(&provenance)
)
),
CliOutputFormat::Json => println!(
"{}",
@@ -9006,26 +9009,24 @@ fn print_help(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::
mod tests {
use super::{
build_runtime_plugin_state_with_loader, build_runtime_with_plugin_state,
collect_session_prompt_history, create_managed_session_handle, describe_tool_progress,
filter_tool_specs, format_bughunter_report, format_commit_preflight_report,
format_commit_skipped_report, format_compact_report, format_connected_line,
format_cost_report, format_history_timestamp, format_internal_prompt_progress_line,
format_issue_report, format_model_report, format_model_switch_report,
format_permissions_report, format_permissions_switch_report, format_pr_report,
format_resume_report, format_status_report, format_tool_call_start, format_tool_result,
format_ultraplan_report, format_unknown_slash_command,
classify_error_kind, collect_session_prompt_history, create_managed_session_handle,
describe_tool_progress, filter_tool_specs, format_bughunter_report,
format_commit_preflight_report, format_commit_skipped_report, format_compact_report,
format_connected_line, format_cost_report, format_history_timestamp,
format_internal_prompt_progress_line, format_issue_report, format_model_report,
format_model_switch_report, format_permissions_report, format_permissions_switch_report,
format_pr_report, format_resume_report, format_status_report, format_tool_call_start,
format_tool_result, format_ultraplan_report, format_unknown_slash_command,
format_unknown_slash_command_message, format_user_visible_api_error,
classify_error_kind,
merge_prompt_with_stdin, normalize_permission_mode, parse_args, parse_export_args,
parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary,
parse_history_count, permission_policy, print_help_to, push_output_block,
render_config_report, render_diff_report, render_diff_report_for, render_memory_report,
split_error_hint,
render_help_topic, render_prompt_history_report, render_repl_help, render_resume_usage,
render_config_report, render_diff_report, render_diff_report_for, render_help_topic,
render_memory_report, render_prompt_history_report, render_repl_help, render_resume_usage,
render_session_markdown, resolve_model_alias, resolve_model_alias_with_config,
resolve_repl_model, resolve_session_reference, response_to_events,
resume_supported_slash_commands, run_resume_command, short_tool_id,
slash_command_completion_candidates_with_sessions, status_context,
slash_command_completion_candidates_with_sessions, split_error_hint, status_context,
summarize_tool_payload_for_markdown, try_resolve_bare_skill_prompt, validate_no_args,
write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary,
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic,
@@ -10006,8 +10007,8 @@ mod tests {
// with a specific error instead of falling through to the prompt
// path (where they surface a misleading "missing Anthropic
// credentials" error or burn API tokens on an empty prompt).
let empty_err = parse_args(&["".to_string()])
.expect_err("empty positional arg should be rejected");
let empty_err =
parse_args(&["".to_string()]).expect_err("empty positional arg should be rejected");
assert!(
empty_err.starts_with("empty prompt:"),
"empty-arg error should be specific, got: {empty_err}"
@@ -10224,7 +10225,8 @@ mod tests {
.expect("write malformed .claw.json");
let context = with_current_dir(&cwd, || {
super::status_context(None).expect("status_context should not hard-fail on config parse errors (#143)")
super::status_context(None)
.expect("status_context should not hard-fail on config parse errors (#143)")
});
// Phase 1 contract: config_load_error is populated with the parse error.
@@ -10261,7 +10263,8 @@ mod tests {
cumulative: runtime::TokenUsage::default(),
estimated_tokens: 0,
};
let json = super::status_json_value(Some("test-model"), usage, "workspace-write", &context, None);
let json =
super::status_json_value(Some("test-model"), usage, "workspace-write", &context, None);
assert_eq!(
json.get("status").and_then(|v| v.as_str()),
Some("degraded"),
@@ -10278,8 +10281,14 @@ mod tests {
json.get("model").and_then(|v| v.as_str()),
Some("test-model")
);
assert!(json.get("workspace").is_some(), "workspace field still reported");
assert!(json.get("sandbox").is_some(), "sandbox field still reported");
assert!(
json.get("workspace").is_some(),
"workspace field still reported"
);
assert!(
json.get("sandbox").is_some(),
"sandbox field still reported"
);
// Clean path: no config error → status: "ok", config_load_error: null.
let clean_cwd = root.join("project-with-clean-config");
@@ -10288,8 +10297,13 @@ mod tests {
super::status_context(None).expect("clean status_context should succeed")
});
assert!(clean_context.config_load_error.is_none());
let clean_json =
super::status_json_value(Some("test-model"), usage, "workspace-write", &clean_context, None);
let clean_json = super::status_json_value(
Some("test-model"),
usage,
"workspace-write",
&clean_context,
None,
);
assert_eq!(
clean_json.get("status").and_then(|v| v.as_str()),
Some("ok"),
@@ -10388,11 +10402,18 @@ mod tests {
// Other unrecognized args should NOT trigger the --json hint.
let err_other = parse_args(&["doctor".to_string(), "garbage".to_string()])
.expect_err("`doctor garbage` should fail without --json hint");
assert!(!err_other.contains("--output-format json"),
"unrelated args should not trigger --json hint: {err_other}");
assert!(
!err_other.contains("--output-format json"),
"unrelated args should not trigger --json hint: {err_other}"
);
// #154: model syntax error should hint at provider prefix when applicable
let err_gpt = parse_args(&["prompt".to_string(), "test".to_string(), "--model".to_string(), "gpt-4".to_string()])
.expect_err("`--model gpt-4` should fail with OpenAI hint");
let err_gpt = parse_args(&[
"prompt".to_string(),
"test".to_string(),
"--model".to_string(),
"gpt-4".to_string(),
])
.expect_err("`--model gpt-4` should fail with OpenAI hint");
assert!(
err_gpt.contains("Did you mean `openai/gpt-4`?"),
"GPT model error should hint openai/ prefix: {err_gpt}"
@@ -10401,8 +10422,13 @@ mod tests {
err_gpt.contains("OPENAI_API_KEY"),
"GPT model error should mention env var: {err_gpt}"
);
let err_qwen = parse_args(&["prompt".to_string(), "test".to_string(), "--model".to_string(), "qwen-plus".to_string()])
.expect_err("`--model qwen-plus` should fail with DashScope hint");
let err_qwen = parse_args(&[
"prompt".to_string(),
"test".to_string(),
"--model".to_string(),
"qwen-plus".to_string(),
])
.expect_err("`--model qwen-plus` should fail with DashScope hint");
assert!(
err_qwen.contains("Did you mean `qwen/qwen-plus`?"),
"Qwen model error should hint qwen/ prefix: {err_qwen}"
@@ -10412,8 +10438,13 @@ mod tests {
"Qwen model error should mention env var: {err_qwen}"
);
// Unrelated invalid model should NOT get a hint
let err_garbage = parse_args(&["prompt".to_string(), "test".to_string(), "--model".to_string(), "asdfgh".to_string()])
.expect_err("`--model asdfgh` should fail");
let err_garbage = parse_args(&[
"prompt".to_string(),
"test".to_string(),
"--model".to_string(),
"asdfgh".to_string(),
])
.expect_err("`--model asdfgh` should fail");
assert!(
!err_garbage.contains("Did you mean"),
"Unrelated model errors should not get a hint: {err_garbage}"
@@ -10423,15 +10454,42 @@ mod tests {
#[test]
fn classify_error_kind_returns_correct_discriminants() {
// #77: error kind classification for JSON error payloads
assert_eq!(classify_error_kind("missing Anthropic credentials; export ..."), "missing_credentials");
assert_eq!(classify_error_kind("no worker state file found at /tmp/..."), "missing_worker_state");
assert_eq!(classify_error_kind("session not found: abc123"), "session_not_found");
assert_eq!(classify_error_kind("failed to restore session: no managed sessions found"), "session_load_failed");
assert_eq!(classify_error_kind("unrecognized argument `--foo` for subcommand `doctor`"), "cli_parse");
assert_eq!(classify_error_kind("invalid model syntax: 'gpt-4'. Expected ..."), "invalid_model_syntax");
assert_eq!(classify_error_kind("unsupported resumed command: /blargh"), "unsupported_resumed_command");
assert_eq!(classify_error_kind("api failed after 3 attempts: ..."), "api_http_error");
assert_eq!(classify_error_kind("something completely unknown"), "unknown");
assert_eq!(
classify_error_kind("missing Anthropic credentials; export ..."),
"missing_credentials"
);
assert_eq!(
classify_error_kind("no worker state file found at /tmp/..."),
"missing_worker_state"
);
assert_eq!(
classify_error_kind("session not found: abc123"),
"session_not_found"
);
assert_eq!(
classify_error_kind("failed to restore session: no managed sessions found"),
"session_load_failed"
);
assert_eq!(
classify_error_kind("unrecognized argument `--foo` for subcommand `doctor`"),
"cli_parse"
);
assert_eq!(
classify_error_kind("invalid model syntax: 'gpt-4'. Expected ..."),
"invalid_model_syntax"
);
assert_eq!(
classify_error_kind("unsupported resumed command: /blargh"),
"unsupported_resumed_command"
);
assert_eq!(
classify_error_kind("api failed after 3 attempts: ..."),
"api_http_error"
);
assert_eq!(
classify_error_kind("something completely unknown"),
"unknown"
);
}
#[test]
@@ -10902,7 +10960,6 @@ mod tests {
assert!(report.contains("Use /help"));
}
#[test]
fn typoed_doctor_subcommand_returns_did_you_mean_error() {
let error = parse_args(&["doctorr".to_string()]).expect_err("doctorr should error");
@@ -10985,7 +11042,6 @@ mod tests {
);
}
#[test]
fn punctuation_bearing_single_token_still_dispatches_to_prompt() {
// #140: Guard against test pollution — isolate cwd + env so this test

View File

@@ -172,7 +172,10 @@ stderr:
);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
let parsed: Value = serde_json::from_str(&stdout).expect("compact json stdout should parse");
assert_eq!(parsed["message"], "Mock streaming says hello from the parity harness.");
assert_eq!(
parsed["message"],
"Mock streaming says hello from the parity harness."
);
assert_eq!(parsed["compact"], true);
assert_eq!(parsed["model"], "claude-sonnet-4-6");
assert!(parsed["usage"].is_object());