Compare commits

...

129 Commits

Author SHA1 Message Date
YeonGyu-Kim
8f55870dad docs(roadmap): add #448 — sandbox JSON has contradictory enabled/supported/active flags
Pinpoint: 'enabled:true, supported:false' is semantic nonsense.
'filesystem_active:true allowed_mounts:[]' contradicts 'workspace-only'.
'active:false filesystem_active:true' has no documented aggregation rule.
Renaming 'enabled' to 'requested' and exposing 'active_components:[]'
would surface real isolation state to automation.
2026-05-11 23:32:30 +09:00
YeonGyu-Kim
7244a82b36 docs(roadmap): add #447 — JSON error envelopes go to stderr; stdout empty on error
Pinpoint: claw --no-such-flag --output-format json writes the JSON
envelope to stderr (115 bytes) while stdout is 0 bytes. Same for
missing_credentials, session_load_failed, invalid_model_syntax —
all 4 error kinds tested put JSON on stderr. Breaks the standard
'output=$(cmd --output-format json)' pattern. Every major CLI
(kubectl/gh/aws/jq/terraform -json) puts JSON on stdout regardless
of success/failure. Sibling: deprecation warnings precede the JSON
envelope on stderr, breaking 'tail -1 | jq' parsing.
2026-05-11 23:01:46 +09:00
YeonGyu-Kim
5ab969e7ae docs(roadmap): add #446 — config loaded 2-3x per invocation; identical deprecation warnings spam
Pinpoint: status emits 3x deprecation warnings, doctor 2x, mcp 2x,
version 0x. Each duplicate is byte-identical (same file/line/field).
Config-load pipeline is fanned out across commands without a cache.
15 redundant disk reads in worst case. Real warnings drowned out by
copies. Count fluctuates between HEADs (3 at 6c0c305a, 4 at d7dbe951,
3 at 5a4cc506) — no architectural fix landed.
2026-05-11 22:33:34 +09:00
YeonGyu-Kim
5a4cc506d5 docs(roadmap): add #445 — skill name-vs-dirname mismatch silently accepted; sibling silent drops
Pinpoint: .claw/skills/wrong-name/SKILL.md with frontmatter name:
actually-different-name silently loads as the frontmatter name. Users
referencing by dir name get skill_not_found while skills list shows
the frontmatter name. Siblings: subdir without SKILL.md silently
skipped; loose .md at skills root dropped; no --scope filter for
workspace vs user merge.
2026-05-11 22:01:12 +09:00
YeonGyu-Kim
9e1eafd02d docs(roadmap): add #444 — no broad-cwd guard for --resume; ROOT/HOME silently writable
Pinpoint: claw --resume latest from / hits 'Read-only file system'
(OS error 30) — only saved by root being read-only. From /tmp or
$HOME, silently creates .claw/sessions/<fingerprint>/ droppings.
Exit code 0 on the read-only-FS error. Stale /tmp/.claw from 13:31
dogfood still present at 21:30 (10 hours, 6+ HEADs later) — #435's
deferred-creation fix hasn't landed. The broad-cwd guard only covers
shorthand prompt path, not resume/status/doctor.
2026-05-11 21:31:33 +09:00
YeonGyu-Kim
b2048856f3 docs(roadmap): add #443 — acp serve exits 0 with status:discoverability_only; #413 still unfixed
Pinpoint: claw acp serve --output-format json exits 0 with explicit
'not implemented' message + supported:false. Automation gating on $?
sees success from a no-op. ROADMAP #413's internal-tracking leak
(discoverability_tracking, tracking) confirmed UNFIXED 11 days later.
Sibling: claw acp status returns kind:unknown (14th catch-all occurrence).
2026-05-11 21:01:24 +09:00
YeonGyu-Kim
19aaf9d05e docs(roadmap): add #442 — agents require TOML format, .md files silently dropped
Pinpoint: claw-code only loads .toml files from .claw/agents/. Claude
Code uses .md with YAML frontmatter — schema divergence. Source code
at commands/src/lib.rs:3378 silently skips non-.toml extensions with
no warning. Help text omits the format requirement. Same silent-drop
pattern as #440 (MCP) and #441 (hooks). Also: .claude/agents/ never
discovered; required fields undocumented; no scaffolding command.
2026-05-11 20:31:50 +09:00
YeonGyu-Kim
8499599b70 docs(roadmap): add #441 — hooks schema diverges from Claude Code documented format
Pinpoint: claw-code expects {hooks:{PreToolUse:['cmd-string']}} while
Claude Code docs specify {hooks:{PreToolUse:[{matcher,hooks:[{type,command}]}]}}.
Users copy-pasting from Claude Code docs get the cryptic 'must be an
array of strings, got an array' error. PR #3000 already addresses
this but is conflicting and unmerged. Siblings: unknown hook event
rejects entire hooks config (#440 pattern); first-error-only halting;
kind:unknown catch-all (13th occurrence).
2026-05-11 20:01:33 +09:00
YeonGyu-Kim
86ff83c233 docs(roadmap): add #440 — one invalid mcpServers entry blocks ALL valid servers
Pinpoint: .claw.json with one valid mcpServers entry + one missing-command
entry → mcp list returns configured_servers:0, servers:[]. The valid
server is silently dropped because parser halts on first error.
Five invalid entries in the same file produce only ONE error message
(first one); user must iterate N times to discover all problems.
Violates ROADMAP product principle #5 (partial success first-class).
2026-05-11 19:31:23 +09:00
YeonGyu-Kim
bd126905db docs(roadmap): add #439 — ancestor CLAUDE.md walk causes silent context bleed
Pinpoint: from /tmp/proj/sub/deep, claw walks ALL ancestors loading
every CLAUDE.md up to $HOME boundary. Stale /tmp/CLAUDE.md silently
bleeds into every workspace under /tmp/*. No --no-parent-memory flag,
no .claw-root boundary marker, no per-file attribution in status JSON.
Git-root is NOT a discovery boundary either.
2026-05-11 19:01:50 +09:00
YeonGyu-Kim
f4a9674086 docs(roadmap): add #438 — memory file discovery only finds CLAUDE.md, ignores AGENTS.md + CLAW.md
Pinpoint: claw-code reads CLAUDE.md (inherited from upstream Claude Code)
but silently ignores AGENTS.md (industry convention used by OpenCode/
Codex/Aider/Cursor/Continue.dev) and CLAW.md (project's own brand name).
Users with mixed-tool workflows maintaining a shared AGENTS.md see
memory_file_count stay low with no warning.
2026-05-11 18:31:17 +09:00
YeonGyu-Kim
d3a982dda9 docs(roadmap): add #437 — version JSON missing is_dirty/branch/commit_date/rustc; git_sha truncated
Pinpoint: claw version --output-format json omits is_dirty, branch,
commit_date, commit_timestamp, rustc_version. git_sha is 7-char short
form (collision risk + no git rev-parse round-trip). executable_path
leaks compile-host path /tmp/claw-dog-0530/... Sibling: prose 'message'
field still duplicates structured data (#391 supposedly fixed).
2026-05-11 18:00:57 +09:00
YeonGyu-Kim
8cf628a53c docs(roadmap): add #436 — init template sets permissions.defaultMode:dontAsk + empty .claw/
Pinpoint: claw init creates .claw.json with permissions.defaultMode:
dontAsk (disabled permission prompts by default) — compounds with #428.
Sibling: .claw/ artifact created as an empty directory (no
settings.json template inside). When .claw/ pre-exists, init skips
the entire artifact without materializing expected sub-content.
2026-05-11 17:31:17 +09:00
YeonGyu-Kim
b8f989b605 docs(roadmap): add #435 — --resume failure: exit 0 text/1 json + creates partition dir
Pinpoint: claw --resume latest on fresh workspace exits 0 in text mode
but 1 in JSON mode (same input, different outcome). Side effect:
.claw/sessions/<fingerprint>/ created on disk despite failure. Siblings:
claw --compact alone drops into REPL; claw --compact 'hello' rejects
shorthand prompt; kind:unknown catch-all yet again.
2026-05-11 17:01:30 +09:00
YeonGyu-Kim
e29010ed48 docs(roadmap): add #434 — POSIX -- separator not recognized; shorthand prompts can't start with dash
Pinpoint: claw -- 'anything' returns 'unknown option: --' with the
misleading 'Did you mean -V?' hint. Every other major CLI (cargo,
git, gh, kubectl, grep) honors POSIX -- as end-of-flags. Shorthand
prompt mode cannot accept any TEXT starting with - or --, forcing
users to remember the explicit 'prompt' verb.
2026-05-11 16:31:21 +09:00
YeonGyu-Kim
0e5f695844 docs(roadmap): add #433 — repeated --output-format silent override + case-sensitive enum
Pinpoint: --output-format json --output-format text silently picks
text, no warning, scripts that compose flags get wrong format.
Siblings: JSON (uppercase) rejected as kind:unknown; CLAW_OUTPUT_FORMAT
env silently ignored; RUST_LOG/CLAW_LOG undocumented.
2026-05-11 16:01:05 +09:00
YeonGyu-Kim
ce39d5c598 docs(roadmap): add #432 — --allowedTools naming inconsistency + missing-value parser bug
Pinpoint: tool-name registry mixes snake_case/PascalCase/UPPERCASE
in single error message; undocumented CamelCase->snake_case alias map
(Read->read_file etc.); missing flag value consumes next positional
(subcommand swallowed). kind:unknown catch-all yet again.
2026-05-11 15:31:25 +09:00
YeonGyu-Kim
fad53e2df9 docs(roadmap): add #431 — skills uninstall requires creds; install error leaks OS string
Pinpoint: claw skills uninstall <bogus> requires API creds despite
being a pure local filesystem op. Siblings: skills install <bogus>
returns raw 'No such file or directory (os error 2)' with kind:unknown;
skills install (no args) treats valid subcommand as unknown action;
agents create doesn't exist (no scaffolding command for agents).
2026-05-11 15:03:45 +09:00
YeonGyu-Kim
328fd114ff docs(roadmap): add #430 — dump-manifests requires upstream TS source; export PATH dropped
Pinpoint: dump-manifests --help advertises 'emit manifests for current
cwd' but actually requires CLAUDE_CODE_UPSTREAM env or --manifests-dir
pointing at upstream TypeScript Claude Code source. Unusable for users
without the original TS repo. Siblings: derivative-work disclosure leak,
kind drift between manifests-dir override path vs default path, export
<PATH> positional silently dropped before validation.
2026-05-11 15:01:37 +09:00
YeonGyu-Kim
075c214439 docs(roadmap): add #429 — no global --cwd flag; misleading 'Did you mean --acp' hint
Pinpoint: claw --cwd PATH rejected as unknown option globally. --cwd
exists ONLY for system-prompt subcommand. Every other major CLI
(cargo -C, git -C, npm --prefix) has global cwd override. Sibling:
'Did you mean --acp?' hint algorithm matches first-character not
semantic category — --acp is ACP/Zed integration, unrelated to cwd.
2026-05-11 14:31:31 +09:00
YeonGyu-Kim
ec882f4c88 docs(roadmap): add #428 — default permission_mode is danger-full-access
Pinpoint: claw runs with full filesystem+network+tool access by default,
no opt-in flag, doctor stays silent. Fix shape: change default to
workspace-write, require explicit opt-in for danger-full-access, add
permissions check to doctor that warns when mode source is default.
Siblings: kind:unknown for invalid_permission_mode (typed-error
catch-all bug), --skip-permissions flag rejected (Claude Code parity).
2026-05-11 14:00:58 +09:00
YeonGyu-Kim
7204844982 docs(roadmap): add #427 — subcommand --help requires auth/config; resume hits auth gate
Pinpoint: claw resume --help, session --help, compact --help all hit
missing_credentials without producing usage. resume <bogus-id> also
requires API creds instead of local-first session_not_found lookup.
Sibling: exit code 0 on these error envelopes (parity bug from #422).
2026-05-11 13:31:38 +09:00
YeonGyu-Kim
1fecdf096b docs(roadmap): add #426 — ANTHROPIC_MODEL env bypasses invalid_model validator
Pinpoint: --model rejects 'bogus-model-xyz' as invalid_model_syntax
but ANTHROPIC_MODEL=bogus-model-xyz returns status:ok with the bogus
value. Siblings: opus alias resolves to 4-6 not 4-7 (current frontier),
CLAW_MODEL and ANTHROPIC_DEFAULT_MODEL silently ignored.
2026-05-11 13:01:08 +09:00
YeonGyu-Kim
3730b459a2 docs(roadmap): add #425 — config precedence undocumented; deprecation warning 4×
Pinpoint: .claw/settings.json silently wins over .claw.json. config
--output-format json reports both loaded:true with no precedence_rank
or per-key attribution. Sibling: deprecation warning fired 3× in
#424's probe, now 4× — config load count regressing upward.
2026-05-11 12:31:16 +09:00
YeonGyu-Kim
d7dbe951ce docs(roadmap): add #424 — bare canonical model names rejected; stale 4-6 suggestion
Pinpoint from Jobdori dogfood. claw --model claude-opus-4-7 returns
invalid_model_syntax error suggesting 'claude-opus-4-6' (one model
behind). Sibling: settings.json deprecation warning repeats 3x per
status invocation (config loaded 3x).
2026-05-11 12:01:33 +09:00
YeonGyu-Kim
6c0c305a4b docs(roadmap): add #423 — claw prompt ignores stdin; kind:unknown for missing arg
Pinpoint from Jobdori dogfood. `echo X | claw prompt` returns
'prompt subcommand requires a prompt string' instead of reading stdin.
Sibling: error kind is 'unknown' not typed 'missing_argument'.
2026-05-11 11:31:12 +09:00
YeonGyu-Kim
3c563fa1dc docs(roadmap): add #422 — unknown subcommand silently sent as chat prompt
Pinpoint from Jobdori dogfood. claw <bogus> with valid creds reaches
Anthropic API as a chat message. Sibling exit-code parity bug:
api_http_error envelope exits 0 while cli_parse exits 1.
2026-05-11 11:01:09 +09:00
YeonGyu-Kim
6aa4b85c95 docs(roadmap): add #421 — JSON cwd leaks /private symlink canonicalization on macOS
Pinpoint from Jobdori dogfood on b98b9a71 in response to Clawhip nudge.
status.workspace.cwd, mcp.working_directory all canonicalize cwd
(intentional for #151 session bleed) but leak the result into JSON
output, breaking string-match automation across macOS symlinks.
2026-05-11 10:32:18 +09:00
Jobdori
b98b9a712e fix(fmt): expand Thinking struct literals to pass cargo fmt 2026-05-09 15:52:54 +09:00
YeonGyu-Kim
357629dbd9 fix(skills): route help flags to local dispatch + fix push_output_block test arity
Cherry-pick from Yeachan-Heo's #2945 with manual conflict resolution:
- classify_skills_slash_command now catches -h/--help anywhere in args
- Restored pending_thinking parameter in push_output_block test calls

Co-authored-by: Yeachan-Heo <bellman@ultraworkers.dev>
2026-05-06 15:41:25 +09:00
YeonGyu-Kim
12b65f9807 Merge pull request #3001 from ultraworkers/fix/batch-issue-fixes
fix: REPL display, /compact panic, identity leak, DeepSeek reasoning, thinking blocks
2026-05-06 15:33:03 +09:00
YeonGyu-Kim
75c08bc982 fix: REPL display, /compact panic, identity leak, DeepSeek reasoning, thinking blocks
Five interrelated fixes from parallel Hephaestus sessions:

1. fix(repl): display assistant text after spinner (#2981, #2982, #2937)
   - Added final_assistant_text() call after run_turn spinner completes
   - REPL now shows response text like run_prompt_json does

2. fix(compact): handle Thinking content blocks (#2985)
   - Added ContentBlock::Thinking variant throughout compact summarizer
   - Prevents panic when /compact encounters thinking blocks

3. fix(prompt): provider-aware model identity (#2822)
   - New ModelFamilyIdentity enum (Claude vs Generic)
   - Non-Anthropic models no longer say 'I am Claude'
   - model_family_identity_for() detects provider and sets identity

4. fix(openai): preserve DeepSeek reasoning_content (#2821)
   - Stream parser now captures reasoning_content from OpenAI-compat
   - Emits ThinkingDelta/SignatureDelta events for reasoning models
   - Thinking blocks included in conversation history for re-send

5. feat(runtime): Thinking block support across codebase
   - AssistantEvent::Thinking variant in conversation.rs
   - ContentBlock::Thinking in session serialization
   - Thinking-aware compact summarization
   - Tests for thinking block ordering and content

Closes #2981, #2982, #2937, #2985, #2822, #2821
2026-05-06 15:32:34 +09:00
YeonGyu-Kim
553d25ee50 docs(roadmap): consolidate items #407-#420 from 12 conflicting PRs
Batch-appended ROADMAP entries from PRs #2950, #2951, #2953, #2954,
#2955, #2956, #2957, #2959, #2960, #2962, #2963, #2964.

All PRs were CI-green but conflicting on ROADMAP.md due to serial
appends to the same file.
2026-05-06 15:00:34 +09:00
YeonGyu-Kim
5be173edf6 Merge pull request #2986 from andhai/pr/glob-search-prune-heavy-dirs
runtime: prune heavy directories during glob searches
2026-05-06 14:53:37 +09:00
YeonGyu-Kim
28998422e2 Merge pull request #2984 from andhai/pr/openai-token-limit-hardening
openai: harden token-limit handling and default output-token caps
2026-05-06 14:53:24 +09:00
YeonGyu-Kim
b4733b67a6 Merge pull request #2949 from Yeachan-Heo/fix/export-help-json
Fix export help JSON output
2026-05-06 14:52:09 +09:00
YeonGyu-Kim
ab44985916 fix(scripts): inject GIT_SHA in dogfood-build.sh so provenance check passes (#2996)
Scripts-only PR — CI intentionally does not run for scripts/ (path filter covers rust/** and docs only). Manually verified: dogfood-build.sh builds, injects GIT_SHA, verifies provenance, and documents CLAW_CONFIG_HOME isolation. Zero stderr with isolated config.
2026-05-05 07:02:13 +09:00
YeonGyu-Kim
d074d1c046 fix(mcp): exit 1 when JSON envelope contains ok:false (#2995)
* fix(mcp): exit 1 when JSON envelope contains ok:false

mcp info, mcp describe, and mcp list-filter all return
{"action":"error","ok":false,...} but previously exited 0,
requiring automation callers to inspect the envelope field.

After this fix: print_mcp detects ok:false in the rendered JSON
value and calls process::exit(1) after printing, so the exit code
reflects the semantic error in the envelope.

Unaffected: mcp list, mcp show, mcp help all have no ok field and
continue to exit 0 (they are not error paths).

Closes ROADMAP #68 (partial — agents bogus/mcp show nonexistent
found:false remain exit:0 as they use different envelope shapes).

* feat(scripts): add dogfood-build.sh — build from checkout and verify provenance

Builds claw from the current HEAD, then checks that the binary's
git_sha matches git rev-parse --short HEAD. Exits non-zero if the
binary is stale or provenance is opaque (git_sha: null).

Usage:
  CLAW=$(bash scripts/dogfood-build.sh)   # fail-fast if stale
  $CLAW version --output-format json       # provenance confirmed

Addresses ROADMAP #69: dogfooders using a stale installed binary
cannot attribute behavior to specific commits. This script makes
dogfood round zero unambiguous.

Also documents the safe workaround for contributors who have a
stale system-installed binary.
2026-05-05 06:09:11 +09:00
YeonGyu-Kim
caeac828b5 fix(permissions): return guidance for multi-word forms instead of falling through to LLM (#2994)
claw permissions list / claw permissions allow <tool> / claw permissions deny <tool>
all fell through to the prompt/LLM path because parse_subcommand had no
arm for "permissions". The single-word bare form was already intercepted
by bare_slash_command_guidance, but any form with rest.len() > 1 bypassed
the single-word guard and landed in the _other => CliAction::Prompt branch.

Fix: add a "permissions" arm in parse_subcommand that returns a structured
guidance Err so all multi-word forms get the same exit:1 + JSON error as
the bare single-word form, without any LLM call or session creation.

Verified: all invocation forms (bare, list, read-only, workspace-write,
allow/deny <tool>) exit 1 with kind:unknown guidance JSON. Zero sessions.
2026-05-05 05:35:50 +09:00
YeonGyu-Kim
85435ad4b5 fix(plugins): route plugin and marketplace aliases through local handler (#2993)
claw plugin list / claw marketplace / claw marketplace list all fell
through to the prompt/LLM path because parse_subcommand only matched
"plugins" (the primary name) while the canonical spec aliases
"plugin" and "marketplace" were unhandled.

This manifested as auth errors and session creation on direct
invocation — dogfood confirmed Gaebal's binary created one session
via plugin prompt fallback.

Fix: extend the plugins arm in parse_subcommand to also match
"plugin" | "marketplace" so all three forms route to the same
CliAction::Plugins without network calls or session creation.

Verified: all six forms (bare + list subcommand for each name) return
kind:plugin JSON, exit 0, and create zero sessions.

Closes ROADMAP #55 partial (plugins/marketplace bypass complete).
2026-05-05 05:16:00 +09:00
YeonGyu-Kim
5eb4b8a944 fix(mcp): return typed error JSON for unsupported actions (info/describe/list-filter) (#2989)
`claw mcp info nonexistent --output-format json` and
`claw mcp list nonexistent --output-format json` fell through to
the generic help renderer, returning an opaque envelope with only
`unexpected` set — no machine-readable error_kind.

Fix:
- Add typed guards in render_mcp_report_for/_json_for for:
  - `list <filter>`: list accepts no filter argument
  - `info <name>` / `describe <name>`: suggest `mcp show`
- New render_mcp_unsupported_action_text/json helpers emit
  `ok:false`, `error_kind:"unsupported_action"`, `hint`, `requested_action`
- `mcp show`, `mcp list`, `mcp help` existing paths unchanged

Test: mcp_unsupported_actions_return_typed_error_not_generic_help
asserts kind=="mcp", ok==false, error_kind=="unsupported_action"
for info/list-filter/describe paths.

Pinpoint: ROADMAP #504
2026-05-05 05:13:07 +09:00
YeonGyu-Kim
65aa559733 fix: support /plugins slash command in resume mode (#2973)
* fix: support /plugins slash command in resume mode

Move SlashCommand::Plugins out of the 'unsupported resumed slash
command' catch-all and add a handler arm in run_resume_command that
calls handle_plugins_slash_command for list/help actions.

Mutation actions (install/uninstall/enable/disable) are rejected with
a clear error since there is no runtime to reload in resume mode.

Add /plugins coverage to resumed_inventory_commands test in
output_format_contract.rs: kind, action, reload_runtime, target.

Before: claw --resume session.jsonl /plugins --output-format json
-> {error: 'unsupported resumed slash command', type: 'error'}, exit 1

After: claw --resume session.jsonl /plugins --output-format json
-> {kind: 'plugin', action: 'list', ...}, exit 0

* style: cargo fmt line wrap in run_resume_command plugins handler

* fix: block /plugins update in resume mode, fix comment

Address REQUEST_CHANGES from OMX review:
1. Add 'update' to the blocked mutation actions in resume mode
   (previously only install/uninstall/enable/disable were blocked)
2. Fix comment: 'Only list is supported' instead of 'Only list/help'
   since /plugins help doesn't actually parse as a valid action

* style: cargo fmt after conflict resolution
2026-05-05 04:55:39 +09:00
YeonGyu-Kim
ac8a24b30b fix(config): emit section and section_value in JSON output for config subcommands (#2990)
`claw config model --output-format json` and all other section subcommands
(`env`, `hooks`, `plugins`) returned identical output with no section field
— the section arg was parsed but discarded (_section parameter).

Fix: render_config_json now:
- Passes section through to handler
- Looks up the section value via runtime_config.get(), converting the
  internal JsonValue to serde_json::Value via render()+parse
- Emits `section` (string) and `section_value` (JSON value or null)
  in the response envelope
- Returns ok:false + error for unsupported section tokens

Test: config_section_json_emits_section_and_value asserts:
- No section field when no section arg
- section + section_value fields present for all known sections
- ok:false + error for unknown section

Pinpoint: ROADMAP #126
2026-05-05 04:50:33 +09:00
YeonGyu-Kim
94b80a05d3 fix(skills): route show/info/list-filter to local, not model invoke (#2988)
`claw skills show <name>`, `claw skills info <name>`, and
`claw skills list <filter>` were all falling through to
SkillSlashDispatch::Invoke, which spawned a real model session,
consumed tokens, and created session files.

Root cause: classify_skills_slash_command had no guards for
these discovery prefixes; every non-reserved arg became Invoke.

Fix:
- Add "show", "info" as Local-only bare tokens
- Add starts_with guards for "show ", "info ", "list " args
- handle_skills_slash_command: filter skill list by name/substring
  for show/info/list-filter paths (no model call, no session)
- handle_skills_slash_command_json: same structured filtering

Test: skills_show_and_list_filter_do_not_invoke_model asserts
  classify_skills_slash_command returns Local for all discovery
  patterns and still returns Invoke for bare skill names.

Pinpoint: ROADMAP #502
2026-05-05 04:50:30 +09:00
YeonGyu-Kim
9b97c4d832 fix(tests): isolate CLAW_CONFIG_HOME in resumed_status JSON test (#2992)
resumed_status_command_emits_structured_json_when_requested was reading
the real ~/.claw/settings.json, causing loaded_config_files to be 1
instead of the expected 0 on machines with user config present.

Root cause: unlike other tests (e.g. resumed_config_command_loads_settings_files),
this test did not pass an isolated CLAW_CONFIG_HOME env var to run_claw,
so claw fell back to the real HOME and loaded the developer's settings file.

Fix: create a temp config-home dir and pass it as CLAW_CONFIG_HOME via
run_claw_with_env. This gives the assertion a clean 0-file baseline.

Unblocks PRs #2973, #2988, #2990 which all failed this same test on main.

Ref: ROADMAP #65
2026-05-05 04:49:46 +09:00
YeonGyu-Kim
1206f4131d fix(resume): emit structured JSON for /agents --output-format json (#2987)
Resumed /agents --output-format json was returning a human-readable
text render wrapped in a JSON envelope field instead of the actual
structured agent list. The run_resume_command handler was calling
handle_agents_slash_command (text) for the json field instead of
handle_agents_slash_command_json.

Fix: use handle_agents_slash_command_json for the json outcome field,
matching the pattern already used by /skills and /plugins.

Test: extended resumed_inventory_commands_emit_structured_json_when_requested
to cover /agents, asserting kind=="agents", action=="list",
agents is an array, and count is a number (not a text render).
2026-05-05 04:20:52 +09:00
YeonGyu-Kim
c99330372c fix(version): add build_date and executable_path to version JSON output
`claw version --output-format json` was missing build_date and
executable_path, making it impossible to identify which binary is
running or correlate it with a specific build/commit.

Fix: version_json_value() now includes:
- build_date: compile-time BUILD_DATE env (already in text output)
- executable_path: std::env::current_exe() at runtime

Test: version_emits_json_when_requested extended to assert both fields
are strings in the JSON envelope.

Pinpoint: ROADMAP #507
2026-05-05 04:20:12 +09:00
Andreas Haida
9a512633a5 Cap OpenAI default output tokens using model metadata 2026-05-03 22:16:12 +02:00
Andreas Haida
6ac13ffdad Handle OpenAI token-limit errors as context-window failures 2026-05-03 22:16:12 +02:00
Andreas Haida
482681cdfe Prune heavy directories during glob searches 2026-05-03 22:13:58 +02:00
YeonGyu-Kim
8e45f1850c test(output_format_contract): add plugins json coverage to inventory_commands test (#2972)
Add four assertions to inventory_commands_emit_structured_json_when_requested:
- kind == "plugin"
- action == "list"
- reload_runtime is boolean
- target is null when no plugin is targeted

Closes the only major --output-format json surface with zero contract
coverage. All other surfaces (agents, mcp, skills, status, sandbox,
doctor, help, version, acp, bootstrap-plan, system-prompt, init, diff,
config) already had test assertions.
2026-05-01 06:03:31 +09:00
YeonGyu-Kim
57096b0a1a docs(roadmap): add no-session kind drift item
Adds ROADMAP #422 documenting the concrete export/resume no-session ErrorKind drift.
2026-05-01 02:46:12 +09:00
Yeachan-Heo
51b9e6b37f Fix export help JSON output 2026-04-30 09:04:11 +00:00
Bellman
e939777f92 Merge pull request #2921 from ultraworkers/docs/roadmap-381-cache-help-json-hangs
docs(roadmap): add #381 — cache help json hangs
2026-04-30 12:09:06 +09:00
Yeachan-Heo
1093e26792 Document cache help JSON hang
Constraint: ROADMAP-only dogfood follow-up for 03:00 nudge on rebuilt claw git_sha d95b230c

Rejected: implementation change to cache help; request was one concrete follow-up if no backlog item
Confidence: high after repeated bounded samples plus version JSON responsiveness sanity check
Scope-risk: narrow
Directive: Continue preflight help hang coverage with distinct cache surface pinpoint
Tested: repeated timeout --kill-after=1s 8s ./rust/target/debug/claw cache --help --output-format json; timeout --kill-after=1s 8s ./rust/target/debug/claw version --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-30 03:06:16 +00:00
Bellman
44cca2054d Merge pull request #2918 from ultraworkers/docs/roadmap-380-tokens-help-json-hangs
docs(roadmap): add #380 — tokens help json hangs
2026-04-30 11:38:50 +09:00
Yeachan-Heo
6dc7b26d82 Document tokens help JSON hang
Constraint: ROADMAP-only dogfood follow-up for 02:30 nudge on rebuilt claw git_sha d95b230c

Rejected: implementation change to tokens help; request was one concrete follow-up if no backlog item
Confidence: high after repeated bounded samples plus version JSON responsiveness sanity check
Scope-risk: narrow
Directive: Continue cost/token preflight help coverage with a distinct tokens surface pinpoint
Tested: repeated timeout 8 ./rust/target/debug/claw tokens --help --output-format json; timeout 8 ./rust/target/debug/claw version --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-30 02:35:19 +00:00
Bellman
a0bd406c8f Merge pull request #2915 from ultraworkers/docs/roadmap-358-cost-help-json-hangs
docs(roadmap): add #358 — cost help json hangs
2026-04-30 11:32:32 +09:00
Yeachan-Heo
b62646edfe Document cost help JSON hang
Constraint: ROADMAP-only dogfood follow-up for 02:00 nudge on rebuilt claw git_sha d95b230c

Rejected: implementation change to cost help; request was one concrete follow-up if no backlog item
Confidence: high after repeated bounded samples plus version JSON responsiveness sanity check
Scope-risk: narrow
Directive: Continue help surface coverage after #356/#357 but preserve exact evidence only
Tested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; repeated timeout 8 ./rust/target/debug/claw cost --help --output-format json; timeout 8 ./rust/target/debug/claw version --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-30 02:03:20 +00:00
Bellman
d95b230cae Merge pull request #2912 from ultraworkers/docs/roadmap-357-doctor-help-json-plain-text
docs(roadmap): add #357 — doctor help json emits plain text
2026-04-30 11:01:19 +09:00
Yeachan-Heo
f48f156754 Document doctor help JSON plain-text fallback
Constraint: ROADMAP-only dogfood follow-up for 01:30 nudge on rebuilt claw git_sha 52a909ce

Rejected: implementation change to doctor help; request was one concrete follow-up if no backlog item
Confidence: high after repeated bounded samples plus status help plain-text sanity check
Scope-risk: narrow
Directive: Replaces invalid hang PR #2911 with verified help JSON-format fallback gap
Tested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; repeated timeout 8 ./rust/target/debug/claw doctor --help --output-format json; timeout 8 ./rust/target/debug/claw status --help --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-30 01:31:48 +00:00
Bellman
52a909cebe Merge pull request #2908 from ultraworkers/docs/roadmap-356-status-help-json-plain-text
docs(roadmap): add #356 — status help json emits plain text
2026-04-30 10:30:47 +09:00
Yeachan-Heo
c4c618e476 Document status help JSON plain-text fallback
Constraint: ROADMAP-only dogfood follow-up for 01:00 nudge on rebuilt claw git_sha 74338dc6

Rejected: implementation change to status help; request was one concrete follow-up if no backlog item
Confidence: high after repeated bounded samples plus version JSON sanity check
Scope-risk: narrow
Directive: Replaces invalid hang PR #2907 with verified help JSON-format fallback gap
Tested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; repeated timeout 8 ./rust/target/debug/claw status --help --output-format json; timeout 8 ./rust/target/debug/claw version --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-30 01:02:15 +00:00
Bellman
74338dc635 Merge pull request #2904 from ultraworkers/docs/roadmap-355-session-json-help-list-hangs
docs(roadmap): add #355 — session json help/list hangs
2026-04-30 10:01:09 +09:00
Yeachan-Heo
c092cf7fef Document session JSON help/list hang
Constraint: ROADMAP-only dogfood follow-up for 00:30 nudge on rebuilt claw git_sha 8e24f304

Rejected: implementation change to session command dispatch; request was one concrete follow-up if no backlog item
Confidence: high after repeated bounded session list samples plus session help bounded probe also hung/no bytes
Scope-risk: narrow
Directive: Switch from memory command introspection to session command introspection clawability
Tested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; repeated timeout 8 ./rust/target/debug/claw session list --output-format json; timeout 8 ./rust/target/debug/claw session help --output-format json/killed after no bytes; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-30 00:32:26 +00:00
Bellman
8e24f3049e Merge pull request #2901 from ultraworkers/docs/roadmap-354-memory-list-hangs
docs(roadmap): add #354 — memory json help/list hangs
2026-04-30 09:30:57 +09:00
Yeachan-Heo
71d8e7b925 Document memory JSON help/list hang
Constraint: ROADMAP-only dogfood follow-up for 00:00 nudge on rebuilt claw git_sha 19947545

Rejected: implementation change to memory command dispatch; request was one concrete follow-up if no backlog item
Confidence: high after bounded memory list samples plus memory help bounded sanity also hung
Scope-risk: narrow
Directive: Switch from plugin lifecycle repetition to memory command introspection clawability
Tested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; timeout 8 ./rust/target/debug/claw memory list --output-format json samples; timeout 8 ./rust/target/debug/claw memory help --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-30 00:02:58 +00:00
Bellman
19947545e2 Merge pull request #2899 from ultraworkers/docs/roadmap-353-plugins-uninstall-stderr-only
docs(roadmap): add #353 — plugins uninstall json error is stderr-only
2026-04-30 09:01:02 +09:00
Yeachan-Heo
f7b2d8d6fe Document plugins uninstall JSON stderr-only not-found
Constraint: ROADMAP-only dogfood follow-up for 23:30 nudge on rebuilt claw git_sha 6f92e54d

Rejected: implementation change to plugin lifecycle uninstall; request was one concrete follow-up if no backlog item
Confidence: high after repeated bounded samples plus prompt list sanity check
Scope-risk: narrow
Directive: Replaces invalid hang PR #2897 with verified stderr-only JSON-mode gap
Tested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; repeated timeout 8 ./rust/target/debug/claw plugins uninstall does-not-exist --output-format json; timeout 8 ./rust/target/debug/claw plugins list --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-29 23:31:56 +00:00
Bellman
6f92e54dc0 Merge pull request #2895 from ultraworkers/docs/roadmap-352-plugins-update-stderr-only
docs(roadmap): add #352 — plugins update json error is stderr-only
2026-04-30 08:30:58 +09:00
Yeachan-Heo
31d9198a02 Document plugins update JSON stderr-only not-found
Constraint: ROADMAP-only dogfood follow-up for 23:00 nudge on rebuilt claw git_sha 5eb1d7d8

Rejected: implementation change to plugin lifecycle update; request was one concrete follow-up if no backlog item
Confidence: high after repeated bounded samples plus prompt list sanity check
Scope-risk: narrow
Directive: Replaces invalid hang PR #2894 with verified stderr-only JSON-mode gap
Tested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; repeated timeout 8 ./rust/target/debug/claw plugins update does-not-exist --output-format json; timeout 8 ./rust/target/debug/claw plugins list --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-29 23:02:03 +00:00
Bellman
5eb1d7d824 Merge pull request #2892 from ultraworkers/docs/roadmap-351-plugins-disable-stderr-only
docs(roadmap): add #351 — plugins disable json error is stderr-only
2026-04-30 08:00:56 +09:00
Yeachan-Heo
3b03375e69 Document plugins disable JSON stderr-only not-found
Constraint: ROADMAP-only dogfood follow-up for 22:30 nudge on rebuilt claw git_sha 0f9e8915

Rejected: implementation change to plugin lifecycle mutation; request was one concrete follow-up if no backlog item
Confidence: high after repeated bounded samples plus prompt list sanity check
Scope-risk: narrow
Directive: Replaces invalid hang PR #2891 with verified stderr-only JSON-mode gap
Tested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; repeated timeout 8 ./rust/target/debug/claw plugins disable does-not-exist --output-format json; timeout 8 ./rust/target/debug/claw plugins list --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-29 22:32:14 +00:00
Bellman
0f9e8915be Merge pull request #2887 from ultraworkers/docs/roadmap-350-plugins-enable-missing-hangs
docs(roadmap): add #350 — plugins enable missing target hangs
2026-04-30 07:30:51 +09:00
Yeachan-Heo
ab95b75fcd Document plugins enable missing-target hang
Constraint: ROADMAP-only dogfood follow-up for 22:00 nudge on rebuilt claw git_sha ee44ff98

Rejected: implementation change to plugin lifecycle mutation; request was one concrete follow-up if no backlog item
Confidence: high after repeated bounded samples plus prompt list sanity check
Scope-risk: narrow
Directive: Keep supported lifecycle missing-target hang distinct from #348 list schema and #349 unsupported show action
Tested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; repeated timeout 8 ./rust/target/debug/claw plugins enable does-not-exist --output-format json; timeout 8 ./rust/target/debug/claw plugins list --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-29 22:02:04 +00:00
Bellman
ee44ff984d Merge pull request #2886 from ultraworkers/docs/roadmap-349-plugins-show-unsupported-success
docs(roadmap): add #349 — plugins unsupported action returns success-shaped json
2026-04-30 07:00:56 +09:00
Yeachan-Heo
2ab26df4bd Document plugins unsupported action success-shaped JSON
Constraint: ROADMAP-only dogfood follow-up for 21:30 nudge on rebuilt claw git_sha a2a38df9

Rejected: implementation change to plugin action dispatch; request was one concrete follow-up if no backlog item
Confidence: high after repeated bounded samples
Scope-risk: narrow
Directive: Replaces invalid hang PR #2885 with verified unsupported-action classification gap
Tested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; repeated timeout 8 ./rust/target/debug/claw plugins show does-not-exist --output-format json; timeout 8 ./rust/target/debug/claw plugins list --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-29 21:32:19 +00:00
Bellman
a2a38df9b8 Merge pull request #2883 from ultraworkers/docs/roadmap-348-plugins-list-prose-only
docs(roadmap): add #348 — plugins list json is prose-only
2026-04-30 06:31:11 +09:00
Yeachan-Heo
fd90c9fe67 Document plugins list prose-only JSON inventory
Constraint: ROADMAP-only dogfood follow-up for 21:00 nudge on rebuilt claw git_sha cca6f682

Rejected: implementation change to plugin list serializer; request was one concrete follow-up if no backlog item
Confidence: high after repeated bounded samples
Scope-risk: narrow
Directive: Keep plugin inventory schema issue distinct from broad help JSON opacity
Tested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; repeated timeout 8 ./rust/target/debug/claw plugins list --output-format json; ./rust/target/debug/claw plugins help --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-29 21:02:04 +00:00
Bellman
cca6f6829c Merge pull request #2881 from ultraworkers/docs/roadmap-347-mcp-show-missing-status-ok
docs(roadmap): add #347 — mcp show missing server reports status ok
2026-04-30 06:01:08 +09:00
Yeachan-Heo
c77d1a87e1 Document mcp show missing status contract gap
Constraint: ROADMAP-only dogfood follow-up for 20:30 nudge on rebuilt claw git_sha ee41b266

Rejected: implementation change to MCP show status schema; request was one concrete follow-up if no backlog item
Confidence: high after bounded successful repro
Scope-risk: narrow
Directive: Replaces invalid hang/nondeterminism PRs with verified status contract gap
Tested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; ./rust/target/debug/claw mcp show does-not-exist --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-29 20:33:42 +00:00
Bellman
ee41b266d3 Merge pull request #2877 from ultraworkers/docs/roadmap-346-agents-show-help-fallback
docs(roadmap): add #346 — agents show falls back to help json
2026-04-30 05:30:50 +09:00
Yeachan-Heo
ca92c695f4 Document agents show help fallback gap
Constraint: ROADMAP-only dogfood follow-up for 20:00 nudge on rebuilt claw git_sha c6c01bea

Rejected: implementation change to native-agent detail dispatch; request was one concrete follow-up if no backlog item
Confidence: high
Scope-risk: narrow
Directive: Keep agent detail fallback distinct from #328/#329 native-agent source/schema issues; closed invalid hang hypotheses first
Tested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; ./rust/target/debug/claw agents list --output-format json; ./rust/target/debug/claw agents show analyst --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-29 20:01:42 +00:00
Bellman
c6c01beaca Merge pull request #2871 from ultraworkers/docs/roadmap-345-config-sections-identical-json
docs(roadmap): add #345 — config sections return identical json
2026-04-30 04:41:58 +09:00
Yeachan-Heo
970cdc925e Document config sections identical JSON gap
Constraint: ROADMAP-only dogfood follow-up for 19:00 nudge on rebuilt claw git_sha a510f734

Rejected: implementation change to config section serialization; request was one concrete follow-up if no backlog item
Confidence: high
Scope-risk: narrow
Directive: Keep section-payload issue distinct from #344 section discovery/help
Tested: ./rust/target/debug/claw --resume latest /config env --output-format json; /config hooks; /config model; /config plugins; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-29 19:31:25 +00:00
Bellman
b2f7a3354f Merge pull request #2870 from ultraworkers/docs/roadmap-344-config-help-section-discovery
docs(roadmap): add #344 — config help lacks structured section discovery
2026-04-30 04:31:05 +09:00
Yeachan-Heo
2a08b7a35c Document config section discovery gap
Constraint: ROADMAP-only dogfood follow-up for 18:30 nudge on rebuilt claw git_sha a510f734

Rejected: implementation change to config slash dispatcher; request was one concrete follow-up if no backlog item
Confidence: high
Scope-risk: narrow
Directive: Keep /config section discovery issue distinct from #342 /commands and #343 /models correction issues
Tested: ./rust/target/debug/claw --resume latest /config help --output-format json; /config list; /config show; bare /config; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-29 19:00:29 +00:00
Bellman
a510f73422 Merge pull request #2866 from ultraworkers/docs/roadmap-343-models-dead-end-suggestion
docs(roadmap): add #343 — models suggestion dead-ends under resume json
2026-04-30 03:31:05 +09:00
Yeachan-Heo
1283c6d532 Document resume model suggestion dead-end
Constraint: ROADMAP-only dogfood follow-up for 17:00 nudge on rebuilt claw git_sha a1bfcd41

Rejected: implementation change to slash suggestion/resume-safety logic; request was one concrete follow-up if no backlog item
Confidence: high
Scope-risk: narrow
Directive: Keep /models suggestion issue distinct from #342 /commands discovery alias
Tested: ./rust/target/debug/claw --resume latest /models --output-format json; ./rust/target/debug/claw --resume latest /model --output-format json; ./rust/target/debug/claw --resume latest /tokens --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-29 17:02:18 +00:00
Bellman
a1bfcd4110 Merge pull request #2863 from ultraworkers/docs/roadmap-342-commands-discovery-alias
docs(roadmap): add #342 — commands discovery alias has no structured fallback
2026-04-30 02:01:43 +09:00
Yeachan-Heo
c49839bb1f Document slash command discovery alias gap
Constraint: ROADMAP-only dogfood follow-up for 16:30 nudge on rebuilt claw git_sha f65b2b4f

Rejected: implementation change to slash dispatcher; request was one concrete follow-up if no backlog item
Confidence: high
Scope-risk: narrow
Directive: Keep /commands discovery issue distinct from #340/#341 stderr-only envelope items
Tested: ./rust/target/debug/claw --resume latest /commands --output-format json; ./rust/target/debug/claw --resume latest /help --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-29 16:31:03 +00:00
Bellman
f65b2b4f0e Merge pull request #2861 from ultraworkers/docs/roadmap-341-tasks-json-dual-vocab
docs(roadmap): add #341 — tasks JSON error envelope uses dual vocabulary
2026-04-30 01:06:27 +09:00
Yeachan-Heo
f4b74e89dd Document why /tasks JSON errors need one stdout contract
Constraint: ROADMAP-only dogfood follow-up for 16:00 nudge on rebuilt claw git_sha 58569131
Rejected: code change in the command dispatcher | request was specifically to add one ROADMAP.md-only item
Confidence: high
Scope-risk: narrow
Directive: Keep /tasks distinct from #340; this is unsupported command stub JSON, not session help
Tested: git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-29 16:02:10 +00:00
Bellman
5856913104 Merge pull request #2859 from ultraworkers/docs/roadmap-340-session-help-json-stderr
docs(roadmap): add #340 — session help JSON error envelope goes to stderr
2026-04-30 00:54:42 +09:00
Yeachan-Heo
d45a0d2f5b Document stderr-only session help JSON contract gap
Capture the dogfood evidence as a roadmap item so the stdout JSON error-envelope contract can be fixed and regression-tested later.\n\nConstraint: User requested exactly one ROADMAP.md-only item #340 from current origin/main.\nConfidence: high\nScope-risk: narrow\nTested: git diff --check; scripts/fmt.sh --check\nNot-tested: Runtime behavior unchanged; documentation-only roadmap entry.
2026-04-29 15:31:59 +00:00
Bellman
dc47482e40 Merge pull request #2857 from ultraworkers/docs/roadmap-339-v2
docs(roadmap): add #339 — session delete not resume-safe, blocks GC automation
2026-04-30 00:26:29 +09:00
YeonGyu-Kim
9537c97231 docs(roadmap): add #339 — session delete not resume-safe, blocks GC automation 2026-04-30 00:18:28 +09:00
Bellman
f56a5afcf7 Merge pull request #2856 from ultraworkers/docs/roadmap-337-workspace-dirty-lifecycle-detail-restore
docs(roadmap): restore #337 workspace dirty lifecycle detail gap
2026-04-30 00:14:48 +09:00
Yeachan-Heo
3efaf551ed Restore roadmap GC lifecycle detail gap
Constraint: ROADMAP.md-only restore of lost #337 from PR #2852 / Jobdori dogfood evidence
Rejected: Renumbering adjacent items | preserving existing #338 and surrounding roadmap entries keeps history stable
Confidence: high
Scope-risk: narrow
Directive: Keep #337 before #338 and do not collapse the dirty-file detail requirement into the broader help/status backlog
Tested: git diff --check; scripts/fmt.sh --check
Not-tested: Product behavior changes; documentation-only change
2026-04-29 15:09:40 +00:00
Bellman
30c9b438ef Merge pull request #2853 from ultraworkers/docs/roadmap-338-help-json-field-drift
docs(roadmap): add #338 for help JSON field drift
2026-04-30 00:06:24 +09:00
Yeachan-Heo
587bb18572 docs(roadmap): add #338 for help JSON field drift
Constraint: Respond to 14:30 dogfood nudge with one direct claw-code pinpoint.\nEvidence: rebuilt actual debug binary at git_sha 24ccb59b; compared top-level help --output-format json with resume-safe /help --output-format json.\nFinding: same help surface uses message in top-level JSON and text in slash/resume JSON.\nTested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; ./rust/target/debug/claw help --output-format json; ./rust/target/debug/claw --resume latest /help --output-format json; git diff --check; scripts/fmt.sh --check.\nNot-tested: full Rust suite; roadmap-only documentation change.
2026-04-29 14:34:26 +00:00
Bellman
24ccb59bd2 Merge pull request #2851 from ultraworkers/docs/roadmap-329-slash-agents-json-opacity
docs(roadmap): add #329 for slash agents JSON opacity
2026-04-29 23:33:47 +09:00
Yeachan-Heo
0e8e75ef75 docs(roadmap): add #329 for slash agents JSON opacity
Constraint: Respond to dogfood nudge with exactly one concrete clawability pinpoint from direct claw-code use.\nEvidence: rebuilt actual debug binary at git_sha 0f7578c0; compared resume-safe /agents --output-format json with top-level claw agents --output-format json.\nFinding: slash /agents JSON only exposes kind,text while top-level agents JSON exposes structured agents[] inventory and provenance.\nTested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; ./rust/target/debug/claw --resume latest /agents --output-format json; ./rust/target/debug/claw agents --output-format json; git diff --check; scripts/fmt.sh --check.\nNot-tested: full Rust suite; roadmap-only documentation change.
2026-04-29 14:01:36 +00:00
Bellman
0f7578c064 Merge pull request #2849 from ultraworkers/docs/roadmap-328-dogfood-pinpoint
Add ROADMAP #328 for native-agent source provenance
2026-04-29 22:35:51 +09:00
Yeachan-Heo
213d406cbf Record why native-agent provenance needs dogfood follow-up
Constraint: Scope requested ROADMAP.md only with exactly one new #328 pinpoint from direct claw dogfood.\nRejected: Implementing the agents-help fix now | user requested roadmap-only evidence item.\nConfidence: high\nScope-risk: narrow\nDirective: Keep agent help source roots derived from the same loader registry as agents list; do not hand-maintain a divergent root list.\nTested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; ./rust/target/debug/claw version --output-format json; ./rust/target/debug/claw agents help --output-format json; ./rust/target/debug/claw agents --output-format json; git diff --check; scripts/fmt.sh --check\nNot-tested: Full Rust test suite; roadmap-only documentation change.
2026-04-29 13:33:23 +00:00
Bellman
ee85fed6ca Merge pull request #2847 from ultraworkers/docs/roadmap-327-dogfood-pinpoint
Add ROADMAP #327 for MCP help source mismatch
2026-04-29 22:06:45 +09:00
Yeachan-Heo
3a34d83749 Record why MCP source help needs dogfood follow-up
Constraint: Scope limited to ROADMAP.md and one new pinpoint #327 from actual rebuilt claw dogfood.
Rejected: Code fix in this branch | user requested roadmap-only filing.
Confidence: high
Scope-risk: narrow
Directive: Keep mcp help source lists derived from actual config discovery, not hard-coded partial docs.
Tested: ./rust/target/debug/claw version --output-format json; ./rust/target/debug/claw mcp --help; ./rust/target/debug/claw mcp help --output-format json; temp .claw.json mcp list proof; git diff --check; scripts/fmt.sh --check
Not-tested: Full Rust test suite, documentation-only change.
2026-04-29 13:02:27 +00:00
Bellman
981aff7c8b Merge pull request #2845 from ultraworkers/docs/roadmap-326-dogfood-pinpoint
docs(roadmap): add #326 pane inventory opacity pinpoint
2026-04-29 21:35:26 +09:00
Yeachan-Heo
c94940effa docs: add roadmap 326 pane inventory opacity 2026-04-29 12:33:36 +00:00
Bellman
b90875fa8e Merge pull request #2843 from ultraworkers/docs/roadmap-325-help-json-schema
docs(roadmap): add #325 help json schema opacity pinpoint
2026-04-29 21:05:12 +09:00
Yeachan-Heo
2567cbcc78 Pin help JSON schema opacity for automation
Document the dogfood gap where help JSON stays parseable but hides command metadata inside a prose message, so future implementation can expose machine-readable command, slash-command, and resume-safety fields.\n\nConstraint: user requested ROADMAP.md-only pinpoint for issue #325 from origin/main d607ff36.\nRejected: implementing the schema now | requested fix shape is roadmap documentation only.\nConfidence: high\nScope-risk: narrow\nDirective: keep message for humans while adding schema/versioned structured help metadata when implementing.\nTested: git diff --check; scripts/fmt.sh --check\nNot-tested: runtime CLI behavior unchanged by docs-only change
2026-04-29 12:02:14 +00:00
Bellman
d607ff3674 Merge pull request #2840 from ultraworkers/docs/roadmap-324-stale-binary-provenance
docs(roadmap): add #324 stale binary provenance pinpoint
2026-04-29 20:34:27 +09:00
Yeachan-Heo
cdf6282965 Record why stale binary provenance needs a roadmap pin
Constraint: Documentation-only follow-up from current main e7074f47 after PR #2838; edit scope limited to ROADMAP.md.\nRejected: Implementing provenance detection now | user requested roadmap entry only.\nConfidence: high\nScope-risk: narrow\nDirective: Future implementation should compare embedded build git_sha/build date to workspace HEAD/dirty state without leaking secrets.\nTested: git diff --check; scripts/fmt.sh --check\nNot-tested: Runtime provenance behavior; this commit only records the roadmap requirement.
2026-04-29 11:31:19 +00:00
Bellman
e7074f47ee Merge pull request #2838 from ultraworkers/docs/roadmap-322-323-clean
docs(roadmap): add #322 #323 — json stream corruption and session identity contradiction
2026-04-29 19:40:50 +09:00
YeonGyu-Kim
9468383b67 docs(roadmap): add #322 #323 — json stream corruption and session identity contradiction 2026-04-29 19:38:00 +09:00
Bellman
1da2781816 Merge pull request #2835 from ultraworkers/docs/roadmap-249-issue-github-oauth-opacity
docs(roadmap): add #249 issue GitHub OAuth opacity pinpoint
2026-04-29 19:31:50 +09:00
Yeachan-Heo
9037430d52 docs(roadmap): add #249 issue github oauth opacity pinpoint 2026-04-29 10:01:16 +00:00
Bellman
8e22f757d8 Merge pull request #2834 from ultraworkers/docs/roadmap-248-prompt-mode-silent-hang
docs(roadmap): add #248 prompt-mode silent-hang pinpoint
2026-04-29 18:31:48 +09:00
Yeachan-Heo
7676b376ae docs(roadmap): add #248 prompt-mode silent-hang pinpoint 2026-04-29 08:24:37 +00:00
Sigrid Jin (ง'̀-'́)ง oO
1011a83823 Merge pull request #2829 from ultraworkers/fix/issue-320-session-lifecycle-classification
Fix session lifecycle classification for idle tmux shells
2026-04-29 16:11:58 +09:00
Yeachan-Heo
1376d92064 Filter stub commands from resume-safe help
Keep claw --help's resume-safe slash command summary aligned with the interactive command list by filtering STUB_COMMANDS and adding regression coverage.
2026-04-29 03:31:34 +00:00
Yeachan-Heo
be53e04671 Classify saved sessions by live work rather than pane existence
Operator status previously treated any tmux pane in a workspace as equivalent to active work. The new classifier uses tmux pane command/path metadata as a soft signal, treats plain shells as idle, and adds dirty-worktree abandoned markers to status and session-list output for clawhip consumers.

Constraint: Keep issue #320 prototype minimal and additive without new dependencies

Rejected: Screen-scraping pane output | fragile and broader than needed for lifecycle classification

Confidence: high

Scope-risk: narrow

Tested: cargo test -p rusty-claude-cli

Tested: cargo check -p rusty-claude-cli

Not-tested: cargo clippy -p rusty-claude-cli --all-targets -- -D warnings is blocked by pre-existing commands crate clippy::unnecessary_wraps warnings
2026-04-28 13:12:37 +00:00
Yeachan-Heo
cb56dc12ab Document Rust formatting wrapper
Make scripts/fmt.sh robust to caller cwd and document it as the supported repo-root formatting entrypoint for the Rust workspace.
2026-04-28 09:38:46 +00:00
Yeachan-Heo
71686a20fc Resolve fmt wrapper path from its own directory
The formatting wrapper should remain safe when invoked through different current directories or shell contexts, so resolve the script directory before entering the Rust workspace and forwarding cargo fmt arguments.

Constraint: Wrapper must be runnable from repo root while forwarding flags like --check
Rejected: Leave relative dirname cd | less robust if invocation context changes
Confidence: high
Scope-risk: narrow
Tested: scripts/fmt.sh --check
Tested: git diff --check
2026-04-28 09:38:40 +00:00
Yeachan-Heo
07992b8a1b Make Rust formatting guidance runnable from repo root
The Rust crate layout expects formatting to run from the rust directory, so add a root-level wrapper that preserves the working command while forwarding user flags like --check. Documentation now points contributors at the wrapper instead of the misleading virtual-workspace manifest invocation.

Constraint: Root-level cargo fmt --manifest-path rust/Cargo.toml is misleading for this virtual workspace
Rejected: Document cd rust && cargo fmt directly | a root wrapper gives one stable repo-root command
Confidence: high
Scope-risk: narrow
Tested: scripts/fmt.sh --check
Tested: git diff --check
2026-04-28 09:38:08 +00:00
Yeachan-Heo
74ea754d29 Restore Rust formatting compliance
Run rustfmt from the Rust workspace so CI format checks pass without changing behavior.

Constraint: Scope is formatting-only across tracked Rust files

Confidence: high

Scope-risk: narrow

Tested: cd rust && cargo fmt --check

Tested: git diff --check
2026-04-28 09:19:16 +00:00
Yeachan-Heo
77afde768c Clarify allowed tool status handling
Reject empty --allowedTools inputs instead of treating them as an empty restriction, and surface status JSON metadata that distinguishes default unrestricted tools from flag-provided allow lists.

Confidence: high
Scope-risk: narrow
Tested: cargo test -p rusty-claude-cli rejects_empty_allowed_tools_flag -- --nocapture
Tested: cargo test -p tools allowed_tools_rejects_empty_token_lists -- --nocapture
Tested: cargo check -p rusty-claude-cli -p tools
Tested: cargo test -p rusty-claude-cli -p tools
Not-tested: full workspace cargo fmt --check is blocked by pre-existing unrelated formatting drift
2026-04-28 05:44:14 +00:00
Yeachan-Heo
6db68a2baa Expose tool permission gates as structured worker blockers
Worker boot could previously stall on an interactive MCP/tool permission prompt while readiness and startup-timeout surfaces only had generic idle/no-evidence shapes. This adds a first-class blocked lifecycle state, structured event payload, startup evidence fields, and regression coverage so callers can report the exact server/tool gate instead of pane-scraping.

Constraint: ROADMAP #200 requires tool/server identity, prompt age, and session-only versus always-allow capability in status/evidence surfaces
Rejected: Treat MCP/tool prompts as trust gates | conflates distinct prompts and loses tool identity
Rejected: Leave allow-scope as pane text only | clawhip still could not classify the blocker without scraping
Confidence: high
Scope-risk: moderate
Directive: Keep tool_permission_required distinct from trust_required; downstream claws rely on server/tool payload plus allow-scope metadata
Tested: cargo test -p runtime tool_permission
Tested: cargo fmt -p runtime -- --check && cargo clippy -p runtime --all-targets -- -D warnings && cargo test -p runtime
Tested: cargo test --workspace
Not-tested: live interactive MCP permission prompt in tmux
2026-04-27 09:28:09 +00:00
Yeachan-Heo
5b910356a2 Preserve trust boundaries during pulled follow-up
The pull brought the branch current with origin/main while replaying local follow-up work. Conflict resolution kept the roadmap/progress additions and integrated the runtime event/trust changes with upstream's newer surfaces.

The trust allowlist now treats worktree_pattern as an additional required predicate, including the missing-worktree case, so auto-trust cannot fall back to cwd-only matching when a worktree constraint was declared. The runtime formatting cleanup keeps clippy/fmt green after the merge.

Constraint: Local branch was 109 commits behind origin/main with dirty tracked follow-up work.

Rejected: Drop the autostash after conflict resolution | keeping it preserves a reversible safety backup for unrelated recovery.

Confidence: high

Scope-risk: moderate

Directive: Do not relax worktree_pattern matching without preserving the missing-worktree regression.

Tested: git diff --cached --check; cargo fmt -p runtime -- --check; cargo clippy -p runtime --all-targets -- -D warnings; cargo test -p runtime; cargo test --workspace; architect verification approved

Not-tested: Live tmux/worker auto-trust behavior outside unit/integration tests
2026-04-27 09:05:50 +00:00
32 changed files with 5701 additions and 306 deletions

View File

@@ -7,7 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Frameworks: none detected from the supported starter markers.
## Verification
- Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`
- Run Rust verification from repo root: `scripts/fmt.sh --check`; for formatting use `scripts/fmt.sh`. Run Rust clippy/tests from `rust/`: `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`
- `src/` and `tests/` are both present; update both surfaces together when behavior changes.
## Repository shape

File diff suppressed because one or more lines are too long

View File

@@ -74,6 +74,18 @@ US-007 COMPLETE (Phase 5 - Plugin/MCP lifecycle maturity)
- DegradedMode behavior
- Tests: 11 unit tests passing
Iteration 2026-04-27 - ROADMAP #200 COMPLETED
------------------------------------------------
- Selected next actionable backlog item because no active task was in progress.
- ROADMAP #200: Interactive MCP/tool permission prompts are invisible blockers.
- Files: rust/crates/runtime/src/worker_boot.rs, rust/crates/runtime/src/recovery_recipes.rs, ROADMAP.md, progress.txt.
- Added tool_permission_required worker status and event classification for interactive MCP/tool permission gates.
- Added structured ToolPermissionPrompt payload with server/tool identity and prompt preview.
- Startup evidence now records tool_permission_prompt_detected and classifies timeout evidence as tool_permission_required.
- Readiness snapshots now mark tool-permission-gated workers as blocked, not ready/idle.
- Tests: targeted tool_permission regressions, full runtime test/clippy/fmt pending in Ralph verification loop.
VERIFICATION STATUS:
------------------
- cargo build --workspace: PASSED
@@ -108,6 +120,29 @@ US-010 COMPLETED (Add model compatibility documentation)
- Cross-referenced with existing code comments in openai_compat.rs
- cargo clippy passes
Iteration 3: 2026-04-16
------------------------
US-012 COMPLETED (Trust prompt resolver with allowlist auto-trust)
- Files: rust/crates/runtime/src/trust_resolver.rs
- Enhanced TrustConfig with pattern matching and serde support:
- TrustAllowlistEntry struct with pattern, worktree_pattern, description
- TrustResolution enum (AutoAllowlisted, ManualApproval)
- Enhanced TrustEvent variants with serde tags and metadata
- Glob pattern matching with * and ? wildcards
- Support for path prefix matching and worktree patterns
- Updated TrustResolver with new resolve() signature:
- Added worktree parameter for worktree pattern matching
- Proper event emission with TrustResolution
- Manual approval detection from screen text
- Added helper functions:
- extract_repo_name() - extracts repo name from path
- detect_manual_approval() - detects manual trust from screen text
- glob_matches() - recursive backtracking glob matcher
- Tests: 25 new tests for pattern matching, serialization, and resolver behavior
- All 483 runtime tests pass
- cargo clippy passes with no warnings
US-011 COMPLETED (Performance optimization: reduce API request serialization overhead)
- Files:
- rust/crates/api/Cargo.toml (added criterion dev-dependency and bench config)
@@ -131,3 +166,213 @@ US-011 COMPLETED (Performance optimization: reduce API request serialization ove
- is_reasoning_model detection: ~26-42ns depending on model
- All tests pass (119 unit tests + 29 integration tests)
- cargo clippy passes
VERIFICATION STATUS (Iteration 3):
----------------------------------
- cargo build --workspace: PASSED
- cargo test --workspace: PASSED (891+ tests)
- cargo clippy --workspace --all-targets -- -D warnings: PASSED
- cargo fmt -- --check: PASSED
All 12 stories from prd.json now have passes: true
- US-001 through US-007: Pre-existing implementations
- US-008: kimi-k2.5 model API compatibility fix
- US-009: Unit tests for kimi model compatibility
- US-010: Model compatibility documentation
- US-011: Performance optimization with criterion benchmarks
- US-012: Trust prompt resolver with allowlist auto-trust
Iteration 4: 2026-04-16
------------------------
US-013 COMPLETED (Phase 2 - Session event ordering + terminal-state reconciliation)
- Files: rust/crates/runtime/src/lane_events.rs
- Added EventTerminality enum (Terminal, Advisory, Uncertainty)
- Added classify_event_terminality() function for event classification
- Added reconcile_terminal_events() function for deterministic event ordering:
- Sorts events by monotonic sequence number
- Deduplicates terminal events by fingerprint
- Detects transport death uncertainty (terminal + transport death)
- Handles out-of-order event bursts
- Added events_materially_differ() for detecting meaningful differences
- Added 8 comprehensive tests for reconciliation logic:
- reconcile_terminal_events_sorts_by_monotonic_sequence
- reconcile_terminal_events_deduplicates_same_fingerprint
- reconcile_terminal_events_detects_transport_death_uncertainty
- reconcile_terminal_events_handles_completed_idle_error_completed_noise
- reconcile_terminal_events_returns_none_for_empty_input
- reconcile_terminal_events_preserves_advisory_events
- events_materially_differ_detects_real_differences
- classify_event_terminality_correctly_classifies
- Fixed test compilation issues with LaneEventBuilder API
VERIFICATION STATUS (Iteration 4):
----------------------------------
- cargo build --workspace: PASSED
- cargo test --workspace: PASSED (891+ tests)
- cargo clippy --workspace --all-targets -- -D warnings: PASSED
- cargo fmt -- --check: PASSED
US-013 marked passes: true in prd.json
US-014 COMPLETED (Phase 2 - Event provenance / environment labeling)
- Files: rust/crates/runtime/src/lane_events.rs
- Added ConfidenceLevel enum (High, Medium, Low, Unknown)
- Added fields to LaneEventMetadata:
- environment_label: Option<String> - environment/channel (production, staging, dev)
- emitter_identity: Option<String> - emitter (clawd, plugin-name, operator-id)
- confidence_level: Option<ConfidenceLevel> - trust level for automation
- Added builder methods: with_environment(), with_emitter(), with_confidence()
- Added filtering functions:
- filter_by_provenance() - select events by source
- filter_by_environment() - select events by environment label
- filter_by_confidence() - select events above confidence threshold
- is_test_event() - check if synthetic source (test, healthcheck, replay)
- is_live_lane_event() - check if production event
- Added 7 comprehensive tests for US-014:
- confidence_level_round_trips_through_serialization
- filter_by_provenance_selects_only_matching_events
- filter_by_environment_selects_only_matching_environment
- filter_by_confidence_selects_events_above_threshold
- is_test_event_detects_synthetic_sources
- is_live_lane_event_detects_production_events
- lane_event_metadata_includes_us014_fields
US-016 COMPLETED (Phase 2 - Duplicate terminal-event suppression)
- Files: rust/crates/runtime/src/lane_events.rs
- Event fingerprinting already implemented via compute_event_fingerprint()
- Fingerprint attached via LaneEventMetadata.event_fingerprint
- Deduplication via dedupe_terminal_events() - returns first occurrence of each fingerprint
- Raw event history preserved separately from deduplicated actionable events
- Material difference detection via events_materially_differ():
- Different event type (Finished vs Failed) is material
- Different status is material
- Different failure class is material
- Different data payload is material
- Reconcile function surfaces latest terminal event when materially different
- Added 5 comprehensive tests for US-016:
- canonical_terminal_event_fingerprint_attached_to_metadata
- dedupe_terminal_events_suppresses_repeated_fingerprints
- dedupe_preserves_raw_event_history_separately
- events_materially_differ_detects_payload_differences
- reconcile_terminal_events_surfaces_latest_when_different
US-017 COMPLETED (Phase 2 - Lane ownership / scope binding)
- Files: rust/crates/runtime/src/lane_events.rs
- LaneOwnership struct already existed with:
- owner: String - owner/assignee identity
- workflow_scope: String - workflow scope (claw-code-dogfood, etc.)
- watcher_action: WatcherAction - Act, Observe, Ignore
- Ownership preserved through lifecycle via with_ownership() builder method
- All lifecycle events (Started -> Ready -> Finished) preserve ownership
- Added 3 comprehensive tests for US-017:
- lane_ownership_attached_to_metadata
- lane_ownership_preserved_through_lifecycle_events
- lane_ownership_watcher_action_variants
US-015 COMPLETED (Phase 2 - Session identity completeness at creation time)
- Files: rust/crates/runtime/src/lane_events.rs
- SessionIdentity struct already existed with:
- title: String - stable title for the session
- workspace: String - workspace/worktree path
- purpose: String - lane/session purpose
- placeholder_reason: Option<String> - reason for placeholder values
- Added reconcile_enriched() method for updating session identity:
- Updates title/workspace/purpose with newly available data
- Clears placeholder_reason when real values are provided
- Preserves existing values for fields not being updated
- Allows incremental enrichment without ambiguity
- Added 2 comprehensive tests:
- session_identity_reconcile_enriched_updates_fields
- session_identity_reconcile_preserves_placeholder_if_no_new_data
US-018 COMPLETED (Phase 2 - Nudge acknowledgment / dedupe contract)
- Files: rust/crates/runtime/src/lane_events.rs
- Added NudgeTracking struct:
- nudge_id: String - unique nudge identifier
- delivered_at: String - timestamp of delivery
- acknowledged: bool - whether acknowledged
- acknowledged_at: Option<String> - when acknowledged
- is_retry: bool - whether this is a retry
- original_nudge_id: Option<String> - original ID if retry
- Added NudgeClassification enum (New, Retry, StaleDuplicate)
- Added classify_nudge() function for deduplication logic
- Added 6 comprehensive tests for US-018
US-019 COMPLETED (Phase 2 - Stable roadmap-id assignment)
- Files: rust/crates/runtime/src/lane_events.rs
- Added RoadmapId struct:
- id: String - canonical unique identifier
- filed_at: String - timestamp when filed
- is_new_filing: bool - new vs update
- supersedes: Option<String> - lineage for supersedes
- Added builder methods: new_filing(), update(), supersedes()
- Added 3 comprehensive tests for US-019
US-020 COMPLETED (Phase 2 - Roadmap item lifecycle state contract)
- Files: rust/crates/runtime/src/lane_events.rs
- Added RoadmapLifecycleState enum (Filed, Acknowledged, InProgress, Blocked, Done, Superseded)
- Added RoadmapLifecycle struct:
- state: RoadmapLifecycleState - current state
- state_changed_at: String - last transition timestamp
- filed_at: String - original filing timestamp
- lineage: Vec<String> - supersession chain
- Added methods: new_filed(), transition(), superseded_by(), is_terminal(), is_active()
- Added 5 comprehensive tests for US-020
VERIFICATION STATUS (Iteration 7):
----------------------------------
- cargo build --workspace: PASSED
- cargo test --workspace: PASSED (891+ tests)
- cargo clippy --workspace --all-targets -- -D warnings: PASSED
- cargo fmt -- --check: PASSED
US-013 through US-015 and US-018 through US-020 now marked passes: true
FINAL VERIFICATION (All 20 Stories Complete):
------------------------------------------------
- cargo build --workspace: PASSED
- cargo test --workspace: PASSED (119+ API tests, 39 runtime tests, 12 integration tests)
- cargo clippy --workspace --all-targets -- -D warnings: PASSED
- cargo fmt -- --check: PASSED
ALL 20 STORIES FROM PRD COMPLETE:
- US-001 through US-012: Pre-existing implementations (verified working)
- US-013: Session event ordering + terminal-state reconciliation
- US-014: Event provenance / environment labeling
- US-015: Session identity completeness at creation time
- US-016: Duplicate terminal-event suppression
- US-017: Lane ownership / scope binding
- US-018: Nudge acknowledgment / dedupe contract
- US-019: Stable roadmap-id assignment
- US-020: Roadmap item lifecycle state contract
Iteration 8: 2026-04-16
------------------------
US-021 COMPLETED (Request body size pre-flight check - from dogfood findings)
- Files:
- rust/crates/api/src/error.rs (new error variant)
- rust/crates/api/src/providers/openai_compat.rs
- Added RequestBodySizeExceeded error variant with actionable message
- Added max_request_body_bytes to OpenAiCompatConfig:
- DashScope: 6MB (6_291_456 bytes) - from dogfood with kimi-k2.5
- OpenAI: 100MB (104_857_600 bytes)
- xAI: 50MB (52_428_800 bytes)
- Added estimate_request_body_size() for pre-flight checks
- Added check_request_body_size() for validation
- Pre-flight check integrated in send_raw_request()
- Tests: 5 new tests for size estimation and limit checking
PROJECT STATUS: COMPLETE (21/21 stories)
Iteration 2026-04-29 - ROADMAP #96 COMPLETED
------------------------------------------------
- Pulled origin/main: already up to date.
- Selected ROADMAP #96 as a small repo-local Immediate Backlog item: the `claw --help` Resume-safe command summary leaked slash-command stubs despite the main Interactive command listing filtering them.
- Files: rust/crates/rusty-claude-cli/src/main.rs, ROADMAP.md, progress.txt.
- Changed help rendering to filter `resume_supported_slash_commands()` through `STUB_COMMANDS` before building the Resume-safe one-liner.
- Added `stub_commands_absent_from_resume_safe_help` regression coverage so future stub additions cannot leak into the Resume-safe summary.
- Targeted verification: `cargo test -p rusty-claude-cli stub_commands_absent_from_resume_safe_help -- --nocapture` passed; `cargo test -p rusty-claude-cli parses_direct_cli_actions -- --nocapture` passed.
- Format/check verification: `cargo fmt --all --check`, `git diff --check`, and `cargo check -p rusty-claude-cli` passed.
- Broader clippy note: `cargo clippy -p rusty-claude-cli --all-targets -- -D warnings` is blocked by pre-existing `clippy::unnecessary_wraps` failures in `rust/crates/commands/src/lib.rs` (`render_mcp_report_for`, `render_mcp_report_json_for`), outside this diff.

View File

@@ -7,7 +7,8 @@ This file provides guidance to Claw Code (clawcode.dev) when working with code i
- Frameworks: none detected from the supported starter markers.
## Verification
- Run Rust verification from the repo root: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`
- From the repository root, run Rust formatting with `scripts/fmt.sh` (or `scripts/fmt.sh --check` for CI-style checks). From this `rust/` directory, the equivalent command is `../scripts/fmt.sh`. Root-level `cargo fmt --manifest-path rust/Cargo.toml` is not the supported formatting command.
- From this `rust/` directory, run Rust verification with `cargo clippy --workspace --all-targets -- -D warnings` and `cargo test --workspace`.
## Working agreement
- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.

View File

@@ -14,6 +14,11 @@ const CONTEXT_WINDOW_ERROR_MARKERS: &[&str] = &[
"too many tokens",
"prompt is too long",
"input is too long",
"input tokens exceed",
"configured limit",
"messages resulted in",
"completion tokens",
"prompt tokens",
"request is too large",
];
@@ -542,6 +547,26 @@ mod tests {
assert_eq!(error.request_id(), Some("req_ctx_123"));
}
#[test]
fn classifies_openai_configured_limit_errors_as_context_window_failures() {
let error = ApiError::Api {
status: reqwest::StatusCode::BAD_REQUEST,
error_type: Some("invalid_request_error".to_string()),
message: Some(
"Input tokens exceed the configured limit of 922000 tokens. Your messages resulted in 1860900 tokens. Please reduce the length of the messages."
.to_string(),
),
request_id: Some("req_ctx_openai_123".to_string()),
body: String::new(),
retryable: false,
suggested_action: None,
};
assert!(error.is_context_window_failure());
assert_eq!(error.safe_failure_class(), "context_window");
assert_eq!(error.request_id(), Some("req_ctx_openai_123"));
}
#[test]
fn missing_credentials_without_hint_renders_the_canonical_message() {
// given

View File

@@ -21,11 +21,12 @@ pub use prompt_cache::{
pub use providers::anthropic::{AnthropicClient, AnthropicClient as ApiClient, AuthSource};
pub use providers::openai_compat::{
build_chat_completion_request, flatten_tool_result_content, is_reasoning_model,
model_rejects_is_error_field, translate_message, OpenAiCompatClient, OpenAiCompatConfig,
model_rejects_is_error_field, model_requires_reasoning_content_in_history, translate_message,
OpenAiCompatClient, OpenAiCompatConfig,
};
pub use providers::{
detect_provider_kind, max_tokens_for_model, max_tokens_for_model_with_override,
resolve_model_alias, ProviderKind,
model_family_identity_for, model_family_identity_for_kind, resolve_model_alias, ProviderKind,
};
pub use sse::{parse_frame, SseParser};
pub use types::{

View File

@@ -250,19 +250,31 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind {
ProviderKind::Anthropic
}
#[must_use]
pub const fn model_family_identity_for_kind(kind: ProviderKind) -> runtime::ModelFamilyIdentity {
match kind {
ProviderKind::Anthropic => runtime::ModelFamilyIdentity::Claude,
ProviderKind::Xai | ProviderKind::OpenAi => runtime::ModelFamilyIdentity::Generic,
}
}
#[must_use]
pub fn model_family_identity_for(model: &str) -> runtime::ModelFamilyIdentity {
model_family_identity_for_kind(detect_provider_kind(model))
}
#[must_use]
pub fn max_tokens_for_model(model: &str) -> u32 {
model_token_limit(model).map_or_else(
|| {
let canonical = resolve_model_alias(model);
if canonical.contains("opus") {
32_000
} else {
64_000
}
},
|limit| limit.max_output_tokens,
)
let canonical = resolve_model_alias(model);
let heuristic = if canonical.contains("opus") {
32_000
} else {
64_000
};
model_token_limit(model)
.map(|limit| heuristic.min(limit.max_output_tokens))
.unwrap_or(heuristic)
}
/// Returns the effective max output tokens for a model, preferring a plugin
@@ -276,7 +288,8 @@ pub fn max_tokens_for_model_with_override(model: &str, plugin_override: Option<u
#[must_use]
pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
let canonical = resolve_model_alias(model);
match canonical.as_str() {
let base_model = canonical.rsplit('/').next().unwrap_or(canonical.as_str());
match base_model {
"claude-opus-4-6" => Some(ModelTokenLimit {
max_output_tokens: 32_000,
context_window_tokens: 200_000,
@@ -289,6 +302,20 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
max_output_tokens: 64_000,
context_window_tokens: 131_072,
}),
// GPT-4.1 family via the OpenAI API.
"gpt-4.1" | "gpt-4.1-mini" | "gpt-4.1-nano" => Some(ModelTokenLimit {
max_output_tokens: 32_768,
context_window_tokens: 1_047_576,
}),
// GPT-5.4 family via the OpenAI API.
"gpt-5.4" => Some(ModelTokenLimit {
max_output_tokens: 128_000,
context_window_tokens: 1_000_000,
}),
"gpt-5.4-mini" | "gpt-5.4-nano" => Some(ModelTokenLimit {
max_output_tokens: 128_000,
context_window_tokens: 400_000,
}),
// Kimi models via DashScope (Moonshot AI)
// Source: https://platform.moonshot.cn/docs/intro
"kimi-k2.5" | "kimi-k1.5" => Some(ModelTokenLimit {
@@ -470,8 +497,8 @@ mod tests {
use super::{
anthropic_missing_credentials, anthropic_missing_credentials_hint, detect_provider_kind,
load_dotenv_file, max_tokens_for_model, max_tokens_for_model_with_override,
model_token_limit, parse_dotenv, preflight_message_request, resolve_model_alias,
ProviderKind,
model_family_identity_for, model_family_identity_for_kind, model_token_limit, parse_dotenv,
preflight_message_request, resolve_model_alias, ProviderKind,
};
/// Serializes every test in this module that mutates process-wide
@@ -530,6 +557,42 @@ mod tests {
);
}
#[test]
fn maps_provider_kind_to_model_family_identity() {
// given: each supported provider kind
let anthropic = ProviderKind::Anthropic;
let openai = ProviderKind::OpenAi;
let xai = ProviderKind::Xai;
// when: converting provider kinds to prompt model family identities
let anthropic_identity = model_family_identity_for_kind(anthropic);
let openai_identity = model_family_identity_for_kind(openai);
let xai_identity = model_family_identity_for_kind(xai);
// then: Anthropic stays Claude and OpenAI-compatible providers are generic
assert_eq!(anthropic_identity, runtime::ModelFamilyIdentity::Claude);
assert_eq!(openai_identity, runtime::ModelFamilyIdentity::Generic);
assert_eq!(xai_identity, runtime::ModelFamilyIdentity::Generic);
}
#[test]
fn maps_model_name_to_model_family_identity() {
// given: Anthropic, OpenAI-compatible, and xAI model names
let claude_model = "claude-opus-4-6";
let openai_model = "openai/gpt-4.1-mini";
let xai_model = "grok-3";
// when: detecting prompt model family identities from model names
let claude_identity = model_family_identity_for(claude_model);
let openai_identity = model_family_identity_for(openai_model);
let xai_identity = model_family_identity_for(xai_model);
// then: Anthropic stays Claude and OpenAI-compatible providers are generic
assert_eq!(claude_identity, runtime::ModelFamilyIdentity::Claude);
assert_eq!(openai_identity, runtime::ModelFamilyIdentity::Generic);
assert_eq!(xai_identity, runtime::ModelFamilyIdentity::Generic);
}
#[test]
fn openai_namespaced_model_routes_to_openai_not_anthropic() {
// Regression: "openai/gpt-4.1-mini" was misrouted to Anthropic when
@@ -614,6 +677,15 @@ mod tests {
fn keeps_existing_max_token_heuristic() {
assert_eq!(max_tokens_for_model("opus"), 32_000);
assert_eq!(max_tokens_for_model("grok-3"), 64_000);
assert_eq!(max_tokens_for_model("gpt-5.4"), 64_000);
}
#[test]
fn caps_default_max_tokens_to_openai_model_limits() {
assert_eq!(max_tokens_for_model("gpt-4.1-mini"), 32_768);
assert_eq!(max_tokens_for_model("openai/gpt-4.1-mini"), 32_768);
assert_eq!(max_tokens_for_model("gpt-5.4"), 64_000);
assert_eq!(max_tokens_for_model("openai/gpt-5.4"), 64_000);
}
#[test]
@@ -680,6 +752,18 @@ mod tests {
.context_window_tokens,
131_072
);
assert_eq!(
model_token_limit("openai/gpt-4.1-mini")
.expect("openai/gpt-4.1-mini should be registered")
.context_window_tokens,
1_047_576
);
assert_eq!(
model_token_limit("gpt-5.4")
.expect("gpt-5.4 should be registered")
.context_window_tokens,
1_000_000
);
}
#[test]
@@ -728,6 +812,42 @@ mod tests {
}
}
#[test]
fn preflight_blocks_oversized_requests_for_gpt_5_4() {
let request = MessageRequest {
model: "gpt-5.4".to_string(),
max_tokens: 64_000,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "x".repeat(3_900_000),
}],
}],
system: Some("Keep the answer short.".to_string()),
tools: None,
tool_choice: None,
stream: true,
..Default::default()
};
let error = preflight_message_request(&request)
.expect_err("oversized gpt-5.4 request should be rejected before the provider call");
match error {
ApiError::ContextWindowExceeded {
model,
requested_output_tokens,
context_window_tokens,
..
} => {
assert_eq!(model, "gpt-5.4");
assert_eq!(requested_output_tokens, 64_000);
assert_eq!(context_window_tokens, 1_000_000);
}
other => panic!("expected context-window preflight failure, got {other:?}"),
}
}
#[test]
fn preflight_skips_unknown_models() {
let request = MessageRequest {
@@ -753,14 +873,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 +888,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

@@ -443,6 +443,8 @@ struct StreamState {
stop_reason: Option<String>,
usage: Option<Usage>,
tool_calls: BTreeMap<u32, ToolCallState>,
thinking_started: bool,
thinking_finished: bool,
}
impl StreamState {
@@ -456,6 +458,8 @@ impl StreamState {
stop_reason: None,
usage: None,
tool_calls: BTreeMap::new(),
thinking_started: false,
thinking_finished: false,
}
}
@@ -493,35 +497,61 @@ impl StreamState {
}
for choice in chunk.choices {
if let Some(reasoning) = choice
.delta
.reasoning_content
.filter(|value| !value.is_empty())
{
if !self.thinking_started {
self.thinking_started = true;
events.push(StreamEvent::ContentBlockStart(ContentBlockStartEvent {
index: 0,
content_block: OutputContentBlock::Thinking {
thinking: String::new(),
signature: None,
},
}));
}
events.push(StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
index: 0,
delta: ContentBlockDelta::ThinkingDelta {
thinking: reasoning,
},
}));
}
if let Some(content) = choice.delta.content.filter(|value| !value.is_empty()) {
self.close_thinking(&mut events);
if !self.text_started {
self.text_started = true;
events.push(StreamEvent::ContentBlockStart(ContentBlockStartEvent {
index: 0,
index: self.text_block_index(),
content_block: OutputContentBlock::Text {
text: String::new(),
},
}));
}
events.push(StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
index: 0,
index: self.text_block_index(),
delta: ContentBlockDelta::TextDelta { text: content },
}));
}
for tool_call in choice.delta.tool_calls {
self.close_thinking(&mut events);
let tool_index_offset = self.tool_index_offset();
let state = self.tool_calls.entry(tool_call.index).or_default();
state.apply(tool_call);
let block_index = state.block_index();
let block_index = state.block_index(tool_index_offset);
if !state.started {
if let Some(start_event) = state.start_event()? {
if let Some(start_event) = state.start_event(tool_index_offset)? {
state.started = true;
events.push(StreamEvent::ContentBlockStart(start_event));
} else {
continue;
}
}
if let Some(delta_event) = state.delta_event() {
if let Some(delta_event) = state.delta_event(tool_index_offset) {
events.push(StreamEvent::ContentBlockDelta(delta_event));
}
if choice.finish_reason.as_deref() == Some("tool_calls") && !state.stopped {
@@ -535,11 +565,12 @@ impl StreamState {
if let Some(finish_reason) = choice.finish_reason {
self.stop_reason = Some(normalize_finish_reason(&finish_reason));
if finish_reason == "tool_calls" {
let tool_index_offset = self.tool_index_offset();
for state in self.tool_calls.values_mut() {
if state.started && !state.stopped {
state.stopped = true;
events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
index: state.block_index(),
index: state.block_index(tool_index_offset),
}));
}
}
@@ -557,19 +588,21 @@ impl StreamState {
self.finished = true;
let mut events = Vec::new();
self.close_thinking(&mut events);
if self.text_started && !self.text_finished {
self.text_finished = true;
events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
index: 0,
index: self.text_block_index(),
}));
}
let tool_index_offset = self.tool_index_offset();
for state in self.tool_calls.values_mut() {
if !state.started {
if let Some(start_event) = state.start_event()? {
if let Some(start_event) = state.start_event(tool_index_offset)? {
state.started = true;
events.push(StreamEvent::ContentBlockStart(start_event));
if let Some(delta_event) = state.delta_event() {
if let Some(delta_event) = state.delta_event(tool_index_offset) {
events.push(StreamEvent::ContentBlockDelta(delta_event));
}
}
@@ -577,7 +610,7 @@ impl StreamState {
if state.started && !state.stopped {
state.stopped = true;
events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
index: state.block_index(),
index: state.block_index(tool_index_offset),
}));
}
}
@@ -603,6 +636,31 @@ impl StreamState {
}
Ok(events)
}
fn close_thinking(&mut self, events: &mut Vec<StreamEvent>) {
if self.thinking_started && !self.thinking_finished {
self.thinking_finished = true;
events.push(StreamEvent::ContentBlockStop(ContentBlockStopEvent {
index: 0,
}));
}
}
const fn text_block_index(&self) -> u32 {
if self.thinking_started {
1
} else {
0
}
}
const fn tool_index_offset(&self) -> u32 {
if self.thinking_started {
2
} else {
1
}
}
}
#[derive(Debug, Default)]
@@ -630,12 +688,12 @@ impl ToolCallState {
}
}
const fn block_index(&self) -> u32 {
self.openai_index + 1
const fn block_index(&self, offset: u32) -> u32 {
self.openai_index + offset
}
#[allow(clippy::unnecessary_wraps)]
fn start_event(&self) -> Result<Option<ContentBlockStartEvent>, ApiError> {
fn start_event(&self, offset: u32) -> Result<Option<ContentBlockStartEvent>, ApiError> {
let Some(name) = self.name.clone() else {
return Ok(None);
};
@@ -644,7 +702,7 @@ impl ToolCallState {
.clone()
.unwrap_or_else(|| format!("tool_call_{}", self.openai_index));
Ok(Some(ContentBlockStartEvent {
index: self.block_index(),
index: self.block_index(offset),
content_block: OutputContentBlock::ToolUse {
id,
name,
@@ -653,14 +711,14 @@ impl ToolCallState {
}))
}
fn delta_event(&mut self) -> Option<ContentBlockDeltaEvent> {
fn delta_event(&mut self, offset: u32) -> Option<ContentBlockDeltaEvent> {
if self.emitted_len >= self.arguments.len() {
return None;
}
let delta = self.arguments[self.emitted_len..].to_string();
self.emitted_len = self.arguments.len();
Some(ContentBlockDeltaEvent {
index: self.block_index(),
index: self.block_index(offset),
delta: ContentBlockDelta::InputJsonDelta {
partial_json: delta,
},
@@ -690,6 +748,8 @@ struct ChatMessage {
#[serde(default)]
content: Option<String>,
#[serde(default)]
reasoning_content: Option<String>,
#[serde(default)]
tool_calls: Vec<ResponseToolCall>,
}
@@ -735,6 +795,8 @@ struct ChunkChoice {
struct ChunkDelta {
#[serde(default)]
content: Option<String>,
#[serde(default)]
reasoning_content: Option<String>,
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
tool_calls: Vec<DeltaToolCall>,
}
@@ -793,6 +855,15 @@ pub fn is_reasoning_model(model: &str) -> bool {
|| canonical.contains("thinking")
}
/// Returns true for OpenAI-compatible DeepSeek V4 models that require prior
/// assistant reasoning to be echoed back as `reasoning_content` in history.
#[must_use]
pub fn model_requires_reasoning_content_in_history(model: &str) -> bool {
let lowered = model.to_ascii_lowercase();
let canonical = lowered.rsplit('/').next().unwrap_or(lowered.as_str());
canonical.starts_with("deepseek-v4")
}
/// Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire.
/// The prefix is used only to select transport; the backend expects the
/// bare model id.
@@ -948,10 +1019,14 @@ pub fn translate_message(message: &InputMessage, model: &str) -> Vec<Value> {
match message.role.as_str() {
"assistant" => {
let mut text = String::new();
let mut reasoning = String::new();
let mut tool_calls = Vec::new();
for block in &message.content {
match block {
InputContentBlock::Text { text: value } => text.push_str(value),
InputContentBlock::Thinking {
thinking: value, ..
} => reasoning.push_str(value),
InputContentBlock::ToolUse { id, name, input } => tool_calls.push(json!({
"id": id,
"type": "function",
@@ -963,13 +1038,18 @@ pub fn translate_message(message: &InputMessage, model: &str) -> Vec<Value> {
InputContentBlock::ToolResult { .. } => {}
}
}
if text.is_empty() && tool_calls.is_empty() {
let include_reasoning =
model_requires_reasoning_content_in_history(model) && !reasoning.is_empty();
if text.is_empty() && tool_calls.is_empty() && !include_reasoning {
Vec::new()
} else {
let mut msg = serde_json::json!({
"role": "assistant",
"content": (!text.is_empty()).then_some(text),
});
if include_reasoning {
msg["reasoning_content"] = json!(reasoning);
}
// Only include tool_calls when non-empty: some providers reject
// assistant messages with an explicit empty tool_calls array.
if !tool_calls.is_empty() {
@@ -1003,6 +1083,7 @@ pub fn translate_message(message: &InputMessage, model: &str) -> Vec<Value> {
}
Some(msg)
}
InputContentBlock::Thinking { .. } => None,
InputContentBlock::ToolUse { .. } => None,
})
.collect(),
@@ -1182,6 +1263,16 @@ fn normalize_response(
"chat completion response missing choices",
))?;
let mut content = Vec::new();
if let Some(thinking) = choice
.message
.reasoning_content
.filter(|value| !value.is_empty())
{
content.push(OutputContentBlock::Thinking {
thinking,
signature: None,
});
}
if let Some(text) = choice.message.content.filter(|value| !value.is_empty()) {
content.push(OutputContentBlock::Text { text });
}
@@ -1413,13 +1504,15 @@ impl StringExt for String {
mod tests {
use super::{
build_chat_completion_request, chat_completions_endpoint, is_reasoning_model,
normalize_finish_reason, openai_tool_choice, parse_tool_arguments, OpenAiCompatClient,
OpenAiCompatConfig,
model_requires_reasoning_content_in_history, normalize_finish_reason, normalize_response,
openai_tool_choice, parse_tool_arguments, OpenAiCompatClient, OpenAiCompatConfig,
StreamState,
};
use crate::error::ApiError;
use crate::types::{
InputContentBlock, InputMessage, MessageRequest, ToolChoice, ToolDefinition,
ToolResultContentBlock,
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
InputContentBlock, InputMessage, MessageRequest, OutputContentBlock, StreamEvent,
ToolChoice, ToolDefinition, ToolResultContentBlock,
};
use serde_json::json;
use std::sync::{Mutex, OnceLock};
@@ -1465,6 +1558,188 @@ mod tests {
assert_eq!(payload["tool_choice"], json!("auto"));
}
#[test]
fn model_requires_reasoning_content_in_history_detects_deepseek_v4_models() {
// Given DeepSeek V4 and non-V4 model names.
let positive = [
"deepseek-v4-flash",
"deepseek-v4-pro",
"openai/deepseek-v4-pro",
"deepseek/deepseek-v4-flash",
];
let negative = [
"deepseek-reasoner",
"deepseek-chat",
"gpt-4o",
"claude-sonnet-4-6",
];
// When checking whether history reasoning_content is required.
// Then only DeepSeek V4 variants require it.
for model in positive {
assert!(model_requires_reasoning_content_in_history(model));
}
for model in negative {
assert!(!model_requires_reasoning_content_in_history(model));
}
}
#[test]
fn legacy_deepseek_reasoner_request_omits_reasoning_content_for_assistant_history() {
// Given an assistant history turn containing thinking.
let request = assistant_history_with_thinking_request("deepseek-reasoner");
// When serializing for legacy deepseek-reasoner.
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
// Then reasoning_content is omitted.
let assistant = &payload["messages"][0];
assert_eq!(assistant["role"], json!("assistant"));
assert!(assistant.get("reasoning_content").is_none());
}
#[test]
fn deepseek_v4_pro_request_includes_reasoning_content_for_assistant_history() {
// Given an assistant history turn containing thinking.
let request = assistant_history_with_thinking_request("openai/deepseek-v4-pro");
// When serializing for DeepSeek V4 Pro.
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
// Then reasoning_content is included on the assistant message.
let assistant = &payload["messages"][0];
assert_eq!(assistant["reasoning_content"], json!("prior reasoning"));
assert_eq!(assistant["content"], json!("answer"));
}
#[test]
fn deepseek_v4_flash_request_includes_reasoning_content_for_assistant_history() {
// Given an assistant history turn containing thinking.
let request = assistant_history_with_thinking_request("deepseek-v4-flash");
// When serializing for DeepSeek V4 Flash.
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
// Then reasoning_content is included on the assistant message.
let assistant = &payload["messages"][0];
assert_eq!(assistant["reasoning_content"], json!("prior reasoning"));
}
#[test]
fn non_streaming_response_with_reasoning_content_emits_thinking_block_first() {
// Given a non-streaming OpenAI-compatible response with reasoning_content.
let response = super::ChatCompletionResponse {
id: "chatcmpl_reasoning".to_string(),
model: "deepseek-v4-pro".to_string(),
choices: vec![super::ChatChoice {
message: super::ChatMessage {
role: "assistant".to_string(),
content: Some("final answer".to_string()),
reasoning_content: Some("hidden thought".to_string()),
tool_calls: Vec::new(),
},
finish_reason: Some("stop".to_string()),
}],
usage: None,
};
// When normalizing the provider response.
let normalized = normalize_response("deepseek-v4-pro", response).expect("normalized");
// Then Thinking is the first content block, before text.
assert_eq!(
normalized.content,
vec![
OutputContentBlock::Thinking {
thinking: "hidden thought".to_string(),
signature: None,
},
OutputContentBlock::Text {
text: "final answer".to_string(),
},
]
);
}
#[test]
fn streaming_chunks_with_reasoning_content_emit_thinking_block_events_before_text() {
// Given streaming chunks with reasoning_content followed by text.
let mut state = StreamState::new("deepseek-v4-pro".to_string());
let mut events = state
.ingest_chunk(super::ChatCompletionChunk {
id: "chatcmpl_stream_reasoning".to_string(),
model: Some("deepseek-v4-pro".to_string()),
choices: vec![super::ChunkChoice {
delta: super::ChunkDelta {
content: None,
reasoning_content: Some("think".to_string()),
tool_calls: Vec::new(),
},
finish_reason: None,
}],
usage: None,
})
.expect("reasoning chunk");
events.extend(
state
.ingest_chunk(super::ChatCompletionChunk {
id: "chatcmpl_stream_reasoning".to_string(),
model: None,
choices: vec![super::ChunkChoice {
delta: super::ChunkDelta {
content: Some(" answer".to_string()),
reasoning_content: None,
tool_calls: Vec::new(),
},
finish_reason: Some("stop".to_string()),
}],
usage: None,
})
.expect("text chunk"),
);
events.extend(state.finish().expect("finish"));
// When reading normalized stream events.
// Then Thinking starts at index 0, text is offset to index 1.
assert!(matches!(events[0], StreamEvent::MessageStart(_)));
assert!(matches!(
events[1],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
index: 0,
content_block: OutputContentBlock::Thinking { .. },
})
));
assert!(matches!(
events[2],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
index: 0,
delta: ContentBlockDelta::ThinkingDelta { .. },
})
));
assert!(matches!(
events[3],
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 0 })
));
assert!(matches!(
events[4],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
index: 1,
content_block: OutputContentBlock::Text { .. },
})
));
assert!(matches!(
events[5],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
index: 1,
delta: ContentBlockDelta::TextDelta { .. },
})
));
assert!(matches!(
events[6],
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 1 })
));
}
#[test]
fn tool_schema_object_gets_strict_fields_for_responses_endpoint() {
// OpenAI /responses endpoint rejects object schemas missing
@@ -1624,6 +1899,27 @@ mod tests {
);
}
fn assistant_history_with_thinking_request(model: &str) -> MessageRequest {
MessageRequest {
model: model.to_string(),
max_tokens: 100,
messages: vec![InputMessage {
role: "assistant".to_string(),
content: vec![
InputContentBlock::Thinking {
thinking: "prior reasoning".to_string(),
signature: None,
},
InputContentBlock::Text {
text: "answer".to_string(),
},
],
}],
stream: false,
..Default::default()
}
}
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
@@ -2195,9 +2491,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

@@ -81,6 +81,11 @@ pub enum InputContentBlock {
Text {
text: String,
},
Thinking {
thinking: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<String>,
},
ToolUse {
id: String,
name: String,
@@ -268,8 +273,9 @@ pub enum StreamEvent {
#[cfg(test)]
mod tests {
use runtime::format_usd;
use serde_json::json;
use super::{MessageResponse, Usage};
use super::{InputContentBlock, MessageResponse, Usage};
#[test]
fn usage_total_tokens_includes_cache_tokens() {
@@ -307,4 +313,33 @@ mod tests {
assert_eq!(format_usd(cost.total_cost_usd()), "$54.6750");
assert_eq!(response.total_tokens(), 1_800_000);
}
#[test]
fn input_content_block_thinking_serializes_with_snake_case_type() {
// given
let block = InputContentBlock::Thinking {
thinking: "pondering".to_string(),
signature: Some("sig_123".to_string()),
};
// when
let serialized = serde_json::to_value(&block).unwrap();
let deserialized: InputContentBlock = serde_json::from_value(json!({
"type": "thinking",
"thinking": "pondering",
"signature": "sig_123"
}))
.unwrap();
// then
assert_eq!(
serialized,
json!({
"type": "thinking",
"thinking": "pondering",
"signature": "sig_123"
})
);
assert_eq!(deserialized, block);
}
}

View File

@@ -63,6 +63,50 @@ async fn send_message_uses_openai_compatible_endpoint_and_auth() {
assert_eq!(body["tools"][0]["type"], json!("function"));
}
#[tokio::test]
async fn send_message_preserves_deepseek_reasoning_content_before_text() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let body = concat!(
"{",
"\"id\":\"chatcmpl_deepseek_reasoning\",",
"\"model\":\"deepseek-v4-pro\",",
"\"choices\":[{",
"\"message\":{\"role\":\"assistant\",\"reasoning_content\":\"Think first\",\"content\":\"Answer second\",\"tool_calls\":[]},",
"\"finish_reason\":\"stop\"",
"}],",
"\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":5}",
"}"
);
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", body)],
)
.await;
let client = OpenAiCompatClient::new("openai-test-key", OpenAiCompatConfig::openai())
.with_base_url(server.base_url());
let response = client
.send_message(&MessageRequest {
model: "openai/deepseek-v4-pro".to_string(),
..sample_request(false)
})
.await
.expect("request should succeed");
assert_eq!(
response.content,
vec![
OutputContentBlock::Thinking {
thinking: "Think first".to_string(),
signature: None,
},
OutputContentBlock::Text {
text: "Answer second".to_string(),
},
]
);
}
#[tokio::test]
async fn send_message_blocks_oversized_xai_requests_before_the_http_call() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));

View File

@@ -2371,6 +2371,40 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
let skills = load_skills_from_roots(&roots)?;
Ok(render_skills_report(&skills))
}
Some(args) if args.starts_with("list ") => {
let filter = args["list ".len()..].trim().to_lowercase();
let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?;
let filtered: Vec<_> = skills
.into_iter()
.filter(|s| s.name.to_lowercase().contains(&filter))
.collect();
Ok(render_skills_report(&filtered))
}
Some("show" | "info" | "describe") => {
let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?;
Ok(render_skills_report(&skills))
}
Some(args)
if args.starts_with("show ")
|| args.starts_with("info ")
|| args.starts_with("describe ") =>
{
let name = args
.splitn(2, ' ')
.nth(1)
.unwrap_or_default()
.trim()
.to_lowercase();
let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?;
let matched: Vec<_> = skills
.into_iter()
.filter(|s| s.name.to_lowercase() == name)
.collect();
Ok(render_skills_report(&matched))
}
Some("install") => Ok(render_skills_usage(Some("install"))),
Some(args) if args.starts_with("install ") => {
let target = args["install ".len()..].trim();
@@ -2402,6 +2436,40 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
let skills = load_skills_from_roots(&roots)?;
Ok(render_skills_report_json(&skills))
}
Some(args) if args.starts_with("list ") => {
let filter = args["list ".len()..].trim().to_lowercase();
let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?;
let filtered: Vec<_> = skills
.into_iter()
.filter(|s| s.name.to_lowercase().contains(&filter))
.collect();
Ok(render_skills_report_json(&filtered))
}
Some("show" | "info" | "describe") => {
let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?;
Ok(render_skills_report_json(&skills))
}
Some(args)
if args.starts_with("show ")
|| args.starts_with("info ")
|| args.starts_with("describe ") =>
{
let name = args
.splitn(2, ' ')
.nth(1)
.unwrap_or_default()
.trim()
.to_lowercase();
let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?;
let matched: Vec<_> = skills
.into_iter()
.filter(|s| s.name.to_lowercase() == name)
.collect();
Ok(render_skills_report_json(&matched))
}
Some("install") => Ok(render_skills_usage_json(Some("install"))),
Some(args) if args.starts_with("install ") => {
let target = args["install ".len()..].trim();
@@ -2419,10 +2487,27 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
#[must_use]
pub fn classify_skills_slash_command(args: Option<&str>) -> SkillSlashDispatch {
match normalize_optional_args(args) {
None | Some("list" | "help" | "-h" | "--help") => SkillSlashDispatch::Local,
None | Some("list" | "help" | "-h" | "--help" | "show" | "info" | "describe") => {
SkillSlashDispatch::Local
}
Some(args)
if args
.split_whitespace()
.any(|part| matches!(part, "-h" | "--help")) =>
{
SkillSlashDispatch::Local
}
Some(args) if args == "install" || args.starts_with("install ") => {
SkillSlashDispatch::Local
}
Some(args)
if args.starts_with("list ")
|| args.starts_with("show ")
|| args.starts_with("info ")
|| args.starts_with("describe ") =>
{
SkillSlashDispatch::Local
}
Some(args) => SkillSlashDispatch::Invoke(format!("${}", args.trim_start_matches('/'))),
}
}
@@ -2596,10 +2681,44 @@ fn render_mcp_report_for(
)),
}
}
Some(args) if args.split_whitespace().next() == Some("list") && args.contains(' ') => {
// `mcp list <filter>` — list does not accept arguments; treat as unsupported action.
Ok(render_mcp_unsupported_action_text(
args,
"list accepts no filter argument; use `claw mcp list`",
))
}
Some(args) if matches!(args.split_whitespace().next(), Some("info" | "describe")) => {
Ok(render_mcp_unsupported_action_text(
args,
"use `claw mcp show <server>` to inspect a server",
))
}
Some(args) => Ok(render_mcp_usage(Some(args))),
}
}
fn render_mcp_unsupported_action_text(action: &str, hint: &str) -> String {
format!(
"MCP\n Error unsupported action '{action}'\n Hint {hint}\n Usage /mcp [list|show <server>|help]"
)
}
fn render_mcp_unsupported_action_json(action: &str, hint: &str) -> Value {
json!({
"kind": "mcp",
"action": "error",
"ok": false,
"error_kind": "unsupported_action",
"requested_action": action,
"hint": hint,
"usage": {
"slash_command": "/mcp [list|show <server>|help]",
"direct_cli": "claw mcp [list|show <server>|help]",
},
})
}
fn render_mcp_report_json_for(
loader: &ConfigLoader,
cwd: &Path,
@@ -2623,10 +2742,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);
@@ -2682,6 +2799,18 @@ fn render_mcp_report_json_for(
})),
}
}
Some(args) if args.split_whitespace().next() == Some("list") && args.contains(' ') => {
Ok(render_mcp_unsupported_action_json(
args,
"list accepts no filter argument; use `claw mcp list`",
))
}
Some(args) if matches!(args.split_whitespace().next(), Some("info" | "describe")) => {
Ok(render_mcp_unsupported_action_json(
args,
"use `claw mcp show <server>` to inspect a server",
))
}
Some(args) => Ok(render_mcp_usage_json(Some(args))),
}
}
@@ -4621,6 +4750,32 @@ mod tests {
assert!(agents_error.contains(" Usage /agents [list|help]"));
}
#[test]
fn skills_show_and_list_filter_do_not_invoke_model() {
// `show`, `info`, `list <filter>` must route to Local, not Invoke.
// Regression for: `claw skills show plan` unexpectedly spawned a model session.
for token in &["show", "info", "describe"] {
assert_eq!(
classify_skills_slash_command(Some(token)),
SkillSlashDispatch::Local,
"`skills {token}` alone must be Local"
);
}
for prefix in &["show ", "info ", "list ", "describe "] {
let arg = format!("{prefix}plan");
assert_eq!(
classify_skills_slash_command(Some(&arg)),
SkillSlashDispatch::Local,
"`skills {arg}` must be Local, not Invoke"
);
}
// Bare invocable tokens still dispatch to Invoke.
assert_eq!(
classify_skills_slash_command(Some("plan")),
SkillSlashDispatch::Invoke("$plan".to_string()),
);
}
#[test]
fn accepts_skills_invocation_arguments_for_prompt_dispatch() {
assert_eq!(
@@ -4643,6 +4798,38 @@ mod tests {
);
}
#[test]
fn mcp_unsupported_actions_return_typed_error_not_generic_help() {
// `mcp info <name>` and `mcp list <filter>` must return typed errors, not raw help.
// Regression for #504: these previously fell through to render_mcp_usage with
// unexpected=arg, giving no machine-readable error_kind.
use crate::handle_mcp_slash_command_json;
use std::path::PathBuf;
let cwd = PathBuf::from("/tmp");
let info_json = handle_mcp_slash_command_json(Some("info nonexistent"), &cwd)
.expect("info nonexistent should not error at IO level");
assert_eq!(info_json["kind"], "mcp");
assert_eq!(info_json["ok"], false);
assert_eq!(info_json["error_kind"], "unsupported_action");
assert!(info_json["hint"]
.as_str()
.unwrap_or_default()
.contains("show"));
let list_filter_json = handle_mcp_slash_command_json(Some("list nonexistent"), &cwd)
.expect("list nonexistent should not error at IO level");
assert_eq!(list_filter_json["kind"], "mcp");
assert_eq!(list_filter_json["ok"], false);
assert_eq!(list_filter_json["error_kind"], "unsupported_action");
let describe_json = handle_mcp_slash_command_json(Some("describe myserver"), &cwd)
.expect("describe myserver should not error at IO level");
assert_eq!(describe_json["kind"], "mcp");
assert_eq!(describe_json["ok"], false);
assert_eq!(describe_json["error_kind"], "unsupported_action");
}
#[test]
fn rejects_invalid_mcp_arguments() {
let show_error = parse_error_message("/mcp show alpha beta");

View File

@@ -248,6 +248,7 @@ fn detect_scenario(request: &MessageRequest) -> Option<Scenario> {
.split_whitespace()
.find_map(|token| token.strip_prefix(SCENARIO_PREFIX))
.and_then(Scenario::parse),
InputContentBlock::Thinking { .. } => None,
_ => None,
})
})

View File

@@ -122,7 +122,7 @@ fn detect_and_emit_ship_prepared(command: &str) {
actor: get_git_actor().unwrap_or_else(|| "unknown".to_string()),
pr_number: None,
};
let _event = LaneEvent::ship_prepared(format!("{}", now), &provenance);
let _event = LaneEvent::ship_prepared(format!("{now}"), &provenance);
// Log to stderr as interim routing before event stream integration
eprintln!(
"[ship.prepared] branch={} -> main, commits={}, actor={}",
@@ -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

@@ -213,6 +213,7 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()),
ContentBlock::Text { .. } => None,
ContentBlock::Thinking { .. } => None,
})
.collect::<Vec<_>>();
tool_names.sort_unstable();
@@ -317,6 +318,9 @@ fn merge_compact_summaries(existing_summary: Option<&str>, new_summary: &str) ->
fn summarize_block(block: &ContentBlock) -> String {
let raw = match block {
ContentBlock::Text { text } => text.clone(),
ContentBlock::Thinking { thinking, .. } => {
format!("thinking ({} chars)", thinking.chars().count())
}
ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"),
ContentBlock::ToolResult {
tool_name,
@@ -378,6 +382,7 @@ fn collect_key_files(messages: &[ConversationMessage]) -> Vec<String> {
ContentBlock::Text { text } => text.as_str(),
ContentBlock::ToolUse { input, .. } => input.as_str(),
ContentBlock::ToolResult { output, .. } => output.as_str(),
ContentBlock::Thinking { thinking, .. } => thinking.as_str(),
})
.flat_map(extract_file_candidates)
.collect::<Vec<_>>();
@@ -400,6 +405,7 @@ fn first_text_block(message: &ConversationMessage) -> Option<&str> {
ContentBlock::Text { text } if !text.trim().is_empty() => Some(text.as_str()),
ContentBlock::ToolUse { .. }
| ContentBlock::ToolResult { .. }
| ContentBlock::Thinking { .. }
| ContentBlock::Text { .. } => None,
})
}
@@ -450,6 +456,10 @@ fn estimate_message_tokens(message: &ConversationMessage) -> usize {
ContentBlock::ToolResult {
tool_name, output, ..
} => (tool_name.len() + output.len()) / 4 + 1,
ContentBlock::Thinking {
thinking,
signature,
} => thinking.len() / 4 + signature.as_ref().map_or(0, |value| value.len() / 4 + 1),
})
.sum()
}

View File

@@ -28,6 +28,10 @@ pub struct ApiRequest {
/// Streamed events emitted while processing a single assistant turn.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AssistantEvent {
Thinking {
thinking: String,
signature: Option<String>,
},
TextDelta(String),
ToolUse {
id: String,
@@ -721,6 +725,16 @@ fn build_assistant_message(
for event in events {
match event {
AssistantEvent::Thinking {
thinking,
signature,
} => {
flush_text_block(&mut text, &mut blocks);
blocks.push(ContentBlock::Thinking {
thinking,
signature,
});
}
AssistantEvent::TextDelta(delta) => text.push_str(&delta),
AssistantEvent::ToolUse { id, name, input } => {
flush_text_block(&mut text, &mut blocks);
@@ -1723,6 +1737,47 @@ mod tests {
.contains("assistant stream produced no content"));
}
#[test]
fn build_assistant_message_places_thinking_block_before_text_and_tool_use() {
// given
let events = vec![
AssistantEvent::Thinking {
thinking: "pondering".to_string(),
signature: Some("sig".to_string()),
},
AssistantEvent::TextDelta("hello".to_string()),
AssistantEvent::ToolUse {
id: "tool-1".to_string(),
name: "echo".to_string(),
input: "payload".to_string(),
},
AssistantEvent::MessageStop,
];
// when
let (message, _, _) = build_assistant_message(events)
.expect("assistant message should preserve thinking, text, and tool blocks");
// then
assert_eq!(
message.blocks,
vec![
ContentBlock::Thinking {
thinking: "pondering".to_string(),
signature: Some("sig".to_string()),
},
ContentBlock::Text {
text: "hello".to_string(),
},
ContentBlock::ToolUse {
id: "tool-1".to_string(),
name: "echo".to_string(),
input: "payload".to_string(),
},
]
);
}
#[test]
fn static_tool_executor_rejects_unknown_tools() {
// given

View File

@@ -1,4 +1,5 @@
use std::cmp::Reverse;
use std::collections::HashSet;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
@@ -7,7 +8,7 @@ use std::time::Instant;
use glob::Pattern;
use regex::RegexBuilder;
use serde::{Deserialize, Serialize};
use walkdir::WalkDir;
use walkdir::{DirEntry, WalkDir};
/// Maximum file size that can be read (10 MB).
const MAX_READ_SIZE: u64 = 10 * 1024 * 1024;
@@ -15,6 +16,15 @@ const MAX_READ_SIZE: u64 = 10 * 1024 * 1024;
/// Maximum file size that can be written (10 MB).
const MAX_WRITE_SIZE: usize = 10 * 1024 * 1024;
const GLOB_SEARCH_IGNORED_DIRS: &[&str] = &[
".git",
"node_modules",
".build",
"target",
"dist",
"coverage",
];
/// Check whether a file appears to contain binary content by examining
/// the first chunk for NUL bytes.
fn is_binary_file(path: &Path) -> io::Result<bool> {
@@ -313,14 +323,22 @@ pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOu
// `Assets/**/*.{cs,uxml,uss}` work correctly.
let expanded = expand_braces(&search_pattern);
let mut seen = std::collections::HashSet::new();
let mut seen = HashSet::new();
let mut matches = Vec::new();
for pat in &expanded {
let entries = glob::glob(pat)
let compiled = Pattern::new(pat)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
let walk_root = derive_glob_walk_root(pat);
let entries = WalkDir::new(&walk_root)
.into_iter()
.filter_entry(|entry| !should_skip_glob_dir(entry));
for entry in entries.flatten() {
if entry.is_file() && seen.insert(entry.clone()) {
matches.push(entry);
let candidate = entry.path();
if entry.file_type().is_file()
&& compiled.matches_path(candidate)
&& seen.insert(candidate.to_path_buf())
{
matches.push(candidate.to_path_buf());
}
}
}
@@ -457,6 +475,39 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
})
}
fn should_skip_glob_dir(entry: &DirEntry) -> bool {
entry.file_type().is_dir()
&& entry
.file_name()
.to_str()
.is_some_and(|name| GLOB_SEARCH_IGNORED_DIRS.contains(&name))
}
fn derive_glob_walk_root(pattern: &str) -> PathBuf {
let path = Path::new(pattern);
let mut prefix = PathBuf::new();
let mut saw_component = false;
for component in path.components() {
let text = component.as_os_str().to_string_lossy();
if component_contains_glob(&text) {
break;
}
prefix.push(component.as_os_str());
saw_component = true;
}
if saw_component {
prefix
} else {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
}
fn component_contains_glob(component: &str) -> bool {
component.contains('*') || component.contains('?') || component.contains('[')
}
fn collect_search_files(base_path: &Path) -> io::Result<Vec<PathBuf>> {
if base_path.is_file() {
return Ok(vec![base_path.to_path_buf()]);
@@ -651,11 +702,13 @@ fn expand_braces(pattern: &str) -> Vec<String> {
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use super::{
edit_file, expand_braces, glob_search, grep_search, is_symlink_escape, read_file,
read_file_in_workspace, write_file, GrepSearchInput, MAX_WRITE_SIZE,
component_contains_glob, derive_glob_walk_root, edit_file, expand_braces, glob_search,
grep_search, is_symlink_escape, read_file, read_file_in_workspace, write_file,
GrepSearchInput, MAX_WRITE_SIZE,
};
fn temp_path(name: &str) -> std::path::PathBuf {
@@ -836,4 +889,50 @@ mod tests {
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn glob_search_skips_common_heavy_directories() {
let dir = temp_path("glob-ignored-dirs");
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::create_dir_all(dir.join("docs")).unwrap();
std::fs::create_dir_all(dir.join("node_modules/pkg")).unwrap();
std::fs::create_dir_all(dir.join(".build/checkouts/pkg")).unwrap();
std::fs::create_dir_all(dir.join("target/debug/deps")).unwrap();
std::fs::write(dir.join("src/AGENTS.md"), "src").unwrap();
std::fs::write(dir.join("docs/AGENTS.md"), "docs").unwrap();
std::fs::write(dir.join("node_modules/pkg/AGENTS.md"), "node_modules").unwrap();
std::fs::write(dir.join(".build/checkouts/pkg/AGENTS.md"), ".build").unwrap();
std::fs::write(dir.join("target/debug/deps/AGENTS.md"), "target").unwrap();
let result =
glob_search("**/AGENTS.md", Some(dir.to_str().unwrap())).expect("glob should succeed");
assert_eq!(result.num_files, 2, "ignored dirs should be pruned");
assert!(result
.filenames
.iter()
.any(|path| path.ends_with("src/AGENTS.md")));
assert!(result
.filenames
.iter()
.any(|path| path.ends_with("docs/AGENTS.md")));
assert!(!result
.filenames
.iter()
.any(|path| path.contains("node_modules")
|| path.contains(".build")
|| path.contains("/target/")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn derive_glob_walk_root_stops_at_first_glob_component() {
let root = derive_glob_walk_root("/tmp/demo/**/AGENTS.md");
assert_eq!(root, PathBuf::from("/tmp/demo"));
assert!(component_contains_glob("**"));
assert!(component_contains_glob("*.rs"));
assert!(!component_contains_glob("src"));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -131,8 +131,8 @@ pub use policy_engine::{
PolicyEngine, PolicyRule, ReconcileReason, ReviewStatus,
};
pub use prompt::{
load_system_prompt, prepend_bullets, ContextFile, ProjectContext, PromptBuildError,
SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
load_system_prompt, prepend_bullets, ContextFile, ModelFamilyIdentity, ProjectContext,
PromptBuildError, SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
};
pub use recovery_recipes::{
attempt_recovery, recipe_for, EscalationPolicy, FailureScenario, RecoveryContext,

View File

@@ -43,6 +43,24 @@ pub const FRONTIER_MODEL_NAME: &str = "Claude Opus 4.6";
const MAX_INSTRUCTION_FILE_CHARS: usize = 4_000;
const MAX_TOTAL_INSTRUCTION_CHARS: usize = 12_000;
/// Neutral identity for the model family line in generated prompts.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ModelFamilyIdentity {
#[default]
Claude,
Generic,
}
impl ModelFamilyIdentity {
#[must_use]
pub const fn family_label(self) -> &'static str {
match self {
Self::Claude => FRONTIER_MODEL_NAME,
Self::Generic => "an AI assistant",
}
}
}
/// Contents of an instruction file included in prompt construction.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextFile {
@@ -97,6 +115,7 @@ pub struct SystemPromptBuilder {
output_style_prompt: Option<String>,
os_name: Option<String>,
os_version: Option<String>,
model_family: Option<ModelFamilyIdentity>,
append_sections: Vec<String>,
project_context: Option<ProjectContext>,
config: Option<RuntimeConfig>,
@@ -122,6 +141,12 @@ impl SystemPromptBuilder {
self
}
#[must_use]
pub fn with_model_family(mut self, model_family: ModelFamilyIdentity) -> Self {
self.model_family = Some(model_family);
self
}
#[must_use]
pub fn with_project_context(mut self, project_context: ProjectContext) -> Self {
self.project_context = Some(project_context);
@@ -179,9 +204,10 @@ impl SystemPromptBuilder {
|| "unknown".to_string(),
|context| context.current_date.clone(),
);
let identity = self.model_family.unwrap_or_default();
let mut lines = vec!["# Environment context".to_string()];
lines.extend(prepend_bullets(vec![
format!("Model family: {FRONTIER_MODEL_NAME}"),
format!("Model family: {}", identity.family_label()),
format!("Working directory: {cwd}"),
format!("Date: {date}"),
format!(
@@ -434,12 +460,14 @@ pub fn load_system_prompt(
current_date: impl Into<String>,
os_name: impl Into<String>,
os_version: impl Into<String>,
model_family: ModelFamilyIdentity,
) -> Result<Vec<String>, PromptBuildError> {
let cwd = cwd.into();
let project_context = ProjectContext::discover_with_git(&cwd, current_date.into())?;
let config = ConfigLoader::default_for(&cwd).load()?;
Ok(SystemPromptBuilder::new()
.with_os(os_name, os_version)
.with_model_family(model_family)
.with_project_context(project_context)
.with_runtime_config(config)
.build())
@@ -522,7 +550,8 @@ mod tests {
use super::{
collapse_blank_lines, display_context_path, normalize_instruction_content,
render_instruction_content, render_instruction_files, truncate_instruction_content,
ContextFile, ProjectContext, SystemPromptBuilder, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
ContextFile, ModelFamilyIdentity, ProjectContext, SystemPromptBuilder,
SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
};
use crate::config::ConfigLoader;
use std::fs;
@@ -804,13 +833,19 @@ mod tests {
std::env::set_var("HOME", &root);
std::env::set_var("CLAW_CONFIG_HOME", root.join("missing-home"));
std::env::set_current_dir(&root).expect("change cwd");
let prompt = super::load_system_prompt(&root, "2026-03-31", "linux", "6.8")
.expect("system prompt should load")
.join(
"
let prompt = super::load_system_prompt(
&root,
"2026-03-31",
"linux",
"6.8",
ModelFamilyIdentity::Claude,
)
.expect("system prompt should load")
.join(
"
",
);
);
std::env::set_current_dir(previous).expect("restore cwd");
if let Some(value) = original_home {
std::env::set_var("HOME", value);
@@ -828,6 +863,50 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn renders_default_claude_model_family_identity() {
// given: a prompt builder without an explicit model family override
let project_context = ProjectContext {
cwd: PathBuf::from("/tmp/project"),
current_date: "2026-03-31".to_string(),
..ProjectContext::default()
};
// when: rendering the system prompt environment section
let prompt = SystemPromptBuilder::new()
.with_os("linux", "6.8")
.with_project_context(project_context)
.render();
// then: the Claude model family label is preserved by default
assert!(prompt.contains("Model family: Claude Opus 4.6"));
}
#[test]
fn renders_generic_model_family_identity_without_claude_label() {
// given: a prompt builder with generic model family identity
let project_context = ProjectContext {
cwd: PathBuf::from("/tmp/project"),
current_date: "2026-03-31".to_string(),
..ProjectContext::default()
};
// when: rendering the system prompt environment section
let prompt = SystemPromptBuilder::new()
.with_os("linux", "6.8")
.with_model_family(ModelFamilyIdentity::Generic)
.with_project_context(project_context)
.render();
let model_family_line = prompt
.lines()
.find(|line| line.contains("Model family:"))
.expect("model family line should render");
// then: the model family line is neutral and excludes Claude Opus 4.6
assert_eq!(model_family_line, " - Model family: an AI assistant");
assert!(!model_family_line.contains("Claude Opus 4.6"));
}
#[test]
fn renders_claude_code_style_sections_with_project_context() {
let root = temp_dir();

View File

@@ -45,7 +45,9 @@ impl FailureScenario {
#[must_use]
pub fn from_worker_failure_kind(kind: WorkerFailureKind) -> Self {
match kind {
WorkerFailureKind::TrustGate => Self::TrustPromptUnresolved,
WorkerFailureKind::TrustGate | WorkerFailureKind::ToolPermissionGate => {
Self::TrustPromptUnresolved
}
WorkerFailureKind::PromptDelivery => Self::PromptMisdelivery,
WorkerFailureKind::Protocol => Self::McpHandshakeFailure,
WorkerFailureKind::Provider | WorkerFailureKind::StartupNoEvidence => {

View File

@@ -30,6 +30,10 @@ pub enum ContentBlock {
Text {
text: String,
},
Thinking {
thinking: String,
signature: Option<String>,
},
ToolUse {
id: String,
name: String,
@@ -737,6 +741,22 @@ impl ContentBlock {
object.insert("type".to_string(), JsonValue::String("text".to_string()));
object.insert("text".to_string(), JsonValue::String(text.clone()));
}
Self::Thinking {
thinking,
signature,
} => {
object.insert(
"type".to_string(),
JsonValue::String("thinking".to_string()),
);
object.insert("thinking".to_string(), JsonValue::String(thinking.clone()));
if let Some(signature) = signature {
object.insert(
"signature".to_string(),
JsonValue::String(signature.clone()),
);
}
}
Self::ToolUse { id, name, input } => {
object.insert(
"type".to_string(),
@@ -783,6 +803,13 @@ impl ContentBlock {
"text" => Ok(Self::Text {
text: required_string(object, "text")?,
}),
"thinking" => Ok(Self::Thinking {
thinking: required_string(object, "thinking")?,
signature: object
.get("signature")
.and_then(JsonValue::as_str)
.map(String::from),
}),
"tool_use" => Ok(Self::ToolUse {
id: required_string(object, "id")?,
name: required_string(object, "name")?,
@@ -1208,6 +1235,36 @@ mod tests {
assert_eq!(restored.session_id, session.session_id);
}
#[test]
fn persists_assistant_thinking_block_round_trip_through_jsonl() {
// given
let mut session = Session::new();
session
.push_message(ConversationMessage::assistant(vec![
ContentBlock::Thinking {
thinking: "trace the path through session persistence".to_string(),
signature: Some("sig-123".to_string()),
},
]))
.expect("thinking block should append");
let path = temp_session_path("thinking-jsonl");
// when
session.save_to_path(&path).expect("session should save");
let restored = Session::load_from_path(&path).expect("session should load");
fs::remove_file(&path).expect("temp file should be removable");
// then
assert_eq!(restored, session);
assert_eq!(
restored.messages[0].blocks[0],
ContentBlock::Thinking {
thinking: "trace the path through session persistence".to_string(),
signature: Some("sig-123".to_string()),
}
);
}
#[test]
fn loads_legacy_session_json_object() {
let path = temp_session_path("legacy");

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

@@ -1,5 +1,7 @@
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
const TRUST_PROMPT_CUES: &[&str] = &[
"do you trust the files in this folder",
"trust the files in this folder",
@@ -8,24 +10,121 @@ const TRUST_PROMPT_CUES: &[&str] = &[
"yes, proceed",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Resolution method for trust decisions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TrustPolicy {
/// Automatically trust this path (allowlisted)
AutoTrust,
/// Require manual approval
RequireApproval,
/// Deny trust for this path
Deny,
}
#[derive(Debug, Clone, PartialEq, Eq)]
/// Events emitted during trust resolution lifecycle.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TrustEvent {
TrustRequired { cwd: String },
TrustResolved { cwd: String, policy: TrustPolicy },
TrustDenied { cwd: String, reason: String },
/// Trust prompt was detected and is required
TrustRequired {
/// Current working directory where trust is needed
cwd: String,
/// Optional repo identifier
#[serde(skip_serializing_if = "Option::is_none")]
repo: Option<String>,
/// Optional worktree path
#[serde(skip_serializing_if = "Option::is_none")]
worktree: Option<String>,
},
/// Trust was resolved (granted)
TrustResolved {
/// Current working directory
cwd: String,
/// The policy that was applied
policy: TrustPolicy,
/// How the trust was resolved
resolution: TrustResolution,
},
/// Trust was denied
TrustDenied {
/// Current working directory
cwd: String,
/// Reason for denial
reason: String,
},
}
#[derive(Debug, Clone, Default)]
/// How trust was resolved.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TrustResolution {
/// Automatically granted due to allowlist
AutoAllowlisted,
/// Manually approved by user
ManualApproval,
}
/// Entry in the trust allowlist with pattern matching support.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrustAllowlistEntry {
/// Repository path or glob pattern to match
pub pattern: String,
/// Optional worktree subpath pattern
#[serde(skip_serializing_if = "Option::is_none")]
pub worktree_pattern: Option<String>,
/// Human-readable description of why this is allowlisted
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
impl TrustAllowlistEntry {
#[must_use]
pub fn new(pattern: impl Into<String>) -> Self {
Self {
pattern: pattern.into(),
worktree_pattern: None,
description: None,
}
}
#[must_use]
pub fn with_worktree_pattern(mut self, pattern: impl Into<String>) -> Self {
self.worktree_pattern = Some(pattern.into());
self
}
#[must_use]
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
}
/// Configuration for trust resolution with allowlist/denylist support.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrustConfig {
allowlisted: Vec<PathBuf>,
denied: Vec<PathBuf>,
/// Allowlisted paths with pattern matching
pub allowlisted: Vec<TrustAllowlistEntry>,
/// Denied paths (exact or prefix matches)
pub denied: Vec<PathBuf>,
/// Whether to emit events for trust decisions
#[serde(default = "default_emit_events")]
pub emit_events: bool,
}
fn default_emit_events() -> bool {
true
}
impl Default for TrustConfig {
fn default() -> Self {
Self {
allowlisted: Vec::new(),
denied: Vec::new(),
emit_events: true,
}
}
}
impl TrustConfig {
@@ -35,8 +134,14 @@ impl TrustConfig {
}
#[must_use]
pub fn with_allowlisted(mut self, path: impl Into<PathBuf>) -> Self {
self.allowlisted.push(path.into());
pub fn with_allowlisted(mut self, path: impl Into<String>) -> Self {
self.allowlisted.push(TrustAllowlistEntry::new(path));
self
}
#[must_use]
pub fn with_allowlisted_entry(mut self, entry: TrustAllowlistEntry) -> Self {
self.allowlisted.push(entry);
self
}
@@ -45,6 +150,147 @@ impl TrustConfig {
self.denied.push(path.into());
self
}
/// Check if a path matches an allowlisted entry using glob patterns.
#[must_use]
pub fn is_allowlisted(
&self,
cwd: &str,
worktree: Option<&str>,
) -> Option<&TrustAllowlistEntry> {
self.allowlisted.iter().find(|entry| {
let path_matches = Self::pattern_matches(&entry.pattern, cwd);
if !path_matches {
return false;
}
match (&entry.worktree_pattern, worktree) {
(Some(wt_pattern), Some(wt)) => Self::pattern_matches(wt_pattern, wt),
(Some(_), None) => false,
(None, _) => true,
}
})
}
/// Match a pattern against a path string.
/// Supports exact matching and glob patterns (* and ?).
fn pattern_matches(pattern: &str, path: &str) -> bool {
let pattern = pattern.trim();
let path = path.trim();
// Exact match
if pattern == path {
return true;
}
// Normalize paths for comparison
let pattern_normalized = pattern.replace("//", "/");
let path_normalized = path.replace("//", "/");
// Check if pattern is a path prefix (e.g., "/tmp/worktrees" matches "/tmp/worktrees/repo-a")
// This handles the common case of directory containment
if !pattern_normalized.contains('*') && !pattern_normalized.contains('?') {
// Prefix match: pattern is a directory that contains path
if path_normalized.starts_with(&pattern_normalized) {
let rest = &path_normalized[pattern_normalized.len()..];
// Must be exact match or continue with /
return rest.is_empty() || rest.starts_with('/');
}
}
// Check if pattern ends with wildcard (prefix match)
if pattern_normalized.ends_with("/*") {
let prefix = pattern_normalized.trim_end_matches("/*");
if let Some(rest) = path_normalized.strip_prefix(prefix) {
// Must either be exact match or continue with /
return rest.is_empty() || rest.starts_with('/');
}
} else if pattern_normalized.ends_with('*') && !pattern_normalized.contains("/*/") {
// Simple trailing * (not a path component wildcard)
let prefix = pattern_normalized.trim_end_matches('*');
if let Some(rest) = path_normalized.strip_prefix(prefix) {
return rest.is_empty() || !rest.starts_with('/');
}
}
// Check if pattern is a path component match (bounded by /)
if path_normalized
.split('/')
.any(|component| component == pattern_normalized)
{
return true;
}
// Check if pattern appears as a substring within a path component
// (e.g., "repo" matches "/tmp/worktrees/repo-a")
if path_normalized
.split('/')
.any(|component| component.contains(&pattern_normalized))
{
return true;
}
// Glob matching for patterns with ? or * in the middle
if pattern.contains('?') || pattern.contains("/*/") || pattern.starts_with("*/") {
return Self::glob_matches(&pattern_normalized, &path_normalized);
}
false
}
/// Simple glob pattern matching (? matches single char, * matches any sequence).
/// Handles patterns like /tmp/*/repo-* where * matches path components.
fn glob_matches(pattern: &str, path: &str) -> bool {
// Use recursive backtracking for proper glob matching
Self::glob_match_recursive(pattern, path, 0, 0)
}
fn glob_match_recursive(pattern: &str, path: &str, p_idx: usize, s_idx: usize) -> bool {
let p_chars: Vec<char> = pattern.chars().collect();
let s_chars: Vec<char> = path.chars().collect();
let mut p = p_idx;
let mut s = s_idx;
while p < p_chars.len() {
match p_chars[p] {
'*' => {
// Try all possible matches for *
p += 1;
if p >= p_chars.len() {
// * at end matches everything remaining
return true;
}
// Try matching 0 or more characters
for skip in 0..=(s_chars.len() - s) {
if Self::glob_match_recursive(pattern, path, p, s + skip) {
return true;
}
}
return false;
}
'?' => {
// ? matches exactly one character
if s >= s_chars.len() {
return false;
}
p += 1;
s += 1;
}
c => {
// Exact character match
if s >= s_chars.len() || s_chars[s] != c {
return false;
}
p += 1;
s += 1;
}
}
}
// Pattern exhausted - path must also be exhausted
s >= s_chars.len()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -86,15 +332,19 @@ impl TrustResolver {
}
#[must_use]
pub fn resolve(&self, cwd: &str, screen_text: &str) -> TrustDecision {
pub fn resolve(&self, cwd: &str, worktree: Option<&str>, screen_text: &str) -> TrustDecision {
if !detect_trust_prompt(screen_text) {
return TrustDecision::NotRequired;
}
let repo = extract_repo_name(cwd);
let mut events = vec![TrustEvent::TrustRequired {
cwd: cwd.to_owned(),
repo: repo.clone(),
worktree: worktree.map(String::from),
}];
// Check denylist first
if let Some(matched_root) = self
.config
.denied
@@ -112,15 +362,12 @@ impl TrustResolver {
};
}
if self
.config
.allowlisted
.iter()
.any(|root| path_matches(cwd, root))
{
// Check allowlist with pattern matching
if self.config.is_allowlisted(cwd, worktree).is_some() {
events.push(TrustEvent::TrustResolved {
cwd: cwd.to_owned(),
policy: TrustPolicy::AutoTrust,
resolution: TrustResolution::AutoAllowlisted,
});
return TrustDecision::Required {
policy: TrustPolicy::AutoTrust,
@@ -128,6 +375,19 @@ impl TrustResolver {
};
}
// Check for manual trust resolution via screen text analysis
if detect_manual_approval(screen_text) {
events.push(TrustEvent::TrustResolved {
cwd: cwd.to_owned(),
policy: TrustPolicy::RequireApproval,
resolution: TrustResolution::ManualApproval,
});
return TrustDecision::Required {
policy: TrustPolicy::RequireApproval,
events,
};
}
TrustDecision::Required {
policy: TrustPolicy::RequireApproval,
events,
@@ -135,17 +395,20 @@ impl TrustResolver {
}
#[must_use]
pub fn trusts(&self, cwd: &str) -> bool {
!self
pub fn trusts(&self, cwd: &str, worktree: Option<&str>) -> bool {
// Check denylist first
let denied = self
.config
.denied
.iter()
.any(|root| path_matches(cwd, root))
&& self
.config
.allowlisted
.iter()
.any(|root| path_matches(cwd, root))
.any(|root| path_matches(cwd, root));
if denied {
return false;
}
// Check allowlist using pattern matching
self.config.is_allowlisted(cwd, worktree).is_some()
}
}
@@ -172,11 +435,240 @@ fn normalize_path(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
/// Extract repository name from a path for event context.
fn extract_repo_name(cwd: &str) -> Option<String> {
let path = Path::new(cwd);
// Try to find a .git directory to identify repo root
let mut current = Some(path);
while let Some(p) = current {
if p.join(".git").is_dir() {
return p.file_name().map(|n| n.to_string_lossy().to_string());
}
current = p.parent();
}
// Fallback: use the last component of the path
path.file_name().map(|n| n.to_string_lossy().to_string())
}
/// Detect if the screen text indicates manual approval was granted.
fn detect_manual_approval(screen_text: &str) -> bool {
let lowered = screen_text.to_ascii_lowercase();
// Look for indicators that user manually approved
MANUAL_APPROVAL_CUES.iter().any(|cue| lowered.contains(cue))
}
const MANUAL_APPROVAL_CUES: &[&str] = &[
"yes, i trust",
"i trust this",
"trusted manually",
"approval granted",
];
#[cfg(test)]
mod path_matching_tests {
use super::*;
#[test]
fn glob_pattern_star_matches_any_sequence() {
assert!(TrustConfig::pattern_matches("/tmp/*", "/tmp/foo"));
assert!(TrustConfig::pattern_matches("/tmp/*", "/tmp/bar/baz"));
assert!(!TrustConfig::pattern_matches("/tmp/*", "/other/tmp/foo"));
}
#[test]
fn glob_pattern_question_matches_single_char() {
assert!(TrustConfig::pattern_matches("/tmp/test?", "/tmp/test1"));
assert!(TrustConfig::pattern_matches("/tmp/test?", "/tmp/testA"));
assert!(!TrustConfig::pattern_matches("/tmp/test?", "/tmp/test12"));
assert!(!TrustConfig::pattern_matches("/tmp/test?", "/tmp/test"));
}
#[test]
fn pattern_matches_exact() {
assert!(TrustConfig::pattern_matches(
"/tmp/worktrees",
"/tmp/worktrees"
));
assert!(!TrustConfig::pattern_matches(
"/tmp/worktrees",
"/tmp/worktrees-other"
));
}
#[test]
fn pattern_matches_prefix_with_wildcard() {
assert!(TrustConfig::pattern_matches(
"/tmp/worktrees/*",
"/tmp/worktrees/repo-a"
));
assert!(TrustConfig::pattern_matches(
"/tmp/worktrees/*",
"/tmp/worktrees/repo-a/subdir"
));
assert!(!TrustConfig::pattern_matches(
"/tmp/worktrees/*",
"/tmp/other/repo"
));
}
#[test]
fn pattern_matches_contains() {
// Pattern contained within path
assert!(TrustConfig::pattern_matches(
"worktrees",
"/tmp/worktrees/repo-a"
));
assert!(TrustConfig::pattern_matches(
"repo",
"/tmp/worktrees/repo-a"
));
}
#[test]
fn allowlist_entry_with_worktree_pattern() {
let config = TrustConfig::new().with_allowlisted_entry(
TrustAllowlistEntry::new("/tmp/worktrees/*")
.with_worktree_pattern("*/.git")
.with_description("Git worktrees"),
);
// Should match when both patterns match
assert!(config
.is_allowlisted("/tmp/worktrees/repo-a", Some("/tmp/worktrees/repo-a/.git"))
.is_some());
// Should not match when worktree pattern doesn't match
assert!(config
.is_allowlisted("/tmp/worktrees/repo-a", Some("/other/path"))
.is_none());
// Should not match when a worktree pattern is required but no worktree is supplied
assert!(config
.is_allowlisted("/tmp/worktrees/repo-a", None)
.is_none());
// Should match when no worktree pattern required and path matches
let config_no_worktree = TrustConfig::new().with_allowlisted("/tmp/worktrees/*");
assert!(config_no_worktree
.is_allowlisted("/tmp/worktrees/repo-a", None)
.is_some());
}
#[test]
fn allowlist_entry_returns_matched_entry() {
let entry = TrustAllowlistEntry::new("/tmp/worktrees/*").with_description("Test worktrees");
let config = TrustConfig::new().with_allowlisted_entry(entry.clone());
let matched = config.is_allowlisted("/tmp/worktrees/repo-a", None);
assert!(matched.is_some());
assert_eq!(
matched.unwrap().description,
Some("Test worktrees".to_string())
);
}
#[test]
fn complex_glob_patterns() {
// Multiple wildcards
assert!(TrustConfig::pattern_matches(
"/tmp/*/repo-*",
"/tmp/worktrees/repo-123"
));
assert!(TrustConfig::pattern_matches(
"/tmp/*/repo-*",
"/tmp/other/repo-abc"
));
assert!(!TrustConfig::pattern_matches(
"/tmp/*/repo-*",
"/tmp/worktrees/other"
));
// Mixed ? and *
assert!(TrustConfig::pattern_matches(
"/tmp/test?/*.txt",
"/tmp/test1/file.txt"
));
assert!(TrustConfig::pattern_matches(
"/tmp/test?/*.txt",
"/tmp/testA/subdir/file.txt"
));
}
#[test]
fn serde_serialization_roundtrip() {
let config = TrustConfig::new()
.with_allowlisted_entry(
TrustAllowlistEntry::new("/tmp/worktrees/*")
.with_worktree_pattern("*/.git")
.with_description("Git worktrees"),
)
.with_denied("/tmp/malicious");
let json = serde_json::to_string(&config).expect("serialization failed");
let deserialized: TrustConfig =
serde_json::from_str(&json).expect("deserialization failed");
assert_eq!(config.allowlisted.len(), deserialized.allowlisted.len());
assert_eq!(config.denied.len(), deserialized.denied.len());
assert_eq!(config.emit_events, deserialized.emit_events);
}
#[test]
fn trust_event_serialization() {
let event = TrustEvent::TrustRequired {
cwd: "/tmp/test".to_string(),
repo: Some("test-repo".to_string()),
worktree: Some("/tmp/test/.git".to_string()),
};
let json = serde_json::to_string(&event).expect("serialization failed");
assert!(json.contains("trust_required"));
assert!(json.contains("/tmp/test"));
assert!(json.contains("test-repo"));
let deserialized: TrustEvent = serde_json::from_str(&json).expect("deserialization failed");
match deserialized {
TrustEvent::TrustRequired {
cwd,
repo,
worktree,
} => {
assert_eq!(cwd, "/tmp/test");
assert_eq!(repo, Some("test-repo".to_string()));
assert_eq!(worktree, Some("/tmp/test/.git".to_string()));
}
_ => panic!("wrong event type"),
}
}
#[test]
fn trust_event_resolved_serialization() {
let event = TrustEvent::TrustResolved {
cwd: "/tmp/test".to_string(),
policy: TrustPolicy::AutoTrust,
resolution: TrustResolution::AutoAllowlisted,
};
let json = serde_json::to_string(&event).expect("serialization failed");
assert!(json.contains("trust_resolved"));
assert!(json.contains("auto_allowlisted"));
let deserialized: TrustEvent = serde_json::from_str(&json).expect("deserialization failed");
match deserialized {
TrustEvent::TrustResolved { resolution, .. } => {
assert_eq!(resolution, TrustResolution::AutoAllowlisted);
}
_ => panic!("wrong event type"),
}
}
}
#[cfg(test)]
mod tests {
use super::{
detect_trust_prompt, path_matches_trusted_root, TrustConfig, TrustDecision, TrustEvent,
TrustPolicy, TrustResolver,
detect_manual_approval, detect_trust_prompt, path_matches_trusted_root,
TrustAllowlistEntry, TrustConfig, TrustDecision, TrustEvent, TrustPolicy, TrustResolution,
TrustResolver,
};
#[test]
@@ -197,7 +689,7 @@ mod tests {
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees"));
// when
let decision = resolver.resolve("/tmp/worktrees/repo-a", "Ready for your input\n>");
let decision = resolver.resolve("/tmp/worktrees/repo-a", None, "Ready for your input\n>");
// then
assert_eq!(decision, TrustDecision::NotRequired);
@@ -213,23 +705,23 @@ mod tests {
// when
let decision = resolver.resolve(
"/tmp/worktrees/repo-a",
None,
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
);
// then
assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust));
assert_eq!(
decision.events(),
&[
TrustEvent::TrustRequired {
cwd: "/tmp/worktrees/repo-a".to_string(),
},
TrustEvent::TrustResolved {
cwd: "/tmp/worktrees/repo-a".to_string(),
policy: TrustPolicy::AutoTrust,
},
]
);
let events = decision.events();
assert_eq!(events.len(), 2);
assert!(matches!(events[0], TrustEvent::TrustRequired { .. }));
assert!(matches!(
events[1],
TrustEvent::TrustResolved {
policy: TrustPolicy::AutoTrust,
resolution: TrustResolution::AutoAllowlisted,
..
}
));
}
#[test]
@@ -240,6 +732,7 @@ mod tests {
// when
let decision = resolver.resolve(
"/tmp/other/repo-b",
None,
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
);
@@ -249,6 +742,8 @@ mod tests {
decision.events(),
&[TrustEvent::TrustRequired {
cwd: "/tmp/other/repo-b".to_string(),
repo: Some("repo-b".to_string()),
worktree: None,
}]
);
}
@@ -265,6 +760,7 @@ mod tests {
// when
let decision = resolver.resolve(
"/tmp/worktrees/repo-c",
None,
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
);
@@ -275,6 +771,8 @@ mod tests {
&[
TrustEvent::TrustRequired {
cwd: "/tmp/worktrees/repo-c".to_string(),
repo: Some("repo-c".to_string()),
worktree: None,
},
TrustEvent::TrustDenied {
cwd: "/tmp/worktrees/repo-c".to_string(),
@@ -284,6 +782,66 @@ mod tests {
);
}
#[test]
fn auto_trusts_with_glob_pattern_allowlist() {
// given
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees/*"));
// when - any repo under /tmp/worktrees should auto-trust
let decision = resolver.resolve(
"/tmp/worktrees/repo-a",
None,
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
);
// then
assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust));
}
#[test]
fn resolve_with_worktree_pattern_matching() {
// given
let config = TrustConfig::new().with_allowlisted_entry(
TrustAllowlistEntry::new("/tmp/worktrees/*").with_worktree_pattern("*/.git"),
);
let resolver = TrustResolver::new(config);
// when - with worktree that matches the pattern
let decision = resolver.resolve(
"/tmp/worktrees/repo-a",
Some("/tmp/worktrees/repo-a/.git"),
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
);
// then - should auto-trust because both patterns match
assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust));
}
#[test]
fn manual_approval_detected_from_screen_text() {
// given
let resolver = TrustResolver::new(TrustConfig::new());
// when - screen text indicates manual approval
let decision = resolver.resolve(
"/tmp/some/repo",
None,
"Do you trust the files in this folder?\nUser selected: Yes, I trust this folder",
);
// then - should detect manual approval
assert_eq!(decision.policy(), Some(TrustPolicy::RequireApproval));
let events = decision.events();
assert!(events.len() >= 2);
assert!(matches!(
events[events.len() - 1],
TrustEvent::TrustResolved {
resolution: TrustResolution::ManualApproval,
..
}
));
}
#[test]
fn sibling_prefix_does_not_match_trusted_root() {
// given
@@ -296,4 +854,70 @@ mod tests {
// then
assert!(!matched);
}
#[test]
fn detects_manual_approval_cues() {
assert!(detect_manual_approval(
"User selected: Yes, I trust this folder"
));
assert!(detect_manual_approval(
"I trust this repository and its contents"
));
assert!(detect_manual_approval("Approval granted by user"));
assert!(!detect_manual_approval(
"Do you trust the files in this folder?"
));
assert!(!detect_manual_approval("Some unrelated text"));
}
#[test]
fn trust_config_default_emit_events() {
let config = TrustConfig::default();
assert!(config.emit_events);
}
#[test]
fn trust_resolver_trusts_method() {
let resolver = TrustResolver::new(
TrustConfig::new()
.with_allowlisted("/tmp/worktrees/*")
.with_denied("/tmp/worktrees/bad-repo"),
);
// Should trust allowlisted paths
assert!(resolver.trusts("/tmp/worktrees/good-repo", None));
// Should not trust denied paths
assert!(!resolver.trusts("/tmp/worktrees/bad-repo", None));
// Should not trust unknown paths
assert!(!resolver.trusts("/tmp/other/repo", None));
}
#[test]
fn trust_policy_serde_roundtrip() {
for policy in [
TrustPolicy::AutoTrust,
TrustPolicy::RequireApproval,
TrustPolicy::Deny,
] {
let json = serde_json::to_string(&policy).expect("serialization failed");
let deserialized: TrustPolicy =
serde_json::from_str(&json).expect("deserialization failed");
assert_eq!(policy, deserialized);
}
}
#[test]
fn trust_resolution_serde_roundtrip() {
for resolution in [
TrustResolution::AutoAllowlisted,
TrustResolution::ManualApproval,
] {
let json = serde_json::to_string(&resolution).expect("serialization failed");
let deserialized: TrustResolution =
serde_json::from_str(&json).expect("deserialization failed");
assert_eq!(resolution, deserialized);
}
}
}

View File

@@ -30,6 +30,7 @@ fn now_secs() -> u64 {
pub enum WorkerStatus {
Spawning,
TrustRequired,
ToolPermissionRequired,
ReadyForPrompt,
Running,
Finished,
@@ -41,6 +42,7 @@ impl std::fmt::Display for WorkerStatus {
match self {
Self::Spawning => write!(f, "spawning"),
Self::TrustRequired => write!(f, "trust_required"),
Self::ToolPermissionRequired => write!(f, "tool_permission_required"),
Self::ReadyForPrompt => write!(f, "ready_for_prompt"),
Self::Running => write!(f, "running"),
Self::Finished => write!(f, "finished"),
@@ -53,6 +55,7 @@ impl std::fmt::Display for WorkerStatus {
#[serde(rename_all = "snake_case")]
pub enum WorkerFailureKind {
TrustGate,
ToolPermissionGate,
PromptDelivery,
Protocol,
Provider,
@@ -71,6 +74,7 @@ pub struct WorkerFailure {
pub enum WorkerEventKind {
Spawning,
TrustRequired,
ToolPermissionRequired,
TrustResolved,
ReadyForPrompt,
PromptMisdelivery,
@@ -104,6 +108,8 @@ pub enum WorkerPromptTarget {
pub enum StartupFailureClassification {
/// Trust prompt is required but not detected/resolved
TrustRequired,
/// Tool permission prompt is required before startup can continue
ToolPermissionRequired,
/// Prompt was delivered to wrong target (shell misdelivery)
PromptMisdelivery,
/// Prompt was sent but acceptance timed out
@@ -130,6 +136,14 @@ pub struct StartupEvidenceBundle {
pub prompt_acceptance_state: bool,
/// Result of trust prompt detection at timeout
pub trust_prompt_detected: bool,
/// Result of tool permission prompt detection at timeout
pub tool_permission_prompt_detected: bool,
/// Age in seconds of the latest tool permission prompt, when observed
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_permission_prompt_age_seconds: Option<u64>,
/// Whether the prompt surface exposed only a session allow path or also an always-allow path
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_permission_allow_scope: Option<ToolPermissionAllowScope>,
/// Transport health summary (true = healthy/responsive)
pub transport_healthy: bool,
/// MCP health summary (true = all servers healthy)
@@ -146,6 +160,15 @@ pub enum WorkerEventPayload {
#[serde(skip_serializing_if = "Option::is_none")]
resolution: Option<WorkerTrustResolution>,
},
ToolPermissionPrompt {
#[serde(skip_serializing_if = "Option::is_none")]
server_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_name: Option<String>,
prompt_age_seconds: u64,
allow_scope: ToolPermissionAllowScope,
prompt_preview: String,
},
PromptDelivery {
prompt_preview: String,
observed_target: WorkerPromptTarget,
@@ -163,6 +186,14 @@ pub enum WorkerEventPayload {
},
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ToolPermissionAllowScope {
SessionOnly,
SessionOrAlways,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerTaskReceipt {
pub repo: String,
@@ -276,6 +307,29 @@ impl WorkerRegistry {
.ok_or_else(|| format!("worker not found: {worker_id}"))?;
let lowered = screen_text.to_ascii_lowercase();
if let Some(tool_prompt) = detect_tool_permission_prompt(screen_text, &lowered) {
worker.status = WorkerStatus::ToolPermissionRequired;
worker.last_error = Some(WorkerFailure {
kind: WorkerFailureKind::ToolPermissionGate,
message: tool_prompt.message(),
created_at: now_secs(),
});
push_event(
worker,
WorkerEventKind::ToolPermissionRequired,
WorkerStatus::ToolPermissionRequired,
Some("tool permission prompt detected".to_string()),
Some(WorkerEventPayload::ToolPermissionPrompt {
server_name: tool_prompt.server_name,
tool_name: tool_prompt.tool_name,
prompt_age_seconds: 0,
allow_scope: tool_prompt.allow_scope,
prompt_preview: tool_prompt.prompt_preview,
}),
);
return Ok(worker.clone());
}
if !worker.trust_gate_cleared && detect_trust_prompt(&lowered) {
worker.status = WorkerStatus::TrustRequired;
worker.last_error = Some(WorkerFailure {
@@ -503,7 +557,9 @@ impl WorkerRegistry {
ready: worker.status == WorkerStatus::ReadyForPrompt,
blocked: matches!(
worker.status,
WorkerStatus::TrustRequired | WorkerStatus::Failed
WorkerStatus::TrustRequired
| WorkerStatus::ToolPermissionRequired
| WorkerStatus::Failed
),
replay_prompt_ready: worker.replay_prompt.is_some(),
last_error: worker.last_error.clone(),
@@ -624,6 +680,18 @@ impl WorkerRegistry {
let now = now_secs();
let elapsed = now.saturating_sub(worker.created_at);
let latest_tool_permission_event = worker
.events
.iter()
.rev()
.find(|event| event.kind == WorkerEventKind::ToolPermissionRequired);
let tool_permission_allow_scope =
latest_tool_permission_event.and_then(|event| match &event.payload {
Some(WorkerEventPayload::ToolPermissionPrompt { allow_scope, .. }) => {
Some(*allow_scope)
}
_ => None,
});
// Build evidence bundle
let evidence = StartupEvidenceBundle {
@@ -640,6 +708,13 @@ impl WorkerRegistry {
.events
.iter()
.any(|e| e.kind == WorkerEventKind::TrustRequired),
tool_permission_prompt_detected: worker
.events
.iter()
.any(|e| e.kind == WorkerEventKind::ToolPermissionRequired),
tool_permission_prompt_age_seconds: latest_tool_permission_event
.map(|event| now.saturating_sub(event.timestamp)),
tool_permission_allow_scope,
transport_healthy,
mcp_healthy,
elapsed_seconds: elapsed,
@@ -694,6 +769,13 @@ fn classify_startup_failure(evidence: &StartupEvidenceBundle) -> StartupFailureC
return StartupFailureClassification::TrustRequired;
}
// Check for tool permission prompts that were not resolved
if evidence.tool_permission_prompt_detected
&& evidence.last_lifecycle_state == WorkerStatus::ToolPermissionRequired
{
return StartupFailureClassification::ToolPermissionRequired;
}
// Check for prompt acceptance timeout
if evidence.prompt_sent_at.is_some()
&& !evidence.prompt_acceptance_state
@@ -815,6 +897,140 @@ fn normalize_path(path: &str) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| Path::new(path).to_path_buf())
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ToolPermissionPromptObservation {
server_name: Option<String>,
tool_name: Option<String>,
allow_scope: ToolPermissionAllowScope,
prompt_preview: String,
}
impl ToolPermissionPromptObservation {
fn message(&self) -> String {
match (&self.server_name, &self.tool_name) {
(Some(server), Some(tool)) => {
format!("worker boot blocked on tool permission prompt for {server}.{tool}")
}
(Some(server), None) => {
format!("worker boot blocked on tool permission prompt for {server}")
}
(None, Some(tool)) => {
format!("worker boot blocked on tool permission prompt for {tool}")
}
(None, None) => "worker boot blocked on tool permission prompt".to_string(),
}
}
}
fn detect_tool_permission_prompt(
screen_text: &str,
lowered: &str,
) -> Option<ToolPermissionPromptObservation> {
let looks_like_prompt = lowered.contains("allow the")
&& lowered.contains("server")
&& lowered.contains("tool")
&& lowered.contains("run");
let looks_like_tool_gate = lowered.contains("allow tool") && lowered.contains("run");
if !looks_like_prompt && !looks_like_tool_gate {
return None;
}
let prompt_line = screen_text
.lines()
.rev()
.find(|line| {
let lowered_line = line.to_ascii_lowercase();
lowered_line.contains("allow")
&& lowered_line.contains("tool")
&& (lowered_line.contains("run") || lowered_line.contains("server"))
})
.unwrap_or(screen_text)
.trim();
let tool_name = extract_quoted_value(prompt_line)
.or_else(|| extract_after(prompt_line, "tool ").map(|token| normalize_tool_token(&token)));
let server_name = extract_between(prompt_line, "the ", " server")
.map(|server| server.trim_end_matches(" MCP").to_string())
.or_else(|| {
tool_name
.as_deref()
.and_then(extract_server_from_qualified_tool)
});
Some(ToolPermissionPromptObservation {
server_name,
tool_name,
allow_scope: detect_tool_permission_allow_scope(lowered),
prompt_preview: prompt_preview(prompt_line),
})
}
fn detect_tool_permission_allow_scope(lowered: &str) -> ToolPermissionAllowScope {
let always_allow_capable = [
"always allow",
"allow always",
"allow this tool always",
"allow for all sessions",
]
.iter()
.any(|needle| lowered.contains(needle));
if always_allow_capable {
return ToolPermissionAllowScope::SessionOrAlways;
}
let session_allow_capable = [
"allow once",
"allow for this session",
"allow this session",
"yes, allow",
]
.iter()
.any(|needle| lowered.contains(needle));
if session_allow_capable {
ToolPermissionAllowScope::SessionOnly
} else {
ToolPermissionAllowScope::Unknown
}
}
fn extract_quoted_value(text: &str) -> Option<String> {
let start = text.find('"')? + 1;
let rest = &text[start..];
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
fn extract_between(text: &str, prefix: &str, suffix: &str) -> Option<String> {
let start = text.find(prefix)? + prefix.len();
let rest = &text[start..];
let end = rest.find(suffix)?;
let value = rest[..end].trim();
(!value.is_empty()).then(|| value.to_string())
}
fn extract_after(text: &str, prefix: &str) -> Option<String> {
let start = text.to_ascii_lowercase().find(prefix)? + prefix.len();
let value = text[start..]
.split_whitespace()
.next()?
.trim_matches(|ch: char| ch == '?' || ch == ':' || ch == '"' || ch == '\'');
(!value.is_empty()).then(|| value.to_string())
}
fn normalize_tool_token(token: &str) -> String {
token
.trim_matches(|ch: char| ch == '?' || ch == ':' || ch == '"' || ch == '\'')
.to_string()
}
fn extract_server_from_qualified_tool(tool: &str) -> Option<String> {
let rest = tool.strip_prefix("mcp__")?;
let (server, _) = rest.split_once("__")?;
(!server.is_empty()).then(|| server.to_string())
}
fn detect_trust_prompt(lowered: &str) -> bool {
[
"do you trust the files in this folder",
@@ -1134,6 +1350,96 @@ mod tests {
assert!(detect_ready_for_prompt("│ >", "│ >"));
}
#[test]
fn tool_permission_prompt_blocks_worker_with_structured_event() {
let registry = WorkerRegistry::new();
let worker = registry.create("/tmp/repo-mcp", &[], true);
let blocked = registry
.observe(
&worker.worker_id,
"Allow the omx_memory MCP server to run tool \"project_memory_read\"?\n\
1. Yes, allow once\n\
2. Always allow this tool",
)
.expect("tool permission observe should succeed");
assert_eq!(blocked.status, WorkerStatus::ToolPermissionRequired);
assert_eq!(
blocked
.last_error
.as_ref()
.expect("tool permission error should exist")
.kind,
WorkerFailureKind::ToolPermissionGate
);
let event = blocked
.events
.iter()
.find(|event| event.kind == WorkerEventKind::ToolPermissionRequired)
.expect("tool permission event should exist");
assert_eq!(
event.payload,
Some(WorkerEventPayload::ToolPermissionPrompt {
server_name: Some("omx_memory".to_string()),
tool_name: Some("project_memory_read".to_string()),
prompt_age_seconds: 0,
allow_scope: ToolPermissionAllowScope::SessionOrAlways,
prompt_preview: prompt_preview(
"Allow the omx_memory MCP server to run tool \"project_memory_read\"?",
),
})
);
let readiness = registry
.await_ready(&worker.worker_id)
.expect("ready snapshot should load");
assert!(readiness.blocked);
assert!(!readiness.ready);
}
#[test]
fn startup_timeout_classifies_tool_permission_prompt() {
let registry = WorkerRegistry::new();
let worker = registry.create("/tmp/repo-mcp-timeout", &[], true);
registry
.observe(
&worker.worker_id,
"Allow the omx_memory MCP server to run tool \"notepad_read\"?\n\
1. Yes, allow once",
)
.expect("tool permission observe should succeed");
let timed_out = registry
.observe_startup_timeout(&worker.worker_id, "claw prompt", true, true)
.expect("startup timeout observe should succeed");
let event = timed_out
.events
.iter()
.find(|event| event.kind == WorkerEventKind::StartupNoEvidence)
.expect("startup no evidence event should exist");
match event.payload.as_ref() {
Some(WorkerEventPayload::StartupNoEvidence {
classification,
evidence,
}) => {
assert_eq!(
*classification,
StartupFailureClassification::ToolPermissionRequired
);
assert!(evidence.tool_permission_prompt_detected);
assert_eq!(
evidence.tool_permission_allow_scope,
Some(ToolPermissionAllowScope::SessionOnly)
);
assert!(evidence.tool_permission_prompt_age_seconds.is_some());
}
_ => panic!("expected StartupNoEvidence payload"),
}
}
#[test]
fn prompt_misdelivery_is_detected_and_replay_can_be_rearmed() {
let registry = WorkerRegistry::new();
@@ -1634,6 +1940,9 @@ mod tests {
prompt_sent_at: Some(1_234_567_890),
prompt_acceptance_state: false,
trust_prompt_detected: true,
tool_permission_prompt_detected: false,
tool_permission_prompt_age_seconds: None,
tool_permission_allow_scope: None,
transport_healthy: true,
mcp_healthy: false,
elapsed_seconds: 60,
@@ -1661,6 +1970,9 @@ mod tests {
prompt_sent_at: None,
prompt_acceptance_state: false,
trust_prompt_detected: false,
tool_permission_prompt_detected: false,
tool_permission_prompt_age_seconds: None,
tool_permission_allow_scope: None,
transport_healthy: false,
mcp_healthy: true,
elapsed_seconds: 30,
@@ -1678,6 +1990,9 @@ mod tests {
prompt_sent_at: None,
prompt_acceptance_state: false,
trust_prompt_detected: false,
tool_permission_prompt_detected: false,
tool_permission_prompt_age_seconds: None,
tool_permission_allow_scope: None,
transport_healthy: true,
mcp_healthy: true,
elapsed_seconds: 10,
@@ -1697,6 +2012,9 @@ mod tests {
prompt_sent_at: None, // No prompt sent yet
prompt_acceptance_state: false,
trust_prompt_detected: false,
tool_permission_prompt_detected: false,
tool_permission_prompt_age_seconds: None,
tool_permission_allow_scope: None,
transport_healthy: true,
mcp_healthy: false, // MCP unhealthy but transport healthy suggests crash
elapsed_seconds: 45,

File diff suppressed because it is too large Load Diff

View File

@@ -126,6 +126,66 @@ fn compact_flag_streaming_text_only_emits_final_message_text() {
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
}
#[test]
fn text_prompt_mode_prints_final_assistant_text_after_spinner() {
// given a workspace pointed at the mock Anthropic service running the
// streaming_text scenario which only 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-mode");
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 for the streaming text scenario
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 stdout should contain the final assistant text, not just spinner output
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!(
plain_stdout.contains("Mock streaming says hello from the parity harness."),
"text prompt stdout should include the assistant text ({stdout:?})"
);
assert!(
plain_stdout.contains("✔ ✨ Done"),
"text prompt stdout should still include spinner completion ({stdout:?})"
);
assert!(
plain_stdout
.lines()
.any(|line| line == "Mock streaming says hello from the parity harness."),
"text prompt stdout should print the assistant text as its own line ({stdout:?})"
);
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
}
#[test]
fn compact_flag_with_json_output_emits_structured_json() {
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
@@ -172,7 +232,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());
@@ -212,3 +275,21 @@ fn unique_temp_dir(label: &str) -> PathBuf {
std::process::id()
))
}
fn strip_ansi_codes(input: &str) -> String {
let mut output = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\u{1b}' && matches!(chars.peek(), Some('[')) {
chars.next();
while let Some(next) = chars.next() {
if ('@'..='~').contains(&next) {
break;
}
}
continue;
}
output.push(ch);
}
output
}

View File

@@ -0,0 +1,138 @@
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Output, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[test]
fn compact_slash_command_in_repl_does_not_start_nested_tokio_runtime() {
// given
let workspace = unique_temp_dir("compact-repl-panic");
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
let output = run_claw_repl(&workspace, &config_home, &home, "/compact\n/exit\n");
// then
assert!(
output.status.success(),
"compact repl run should succeed\nstdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8");
assert!(
!stderr.contains("Cannot start a runtime"),
"stderr must not contain nested runtime panic: {stderr:?}"
);
assert!(
!stderr.contains("panicked at"),
"stderr must not contain panic output: {stderr:?}"
);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
let plain_stdout = strip_ansi_codes(&stdout);
assert!(
plain_stdout.contains("Compaction skipped")
|| plain_stdout.contains("Result skipped")
|| plain_stdout.contains("Result compacted"),
"stdout should contain compact report output ({stdout:?})"
);
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
}
fn run_claw_repl(
cwd: &std::path::Path,
config_home: &std::path::Path,
home: &std::path::Path,
stdin: &str,
) -> Output {
let mut command = python_pty_command(env!("CARGO_BIN_EXE_claw"));
let mut child = command
.current_dir(cwd)
.env_clear()
.env("ANTHROPIC_API_KEY", "test-compact-repl-key")
.env("CLAW_CONFIG_HOME", config_home)
.env("HOME", home)
.env("NO_COLOR", "1")
.env("PATH", "/usr/bin:/bin")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("claw should launch");
child
.stdin
.as_mut()
.expect("stdin should be piped")
.write_all(stdin.as_bytes())
.expect("stdin should write");
child.wait_with_output().expect("claw should finish")
}
fn python_pty_command(claw: &str) -> Command {
let mut command = Command::new("python3");
command.args([
"-c",
r#"
import os
import pty
import subprocess
import sys
claw = sys.argv[1]
payload = sys.stdin.buffer.read()
master, slave = pty.openpty()
child = subprocess.Popen([claw], stdin=slave, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
os.close(slave)
os.write(master, payload)
stdout, stderr = child.communicate(timeout=30)
os.close(master)
sys.stdout.buffer.write(stdout)
sys.stderr.buffer.write(stderr)
raise SystemExit(child.returncode)
"#,
claw,
]);
command
}
fn unique_temp_dir(label: &str) -> PathBuf {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be after epoch")
.as_millis();
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"claw-{label}-{}-{millis}-{counter}",
std::process::id()
))
}
fn strip_ansi_codes(input: &str) -> String {
let mut output = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\u{1b}' && matches!(chars.peek(), Some('[')) {
chars.next();
for next in chars.by_ref() {
if ('@'..='~').contains(&next) {
break;
}
}
continue;
}
output.push(ch);
}
output
}

View File

@@ -22,6 +22,42 @@ fn help_emits_json_when_requested() {
.contains("Usage:"));
}
#[test]
fn export_help_emits_bounded_json_when_requested_384() {
let root = unique_temp_dir("export-help-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let parsed = assert_json_command(&root, &["export", "--help", "--output-format", "json"]);
assert_eq!(parsed["kind"], "help");
assert_eq!(parsed["topic"], "export");
assert_eq!(parsed["command"], "export");
assert_eq!(
parsed["usage"],
"claw export [--session <id|latest>] [--output <path>] [--output-format <format>]"
);
assert_eq!(parsed["defaults"]["session"], "latest");
assert!(parsed["options"].as_array().expect("options").len() >= 4);
assert!(parsed.get("message").is_none());
}
#[test]
fn export_help_preserves_plaintext_in_text_mode_384() {
let root = unique_temp_dir("export-help-text");
fs::create_dir_all(&root).expect("temp dir should exist");
let output = run_claw(&root, &["export", "--help"], &[]);
assert!(
output.status.success(),
"stdout:\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 utf8");
assert!(stdout.starts_with("Export\n"));
assert!(stdout.contains("Usage claw export"));
serde_json::from_str::<Value>(&stdout).expect_err("text help should remain plaintext");
}
#[test]
fn version_emits_json_when_requested() {
let root = unique_temp_dir("version-json");
@@ -30,6 +66,15 @@ fn version_emits_json_when_requested() {
let parsed = assert_json_command(&root, &["--output-format", "json", "version"]);
assert_eq!(parsed["kind"], "version");
assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
// Provenance fields must be present for binary identification (#507).
assert!(
parsed["build_date"].is_string(),
"build_date must be a string in version JSON"
);
assert!(
parsed["executable_path"].is_string(),
"executable_path must be a string in version JSON so callers can identify which binary is running"
);
}
#[test]
@@ -105,6 +150,18 @@ fn inventory_commands_emit_structured_json_when_requested() {
let skills = assert_json_command(&root, &["--output-format", "json", "skills"]);
assert_eq!(skills["kind"], "skills");
assert_eq!(skills["action"], "list");
let plugins = assert_json_command(&root, &["--output-format", "json", "plugins"]);
assert_eq!(plugins["kind"], "plugin");
assert_eq!(plugins["action"], "list");
assert!(
plugins["reload_runtime"].is_boolean(),
"plugins reload_runtime should be a boolean"
);
assert!(
plugins["target"].is_null(),
"plugins target should be null when no plugin is targeted"
);
}
#[test]
@@ -348,6 +405,62 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() {
assert_eq!(skills["action"], "list");
assert!(skills["summary"]["total"].is_number());
assert!(skills["skills"].is_array());
let agents = assert_json_command_with_env(
&root,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 session path"),
"/agents",
],
&[
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
],
);
assert_eq!(agents["kind"], "agents");
assert_eq!(agents["action"], "list");
assert!(
agents["agents"].is_array(),
"agents field must be a JSON array"
);
assert!(
agents["count"].is_number(),
"count must be a number, not a text render"
);
let plugins = assert_json_command_with_env(
&root,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 session path"),
"/plugins",
],
&[
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
],
);
assert_eq!(plugins["kind"], "plugin");
assert_eq!(plugins["action"], "list");
assert!(
plugins["reload_runtime"].is_boolean(),
"plugins reload_runtime should be a boolean"
);
assert!(
plugins["target"].is_null(),
"plugins target should be null when no plugin is targeted"
);
}
#[test]
@@ -384,6 +497,44 @@ fn resumed_version_and_init_emit_structured_json_when_requested() {
assert!(root.join("CLAUDE.md").exists());
}
#[test]
fn config_section_json_emits_section_and_value() {
let root = unique_temp_dir("config-section-json");
fs::create_dir_all(&root).expect("temp dir should exist");
// Without a section: should return base envelope (no section field).
let base = assert_json_command(&root, &["--output-format", "json", "config"]);
assert_eq!(base["kind"], "config");
assert!(base["loaded_files"].is_number());
assert!(base["merged_keys"].is_number());
assert!(
base.get("section").is_none(),
"no section field without section arg"
);
// With a known section: should add section + section_value fields.
for section in &["model", "env", "hooks", "plugins"] {
let result = assert_json_command(&root, &["--output-format", "json", "config", section]);
assert_eq!(result["kind"], "config", "section={section}");
assert_eq!(
result["section"].as_str(),
Some(*section),
"section field must match requested section, got {result:?}"
);
assert!(
result.get("section_value").is_some(),
"section_value field must be present for section={section}"
);
}
// With an unsupported section: should return ok:false + error field.
let bad = assert_json_command(&root, &["--output-format", "json", "config", "unknown"]);
assert_eq!(bad["kind"], "config");
assert_eq!(bad["ok"], false);
assert!(bad["error"].as_str().is_some());
assert!(bad["section"].as_str().is_some());
}
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
assert_json_command_with_env(current_dir, args, &[])
}

View File

@@ -227,6 +227,8 @@ fn resumed_status_command_emits_structured_json_when_requested() {
// given
let temp_dir = unique_temp_dir("resume-status-json");
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let config_home = temp_dir.join("config-home");
fs::create_dir_all(&config_home).expect("isolated config home should exist");
let session_path = temp_dir.join("session.jsonl");
let mut session = workspace_session(&temp_dir);
@@ -238,7 +240,9 @@ fn resumed_status_command_emits_structured_json_when_requested() {
.expect("session should persist");
// when
let output = run_claw(
// Use an isolated CLAW_CONFIG_HOME so ~/.claw/settings.json is not loaded,
// which would cause loaded_config_files to be non-zero (#65).
let output = run_claw_with_env(
&temp_dir,
&[
"--output-format",
@@ -247,6 +251,7 @@ fn resumed_status_command_emits_structured_json_when_requested() {
session_path.to_str().expect("utf8 path"),
"/status",
],
&[("CLAW_CONFIG_HOME", config_home.to_str().expect("utf8 path"))],
);
// then

View File

@@ -4,9 +4,10 @@ use std::process::Command;
use std::time::{Duration, Instant};
use api::{
max_tokens_for_model, resolve_model_alias, ApiError, ContentBlockDelta, InputContentBlock,
InputMessage, MessageRequest, MessageResponse, OutputContentBlock, ProviderClient,
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
max_tokens_for_model, model_family_identity_for, resolve_model_alias, ApiError,
ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse,
OutputContentBlock, ProviderClient, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
ToolResultContentBlock,
};
use plugins::PluginTool;
use reqwest::blocking::Client;
@@ -240,6 +241,13 @@ impl GlobalToolRegistry {
}
}
if allowed.is_empty() {
return Err(format!(
"--allowedTools was provided with no usable tool names (got `{}`). Omit the flag to allow all tools.",
values.join(" ")
));
}
Ok(Some(allowed))
}
@@ -3068,27 +3076,33 @@ fn extract_quoted_value(input: &str) -> Option<(String, &str)> {
}
fn decode_duckduckgo_redirect(url: &str) -> Option<String> {
if url.starts_with("http://") || url.starts_with("https://") {
return Some(html_entity_decode_url(url));
}
let joined = if url.starts_with("//") {
format!("https:{url}")
} else if url.starts_with('/') {
format!("https://duckduckgo.com{url}")
let decoded = html_entity_decode_url(url);
let parsed = if decoded.starts_with("http://") || decoded.starts_with("https://") {
reqwest::Url::parse(&decoded).ok()
} else if decoded.starts_with("//") {
reqwest::Url::parse(&format!("https:{decoded}")).ok()
} else if decoded.starts_with('/') {
reqwest::Url::parse(&format!("https://duckduckgo.com{decoded}")).ok()
} else {
return None;
};
}?;
let parsed = reqwest::Url::parse(&joined).ok()?;
if parsed.path() == "/l/" || parsed.path() == "/l" {
let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase();
if (host == "duckduckgo.com" || host.ends_with(".duckduckgo.com"))
&& (parsed.path() == "/l/" || parsed.path() == "/l")
{
for (key, value) in parsed.query_pairs() {
if key == "uddg" {
return Some(html_entity_decode_url(value.as_ref()));
}
}
}
Some(joined)
if decoded.starts_with("http://") || decoded.starts_with("https://") {
Some(decoded)
} else {
Some(parsed.to_string())
}
}
fn html_entity_decode_url(url: &str) -> String {
@@ -3503,7 +3517,7 @@ where
.filter(|name| !name.is_empty())
.unwrap_or_else(|| slugify_agent_name(&input.description));
let created_at = iso8601_now();
let system_prompt = build_agent_system_prompt(&normalized_subagent_type)?;
let system_prompt = build_agent_system_prompt(&normalized_subagent_type, &model)?;
let allowed_tools = allowed_tools_for_subagent(&normalized_subagent_type);
let output_contents = format!(
@@ -3616,13 +3630,14 @@ fn build_agent_runtime(
))
}
fn build_agent_system_prompt(subagent_type: &str) -> Result<Vec<String>, String> {
fn build_agent_system_prompt(subagent_type: &str, model: &str) -> Result<Vec<String>, String> {
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
let mut prompt = load_system_prompt(
cwd,
DEFAULT_AGENT_SYSTEM_DATE.to_string(),
std::env::consts::OS,
"unknown",
model_family_identity_for(model),
)
.map_err(|error| error.to_string())?;
prompt.push(format!(
@@ -4623,13 +4638,21 @@ async fn stream_with_provider(
let mut stream = client.stream_message(message_request).await?;
let mut events = Vec::new();
let mut pending_tools: BTreeMap<u32, (String, String, String)> = BTreeMap::new();
let mut pending_thinking: BTreeMap<u32, (String, Option<String>)> = BTreeMap::new();
let mut saw_stop = false;
while let Some(event) = stream.next_event().await? {
match event {
ApiStreamEvent::MessageStart(start) => {
for block in start.message.content {
push_output_block(block, 0, &mut events, &mut pending_tools, true);
push_output_block(
block,
0,
&mut events,
&mut pending_tools,
&mut pending_thinking,
true,
);
}
}
ApiStreamEvent::ContentBlockStart(start) => {
@@ -4638,6 +4661,7 @@ async fn stream_with_provider(
start.index,
&mut events,
&mut pending_tools,
&mut pending_thinking,
true,
);
}
@@ -4652,10 +4676,26 @@ async fn stream_with_provider(
input.push_str(&partial_json);
}
}
ContentBlockDelta::ThinkingDelta { .. }
| ContentBlockDelta::SignatureDelta { .. } => {}
ContentBlockDelta::ThinkingDelta { thinking } => {
if let Some((pending, _)) = pending_thinking.get_mut(&delta.index) {
pending.push_str(&thinking);
}
}
ContentBlockDelta::SignatureDelta { signature } => {
if let Some((_, pending_signature)) = pending_thinking.get_mut(&delta.index) {
pending_signature
.get_or_insert_with(String::new)
.push_str(&signature);
}
}
},
ApiStreamEvent::ContentBlockStop(stop) => {
if let Some((thinking, signature)) = pending_thinking.remove(&stop.index) {
events.push(AssistantEvent::Thinking {
thinking,
signature,
});
}
if let Some((id, name, input)) = pending_tools.remove(&stop.index) {
events.push(AssistantEvent::ToolUse { id, name, input });
}
@@ -4752,6 +4792,13 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
.iter()
.map(|block| match block {
ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
ContentBlock::Thinking {
thinking,
signature,
} => InputContentBlock::Thinking {
thinking: thinking.clone(),
signature: signature.clone(),
},
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
@@ -4771,6 +4818,9 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
is_error: *is_error,
},
})
.filter(
|block| !matches!(block, InputContentBlock::Text { text } if text.is_empty()),
)
.collect::<Vec<_>>();
(!content.is_empty()).then(|| InputMessage {
role: role.to_string(),
@@ -4785,6 +4835,7 @@ fn push_output_block(
block_index: u32,
events: &mut Vec<AssistantEvent>,
pending_tools: &mut BTreeMap<u32, (String, String, String)>,
pending_thinking: &mut BTreeMap<u32, (String, Option<String>)>,
streaming_tool_input: bool,
) {
match block {
@@ -4804,17 +4855,38 @@ fn push_output_block(
};
pending_tools.insert(block_index, (id, name, initial_input));
}
OutputContentBlock::Thinking { .. } | OutputContentBlock::RedactedThinking { .. } => {}
OutputContentBlock::Thinking {
thinking,
signature,
} => {
if streaming_tool_input {
pending_thinking.insert(block_index, (thinking, signature));
} else {
events.push(AssistantEvent::Thinking {
thinking,
signature,
});
}
}
OutputContentBlock::RedactedThinking { .. } => {}
}
}
fn response_to_events(response: MessageResponse) -> Vec<AssistantEvent> {
let mut events = Vec::new();
let mut pending_tools = BTreeMap::new();
let mut pending_thinking = BTreeMap::new();
for (index, block) in response.content.into_iter().enumerate() {
let index = u32::try_from(index).expect("response block index overflow");
push_output_block(block, index, &mut events, &mut pending_tools, false);
push_output_block(
block,
index,
&mut events,
&mut pending_tools,
&mut pending_thinking,
false,
);
if let Some((id, name, input)) = pending_tools.remove(&index) {
events.push(AssistantEvent::ToolUse { id, name, input });
}
@@ -6127,12 +6199,13 @@ mod tests {
use std::time::Duration;
use super::{
agent_permission_policy, allowed_tools_for_subagent, classify_lane_failure,
derive_agent_state, execute_agent_with_spawn, execute_tool, extract_recovery_outcome,
final_assistant_text, global_cron_registry, maybe_commit_provenance, mvp_tool_specs,
permission_mode_from_plugin, persist_agent_terminal_state, push_output_block,
run_task_packet, AgentInput, AgentJob, GlobalToolRegistry, LaneEventName, LaneFailureClass,
ProviderRuntimeClient, SubagentToolExecutor,
agent_permission_policy, allowed_tools_for_subagent, build_agent_system_prompt,
classify_lane_failure, derive_agent_state, execute_agent_with_spawn, execute_tool,
extract_recovery_outcome, final_assistant_text, global_cron_registry,
maybe_commit_provenance, mvp_tool_specs, permission_mode_from_plugin,
persist_agent_terminal_state, push_output_block, run_task_packet, AgentInput, AgentJob,
GlobalToolRegistry, LaneEventName, LaneFailureClass, ProviderRuntimeClient,
SubagentToolExecutor,
};
use api::OutputContentBlock;
use runtime::ProviderFallbackConfig;
@@ -6883,6 +6956,21 @@ mod tests {
assert!(empty_permission.contains("unsupported plugin permission: "));
}
#[test]
fn allowed_tools_rejects_empty_token_lists() {
let registry = GlobalToolRegistry::builtin();
for raw in ["", ",,", " "] {
let err = registry
.normalize_allowed_tools(&[raw.to_string()])
.expect_err("empty allow-list input should be rejected");
assert!(
err.contains("--allowedTools was provided with no usable tool names"),
"unexpected error for {raw:?}: {err}"
);
}
}
#[test]
fn runtime_tools_extend_registry_definitions_permissions_and_search() {
let registry = GlobalToolRegistry::builtin()
@@ -7126,10 +7214,103 @@ mod tests {
assert!(error.contains("relative URL without a base") || error.contains("empty host"));
}
#[test]
fn web_search_decodes_absolute_duckduckgo_redirect_urls() {
// given
let _guard = env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let server = TestServer::spawn(Arc::new(|request_line: &str| {
assert!(request_line.contains("GET /search?q=duckduckgo+redirects "));
HttpResponse::html(
200,
"OK",
r#"
<html><body>
<a rel="nofollow" class="result__a" href="https://duckduckgo.com/l/?uddg=https%3A%2F%2Fdocs.rs%2Freqwest&amp;rut=abc">Reqwest docs</a>
</body></html>
"#,
)
}));
// when
std::env::set_var(
"CLAWD_WEB_SEARCH_BASE_URL",
format!("http://{}/search", server.addr()),
);
let result = execute_tool(
"WebSearch",
&json!({
"query": "duckduckgo redirects"
}),
)
.expect("WebSearch should succeed");
std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
// then
let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
let results = output["results"].as_array().expect("results array");
let search_result = results
.iter()
.find(|item| item.get("content").is_some())
.expect("search result block present");
let content = search_result["content"].as_array().expect("content array");
assert_eq!(content.len(), 1);
assert_eq!(content[0]["title"], "Reqwest docs");
assert_eq!(content[0]["url"], "https://docs.rs/reqwest");
}
#[test]
fn web_search_decodes_protocol_relative_duckduckgo_redirect_urls() {
// given
let _guard = env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let server = TestServer::spawn(Arc::new(|request_line: &str| {
assert!(request_line.contains("GET /search?q=duckduckgo+protocol+relative "));
HttpResponse::html(
200,
"OK",
r#"
<html><body>
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fdocs.rs%2Ftokio&amp;rut=xyz">Tokio Docs</a>
</body></html>
"#,
)
}));
// when
std::env::set_var(
"CLAWD_WEB_SEARCH_BASE_URL",
format!("http://{}/search", server.addr()),
);
let result = execute_tool(
"WebSearch",
&json!({
"query": "duckduckgo protocol relative"
}),
)
.expect("WebSearch should succeed");
std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
// then
let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
let results = output["results"].as_array().expect("results array");
let search_result = results
.iter()
.find(|item| item.get("content").is_some())
.expect("search result block present");
let content = search_result["content"].as_array().expect("content array");
assert_eq!(content.len(), 1);
assert_eq!(content[0]["title"], "Tokio Docs");
assert_eq!(content[0]["url"], "https://docs.rs/tokio");
}
#[test]
fn pending_tools_preserve_multiple_streaming_tool_calls_by_index() {
let mut events = Vec::new();
let mut pending_tools = BTreeMap::new();
let mut pending_thinking = BTreeMap::new();
push_output_block(
OutputContentBlock::ToolUse {
@@ -7140,6 +7321,7 @@ mod tests {
1,
&mut events,
&mut pending_tools,
&mut pending_thinking,
true,
);
push_output_block(
@@ -7151,6 +7333,7 @@ mod tests {
2,
&mut events,
&mut pending_tools,
&mut pending_thinking,
true,
);
@@ -8387,6 +8570,28 @@ mod tests {
assert!(!verification.contains("write_file"));
}
#[test]
fn subagent_system_prompt_uses_resolved_model_identity() {
// given: a temporary workspace and an OpenAI-compatible subagent model
let _guard = env_guard();
let root = temp_path("subagent-prompt-identity");
fs::create_dir_all(&root).expect("create temp workspace");
let previous = std::env::current_dir().expect("current dir");
std::env::set_current_dir(&root).expect("enter temp workspace");
// when: building the subagent system prompt
let prompt = build_agent_system_prompt("Explore", "openai/gpt-4.1-mini")
.expect("subagent system prompt should build")
.join("\n");
std::env::set_current_dir(previous).expect("restore current dir");
// then: the prompt renders a generic model family identity
assert!(prompt.contains("Model family: an AI assistant"));
assert!(!prompt.contains("Model family: Claude Opus 4.6"));
fs::remove_dir_all(root).expect("cleanup temp workspace");
}
#[derive(Debug)]
struct MockSubagentApiClient {
calls: usize,

68
scripts/dogfood-build.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# dogfood-build.sh — Build claw from current checkout and verify provenance.
#
# Injects GIT_SHA at build time so version JSON is non-null.
# Suppresses Cargo compile noise on stderr.
# Prints the verified binary path on success. Use as:
#
# CLAW=$(bash scripts/dogfood-build.sh)
#
# Then dogfood with config isolation (avoids real user config bleeding in):
#
# CLAW_CONFIG_HOME=$(mktemp -d) $CLAW plugins list --output-format json
#
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RUST_DIR="$REPO_ROOT/rust"
BINARY="$RUST_DIR/target/debug/claw"
EXPECTED_SHA="$(git -C "$REPO_ROOT" rev-parse --short HEAD)"
echo "▶ Building claw from $REPO_ROOT" >&2
echo " Commit: $(git -C "$REPO_ROOT" log --oneline -1)" >&2
# Inject GIT_SHA so version JSON returns a non-null sha.
# Redirect cargo stderr to /dev/null to suppress compile noise;
# on build failure cargo exits non-zero and set -e aborts.
if ! GIT_SHA="$EXPECTED_SHA" cargo build \
--manifest-path "$RUST_DIR/Cargo.toml" \
-p rusty-claude-cli -q 2>/dev/null; then
# Re-run with visible output so the user sees the error
echo "✗ Build failed — rerunning with output:" >&2
GIT_SHA="$EXPECTED_SHA" cargo build \
--manifest-path "$RUST_DIR/Cargo.toml" \
-p rusty-claude-cli 2>&1 | sed 's/^/ /' >&2
exit 1
fi
if [[ ! -x "$BINARY" ]]; then
echo "✗ Binary not found at $BINARY" >&2
exit 1
fi
BINARY_SHA=$("$BINARY" version --output-format json 2>/dev/null \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('git_sha') or 'null')" 2>/dev/null \
|| echo "null")
if [[ "$BINARY_SHA" == "null" || -z "$BINARY_SHA" ]]; then
echo "✗ Provenance check failed: binary reports git_sha: null" >&2
exit 1
fi
if [[ "$BINARY_SHA" != "$EXPECTED_SHA" ]]; then
echo "✗ Provenance mismatch: binary=$BINARY_SHA, HEAD=$EXPECTED_SHA" >&2
exit 1
fi
echo "✓ Binary verified: $BINARY_SHA == HEAD" >&2
echo "" >&2
echo " export CLAW=$BINARY" >&2
echo "" >&2
echo " Dogfood with isolated config (no real user config on stderr):" >&2
echo " CLAW_ISOLATED=\$(mktemp -d)" >&2
echo " CLAW_CONFIG_HOME=\$CLAW_ISOLATED \$CLAW plugins list --output-format json" >&2
echo " rm -rf \$CLAW_ISOLATED" >&2
echo "" >&2
echo " cargo run overhead: ~1s/invocation vs 7ms for pre-built binary." >&2
echo " Prefer pre-built binary (\$CLAW) for dogfood loops." >&2
echo "$BINARY"

7
scripts/fmt.sh Executable file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT/rust"
exec cargo fmt "$@"