pi-openai-codex-compat 0.0.3 → 0.0.4

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.
@@ -41,6 +41,12 @@ type ToolCallSlot = Extract<OutputSlot, { type: "toolCall" }>;
41
41
 
42
42
  type ProcessCodexStreamOptions = {
43
43
  applyServiceTierPricing?(usage: Usage, responseServiceTier: string | undefined): void;
44
+ attemptState?: CodexStreamAttemptState;
45
+ };
46
+
47
+ export type CodexStreamAttemptState = {
48
+ startedContentIndexes: Set<number>;
49
+ completedContentIndexes: Set<number>;
44
50
  };
45
51
 
46
52
  type CodexResponseStatus =
@@ -68,6 +74,18 @@ function stringValue(value: unknown): string {
68
74
  return typeof value === "string" ? value : "";
69
75
  }
70
76
 
77
+ function piToolCallName(item: JsonRecord): string {
78
+ const wireName = stringValue(item.name);
79
+ const name =
80
+ item["namespace"] === undefined
81
+ ? wireName
82
+ : namespacedToolCallName(item["namespace"], wireName);
83
+ if (item["namespace"] === undefined && CODEX_NAMESPACED_TOOL_NAMES.has(name)) {
84
+ throw new Error(`Codex returned namespaced tool "${name}" as a flat function call.`);
85
+ }
86
+ return name;
87
+ }
88
+
71
89
  function encodeTextSignature(id: string, phase: unknown): string {
72
90
  const payload: TextSignatureV1 = { v: 1, id };
73
91
  if (phase === "commentary" || phase === "final_answer") payload.phase = phase;
@@ -216,15 +234,24 @@ export async function processCodexStream(
216
234
  });
217
235
  };
218
236
 
