返回 CodeWhale
tool_success.rs
根目录 / crates / core / tests / tool_success.rs
1 use std::{
2 path::Path,
3 sync::{Arc, Mutex},
4 };
5
6 use async_trait::async_trait;
7 use codewhale_config::ConfigToml;
8 use codewhale_core::Runtime;
9 use codewhale_execpolicy::{AskForApproval, ExecPolicyEngine};
10 use codewhale_hooks::{HookDispatcher, HookEvent, HookSink};
11 use codewhale_mcp::McpManager;
12 use codewhale_protocol::{ToolKind, ToolOutput, ToolPayload};
13 use codewhale_state::StateStore;
14 use codewhale_tools::{
15 FunctionCallError, ToolCall, ToolCallSource, ToolDescriptor, ToolHandler, ToolInvocation,
16 ToolRegistry,
17 };
18 use serde_json::json;
19 use uuid::Uuid;
20
21 struct FixtureTool {
22 kind: ToolKind,
23 output: ToolOutput,
24 }
25
26 #[async_trait]
27 impl ToolHandler for FixtureTool {
28 fn kind(&self) -> ToolKind {
29 self.kind
30 }
31
32 async fn handle(&self, _invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
33 Ok(self.output.clone())
34 }
35 }
36
37 #[derive(Default)]
38 struct RecordingSink(Mutex<Vec<HookEvent>>);
39
40 #[async_trait]
41 impl HookSink for RecordingSink {
42 async fn emit(&self, event: &HookEvent) -> anyhow::Result<()> {
43 self.0
44 .lock()
45 .expect("recording hook lock")
46 .push(event.clone());
47 Ok(())
48 }
49 }
50
51 async fn invoke_fixture(
52 name: &str,
53 kind: ToolKind,
54 payload: ToolPayload,
55 output: ToolOutput,
56 ) -> (serde_json::Value, Vec<HookEvent>) {
57 let mut registry = ToolRegistry::default();
58 registry
59 .register(
60 ToolDescriptor {
61 name: name.into(),
62 input_schema: json!({"type":"object"}),
63 output_schema: json!({"type":"object"}),
64 supports_parallel_tool_calls: true,
65 timeout_ms: None,
66 },
67 Arc::new(FixtureTool { kind, output }),
68 )
69 .expect("register fixture tool");
70
71 let recording = Arc::new(RecordingSink::default());
72 let mut hooks = HookDispatcher::default();
73 hooks.add_sink(recording.clone());
74 let state_path = std::env::temp_dir().join(format!(
75 "codewhale-core-tool-success-{name}-{}.db",
76 Uuid::new_v4().simple()
77 ));
78 let runtime = Runtime::new(
79 ConfigToml::default(),
80 StateStore::open(Some(state_path)).expect("open temporary state"),
81 Arc::new(registry),
82 Arc::new(McpManager::default()),
83 ExecPolicyEngine::new(vec![], vec![]),
84 hooks,
85 );
86 let result = runtime
87 .invoke_tool(
88 ToolCall {
89 name: name.into(),
90 payload,
91 source: ToolCallSource::Direct,
92 raw_tool_call_id: None,
93 },
94 AskForApproval::Never,
95 Path::new("/tmp/codewhale"),
96 )
97 .await
98 .expect("application failure remains a transport-successful tool result");
99 let events = recording.0.lock().expect("recording hook lock").clone();
100 (result, events)
101 }
102
103 fn assert_failed_lifecycle(events: &[HookEvent], expected_tool: &str) {
104 let terminal = events
105 .iter()
106 .find_map(|event| match event {
107 HookEvent::ToolLifecycle {
108 tool_name,
109 phase,
110 payload,
111 ..
112 } if tool_name == expected_tool && phase == "failed" => Some(payload),
113 _ => None,
114 })
115 .expect("failed application lifecycle hook");
116 assert_eq!(terminal["ok"], false);
117 }
118
119 #[tokio::test]
120 async fn invoke_tool_preserves_application_failure_as_a_tool_result() {
121 let (result, events) = invoke_fixture(
122 "application_failure_tool",
123 ToolKind::Function,
124 ToolPayload::Function {
125 arguments: "{}".into(),
126 },
127 ToolOutput::Function {
128 body: Some(json!({"message": "application failure remains visible"})),
129 success: false,
130 },
131 )
132 .await;
133
134 assert_eq!(result["ok"], false);
135 assert_eq!(result["status"], "failed");
136 assert!(result.get("error").is_none());
137 assert_eq!(result["output"]["type"], "function");
138 assert_eq!(result["output"]["success"], false);
139 assert_eq!(
140 result["output"]["body"]["message"],
141 "application failure remains visible"
142 );
143 assert_eq!(result["events"][1]["event"], "tool_call_result");
144 assert_eq!(result["events"][1]["output"]["success"], false);
145 assert_failed_lifecycle(&events, "application_failure_tool");
146 }
147
148 #[tokio::test]
149 async fn invoke_tool_fails_closed_for_malformed_mcp_error_metadata() {
150 let malformed_result = json!({
151 "content": [{"type": "text", "text": "malformed failure remains visible"}],
152 "isError": "unknown"
153 });
154 let (result, events) = invoke_fixture(
155 "malformed_mcp_failure_tool",
156 ToolKind::Mcp,
157 ToolPayload::Mcp {
158 server: "fixture".into(),
159 tool: "malformed".into(),
160 raw_arguments: json!({}),
161 raw_tool_call_id: None,
162 },
163 ToolOutput::Mcp {
164 result: malformed_result,
165 },
166 )
167 .await;
168
169 assert_eq!(result["ok"], false);
170 assert_eq!(result["status"], "failed");
171 assert!(result.get("error").is_none());
172 assert_eq!(result["output"]["type"], "mcp");
173 assert_eq!(result["output"]["result"]["isError"], "unknown");
174 assert_eq!(
175 result["output"]["result"]["content"][0]["text"],
176 "malformed failure remains visible"
177 );
178 assert_eq!(result["events"][1]["event"], "tool_call_result");
179 assert_eq!(
180 result["events"][1]["output"]["result"]["isError"],
181 "unknown"
182 );
183 assert_failed_lifecycle(&events, "malformed_mcp_failure_tool");
184 }
185
185 lines RUST