mixdog 0.9.87 → 0.9.89

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 (167) hide show
  1. package/package.json +13 -7
  2. package/scripts/tool-failures.mjs +60 -24
  3. package/src/agents/heavy-worker/AGENT.md +3 -4
  4. package/src/agents/worker/AGENT.md +1 -2
  5. package/src/cli.mjs +8 -0
  6. package/src/defaults/cycle3-review-prompt.md +4 -4
  7. package/src/rules/agent/00-core.md +3 -0
  8. package/src/rules/agent/42-cycle3-agent.md +5 -6
  9. package/src/rules/lead/01-general.md +2 -0
  10. package/src/rules/shared/01-tool.md +29 -35
  11. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +3 -51
  12. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +59 -0
  13. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -2
  14. package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +67 -0
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +57 -4
  16. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +2 -3
  17. package/src/runtime/agent/orchestrator/config.mjs +29 -11
  18. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +0 -24
  21. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +3 -3
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +25 -17
  23. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +321 -22
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -22
  25. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +28 -23
  26. package/src/runtime/agent/orchestrator/providers/gemini.mjs +10 -2
  27. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +0 -1
  28. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +0 -1
  29. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +2 -5
  30. package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +27 -0
  31. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +23 -0
  32. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +329 -0
  33. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +23 -1
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +107 -20
  35. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +4 -3
  36. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +124 -12
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +19 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +110 -75
  41. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +96 -113
  42. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +160 -11
  43. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +18 -1
  44. package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +147 -117
  45. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +88 -0
  46. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +50 -8
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -0
  48. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +47 -6
  50. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +485 -24
  51. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +100 -2
  52. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -0
  53. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +80 -14
  54. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +142 -2
  55. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +128 -32
  56. package/src/runtime/agent/orchestrator/session/manager.mjs +5 -1
  57. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +77 -4
  58. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +70 -39
  59. package/src/runtime/agent/orchestrator/session/store/fs-probe.mjs +91 -0
  60. package/src/runtime/agent/orchestrator/session/store/listing.mjs +141 -64
  61. package/src/runtime/agent/orchestrator/session/store/live-state.mjs +253 -0
  62. package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +211 -28
  63. package/src/runtime/agent/orchestrator/session/store/save-fault.mjs +313 -0
  64. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +622 -53
  65. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +43 -10
  66. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +53 -8
  67. package/src/runtime/agent/orchestrator/session/store/summary-rebuild-worker.mjs +20 -0
  68. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +113 -9
  69. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +2 -0
  70. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +476 -52
  71. package/src/runtime/agent/orchestrator/session/store.mjs +721 -196
  72. package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -4
  73. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -0
  74. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +4 -6
  75. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  76. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +146 -90
  77. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +6 -0
  78. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +93 -17
  79. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -0
  80. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +7 -7
  81. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +6 -1
  82. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +17 -1
  83. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +85 -5
  84. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +52 -0
  85. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +54 -51
  86. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -15
  87. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +132 -8
  88. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +86 -3
  89. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +6 -1
  90. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +210 -38
  91. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +23 -1
  92. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +87 -19
  93. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  94. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -8
  95. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -2
  96. package/src/runtime/media/lanes.mjs +2 -2
  97. package/src/runtime/media/renditions.mjs +101 -17
  98. package/src/runtime/media/renditions.test.mjs +54 -0
  99. package/src/runtime/media/store.mjs +111 -43
  100. package/src/runtime/media/store.test.mjs +53 -11
  101. package/src/runtime/memory/index.mjs +26 -50
  102. package/src/runtime/memory/lib/core-memory-candidates.mjs +2 -7
  103. package/src/runtime/memory/lib/core-memory-store.mjs +2 -9
  104. package/src/runtime/memory/lib/cycle-scheduler.mjs +10 -0
  105. package/src/runtime/memory/lib/embedding-provider.mjs +3 -3
  106. package/src/runtime/memory/lib/embedding-worker.mjs +60 -15
  107. package/src/runtime/memory/lib/http-router.mjs +4 -0
  108. package/src/runtime/memory/lib/ko-morph.mjs +49 -6
  109. package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -18
  110. package/src/runtime/memory/lib/memory-cycle2.mjs +3 -3
  111. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -3
  112. package/src/runtime/memory/lib/memory-recall-store.mjs +24 -0
  113. package/src/runtime/memory/lib/query-handlers.mjs +26 -28
  114. package/src/runtime/memory/tool-defs.mjs +3 -3
  115. package/src/runtime/shared/atomic-file.mjs +5 -1
  116. package/src/runtime/shared/child-guardian.mjs +73 -2
  117. package/src/runtime/shared/provider-api-key.mjs +22 -5
  118. package/src/runtime/shared/tool-execution-contract.mjs +53 -0
  119. package/src/session-runtime/config-helpers.mjs +6 -5
  120. package/src/session-runtime/lifecycle-api.mjs +16 -4
  121. package/src/session-runtime/media-api.mjs +31 -16
  122. package/src/session-runtime/prewarm.mjs +2 -24
  123. package/src/session-runtime/runtime-core.mjs +33 -15
  124. package/src/session-runtime/runtime-tunables.mjs +0 -3
  125. package/src/session-runtime/session-lifecycle.mjs +0 -6
  126. package/src/session-runtime/session-title.mjs +228 -0
  127. package/src/session-runtime/session-turn-api.mjs +11 -7
  128. package/src/session-runtime/workflow-agents-api.mjs +16 -0
  129. package/src/session-runtime/workflow.mjs +7 -5
  130. package/src/standalone/agent-tool/job-views.mjs +90 -36
  131. package/src/standalone/agent-tool/spawn-flow.mjs +25 -216
  132. package/src/standalone/agent-tool/worker-index.mjs +8 -0
  133. package/src/standalone/agent-tool/worker-rows.mjs +2 -1
  134. package/src/standalone/agent-tool.mjs +17 -17
  135. package/src/standalone/agent-watchdog-registry.mjs +19 -2
  136. package/src/standalone/channel-daemon.mjs +8 -0
  137. package/src/standalone/explore-tool.mjs +1 -1
  138. package/src/standalone/memory-runtime-proxy.mjs +4 -7
  139. package/src/standalone/projects.mjs +4 -1
  140. package/src/standalone/usage-dashboard.mjs +25 -3
  141. package/src/tui/App.jsx +5 -3
  142. package/src/tui/app/resume-picker.mjs +7 -2
  143. package/src/tui/app/route-pickers.mjs +11 -117
  144. package/src/tui/app/slash-dispatch.mjs +13 -3
  145. package/src/tui/app/transcript-row-estimate.mjs +1 -1
  146. package/src/tui/app/transcript-window.mjs +17 -93
  147. package/src/tui/app/use-transcript-scroll.mjs +32 -2
  148. package/src/tui/app/use-transcript-window.mjs +64 -114
  149. package/src/tui/components/Markdown.jsx +19 -2
  150. package/src/tui/components/Message.jsx +8 -3
  151. package/src/tui/components/ToolExecution.jsx +7 -3
  152. package/src/tui/dist/index.mjs +243 -223
  153. package/src/tui/engine/live-share.mjs +15 -5
  154. package/src/tui/engine/render-timing.mjs +2 -2
  155. package/src/tui/engine/session-api-ext.mjs +74 -4
  156. package/src/tui/engine/session-flow.mjs +3 -1
  157. package/src/tui/engine.mjs +2 -2
  158. package/src/tui/hooks/useSharedTick.mjs +0 -2
  159. package/src/tui/index.jsx +5 -5
  160. package/src/ui/statusline-agents.mjs +44 -11
  161. package/src/vendor/statusline/bin/statusline-route.mjs +26 -3
  162. package/src/workflows/default/WORKFLOW.md +3 -3
  163. package/src/workflows/solo/WORKFLOW.md +5 -3
  164. package/src/runtime/media/index.mjs +0 -18
  165. package/src/runtime/memory/lib/embedding-warmup.mjs +0 -68
  166. package/src/standalone/agent-shard/shard-child.mjs +0 -300
  167. package/src/standalone/agent-shard/shard-pool.mjs +0 -443
@@ -6,7 +6,8 @@ import {
6
6
  createTimeoutSignal,
7
7
  providerTimeoutError,
8
8
  } from '../stall-policy.mjs';
9
- import { populateHttpStatusFromMessage } from './retry-classifier.mjs';
9
+ import { typedStatusFrom } from './retry-classifier.mjs';
10
+ import { stampStreamOutcome, STREAM_TRANSPORTS } from './lib/stream-outcome.mjs';
10
11
  import { customToolCallFromResponseItem } from './custom-tool-wire.mjs';
11
12
  import { createLeakGuard, createToolCallDedupe, dedupeToolCallList } from './anthropic-leaked-toolcall.mjs';
12
13
  import { randomBytes } from 'crypto';
@@ -295,6 +296,12 @@ export async function consumeCompatChatCompletionStream(stream, {
295
296
  // must be treated as permanent — the rendered text cannot be withdrawn and
296
297
  // a retry would concatenate a second attempt.
297
298
  let emittedText = false;
299
+ // Reasoning exposure invariant: reasoning_content / reasoning / thinking
300
+ // deltas are relayed to the client (onStreamDelta('reasoning')) and kept in
301
+ // the assembled message, so they are OBSERVED, VISIBLE output. A failure
302
+ // afterwards must never be replayed — a retry (or a non-streaming reset
303
+ // recovery) would duplicate the exposed reasoning.
304
+ let emittedReasoning = false;
298
305
  let model = '';
299
306
  let responseId = '';
300
307
  let stopReason = null;
@@ -327,6 +334,32 @@ export async function consumeCompatChatCompletionStream(stream, {
327
334
  return call;
328
335
  };
329
336
  const leakedCalls = [];
337
+ // Canonical stream-outcome stamp for EVERY reject path of this consumer.
338
+ // Without it the failure is "unknown" to the replay gates, and an upstream
339
+ // recovery (withRetry, transport fallback, non-streaming reset) could
340
+ // re-issue a turn whose text/reasoning was already relayed or whose tool
341
+ // call was already dispatched.
342
+ const _stampCompatOutcome = (err, extra = {}) => {
343
+ try {
344
+ stampStreamOutcome(err, {
345
+ transport: STREAM_TRANSPORTS.SSE,
346
+ provider: 'openai-compat',
347
+ terminalObserved: !!stopReason,
348
+ continuation: !stopReason,
349
+ textEmitted: emittedText === true,
350
+ textObservedChars: content.length,
351
+ reasoningEmitted: emittedReasoning === true || reasoningContent.length > 0,
352
+ toolCallsStarted: toolAcc.size > 0 || leakedCalls.length > 0,
353
+ toolCallsComplete: leakedCalls.length,
354
+ toolCallsDispatched: streamEmitState.emittedToolCall === true
355
+ ? Math.max(1, leakedCalls.length)
356
+ : 0,
357
+ pendingToolInput: toolAcc.size > 0,
358
+ ...extra,
359
+ });
360
+ } catch { /* stamping is best-effort */ }
361
+ return err;
362
+ };
330
363
  const relayText = (delta) => {
331
364
  const { text, calls } = leakGuard.push(delta);
332
365
  if (text) {
@@ -407,6 +440,7 @@ export async function consumeCompatChatCompletionStream(stream, {
407
440
  if (reasoningDelta !== null) {
408
441
  reasoningContent += reasoningDelta;
409
442
  if (reasoningDelta) {
443
+ emittedReasoning = true;
410
444
  reportProgress('reasoning');
411
445
  }
412
446
  }
@@ -432,7 +466,7 @@ export async function consumeCompatChatCompletionStream(stream, {
432
466
  err.pendingToolUse = toolAcc.size > 0 || leakedCalls.length > 0;
433
467
  err.partialModel = model || undefined;
434
468
  } catch { /* best-effort */ }
435
- throw markUnsafeRetryIfToolEmitted(err, streamEmitState);
469
+ throw _stampCompatOutcome(markUnsafeRetryIfToolEmitted(err, streamEmitState));
436
470
  }
437
471
  // Partial-final recovery: on a mid-stream stall, attach the
438
472
  // streamed partial state so the loop can accept a wedged FINAL no-tool
@@ -445,13 +479,14 @@ export async function consumeCompatChatCompletionStream(stream, {
445
479
  err.partialModel = model || undefined;
446
480
  } catch { /* best-effort */ }
447
481
  }
448
- throw markUnsafeRetryIfToolEmitted(err, streamEmitState);
482
+ throw _stampCompatOutcome(markUnsafeRetryIfToolEmitted(err, streamEmitState));
449
483
  } finally {
450
484
  firstByteTimeout.cleanup();
451
485
  }
452
486
  if (!sawFirstEvent) {
453
- if (firstByteTimeout.signal?.aborted) throw firstByteCompatStreamError(label);
454
- throw firstByteCompatStreamError(label);
487
+ // Pre-output: nothing was sampled, so this stays replay-safe.
488
+ if (firstByteTimeout.signal?.aborted) throw _stampCompatOutcome(firstByteCompatStreamError(label));
489
+ throw _stampCompatOutcome(firstByteCompatStreamError(label));
455
490
  }
456
491
  if (!stopReason) {
457
492
  const err = truncatedCompatStreamError(label, 'no finish_reason');
@@ -468,7 +503,7 @@ export async function consumeCompatChatCompletionStream(stream, {
468
503
  err.partialModel = model || undefined;
469
504
  } catch { /* best-effort */ }
470
505
  }
471
- throw markUnsafeRetryIfToolEmitted(err, streamEmitState);
506
+ throw _stampCompatOutcome(markUnsafeRetryIfToolEmitted(err, streamEmitState));
472
507
  }
473
508
  const message = {
474
509
  content: content || null,
@@ -496,7 +531,7 @@ export async function consumeCompatChatCompletionStream(stream, {
496
531
  try { err.message += ` finish_reason=${stopReason}`; } catch {}
497
532
  }
498
533
  if (emittedText) markErrorLiveTextEmitted(err);
499
- throw markUnsafeRetryIfToolEmitted(err, streamEmitState);
534
+ throw _stampCompatOutcome(markUnsafeRetryIfToolEmitted(err, streamEmitState));
500
535
  }
501
536
  if (Array.isArray(toolCalls) && toolCalls.length) {
502
537
  for (const call of toolCalls) emitCompatToolCallOnce(streamEmitState, call, onToolCall);
@@ -572,6 +607,11 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
572
607
  case 'response.reasoning_text.delta':
573
608
  case 'response.reasoning_summary_text.delta':
574
609
  if (event.delta) {
610
+ // Reasoning exposure latch (mirrors the Chat path): set at
611
+ // DELTA time, not at completion. Exposed reasoning cannot be
612
+ // withdrawn, so any later failure is non-replayable — a retry
613
+ // or non-streaming reset would duplicate it.
614
+ state.emittedReasoning = true;
575
615
  try { onStreamDelta?.('reasoning'); } catch {}
576
616
  }
577
617
  break;
@@ -737,7 +777,7 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
737
777
  else if (event.response.status === 'failed') {
738
778
  const msg = event.response?.error?.message || 'response.done failed';
739
779
  const err = new Error(`xAI Responses stream response.done failed: ${msg}`);
740
- populateHttpStatusFromMessage(err, msg);
780
+ _applyTypedResponsesFailure(err, event);
741
781
  throw err;
742
782
  } else if (event.response.status === 'incomplete') {
743
783
  const reason = incompleteReasonFromResponsesEvent(event);
@@ -764,11 +804,10 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
764
804
  case 'response.failed': {
765
805
  const msg = event.response?.error?.message || event.error?.message || event.message || 'response.failed';
766
806
  const err = new Error(`xAI Responses stream response.failed: ${msg}`);
767
- populateHttpStatusFromMessage(err, msg);
768
- // xAI's reference sampler treats protocol-level failed/error
769
- // events as synthetic HTTP 500s. Gate by the xAI stream label so
770
- // shared compat consumers retain their existing classification.
771
- if (String(label || '').toLowerCase().startsWith('xai')) err.httpStatus = 500;
807
+ // The wire event's OWN typed status/code is preserved verbatim. A
808
+ // forbidden/unknown failure is never coerced into a synthetic 500:
809
+ // without typed evidence it stays unclassified and is surfaced.
810
+ _applyTypedResponsesFailure(err, event);
772
811
  throw err;
773
812
  }
774
813
  case 'response.incomplete': {
@@ -791,8 +830,7 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
791
830
  case 'error': {
792
831
  const msg = event.message || event.error?.message || 'unknown';
793
832
  const err = new Error(`xAI Responses stream error: ${msg}`);
794
- populateHttpStatusFromMessage(err, msg);
795
- if (String(label || '').toLowerCase().startsWith('xai')) err.httpStatus = 500;
833
+ _applyTypedResponsesFailure(err, event);
796
834
  throw err;
797
835
  }
798
836
  default:
@@ -800,6 +838,20 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
800
838
  }
801
839
  }
802
840
 
841
+ // Copy the TYPED failure evidence a Responses `response.failed` / `error`
842
+ // event carries (numeric HTTP status, provider error code/type) onto the
843
+ // thrown error. Message text is never parsed, and nothing is synthesized when
844
+ // the event declares no typed status.
845
+ function _applyTypedResponsesFailure(err, event) {
846
+ const detail = event?.response?.error || event?.error || null;
847
+ const typed = typedStatusFrom(detail, event);
848
+ if (typed) err.httpStatus = typed;
849
+ const code = detail?.code ?? detail?.type ?? event?.code ?? null;
850
+ if (code != null && code !== '') err.providerErrorCode = String(code);
851
+ if (detail) err.providerError = detail;
852
+ return err;
853
+ }
854
+
803
855
  export async function consumeCompatResponsesStream(stream, {
804
856
  signal,
805
857
  label,
@@ -847,6 +899,10 @@ export async function consumeCompatResponsesStream(stream, {
847
899
  // has been forwarded. A later failure is non-retryable (rendered text
848
900
  // cannot be withdrawn; a retry would concatenate attempts).
849
901
  emittedText: false,
902
+ // Reasoning-exposure invariant, latched by
903
+ // handleCompatResponsesStreamEvent on the first non-empty reasoning
904
+ // delta (see the Chat path's emittedReasoning).
905
+ emittedReasoning: false,
850
906
  semanticIdleDeadlineAt: 0,
851
907
  };
852
908
  const reportProgress = (kind) => {
@@ -894,6 +950,34 @@ export async function consumeCompatResponsesStream(stream, {
894
950
  for (const c of calls) dispatchLeakedCall(c);
895
951
  };
896
952
  const deps = { label, parseResponsesToolCalls, responseOutputText, onStreamDelta: reportProgress, onToolCall, onTextDelta, relayLeakText };
953
+ // Canonical stream-outcome stamp for EVERY reject path of the Responses
954
+ // consumer — identical contract to the Chat consumer. Without it a failure
955
+ // is "unknown" to the replay gates and an upstream retry / transport
956
+ // fallback / non-streaming reset could duplicate exposed output.
957
+ const _toolInFlight = () => (state.pendingCalls?.size > 0)
958
+ || (state.toolTracker?.items?.size > 0)
959
+ || state.toolInFlight === true;
960
+ const _stampResponsesOutcome = (err, extra = {}) => {
961
+ try {
962
+ stampStreamOutcome(err, {
963
+ transport: STREAM_TRANSPORTS.SSE,
964
+ provider: 'openai-compat-responses',
965
+ terminalObserved: state.completed === true,
966
+ continuation: state.completed !== true,
967
+ textEmitted: state.emittedText === true,
968
+ textObservedChars: (state.content || '').length,
969
+ reasoningEmitted: state.emittedReasoning === true,
970
+ toolCallsStarted: state.toolCalls.length > 0 || leakedCalls.length > 0 || _toolInFlight(),
971
+ toolCallsComplete: state.toolCalls.length + leakedCalls.length,
972
+ toolCallsDispatched: state.emittedToolCall === true
973
+ ? Math.max(1, state.emittedToolCallKeys?.size || 0)
974
+ : 0,
975
+ pendingToolInput: _toolInFlight(),
976
+ ...extra,
977
+ });
978
+ } catch { /* stamping is best-effort */ }
979
+ return err;
980
+ };
897
981
  try {
898
982
  while (true) {
899
983
  const { value: event, done } = await nextAsyncWithWatchdog(iterator, {
@@ -930,13 +1014,14 @@ export async function consumeCompatResponsesStream(stream, {
930
1014
  err.partialModel = state.model || undefined;
931
1015
  } catch { /* best-effort */ }
932
1016
  }
933
- throw markUnsafeRetryIfToolEmitted(err, state);
1017
+ throw _stampResponsesOutcome(markUnsafeRetryIfToolEmitted(err, state));
934
1018
  } finally {
935
1019
  firstByteTimeout.cleanup();
936
1020
  }
937
1021
  if (!sawFirstEvent) {
938
- if (firstByteTimeout.signal?.aborted) throw firstByteCompatStreamError(label);
939
- throw firstByteCompatStreamError(label);
1022
+ // Pre-output: nothing was sampled, so this stays replay-safe.
1023
+ if (firstByteTimeout.signal?.aborted) throw _stampResponsesOutcome(firstByteCompatStreamError(label));
1024
+ throw _stampResponsesOutcome(firstByteCompatStreamError(label));
940
1025
  }
941
1026
  if (!state.completed) {
942
1027
  const err = truncatedCompatStreamError(label, 'no response.completed');
@@ -957,11 +1042,13 @@ export async function consumeCompatResponsesStream(stream, {
957
1042
  err.partialModel = state.model || undefined;
958
1043
  } catch { /* best-effort */ }
959
1044
  }
960
- throw err;
1045
+ throw _stampResponsesOutcome(err);
961
1046
  }
962
1047
  const unresolved = state.toolCalls.find(t => t._pendingItemId);
963
1048
  if (unresolved) {
964
- throw new Error(`xAI Responses stream function_call salvage failed: missing call_id/name for item_id=${unresolved._pendingItemId || '?'}`);
1049
+ throw _stampResponsesOutcome(new Error(
1050
+ `xAI Responses stream function_call salvage failed: missing call_id/name for item_id=${unresolved._pendingItemId || '?'}`,
1051
+ ));
965
1052
  }
966
1053
  const response = state.completedResponse || {
967
1054
  id: state.responseId || null,
@@ -286,9 +286,10 @@ export class OpenAICompatProvider {
286
286
  const structuredStatus = [err?.status, err?.httpStatus, err?.response?.status]
287
287
  .map(value => Number(value))
288
288
  .find(value => Number.isFinite(value) && value > 0) || 0;
289
- const status = structuredStatus > 0
290
- ? structuredStatus
291
- : (/\b401\b/.test(String(err?.message || '')) ? 401 : 0);
289
+ // Credential reload + reissue requires a TYPED 401. A message that
290
+ // merely mentions "401" is not evidence, and a typed 403 is a
291
+ // permission decision — reloading the key cannot change it.
292
+ const status = structuredStatus;
292
293
  if (status === 401) {
293
294
  if (err.liveTextEmitted === true || err.emittedToolCall === true || err.unsafeToRetry === true) {
294
295
  throw err;
@@ -22,11 +22,13 @@ import {
22
22
  createPassthroughSignal,
23
23
  } from '../stall-policy.mjs';
24
24
  import {
25
+ classifyError,
25
26
  jitterDelayMs,
26
- populateHttpStatusFromMessage,
27
27
  shouldFallbackTransport,
28
28
  sleepWithAbort,
29
+ typedStatusFrom,
29
30
  } from './retry-classifier.mjs';
31
+ import { stampStreamOutcome, readStreamOutcome, STREAM_TRANSPORTS } from './lib/stream-outcome.mjs';
30
32
  import { getLlmDispatcher } from '../../../shared/llm/http-agent.mjs';
31
33
  import { makeInvalidToolArgsMarker } from './openai-compat-stream.mjs';
32
34
  import { createLeakGuard, createToolCallDedupe, dedupeToolCallList } from './anthropic-leaked-toolcall.mjs';
@@ -111,6 +113,19 @@ function _isMaxOutputIncompleteReason(reason) {
111
113
  return /^(?:max_output_tokens|max_tokens|length|output_token_limit)$/i.test(String(reason || '').trim());
112
114
  }
113
115
 
116
+ // Wire-level `end_turn` on a terminal Responses frame (codex-rs
117
+ // codex-api/src/sse/responses.rs ResponseCompleted.end_turn: Option<bool>).
118
+ // Optional by contract: only a real boolean normalizes; a missing/non-boolean
119
+ // field stays undefined so absence is never collapsed into false.
120
+ export function _endTurnFromEvent(event) {
121
+ if (!event || typeof event !== 'object') return undefined;
122
+ const fromResponse = event.response?.end_turn;
123
+ if (typeof fromResponse === 'boolean') return fromResponse;
124
+ const topLevel = event.end_turn;
125
+ if (typeof topLevel === 'boolean') return topLevel;
126
+ return undefined;
127
+ }
128
+
114
129
  function _pushOutputTextAnnotations(part, citations, citationKeys) {
115
130
  const annotations = Array.isArray(part?.annotations) ? part.annotations : [];
116
131
  for (const raw of annotations) {
@@ -166,6 +181,9 @@ function _buildOpenAIHttpFallbackHeaders({ auth, cacheKey }) {
166
181
  // transport fallback, even if a caller has marked a prior WS attempt exhausted.
167
182
  export function _shouldUseOpenAIHttpFallback(err, externalSignal) {
168
183
  if (Number(err?.httpStatus || 0) === 429) return false;
184
+ // Codex switches WS→HTTPS on a typed transport failure; the only extra
185
+ // deny is exposure (relayed text / dispatched tool), which the shared
186
+ // predicate reads off the canonical record stamped by the WS transport.
169
187
  return shouldFallbackTransport(err, {
170
188
  signal: externalSignal,
171
189
  enabled: _envFlag('MIXDOG_OPENAI_OAUTH_HTTP_FALLBACK', true),
@@ -250,10 +268,26 @@ export async function sendViaHttpSse({
250
268
  }
251
269
 
252
270
  const retryableStatus = response && response.status >= 500 && response.status <= 599;
271
+ // Typed transient transport failures only (errno / SDK connection
272
+ // type). An unknown pre-response failure throws immediately instead of
273
+ // re-issuing the POST.
253
274
  const retryableTransport = !response && requestError
275
+ && classifyError(requestError) === 'transient'
254
276
  && !externalSignal?.aborted
255
277
  && !totalTimeout.signal?.aborted;
256
278
  if (attempt < CODEX_REQUEST_MAX_RETRIES && (retryableStatus || retryableTransport)) {
279
+ // Reissuing the POST is a REPLAY: allowed for a typed transient
280
+ // failure of the initial request, denied once the failure carries
281
+ // exposure evidence (relayed output / dispatched tool call).
282
+ const attemptFailure = requestError || Object.assign(
283
+ new Error(`OpenAI OAuth HTTP fallback ${response.status}`),
284
+ { httpStatus: response.status, headers: response.headers, initialResponseError: true },
285
+ );
286
+ if (readStreamOutcome(attemptFailure).replaySafe !== true) {
287
+ if (response) await response.arrayBuffer().catch(() => {});
288
+ totalTimeout.cleanup();
289
+ throw attemptFailure;
290
+ }
257
291
  // A non-success response has not exposed any streamed output. Drain
258
292
  // its body before reissuing so the dispatcher can reuse the socket.
259
293
  if (response) await response.arrayBuffer().catch(() => {});
@@ -269,6 +303,8 @@ export async function sendViaHttpSse({
269
303
  }
270
304
  if (requestError) {
271
305
  totalTimeout.cleanup();
306
+ // The initial response never arrived: nothing was sampled, so the
307
+ // typed rules upstream decide whether to retry.
272
308
  throw requestError;
273
309
  }
274
310
  break;
@@ -288,13 +324,13 @@ export async function sendViaHttpSse({
288
324
  const err = new Error(`OpenAI OAuth HTTP fallback ${response.status}: ${text.slice(0, 200)}`);
289
325
  err.httpStatus = response.status;
290
326
  err.headers = response.headers;
291
- populateHttpStatusFromMessage(err, text);
327
+ err.initialResponseError = true;
292
328
  totalTimeout.cleanup();
293
329
  throw err;
294
330
  }
295
331
  if (!response.body) {
296
332
  totalTimeout.cleanup();
297
- throw new Error('OpenAI OAuth HTTP fallback returned no response body');
333
+ throw Object.assign(new Error('OpenAI OAuth HTTP fallback returned no response body'), { initialResponseError: true });
298
334
  }
299
335
 
300
336
  try { onStreamDelta?.('transport'); } catch {}
@@ -423,12 +459,21 @@ export async function sendViaHttpSse({
423
459
  const webSearchCallKeys = new Set();
424
460
  let completed = false;
425
461
  let stopReason = null;
462
+ // Normalized wire `end_turn` from the terminal frame; undefined unless the
463
+ // server actually supplied a boolean.
464
+ let endTurn;
426
465
  // Gateway live-text relay invariant: set once a non-empty text chunk has
427
466
  // been forwarded to the client. A failure afterwards is non-retryable —
428
467
  // the rendered text cannot be withdrawn and a re-request would concatenate
429
468
  // a second attempt.
430
469
  let emittedText = false;
431
470
 
471
+ // Reasoning-exposure invariant: set the moment a reasoning/summary delta is
472
+ // seen (NOT at completion, where reasoningItems is assembled). Exposed
473
+ // reasoning is a replay boundary for retry, transport fallback and the
474
+ // reactive compact retry.
475
+ let emittedReasoning = false;
476
+
432
477
  // Tool-emit invariant (mirrors emittedText, WS path's emittedToolCall): set
433
478
  // once onToolCall has actually dispatched a call. A failure afterwards is
434
479
  // non-retryable — the side-effecting tool already ran, and any upstream
@@ -439,6 +484,32 @@ export async function sendViaHttpSse({
439
484
  if (emittedToolCall && err) { try { err.emittedToolCall = true; err.unsafeToRetry = true; } catch {} }
440
485
  return err;
441
486
  };
487
+ // Canonical stream-outcome contract for the HTTP/SSE transport. ONE hint
488
+ // builder is shared by the mid-stream catch and by every post-loop reject
489
+ // so no reject path can escape unstamped (an unstamped outcome is
490
+ // "unknown", which the consumers treat as fail-closed).
491
+ const _outcomeHints = (extra = {}) => ({
492
+ transport: STREAM_TRANSPORTS.HTTP_SSE,
493
+ provider: 'openai-responses',
494
+ terminalObserved: completed === true,
495
+ continuation: completed !== true,
496
+ textEmitted: emittedText === true,
497
+ textObservedChars: content.length,
498
+ reasoningEmitted: emittedReasoning === true || reasoningItems.length > 0,
499
+ toolCallsStarted: pendingCalls.size > 0 || activeToolItems.size > 0
500
+ || _toolInFlight === true || toolCalls.length > 0,
501
+ toolCallsComplete: toolCalls.length,
502
+ toolCallsDispatched: emittedToolCallIds.size,
503
+ pendingToolInput: pendingCalls.size > 0 || activeToolItems.size > 0 || _toolInFlight === true,
504
+ // Protocol distinction: a terminal frame carrying end_turn=false keeps
505
+ // the SAME user turn open — terminal observed, still a continuation.
506
+ ...(endTurn === false ? { continuationDeclared: true } : {}),
507
+ ...extra,
508
+ });
509
+ const _stampOutcome = (err, extra = {}) => {
510
+ try { stampStreamOutcome(err, _outcomeHints(extra)); } catch { /* best-effort */ }
511
+ return err;
512
+ };
442
513
 
443
514
  // Single-emit guard for tool calls (matches the WS path's
444
515
  // emittedToolCall intent). The HTTP/SSE event stream can surface the
@@ -593,7 +664,13 @@ export async function sendViaHttpSse({
593
664
  break;
594
665
  case 'response.reasoning_text.delta':
595
666
  case 'response.reasoning_summary_text.delta':
596
- if (event.delta) meaningful('reasoning');
667
+ if (event.delta) {
668
+ // Reasoning exposure is a replay boundary the MOMENT a delta
669
+ // arrives — not at response completion. A failure after this
670
+ // point must never be re-issued (duplicate exposed thinking).
671
+ emittedReasoning = true;
672
+ meaningful('reasoning');
673
+ }
597
674
  break;
598
675
  case 'response.output_item.added':
599
676
  if (event.item?.type === 'function_call') {
@@ -786,14 +863,24 @@ export async function sendViaHttpSse({
786
863
  }
787
864
  if (!reportedBundleProgress) meaningful('semantic');
788
865
  completed = true;
866
+ {
867
+ const wireEndTurn = _endTurnFromEvent(event);
868
+ if (typeof wireEndTurn === 'boolean') endTurn = wireEndTurn;
869
+ }
789
870
  break;
790
871
  }
791
872
  case 'response.done':
792
- if (!event.response || event.response.status === 'completed') completed = true;
793
- else if (event.response.status === 'failed') {
873
+ if (!event.response || event.response.status === 'completed') {
874
+ completed = true;
875
+ // Terminal success frame for streams that never emit a
876
+ // separate response.completed — same optional end_turn.
877
+ const wireEndTurn = _endTurnFromEvent(event);
878
+ if (typeof wireEndTurn === 'boolean') endTurn = wireEndTurn;
879
+ } else if (event.response.status === 'failed') {
794
880
  const msg = event.response?.error?.message || 'response.done failed';
795
881
  const err = new Error(`OpenAI OAuth HTTP fallback response.done failed: ${msg}`);
796
- populateHttpStatusFromMessage(err, msg);
882
+ const typed = typedStatusFrom(event.response?.error, event.error, event);
883
+ if (typed) err.httpStatus = typed;
797
884
  throw err;
798
885
  } else if (event.response.status === 'incomplete') {
799
886
  const reason = _incompleteReasonFromEvent(event);
@@ -822,7 +909,9 @@ export async function sendViaHttpSse({
822
909
  case 'response.failed': {
823
910
  const msg = event.response?.error?.message || event.error?.message || event.message || 'response.failed';
824
911
  const err = new Error(`OpenAI OAuth HTTP fallback response.failed: ${msg}`);
825
- populateHttpStatusFromMessage(err, msg);
912
+ // Typed status only — a text-only failure stays unclassified.
913
+ const typed = typedStatusFrom(event.response?.error, event.error, event);
914
+ if (typed) err.httpStatus = typed;
826
915
  throw err;
827
916
  }
828
917
  case 'response.incomplete': {
@@ -848,7 +937,8 @@ export async function sendViaHttpSse({
848
937
  case 'error': {
849
938
  const msg = event.message || event.error?.message || 'unknown';
850
939
  const err = new Error(`OpenAI OAuth HTTP fallback error: ${msg}`);
851
- populateHttpStatusFromMessage(err, msg);
940
+ const typed = typedStatusFrom(event.error, event);
941
+ if (typed) err.httpStatus = typed;
852
942
  throw err;
853
943
  }
854
944
  default:
@@ -903,6 +993,8 @@ export async function sendViaHttpSse({
903
993
  // Tool-emit invariant: an error after a dispatched tool call must not
904
994
  // reissue the turn (double-execution). Stamp emittedToolCall too.
905
995
  _stampToolSafety(err);
996
+ // Canonical record (same shape as the WS path); aliases above preserved.
997
+ _stampOutcome(err);
906
998
  throw err;
907
999
  } finally {
908
1000
  _pendingReadReject = null;
@@ -917,10 +1009,28 @@ export async function sendViaHttpSse({
917
1009
 
918
1010
  const unresolved = toolCalls.find(t => t._pendingItemId);
919
1011
  if (unresolved) {
920
- throw _stampToolSafety(new Error(`OpenAI OAuth HTTP fallback function_call salvage failed: missing call_id/name for item_id=${unresolved._pendingItemId || '?'}`));
1012
+ throw _stampOutcome(_stampToolSafety(new Error(
1013
+ `OpenAI OAuth HTTP fallback function_call salvage failed: missing call_id/name for item_id=${unresolved._pendingItemId || '?'}`,
1014
+ )));
921
1015
  }
922
- if (!completed && !content && !toolCalls.length) {
923
- throw _stampToolSafety(new Error('OpenAI OAuth HTTP fallback ended before response.completed'));
1016
+ // EOF without a terminal frame is ALWAYS a failure, regardless of how much
1017
+ // partial text or how many tool calls were streamed. The turn has no
1018
+ // terminal signal, so it is a continuation: returning it would report an
1019
+ // unfinished sample as a completed assistant turn (and, with tool calls
1020
+ // already dispatched, a half-finished side-effecting turn). The partial
1021
+ // rides on the error for interrupted-turn persistence.
1022
+ if (!completed) {
1023
+ const err = _stampToolSafety(new Error(
1024
+ `OpenAI OAuth HTTP fallback ended before response.completed `
1025
+ + `(text=${content.length} chars, toolCalls=${toolCalls.length})`,
1026
+ ));
1027
+ try {
1028
+ err.partialContent = content;
1029
+ err.partialToolCalls = toolCalls.length ? toolCalls.slice() : undefined;
1030
+ err.partialModel = model || undefined;
1031
+ err.pendingToolUse = pendingCalls.size > 0 || activeToolItems.size > 0 || _toolInFlight === true;
1032
+ } catch { /* best-effort enrichment */ }
1033
+ throw _stampOutcome(err, { terminalObserved: false, continuation: true });
924
1034
  }
925
1035
 
926
1036
  const liveModel = model || useModel;
@@ -963,6 +1073,8 @@ export async function sendViaHttpSse({
963
1073
  webSearchCalls: webSearchCalls.length ? webSearchCalls : undefined,
964
1074
  usage: usage || undefined,
965
1075
  stopReason: stopReason || undefined,
1076
+ // Only present when the terminal frame carried the wire field.
1077
+ ...(typeof endTurn === 'boolean' ? { endTurn } : {}),
966
1078
  // P1 audit fix: text-only max-output cutoff (openai-oauth HTTP/SSE
967
1079
  // fallback maps status:'incomplete'/reason=max_output_tokens to
968
1080
  // stopReason='length' above and treats it as success). Flag it so
@@ -40,6 +40,7 @@ import {
40
40
  MIDSTREAM_RETRY_POLICY,
41
41
  sleepWithAbort,
42
42
  } from './retry-classifier.mjs';
43
+ import { stampStreamOutcome, STREAM_TRANSPORTS } from './lib/stream-outcome.mjs';
43
44
  import {
44
45
  WS_IDLE_MS,
45
46
  acquireWebSocket,
@@ -977,15 +978,31 @@ export async function sendViaWebSocket({
977
978
  // duplicate exposed thinking/tool argument streams and can make a
978
979
  // partially generated side-effecting call diverge. Keep the Codex
979
980
  // retry budget only for failures before any such model output.
981
+ // Exposed reasoning is a visibility boundary. A tool call that only
982
+ // STARTED assembling was never dispatched, so it is recorded but is
983
+ // NOT a side effect and must not veto a retry.
980
984
  if (midState.emittedReasoning || midState.startedToolCall) {
981
985
  try {
982
- err.unsafeToRetry = true;
983
- if (midState.emittedReasoning) err.partialReasoningEmitted = true;
986
+ if (midState.emittedReasoning) {
987
+ err.unsafeToRetry = true;
988
+ err.partialReasoningEmitted = true;
989
+ }
984
990
  if (midState.startedToolCall) err.partialToolCallStarted = true;
985
991
  } catch {}
986
992
  }
987
993
  _stampLiveText(err);
988
994
  _stampTool(err);
995
+ // Canonical stream-outcome record for the WS transport, merged
996
+ // with the cross-attempt safety latches above. _streamResponse
997
+ // already stamps its own reject paths; this covers frame-send /
998
+ // handshake-adjacent failures that never reached the stream loop.
999
+ try {
1000
+ stampStreamOutcome(err, midState, {
1001
+ transport: STREAM_TRANSPORTS.WS,
1002
+ provider: 'openai-oauth',
1003
+ continuation: midState.sawCompleted !== true,
1004
+ });
1005
+ } catch { /* stamping is best-effort */ }
989
1006
  const classifier = err?.unsafeToRetry === true
990
1007
  ? null
991
1008
  : _classifyMidstreamError(err, midState);
@@ -39,7 +39,7 @@ import {
39
39
  createTimeoutSignal,
40
40
  createPassthroughSignal,
41
41
  } from '../stall-policy.mjs';
42
- import { populateHttpStatusFromMessage, shouldFallbackTransport } from './retry-classifier.mjs';
42
+ import { shouldFallbackTransport } from './retry-classifier.mjs';
43
43
  import { getLlmDispatcher, preconnect } from '../../../shared/llm/http-agent.mjs';
44
44
  import { makeInvalidToolArgsMarker } from './openai-compat-stream.mjs';
45
45
  import { createLeakGuard, createToolCallDedupe, dedupeToolCallList } from './anthropic-leaked-toolcall.mjs';
@@ -40,7 +40,7 @@ import {
40
40
  createTimeoutSignal,
41
41
  createPassthroughSignal,
42
42
  } from '../stall-policy.mjs';
43
- import { populateHttpStatusFromMessage, shouldFallbackTransport } from './retry-classifier.mjs';
43
+ import { shouldFallbackTransport } from './retry-classifier.mjs';
44
44
  import { getLlmDispatcher, preconnect } from '../../../shared/llm/http-agent.mjs';
45
45
  import { makeInvalidToolArgsMarker } from './openai-compat-stream.mjs';
46
46
  import { createLeakGuard, createToolCallDedupe, dedupeToolCallList } from './anthropic-leaked-toolcall.mjs';