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
75 changes: 73 additions & 2 deletions provider/openaiprovider/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -618,8 +618,7 @@ func responsesBuildMessageParam(msg *message.Message, resp responses.ResponseInp
case *message.TextContent:
outputContents = append(outputContents, responses.ResponseOutputMessageContentUnionParam{
OfOutputText: &responses.ResponseOutputTextParam{
// TODO: Convert message annotations back to Responses output-text annotations.
Annotations: []responses.ResponseOutputTextAnnotationUnionParam{},
Annotations: annotationsToOutputText(c.Annotations),
Text: c.Text,
},
})
Expand Down Expand Up @@ -1901,6 +1900,78 @@ func populateAnnotations(anns []responses.ResponseOutputTextAnnotationUnion, con
}
}

// annotationsToOutputText is the inverse of populateAnnotations: it converts
// framework citation annotations back into Responses output-text annotation
// params so that an assistant turn replayed from client-side history carries
// the citations it originally arrived with. Which variant to emit is inferred
// from the populated fields, mirroring how populateAnnotations maps each one.
// Annotations that are not citations are skipped.
func annotationsToOutputText(anns []message.Annotation) []responses.ResponseOutputTextAnnotationUnionParam {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comparing this to the Python parity implementation _annotations_to_output_text (python/packages/openai/agent_framework_openai/_chat_client.py:460-529), the file_citation and file_path Responses branches there restore the original index value from additional_properties["index"] (populated on ingestion at _chat_client.py:3047-3062, and again in the streaming path at _chat_client.py:3744-3770, both storing "index": annotation.index).

ResponseOutputTextAnnotationFileCitationParam.Index and ResponseOutputTextAnnotationFilePathParam.Index are api:"required" fields on the OpenAI Go SDK (github.com/openai/openai-go/v3@v3.61.0 responses/response.go:21196-21208 and :21279-21289). annotationsToOutputText's cit.FileID != "" && cit.Title != "" and cit.FileID != "" branches never set Index, so it always serializes as 0.

This is tightly coupled to this PR: before this change annotations were always empty, so no incorrect index was ever sent; now that file_citation/file_path annotations are replayed, any assistant turn with more than one file citation (or a non-zero original index) will silently emit the wrong index on replay, diverging from the round-trip fidelity Python achieves via additional_properties["index"].

Root cause is that Go's message.CitationAnnotation (message/annotation.go:80-90) has no field to carry this "index" value from populateAnnotations (which also drops it today), so it cannot be reconstructed here. Suggest adding an index carrier (e.g. via AdditionalProperties["Index"], consistent with how ContainerId is already threaded through AdditionalProperties) in both populateAnnotations and annotationsToOutputText so this new replay path doesn't regress round-trip fidelity for multi-citation messages.

out := make([]responses.ResponseOutputTextAnnotationUnionParam, 0, len(anns))
for _, ann := range anns {
cit, ok := ann.(*message.CitationAnnotation)
if !ok {
continue
}
containerID, _ := cit.AdditionalProperties["ContainerId"].(string)
switch {
case containerID != "" && cit.FileID != "":
for _, span := range citationSpans(cit.AnnotatedRegions) {
out = append(out, responses.ResponseOutputTextAnnotationUnionParam{
OfContainerFileCitation: &responses.ResponseOutputTextAnnotationContainerFileCitationParam{
ContainerID: containerID,
FileID: cit.FileID,
Filename: cit.Title,
StartIndex: span[0],
EndIndex: span[1],
},
})
}
case cit.URL != "":
for _, span := range citationSpans(cit.AnnotatedRegions) {
out = append(out, responses.ResponseOutputTextAnnotationUnionParam{
OfURLCitation: &responses.ResponseOutputTextAnnotationURLCitationParam{
URL: cit.URL,
Title: cit.Title,
StartIndex: span[0],
EndIndex: span[1],
},
})
}
case cit.FileID != "" && cit.Title != "":
out = append(out, responses.ResponseOutputTextAnnotationUnionParam{
OfFileCitation: &responses.ResponseOutputTextAnnotationFileCitationParam{
FileID: cit.FileID,
Filename: cit.Title,
},
})
case cit.FileID != "":
out = append(out, responses.ResponseOutputTextAnnotationUnionParam{
OfFilePath: &responses.ResponseOutputTextAnnotationFilePathParam{
FileID: cit.FileID,
},
})
}
}
return out
}

