v0.51.0 a9d674e

openai_responses_phase_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close