@@ -847,3 +847,156 @@ async def hang(tool_call_id, params, *, signal=None, on_update=None):
847847 # layer's backfill.
848848 result_ids = [e .message .tool_call_id for e in events if e .type == "message_end" ]
849849 assert result_ids == ["t1" ]
850+
851+
852+ class TestFaultIsolationEdgeCases :
853+ """Cover the defensive branches of the fault-isolation machinery."""
854+
855+ @staticmethod
856+ def _two_call_msg ():
857+ return make_assistant_msg (
858+ [
859+ ToolCall (id = "t1" , name = "a" , arguments = {"value" : "x" }),
860+ ToolCall (id = "t2" , name = "b" , arguments = {"value" : "x" }),
861+ ]
862+ )
863+
864+ async def test_after_tool_call_hitl_propagates_with_siblings_persisted (self ):
865+ """_finalize must re-raise HITL control exceptions from the hook —
866+ they are a suspend, not a tool failure — after siblings persist."""
867+ from cubepi .hitl .exceptions import HitlDetached
868+
869+ async def after (after_ctx , * , signal = None ):
870+ if after_ctx .tool_call .id == "t2" :
871+ raise HitlDetached ()
872+ return None
873+
874+ ctx = make_context ([make_echo_tool (name = "a" ), make_echo_tool (name = "b" )])
875+ events = []
876+ with pytest .raises (HitlDetached ):
877+ await execute_tool_calls (
878+ ctx ,
879+ self ._two_call_msg (),
880+ tool_execution = "parallel" ,
881+ after_tool_call = after ,
882+ emit = lambda e : events .append (e ),
883+ )
884+ result_ids = [e .message .tool_call_id for e in events if e .type == "message_end" ]
885+ assert result_ids == ["t1" ]
886+
887+ async def test_hitl_reraise_survives_emit_failure (self ):
888+ """Persisting sibling results is best-effort: an emit failure must
889+ not swallow the control exception the suspend machinery needs."""
890+ from cubepi .hitl .exceptions import HitlDetached
891+
892+ async def hitl_raiser (tool_call_id , params , * , signal = None , on_update = None ):
893+ raise HitlDetached ()
894+
895+ def flaky_emit (event ):
896+ if event .type == "message_start" :
897+ raise RuntimeError ("listener blew up" )
898+
899+ ctx = make_context (
900+ [
901+ make_echo_tool (name = "a" ),
902+ make_echo_tool (name = "b" , execute_fn = hitl_raiser ),
903+ ]
904+ )
905+ with pytest .raises (HitlDetached ):
906+ await execute_tool_calls (
907+ ctx ,
908+ self ._two_call_msg (),
909+ tool_execution = "parallel" ,
910+ emit = flaky_emit ,
911+ )
912+
913+ async def test_cancel_reraise_survives_emit_failure (self ):
914+ """Same best-effort contract on the outer-cancel salvage path."""
915+ started = asyncio .Event ()
916+
917+ async def fast_ok (tool_call_id , params , * , signal = None , on_update = None ):
918+ return AgentToolResult (content = [TextContent (text = "ok" )])
919+
920+ async def hang (tool_call_id , params , * , signal = None , on_update = None ):
921+ started .set ()
922+ await asyncio .Event ().wait ()
923+
924+ def flaky_emit (event ):
925+ if event .type == "message_start" :
926+ raise RuntimeError ("listener blew up" )
927+
928+ ctx = make_context (
929+ [
930+ make_echo_tool (name = "a" , execute_fn = fast_ok ),
931+ make_echo_tool (name = "b" , execute_fn = hang ),
932+ ]
933+ )
934+ runner = asyncio .create_task (
935+ execute_tool_calls (
936+ ctx ,
937+ self ._two_call_msg (),
938+ tool_execution = "parallel" ,
939+ emit = flaky_emit ,
940+ )
941+ )
942+ await started .wait ()
943+ await asyncio .sleep (0.02 )
944+ runner .cancel ()
945+ with pytest .raises (asyncio .CancelledError ):
946+ await runner
947+
948+ async def test_cancel_reraise_survives_emit_cancelled_error (self ):
949+ """A second cancel landing during salvage emission re-raises cleanly."""
950+ started = asyncio .Event ()
951+
952+ async def fast_ok (tool_call_id , params , * , signal = None , on_update = None ):
953+ return AgentToolResult (content = [TextContent (text = "ok" )])
954+
955+ async def hang (tool_call_id , params , * , signal = None , on_update = None ):
956+ started .set ()
957+ await asyncio .Event ().wait ()
958+
959+ def cancelling_emit (event ):
960+ if event .type == "message_start" :
961+ raise asyncio .CancelledError ()
962+
963+ ctx = make_context (
964+ [
965+ make_echo_tool (name = "a" , execute_fn = fast_ok ),
966+ make_echo_tool (name = "b" , execute_fn = hang ),
967+ ]
968+ )
969+ runner = asyncio .create_task (
970+ execute_tool_calls (
971+ ctx ,
972+ self ._two_call_msg (),
973+ tool_execution = "parallel" ,
974+ emit = cancelling_emit ,
975+ )
976+ )
977+ await started .wait ()
978+ await asyncio .sleep (0.02 )
979+ runner .cancel ()
980+ with pytest .raises (asyncio .CancelledError ):
981+ await runner
982+
983+
984+ class TestBeforeToolCallEditedArgs :
985+ async def test_edited_args_revalidated_and_used (self ):
986+ async def before (before_ctx , * , signal = None ):
987+ return BeforeToolCallResult (edited_args = {"value" : "rewritten" })
988+
989+ ctx = make_context ([make_echo_tool ()])
990+ msg = make_assistant_msg (
991+ [ToolCall (id = "t1" , name = "echo" , arguments = {"value" : "original" })]
992+ )
993+ batch = await execute_tool_calls (
994+ ctx ,
995+ msg ,
996+ tool_execution = "sequential" ,
997+ before_tool_call = before ,
998+ emit = lambda e : None ,
999+ )
1000+ assert len (batch .messages ) == 1
1001+ assert not batch .messages [0 ].is_error
1002+ assert "rewritten" in batch .messages [0 ].content [0 ].text
0 commit comments