Compare commits

...

15 Commits

Author SHA1 Message Date
YeonGyu-Kim
810036bf09 test(cli): add integration test for model persistence in resumed /status
New test: resumed_status_surfaces_persisted_model
- Creates session with model='claude-sonnet-4-6'
- Resumes with --output-format json /status
- Asserts model round-trips through session metadata

Resume integration tests: 11 → 12.
2026-04-10 10:31:05 +09:00
YeonGyu-Kim
0f34c66acd feat(session): persist model in session metadata — ROADMAP #59
Add 'model: Option<String>' to Session struct. The model used is now
saved in the session_meta JSONL record and surfaced in resumed /status:
- JSON mode: {model: 'claude-sonnet-4-6'} instead of null
- Text mode: shows actual model instead of 'restored-session'

Model is set in build_runtime_with_plugin_state() before the runtime
is constructed, and only when not already set (preserves model through
fork/resume cycles).

Backward compatible: old sessions without a model field load cleanly
with model: None (shown as null in JSON, 'restored-session' in text).

All workspace tests pass.
2026-04-10 10:05:42 +09:00
YeonGyu-Kim
6af0189906 docs(roadmap): file ROADMAP #58 (Windows HOME crash) and #59 (session model persistence)
#58 Windows startup crash from missing HOME env var — done at b95d330.
#59 Session metadata does not persist the model used — open.
2026-04-10 09:00:41 +09:00
YeonGyu-Kim
b95d330310 fix(startup): fall back to USERPROFILE when HOME is not set (Windows)
On Windows, HOME is often unset. The CLI crashed at startup with
'error: io error: HOME is not set' because three paths only checked
HOME:
- config_home_dir() in tools crate (config/settings loading)
- credentials_home_dir() in runtime crate (OAuth credentials)
- detect_broad_cwd() in CLI (CWD-is-home-dir check)
- skill lookup roots in tools crate

All now fall through to USERPROFILE when HOME is absent. Error message
updated to suggest USERPROFILE or CLAW_CONFIG_HOME on Windows.

Source: MaxDerVerpeilte in #claw-code (Windows user, 2026-04-10).
2026-04-10 08:33:35 +09:00
YeonGyu-Kim
74311cc511 test(cli): add 5 integration tests for resume JSON parity
New integration tests covering recent JSON parity work:
- resumed_version_command_emits_structured_json
- resumed_export_command_emits_structured_json
- resumed_help_command_emits_structured_json
- resumed_no_command_emits_restored_json
- resumed_stub_command_emits_not_implemented_json

Prevents regression on ROADMAP #54 (stub command error), #55 (session
list), #56 (--resume no-command JSON), #57 (session load errors).

Resume integration tests: 6 → 11.
2026-04-10 08:03:17 +09:00
YeonGyu-Kim
6ae8850d45 fix(api): silence dead_code warning and remove duplicated #[test] attr
- Add #[allow(dead_code)] on test-only Delta struct (content field
  used for deserialization but not read in assertion)
- Remove duplicated #[test] attribute on
  assistant_message_without_tool_calls_omits_tool_calls_field

Zero warnings in cargo test --workspace.
2026-04-10 07:33:22 +09:00
YeonGyu-Kim
ef9439d772 docs(roadmap): file ROADMAP #54-#57 from 2026-04-10 dogfood cycle
#54 circular 'Did you mean /X?' for spec commands with no parse arm (done)
#55 /session list unsupported in resume mode (done)
#56 --resume no-command ignores --output-format json (done)
#57 session load errors bypass --output-format json (done)
2026-04-10 07:04:21 +09:00
YeonGyu-Kim
4f670e5513 fix(cli): emit JSON for --resume with no command in --output-format json mode
claw --output-format json --resume <session> (no command) was printing:
  'Restored session from <path> (N messages).'
to stdout as prose, regardless of output format.

Now emits:
  {"kind":"restored","session_id":"...","path":"...","message_count":N}

