Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1952,7 +1952,7 @@ def _append_segmented_snapshot_messages(flow: FlowState, all_messages: list[dict
]
if not calls:
continue
message_id = tool_open_id or generate_event_id()
message_id = segment.get("id") or tool_open_id or generate_event_id()
tool_open_id = None
all_messages.append({"id": message_id, "role": "assistant", "tool_calls": [call.copy() for call in calls]})
# Only mark the calls we actually emitted; a stale segment id that
Expand Down
33 changes: 23 additions & 10 deletions python/packages/ag-ui/agent_framework_ag_ui/_run_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,12 +511,25 @@ def _text_segment_for(flow: FlowState, message_id: str) -> dict[str, Any] | None
return None


def _track_tool_call_segment(flow: FlowState, tool_call_id: str) -> None:
"""Record a tool call in the current tool segment, opening one if needed."""
def _new_tool_call_segment_id(flow: FlowState) -> str:
"""Allocate an ID that is distinct from any streamed text segment."""
text_message_ids = {segment.get("id") for segment in flow.snapshot_segments if segment["kind"] == "text"}
if flow.message_id and flow.message_id not in text_message_ids:
return flow.message_id
return generate_event_id()


def _track_tool_call_segment(flow: FlowState, tool_call_id: str) -> str:
"""Record a tool call and return the message ID used by its stream events."""
segment: dict[str, Any]
if flow.snapshot_segments and flow.snapshot_segments[-1]["kind"] == "tool_calls":
flow.snapshot_segments[-1]["call_ids"].append(tool_call_id)
segment = flow.snapshot_segments[-1]
Comment on lines +522 to +526
segment.setdefault("id", _new_tool_call_segment_id(flow))
else:
flow.snapshot_segments.append({"kind": "tool_calls", "call_ids": [tool_call_id]})
segment = {"kind": "tool_calls", "id": _new_tool_call_segment_id(flow), "call_ids": []}
flow.snapshot_segments.append(segment)
segment["call_ids"].append(tool_call_id)
return str(segment["id"])


def _track_reasoning_segment(flow: FlowState, message_id: str) -> None:
Expand Down Expand Up @@ -575,11 +588,12 @@ def _emit_tool_call(
if predictive_handler:
predictive_handler.reset_streaming()

tool_message_id = _track_tool_call_segment(flow, tool_call_id)
events.append(
ToolCallStartEvent(
tool_call_id=tool_call_id,
tool_call_name=content.name,
parent_message_id=flow.message_id,
parent_message_id=tool_message_id,
)
)

Expand All @@ -590,7 +604,6 @@ def _emit_tool_call(
}
flow.pending_tool_calls.append(tool_entry)
flow.tool_calls_by_id[tool_call_id] = tool_entry
_track_tool_call_segment(flow, tool_call_id)

elif tool_call_id:
flow.tool_call_id = tool_call_id
Expand Down Expand Up @@ -847,11 +860,12 @@ def _emit_approval_request(

if require_confirmation:
confirm_id = generate_event_id()
confirm_message_id = _track_tool_call_segment(flow, confirm_id)
events.append(
ToolCallStartEvent(
tool_call_id=confirm_id,
tool_call_name="confirm_changes",
parent_message_id=flow.message_id,
parent_message_id=confirm_message_id,
)
)
args: dict[str, Any] = {
Expand All @@ -872,7 +886,6 @@ def _emit_approval_request(
flow.pending_tool_calls.append(confirm_entry)
flow.tool_calls_by_id[confirm_id] = confirm_entry
flow.tool_calls_ended.add(confirm_id)
_track_tool_call_segment(flow, confirm_id)

flow.waiting_for_approval = True
return events
Expand Down Expand Up @@ -909,12 +922,13 @@ def _emit_mcp_tool_call(content: Content, flow: FlowState) -> list[BaseEvent]:
tool_name = content.tool_name or "mcp_tool"

display_name = tool_name
tool_message_id = _track_tool_call_segment(flow, tool_call_id)

events.append(
ToolCallStartEvent(
tool_call_id=tool_call_id,
tool_call_name=display_name,
parent_message_id=flow.message_id,
parent_message_id=tool_message_id,
)
)

Expand All @@ -934,7 +948,6 @@ def _emit_mcp_tool_call(content: Content, flow: FlowState) -> list[BaseEvent]:
}
flow.pending_tool_calls.append(tool_entry)
flow.tool_calls_by_id[tool_call_id] = tool_entry
_track_tool_call_segment(flow, tool_call_id)

return events

Expand Down
47 changes: 47 additions & 0 deletions python/packages/ag-ui/tests/ag_ui/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
TextMessageEndEvent,
TextMessageStartEvent,
ToolCallArgsEvent,
ToolCallStartEvent,
)
from agent_framework import AgentResponseUpdate, Content, Message, ResponseStream
from agent_framework.exceptions import AgentInvalidResponseException
Expand Down Expand Up @@ -661,6 +662,24 @@ def test_snapshot_preserves_stream_order_around_tool_results():
assert kinds[3][1]["id"] != kinds[0][1]["id"]


def test_snapshot_reuses_streamed_tool_message_id_after_text():
"""Tool-call snapshots reuse the stream ID used by the reference client merge."""
flow = FlowState()
_emit_text(Content.from_text("First, the plan."), flow)
tool_events = _emit_tool_call(Content.from_function_call(call_id="call_1", name="docs_fetch", arguments="{}"), flow)
tool_start = next(event for event in tool_events if isinstance(event, ToolCallStartEvent))
_emit_tool_result(Content.from_function_result(call_id="call_1", result="done"), flow)
_emit_text(Content.from_text("And the summary."), flow)

event = _build_messages_snapshot(flow, [])

kinds = _snapshot_kinds(event)
assert [kind for kind, _ in kinds] == ["text", "tool_calls", "result", "text"]
assert tool_start.parent_message_id is not None
assert kinds[1][1]["id"] == tool_start.parent_message_id
assert kinds[1][1]["id"] != kinds[0][1]["id"]


def test_snapshot_tool_only_message_reuses_stream_message_id():
"""Tool-only turns keep the message id the stream opened with."""
flow = FlowState()
Expand Down Expand Up @@ -937,6 +956,26 @@ def test_emit_approval_request_populates_interrupt_metadata():
}


def test_emit_approval_request_reuses_confirmation_message_id_in_snapshot():
"""Confirmation tool events and snapshots share the same message ID."""
flow = FlowState()
_emit_text(Content.from_text("Before approval."), flow)
text_message_id = flow.message_id
function_call = Content.from_function_call(call_id="call_123", name="write_doc", arguments={"content": "x"})
approval_content = Content.from_function_approval_request(id="approval_1", function_call=function_call)

events = _emit_approval_request(approval_content, flow)
confirm_start = next(
event for event in events if isinstance(event, ToolCallStartEvent) and event.tool_call_name == "confirm_changes"
)
snapshot = _build_messages_snapshot(flow, [])
kinds = _snapshot_kinds(snapshot)

assert [kind for kind, _ in kinds] == ["text", "tool_calls"]
assert confirm_start.parent_message_id == kinds[1][1]["id"]
assert confirm_start.parent_message_id != text_message_id


def test_emit_approval_request_accumulates_multiple_interrupts():
"""Multiple approval requests in the same turn should accumulate in flow.interrupts."""
flow = FlowState(message_id="msg-1")
Expand Down Expand Up @@ -1583,6 +1622,8 @@ class TestEmitMcpToolCall:
def test_produces_start_and_args_events(self):
"""MCP tool call emits ToolCallStart + ToolCallArgs events."""
flow = FlowState()
_emit_text(Content.from_text("Before MCP call."), flow)
text_message_id = flow.message_id
content = Content.from_mcp_server_tool_call(
call_id="mcp_call_1",
tool_name="search",
Expand All @@ -1600,6 +1641,12 @@ def test_produces_start_and_args_events(self):
assert events[1].tool_call_id == "mcp_call_1" # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
assert "weather" in events[1].delta # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]

snapshot = _build_messages_snapshot(flow, [])
kinds = _snapshot_kinds(snapshot)
assert [kind for kind, _ in kinds] == ["text", "tool_calls"]
assert events[0].parent_message_id == kinds[1][1]["id"] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
assert events[0].parent_message_id != text_message_id # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]

def test_tracks_in_flow_state(self):
"""MCP tool call is tracked in flow.pending_tool_calls and tool_calls_by_id."""
flow = FlowState()
Expand Down
Loading