codex-copilot-dx 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -283,6 +283,8 @@ For `/v1/responses/compact`, CCDX sends one terminal `compaction_trigger`, valid
283
283
 
284
284
  Successful streaming responses are accepted only when the upstream body is SSE and reaches its protocol terminal event. Unexpected EOF becomes a protocol-native stream error, and repeated blank tool-argument deltas are stopped per tool call. Safe quota, retry, model, trace, and upstream request-ID metadata are forwarded; cookies, authorization, body-length, and encoding headers are not.
285
285
 
286
+ CCDX 0.7.3 keeps each native Responses message on its first upstream ID when Copilot changes that ID during streaming. Message deltas, completion events, and cached history stay consistent, preventing duplicate live messages in Codex App. Tool identities, reasoning/encrypted content, message phases, and speed-tier routing are unchanged. Stable events retain their original SSE bytes; changed events are rewritten with bounded, backpressured streaming.
287
+
286
288
  Newer Codex App builds can advertise an `image_gen` namespace that already exists upstream. CCDX removes that exact conflicting client tool before forwarding and retries once only when Copilot explicitly reports an image namespace collision. Image input and screenshot optimization remain enabled.
287
289
 
288
290
  ## Development
@@ -295,6 +297,8 @@ npm run bench:payload
295
297
 
296
298
  `npm test` runs unit and handler-level tests. `npm run test:smoke` starts a real local adapter with fully injected offline upstreams. `npm run bench:check` enforces linear SSE scanning, image/tool processing, and request-admission resource limits. `npm run pack:check` verifies npm tarball contents without publishing. `npm run bench:payload` is a report-only isolated-process benchmark for 5–60 MiB image payloads and does not contact Copilot. CI runs verification on the supported Node.js release lines.
297
299
 
300
+ From a source checkout, `node scripts/codex-message-id-replay.mjs --codex /absolute/path/to/codex` checks message identity against an installed Codex runtime using only synthetic SSE, a local mock server, and automatically cleaned temporary Codex directories. It does not use the real client configuration or call Copilot.
301
+
298
302
  ## License
299
303
 
300
304
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "Use Codex App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
@@ -32,12 +32,56 @@ import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
32
32
  import { recordResponsesUsage } from "./usage.mjs";
33
33
 
34
34
  const MAX_SSE_BUFFER_BYTES = loadRuntimeConfig().maxSseBufferBytes;
35
+ const SSE_WRITE_BATCH_BYTES = 64 * 1024;
35
36
  const RESPONSES_TERMINAL_EVENT_TYPES = new Set([
36
37
  "response.completed",
37
38
  "response.incomplete",
38
39
  "response.failed",
39
40
  "error",
40
41
  ]);
42
+ const MESSAGE_CONTENT_EVENT_TYPES = new Set([
43
+ "response.content_part.added",
44
+ "response.content_part.done",
45
+ "response.output_text.delta",
46
+ "response.output_text.done",
47
+ "response.output_text.annotation.added",
48
+ "response.refusal.delta",
49
+ "response.refusal.done",
50
+ ]);
51
+
52
+ function normalizeMessageIds(state, event, eventType) {
53
+ let changed = false;
54
+ const replaceId = (object, field, id) => {
55
+ if (id && typeof object?.[field] === "string" && object[field] !== id) {
56
+ object[field] = id;
57
+ changed = true;
58
+ }
59
+ };
60
+ const index = event.output_index;
61
+ if (Number.isSafeInteger(index) && index >= 0) {
62
+ // Bind only confirmed message items, using the full output index (including tools).
63
+ // IDs are opaque: retain the first upstream ID without decoding or synthesizing one.
64
+ if (eventType === "response.output_item.added" && event.item?.type === "message"
65
+ && typeof event.item.id === "string" && event.item.id.length > 0
66
+ && !state.messageIds.has(index)) {
67
+ state.messageIds.set(index, event.item.id);
68
+ }
69
+ const id = state.messageIds.get(index);
70
+ if (["response.output_item.added", "response.output_item.done"].includes(eventType)
71
+ && event.item?.type === "message") {
72
+ replaceId(event.item, "id", id);
73
+ } else if (MESSAGE_CONTENT_EVENT_TYPES.has(eventType)) {
74
+ replaceId(event, "item_id", id);
75
+ }
76
+ }
77
+ if (RESPONSES_TERMINAL_EVENT_TYPES.has(eventType) && Array.isArray(event.response?.output)) {
78
+ for (let index = 0; index < event.response.output.length; index += 1) {
79
+ const item = event.response.output[index];
80
+ if (item?.type === "message") replaceId(item, "id", state.messageIds.get(index));
81
+ }
82
+ }
83
+ return changed;
84
+ }
41
85
 
42
86
  function inspectResponseSseEvent(state, eventName, data) {
43
87
  if (!data || state.sawTerminal) return;
@@ -57,6 +101,7 @@ function inspectResponseSseEvent(state, eventName, data) {
57
101
  );
58
102
  }
59
103
  const eventType = event.type || eventName;
104
+ const changed = normalizeMessageIds(state, event, eventType);
60
105
  const outputTokens = event.response?.usage?.output_tokens ?? event.usage?.output_tokens;
61
106
  if (outputTokens !== undefined) markOutputTokens(outputTokens);
62
107
  if (!state.sawOutput && isResponsesOutputEvent(event, eventType)) {
@@ -66,7 +111,7 @@ function inspectResponseSseEvent(state, eventName, data) {
66
111
  if (["response.function_call_arguments.delta", "response.custom_tool_call_input.delta"].includes(eventType)) {
67
112
  state.toolArgumentGuard.observe(event.output_index ?? event.item_id ?? event.call_id, event.delta);
68
113
  }
69
- if (!RESPONSES_TERMINAL_EVENT_TYPES.has(eventType)) return;
114
+ if (!RESPONSES_TERMINAL_EVENT_TYPES.has(eventType)) return changed ? event : null;
70
115
  const expectedStatus = {
71
116
  "response.completed": "completed",
72
117
  "response.incomplete": "incomplete",
@@ -80,12 +125,14 @@ function inspectResponseSseEvent(state, eventName, data) {
80
125
  }
81
126
  state.sawTerminal = true;
82
127
  if (eventType === "response.failed" || eventType === "error") markStreamFailure();
83
- if (eventType !== "response.completed") return;
84
- const response = event.response;
85
- state.completed = { response, event };
128
+ if (eventType === "response.completed") {
129
+ const response = event.response;
130
+ state.completed = { response, event };
131
+ }
132
+ return changed ? event : null;
86
133
  }
87
134
 
88
- function createResponseSseInspector(state) {
135
+ function createResponseSseTransformer(state) {
89
136
  let eventBuffer = Buffer.allocUnsafe(Math.min(4096, MAX_SSE_BUFFER_BYTES + 4));
90
137
  let eventLength = 0;
91
138
  let previous1 = -1;
@@ -111,12 +158,12 @@ function createResponseSseInspector(state) {
111
158
  eventLength += bytes.byteLength;
112
159
  };
113
160
 
114
- const inspectEvent = (delimiterLength) => {
115
- const contentLength = eventLength - delimiterLength;
161
+ const inspectEvent = (frame, delimiterLength) => {
162
+ const contentLength = frame.byteLength - delimiterLength;
116
163
  if (contentLength > MAX_SSE_BUFFER_BYTES) {
117
164
  throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
118
165
  }
119
- const chunk = eventBuffer.subarray(0, contentLength).toString("utf8");
166
+ const chunk = frame.subarray(0, contentLength).toString("utf8");
120
167
  const lines = chunk.split(/\r?\n/);
121
168
  const eventName = lines
122
169
  .find((line) => line.startsWith("event:"))
@@ -126,15 +173,31 @@ function createResponseSseInspector(state) {
126
173
  .filter((line) => line.startsWith("data:"))
127
174
  .map((line) => line.slice(5).trimStart())
128
175
  .join("\n");
129
- if (data) inspectResponseSseEvent(state, eventName, data);
130
- eventLength = 0;
131
- previous1 = -1;
132
- previous2 = -1;
133
- previous3 = -1;
176
+ const changed = data && inspectResponseSseEvent(state, eventName, data);
177
+ if (!changed) return frame;
178
+ let wroteData = false;
179
+ const rewritten = lines.filter((line) => {
180
+ if (!line.startsWith("data:")) return true;
181
+ if (wroteData) return false;
182
+ wroteData = true;
183
+ return true;
184
+ }).map((line) => line.startsWith("data:") ? `data: ${JSON.stringify(changed)}` : line);
185
+ return Buffer.from(rewritten.join(chunk.includes("\r\n") ? "\r\n" : "\n")
186
+ + frame.subarray(contentLength).toString("ascii"));
134
187
  };
135
188
 
136
189
  return {
137
190
  push(value) {
191
+ // Fetch exposes Uint8Array chunks; use a zero-copy Buffer view for UTF-8 decoding.
192
+ if (!Buffer.isBuffer(value)) value = Buffer.from(value.buffer, value.byteOffset, value.byteLength);
193
+ let pending = [];
194
+ let pendingBytes = 0;
195
+ const flush = () => {
196
+ const output = pending.length === 1 ? pending[0] : Buffer.concat(pending, pendingBytes);
197
+ pending = [];
198
+ pendingBytes = 0;
199
+ return output;
200
+ };
138
201
  let segmentStart = 0;
139
202
  for (let index = 0; index < value.byteLength; index += 1) {
140
203
  const byte = value[index];
@@ -145,10 +208,32 @@ function createResponseSseInspector(state) {
145
208
  delimiterLength = previous3 === 0x0d ? 4 : 3;
146
209
  }
147
210
  if (delimiterLength) {
148
- append(value.subarray(segmentStart, index + 1));
149
- inspectEvent(delimiterLength);
211
+ let frame = value.subarray(segmentStart, index + 1);
212
+ if (eventLength > 0) {
213
+ append(frame);
214
+ // Writes may retain a Buffer after returning; never expose the reusable buffer.
215
+ if (eventLength > SSE_WRITE_BATCH_BYTES) {
216
+ frame = eventBuffer.subarray(0, eventLength);
217
+ eventBuffer = Buffer.allocUnsafe(Math.min(4096, MAX_SSE_BUFFER_BYTES + 4));
218
+ } else {
219
+ frame = Buffer.from(eventBuffer.subarray(0, eventLength));
220
+ }
221
+ }
222
+ eventLength = 0;
223
+ previous1 = -1;
224
+ previous2 = -1;
225
+ previous3 = -1;
150
226
  segmentStart = index + 1;
151
- if (state.sawTerminal) return index + 1;
227
+ const forwarded = inspectEvent(frame, delimiterLength);
228
+ const last = pending.at(-1);
229
+ if (last && last.buffer === forwarded.buffer
230
+ && last.byteOffset + last.byteLength === forwarded.byteOffset) {
231
+ pending[pending.length - 1] = Buffer.from(last.buffer, last.byteOffset, last.byteLength + forwarded.byteLength);
232
+ } else {
233
+ pending.push(forwarded);
234
+ }
235
+ pendingBytes += forwarded.byteLength;
236
+ if (state.sawTerminal) return flush();
152
237
  continue;
153
238
  }
154
239
  previous3 = previous2;
@@ -159,7 +244,7 @@ function createResponseSseInspector(state) {
159
244
  if (eventLength > MAX_SSE_BUFFER_BYTES + 3) {
160
245
  throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
161
246
  }
162
- return null;
247
+ return pendingBytes > 0 ? flush() : null;
163
248
  },
164
249
  };
165
250
  }
@@ -235,8 +320,9 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
235
320
  sawTerminal: false,
236
321
  sawOutput: false,
237
322
  toolArgumentGuard: new ToolArgumentDeltaGuard(),
323
+ messageIds: new Map(),
238
324
  };
239
- const inspector = createResponseSseInspector(streamState);
325
+ const transformer = createResponseSseTransformer(streamState);
240
326
  try {
241
327
  while (true) {
242
328
  const { done, value } = await reader.read();
@@ -247,9 +333,12 @@ export async function proxyCopilotResponses(reqContext, req, res, upstream = cop
247
333
  return { successful: Boolean(streamState.completed), compacted: false };
248
334
  }
249
335
  options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
250
- const terminalOffset = inspector.push(value);
251
- const forwarded = terminalOffset === null ? value : value.subarray(0, terminalOffset);
252
- if (forwarded.byteLength > 0 && !await writeOrDrain(res, forwarded)) return;
336
+ // Batch within this read, not across reads, and await drain before processing more.
337
+ for (let offset = 0; offset < value.byteLength; offset += SSE_WRITE_BATCH_BYTES) {
338
+ const frame = transformer.push(value.subarray(offset, offset + SSE_WRITE_BATCH_BYTES));
339
+ if (frame && !await writeOrDrain(res, frame)) return;
340
+ if (streamState.sawTerminal) break;
341
+ }
253
342
  if (streamState.sawTerminal) {
254
343
  storeCompletedResponse(reqContext, streamState.completed);
255
344
  res.end();