From 9b2c3b17b74a19249c38d26593a272533cbfc8db Mon Sep 17 00:00:00 2001 From: Marco Boschi Date: Mon, 10 Aug 2026 15:35:02 +0200 Subject: [PATCH 1/3] fix: avoid returning mutable contant by reference --- integrations/aws-strands/python/src/ag_ui_strands/agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrations/aws-strands/python/src/ag_ui_strands/agent.py b/integrations/aws-strands/python/src/ag_ui_strands/agent.py index 57e58a55f9..14cf288040 100644 --- a/integrations/aws-strands/python/src/ag_ui_strands/agent.py +++ b/integrations/aws-strands/python/src/ag_ui_strands/agent.py @@ -82,7 +82,7 @@ def _wrap_resume_response(status: str, payload: Any) -> dict: destructures it (e.g. via ``.get("cancelled")`` / ``.get("response")``). """ if status == "cancelled": - return INTERRUPT_CANCELLED + return dict(INTERRUPT_CANCELLED) return {"response": payload} From 40195f1642841cb2666f15b1cfcdfed4ceeb0a2a Mon Sep 17 00:00:00 2001 From: Marco Boschi Date: Mon, 10 Aug 2026 16:40:19 +0200 Subject: [PATCH 2/3] fix: try to always capture terminal result and exclude completed interrupts --- .../python/src/ag_ui_strands/agent.py | 23 ++++-- .../python/tests/test_interrupt.py | 78 ++++++++++++++++++- 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/integrations/aws-strands/python/src/ag_ui_strands/agent.py b/integrations/aws-strands/python/src/ag_ui_strands/agent.py index 14cf288040..6d6a7fd121 100644 --- a/integrations/aws-strands/python/src/ag_ui_strands/agent.py +++ b/integrations/aws-strands/python/src/ag_ui_strands/agent.py @@ -150,7 +150,14 @@ def _extract_interrupts(agent: Any, terminal_result: Any) -> list: return list(interrupts) interrupt_state = getattr(agent, "_interrupt_state", None) if interrupt_state is not None and getattr(interrupt_state, "activated", False): - return list(getattr(interrupt_state, "interrupts", {}).values()) + # Mirrors Strands' own gate (strands/types/interrupt.py: ``if interrupt_.response:``) + # — an interrupt with a truthy response was already answered by a prior partial + # resume and must not be re-reported as still pending. + return [ + interrupt + for interrupt in getattr(interrupt_state, "interrupts", {}).values() + if not getattr(interrupt, "response", None) + ] return [] @@ -1243,6 +1250,13 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: try: async for event in agent_stream: + # Capture the terminal ``AgentResult`` (always emitted last + # by ``stream_async``) so a native interrupt pause can be + # detected after the loop. Recorded first so it is never + # dropped, even on the halt-event-stream break below. + if "result" in event and event["result"] is not None: + terminal_result = event["result"] + # Frontend-tool halt: STOP the loop rather than muting the # wire and draining it. The proxy tool returns a SUCCESSFUL # "Forwarded to client" placeholder, so Strands has every @@ -1266,13 +1280,6 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: logger.debug(f"Received event: {event}") - # Capture the terminal ``AgentResult`` (always emitted last - # by ``stream_async``) so a native interrupt pause can be - # detected after the loop. Recorded before the - # ``complete``/``force_stop`` break so it is never dropped. - if "result" in event and event["result"] is not None: - terminal_result = event["result"] - # Skip lifecycle events if event.get("init_event_loop") or event.get("start_event_loop"): continue diff --git a/integrations/aws-strands/python/tests/test_interrupt.py b/integrations/aws-strands/python/tests/test_interrupt.py index 2186af54e6..80fc8bd062 100644 --- a/integrations/aws-strands/python/tests/test_interrupt.py +++ b/integrations/aws-strands/python/tests/test_interrupt.py @@ -145,6 +145,80 @@ async def test_pause_emits_interrupt_outcome(self): "strands_reason": {"summary": "delete all"}, } + @pytest.mark.asyncio + async def test_terminal_result_captured_despite_halt_in_same_cycle(self): + """The terminal ``AgentResult`` capture must run before the + ``halt_event_stream`` break check — otherwise a native interrupt whose + terminal event arrives on/after the same cycle that triggers a + frontend-tool halt is silently dropped (the run finishes bare instead + of surfacing the interrupt). + """ + open_interrupt = StrandsInterrupt( + id="v1:tool_call:tu-native:00000000-0000-0000-0000-000000000000", + name="confirm", + ) + events = [ + { + "current_tool_use": { + "toolUseId": "tu-fe", + "name": "get_cell", + "input": '{"cell": "B4"}', + } + }, + {"event": {"contentBlockStop": {}}}, + # Empty content models the interrupted turn skipping + # ToolResultMessageEvent; pending_halt still + # latches halt_event_stream here regardless. + {"message": {"role": "user", "content": []}}, + {"result": _agent_result_with_interrupt([open_interrupt])}, + ] + core = _MockStrandsCore(terminal_events=events) + agent = _make_base_agent() + frontend_tool = Tool(name="get_cell", description="Read a cell", parameters={}) + + with patch("ag_ui_strands.agent.StrandsAgentCore", return_value=core): + events_out = await _collect_events(agent, _make_run_input(tools=[frontend_tool])) + + finished = next(e for e in events_out if e.type == EventType.RUN_FINISHED) + assert ( + finished.outcome is not None + ), "terminal interrupt result was dropped on the halt path (round1.md #7a)" + assert finished.outcome.type == "interrupt" + assert finished.outcome.interrupts[0].id == open_interrupt.id + + @pytest.mark.asyncio + async def test_fallback_excludes_already_answered_interrupts(self): + """When the terminal ``AgentResult`` is unavailable and ``_extract_interrupts`` + falls back to the live ``_interrupt_state``, an interrupt that was already + answered by a prior partial resume (truthy ``.response``) must not be + re-reported as still pending alongside the genuinely open one. + """ + answered = StrandsInterrupt( + id="v1:tool_call:tu-answered:00000000-0000-0000-0000-000000000000", + name="answered", + response={"response": "yes"}, + ) + open_interrupt = StrandsInterrupt( + id="v1:tool_call:tu-open:00000000-0000-0000-0000-000000000000", + name="open", + ) + # No terminal ``{"result": ...}`` event — mirrors the halt-event-stream + # path where the stream breaks before a terminal AgentResult is captured. + core = _MockStrandsCore( + terminal_events=[], + interrupts=[answered, open_interrupt], + ) + agent = _make_base_agent() + + with patch("ag_ui_strands.agent.StrandsAgentCore", return_value=core): + events = await _collect_events(agent, _make_run_input()) + + finished = next(e for e in events if e.type == EventType.RUN_FINISHED) + assert finished.outcome is not None + assert finished.outcome.type == "interrupt" + reported_ids = {i.id for i in finished.outcome.interrupts} + assert reported_ids == {open_interrupt.id} + @pytest.mark.asyncio async def test_no_interrupt_finishes_bare(self): """A normal run finishes with no outcome (back-compat, no behavior change).""" @@ -182,9 +256,7 @@ async def test_resolved_resume_builds_interrupt_response_prompt(self): @pytest.mark.parametrize("falsy_payload", [None, False, "", 0, [], {}]) @pytest.mark.asyncio - async def test_resolved_resume_wraps_falsy_payload_in_truthy_envelope( - self, falsy_payload - ): + async def test_resolved_resume_wraps_falsy_payload_in_truthy_envelope(self, falsy_payload): """Falsy resume payloads must be wrapped so Strands' ``if response:`` gate passes. Regression for ``round1.md`` #1: without the envelope, ``None``/``False``/ From d5d1ce68cd57773d444e54b6a81784fa91c0bc44 Mon Sep 17 00:00:00 2001 From: Marco Boschi Date: Tue, 11 Aug 2026 08:31:13 +0200 Subject: [PATCH 3/3] chore: drop TS references --- integrations/aws-strands/python/tests/test_interrupt.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/integrations/aws-strands/python/tests/test_interrupt.py b/integrations/aws-strands/python/tests/test_interrupt.py index b21b2d8a77..28ea6abc32 100644 --- a/integrations/aws-strands/python/tests/test_interrupt.py +++ b/integrations/aws-strands/python/tests/test_interrupt.py @@ -424,9 +424,7 @@ async def test_mixed_resume_batch_with_falsy_payload_and_tool_behaviors( ``False`` keeps coverage that a latched-but-unfired halt does not corrupt the interrupt path (moving the latch earlier would break this param and - not the other); ``True`` models immediate hand-off and is the only config - the TypeScript adapter — which latches per-tool on ``afterToolCallEvent`` - — emits the interrupt outcome for at all. + not the other); ``True`` models immediate hand-off. """ tool_behaviors = { "confirm_action": ToolBehavior(