@wlv-zedd/dsh-chatgpt-web 1.0.0

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.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/assets/demo.gif +0 -0
  4. package/assets/hero-demo.png +0 -0
  5. package/assets/promo-dshmarket-official.png +0 -0
  6. package/cordis.patch.yml +4 -0
  7. package/lib/cli.js +239642 -0
  8. package/lib/plugin.js +195 -0
  9. package/package.json +88 -0
  10. package/screenshots.json +5 -0
  11. package/src/adapters/base.ts +16 -0
  12. package/src/adapters/chatgpt-web/adapter-error.ts +59 -0
  13. package/src/adapters/chatgpt-web/browser-helper-main.ts +513 -0
  14. package/src/adapters/chatgpt-web/browser-helper-prompt-selection.ts +27 -0
  15. package/src/adapters/chatgpt-web/browser-worker.ts +4944 -0
  16. package/src/adapters/chatgpt-web/codex-rollout-environment.ts +628 -0
  17. package/src/adapters/chatgpt-web/compaction-handoff.ts +533 -0
  18. package/src/adapters/chatgpt-web/compaction-transaction.ts +142 -0
  19. package/src/adapters/chatgpt-web/concurrency.ts +6 -0
  20. package/src/adapters/chatgpt-web/conversation-key.ts +58 -0
  21. package/src/adapters/chatgpt-web/environment.ts +669 -0
  22. package/src/adapters/chatgpt-web/index.ts +1544 -0
  23. package/src/adapters/chatgpt-web/input-tokens.ts +74 -0
  24. package/src/adapters/chatgpt-web/launcher-helper-client.ts +695 -0
  25. package/src/adapters/chatgpt-web/markdown.ts +418 -0
  26. package/src/adapters/chatgpt-web/mcp-main.ts +25 -0
  27. package/src/adapters/chatgpt-web/mcp-server.ts +933 -0
  28. package/src/adapters/chatgpt-web/model.ts +70 -0
  29. package/src/adapters/chatgpt-web/native-compaction-control.ts +74 -0
  30. package/src/adapters/chatgpt-web/output-validation.ts +62 -0
  31. package/src/adapters/chatgpt-web/process-line-writer.ts +46 -0
  32. package/src/adapters/chatgpt-web/prompt.ts +702 -0
  33. package/src/adapters/chatgpt-web/retry-policy.ts +73 -0
  34. package/src/adapters/chatgpt-web/rolling-checkpoint.ts +384 -0
  35. package/src/adapters/chatgpt-web/thread-environment.ts +238 -0
  36. package/src/adapters/chatgpt-web/tool-stream-parser.ts +601 -0
  37. package/src/adapters/chatgpt-web/turn-broker.ts +1481 -0
  38. package/src/adapters/chatgpt-web/turn-execution.ts +816 -0
  39. package/src/adapters/chatgpt-web/turn-progress.ts +292 -0
  40. package/src/adapters/chatgpt-web/usage.ts +121 -0
  41. package/src/adapters/image.ts +9 -0
  42. package/src/bridge.ts +1083 -0
  43. package/src/browser-login.ts +521 -0
  44. package/src/chatgpt-session.ts +240 -0
  45. package/src/chatgpt-web-models.ts +400 -0
  46. package/src/cli.ts +568 -0
  47. package/src/codex-integration-document.ts +824 -0
  48. package/src/codex-integration-journal.ts +212 -0
  49. package/src/codex-integration-route.ts +515 -0
  50. package/src/codex-integration-shared.ts +332 -0
  51. package/src/codex-integration.ts +529 -0
  52. package/src/codex-interrupt-hook.ts +158 -0
  53. package/src/config.ts +616 -0
  54. package/src/dev-chat/cli.ts +432 -0
  55. package/src/dev-chat/constants.ts +3 -0
  56. package/src/dev-chat/driver.ts +655 -0
  57. package/src/dev-chat/profile.ts +223 -0
  58. package/src/dev-chat/session.ts +287 -0
  59. package/src/dev-chat/transport.ts +54 -0
  60. package/src/doctor.ts +237 -0
  61. package/src/event-queue.ts +45 -0
  62. package/src/http-body.ts +30 -0
  63. package/src/launcher-browser-host.ts +695 -0
  64. package/src/lib/errors.ts +281 -0
  65. package/src/lib/token-estimate.ts +42 -0
  66. package/src/login-helper.cjs +140 -0
  67. package/src/model-catalog.ts +197 -0
  68. package/src/native-passthrough.ts +261 -0
  69. package/src/plugin.ts +191 -0
  70. package/src/process.ts +45 -0
  71. package/src/responses/compaction.ts +199 -0
  72. package/src/responses/parser.ts +633 -0
  73. package/src/responses/reasoning-envelope.ts +49 -0
  74. package/src/responses/schema.ts +172 -0
  75. package/src/responses/state.ts +230 -0
  76. package/src/server.ts +1111 -0
  77. package/src/service.ts +315 -0
  78. package/src/setup.ts +671 -0
  79. package/src/stall-timeout.ts +23 -0
  80. package/src/tunnel-service.ts +160 -0
  81. package/src/tunnel.ts +417 -0
  82. package/src/turndown-plugin-gfm.d.ts +5 -0
  83. package/src/types.ts +307 -0
  84. package/src/usage/totals.ts +12 -0
  85. package/src/version.ts +1 -0
