mixdog 0.9.145 → 0.9.147

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 (46) hide show
  1. package/package.json +2 -2
  2. package/src/lib/rules-builder.cjs +12 -3
  3. package/src/output-styles/common.md +7 -9
  4. package/src/output-styles/simple.md +1 -1
  5. package/src/rules/shared/10-tool-workflow.md +18 -28
  6. package/src/rules/shared/40-editing.md +18 -6
  7. package/src/rules/shared/60-verification.md +0 -1
  8. package/src/rules/shared/70-delivery.md +4 -1
  9. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +4 -1
  10. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +11 -4
  11. package/src/runtime/agent/orchestrator/providers/openai-codex-identity.test.mjs +132 -0
  12. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +82 -40
  13. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +64 -29
  14. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +24 -6
  15. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +41 -3
  16. package/src/runtime/agent/orchestrator/providers/openai-ws-delta-parity.test.mjs +190 -0
  17. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +41 -28
  18. package/src/runtime/agent/orchestrator/providers/openai-ws-headers.mjs +9 -49
  19. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +78 -30
  20. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +12 -0
  21. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +8 -0
  22. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +9 -0
  23. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +7 -0
  24. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +79 -0
  25. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +10 -2
  26. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -9
  27. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-insights.mjs +22 -10
  28. package/src/runtime/agent/orchestrator/tools/builtin/shell-lossless-compact.mjs +30 -1
  29. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  30. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -1
  32. package/src/runtime/shared/edit-tool-dialect.mjs +8 -0
  33. package/src/runtime/shared/pristine-execution-contract.json +3 -0
  34. package/src/runtime/shared/task-notification-envelope.mjs +14 -0
  35. package/src/session-runtime/lifecycle-api.mjs +44 -1
  36. package/src/session-runtime/runtime-core.mjs +1 -0
  37. package/src/session-runtime/session-lifecycle.test.mjs +23 -0
  38. package/src/session-runtime/tool-policy-refresh.mjs +4 -1
  39. package/src/session-runtime/tool-policy-surface.test.mjs +23 -8
  40. package/src/tui/session/context-state.mjs +17 -3
  41. package/src/tui/session/session-api-ext.mjs +6 -1
  42. package/src/tui/session/session-api.mjs +6 -1
  43. package/src/tui/session/tool-card-results.mjs +7 -0
  44. package/src/tui/session/tool-result-status.mjs +31 -1
  45. package/src/tui/session/tool-result-status.test.mjs +30 -0
  46. package/src/tui/session/turn.mjs +12 -3
