From d8535bf9387d9bd5ead931d466d20a0f4eb9f244 Mon Sep 17 00:00:00 2001 From: bellman Date: Thu, 4 Jun 2026 15:08:56 +0900 Subject: [PATCH] fix: keep failed resume side-effect free Generated with https://github.com/Yeachan-Heo/gajae-code Co-authored-by: Gajae Code --- ROADMAP.md | 4 +- rust/crates/runtime/src/session_control.rs | 49 ++++++++++++-- rust/crates/rusty-claude-cli/src/main.rs | 45 +++++++++++-- .../tests/output_format_contract.rs | 48 +++++++++++++ .../tests/resume_slash_commands.rs | 67 +++++++++++++++++++ 5 files changed, 201 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a4d00348..ec941bfa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6377,10 +6377,10 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) 433. **DONE — `--output-format` selection is typed, case-insensitive, env-configurable, and auditable** — fixed 2026-06-04 in `fix: type output format selection`. `CliOutputFormat::parse` now accepts `text`/`json` in any casing, `CLAW_OUTPUT_FORMAT` seeds the default output format when no CLI output-format flag is present, explicit flags override the env default, and repeated flags emit `warning: --output-format specified multiple times; using last value '...'`. `status --output-format json` exposes `format_source`, `format_raw`, and `format_overridden`; invalid values return typed `invalid_output_format` JSON with `value`, `expected:["text","json"]`, and a recovery hint instead of `kind:"unknown"`. Top-level help documents `CLAW_OUTPUT_FORMAT`, `CLAW_LOG`, and `RUST_LOG`, and doctor system JSON surfaces those env values. Regression coverage: `output_format_flags_and_env_have_typed_contract_433` and `classify_error_kind_returns_correct_discriminants`. Verification: `cargo fmt --manifest-path rust/Cargo.toml --all -- --check`, focused output-format and classifier tests, `scripts/roadmap-check-ids.sh`, `git diff --check`, `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli --no-run`, and `cargo build --manifest-path rust/Cargo.toml --workspace --locked`. -434. **DONE — POSIX `--` and dash-prefixed shorthand prompts stay on the prompt path** — fixed 2026-06-04 in CI recovery after `41678eb` turned Rust CI red. Global argument parsing now treats `--` as an end-of-flags separator, stops JSON-output pre-scans before the separator, and forwards every following token as positional prompt text. Shorthand prompt mode accepts dash-prefixed text that is not a registered or near-miss CLI flag (`-not-a-flag`, `--bogus-flag-like literal`) while still rejecting real typo-like options such as `--resum` with the `--resume` suggestion. Direct `/status` invocation again remains REPL/session-only so the existing parser contract is restored. Help, USAGE, and rust README document the `claw -- "-prompt-with-dash"` form. Regression coverage: `parses_dash_prefixed_prompt_text_434`, plus rerun CI-red parser tests `parses_bare_prompt_and_json_output_flag` and `parses_direct_agents_mcp_and_skills_slash_commands`. Verification: `cargo fmt --manifest-path rust/Cargo.toml --all -- --check`, focused parser tests, `scripts/roadmap-check-ids.sh`, `git diff --check -- USAGE.md rust/README.md rust/crates/rusty-claude-cli/src/main.rs ROADMAP.md`, docs source-of-truth/release-readiness/unit helper checks, `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli --no-run`, `cargo build --manifest-path rust/Cargo.toml --workspace --locked`, and `cargo clippy --manifest-path rust/Cargo.toml --workspace`. Local `cargo test --manifest-path rust/Cargo.toml --workspace` still hits the pre-existing Darwin-only `runtime::worker_boot::startup_preflight_warns_when_git_metadata_is_not_writable` permission assertion after all CLI parser tests pass; the red GitHub jobs were parser failures on head `41678eb`. +434. **DONE — POSIX `--` and dash-prefixed shorthand prompts stay on the prompt path** — fixed 2026-06-04 in CI recovery after `41678eb` turned Rust CI red. Global argument parsing now treats `--` as an end-of-flags separator, stops JSON-output pre-scans before the separator, and forwards every following token as positional prompt text. Shorthand prompt mode accepts dash-prefixed text that is not a registered or near-miss CLI flag (`-not-a-flag`, `--bogus-flag-like literal`) while still rejecting real typo-like options such as `--resum` with the `--resume` suggestion. Direct `/status` invocation routes to the local status action per the resume-safe slash command contract, matching the CI-red parser regression tests restored after `7cfd83f`. Help, USAGE, and rust README document the `claw -- "-prompt-with-dash"` form. Regression coverage: `parses_dash_prefixed_prompt_text_434`, plus rerun CI-red parser tests `parses_bare_prompt_and_json_output_flag` and `parses_direct_agents_mcp_and_skills_slash_commands`. Verification: `cargo fmt --manifest-path rust/Cargo.toml --all -- --check`, focused parser tests, `scripts/roadmap-check-ids.sh`, `git diff --check -- USAGE.md rust/README.md rust/crates/rusty-claude-cli/src/main.rs ROADMAP.md`, docs source-of-truth/release-readiness/unit helper checks, `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli --no-run`, `cargo build --manifest-path rust/Cargo.toml --workspace --locked`, and `cargo clippy --manifest-path rust/Cargo.toml --workspace`. Local `cargo test --manifest-path rust/Cargo.toml --workspace` still hits the pre-existing Darwin-only `runtime::worker_boot::startup_preflight_warns_when_git_metadata_is_not_writable` permission assertion after all CLI parser tests pass; the red GitHub jobs were parser failures on head `41678eb`. -435. **`claw --resume latest` on a fresh workspace exit code is 0 in text mode but 1 in JSON mode (text mode lies about success); sibling: failed `--resume` creates the `.claw/sessions//` directory tree as a filesystem side effect of the failure** — dogfooded 2026-05-11 by Jobdori on `e29010ed` in response to Clawhip pinpoint nudge at `1503305692566655096`. Reproduction (fresh empty dir, no `.claw/`, no sessions): `claw --resume latest` (text mode) prints `failed to restore session: no managed sessions found in .claw/sessions/0ead448127a2de44/` and exits **0**. Same invocation with `--output-format json` correctly exits **1** with `kind:"session_load_failed"`. Exit-code parity broken on the same input depending on format flag. **Sibling filesystem-side-effect bug:** after the failed `--resume latest` on a fresh empty workspace, the directory `.claw/sessions/0ead448127a2de44/` (the workspace-fingerprint partition) is created on disk despite the operation failing. The user did not opt into creating workspace metadata — they asked to resume an existing session, the resume failed, and now there's a partition directory hanging around. The fingerprint directory ought to be created lazily on first successful session save, not as a side effect of every resume attempt. **Three sibling findings in the same probe:** (a) **`claw --compact` alone (no other args) drops into the interactive REPL with the ANSI welcome banner** — `--compact` is documented as a modifier that strips tool call details in text mode for piping (`--compact ... useful for piping`), not as a verb that activates the REPL. Running `claw --compact` with no positional should be a no-op or an error explaining the flag needs a subcommand or prompt; entering the REPL is the wrong default. (b) **`claw --compact "hello"` (shorthand prompt) returns `{"error":"unknown subcommand: hello.","hint":"Did you mean help","kind":"unknown"}` — `--compact` disables shorthand prompt mode entirely**, treating the positional as a subcommand instead of as prompt text. Users must use the explicit `prompt` verb (`claw --compact prompt "hello"`) which contradicts the `claw [flags] TEXT` usage line in `--help`. (c) `kind:"unknown"` again for the unknown-subcommand error in --compact path — same catch-all bucket bug appearing for the 11th time across pinpoints. **Required fix shape:** (a) exit code 1 for all `failed_to_restore` / `session_load_failed` text-mode failures; text mode should print to stderr and exit non-zero, not print to stdout and exit 0; (b) defer `.claw/sessions//` creation to first successful save; failed `--resume` must not leave filesystem droppings; (c) `claw --compact` alone (no positional, no subcommand, stdin is TTY) should emit `kind:"missing_argument"` with `argument:"prompt or subcommand"` rather than activating the REPL; (d) `--compact` must be transparent to shorthand prompt mode parsing — `claw --compact "hello"` is equivalent to `claw --compact prompt "hello"`, both should reach the prompt path; (e) emit typed `kind:"unknown_subcommand"` not `kind:"unknown"` for fallthrough cases. **Why this matters:** scripts that gate on `$?` after `claw --resume latest` see success on text mode and failure on JSON mode — the same operation, two outcomes. The filesystem side effect pollutes a user's worktree with workspace partitions they didn't ask for, and CI pipelines that snapshot `.claw/` size silently grow on every failed `--resume`. Cross-references #422 (exit-code parity across error envelopes), #423 (`kind:"unknown"` for `missing_argument`), #434 (shorthand prompt limitations). Source: Jobdori live dogfood, `e29010ed`, 2026-05-11. +435. **DONE — failed resume is non-zero and side-effect free; `--compact` stays a prompt modifier** — fixed 2026-06-04 in `fix: keep failed resume side-effect free`. Fresh-workspace `claw --resume latest` exits 1 in text and JSON modes; text writes the restore failure to stderr, JSON writes a typed `no_managed_sessions` restore envelope to stdout, and failed lookup no longer creates `.claw/sessions//`. `SessionStore::from_cwd`/`from_data_dir` now only derive the fingerprinted path; session save remains responsible for creating it. Global `--compact` no longer starts the REPL when it has no prompt or stdin: it returns typed `missing_argument` with `argument:"prompt or subcommand"`. `claw --compact "hello"` remains shorthand prompt mode and reaches provider/auth validation rather than command-not-found. Regression coverage: `session_store_from_cwd_is_side_effect_free_until_save`, `resume_latest_missing_session_fails_without_creating_session_dirs_435`, `compact_flag_missing_argument_and_shorthand_prompt_contract_435`, and `parses_compact_flag_for_prompt_mode`; broader checks reran `runtime session_control`, `resume_slash_commands`, `output_format_contract`, `claw` bin tests, `cargo fmt --all -- --check`, `scripts/roadmap-check-ids.sh`, `git diff --check`, and `cargo build --workspace --locked`. 436. **`claw init` shipped `.claw.json` template explicitly sets `permissions.defaultMode:"dontAsk"` — every user who runs `claw init` gets a config file that disables permission prompts by default; sibling: `init` creates an empty `.claw/` directory with no settings.json template inside, and when `.claw/` already exists it skips the whole artifact (no settings template materialized)** — dogfooded 2026-05-11 by Jobdori on `b8f989b6` in response to Clawhip pinpoint nudge at `1503313241751949335`. Reproduction: `mkdir /tmp/probe && cd /tmp/probe && claw init --output-format json` returns `artifacts:[{name:".claw/",status:"created"},{name:".claw.json",status:"created"},...]`. Inspecting the created `.claw.json`: `{"permissions":{"defaultMode":"dontAsk"}}`. This is the polar opposite of safe-by-default: every user who follows the documented onboarding flow (`claw init` after `curl install.sh`) ships their workspace with permission prompts disabled. Compounds with **#428** (default runtime permission_mode is `danger-full-access`) — between the runtime default and the init template, a fresh claw setup has zero user-facing safety friction. **Sibling: `.claw/` artifact is an empty directory.** After `claw init`, `find .claw -type f` returns nothing. No `settings.json`, no template, no scaffolding — just `mkdir .claw`. The `--help` description implies init produces a usable workspace, but `.claw/settings.json` (the project-scope counterpart of `~/.claw/settings.json`) is never templated. **Sibling: `.claw/` skip-on-exists drops the entire artifact.** If `.claw/` already exists (e.g., from a partial setup, a `--resume` failure side effect per #435, or manual creation), `claw init` returns `.claw/: skipped` and does not materialize any expected sub-content. The other artifacts (`.claw.json`, `.gitignore`, `CLAUDE.md`) are still created, but a future `claw skills install` or `claw plugins enable` may expect `.claw/` to contain template files that are now missing. **Required fix shape:** (a) the shipped `.claw.json` template must default to `permissions.defaultMode:"acceptEdits"` or `"plan"` (safe-by-default modes per #428 spec) — `"dontAsk"` requires explicit opt-in; (b) `claw init` must materialize `.claw/settings.json` with documented schema defaults inside `.claw/` so the directory is useful on its own; (c) when `.claw/` already exists, `init` must report `partial` status (not `skipped`) and still try to create missing sub-files like `.claw/settings.json` without overwriting existing files; (d) emit per-sub-file artifact entries for `.claw/settings.json` and `.claw/sessions/` (skipped status if absent, deferred-to-first-save acceptable) so automation knows what's present; (e) regression test: `claw init` produces a `.claw.json` whose `permissions.defaultMode` is NOT `dontAsk`; `.claw/` contains at least one templated file. **Why this matters:** init is the primary onboarding surface. Every first-time user piping `curl install.sh | sh && claw init` gets a workspace pre-configured to skip permission prompts — and that workspace gets committed to the user's repo via the `init`-added entry. The `.claw/` empty-directory bug means feature discovery (skills, plugins) lacks the scaffolding it implies. Cross-references #428 (runtime default permission_mode), #50/#87/#91/#94/#97/#101/#106/#115/#123 (permission-rule audit), #435 (filesystem side effects on failed resume). Source: Jobdori live dogfood, `b8f989b6`, 2026-05-11. diff --git a/rust/crates/runtime/src/session_control.rs b/rust/crates/runtime/src/session_control.rs index e6c3f6c0..ebb252e1 100644 --- a/rust/crates/runtime/src/session_control.rs +++ b/rust/crates/runtime/src/session_control.rs @@ -28,7 +28,8 @@ pub struct SessionStore { impl SessionStore { /// Build a store from the server's current working directory. /// - /// The on-disk layout becomes `/.claw/sessions//`. + /// The on-disk layout is `/.claw/sessions//`, + /// created lazily on first successful session save. pub fn from_cwd(cwd: impl AsRef) -> Result { let cwd = cwd.as_ref(); // #151: canonicalize so equivalent paths (symlinks, relative vs @@ -40,7 +41,6 @@ impl SessionStore { .join(".claw") .join("sessions") .join(workspace_fingerprint(&canonical_cwd)); - fs::create_dir_all(&sessions_root)?; Ok(Self { sessions_root, workspace_root: canonical_cwd, @@ -49,7 +49,8 @@ impl SessionStore { /// Build a store from an explicit `--data-dir` flag. /// - /// The on-disk layout becomes `/sessions//` + /// The on-disk layout is `/sessions//`, + /// created lazily on first successful session save. /// where `` is derived from `workspace_root`. pub fn from_data_dir( data_dir: impl AsRef, @@ -64,7 +65,6 @@ impl SessionStore { .as_ref() .join("sessions") .join(workspace_fingerprint(&canonical_workspace)); - fs::create_dir_all(&sessions_root)?; Ok(Self { sessions_root, workspace_root: canonical_workspace, @@ -760,14 +760,21 @@ mod tests { use crate::session::Session; use std::fs; use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; + static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + fn temp_dir() -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("time should be after epoch") .as_nanos(); - std::env::temp_dir().join(format!("runtime-session-control-{nanos}")) + let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "runtime-session-control-{}-{nanos}-{counter}", + std::process::id() + )) } fn persist_session(root: &Path, text: &str) -> Session { @@ -981,6 +988,38 @@ mod tests { } } + #[test] + fn session_store_from_cwd_is_side_effect_free_until_save() { + // given + let base = temp_dir(); + let workspace = base.join("fresh-workspace"); + fs::create_dir_all(&workspace).expect("workspace should exist"); + + // when + let store = SessionStore::from_cwd(&workspace).expect("store should build"); + + // then — resolving the store must not create .claw/session partitions. + assert!( + !workspace.join(".claw").exists(), + "session store construction must not create .claw side effects" + ); + assert!( + !store.sessions_dir().exists(), + "session partition should be created lazily on save" + ); + + let session = persist_session_via_store(&store, "first saved turn"); + assert!( + store + .sessions_dir() + .join(format!("{}.jsonl", session.session_id)) + .exists(), + "saving a managed session should create the lazy session partition" + ); + + fs::remove_dir_all(base).expect("temp dir should clean up"); + } + #[test] fn session_store_from_cwd_isolates_sessions_by_workspace() { // given diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 98ba62dc..6925f0e0 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -382,9 +382,16 @@ fn main() { object.insert("available".to_string(), serde_json::json!(available)); object.insert("tool_aliases".to_string(), aliases); } - } else if kind == "missing_argument" && message.contains("--allowedTools") { + } else if kind == "missing_argument" { if let Some(object) = error_json.as_object_mut() { - object.insert("argument".to_string(), serde_json::json!("--allowedTools")); + if message.contains("--allowedTools") { + object.insert("argument".to_string(), serde_json::json!("--allowedTools")); + } else if message.contains("prompt or subcommand") { + object.insert( + "argument".to_string(), + serde_json::json!("prompt or subcommand"), + ); + } } } // #819/#820/#823: JSON mode error envelopes must go to stdout so machine @@ -1751,11 +1758,15 @@ fn parse_args(args: &[String]) -> Result { if rest.is_empty() { let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode); + let stdin_is_terminal = std::io::stdin().is_terminal(); + if compact && stdin_is_terminal { + return Err(compact_missing_argument_error()); + } // When stdin is not a terminal (pipe/redirect) and no prompt is given on the // command line, read stdin as the prompt and dispatch as a one-shot Prompt // rather than starting the interactive REPL (which would consume the pipe and // print the startup banner, then exit without sending anything to the API). - if !std::io::stdin().is_terminal() { + if !stdin_is_terminal { let mut buf = String::new(); let _ = std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf); let piped = buf.trim().to_string(); @@ -1766,12 +1777,15 @@ fn parse_args(args: &[String]) -> Result { allowed_tools, permission_mode, output_format, - compact: false, + compact, base_commit, reasoning_effort, allow_broad_cwd, }); } + if compact { + return Err(compact_missing_argument_error()); + } // Non-TTY stdin with no piped content: refuse to start the interactive // REPL (it would block forever waiting for input that will never arrive). // (#696: emit a typed error instead of hanging indefinitely) @@ -2087,7 +2101,8 @@ Usage: claw prompt or echo '' | claw prompt".to_string()); allow_broad_cwd, ), other => { - if !other.starts_with('-') + if !compact + && !other.starts_with('-') && looks_like_subcommand_typo(other) && (rest.len() == 1 || (output_format == CliOutputFormat::Json && model_flag_raw.is_none())) @@ -2850,6 +2865,11 @@ fn allowed_tools_missing_error() -> String { "missing_argument: --allowedTools requires a tool list before subcommands or flags.\nUsage: --allowedTools [,...] e.g. --allowedTools read,glob".to_string() } +fn compact_missing_argument_error() -> String { + "missing_argument: --compact requires prompt text, piped stdin, or a subcommand. argument: prompt or subcommand\nUsage: claw --compact or echo '' | claw --compact" + .to_string() +} + fn allowed_tool_aliases_json(registry: &GlobalToolRegistry) -> Value { Value::Object( registry @@ -13558,6 +13578,21 @@ mod tests { allow_broad_cwd: false, } ); + assert_eq!( + parse_args(&["--compact".to_string(), "hello".to_string()]) + .expect("compact single-word prompt should parse"), + CliAction::Prompt { + prompt: "hello".to_string(), + model: DEFAULT_MODEL.to_string(), + output_format: CliOutputFormat::Text, + allowed_tools: None, + permission_mode: PermissionMode::WorkspaceWrite, + compact: true, + base_commit: None, + reasoning_effort: None, + allow_broad_cwd: false, + } + ); } #[test] diff --git a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs index e352bb0f..c891c52f 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -5424,6 +5424,54 @@ fn multi_word_unknown_subcommand_json_emits_command_not_found_826() { ); } +#[test] +fn compact_flag_missing_argument_and_shorthand_prompt_contract_435() { + let root = unique_temp_dir("compact-flag-435"); + let config_home = root.join("config-home"); + let home = root.join("home"); + std::fs::create_dir_all(&root).expect("create temp dir"); + std::fs::create_dir_all(&config_home).expect("create config home"); + std::fs::create_dir_all(&home).expect("create home"); + let envs = [ + ( + "CLAW_CONFIG_HOME", + config_home.to_str().expect("config home utf8"), + ), + ("HOME", home.to_str().expect("home utf8")), + ("ANTHROPIC_API_KEY", ""), + ("ANTHROPIC_AUTH_TOKEN", ""), + ("OPENAI_API_KEY", ""), + ]; + + let missing = run_claw(&root, &["--output-format", "json", "--compact"], &envs); + assert_eq!(missing.status.code(), Some(1)); + assert!( + missing.stderr.is_empty(), + "compact missing-argument JSON should keep stderr empty: {}", + String::from_utf8_lossy(&missing.stderr) + ); + let missing_json = parse_json_stdout(&missing, "compact missing argument"); + assert_eq!(missing_json["error_kind"], "missing_argument"); + assert_eq!(missing_json["argument"], "prompt or subcommand"); + + let prompt = run_claw( + &root, + &["--output-format", "json", "--compact", "hello"], + &envs, + ); + assert_eq!(prompt.status.code(), Some(1)); + assert!( + prompt.stderr.is_empty(), + "compact prompt JSON should keep stderr empty: {}", + String::from_utf8_lossy(&prompt.stderr) + ); + let prompt_json = parse_json_stdout(&prompt, "compact shorthand prompt"); + assert_eq!( + prompt_json["error_kind"], "missing_credentials", + "--compact hello should stay on the prompt/provider path, not command_not_found: {prompt_json}" + ); +} + // #827: direct /unknown-slash-command must emit typed error_kind, not "unknown" // Uses the direct-slash CLI path (no session load needed; reproducible on CI). #[test] diff --git a/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs b/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs index 6b19b3dd..ddaf6e08 100644 --- a/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs +++ b/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs @@ -222,6 +222,73 @@ fn resume_latest_restores_the_most_recent_managed_session() { assert!(stdout.contains(newer_path.to_str().expect("utf8 path"))); } +#[test] +fn resume_latest_missing_session_fails_without_creating_session_dirs_435() { + // given + let temp_dir = unique_temp_dir("resume-latest-missing-435"); + let project_dir = temp_dir.join("project"); + let config_home = temp_dir.join("config-home"); + let home = temp_dir.join("home"); + fs::create_dir_all(&project_dir).expect("project dir should exist"); + fs::create_dir_all(&config_home).expect("config home should exist"); + fs::create_dir_all(&home).expect("home should exist"); + let envs = [ + ( + "CLAW_CONFIG_HOME", + config_home.to_str().expect("utf8 config home"), + ), + ("HOME", home.to_str().expect("utf8 home")), + ("ANTHROPIC_API_KEY", ""), + ("ANTHROPIC_AUTH_TOKEN", ""), + ("OPENAI_API_KEY", ""), + ]; + + // when — both text and JSON resume failures should be non-zero and read-only. + let text = run_claw_with_env(&project_dir, &["--resume", "latest"], &envs); + let json = run_claw_with_env( + &project_dir, + &["--output-format", "json", "--resume", "latest"], + &envs, + ); + + // then + assert_eq!( + text.status.code(), + Some(1), + "text resume failure must be non-zero" + ); + assert!( + text.stdout.is_empty(), + "text resume failure should not claim success on stdout: {}", + String::from_utf8_lossy(&text.stdout) + ); + let text_stderr = String::from_utf8_lossy(&text.stderr); + assert!( + text_stderr.contains("no managed sessions found"), + "text failure should explain missing sessions: {text_stderr}" + ); + + assert_eq!( + json.status.code(), + Some(1), + "JSON resume failure must be non-zero" + ); + assert!( + json.stderr.is_empty(), + "JSON resume failure should keep stderr empty: {}", + String::from_utf8_lossy(&json.stderr) + ); + let parsed: Value = serde_json::from_slice(&json.stdout) + .expect("JSON resume failure should emit JSON to stdout"); + assert_eq!(parsed["status"], "error"); + assert_eq!(parsed["action"], "restore"); + assert_eq!(parsed["error_kind"], "no_managed_sessions"); + assert!( + !project_dir.join(".claw").exists(), + "failed resume must not create .claw/session directories" + ); +} + #[test] fn resumed_status_command_emits_structured_json_when_requested() { // given