@@ -197,6 +197,118 @@ async def test_send_request_skips_the_surface_gate_when_method_absent_at_version
197197 assert isinstance (result , types .EmptyResult )
198198
199199
200+ @pytest .mark .anyio
201+ async def test_create_message_tool_result_validation ():
202+ """Test tool_use/tool_result validation in create_message."""
203+ dispatcher = StubDispatcher (
204+ result = {"role" : "assistant" , "content" : [{"type" : "text" , "text" : "ok" }], "model" : "m" }
205+ )
206+ session = _make_session (
207+ dispatcher , capabilities = ClientCapabilities (sampling = SamplingCapability (tools = SamplingToolsCapability ()))
208+ )
209+ tool = types .Tool (name = "test_tool" , input_schema = {"type" : "object" })
210+ text = types .TextContent (type = "text" , text = "hello" )
211+ tool_use = types .ToolUseContent (type = "tool_use" , id = "call_1" , name = "test_tool" , input = {})
212+ tool_result = types .ToolResultContent (type = "tool_result" , tool_use_id = "call_1" , content = [])
213+
214+ # Case 1: tool_result mixed with other content
215+ with pytest .raises (ValueError , match = "only tool_result content" ):
216+ await session .create_message (
217+ messages = [
218+ types .SamplingMessage (role = "user" , content = text ),
219+ types .SamplingMessage (role = "assistant" , content = tool_use ),
220+ types .SamplingMessage (role = "user" , content = [tool_result , text ]),
221+ ],
222+ max_tokens = 100 ,
223+ tools = [tool ],
224+ )
225+
226+ # Case 2: tool_result without previous message
227+ with pytest .raises (ValueError , match = "requires a previous message" ):
228+ await session .create_message (
229+ messages = [types .SamplingMessage (role = "user" , content = tool_result )],
230+ max_tokens = 100 ,
231+ tools = [tool ],
232+ )
233+
234+ # Case 3: tool_result without previous tool_use
235+ with pytest .raises (ValueError , match = "do not match any tool_use" ):
236+ await session .create_message (
237+ messages = [
238+ types .SamplingMessage (role = "user" , content = text ),
239+ types .SamplingMessage (role = "user" , content = tool_result ),
240+ ],
241+ max_tokens = 100 ,
242+ tools = [tool ],
243+ )
244+
245+ # Case 4: mismatched tool IDs
246+ with pytest .raises (ValueError , match = "ids of tool_result blocks and tool_use blocks" ):
247+ await session .create_message (
248+ messages = [
249+ types .SamplingMessage (role = "user" , content = text ),
250+ types .SamplingMessage (role = "assistant" , content = tool_use ),
251+ types .SamplingMessage (
252+ role = "user" ,
253+ content = types .ToolResultContent (type = "tool_result" , tool_use_id = "wrong_id" , content = []),
254+ ),
255+ ],
256+ max_tokens = 100 ,
257+ tools = [tool ],
258+ )
259+
260+ # Case 4b: earlier mismatched tool result with a later plain message
261+ with pytest .raises (ValueError , match = "ids of tool_result blocks and tool_use blocks" ):
262+ await session .create_message (
263+ messages = [
264+ types .SamplingMessage (role = "assistant" , content = tool_use ),
265+ types .SamplingMessage (
266+ role = "user" ,
267+ content = types .ToolResultContent (type = "tool_result" , tool_use_id = "wrong_id" , content = []),
268+ ),
269+ types .SamplingMessage (role = "assistant" , content = text ),
270+ ],
271+ max_tokens = 100 ,
272+ tools = [tool ],
273+ )
274+
275+ # Case 5: text-only message with tools (no tool_results) - passes validation
276+ await session .create_message (
277+ messages = [types .SamplingMessage (role = "user" , content = text )],
278+ max_tokens = 100 ,
279+ tools = [tool ],
280+ )
281+
282+ # Case 6: valid matching tool_result/tool_use IDs - passes validation
283+ await session .create_message (
284+ messages = [
285+ types .SamplingMessage (role = "user" , content = text ),
286+ types .SamplingMessage (role = "assistant" , content = tool_use ),
287+ types .SamplingMessage (role = "user" , content = tool_result ),
288+ ],
289+ max_tokens = 100 ,
290+ tools = [tool ],
291+ )
292+
293+ # Case 7: validation runs even without `tools` parameter
294+ # (tool loop continuation may omit tools while containing tool_result)
295+ with pytest .raises (ValueError , match = "do not match any tool_use" ):
296+ await session .create_message (
297+ messages = [
298+ types .SamplingMessage (role = "user" , content = text ),
299+ types .SamplingMessage (role = "user" , content = tool_result ),
300+ ],
301+ max_tokens = 100 ,
302+ )
303+
304+ # Case 8: empty messages list - skips validation entirely
305+ no_tools_session = _make_session (
306+ StubDispatcher (result = {"role" : "assistant" , "content" : {"type" : "text" , "text" : "ok" }, "model" : "m" }),
307+ capabilities = ClientCapabilities (sampling = SamplingCapability (tools = SamplingToolsCapability ())),
308+ )
309+ await no_tools_session .create_message (messages = [], max_tokens = 100 )
310+
311+
200312@pytest .mark .anyio
201313async def test_send_request_validates_result_alias_only ():
202314 """Peer results validate alias-only; a snake_case key from the wire is
0 commit comments