diff --git a/agent/hosting/aguihosting/agui_test.go b/agent/hosting/aguihosting/agui_test.go index 1afe6aa3..0406224a 100644 --- a/agent/hosting/aguihosting/agui_test.go +++ b/agent/hosting/aguihosting/agui_test.go @@ -288,3 +288,71 @@ func TestHandler_UnknownDataContent_UsesCurrentMessageLifecycle(t *testing.T) { t.Fatalf("expected fallback text to use current message id/content, got %q", content) } } + +// TestHandler_ToolResult_HasDistinctMessageID verifies that tool result events get a +// distinct message ID from the preceding text/tool-call message to avoid AG-UI +// message ID collisions (mirrors .NET fix in microsoft/agent-framework#5800). +func TestHandler_ToolResult_HasDistinctMessageID(t *testing.T) { + a := newTestAgent(func(_ context.Context, _ []*message.Message, _ ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + return func(yield func(*agent.ResponseUpdate, error) bool) { + yield(&agent.ResponseUpdate{ + MessageID: "msg-stream-1", + Role: message.RoleAssistant, + Contents: message.Contents{&message.TextContent{Text: "Checking weather..."}}, + }, nil) + yield(&agent.ResponseUpdate{ + MessageID: "msg-stream-1", + Role: message.RoleAssistant, + Contents: message.Contents{&message.FunctionCallContent{CallID: "call-1", Name: "get_weather", Arguments: `{}`}}, + }, nil) + yield(&agent.ResponseUpdate{ + MessageID: "msg-stream-1", + Role: message.RoleTool, + Contents: message.Contents{&message.FunctionResultContent{CallID: "call-1", Result: "72F and sunny"}}, + }, nil) + } + }) + h := aguihosting.NewJSONHTTPHandler(aguihosting.HandlerConfig{Agent: a}) + + body := `{"threadId":"thread-1","runId":"run-1","messages":[{"id":"u1","role":"user","content":"what is the weather?"}]}` + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + content := rr.Body.String() + + // Tool call result must be present + if !strings.Contains(content, `"toolCallId":"call-1"`) { + t.Fatalf("expected tool call result event with toolCallId=call-1, got %q", content) + } + + // Extract the tool result messageId and the text message start messageId. + // They must differ so that the tool result does not collide with the text message. + var toolResultMsgID, textStartMsgID string + for _, line := range strings.Split(content, "\n") { + if !strings.HasPrefix(line, "data:") { + continue + } + data := strings.TrimPrefix(line, "data: ") + var evt map[string]any + if err := json.Unmarshal([]byte(data), &evt); err != nil { + continue + } + switch evt["type"] { + case "TEXT_MESSAGE_START": + textStartMsgID, _ = evt["messageId"].(string) + case "TOOL_CALL_RESULT": + toolResultMsgID, _ = evt["messageId"].(string) + } + } + + if textStartMsgID == "" { + t.Fatal("expected TEXT_MESSAGE_START event") + } + if toolResultMsgID == "" { + t.Fatal("expected TOOL_CALL_RESULT event") + } + if textStartMsgID == toolResultMsgID { + t.Fatalf("tool result message ID %q must differ from text message ID %q", toolResultMsgID, textStartMsgID) + } +} diff --git a/agent/hosting/aguihosting/events.go b/agent/hosting/aguihosting/events.go index 54788070..18f25f75 100644 --- a/agent/hosting/aguihosting/events.go +++ b/agent/hosting/aguihosting/events.go @@ -103,6 +103,13 @@ func updatesToAGUIEvents( msgID = aguiEvents.GenerateMessageID() } + // Tool result events must not share the same message ID as the preceding + // text/tool-call message to avoid AG-UI message ID collisions (#5800). + toolResultMsgID := msgID + if hasFunctionResultContent(update.Contents) { + toolResultMsgID = aguiEvents.GenerateMessageID() + } + if currentReasoningMsgID != "" && currentReasoningMsgID != msgID { if !yield(aguiEvents.NewReasoningMessageEndEvent(currentReasoningMsgID), nil) { return @@ -163,7 +170,11 @@ func updatesToAGUIEvents( } continue } - events, convErr := contentToEvents(c, msgID) + contentMsgID := msgID + if _, ok := c.(*message.FunctionResultContent); ok { + contentMsgID = toolResultMsgID + } + events, convErr := contentToEvents(c, contentMsgID) if convErr != nil { if !yield(aguiEvents.NewRunErrorEvent(convErr.Error(), aguiEvents.WithRunID(runID)), convErr) { return @@ -192,6 +203,15 @@ func updatesToAGUIEvents( } } +func hasFunctionResultContent(contents message.Contents) bool { + for _, c := range contents { + if _, ok := c.(*message.FunctionResultContent); ok { + return true + } + } + return false +} + func hasTextLikeContent(contents message.Contents) bool { for _, c := range contents { text, ok := c.(*message.TextContent)