237
+ const trackStarted = <TSlot extends OutputSlot>(slot: TSlot): TSlot => {
238
+ options?.attemptState?.startedContentIndexes.add(slot.contentIndex);
239
+ return slot;
240
+ };
241
+
242
+ const trackCompleted = (slot: OutputSlot): void => {
243
+ options?.attemptState?.completedContentIndexes.add(slot.contentIndex);
244
+ };
245
+
219
246
  const createSlot = (index: number, item: JsonRecord): OutputSlot | undefined => {
220
247
  if (item.type === "reasoning") {
221
248
  const block: ThinkingContent = { type: "thinking", thinking: "" };
222
249
  output.content.push(block);
223
- const slot = {
250
+ const slot = trackStarted({
224
251
  type: "thinking",
225
252
  block,
226
253
  contentIndex: output.content.length - 1,
227
- } satisfies OutputSlot;
254
+ } satisfies OutputSlot);
228
255
  slots.set(index, slot);
229
256
  stream.push({ type: "thinking_start", contentIndex: slot.contentIndex, partial: output });
230
257
  return slot;
@@ -233,24 +260,17 @@ export async function processCodexStream(
233
260
  applyMessagePhaseStopReason(item);
234
261
  const block: TextContent = { type: "text", text: "" };
235
262
  output.content.push(block);
236
- const slot = {
263
+ const slot = trackStarted({
237
264
  type: "text",
238
265
  block,
239
266
  contentIndex: output.content.length - 1,
240
- } satisfies OutputSlot;
267
+ } satisfies OutputSlot);
241
268
  slots.set(index, slot);
242
269
  stream.push({ type: "text_start", contentIndex: slot.contentIndex, partial: output });
243
270
  return slot;
244
271
  }
245
272
  if (item.type === "function_call") {
246
- const wireName = stringValue(item.name);
247
- const name =
248
- item["namespace"] === undefined
249
- ? wireName
250
- : namespacedToolCallName(item["namespace"], wireName);
251
- if (item["namespace"] === undefined && CODEX_NAMESPACED_TOOL_NAMES.has(name)) {
252
- throw new Error(`Codex returned namespaced tool "${name}" as a flat function call.`);
253
- }
273
+ const name = piToolCallName(item);
254
274
  const block: StreamingToolCall = {
255
275
  type: "toolCall",
256
276
  id: `${stringValue(item["call_id"])}|${stringValue(item.id)}`,
@@ -259,17 +279,17 @@ export async function processCodexStream(
259
279
  partialJson: typeof item.arguments === "string" ? item.arguments : "",
260
280
  };
261
281
  output.content.push(block);
262
- const slot = {
282
+ const slot = trackStarted({
263
283
  type: "toolCall",
264
284
  block,
265
285
  contentIndex: output.content.length - 1,
266
- } satisfies OutputSlot;
286
+ } satisfies OutputSlot);
267
287
  slots.set(index, slot);
268
288
  stream.push({ type: "toolcall_start", contentIndex: slot.contentIndex, partial: output });
269
289
  return slot;
270
290
  }
271
291
  if (item.type === "custom_tool_call") {
272
- const name = stringValue(item.name);
292
+ const name = piToolCallName(item);
273
293
  const property = grammarToolInputProperties.get(name) ?? "input";
274
294
  const input = typeof item["input"] === "string" ? item["input"] : "";
275
295
  const block: StreamingToolCall = {
@@ -283,11 +303,11 @@ export async function processCodexStream(
283
303
  },
284
304
  };
285
305
  output.content.push(block);
286
- const slot = {
306
+ const slot = trackStarted({
287
307
  type: "toolCall",
288
308
  block,
289
309
  contentIndex: output.content.length - 1,
290
- } satisfies OutputSlot;
310
+ } satisfies OutputSlot);
291
311
  slots.set(index, slot);
292
312
  stream.push({ type: "toolcall_start", contentIndex: slot.contentIndex, partial: output });
293
313
  return slot;
@@ -448,6 +468,7 @@ export async function processCodexStream(
448
468
  content: slot.block.thinking,
449
469
  partial: output,
450
470
  });
471
+ trackCompleted(slot);
451
472
  slots.delete(index);
452
473
  } else if (item.type === "message" && slot?.type === "text") {
453
474
  slot.block.text = itemContentText(item);
@@ -460,17 +481,14 @@ export async function processCodexStream(
460
481
  content: slot.block.text,
461
482
  partial: output,
462
483
  });
484
+ trackCompleted(slot);
463
485
  slots.delete(index);
464
486
  } else if (
465
487
  item.type === "function_call" &&
466
488
  slot?.type === "toolCall" &&
467
489
  slot.block.partialJson !== undefined
468
490
  ) {
469
- if (item["namespace"] !== undefined) {
470
- slot.block.name = namespacedToolCallName(item["namespace"], item.name);
471
- } else if (typeof item.name === "string" && CODEX_NAMESPACED_TOOL_NAMES.has(item.name)) {
472
- throw new Error(`Codex returned namespaced tool "${item.name}" as a flat function call.`);
473
- }
491
+ slot.block.name = piToolCallName(item);
474
492
  const argumentsJson =
475
493
  typeof item.arguments === "string" ? item.arguments : slot.block.partialJson || "{}";
476
494
  slot.block.arguments = parseStreamingJson(argumentsJson);
@@ -481,8 +499,10 @@ export async function processCodexStream(
481
499
  toolCall: slot.block,
482
500
  partial: output,
483
501
  });
502
+ trackCompleted(slot);
484
503
  slots.delete(index);
485
504
  } else if (item.type === "custom_tool_call" && slot?.type === "toolCall") {
505
+ slot.block.name = piToolCallName(item);
486
506
  const input = typeof item["input"] === "string" ? item["input"] : customInput(slot.block);
487
507
  pushToolDelta(slot, appendCustomInput(slot.block, input, true));
488
508
  delete slot.block.customInput;
@@ -492,6 +512,7 @@ export async function processCodexStream(
492
512
  toolCall: slot.block,
493
513
  partial: output,
494
514
  });
515
+ trackCompleted(slot);
495
516
  slots.delete(index);
