Skip to content
Open
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 agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
43 changes: 43 additions & 0 deletions agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down
63 changes: 44 additions & 19 deletions agent/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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
Comment on lines +193 to +195
}
updates = append(updates, extra)
}

Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
142 changes: 139 additions & 3 deletions agent/response_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions provider/a2aprovider/a2a.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading