From 57bd534fd98ad7f29e0ca8cbcec4c91da9ab7abf Mon Sep 17 00:00:00 2001 From: Francesco De Felice Date: Thu, 6 Aug 2026 09:18:24 +0200 Subject: [PATCH] fix: handle falsy resume payload --- .../python/src/ag_ui_strands/agent.py | 24 +++++++--- .../python/tests/test_interrupt.py | 44 ++++++++++++++++--- 2 files changed, 57 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 e7b192fecc..eeeb695888 100644 --- a/integrations/aws-strands/python/src/ag_ui_strands/agent.py +++ b/integrations/aws-strands/python/src/ag_ui_strands/agent.py @@ -72,6 +72,20 @@ def _extract_agent_kwargs(agent: StrandsAgentCore) -> dict: INTERRUPT_CANCELLED = {"cancelled": True} +def _wrap_resume_response(status: str, payload: Any) -> dict: + """Package a ``ResumeEntry`` for Strands' ``interruptResponse`` shape. + + Strands' resume gate is truthiness-based (i.e. ``if interrupt_.response:``), + so a raw falsy payload (``None``, ``False``, ``""``, ``0``, ``[]``, ``{}``) + re-raises the same interrupt and re-runs the tool body — an infinite approve loop. + Always hand Strands a truthy envelope; up to the tool implementation to properly + destructures it (e.g. via ``.get("cancelled")`` / ``.get("response")``). + """ + if status == "cancelled": + return INTERRUPT_CANCELLED + return {"response": payload} + + def _get_strands_session_manager(agent: Any) -> Any: """Return the agent's Strands ``SessionManager``, or ``None``. @@ -1002,8 +1016,8 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: and has_nonvoid_frontend_result ) - # A client answering a native Strands interrupt sends its responses - # in ``RunAgentInput.resume`` (per the AG-UI interrupt round-trip), + # A client answering to an interrupt sends its responses + # in ``RunAgentInput.resume`` (as per the AG-UI interrupt round-trip), # not as a new user message. Translate those into the Strands resume # prompt shape ``[{"interruptResponse": {"interruptId", "response"}}]`` # and drive the stream with it — this takes precedence over every @@ -1015,10 +1029,8 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]: { "interruptResponse": { "interruptId": entry.interrupt_id, - "response": ( - INTERRUPT_CANCELLED - if entry.status == "cancelled" - else entry.payload + "response": _wrap_resume_response( + entry.status, entry.payload ), } } diff --git a/integrations/aws-strands/python/tests/test_interrupt.py b/integrations/aws-strands/python/tests/test_interrupt.py index d7cb9a179e..455d844603 100644 --- a/integrations/aws-strands/python/tests/test_interrupt.py +++ b/integrations/aws-strands/python/tests/test_interrupt.py @@ -158,7 +158,11 @@ async def test_no_interrupt_finishes_bare(self): class TestResumeConsumption: @pytest.mark.asyncio async def test_resolved_resume_builds_interrupt_response_prompt(self): - """A resolved ResumeEntry is translated into the Strands resume prompt.""" + """A resolved ResumeEntry is translated into the Strands resume prompt. + + The raw payload is wrapped in ``{"response": ...}`` so Strands' truthiness + gate always passes; the tool destructures via ``.get("response")``. + """ core = _MockStrandsCore(terminal_events=[]) agent = _make_base_agent() resume = [ResumeEntry(interrupt_id="int-1", status="resolved", payload="yes")] @@ -167,8 +171,33 @@ async def test_resolved_resume_builds_interrupt_response_prompt(self): await _collect_events(agent, _make_run_input(resume=resume)) assert core.stream_prompts == [ - [{"interruptResponse": {"interruptId": "int-1", "response": "yes"}}] + [{"interruptResponse": {"interruptId": "int-1", "response": {"response": "yes"}}}] + ] + + @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 + ): + """Falsy resume payloads must be wrapped so Strands' ``if response:`` gate passes. + + Regression for ``round1.md`` #1: without the envelope, ``None``/``False``/ + ``""``/``0``/``[]``/``{}`` re-emit the same interrupt id on the resume + run, re-running the tool body forever. + """ + core = _MockStrandsCore(terminal_events=[]) + agent = _make_base_agent() + resume = [ResumeEntry(interrupt_id="int-1", status="resolved", payload=falsy_payload)] + + with patch("ag_ui_strands.agent.StrandsAgentCore", return_value=core): + await _collect_events(agent, _make_run_input(resume=resume)) + + [wrapped] = core.stream_prompts + assert wrapped == [ + {"interruptResponse": {"interruptId": "int-1", "response": {"response": falsy_payload}}} ] + # The envelope itself must be truthy — that is the whole point. + assert bool(wrapped[0]["interruptResponse"]["response"]) @pytest.mark.asyncio async def test_cancelled_resume_uses_sentinel(self): @@ -199,7 +228,7 @@ async def test_multiple_resume_entries(self): assert core.stream_prompts == [ [ - {"interruptResponse": {"interruptId": "a", "response": {"k": 1}}}, + {"interruptResponse": {"interruptId": "a", "response": {"response": {"k": 1}}}}, {"interruptResponse": {"interruptId": "b", "response": INTERRUPT_CANCELLED}}, ] ] @@ -218,8 +247,13 @@ async def test_multiple_resume_entries(self): @tool(context=True) def confirm_action(key: str, tool_context: ToolContext) -> dict: - approval = tool_context.interrupt("confirm_action", reason={"key": key}) - if approval: + # Resume envelope: {"cancelled": True} on cancel, {"response": } on + # resolve. Destructure — do NOT truthiness-check the envelope, since it is + # always truthy on resolve (that's the whole point of the wrap). + envelope = tool_context.interrupt("confirm_action", reason={"key": key}) + if envelope.get("cancelled"): + return {"status": "success", "content": [{"text": f"denied {key}"}]} + if envelope.get("response"): return {"status": "success", "content": [{"text": f"confirmed {key}"}]} return {"status": "success", "content": [{"text": f"denied {key}"}]}