@parall/codex-agent 1.51.0 → 1.52.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.
@@ -1 +1 @@
1
- {"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,eAAe,EACf,eAAe,EACf,YAAY,EACZ,QAAQ,EACR,iBAAiB,EACjB,aAAa,EACb,YAAY,EACb,MAAM,oBAAoB,CAAC;AAa5B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAEpD,OAAO,EAEL,KAAK,eAAe,EAErB,MAAM,2BAA2B,CAAC;AAGnC,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAGhE,KAAK,4BAA4B,GAAG,IAAI,CACtC,gBAAgB,EACd,gBAAgB,GAChB,UAAU,GACV,WAAW,GACX,OAAO,GACP,iBAAiB,GACjB,SAAS,GACT,cAAc,CACjB,GAAG;IACF,cAAc,EAAE,mBAAmB,CAAC;IACpC,GAAG,CAAC,EAAE,aAAa,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B;;;;;;OAMG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;;;;;;;;;;;;;OAiBG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,gFAAgF;IAChF,4BAA4B,CAAC,EAAE,MAAM,CAAC;IACtC,8EAA8E;IAC9E,4BAA4B,CAAC,EAAE,MAAM,CAAC;CACvC,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,qBAAa,qBAAsB,YAAW,eAAe;IA8C/C,OAAO,CAAC,QAAQ,CAAC,IAAI;IA7CjC,OAAO,CAAC,MAAM,CAAmC;IACjD,OAAO,CAAC,IAAI,CAA+C;IAC3D,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,YAAY,CAA8B;IAClD,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA+B;IAC3D,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA6B;IAC3D,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAA6B;IAC/D,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAqB;IACtD,0EAA0E;IAC1E,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAqB;IAC1D;;;;;;;;OAQG;IACH,OAAO,CAAC,mBAAmB,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAA8B;IAC/D,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAkC;IACxE,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,8BAA8B,CAAK;IAE3C;;;;;;;OAOG;IACH,OAAO,CAAC,aAAa;gBAYQ,IAAI,EAAE,4BAA4B;IAS/D,6EAA6E;IAC7E,kBAAkB,CAAC,GAAG,EAAE,eAAe,GAAG,MAAM,IAAI;IAKpD;;;;;OAKG;IACH,OAAO,CAAC,kBAAkB;IAU1B,YAAY,CAAC,MAAM,EAAE;QACnB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QACtB,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAChC,qBAAqB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;KACvC,GAAG,IAAI;IAQF,qBAAqB,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAqB/E,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAyBvC,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO;IAI1C,QAAQ,CAAC,EACd,KAAK,EACL,YAAY,EACZ,UAAU,EACV,OAAO,GACR,EAAE,YAAY,GAAG,aAAa,CAAC,YAAY,CAAC;IAoT7C;;;;;;OAMG;YACW,kBAAkB;IAoGhC,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAMjD,WAAW,CAAC,EAAE,UAAU,EAAE,gBAAgB,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;IA6ChG,WAAW,CAAC,EAAE,IAAI,EAAE,EAAE,eAAe;IAIrC,OAAO,CAAC,cAAc;IAKtB;;;;;;;;;;;;OAYG;IACH,qBAAqB,IAAI,IAAI;IAI7B,OAAO,CAAC,gBAAgB,CAAS;IAEjC;;;;OAIG;IACH,OAAO,CAAC,iBAAiB,CAAK;YAEhB,mBAAmB;IAS3B,IAAI;YA2BI,aAAa;YA4Bb,OAAO;IA8HrB,OAAO,CAAC,qBAAqB;YAsCf,UAAU;IAqDxB,OAAO,CAAC,iBAAiB;CAsD1B"}
package/dist/dispatch.js CHANGED
@@ -436,6 +436,11 @@ export class CodexAppServerAdapter {
436
436
  yield { ...runtimeEvent, project: false, groupKey };
437
437
  continue;
438
438
  }
439
+ if (runtimeEvent.type === 'turn_outcome') {
440
+ // Turn-boundary summary — no groupKey, passed through as-is.
441
+ yield runtimeEvent;
442
+ continue;
443
+ }
439
444
  yield { ...runtimeEvent, groupKey };
