Skip to content

Commit d859f8a

Browse files
committed
test(tools): cover fault-isolation defensive branches + edited_args revalidation
1 parent b406012 commit d859f8a

1 file changed

Lines changed: 153 additions & 0 deletions

File tree

tests/agent/test_tools.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)