package/src/bridge.ts ADDED
@@ -0,0 +1,1083 @@
1
+ import type { AdapterEvent, CodexMessagePhase, CodexProviderContinuationState, CodexUsage } from "./types";
2
+ import { adapterFailureFromMessage, classifyError, type CodexErrorPayload } from "./lib/errors";
3
+ import { encodeCompactionSummary } from "./responses/compaction";
4
+ import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope";
5
+ import { resolveStallTimeoutSec } from "./stall-timeout";
6
+ import { usageDisplayTotalTokens } from "./usage/totals";
7
+
8
+ function uuid(): string {
9
+ return crypto.randomUUID().replace(/-/g, "");
10
+ }
11
+
12
+ function sseEvent(name: string, data: Record<string, unknown>): string {
13
+ return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
14
+ }
15
+
16
+ function responsesUsage(usage: CodexUsage | undefined): Record<string, unknown> {
17
+ if (!usage) return { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
18
+ // inputTokens is already inclusive of cache read/write (types.ts convention).
19
+ const inputTokens = usage.inputTokens;
20
+ const out: Record<string, unknown> = {
21
+ input_tokens: inputTokens,
22
+ output_tokens: usage.outputTokens,
23
+ total_tokens: usageDisplayTotalTokens(usage) ?? inputTokens + usage.outputTokens,
24
+ };
25
+ const inputDetails: Record<string, number> = {};
26
+ if (usage.cachedInputTokens !== undefined) {
27
+ // cached_tokens carries cache READS only, matching OpenAI semantics.
28
+ inputDetails.cached_tokens = usage.cachedInputTokens;
29
+ }
30
+ if (usage.cacheCreationInputTokens !== undefined) {
31
+ inputDetails.cache_write_tokens = usage.cacheCreationInputTokens;
32
+ }
33
+ if (Object.keys(inputDetails).length > 0) {
34
+ out.input_tokens_details = inputDetails;
35
+ }
36
+ if (usage.reasoningOutputTokens !== undefined) {
37
+ out.output_tokens_details = { reasoning_tokens: usage.reasoningOutputTokens };
38
+ }
39
+ return out;
40
+ }
41
+
42
+ function responseError(status: number, type: string, message: string): CodexErrorPayload {
43
+ return classifyError(status, type, message);
44
+ }
45
+
46
+ function adapterFailureFromEvent(event: Extract<AdapterEvent, { type: "error" }>): { httpStatus: number; error: CodexErrorPayload } {
47
+ if (event.status === undefined && event.errorType === undefined && event.code === undefined) {
48
+ return adapterFailureFromMessage(event.message);
49
+ }
50
+ const fallback = adapterFailureFromMessage(event.message);
51
+ const httpStatus = event.status ?? fallback.httpStatus;
52
+ const error = classifyError(httpStatus, event.errorType ?? fallback.error.type, event.message);
53
+ if (event.errorType !== undefined) error.type = event.errorType;
54
+ if (event.code !== undefined) error.code = event.code;
55
+ return { httpStatus, error };
56
+ }
57
+
58
+ export { adapterFailureFromMessage } from "./lib/errors";
59
+
60
+ interface OutputItem {
61
+ type: string;
62
+ id: string;
63
+ [key: string]: unknown;
64
+ }
65
+
66
+ const PLAINTEXT_COLLABORATION_CALLS = new Set([
67
+ "spawn_agent",
68
+ "send_message",
69
+ "followup_task",
70
+ ]);
71
+
72
+ /**
73
+ * Codex MultiAgent V2 normally treats collaboration message arguments as backend ciphertext.
74
+ * An empty encrypted_function_args list is the protocol's explicit plaintext-delivery marker.
75
+ */
76
+ function plaintextCollaborationFields(namespace: string | undefined, name: string): Record<string, unknown> {
77
+ return namespace === "collaboration" && PLAINTEXT_COLLABORATION_CALLS.has(name)
78
+ ? { encrypted_function_args: [] }
79
+ : {};
80
+ }
81
+
82
+ export type ResponsesTerminalStatus = "completed" | "failed" | "incomplete";
83
+
84
+ export function bridgeToResponsesSSE(
85
+ events: AsyncIterable<AdapterEvent>,
86
+ modelId: string,
87
+ toolNsMap?: Map<string, { namespace: string; name: string }>,
88
+ freeformToolNames?: Set<string>,
89
+ toolSearchToolNames?: Set<string>,
90
+ onCancel?: () => void,
91
+ heartbeatMs = 2_000,
92
+ options?: {
93
+ responseId?: string;
94
+ stallTimeoutSec?: number;
95
+ hideThinkingSummary?: boolean;
96
+ /**
97
+ * Remote compaction v2 turn: accumulate all assistant text and, on done, emit ONE synthetic
98
+ * `{type:"compaction", encrypted_content:"ocx1:"+base64(text)}` output item before
99
+ * response.completed — codex-rs collect_compaction_output requires exactly one.
100
+ */
101
+ compaction?: boolean;
102
+ /** One-shot: first non-empty text/thinking/raw-reasoning delta observed (WP4 TTFT). */
103
+ onFirstOutput?: () => void;
104
+ onTerminal?: (status: ResponsesTerminalStatus) => void;
105
+ onCompletedResponse?: (response: Record<string, unknown>, providerState?: CodexProviderContinuationState) => void;
106
+ /** Test seam for the platform-specific Bun stream transport. */
107
+ streamPlatform?: NodeJS.Platform;
108
+ /** Test seam for the monotonic upstream-silence clock. */
109
+ now?: () => number;
110
+ },
111
+ ): ReadableStream<Uint8Array> {
112
+ // Freeform/custom tools (apply_patch) carry their body in `input`; the model is given a
113
+ // function with `{input:string}`, so unwrap it here when relaying back as a custom_tool_call.
114
+ const freeformInput = (args: string): string => {
115
+ try { const o = JSON.parse(args); if (o && typeof o.input === "string") return o.input; } catch { /* raw */ }
116
+ return args;
117
+ };
118
+ // Best-effort unwrap of a PARTIAL freeform arg buffer for live input streaming
119
+ // (`response.custom_tool_call_input.delta` — codex-rs uses it for UI preview only;
120
+ // the completed custom_tool_call item stays authoritative). Compact `{"input":"...`
121
+ // buffers get their string value progressively unescaped; anything else streams raw.
122
+ const FREEFORM_WRAP_PREFIX = '{"input":"';
123
+ const freeformPartialInput = (args: string): string => {
124
+ if (!args.startsWith(FREEFORM_WRAP_PREFIX)) return args;
125
+ const body = args.slice(FREEFORM_WRAP_PREFIX.length);
126
+ let out = "";
127
+ for (let i = 0; i < body.length; i++) {
128
+ const c = body[i];
129
+ if (c === '"') break; // unescaped closing quote: value complete
130
+ if (c === "\\") {
131
+ const n = body[i + 1];
132
+ if (n === undefined) break; // escape split across chunks: wait for more
133
+ i++;
134
+ if (n === "n") out += "\n";
135
+ else if (n === "t") out += "\t";
136
+ else if (n === "r") out += "\r";
137
+ else if (n === "u") {
138
+ const hex = body.slice(i + 1, i + 5);
139
+ if (hex.length === 4 && /^[0-9a-fA-F]{4}$/.test(hex)) { out += String.fromCharCode(parseInt(hex, 16)); i += 4; }
140
+ else break; // incomplete \uXXXX: wait for more
141
+ } else out += n; // \" \\ \/ etc.
142
+ } else out += c;
143
+ }
144
+ return out;
145
+ };
146
+ // tool_search_call carries arguments as a JSON object ({query, limit}); parse the model's arg string.
147
+ const parseArgsObj = (args: string): Record<string, unknown> => {
148
+ try { const o = JSON.parse(args); return o && typeof o === "object" ? o : {}; } catch { return {}; }
149
+ };
150
+ const encoder = new TextEncoder();
151
+ const responseId = options?.responseId ?? `resp_${uuid()}`;
152
+ let seq = 0;
153
+ // Set once the client is gone (cancel) or an enqueue throws on a torn-down controller, so we
154
+ // never enqueue again and never throw a second time inside start() — the RC2 double-throw that
155
+ // otherwise surfaced as proxy-side stream noise on every client disconnect.
156
+ let closed = false;
157
+ let clientCancelled = false;
158
+ let terminalReported = false;
159
+ const reportTerminal = (status: ResponsesTerminalStatus) => {
160
+ if (terminalReported || clientCancelled || closed) return;
161
+ terminalReported = true;
162
+ options?.onTerminal?.(status);
163
+ };
164
+ // RC3 keep-alive: Codex's idle timer is timeout(idle_timeout, stream.next()) over an
165
+ // eventsource_stream; ANY received event re-arms it, while an unknown type is ignored
166
+ // (responses.rs `_ => Ok(None)`). We emit a real, parser-ignored `response.heartbeat` only during
167
+ // upstream silence so a stalled routed provider never trips "idle timeout waiting for SSE".
168
+ let beat: ReturnType<typeof setInterval> | undefined;
169
+ let controller: ReadableStreamDefaultController<Uint8Array>;
170
+ let emittedFrames = 0;
171
+ let gated = false;
172
+ let stepping = false;
173
+ const emit = (name: string, data: Record<string, unknown>) => {
174
+ if (closed) return;
175
+ try {
176
+ controller.enqueue(encoder.encode(sseEvent(name, { type: name, sequence_number: seq++, ...data })));
177
+ emittedFrames++;
178
+ } catch {
179
+ closed = true;
180
+ }
181
+ };
182
+ const emitDone = () => {
183
+ if (closed) return;
184
+ try {
185
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
186
+ emittedFrames++;
187
+ } catch {
188
+ closed = true;
189
+ }
190
+ };
191
+
192
+ const createdAt = Math.floor(Date.now() / 1000);
193
+ let outputIndex = 0;
194
+ const finishedItems: OutputItem[] = [];
195
+
196
+ const responseSnapshot = (status: string, output: OutputItem[], endTurn?: boolean) => ({
197
+ id: responseId, object: "response", created_at: createdAt,
198
+ status, model: modelId, output, usage: null,
199
+ ...(endTurn !== undefined ? { end_turn: endTurn } : {}),
200
+ });
201
+
202
+ const heartbeatFrame = encoder.encode('event: response.heartbeat\ndata: {"type":"response.heartbeat"}\n\n');
203
+ let stallWarned = false;
204
+ const now = options?.now ?? (() => performance.now());
205
+ let lastAdapterEventAt = now();
206
+ let lastAdapterEventType = "<none>";
207
+ let adapterEventCount = 0;
208
+ const streamStartedAt = lastAdapterEventAt;
209
+ const stallSec = resolveStallTimeoutSec(options?.stallTimeoutSec);
210
+ const stallTimeoutMs = stallSec * 1000;
211
+
212
+ let currentMsg: { itemId: string; outputIndex: number; text: string; phase?: CodexMessagePhase } | null = null;
213
+ let currentReasoning: { itemId: string; outputIndex: number; text: string } | null = null;
214
+ let currentRawReasoning: { itemId: string; outputIndex: number; text: string } | null = null;
215
+ // Opaque signed-reasoning round-trip state: the signature signs the CURRENT thinking
216
+ // block; redacted blocks are opaque payloads replayed verbatim. Attached to the reasoning
217
+ // item as an ocxr1 encrypted_content envelope on close. hiddenThinkingText collects the
218
+ // suppressed text under hideThinkingSummary so the signed text still round-trips.
219
+ let pendingSignature: string | undefined;
220
+ let pendingRedacted: string[] = [];
221
+ let hiddenThinkingText = "";
222
+ const takeReasoningEnvelope = (hiddenText?: string): string | undefined => {
223
+ if (!pendingSignature && pendingRedacted.length === 0) return undefined;
224
+ const envelope: ReasoningEnvelope = {};
225
+ if (pendingSignature) envelope.sig = pendingSignature;
226
+ if (pendingRedacted.length > 0) envelope.red = pendingRedacted;
227
+ if (hiddenText) envelope.txt = hiddenText;
228
+ pendingSignature = undefined;
229
+ pendingRedacted = [];
230
+ return encodeReasoningEnvelope(envelope);
231
+ };
232
+ // hideThinkingSummary path: no visible reasoning item exists, but a signed thinking block
233
+ // must still round-trip — emit an envelope-only reasoning item (empty summary, no text leak).
234
+ const flushHiddenReasoningEnvelope = () => {
235
+ const encrypted = takeReasoningEnvelope(hiddenThinkingText || undefined);
236
+ hiddenThinkingText = "";
237
+ if (!encrypted) return;
238
+ const itemId = `rs_${uuid()}`;
239
+ const item = { type: "reasoning", id: itemId, summary: [] as never[], encrypted_content: encrypted };
240
+ emit("response.output_item.added", { output_index: outputIndex, item });
241
+ emit("response.output_item.done", { output_index: outputIndex, item });
242
+ finishedItems.push(item as OutputItem);
243
+ outputIndex++;
244
+ };
245
+ // hideThinkingSummary for raw reasoning: no
246
+ // visible reasoning item is emitted — the app renders nothing, so tool cells keep grouping
247
+ // like native models — but the text still round-trips in a txt-only ocxr1 envelope so
248
+ // preserveReasoningContentModels replay (GLM interleaved thinking) keeps working. Direct
249
+ // encodeReasoningEnvelope: takeReasoningEnvelope's sig/red guard would drop txt-only.
250
+ let hiddenRawReasoningText = "";
251
+ const flushHiddenRawReasoning = () => {
252
+ if (!hiddenRawReasoningText) return;
253
+ const encrypted = encodeReasoningEnvelope({ txt: hiddenRawReasoningText });
254
+ hiddenRawReasoningText = "";
255
+ const itemId = `rs_${uuid()}`;
256
+ const item = { type: "reasoning", id: itemId, summary: [] as never[], encrypted_content: encrypted };
257
+ emit("response.output_item.added", { output_index: outputIndex, item });
258
+ emit("response.output_item.done", { output_index: outputIndex, item });
259
+ finishedItems.push(item as OutputItem);
260
+ outputIndex++;
261
+ };
262
+ // Full assistant text of a compaction turn (across message boundaries) — becomes the
263
+ // synthetic compaction item's payload on done.
264
+ let compactionText = "";
265
+ let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; namespace?: string; freeform?: boolean; toolSearch?: boolean; inputEmitted?: string } | null = null;
266
+ const closeCurrentMessage = () => {
267
+ if (!currentMsg) return;
268
+ // Finalize the text part (Responses protocol). Without these .done events Codex never
269
+ // commits the content part and renders the message as truncated / cut off.
270
+ emit("response.output_text.done", {
271
+ item_id: currentMsg.itemId, output_index: currentMsg.outputIndex, content_index: 0, text: currentMsg.text,
272
+ });
273
+ emit("response.content_part.done", {
274
+ item_id: currentMsg.itemId, output_index: currentMsg.outputIndex, content_index: 0,
275
+ part: { type: "output_text", text: currentMsg.text, annotations: [] },
276
+ });
277
+ const item = {
278
+ type: "message", id: currentMsg.itemId, status: "completed", role: "assistant",
279
+ content: [{ type: "output_text", text: currentMsg.text, annotations: [] }],
280
+ ...(currentMsg.phase ? { phase: currentMsg.phase } : {}),
281
+ };
282
+ emit("response.output_item.done", { output_index: currentMsg.outputIndex, item });
283
+ finishedItems.push(item as OutputItem);
284
+ outputIndex++;
285
+ currentMsg = null;
286
+ };
287
+
288
+ const closeCurrentReasoning = () => {
289
+ if (!currentReasoning) return;
290
+ emit("response.reasoning_summary_text.done", {
291
+ item_id: currentReasoning.itemId, output_index: currentReasoning.outputIndex, summary_index: 0, text: currentReasoning.text,
292
+ });
293
+ emit("response.reasoning_summary_part.done", {
294
+ item_id: currentReasoning.itemId, output_index: currentReasoning.outputIndex, summary_index: 0,
295
+ part: { type: "summary_text", text: currentReasoning.text },
296
+ });
297
+ const encrypted = takeReasoningEnvelope();
298
+ const item = {
299
+ type: "reasoning", id: currentReasoning.itemId,
300
+ summary: [{ type: "summary_text", text: currentReasoning.text }],
301
+ ...(encrypted ? { encrypted_content: encrypted } : {}),
302
+ };
303
+ emit("response.output_item.done", { output_index: currentReasoning.outputIndex, item });
304
+ finishedItems.push(item as OutputItem);
305
+ outputIndex++;
306
+ currentReasoning = null;
307
+ };
308
+
309
+ const closeCurrentRawReasoning = () => {
310
+ if (!currentRawReasoning) return;
311
+ const item = {
312
+ type: "reasoning", id: currentRawReasoning.itemId, summary: [],
313
+ content: [{ type: "reasoning_text", text: currentRawReasoning.text }],
314
+ };
315
+ emit("response.output_item.done", { output_index: currentRawReasoning.outputIndex, item });
316
+ finishedItems.push(item as OutputItem);
317
+ outputIndex++;
318
+ currentRawReasoning = null;
319
+ };
320
+
321
+ const closeCurrentToolCall = () => {
322
+ if (!currentToolCall) return;
323
+ // Empty input (no-arg tools like computer_use get_app_state / list_apps) must serialize as
324
+ // "{}", never "" — Codex echoes the call back as a function_call next turn, and JSON.parse("")
325
+ // would 400 the whole session ("invalid JSON arguments"), poisoning all later turns.
326
+ const argsStr = currentToolCall.args || "{}";
327
+ // Finalize streamed function-call arguments so Codex commits the call (incl. MCP / computer_use).
328
+ if (!currentToolCall.freeform && !currentToolCall.toolSearch) {
329
+ emit("response.function_call_arguments.done", {
330
+ item_id: currentToolCall.itemId, output_index: currentToolCall.outputIndex, arguments: argsStr,
331
+ });
332
+ }
333
+ if (currentToolCall.freeform) {
334
+ emit("response.custom_tool_call_input.done", {
335
+ item_id: currentToolCall.itemId, output_index: currentToolCall.outputIndex,
336
+ input: freeformInput(currentToolCall.args),
337
+ });
338
+ }
339
+ const item = currentToolCall.toolSearch
340
+ ? {
341
+ type: "tool_search_call", id: currentToolCall.itemId,
342
+ call_id: currentToolCall.callId, execution: "client",
343
+ arguments: parseArgsObj(currentToolCall.args), status: "completed",
344
+ }
345
+ : currentToolCall.freeform
346
+ ? {
347
+ type: "custom_tool_call", id: currentToolCall.itemId,
348
+ call_id: currentToolCall.callId, name: currentToolCall.name,
349
+ input: freeformInput(currentToolCall.args), status: "completed",
350
+ }
351
+ : {
352
+ type: "function_call", id: currentToolCall.itemId,
353
+ call_id: currentToolCall.callId, name: currentToolCall.name,
354
+ arguments: argsStr, status: "completed",
355
+ ...(currentToolCall.namespace ? { namespace: currentToolCall.namespace } : {}),
356
+ ...plaintextCollaborationFields(currentToolCall.namespace, currentToolCall.name),
357
+ };
358
+ emit("response.output_item.done", { output_index: currentToolCall.outputIndex, item });
359
+ finishedItems.push(item as OutputItem);
360
+ outputIndex++;
361
+ currentToolCall = null;
362
+ };
363
+
364
+ // RC1: guarantee the Responses stream always ends with exactly one terminal event. Set true
365
+ // when a done/error/catch terminal is emitted; if the adapter generator returns without one
366
+ // we synthesize response.completed below, so Codex never hits the parser's
367
+ // "stream closed before response.completed" (responses.rs) -> ApiError::Stream.
368
+ let terminated = false;
369
+ let firstOutputReported = false;
370
+ const reportFirstOutput = (event: AdapterEvent): void => {
371
+ if (firstOutputReported) return;
372
+ const nonEmpty = event.type === "text_delta"
373
+ ? event.text.length > 0
374
+ : event.type === "thinking_delta"
375
+ ? event.thinking.length > 0
376
+ : event.type === "reasoning_raw_delta"
377
+ ? event.text.length > 0
378
+ : false;
379
+ if (!nonEmpty) return;
380
+ firstOutputReported = true;
381
+ try { options?.onFirstOutput?.(); } catch { /* metrics must not break the stream */ }
382
+ };
383
+ const it = events[Symbol.asyncIterator]();
384
+ let iteratorStarted = false;
385
+ let iteratorReturned = false;
386
+ let upstreamDone = false;
387
+ const returnIterator = () => {
388
+ if (iteratorReturned) return;
389
+ iteratorReturned = true;
390
+ const finishReturn = () => {
391
+ try {
392
+ void it.return?.()?.catch(() => {});
393
+ } catch {
394
+ /* synchronous iterator cleanup failure is also best-effort */
395
+ }
396
+ };
397
+ // Async-generator return() before the first next() does not enter the generator, so its
398
+ // finally blocks cannot cancel prepared upstream bodies. The cancel hook has already
399
+ // aborted the turn; bootstrap one cleanup step, then close the iterator without awaiting it.
400
+ if (!iteratorStarted) {
401
+ iteratorStarted = true;
402
+ try {
403
+ void it.next().then(finishReturn, () => {}).catch(() => {});
404
+ } catch {
405
+ /* synchronous iterator start failure is also best-effort */
406
+ }
407
+ return;
408
+ }
409
+ finishReturn();
410
+ };
411
+ const step = async () => {
412
+ if (stepping || closed) return;
413
+ stepping = true;
414
+ gated = false;
415
+ const emittedAtStart = emittedFrames;
416
+ try {
417
+ while (!terminated && !closed && emittedFrames === emittedAtStart) {
418
+ iteratorStarted = true;
419
+ const next = await it.next();
420
+ if (next.done) { upstreamDone = true; break; }
421
+ const event = next.value;
422
+ let terminalEvent = false;
423
+ lastAdapterEventAt = now();
424
+ lastAdapterEventType = event.type;
425
+ adapterEventCount += 1;
426
+ stallWarned = false;
427
+ reportFirstOutput(event);
428
+ // Compaction turns emit ONLY the synthetic compaction item + response.completed. The
429
+ // summary text is accumulated silently: emitting it as a normal assistant message would
430
+ // duplicate the summary if this response is ever replayed via previous_response_id
431
+ // expansion (rememberResponseState stores input + output). Codex ignores extra items but
432
+ // its compaction UI renders nothing mid-turn, so nothing is lost visually.
433
+ if (options?.compaction) {
434
+ if (event.type === "text_delta") { compactionText += event.text; continue; }
435
+ if (event.type !== "done" && event.type !== "incomplete" && event.type !== "error") continue;
436
+ }
437
+ switch (event.type) {
438
+ case "assistant_boundary": {
439
+ // A guarded continuation starts a fresh assistant output item while keeping the
440
+ // intermediate, suspicious text in the same Responses turn.
441
+ if (currentMsg) closeCurrentMessage();
442
+ if (currentReasoning) closeCurrentReasoning();
443
+ if (currentRawReasoning) closeCurrentRawReasoning();
444
+ flushHiddenRawReasoning();
445
+ if (currentToolCall) closeCurrentToolCall();
446
+ flushHiddenReasoningEnvelope();
447
+ break;
448
+ }
449
+ case "text_delta": {
450
+ if (currentReasoning) closeCurrentReasoning();
451
+ if (currentRawReasoning) closeCurrentRawReasoning();
452
+ flushHiddenRawReasoning();
453
+ if (currentToolCall) closeCurrentToolCall();
454
+ if (currentMsg && currentMsg.phase !== event.phase) closeCurrentMessage();
455
+ if (!currentMsg) {
456
+ const itemId = `msg_${uuid()}`;
457
+ const item = {
458
+ type: "message", id: itemId, status: "in_progress", role: "assistant",
459
+ content: [] as { type: string; text: string; annotations: never[] }[],
460
+ ...(event.phase ? { phase: event.phase } : {}),
461
+ };
462
+ emit("response.output_item.added", { output_index: outputIndex, item });
463
+ emit("response.content_part.added", {
464
+ item_id: itemId, output_index: outputIndex, content_index: 0,
465
+ part: { type: "output_text", text: "", annotations: [] },
466
+ });
467
+ currentMsg = { itemId, outputIndex, text: "", ...(event.phase ? { phase: event.phase } : {}) };
468
+ }
469
+ currentMsg.text += event.text;
470
+ emit("response.output_text.delta", {
471
+ item_id: currentMsg.itemId, output_index: currentMsg.outputIndex,
472
+ content_index: 0, delta: event.text,
473
+ });
474
+ break;
475
+ }
476
+ case "thinking_delta": {
477
+ if (options?.hideThinkingSummary) { hiddenThinkingText += event.thinking; break; }
478
+ if (currentMsg) closeCurrentMessage();
479
+ if (currentRawReasoning) closeCurrentRawReasoning();
480
+ flushHiddenRawReasoning();
481
+ if (currentToolCall) closeCurrentToolCall();
482
+ if (!currentReasoning) {
483
+ const itemId = `rs_${uuid()}`;
484
+ const item = { type: "reasoning", id: itemId, summary: [] as { type: string; text: string }[] };
485
+ emit("response.output_item.added", { output_index: outputIndex, item });
486
+ emit("response.reasoning_summary_part.added", {
487
+ item_id: itemId, output_index: outputIndex, summary_index: 0,
488
+ part: { type: "summary_text", text: "" },
489
+ });
490
+ currentReasoning = { itemId, outputIndex, text: "" };
491
+ }
492
+ currentReasoning.text += event.thinking;
493
+ emit("response.reasoning_summary_text.delta", {
494
+ item_id: currentReasoning.itemId, output_index: currentReasoning.outputIndex,
495
+ summary_index: 0, delta: event.thinking,
496
+ });
497
+ break;
498
+ }
499
+ case "thinking_signature": {
500
+ pendingSignature = event.signature;
501
+ // Signature arrives at the end of the thinking block. With a visible reasoning item
502
+ // open, closeCurrentReasoning attaches the envelope; hidden/suppressed blocks flush
503
+ // an envelope-only reasoning item now.
504
+ if (!currentReasoning) flushHiddenReasoningEnvelope();
505
+ break;
506
+ }
507
+ case "redacted_thinking": {
508
+ pendingRedacted.push(event.data);
509
+ break;
510
+ }
511
+ case "reasoning_raw_delta": {
512
+ if (options?.hideThinkingSummary) { hiddenRawReasoningText += event.text; break; }
513
+ if (currentMsg) closeCurrentMessage();
514
+ if (currentReasoning) closeCurrentReasoning();
515
+ if (currentToolCall) closeCurrentToolCall();
516
+ if (!currentRawReasoning) {
517
+ const itemId = `rs_${uuid()}`;
518
+ const item = { type: "reasoning", id: itemId, summary: [] as never[], content: [] as { type: string; text: string }[] };
519
+ emit("response.output_item.added", { output_index: outputIndex, item });
520
+ currentRawReasoning = { itemId, outputIndex, text: "" };
521
+ }
522
+ currentRawReasoning.text += event.text;
523
+ emit("response.reasoning_text.delta", {
524
+ item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex,
525
+ content_index: 0, delta: event.text,
526
+ });
527
+ break;
528
+ }
529
+ case "tool_call_start": {
530
+ if (currentMsg) closeCurrentMessage();
531
+ if (currentReasoning) closeCurrentReasoning();
532
+ if (currentRawReasoning) closeCurrentRawReasoning();
533
+ flushHiddenRawReasoning();
534
+ if (currentToolCall) closeCurrentToolCall();
535
+ const mapped = toolNsMap?.get(event.name);
536
+ const realName = mapped?.name ?? event.name;
537
+ const ns = mapped?.namespace;
538
+ const toolSearch = toolSearchToolNames?.has(realName) ?? false;
539
+ const freeform = !toolSearch && (freeformToolNames?.has(realName) ?? false);
540
+ const itemId = `${toolSearch ? "tsc" : freeform ? "ctc" : "fc"}_${uuid()}`;
541
+ const item = toolSearch
542
+ ? { type: "tool_search_call", id: itemId, call_id: event.id, execution: "client", arguments: {}, status: "in_progress" }
543
+ : freeform
544
+ ? { type: "custom_tool_call", id: itemId, call_id: event.id, name: realName, input: "", status: "in_progress" }
545
+ : {
546
+ type: "function_call", id: itemId, call_id: event.id, name: realName,
547
+ arguments: "", status: "in_progress", ...(ns ? { namespace: ns } : {}),
548
+ ...plaintextCollaborationFields(ns, realName),
549
+ };
550
+ emit("response.output_item.added", { output_index: outputIndex, item });
551
+ currentToolCall = { itemId, outputIndex, callId: event.id, name: realName, args: "", namespace: ns, freeform, toolSearch };
552
+ break;
553
+ }
554
+ case "tool_call_delta": {
555
+ if (currentToolCall) {
556
+ currentToolCall.args += event.arguments;
557
+ if (!currentToolCall.freeform && !currentToolCall.toolSearch) {
558
+ emit("response.function_call_arguments.delta", {
559
+ item_id: currentToolCall.itemId, output_index: currentToolCall.outputIndex,
560
+ delta: event.arguments,
561
+ });
562
+ }
563
+ if (currentToolCall.freeform) {
564
+ // Hold while the buffer is still an ambiguous prefix of the JSON wrapper,
565
+ // then stream only the unwrapped input suffix (never rewind on mode flips).
566
+ if (!FREEFORM_WRAP_PREFIX.startsWith(currentToolCall.args)) {
567
+ const full = freeformPartialInput(currentToolCall.args);
568
+ const emitted = currentToolCall.inputEmitted ?? "";
569
+ if (full.startsWith(emitted) && full.length > emitted.length) {
570
+ emit("response.custom_tool_call_input.delta", {
571
+ item_id: currentToolCall.itemId, output_index: currentToolCall.outputIndex,
572
+ delta: full.slice(emitted.length),
573
+ });
574
+ currentToolCall.inputEmitted = full;
575
+ }
576
+ }
577
+ }
578
+ }
579
+ break;
580
+ }
581
+ case "tool_call_end": {
582
+ closeCurrentToolCall();
583
+ break;
584
+ }
585
+ case "done": {
586
+ if (currentMsg) closeCurrentMessage();
587
+ if (currentReasoning) closeCurrentReasoning();
588
+ if (currentRawReasoning) closeCurrentRawReasoning();
589
+ flushHiddenRawReasoning();
590
+ if (currentToolCall) closeCurrentToolCall();
591
+ // Redacted-only turns (or hidden thinking without a trailing signature event) still
592
+ // need their envelope-only reasoning item so the blocks replay next turn.
593
+ flushHiddenReasoningEnvelope();
594
+ if (options?.compaction) {
595
+ // Exactly one compaction item per turn; codex-rs takes the first and fatals on 0.
596
+ const item = {
597
+ type: "compaction", id: `cmp_${uuid()}`,
598
+ encrypted_content: encodeCompactionSummary(compactionText),
599
+ };
600
+ emit("response.output_item.done", { output_index: outputIndex, item });
601
+ finishedItems.push(item as OutputItem);
602
+ outputIndex++;
603
+ }
604
+ if (event.stopReason === "max_tokens" || event.stopReason === "content_filter") {
605
+ // Upstream stopped before a normal completion. Surface as incomplete so the
606
+ // client can distinguish a truncated/filtered turn from a finished one.
607
+ const response = {
608
+ ...responseSnapshot("incomplete", finishedItems, event.endTurn),
609
+ usage: responsesUsage(event.usage),
610
+ incomplete_details: {
611
+ reason: event.stopReason === "max_tokens" ? "max_output_tokens" : "content_filter",
612
+ },
613
+ };
614
+ // Cache max-output partials so previous_response_id replay can continue them;
615
+ // rememberResponseState rejects content-filtered incomplete responses.
616
+ options?.onCompletedResponse?.(response, event.providerState);
617
+ emit("response.incomplete", { response });
618
+ reportTerminal("incomplete");
619
+ } else {
620
+ const response = { ...responseSnapshot("completed", finishedItems, event.endTurn), usage: responsesUsage(event.usage) };
621
+ options?.onCompletedResponse?.(response, event.providerState);
622
+ emit("response.completed", {
623
+ response,
624
+ });
625
+ reportTerminal("completed");
626
+ }
627
+ terminalEvent = true;
628
+ break;
629
+ }
630
+ case "incomplete": {
631
+ if (currentMsg) closeCurrentMessage();
632
+ if (currentReasoning) closeCurrentReasoning();
633
+ if (currentRawReasoning) closeCurrentRawReasoning();
634
+ flushHiddenRawReasoning();
635
+ if (currentToolCall) closeCurrentToolCall();
636
+ flushHiddenReasoningEnvelope();
637
+ emit("response.incomplete", {
638
+ response: {
639
+ ...responseSnapshot("incomplete", finishedItems, event.endTurn),
640
+ usage: responsesUsage(event.usage),
641
+ incomplete_details: {
642
+ reason: event.reason,
643
+ ...(event.message ? { message: event.message } : {}),
644
+ ...(event.retryable !== undefined ? { retryable: event.retryable } : {}),
645
+ },
646
+ },
647
+ });
648
+ reportTerminal("incomplete");
649
+ terminalEvent = true;
650
+ break;
651
+ }
652
+ case "error": {
653
+ if (currentMsg) closeCurrentMessage();
654
+ if (currentReasoning) closeCurrentReasoning();
655
+ if (currentRawReasoning) closeCurrentRawReasoning();
656
+ flushHiddenRawReasoning();
657
+ if (currentToolCall) closeCurrentToolCall();
658
+ const failure = adapterFailureFromEvent(event);
659
+ emit("response.failed", {
660
+ response: {
661
+ ...responseSnapshot("failed", finishedItems),
662
+ // Partial consumption from a mid-stream upstream failure: surfaced so the request
663
+ // log can record real tokens instead of usageStatus "unreported" with 0.
664
+ ...(event.usage ? { usage: responsesUsage(event.usage) } : {}),
665
+ error: failure.error,
666
+ last_error: failure.error,
667
+ ...(event.retryable !== undefined ? { retryable: event.retryable } : {}),
668
+ },
669
+ });
670
+ reportTerminal("failed");
671
+ terminalEvent = true;
672
+ break;
673
+ }
674
+ }
675
+ if (terminalEvent) {
676
+ onCancel?.();
677
+ terminated = true;
678
+ returnIterator();
679
+ break;
680
+ }
681
+ }
682
+ } catch (err) {
683
+ if (!terminated) {
684
+ flushHiddenRawReasoning();
685
+ emit("response.failed", {
686
+ response: {
687
+ ...responseSnapshot("failed", finishedItems),
688
+ error: responseError(500, "proxy_error", err instanceof Error ? err.message : String(err)),
689
+ last_error: responseError(500, "proxy_error", err instanceof Error ? err.message : String(err)),
690
+ },
691
+ });
692
+ reportTerminal("failed");
693
+ onCancel?.();
694
+ terminated = true;
695
+ returnIterator();
696
+ }
697
+ }
698
+
699
+ if (!terminated && !upstreamDone) {
700
+ gated = true;
701
+ stepping = false;
702
+ return;
703
+ }
704
+ if (beat) { clearInterval(beat); beat = undefined; }
705
+
706
+ if (!terminated) {
707
+ // The adapter generator ended without an explicit done/error event. Mark as incomplete
708
+ // rather than completed so Codex can distinguish a clean finish from a truncated stream.
709
+ if (currentMsg) closeCurrentMessage();
710
+ if (currentReasoning) closeCurrentReasoning();
711
+ if (currentRawReasoning) closeCurrentRawReasoning();
712
+ flushHiddenRawReasoning();
713
+ if (currentToolCall) closeCurrentToolCall();
714
+ emit("response.incomplete", {
715
+ response: {
716
+ ...responseSnapshot("incomplete", finishedItems),
717
+ usage: responsesUsage(undefined),
718
+ incomplete_details: { reason: "adapter_eof" },
719
+ },
720
+ });
721
+ reportTerminal("incomplete");
722
+ terminated = true;
723
+ }
724
+
725
+ emitDone();
726
+ try {
727
+ controller.close();
728
+ } catch {
729
+ /* already closed (e.g. client cancelled) */
730
+ }
731
+ closed = true;
732
+ gated = true;
733
+ stepping = false;
734
+ };
735
+
736
+ const startStream = () => {
737
+ emit("response.created", { response: responseSnapshot("in_progress", []) });
738
+ gated = true;
739
+ beat = setInterval(() => {
740
+ if (closed || gated) return;
741
+ const checkedAt = now();
742
+ const silenceMs = checkedAt - lastAdapterEventAt;
743
+ if (silenceMs >= stallTimeoutMs / 2 && !stallWarned) {
744
+ // Halfway to cancelling the turn. A healthy adapter heartbeats far more often than
745
+ // this, so reaching here at all means a keep-alive gap that should be found before it
746
+ // costs a user their turn.
747
+ stallWarned = true;
748
+ console.warn(
749
+ `[bridge] upstream silence halfway to the stall budget model=${modelId}`
750
+ + ` response=${responseId} stallSec=${stallSec} adapterEvents=${adapterEventCount}`
751
+ + ` lastEvent=${lastAdapterEventType} sinceLastEventMs=${silenceMs}`,
752
+ );
753
+ }
754
+ if (silenceMs >= stallTimeoutMs) {
755
+ console.error(
756
+ `[bridge] upstream_stall_timeout model=${modelId} response=${responseId}`
757
+ + ` stallSec=${stallSec} adapterEvents=${adapterEventCount}`
758
+ + ` lastEvent=${lastAdapterEventType} sinceLastEventMs=${silenceMs}`
759
+ + ` sinceStreamStartMs=${checkedAt - streamStartedAt}`
760
+ + ` iteratorStarted=${iteratorStarted} upstreamDone=${upstreamDone} emittedFrames=${emittedFrames}`,
761
+ );
762
+ if (currentMsg) closeCurrentMessage();
763
+ if (currentReasoning) closeCurrentReasoning();
764
+ if (currentRawReasoning) closeCurrentRawReasoning();
765
+ flushHiddenRawReasoning();
766
+ if (currentToolCall) closeCurrentToolCall();
767
+ emit("response.incomplete", {
768
+ response: {
769
+ ...responseSnapshot("incomplete", finishedItems),
770
+ incomplete_details: { reason: "upstream_stall_timeout" },
771
+ },
772
+ });
773
+ reportTerminal("incomplete");
774
+ onCancel?.();
775
+ terminated = true;
776
+ returnIterator();
777
+ emitDone();
778
+ if (beat) clearInterval(beat);
779
+ beat = undefined;
780
+ try { controller.close(); } catch { /* already closed */ }
781
+ closed = true;
782
+ return;
783
+ }
784
+ try {
785
+ controller.enqueue(heartbeatFrame);
786
+ emittedFrames++;
787
+ } catch {
788
+ closed = true;
789
+ }
790
+ }, heartbeatMs);
791
+ };
792
+
793
+ const waitForCapacity = async () => {
794
+ while (!closed && (controller.desiredSize ?? 1) <= 0) {
795
+ await new Promise<void>(resolve => setTimeout(resolve, 5));
796
+ }
797
+ };
798
+
799
+ const pump = async () => {
800
+ while (!closed) {
801
+ await waitForCapacity();
802
+ if (closed) return;
803
+ await step();
804
+ }
805
+ };
806
+
807
+ const cancelStream = () => {
808
+ // Client (Codex) disconnected. Stop emitting and let the caller abort the upstream fetch so a
809
+ // cancelled turn does not leak the upstream stream or keep draining tokens (RC2).
810
+ clientCancelled = true;
811
+ closed = true;
812
+ if (beat) clearInterval(beat);
813
+ onCancel?.();
814
+ returnIterator();
815
+ };
816
+
817
+ if ((options?.streamPlatform ?? process.platform) === "win32") {
818
+ // Returning a Promise from a ReadableStream pull() served by Bun on Windows hits Bun#32111's
819
+ // native teardown crash. Keep only Windows push-driven and retain HWM backpressure by polling
820
+ // desiredSize; Darwin/Linux use the native pull contract below.
821
+ return new ReadableStream<Uint8Array>({
822
+ start(streamController) {
823
+ controller = streamController;
824
+ startStream();
825
+ void pump().catch(error => {
826
+ if (closed) return;
827
+ closed = true;
828
+ if (beat) clearInterval(beat);
829
+ onCancel?.();
830
+ returnIterator();
831
+ try { controller.error(error); } catch { /* already closed */ }
832
+ });
833
+ },
834
+ cancel: cancelStream,
835
+ });
836
+ }
837
+
838
+ return new ReadableStream<Uint8Array>({
839
+ start(streamController) {
840
+ controller = streamController;
841
+ startStream();
842
+ },
843
+ pull() {
844
+ return step();
845
+ },
846
+ cancel: cancelStream,
847
+ });
848
+ }
849
+
850
+ export function buildResponseJSON(
851
+ events: AdapterEvent[],
852
+ modelId: string,
853
+ options?: {
854
+ hideThinkingSummary?: boolean;
855
+ toolNsMap?: Map<string, { namespace: string; name: string }>;
856
+ freeformToolNames?: Set<string>;
857
+ toolSearchToolNames?: Set<string>;
858
+ /** Remote compaction v2 turn — append one synthetic compaction output item (see bridgeToResponsesSSE). */
859
+ compaction?: boolean;
860
+ onProviderState?: (state: CodexProviderContinuationState) => void;
861
+ },
862
+ ): Record<string, unknown> {
863
+ const responseId = `resp_${uuid()}`;
864
+ const output: OutputItem[] = [];
865
+ let usage: CodexUsage | undefined;
866
+ let errorEvent: Extract<AdapterEvent, { type: "error" }> | undefined;
867
+ let incompleteEvent: Extract<AdapterEvent, { type: "incomplete" }> | undefined;
868
+ let endTurn: boolean | undefined;
869
+ let stopReason: string | undefined;
870
+ let compactionText = "";
871
+
872
+ let currentText = "";
873
+ let currentTextPhase: CodexMessagePhase | undefined;
874
+ let currentSummaryReasoning = "";
875
+ let currentRawReasoning = "";
876
+ // Opaque signed-reasoning round-trip (batch): see bridgeToResponsesSSE counterpart.
877
+ let batchSignature: string | undefined;
878
+ let batchRedacted: string[] = [];
879
+ let currentToolCallId = "";
880
+ let currentToolCallName = "";
881
+ let currentToolCallArgs = "";
882
+ const freeformInput = (args: string): string => {
883
+ try { const o = JSON.parse(args); if (o && typeof o.input === "string") return o.input; } catch { /* raw */ }
884
+ return args;
885
+ };
886
+ const parseArgsObj = (args: string): Record<string, unknown> => {
887
+ try { const o = JSON.parse(args); return o && typeof o === "object" ? o : {}; } catch { return {}; }
888
+ };
889
+
890
+ const flushText = () => {
891
+ if (!currentText) return;
892
+ output.push({
893
+ type: "message", id: `msg_${uuid()}`, role: "assistant", status: "completed",
894
+ content: [{ type: "output_text", text: currentText, annotations: [] }],
895
+ ...(currentTextPhase ? { phase: currentTextPhase } : {}),
896
+ });
897
+ currentText = "";
898
+ currentTextPhase = undefined;
899
+ };
900
+ const flushSummaryReasoning = () => {
901
+ if (!currentSummaryReasoning && !batchSignature && batchRedacted.length === 0) return;
902
+ const envelope: ReasoningEnvelope = {};
903
+ if (batchSignature) envelope.sig = batchSignature;
904
+ if (batchRedacted.length > 0) envelope.red = batchRedacted;
905
+ const hidden = options?.hideThinkingSummary === true;
906
+ if (hidden && currentSummaryReasoning && (envelope.sig || envelope.red)) envelope.txt = currentSummaryReasoning;
907
+ const encrypted = envelope.sig || envelope.red || envelope.txt ? encodeReasoningEnvelope(envelope) : undefined;
908
+ batchSignature = undefined;
909
+ batchRedacted = [];
910
+ if (hidden && !encrypted) { currentSummaryReasoning = ""; return; }
911
+ output.push({
912
+ type: "reasoning", id: `rs_${uuid()}`,
913
+ summary: !hidden && currentSummaryReasoning ? [{ type: "summary_text", text: currentSummaryReasoning }] : [],
914
+ ...(encrypted ? { encrypted_content: encrypted } : {}),
915
+ });
916
+ currentSummaryReasoning = "";
917
+ };
918
+ const flushRawReasoning = () => {
919
+ if (!currentRawReasoning) return;
920
+ if (options?.hideThinkingSummary === true) {
921
+ // Same contract as the streaming path: no visible reasoning, txt-only envelope round-trip.
922
+ output.push({
923
+ type: "reasoning", id: `rs_${uuid()}`, summary: [],
924
+ encrypted_content: encodeReasoningEnvelope({ txt: currentRawReasoning }),
925
+ });
926
+ currentRawReasoning = "";
927
+ return;
928
+ }
929
+ output.push({
930
+ type: "reasoning", id: `rs_${uuid()}`, summary: [],
931
+ content: [{ type: "reasoning_text", text: currentRawReasoning }],
932
+ });
933
+ currentRawReasoning = "";
934
+ };
935
+ const flushToolCall = () => {
936
+ if (!currentToolCallId) return;
937
+ const mapped = options?.toolNsMap?.get(currentToolCallName);
938
+ const realName = mapped?.name ?? currentToolCallName;
939
+ const ns = mapped?.namespace;
940
+ const toolSearch = options?.toolSearchToolNames?.has(realName) ?? false;
941
+ const freeform = !toolSearch && (options?.freeformToolNames?.has(realName) ?? false);
942
+ if (toolSearch) {
943
+ output.push({
944
+ type: "tool_search_call", id: `tsc_${uuid()}`,
945
+ call_id: currentToolCallId, execution: "client",
946
+ arguments: parseArgsObj(currentToolCallArgs), status: "completed",
947
+ });
948
+ } else if (freeform) {
949
+ output.push({
950
+ type: "custom_tool_call", id: `ctc_${uuid()}`,
951
+ call_id: currentToolCallId, name: realName,
952
+ input: freeformInput(currentToolCallArgs), status: "completed",
953
+ });
954
+ } else {
955
+ output.push({
956
+ type: "function_call", id: `fc_${uuid()}`,
957
+ call_id: currentToolCallId, name: realName,
958
+ arguments: currentToolCallArgs || "{}", status: "completed",
959
+ ...(ns ? { namespace: ns } : {}),
960
+ ...plaintextCollaborationFields(ns, realName),
961
+ });
962
+ }
963
+ currentToolCallId = "";
964
+ currentToolCallName = "";
965
+ currentToolCallArgs = "";
966
+ };
967
+
968
+ for (const e of events) {
969
+ switch (e.type) {
970
+ case "assistant_boundary":
971
+ flushText();
972
+ flushSummaryReasoning();
973
+ flushRawReasoning();
974
+ flushToolCall();
975
+ break;
976
+ case "text_delta":
977
+ if (currentText && currentTextPhase !== e.phase) flushText();
978
+ if (currentSummaryReasoning) flushSummaryReasoning();
979
+ if (currentRawReasoning) flushRawReasoning();
980
+ if (currentToolCallId) flushToolCall();
981
+ // Compaction turns keep the summary out of normal message output (replay dedup — see
982
+ // bridgeToResponsesSSE); it ships only inside the synthetic compaction item below.
983
+ if (options?.compaction) compactionText += e.text;
984
+ else {
985
+ currentTextPhase = e.phase;
986
+ currentText += e.text;
987
+ }
988
+ break;
989
+ case "thinking_delta":
990
+ if (currentText) flushText();
991
+ if (currentRawReasoning) flushRawReasoning();
992
+ if (currentToolCallId) flushToolCall();
993
+ currentSummaryReasoning += e.thinking;
994
+ break;
995
+ case "thinking_signature":
996
+ // End of the current thinking block — flush it WITH the signature envelope so the
997
+ // block/signature pairing survives multi-block turns.
998
+ batchSignature = e.signature;
999
+ flushSummaryReasoning();
1000
+ break;
1001
+ case "redacted_thinking":
1002
+ batchRedacted.push(e.data);
1003
+ break;
1004
+ case "reasoning_raw_delta":
1005
+ if (currentText) flushText();
1006
+ if (currentSummaryReasoning) flushSummaryReasoning();
1007
+ if (currentToolCallId) flushToolCall();
1008
+ currentRawReasoning += e.text;
1009
+ break;
1010
+ case "tool_call_start":
1011
+ if (currentText) flushText();
1012
+ if (currentSummaryReasoning) flushSummaryReasoning();
1013
+ if (currentRawReasoning) flushRawReasoning();
1014
+ flushToolCall();
1015
+ currentToolCallId = e.id;
1016
+ currentToolCallName = e.name;
1017
+ currentToolCallArgs = "";
1018
+ break;
1019
+ case "tool_call_delta":
1020
+ currentToolCallArgs += e.arguments;
1021
+ break;
1022
+ case "tool_call_end":
1023
+ flushToolCall();
1024
+ break;
1025
+ case "error":
1026
+ errorEvent = e;
1027
+ usage = e.usage ?? usage;
1028
+ break;
1029
+ case "incomplete":
1030
+ incompleteEvent = e;
1031
+ endTurn = e.endTurn;
1032
+ if (e.providerState) options?.onProviderState?.(e.providerState);
1033
+ break;
1034
+ case "done":
1035
+ usage = e.usage;
1036
+ endTurn = e.endTurn;
1037
+ if (e.providerState) options?.onProviderState?.(e.providerState);
1038
+ if (e.stopReason === "max_tokens") stopReason = "max_tokens";
1039
+ break;
1040
+ }
1041
+ }
1042
+ flushText();
1043
+ flushSummaryReasoning();
1044
+ flushRawReasoning();
1045
+ flushToolCall();
1046
+ // A truncated turn must never become replacement history. Emit a compaction item only after
1047
+ // authoritative turn completion.
1048
+ if (options?.compaction && !errorEvent && !incompleteEvent && stopReason !== "max_tokens") {
1049
+ output.push({ type: "compaction", id: `cmp_${uuid()}`, encrypted_content: encodeCompactionSummary(compactionText) });
1050
+ }
1051
+
1052
+ const failure = errorEvent ? adapterFailureFromEvent(errorEvent) : undefined;
1053
+ const status = errorEvent
1054
+ ? "failed"
1055
+ : incompleteEvent || stopReason === "max_tokens"
1056
+ ? "incomplete"
1057
+ : "completed";
1058
+ return {
1059
+ id: responseId, object: "response",
1060
+ created_at: Math.floor(Date.now() / 1000),
1061
+ status,
1062
+ model: modelId, output,
1063
+ ...(endTurn !== undefined ? { end_turn: endTurn } : {}),
1064
+ ...(failure ? { error: failure.error, last_error: failure.error } : {}),
1065
+ ...(errorEvent?.retryable !== undefined ? { retryable: errorEvent.retryable } : {}),
1066
+ ...(incompleteEvent ? {
1067
+ incomplete_details: {
1068
+ reason: incompleteEvent.reason,
1069
+ ...(incompleteEvent.message ? { message: incompleteEvent.message } : {}),
1070
+ ...(incompleteEvent.retryable !== undefined ? { retryable: incompleteEvent.retryable } : {}),
1071
+ },
1072
+ } : stopReason === "max_tokens" ? {
1073
+ incomplete_details: { reason: "max_output_tokens" },
1074
+ } : {}),
1075
+ usage: responsesUsage(incompleteEvent?.usage ?? usage),
1076
+ };
1077
+ }
1078
+
1079
+ export function formatErrorResponse(status: number, type: string, message: string): Response {
1080
+ return new Response(JSON.stringify({ error: classifyError(status, type, message) }), {
1081
+ status, headers: { "Content-Type": "application/json" },
1082
+ });
1083
+ }