From 54282ca6b79a41850201b097420c9c769a1bea9f Mon Sep 17 00:00:00 2001 From: AlexMikhalev Date: Sat, 8 Aug 2026 18:29:16 +0100 Subject: [PATCH 1/2] fix(learnings): accept Claude tool_response/exitCode envelopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live Claude Code PostToolUse sends tool_response + exitCode, not the fixture tool_result + exit_code shape — learn hook fail-opened with zero captures (#2704 / multi-client plan C2). - serde alias tool_response → tool_result, exitCode → exit_code - Auto format: normalize bash→Bash; legacy {tool,result} from #2704 - Unit tests for live Claude, lowercase bash, legacy sample Host Phase 0 (separate): PATH agent 1.21.1, Claude post→learn hook, OpenCode terraphim-learn plugin. --- crates/terraphim_agent/src/learnings/hook.rs | 111 ++++++++++++++++++- 1 file changed, 107 insertions(+), 4 deletions(-) diff --git a/crates/terraphim_agent/src/learnings/hook.rs b/crates/terraphim_agent/src/learnings/hook.rs index 5f70454..fe7516e 100644 --- a/crates/terraphim_agent/src/learnings/hook.rs +++ b/crates/terraphim_agent/src/learnings/hook.rs @@ -344,7 +344,11 @@ pub struct HookInput { pub tool_name: String, /// Tool input parameters pub tool_input: ToolInput, - /// Tool execution result + /// Tool execution result. + /// + /// Claude Code live PostToolUse often sends `tool_response` instead of + /// `tool_result` — accept both. + #[serde(alias = "tool_response")] pub tool_result: ToolResult, } @@ -368,7 +372,9 @@ pub struct ToolInput { #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct ToolResult { - /// Exit code (0 = success, non-zero = failure) + /// Exit code (0 = success, non-zero = failure). + /// Claude live payloads use camelCase `exitCode`. + #[serde(alias = "exitCode")] pub exit_code: i32, /// Standard output captured from the tool #[serde(default)] @@ -506,8 +512,61 @@ impl HookInput { let value: serde_json::Value = serde_json::from_str(json)?; // Claude / Codex / opencode-normalised: canonical tool event. - if value.get("tool_name").is_some() && value.get("tool_result").is_some() { - return serde_json::from_str(json); + // Live Claude may use `tool_response` instead of `tool_result`. + if value.get("tool_name").is_some() + && (value.get("tool_result").is_some() || value.get("tool_response").is_some()) + { + return serde_json::from_str(json).map(|mut input: HookInput| { + // Normalize tool name so should_capture matches. + if input.tool_name.eq_ignore_ascii_case("bash") { + input.tool_name = "Bash".to_string(); + } + input + }); + } + // Legacy / minimal: { "tool": "Bash", "result": { "exit_code": 1 } } (#2704 sample) + if value.get("tool").is_some() && value.get("result").is_some() { + let tool = value + .get("tool") + .and_then(|v| v.as_str()) + .unwrap_or("Bash"); + let result = value.get("result").cloned().unwrap_or_default(); + let exit = result + .get("exit_code") + .or_else(|| result.get("exitCode")) + .and_then(|v| v.as_i64()) + .unwrap_or(0) as i32; + let cmd = value + .get("tool_input") + .and_then(|t| t.get("command")) + .and_then(|c| c.as_str()) + .or_else(|| value.get("command").and_then(|c| c.as_str())) + .map(|s| s.to_string()); + let tool_name = if tool.eq_ignore_ascii_case("bash") { + "Bash".to_string() + } else { + tool.to_string() + }; + return Ok(HookInput { + tool_name, + tool_input: ToolInput { + command: cmd, + extra: HashMap::new(), + }, + tool_result: ToolResult { + exit_code: exit, + stdout: result + .get("stdout") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + stderr: result + .get("stderr") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + }, + }); } // opencode native: `tool` + (`args` | `output`), no `tool_name`. if value.get("tool").is_some() @@ -613,6 +672,50 @@ mod tests { assert_eq!(input.tool_result.stderr, "rejected"); } + #[test] + fn test_hook_input_claude_tool_response_exit_code_alias() { + // Live Claude Code PostToolUse shape (2026-08 investigation) + let json = r#"{ + "tool_name": "Bash", + "tool_input": {"command": "ls /nope-live"}, + "tool_response": { + "exitCode": 2, + "stdout": "", + "stderr": "No such file", + "interrupted": false, + "isImage": false + } + }"#; + let input = HookInput::from_json_with_format(json, AgentFormat::Claude).unwrap(); + assert_eq!(input.tool_name, "Bash"); + assert_eq!(input.command(), Some("ls /nope-live")); + assert_eq!(input.tool_result.exit_code, 2); + assert_eq!(input.tool_result.stderr, "No such file"); + assert!(input.should_capture()); + } + + #[test] + fn test_hook_input_auto_normalizes_lowercase_bash() { + let json = r#"{ + "tool_name": "bash", + "tool_input": {"command": "false"}, + "tool_result": {"exit_code": 1, "stdout": "", "stderr": "x"} + }"#; + let input = HookInput::from_json_with_format(json, AgentFormat::Auto).unwrap(); + assert_eq!(input.tool_name, "Bash"); + assert!(input.should_capture()); + } + + #[test] + fn test_hook_input_legacy_2704_tool_result_object() { + let json = r#"{"tool":"Bash","command":"false","result":{"exit_code":1,"stderr":"fail"}}"#; + let input = HookInput::from_json_with_format(json, AgentFormat::Auto).unwrap(); + assert_eq!(input.tool_name, "Bash"); + assert_eq!(input.command(), Some("false")); + assert_eq!(input.tool_result.exit_code, 1); + assert!(input.should_capture()); + } + #[test] fn test_should_capture_failed_bash() { let input = HookInput { From 28d53981e4735398e40006e04a6903f9e0b9d7fd Mon Sep 17 00:00:00 2001 From: AlexMikhalev Date: Sat, 8 Aug 2026 18:45:20 +0100 Subject: [PATCH 2/2] style(learnings): cargo fmt hook.rs for CI --- crates/terraphim_agent/src/learnings/hook.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/terraphim_agent/src/learnings/hook.rs b/crates/terraphim_agent/src/learnings/hook.rs index fe7516e..0a39442 100644 --- a/crates/terraphim_agent/src/learnings/hook.rs +++ b/crates/terraphim_agent/src/learnings/hook.rs @@ -526,10 +526,7 @@ impl HookInput { } // Legacy / minimal: { "tool": "Bash", "result": { "exit_code": 1 } } (#2704 sample) if value.get("tool").is_some() && value.get("result").is_some() { - let tool = value - .get("tool") - .and_then(|v| v.as_str()) - .unwrap_or("Bash"); + let tool = value.get("tool").and_then(|v| v.as_str()).unwrap_or("Bash"); let result = value.get("result").cloned().unwrap_or_default(); let exit = result .get("exit_code")