# Copyright (c) 2026, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors # # SPDX-License-Identifier: Apache-2.0 import asyncio import logging from typing import Any import pytest from acp import schema from mcp import ClientSession from mcp.client.streamable_http import streamable_http_client from ag2 import Agent, Context from ag2.acp import MCPCapabilityError from ag2.acp.testing import ACPTurn, fake_acp_config from ag2.events import BaseEvent, ModelReasoning, ModelResponse, ToolCallEvent, ToolResultEvent from ag2.events.tool_events import BuiltinToolCallEvent, BuiltinToolResultEvent from ag2.exceptions import HumanInputNotProvidedError, UnsupportedToolError from ag2.history import HUMAN_INPUT_ABANDONED_TOOL_RESULT from ag2.stream import MemoryStream from ag2.tools.builtin.mcp_server import MCPServerTool from ag2.tools.builtin.web_search import WebSearchTool from ag2.tools.final.function_tool import FunctionTool def _text(text: str) -> schema.TextContentBlock: return schema.TextContentBlock(type="text", text=text) def _text_update(text: str) -> schema.AgentMessageChunk: return schema.AgentMessageChunk(session_update="agent_message_chunk", content=_text(text)) def _model_option(current: str, *values: str) -> schema.SessionConfigOptionSelect: return schema.SessionConfigOptionSelect( id="model", name="Model", category="model", type="select", current_value=current, options=[schema.SessionConfigSelectOption(value=v, name=v) for v in values], ) def _grouped_model_option(current: str, groups: dict[str, list[str]]) -> schema.SessionConfigOptionSelect: """A model picker whose entries are groups rather than flat options. ACP allows either shape. No CLI agent tested so far emits the grouped one (Claude Code advertises 5 flat options, Kilo 320), so it needs covering here or it goes unexercised entirely. """ return schema.SessionConfigOptionSelect( id="model", name="Model", category="model", type="select", current_value=current, options=[ schema.SessionConfigSelectGroup( group=name, name=name, options=[schema.SessionConfigSelectOption(value=v, name=v) for v in values], ) for name, values in groups.items() ], ) def _image_update(data: str = "aGVsbG8=") -> schema.AgentMessageChunk: return schema.AgentMessageChunk( session_update="agent_message_chunk", content=schema.ImageContentBlock(type="image", data=data, mime_type="image/png"), ) def _hi_turn() -> ACPTurn: return ACPTurn(updates=[_text_update("hi")]) @pytest.mark.asyncio async def test_ask_streams_thoughts_tools_and_returns_text() -> None: cfg = fake_acp_config( ACPTurn( updates=[ schema.AgentThoughtChunk(session_update="agent_thought_chunk", content=_text("planning")), schema.AgentMessageChunk(session_update="agent_message_chunk", content=_text("done")), schema.ToolCallStart(session_update="tool_call", tool_call_id="t1", title="Echo", status="pending"), schema.ToolCallProgress( session_update="tool_call_update", tool_call_id="t1", status="completed", content=[schema.ContentToolCallContent(type="content", content=_text("ok"))], ), ], usage=schema.Usage(input_tokens=3, output_tokens=1, total_tokens=4), ), permission_policy="auto", ) agent = Agent("acp", config=cfg) seen: list[BaseEvent] = [] try: async with agent.run("hello") as run: run.stream.subscribe(lambda e: seen.append(e)) result = await run.result() finally: await cfg.aclose() assert result.body == "done" assert any(isinstance(e, ModelReasoning) and e.content == "planning" for e in seen) assert any(isinstance(e, BuiltinToolCallEvent) and e.name == "Echo" for e in seen) assert any(isinstance(e, BuiltinToolResultEvent) for e in seen) @pytest.mark.asyncio async def test_turn_timeout_surfaces_timeout() -> None: cfg = fake_acp_config(ACPTurn(hang=True), permission_policy="auto", turn_timeout=0.5) agent = Agent("acp", config=cfg) seen: list[BaseEvent] = [] try: async with agent.run("hang") as run: run.stream.subscribe(lambda e: seen.append(e)) result = await run.result() finally: await cfg.aclose() # The turn timed out; body is whatever streamed before the timeout (empty here). assert result.body == "" [response] = [e for e in seen if isinstance(e, ModelResponse)] assert response.finish_reason == "timeout" @pytest.mark.asyncio async def test_cancelling_the_caller_stops_the_turn_it_started() -> None: """A cancelled ``ask()`` takes the prompt with it, on the default no-deadline path. The turn runs on a task of its own so a dead human-input channel can stop it, and ``asyncio.wait`` does not cancel what it waits on. Left alone the prompt would outlive the call that started it — the agent still working, the session never torn down — which is the very thing the channel-failure path exists to prevent. """ cfg = fake_acp_config(ACPTurn(hang=True), permission_policy="auto") # turn_timeout defaults to None agent = Agent("acp", config=cfg) turn = asyncio.ensure_future(agent.ask("hang")) await asyncio.sleep(0.2) # let the prompt reach the agent and hang there turn.cancel() with pytest.raises(asyncio.CancelledError): await turn # The scripted turn returns only once it is stopped, and stopping it is what # tears the session down — so a session still open is a prompt still running. assert not any(session.started for session in cfg.sessions.values()) await cfg.aclose() @pytest.mark.asyncio class TestModelSelection: async def test_selected_via_config_option(self) -> None: calls: list[tuple[str, str | bool]] = [] cfg = fake_acp_config( _hi_turn(), config_options=[_model_option("provider/default", "provider/default", "provider/smart")], config_option_calls=calls, permission_policy="auto", model="provider/smart", ) agent = Agent("acp", config=cfg) try: reply = await agent.ask("hello") finally: await cfg.aclose() assert reply.body == "hi" assert calls == [("model", "provider/smart")] async def test_matching_current_is_not_resent(self) -> None: calls: list[tuple[str, str | bool]] = [] cfg = fake_acp_config( _hi_turn(), config_options=[_model_option("provider/default", "provider/default", "provider/smart")], config_option_calls=calls, permission_policy="auto", model="provider/default", ) agent = Agent("acp", config=cfg) try: await agent.ask("hello") finally: await cfg.aclose() assert calls == [] async def test_ignored_when_agent_has_no_model_option(self) -> None: # No configOptions advertised — `model` stays response metadata, as before. calls: list[tuple[str, str | bool]] = [] cfg = fake_acp_config(_hi_turn(), config_option_calls=calls, permission_policy="auto", model="provider/smart") agent = Agent("acp", config=cfg) try: reply = await agent.ask("hello") finally: await cfg.aclose() assert reply.body == "hi" assert calls == [] assert reply.response.model == "provider/smart" async def test_reports_agent_default_when_model_unset(self) -> None: """With no `model` set, report what the agent says it runs, not None. This is the case that matters most: an agent sitting on a default it cannot answer with (Kilo ships on an image model) produces an empty reply, and `model=None` would strip the one clue worth having. """ cfg = fake_acp_config( _hi_turn(), config_options=[_model_option("provider/image-only", "provider/image-only", "provider/text")], permission_policy="auto", ) agent = Agent("acp", config=cfg) try: reply = await agent.ask("hello") finally: await cfg.aclose() assert reply.response.model == "provider/image-only" async def test_not_offered_raises(self) -> None: cfg = fake_acp_config( _hi_turn(), config_options=[_model_option("provider/default", "provider/default")], permission_policy="auto", model="provider/nonexistent", ) agent = Agent("acp", config=cfg) try: with pytest.raises(ValueError, match="not offered by the ACP agent"): await agent.ask("hello") finally: await cfg.aclose() async def test_not_offered_suggests_close_matches(self) -> None: """Agents can offer hundreds of ids, so the error points at near misses.""" cfg = fake_acp_config( _hi_turn(), config_options=[_model_option("a/default", "a/default", "a/claude-haiku-4.5", "a/claude-sonnet-5")], permission_policy="auto", model="a/claude-haiku-4-5", # dashes instead of the dot ) agent = Agent("acp", config=cfg) try: with pytest.raises(ValueError, match=r"\(3 offered\).*a/claude-haiku-4\.5"): await agent.ask("hello") finally: await cfg.aclose() async def test_selected_from_grouped_options(self) -> None: """ACP allows grouped entries; no live agent emits them, so cover it here.""" calls: list[tuple[str, str | bool]] = [] cfg = fake_acp_config( _hi_turn(), config_options=[ _grouped_model_option( "anthropic/default", {"Anthropic": ["anthropic/default", "anthropic/haiku"], "Google": ["google/flash"]}, ) ], config_option_calls=calls, permission_policy="auto", model="google/flash", ) agent = Agent("acp", config=cfg) try: reply = await agent.ask("hello") finally: await cfg.aclose() assert reply.body == "hi" assert calls == [("model", "google/flash")] async def test_not_offered_in_any_group_raises(self) -> None: cfg = fake_acp_config( _hi_turn(), config_options=[_grouped_model_option("anthropic/default", {"Anthropic": ["anthropic/default"]})], permission_policy="auto", model="google/flash", ) agent = Agent("acp", config=cfg) try: with pytest.raises(ValueError, match="not offered by the ACP agent"): await agent.ask("hello") finally: await cfg.aclose() @pytest.mark.asyncio class TestEmptyTurnWarning: """A turn that ends normally yet produced nothing is worth a word. Some CLI agents swallow provider-side failures (an unauthorized or text-incapable model) and end the turn with ``end_turn`` and no output at all — indistinguishable, from AG2's side, from a healthy silent turn. Warn so the empty reply is not the only clue. """ async def test_empty_end_turn_warns(self, caplog: pytest.LogCaptureFixture) -> None: cfg = fake_acp_config(ACPTurn(updates=[]), permission_policy="auto", model="provider/model") agent = Agent("acp", config=cfg) try: with caplog.at_level(logging.WARNING, logger="ag2.acp.client"): reply = await agent.ask("hello") finally: await cfg.aclose() assert reply.body == "" [record] = [r for r in caplog.records if r.levelno == logging.WARNING] assert "provider/model" in record.getMessage() async def test_tool_only_turn_does_not_warn(self, caplog: pytest.LogCaptureFixture) -> None: """The agent worked — it just had nothing to say. Not a silent failure.""" cfg = fake_acp_config( ACPTurn( updates=[ schema.ToolCallStart(session_update="tool_call", tool_call_id="t1", title="Write", status="pending") ] ), permission_policy="auto", ) agent = Agent("acp", config=cfg) try: with caplog.at_level(logging.WARNING, logger="ag2.acp.client"): reply = await agent.ask("hello") finally: await cfg.aclose() assert reply.body == "" assert [r for r in caplog.records if r.levelno == logging.WARNING] == [] async def test_file_only_turn_does_not_warn(self, caplog: pytest.LogCaptureFixture) -> None: """An image/audio reply is output too, even though it carries no text.""" cfg = fake_acp_config(ACPTurn(updates=[_image_update()]), permission_policy="auto") agent = Agent("acp", config=cfg) try: with caplog.at_level(logging.WARNING, logger="ag2.acp.client"): reply = await agent.ask("draw something") finally: await cfg.aclose() assert reply.body == "" assert len(reply.response.files) == 1 assert [r for r in caplog.records if r.levelno == logging.WARNING] == [] async def test_text_turn_does_not_warn(self, caplog: pytest.LogCaptureFixture) -> None: cfg = fake_acp_config(_hi_turn(), permission_policy="auto") agent = Agent("acp", config=cfg) try: with caplog.at_level(logging.WARNING, logger="ag2.acp.client"): reply = await agent.ask("hello") finally: await cfg.aclose() assert reply.body == "hi" assert [r for r in caplog.records if r.levelno == logging.WARNING] == [] async def test_timed_out_turn_does_not_warn(self, caplog: pytest.LogCaptureFixture) -> None: """A timeout already reports itself through ``finish_reason``.""" cfg = fake_acp_config(ACPTurn(hang=True), permission_policy="auto", turn_timeout=0.5) agent = Agent("acp", config=cfg) try: with caplog.at_level(logging.WARNING, logger="ag2.acp.client"): reply = await agent.ask("hang") finally: await cfg.aclose() assert reply.body == "" assert [r for r in caplog.records if r.levelno == logging.WARNING] == [] @pytest.mark.asyncio async def test_aclose_closes_session() -> None: cfg = fake_acp_config( ACPTurn(updates=[schema.AgentMessageChunk(session_update="agent_message_chunk", content=_text("hi"))]), permission_policy="auto", ) agent = Agent("acp", config=cfg) async with agent.run("hello") as run: await run.result() assert cfg.sessions # a live session was created conns = [s.conn for s in cfg.sessions.values()] await cfg.aclose() assert cfg.sessions == {} for conn in conns: assert conn is not None and conn.closed # the connection context was exited @pytest.mark.asyncio async def test_config_as_async_context_manager_closes_sessions() -> None: def add(a: int, b: int) -> int: """Add two integers.""" return a + b cfg = fake_acp_config(ACPTurn(updates=[_text_update("hi")]), permission_policy="auto") async with cfg as entered: assert entered is cfg agent = Agent("acp", config=cfg, tools=[add]) async with agent.run("hello") as run: await run.result() assert cfg.sessions # live while the config scope is open gateways = [s.gateway for s in cfg.sessions.values() if s.gateway is not None] assert gateways and all(g.url is not None for g in gateways) assert cfg.sessions == {} # exiting the scope tore everything down assert all(g.url is None for g in gateways) # and stopped the tool gateways @pytest.mark.asyncio async def test_external_server_named_like_the_gateway_is_rejected() -> None: """Two identically-named mcp_servers entries would silently shadow each other.""" def add(a: int, b: int) -> int: """Add two integers.""" return a + b cfg = fake_acp_config(ACPTurn(updates=[_text_update("hi")]), permission_policy="auto") agent = Agent( "acp", config=cfg, tools=[add, MCPServerTool(server_url="https://example.com/mcp", server_label="ag2")], ) try: with pytest.raises(ValueError, match="collides"): async with agent.run("hello") as run: await run.result() assert cfg.sessions == {} # nothing leaked on the failure path finally: await cfg.aclose() @pytest.mark.asyncio async def test_external_server_named_like_the_gateway_is_fine_without_function_tools() -> None: """No function tools means no gateway, so there is nothing to collide with.""" cfg = fake_acp_config(ACPTurn(updates=[_text_update("hi")]), permission_policy="auto") agent = Agent( "acp", config=cfg, tools=[MCPServerTool(server_url="https://example.com/mcp", server_label="ag2")], ) try: async with agent.run("hello") as run: result = await run.result() assert result.body == "hi" session = next(iter(cfg.sessions.values())) assert session.gateway is None assert [s.name for s in session.conn.new_session_kwargs["mcp_servers"]] == ["ag2"] finally: await cfg.aclose() @pytest.mark.asyncio async def test_config_context_manager_closes_sessions_on_error() -> None: def add(a: int, b: int) -> int: """Add two integers.""" return a + b cfg = fake_acp_config(ACPTurn(updates=[_text_update("hi")]), permission_policy="auto") with pytest.raises(RuntimeError, match="boom"): async with cfg: agent = Agent("acp", config=cfg, tools=[add]) async with agent.run("hello") as run: await run.result() assert cfg.sessions raise RuntimeError("boom") assert cfg.sessions == {} # torn down even though the block raised @pytest.mark.asyncio async def test_function_tools_are_exposed_and_callable_over_mcp() -> None: observed: dict[str, Any] = {} def add(a: int, b: int) -> int: """Add two integers.""" observed["args"] = (a, b) return a + b cfg: Any = None # assigned below; on_prompt closure needs it async def drive_mcp() -> None: # Runs inside the fake agent's prompt turn — exactly when a real CLI # agent would call the gateway. session = next(iter(cfg.sessions.values())) assert session.gateway is not None and session.gateway.url is not None observed["mcp_servers"] = session.conn.new_session_kwargs["mcp_servers"] async with ( streamable_http_client(session.gateway.url) as (read, write), ClientSession(read, write) as mcp_session, ): await mcp_session.initialize() listed = await mcp_session.list_tools() observed["tool_names"] = [t.name for t in listed.tools] result = await mcp_session.call_tool("add", {"a": 2, "b": 3}) observed["call_text"] = result.content[0].text observed["call_is_error"] = result.is_error cfg = fake_acp_config( ACPTurn(updates=[_text_update("done")], on_prompt=drive_mcp), permission_policy="auto", ) agent = Agent("acp", config=cfg, tools=[add]) try: async with agent.run("please add 2 and 3 using the add tool") as run: result = await run.result() finally: await cfg.aclose() assert result.body == "done" assert observed["tool_names"] == ["add"] assert observed["args"] == (2, 3) assert observed["call_is_error"] is not True assert observed["call_text"] == "5" # the gateway itself was advertised to the agent via session/new (gateway_server,) = [s for s in observed["mcp_servers"] if s.name == "ag2"] assert gateway_server.url.startswith("http://127.0.0.1:") @pytest.mark.asyncio async def test_expose_tools_false_disables_gateway() -> None: def add(a: int, b: int) -> int: """Add two integers.""" return a + b cfg = fake_acp_config( ACPTurn(updates=[_text_update("done")]), permission_policy="auto", expose_tools=False, ) agent = Agent("acp", config=cfg, tools=[add]) try: async with agent.run("hello") as run: await run.result() session = next(iter(cfg.sessions.values())) assert session.gateway is None assert session.conn.new_session_kwargs.get("mcp_servers") is None finally: await cfg.aclose() @pytest.mark.asyncio async def test_provider_builtin_tool_is_hard_error() -> None: cfg = fake_acp_config(ACPTurn(updates=[_text_update("done")]), permission_policy="auto") agent = Agent("acp", config=cfg, tools=[WebSearchTool()]) try: with pytest.raises(UnsupportedToolError): async with agent.run("hello") as run: await run.result() assert cfg.sessions == {} # nothing leaked on the failure path finally: await cfg.aclose() @pytest.mark.asyncio async def test_concurrent_tool_calls_are_correlated() -> None: async def add(a: int, b: int) -> int: """Add two integers.""" await asyncio.sleep(0.05) # keep both calls in flight simultaneously return a + b observed: dict[str, Any] = {} cfg: Any = None async def drive_mcp() -> None: session = next(iter(cfg.sessions.values())) async with ( streamable_http_client(session.gateway.url) as (read, write), ClientSession(read, write) as mcp_session, ): await mcp_session.initialize() first, second = await asyncio.gather( mcp_session.call_tool("add", {"a": 1, "b": 2}), mcp_session.call_tool("add", {"a": 3, "b": 4}), ) observed["results"] = (first.content[0].text, second.content[0].text) cfg = fake_acp_config(ACPTurn(updates=[_text_update("done")], on_prompt=drive_mcp), permission_policy="auto") agent = Agent("acp", config=cfg, tools=[add]) try: async with agent.run("add things") as run: await run.result() finally: await cfg.aclose() assert observed["results"] == ("3", "7") # each call got its own result, not the other's @pytest.mark.asyncio async def test_unknown_tool_name_returns_error_not_hang() -> None: def add(a: int, b: int) -> int: """Add two integers.""" return a + b observed: dict[str, Any] = {} cfg: Any = None async def drive_mcp() -> None: session = next(iter(cfg.sessions.values())) async with ( streamable_http_client(session.gateway.url) as (read, write), ClientSession(read, write) as mcp_session, ): await mcp_session.initialize() result = await asyncio.wait_for(mcp_session.call_tool("nope", {}), timeout=5) observed["is_error"] = result.is_error observed["text"] = result.content[0].text cfg = fake_acp_config(ACPTurn(updates=[_text_update("done")], on_prompt=drive_mcp), permission_policy="auto") agent = Agent("acp", config=cfg, tools=[add]) try: async with agent.run("hello") as run: await run.result() finally: await cfg.aclose() assert observed["is_error"] is True assert "nope" in observed["text"] @pytest.mark.asyncio async def test_capability_error_tears_down_gateway_and_session() -> None: def add(a: int, b: int) -> int: """Add two integers.""" return a + b cfg = fake_acp_config( ACPTurn(updates=[_text_update("done")]), permission_policy="auto", agent_capabilities=schema.AgentCapabilities(), # no HTTP MCP support ) agent = Agent("acp", config=cfg, tools=[add]) try: with pytest.raises(MCPCapabilityError): async with agent.run("hello") as run: await run.result() assert cfg.sessions == {} # the started gateway did not leak a session finally: await cfg.aclose() @pytest.mark.asyncio async def test_second_turn_hot_updates_gateway_tools() -> None: def add(a: int, b: int) -> int: """Add two integers.""" return a + b def mul(a: int, b: int) -> int: """Multiply two integers.""" return a * b observed: dict[str, Any] = {} cfg: Any = None def snapshot_tools(key: str): async def probe() -> None: session = next(iter(cfg.sessions.values())) async with ( streamable_http_client(session.gateway.url) as (read, write), ClientSession(read, write) as mcp_session, ): await mcp_session.initialize() listed = await mcp_session.list_tools() observed[key] = sorted(t.name for t in listed.tools) return probe cfg = fake_acp_config( ACPTurn(updates=[_text_update("one")], on_prompt=snapshot_tools("turn1")), ACPTurn(updates=[_text_update("two")], on_prompt=snapshot_tools("turn2")), permission_policy="auto", ) agent = Agent("acp", config=cfg, tools=[add]) try: async with agent.run("first") as run: reply = await run.result() await reply.ask("second", tools=[FunctionTool.ensure_tool(mul)]) finally: await cfg.aclose() assert observed["turn1"] == ["add"] assert observed["turn2"] == ["add", "mul"] # the gateway serves the new turn's snapshot @pytest.mark.asyncio async def test_second_turn_external_server_drift_is_hard_error() -> None: def add(a: int, b: int) -> int: """Add two integers.""" return a + b cfg = fake_acp_config( ACPTurn(updates=[_text_update("one")]), permission_policy="auto", ) agent = Agent("acp", config=cfg, tools=[add]) try: async with agent.run("first") as run: reply = await run.result() with pytest.raises(ValueError, match="MCPServerTool set changed"): await reply.ask("second", tools=[MCPServerTool(server_url="https://x/mcp", server_label="ext")]) finally: await cfg.aclose() @pytest.mark.asyncio async def test_second_turn_function_tools_without_gateway_is_hard_error() -> None: def add(a: int, b: int) -> int: """Add two integers.""" return a + b cfg = fake_acp_config( ACPTurn(updates=[_text_update("one")]), permission_policy="auto", ) agent = Agent("acp", config=cfg) # first turn exposes nothing -> no gateway try: async with agent.run("first") as run: reply = await run.result() with pytest.raises(ValueError, match="without a tool"): await reply.ask("second", tools=[FunctionTool.ensure_tool(add)]) finally: await cfg.aclose() @pytest.mark.asyncio async def test_gateway_shuts_down_with_session() -> None: def add(a: int, b: int) -> int: """Add two integers.""" return a + b cfg = fake_acp_config( ACPTurn(updates=[_text_update("done")]), permission_policy="auto", ) agent = Agent("acp", config=cfg, tools=[add]) async with agent.run("hello") as run: await run.result() session = next(iter(cfg.sessions.values())) gateway = session.gateway assert gateway is not None and gateway.url is not None await cfg.aclose() assert gateway.url is None # closed together with the session @pytest.mark.asyncio class TestAnUnanswerableQuestionEndsTheACPBackedTurn: """The CLI agent drives the loop, but the question was AG2's to answer. A tool served over the gateway runs in this process, so a dead human-input channel is this side's failure — and the agent's own words are not a substitute for the answer it never got. Reported as the same exception every surface where AG2 runs the loop reports. """ def _asking_agent(self, *, turn: ACPTurn) -> tuple[Agent, Any]: async def ask_human(ctx: Context) -> str: """Ask the operator for the passphrase.""" return await ctx.input("What is the passphrase?") cfg = fake_acp_config(turn, permission_policy="auto", turn_timeout=5.0) return Agent("acp", config=cfg, tools=[ask_human]), cfg @staticmethod def _call_the_gateway(cfg_holder: dict[str, Any]) -> Any: async def drive_mcp() -> None: # Runs inside the fake agent's prompt turn — exactly when a real CLI # agent would call the gateway. session = next(iter(cfg_holder["cfg"].sessions.values())) assert session.gateway is not None and session.gateway.url is not None async with ( streamable_http_client(session.gateway.url) as (read, write), ClientSession(read, write) as mcp_session, ): await mcp_session.initialize() result = await mcp_session.call_tool("ask_human", {}) cfg_holder["tool_text"] = result.content[0].text return drive_mcp async def test_the_turn_fails_instead_of_reporting_the_agents_answer(self) -> None: holder: dict[str, Any] = {} # ``hang=True``: the agent keeps working after the tool call, and only # returns once it is cancelled. A turn that waited this out would report # success, which is the behaviour being excluded. agent, cfg = self._asking_agent( turn=ACPTurn(hang=True, on_prompt=self._call_the_gateway(holder), updates=[_text_update("all done")]) ) holder["cfg"] = cfg try: with pytest.raises(HumanInputNotProvidedError): await agent.ask("get the passphrase") finally: await cfg.aclose() # The agent was answered — an unanswered tools/call would hang it — but # not with AG2's own wiring advice. assert "hitl_hook" not in holder["tool_text"] async def test_the_agent_is_stopped_rather_than_waited_out(self) -> None: """The prompt only returns on cancel, so finishing at all proves it was sent. ``turn_timeout`` is 5s and the scripted turn hangs until cancelled: if the failure did not cancel, this would take the full timeout instead of ending as soon as the channel died. """ holder: dict[str, Any] = {} agent, cfg = self._asking_agent(turn=ACPTurn(hang=True, on_prompt=self._call_the_gateway(holder))) holder["cfg"] = cfg started = asyncio.get_running_loop().time() try: with pytest.raises(HumanInputNotProvidedError): await agent.ask("get the passphrase") finally: await cfg.aclose() assert asyncio.get_running_loop().time() - started < 4.0 async def test_the_abandoned_call_is_closed_off_in_history(self) -> None: """The same promise the AG2-driven surfaces make, on the surface that drives itself. The gateway sends its ``ToolCallEvent`` alone, so the repair at the turn boundary has no batch to find it in: an unanswered call would sit in the transcript of every stream reused after this failure. """ holder: dict[str, Any] = {} agent, cfg = self._asking_agent(turn=ACPTurn(hang=True, on_prompt=self._call_the_gateway(holder))) holder["cfg"] = cfg stream = MemoryStream() try: with pytest.raises(HumanInputNotProvidedError): await agent.ask("get the passphrase", stream=stream) finally: await cfg.aclose() events = list(await stream.history.get_events()) called = {event.id for event in events if isinstance(event, ToolCallEvent)} answered = {event.parent_id for event in events if isinstance(event, ToolResultEvent)} assert called and called == answered stand_ins = [result.result.parts[0].content for result in events if isinstance(result, ToolResultEvent)] assert stand_ins == [HUMAN_INPUT_ABANDONED_TOOL_RESULT]