Skip to content

OpenAI Responses reasoning items (incl. encrypted content) are dropped when tool-call turns are replayed under store:false #812

Description

@Fanzzzd

OpenCodeReview version

Bug description

OpenAIResponsesClient forces store: false (stateless, by design), which makes the client fully responsible for resending conversation state. But the next request's input is rebuilt from the normalized projection only — assistant text plus function calls. Typed reasoning output items, including encrypted_content, item IDs, and original output ordering, are dropped on every tool turn.

OpenAI's guidance for manually managed history is to preserve and resend the response's output items; for store: false / Zero Data Retention flows, encrypted reasoning items must be replayed. Rebuilding only a message and function calls loses reasoning continuity — and via the same Content() fallback described in #805, the reasoning summary additionally leaks into visible assistant text.

Source trace

All references are main @ 71d2981:

  1. buildResponsesParams rebuilds assistant history as an EasyInputMessage plus function-call items; no code path emits a reasoning input item.
  2. Store: false means manual replay is the only continuation mechanism available.
  3. mapResponsesResponse keeps only the reasoning summary text; encrypted_content and item identity are dropped at parse time.

Deterministic reproduction

No live endpoint or API key needed — this exercises the adapter's own response→history→request chain:

func TestResponsesToolCallReplayPreservesReasoningItems(t *testing.T) {
	raw := `{
		"id": "resp_01",
		"object": "response",
		"status": "completed",
		"model": "gpt-5.2",
		"output": [
			{"id": "rs_01", "type": "reasoning", "summary": [{"type": "summary_text", "text": "private plan"}], "encrypted_content": "gAAAA-encrypted-reasoning-state"},
			{"id": "fc_01", "type": "function_call", "call_id": "call_01", "name": "file_read", "arguments": "{\"path\":\"main.go\"}", "status": "completed"}
		]
	}`
	var sdkResp responses.Response
	if err := json.Unmarshal([]byte(raw), &sdkResp); err != nil {
		t.Fatal(err)
	}

	client := NewOpenAIResponsesClient(ClientConfig{URL: "https://api.openai.com/v1/responses"})
	resp := client.mapResponsesResponse(&sdkResp)

	// Same response-to-history conversion used by llmloop.Runner.
	history := NewToolCallMessage(resp.Content(), resp.ToolCalls())

	params := client.buildResponsesParams("gpt-5.2", ChatRequest{
		Messages: []Message{
			{Role: "user", Content: "review this file"},
			history,
			NewToolResultMessage("call_01", "package main"),
		},
	})
	payload, err := json.Marshal(params.Input)
	if err != nil {
		t.Fatal(err)
	}
	if !bytes.Contains(payload, []byte(`"type":"reasoning"`)) || !bytes.Contains(payload, []byte("gAAAA-encrypted-reasoning-state")) {
		t.Fatalf("Responses replay input dropped the typed reasoning item: %s", payload)
	}
}

Run:

go test ./internal/llm -run '^TestResponsesToolCallReplayPreservesReasoningItems$' -count=1

Actual result on main @ 71d2981:

--- FAIL: TestResponsesToolCallReplayPreservesReasoningItems (0.00s)
    Responses replay input dropped the typed reasoning item: [{"content":"review this file","role":"user"},{"content":"private plan","role":"assistant"},{"arguments":"{\"path\":\"main.go\"}","call_id":"call_01","name":"file_read","type":"function_call"},{"call_id":"call_01","output":"package main","type":"function_call_output"}]

The follow-up input contains no reasoning item at all; the reasoning summary ("private plan") came back as ordinary visible assistant text, and the encrypted state and item IDs are gone.

Expected behavior

The follow-up request's input should include the original typed output items — the reasoning item with its encrypted_content and ID, and the function_call items with their call_ids, in original order — followed by the matched function_call_output items.

Official guidance: https://developers.openai.com/api/docs/guides/latest-model#using-gpt-56

Proposed direction

An adapter-owned openAIResponsesReplay envelope behind the same private replayEnvelope seam discussed in #805/#806, not a reuse of the Chat Completions envelope:

  • retain the typed response output items required as subsequent input, including reasoning and function-call items in original order with their IDs;
  • request and preserve reasoning.encrypted_content whenever the active storage policy requires it;
  • use the official SDK's typed input/output conversions where they preserve the wire contract, with narrowly scoped raw preservation only for unsupported provider fields;
  • stay opaque to the review loop, normalized ChatResponse, persistence, and logs;
  • participate in cloning, token accounting, and compression through the existing seam.

previous_response_id could later be supported as an explicit, mutually exclusive server-stored continuation mode; it is not a substitute under the current store: false policy.

Key regression coverage for any fix: a strict two-request fake Responses endpoint verifying the original typed output items and matched function_call_output items with exact IDs and ordering; parallel function calls; the store: false encrypted-reasoning round trip; log/persistence tests proving encrypted state is never exposed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions