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.
- package/package.json +2 -2
- package/src/lib/rules-builder.cjs +12 -3
- package/src/output-styles/common.md +7 -9
- package/src/output-styles/simple.md +1 -1
- package/src/rules/shared/10-tool-workflow.md +18 -28
- package/src/rules/shared/40-editing.md +18 -6
- package/src/rules/shared/60-verification.md +0 -1
- package/src/rules/shared/70-delivery.md +4 -1
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +4 -1
- package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +11 -4
- package/src/runtime/agent/orchestrator/providers/openai-codex-identity.test.mjs +132 -0
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +82 -40
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +64 -29
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +24 -6
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +41 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta-parity.test.mjs +190 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +41 -28
- package/src/runtime/agent/orchestrator/providers/openai-ws-headers.mjs +9 -49
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +78 -30
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +12 -0
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +9 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +79 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +10 -2
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -9
- package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-insights.mjs +22 -10
- package/src/runtime/agent/orchestrator/tools/builtin/shell-lossless-compact.mjs +30 -1
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -1
- package/src/runtime/shared/edit-tool-dialect.mjs +8 -0
- package/src/runtime/shared/pristine-execution-contract.json +3 -0
- package/src/runtime/shared/task-notification-envelope.mjs +14 -0
- package/src/session-runtime/lifecycle-api.mjs +44 -1
- package/src/session-runtime/runtime-core.mjs +1 -0
- package/src/session-runtime/session-lifecycle.test.mjs +23 -0
- package/src/session-runtime/tool-policy-refresh.mjs +4 -1
- package/src/session-runtime/tool-policy-surface.test.mjs +23 -8
- package/src/tui/session/context-state.mjs +17 -3
- package/src/tui/session/session-api-ext.mjs +6 -1
- package/src/tui/session/session-api.mjs +6 -1
- package/src/tui/session/tool-card-results.mjs +7 -0
- package/src/tui/session/tool-result-status.mjs +31 -1
- package/src/tui/session/tool-result-status.test.mjs +30 -0
- 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
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
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
|
-
*
|
|
349
|
-
*
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
857
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
670
|
-
// properties (instructions/tools/etc.) but never
|
|
671
|
-
// transcript/user input. The completed
|
|
672
|
-
// retained by the WS transport and
|
|
673
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
233
|
+
undefined,
|
|
242
234
|
output,
|
|
243
235
|
tail.length,
|
|
244
236
|
outputs.length,
|
|
245
237
|
),
|
|
246
238
|
};
|
|
247
239
|
}
|
|
248
|
-
|
|
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
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
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
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
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
|
|
81
|
-
//
|
|
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
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
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
|
-
|
|
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 -------------------------------
|