Compare commits

..

2 Commits

Author SHA1 Message Date
bellman
96fd46cfbe fix: add missing thought_signature field in tools tests
Fixes two test assertions that were missing the new Option<String>
field added to the pending_tools tuple.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 03:00:54 +09:00
sunmanbitch
b119afcaca In the provider compatibility layer for Gemini (and other providers requiring ), fully support the flow, round-trip, and placeholder fallback of thought_signature. 2026-06-04 22:52:47 +08:00
20 changed files with 775 additions and 1762 deletions

File diff suppressed because one or more lines are too long

View File

@@ -615,7 +615,6 @@ The list is also the precedence chain: project-local settings override project s
```
Object-style matchers are optional. When present, they match tool names case-insensitively and support `*` wildcards plus comma or pipe separated alternatives. Nested hook `type` may be omitted or set to `"command"`; each nested command runs in configuration order.
Legacy bare-string hook entries still load for backward compatibility but emit deprecation warnings suggesting migration to object-style entries. Unknown hook event names (e.g. `Stop`, `Notification`) are recorded as invalid without rejecting valid hooks. `status --output-format json` mirrors partial hook validation under `hook_validation` with `valid_count`, `invalid_count`, and `invalid_hooks:[{event, index, hook_index, kind, error_field, reason, valid:false}]`. `doctor --output-format json` includes a `hook validation` check so automation can repair every rejected hook entry without losing usable hooks.
## Project instruction rules

View File

@@ -151,7 +151,6 @@ Top-level commands:
`claw version --output-format json` is the provenance probe for automation: it reports full `git_sha`, derived `git_sha_short`, `is_dirty`, `branch`, `commit_date`, `commit_timestamp`, `rustc_version`, runtime `executable_path`, and `binary_provenance`; the text report is available as `human_readable` instead of a duplicate `message` field.
`status --output-format json` reports loaded project memory files under `workspace.memory_files[]` with each file's `path`, `source` (`claude_md`, `claw_md`, `agents_md`, or scoped/rule sources), `origin`, `scope_path`, `outside_project`, `chars`, and `contributes`; `claw doctor --output-format json` includes a dedicated `memory` check. Root instruction-file priority is `CLAUDE.md`, then `CLAW.md`, then `AGENTS.md`, discovery is bounded to the current git root when present (otherwise cwd only), and all non-duplicate loaded files contribute to the rendered system prompt.
`claw mcp --output-format json` reports partial MCP config success: valid servers remain in `servers[]` while malformed siblings appear in `invalid_servers[]`, with `total_configured`, `valid_count`, and `invalid_count` split out for automation. `status` mirrors this as `mcp_validation`, and doctor includes an `mcp validation` check.
`status --output-format json` also reports partial hook config success under `hook_validation`: valid hook entries are retained while malformed or unknown-event siblings appear in `invalid_hooks[]`, with `valid_count`, `invalid_count`, and typed `kind` fields (`invalid_hooks_config` or `unknown_hook_event`) for automation. `doctor --output-format json` includes a `hook validation` check, and `config --output-format json` includes `hook_validation` metadata with degraded status when invalid entries exist.
Shorthand prompt mode honors the POSIX `--` end-of-flags separator, so `claw -- "-prompt-with-dash"` and unknown dash-prefixed non-flag text stay on the prompt path instead of being treated as CLI options.
`claw dump-manifests` is self-contained: it emits the Rust resolver inventory for the selected workspace (commands, tools, agents, skills, and bootstrap phases) without requiring an upstream Claude Code TypeScript checkout. Use `--manifests-dir PATH` only to scope resolver discovery to another directory.

View File

@@ -38,6 +38,7 @@ fn create_sample_request(message_count: usize) -> MessageRequest {
id: format!("call_{}", i),
name: "read_file".to_string(),
input: json!({"path": format!("/tmp/file{}", i)}),
thought_signature: None,
},
],
}),
@@ -57,6 +58,7 @@ fn create_sample_request(message_count: usize) -> MessageRequest {
id: format!("call_{}", i),
name: "write_file".to_string(),
input: json!({"path": format!("/tmp/out{}", i), "content": "data"}),
thought_signature: None,
}],
}),
}
@@ -105,11 +107,13 @@ fn bench_translate_message(c: &mut Criterion) {
id: "call_1".to_string(),
name: "read_file".to_string(),
input: json!({"path": "/tmp/test"}),
thought_signature: None,
},
InputContentBlock::ToolUse {
id: "call_2".to_string(),
name: "write_file".to_string(),
input: json!({"path": "/tmp/out", "content": "data"}),
thought_signature: None,
},
],
};

View File

@@ -586,6 +586,21 @@ impl StreamState {
}
}
if let Some(delta_extra) = &choice.delta.extra_content {
if let Some(delta_sig) = delta_extra
.get("google")
.and_then(|g| g.get("thought_signature"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
for state in self.tool_calls.values_mut() {
if state.thought_signature.is_none() {
state.thought_signature.get_or_insert(delta_sig.to_string());
}
}
}
}
if let Some(finish_reason) = choice.finish_reason {
self.stop_reason = Some(normalize_finish_reason(&finish_reason));
if finish_reason == "tool_calls" {
@@ -693,6 +708,7 @@ struct ToolCallState {
id: Option<String>,
name: Option<String>,
arguments: String,
thought_signature: Option<String>,
emitted_len: usize,
started: bool,
stopped: bool,
@@ -710,6 +726,24 @@ impl ToolCallState {
if let Some(arguments) = tool_call.function.arguments {
self.arguments.push_str(&arguments);
}
if let Some(sig) = tool_call.thought_signature.filter(|s| !s.is_empty()) {
self.thought_signature.get_or_insert(sig);
}
// https://ai.google.dev/gemini-api/docs/thought-signatures
if self.thought_signature.is_none() {
if let Some(sig) = tool_call
.extra_content
.as_ref()
.and_then(|ec| ec.get("google"))
.and_then(|g| g.get("thought_signature"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
self.thought_signature.get_or_insert(sig.to_string());
}
}
}
const fn block_index(&self, offset: u32) -> u32 {
@@ -731,6 +765,7 @@ impl ToolCallState {
id,
name,
input: json!({}),
thought_signature: self.thought_signature.clone(),
},
}))
}
@@ -782,6 +817,10 @@ struct ChatMessage {
struct ResponseToolCall {
id: String,
function: ResponseToolFunction,
#[serde(default)]
thought_signature: Option<String>,
#[serde(default)]
extra_content: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
@@ -852,6 +891,8 @@ struct ChunkDelta {
thinking: Option<ThinkingDelta>,
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
tool_calls: Vec<DeltaToolCall>,
#[serde(default)]
extra_content: Option<serde_json::Value>,
}
#[derive(Debug, Default, Deserialize)]
@@ -860,7 +901,7 @@ struct ThinkingDelta {
content: Option<String>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Default, Deserialize)]
struct DeltaToolCall {
#[serde(default)]
index: u32,
@@ -868,6 +909,10 @@ struct DeltaToolCall {
id: Option<String>,
#[serde(default)]
function: DeltaFunction,
#[serde(default)]
thought_signature: Option<String>,
#[serde(default)]
extra_content: Option<serde_json::Value>,
}
#[derive(Debug, Default, Deserialize)]
@@ -923,6 +968,22 @@ pub fn model_requires_reasoning_content_in_history(model: &str) -> bool {
canonical.starts_with("deepseek-v4")
}
/// Dummy thought signature accepted by Gemini as a validation bypass for
/// conversation history that lacks a real signature. Source:
/// - LiteLLM: https://github.com/BerriAI/litellm/pull/16812
/// - Google: https://ai.google.dev/gemini-api/docs/thought-signatures#faqs
const GEMINI_DUMMY_THOUGHT_SIGNATURE: &str = "c2tpcF90aG91Z2h0X3NpZ25hdHVyZV92YWxpZGF0b3I=";
/// Returns true if the model is a Gemini model (Gemini 2.5+, 3+ etc) that
/// requires `thought_signature` on function calls in conversation history.
#[must_use]
pub fn is_gemini_model(model: &str) -> bool {
let lowered = model.to_ascii_lowercase();
let canonical = lowered.rsplit('/').next().unwrap_or(lowered.as_str());
canonical.starts_with("gemini")
}
/// 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. Use `local/` to force OpenAI-compatible routing while
@@ -1216,14 +1277,32 @@ pub fn translate_message(message: &InputMessage, model: &str) -> Vec<Value> {
InputContentBlock::Thinking {
thinking: value, ..
} => reasoning.push_str(value),
InputContentBlock::ToolUse { id, name, input } => tool_calls.push(json!({
"id": id,
"type": "function",
"function": {
"name": name,
"arguments": input.to_string(),
InputContentBlock::ToolUse { id, name, input, thought_signature } => {
let mut tc = json!({
"id": id,
"type": "function",
"function": {
"name": name,
"arguments": input.to_string(),
}
});
let sig_for_gemini = thought_signature.clone().or_else(|| {
if is_gemini_model(model) {
Some(GEMINI_DUMMY_THOUGHT_SIGNATURE.to_string())
} else {
None
}
});
if let Some(sig) = sig_for_gemini {
tc["extra_content"] = json!({
"google": {
"thought_signature": sig
}
});
}
})),
tool_calls.push(tc);
}
InputContentBlock::ToolResult { .. } => {}
}
}
@@ -1468,10 +1547,22 @@ fn normalize_response(
content.push(OutputContentBlock::Text { text });
}
for tool_call in choice.message.tool_calls {
let thought_signature = tool_call.thought_signature.or_else(|| {
tool_call
.extra_content
.as_ref()
.and_then(|ec| ec.get("google"))
.and_then(|g| g.get("thought_signature"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from)
});
content.push(OutputContentBlock::ToolUse {
id: tool_call.id,
name: tool_call.function.name,
input: parse_tool_arguments(&tool_call.function.arguments),
thought_signature,
});
}
@@ -1866,6 +1957,7 @@ mod tests {
id: "call_1".to_string(),
name: "get_weather".to_string(),
input: json!({"city": "Paris"}),
thought_signature: None,
}],
}],
stream: false,
@@ -1943,6 +2035,7 @@ mod tests {
reasoning_content: Some("think".to_string()),
thinking: None,
tool_calls: Vec::new(),
extra_content: None,
},
finish_reason: None,
}],
@@ -1960,6 +2053,7 @@ mod tests {
reasoning_content: None,
thinking: None,
tool_calls: Vec::new(),
extra_content: None,
},
finish_reason: Some("stop".to_string()),
}],
@@ -2504,6 +2598,7 @@ mod tests {
id: "call_1".to_string(),
name: "read_file".to_string(),
input: serde_json::json!({"path": "/tmp/test"}),
thought_signature: None,
}],
}],
stream: false,
@@ -2722,6 +2817,7 @@ mod tests {
id: "call_1".to_string(),
name: "read_file".to_string(),
input: serde_json::json!({"path": "/tmp/test"}),
thought_signature: None,
}],
},
InputMessage {

View File

@@ -100,6 +100,8 @@ pub enum InputContentBlock {
id: String,
name: String,
input: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
thought_signature: Option<String>,
},
ToolResult {
tool_use_id: String,
@@ -167,6 +169,8 @@ pub enum OutputContentBlock {
id: String,
name: String,
input: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
thought_signature: Option<String>,
},
Thinking {
#[serde(default)]

View File

@@ -768,7 +768,7 @@ fn tool_calls_for_json(content: &[OutputContentBlock]) -> Vec<Value> {
content
.iter()
.filter_map(|b| {
if let OutputContentBlock::ToolUse { id, name, input } = b {
if let OutputContentBlock::ToolUse { id, name, input, .. } = b {
Some(json!({
"id": id,
"name": name,
@@ -1474,7 +1474,7 @@ async fn stream_to_message_response(
block_kind.insert(index, BlockKind::Text);
text_buf.insert(index, text);
}
OutputContentBlock::ToolUse { id, name, input } => {
OutputContentBlock::ToolUse { id, name, input, .. } => {
let json = if input.as_object().is_some_and(|m| m.is_empty()) {
String::new()
} else {
@@ -1523,7 +1523,7 @@ async fn stream_to_message_response(
Some(BlockKind::Tool { id, name, json }) => {
let input = serde_json::from_str::<Value>(&json)
.unwrap_or_else(|_| json!({ "raw": json }));
finished.insert(idx, OutputContentBlock::ToolUse { id, name, input });
finished.insert(idx, OutputContentBlock::ToolUse { id, name, input, thought_signature: None });
}
None => {}
}
@@ -1581,7 +1581,7 @@ fn collect_tool_uses(content: &[OutputContentBlock]) -> Vec<ToolUse<'_>> {
content
.iter()
.filter_map(|b| {
if let OutputContentBlock::ToolUse { id, name, input } = b {
if let OutputContentBlock::ToolUse { id, name, input, .. } = b {
Some(ToolUse {
id: id.as_str(),
name: name.as_str(),
@@ -1601,10 +1601,11 @@ fn output_to_input_blocks(blocks: &[OutputContentBlock]) -> Vec<InputContentBloc
OutputContentBlock::Text { text } => {
Some(InputContentBlock::Text { text: text.clone() })
}
OutputContentBlock::ToolUse { id, name, input } => Some(InputContentBlock::ToolUse {
OutputContentBlock::ToolUse { id, name, input, .. } => Some(InputContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input: input.clone(),
thought_signature: None,
}),
OutputContentBlock::Thinking { .. } | OutputContentBlock::RedactedThinking { .. } => {
None

View File

@@ -2147,7 +2147,7 @@ impl DefinitionSource {
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AgentSummary {
struct AgentSummary {
name: String,
description: Option<String>,
model: Option<String>,
@@ -2158,20 +2158,6 @@ pub(crate) struct AgentSummary {
path: Option<PathBuf>,
}
/// An agent definition file that could not be loaded.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InvalidAgentConfig {
pub(crate) path: PathBuf,
pub(crate) reason: String,
}
/// Loaded agent definitions plus any invalid entries that were skipped.
#[derive(Debug, Clone, Default)]
pub(crate) struct AgentCollection {
pub(crate) agents: Vec<AgentSummary>,
pub(crate) invalid_agents: Vec<InvalidAgentConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SkillSummary {
name: String,
@@ -2181,23 +2167,6 @@ struct SkillSummary {
origin: SkillOrigin,
// #729: on-disk path parity with AgentSummary
path: Option<PathBuf>,
// #445: directory name for detecting name/dir mismatch
dir_name: Option<String>,
}
/// A skill where the frontmatter name differs from the directory name.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SkillMetadataDrift {
pub(crate) dir_name: String,
pub(crate) frontmatter_name: String,
pub(crate) path: PathBuf,
}
/// Loaded skill definitions plus any metadata drift entries.
#[derive(Debug, Clone, Default)]
pub(crate) struct SkillCollection {
pub(crate) skills: Vec<SkillSummary>,
pub(crate) metadata_drift: Vec<SkillMetadataDrift>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -2525,8 +2494,8 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
match normalize_optional_args(args) {
None | Some("list") => {
let roots = discover_definition_roots(cwd, "agents");
let collection = load_agents_from_roots_with_invalids(&roots)?;
Ok(render_agents_report_json(cwd, &collection))
let agents = load_agents_from_roots(&roots)?;
Ok(render_agents_report_json(cwd, &agents))
}
Some(args) if args.starts_with("list ") => {
let filter = args["list ".len()..].trim().to_lowercase();
@@ -2543,26 +2512,17 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
}));
}
let roots = discover_definition_roots(cwd, "agents");
let collection = load_agents_from_roots_with_invalids(&roots)?;
let filtered_agents: Vec<_> = collection
.agents
let agents = load_agents_from_roots(&roots)?;
let filtered: Vec<_> = agents
.into_iter()
.filter(|a| a.name.to_lowercase().contains(&filter))
.collect();
let filtered_collection = AgentCollection {
agents: filtered_agents,
invalid_agents: collection.invalid_agents,
};
Ok(render_agents_report_json(cwd, &filtered_collection))
Ok(render_agents_report_json(cwd, &filtered))
}
Some("show" | "info" | "describe") => {
let roots = discover_definition_roots(cwd, "agents");
let collection = load_agents_from_roots_with_invalids(&roots)?;
Ok(render_agents_report_json_with_action(
cwd,
&collection,
"show",
))
let agents = load_agents_from_roots(&roots)?;
Ok(render_agents_report_json_with_action(cwd, &agents, "show"))
}
Some(args)
if args.starts_with("show ")
@@ -2593,9 +2553,8 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
}));
}
let roots = discover_definition_roots(cwd, "agents");
let collection = load_agents_from_roots_with_invalids(&roots)?;
let matched: Vec<_> = collection
.agents
let agents = load_agents_from_roots(&roots)?;
let matched: Vec<_> = agents
.into_iter()
.filter(|a| a.name.to_lowercase() == name)
.collect();
@@ -2612,15 +2571,7 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
"hint": "Run `claw agents list` to see available agents.",
}));
}
let matched_collection = AgentCollection {
agents: matched,
invalid_agents: collection.invalid_agents,
};
Ok(render_agents_report_json_with_action(
cwd,
&matched_collection,
"show",
))
Ok(render_agents_report_json_with_action(cwd, &matched, "show"))
}
Some("create") => Ok(render_agents_missing_argument_json("create", "agent_name")),
Some(args) if args.starts_with("create ") => {
@@ -2815,8 +2766,8 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
match normalize_optional_args(args) {
None | Some("list") => {
let roots = discover_skill_roots(cwd);
let collection = load_skills_from_roots_with_drift(&roots)?;
Ok(render_skills_report_json_with_action(&collection, "list"))
let skills = load_skills_from_roots(&roots)?;
Ok(render_skills_report_json_with_action(&skills, "list"))
}
Some(args) if args.starts_with("list ") => {
let filter = args["list ".len()..].trim().to_lowercase();
@@ -2833,25 +2784,17 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
}));
}
let roots = discover_skill_roots(cwd);
let collection = load_skills_from_roots_with_drift(&roots)?;
let filtered_skills: Vec<_> = collection
.skills
let skills = load_skills_from_roots(&roots)?;
let filtered: Vec<_> = skills
.into_iter()
.filter(|s| s.name.to_lowercase().contains(&filter))
.collect();
let filtered_collection = SkillCollection {
skills: filtered_skills,
metadata_drift: collection.metadata_drift,
};
Ok(render_skills_report_json_with_action(
&filtered_collection,
"list",
))
Ok(render_skills_report_json_with_action(&filtered, "list"))
}
Some("show" | "info" | "describe") => {
let roots = discover_skill_roots(cwd);
let collection = load_skills_from_roots_with_drift(&roots)?;
Ok(render_skills_report_json_with_action(&collection, "show"))
let skills = load_skills_from_roots(&roots)?;
Ok(render_skills_report_json_with_action(&skills, "show"))
}
Some(args)
if args.starts_with("show ")
@@ -2882,9 +2825,8 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
}));
}
let roots = discover_skill_roots(cwd);
let collection = load_skills_from_roots_with_drift(&roots)?;
let matched: Vec<_> = collection
.skills
let skills = load_skills_from_roots(&roots)?;
let matched: Vec<_> = skills
.into_iter()
.filter(|s| s.name.to_lowercase() == name)
.collect();
@@ -2901,14 +2843,7 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
"hint": "Run `claw skills list` to see available skills.",
}));
}
let matched_collection = SkillCollection {
skills: matched,
metadata_drift: collection.metadata_drift,
};
Ok(render_skills_report_json_with_action(
&matched_collection,
"show",
))
Ok(render_skills_report_json_with_action(&matched, "show"))
}
Some("install") => Ok(render_skills_missing_argument_json(
"install",
@@ -3308,15 +3243,7 @@ fn render_mcp_report_json_for(
"use `claw mcp show <server>` to inspect a server",
))
}
Some(args) => {
// #681: unsupported mutation verbs (add, remove, delete, enable, disable)
// and other unknown sub-actions return a typed error instead of help with exit 0.
let verb = args.split_whitespace().next().unwrap_or(args);
Ok(render_mcp_unsupported_action_json(
args,
&format!("`{verb}` is not a supported MCP sub-action; supported actions: list, show, help"),
))
}
Some(args) => Ok(render_mcp_usage_json(Some(args))),
}
}
@@ -3975,69 +3902,30 @@ fn push_unique_skill_root(
fn load_agents_from_roots(
roots: &[(DefinitionSource, PathBuf)],
) -> std::io::Result<Vec<AgentSummary>> {
let collection = load_agents_from_roots_with_invalids(roots)?;
Ok(collection.agents)
}
/// Load agent definitions from all roots, collecting both valid agents and
/// invalid entries (wrong extension, broken frontmatter, etc.).
fn load_agents_from_roots_with_invalids(
roots: &[(DefinitionSource, PathBuf)],
) -> std::io::Result<AgentCollection> {
let mut agents = Vec::new();
let mut invalid_agents = Vec::new();
let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
for (source, root) in roots {
let mut root_agents = Vec::new();
for entry in fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
let ext = path.extension().and_then(|e| e.to_str());
match ext {
Some("toml") => {
let contents = fs::read_to_string(&path)?;
let fallback_name = path.file_stem().map_or_else(
|| entry.file_name().to_string_lossy().to_string(),
|stem| stem.to_string_lossy().to_string(),
);
root_agents.push(AgentSummary {
name: parse_toml_string(&contents, "name").unwrap_or(fallback_name),
description: parse_toml_string(&contents, "description"),
model: parse_toml_string(&contents, "model"),
reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"),
source: *source,
shadowed_by: None,
path: Some(path),
});
}
Some("md") => {
let contents = fs::read_to_string(&path)?;
let (name, description, model, reasoning_effort) =
parse_agent_frontmatter(&contents);
if name.is_none() && description.is_none() {
invalid_agents.push(InvalidAgentConfig {
path,
reason: "Markdown agent file has no YAML frontmatter with name or description fields".to_string(),
});
continue;
}
let fallback_name = path.file_stem().map_or_else(
|| entry.file_name().to_string_lossy().to_string(),
|stem| stem.to_string_lossy().to_string(),
);
root_agents.push(AgentSummary {
name: name.unwrap_or(fallback_name),
description,
model,
reasoning_effort,
source: *source,
shadowed_by: None,
path: Some(path),
});
}
_ => continue,
if entry.path().extension().is_none_or(|ext| ext != "toml") {
continue;
}
let contents = fs::read_to_string(entry.path())?;
let fallback_name = entry.path().file_stem().map_or_else(
|| entry.file_name().to_string_lossy().to_string(),
|stem| stem.to_string_lossy().to_string(),
);
root_agents.push(AgentSummary {
name: parse_toml_string(&contents, "name").unwrap_or(fallback_name),
description: parse_toml_string(&contents, "description"),
model: parse_toml_string(&contents, "model"),
reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"),
source: *source,
shadowed_by: None,
path: Some(entry.path()),
});
}
root_agents.sort_by(|left, right| left.name.cmp(&right.name));
@@ -4052,22 +3940,11 @@ fn load_agents_from_roots_with_invalids(
}
}
Ok(AgentCollection {
agents,
invalid_agents,
})
Ok(agents)
}
fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSummary>> {
let collection = load_skills_from_roots_with_drift(roots)?;
Ok(collection.skills)
}
/// Load skill definitions from all roots, collecting metadata drift entries
/// where the frontmatter name differs from the directory name.
fn load_skills_from_roots_with_drift(roots: &[SkillRoot]) -> std::io::Result<SkillCollection> {
let mut skills = Vec::new();
let mut metadata_drift = Vec::new();
let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
for root in roots {
@@ -4084,26 +3961,15 @@ fn load_skills_from_roots_with_drift(roots: &[SkillRoot]) -> std::io::Result<Ski
continue;
}
let contents = fs::read_to_string(skill_path)?;
let dir_name = entry.file_name().to_string_lossy().to_string();
let (name, description) = parse_skill_frontmatter(&contents);
// #445: detect name/dir mismatch
if let Some(ref frontmatter_name) = name {
if frontmatter_name != &dir_name {
metadata_drift.push(SkillMetadataDrift {
dir_name: dir_name.clone(),
frontmatter_name: frontmatter_name.clone(),
path: entry.path(),
});
}
}
root_skills.push(SkillSummary {
name: name.unwrap_or_else(|| dir_name.clone()),
name: name
.unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()),
description,
source: root.source,
shadowed_by: None,
origin: root.origin,
path: Some(entry.path()),
dir_name: Some(dir_name),
});
}
SkillOrigin::LegacyCommandsDir => {
@@ -4136,7 +4002,6 @@ fn load_skills_from_roots_with_drift(roots: &[SkillRoot]) -> std::io::Result<Ski
shadowed_by: None,
origin: root.origin,
path: Some(markdown_path),
dir_name: None,
});
}
}
@@ -4154,10 +4019,7 @@ fn load_skills_from_roots_with_drift(roots: &[SkillRoot]) -> std::io::Result<Ski
}
}
Ok(SkillCollection {
skills,
metadata_drift,
})
Ok(skills)
}
fn parse_toml_string(contents: &str, key: &str) -> Option<String> {
@@ -4229,63 +4091,6 @@ fn unquote_frontmatter_value(value: &str) -> String {
.to_string()
}
/// Parse agent metadata from YAML frontmatter in `.md` agent files.
/// Returns (name, description, model, reasoning_effort) extracted from
/// the `---`-delimited YAML block at the top of the file.
fn parse_agent_frontmatter(
contents: &str,
) -> (
Option<String>,
Option<String>,
Option<String>,
Option<String>,
) {
let mut lines = contents.lines();
if lines.next().map(str::trim) != Some("---") {
return (None, None, None, None);
}
let mut name = None;
let mut description = None;
let mut model = None;
let mut reasoning_effort = None;
for line in lines {
let trimmed = line.trim();
if trimmed == "---" {
break;
}
if let Some(value) = trimmed.strip_prefix("name:") {
let value = unquote_frontmatter_value(value.trim());
if !value.is_empty() {
name = Some(value);
}
continue;
}
if let Some(value) = trimmed.strip_prefix("description:") {
let value = unquote_frontmatter_value(value.trim());
if !value.is_empty() {
description = Some(value);
}
continue;
}
if let Some(value) = trimmed.strip_prefix("model:") {
let value = unquote_frontmatter_value(value.trim());
if !value.is_empty() {
model = Some(value);
}
continue;
}
if let Some(value) = trimmed.strip_prefix("model_reasoning_effort:") {
let value = unquote_frontmatter_value(value.trim());
if !value.is_empty() {
reasoning_effort = Some(value);
}
}
}
(name, description, model, reasoning_effort)
}
fn render_agents_report(agents: &[AgentSummary]) -> String {
if agents.is_empty() {
return "No agents found.".to_string();
@@ -4328,42 +4133,31 @@ fn render_agents_report(agents: &[AgentSummary]) -> String {
lines.join("\n").trim_end().to_string()
}
fn render_agents_report_json(cwd: &Path, collection: &AgentCollection) -> Value {
render_agents_report_json_with_action(cwd, collection, "list")
fn render_agents_report_json(cwd: &Path, agents: &[AgentSummary]) -> Value {
render_agents_report_json_with_action(cwd, agents, "list")
}
fn render_agents_report_json_with_action(
cwd: &Path,
collection: &AgentCollection,
agents: &[AgentSummary],
action: &str,
) -> Value {
let agents = &collection.agents;
let invalid_agents = &collection.invalid_agents;
let active = agents
.iter()
.filter(|agent| agent.shadowed_by.is_none())
.count();
let has_invalids = !invalid_agents.is_empty();
let status = if has_invalids { "degraded" } else { "ok" };
json!({
"kind": "agents",
"status": status,
"status": "ok",
"action": action,
"working_directory": cwd.display().to_string(),
"count": agents.len(),
"valid_count": agents.len(),
"invalid_count": invalid_agents.len(),
"summary": {
"total": agents.len(),
"active": active,
"shadowed": agents.len().saturating_sub(active),
},
"agents": agents.iter().map(agent_summary_json).collect::<Vec<_>>(),
"invalid_agents": invalid_agents.iter().map(|invalid| json!({
"path": invalid.path.display().to_string(),
"reason": &invalid.reason,
"valid": false,
})).collect::<Vec<_>>(),
})
}
@@ -4483,34 +4277,21 @@ fn render_skills_report(skills: &[SkillSummary]) -> String {
lines.join("\n").trim_end().to_string()
}
fn render_skills_report_json_with_action(collection: &SkillCollection, action: &str) -> Value {
let skills = &collection.skills;
let metadata_drift = &collection.metadata_drift;
fn render_skills_report_json_with_action(skills: &[SkillSummary], action: &str) -> Value {
let active = skills
.iter()
.filter(|skill| skill.shadowed_by.is_none())
.count();
let has_drift = !metadata_drift.is_empty();
let status = if has_drift { "degraded" } else { "ok" };
// #410: add `count` field for polymorphic consumption parity with agents list
json!({
"kind": "skills",
"status": status,
"status": "ok",
"action": action,
"count": skills.len(),
"valid_count": skills.len(),
"metadata_drift_count": metadata_drift.len(),
"summary": {
"total": skills.len(),
"active": active,
"shadowed": skills.len().saturating_sub(active),
},
"skills": skills.iter().map(skill_summary_json).collect::<Vec<_>>(),
"metadata_drift": metadata_drift.iter().map(|drift| json!({
"dir_name": &drift.dir_name,
"frontmatter_name": &drift.frontmatter_name,
"path": drift.path.display().to_string(),
})).collect::<Vec<_>>(),
})
}
@@ -4686,7 +4467,6 @@ fn render_mcp_summary_report_json(cwd: &Path, mcp: &McpConfigCollection) -> Valu
json!({
"kind": "mcp",
"action": "list",
"count": mcp.valid_count(),
"working_directory": cwd.display().to_string(),
"configured_servers": mcp.valid_count(),
"total_configured": mcp.total_configured(),
@@ -4872,7 +4652,7 @@ fn render_agents_usage_json(unexpected: Option<&str>) -> Value {
"direct_cli": "claw agents [list|show <name>|create <name>|help]",
"format": "toml",
"create": "claw agents create <name>",
"sources": [".claw/agents", "~/.claw/agents", "~/.codex/agents", "$CLAW_CONFIG_HOME/agents"],
"sources": [".claw/agents", "~/.claw/agents", "$CLAW_CONFIG_HOME/agents"],
},
"unexpected": unexpected,
})
@@ -5002,7 +4782,7 @@ fn render_mcp_usage_json(unexpected: Option<&str>) -> Value {
"usage": {
"slash_command": "/mcp [list|show <server>|help]",
"direct_cli": "claw mcp [list|show <server>|help]",
"sources": [".claw.json", ".claw/settings.json", ".claw/settings.local.json"],
"sources": [".claw/settings.json", ".claw/settings.local.json"],
},
"unexpected": unexpected,
})
@@ -5190,51 +4970,34 @@ fn mcp_oauth_json(oauth: Option<&McpOAuthConfig>) -> Value {
}
fn mcp_server_details_json(config: &McpServerConfig) -> Value {
// #90: redact sensitive fields — args/url/headers_helper can contain
// credentials. Show structure without leaking secrets.
match config {
McpServerConfig::Stdio(config) => json!({
"command": &config.command,
"args_count": config.args.len(),
"args": &config.args,
"env_keys": config.env.keys().cloned().collect::<Vec<_>>(),
"tool_call_timeout_ms": config.tool_call_timeout_ms,
}),
McpServerConfig::Sse(config) | McpServerConfig::Http(config) => {
let redacted_url = redact_url(&config.url);
json!({
"url": redacted_url,
"header_keys": config.headers.keys().cloned().collect::<Vec<_>>(),
"headers_helper_configured": config.headers_helper.is_some(),
"oauth": mcp_oauth_json(config.oauth.as_ref()),
})
}
McpServerConfig::Ws(config) => {
let redacted_url = redact_url(&config.url);
json!({
"url": redacted_url,
"header_keys": config.headers.keys().cloned().collect::<Vec<_>>(),
"headers_helper_configured": config.headers_helper.is_some(),
})
}
McpServerConfig::Sse(config) | McpServerConfig::Http(config) => json!({
"url": &config.url,
"header_keys": config.headers.keys().cloned().collect::<Vec<_>>(),
"headers_helper": &config.headers_helper,
"oauth": mcp_oauth_json(config.oauth.as_ref()),
}),
McpServerConfig::Ws(config) => json!({
"url": &config.url,
"header_keys": config.headers.keys().cloned().collect::<Vec<_>>(),
"headers_helper": &config.headers_helper,
}),
McpServerConfig::Sdk(config) => json!({
"name": &config.name,
}),
McpServerConfig::ManagedProxy(config) => json!({
"url": redact_url(&config.url),
"url": &config.url,
"id": &config.id,
}),
}
}
fn redact_url(url: &str) -> String {
// #90: strip query params which may contain tokens, keep scheme+host+path
if let Some(query_start) = url.find('?') {
format!("{}?...", &url[..query_start])
} else {
url.to_string()
}
}
fn mcp_server_json(name: &str, server: &ScopedMcpServerConfig) -> Value {
json!({
"name": name,
@@ -5364,7 +5127,7 @@ mod tests {
render_agents_report_json, render_mcp_report_json_for, render_plugins_report,
render_plugins_report_with_failures, render_skills_report, render_slash_command_help,
render_slash_command_help_detail, resolve_skill_path, resume_supported_slash_commands,
slash_command_specs, suggest_slash_commands, validate_slash_command_input, AgentCollection,
slash_command_specs, suggest_slash_commands, validate_slash_command_input,
DefinitionSource, SkillOrigin, SkillRoot, SkillSlashDispatch, SlashCommand,
};
use plugins::{
@@ -6358,10 +6121,7 @@ mod tests {
];
let report = render_agents_report_json(
&workspace,
&AgentCollection {
agents: load_agents_from_roots(&roots).expect("agent roots should load"),
invalid_agents: Vec::new(),
},
&load_agents_from_roots(&roots).expect("agent roots should load"),
);
assert_eq!(report["kind"], "agents");
@@ -6514,10 +6274,7 @@ mod tests {
},
];
let report = super::render_skills_report_json_with_action(
&super::SkillCollection {
skills: load_skills_from_roots(&roots).expect("skills should load"),
metadata_drift: Vec::new(),
},
&load_skills_from_roots(&roots).expect("skills should load"),
"list",
);
assert_eq!(report["kind"], "skills");
@@ -6890,7 +6647,7 @@ mod tests {
let help =
render_mcp_report_json_for(&loader, &workspace, Some("help")).expect("mcp help json");
assert_eq!(help["action"], "help");
assert_eq!(help["usage"]["sources"][0], ".claw.json");
assert_eq!(help["usage"]["sources"][0], ".claw/settings.json");
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(config_home);

View File

@@ -746,6 +746,7 @@ fn tool_message_response_many(id: &str, tool_uses: &[ToolUseMessage<'_>]) -> Mes
id: tool_use.tool_id.to_string(),
name: tool_use.tool_name.to_string(),
input: tool_use.input.clone(),
thought_signature: None,
})
.collect(),
model: DEFAULT_MODEL.to_string(),

View File

@@ -780,6 +780,7 @@ mod tests {
id: tool_id.to_string(),
name: "search".to_string(),
input: "{\"q\":\"*.rs\"}".to_string(),
thought_signature: None,
},
]))
.unwrap();

View File

@@ -182,7 +182,6 @@ pub struct RuntimeHookConfig {
pre_tool_use: Vec<RuntimeHookCommand>,
post_tool_use: Vec<RuntimeHookCommand>,
post_tool_use_failure: Vec<RuntimeHookCommand>,
invalid_hooks: Vec<RuntimeInvalidHookConfig>,
}
/// A hook command plus optional tool matcher from object-style hook config.
@@ -192,16 +191,6 @@ pub struct RuntimeHookCommand {
matcher: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeInvalidHookConfig {
pub event: String,
pub index: Option<usize>,
pub hook_index: Option<usize>,
pub kind: String,
pub error_field: String,
pub reason: String,
}
/// Raw permission rule lists grouped by allow, deny, and ask behavior.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimePermissionRuleConfig {
@@ -1209,7 +1198,6 @@ impl RuntimeHookConfig {
pre_tool_use,
post_tool_use,
post_tool_use_failure,
invalid_hooks: Vec::new(),
}
}
@@ -1247,8 +1235,6 @@ impl RuntimeHookConfig {
&mut self.post_tool_use_failure,
other.post_tool_use_failure_entries(),
);
self.invalid_hooks
.extend(other.invalid_hooks.iter().cloned());
}
#[must_use]
@@ -1260,25 +1246,6 @@ impl RuntimeHookConfig {
pub fn post_tool_use_failure_entries(&self) -> &[RuntimeHookCommand] {
&self.post_tool_use_failure
}
#[must_use]
pub fn invalid_hooks(&self) -> &[RuntimeInvalidHookConfig] {
&self.invalid_hooks
}
#[must_use]
pub fn invalid_count(&self) -> usize {
self.invalid_hooks.len()
}
#[must_use]
pub fn has_invalid_hooks(&self) -> bool {
!self.invalid_hooks.is_empty()
}
pub fn push_invalid_hook(&mut self, invalid: RuntimeInvalidHookConfig) {
self.invalid_hooks.push(invalid);
}
}
fn hook_commands(commands: &[RuntimeHookCommand]) -> Vec<String> {
@@ -1667,217 +1634,14 @@ fn parse_optional_hooks_config_object(
return Ok(RuntimeHookConfig::default());
};
let hooks = expect_object(hooks_value, context)?;
Ok(parse_hooks_object_partial(hooks, context))
}
fn parse_hooks_object_partial(
hooks: &BTreeMap<String, JsonValue>,
context: &str,
) -> RuntimeHookConfig {
let mut config = RuntimeHookConfig::default();
parse_hook_event_partial(
&mut config,
hooks,
"PreToolUse",
context,
|config, command| {
config.pre_tool_use.push(command);
},
);
parse_hook_event_partial(
&mut config,
hooks,
"PostToolUse",
context,
|config, command| {
config.post_tool_use.push(command);
},
);
parse_hook_event_partial(
&mut config,
hooks,
"PostToolUseFailure",
context,
|config, command| {
config.post_tool_use_failure.push(command);
},
);
for event in hooks.keys().filter(|event| !is_supported_hook_event(event)) {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.clone(),
index: None,
hook_index: None,
kind: "unknown_hook_event".to_string(),
error_field: event.clone(),
reason: format!("{context}: unknown hook event {event}"),
});
}
config
}
fn is_supported_hook_event(event: &str) -> bool {
matches!(event, "PreToolUse" | "PostToolUse" | "PostToolUseFailure")
}
fn parse_hook_event_partial(
config: &mut RuntimeHookConfig,
hooks: &BTreeMap<String, JsonValue>,
event: &str,
context: &str,
mut push_command: impl FnMut(&mut RuntimeHookConfig, RuntimeHookCommand),
) {
let Some(value) = hooks.get(event) else {
return;
};
let Some(array) = value.as_array() else {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.to_string(),
index: None,
hook_index: None,
kind: "invalid_hooks_config".to_string(),
error_field: event.to_string(),
reason: format!("{context}: field {event} must be an array"),
});
return;
};
for (index, item) in array.iter().enumerate() {
if let Some(command) = item.as_str() {
if command.trim().is_empty() {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.to_string(),
index: Some(index),
hook_index: None,
kind: "invalid_hooks_config".to_string(),
error_field: "command".to_string(),
reason: format!("{context}: field {event}[{index}] must be a non-empty string"),
});
} else {
push_command(config, RuntimeHookCommand::new(command.to_string()));
}
continue;
}
let Some(entry) = item.as_object() else {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.to_string(),
index: Some(index),
hook_index: None,
kind: "invalid_hooks_config".to_string(),
error_field: event.to_string(),
reason: format!(
"{context}: field {event}[{index}] must be a string or hook object"
),
});
continue;
};
let matcher = match optional_hook_matcher(entry, context, event, index) {
Ok(matcher) => matcher,
Err(error) => {
config.push_invalid_hook(runtime_invalid_hook(
event,
Some(index),
None,
"matcher",
error,
));
continue;
}
};
let Some(hook_array) = entry.get("hooks").and_then(JsonValue::as_array) else {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.to_string(),
index: Some(index),
hook_index: None,
kind: "invalid_hooks_config".to_string(),
error_field: "hooks".to_string(),
reason: format!("{context}: field {event}[{index}].hooks must be an array"),
});
continue;
};
for (hook_index, hook) in hook_array.iter().enumerate() {
let Some(hook_object) = hook.as_object() else {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.to_string(),
index: Some(index),
hook_index: Some(hook_index),
kind: "invalid_hooks_config".to_string(),
error_field: "hooks".to_string(),
reason: format!(
"{context}: field {event}[{index}].hooks[{hook_index}] must be an object"
),
});
continue;
};
if let Some(hook_type) = hook_object.get("type") {
let Some(hook_type) = hook_type.as_str() else {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.to_string(),
index: Some(index),
hook_index: Some(hook_index),
kind: "invalid_hooks_config".to_string(),
error_field: "type".to_string(),
reason: format!(
"{context}: field {event}[{index}].hooks[{hook_index}].type must be a string"
),
});
continue;
};
if hook_type != "command" {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.to_string(),
index: Some(index),
hook_index: Some(hook_index),
kind: "invalid_hooks_config".to_string(),
error_field: "type".to_string(),
reason: format!(
"{context}: field {event}[{index}].hooks[{hook_index}].type must be \"command\""
),
});
continue;
}
}
let Some(command) = hook_object
.get("command")
.and_then(JsonValue::as_str)
.filter(|command| !command.trim().is_empty())
else {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.to_string(),
index: Some(index),
hook_index: Some(hook_index),
kind: "invalid_hooks_config".to_string(),
error_field: "command".to_string(),
reason: format!(
"{context}: field {event}[{index}].hooks[{hook_index}].command must be a non-empty string"
),
});
continue;
};
push_command(
config,
RuntimeHookCommand::with_matcher(command.to_string(), matcher.clone()),
);
}
}
}
fn runtime_invalid_hook(
event: &str,
index: Option<usize>,
hook_index: Option<usize>,
error_field: &str,
error: ConfigError,
) -> RuntimeInvalidHookConfig {
RuntimeInvalidHookConfig {
event: event.to_string(),
index,
hook_index,
kind: "invalid_hooks_config".to_string(),
error_field: error_field.to_string(),
reason: config_error_detail(&error),
}
Ok(RuntimeHookConfig {
pre_tool_use: optional_hook_command_array(hooks, "PreToolUse", context)?
.unwrap_or_default(),
post_tool_use: optional_hook_command_array(hooks, "PostToolUse", context)?
.unwrap_or_default(),
post_tool_use_failure: optional_hook_command_array(hooks, "PostToolUseFailure", context)?
.unwrap_or_default(),
})
}
fn validate_optional_hooks_config(
@@ -2344,6 +2108,77 @@ fn optional_string_array(
}
}
fn optional_hook_command_array(
object: &BTreeMap<String, JsonValue>,
key: &str,
context: &str,
) -> Result<Option<Vec<RuntimeHookCommand>>, ConfigError> {
let Some(value) = object.get(key) else {
return Ok(None);
};
let Some(array) = value.as_array() else {
return Err(ConfigError::Parse(format!(
"{context}: field {key} must be an array"
)));
};
let mut commands = Vec::new();
for (index, item) in array.iter().enumerate() {
if let Some(command) = item.as_str() {
commands.push(RuntimeHookCommand::new(command.to_string()));
continue;
}
let Some(entry) = item.as_object() else {
return Err(ConfigError::Parse(format!(
"{context}: field {key}[{index}] must be a string or hook object"
)));
};
let matcher = optional_hook_matcher(entry, context, key, index)?;
let hooks = entry
.get("hooks")
.and_then(JsonValue::as_array)
.ok_or_else(|| {
ConfigError::Parse(format!(
"{context}: field {key}[{index}].hooks must be an array"
))
})?;
for (hook_index, hook) in hooks.iter().enumerate() {
let Some(hook_object) = hook.as_object() else {
return Err(ConfigError::Parse(format!(
"{context}: field {key}[{index}].hooks[{hook_index}] must be an object"
)));
};
if let Some(hook_type) = hook_object.get("type") {
let Some(hook_type) = hook_type.as_str() else {
return Err(ConfigError::Parse(format!(
"{context}: field {key}[{index}].hooks[{hook_index}].type must be a string"
)));
};
if hook_type != "command" {
return Err(ConfigError::Parse(format!(
"{context}: field {key}[{index}].hooks[{hook_index}].type must be \"command\""
)));
}
}
let command = hook_object
.get("command")
.and_then(JsonValue::as_str)
.filter(|command| !command.trim().is_empty())
.ok_or_else(|| {
ConfigError::Parse(format!(
"{context}: field {key}[{index}].hooks[{hook_index}].command must be a non-empty string"
))
})?;
commands.push(RuntimeHookCommand::with_matcher(
command.to_string(),
matcher.clone(),
));
}
}
Ok(Some(commands))
}
fn optional_hook_matcher(
entry: &BTreeMap<String, JsonValue>,
context: &str,
@@ -2593,7 +2428,7 @@ mod tests {
}
#[test]
fn records_object_style_hook_entries_without_command_441() {
fn rejects_object_style_hook_entries_without_command() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
@@ -2605,20 +2440,12 @@ mod tests {
)
.expect("write settings");
let loaded = ConfigLoader::new(&cwd, &home)
let error = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load valid siblings and record malformed hook entry");
.expect_err("config should reject malformed hook entry");
assert!(loaded.hooks().pre_tool_use().is_empty());
assert_eq!(loaded.hooks().invalid_count(), 1);
assert_eq!(
loaded.hooks().invalid_hooks()[0].kind,
"invalid_hooks_config"
);
assert_eq!(loaded.hooks().invalid_hooks()[0].event, "PreToolUse");
assert_eq!(loaded.hooks().invalid_hooks()[0].error_field, "command");
assert!(loaded.hooks().invalid_hooks()[0]
.reason
assert!(error
.to_string()
.contains("command must be a non-empty string"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
@@ -3361,7 +3188,7 @@ mod tests {
}
#[test]
fn loads_valid_hook_entries_and_records_invalid_siblings_441() {
fn rejects_invalid_hook_entries_before_merge() {
// given
let root = temp_dir();
let cwd = root.join("project");
@@ -3381,21 +3208,19 @@ mod tests {
)
.expect("write invalid project settings");
let loaded = ConfigLoader::new(&cwd, &home)
// when
let error = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load valid hook entries and record invalid siblings");
.expect_err("config should fail");
assert_eq!(loaded.hooks().pre_tool_use(), &["project".to_string()]);
assert_eq!(loaded.hooks().invalid_count(), 1);
assert_eq!(loaded.hooks().invalid_hooks()[0].event, "PreToolUse");
assert_eq!(
loaded.hooks().invalid_hooks()[0].kind,
"invalid_hooks_config"
// then — config validation now catches the mixed array before the hooks parser
let rendered = error.to_string();
assert!(
rendered.contains("hooks.PreToolUse")
&& rendered.contains("must be an array of strings"),
"expected validation error for hooks.PreToolUse, got: {rendered}"
);
assert_eq!(loaded.hooks().invalid_hooks()[0].index, Some(1));
assert!(loaded.hooks().invalid_hooks()[0]
.reason
.contains("must be a string or hook object"));
assert!(!rendered.contains("merged settings.hooks"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
@@ -3538,7 +3363,7 @@ mod tests {
}
#[test]
fn hook_event_wrong_type_is_recorded_without_config_failure_441() {
fn validates_wrong_type_for_known_field_with_field_path() {
// given
let root = temp_dir();
let cwd = root.join("project");
@@ -3552,145 +3377,29 @@ mod tests {
)
.expect("write user settings");
let loaded = ConfigLoader::new(&cwd, &home)
// when
let error = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should record malformed hook event without failing");
.expect_err("config should fail");
assert!(loaded.hooks().pre_tool_use().is_empty());
assert_eq!(loaded.hooks().invalid_count(), 1);
assert_eq!(loaded.hooks().invalid_hooks()[0].event, "PreToolUse");
assert_eq!(
loaded.hooks().invalid_hooks()[0].kind,
"invalid_hooks_config"
);
assert_eq!(loaded.hooks().invalid_hooks()[0].index, None);
assert!(loaded.hooks().invalid_hooks()[0]
.reason
.contains("field PreToolUse must be an array"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn collects_all_invalid_hook_siblings_instead_of_halting_at_first_441() {
// ROADMAP #441 finding (c): first-error-only halting means users must fix
// one hook at a time. After #441 partial fix, all invalid entries in the
// same config are collected.
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
r#"{"hooks":{"PreToolUse":[42],"PostToolUse":"not-an-array","InvalidEvent":["cmd"]}}"#,
)
.expect("write settings");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should collect all invalid hooks without halting at first");
assert!(loaded.hooks().pre_tool_use().is_empty());
assert!(loaded.hooks().post_tool_use().is_empty());
// Three distinct invalid entries: 42, wrong type, unknown event
assert_eq!(loaded.hooks().invalid_count(), 3);
let invalid = loaded.hooks().invalid_hooks();
// PreToolUse[0]=42
assert_eq!(invalid[0].event, "PreToolUse");
assert_eq!(invalid[0].index, Some(0));
assert_eq!(invalid[0].kind, "invalid_hooks_config");
// PostToolUse wrong type
assert_eq!(invalid[1].event, "PostToolUse");
assert_eq!(invalid[1].index, None);
assert_eq!(invalid[1].kind, "invalid_hooks_config");
// Unknown event
assert_eq!(invalid[2].event, "InvalidEvent");
assert_eq!(invalid[2].index, None);
assert_eq!(invalid[2].kind, "unknown_hook_event");
assert!(invalid[2]
.reason
.contains("unknown hook event InvalidEvent"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn unknown_hook_events_recorded_with_correct_kind_441() {
// ROADMAP #441 finding (a): unknown event names like Stop/Notification
// should not reject entire hooks config; they are recorded as invalid.
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
r#"{"hooks":{"PreToolUse":["valid-cmd"],"Stop":"not-an-array","Notification":[{}]}}"#,
)
.expect("write settings");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load valid hooks and record unknown event siblings");
// Valid PreToolUse hook should load
assert_eq!(loaded.hooks().pre_tool_use(), &["valid-cmd".to_string()]);
// Stop and Notification are unknown events; each gets one invalid entry
// Notification:[{}] also has an empty-object entry issue but since we
// don't parse unknown events, only the unknown-event invalid is recorded
let invalid = loaded.hooks().invalid_hooks();
// then
let rendered = error.to_string();
assert!(
invalid.len() >= 2,
"expected at least 2 invalid hooks, got {}",
invalid.len()
rendered.contains(&user_settings.display().to_string()),
"error should include file path, got: {rendered}"
);
let stop = invalid
.iter()
.find(|h| h.event == "Stop")
.expect("Stop invalid hook");
assert_eq!(stop.kind, "unknown_hook_event");
assert_eq!(stop.index, None);
assert!(stop.reason.contains("unknown hook event Stop"));
let notif = invalid
.iter()
.find(|h| h.event == "Notification")
.expect("Notification invalid hook");
assert_eq!(notif.kind, "unknown_hook_event");
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn documented_claude_code_hook_format_loads_without_error_441() {
// ROADMAP #441: the Claude Code documented hook format
// {"hooks":{"PreToolUse":[{"matcher":"Read","hooks":[{"type":"command","command":"..."}]}]}}
// must load without config_load_error.
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
r#"{"hooks":{"PreToolUse":[{"matcher":"Read","hooks":[{"type":"command","command":"/bin/echo pretool"}]}]}}"#,
)
.expect("write settings");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("Claude Code documented hook format must load without error");
assert_eq!(
loaded.hooks().pre_tool_use(),
&["/bin/echo pretool".to_string()]
assert!(
rendered.contains("hooks"),
"error should include field path component 'hooks', got: {rendered}"
);
assert!(
rendered.contains("PreToolUse"),
"error should describe the type mismatch, got: {rendered}"
);
assert!(
rendered.contains("array"),
"error should describe the expected type, got: {rendered}"
);
assert_eq!(loaded.hooks().invalid_count(), 0);
let entries = loaded.hooks().pre_tool_use_entries();
assert_eq!(entries[0].matcher(), Some("Read"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}

View File

@@ -118,7 +118,10 @@ impl FieldType {
Self::StringArray => value
.as_array()
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())),
Self::HookArray => true,
Self::HookArray => value.as_array().is_some_and(|arr| {
arr.iter()
.all(|entry| entry.as_str().is_some() || entry.as_object().is_some())
}),
Self::RulesImport => {
value.as_str().is_some()
|| value
@@ -436,56 +439,8 @@ fn validate_object_keys(
result
}
/// Emit deprecation warnings for bare string hook entries in the hooks object.
/// Legacy `["command-string"]` arrays still load but suggest migration to the
/// structured `{matcher, hooks:[{type, command}]}` form.
fn validate_hook_entry_format(
hooks: &BTreeMap<String, JsonValue>,
source: &str,
path_display: &str,
) -> ValidationResult {
let mut result = ValidationResult {
errors: Vec::new(),
warnings: Vec::new(),
};
for spec in HOOKS_FIELDS {
let Some(value) = hooks.get(spec.name) else {
continue;
};
let Some(array) = value.as_array() else {
continue;
};
for item in array {
if item.as_str().is_some() {
result.warnings.push(ConfigDiagnostic {
path: path_display.to_string(),
field: format!("hooks.{}", spec.name),
line: find_key_line(source, spec.name),
kind: DiagnosticKind::Deprecated {
replacement: "object-style hook entries with hooks:[{type:\"command\",command:\"...\"}]",
},
});
// One deprecation warning per event is enough
break;
}
}
}
result
}
fn suggest_field(input: &str, candidates: &[&str]) -> Option<String> {
let input_lower = input.to_ascii_lowercase();
// #461: prefix-aware matching — if input is a prefix of a candidate,
// treat it as distance 0 (perfect prefix match) to avoid edit-distance
// misranking (e.g., "mcp" → "env" instead of "mcpServers").
let prefix_match = candidates
.iter()
.filter(|c| c.to_ascii_lowercase().starts_with(&input_lower))
.min_by_key(|c| c.len())
.map(|name| name.to_string());
if prefix_match.is_some() {
return prefix_match;
}
candidates
.iter()
.filter_map(|candidate| {
@@ -555,7 +510,6 @@ pub fn validate_config_file(
source,
&path_display,
));
result.merge(validate_hook_entry_format(hooks, source, &path_display));
}
if let Some(permissions) = object.get("permissions").and_then(JsonValue::as_object) {
result.merge(validate_object_keys(
@@ -760,7 +714,7 @@ mod tests {
#[test]
fn validates_nested_hooks_keys() {
// given
let source = r#"{"hooks": {"PreToolUse": [{"hooks":[{"type":"command","command":"cmd"}]}], "BadHook": ["x"]}}"#;
let source = r#"{"hooks": {"PreToolUse": ["cmd"], "BadHook": ["x"]}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
@@ -769,12 +723,7 @@ mod tests {
// then
assert!(result.errors.is_empty());
assert_eq!(
result.warnings.len(),
1,
"expected only the unknown key warning, got {:?}",
result.warnings
);
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "hooks.BadHook");
}
@@ -790,14 +739,15 @@ mod tests {
}
#[test]
fn allows_wrong_hook_entry_types_for_partial_runtime_validation_441() {
fn rejects_wrong_hook_entry_types() {
let source = r#"{"hooks":{"PreToolUse":[42]}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
let result = validate_config_file(object, source, &test_path());
assert!(result.errors.is_empty(), "{:?}", result.errors);
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "hooks.PreToolUse");
}
#[test]
@@ -897,7 +847,7 @@ mod tests {
// given
let source = r#"{
"model": "opus",
"hooks": {"PreToolUse": [{"hooks":[{"type":"command","command":"guard"}]}]},
"hooks": {"PreToolUse": ["guard"]},
"permissions": {"defaultMode": "plan", "allow": ["Read"]},
"mcpServers": {},
"sandbox": {"enabled": false}

View File

@@ -37,6 +37,7 @@ pub enum AssistantEvent {
id: String,
name: String,
input: String,
thought_signature: Option<String>,
},
Usage(TokenUsage),
PromptCache(PromptCacheEvent),
@@ -381,7 +382,7 @@ where
.blocks
.iter()
.filter_map(|block| match block {
ContentBlock::ToolUse { id, name, input } => {
ContentBlock::ToolUse { id, name, input, .. } => {
Some((id.clone(), name.clone(), input.clone()))
}
_ => None,
@@ -741,9 +742,9 @@ fn build_assistant_message(
});
}
AssistantEvent::TextDelta(delta) => text.push_str(&delta),
AssistantEvent::ToolUse { id, name, input } => {
AssistantEvent::ToolUse { id, name, input, thought_signature } => {
flush_text_block(&mut text, &mut blocks);
blocks.push(ContentBlock::ToolUse { id, name, input });
blocks.push(ContentBlock::ToolUse { id, name, input, thought_signature });
}
AssistantEvent::Usage(value) => usage = Some(value),
AssistantEvent::PromptCache(event) => prompt_cache_events.push(event),
@@ -880,6 +881,7 @@ mod tests {
id: "tool-1".to_string(),
name: "add".to_string(),
input: "2,2".to_string(),
thought_signature: None,
},
AssistantEvent::Usage(TokenUsage {
input_tokens: 20,
@@ -1046,6 +1048,7 @@ mod tests {
id: "tool-1".to_string(),
name: "blocked".to_string(),
input: "secret".to_string(),
thought_signature: None,
},
AssistantEvent::MessageStop,
])
@@ -1091,6 +1094,7 @@ mod tests {
id: "tool-1".to_string(),
name: "blocked".to_string(),
input: r#"{"path":"secret.txt"}"#.to_string(),
thought_signature: None,
},
AssistantEvent::MessageStop,
])
@@ -1153,6 +1157,7 @@ mod tests {
id: "tool-1".to_string(),
name: "blocked".to_string(),
input: r#"{"path":"secret.txt"}"#.to_string(),
thought_signature: None,
},
AssistantEvent::MessageStop,
])
@@ -1213,6 +1218,7 @@ mod tests {
id: "tool-1".to_string(),
name: "add".to_string(),
input: r#"{"lhs":2,"rhs":2}"#.to_string(),
thought_signature: None,
},
AssistantEvent::MessageStop,
]),
@@ -1288,6 +1294,7 @@ mod tests {
id: "tool-1".to_string(),
name: "fail".to_string(),
input: r#"{"path":"README.md"}"#.to_string(),
thought_signature: None,
},
AssistantEvent::MessageStop,
]),
@@ -1755,6 +1762,7 @@ mod tests {
id: "tool-1".to_string(),
name: "echo".to_string(),
input: "payload".to_string(),
thought_signature: None,
},
AssistantEvent::MessageStop,
];
@@ -1778,6 +1786,7 @@ mod tests {
id: "tool-1".to_string(),
name: "echo".to_string(),
input: "payload".to_string(),
thought_signature: None,
},
]
);
@@ -1811,6 +1820,7 @@ mod tests {
id: "tool-1".to_string(),
name: "echo".to_string(),
input: "payload".to_string(),
thought_signature: None,
},
AssistantEvent::MessageStop,
])

View File

@@ -71,8 +71,8 @@ pub use config::{
McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig,
RuntimeInvalidHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig,
ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig,
CLAW_SETTINGS_SCHEMA_NAME,
};
pub use config_validate::{
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,

View File

@@ -42,6 +42,7 @@ pub enum ContentBlock {
id: String,
name: String,
input: String,
thought_signature: Option<String>,
},
ToolResult {
tool_use_id: String,
@@ -817,7 +818,7 @@ impl ContentBlock {
);
}
}
Self::ToolUse { id, name, input } => {
Self::ToolUse { id, name, input, thought_signature } => {
object.insert(
"type".to_string(),
JsonValue::String("tool_use".to_string()),
@@ -825,6 +826,12 @@ impl ContentBlock {
object.insert("id".to_string(), JsonValue::String(id.clone()));
object.insert("name".to_string(), JsonValue::String(name.clone()));
object.insert("input".to_string(), JsonValue::String(input.clone()));
if let Some(sig) = thought_signature {
object.insert(
"thought_signature".to_string(),
JsonValue::String(sig.clone()),
);
}
}
Self::ToolResult {
tool_use_id,
@@ -874,6 +881,7 @@ impl ContentBlock {
id: required_string(object, "id")?,
name: required_string(object, "name")?,
input: required_string(object, "input")?,
thought_signature: object.get("thought_signature").and_then(JsonValue::as_str).map(String::from)
}),
"tool_result" => Ok(Self::ToolResult {
tool_use_id: required_string(object, "tool_use_id")?,
@@ -1069,7 +1077,7 @@ fn persisted_block_json(block: &ContentBlock) -> JsonValue {
);
}
}
ContentBlock::ToolUse { id, name, input } => {
ContentBlock::ToolUse { id, name, input, thought_signature } => {
object.insert(
"type".to_string(),
JsonValue::String("tool_use".to_string()),
@@ -1083,6 +1091,12 @@ fn persisted_block_json(block: &ContentBlock) -> JsonValue {
"input".to_string(),
JsonValue::String(sanitize_jsonl_field(input)),
);
if let Some(sig) = thought_signature {
object.insert(
"thought_signature".to_string(),
JsonValue::String(sanitize_jsonl_field(sig)),
);
}
}
ContentBlock::ToolResult {
tool_use_id,
@@ -1433,6 +1447,7 @@ mod tests {
id: "tool-1".to_string(),
name: "bash".to_string(),
input: "echo hi".to_string(),
thought_signature: None,
},
],
Some(TokenUsage {
@@ -1596,6 +1611,7 @@ mod tests {
id: "tool-1".to_string(),
name: "bash".to_string(),
input: format!("Authorization: Bearer {secret}"),
thought_signature: None,
},
]))
.expect("tool use should append");

View File

@@ -686,6 +686,7 @@ mod tests {
id: "1".to_string(),
name: "read_file".to_string(),
input: r#"{"path":"src/main.rs"}"#.to_string(),
thought_signature: None,
}]),
ConversationMessage::tool_result(
"1",
@@ -697,6 +698,7 @@ mod tests {
id: "2".to_string(),
name: "edit_file".to_string(),
input: r#"{"path":"src/main.rs","old":"old","new":"new"}"#.to_string(),
thought_signature: None,
}]),
ConversationMessage::tool_result(
"2",
@@ -718,6 +720,7 @@ mod tests {
id: "1".to_string(),
name: "read_file".to_string(),
input: r#"{"path":"src/main.rs"}"#.to_string(),
thought_signature: None,
}]),
ConversationMessage::tool_result(
"1",
@@ -746,6 +749,7 @@ mod tests {
id: "t".to_string(),
name: "bash".to_string(),
input: r#"{"command":"ls"}"#.to_string(),
thought_signature: None,
},
]));
@@ -764,6 +768,7 @@ mod tests {
id: format!("read_{i}"),
name: "read_file".to_string(),
input: format!(r#"{{"path":"src/{i}.rs"}}"#),
thought_signature: None,
},
]));
messages.push(ConversationMessage::tool_result(
@@ -789,6 +794,7 @@ mod tests {
id: "1".to_string(),
name: "read_file".to_string(),
input: r#"{"path":"src/main.rs"}"#.to_string(),
thought_signature: None,
}]),
ConversationMessage::tool_result(
"1",
@@ -800,6 +806,7 @@ mod tests {
id: "2".to_string(),
name: "edit_file".to_string(),
input: r#"{"path":"src/main.rs","old":"buggy","new":"fixed"}"#.to_string(),
thought_signature: None,
}]),
ConversationMessage::tool_result(
"2",

File diff suppressed because it is too large Load Diff

View File

@@ -112,7 +112,6 @@ fn assert_doctor_help_json_contract(parsed: &Value) {
assert!(checks.iter().any(|check| check == "boot preflight"));
assert!(checks.iter().any(|check| check == "memory"));
assert!(checks.iter().any(|check| check == "mcp validation"));
assert!(checks.iter().any(|check| check == "hook validation"));
}
#[test]
@@ -841,19 +840,14 @@ fn acp_guidance_emits_json_when_requested() {
let root = unique_temp_dir("acp-json");
fs::create_dir_all(&root).expect("temp dir should exist");
// #443: acp serve exits 2 (not implemented) instead of 0
let output = run_claw(&root, &["--output-format", "json", "acp"], &[]);
assert_eq!(
output.status.code(),
Some(2),
"acp should exit 2 (not implemented)"
);
let acp: Value =
serde_json::from_slice(&output.stdout).expect("acp stdout should be valid json");
let acp = assert_json_command(&root, &["--output-format", "json", "acp"]);
assert_eq!(acp["kind"], "acp");
assert_eq!(acp["schema_version"], "1.0");
assert_eq!(acp["status"], "not_implemented");
assert_eq!(acp["status"], "unsupported");
assert_eq!(acp["phase"], "discoverability_only");
assert_eq!(acp["supported"], false);
assert_eq!(acp["exit_code"], 0);
assert_eq!(acp["serve_alias_only"], true);
assert_eq!(acp["protocol"]["json_rpc"], false);
assert_eq!(acp["protocol"]["daemon"], false);
assert!(acp["protocol"]["endpoint"].is_null());
@@ -861,23 +855,12 @@ fn acp_guidance_emits_json_when_requested() {
acp["contracts"]["unsupported_invocation_kind"],
"unsupported_acp_invocation"
);
// #443: internal tracking IDs removed from public JSON
assert!(
acp.get("discoverability_tracking").is_none(),
"discoverability_tracking should be removed (#443)"
);
assert!(
acp.get("tracking").is_none(),
"tracking should be removed (#443)"
);
assert!(
acp.get("recommended_workflows").is_none(),
"recommended_workflows should be removed (#443)"
);
assert_eq!(acp["discoverability_tracking"], "ROADMAP #64a");
assert_eq!(acp["tracking"], "ROADMAP #76 / #3033 / #3004");
assert!(acp["message"]
.as_str()
.expect("acp message")
.contains("not implemented"));
.contains("discoverability alias"));
}
#[test]
@@ -1476,7 +1459,7 @@ fn doctor_and_resume_status_emit_json_when_requested() {
.is_some_and(|available| available.iter().any(|name| name == "web_fetch")));
let checks = doctor["checks"].as_array().expect("doctor checks");
assert_eq!(checks.len(), 12);
assert_eq!(checks.len(), 10);
let check_names = checks
.iter()
.map(|check| {
@@ -1496,10 +1479,8 @@ fn doctor_and_resume_status_emit_json_when_requested() {
check_names,
vec![
"auth",
"base urls",
"config",
"mcp validation",
"hook validation",
"install source",
"workspace",
"memory",
@@ -2082,7 +2063,7 @@ fn local_json_surfaces_have_non_empty_action_contract_714() {
&git_workspace,
strings(&["--output-format", "json", "diff"]),
),
// #443: ACP exits 2 (not implemented); tested separately in acp_guidance_emits_json_when_requested
(&workspace, strings(&["--output-format", "json", "acp"])),
(&workspace, strings(&["--output-format", "json", "config"])),
(
&workspace,
@@ -3885,7 +3866,7 @@ fn agents_plugins_mcp_unknown_subcommand_have_hint_774() {
};
let parsed: serde_json::Value =
serde_json::from_str(json_str.trim()).expect("mcp bogus should emit JSON");
assert_eq!(parsed["error_kind"], "unsupported_action");
assert_eq!(parsed["error_kind"], "unknown_mcp_action");
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(!hint.is_empty(), "mcp bogus hint must be non-null (#774)");
}
@@ -4165,8 +4146,8 @@ fn acp_unsupported_invocation_has_hint_782() {
.expect("hint must be non-null (#782)");
assert!(!hint.is_empty(), "hint must not be empty");
assert!(
hint.contains("not implemented") || hint.contains("unsupported"),
"hint should explain the not-implemented status, got: {hint:?}"
hint.contains("discoverability") || hint.contains("ROADMAP"),
"hint should explain the discoverability-only status, got: {hint:?}"
);
}

View File

@@ -530,9 +530,8 @@ fn resumed_help_command_emits_structured_json() {
let stdout = String::from_utf8(output.stdout).expect("utf8");
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
assert_eq!(parsed["kind"], "help");
// #338: resume help now uses 'message' field for parity with top-level help
assert!(parsed["message"].as_str().is_some());
let text = parsed["message"].as_str().unwrap();
assert!(parsed["text"].as_str().is_some());
let text = parsed["text"].as_str().unwrap();
assert!(text.contains("/status"), "help text should list /status");
}

View File

@@ -5203,7 +5203,7 @@ async fn stream_with_provider(
) -> Result<Vec<AssistantEvent>, ApiError> {
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_tools: BTreeMap<u32, (String, String, String, Option<String>)> = BTreeMap::new();
let mut pending_thinking: BTreeMap<u32, (String, Option<String>)> = BTreeMap::new();
let mut saw_stop = false;
@@ -5238,7 +5238,7 @@ async fn stream_with_provider(
}
}
ContentBlockDelta::InputJsonDelta { partial_json } => {
if let Some((_, _, input)) = pending_tools.get_mut(&delta.index) {
if let Some((_, _, input, _)) = pending_tools.get_mut(&delta.index) {
input.push_str(&partial_json);
}
}
@@ -5262,8 +5262,8 @@ async fn stream_with_provider(
signature,
});
}
if let Some((id, name, input)) = pending_tools.remove(&stop.index) {
events.push(AssistantEvent::ToolUse { id, name, input });
if let Some((id, name, input, thought_signature)) = pending_tools.remove(&stop.index) {
events.push(AssistantEvent::ToolUse { id, name, input, thought_signature });
}
}
ApiStreamEvent::MessageDelta(delta) => {
@@ -5371,11 +5371,12 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
thinking: thinking.clone(),
signature: signature.clone(),
},
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
ContentBlock::ToolUse { id, name, input, thought_signature } => InputContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input: serde_json::from_str(input)
.unwrap_or_else(|_| serde_json::json!({ "raw": input })),
thought_signature: thought_signature.clone(),
},
ContentBlock::ToolResult {
tool_use_id,
@@ -5406,7 +5407,7 @@ fn push_output_block(
block: OutputContentBlock,
block_index: u32,
events: &mut Vec<AssistantEvent>,
pending_tools: &mut BTreeMap<u32, (String, String, String)>,
pending_tools: &mut BTreeMap<u32, (String, String, String, Option<String>)>,
pending_thinking: &mut BTreeMap<u32, (String, Option<String>)>,
streaming_tool_input: bool,
) {
@@ -5416,7 +5417,7 @@ fn push_output_block(
events.push(AssistantEvent::TextDelta(text));
}
}
OutputContentBlock::ToolUse { id, name, input } => {
OutputContentBlock::ToolUse { id, name, input, thought_signature } => {
let initial_input = if streaming_tool_input
&& input.is_object()
&& input.as_object().is_some_and(serde_json::Map::is_empty)
@@ -5425,7 +5426,7 @@ fn push_output_block(
} else {
input.to_string()
};
pending_tools.insert(block_index, (id, name, initial_input));
pending_tools.insert(block_index, (id, name, initial_input, thought_signature));
}
OutputContentBlock::Thinking {
thinking,
@@ -5459,8 +5460,8 @@ fn response_to_events(response: MessageResponse) -> Vec<AssistantEvent> {
&mut pending_thinking,
false,
);
if let Some((id, name, input)) = pending_tools.remove(&index) {
events.push(AssistantEvent::ToolUse { id, name, input });
if let Some((id, name, input, thought_signature)) = pending_tools.remove(&index) {
events.push(AssistantEvent::ToolUse { id, name, input, thought_signature });
}
}
@@ -8066,6 +8067,7 @@ mod tests {
id: "tool-1".to_string(),
name: "read_file".to_string(),
input: json!({}),
thought_signature: None,
},
1,
&mut events,
@@ -8078,6 +8080,7 @@ mod tests {
id: "tool-2".to_string(),
name: "grep_search".to_string(),
input: json!({}),
thought_signature: None,
},
2,
&mut events,
@@ -8103,6 +8106,7 @@ mod tests {
"tool-1".to_string(),
"read_file".to_string(),
"{\"path\":\"src/main.rs\"}".to_string(),
None,
))
);
assert_eq!(
@@ -8111,6 +8115,7 @@ mod tests {
"tool-2".to_string(),
"grep_search".to_string(),
"{\"pattern\":\"TODO\"}".to_string(),
None,
))
);
}
@@ -9358,6 +9363,7 @@ mod tests {
id: "tool-1".to_string(),
name: "read_file".to_string(),
input: json!({ "path": self.input_path }).to_string(),
thought_signature: None,
},
AssistantEvent::MessageStop,
])