openai_responses_stream_output_items_test.go 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. package service
  2. import (
  3. "testing"
  4. "github.com/stretchr/testify/require"
  5. "github.com/tidwall/gjson"
  6. )
  7. // A terminal event that arrives with an empty output must be rebuilt from the
  8. // items the stream reported, not from delta accumulation. The accumulator
  9. // models only one reasoning and one message, so rebuilding through it collapses
  10. // a multi-item turn into a single fabricated message.
  11. func TestNormalizeResponsesStreamingTerminalOutputPreservesReportedItems(t *testing.T) {
  12. doneItems := newResponsesStreamOutputItems()
  13. doneItems.Observe([]byte(`{
  14. "type":"response.output_item.done",
  15. "output_index":0,
  16. "item":{"id":"rs_1","type":"reasoning","summary":[{"type":"summary_text","text":"thinking"}],"encrypted_content":"opaque"}
  17. }`))
  18. doneItems.Observe([]byte(`{
  19. "type":"response.output_item.done",
  20. "output_index":1,
  21. "item":{"id":"msg_1","type":"message","status":"completed","phase":"final_answer","role":"assistant","content":[{"type":"output_text","text":"shipped","annotations":[],"logprobs":[]}]}
  22. }`))
  23. normalized, changed := normalizeResponsesStreamingTerminalOutput(
  24. []byte(`{"type":"response.completed","response":{"status":"completed","output":[]}}`),
  25. nil,
  26. doneItems,
  27. nil,
  28. )
  29. require.True(t, changed)
  30. output := gjson.GetBytes(normalized, "response.output")
  31. require.True(t, output.IsArray())
  32. require.Len(t, output.Array(), 2, "both reported items must survive")
  33. require.Equal(t, "reasoning", gjson.GetBytes(normalized, "response.output.0.type").String())
  34. require.Equal(t, "rs_1", gjson.GetBytes(normalized, "response.output.0.id").String())
  35. require.Equal(t, "opaque", gjson.GetBytes(normalized, "response.output.0.encrypted_content").String(),
  36. "fields the gateway does not model must survive verbatim")
  37. require.Equal(t, "message", gjson.GetBytes(normalized, "response.output.1.type").String())
  38. require.Equal(t, "msg_1", gjson.GetBytes(normalized, "response.output.1.id").String(),
  39. "the reported id must be reused, not regenerated")
  40. require.Equal(t, "completed", gjson.GetBytes(normalized, "response.output.1.status").String())
  41. require.Equal(t, "final_answer", gjson.GetBytes(normalized, "response.output.1.phase").String())
  42. require.Equal(t, "shipped", gjson.GetBytes(normalized, "response.output.1.content.0.text").String())
  43. }
  44. // Items are ordered by output_index, not by arrival order.
  45. func TestResponsesStreamOutputItemsOrderByOutputIndex(t *testing.T) {
  46. doneItems := newResponsesStreamOutputItems()
  47. doneItems.Observe([]byte(`{"type":"response.output_item.done","output_index":2,"item":{"id":"c","type":"message"}}`))
  48. doneItems.Observe([]byte(`{"type":"response.output_item.done","output_index":0,"item":{"id":"a","type":"reasoning"}}`))
  49. built, ok := doneItems.BuildOutput()
  50. require.True(t, ok)
  51. require.Equal(t, "a", gjson.GetBytes(built, "0.id").String())
  52. require.Equal(t, "c", gjson.GetBytes(built, "1.id").String())
  53. }
  54. // A stream that never reports a done item keeps the previous rebuild path.
  55. func TestNormalizeResponsesStreamingTerminalOutputIgnoresNonDoneEvents(t *testing.T) {
  56. doneItems := newResponsesStreamOutputItems()
  57. doneItems.Observe([]byte(`{"type":"response.output_item.added","output_index":0,"item":{"id":"msg_1","type":"message"}}`))
  58. doneItems.Observe([]byte(`{"type":"response.output_text.delta","output_index":0,"delta":"hi"}`))
  59. require.False(t, doneItems.HasItems())
  60. raw := []byte(`{"type":"response.completed","response":{"status":"completed","output":[]}}`)
  61. normalized, changed := normalizeResponsesStreamingTerminalOutput(raw, nil, doneItems, nil)
  62. require.False(t, changed)
  63. require.Equal(t, string(raw), string(normalized))
  64. }
  65. // The terminal event can arrive with a non-empty but truncated output: the
  66. // stream reported two items, the terminal carries one, and its id was not the
  67. // one the stream reported. The reported items win.
  68. func TestNormalizeResponsesStreamingTerminalOutputRepairsTruncatedOutput(t *testing.T) {
  69. doneItems := newResponsesStreamOutputItems()
  70. doneItems.Observe([]byte(`{
  71. "type":"response.output_item.done","output_index":0,
  72. "item":{"id":"rs_real","type":"reasoning","status":"in_progress","summary":[]}
  73. }`))
  74. doneItems.Observe([]byte(`{
  75. "type":"response.output_item.done","output_index":1,
  76. "item":{"id":"msg_real","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"shipped","annotations":[],"logprobs":[]}]}
  77. }`))
  78. normalized, changed := normalizeResponsesStreamingTerminalOutput([]byte(`{
  79. "type":"response.completed",
  80. "response":{"status":"completed","output":[{"type":"message","role":"assistant","id":"msg_fabricated","status":"completed","content":[{"type":"output_text","text":"shipped","annotations":[],"logprobs":[]}]}]}
  81. }`), nil, doneItems, nil)
  82. require.True(t, changed)
  83. require.Len(t, gjson.GetBytes(normalized, "response.output").Array(), 2)
  84. require.Equal(t, "reasoning", gjson.GetBytes(normalized, "response.output.0.type").String())
  85. require.Equal(t, "rs_real", gjson.GetBytes(normalized, "response.output.0.id").String())
  86. require.Equal(t, "msg_real", gjson.GetBytes(normalized, "response.output.1.id").String(),
  87. "the id the stream reported must replace the fabricated one")
  88. }
  89. // A terminal output that is already complete is never rewritten.
  90. func TestNormalizeResponsesStreamingTerminalOutputLeavesCompleteOutputAlone(t *testing.T) {
  91. doneItems := newResponsesStreamOutputItems()
  92. doneItems.Observe([]byte(`{
  93. "type":"response.output_item.done","output_index":0,
  94. "item":{"id":"msg_real","type":"message","status":"completed"}
  95. }`))
  96. raw := []byte(`{"type":"response.completed","response":{"status":"completed","output":[{"type":"message","id":"msg_upstream","status":"completed","vendor":"keep"}]}}`)
  97. normalized, changed := normalizeResponsesStreamingTerminalOutput(raw, nil, doneItems, nil)
  98. require.False(t, changed)
  99. require.Equal(t, string(raw), string(normalized))
  100. }