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