diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index 370e6c1137..71dc8f5914 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -191,12 +191,13 @@ Before acting on a model function-call batch, the loop classifies every actionab call aborts the complete batch before approval state changes or execution. Otherwise approval-required and Host-owned calls are returned together in model order, while session-backed executable siblings remain deferred. An incomplete session-backed mixed approval/Host response remains pending without executing a deferred call; a stateless incomplete -response is rejected, including when no response is supplied, message roles vary within the model output, or a newer -standalone request follows the incomplete batch. Stateless discovery selects the latest unresolved mixed batch, so a -completed batch remains inert on later turns. Correlation is scoped to the active mixed batch so completed or abandoned -historical Host calls remain unchanged. -Session-backed approval-only batches likewise remain atomic across resume calls: decisions may be accumulated, but no -call executes and the model is not resumed until every approval in the original batch has a decision. +response is rejected, including when no response is supplied, non-user message roles vary within the model output, or +a newer standalone request follows the incomplete batch. Stateless discovery selects the latest unresolved mixed batch +across non-user output messages. An unrelated user turn delimits standalone batches, so pauses from separate turns are +not synthesized into one mixed batch; a completed batch remains inert on later turns. Correlation is scoped to the +active mixed batch so completed or abandoned historical Host calls remain unchanged. Session-backed approval-only +batches likewise remain atomic across resume calls: decisions may be accumulated, but no call executes and the model +is not resumed until every approval in the original batch has a decision. `ToolApprovalMiddleware` may resolve approval requests through standing or automatic policies, but it preserves non-approval user-input requests and does not split or reorder manual approvals relative to their Host-owned siblings. @@ -623,7 +624,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | Scenario | Required invariant | Primary regression test | |---|---|---| | Fatal call mixed with pauses | Complete-batch classification raises before approval or execution, independent of call order. | `packages/core/tests/core/test_function_invocation_logic.py::test_mixed_batch_fatal_unknown_precedes_every_pause` | -| Approval and Host-owned calls | Both pause types are returned in model order; a session-backed partial response remains pending across serialization; a stateless zero-response or partial response fails closed across message roles and cannot be hidden by a newer standalone request; completed mixed batches remain inert; historical Host calls do not participate; a complete response executes the exact approved arguments once. | `test_mixed_batch_returns_approval_and_host_pause_in_model_order`, `test_mixed_batch_requires_complete_responses_before_execution`, `test_stateless_split_mixed_batch_rejects_incomplete_replay_before_execution`, `test_stateless_mixed_batch_across_message_roles_requires_complete_responses`, `test_later_standalone_request_does_not_hide_incomplete_stateless_mixed_batch`, `test_completed_split_stateless_mixed_batch_is_inert_on_later_turn`, `test_active_mixed_pause_ignores_historical_host_requests` | +| Approval and Host-owned calls | Both pause types are returned in model order; a session-backed partial response remains pending across serialization; occurrence-identified Host results reserve their slots before id-less results use the unique unanswered occurrence, while authoritative session state recognizes equivalent duplicates and conflicting duplicates fail closed; stateless replay does not treat an ambiguous id-less result as a Host duplicate without occurrence provenance; a stateless zero-response or partial response fails closed across non-user message roles and cannot be hidden by a newer standalone request; standalone pauses separated by an unrelated user turn remain independent and response-order invariant when call IDs are reused; exact Host occurrence identity outranks newer call-ID-only candidates; each stateless response belongs to the nearest compatible request batch even when it cannot be assigned to one item, response ownership is discovered in linear time, and Host-result exclusions close only their Host occurrences without keeping historical completed calls open or reinterpreting later local terminal results during approval normalization; completed mixed batches remain inert even when approval and Host requests reuse a call ID; historical Host calls do not participate; a complete response executes the exact approved arguments once. | `test_mixed_batch_returns_approval_and_host_pause_in_model_order`, `test_mixed_batch_requires_complete_responses_before_execution`, `test_stateful_mixed_batch_accepts_equivalent_idless_host_result_replay`, `test_stateful_mixed_batch_assigns_idless_equal_result_to_unanswered_occurrence`, `test_stateless_mixed_batch_rejects_conflicting_identified_host_results`, `test_stateless_split_mixed_batch_rejects_incomplete_replay_before_execution`, `test_stateless_mixed_batch_across_non_user_message_roles_requires_complete_responses`, `test_stateless_abandoned_approval_does_not_join_later_host_request`, `test_stateless_separated_pauses_with_reused_call_id_are_order_independent`, `test_exact_older_host_result_does_not_consume_newer_reused_call_approval`, `test_later_standalone_request_does_not_hide_incomplete_stateless_mixed_batch`, `test_later_idless_host_result_does_not_complete_older_stateless_mixed_batch`, `test_completed_approval_result_is_not_claimed_by_older_stateless_host_request`, `test_historical_stateless_host_result_does_not_capture_later_reused_call_approval`, `test_excluded_host_result_closes_own_occurrence_before_reused_call_approval`, `test_ambiguous_later_host_result_does_not_complete_older_stateless_mixed_batch`, `test_id_bearing_result_for_idless_host_request_does_not_consume_approval`, `test_stateless_pause_response_ownership_scans_contents_linearly`, `test_completed_split_stateless_mixed_batch_is_inert_on_later_turn`, `test_completed_stateless_mixed_batch_with_reused_call_id_is_inert`, `test_equal_idless_terminal_result_does_not_reexecute_completed_stateless_mixed_approval`, `test_active_mixed_pause_ignores_historical_host_requests` | | Safe and approval-required calls in one batch | Hidden safe calls replay only with the matching visible approval. | `packages/core/tests/core/test_harness_tool_approval.py::test_mixed_batch_hides_already_approved_request_until_approval_replay` | | Restored approval state | Serialized `ToolApprovalState` restores mixed-batch behavior. | `test_mixed_batch_accepts_restored_tool_approval_state` | | Unrelated turn before approval | Hidden calls do not execute on an unrelated turn. | `test_hidden_mixed_batch_requests_do_not_replay_on_unrelated_turn` | diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index a9f878c05a..467a867b46 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -3058,6 +3058,7 @@ def _match_mixed_pause_responses( responses: Sequence[Content], *, approval_response_binder: Callable[[Content], Content | None] | None = None, + allow_idless_host_duplicates: bool = True, ) -> tuple[set[int], bool, list[Content], set[int]]: """Match one complete mixed pause batch without depending on its storage source.""" approval_items: dict[str, int | None] = {} @@ -3084,63 +3085,73 @@ def _match_mixed_pause_responses( host_items_by_occurrence[request.id] = index matched_content_ids: set[int] = set() - for response in responses: - item_index: int | None = None - if response.type == "function_approval_response": - candidate = approval_response_binder(response) if approval_response_binder is not None else response - if candidate is None: + for match_idless_host_results in (False, True): + for response in responses: + is_idless_host_result = response.type == "function_result" and response.id is None + if is_idless_host_result != match_idless_host_results: continue - response_identities = { - str(identity) - for identity in ( - candidate.additional_properties.get(_APPROVAL_REQUEST_ID_KEY), - candidate.id, - ) - if identity is not None - } - matching_indexes = { - matched_index - for identity in response_identities - if (matched_index := approval_items.get(identity)) is not None - } - if len(matching_indexes) == 1: - item_index = matching_indexes.pop() - elif response.type == "function_result" and response.call_id in host_items_by_call: - candidate = response - if response.id is not None: - item_index = host_items_by_occurrence.get(response.id) - request = _content_from_state(items[item_index].get("request")) if item_index is not None else None - if request is None or request.call_id != response.call_id: - item_index = None - else: - matching_indexes = [ - pending_index - for pending_index in host_items_by_call[response.call_id] - if items[pending_index].get("response") is None - or ( - isinstance(items[pending_index].get("response"), Mapping) - and _same_mixed_pause_response( - cast(Mapping[str, Any], items[pending_index]["response"]), - response.to_dict(), - ) + + item_index: int | None = None + if response.type == "function_approval_response": + candidate = approval_response_binder(response) if approval_response_binder is not None else response + if candidate is None: + continue + response_identities = { + str(identity) + for identity in ( + candidate.additional_properties.get(_APPROVAL_REQUEST_ID_KEY), + candidate.id, ) - ] + if identity is not None + } + matching_indexes = { + matched_index + for identity in response_identities + if (matched_index := approval_items.get(identity)) is not None + } if len(matching_indexes) == 1: - item_index = matching_indexes[0] - else: - continue + item_index = matching_indexes.pop() + elif response.type == "function_result" and response.call_id in host_items_by_call: + candidate = response + if response.id is not None: + item_index = host_items_by_occurrence.get(response.id) + request = _content_from_state(items[item_index].get("request")) if item_index is not None else None + if request is None or request.call_id != response.call_id: + item_index = None + else: + unanswered_indexes = [ + pending_index + for pending_index in host_items_by_call[response.call_id] + if items[pending_index].get("response") is None + ] + if len(unanswered_indexes) == 1: + item_index = unanswered_indexes[0] + elif not unanswered_indexes and allow_idless_host_duplicates: + duplicate_indexes = [ + pending_index + for pending_index in host_items_by_call[response.call_id] + if isinstance(items[pending_index].get("response"), Mapping) + and _same_mixed_pause_response( + cast(Mapping[str, Any], items[pending_index]["response"]), + response.to_dict(), + ) + ] + if len(duplicate_indexes) == 1: + item_index = duplicate_indexes[0] + else: + continue - if item_index is None: - continue - candidate_state = candidate.to_dict() - stored_response = items[item_index].get("response") - if stored_response is not None and ( - not isinstance(stored_response, Mapping) - or not _same_mixed_pause_response(cast(Mapping[str, Any], stored_response), candidate_state) - ): - raise RuntimeError(f"Conflicting response for mixed pause occurrence {candidate.id!r}.") - items[item_index]["response"] = candidate_state - matched_content_ids.add(id(response)) + if item_index is None: + continue + candidate_state = candidate.to_dict() + stored_response = items[item_index].get("response") + if stored_response is not None and ( + not isinstance(stored_response, Mapping) + or not _same_mixed_pause_response(cast(Mapping[str, Any], stored_response), candidate_state) + ): + raise RuntimeError(f"Conflicting response for mixed pause occurrence {candidate.id!r}.") + items[item_index]["response"] = candidate_state + matched_content_ids.add(id(response)) if any(item.get("response") is None for item in items): return matched_content_ids, True, [], set() @@ -3218,7 +3229,7 @@ def bind_approval_response(response: Content) -> Content | None: def _stateless_mixed_pause_batch_status( messages: list[Message], ) -> tuple[bool, set[int]]: - """Validate the latest unresolved stateless mixed batch and order its responses.""" + """Validate the latest unresolved stateless mixed batch and identify Host results.""" from ._types import Message flattened_contents = [content for message in messages for content in message.contents] @@ -3226,6 +3237,7 @@ def _stateless_mixed_pause_batch_status( batch_items: list[dict[str, Any]] = [] batch_kinds: set[str] = set() last_request_index = -1 + content_index = -1 def answers_current_batch(content: Content) -> bool: if content.type not in {"function_approval_response", "function_result"}: @@ -3256,52 +3268,183 @@ def answers_current_batch(content: Content) -> bool: return True return False - for content_index, content in enumerate(flattened_contents): - if batch_items and answers_current_batch(content): - request_batches.append((last_request_index, batch_items, batch_kinds)) - batch_items = [] - batch_kinds = set() - if content.type == "function_approval_request": - kind = "approval" - elif content.type == "function_call" and content.user_input_request: - kind = "host" - else: - continue - last_request_index = content_index - batch_kinds.add(kind) - batch_items.append({"kind": kind, "request": content.to_dict()}) - if batch_items: + def finish_current_batch() -> None: + nonlocal batch_items, batch_kinds + if not batch_items: + return request_batches.append((last_request_index, batch_items, batch_kinds)) + batch_items = [] + batch_kinds = set() - for batch_end, items, kinds in reversed(request_batches): - if kinds != {"approval", "host"}: + for message in messages: + if ( + message.role == "user" + and batch_items + and not any(answers_current_batch(content) for content in message.contents) + ): + finish_current_batch() + for content in message.contents: + content_index += 1 + if batch_items and answers_current_batch(content): + finish_current_batch() + if content.type == "function_approval_request": + kind = "approval" + elif content.type == "function_call" and content.user_input_request: + kind = "host" + else: + continue + last_request_index = content_index + batch_kinds.add(kind) + batch_items.append({"kind": kind, "request": content.to_dict()}) + finish_current_batch() + + responses_by_batch: list[list[Content]] = [[] for _ in request_batches] + latest_approval_by_identity: dict[str, int] = {} + latest_approval_by_call: dict[str, int] = {} + latest_host_by_occurrence: dict[tuple[str, str], int] = {} + latest_host_by_call: dict[str, int] = {} + latest_idless_host_by_call: dict[str, int] = {} + reserved_host_result_ids: set[int] = set() + response_content_indices: dict[int, int] = {} + host_result_content_indices: dict[int, int] = {} + next_batch_index = 0 + + def register_request_batch(batch_index: int) -> None: + for item in request_batches[batch_index][1]: + request = _content_from_state(item.get("request")) + if request is None: + continue + if item.get("kind") == "approval": + function_call = request.function_call + for identity in ( + request.id, + function_call.id if function_call is not None else None, + ): + if identity is not None: + latest_approval_by_identity[identity] = batch_index + if function_call is not None and function_call.call_id is not None: + latest_approval_by_call[function_call.call_id] = batch_index + elif item.get("kind") == "host" and request.call_id is not None: + latest_host_by_call[request.call_id] = batch_index + if request.id is None: + latest_idless_host_by_call[request.call_id] = batch_index + else: + latest_host_by_occurrence[request.call_id, request.id] = batch_index + + for response_index, content in enumerate(flattened_contents): + if content.type not in {"function_approval_response", "function_result"}: continue - responses = [ - content - for content in flattened_contents[batch_end + 1 :] - if content.type in {"function_approval_response", "function_result"} - ] - matched_response_ids, incomplete, ordered_responses, host_result_ids = _match_mixed_pause_responses( + while next_batch_index < len(request_batches) and request_batches[next_batch_index][0] < response_index: + register_request_batch(next_batch_index) + next_batch_index += 1 + + owner_candidates: list[tuple[int, str]] = [] + approval_batch_index: int | None = None + host_batch_index: int | None = None + exact_host_batch_index: int | None = None + if content.type == "function_approval_response": + identities = { + str(identity) + for identity in ( + content.additional_properties.get(_APPROVAL_REQUEST_ID_KEY), + content.id, + ) + if identity is not None + } + owner_candidates.extend( + (batch_index, "approval") + for identity in identities + if (batch_index := latest_approval_by_identity.get(identity)) is not None + ) + elif content.call_id is not None: + approval_batch_index = latest_approval_by_call.get(content.call_id) + if approval_batch_index is not None: + owner_candidates.append((approval_batch_index, "approval")) + if content.id is None: + host_batch_index = latest_host_by_call.get(content.call_id) + else: + exact_host_batch_index = latest_host_by_occurrence.get((content.call_id, content.id)) + host_batch_index = max( + ( + batch_index + for batch_index in ( + exact_host_batch_index, + latest_idless_host_by_call.get(content.call_id), + ) + if batch_index is not None + ), + default=None, + ) + if host_batch_index is not None: + owner_candidates.append((host_batch_index, "host")) + + if not owner_candidates: + continue + if exact_host_batch_index is not None: + owner_batch_index, owner_kind = exact_host_batch_index, "host" + else: + owner_batch_index, owner_kind = max( + owner_candidates, + key=lambda candidate: (candidate[0], candidate[1] == "host"), + ) + responses_by_batch[owner_batch_index].append(content) + response_content_indices[id(content)] = response_index + if owner_kind == "host" and (exact_host_batch_index is not None or approval_batch_index != owner_batch_index): + reserved_host_result_ids.add(id(content)) + host_result_content_indices[id(content)] = response_index + + batch_matches: list[tuple[set[int], bool, list[Content], set[int]]] = [] + matched_host_result_ids = set(reserved_host_result_ids) + for batch_index, (_, items, _) in enumerate(request_batches): + responses = responses_by_batch[batch_index] + match = _match_mixed_pause_responses( items, responses, + allow_idless_host_duplicates=False, ) + batch_matches.append(match) + matched_response_ids = match[0] + for response in responses: + response_id = id(response) + if response.type == "function_result" and response_id in matched_response_ids: + matched_host_result_ids.add(response_id) + host_result_content_indices[response_id] = response_content_indices[response_id] + + pending_approval_response_ids = { + id(response) + for response in _collect_approval_responses( + messages, + non_approval_result_ids=matched_host_result_ids, + ).values() + } + pending_approval_batch_indices = { + batch_index + for batch_index, responses in enumerate(responses_by_batch) + if any(id(response) in pending_approval_response_ids for response in responses) + } + first_pending_approval_content_index = min( + (request_batches[batch_index][0] for batch_index in pending_approval_batch_indices), + default=None, + ) + active_host_result_ids = { + result_id + for result_id, response_index in host_result_content_indices.items() + if first_pending_approval_content_index is not None and response_index > first_pending_approval_content_index + } + for batch_index in range(len(request_batches) - 1, -1, -1): + _, _, kinds = request_batches[batch_index] + matched_response_ids, incomplete, ordered_responses, ordered_host_result_ids = batch_matches[batch_index] + if kinds != {"approval", "host"}: + continue if incomplete: - return True, host_result_ids - - pending_approval_response_ids = { - id(response) - for response in _collect_approval_responses( - messages, - non_approval_result_ids={ - id(content) - for content in responses - if content.type == "function_result" and id(content) in matched_response_ids - }, - ).values() - } + return True, active_host_result_ids + if matched_response_ids.isdisjoint(pending_approval_response_ids): continue + original_host_result_ids = matched_response_ids & active_host_result_ids + active_host_result_ids.difference_update(original_host_result_ids) + active_host_result_ids.update(ordered_host_result_ids) filtered_messages: list[Message] = [] for message in messages: message.contents = [content for content in message.contents if id(content) not in matched_response_ids] @@ -3309,8 +3452,8 @@ def answers_current_batch(content: Content) -> bool: filtered_messages.append(message) filtered_messages.append(Message(role="user", contents=ordered_responses)) messages[:] = filtered_messages - return False, host_result_ids - return False, set() + return False, active_host_result_ids + return False, active_host_result_ids def _collect_approval_responses( @@ -3614,11 +3757,37 @@ def find_open_occurrence(call_id: str, *, require_unbound: bool = False) -> _App for occurrence in occurrences_by_call_id.get(call_id, []): if occurrence.closed: continue - if require_unbound and occurrence.approval_id is not None: + if require_unbound and (occurrence.approval_id is not None or occurrence.function_call.user_input_request): continue return occurrence return None + def find_open_host_occurrence(result: Content) -> _ApprovalCallOccurrence | None: + if result.call_id is None: + return None + occurrences = occurrences_by_call_id.get(result.call_id, []) + if result.id is not None: + exact = next( + ( + occurrence + for occurrence in occurrences + if not occurrence.closed + and occurrence.function_call.user_input_request + and occurrence.function_call.id == result.id + ), + None, + ) + if exact is not None: + return exact + return next( + ( + occurrence + for occurrence in occurrences + if not occurrence.closed and occurrence.function_call.user_input_request + ), + None, + ) + def find_approval_occurrence(approval_id: str) -> _ApprovalCallOccurrence | None: for occurrence in occurrences_by_approval_id.get(approval_id, []): if not occurrence.closed: @@ -3719,6 +3888,8 @@ def find_approval_occurrence(approval_id: str) -> _ApprovalCallOccurrence | None if content.call_id is None: continue if non_approval_result_ids is not None and id(content) in non_approval_result_ids: + if occurrence := find_open_host_occurrence(content): + occurrence.closed = True continue occurrence = find_open_occurrence(content.call_id) if occurrence is None: diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index 3ae3bca001..f97e80ea0f 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -3666,9 +3666,11 @@ def approval_func() -> str: assert calls == 0 -@pytest.mark.parametrize("metadata_role", ["tool", "user"]) -def test_stateless_mixed_batch_across_message_roles_requires_complete_responses(metadata_role: str) -> None: - """Intervening message roles do not split a mixed batch or change response order.""" +@pytest.mark.parametrize("metadata_role", ["assistant", "tool"]) +def test_stateless_mixed_batch_across_non_user_message_roles_requires_complete_responses( + metadata_role: str, +) -> None: + """Intervening non-user message roles do not split a mixed batch or change response order.""" from agent_framework._tools import _stateless_mixed_pause_batch_status approval_call = Content.from_function_call( @@ -3719,6 +3721,176 @@ def test_stateless_mixed_batch_across_message_roles_requires_complete_responses( assert host_result_ids == {id(complete_messages[-1].contents[1])} +async def test_stateless_abandoned_approval_does_not_join_later_host_request( + chat_client_base: SupportsChatGetResponse, +) -> None: + """Standalone pauses separated by a user turn do not form a synthetic mixed batch.""" + from agent_framework import FunctionTool + + calls = 0 + + @tool(name="approval_func", approval_mode="always_require") + def approval_func() -> str: + nonlocal calls + calls += 1 + return "approved" + + approval_call = Content.from_function_call( + call_id="approval", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + host_request = Content.from_function_call( + call_id="host", + name="host_func", + arguments={}, + id="host-occurrence", + ) + host_request.user_input_request = True + host_result = Content.from_function_result(call_id="host", result="host result") + host_result.id = "host-occurrence" + host_func = FunctionTool(name="host_func", func=None, description="Handled by the caller") + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + messages = [ + Message(role="assistant", contents=[approval_request]), + Message(role="user", contents=["unrelated follow-up"]), + Message(role="assistant", contents=[host_request]), + Message(role="user", contents=[host_result]), + ] + + response = await chat_client_base.get_response( + messages, + options={"tools": [approval_func, host_func]}, + ) + + assert response.text == "done" + assert calls == 0 + assert chat_client_base.call_count == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + +@pytest.mark.parametrize("approval_response_first", [True, False], ids=["approval-first", "host-first"]) +async def test_stateless_separated_pauses_with_reused_call_id_are_order_independent( + chat_client_base: SupportsChatGetResponse, + approval_response_first: bool, +) -> None: + """Responses for standalone pauses with a reused call ID remain occurrence-scoped.""" + from agent_framework import FunctionTool + + calls = 0 + + @tool(name="approval_func", approval_mode="always_require") + def approval_func() -> str: + nonlocal calls + calls += 1 + return "approved" + + approval_call = Content.from_function_call( + call_id="shared", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="host-occurrence", + ) + host_request.user_input_request = True + approval_response = approval_request.to_function_approval_response(approved=True) + host_result = Content.from_function_result(call_id="shared", result="host result") + host_result.id = "host-occurrence" + responses = [approval_response, host_result] if approval_response_first else [host_result, approval_response] + host_func = FunctionTool(name="host_func", func=None, description="Handled by the caller") + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + messages = [ + Message(role="assistant", contents=[approval_request]), + Message(role="user", contents=["unrelated follow-up"]), + Message(role="assistant", contents=[host_request]), + Message(role="user", contents=responses), + ] + + response = await chat_client_base.get_response( + messages, + options={"tools": [approval_func, host_func]}, + ) + + assert response.text == "done" + assert calls == 1 + assert chat_client_base.call_count == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + +@pytest.mark.parametrize("approval_response_first", [True, False], ids=["approval-first", "host-first"]) +async def test_exact_older_host_result_does_not_consume_newer_reused_call_approval( + chat_client_base: SupportsChatGetResponse, + approval_response_first: bool, +) -> None: + """An exact Host occurrence remains authoritative over a newer call-ID-only approval candidate.""" + from agent_framework import FunctionTool + + calls = 0 + + @tool(name="approval_func", approval_mode="always_require") + def approval_func() -> str: + nonlocal calls + calls += 1 + return "approved" + + host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="host-occurrence", + ) + host_request.user_input_request = True + approval_call = Content.from_function_call( + call_id="shared", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + approval_response = approval_request.to_function_approval_response(approved=True) + host_result = Content.from_function_result(call_id="shared", result="host result") + host_result.id = "host-occurrence" + responses = [approval_response, host_result] if approval_response_first else [host_result, approval_response] + host_func = FunctionTool(name="host_func", func=None, description="Handled by the caller") + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + messages = [ + Message(role="assistant", contents=[host_request]), + Message(role="user", contents=["unrelated follow-up"]), + Message(role="assistant", contents=[approval_request]), + Message(role="user", contents=responses), + ] + + response = await chat_client_base.get_response( + messages, + options={"tools": [approval_func, host_func]}, + ) + + assert response.text == "done" + assert calls == 1 + assert chat_client_base.call_count == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + async def test_later_standalone_request_does_not_hide_incomplete_stateless_mixed_batch( chat_client_base: SupportsChatGetResponse, ) -> None: @@ -3781,10 +3953,10 @@ def approval_func() -> str: assert calls == 0 -async def test_completed_split_stateless_mixed_batch_is_inert_on_later_turn( +async def test_later_idless_host_result_does_not_complete_older_stateless_mixed_batch( chat_client_base: SupportsChatGetResponse, ) -> None: - """A terminal result prevents completed split mixed approvals from replaying.""" + """A call-ID-only Host result belongs to the nearest compatible request batch.""" from agent_framework import FunctionTool calls = 0 @@ -3805,33 +3977,84 @@ def approval_func() -> str: id="approval-occurrence", function_call=approval_call, ) - host_request = Content.from_function_call( - call_id="host", + older_host_request = Content.from_function_call( + call_id="shared", name="host_func", arguments={}, - id="host-occurrence", + id="older-host-occurrence", ) - host_request.user_input_request = True - host_result = Content.from_function_result(call_id="host", result="host result") - host_result.id = "host-occurrence" - approval_result = Content.from_function_result(call_id="approval", result="approved") + older_host_request.user_input_request = True + later_host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="later-host-occurrence", + ) + later_host_request.user_input_request = True + later_host_result = Content.from_function_result(call_id="shared", result="later host result") + host_func = FunctionTool(name="host_func", func=None, description="Handled by the caller") + messages = [ + Message(role="assistant", contents=[approval_request, older_host_request]), + Message(role="user", contents=[approval_request.to_function_approval_response(approved=True)]), + Message(role="assistant", contents=[later_host_request]), + Message(role="user", contents=[later_host_result]), + ] + + with pytest.raises( + RuntimeError, + match="A mixed function-call batch requires responses for every approval and Host-owned request", + ): + await chat_client_base.get_response( + messages, + options={"tools": [approval_func, host_func]}, + ) + + assert calls == 0 + assert chat_client_base.call_count == 0 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + +async def test_completed_approval_result_is_not_claimed_by_older_stateless_host_request( + chat_client_base: SupportsChatGetResponse, +) -> None: + """A completed approval result remains owned by the nearer approval batch.""" + from agent_framework import FunctionTool + + calls = 0 + + @tool(name="approval_func", approval_mode="always_require") + def approval_func() -> str: + nonlocal calls + calls += 1 + return "approved" + + older_host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="older-host-occurrence", + ) + older_host_request.user_input_request = True + approval_call = Content.from_function_call( + call_id="shared", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + completed_approval_result = Content.from_function_result(call_id="shared", result="approved") host_func = FunctionTool(name="host_func", func=None, description="Handled by the caller") chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] ChatResponse(messages=Message(role="assistant", contents=["later response"])), ] messages = [ - Message(role="user", contents=["run mixed"]), + Message(role="assistant", contents=[older_host_request]), + Message(role="user", contents=["unrelated follow-up"]), Message(role="assistant", contents=[approval_request]), - Message(role="tool", contents=[Content.from_text("provider metadata")]), - Message(role="assistant", contents=[host_request]), - Message( - role="user", - contents=[ - approval_request.to_function_approval_response(approved=True), - host_result, - ], - ), - Message(role="tool", contents=[approval_result]), + Message(role="user", contents=[approval_request.to_function_approval_response(approved=True)]), + Message(role="tool", contents=[completed_approval_result]), Message(role="assistant", contents=["done"]), Message(role="user", contents=["later"]), ] @@ -3843,6 +4066,650 @@ def approval_func() -> str: assert response.text == "later response" assert calls == 0 + assert chat_client_base.call_count == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + +def test_historical_stateless_host_result_does_not_capture_later_reused_call_approval() -> None: + """Historical Host exclusions do not keep completed calls open during approval normalization.""" + from agent_framework._tools import ( + _collect_approval_responses, + _replace_approval_contents_with_results, + _stateless_mixed_pause_batch_status, + ) + + old_host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="old-host-occurrence", + ) + old_host_request.user_input_request = True + old_host_result = Content.from_function_result(call_id="shared", result="old host result") + old_host_result.id = "old-host-occurrence" + approval_call = Content.from_function_call( + call_id="shared", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + approval_response = approval_request.to_function_approval_response(approved=True) + messages = [ + Message(role="assistant", contents=[old_host_request]), + Message(role="user", contents=[old_host_result]), + Message(role="assistant", contents=[approval_request]), + Message(role="user", contents=[approval_response]), + ] + + incomplete, active_host_result_ids = _stateless_mixed_pause_batch_status(messages) + pending_responses = _collect_approval_responses( + messages, + non_approval_result_ids=active_host_result_ids, + ) + approval_result = Content.from_function_result(call_id="shared", result="approved result") + _replace_approval_contents_with_results( + messages, + pending_responses, + [[approval_result]], + non_approval_result_ids=active_host_result_ids, + ) + + assert incomplete is False + assert active_host_result_ids == set() + assert list(pending_responses) == ["approval-occurrence"] + assert [ + (content.type, content.name, content.id, content.result) + for message in messages + for content in message.contents + if content.type in {"function_call", "function_result"} + ] == [ + ("function_call", "host_func", "old-host-occurrence", None), + ("function_result", None, "old-host-occurrence", "old host result"), + ("function_call", "approval_func", "approval-occurrence", None), + ("function_result", None, None, "approved result"), + ] + + +def test_excluded_host_result_closes_own_occurrence_before_reused_call_approval() -> None: + """Excluded Host results close only Host calls before later approval normalization.""" + from agent_framework._tools import ( + _collect_approval_responses, + _replace_approval_contents_with_results, + _stateless_mixed_pause_batch_status, + ) + + earlier_call = Content.from_function_call( + call_id="earlier", + name="earlier_func", + arguments={}, + id="earlier-occurrence", + ) + earlier_request = Content.from_function_approval_request( + id="earlier-occurrence", + function_call=earlier_call, + ) + host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="host-occurrence", + ) + host_request.user_input_request = True + host_result = Content.from_function_result(call_id="shared", result="host result") + host_result.id = "host-occurrence" + later_call = Content.from_function_call( + call_id="shared", + name="later_func", + arguments={}, + id="later-occurrence", + ) + later_request = Content.from_function_approval_request( + id="later-occurrence", + function_call=later_call, + ) + messages = [ + Message(role="assistant", contents=[earlier_request]), + Message(role="user", contents=[earlier_request.to_function_approval_response(approved=True)]), + Message(role="assistant", contents=[host_request]), + Message(role="user", contents=[host_result]), + Message(role="assistant", contents=[later_request]), + Message(role="user", contents=[later_request.to_function_approval_response(approved=True)]), + ] + + incomplete, active_host_result_ids = _stateless_mixed_pause_batch_status(messages) + pending_responses = _collect_approval_responses( + messages, + non_approval_result_ids=active_host_result_ids, + ) + earlier_result = Content.from_function_result(call_id="earlier", result="earlier result") + later_result = Content.from_function_result(call_id="shared", result="later result") + _replace_approval_contents_with_results( + messages, + pending_responses, + [[earlier_result], [later_result]], + non_approval_result_ids=active_host_result_ids, + ) + + assert incomplete is False + assert list(pending_responses) == ["earlier-occurrence", "later-occurrence"] + assert [ + (content.type, content.name, content.id, content.result) + for message in messages + for content in message.contents + if content.type in {"function_call", "function_result"} + ] == [ + ("function_call", "earlier_func", "earlier-occurrence", None), + ("function_result", None, None, "earlier result"), + ("function_call", "host_func", "host-occurrence", None), + ("function_result", None, "host-occurrence", "host result"), + ("function_call", "later_func", "later-occurrence", None), + ("function_result", None, None, "later result"), + ] + + +async def test_ambiguous_later_host_result_does_not_complete_older_stateless_mixed_batch( + chat_client_base: SupportsChatGetResponse, +) -> None: + """An ambiguous result remains reserved to the nearer Host batch.""" + from agent_framework import FunctionTool + + calls = 0 + + @tool(name="approval_func", approval_mode="always_require") + def approval_func() -> str: + nonlocal calls + calls += 1 + return "approved" + + approval_call = Content.from_function_call( + call_id="approval", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + older_host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="older-host-occurrence", + ) + older_host_request.user_input_request = True + later_host_requests = [ + Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id=f"later-host-occurrence-{index}", + ) + for index in range(2) + ] + for request in later_host_requests: + request.user_input_request = True + ambiguous_result = Content.from_function_result(call_id="shared", result="later host result") + host_func = FunctionTool(name="host_func", func=None, description="Handled by the caller") + messages = [ + Message(role="assistant", contents=[approval_request, older_host_request]), + Message(role="user", contents=[approval_request.to_function_approval_response(approved=True)]), + Message(role="assistant", contents=later_host_requests), + Message(role="user", contents=[ambiguous_result]), + ] + + with pytest.raises( + RuntimeError, + match="A mixed function-call batch requires responses for every approval and Host-owned request", + ): + await chat_client_base.get_response( + messages, + options={"tools": [approval_func, host_func]}, + ) + + assert calls == 0 + assert chat_client_base.call_count == 0 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + +async def test_id_bearing_result_for_idless_host_request_does_not_consume_approval( + chat_client_base: SupportsChatGetResponse, +) -> None: + """A result compatible with an ID-less Host request remains Host-owned.""" + from agent_framework import FunctionTool + + calls = 0 + + @tool(name="approval_func", approval_mode="always_require") + def approval_func() -> str: + nonlocal calls + calls += 1 + return "approved" + + approval_call = Content.from_function_call( + call_id="shared", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + ) + host_request.user_input_request = True + approval_response = approval_request.to_function_approval_response(approved=True) + host_result = Content.from_function_result(call_id="shared", result="host result") + host_result.id = "host-result-occurrence" + host_func = FunctionTool(name="host_func", func=None, description="Handled by the caller") + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + messages = [ + Message(role="assistant", contents=[approval_request]), + Message(role="user", contents=["unrelated follow-up"]), + Message(role="assistant", contents=[host_request]), + Message(role="user", contents=[approval_response, host_result]), + ] + + response = await chat_client_base.get_response( + messages, + options={"tools": [approval_func, host_func]}, + ) + + assert response.text == "done" + assert calls == 1 + assert chat_client_base.call_count == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + +def test_stateless_pause_response_ownership_scans_contents_linearly() -> None: + """Stateless response ownership reads transcript content a bounded number of times.""" + from agent_framework._tools import _stateless_mixed_pause_batch_status + + class CountingContent(Content): + type_reads = 0 + + def __getattribute__(self, name: str) -> Any: + if name == "type": + CountingContent.type_reads += 1 + return super().__getattribute__(name) + + batch_count = 200 + messages: list[Message] = [] + for index in range(batch_count): + request = CountingContent.from_function_call( + call_id=f"host-{index}", + name="host_func", + arguments={}, + id=f"host-occurrence-{index}", + ) + request.user_input_request = True + result = CountingContent.from_function_result(call_id=f"host-{index}", result="host result") + result.id = f"host-occurrence-{index}" + messages.extend([ + Message(role="assistant", contents=[request]), + Message(role="user", contents=[result]), + ]) + + CountingContent.type_reads = 0 + incomplete, host_result_ids = _stateless_mixed_pause_batch_status(messages) + + assert incomplete is False + assert host_result_ids == set() + assert CountingContent.type_reads < batch_count * 50 + + +async def test_completed_split_stateless_mixed_batch_is_inert_on_later_turn( + chat_client_base: SupportsChatGetResponse, +) -> None: + """A terminal result prevents completed split mixed approvals from replaying.""" + from agent_framework import FunctionTool + + calls = 0 + + @tool(name="approval_func", approval_mode="always_require") + def approval_func() -> str: + nonlocal calls + calls += 1 + return "approved" + + approval_call = Content.from_function_call( + call_id="approval", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + host_request = Content.from_function_call( + call_id="host", + name="host_func", + arguments={}, + id="host-occurrence", + ) + host_request.user_input_request = True + host_result = Content.from_function_result(call_id="host", result="host result") + host_result.id = "host-occurrence" + approval_result = Content.from_function_result(call_id="approval", result="approved") + host_func = FunctionTool(name="host_func", func=None, description="Handled by the caller") + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=Message(role="assistant", contents=["later response"])), + ] + messages = [ + Message(role="user", contents=["run mixed"]), + Message(role="assistant", contents=[approval_request]), + Message(role="tool", contents=[Content.from_text("provider metadata")]), + Message(role="assistant", contents=[host_request]), + Message( + role="user", + contents=[ + approval_request.to_function_approval_response(approved=True), + host_result, + ], + ), + Message(role="tool", contents=[approval_result]), + Message(role="assistant", contents=["done"]), + Message(role="user", contents=["later"]), + ] + + response = await chat_client_base.get_response( + messages, + options={"tools": [approval_func, host_func]}, + ) + + assert response.text == "later response" + assert calls == 0 + + +async def test_completed_stateless_mixed_batch_with_reused_call_id_is_inert( + chat_client_base: SupportsChatGetResponse, +) -> None: + """A terminal local result cannot become Host-owned after the Host response is complete.""" + from agent_framework import FunctionTool + + calls = 0 + + @tool(name="approval_func", approval_mode="always_require") + def approval_func() -> str: + nonlocal calls + calls += 1 + return "approved" + + approval_call = Content.from_function_call( + call_id="shared", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="host-occurrence", + ) + host_request.user_input_request = True + host_result = Content.from_function_result(call_id="shared", result="host result") + host_result.id = "host-occurrence" + approval_result = Content.from_function_result(call_id="shared", result="approved") + host_func = FunctionTool(name="host_func", func=None, description="Handled by the caller") + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=Message(role="assistant", contents=["later response"])), + ] + messages = [ + Message(role="assistant", contents=[approval_request, host_request]), + Message( + role="user", + contents=[ + approval_request.to_function_approval_response(approved=True), + host_result, + ], + ), + Message(role="tool", contents=[approval_result]), + Message(role="assistant", contents=["done"]), + Message(role="user", contents=["later"]), + ] + + response = await chat_client_base.get_response( + messages, + options={"tools": [approval_func, host_func]}, + ) + + assert response.text == "later response" + assert calls == 0 + assert chat_client_base.call_count == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + +async def test_equal_idless_terminal_result_does_not_reexecute_completed_stateless_mixed_approval( + chat_client_base: SupportsChatGetResponse, +) -> None: + """An ambiguous id-less result cannot restore stateless approval authority.""" + from agent_framework import FunctionTool + + calls = 0 + + @tool(name="approval_func", approval_mode="always_require") + def approval_func() -> str: + nonlocal calls + calls += 1 + return "same result" + + approval_call = Content.from_function_call( + call_id="shared", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="host-occurrence", + ) + host_request.user_input_request = True + host_result = Content.from_function_result(call_id="shared", result="same result") + approval_result = Content.from_function_result(call_id="shared", result="same result") + host_func = FunctionTool(name="host_func", func=None, description="Handled by the caller") + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=Message(role="assistant", contents=["later response"])), + ] + messages = [ + Message(role="assistant", contents=[approval_request, host_request]), + Message( + role="user", + contents=[ + approval_request.to_function_approval_response(approved=True), + host_result, + ], + ), + Message(role="tool", contents=[approval_result]), + Message(role="assistant", contents=["done"]), + Message(role="user", contents=["later"]), + ] + + response = await chat_client_base.get_response( + messages, + options={"tools": [approval_func, host_func]}, + ) + + assert response.text == "later response" + assert calls == 0 + assert chat_client_base.call_count == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + +def test_stateful_mixed_batch_accepts_equivalent_idless_host_result_replay() -> None: + """Authoritative session state can recognize an equivalent Host-result replay.""" + from agent_framework._tools import ( + _stage_pending_mixed_pause_responses, + _store_pending_approval_requests, + _store_pending_mixed_pause_batch, + ) + + session = AgentSession() + approval_call = Content.from_function_call( + call_id="shared", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="host-occurrence", + ) + host_request.user_input_request = True + host_result = Content.from_function_result(call_id="shared", result="host result") + duplicate_host_result = Content.from_function_result(call_id="shared", result="host result") + _store_pending_approval_requests(session, [approval_request]) + _store_pending_mixed_pause_batch(session, [[approval_request], [host_request]]) + messages = [ + Message( + role="user", + contents=[ + approval_request.to_function_approval_response(approved=True), + host_result, + duplicate_host_result, + ], + ) + ] + + incomplete, completed, host_result_ids = _stage_pending_mixed_pause_responses(messages, session) + + assert incomplete is False + assert completed is True + assert [(content.type, content.result) for content in messages[-1].contents] == [ + ("function_approval_response", None), + ("function_result", "host result"), + ] + assert host_result_ids == {id(messages[-1].contents[1])} + + +@pytest.mark.parametrize("identified_first", [True, False], ids=["identified-first", "idless-first"]) +def test_stateful_mixed_batch_assigns_idless_equal_result_to_unanswered_occurrence( + identified_first: bool, +) -> None: + """Occurrence-identified Host results reserve their slots before id-less matching.""" + from agent_framework._tools import ( + _stage_pending_mixed_pause_responses, + _store_pending_approval_requests, + _store_pending_mixed_pause_batch, + ) + + session = AgentSession() + approval_call = Content.from_function_call( + call_id="approval", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + first_host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="first-host-occurrence", + ) + first_host_request.user_input_request = True + second_host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="second-host-occurrence", + ) + second_host_request.user_input_request = True + identified_result = Content.from_function_result(call_id="shared", result="same result") + identified_result.id = "first-host-occurrence" + idless_result = Content.from_function_result(call_id="shared", result="same result") + host_results = [identified_result, idless_result] if identified_first else [idless_result, identified_result] + _store_pending_approval_requests(session, [approval_request]) + _store_pending_mixed_pause_batch( + session, + [[approval_request], [first_host_request], [second_host_request]], + ) + messages = [ + Message( + role="user", + contents=[ + approval_request.to_function_approval_response(approved=True), + *host_results, + ], + ) + ] + + incomplete, completed, host_result_ids = _stage_pending_mixed_pause_responses(messages, session) + + assert incomplete is False + assert completed is True + assert [(content.type, content.id, content.result) for content in messages[-1].contents] == [ + ("function_approval_response", "approval-occurrence", None), + ("function_result", "first-host-occurrence", "same result"), + ("function_result", None, "same result"), + ] + assert host_result_ids == {id(messages[-1].contents[1]), id(messages[-1].contents[2])} + + +def test_stateless_mixed_batch_rejects_conflicting_identified_host_results() -> None: + """Conflicting results for one identified Host occurrence fail closed.""" + from agent_framework._tools import _stateless_mixed_pause_batch_status + + approval_call = Content.from_function_call( + call_id="approval", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + host_request = Content.from_function_call( + call_id="host", + name="host_func", + arguments={}, + id="host-occurrence", + ) + host_request.user_input_request = True + first_result = Content.from_function_result(call_id="host", result="first result") + first_result.id = "host-occurrence" + conflicting_result = Content.from_function_result(call_id="host", result="conflicting result") + conflicting_result.id = "host-occurrence" + messages = [ + Message(role="assistant", contents=[approval_request, host_request]), + Message( + role="user", + contents=[ + approval_request.to_function_approval_response(approved=True), + first_result, + conflicting_result, + ], + ), + ] + + with pytest.raises(RuntimeError, match="Conflicting response for mixed pause occurrence 'host-occurrence'"): + _stateless_mixed_pause_batch_status(messages) def test_active_mixed_pause_ignores_historical_host_requests() -> None: