Skip to content

Replay citation annotations on the Responses input path - #1101

Open
PratikDhanave (PratikDhanave) wants to merge 1 commit into
microsoft:mainfrom
PratikDhanaveFork:fix/openai-responses-replay-annotations
Open

PratikDhanave (PratikDhanave) wants to merge 1 commit into
microsoft:mainfrom
PratikDhanaveFork:fix/openai-responses-replay-annotations

Conversation

@PratikDhanave

Copy link
Copy Markdown
Contributor

When a Responses assistant turn is rebuilt from client-side history, responsesBuildMessageParam always emitted an empty output_text annotations array — a standing // TODO: Convert message annotations back to Responses output-text annotations.

populateAnnotations maps inbound Responses citations (url_citation, file_citation, container_file_citation, file_path) onto message.CitationAnnotation, but the reverse was never implemented. So a prior assistant turn carrying url/file/container citations round-tripped through history forwarding with those citations stripped. The Python client recovers them via _annotations_to_output_text for exactly this reason ("so assistant messages roundtrip cleanly through history forwarding").

Change

  • Add annotationsToOutputText (the inverse of populateAnnotations) and use it at the replay site instead of the empty slice. The output-text variant is inferred from the populated citation fields, mirroring how populateAnnotations maps each one.
  • Add citationSpans to extract [start, end) pairs from annotated regions; regions missing either bound are skipped, and a citation with multiple regions fans out into one annotation per region (each Responses annotation carries a single span).

No new public API — this uses the existing message.CitationAnnotation type.

Test

  • TestResponsesAssistantReplayRoundTripsCitationAnnotations: replays an assistant TextContent carrying a URL citation (with a span) and a file citation, and asserts the captured request input[].content[].annotations reconstructs both. Fails before the change (empty annotations), passes after.

Building a Responses assistant turn from client-side history always sent an
empty output_text annotations array, dropping any citations the text arrived
with (a standing TODO). populateAnnotations maps inbound Responses citations
onto CitationAnnotation, but the reverse was never implemented, so a prior
assistant turn round-tripped through history forwarding with its url/file/
container citations stripped.

Add annotationsToOutputText, the inverse of populateAnnotations, inferring
each output-text annotation variant from the populated citation fields (as
the Python client does). Regions without both span bounds are skipped, and a
citation with multiple regions fans out to one annotation per region.
Copilot AI lite review requested due to automatic review settings September 18, 2026 12:22
@github-actions github-actions Bot added area:provider Changes files in the provider area area:provider/openai Changes files in the provider / openai area size:large At most 300 changed lines across at most 10 files labels Sep 18, 2026

Copilot AI left a comment

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.

🔵 Needs a closer look

Citation preservation issues remain, and several conversion paths and span edge cases are untested.

Pull request overview

Adds replay of Responses citation annotations when rebuilding assistant history.

Changes:

  • Converts framework citations back to Responses annotations.
  • Handles citation spans and multiple regions.
  • Adds URL and file citation replay tests.
File summaries
File Summary
provider/openaiprovider/responses.go Implements citation conversion and span handling; unresolved issues remain around citation variants, file indexes, and edge-case coverage.
provider/openaiprovider/responses_test.go Tests URL and file replay, but lacks coverage for container, file-path, multi-region, and incomplete-span cases.
Review details

Suppressed comments (4)