440
445
  }
441
446
  }
@@ -19,6 +19,13 @@ import type { RuntimeEvent } from '@parall/agent-core';
19
19
  */
20
20
  export declare class EventMapper {
21
21
  private readonly toolCallStart;
22
+ /**
23
+ * Latest thread/tokenUsage/updated snapshot for this turn. On the pinned
24
+ * app-server (0.144.x) `turn/completed.turn` carries NO usage field —
25
+ * token accounting arrives through this separate notification
26
+ * (`tokenUsage.last`), so the mapper buffers it for the turn boundary.
27
+ */
28
+ private lastTokenUsage;
22
29
  /**
23
30
  * Map a single server notification to zero or more RuntimeEvents.
24
31
  * Return `null` for notifications that have no chat surface.
@@ -1 +1 @@
1
- {"version":3,"file":"event-mapping.d.ts","sourceRoot":"","sources":["../src/event-mapping.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA6B;IAE3D;;;OAGG;IACH,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,YAAY,EAAE;IA8CpD,OAAO,CAAC,OAAO;IA4Jf;;;;;OAKG;IACH,OAAO,CAAC,eAAe;CAaxB"}
1
+ {"version":3,"file":"event-mapping.d.ts","sourceRoot":"","sources":["../src/event-mapping.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAA+B,MAAM,oBAAoB,CAAC;AAEpF;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA6B;IAC3D;;;;;OAKG;IACH,OAAO,CAAC,cAAc,CAAsC;IAE5D;;;OAGG;IACH,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,YAAY,EAAE;IA8DpD,OAAO,CAAC,OAAO;IA4Jf;;;;;OAKG;IACH,OAAO,CAAC,eAAe;CAaxB"}
@@ -18,6 +18,13 @@
18
18
  */
19
19
  export class EventMapper {
20
20
  toolCallStart = new Map();
21
+ /**
22
+ * Latest thread/tokenUsage/updated snapshot for this turn. On the pinned
23
+ * app-server (0.144.x) `turn/completed.turn` carries NO usage field —
24
+ * token accounting arrives through this separate notification
25
+ * (`tokenUsage.last`), so the mapper buffers it for the turn boundary.
26
+ */
27
+ lastTokenUsage;
21
28
  /**
22
29
  * Map a single server notification to zero or more RuntimeEvents.
23
30
  * Return `null` for notifications that have no chat surface.
@@ -45,6 +52,15 @@ export class EventMapper {
45
52
  events.push(...this.mapItem(item, 'completed'));
46
53
  break;
47
54
  }
55
+ case 'thread/tokenUsage/updated': {
56
+ // Buffered for the turn boundary — see the field comment.
57
+ const tokenUsage = p.tokenUsage;
58
+ const last = tokenUsage?.last;
59
+ if (last && typeof last === 'object') {
60
+ this.lastTokenUsage = last;
61
+ }
62
+ break;
63
+ }
48
64
  case 'turn/completed': {
49
65
  const turn = p.turn;
50
66
  const status = turn ? asString(turn.status) : undefined;
@@ -53,6 +69,12 @@ export class EventMapper {
53
69
  const message = asString(error?.message) ?? 'Codex turn failed';
54
70
  events.push({ type: 'error', message });
55
71
  }
72
+ // Turn-boundary classification (agent-turn-outcome-design.md §4.2):
73
+ // structured codexErrorInfo first, message families second, generic
74
+ // api_error otherwise. Usage rides along on both outcomes. Pushed
75
+ // after the error event so the gateway folds them in wire order.
76
+ events.push(buildCodexTurnOutcome(status, error, turn?.usage ?? this.lastTokenUsage));
77
+ this.lastTokenUsage = undefined;
56
78
  break;
57
79
  }
58
80
  case 'error': {
@@ -236,6 +258,124 @@ function asString(value) {
236
258
  const trimmed = value.trim();
237
259
  return trimmed.length > 0 ? trimmed : undefined;
238
260
  }
261
+ const CODEX_LIMIT_TEXT = /usage limit|rate limit|quota exceeded|plan limit/i;
262
+ const CODEX_AUTH_TEXT = /unauthorized|not logged in|invalid api key|authentication/i;
263
+ const CODEX_CONTEXT_TEXT = /context (window|length)|prompt is too long|request too large/i;
264
+ /**
265
+ * Extract the structured discriminator from a failed turn's error. On the
266
+ * pinned app-server (0.144.x) this is `error.codexErrorInfo` — a Rust enum
267
+ * serialized either as a bare string ("usageLimitExceeded") or as a
268
+ * single-key object ({"httpError": {"status": 429}}). Returns the variant
269
+ * name plus any HTTP status found inside the variant payload. `error.code`
270
+ * is kept as a secondary read for other/newer protocol shapes.
271
+ */
272
+ function codexErrorDiscriminator(error) {
273
+ const info = error?.codexErrorInfo;
274
+ if (typeof info === 'string')
275
+ return { variant: info };
276
+ if (info && typeof info === 'object') {
277
+ const keys = Object.keys(info);
278
+ if (keys.length > 0) {
279
+ const variant = keys[0];
280
+ const payload = info[variant];
281
+ let status;
282
+ if (payload && typeof payload === 'object') {
283
+ // Pinned protocol carries camelCase `httpStatusCode`; `status` is
284
+ // kept as a compatibility fallback only (review round 3 P1).
285
+ const rec = payload;
286
+ const s = rec.httpStatusCode ?? rec.status;
287
+ if (typeof s === 'number' && Number.isFinite(s))
288
+ status = s;
289
+ }
290
+ return { variant, status };
291
+ }
292
+ }
293
+ return {};
294
+ }
295
+ /**
296
+ * Classify a codex turn/completed frame into a TurnOutcomeEvent. The
297
+ * structured `codexErrorInfo` variant is the primary discriminator
298
+ * (usageLimitExceeded / contextWindowExceeded / unauthorized / HTTP-status
299
+ * variants), `error.code` and message families only refine, so unknown
300
+ * wording degrades to `api_error` — never to a wrong deferral. codex does
301
+ * not report a reset time, so usage_limit carries no retryAt and the server
302
+ * applies its default deferral window.
303
+ */
304
+ function buildCodexTurnOutcome(status, error, usageRaw) {
305
+ const usage = extractCodexUsage(usageRaw);
306
+ if (status !== 'failed') {
307
+ return { type: 'turn_outcome', outcome: 'ok', ...(usage ? { usage } : {}) };
308
+ }
309
+ const message = asString(error?.message) ?? '';
310
+ const { variant, status: httpStatus } = codexErrorDiscriminator(error);
311
+ const code = typeof error?.code === 'number' ? error.code : (asString(error?.code) ?? undefined);
312
+ const codeStr = httpStatus !== undefined ? String(httpStatus) : code === undefined ? '' : String(code);
313
+ const variantLower = (variant ?? '').toLowerCase();
314
+ // Structured discriminators (variant + HTTP status) rule first; message text
315
+ // only refines when neither can classify. Mixing text into the same OR as
316
+ // the variant let a `usageLimitExceeded` frame whose message happened to
317
+ // quote "authentication"/"rate limit" misclassify as auth and lose the
318
+ // deferred redrive — the exact read-no-reply regression this PR prevents.
319
+ let outcome = 'api_error';
320
+ if (variantLower.includes('unauthorized') || codeStr === '401' || codeStr === '403') {
321
+ outcome = 'auth';
322
+ }
323
+ else if (variantLower.includes('contextwindow') || codeStr === '413') {
324
+ outcome = 'context_overflow';
325
+ }
326
+ else if (variantLower.includes('usagelimit') ||
327
+ variantLower.includes('ratelimit') ||
328
+ codeStr === '429') {
329
+ outcome = 'usage_limit';
330
+ }
331
+ else if (CODEX_AUTH_TEXT.test(message)) {
332
+ outcome = 'auth';
333
+ }
334
+ else if (CODEX_CONTEXT_TEXT.test(message)) {
335
+ outcome = 'context_overflow';
336
+ }
337
+ else if (CODEX_LIMIT_TEXT.test(message)) {
338
+ outcome = 'usage_limit';
339
+ }
340
+ const raw = {};
341
+ if (variant)
342
+ raw.codex_error_info = variant;
343
+ if (httpStatus !== undefined)
344
+ raw.status = httpStatus;
345
+ if (code !== undefined)
346
+ raw.code = code;
347
+ return {
348
+ type: 'turn_outcome',
349
+ outcome,
350
+ ...(message ? { detail: message.slice(0, 500) } : {}),
351
+ ...(usage ? { usage } : {}),
352
+ ...(Object.keys(raw).length > 0 ? { raw } : {}),
353
+ };
354
+ }
355
+ /** codex app-server usage fields are camelCase; tolerate snake_case too. */
356
+ function extractCodexUsage(raw) {
357
+ if (!raw || typeof raw !== 'object')
358
+ return undefined;
359
+ const r = raw;
360
+ const num = (a, b) => {
361
+ if (typeof a === 'number' && Number.isFinite(a))
362
+ return a;
363
+ if (typeof b === 'number' && Number.isFinite(b))
364
+ return b;
365
+ return undefined;
366
+ };
367
+ const usage = {};
368
+ const input = num(r.inputTokens, r.input_tokens);
369
+ if (input !== undefined)
370
+ usage.inputTokens = input;
371
+ const output = num(r.outputTokens, r.output_tokens);
372
+ if (output !== undefined)
373
+ usage.outputTokens = output;
374
+ const cached = num(r.cachedInputTokens, r.cached_input_tokens);
375
+ if (cached !== undefined)
376
+ usage.cacheReadTokens = cached;
377
+ return Object.keys(usage).length > 0 ? usage : undefined;
378
+ }
239
379
  function joinReasoningText(item) {
240
380
  const summary = Array.isArray(item.summary)
241
381
  ? item.summary.filter((x) => typeof x === 'string')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/codex-agent",
3
- "version": "1.51.0",
3
+ "version": "1.52.0",
4
4
  "description": "Codex CLI bridge runtime for self-hosted Parall agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,9 +25,9 @@
25
25
  "src"
26
26
  ],
27
27
  "dependencies": {
28
- "@parall/agent-core": "1.51.0",
29
- "@parall/cli": "1.51.0",
30
- "@parall/sdk": "1.51.0"
28
+ "@parall/cli": "1.52.0",
29
+ "@parall/sdk": "1.52.0",
30
+ "@parall/agent-core": "1.52.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^22.0.0",
package/src/dispatch.ts CHANGED
@@ -531,6 +531,11 @@ export class CodexAppServerAdapter implements DispatchAdapter {
531
531
  yield { ...runtimeEvent, project: false, groupKey };
532
532
  continue;
533
533
  }
534
+ if (runtimeEvent.type === 'turn_outcome') {
535
+ // Turn-boundary summary — no groupKey, passed through as-is.
536
+ yield runtimeEvent;
537
+ continue;
538
+ }
534
539
  yield { ...runtimeEvent, groupKey };
535
540
  }
536
541
  } finally {
@@ -1,4 +1,4 @@
1
- import type { RuntimeEvent } from '@parall/agent-core';
1
+ import type { RuntimeEvent, TurnOutcomeEvent, TurnUsage } from '@parall/agent-core';
2
2
 
3
3
  /**
4
4
  * Translation layer between `codex app-server` JSON-RPC notifications and
@@ -20,6 +20,13 @@ import type { RuntimeEvent } from '@parall/agent-core';
20
20
  */
21
21
  export class EventMapper {
22
22
  private readonly toolCallStart = new Map<string, number>();
23
+ /**
24
+ * Latest thread/tokenUsage/updated snapshot for this turn. On the pinned
25
+ * app-server (0.144.x) `turn/completed.turn` carries NO usage field —
26
+ * token accounting arrives through this separate notification
27
+ * (`tokenUsage.last`), so the mapper buffers it for the turn boundary.
28
+ */
29
+ private lastTokenUsage: Record<string, unknown> | undefined;
23
30
 
24
31
  /**
25
32
  * Map a single server notification to zero or more RuntimeEvents.
@@ -50,6 +57,16 @@ export class EventMapper {
50
57
  break;
51
58
  }
52
59
 
60
+ case 'thread/tokenUsage/updated': {
61
+ // Buffered for the turn boundary — see the field comment.
62
+ const tokenUsage = p.tokenUsage as Record<string, unknown> | undefined;
63
+ const last = tokenUsage?.last;
64
+ if (last && typeof last === 'object') {
65
+ this.lastTokenUsage = last as Record<string, unknown>;
66
+ }
67
+ break;
68
+ }
69
+
53
70
  case 'turn/completed': {
54
71
  const turn = p.turn as Record<string, unknown> | undefined;
55
72
  const status = turn ? asString(turn.status) : undefined;
@@ -58,6 +75,12 @@ export class EventMapper {
58
75
  const message = asString(error?.message) ?? 'Codex turn failed';
59
76
  events.push({ type: 'error', message });
60
77
  }
78
+ // Turn-boundary classification (agent-turn-outcome-design.md §4.2):
79
+ // structured codexErrorInfo first, message families second, generic
80
+ // api_error otherwise. Usage rides along on both outcomes. Pushed
81
+ // after the error event so the gateway folds them in wire order.
82
+ events.push(buildCodexTurnOutcome(status, error, turn?.usage ?? this.lastTokenUsage));
83
+ this.lastTokenUsage = undefined;
61
84
  break;
62
85
  }
63
86
 
@@ -254,6 +277,122 @@ function asString(value: unknown): string | undefined {
254
277
  return trimmed.length > 0 ? trimmed : undefined;
255
278
  }
256
279
 
280
+ const CODEX_LIMIT_TEXT = /usage limit|rate limit|quota exceeded|plan limit/i;
281
+ const CODEX_AUTH_TEXT = /unauthorized|not logged in|invalid api key|authentication/i;
282
+ const CODEX_CONTEXT_TEXT = /context (window|length)|prompt is too long|request too large/i;
283
+
284
+ /**
285
+ * Extract the structured discriminator from a failed turn's error. On the
286
+ * pinned app-server (0.144.x) this is `error.codexErrorInfo` — a Rust enum
287
+ * serialized either as a bare string ("usageLimitExceeded") or as a
288
+ * single-key object ({"httpError": {"status": 429}}). Returns the variant
289
+ * name plus any HTTP status found inside the variant payload. `error.code`
290
+ * is kept as a secondary read for other/newer protocol shapes.
291
+ */
292
+ function codexErrorDiscriminator(error: Record<string, unknown> | undefined): {
293
+ variant?: string;
294
+ status?: number;
295
+ } {
296
+ const info = error?.codexErrorInfo;
297
+ if (typeof info === 'string') return { variant: info };
298
+ if (info && typeof info === 'object') {
299
+ const keys = Object.keys(info);
300
+ if (keys.length > 0) {
301
+ const variant = keys[0];
302
+ const payload = (info as Record<string, unknown>)[variant];
303
+ let status: number | undefined;
304
+ if (payload && typeof payload === 'object') {
305
+ // Pinned protocol carries camelCase `httpStatusCode`; `status` is
306
+ // kept as a compatibility fallback only (review round 3 P1).
307
+ const rec = payload as Record<string, unknown>;
308
+ const s = rec.httpStatusCode ?? rec.status;
309
+ if (typeof s === 'number' && Number.isFinite(s)) status = s;
310
+ }
311
+ return { variant, status };
312
+ }
313
+ }
314
+ return {};
315
+ }
316
+
317
+ /**
318
+ * Classify a codex turn/completed frame into a TurnOutcomeEvent. The
319
+ * structured `codexErrorInfo` variant is the primary discriminator
320
+ * (usageLimitExceeded / contextWindowExceeded / unauthorized / HTTP-status
321
+ * variants), `error.code` and message families only refine, so unknown
322
+ * wording degrades to `api_error` — never to a wrong deferral. codex does
323
+ * not report a reset time, so usage_limit carries no retryAt and the server
324
+ * applies its default deferral window.
325
+ */
326
+ function buildCodexTurnOutcome(
327
+ status: string | undefined,
328
+ error: Record<string, unknown> | undefined,
329
+ usageRaw: unknown,
330
+ ): TurnOutcomeEvent {
331
+ const usage = extractCodexUsage(usageRaw);
332
+ if (status !== 'failed') {
333
+ return { type: 'turn_outcome', outcome: 'ok', ...(usage ? { usage } : {}) };
334
+ }
335
+ const message = asString(error?.message) ?? '';
336
+ const { variant, status: httpStatus } = codexErrorDiscriminator(error);
337
+ const code = typeof error?.code === 'number' ? error.code : (asString(error?.code) ?? undefined);
338
+ const codeStr =
339
+ httpStatus !== undefined ? String(httpStatus) : code === undefined ? '' : String(code);
340
+ const variantLower = (variant ?? '').toLowerCase();
341
+ // Structured discriminators (variant + HTTP status) rule first; message text
342
+ // only refines when neither can classify. Mixing text into the same OR as
343
+ // the variant let a `usageLimitExceeded` frame whose message happened to
344
+ // quote "authentication"/"rate limit" misclassify as auth and lose the
345
+ // deferred redrive — the exact read-no-reply regression this PR prevents.
346
+ let outcome: TurnOutcomeEvent['outcome'] = 'api_error';
347
+ if (variantLower.includes('unauthorized') || codeStr === '401' || codeStr === '403') {
348
+ outcome = 'auth';
349
+ } else if (variantLower.includes('contextwindow') || codeStr === '413') {
350
+ outcome = 'context_overflow';
351
+ } else if (
352
+ variantLower.includes('usagelimit') ||
353
+ variantLower.includes('ratelimit') ||
354
+ codeStr === '429'
355
+ ) {
356
+ outcome = 'usage_limit';
357
+ } else if (CODEX_AUTH_TEXT.test(message)) {
358
+ outcome = 'auth';
359
+ } else if (CODEX_CONTEXT_TEXT.test(message)) {
360
+ outcome = 'context_overflow';
361
+ } else if (CODEX_LIMIT_TEXT.test(message)) {
362
+ outcome = 'usage_limit';
363
+ }
364
+ const raw: Record<string, string | number | boolean | null> = {};
365
+ if (variant) raw.codex_error_info = variant;
366
+ if (httpStatus !== undefined) raw.status = httpStatus;
367
+ if (code !== undefined) raw.code = code;
368
+ return {
369
+ type: 'turn_outcome',
370
+ outcome,
371
+ ...(message ? { detail: message.slice(0, 500) } : {}),
372
+ ...(usage ? { usage } : {}),
373
+ ...(Object.keys(raw).length > 0 ? { raw } : {}),
374
+ };
375
+ }
376
+
377
+ /** codex app-server usage fields are camelCase; tolerate snake_case too. */
378
+ function extractCodexUsage(raw: unknown): TurnUsage | undefined {
379
+ if (!raw || typeof raw !== 'object') return undefined;
380
+ const r = raw as Record<string, unknown>;
381
+ const num = (a: unknown, b: unknown): number | undefined => {
382
+ if (typeof a === 'number' && Number.isFinite(a)) return a;
383
+ if (typeof b === 'number' && Number.isFinite(b)) return b;
384
+ return undefined;
385
+ };
386
+ const usage: TurnUsage = {};
387
+ const input = num(r.inputTokens, r.input_tokens);
388
+ if (input !== undefined) usage.inputTokens = input;
389
+ const output = num(r.outputTokens, r.output_tokens);
390
+ if (output !== undefined) usage.outputTokens = output;
391
+ const cached = num(r.cachedInputTokens, r.cached_input_tokens);
392
+ if (cached !== undefined) usage.cacheReadTokens = cached;
393
+ return Object.keys(usage).length > 0 ? usage : undefined;
394
+ }
395
+
257
396
  function joinReasoningText(item: Record<string, unknown>): string {
258
397
  const summary = Array.isArray(item.summary)
259
398
  ? item.summary.filter((x) => typeof x === 'string')