496
517
  }
497
518
  } else if (
@@ -500,12 +521,17 @@ export async function processCodexStream(
500
521
  ) {
501
522
  finalize(event.response);
502
523
  } else if (event.type === "response.failed") {
503
- terminal = true;
504
524
  const response = isObject(event.response) ? event.response : undefined;
525
+ if (response) {
526
+ finalize(response);
527
+ } else {
528
+ terminal = true;
529
+ }
530
+ output.stopReason = "error";
531
+ output.rawStopReason ??= "failed";
505
532
  const error = isObject(response?.["error"]) ? response["error"] : undefined;
506
- throw new Error(
507
- typeof error?.["message"] === "string" ? error["message"] : "Codex response failed",
508
- );
533
+ output.errorMessage =
534
+ typeof error?.["message"] === "string" ? error["message"] : "Codex response failed";
509
535
  }
510
536
  }
511
537
 
@@ -0,0 +1,156 @@
1
+ import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
2
+ import { uuidv7 } from "@earendil-works/pi-ai";
3
+ import { codexCacheKey } from "./codex-cache-key.ts";
4
+
5
+ export const CODEX_THREAD_MARKER_ENTRY_TYPE = "openai-codex-compat-thread";
6
+
7
+ export type CodexThreadMarkerData = {
8
+ version: 1;
9
+ sessionId: string;
10
+ threadId: string;
11
+ forkedFromThreadId: string;
12
+ branchParentEntryId: string | null;
13
+ };
14
+
15
+ export type CodexThreadIdentity = {
16
+ threadId: string;
17
+ forkedFromThreadId?: string;
18
+ };
19
+
20
+ type PendingTreeFork = {
21
+ expectedLeafId: string | null;
22
+ };
23
+
24
+ function markerData(entry: SessionEntry, sessionId: string): CodexThreadMarkerData | undefined {
25
+ if (entry.type !== "custom" || entry.customType !== CODEX_THREAD_MARKER_ENTRY_TYPE) {
26
+ return undefined;
27
+ }
28
+ const data = entry.data;
29
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
30
+ throw new Error("The active Pi branch contains an invalid OpenAI Codex thread marker.");
31
+ }
32
+ const candidate = data as Record<string, unknown>;
33
+ if (
34
+ candidate["version"] !== 1 ||
35
+ typeof candidate["sessionId"] !== "string" ||
36
+ candidate["sessionId"].length === 0 ||
37
+ typeof candidate["threadId"] !== "string" ||
38
+ candidate["threadId"].length === 0 ||
39
+ typeof candidate["forkedFromThreadId"] !== "string" ||
40
+ candidate["forkedFromThreadId"].length === 0 ||
41
+ (candidate["branchParentEntryId"] !== null &&
42
+ typeof candidate["branchParentEntryId"] !== "string")
43
+ ) {
44
+ throw new Error("The active Pi branch contains an invalid OpenAI Codex thread marker.");
45
+ }
46
+ if (candidate["sessionId"] !== sessionId) return undefined;
47
+ if (entry.parentId !== candidate["branchParentEntryId"]) {
48
+ throw new Error("The active Pi branch contains a misplaced OpenAI Codex thread marker.");
49
+ }
50
+ return candidate as CodexThreadMarkerData;
51
+ }
52
+
53
+ function latestMarkerIndex(sessionId: string, branch: readonly SessionEntry[]): number {
54
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
55
+ if (markerData(branch[index]!, sessionId)) return index;
56
+ }
57
+ return -1;
58
+ }
59
+
60
+ export function resolveCodexThreadIdentity(
61
+ sessionId: string,
62
+ branch: readonly SessionEntry[],
63
+ ): CodexThreadIdentity {
64
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
65
+ const marker = markerData(branch[index]!, sessionId);
66
+ if (marker) {
67
+ return {
68
+ threadId: marker.threadId,
69
+ forkedFromThreadId: marker.forkedFromThreadId,
70
+ };
71
+ }
72
+ }
73
+ return { threadId: codexCacheKey(sessionId)! };
74
+ }
75
+
76
+ function shouldForkOnNextAppend(
77
+ sessionId: string,
78
+ branch: readonly SessionEntry[],
79
+ entries: readonly SessionEntry[],
80
+ ): boolean {
81
+ // Pi does not persist an explicit branch-start flag. Its append order is
82
+ // stable, so the first child inherits its thread and later children are
83
+ // forks unless the active path already contains a marker for that fork.
84
+ const firstChildByParent = new Map<string | null, string>();
85
+ for (const entry of entries) {
86
+ if (!firstChildByParent.has(entry.parentId)) {
87
+ firstChildByParent.set(entry.parentId, entry.id);
88
+ }
89
+ }
90
+
91
+ const markerIndex = latestMarkerIndex(sessionId, branch);
92
+ for (let index = markerIndex + 1; index < branch.length; index += 1) {
93
+ const entry = branch[index]!;
94
+ if (firstChildByParent.get(entry.parentId) !== entry.id) return true;
95
+ }
96
+
97
+ const leafId = branch.at(-1)?.id ?? null;
98
+ return entries.some((entry) => entry.parentId === leafId);
99
+ }
100
+
101
+ function armPendingFork(pending: Map<string, PendingTreeFork>, ctx: ExtensionContext): void {
102
+ const sessionId = ctx.sessionManager.getSessionId();
103
+ const branch = ctx.sessionManager.getBranch() as SessionEntry[];
104
+ const entries = ctx.sessionManager.getEntries() as SessionEntry[];
105
+ if (!shouldForkOnNextAppend(sessionId, branch, entries)) {
106
+ pending.delete(sessionId);
107
+ return;
108
+ }
109
+ pending.set(sessionId, { expectedLeafId: ctx.sessionManager.getLeafId() });
110
+ }
111
+
112
+ function pendingLeafIsActive(pending: PendingTreeFork, branch: readonly SessionEntry[]): boolean {
113
+ return (
114
+ pending.expectedLeafId === null || branch.some((entry) => entry.id === pending.expectedLeafId)
115
+ );
116
+ }
117
+
118
+ export default function registerCodexThreadLineage(pi: ExtensionAPI): void {
119
+ const pending = new Map<string, PendingTreeFork>();
120
+
121
+ pi.on("session_start", (_event, ctx) => {
122
+ armPendingFork(pending, ctx);
123
+ });
124
+
125
+ pi.on("session_tree", (_event, ctx) => {
126
+ armPendingFork(pending, ctx);
127
+ });
128
+
129
+ pi.on("message_end", (event, ctx) => {
130
+ if (event.message.role !== "user") return;
131
+ const sessionId = ctx.sessionManager.getSessionId();
132
+ const candidate = pending.get(sessionId);
133
+ if (!candidate) return;
134
+ pending.delete(sessionId);
135
+
136
+ const branch = ctx.sessionManager.getBranch() as SessionEntry[];
137
+ if (!pendingLeafIsActive(candidate, branch)) return;
138
+
139
+ const parent = resolveCodexThreadIdentity(sessionId, branch);
140
+ const branchParentEntryId = ctx.sessionManager.getLeafId();
141
+ // Pi invokes message_end handlers immediately before persisting the
142
+ // finalized user message. Advancing the leaf here makes this context-free
143
+ // marker the user's parent without writing anything during /tree itself.
144
+ pi.appendEntry<CodexThreadMarkerData>(CODEX_THREAD_MARKER_ENTRY_TYPE, {
145
+ version: 1,
146
+ sessionId,
147
+ threadId: uuidv7(),
148
+ forkedFromThreadId: parent.threadId,
149
+ branchParentEntryId,
150
+ });
151
+ });
152
+
153
+ pi.on("session_shutdown", (_event, ctx) => {
154
+ pending.delete(ctx.sessionManager.getSessionId());
155
+ });
156
+ }