provider/openaiprovider/responses.go:1922

  • The added test exercises only the URL and filename-bearing file-citation branches. The new container/file-path conversions and the citationSpans edge cases (multiple regions and a missing bound) are untested, so regressions in three of the four variants or the span fan-out behavior would pass CI; add focused cases for those branches.
		case containerID != "" && cit.FileID != "":
			for _, span := range citationSpans(cit.AnnotatedRegions) {
				out = append(out, responses.ResponseOutputTextAnnotationUnionParam{
					OfContainerFileCitation: &responses.ResponseOutputTextAnnotationContainerFileCitationParam{
						ContainerID: containerID,

provider/openaiprovider/responses.go:1946

  • This replay drops the index carried by Responses file_citation annotations. The Responses payloads in this package include that field (for example, the non-streaming fixture), and populateAnnotations currently retains the original variant in RawRepresentation; reconstructing every file citation without its original index changes non-zero citations (and can produce an invalid input annotation). Preserve the index through the framework representation or recover it from the raw annotation before building this param.
				OfFileCitation: &responses.ResponseOutputTextAnnotationFileCitationParam{
					FileID:   cit.FileID,
					Filename: cit.Title,
				},

provider/openaiprovider/responses.go:1952

  • This heuristic changes a valid file_citation with an empty filename into a file_path annotation: populateAnnotations already accepts that shape (the existing streaming fixture at responses_test.go:4454 has one), and RawRepresentation is excluded from serialized client history, so the original variant cannot be recovered here. Preserve an explicit annotation variant through history (or otherwise retain the distinction) before choosing between file_citation and file_path; otherwise replay changes the citation type.
		case cit.FileID != "":
			out = append(out, responses.ResponseOutputTextAnnotationUnionParam{
				OfFilePath: &responses.ResponseOutputTextAnnotationFilePathParam{
					FileID: cit.FileID,
				},

provider/openaiprovider/responses_test.go:1053

  • This test covers only a URL citation with one complete span and a file citation. The new container/file-path branches and citationSpans fan-out/partial-bound behavior remain untested, so regressions in the other variants can pass unnoticed; add cases for those paths (including multiple and incomplete regions).
					&message.CitationAnnotation{
						Title:            "Example",
						URL:              "https://example.com",
						AnnotatedRegions: message.AnnotatedRegions{&message.TextSpanAnnotatedRegion{StartIndex: &start, EndIndex: &end}},
					},
  • Files reviewed: 2/2 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@github-actions github-actions Bot added kind:code Changes production behavior or code kind:tests Changes tests, fixtures, or test infrastructure labels Sep 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Scope: user-visible behavior (internal helpers, but change is observable in the wire request Go sends to the OpenAI Responses API)

Changed Go contract: no new exported API. responsesBuildMessageParam (unexported) now serializes non-empty output_text annotations via new unexported helpers annotationsToOutputText and citationSpans, replacing a previously hard-coded empty annotations array when replaying a prior assistant turn from client-side history.

Upstream evidence reviewed:

  • Python: python/packages/openai/agent_framework_openai/_chat_client.py:460-529 (_annotations_to_output_text, the direct analogue this PR ports), and the forward-direction annotation parsing at :3030-3100 (non-streaming) and :3740-3815 (streaming) that populates additional_properties["index"]/["container_id"] for later round-tripping.
  • Go: provider/openaiprovider/responses.gopopulateAnnotations (forward direction, pre-existing) and the new annotationsToOutputText/citationSpans (this PR).
  • OpenAI Go SDK (github.com/openai/openai-go/v3@v3.61.0, responses/response.go): ResponseOutputTextAnnotationFileCitationParam (:21196) and ResponseOutputTextAnnotationFilePathParam (:21279) both mark Index as api:"required".

Result: findings reported. The Go port correctly mirrors Python's overall structure (container_file_citation / url_citation / file_citation / file_path branching, one annotation per annotated region) and this closes a real gap versus a standing TODO. However, unlike Python's _annotations_to_output_text, the Go file_citation and file_path branches never populate the SDK-required Index field (it's left at its zero value), because message.CitationAnnotation has no field to carry the "index" value Python threads through additional_properties["index"]. This is a pre-existing gap in populateAnnotations (which also drops the index on ingestion) but the semantic impact only becomes user-visible now that annotations are replayed onto the outgoing request. See inline comment for details and a suggested fix (add an Index carrier, consistent with how ContainerId is already threaded through AdditionalProperties).

Generated by Go API Consistency Review Agent · copilot · auto · 88.6 AIC · ⌖ 6.75 AIC · ⊞ 9.2K ·

@github-actions github-actions Bot left a comment

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.

Generated by Go API Consistency Review Agent · copilot · auto · 88.6 AIC · ⌖ 6.75 AIC · ⊞ 9.2K

// 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:provider/openai Changes files in the provider / openai area area:provider Changes files in the provider area kind:code Changes production behavior or code kind:tests Changes tests, fixtures, or test infrastructure size:large At most 300 changed lines across at most 10 files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants