Skip to content

Commit ea7da97

Browse files
authored
fix(openai): handle done-only/content-part responses (RooCodeInc#11621)
* fix(openai-codex): handle done-only/content-part responses * fix(openai): align native done-event stream fallbacks with codex * fix(openai): address stream fallback review feedback
1 parent 9a8af61 commit ea7da97

5 files changed

Lines changed: 803 additions & 20 deletions

File tree

.changeset/sly-candles-hide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"roo-cline": patch
3+
---
4+
5+
Fix OpenAI Codex and OpenAI Native stream parsing for done-only and `content_part` events, including duplicate-text guards when deltas are already streamed.

src/api/providers/__tests__/openai-codex-native-tool-calls.spec.ts

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,312 @@ describe("OpenAiCodexHandler native tool calls", () => {
9797
name: "attempt_completion",
9898
})
9999
})
100+
101+
it("yields text when Codex emits assistant message only in response.output_item.done", async () => {
102+
vi.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token")
103+
vi.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test")
104+
;(handler as any).client = {
105+
responses: {
106+
create: vi.fn().mockResolvedValue({
107+
async *[Symbol.asyncIterator]() {
108+
yield {
109+
type: "response.output_item.done",
110+
item: {
111+
type: "message",
112+
role: "assistant",
113+
content: [{ type: "output_text", text: "hello from spark" }],
114+
},
115+
output_index: 0,
116+
}
117+
yield {
118+
type: "response.completed",
119+
response: {
120+
id: "resp_done_only",
121+
status: "completed",
122+
output: [
123+
{
124+
type: "message",
125+
role: "assistant",
126+
content: [{ type: "output_text", text: "hello from spark" }],
127+
},
128+
],
129+
usage: { input_tokens: 1, output_tokens: 2 },
130+
},
131+
}
132+
},
133+
}),
134+
},
135+
}
136+
137+
const stream = handler.createMessage("system", [{ role: "user", content: "test" } as any], {
138+
taskId: "t",
139+
tools: [],
140+
})
141+
142+
const chunks: any[] = []
143+
for await (const chunk of stream) {
144+
chunks.push(chunk)
145+
}
146+
147+
const textChunks = chunks.filter((c) => c.type === "text")
148+
expect(textChunks.length).toBeGreaterThan(0)
149+
expect(textChunks.map((c) => c.text).join("")).toContain("hello from spark")
150+
})
151+
152+
it("yields text when Codex emits assistant message only in response.completed output", async () => {
153+
vi.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token")
154+
vi.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test")
155+
;(handler as any).client = {
156+
responses: {
157+
create: vi.fn().mockResolvedValue({
158+
async *[Symbol.asyncIterator]() {
159+
yield {
160+
type: "response.completed",
161+
response: {
162+
id: "resp_completed_only",
163+
status: "completed",
164+
output: [
165+
{
166+
type: "message",
167+
role: "assistant",
168+
content: [{ type: "output_text", text: "final payload only" }],
169+
},
170+
],
171+
usage: { input_tokens: 1, output_tokens: 2 },
172+
},
173+
}
174+
},
175+
}),
176+
},
177+
}
178+
179+
const stream = handler.createMessage("system", [{ role: "user", content: "test" } as any], {
180+
taskId: "t",
181+
tools: [],
182+
})
183+
184+
const chunks: any[] = []
185+
for await (const chunk of stream) {
186+
chunks.push(chunk)
187+
}
188+
189+
const textChunks = chunks.filter((c) => c.type === "text")
190+
expect(textChunks.length).toBeGreaterThan(0)
191+
expect(textChunks.map((c) => c.text).join("")).toContain("final payload only")
192+
})
193+
194+
it("yields text when Codex emits response.output_text.done without deltas", async () => {
195+
vi.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token")
196+
vi.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test")
197+
;(handler as any).client = {
198+
responses: {
199+
create: vi.fn().mockResolvedValue({
200+
async *[Symbol.asyncIterator]() {
201+
yield {
202+
type: "response.output_text.done",
203+
text: "done-event text only",
204+
}
205+
yield {
206+
type: "response.completed",
207+
response: {
208+
id: "resp_done_text_only",
209+
status: "completed",
210+
output: [],
211+
usage: { input_tokens: 1, output_tokens: 2 },
212+
},
213+
}
214+
},
215+
}),
216+
},
217+
}
218+
219+
const stream = handler.createMessage("system", [{ role: "user", content: "test" } as any], {
220+
taskId: "t",
221+
tools: [],
222+
})
223+
224+
const chunks: any[] = []
225+
for await (const chunk of stream) {
226+
chunks.push(chunk)
227+
}
228+
229+
const textChunks = chunks.filter((c) => c.type === "text")
230+
expect(textChunks.length).toBeGreaterThan(0)
231+
expect(textChunks.map((c) => c.text).join("")).toContain("done-event text only")
232+
})
233+
234+
it("yields tool_call when Codex emits function_call only in response.output_item.done", async () => {
235+
vi.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token")
236+
vi.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test")
237+
;(handler as any).client = {
238+
responses: {
239+
create: vi.fn().mockResolvedValue({
240+
async *[Symbol.asyncIterator]() {
241+
yield {
242+
type: "response.output_item.done",
243+
item: {
244+
type: "function_call",
245+
call_id: "call_done_only",
246+
name: "attempt_completion",
247+
arguments: '{"result":"ok"}',
248+
},
249+
output_index: 0,
250+
}
251+
yield {
252+
type: "response.completed",
253+
response: {
254+
id: "resp_done_tool_only",
255+
status: "completed",
256+
output: [],
257+
usage: { input_tokens: 1, output_tokens: 2 },
258+
},
259+
}
260+
},
261+
}),
262+
},
263+
}
264+
265+
const stream = handler.createMessage("system", [{ role: "user", content: "test" } as any], {
266+
taskId: "t",
267+
tools: [],
268+
})
269+
270+
const chunks: any[] = []
271+
for await (const chunk of stream) {
272+
chunks.push(chunk)
273+
}
274+
275+
const toolCalls = chunks.filter((c) => c.type === "tool_call")
276+
expect(toolCalls.length).toBeGreaterThan(0)
277+
expect(toolCalls[0]).toMatchObject({
278+
type: "tool_call",
279+
id: "call_done_only",
280+
name: "attempt_completion",
281+
})
282+
})
283+
284+
it("yields text when Codex emits response.content_part.added", async () => {
285+
vi.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token")
286+
vi.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test")
287+
;(handler as any).client = {
288+
responses: {
289+
create: vi.fn().mockResolvedValue({
290+
async *[Symbol.asyncIterator]() {
291+
yield {
292+
type: "response.content_part.added",
293+
part: {
294+
type: "output_text",
295+
text: "content part text",
296+
},
297+
output_index: 0,
298+
content_index: 0,
299+
}
300+
yield {
301+
type: "response.completed",
302+
response: {
303+
id: "resp_content_part",
304+
status: "completed",
305+
output: [],
306+
usage: { input_tokens: 1, output_tokens: 2 },
307+
},
308+
}
309+
},
310+
}),
311+
},
312+
}
313+
314+
const stream = handler.createMessage("system", [{ role: "user", content: "test" } as any], {
315+
taskId: "t",
316+
tools: [],
317+
})
318+
319+
const chunks: any[] = []
320+
for await (const chunk of stream) {
321+
chunks.push(chunk)
322+
}
323+
324+
const textChunks = chunks.filter((c) => c.type === "text")
325+
expect(textChunks.length).toBeGreaterThan(0)
326+
expect(textChunks.map((c) => c.text).join("")).toContain("content part text")
327+
})
328+
329+
it("does not duplicate text when Codex emits delta and output_text.done", async () => {
330+
vi.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token")
331+
vi.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test")
332+
;(handler as any).client = {
333+
responses: {
334+
create: vi.fn().mockResolvedValue({
335+
async *[Symbol.asyncIterator]() {
336+
yield { type: "response.output_text.delta", delta: "hello " }
337+
yield { type: "response.output_text.delta", delta: "world" }
338+
yield { type: "response.output_text.done", text: "hello world" }
339+
yield {
340+
type: "response.completed",
341+
response: {
342+
id: "resp_delta_done",
343+
status: "completed",
344+
output: [],
345+
usage: { input_tokens: 1, output_tokens: 2 },
346+
},
347+
}
348+
},
349+
}),
350+
},
351+
}
352+
353+
const stream = handler.createMessage("system", [{ role: "user", content: "test" } as any], {
354+
taskId: "t",
355+
tools: [],
356+
})
357+
358+
const chunks: any[] = []
359+
for await (const chunk of stream) {
360+
chunks.push(chunk)
361+
}
362+
363+
const textChunks = chunks.filter((c) => c.type === "text")
364+
expect(textChunks.map((c) => c.text).join("")).toBe("hello world")
365+
})
366+
367+
it("does not duplicate text when Codex emits delta and content_part.added", async () => {
368+
vi.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token")
369+
vi.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test")
370+
;(handler as any).client = {
371+
responses: {
372+
create: vi.fn().mockResolvedValue({
373+
async *[Symbol.asyncIterator]() {
374+
yield { type: "response.output_text.delta", delta: "hello world" }
375+
yield {
376+
type: "response.content_part.added",
377+
part: { type: "output_text", text: "hello world" },
378+
output_index: 0,
379+
content_index: 0,
380+
}
381+
yield {
382+
type: "response.completed",
383+
response: {
384+
id: "resp_delta_content_part",
385+
status: "completed",
386+
output: [],
387+
usage: { input_tokens: 1, output_tokens: 2 },
388+
},
389+
}
390+
},
391+
}),
392+
},
393+
}
394+
395+
const stream = handler.createMessage("system", [{ role: "user", content: "test" } as any], {
396+
taskId: "t",
397+
tools: [],
398+
})
399+
400+
const chunks: any[] = []
401+
for await (const chunk of stream) {
402+
chunks.push(chunk)
403+
}
404+
405+
const textChunks = chunks.filter((c) => c.type === "text")
406+
expect(textChunks.map((c) => c.text).join("")).toBe("hello world")
407+
})
100408
})

0 commit comments

Comments
 (0)