@@ -330,36 +330,34 @@ function _warmupContinuityTrace({
330
330
  * The request body may carry retained `reasoning` items
331
331
  * (opts.replayEncryptedReasoning; default ON for openai-oauth, kill switch
332
332
  * MIXDOG_OAI_DISABLE_REASONING_REPLAY=1).
333
- * Whether they actually go on the wire is decided PER SOCKET ENTRY, once, at
334
- * the first frame that entry sends, and stays sticky for its lifetime so the
335
- * append-only prefix bookkeeping (entry.lastRequestInput) never mixes
336
- * conventions:
337
333
  *
338
- * - fresh entry + reasoning items present → mid-session reconnect/recovery:
339
- * KEEP them. This is the codex behavior on a broken delta chain — the full
340
- * frame replays retained reasoning so the model does not re-reason the
341
- * whole transcript (client.rs full-request path).
342
- * - fresh entry + no reasoning items → virgin session: nothing to
343
- * replay; the chain locks to today's wire (subsequent frames strip).
344
- * - entry with prior chain state (pooled reuse / completed warmup) → STRIP:
345
- * re-sending an rs_* item a live server chain already saw is rejected as a
346
- * duplicate.
334
+ * They stay in the LOGICAL request history, always. The reference client never
335
+ * removes reasoning from the history it chains on: its incremental-request
336
+ * check proves the new request extends the exact previous request + response,
337
+ * and only THEN does the delta builder drop those already-anchored items from
338
+ * the wire tail. An rs_* item is therefore never sent twice on a live chain,
339
+ * while a full frame — which carries no previous_response_id — still replays
340
+ * retained reasoning instead of making the model re-reason the transcript.
347
341
  *
348
- * `suppress` (rejection safety net) forces the strip convention for the rest
349
- * of the send. Returns the body unchanged when nothing needs stripping.
342
+ * Removing reasoning HERE, before the delta computation, broke that proof: the
343
+ * previous response's reasoning could no longer be matched against the request
344
+ * prefix, so every reasoning-model session fell back to full frames from its
345
+ * second call onward and lost previous_response_id with it.
346
+ *
347
+ * `suppress` is the rejection safety net only. Once the server rejects a
348
+ * replayed rs_* item as a duplicate, the retry drops reasoning for the rest of
349
+ * the send and the chain degrades to full frames, exactly as any broken chain
350
+ * does.
350
351
  */
351
352
  export function _applyReasoningReplayPolicy(entry, body, { suppress = false } = {}) {
352
353
  const input = Array.isArray(body?.input) ? body.input : null;
353
354
  if (!entry || !input) return body;
354
- const hasReasoning = input.some((item) => item?.type === 'reasoning');
355
- if (suppress) {
356
- entry.replayReasoning = false;
357
- } else if (entry.replayReasoning == null) {
358
- entry.replayReasoning = hasReasoning
359
- && entry.lastResponseId == null
360
- && entry.lastRequestInput == null;
355
+ if (!suppress) {
356
+ entry.replayReasoning = true;
357
+ return body;
361
358
  }
362
- if (!hasReasoning || entry.replayReasoning === true) return body;
359
+ entry.replayReasoning = false;
360
+ if (!input.some((item) => item?.type === 'reasoning')) return body;
363
361
  return { ...body, input: input.filter((item) => item?.type !== 'reasoning') };
364
362
  }
365
363
 
@@ -575,7 +573,16 @@ export async function sendViaWebSocket({
575
573
  // this carry-forward would not help and is therefore gated to xAI.
576
574
  let carryForwardCache = null;
577
575
  const useCodexWsClientMetadata = traceProvider === 'openai-oauth';
578
- const codexMetadataContext = { poolKey, cacheKey, sendOpts };
576
+ // model + serviceTier feed the handshake routing hint. service_tier is on
577
+ // the body only when fast selected the priority tier, so the hint carries
578
+ // `model=` alone otherwise — the same shape the reference client sends.
579
+ const codexMetadataContext = {
580
+ poolKey,
581
+ cacheKey,
582
+ sendOpts,
583
+ model: useModel,
584
+ serviceTier: body?.service_tier || '',
585
+ };
579
586
  const codexHandshakeHeaders = useCodexWsClientMetadata
580
587
  ? _codexWsCompatibilityHeaders({ ...codexMetadataContext, handshake: true })
581
588
  : null;
@@ -853,10 +860,11 @@ export async function sendViaWebSocket({
853
860
  // Codex startup prewarm contains stable instructions/tools but
854
861
  // no live user/transcript input. Enforce that at the transport
855
862
  // boundary as well as in the OAuth caller so a future caller
856
- // cannot accidentally duplicate the transcript. WS-only fields
857
- // are omitted exactly as in Codex's prewarm request.
863
+ // cannot accidentally duplicate the transcript. Keep the same
864
+ // request properties as the real turn; Codex changes only the
865
+ // input tail and adds generate:false.
858
866
  const parityWarmupBody = { ...warmupBody, input: [], generate: false };
859
- const warmupFrame = _buildResponseCreateFrame(parityWarmupBody, { omitTransportFields: true });
867
+ const warmupFrame = _buildResponseCreateFrame(parityWarmupBody);
860
868
  const warmupMetadataContext = {
861
869
  ...codexMetadataContext,
862
870
  sendOpts: {
@@ -964,9 +972,28 @@ export async function sendViaWebSocket({
964
972
  // current call's header is identical and its first N input items
965
973
  // equal the previous call's N items (append-only history).
966
974
  try {
967
- const { client_metadata: _cm, input: frameInput, ...frameHeader } = frame;
975
+ // previous_response_id is the per-call anchor: it changes on
976
+ // every delta frame by design, so including it made
977
+ // prev_match structurally false for the entire delta path and
978
+ // the probe could never report what it was built to report —
979
+ // whether OUR request prefix stayed stable across calls.
980
+ const {
981
+ client_metadata: _cm,
982
+ input: frameInput,
983
+ previous_response_id: _prevAnchor,
984
+ ...frameHeader
985
+ } = frame;
968
986
  const headerHash = _hashText(JSON.stringify(frameHeader), 16);
969
- const itemHashes = (Array.isArray(frameInput) ? frameInput : [])
987
+ // A delta frame carries only the tail, so hashing frame.input
988
+ // compares [C] against [A,B] and prev_match can never hold on
989
+ // the delta path. The question the probe exists to answer is
990
+ // whether the LOGICAL conversation stayed append-only, so hash
991
+ // the full request body input and fall back to the frame only
992
+ // when the body is unavailable.
993
+ const logicalInput = Array.isArray(requestBody?.input)
994
+ ? requestBody.input
995
+ : (Array.isArray(frameInput) ? frameInput : []);
996
+ const itemHashes = logicalInput
970
997
  .map((item) => _hashText(JSON.stringify(item), 12));
971
998
  framePrefixHash = headerHash;
972
999
  framePrefixHeadHash = _hashText(itemHashes.join(','), 16);
@@ -1398,6 +1425,14 @@ export async function sendViaWebSocket({
1398
1425
  ws_client_metadata_has_thread_id: wireFrameMetadataTrace.hasThreadId,
1399
1426
  ws_client_metadata_has_turn_state: wireFrameHadTurnState,
1400
1427
  ws_entry_turn_state_available: useCodexWsClientMetadata && !!entry.turnState,
1428
+ // Fingerprint only. The token is a routing credential, so it is
1429
+ // hashed like every other handshake secret; the length still
1430
+ // distinguishes a real server token from a stub value, and the
1431
+ // hash shows whether one session keeps a single pin or is
1432
+ // re-issued (reconnect / turn rollover).
1433
+ ws_entry_turn_state_fp: typeof entry.turnState === 'string' && entry.turnState
1434
+ ? `${createHash('sha256').update(entry.turnState).digest('hex').slice(0, 12)}:len${entry.turnState.length}`
1435
+ : null,
1401
1436
  chain_delta_reason: mode === 'delta' ? null : deltaReason,
1402
1437
  chain_stripped_response_items: strippedResponseItems,
1403
1438
  chain_skipped_response_items: skippedResponseItems,
@@ -666,11 +666,18 @@ export class OpenAIOAuthProvider {
666
666
  // the hot WS/cache path for temporary blips while still preventing
667
667
  // TUI-level hangs. Sticky HTTP fallback is only armed after this
668
668
  // bounded reconnect budget is exhausted.
669
- // Codex startup-prewarm parity: build from the stable request
670
- // properties (instructions/tools/etc.) but never send the live
671
- // transcript/user input. The completed generate:false response is
672
- // retained by the WS transport and anchors the first real request.
673
- warmupBody: _envFlag('MIXDOG_OPENAI_OAUTH_WS_WARMUP', false)
669
+ // Startup prewarm, matching the reference client: build from the
670
+ // stable request properties (instructions/tools/etc.) but never
671
+ // send the live transcript/user input. The completed
672
+ // generate:false response is retained by the WS transport and
673
+ // anchors the first real request.
674
+ //
675
+ // ON by default. Without it every session's first call reports
676
+ // chain reason `no_anchor` — there is nothing to chain from — and
677
+ // pays a cold prefix; 973 of the recorded no_anchor rows are
678
+ // exactly that. Every prewarmed call on record (72/72) kept its
679
+ // chain continuous. MIXDOG_OPENAI_OAUTH_WS_WARMUP=0 disables it.
680
+ warmupBody: _envFlag('MIXDOG_OPENAI_OAUTH_WS_WARMUP', true)
674
681
  ? { ...body, input: [], generate: false }
675
682
  : null,
676
683
  _carriedWarmup: carriedWarmup,
@@ -806,7 +813,18 @@ export class OpenAIOAuthProvider {
806
813
  // session_id and pool-compatibility key match the first real send.
807
814
  const cacheKey = buildStableProviderPromptCacheKey('openai-oauth', opts);
808
815
  const [auth] = await Promise.all([this.ensureAuth(), _warmVersion()]);
809
- const codexHeaders = _codexWsCompatibilityHeaders({ poolKey, cacheKey, sendOpts: opts, handshake: true });
816
+ const codexHeaders = _codexWsCompatibilityHeaders({
817
+ poolKey,
818
+ cacheKey,
819
+ sendOpts: opts,
820
+ model: opts.model,
821
+ // Same tier the request body will carry, so the prewarm
822
+ // handshake routes to the node the first real send wants.
823
+ serviceTier: opts.fast === true && codexModelSupportsServiceTier(opts.model, 'priority')
824
+ ? 'priority'
825
+ : '',
826
+ handshake: true,
827
+ });
810
828
  const _t0 = Date.now();
811
829
  const acquired = await _acquire({
812
830
  auth,
@@ -108,9 +108,12 @@ export function convertMessagesToResponsesInput(messages, opts = {}) {
108
108
  const wireMessage = (role, content) => (wireParity
109
109
  ? { type: 'message', role, content, internal_chat_message_metadata_passthrough: {} }
110
110
  : { role, content });
111
- const pushReasoningItems = (message) => {
111
+ // `phase` replays each retained item on the side of the assistant text it
112
+ // was emitted on, so the rebuilt turn keeps the response's own item order.
113
+ const pushReasoningItems = (message, phase = 'before') => {
112
114
  if (!replayEncryptedReasoning || message?.role !== 'assistant' || !Array.isArray(message.reasoningItems)) return;
113
115
  for (const item of message.reasoningItems) {
116
+ if ((item?.afterText === true) !== (phase === 'after')) continue;
114
117
  // Collector shape contract: the WS/HTTP stream collectors store
115
118
  // retained items as {id, encrypted_content, summary} WITHOUT a
116
119
  // type tag (openai-ws-stream pushReasoningItem). Requiring
@@ -163,7 +166,7 @@ export function convertMessagesToResponsesInput(messages, opts = {}) {
163
166
  continue;
164
167
  }
165
168
  flushToolMedia();
166
- pushReasoningItems(m);
169
+ pushReasoningItems(m, 'before');
167
170
  if (m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length) {
168
171
  // Default path deliberately omits reasoning replay: openai-oauth
169
172
  // rejects an `rs_*` item repeated inside the same stateful
@@ -171,6 +174,7 @@ export function convertMessagesToResponsesInput(messages, opts = {}) {
171
174
  // converter option with stateless HTTP headers, where the complete
172
175
  // retained conversation is the only continuation source.
173
176
  if (m.content) out.push(wireMessage('assistant', normalizeContentForOpenAIResponses(m.content, { role: 'assistant' })));
177
+ pushReasoningItems(m, 'after');
174
178
  for (const tc of m.toolCalls) {
175
179
  const nativeSearchCall = nativeToolSearchCallInput(tc);
176
180
  if (nativeSearchCall) {
@@ -198,6 +202,7 @@ export function convertMessagesToResponsesInput(messages, opts = {}) {
198
202
  m.role === 'assistant' ? 'assistant' : 'user',
199
203
  normalizeContentForOpenAIResponses(m.content, { role: m.role }),
200
204
  ));
205
+ pushReasoningItems(m, 'after');
201
206
  }
202
207
  flushToolMedia();
203
208
  return out;
@@ -253,8 +258,27 @@ export function buildRequestBody(messages, model, tools, sendOpts) {
253
258
  // wire (the only remap; every other effort passes through). Default medium.
254
259
  // Kept inline (not a module const) so buildRequestBody stays self-contained.
255
260
  // Extract system/instructions
261
+ // The volatile environment block (session header, cwd, shell startup
262
+ // capabilities) is per-session BY DEFINITION, so leaving it inside
263
+ // `instructions` makes the cached prefix unique to a single session and
264
+ // nothing can ever be shared. Measured 2026-08-21 on 8 parallel bench
265
+ // sessions: the first 10,408 bytes of `instructions` were byte-identical
266
+ // and only these lines differed, yet all 8 sessions paid a full cold
267
+ // prefix (0 cached tokens on every first call). The reference client keeps
268
+ // instructions static and delivers the same information as a leading
269
+ // <environment_context> input item; mirror that split here. Anthropic and
270
+ // Gemini paths are untouched — they consume the env block as its own
271
+ // unmarked system block.
256
272
  const systemMsgs = messages.filter(m => m.role === 'system');
257
- const instructions = systemMsgs.map(m => m.content).join('\n\n') || 'You are a helpful assistant.';
273
+ const environmentMsgs = systemMsgs.filter(m => m?.cacheTier === 'env');
274
+ const prefixSystemMsgs = environmentMsgs.length
275
+ ? systemMsgs.filter(m => m?.cacheTier !== 'env')
276
+ : systemMsgs;
277
+ const instructions = prefixSystemMsgs.map(m => m.content).join('\n\n') || 'You are a helpful assistant.';
278
+ const environmentText = environmentMsgs
279
+ .map(m => (typeof m.content === 'string' ? m.content : ''))
280
+ .filter(Boolean)
281
+ .join('\n\n---\n\n');
258
282
  const opts = sendOpts || {};
259
283
  const promptCacheProvider = opts.promptCacheProvider || 'openai-oauth';
260
284
  // Recovery-only encrypted-reasoning replay is DEFAULT ON for the OAuth
@@ -274,6 +298,20 @@ export function buildRequestBody(messages, model, tools, sendOpts) {
274
298
  nativeToolSearchProvider: promptCacheProvider,
275
299
  replayEncryptedReasoning,
276
300
  });
301
+ if (environmentText) {
302
+ // Leading input item, after the cached prefix instead of inside it.
303
+ // convertMessagesToResponsesInput skips every system message, so this
304
+ // is the only copy on the wire — the information reaches the model
305
+ // unchanged, just one position later.
306
+ input.unshift({
307
+ type: 'message',
308
+ role: 'user',
309
+ content: [{
310
+ type: 'input_text',
311
+ text: `<environment_context>\n${environmentText}\n</environment_context>`,
312
+ }],
313
+ });
314
+ }
277
315
  // Match the request body shape the OAuth backend expects so the
278
316
  // server-side auto-cache routes correctly. text.verbosity / include /
279
317
  // tool_choice / parallel_tool_calls are all inert without side effects
@@ -0,0 +1,190 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+
4
+ import {
5
+ _computeDelta,
6
+ _sansInput,
7
+ _stableStringify,
8
+ } from './openai-ws-delta.mjs';
9
+ import { _applyReasoningReplayPolicy } from './openai-oauth-ws.mjs';
10
+ import { _captureTurnStateFromEvent } from './openai-ws-stream.mjs';
11
+ import { _convertMessagesToResponsesInputForTest } from './openai-responses-payload.mjs';
12
+
13
+ // A turn may reason, speak, then reason again before calling a tool. Replaying
14
+ // every retained item in front of the text invents an order the response never
15
+ // produced, and the next request's incremental check fails on the message item.
16
+ test('a turn that reasons, speaks, then reasons again replays in that order', () => {
17
+ const beforeText = { id: 'rs-1', encrypted_content: 'e1', summary: [] };
18
+ const afterText = { id: 'rs-2', encrypted_content: 'e2', summary: [], afterText: true };
19
+ const input = _convertMessagesToResponsesInputForTest([
20
+ { role: 'user', content: 'task' },
21
+ {
22
+ role: 'assistant',
23
+ content: 'looking now',
24
+ reasoningItems: [beforeText, afterText],
25
+ toolCalls: [{ id: 'call-1', name: 'read', arguments: { path: 'a' } }],
26
+ },
27
+ { role: 'tool', toolCallId: 'call-1', content: 'contents' },
28
+ ], { replayEncryptedReasoning: true });
29
+
30
+ assert.deepEqual(input.map((item) => item.type ?? 'message'), [
31
+ 'message',
32
+ 'reasoning',
33
+ 'message',
34
+ 'reasoning',
35
+ 'function_call',
36
+ 'function_call_output',
37
+ ]);
38
+ assert.equal(input[1].id, 'rs-1');
39
+ assert.equal(input[3].id, 'rs-2');
40
+ // The ordering marker is bookkeeping, never a wire field.
41
+ assert.equal(input[3].afterText, undefined);
42
+ });
43
+
44
+ // A live chain's sticky-routing token can never be swapped mid-session: the
45
+ // entry holds it write-once, whichever path first supplied it.
46
+ test('a live turn-state token is never replaced by a later event token', () => {
47
+ const entry = {};
48
+ _captureTurnStateFromEvent(entry, {
49
+ type: 'response.created',
50
+ headers: { 'x-codex-turn-state': 'handshake-token' },
51
+ });
52
+ _captureTurnStateFromEvent(entry, {
53
+ type: 'response.created',
54
+ headers: { 'x-codex-turn-state': 'later-token' },
55
+ });
56
+ assert.equal(entry.turnState, 'handshake-token');
57
+ });
58
+
59
+ // The logical request history always carries retained reasoning — a pooled
60
+ // entry with a live chain included. Only the duplicate-rejection retry drops it.
61
+ test('reasoning stays in the logical history on every send', () => {
62
+ const reasoning = {
63
+ type: 'reasoning',
64
+ id: 'rs-1',
65
+ encrypted_content: 'encrypted',
66
+ summary: [],
67
+ };
68
+ const toolOutput = { type: 'function_call_output', call_id: 'call-1', output: 'ok' };
69
+ const body = { input: [reasoning, toolOutput] };
70
+ const entry = { lastResponseId: 'resp-1', lastRequestInput: [] };
71
+
72
+ const retained = _applyReasoningReplayPolicy(entry, body);
73
+ assert.equal(retained, body);
74
+ assert.equal(retained.input[0], reasoning);
75
+ assert.equal(entry.replayReasoning, true);
76
+
77
+ const suppressed = _applyReasoningReplayPolicy(entry, body, { suppress: true });
78
+ assert.equal(entry.replayReasoning, false);
79
+ assert.deepEqual(suppressed.input, [toolOutput]);
80
+ });
81
+
82
+ test('Codex delta parity falls back when a previous response item is absent', (t) => {
83
+ const priorTransport = process.env.MIXDOG_OAI_TRANSPORT;
84
+ process.env.MIXDOG_OAI_TRANSPORT = 'ws-delta';
85
+ t.after(() => {
86
+ if (priorTransport == null) delete process.env.MIXDOG_OAI_TRANSPORT;
87
+ else process.env.MIXDOG_OAI_TRANSPORT = priorTransport;
88
+ });
89
+
90
+ const previousInput = [{
91
+ type: 'message',
92
+ role: 'user',
93
+ content: [{ type: 'input_text', text: 'task' }],
94
+ }];
95
+ const previousCall = {
96
+ type: 'function_call',
97
+ call_id: 'call-1',
98
+ name: 'read',
99
+ arguments: '{"path":"/app/input.txt"}',
100
+ };
101
+ const body = {
102
+ model: 'gpt-5.6-sol',
103
+ input: [
104
+ ...previousInput,
105
+ // The reasoning item from the previous response is missing here.
106
+ previousCall,
107
+ {
108
+ type: 'function_call_output',
109
+ call_id: 'call-1',
110
+ output: 'fixture',
111
+ },
112
+ ],
113
+ };
114
+ const entry = {
115
+ lastRequestSansInput: _stableStringify(_sansInput({
116
+ model: body.model,
117
+ input: previousInput,
118
+ })),
119
+ lastRequestInput: previousInput,
120
+ lastResponseId: 'resp-1',
121
+ lastResponseItems: [
122
+ {
123
+ type: 'reasoning',
124
+ id: 'rs-1',
125
+ encrypted_content: 'encrypted',
126
+ },
127
+ previousCall,
128
+ ],
129
+ turnState: 'sticky',
130
+ };
131
+
132
+ const delta = _computeDelta({ entry, body, traceProvider: 'openai-oauth' });
133
+ assert.equal(delta.mode, 'full');
134
+ assert.equal(delta.reason, 'response_output_mismatch:reasoning');
135
+ assert.equal(delta.frame.previous_response_id, undefined);
136
+ assert.deepEqual(delta.frame.input, body.input);
137
+ });
138
+
139
+ // Retained reasoning rides the logical history, and the delta builder is what
140
+ // keeps it off the wire: it belongs to the anchored previous response, so the
141
+ // frame carries only what is genuinely new. This is the whole reason the
142
+ // history keeps the item instead of dropping it before the delta runs.
143
+ test('the delta frame drops the anchored reasoning item from the wire tail', (t) => {
144
+ const priorTransport = process.env.MIXDOG_OAI_TRANSPORT;
145
+ process.env.MIXDOG_OAI_TRANSPORT = 'ws-delta';
146
+ t.after(() => {
147
+ if (priorTransport == null) delete process.env.MIXDOG_OAI_TRANSPORT;
148
+ else process.env.MIXDOG_OAI_TRANSPORT = priorTransport;
149
+ });
150
+
151
+ const previousInput = [{
152
+ type: 'message',
153
+ role: 'user',
154
+ content: [{ type: 'input_text', text: 'task' }],
155
+ }];
156
+ const reasoning = { type: 'reasoning', id: 'rs-1', encrypted_content: 'encrypted' };
157
+ const previousCall = {
158
+ type: 'function_call',
159
+ call_id: 'call-1',
160
+ name: 'read',
161
+ arguments: '{"path":"/app/input.txt"}',
162
+ };
163
+ const toolOutput = {
164
+ type: 'function_call_output',
165
+ call_id: 'call-1',
166
+ output: 'fixture',
167
+ };
168
+ const body = {
169
+ model: 'gpt-5.6-sol',
170
+ input: [...previousInput, reasoning, previousCall, toolOutput],
171
+ };
172
+ const entry = {
173
+ lastRequestSansInput: _stableStringify(_sansInput({
174
+ model: body.model,
175
+ input: previousInput,
176
+ })),
177
+ lastRequestInput: previousInput,
178
+ lastResponseId: 'resp-1',
179
+ lastResponseItems: [reasoning, previousCall],
180
+ replayReasoning: true,
181
+ turnState: 'sticky',
182
+ };
183
+
184
+ const delta = _computeDelta({ entry, body, traceProvider: 'openai-oauth' });
185
+ assert.equal(delta.mode, 'delta');
186
+ assert.equal(delta.reason, null);
187
+ assert.equal(delta.frame.previous_response_id, 'resp-1');
188
+ assert.deepEqual(delta.frame.input, [toolOutput]);
189
+ assert.equal(delta.strippedResponseItems, 2);
190
+ });
@@ -146,16 +146,6 @@ function _stripRequestPrefix(curInput, prevInput) {
146
146
  return current.slice(previous.length);
147
147
  }
148
148
 
149
- function _isReplayLikeHead(item, responseItem) {
150
- if (!item || !responseItem) return false;
151
- const inputType = item.type || (item.role === 'assistant' ? 'message' : '');
152
- const responseType = responseItem.type || '';
153
- if (responseType === 'message') return inputType === 'message';
154
- if (responseType === 'function_call') return inputType === 'function_call';
155
- if (responseType === 'tool_search_call') return inputType === 'tool_search_call';
156
- return inputType === responseType;
157
- }
158
-
159
149
  // The hash input follows the same per-item normalizations used by
160
150
  // _logicalResponseItemMatch, but is never emitted itself. This retains a
161
151
  // stable comparison fingerprint after history compaction without putting
@@ -222,32 +212,51 @@ export function _stripResponseItemsFromHead(items, responseItems) {
222
212
  const outputs = Array.isArray(responseItems) ? responseItems : [];
223
213
  let cursor = 0;
224
214
  let stripped = 0;
225
- let skipped = 0;
226
215
  for (const output of outputs) {
227
- if (cursor >= tail.length) break;
228
- if (_logicalResponseItemMatch(tail[cursor], output)) {
229
- cursor += 1;
230
- stripped += 1;
231
- continue;
232
- }
233
- if (_isReplayLikeHead(tail[cursor], output)) {
216
+ // The incremental request must extend the EXACT previous request plus
217
+ // its response: every item the previous response produced has to appear
218
+ // in the current prefix, in order, before anything new. Skipping a
219
+ // missing one builds a delta against a logical history the server never
220
+ // saw and can invalidate the cache chain while previous_response_id
221
+ // stays syntactically valid, so any gap forces a full-frame fallback.
222
+ // There is no per-item exception — reasoning included. Reasoning items
223
+ // are kept in the logical history precisely so this proof holds, and
224
+ // they leave the wire here, as part of the anchored prefix.
225
+ if (cursor >= tail.length) {
234
226
  return {
235
227
  ok: false,
236
228
  reason: `response_output_mismatch:${output?.type || 'unknown'}`,
237
229
  tail,
238
230
  stripped,
239
- skipped,
231
+ skipped: 0,
240
232
  responseOutputMismatch: _responseOutputMismatchDiagnostics(
241
- tail[cursor],
233
+ undefined,
242
234
  output,
243
235
  tail.length,
244
236
  outputs.length,
245
237
  ),
246
238
  };
247
239
  }
248
- skipped += 1;
240
+ if (_logicalResponseItemMatch(tail[cursor], output)) {
241
+ cursor += 1;
242
+ stripped += 1;
243
+ continue;
244
+ }
245
+ return {
246
+ ok: false,
247
+ reason: `response_output_mismatch:${output?.type || 'unknown'}`,
248
+ tail,
249
+ stripped,
250
+ skipped: 0,
251
+ responseOutputMismatch: _responseOutputMismatchDiagnostics(
252
+ tail[cursor],
253
+ output,
254
+ tail.length,
255
+ outputs.length,
256
+ ),
257
+ };
249
258
  }
250
- return { ok: true, reason: null, tail: tail.slice(cursor), stripped, skipped };
259
+ return { ok: true, reason: null, tail: tail.slice(cursor), stripped, skipped: 0 };
251
260
  }
252
261
 
253
262
  // Official OpenAI Responses WebSocket guide: response.create WS frames mirror
@@ -303,12 +312,16 @@ export function _buildResponseCreateFrame(body, { previousResponseId = null, inp
303
312
 
304
313
  export function _computeDelta({ entry, body, traceProvider }) {
305
314
  // DEFAULT: full-frame sends. codex's delta path is only cache-safe with
306
- // the x-codex-turn-state sticky-routing token, which the backend issues to
307
- // codex but never to us (R11-R14 2026-07-03: zero turn-state events across
308
- // 200+ calls despite UA/version/beta-features/client_metadata parity;
309
- // delta measured 18-28% warm miss vs full-frame 0.0%). Without the sticky
310
- // token, previous_response_id requests hop cache nodes and only the first
311
- // prefix blocks hit. Re-enable delta explicitly via
315
+ // the x-codex-turn-state sticky-routing token. R11-R14 (2026-07-03)
316
+ // measured zero turn-state EVENTS across 200+ calls despite
317
+ // UA/version/beta-features/client_metadata parity, and delta at 18-28% warm
318
+ // miss vs full-frame 0.0%. The event channel is still silent, but the
319
+ // conclusion "the backend never issues it to us" was wrong: the token
320
+ // arrives on the handshake 101 response instead (2026-08-21 bench: 81 of 88
321
+ // calls carried it from iteration 1, before any response event existed), so
322
+ // the pool captures it at upgrade and the frame metadata replays it.
323
+ // Without the sticky token, previous_response_id requests hop cache nodes
324
+ // and only the first prefix blocks hit.
312
325
  // Delta gating flows through the single transport-policy switch
313
326
  // (MIXDOG_OAI_TRANSPORT: ws-full | ws-delta | http-sse). Default ws-delta
314
327
  // selects the refs-compatible safe delta; 'ws-full'/'http-sse' force full
@@ -36,22 +36,9 @@ export function _envOn(name) {
36
36
  return ['1', 'true', 'yes', 'on'].includes(v);
37
37
  }
38
38
 
39
- // --- Opt-in Codex fingerprint/id parity knobs ------------------------------
40
- // All default OFF; unset envs leave the winning combo
41
- // (MIXDOG_OAI_CODEX_WIRE_PARITY=1 + ws-delta + underscore session_id) exactly
42
- // as-is. These add EXTRA parity dimensions for backend fingerprint probes.
43
-
44
- // The reference client sends dashed RFC-4122 UUIDs as session-id/thread-id;
45
- // we key those dashed handshake headers off the underscore cacheKey by
46
- // default. Opt in with MIXDOG_OAI_CODEX_WIRE_PARITY_UUID_IDS to reshape ONLY
47
- // the dashed pair (session-id/thread-id/x-client-request-id) into codex's UUID
48
- // format. The value is derived deterministically from the id so it stays
49
- // stable per cache key (prefix-cache continuity preserved), and the underscore
50
- // `session_id` header (the backend prefix-dedupe key) is left untouched.
51
- export function _codexUuidIdParity() {
52
- const v = String(process.env.MIXDOG_OAI_CODEX_WIRE_PARITY_UUID_IDS || '').trim().toLowerCase();
53
- return ['1', 'true', 'yes', 'on', 'uuid', 'dashed'].includes(v);
54
- }
39
+ // Dashed handshake ids are derived deterministically from the cache key, so a
40
+ // session keeps the same pair for its whole life and prefix-cache continuity
41
+ // holds.
55
42
 
56
43
  export function _codexDashedId(value) {
57
44
  const h = createHash('sha256').update(String(value)).digest();
@@ -62,23 +49,9 @@ export function _codexDashedId(value) {
62
49
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
63
50
  }
64
51
 
65
- // Opt-in turn-state gate experiment bundle (MIXDOG_OAI_CODEX_TURN_STATE_GATE).
66
- // The debugger's working hypothesis for what gates x-codex-turn-state issuance
67
- // is a specific wire shape: Codex-shaped UUIDv7 session/thread ids + dashed-only
68
- // handshake headers (drop the underscore session_id) + NO duplicated
69
- // x-client-request-id + a parent-thread header (added in openai-oauth-ws.mjs).
70
- // This env ties those dimensions together as ONE experiment so operators can
71
- // A/B the whole gate shape without hand-composing four flags. Default OFF; it
72
- // leaves MIXDOG_OAI_CODEX_WIRE_PARITY_UUID_IDS / _SESSION_ID and every existing
73
- // flag intact and only reshapes the wire when explicitly enabled.
74
- export function _codexTurnStateGate() {
75
- const v = String(process.env.MIXDOG_OAI_CODEX_TURN_STATE_GATE || '').trim().toLowerCase();
76
- return ['1', 'true', 'yes', 'on'].includes(v);
77
- }
78
-
79
52
  // Same derivation as _codexDashedId but stamps the version-7 nibble so the
80
- // dashed pair reads as a Codex-shaped UUIDv7. Deterministic per id (prefix
81
- // cache continuity preserved); only used under the turn-state gate bundle.
53
+ // dashed pair reads as a Codex-shaped UUIDv7, which is the shape the reference
54
+ // client sends. Deterministic per id, so prefix-cache continuity holds.
82
55
  export function _codexDashedIdV7(value) {
83
56
  const h = createHash('sha256').update(String(value)).digest();
84
57
  const b = Buffer.from(h.subarray(0, 16));
@@ -88,24 +61,11 @@ export function _codexDashedIdV7(value) {
88
61
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
89
62
  }
90
63
 
91
- // codex advertises enabled beta features on the WS handshake; the backend
92
- // plausibly gates x-codex-turn-state issuance on that list (see the
93
- // x-codex-beta-features comment below). MIXDOG_CODEX_BETA_FEATURES replaces the
94
- // whole list; MIXDOG_OAI_CODEX_TURN_STATE_FEATURES is a safe ADD-ONLY opt-in
95
- // that appends turn-state-gating feature token(s) (comma-separated, de-duped)
96
- // without dropping the default `remote_compaction_v2`. Unset = default list.
64
+ // Beta features advertised on the WS handshake. MIXDOG_CODEX_BETA_FEATURES
65
+ // replaces the list when an operator needs to pin exactly what a known-good
66
+ // client build reports; unset keeps the default.
97
67
  export function _codexBetaFeatures() {
98
- const base = process.env.MIXDOG_CODEX_BETA_FEATURES || 'remote_compaction_v2';
99
- const extra = String(process.env.MIXDOG_OAI_CODEX_TURN_STATE_FEATURES || '').trim();
100
- if (!extra) return base;
101
- const seen = new Set();
102
- const out = [];
103
- for (const tok of `${base},${extra}`.split(',').map((s) => s.trim()).filter(Boolean)) {
104
- if (seen.has(tok)) continue;
105
- seen.add(tok);
106
- out.push(tok);
107
- }
108
- return out.join(',');
68
+ return process.env.MIXDOG_CODEX_BETA_FEATURES || 'remote_compaction_v2';
109
69
  }
110
70
 
111
71
  // --- Opt-in raw WS capture for wire byte-diff -------------------------------