1package provider
2
3import (
4 "context"
5 "encoding/json"
6 "strings"
7 "testing"
8)
9
10// phaseTexts returns (text, phase) pairs for assistant text blocks.
11func phaseTexts(t *testing.T, done EventDone) [][2]string {
12 t.Helper()
13 var out [][2]string
14 for _, c := range done.Message.Content {
15 if tb, ok := c.(TextBlock); ok {
16 out = append(out, [2]string{tb.Text, tb.Phase})
17 }
18 }
19 return out
20}
21
22func TestResponsesPhaseCommentaryThenFinal(t *testing.T) {
23 done, _ := runResponsesCompletionFixture(t,
24 `{"type":"response.output_item.added","output_index":0,"item":{"type":"message","phase":"commentary"}}`,
25 `{"type":"response.output_text.delta","output_index":0,"delta":"working on it"}`,
26 `{"type":"response.output_item.done","output_index":0,"item":{"type":"message"}}`,
27 `{"type":"response.output_item.added","output_index":1,"item":{"type":"message","phase":"final_answer"}}`,
28 `{"type":"response.output_text.delta","output_index":1,"delta":"done"}`,
29 `{"type":"response.output_item.done","output_index":1,"item":{"type":"message"}}`,
30 completedPayload("response.completed", `"id":"resp-1","usage":{"input_tokens":1,"output_tokens":1}`),
31 )
32 got := phaseTexts(t, done)
33 want := [][2]string{{"working on it", "commentary"}, {"done", "final_answer"}}
34 if len(got) != len(want) {
35 t.Fatalf("blocks = %#v, want %#v", got, want)
36 }
37 for i := range want {
38 if got[i] != want[i] {
39 t.Fatalf("blocks = %#v, want %#v", got, want)
40 }
41 }
42}
43
44func TestResponsesPhaseOnItemCompletion(t *testing.T) {
45 // Phase first appears on item-done rather than item-added.
46 done, _ := runResponsesCompletionFixture(t,
47 `{"type":"response.output_item.added","output_index":0,"item":{"type":"message"}}`,
48 `{"type":"response.output_text.delta","output_index":0,"delta":"late phase"}`,
49 `{"type":"response.output_item.done","output_index":0,"item":{"type":"message","phase":"commentary"}}`,
50 completedPayload("response.completed", `"id":"resp-1","usage":{"input_tokens":1,"output_tokens":1}`),
51 )
52 got := phaseTexts(t, done)
53 if len(got) != 1 || got[0] != [2]string{"late phase", "commentary"} {
54 t.Fatalf("blocks = %#v, want [{late phase commentary}]", got)
55 }
56}
57
58func TestResponsesPhaseAddedNotErasedByDone(t *testing.T) {
59 // Phase observed on item-added must survive a phaseless item-done.
60 done, _ := runResponsesCompletionFixture(t,
61 `{"type":"response.output_item.added","output_index":0,"item":{"type":"message","phase":"commentary"}}`,
62 `{"type":"response.output_text.delta","output_index":0,"delta":"keep me"}`,
63 `{"type":"response.output_item.done","output_index":0,"item":{"type":"message"}}`,
64 completedPayload("response.completed", `"id":"resp-1","usage":{"input_tokens":1,"output_tokens":1}`),
65 )
66 got := phaseTexts(t, done)
67 if len(got) != 1 || got[0] != [2]string{"keep me", "commentary"} {
68 t.Fatalf("blocks = %#v, want [{keep me commentary}]", got)
69 }
70}
71
72func TestResponsesPhaseTerminalReconciliation(t *testing.T) {
73 // Phase only present in the terminal response.output array.
74 done, _ := runResponsesCompletionFixture(t,
75 `{"type":"response.output_item.added","output_index":0,"item":{"type":"message"}}`,
76 `{"type":"response.output_text.delta","output_index":0,"delta":"terminal phase"}`,
77 `{"type":"response.output_item.done","output_index":0,"item":{"type":"message"}}`,
78 `{"type":"response.completed","response":{"id":"resp-1","usage":{"input_tokens":1,"output_tokens":1},"output":[{"type":"message","content":[{"type":"output_text","text":"terminal phase","phase":"final_answer"}]}]}}`,
79 )
80 got := phaseTexts(t, done)
81 if len(got) != 1 || got[0] != [2]string{"terminal phase", "final_answer"} {
82 t.Fatalf("blocks = %#v, want [{terminal phase final_answer}]", got)
83 }
84}
85
86func TestResponsesPhaseAbsentAndUnknown(t *testing.T) {
87 done, _ := runResponsesCompletionFixture(t,
88 `{"type":"response.output_item.added","output_index":0,"item":{"type":"message"}}`,
89 `{"type":"response.output_text.delta","output_index":0,"delta":"plain"}`,
90 `{"type":"response.output_item.done","output_index":0,"item":{"type":"message"}}`,
91 `{"type":"response.output_item.added","output_index":1,"item":{"type":"message","phase":"mystery"}}`,
92 `{"type":"response.output_text.delta","output_index":1,"delta":"weird"}`,
93 `{"type":"response.output_item.done","output_index":1,"item":{"type":"message"}}`,
94 completedPayload("response.completed", `"id":"resp-1","usage":{"input_tokens":1,"output_tokens":1}`),
95 )
96 if done.Stop != StopEnd {
97 t.Fatalf("stop = %q, want %q", done.Stop, StopEnd)
98 }
99 got := phaseTexts(t, done)
100 want := [][2]string{{"plain", ""}, {"weird", "mystery"}}
101 if len(got) != len(want) {
102 t.Fatalf("blocks = %#v, want %#v", got, want)
103 }
104 for i := range want {
105 if got[i] != want[i] {
106 t.Fatalf("blocks = %#v, want %#v", got, want)
107 }
108 }
109 // Unknown phases are stored but not interpreted as recognized.
110 for _, c := range done.Message.Content {
111 if tb, ok := c.(TextBlock); ok && tb.Phase == "mystery" && IsRecognizedTextPhase(tb.Phase) {
112 t.Fatalf("unknown phase treated as recognized: %q", tb.Phase)
113 }
114 }
115}
116
117func TestResponsesPhaseToolInterleaving(t *testing.T) {
118 done, _ := runResponsesCompletionFixture(t,
119 `{"type":"response.output_item.added","output_index":0,"item":{"type":"message","phase":"commentary"}}`,
120 `{"type":"response.output_text.delta","output_index":0,"delta":"checking"}`,
121 `{"type":"response.output_item.done","output_index":0,"item":{"type":"message"}}`,
122 `{"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","call_id":"call-1","name":"echo"}}`,
123 `{"type":"response.function_call_arguments.delta","output_index":1,"delta":"{}"}`,
124 `{"type":"response.output_item.done","output_index":1,"item":{"type":"function_call"}}`,
125 completedPayload("response.completed", `"id":"resp-1","usage":{"input_tokens":1,"output_tokens":1}`),
126 )
127 if done.Stop != StopToolUse {
128 t.Fatalf("stop = %q, want %q", done.Stop, StopToolUse)
129 }
130 if len(done.Message.Content) != 2 {
131 t.Fatalf("content = %#v, want 2 blocks", done.Message.Content)
132 }
133 tb, ok := done.Message.Content[0].(TextBlock)
134 if !ok || tb.Text != "checking" || tb.Phase != "commentary" {
135 t.Fatalf("first block = %#v, want commentary text", done.Message.Content[0])
136 }
137 if tc, ok := done.Message.Content[1].(ToolCallBlock); !ok || tc.ID != "call-1" {
138 t.Fatalf("second block = %#v, want tool call", done.Message.Content[1])
139 }
140}
141
142func TestResponsesPhaseRequestReplay(t *testing.T) {
143 c := newOpenAICodexClient("token", "acct", "")
144 req := Request{
145 Model: "gpt-5.2",
146 Messages: []Message{
147 {Role: RoleAssistant, Content: []Content{
148 TextBlock{Text: "progress", Phase: "commentary"},
149 TextBlock{Text: "result", Phase: "final_answer"},
150 TextBlock{Text: "plain"},
151 TextBlock{Text: "odd", Phase: "mystery"},
152 }},
153 },
154 }
155 wire, err := c.buildRequest(req)
156 if err != nil {
157 t.Fatalf("buildRequest: %v", err)
158 }
159 raw, err := json.Marshal(wire)
160 if err != nil {
161 t.Fatalf("marshal: %v", err)
162 }
163 var decoded struct {
164 Input []struct {
165 Type string `json:"type"`
166 Role string `json:"role"`
167 Content []struct {
168 Type string `json:"type"`
169 Text string `json:"text"`
170 Phase string `json:"phase"`
171 } `json:"content"`
172 } `json:"input"`
173 }
174 if err := json.Unmarshal(raw, &decoded); err != nil {
175 t.Fatalf("unmarshal: %v", err)
176 }
177 var phases []string
178 for _, item := range decoded.Input {
179 if item.Role != "assistant" {
180 continue
181 }
182 for _, part := range item.Content {
183 phases = append(phases, part.Text+":"+part.Phase)
184 }
185 }
186 want := []string{"progress:commentary", "result:final_answer", "plain:", "odd:"}
187 if len(phases) != len(want) {
188 t.Fatalf("replay = %v, want %v (raw %s)", phases, want, raw)
189 }
190 for i := range want {
191 if phases[i] != want[i] {
192 t.Fatalf("replay = %v, want %v (raw %s)", phases, want, raw)
193 }
194 }
195 if strings.Contains(string(raw), "mystery") {
196 t.Fatalf("unrecognized phase leaked to wire: %s", raw)
197 }
198}
199
200func TestResponsesPhaseWebSocketSharedStateMachine(t *testing.T) {
201 // The WebSocket transport feeds the same runResponseEvents parser, so
202 // exercise it through the shared entry point used by the socket path.
203 c := newOpenAICodexClient("token", "acct", "")
204 first := sseEvent{Data: `{"type":"response.output_item.added","output_index":0,"item":{"type":"message","phase":"commentary"}}`}
205 raw := make(chan sseEvent, 4)
206 raw <- sseEvent{Data: `{"type":"response.output_text.delta","output_index":0,"delta":"ws text"}`}
207 raw <- sseEvent{Data: `{"type":"response.output_item.done","output_index":0,"item":{"type":"message"}}`}
208 raw <- sseEvent{Data: completedPayload("response.completed", `"id":"resp-1","usage":{"input_tokens":1,"output_tokens":1}`)}
209 close(raw)
210 out := make(chan Event, 32)
211 go c.runResponseEventsWithFirst(context.Background(), Request{Model: "gpt-5.2"}, out, raw, &first, nil)
212 for ev := range out {
213 if done, ok := ev.(EventDone); ok {
214 got := phaseTexts(t, done)
215 if len(got) != 1 || got[0] != [2]string{"ws text", "commentary"} {
216 t.Fatalf("blocks = %#v, want [{ws text commentary}]", got)
217 }
218 return
219 }
220 }
221 t.Fatal("stream ended without EventDone")
222}