159 CLI tests pass.
2026-04-10 06:31:16 +09:00
YeonGyu-Kim
8dcf10361f fix(cli): implement /session list in resume mode — ROADMAP #21 partial
/session list previously returned 'unsupported resumed slash command' in
--output-format json --resume mode. It only reads the sessions directory
so does not need a live runtime session.

Adds a Session{action:"list"} arm in run_resume_command() before the
unsupported catchall. Emits:
  {kind:session_list, sessions:[...ids], active:<current-session-id>}

159 CLI tests pass.
2026-04-10 06:03:29 +09:00
YeonGyu-Kim
cf129c8793 fix(cli): emit JSON error when session fails to load in --output-format json mode
'failed to restore session' errors from both the path-resolution step
and the JSONL-load step now check output_format and emit:
  {"type":"error","error":"failed to restore session: <detail>"}
instead of bare eprintln prose.

Covers: session not found, corrupt JSONL, permission errors.
2026-04-10 05:01:56 +09:00
YeonGyu-Kim
c0248253ac fix(cli): remove 'stats' from STUB_COMMANDS — it is implemented
/stats was accidentally listed in STUB_COMMANDS (both in the original list
and overlooked in 1e14d59). Since SlashCommand::Stats is fully implemented
with REPL and resume dispatch, it should not be intercepted as unimplemented.

/tokens and /cache alias to Stats and were already working correctly.
/stats now works again in all modes.
2026-04-10 04:32:05 +09:00
YeonGyu-Kim
1e14d59a71 fix(cli): stop circular 'Did you mean /X?' for spec commands with no parse arm
23 spec-registered commands had no parse arm in validate_slash_command_input,
causing the circular error 'Unknown slash command: /X — Did you mean /X?'
when users typed them in --resume mode.

Two fixes:
1. Add the 23 confirmed parse-armless commands to STUB_COMMANDS (excluded
   from REPL completions and help output).
2. In resume dispatch, intercept STUB_COMMANDS before SlashCommand::parse
   and emit a clean '{error: "/X is not yet implemented in this build"}'
   instead of the confusing error from the Err parse path.

Affected: /allowed-tools, /bookmarks, /workspace, /reasoning, /budget,
/rate-limit, /changelog, /diagnostics, /metrics, /tool-details, /focus,
/unfocus, /pin, /unpin, /language, /profile, /max-tokens, /temperature,
/system-prompt, /notifications, /telemetry, /env, /project, plus ~40
additional unreachable spec names.

159 CLI tests pass.
2026-04-10 04:05:41 +09:00
YeonGyu-Kim
11e2353585 fix(cli): JSON parity for /export and /agents in resume mode
/export now emits: {kind:export, file:<path>, message_count:<n>}
/agents now emits: {kind:agents, text:<agents report>}

Previously both returned json:None and fell through to prose output even
in --output-format json --resume mode. 159 CLI tests pass.
2026-04-10 03:32:24 +09:00
YeonGyu-Kim
0845705639 fix(tests): update test assertions for null model in resume /status; drop unused import
Two integration tests expected 'model':'restored-session' in the /status
JSON output but dc4fa55 changed resume mode to emit null for model.
Updated both assertions to assert model is null (correct behavior).

Also remove unused 'estimate_session_tokens' import in compact.rs tests
(surfaced as warning in CI, kept failing CI green noise).

All workspace tests pass.
2026-04-10 03:21:58 +09:00
YeonGyu-Kim
316864227c fix(cli): JSON parity for /help and /diff in resume mode
/help now emits: {kind:help, text:<full help text>}
/diff now emits:
  - no git repo: {kind:diff, result:no_git_repo, detail:...}
  - clean tree:  {kind:diff, result:clean, staged:'', unstaged:''}
  - changes:     {kind:diff, result:changes, staged:..., unstaged:...}

Previously both returned json:None and fell through to prose output even
in --output-format json --resume mode. 159 CLI tests pass.
2026-04-10 03:02:00 +09:00
9 changed files with 475 additions and 29 deletions