// citationSpans extracts the [start, end) index pairs from a citation's
// annotated regions, skipping regions without both bounds set. Each Responses
// annotation carries a single span, so a citation with multiple regions is
// fanned out into one annotation per region.
func citationSpans(regions message.AnnotatedRegions) [][2]int64 {
var spans [][2]int64
for _, region := range regions {
span, ok := region.(*message.TextSpanAnnotatedRegion)
if !ok || span.StartIndex == nil || span.EndIndex == nil {
continue
}
spans = append(spans, [2]int64{int64(*span.StartIndex), int64(*span.EndIndex)})
}
return spans
}

func textSpanAnnotatedRegion(start, end int64) *message.TextSpanAnnotatedRegion {
startIndex, endIndex := int(start), int(end)
return &message.TextSpanAnnotatedRegion{StartIndex: &startIndex, EndIndex: &endIndex}
Expand Down
66 changes: 66 additions & 0 deletions provider/openaiprovider/responses_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,72 @@ func TestResponsesAssistantReplayPreservesHostedToolItems(t *testing.T) {
}
}

// A replayed assistant turn from client-side history must carry back the
// citations its text originally arrived with, so the Responses API sees the
// same annotations on round-trip. This is the inverse of populateAnnotations.
func TestResponsesAssistantReplayRoundTripsCitationAnnotations(t *testing.T) {
start, end := 0, 5
contents := message.Contents{
&message.TextContent{
Text: "hello world",
ContentHeader: message.ContentHeader{
Annotations: []message.Annotation{
&message.CitationAnnotation{
Title: "Example",
URL: "https://example.com",
AnnotatedRegions: message.AnnotatedRegions{&message.TextSpanAnnotatedRegion{StartIndex: &start, EndIndex: &end}},
},
&message.CitationAnnotation{
FileID: "file_123",
Title: "doc.pdf",
},
},
},
},
}

var captured map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
t.Fatal(err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"id":"resp","object":"response","created_at":1,"status":"completed","model":"gpt-4o-mini","output":[]}`)
}))
defer server.Close()

a := newTestResponsesClient(server, "gpt-4o-mini")
if _, err := a.Run(t.Context(), []*message.Message{{Role: message.RoleAssistant, Contents: contents}}).Collect(); err != nil {
t.Fatal(err)
}

input, ok := captured["input"].([]any)
if !ok || len(input) != 1 {
t.Fatalf("input = %#v", captured["input"])
}
item, _ := input[0].(map[string]any)
textContents, _ := item["content"].([]any)
if len(textContents) != 1 {
t.Fatalf("assistant content = %#v, want one output_text", item["content"])
}
outputText, _ := textContents[0].(map[string]any)
anns, _ := outputText["annotations"].([]any)
if len(anns) != 2 {
t.Fatalf("annotations = %#v, want url_citation and file_citation", outputText["annotations"])
}
url, _ := anns[0].(map[string]any)
if url["type"] != "url_citation" || url["url"] != "https://example.com" || url["title"] != "Example" {
t.Errorf("url citation = %#v", url)
}
if url["start_index"] != float64(0) || url["end_index"] != float64(5) {
t.Errorf("url citation span = [%v, %v), want [0, 5)", url["start_index"], url["end_index"])
}
file, _ := anns[1].(map[string]any)
if file["type"] != "file_citation" || file["file_id"] != "file_123" || file["filename"] != "doc.pdf" {
t.Errorf("file citation = %#v", file)
}
}

func TestResponsesAssistantReplayReconstructsPersistedMCPContents(t *testing.T) {
original := message.Contents{
&message.ToolApprovalRequestContent{
Expand Down
Loading