diff --git a/sdk/api/handlers/openai/openai_responses_websocket.go b/sdk/api/handlers/openai/openai_responses_websocket.go index 6457cd3ee..273547d83 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket.go +++ b/sdk/api/handlers/openai/openai_responses_websocket.go @@ -703,29 +703,20 @@ func mergeJSONArrayRaw(existingRaw, appendRaw string) (string, error) { return string(out), nil } -// inputContainsFullTranscript returns true when the input array looks like a -// complete conversation history rather than an incremental append. After a -// client-side compact the input already carries the full (compacted) transcript -// which may include assistant messages or compaction items. Merging that with -// the stale lastRequest / lastResponseOutput would duplicate or break -// function_call / function_call_output pairings, so the caller should use the -// input as-is. +// inputContainsFullTranscript returns true when the input array carries compact +// replay markers that indicate the client already sent the full conversation +// transcript. Merging that input with stale lastRequest/lastResponseOutput +// would duplicate or break function_call/function_call_output pairings, so the +// caller should use the input as-is. // -// Heuristic: the array is a full transcript when it contains either -// - a message with role="assistant", or -// - a compaction item (type="compaction" or "compaction_summary"). -// -// Normal incremental turns only contain user messages or function_call_output -// items and never carry either of these signals. +// Assistant messages alone are not enough to classify the payload as a replay: +// incremental websocket requests may legitimately append assistant items. func inputContainsFullTranscript(input gjson.Result) bool { if !input.IsArray() { return false } for _, item := range input.Array() { t := item.Get("type").String() - if t == "message" && item.Get("role").String() == "assistant" { - return true - } if t == "compaction" || t == "compaction_summary" { return true } diff --git a/sdk/api/handlers/openai/openai_responses_websocket_test.go b/sdk/api/handlers/openai/openai_responses_websocket_test.go index 2c5ef579b..82b96f141 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket_test.go +++ b/sdk/api/handlers/openai/openai_responses_websocket_test.go @@ -1401,13 +1401,13 @@ func TestResponsesWebsocketCompactionResetsTurnStateOnTranscriptReplacement(t *t } } -func TestInputContainsFullTranscriptDetectsAssistantMessage(t *testing.T) { +func TestInputContainsFullTranscriptFalseForAssistantMessageOnly(t *testing.T) { input := gjson.Parse(`[ {"type":"message","role":"user","content":"hello"}, {"type":"message","role":"assistant","content":"hi there"} ]`) - if !inputContainsFullTranscript(input) { - t.Fatal("expected full transcript when assistant message is present") + if inputContainsFullTranscript(input) { + t.Fatal("assistant message alone must not be treated as full transcript") } } @@ -1501,3 +1501,33 @@ func TestNormalizeSubsequentRequestIncrementalInputStillMerges(t *testing.T) { } } } + +func TestNormalizeSubsequentRequestAssistantIncrementalInputStillMerges(t *testing.T) { + lastRequest := []byte(`{"model":"gpt-5.4","stream":true,"input":[ + {"type":"message","role":"user","id":"msg-1","content":"hello"} + ]}`) + lastResponseOutput := []byte(`[ + {"type":"message","role":"assistant","id":"msg-2","content":"prior assistant"}, + {"type":"function_call","id":"fc-1","call_id":"call-1","name":"bash","arguments":"{}"} + ]`) + raw := []byte(`{"type":"response.append","input":[ + {"type":"message","role":"assistant","id":"msg-3","content":"patched assistant turn"} + ]}`) + + normalized, _, errMsg := normalizeResponsesWebsocketRequest(raw, lastRequest, lastResponseOutput) + if errMsg != nil { + t.Fatalf("unexpected error: %v", errMsg.Error) + } + + input := gjson.GetBytes(normalized, "input").Array() + if len(input) != 4 { + t.Fatalf("input len = %d, want 4 (merged)", len(input)) + } + wantIDs := []string{"msg-1", "msg-2", "fc-1", "msg-3"} + for i, want := range wantIDs { + got := input[i].Get("id").String() + if got != want { + t.Fatalf("input[%d].id = %q, want %q", i, got, want) + } + } +}