View File

@@ -532,3 +532,15 @@ Model name prefix now wins unconditionally over env-var presence. Regression tes
52. **`cargo install claw-code` false-positive install: deprecated stub silently succeeds** — dogfooded 2026-04-10 via #claw-code. User runs `cargo install claw-code`, install succeeds, Cargo places `claw-code-deprecated.exe`, user runs `claw` and gets `command not found`. The deprecated binary only prints `"claw-code has been renamed to agent-code"`. The success signal is false-positive: install appears to work but leaves the user with no working `claw` binary. Fix shape: (a) README must warn explicitly against `cargo install claw-code` with the hyphen (current note only warns about `clawcode` without hyphen); (b) if the deprecated crate is in our control, update its binary to print a clearer redirect message including `cargo install agent-code`; (c) ensure the Windows setup doc path mentions `agent-code` explicitly. Source: user in #claw-code 2026-04-10; traced by gaebal-gajae.
53. **`cargo install agent-code` produces `agent.exe`, not `agent-code.exe` — binary name mismatch in docs** — dogfooded 2026-04-10 via #claw-code. User follows the `claw-code` rename hint to run `cargo install agent-code`, install succeeds, but the installed binary is `agent.exe` (Unix: `agent`), not `agent-code` or `agent-code.exe`. User tries `agent-code --version`, gets `command not found`, concludes install is broken. The package name (`agent-code`), the crate name, and the installed binary name (`agent`) are all different. Fix shape: docs must show the full chain explicitly: `cargo install agent-code` → run via `agent` (Unix) / `agent.exe` (Windows). ROADMAP #52 note updated with corrected binary name. Source: user in #claw-code 2026-04-10; traced by gaebal-gajae.
54. **Circular "Did you mean /X?" error for spec-registered commands with no parse arm** — dogfooded 2026-04-10. 23 commands in the spec (shown in `/help` output) had no parse arm in `validate_slash_command_input`, so typing them produced `"Unknown slash command: /X — Did you mean /X?"`. The "Did you mean" suggestion pointed at the exact command the user just typed. Root cause: spec registration and parse-arm implementation were independent — a command could appear in help and completions without being parseable. **Done at `1e14d59` 2026-04-10**: added all 23 to STUB_COMMANDS and added pre-parse intercept in resume dispatch. Source: Jobdori dogfood.
55. **`/session list` unsupported in resume mode despite only needing directory read** — dogfooded 2026-04-10. `/session list` in `--output-format json --resume` mode returned `"unsupported resumed slash command"`. The command only reads the sessions directory — no live runtime needed. **Done at `8dcf103` 2026-04-10**: added `Session{action:"list"}` arm in `run_resume_command()`. Emits `{kind:session_list, sessions:[...ids], active:<id>}`. Partial progress on ROADMAP #21. Source: Jobdori dogfood.
56. **`--resume` with no command ignores `--output-format json`** — dogfooded 2026-04-10. `claw --output-format json --resume <session>` (no slash command) printed prose `"Restored session from <path> (N messages)."` to stdout, ignoring the JSON output format flag. **Done at `4f670e5` 2026-04-10**: empty-commands path now emits `{kind:restored, session_id, path, message_count}` in JSON mode. Source: Jobdori dogfood.
57. **Session load errors bypass `--output-format json` — prose error on corrupt JSONL** — dogfooded 2026-04-10. `claw --output-format json --resume <corrupt.jsonl> /status` printed bare prose `"failed to restore session: ..."` to stderr, not a JSON error object. Both the path-resolution and JSONL-load error paths ignored `output_format`. **Done at `cf129c8` 2026-04-10**: both paths now emit `{type:error, error:"failed to restore session: <detail>"}` in JSON mode. Source: Jobdori dogfood.
58. **Windows startup crash: `HOME is not set`** — user report 2026-04-10 in #claw-code (MaxDerVerpeilte). On Windows, `HOME` is often unset — `USERPROFILE` is the native equivalent. Four code paths only checked `HOME`: `config_home_dir()` (tools), `credentials_home_dir()` (runtime/oauth), `detect_broad_cwd()` (CLI), and skill lookup roots (tools). All crashed or silently skipped on stock Windows installs. **Done at `b95d330` 2026-04-10**: all four paths now fall back to `USERPROFILE` when `HOME` is absent. Error message updated to suggest `USERPROFILE` or `CLAW_CONFIG_HOME`. Source: MaxDerVerpeilte in #claw-code.
59. **Session metadata does not persist the model used** — dogfooded 2026-04-10. When resuming a session, `/status` reports `model: null` because the session JSONL stores no model field. A claw resuming a session cannot tell what model was originally used. The model is only known at runtime construction time via CLI flag or config. Fix shape: persist `model` in the session metadata record on first API call, and surface it in resumed `/status`. Source: Jobdori dogfood.

