icebear icebear0828 commited on
Commit
1531084
·
unverified ·
1 Parent(s): 1d9f789

fix: handle new Responses SSE lifecycle events (#137)

Browse files

* fix: handle new Responses SSE lifecycle events (output_item.added message, content_part)

Upstream Codex API now wraps text responses in message/content_part
lifecycle events. Our parser only recognized output_item.added with
item.type=function_call, causing unknown event log spam.

- Widen output_item.added to accept any item.type (message, etc.)
- Add content_part.added and content_part.done as recognized no-op events
- Update extractor to handle optional call_id/name on non-function_call items

* chore: remove loose index signature from CodexOutputItemAddedEvent.item

---------

Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>

CHANGELOG.md CHANGED
@@ -8,6 +8,7 @@
8
 
9
  ### Fixed
10
 
 
11
  - 新模型(如 `gpt-5.4-mini`)无法被动态发现的问题
12
  - 移除 `isCodexCompatibleId()` 白名单过滤,信任后端 `/codex/models` 返回
13
  - 同一 Team 的多个账号因共享 `chatgpt_account_id` 只能添加一个的问题(#126)
 
8
 
9
  ### Fixed
10
 
11
+ - Responses SSE 新事件(`response.output_item.added` with `item.type=message`、`response.content_part.added/done`)未被识别,导致 `[CodexEvents] Unknown event` 日志刷屏
12
  - 新模型(如 `gpt-5.4-mini`)无法被动态发现的问题
13
  - 移除 `isCodexCompatibleId()` 白名单过滤,信任后端 `/codex/models` 返回
14
  - 同一 Team 的多个账号因共享 `chatgpt_account_id` 只能添加一个的问题(#126)
src/translation/codex-event-extractor.ts CHANGED
@@ -92,7 +92,7 @@ export async function* iterateCodexEvents(
92
  break;
93
 
94
  case "response.output_item.added":
95
- if (typed.item.type === "function_call") {
96
  // Register item_id → call_id mapping
97
  itemIdToCallInfo.set(typed.item.id, {
98
  callId: typed.item.call_id,
@@ -128,7 +128,9 @@ export async function* iterateCodexEvents(
128
  }
129
 
130
  case "response.output_item.done":
131
- // Completion marker — tool call data already delivered via delta/done events
 
 
132
  break;
133
 
134
  case "response.incomplete":
 
92
  break;
93
 
94
  case "response.output_item.added":
95
+ if (typed.item.type === "function_call" && typed.item.call_id && typed.item.name) {
96
  // Register item_id → call_id mapping
97
  itemIdToCallInfo.set(typed.item.id, {
98
  callId: typed.item.call_id,
 
128
  }
129
 
130
  case "response.output_item.done":
131
+ case "response.content_part.added":
132
+ case "response.content_part.done":
133
+ // Lifecycle markers — no data extraction needed
134
  break;
135
 
136
  case "response.incomplete":
src/types/__tests__/codex-events.test.ts ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseCodexEvent } from "../codex-events.js";
3
+ import type { CodexSSEEvent } from "../../proxy/codex-api.js";
4
+
5
+ function makeRaw(event: string, data: unknown): CodexSSEEvent {
6
+ return { event, data };
7
+ }
8
+
9
+ describe("parseCodexEvent — message output_item.added", () => {
10
+ it("parses output_item.added with item.type=message as known event", () => {
11
+ const raw = makeRaw("response.output_item.added", {
12
+ type: "response.output_item.added",
13
+ item: {
14
+ id: "msg_abc",
15
+ type: "message",
16
+ status: "in_progress",
17
+ content: [],
18
+ role: "assistant",
19
+ },
20
+ output_index: 0,
21
+ sequence_number: 2,
22
+ });
23
+ const result = parseCodexEvent(raw);
24
+ expect(result.type).toBe("response.output_item.added");
25
+ expect(result.type).not.toBe("unknown");
26
+ });
27
+
28
+ it("still parses output_item.added with item.type=function_call", () => {
29
+ const raw = makeRaw("response.output_item.added", {
30
+ type: "response.output_item.added",
31
+ item: {
32
+ id: "fc_1",
33
+ type: "function_call",
34
+ call_id: "call_1",
35
+ name: "get_weather",
36
+ },
37
+ output_index: 0,
38
+ });
39
+ const result = parseCodexEvent(raw);
40
+ expect(result.type).toBe("response.output_item.added");
41
+ if (result.type === "response.output_item.added") {
42
+ expect(result.item.type).toBe("function_call");
43
+ expect(result.item.call_id).toBe("call_1");
44
+ expect(result.item.name).toBe("get_weather");
45
+ }
46
+ });
47
+ });
48
+
49
+ describe("parseCodexEvent — content_part events", () => {
50
+ it("parses response.content_part.added as known event", () => {
51
+ const raw = makeRaw("response.content_part.added", {
52
+ type: "response.content_part.added",
53
+ content_index: 0,
54
+ item_id: "msg_abc",
55
+ output_index: 0,
56
+ part: { type: "output_text", annotations: [], logprobs: [], text: "" },
57
+ sequence_number: 3,
58
+ });
59
+ const result = parseCodexEvent(raw);
60
+ expect(result.type).toBe("response.content_part.added");
61
+ });
62
+
63
+ it("parses response.content_part.done as known event", () => {
64
+ const raw = makeRaw("response.content_part.done", {
65
+ type: "response.content_part.done",
66
+ content_index: 0,
67
+ item_id: "msg_abc",
68
+ output_index: 0,
69
+ part: { type: "output_text", annotations: [], logprobs: [], text: "Hello world" },
70
+ sequence_number: 4,
71
+ });
72
+ const result = parseCodexEvent(raw);
73
+ expect(result.type).toBe("response.content_part.done");
74
+ });
75
+ });
src/types/codex-events.ts CHANGED
@@ -63,13 +63,29 @@ export interface CodexOutputItemAddedEvent {
63
  type: "response.output_item.added";
64
  outputIndex: number;
65
  item: {
66
- type: "function_call";
67
  id: string;
68
- call_id: string;
69
- name: string;
70
  };
71
  }
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  export interface CodexFunctionCallArgsDeltaEvent {
74
  type: "response.function_call_arguments.delta";
75
  delta: string;
@@ -133,6 +149,8 @@ export type TypedCodexEvent =
133
  | CodexCompletedEvent
134
  | CodexOutputItemAddedEvent
135
  | CodexOutputItemDoneEvent
 
 
136
  | CodexIncompleteEvent
137
  | CodexQueuedEvent
138
  | CodexFunctionCallArgsDeltaEvent
@@ -223,22 +241,30 @@ export function parseCodexEvent(evt: CodexSSEEvent): TypedCodexEvent {
223
  : { type: "unknown", raw: data };
224
  }
225
  case "response.output_item.added": {
226
- if (
227
- isRecord(data) &&
228
- isRecord(data.item) &&
229
- data.item.type === "function_call" &&
230
- typeof data.item.call_id === "string" &&
231
- typeof data.item.name === "string"
232
- ) {
233
  return {
234
  type: "response.output_item.added",
235
  outputIndex: typeof data.output_index === "number" ? data.output_index : 0,
236
- item: {
237
- type: "function_call",
238
- id: typeof data.item.id === "string" ? data.item.id : "",
239
- call_id: data.item.call_id,
240
- name: data.item.name,
241
- },
 
 
 
 
 
 
 
 
242
  };
243
  }
244
  return { type: "unknown", raw: data };
 
63
  type: "response.output_item.added";
64
  outputIndex: number;
65
  item: {
66
+ type: string;
67
  id: string;
68
+ call_id?: string;
69
+ name?: string;
70
  };
71
  }
72
 
73
+ export interface CodexContentPartAddedEvent {
74
+ type: "response.content_part.added";
75
+ contentIndex: number;
76
+ outputIndex: number;
77
+ itemId: string;
78
+ part: Record<string, unknown>;
79
+ }
80
+
81
+ export interface CodexContentPartDoneEvent {
82
+ type: "response.content_part.done";
83
+ contentIndex: number;
84
+ outputIndex: number;
85
+ itemId: string;
86
+ part: Record<string, unknown>;
87
+ }
88
+
89
  export interface CodexFunctionCallArgsDeltaEvent {
90
  type: "response.function_call_arguments.delta";
91
  delta: string;
 
149
  | CodexCompletedEvent
150
  | CodexOutputItemAddedEvent
151
  | CodexOutputItemDoneEvent
152
+ | CodexContentPartAddedEvent
153
+ | CodexContentPartDoneEvent
154
  | CodexIncompleteEvent
155
  | CodexQueuedEvent
156
  | CodexFunctionCallArgsDeltaEvent
 
241
  : { type: "unknown", raw: data };
242
  }
243
  case "response.output_item.added": {
244
+ if (isRecord(data) && isRecord(data.item) && typeof data.item.type === "string") {
245
+ const item: CodexOutputItemAddedEvent["item"] = {
246
+ type: data.item.type,
247
+ id: typeof data.item.id === "string" ? data.item.id : "",
248
+ };
249
+ if (typeof data.item.call_id === "string") item.call_id = data.item.call_id;
250
+ if (typeof data.item.name === "string") item.name = data.item.name;
251
  return {
252
  type: "response.output_item.added",
253
  outputIndex: typeof data.output_index === "number" ? data.output_index : 0,
254
+ item,
255
+ };
256
+ }
257
+ return { type: "unknown", raw: data };
258
+ }
259
+ case "response.content_part.added":
260
+ case "response.content_part.done": {
261
+ if (isRecord(data) && isRecord(data.part)) {
262
+ return {
263
+ type: evt.event as "response.content_part.added" | "response.content_part.done",
264
+ contentIndex: typeof data.content_index === "number" ? data.content_index : 0,
265
+ outputIndex: typeof data.output_index === "number" ? data.output_index : 0,
266
+ itemId: typeof data.item_id === "string" ? data.item_id : "",
267
+ part: data.part as Record<string, unknown>,
268
  };
269
  }
270
  return { type: "unknown", raw: data };