From 9e24346cd6b9bba68f9a57f54806342662661e07 Mon Sep 17 00:00:00 2001 From: Kirill <17173528+0xff23@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:25:51 -0700 Subject: [PATCH 1/2] Avoid messages for response-only updates --- agent/agent.go | 2 +- agent/agent_test.go | 43 +++++++ agent/response.go | 63 +++++++--- agent/response_test.go | 142 +++++++++++++++++++++- provider/a2aprovider/a2a.go | 12 +- provider/a2aprovider/a2a_test.go | 33 +++-- provider/aguiprovider/agui.go | 2 - provider/aguiprovider/agui_test.go | 11 +- provider/openaiprovider/responses.go | 19 ++- provider/openaiprovider/responses_test.go | 47 ++++++- 10 files changed, 317 insertions(+), 57 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index e3775636d..8d47d388b 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -528,7 +528,7 @@ func (a *Agent) setAuthor(update *ResponseUpdate) { if update.AgentID == "" { update.AgentID = a.id } - if update.AuthorName == "" { + if update.AuthorName == "" && responseUpdateHasMessageData(update) { update.AuthorName = a.name } } diff --git a/agent/agent_test.go b/agent/agent_test.go index fbf2e08d9..a7620e14c 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -463,6 +463,49 @@ func newGenericTestAgent(runFn func(context.Context, []*message.Message, ...agen }) } +func TestAgent_Collect_ResponseMetadataOnlyUpdateDoesNotCreateMessage(t *testing.T) { + run := func(context.Context, []*message.Message, ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + return func(yield func(*agent.ResponseUpdate, error) bool) { + yield(&agent.ResponseUpdate{ + ContinuationToken: "next", + }, nil) + } + } + a := newGenericTestAgent(run, nil) + + resp, err := a.RunText(t.Context(), "hello").Collect() + if err != nil { + t.Fatalf("Collect() error = %v", err) + } + if len(resp.Messages) != 0 { + t.Fatalf("len(Messages) = %d, want 0", len(resp.Messages)) + } + if resp.ContinuationToken == "" { + t.Fatal("ContinuationToken is empty") + } +} + +func TestAgent_Collect_RawOnlyUpdateDoesNotCreateMessage(t *testing.T) { + raw := struct{ Kind string }{Kind: "task-status"} + run := func(context.Context, []*message.Message, ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + return func(yield func(*agent.ResponseUpdate, error) bool) { + yield(&agent.ResponseUpdate{RawRepresentation: raw}, nil) + } + } + a := newGenericTestAgent(run, nil) + + resp, err := a.RunText(t.Context(), "hello").Collect() + if err != nil { + t.Fatalf("Collect() error = %v", err) + } + if len(resp.Messages) != 0 { + t.Fatalf("len(Messages) = %d, want 0", len(resp.Messages)) + } + if resp.RawRepresentation != raw { + t.Fatalf("RawRepresentation = %#v, want %#v", resp.RawRepresentation, raw) + } +} + func TestNew_IgnoresNilMiddleware(t *testing.T) { var providerCalls, agentMiddlewareCalls, providerMiddlewareCalls int run := func(context.Context, []*message.Message, ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { diff --git a/agent/response.go b/agent/response.go index a5cbe11c3..ef14d20cf 100644 --- a/agent/response.go +++ b/agent/response.go @@ -142,6 +142,7 @@ func (resp *Response) Coalesce() { // Each message in the response becomes a separate update. Response-level // additional properties and a non-empty continuation token are included as // an additional metadata-only update without a message ID when present. +// If there are no messages, any response metadata is emitted in such an update. func (resp *Response) ToUpdates() []*ResponseUpdate { if resp == nil { return nil @@ -154,6 +155,14 @@ func (resp *Response) ToUpdates() []*ResponseUpdate { if createdAt.IsZero() { createdAt = resp.CreatedAt } + // Only an otherwise unidentifiable metadata-only message needs a role + // marker to distinguish it from the trailing response metadata update. + // targetMessage already defaults collected messages to assistant; leave + // the original role unchanged whenever other message data identifies it. + role := msg.Role + if role == "" && msg.ID == "" && msg.AuthorName == "" && len(msg.Contents) == 0 { + role = message.RoleAssistant + } updates = append(updates, &ResponseUpdate{ RawRepresentation: msg.RawRepresentation, AdditionalProperties: msg.AdditionalProperties, @@ -163,13 +172,16 @@ func (resp *Response) ToUpdates() []*ResponseUpdate { ConversationID: resp.ConversationID, FinishReason: resp.FinishReason, AuthorName: msg.AuthorName, - Role: msg.Role, + Role: role, CreatedAt: createdAt, Contents: msg.Contents, }) } - if hasAdditionalProperties || resp.ContinuationToken != "" { + // With no messages there is no per-message update to carry response metadata. + hasResponseMetadata := resp.AgentID != "" || resp.ID != "" || resp.ConversationID != nil || + resp.FinishReason != "" || !resp.CreatedAt.IsZero() || resp.RawRepresentation != nil + if hasAdditionalProperties || resp.ContinuationToken != "" || (len(resp.Messages) == 0 && hasResponseMetadata) { extra := &ResponseUpdate{ AdditionalProperties: resp.AdditionalProperties, AgentID: resp.AgentID, @@ -178,6 +190,10 @@ func (resp *Response) ToUpdates() []*ResponseUpdate { ContinuationToken: resp.ContinuationToken, CreatedAt: resp.CreatedAt, } + if len(resp.Messages) == 0 { + extra.FinishReason = resp.FinishReason + extra.RawRepresentation = resp.RawRepresentation + } updates = append(updates, extra) } @@ -191,25 +207,28 @@ func (resp *Response) Update(update *ResponseUpdate) { if update == nil { return } - msg := resp.targetMessage(update) - // Some members on ResponseUpdate map to members of Message. - // Incorporate those into the latest message; in cases where the message - // stores a single value, prefer the latest update's value over anything - // stored in the message. - msg.AuthorName = cmp.Or(update.AuthorName, msg.AuthorName) - msg.Role = cmp.Or(update.Role, msg.Role) - msg.ID = cmp.Or(update.MessageID, msg.ID) - if !isValidCreatedAt(msg.CreatedAt) && isValidCreatedAt(update.CreatedAt) { - msg.CreatedAt = update.CreatedAt - } - msg.Contents = append(msg.Contents, update.Contents...) - if update.MessageID != "" && update.AdditionalProperties != nil { - if msg.AdditionalProperties == nil { - msg.AdditionalProperties = make(map[string]any) + // A response-level metadata update must not create an empty message. + if responseUpdateHasMessageData(update) { + msg := resp.targetMessage(update) + // Some members on ResponseUpdate map to members of Message. + // Incorporate those into the latest message; in cases where the message + // stores a single value, prefer the latest update's value over anything + // stored in the message. + msg.AuthorName = cmp.Or(update.AuthorName, msg.AuthorName) + msg.Role = cmp.Or(update.Role, msg.Role) + msg.ID = cmp.Or(update.MessageID, msg.ID) + if !isValidCreatedAt(msg.CreatedAt) && isValidCreatedAt(update.CreatedAt) { + msg.CreatedAt = update.CreatedAt } - maps.Copy(msg.AdditionalProperties, update.AdditionalProperties) + msg.Contents = append(msg.Contents, update.Contents...) + if update.MessageID != "" && update.AdditionalProperties != nil { + if msg.AdditionalProperties == nil { + msg.AdditionalProperties = make(map[string]any) + } + maps.Copy(msg.AdditionalProperties, update.AdditionalProperties) + } + msg.RawRepresentation = appendRawRepresentation(msg.RawRepresentation, update.RawRepresentation) } - msg.RawRepresentation = appendRawRepresentation(msg.RawRepresentation, update.RawRepresentation) // Other members on a ResponseUpdate map to members of the response. // Prefer metadata supplied by later updates; omitted metadata does not @@ -344,6 +363,12 @@ type ResponseUpdate struct { Contents message.Contents `json:",omitzero"` } +// RawRepresentation alone is response metadata, not evidence of a message. +// Providers may emit raw-only lifecycle events before or after message updates. +func responseUpdateHasMessageData(update *ResponseUpdate) bool { + return update != nil && (update.MessageID != "" || update.AuthorName != "" || update.Role != "" || len(update.Contents) > 0) +} + // String returns the concatenated text contents of this update. func (r *ResponseUpdate) String() string { if r == nil { diff --git a/agent/response_test.go b/agent/response_test.go index 3ca46b836..654923937 100644 --- a/agent/response_test.go +++ b/agent/response_test.go @@ -695,6 +695,19 @@ func TestResponse_Update_RawRepresentation(t *testing.T) { } } +func TestResponse_Update_RawRepresentationOnlyDoesNotCreateMessage(t *testing.T) { + resp := &agent.Response{} + + resp.Update(&agent.ResponseUpdate{RawRepresentation: "raw-lifecycle-event"}) + + if len(resp.Messages) != 0 { + t.Fatalf("expected no messages for raw-only response update, got %d", len(resp.Messages)) + } + if resp.RawRepresentation != "raw-lifecycle-event" { + t.Fatalf("expected raw representation on response, got %#v", resp.RawRepresentation) + } +} + func TestResponse_ToUpdates_RoundTripPreservesRawRepresentationWithContinuationToken(t *testing.T) { // A response with a message raw representation and a continuation token emits // a trailing metadata-only update (RawRepresentation nil). Collecting the @@ -1076,13 +1089,18 @@ func TestResponse_ToUpdates_ConversationIDOnMetadataUpdate(t *testing.T) { } } -func TestResponse_ToUpdates_WithFinishReasonOnlyProducesEmptySlice(t *testing.T) { +func TestResponse_ToUpdates_WithFinishReasonOnlyProducesMetadataUpdate(t *testing.T) { resp := &agent.Response{FinishReason: "length"} updates := resp.ToUpdates() - if len(updates) != 0 { - t.Fatalf("expected no updates, got %d", len(updates)) + if len(updates) != 1 { + t.Fatalf("expected one metadata update, got %d", len(updates)) + } + var collected agent.Response + collected.Update(updates[0]) + if collected.FinishReason != "length" || len(collected.Messages) != 0 { + t.Fatalf("collected = %#v, want finish reason without messages", &collected) } } @@ -1176,6 +1194,124 @@ func TestResponse_ToUpdates_WithAdditionalPropertiesOnlyProducesSingleUpdate(t * } } +func TestResponse_ToUpdates_RoundTripDoesNotCreateMessageForResponseMetadata(t *testing.T) { + tests := []struct { + name string + response *agent.Response + wantToken string + wantProperty any + }{ + { + name: "continuation token", + response: &agent.Response{ContinuationToken: "token-123"}, + wantToken: "token-123", + }, + { + name: "additional properties", + response: &agent.Response{AdditionalProperties: map[string]any{"key": "value"}}, + wantProperty: "value", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var collected agent.Response + for _, update := range tt.response.ToUpdates() { + collected.Update(update) + } + + if len(collected.Messages) != 0 { + t.Fatalf("expected no messages after round trip, got %d", len(collected.Messages)) + } + if collected.ContinuationToken != tt.wantToken { + t.Errorf("continuation token = %q, want %q", collected.ContinuationToken, tt.wantToken) + } + if got := collected.AdditionalProperties["key"]; got != tt.wantProperty { + t.Errorf("additional property = %v, want %v", got, tt.wantProperty) + } + }) + } +} + +func TestResponse_ToUpdates_RoundTripPreservesResponseMetadataWithoutMessages(t *testing.T) { + original := &agent.Response{ + AgentID: "agent", ID: "response", ConversationID: new("conversation"), + FinishReason: "stop", CreatedAt: time.Now(), RawRepresentation: "raw-response", + } + var collected agent.Response + for _, update := range original.ToUpdates() { + collected.Update(update) + } + if !reflect.DeepEqual(&collected, original) { + t.Fatalf("collected = %#v, want %#v", &collected, original) + } +} + +func TestResponse_ToUpdates_PreservesEmptyRoleOnMessageData(t *testing.T) { + for _, msg := range []*message.Message{ + {ID: "message-id"}, + {AuthorName: "author"}, + {Contents: message.Contents{&message.TextContent{Text: "hello"}}}, + } { + updates := (&agent.Response{Messages: []*message.Message{msg}}).ToUpdates() + if len(updates) != 1 || updates[0].Role != "" { + t.Fatalf("ToUpdates normalized an already identifiable message: %#v", updates) + } + } +} + +func TestResponse_ToUpdates_RoundTripPreservesMetadataOnlyMessages(t *testing.T) { + createdAt := time.Date(2026, time.September, 20, 12, 0, 0, 0, time.UTC) + tests := []struct { + name string + message *message.Message + }{ + { + name: "additional properties", + message: &message.Message{ + // MessageID is required by the response-update property scoping contract. + ID: "metadata-message", + AdditionalProperties: map[string]any{"key": "value"}, + }, + }, + { + name: "created at", + message: &message.Message{ + CreatedAt: createdAt, + }, + }, + { + name: "raw representation", + message: &message.Message{ + RawRepresentation: "raw-message", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + original := &agent.Response{Messages: []*message.Message{tt.message}} + var collected agent.Response + for _, update := range original.ToUpdates() { + collected.Update(update) + } + + if len(collected.Messages) != 1 { + t.Fatalf("message count = %d, want 1", len(collected.Messages)) + } + if got := collected.Messages[0].AdditionalProperties["key"]; got != tt.message.AdditionalProperties["key"] { + t.Errorf("additional property = %v, want %v", got, tt.message.AdditionalProperties["key"]) + } + if got := collected.Messages[0].CreatedAt; !got.Equal(tt.message.CreatedAt) { + t.Errorf("created at = %v, want %v", got, tt.message.CreatedAt) + } + if got := collected.Messages[0].RawRepresentation; got != tt.message.RawRepresentation { + t.Errorf("raw representation = %v, want %v", got, tt.message.RawRepresentation) + } + }) + } +} + func TestResponse_String(t *testing.T) { msg := func(texts ...string) *message.Message { var contents message.Contents diff --git a/provider/a2aprovider/a2a.go b/provider/a2aprovider/a2a.go index 45e1fa69d..8d2429999 100644 --- a/provider/a2aprovider/a2a.go +++ b/provider/a2aprovider/a2a.go @@ -289,7 +289,11 @@ func sendMsg(session *agent.Session, contextID string, seq iter.Seq2[a2a.Event, yield(nil, err) return } - update := newResponseUpdate(e, e.Metadata, string(e.TaskID), messageID, message.RoleAssistant, contents) + role := message.Role("") + if messageID != "" || len(contents) > 0 { + role = message.RoleAssistant + } + update := newResponseUpdate(e, e.Metadata, string(e.TaskID), messageID, role, contents) update.FinishReason = finishReasonForTaskState(e.Status.State) if !forward(update, nil) { return @@ -384,7 +388,11 @@ func yieldTask(yield func(*agent.ResponseUpdate, error) bool, task *a2a.Task, sp if task.Status.Message != nil { messageID = task.Status.Message.ID } - update := newResponseUpdate(task, cloneMetadata(task.Metadata), string(task.ID), messageID, message.RoleAssistant, contents) + role := message.Role("") + if messageID != "" || len(contents) > 0 { + role = message.RoleAssistant + } + update := newResponseUpdate(task, cloneMetadata(task.Metadata), string(task.ID), messageID, role, contents) update.ContinuationToken = continuationToken update.FinishReason = finishReason return yield(update, nil) diff --git a/provider/a2aprovider/a2a_test.go b/provider/a2aprovider/a2a_test.go index 642730bce..229736ba9 100644 --- a/provider/a2aprovider/a2a_test.go +++ b/provider/a2aprovider/a2a_test.go @@ -2255,8 +2255,8 @@ func TestRunStreamingWithTaskStatusUpdateEvent(t *testing.T) { } update := updates[0] - if update.Role != message.RoleAssistant { - t.Errorf("update.Role = %q, want %q", update.Role, message.RoleAssistant) + if update.Role != "" { + t.Errorf("update.Role = %q, want empty for lifecycle-only status", update.Role) } if update.ResponseID != taskID { t.Errorf("update.ResponseID = %q, want %q", update.ResponseID, taskID) @@ -2852,12 +2852,9 @@ func TestRunWithTaskResponsePreservesMetadataAndRawScopes(t *testing.T) { if inputRequired { wantMessages++ } - // The existing iterator collector retains one empty lifecycle message. - if len(result.Messages) != max(1, wantMessages) { - t.Fatalf("messages = %d, want %d", len(result.Messages), max(1, wantMessages)) - } - if wantMessages == 0 && result.Messages[0].RawRepresentation != task { - t.Errorf("empty message raw = %T, want *a2a.Task", result.Messages[0].RawRepresentation) + // Lifecycle metadata remains on the response without creating a message. + if len(result.Messages) != wantMessages { + t.Fatalf("messages = %d, want %d", len(result.Messages), wantMessages) } for i, artifact := range task.Artifacts { msg := result.Messages[i] @@ -3116,3 +3113,23 @@ func TestRunStreamingWithInputRequiredStatusContents(t *testing.T) { t.Errorf("text = %q, want Where would you like to fly?", texts[0].Text) } } + +func TestAgentRunStreamingLifecycleOnlyTaskDoesNotCreateMessage(t *testing.T) { + transport := &mockA2ATransport{streamingResponseToReturn: &a2a.Task{ + ID: "task-raw", + ContextID: "ctx-raw", + Status: a2a.TaskStatus{State: a2a.TaskStateSubmitted}, + }} + a := newTestAgent(transport, agent.Config{}) + + resp, err := a.RunText(t.Context(), "start", agent.Stream(true)).Collect() + if err != nil { + t.Fatal(err) + } + if len(resp.Messages) != 0 { + t.Fatalf("len(Messages) = %d, want 0", len(resp.Messages)) + } + if resp.RawRepresentation == nil { + t.Fatal("RawRepresentation is nil") + } +} diff --git a/provider/aguiprovider/agui.go b/provider/aguiprovider/agui.go index dada97917..daff9a5d0 100644 --- a/provider/aguiprovider/agui.go +++ b/provider/aguiprovider/agui.go @@ -433,7 +433,6 @@ func (a *toolCallAccumulator) onEvent(evt aguiEvents.Event) ([]*agent.ResponseUp switch e := evt.(type) { case *aguiEvents.RunStartedEvent: return []*agent.ResponseUpdate{{ - Role: message.RoleAssistant, ResponseID: e.RunID(), CreatedAt: eventTime(evt), AdditionalProperties: map[string]any{ @@ -454,7 +453,6 @@ func (a *toolCallAccumulator) onEvent(evt aguiEvents.Event) ([]*agent.ResponseUp props["result"] = e.Result } return []*agent.ResponseUpdate{{ - Role: message.RoleAssistant, ResponseID: e.RunID(), CreatedAt: eventTime(evt), FinishReason: "stop", diff --git a/provider/aguiprovider/agui_test.go b/provider/aguiprovider/agui_test.go index 4c91410b4..8166d687e 100644 --- a/provider/aguiprovider/agui_test.go +++ b/provider/aguiprovider/agui_test.go @@ -220,7 +220,7 @@ func TestAGUIAgentRun_ConfigInstructionsBecomeSystemMessage(t *testing.T) { } } -func TestAGUIAgentRun_WithEmptyEventStream_EmitsMetadataUpdate(t *testing.T) { +func TestAGUIAgentRun_WithEmptyEventStream_DoesNotCreateMessage(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") writeSSE(t, w, aguiEvents.NewRunStartedEvent("thread-1", "run-1")) @@ -233,13 +233,8 @@ func TestAGUIAgentRun_WithEmptyEventStream_EmitsMetadataUpdate(t *testing.T) { if err != nil { t.Fatalf("run error: %v", err) } - if len(resp.Messages) == 0 { - t.Fatal("expected at least one metadata message") - } - for _, msg := range resp.Messages { - if msg.Role != message.RoleAssistant { - t.Fatalf("message role = %q, want %q", msg.Role, message.RoleAssistant) - } + if len(resp.Messages) != 0 { + t.Fatalf("messages length = %d, want 0", len(resp.Messages)) } } diff --git a/provider/openaiprovider/responses.go b/provider/openaiprovider/responses.go index 7fd293ce6..8126e8223 100644 --- a/provider/openaiprovider/responses.go +++ b/provider/openaiprovider/responses.go @@ -1133,7 +1133,6 @@ func responsesProcessResponse(resp *responses.Response, seqNum int64, yield func ResponseID: resp.ID, FinishReason: finishReason, CreatedAt: time.Unix(int64(resp.CreatedAt), 0), - Role: message.RoleAssistant, } // Only set ContinuationToken if it's not empty if contToken != "" { @@ -1470,7 +1469,7 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, var u *agent.ResponseUpdate switch event := update.AsAny().(type) { case responses.ResponseCreatedEvent: - u = createUpdate(message.RoleAssistant, nil) + u = createUpdate("", nil) u.MessageID = "" u.CreatedAt = time.Unix(int64(event.Response.CreatedAt), 0) u.ResponseID = event.Response.ID @@ -1480,7 +1479,7 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, } case responses.ResponseQueuedEvent: - u = createUpdate(message.RoleAssistant, nil) + u = createUpdate("", nil) u.MessageID = "" u.CreatedAt = time.Unix(int64(event.Response.CreatedAt), 0) u.ResponseID = event.Response.ID @@ -1490,7 +1489,7 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, } case responses.ResponseInProgressEvent: - u = createUpdate(message.RoleAssistant, nil) + u = createUpdate("", nil) u.MessageID = "" u.CreatedAt = time.Unix(int64(event.Response.CreatedAt), 0) u.ResponseID = event.Response.ID @@ -1511,13 +1510,13 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, state.anyFunctions = true state.role = message.RoleAssistant } - u = createUpdate(message.RoleAssistant, nil) + u = createUpdate("", nil) if contToken := createContinuationToken(responseID, event.SequenceNumber, responses.ResponseStatusInProgress, isBackground); contToken != "" { u.ContinuationToken = contToken } case responses.ResponseCompletedEvent: - u = createUpdate(message.RoleAssistant, nil) + u = createUpdate("", nil) u.MessageID = "" u.CreatedAt = time.Unix(int64(event.Response.CreatedAt), 0) u.ResponseID = event.Response.ID @@ -1532,7 +1531,7 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, } case responses.ResponseIncompleteEvent: - u = createUpdate(message.RoleAssistant, nil) + u = createUpdate("", nil) u.MessageID = "" u.CreatedAt = time.Unix(int64(event.Response.CreatedAt), 0) u.ResponseID = event.Response.ID @@ -1543,7 +1542,7 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, } case responses.ResponseFailedEvent: - u = createUpdate(message.RoleAssistant, nil) + u = createUpdate("", nil) u.MessageID = "" u.CreatedAt = time.Unix(int64(event.Response.CreatedAt), 0) u.ResponseID = event.Response.ID @@ -1657,7 +1656,7 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, case responses.ResponseOutputItemDoneEvent: // Create update for all output item done events - u = createUpdate(message.RoleAssistant, nil) + u = createUpdate("", nil) if contToken := createContinuationToken(responseID, event.SequenceNumber, responses.ResponseStatusInProgress, isBackground); contToken != "" { u.ContinuationToken = contToken } @@ -1765,7 +1764,7 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, u.ContinuationToken = contToken } default: - u = createUpdate(message.RoleAssistant, nil) + u = createUpdate("", nil) if contToken := createContinuationToken(responseID, update.SequenceNumber, responses.ResponseStatusInProgress, isBackground); contToken != "" { u.ContinuationToken = contToken } diff --git a/provider/openaiprovider/responses_test.go b/provider/openaiprovider/responses_test.go index 5bef8e3ba..cc0ede249 100644 --- a/provider/openaiprovider/responses_test.go +++ b/provider/openaiprovider/responses_test.go @@ -7392,8 +7392,8 @@ func TestResponsesBackgroundResponses_FirstCall(t *testing.T) { t.Fatalf("error = %v", err) } - if len(resp.Messages) != 1 { - t.Errorf("expected 1 message (for continuation token), got %d", len(resp.Messages)) + if len(resp.Messages) != 0 { + t.Errorf("expected no messages for continuation-only response, got %d", len(resp.Messages)) } if resp.ContinuationToken == "" { @@ -7479,8 +7479,8 @@ func testResponsesBackgroundPolling(t *testing.T, status string) { t.Error("expected ContinuationToken to be set for queued/in_progress status") } - if len(resp.Messages) != 1 { - t.Errorf("expected 1 message for %s status, got %d", status, len(resp.Messages)) + if len(resp.Messages) != 0 { + t.Errorf("expected no messages for %s status, got %d", status, len(resp.Messages)) } case "completed": @@ -8457,3 +8457,42 @@ func TestResponsesFunctionCallUsesToolCallsFinishReason_NonStreaming(t *testing. t.Fatalf("FinishReason = %q, want tool_calls", resp.FinishReason) } } + +func TestResponsesBackgroundResponses_StreamingLifecycleOnlyDoesNotCreateMessage(t *testing.T) { + const input = `{"model":"gpt-4o-2024-08-06","background":true,"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}],"stream":true}` + const output = `event: response.created + +data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_lifecycle","object":"response","created_at":1758724519,"status":"queued","background":true,"model":"gpt-4o-2024-08-06","output":[]}} + +event: response.queued + +data: {"type":"response.queued","sequence_number":1,"response":{"id":"resp_lifecycle","object":"response","created_at":1758724519,"status":"queued","background":true,"model":"gpt-4o-2024-08-06","output":[]}} + +event: response.in_progress + +data: {"type":"response.in_progress","sequence_number":2,"response":{"id":"resp_lifecycle","object":"response","created_at":1758724519,"status":"in_progress","background":true,"model":"gpt-4o-2024-08-06","output":[]}} + +` + server := newTestResponsesServerStreaming(t, input, output) + defer server.Close() + a := newTestResponsesClient(server, "gpt-4o-2024-08-06") + session, err := a.CreateSession(t.Context()) + if err != nil { + t.Fatal(err) + } + + resp, err := a.RunText( + t.Context(), "hello", agent.Stream(true), + agent.AllowBackgroundResponses(true), + agent.WithSession(session), + ).Collect() + if err != nil { + t.Fatal(err) + } + if len(resp.Messages) != 0 { + t.Fatalf("len(Messages) = %d, want 0", len(resp.Messages)) + } + if resp.RawRepresentation == nil { + t.Fatal("RawRepresentation is nil") + } +} From 5c92893db0299c21dbf7d87d83cdebeefe588db5 Mon Sep 17 00:00:00 2001 From: Kirill <17173528+0xff23@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:36:22 -0700 Subject: [PATCH 2/2] fix(openaiprovider): clear inherited lifecycle roles --- provider/openaiprovider/responses.go | 8 +++- provider/openaiprovider/responses_test.go | 54 +++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/provider/openaiprovider/responses.go b/provider/openaiprovider/responses.go index 8126e8223..4504bfa5f 100644 --- a/provider/openaiprovider/responses.go +++ b/provider/openaiprovider/responses.go @@ -1465,11 +1465,12 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, } // Lifecycle metadata belongs to the response, not the active message. - // Omit its MessageID without resetting the state used by content updates. + // Omit its Role and MessageID without resetting the state used by content updates. var u *agent.ResponseUpdate switch event := update.AsAny().(type) { case responses.ResponseCreatedEvent: u = createUpdate("", nil) + u.Role = "" u.MessageID = "" u.CreatedAt = time.Unix(int64(event.Response.CreatedAt), 0) u.ResponseID = event.Response.ID @@ -1480,6 +1481,7 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, case responses.ResponseQueuedEvent: u = createUpdate("", nil) + u.Role = "" u.MessageID = "" u.CreatedAt = time.Unix(int64(event.Response.CreatedAt), 0) u.ResponseID = event.Response.ID @@ -1490,6 +1492,7 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, case responses.ResponseInProgressEvent: u = createUpdate("", nil) + u.Role = "" u.MessageID = "" u.CreatedAt = time.Unix(int64(event.Response.CreatedAt), 0) u.ResponseID = event.Response.ID @@ -1517,6 +1520,7 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, case responses.ResponseCompletedEvent: u = createUpdate("", nil) + u.Role = "" u.MessageID = "" u.CreatedAt = time.Unix(int64(event.Response.CreatedAt), 0) u.ResponseID = event.Response.ID @@ -1532,6 +1536,7 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, case responses.ResponseIncompleteEvent: u = createUpdate("", nil) + u.Role = "" u.MessageID = "" u.CreatedAt = time.Unix(int64(event.Response.CreatedAt), 0) u.ResponseID = event.Response.ID @@ -1543,6 +1548,7 @@ func responsesProcessStreamingUpdate(update responses.ResponseStreamEventUnion, case responses.ResponseFailedEvent: u = createUpdate("", nil) + u.Role = "" u.MessageID = "" u.CreatedAt = time.Unix(int64(event.Response.CreatedAt), 0) u.ResponseID = event.Response.ID diff --git a/provider/openaiprovider/responses_test.go b/provider/openaiprovider/responses_test.go index cc0ede249..61f9b613b 100644 --- a/provider/openaiprovider/responses_test.go +++ b/provider/openaiprovider/responses_test.go @@ -8458,6 +8458,60 @@ func TestResponsesFunctionCallUsesToolCallsFinishReason_NonStreaming(t *testing. } } +func TestResponsesStreamingLifecycleAfterMessageDoesNotModifyMessage(t *testing.T) { + for _, status := range []string{"incomplete", "completed"} { + t.Run(status, func(t *testing.T) { + const input = `{"model":"gpt-4o-mini","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}],"stream":true}` + output := `event: response.output_item.added +data: {"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"type":"message","id":"msg_real","role":"assistant","content":[]}} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":1,"item_id":"msg_real","output_index":0,"content_index":0,"delta":"Hello"} + +event: response.` + status + ` +data: {"type":"response.` + status + `","sequence_number":2,"response":{"id":"resp_real","object":"response","created_at":1758724519,"status":"` + status + `","model":"gpt-4o-mini","output":[]}} + +` + server := newTestResponsesServerStreaming(t, input, output) + defer server.Close() + a := newTestResponsesClient(server, "gpt-4o-mini") + resp := &agent.Response{} + var count int + for update, err := range a.RunText(t.Context(), "hello", agent.Stream(true)) { + if err != nil { + t.Fatal(err) + } + count++ + if count == 3 { + if update.Role != "" || update.AuthorName != "" || update.MessageID != "" { + t.Errorf("lifecycle update has message identity: role=%q author=%q id=%q", update.Role, update.AuthorName, update.MessageID) + } + } + resp.Update(update) + } + if count != 3 || len(resp.Messages) != 1 { + t.Fatalf("updates=%d messages=%d, want 3 and 1", count, len(resp.Messages)) + } + msg := resp.Messages[0] + if msg.ID != "msg_real" || msg.Role != message.RoleAssistant || msg.String() != "Hello" { + t.Errorf("message changed: %#v", msg) + } + if !msg.CreatedAt.IsZero() { + t.Errorf("lifecycle timestamp leaked into message: %v", msg.CreatedAt) + } + if raw, ok := msg.RawRepresentation.([]any); !ok || len(raw) != 2 { + t.Errorf("message raw = %#v, want only item-added and text-delta events", msg.RawRepresentation) + } + if raw, ok := resp.RawRepresentation.([]any); !ok || len(raw) != 3 { + t.Errorf("response raw = %#v, want all three events", resp.RawRepresentation) + } + if resp.ID != "resp_real" || resp.CreatedAt.IsZero() { + t.Errorf("response metadata missing: %#v", resp) + } + }) + } +} + func TestResponsesBackgroundResponses_StreamingLifecycleOnlyDoesNotCreateMessage(t *testing.T) { const input = `{"model":"gpt-4o-2024-08-06","background":true,"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}],"stream":true}` const output = `event: response.created