View File

@@ -1652,6 +1652,7 @@ mod tests {
}"#;
use super::deserialize_null_as_empty_vec;
#[allow(dead_code)]
#[derive(serde::Deserialize, Debug)]
struct Delta {
content: Option<String>,
@@ -1666,7 +1667,6 @@ mod tests {
);
}
#[test]
/// Regression: when building a multi-turn request where a prior assistant
/// turn has no tool calls, the serialized assistant message must NOT include
/// `tool_calls: []`. Some providers reject requests that carry an empty

View File

@@ -555,7 +555,7 @@ fn extract_summary_timeline(summary: &str) -> Vec<String> {
#[cfg(test)]
mod tests {
use super::{
collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
collect_key_files, compact_session, format_compact_summary,
get_compact_continuation_message, infer_pending_work, should_compact, CompactionConfig,
};
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};

View File

@@ -335,7 +335,14 @@ fn credentials_home_dir() -> io::Result<PathBuf> {
return Ok(PathBuf::from(path));
}
let home = std::env::var_os("HOME")
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME is not set"))?;
.or_else(|| std::env::var_os("USERPROFILE"))
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"HOME is not set (on Windows, set USERPROFILE or HOME, \
or use CLAW_CONFIG_HOME to point directly at the config directory)",
)
})?;
Ok(PathBuf::from(home).join(".claw"))
}

View File

