@@ -125,6 +125,118 @@ async def test_send_request_without_back_channel_or_related_id_fails_fast():
125125 assert dispatcher .requests [0 ][3 ] == 3
126126
127127
128+ @pytest .mark .anyio
129+ async def test_create_message_tool_result_validation ():
130+ """Test tool_use/tool_result validation in create_message."""
131+ dispatcher = StubDispatcher (
132+ result = {"role" : "assistant" , "content" : [{"type" : "text" , "text" : "ok" }], "model" : "m" }
133+ )
134+ session = _make_session (
135+ dispatcher , capabilities = ClientCapabilities (sampling = SamplingCapability (tools = SamplingToolsCapability ()))
136+ )
137+ tool = types .Tool (name = "test_tool" , input_schema = {"type" : "object" })
138+ text = types .TextContent (type = "text" , text = "hello" )
139+ tool_use = types .ToolUseContent (type = "tool_use" , id = "call_1" , name = "test_tool" , input = {})
140+ tool_result = types .ToolResultContent (type = "tool_result" , tool_use_id = "call_1" , content = [])
141+
142+ # Case 1: tool_result mixed with other content
143+ with pytest .raises (ValueError , match = "only tool_result content" ):
144+ await session .create_message (
145+ messages = [
146+ types .SamplingMessage (role = "user" , content = text ),
147+ types .SamplingMessage (role = "assistant" , content = tool_use ),
148+ types .SamplingMessage (role = "user" , content = [tool_result , text ]),
149+ ],
150+ max_tokens = 100 ,
151+ tools = [tool ],
152+ )
153+
154+ # Case 2: tool_result without previous message
155+ with pytest .raises (ValueError , match = "requires a previous message" ):
156+ await session .create_message (
157+ messages = [types .SamplingMessage (role = "user" , content = tool_result )],
158+ max_tokens = 100 ,
159+ tools = [tool ],
160+ )
161+
162+ # Case 3: tool_result without previous tool_use
163+ with pytest .raises (ValueError , match = "do not match any tool_use" ):
164+ await session .create_message (
165+ messages = [
166+ types .SamplingMessage (role = "user" , content = text ),
167+ types .SamplingMessage (role = "user" , content = tool_result ),
168+ ],
169+ max_tokens = 100 ,
170+ tools = [tool ],
171+ )
172+
173+ # Case 4: mismatched tool IDs
174+ with pytest .raises (ValueError , match = "ids of tool_result blocks and tool_use blocks" ):
175+ await session .create_message (
176+ messages = [
177+ types .SamplingMessage (role = "user" , content = text ),
178+ types .SamplingMessage (role = "assistant" , content = tool_use ),
179+ types .SamplingMessage (
180+ role = "user" ,
181+ content = types .ToolResultContent (type = "tool_result" , tool_use_id = "wrong_id" , content = []),
182+ ),
183+ ],
184+ max_tokens = 100 ,
185+ tools = [tool ],
186+ )
187+
188+ # Case 4b: earlier mismatched tool result with a later plain message
189+ with pytest .raises (ValueError , match = "ids of tool_result blocks and tool_use blocks" ):
190+ await session .create_message (
191+ messages = [
192+ types .SamplingMessage (role = "assistant" , content = tool_use ),
193+ types .SamplingMessage (
194+ role = "user" ,
195+ content = types .ToolResultContent (type = "tool_result" , tool_use_id = "wrong_id" , content = []),
196+ ),
197+ types .SamplingMessage (role = "assistant" , content = text ),
198+ ],
199+ max_tokens = 100 ,
200+ tools = [tool ],
201+ )
202+
203+ # Case 5: text-only message with tools (no tool_results) - passes validation
204+ await session .create_message (
205+ messages = [types .SamplingMessage (role = "user" , content = text )],
206+ max_tokens = 100 ,
207+ tools = [tool ],
208+ )
209+
210+ # Case 6: valid matching tool_result/tool_use IDs - passes validation
211+ await session .create_message (
212+ messages = [
213+ types .SamplingMessage (role = "user" , content = text ),
214+ types .SamplingMessage (role = "assistant" , content = tool_use ),
215+ types .SamplingMessage (role = "user" , content = tool_result ),
216+ ],
217+ max_tokens = 100 ,
218+ tools = [tool ],
219+ )
220+
221+ # Case 7: validation runs even without `tools` parameter
222+ # (tool loop continuation may omit tools while containing tool_result)
223+ with pytest .raises (ValueError , match = "do not match any tool_use" ):
224+ await session .create_message (
225+ messages = [
226+ types .SamplingMessage (role = "user" , content = text ),
227+ types .SamplingMessage (role = "user" , content = tool_result ),
228+ ],
229+ max_tokens = 100 ,
230+ )
231+
232+ # Case 8: empty messages list - skips validation entirely
233+ no_tools_session = _make_session (
234+ StubDispatcher (result = {"role" : "assistant" , "content" : {"type" : "text" , "text" : "ok" }, "model" : "m" }),
235+ capabilities = ClientCapabilities (sampling = SamplingCapability (tools = SamplingToolsCapability ())),
236+ )
237+ await no_tools_session .create_message (messages = [], max_tokens = 100 )
238+
239+
128240@pytest .mark .anyio
129241async def test_send_request_validates_result_alias_only ():
130242 """Peer results validate alias-only; a snake_case key from the wire is
0 commit comments