# Copyright (c) 2026, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors # # SPDX-License-Identifier: Apache-2.0 """The CLI agent asking the user something: ACP ``elicitation/create``. Every test drives the public ``Agent.run`` surface with a config bound to the in-process scripted agent, and asserts only what a caller can observe: the Reply, the events on the Run's stream, the prompts the human was shown, and the response the scripted agent got back. """ import asyncio from typing import Any import pytest from acp import schema from ag2 import Agent, Context from ag2.acp.elicitation import MAX_ATTEMPTS from ag2.acp.events import ACPElicitation from ag2.acp.testing import FAKE_SESSION_ID, ACPTurn, ScriptedElicitation, fake_acp_config from ag2.events import BaseEvent, HumanInputRequest, ModelResponse AUTH_URL = "https://example.com/authorize" def _text_update(text: str) -> schema.AgentMessageChunk: return schema.AgentMessageChunk( session_update="agent_message_chunk", content=schema.TextContentBlock(type="text", text=text), ) def _url_mode(url: str = AUTH_URL, elicitation_id: str = "elicit-1") -> schema.ElicitationUrlSessionMode: return schema.ElicitationUrlSessionMode(session_id=FAKE_SESSION_ID, elicitation_id=elicitation_id, url=url) def _form_mode(properties: dict[str, Any], required: list[str] | None = None) -> schema.ElicitationFormSessionMode: return schema.ElicitationFormSessionMode( session_id=FAKE_SESSION_ID, requested_schema=schema.ElicitationSchema(type="object", properties=properties, required=required), ) def _asks(*elicitations: ScriptedElicitation, reply: str = "done") -> ACPTurn: """A turn where the agent asks, then replies once answered.""" return ACPTurn(elicitations=elicitations, updates=[_text_update(reply)]) class Human: """A scripted human, answering the prompts it is shown in order. Running out of answers is a failure, not an empty answer: an unexpected extra prompt would otherwise be silently absorbed as "take the default". """ def __init__(self, *answers: str) -> None: self._answers = list(answers) self.prompts: list[str] = [] def answer(self, event: HumanInputRequest) -> str: self.prompts.append(event.content) if not self._answers: raise AssertionError(f"the agent asked one question too many: {event.content!r}") return self._answers.pop(0) @property def prompt(self) -> str: """The only prompt the human was shown (fails if there was not exactly one).""" [only] = self.prompts return only async def _reply_body(cfg: Any, human: Human | None = None) -> str: """Run one turn against the scripted agent and return the reply's text.""" agent = Agent("acp", config=cfg, hitl_hook=human.answer if human is not None else None) try: result = await agent.ask("hello") finally: await cfg.aclose() return result.body @pytest.mark.asyncio class TestCapabilityAdvertisement: async def test_ask_advertises_form_and_url(self) -> None: initialized: list[schema.ClientCapabilities | None] = [] cfg = fake_acp_config(_asks(), initialize_calls=initialized) await _reply_body(cfg) [capabilities] = initialized assert capabilities is not None assert capabilities.elicitation == schema.ElicitationCapabilities( form=schema.ElicitationFormCapabilities(), url=schema.ElicitationUrlCapabilities(), ) async def test_decline_advertises_nothing(self) -> None: # Not "advertise and refuse": a conforming agent must never ask at all. initialized: list[schema.ClientCapabilities | None] = [] cfg = fake_acp_config(_asks(), elicitation_policy="decline", initialize_calls=initialized) await _reply_body(cfg) [capabilities] = initialized assert capabilities is not None assert capabilities.elicitation is None @pytest.mark.asyncio class TestDeclinePolicy: async def test_a_question_asked_anyway_is_declined(self) -> None: answers: list[schema.CreateElicitationResponse] = [] cfg = fake_acp_config( _asks(ScriptedElicitation("Authorize GitHub", _url_mode())), elicitation_policy="decline", elicitation_responses=answers, ) body = await _reply_body(cfg) assert [a.action for a in answers] == ["decline"] assert body == "done" async def test_the_human_is_never_prompted(self) -> None: human = Human() # any prompt at all raises cfg = fake_acp_config( _asks(ScriptedElicitation("Authorize GitHub", _url_mode())), elicitation_policy="decline", ) await _reply_body(cfg, human) assert human.prompts == [] @pytest.mark.asyncio class TestUrlMode: async def test_shows_the_message_and_the_url(self) -> None: human = Human("yes") cfg = fake_acp_config(_asks(ScriptedElicitation("Authorize GitHub", _url_mode()))) await _reply_body(cfg, human) assert "Authorize GitHub" in human.prompt assert AUTH_URL in human.prompt async def test_confirming_accepts(self) -> None: answers: list[schema.CreateElicitationResponse] = [] cfg = fake_acp_config( _asks(ScriptedElicitation("Authorize GitHub", _url_mode()), reply="authorized"), elicitation_responses=answers, ) body = await _reply_body(cfg, Human("yes")) # url mode requests no fields, so an accept carries no content. assert answers == [schema.AcceptElicitationResponse(action="accept", content=None)] # The answer reached the agent inside the same turn: only one turn is # scripted, and it went on to reply. assert body == "authorized" async def test_refusing_declines(self) -> None: answers: list[schema.CreateElicitationResponse] = [] cfg = fake_acp_config( _asks(ScriptedElicitation("Authorize GitHub", _url_mode())), elicitation_responses=answers, ) body = await _reply_body(cfg, Human("no")) assert [a.action for a in answers] == ["decline"] assert body == "done" async def test_the_completion_notification_is_handled(self) -> None: answers: list[schema.CreateElicitationResponse] = [] cfg = fake_acp_config( _asks(ScriptedElicitation("Authorize GitHub", _url_mode(), complete=True)), elicitation_responses=answers, ) body = await _reply_body(cfg, Human("yes")) assert [a.action for a in answers] == ["accept"] assert body == "done" @pytest.mark.asyncio class TestStreamEvent: async def test_the_question_appears_on_the_stream(self) -> None: seen: list[BaseEvent] = [] cfg = fake_acp_config(_asks(ScriptedElicitation("Authorize GitHub", _url_mode()))) agent = Agent("acp", config=cfg, hitl_hook=Human("yes").answer) try: async with agent.run("hello") as run: run.stream.subscribe(lambda event: seen.append(event)) await run.result() finally: await cfg.aclose() assert [e for e in seen if isinstance(e, ACPElicitation)] == [ ACPElicitation("Authorize GitHub", "url", url=AUTH_URL) ] async def test_a_form_question_names_the_requested_fields(self) -> None: seen: list[BaseEvent] = [] mode = _form_mode({ "name": schema.ElicitationStringPropertySchema(type="string"), "count": schema.ElicitationIntegerPropertySchema(type="integer"), }) cfg = fake_acp_config(_asks(ScriptedElicitation("Name it", mode))) agent = Agent("acp", config=cfg, hitl_hook=Human("x", "1").answer) try: async with agent.run("hello") as run: run.stream.subscribe(lambda event: seen.append(event)) await run.result() finally: await cfg.aclose() assert [e for e in seen if isinstance(e, ACPElicitation)] == [ ACPElicitation("Name it", "form", fields=["name", "count"]) ] async def test_the_event_is_published_in_the_conversation_context(self) -> None: # Per ADR 0004 the event goes out with `context.send`, so a subscriber is # dispatched under the run's own context — not some other stream's. contexts: list[Context] = [] def record(event: BaseEvent, ctx: Context) -> None: if isinstance(event, ACPElicitation): contexts.append(ctx) cfg = fake_acp_config(_asks(ScriptedElicitation("Authorize GitHub", _url_mode()))) agent = Agent("acp", config=cfg, hitl_hook=Human("yes").answer) try: async with agent.run("hello") as run: run.stream.subscribe(record) await run.result() stream_id = run.stream.id finally: await cfg.aclose() assert [ctx.stream.id for ctx in contexts] == [stream_id] async def test_the_event_precedes_the_prompt(self) -> None: # An observer must see the question even when something other than an # interactive human ends up answering it. timeline: list[Any] = [] def note_prompt(event: HumanInputRequest) -> str: timeline.append("prompted") return "yes" cfg = fake_acp_config(_asks(ScriptedElicitation("Authorize GitHub", _url_mode()))) agent = Agent("acp", config=cfg, hitl_hook=note_prompt) try: async with agent.run("hello") as run: run.stream.subscribe( lambda event: timeline.append(event) if isinstance(event, ACPElicitation) else None ) await run.result() finally: await cfg.aclose() assert [type(entry).__name__ for entry in timeline] == ["ACPElicitation", "str"] @pytest.mark.asyncio class TestFormRendering: async def test_one_prompt_per_property_in_schema_order(self) -> None: human = Human("refactor", "3") mode = _form_mode({ "strategy": schema.ElicitationStringPropertySchema(type="string"), "batch": schema.ElicitationIntegerPropertySchema(type="integer"), }) cfg = fake_acp_config(_asks(ScriptedElicitation("Plan the change", mode))) await _reply_body(cfg, human) assert len(human.prompts) == 2 assert "strategy" in human.prompts[0] assert "batch" in human.prompts[1] async def test_the_title_is_shown_and_falls_back_to_the_name(self) -> None: human = Human("a", "b") mode = _form_mode({ "titled": schema.ElicitationStringPropertySchema(type="string", title="Refactoring strategy"), "untitled": schema.ElicitationStringPropertySchema(type="string"), }) cfg = fake_acp_config(_asks(ScriptedElicitation("Plan the change", mode))) await _reply_body(cfg, human) assert "Refactoring strategy" in human.prompts[0] assert "untitled" in human.prompts[1] async def test_the_description_is_shown(self) -> None: human = Human("a") mode = _form_mode({ "strategy": schema.ElicitationStringPropertySchema( type="string", description="How aggressively to rewrite call sites" ) }) cfg = fake_acp_config(_asks(ScriptedElicitation("Plan the change", mode))) await _reply_body(cfg, human) assert "How aggressively to rewrite call sites" in human.prompt async def test_allowed_values_are_shown(self) -> None: human = Human("inline") mode = _form_mode({ "strategy": schema.ElicitationStringPropertySchema(type="string", enum=["inline", "extract"]) }) cfg = fake_acp_config(_asks(ScriptedElicitation("Plan the change", mode))) await _reply_body(cfg, human) assert "inline" in human.prompt assert "extract" in human.prompt async def test_a_value_outside_the_allowed_set_re_prompts(self) -> None: # The agent branches on those values; a spelling it never offered is as # useless to it as a word where a number was asked for. answers: list[schema.CreateElicitationResponse] = [] human = Human("sideways", "extract") mode = _form_mode({ "strategy": schema.ElicitationStringPropertySchema(type="string", enum=["inline", "extract"]) }) cfg = fake_acp_config(_asks(ScriptedElicitation("Plan the change", mode)), elicitation_responses=answers) await _reply_body(cfg, human) assert len(human.prompts) == 2 assert answers == [schema.AcceptElicitationResponse(action="accept", content={"strategy": "extract"})] async def test_a_default_is_shown_and_taken_on_an_empty_answer(self) -> None: answers: list[schema.CreateElicitationResponse] = [] human = Human("") mode = _form_mode({"batch": schema.ElicitationIntegerPropertySchema(type="integer", default=5)}) cfg = fake_acp_config(_asks(ScriptedElicitation("Plan the change", mode)), elicitation_responses=answers) await _reply_body(cfg, human) assert "5" in human.prompt assert answers == [schema.AcceptElicitationResponse(action="accept", content={"batch": 5})] async def test_a_required_property_without_a_default_is_re_prompted(self) -> None: answers: list[schema.CreateElicitationResponse] = [] human = Human("", "", "extract") mode = _form_mode( {"strategy": schema.ElicitationStringPropertySchema(type="string")}, required=["strategy"], ) cfg = fake_acp_config(_asks(ScriptedElicitation("Plan the change", mode)), elicitation_responses=answers) await _reply_body(cfg, human) assert len(human.prompts) == 3 assert answers == [schema.AcceptElicitationResponse(action="accept", content={"strategy": "extract"})] async def test_an_optional_property_left_empty_is_omitted(self) -> None: # Not filled in with a guess: the agent asked for it or it is absent. answers: list[schema.CreateElicitationResponse] = [] mode = _form_mode({ "note": schema.ElicitationStringPropertySchema(type="string"), "batch": schema.ElicitationIntegerPropertySchema(type="integer"), }) cfg = fake_acp_config(_asks(ScriptedElicitation("Plan the change", mode)), elicitation_responses=answers) await _reply_body(cfg, Human("", "4")) assert answers == [schema.AcceptElicitationResponse(action="accept", content={"batch": 4})] async def test_every_property_type_is_answered_in_its_own_type(self) -> None: answers: list[schema.CreateElicitationResponse] = [] mode = _form_mode({ "strategy": schema.ElicitationStringPropertySchema(type="string"), "ratio": schema.ElicitationNumberPropertySchema(type="number"), "batch": schema.ElicitationIntegerPropertySchema(type="integer"), "dry_run": schema.ElicitationBooleanPropertySchema(type="boolean"), "skip": schema.ElicitationMultiSelectPropertySchema( type="array", items=schema.StringMultiSelectItems(type="string", enum=["tests", "docs", "examples"]), ), }) cfg = fake_acp_config(_asks(ScriptedElicitation("Plan the change", mode)), elicitation_responses=answers) await _reply_body(cfg, Human("extract", "0.5", "12", "yes", "tests, docs")) assert answers == [ schema.AcceptElicitationResponse( action="accept", content={ "strategy": "extract", "ratio": 0.5, "batch": 12, "dry_run": True, "skip": ["tests", "docs"], }, ) ] @pytest.mark.parametrize( ("prop", "bad", "good", "expected"), [ (schema.ElicitationIntegerPropertySchema(type="integer"), "twelve", "12", 12), (schema.ElicitationNumberPropertySchema(type="number"), "half", "0.5", 0.5), (schema.ElicitationBooleanPropertySchema(type="boolean"), "maybe", "no", False), ( schema.ElicitationMultiSelectPropertySchema( type="array", items=schema.StringMultiSelectItems(type="string", enum=["tests"]) ), "docs", "tests", ["tests"], ), ], ids=["integer", "number", "boolean", "multi-select"], ) async def test_an_uncoercible_answer_re_prompts(self, prop: Any, bad: str, good: str, expected: Any) -> None: # Never sent through as a string: the agent asked for a number and must # get a number. answers: list[schema.CreateElicitationResponse] = [] human = Human(bad, good) cfg = fake_acp_config( _asks(ScriptedElicitation("Plan the change", _form_mode({"value": prop}))), elicitation_responses=answers, ) await _reply_body(cfg, human) assert len(human.prompts) == 2 assert answers == [schema.AcceptElicitationResponse(action="accept", content={"value": expected})] async def test_refusing_part_way_through_declines_the_whole_form(self) -> None: # A half-filled form is not what the agent asked for, and not something # AG2 should invent the rest of. answers: list[schema.CreateElicitationResponse] = [] mode = _form_mode({ "strategy": schema.ElicitationStringPropertySchema(type="string"), "batch": schema.ElicitationIntegerPropertySchema(type="integer"), }) cfg = fake_acp_config(_asks(ScriptedElicitation("Plan the change", mode)), elicitation_responses=answers) body = await _reply_body(cfg, Human("extract", "!decline")) assert answers == [schema.DeclineElicitationResponse(action="decline")] assert body == "done" async def test_an_endlessly_unanswerable_property_declines_rather_than_looping(self) -> None: # A human types `!decline` to give up; a programmatic hook cannot, so the # re-prompt loop is bounded or the turn never ends. answers: list[schema.CreateElicitationResponse] = [] human = Human(*["twelve"] * MAX_ATTEMPTS) cfg = fake_acp_config( _asks( ScriptedElicitation( "Plan the change", _form_mode({"batch": schema.ElicitationIntegerPropertySchema(type="integer")}), ) ), elicitation_responses=answers, ) body = await _reply_body(cfg, human) assert len(human.prompts) == MAX_ATTEMPTS assert answers == [schema.DeclineElicitationResponse(action="decline")] assert body == "done" @pytest.mark.parametrize( "unrenderable", [ schema.ElicitationOtherPropertySchema(type="swatch"), # A multi-select whose option list is in a shape this version cannot # read: accepting it blind would send values the agent never offered. schema.ElicitationMultiSelectPropertySchema( type="array", items=schema.OtherMultiSelectItems(type="swatch") ), ], ids=["unknown-type", "unknown-multi-select-items"], ) async def test_a_field_this_version_cannot_render_declines_before_prompting(self, unrenderable: Any) -> None: # The unrenderable field is *second*: a form AG2 cannot fill in completely # is refused on arrival, so the human never answers a field whose answer # would then be thrown away. `Human()` has no answers, so any prompt fails. answers: list[schema.CreateElicitationResponse] = [] human = Human() mode = _form_mode({ "strategy": schema.ElicitationStringPropertySchema(type="string"), "colour": unrenderable, }) cfg = fake_acp_config(_asks(ScriptedElicitation("Pick a colour", mode)), elicitation_responses=answers) body = await _reply_body(cfg, human) assert human.prompts == [] assert answers == [schema.DeclineElicitationResponse(action="decline")] assert body == "done" async def test_declared_bounds_are_shown_and_enforced(self) -> None: # Sending a value the agent already said it cannot use is no better than # sending the wrong type, and a limit that is enforced must be visible. answers: list[schema.CreateElicitationResponse] = [] human = Human("99", "7") mode = _form_mode({"batch": schema.ElicitationIntegerPropertySchema(type="integer", minimum=1, maximum=10)}) cfg = fake_acp_config(_asks(ScriptedElicitation("Plan the change", mode)), elicitation_responses=answers) await _reply_body(cfg, human) assert "1" in human.prompts[0] and "10" in human.prompts[0] assert len(human.prompts) == 2 assert answers == [schema.AcceptElicitationResponse(action="accept", content={"batch": 7})] async def test_a_number_with_no_json_form_re_prompts(self) -> None: # "nan" parses as a float but crosses the wire as `null`, which is not a # number the agent can use. answers: list[schema.CreateElicitationResponse] = [] human = Human("nan", "0.5") mode = _form_mode({"ratio": schema.ElicitationNumberPropertySchema(type="number")}) cfg = fake_acp_config(_asks(ScriptedElicitation("Plan the change", mode)), elicitation_responses=answers) await _reply_body(cfg, human) assert len(human.prompts) == 2 assert answers == [schema.AcceptElicitationResponse(action="accept", content={"ratio": 0.5})] @pytest.mark.asyncio class TestDegradation: async def test_an_unrecognised_mode_is_declined(self) -> None: # ACP 0.12's `Other`-shaped variants exist so a client can degrade on a # mode it does not know. Declining tells the agent to fall back; a # transport-level failure would just break its turn. answers: list[schema.CreateElicitationResponse] = [] cfg = fake_acp_config( _asks(ScriptedElicitation("Speak your answer", schema.ElicitationOtherPropertySchema(type="voice"))), elicitation_responses=answers, ) body = await _reply_body(cfg, Human()) assert answers == [schema.DeclineElicitationResponse(action="decline")] assert body == "done" async def test_no_hitl_channel_cancels_rather_than_blocking(self) -> None: answers: list[schema.CreateElicitationResponse] = [] cfg = fake_acp_config( _asks(ScriptedElicitation("Authorize GitHub", _url_mode())), elicitation_responses=answers, ) # No hitl_hook: there is no human to reach. body = await _reply_body(cfg) assert answers == [schema.CancelElicitationResponse(action="cancel")] assert body == "done" async def test_a_request_scoped_question_reaches_the_human_before_a_session_exists(self) -> None: # This is how a pre-session authentication flow works, so it must not # assume a live session. answers: list[schema.CreateElicitationResponse] = [] human = Human("yes") cfg = fake_acp_config( _asks(), initialize_elicitations=[ ScriptedElicitation( "Log in first", schema.ElicitationUrlRequestMode(request_id=1, elicitation_id="elicit-1", url=AUTH_URL), ) ], elicitation_responses=answers, ) body = await _reply_body(cfg, human) assert AUTH_URL in human.prompt assert answers == [schema.AcceptElicitationResponse(action="accept", content=None)] assert body == "done" async def test_cancelling_the_turn_leaves_no_prompt_outstanding(self) -> None: human = _NeverAnswers() seen: list[BaseEvent] = [] cfg = fake_acp_config( _asks(ScriptedElicitation("Authorize GitHub", _url_mode())), turn_timeout=0.3, cancel_timeout=0.1, ) agent = Agent("acp", config=cfg, hitl_hook=human.answer) try: async with agent.run("hello") as run: run.stream.subscribe(lambda event: seen.append(event)) await run.result() finally: await cfg.aclose() # The turn ended rather than hanging on the unanswered question... [response] = [e for e in seen if isinstance(e, ModelResponse)] assert response.finish_reason == "timeout" # ...and the question went with it. assert human.prompted, "the human was never asked, so nothing was cancelled" assert human.cancelled, "the abandoned run left a prompt waiting for input" class _NeverAnswers: """A human who is shown the question and never answers it.""" def __init__(self) -> None: self.prompted = False self.cancelled = False async def answer(self, event: HumanInputRequest) -> str: self.prompted = True try: await asyncio.Event().wait() except asyncio.CancelledError: self.cancelled = True raise return "" # pragma: no cover - the wait above never completes