@@ -96,6 +96,9 @@ pub struct Session {
pub fork: Option<SessionFork>,
pub workspace_root: Option<PathBuf>,
pub prompt_history: Vec<SessionPromptEntry>,
/// The model used in this session, persisted so resumed sessions can
/// report which model was originally used.
pub model: Option<String>,
persistence: Option<SessionPersistence>,
}
@@ -161,6 +164,7 @@ impl Session {
fork: None,
workspace_root: None,
prompt_history: Vec::new(),
model: None,
persistence: None,
}
}
@@ -263,6 +267,7 @@ impl Session {
}),
workspace_root: self.workspace_root.clone(),
prompt_history: self.prompt_history.clone(),
model: self.model.clone(),
persistence: None,
}
}
@@ -371,6 +376,10 @@ impl Session {
.collect()
})
.unwrap_or_default();
let model = object
.get("model")
.and_then(JsonValue::as_str)
.map(String::from);
Ok(Self {
version,
session_id,
@@ -381,6 +390,7 @@ impl Session {
fork,
workspace_root,
prompt_history,
model,
persistence: None,
})
}
@@ -394,6 +404,7 @@ impl Session {
let mut compaction = None;
let mut fork = None;
let mut workspace_root = None;
let mut model = None;
let mut prompt_history = Vec::new();
for (line_number, raw_line) in contents.lines().enumerate() {
@@ -433,6 +444,10 @@ impl Session {
.get("workspace_root")
.and_then(JsonValue::as_str)
.map(PathBuf::from);
model = object
.get("model")
.and_then(JsonValue::as_str)
.map(String::from);
}
"message" => {
let message_value = object.get("message").ok_or_else(|| {
@@ -475,6 +490,7 @@ impl Session {
fork,
workspace_root,
prompt_history,
model,
persistence: None,
})
}
@@ -580,6 +596,9 @@ impl Session {
JsonValue::String(workspace_root_to_string(workspace_root)?),
);
}
if let Some(model) = &self.model {
object.insert("model".to_string(), JsonValue::String(model.clone()));
}
Ok(JsonValue::Object(object))
}

View File

@@ -2221,7 +2221,17 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
match resolve_session_reference(&session_path.display().to_string()) {
Ok(handle) => handle.path,
Err(error) => {
eprintln!("failed to restore session: {error}");
if output_format == CliOutputFormat::Json {
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"error": format!("failed to restore session: {error}"),
})
);
} else {
eprintln!("failed to restore session: {error}");
}
std::process::exit(1);
}
}
@@ -2230,22 +2240,71 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
let session = match Session::load_from_path(&resolved_path) {
Ok(session) => session,
Err(error) => {
eprintln!("failed to restore session: {error}");
if output_format == CliOutputFormat::Json {
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"error": format!("failed to restore session: {error}"),
})
);
} else {
eprintln!("failed to restore session: {error}");
}
std::process::exit(1);
}
};
if commands.is_empty() {
println!(
"Restored session from {} ({} messages).",
resolved_path.display(),
session.messages.len()
);
if output_format == CliOutputFormat::Json {
println!(
"{}",
serde_json::json!({
"kind": "restored",
"session_id": session.session_id,
"path": resolved_path.display().to_string(),
"message_count": session.messages.len(),
})
);
} else {
println!(
"Restored session from {} ({} messages).",
resolved_path.display(),
session.messages.len()
);
}
return;
}
let mut session = session;
for raw_command in commands {
// Intercept spec commands that have no parse arm before calling
// SlashCommand::parse — they return Err(SlashCommandParseError) which
// formats as the confusing circular "Did you mean /X?" message.
// STUB_COMMANDS covers both completions-filtered stubs and parse-less
// spec entries; treat both as unsupported in resume mode.
{
let cmd_root = raw_command
.trim_start_matches('/')
.split_whitespace()
.next()
.unwrap_or("");
if STUB_COMMANDS.contains(&cmd_root) {
if output_format == CliOutputFormat::Json {
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"error": format!("/{cmd_root} is not yet implemented in this build"),
"command": raw_command,
})
);
} else {
eprintln!("/{cmd_root} is not yet implemented in this build");
}
std::process::exit(2);
}
}
let command = match SlashCommand::parse(raw_command) {
Ok(Some(command)) => command,
Ok(None) => {
@@ -2662,7 +2721,7 @@ fn run_resume_command(
SlashCommand::Help => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_repl_help()),
json: None,
json: Some(serde_json::json!({ "kind": "help", "text": render_repl_help() })),
}),
SlashCommand::Compact => {
let result = runtime::compact_session(
@@ -2730,7 +2789,7 @@ fn run_resume_command(
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format_status_report(
"restored-session",
session.model.as_deref().unwrap_or("restored-session"),
StatusUsage {
message_count: session.messages.len(),
turns: tracker.turns(),
@@ -2742,7 +2801,7 @@ fn run_resume_command(
&context,
)),
json: Some(status_json_value(
None,
session.model.as_deref(),
StatusUsage {
message_count: session.messages.len(),
turns: tracker.turns(),
@@ -2817,13 +2876,16 @@ fn run_resume_command(
json: Some(init_json_value(&message)),
})
}
SlashCommand::Diff => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_diff_report_for(
&std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
)?),
json: None,
}),
SlashCommand::Diff => {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let message = render_diff_report_for(&cwd)?;
let json = render_diff_json_for(&cwd)?;
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(message),
json: Some(json),
})
}
SlashCommand::Version => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_version_report()),
@@ -2832,14 +2894,19 @@ fn run_resume_command(
SlashCommand::Export { path } => {
let export_path = resolve_export_path(path.as_deref(), session)?;
fs::write(&export_path, render_export_text(session))?;
let msg_count = session.messages.len();
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(format!(
"Export\n Result wrote transcript\n File {}\n Messages {}",
export_path.display(),
session.messages.len(),
msg_count,
)),
json: None,
json: Some(serde_json::json!({
"kind": "export",
"file": export_path.display().to_string(),
"message_count": msg_count,
})),
})
}
SlashCommand::Agents { args } => {
@@ -2847,7 +2914,10 @@ fn run_resume_command(
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?),
json: None,
json: Some(serde_json::json!({
"kind": "agents",
"text": handle_agents_slash_command(args.as_deref(), &cwd)?,
})),
})
}
SlashCommand::Skills { args } => {
@@ -2906,6 +2976,25 @@ fn run_resume_command(
})
}
SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()),
// /session list can be served from the sessions directory without a live session.
SlashCommand::Session {
action: Some(ref act),
..
} if act == "list" => {
let sessions = list_managed_sessions().unwrap_or_default();
let session_ids: Vec<String> = sessions.iter().map(|s| s.id.clone()).collect();
let active_id = session.session_id.clone();
let text = render_session_list(&active_id).unwrap_or_else(|e| format!("error: {e}"));
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(text),
json: Some(serde_json::json!({
"kind": "session_list",
"sessions": session_ids,
"active": active_id,
})),
})
}
SlashCommand::Bughunter { .. }
| SlashCommand::Commit { .. }
| SlashCommand::Pr { .. }
@@ -2966,6 +3055,7 @@ fn detect_broad_cwd() -> Option<PathBuf> {
return None;
};
let is_home = env::var_os("HOME")
.or_else(|| env::var_os("USERPROFILE"))
.map(|h| PathBuf::from(h) == cwd)
.unwrap_or(false);
let is_root = cwd.parent().is_none();
@@ -5554,6 +5644,30 @@ fn render_diff_report_for(cwd: &Path) -> Result<String, Box<dyn std::error::Erro
Ok(format!("Diff\n\n{}", sections.join("\n\n")))
}
fn render_diff_json_for(cwd: &Path) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let in_git_repo = std::process::Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.current_dir(cwd)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !in_git_repo {
return Ok(serde_json::json!({
"kind": "diff",
"result": "no_git_repo",
"detail": format!("{} is not inside a git project", cwd.display()),
}));
}
let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?;
let unstaged = run_git_diff_command_in(cwd, &["diff"])?;
Ok(serde_json::json!({
"kind": "diff",
"result": if staged.trim().is_empty() && unstaged.trim().is_empty() { "clean" } else { "changes" },
"staged": staged.trim(),
"unstaged": unstaged.trim(),
}))
}
fn run_git_diff_command_in(
cwd: &Path,
args: &[&str],
@@ -6638,7 +6752,7 @@ fn build_runtime(
#[allow(clippy::needless_pass_by_value)]
#[allow(clippy::too_many_arguments)]
fn build_runtime_with_plugin_state(
session: Session,
mut session: Session,
session_id: &str,
model: String,
system_prompt: Vec<String>,
@@ -6649,6 +6763,10 @@ fn build_runtime_with_plugin_state(
progress_reporter: Option<InternalPromptProgressReporter>,
runtime_plugin_state: RuntimePluginState,
) -> Result<BuiltRuntime, Box<dyn std::error::Error>> {
// Persist the model in session metadata so resumed sessions can report it.
if session.model.is_none() {
session.model = Some(model.clone());
}
let RuntimePluginState {
feature_config,
tool_registry,
@@ -7285,7 +7403,6 @@ const STUB_COMMANDS: &[&str] = &[
"logout",
"vim",
"upgrade",
"stats",
"share",
"feedback",
"files",
@@ -7320,6 +7437,78 @@ const STUB_COMMANDS: &[&str] = &[
"tag",
"output-style",
"add-dir",
// Spec entries with no parse arm — produce circular "Did you mean" error
// without this guard. Adding here routes them to the proper unsupported
// message and excludes them from REPL completions / help.
// NOTE: do NOT add "stats", "tokens", "cache" — they are implemented.
"allowed-tools",
"bookmarks",
"workspace",
"reasoning",
"budget",
"rate-limit",
"changelog",
"diagnostics",
"metrics",
"tool-details",
"focus",
"unfocus",
"pin",
"unpin",
"language",
"profile",
"max-tokens",
"temperature",
"system-prompt",
"notifications",
"telemetry",
"env",
"project",
"terminal-setup",
"api-key",
"reset",
"undo",
"stop",
"retry",
"paste",
"screenshot",
"image",
"search",
"listen",
"speak",
"format",
"test",
"lint",
"build",
"run",
"git",
"stash",
"blame",
"log",
"cron",
"team",
"benchmark",
"migrate",
"templates",
"explain",
"refactor",
"docs",
"fix",
"perf",
"chat",
"web",
"map",
"symbols",
"references",
"definition",
"hover",
"autofix",
"multi",
"macro",
"alias",
"parallel",
"subagent",
"agent",
];
fn slash_command_completion_candidates_with_sessions(

View File

@@ -253,7 +253,8 @@ fn doctor_and_resume_status_emit_json_when_requested() {
],
);
assert_eq!(resumed["kind"], "status");
assert_eq!(resumed["model"], "restored-session");
// model is null in resume mode (not known without --model flag)
assert!(resumed["model"].is_null());
assert_eq!(resumed["usage"]["messages"], 1);
assert!(resumed["workspace"]["cwd"].as_str().is_some());
assert!(resumed["sandbox"]["filesystem_mode"].as_str().is_some());

View File

@@ -261,7 +261,8 @@ fn resumed_status_command_emits_structured_json_when_requested() {
let parsed: Value =
serde_json::from_str(stdout.trim()).expect("resume status output should be json");
assert_eq!(parsed["kind"], "status");
assert_eq!(parsed["model"], "restored-session");
// model is null in resume mode (not known without --model flag)
assert!(parsed["model"].is_null());
assert_eq!(parsed["permission_mode"], "danger-full-access");
assert_eq!(parsed["usage"]["messages"], 1);
assert!(parsed["usage"]["turns"].is_number());
@@ -275,6 +276,47 @@ fn resumed_status_command_emits_structured_json_when_requested() {
assert!(parsed["sandbox"]["filesystem_mode"].as_str().is_some());
}
#[test]
fn resumed_status_surfaces_persisted_model() {
// given — create a session with model already set
let temp_dir = unique_temp_dir("resume-status-model");
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let session_path = temp_dir.join("session.jsonl");
let mut session = Session::new();
session.model = Some("claude-sonnet-4-6".to_string());
session
.push_user_text("model persistence fixture")
.expect("write ok");
session.save_to_path(&session_path).expect("persist ok");
// when
let output = run_claw(
&temp_dir,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 path"),
"/status",
],
);
// then
assert!(
output.status.success(),
"stderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("utf8");
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
assert_eq!(parsed["kind"], "status");
assert_eq!(
parsed["model"], "claude-sonnet-4-6",
"model should round-trip through session metadata"
);
}
#[test]
fn resumed_sandbox_command_emits_structured_json_when_requested() {
// given
@@ -318,6 +360,175 @@ fn resumed_sandbox_command_emits_structured_json_when_requested() {
assert!(parsed["markers"].is_array());
}
#[test]
fn resumed_version_command_emits_structured_json() {
let temp_dir = unique_temp_dir("resume-version-json");
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let session_path = temp_dir.join("session.jsonl");
Session::new()
.save_to_path(&session_path)
.expect("session should persist");
let output = run_claw(
&temp_dir,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 path"),
"/version",
],
);
assert!(
output.status.success(),
"stderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("utf8");
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
assert_eq!(parsed["kind"], "version");
assert!(parsed["version"].as_str().is_some());
assert!(parsed["git_sha"].as_str().is_some());
assert!(parsed["target"].as_str().is_some());
}
#[test]
fn resumed_export_command_emits_structured_json() {
let temp_dir = unique_temp_dir("resume-export-json");
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let session_path = temp_dir.join("session.jsonl");
let mut session = Session::new();
session
.push_user_text("export json fixture")
.expect("write ok");
session.save_to_path(&session_path).expect("persist ok");
let output = run_claw(
&temp_dir,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 path"),
"/export",
],
);
assert!(
output.status.success(),
"stderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("utf8");
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
assert_eq!(parsed["kind"], "export");
assert!(parsed["file"].as_str().is_some());
assert_eq!(parsed["message_count"], 1);
}
#[test]
fn resumed_help_command_emits_structured_json() {
let temp_dir = unique_temp_dir("resume-help-json");
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let session_path = temp_dir.join("session.jsonl");
Session::new()
.save_to_path(&session_path)
.expect("persist ok");
let output = run_claw(
&temp_dir,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 path"),
"/help",
],
);
assert!(
output.status.success(),
"stderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("utf8");
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
assert_eq!(parsed["kind"], "help");
assert!(parsed["text"].as_str().is_some());
let text = parsed["text"].as_str().unwrap();
assert!(text.contains("/status"), "help text should list /status");
}
#[test]
fn resumed_no_command_emits_restored_json() {
let temp_dir = unique_temp_dir("resume-no-cmd-json");
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let session_path = temp_dir.join("session.jsonl");
let mut session = Session::new();
session
.push_user_text("restored json fixture")
.expect("write ok");
session.save_to_path(&session_path).expect("persist ok");
let output = run_claw(
&temp_dir,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 path"),
],
);
assert!(
output.status.success(),
"stderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("utf8");
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
assert_eq!(parsed["kind"], "restored");
assert!(parsed["session_id"].as_str().is_some());
assert!(parsed["path"].as_str().is_some());
assert_eq!(parsed["message_count"], 1);
}
#[test]
fn resumed_stub_command_emits_not_implemented_json() {
let temp_dir = unique_temp_dir("resume-stub-json");
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let session_path = temp_dir.join("session.jsonl");
Session::new()
.save_to_path(&session_path)
.expect("persist ok");
let output = run_claw(
&temp_dir,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 path"),
"/allowed-tools",
],
);
// Stub commands exit with code 2
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).expect("utf8");
let parsed: Value = serde_json::from_str(stderr.trim()).expect("should be json");
assert_eq!(parsed["type"], "error");
assert!(
parsed["error"]
.as_str()
.unwrap()
.contains("not yet implemented"),
"error should say not yet implemented: {:?}",
parsed["error"]
);
}
fn run_claw(current_dir: &Path, args: &[&str]) -> Output {
run_claw_with_env(current_dir, args, &[])
}

View File

@@ -3066,7 +3066,7 @@ fn skill_lookup_roots() -> Vec<SkillLookupRoot> {
if let Ok(codex_home) = std::env::var("CODEX_HOME") {
push_prefixed_skill_lookup_roots(&mut roots, std::path::Path::new(&codex_home));
}
if let Ok(home) = std::env::var("HOME") {
if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
push_home_skill_lookup_roots(&mut roots, std::path::Path::new(&home));
}
if let Ok(claude_config_dir) = std::env::var("CLAUDE_CONFIG_DIR") {
@@ -4987,7 +4987,14 @@ fn config_home_dir() -> Result<PathBuf, String> {
if let Ok(path) = std::env::var("CLAW_CONFIG_HOME") {
return Ok(PathBuf::from(path));
}
let home = std::env::var("HOME").map_err(|_| String::from("HOME is not set"))?;
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map_err(|_| {
String::from(
"HOME is not set (on Windows, set USERPROFILE or HOME, \
or use CLAW_CONFIG_HOME to point directly at the config directory)",
)
})?;
Ok(PathBuf::from(home).join(".claw"))
}