mixdog 0.9.109 → 0.9.110

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 (26) hide show
  1. package/package.json +1 -1
  2. package/scripts/run-suite.mjs +2 -0
  3. package/src/rules/shared/01-tool.md +36 -35
  4. package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +7 -1
  5. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +67 -20
  6. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +7 -1
  7. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +31 -18
  8. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +5 -3
  9. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +81 -0
  10. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +64 -0
  11. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +44 -9
  12. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +9 -0
  13. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +6 -7
  14. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +24 -0
  15. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -2
  16. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +13 -6
  17. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +10 -1
  18. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +37 -14
  19. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +33 -61
  20. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +65 -4
  21. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  22. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +6 -37
  23. package/src/runtime/shared/pristine-execution-contract.json +4 -1
  24. package/src/session-runtime/session-title.mjs +14 -1
  25. package/src/standalone/agent-tool/spawn-flow.mjs +18 -0
  26. package/src/workflows/default/WORKFLOW.md +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.109",
3
+ "version": "0.9.110",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -51,6 +51,7 @@ export const SUITES = {
51
51
  'pretool-ask-runtime-test.mjs',
52
52
  'prompt-input-parity-test.mjs',
53
53
  'reactive-compact-persist-smoke.mjs',
54
+ 'reasoning-replay-policy-test.mjs',
54
55
  'repl-stream-finalize-test.mjs',
55
56
  'result-classification-test.mjs',
56
57
  'rg-runner-test.mjs',
@@ -60,6 +61,7 @@ export const SUITES = {
60
61
  'session-title-controller-test.mjs',
61
62
  'set-effort-config-test.mjs',
62
63
  'shell-jobs-windows-hide-test.mjs',
64
+ 'spawn-ws-prewarm-test.mjs',
63
65
  'spinner-meta-test.mjs',
64
66
  'statusline-agents-test.mjs',
65
67
  'statusline-quota-hysteresis-test.mjs',
@@ -2,51 +2,52 @@
2
2
 
3
3
  - Baseline routing assigns each facet directly by the evidence needed to
4
4
  determine the complete edit:
5
- path/name only→`find`; wildcard/recursive paths→`glob`; exact directory
6
- entries→`list`;
5
+ path/name only→`find`; wildcard/recursive paths→`glob` (including known-root
6
+ unknown descendants); exact directory entries→`list`;
7
7
  source content/value/`path:line`→`grep`; exact symbol/relation→`code_graph`;
8
8
  known file/range→`read`;
9
- web/current→`search`; returned URL body→`web_fetch`; prior work→`recall`;
9
+ web/current→`search`; returned URL body→`web_fetch`; prior work→`recall`
10
+ (history only, never current local state);
10
11
  durable compact English memory→`memory`; explicit project change→`cwd`;
11
12
  explicit user-requested conversation reset→`session_manage`.
12
13
  Use only named tools present in the current tool surface.
13
14
  `explore`, when exposed, is a fast path only for facets whose repository
14
15
  coordinates remain unknown: call it first once for all such independent
15
- facets in one query array. It returns the minimal complete direct
16
- `path:line` anchors, not analysis or solutions; resume baseline routing
17
- from those anchors.
16
+ facets in one query array. It returns direct `path:line` anchors, not
17
+ analysis or solutions; anchors are tool-verified coordinates that END
18
+ their location facets — never re-locate or second-guess them; resume
19
+ baseline routing from them.
18
20
  - Act only on verified identities (cwd/project/user/tool-returned) — paths,
19
21
  module specifiers, symbols, data/record shapes alike; a guessed identity is
20
- itself a facet, verified by the cheapest batched probe (one lookup or sample
21
- record) before anything depends on it. Within the current project, pass
22
+ verified by one lookup or sample only when the next call or edit references
23
+ it. Within the current project, pass
22
24
  project-relative paths and omit optional scopes equal to its root; explicit
23
25
  paths may be outside cwd only for targets outside the project.
24
- - A conclusive result ends its facet, and known state task/brief-supplied
25
- facts, returned content, and the effects of your own successful calls — is
26
- never re-acquired: never broaden, repeat, or reconfirm. Follow up only when
27
- prior output is needed to form the next call; on failure rerun only the
28
- failed check.
29
- Batch calls iff no call needs another's output (as input or to decide its
30
- need/scope) or can change another's inputs/state; otherwise serialize, and
31
- drop a call whose deciding evidence already suffices. Before each retrieval
32
- batch, deduplicate every facet the task still requires, route each once to
33
- the cheapest sufficient tool with all required variants/scopes, and launch
34
- every independent call together. Never
35
- split one decision across overlapping facets, duplicate/broaden a facet
36
- through another tool, add `shell`/`apply_patch` mutation merely to widen
37
- retrieval, reserve known work, or cap fanout.
38
- Take the cheapest sufficient evidence per facet:
39
- symbol relations end at `code_graph`, values/locations end at the context
40
- grep returns; `read` covers only what returned spans cannot, as an anchored
41
- offset/limit window never a full-file read when a window suffices;
42
- adjacent context around an edit point counts as needed evidence. The moment
43
- evidence determines the edit, stop retrieving and patch.
44
- - Once the edit is determined, finish in one assistant turn: one
45
- `apply_patch` per file or cohesive unit, all patches first, then one batched
46
- verification `shell` for required postconditions only; runtime waits
47
- for every patch and skips the shell if any fails. Retry only failed envelopes.
48
- Create or edit text only with `apply_patch`, never `shell`. Earlier `shell`
49
- is only for executable/runtime/state evidence unavailable to file tools—an
50
- independent facet, batched with the rest.
26
+ - Plan the fewest dependent rounds, then the fewest calls. A conclusive
27
+ result ends its facet, and known state task/brief-supplied facts,
28
+ returned content, your own successful calls' effects is never
29
+ re-acquired, broadened, or reconfirmed. Batch calls iff none needs
30
+ another's output or can change another's inputs/state; otherwise
31
+ serialize. Before each batch, deduplicate the facets still required by the request,
32
+ route each once to the cheapest sufficient tool with all required
33
+ variants/scopes, and launch every independent call together never
34
+ split or duplicate a facet across tools, mutate merely to widen
35
+ retrieval, reserve known work, or cap fanout. Symbol relations end at
36
+ `code_graph`; values/locations end at the context grep returns; `read`
37
+ covers only what returned spans cannot, as an anchored offset/limit
38
+ window. The moment evidence determines the answer, edit, or deliverable,
39
+ stop retrieving; patch if needed.
40
+ - Once the edit or deliverable is determined, finish in one assistant turn:
41
+ one `apply_patch` per file or cohesive unit, all patches first, then one
42
+ batched verification `shell` that runs the real required postconditions
43
+ on every changed file and produced artifact, never echoes a claim;
44
+ runtime waits for every patch and skips the shell
45
+ if any fails. Retry only failed envelopes; rerun a failed check only
46
+ after a fix that can change its result, else report it unresolved.
47
+ Hand-authored text is edited only with `apply_patch`; computed artifacts
48
+ (data/reports/derived values) come from `shell` computation, never
49
+ hand-transcribed numbers. Earlier `shell` is only for runtime/state
50
+ evidence unavailable to file tools—an independent facet, batched with
51
+ the rest.
51
52
  - A background `task_id` ends the turn; completion resumes work. Never poll;
52
53
  use task control only for recovery or a required blocking result.
@@ -39,7 +39,13 @@ export function createTitleCompletion(deps = {}) {
39
39
  config,
40
40
  });
41
41
  if (!route || typeof route !== 'object') {
42
- throw new Error('Session title maintenance route is unresolved.');
42
+ const error = new Error('Session title maintenance route is unresolved.');
43
+ // Machine-readable marker: callers (session-title controller)
44
+ // downgrade this to a one-shot "titling disabled" skip instead of
45
+ // logging a stack per session (e.g. bench profiles without a
46
+ // maintainer/default route).
47
+ error.code = 'MAINTENANCE_ROUTE_UNRESOLVED';
48
+ throw error;
43
49
  }
44
50
  const providerName = String(route.provider || '').trim();
45
51
  const model = String(route.model || '').trim();
@@ -41,6 +41,8 @@ import {
41
41
  } from './anthropic-oauth-credentials.mjs';
42
42
  import {
43
43
  PROVIDER_FIRST_BYTE_TIMEOUT_MS,
44
+ PROVIDER_NONSTREAM_TOTAL_TIMEOUT_MS,
45
+ createTimeoutSignal,
44
46
  createPassthroughSignal,
45
47
  } from '../stall-policy.mjs';
46
48
  import {
@@ -740,7 +742,11 @@ export class AnthropicOAuthProvider {
740
742
  // Test seam: injectable request factory for retry-path tests.
741
743
  const doRequestImpl = typeof opts._doRequestFn === 'function' ? opts._doRequestFn : doRequest;
742
744
 
743
- const requestWithRetry = async (accessToken, requestBody = body) => withRetry(async ({ signal: attemptSignal }) => {
745
+ const requestWithRetry = async (
746
+ accessToken,
747
+ requestBody = body,
748
+ retrySignal = totalSignal,
749
+ ) => withRetry(async ({ signal: attemptSignal }) => {
744
750
  const result = await doRequestImpl(accessToken, attemptSignal, requestBody);
745
751
  const status = Number(result?.response?.status || 0);
746
752
  const transientStatus = classifyError({ httpStatus: status }) === 'transient';
@@ -772,7 +778,7 @@ export class AnthropicOAuthProvider {
772
778
  }
773
779
  return result;
774
780
  }, {
775
- signal: totalSignal,
781
+ signal: retrySignal,
776
782
  maxAttempts: anthropicMaxAttempts(),
777
783
  backoffMs: ANTHROPIC_RETRY_BACKOFF_MS,
778
784
  retryJitterRatio: ANTHROPIC_RETRY_JITTER_RATIO,
@@ -816,28 +822,69 @@ export class AnthropicOAuthProvider {
816
822
  const issueNonStreamingFallback = async (controller, abortReason) => {
817
823
  try { controller?.abort?.(abortReason); } catch {}
818
824
  try { onStageChange?.('requesting', { transport: 'non-streaming-fallback' }); } catch {}
819
- let fallback = await requestWithRetry(creds.accessToken, { ...body, stream: false });
820
- if (fallback.response.status === 401) {
821
- cleanupCancelHandler(fallback.cancelHandler);
822
- try { fallback.controller?.abort?.(); } catch {}
823
- creds = await this.ensureAuth({ forceRefresh: true, reason: '401' });
824
- fallback = await requestWithRetry(creds.accessToken, { ...body, stream: false });
825
- }
826
- if (!fallback.response.ok) {
827
- const text = await fallback.response.text().catch(() => '');
828
- cleanupCancelHandler(fallback.cancelHandler);
829
- try { fallback.controller?.abort?.(); } catch {}
830
- const fallbackError = new Error(`Anthropic OAuth API ${fallback.response.status}: ${this.scrubTokens(text).slice(0, 200)}`);
831
- fallbackError.status = fallback.response.status;
832
- fallbackError.httpStatus = fallback.response.status;
833
- throw fallbackError;
834
- }
825
+ const timeoutMs = Number(opts._nonStreamingTimeoutMs) > 0
826
+ ? Number(opts._nonStreamingTimeoutMs)
827
+ : PROVIDER_NONSTREAM_TOTAL_TIMEOUT_MS;
828
+ const lifetime = createTimeoutSignal(
829
+ totalSignal,
830
+ timeoutMs,
831
+ 'Anthropic OAuth non-streaming fallback',
832
+ );
833
+ let fallback = null;
834
+ let lifetimeAbortHandler = null;
835
+ const releaseFallback = (reason) => {
836
+ if (lifetimeAbortHandler) {
837
+ try { lifetime.signal.removeEventListener('abort', lifetimeAbortHandler); } catch {}
838
+ lifetimeAbortHandler = null;
839
+ }
840
+ cleanupCancelHandler(fallback?.cancelHandler);
841
+ try { fallback?.controller?.abort?.(reason); } catch {}
842
+ fallback = null;
843
+ };
844
+ const requestFallback = async (accessToken) => {
845
+ const result = await requestWithRetry(
846
+ accessToken,
847
+ { ...body, stream: false },
848
+ lifetime.signal,
849
+ );
850
+ fallback = result;
851
+ lifetimeAbortHandler = () => {
852
+ try { result.controller?.abort?.(lifetime.signal.reason); } catch {}
853
+ };
854
+ if (lifetime.signal.aborted) {
855
+ lifetimeAbortHandler();
856
+ const reason = lifetime.signal.reason;
857
+ throw reason instanceof Error
858
+ ? reason
859
+ : new Error('Anthropic OAuth non-streaming fallback aborted');
860
+ }
861
+ lifetime.signal.addEventListener('abort', lifetimeAbortHandler, { once: true });
862
+ return result;
863
+ };
835
864
  try {
865
+ fallback = await requestFallback(creds.accessToken);
866
+ if (fallback.response.status === 401) {
867
+ releaseFallback('Anthropic OAuth non-streaming fallback refreshing auth');
868
+ creds = await this.ensureAuth({ forceRefresh: true, reason: '401' });
869
+ fallback = await requestFallback(creds.accessToken);
870
+ }
871
+ if (!fallback.response.ok) {
872
+ const text = await fallback.response.text().catch(() => '');
873
+ const fallbackError = new Error(`Anthropic OAuth API ${fallback.response.status}: ${this.scrubTokens(text).slice(0, 200)}`);
874
+ fallbackError.status = fallback.response.status;
875
+ fallbackError.httpStatus = fallback.response.status;
876
+ throw fallbackError;
877
+ }
836
878
  const message = await fallback.response.json();
837
879
  return normalizeAnthropicNonStreamingResponse(message, useModel);
880
+ } catch (err) {
881
+ if (lifetime.signal.aborted && lifetime.signal.reason instanceof Error) {
882
+ throw lifetime.signal.reason;
883
+ }
884
+ throw err;
838
885
  } finally {
839
- cleanupCancelHandler(fallback.cancelHandler);
840
- try { fallback.controller?.abort?.('Anthropic non-streaming fallback complete'); } catch {}
886
+ releaseFallback('Anthropic non-streaming fallback complete');
887
+ lifetime.cleanup();
841
888
  }
842
889
  };
843
890
 
@@ -47,7 +47,13 @@ function _captureMidstreamAbort(state, reason) {
47
47
  const reasonName = reason?.name || '';
48
48
  if (reasonName === 'AgentStallAbortError' || reasonName === 'StreamStalledAbortError') {
49
49
  state.watchdogAbort = reasonName;
50
- } else {
50
+ } else if (reasonName !== 'ProviderTimeoutError' && reasonName !== 'StreamStalledError') {
51
+ // Internal timeout/stall abort reasons are transport symptoms, not a
52
+ // caller decision. Recording them as userAbort silently vetoed the
53
+ // mid-stream retry ladder (_classifyMidstreamSse returns null on
54
+ // userAbort → the turn surfaced as an instant unlogged failure).
55
+ // Leaving state untouched keeps them classifiable by the thrown error
56
+ // itself (EPROVIDERTIMEOUT/ESTREAMSTALL → transient/stall retry).
51
57
  state.userAbort = true;
52
58
  }
53
59
  }
@@ -19,6 +19,7 @@ import { readStreamOutcome } from './lib/stream-outcome.mjs';
19
19
  import { traceAgentUsage } from '../agent-trace.mjs';
20
20
  import {
21
21
  PROVIDER_FIRST_BYTE_TIMEOUT_MS,
22
+ PROVIDER_NONSTREAM_TOTAL_TIMEOUT_MS,
22
23
  createTimeoutSignal,
23
24
  createPassthroughSignal,
24
25
  } from '../stall-policy.mjs';
@@ -327,25 +328,37 @@ export class AnthropicProvider {
327
328
  try { streamController.abort?.(abortReason); } catch {}
328
329
  try { onStageChange?.('requesting', { transport: 'non-streaming-fallback' }); } catch {}
329
330
  const nonStreamingParams = { ...params, stream: false };
330
- const message = await withRetry(
331
- async ({ signal: attemptSignal }) => this.client.messages.create(nonStreamingParams, {
332
- signal: attemptSignal,
333
- ...(betaHeaders ? { headers: betaHeaders } : {}),
334
- }),
335
- {
336
- signal: totalSignal,
337
- maxAttempts: anthropicMaxAttempts(),
338
- backoffMs: ANTHROPIC_RETRY_BACKOFF_MS,
339
- retryJitterRatio: ANTHROPIC_RETRY_JITTER_RATIO,
340
- retryJitterMode: 'positive',
341
- perAttemptTimeoutMs: anthropicRequestTimeoutMs(),
342
- perAttemptLabel: `${this.name} Anthropic non-streaming fallback`,
343
- provider: 'anthropic',
344
- model: useModel,
345
- fallbackModel: opts._fallbackTriggered ? undefined : opts.fallbackModel,
346
- },
331
+ const timeoutMs = Number(opts._nonStreamingTimeoutMs) > 0
332
+ ? Number(opts._nonStreamingTimeoutMs)
333
+ : PROVIDER_NONSTREAM_TOTAL_TIMEOUT_MS;
334
+ const lifetime = createTimeoutSignal(
335
+ totalSignal,
336
+ timeoutMs,
337
+ `${this.name} Anthropic non-streaming fallback`,
347
338
  );
348
- return buildReturnFromParse(normalizeAnthropicNonStreamingResponse(message, useModel));
339
+ try {
340
+ const message = await withRetry(
341
+ async ({ signal: attemptSignal }) => this.client.messages.create(nonStreamingParams, {
342
+ signal: attemptSignal,
343
+ ...(betaHeaders ? { headers: betaHeaders } : {}),
344
+ }),
345
+ {
346
+ signal: lifetime.signal,
347
+ maxAttempts: anthropicMaxAttempts(),
348
+ backoffMs: ANTHROPIC_RETRY_BACKOFF_MS,
349
+ retryJitterRatio: ANTHROPIC_RETRY_JITTER_RATIO,
350
+ retryJitterMode: 'positive',
351
+ perAttemptTimeoutMs: anthropicRequestTimeoutMs(),
352
+ perAttemptLabel: `${this.name} Anthropic non-streaming fallback`,
353
+ provider: 'anthropic',
354
+ model: useModel,
355
+ fallbackModel: opts._fallbackTriggered ? undefined : opts.fallbackModel,
356
+ },
357
+ );
358
+ return buildReturnFromParse(normalizeAnthropicNonStreamingResponse(message, useModel));
359
+ } finally {
360
+ lifetime.cleanup();
361
+ }
349
362
  };
350
363
 
351
364
  const recoverNonStreaming = async (midState, streamingError, streamController) => {
@@ -167,7 +167,7 @@ function _pushOutputTextAnnotations(part, citations, citationKeys) {
167
167
  }
168
168
  }
169
169
 
170
- function _buildOpenAIHttpFallbackHeaders({ auth, cacheKey }) {
170
+ function _buildOpenAIHttpFallbackHeaders({ auth, cacheKey, statelessConversation = false }) {
171
171
  if (auth?.type === 'openai-direct') {
172
172
  // Public API-key auth: Bearer <OPENAI_API_KEY>, no chatgpt-account-id /
173
173
  // originator (mirrors openai-ws-pool _buildHandshakeHeaders' direct
@@ -189,7 +189,7 @@ function _buildOpenAIHttpFallbackHeaders({ auth, cacheKey }) {
189
189
  'chatgpt-account-id': auth.account_id || '',
190
190
  'x-client-request-id': randomBytes(16).toString('hex'),
191
191
  };
192
- if (cacheKey) {
192
+ if (cacheKey && !statelessConversation) {
193
193
  const sid = String(cacheKey);
194
194
  // Backend-native anchors (see openai-ws-pool _buildHandshakeHeaders):
195
195
  // the hyphenated `session-id`/`thread-id` pair; legacy underscore
@@ -252,7 +252,9 @@ export async function sendViaHttpSse({
252
252
  // one still aborts, and
253
253
  // (c) externalSignal (client disconnect / replaced-by-newer-request).
254
254
  const totalTimeout = createPassthroughSignal(externalSignal);
255
- const headers = _buildOpenAIHttpFallbackHeaders({ auth, cacheKey });
255
+ const statelessConversation = opts?.statelessConversation === true
256
+ || _envFlag('MIXDOG_OAI_STATELESS_HTTP', false);
257
+ const headers = _buildOpenAIHttpFallbackHeaders({ auth, cacheKey, statelessConversation });
256
258
  const fetchStartedAt = Date.now();
257
259
  const responsesUrl = auth?.type === 'openai-direct'
258
260
  ? OPENAI_DIRECT_RESPONSES_URL
@@ -313,6 +313,57 @@ function _warmupContinuityTrace({
313
313
  * err.attempts — 1..HANDSHAKE_MAX_ATTEMPTS
314
314
  * err.retryClassifier — final classifier string, or null for permanent
315
315
  */
316
+ /**
317
+ * Recovery-only encrypted-reasoning replay policy (codex full-frame parity).
318
+ *
319
+ * The request body may carry retained `reasoning` items
320
+ * (opts.replayEncryptedReasoning; default ON for openai-oauth, kill switch
321
+ * MIXDOG_OAI_DISABLE_REASONING_REPLAY=1).
322
+ * Whether they actually go on the wire is decided PER SOCKET ENTRY, once, at
323
+ * the first frame that entry sends, and stays sticky for its lifetime so the
324
+ * append-only prefix bookkeeping (entry.lastRequestInput) never mixes
325
+ * conventions:
326
+ *
327
+ * - fresh entry + reasoning items present → mid-session reconnect/recovery:
328
+ * KEEP them. This is the codex behavior on a broken delta chain — the full
329
+ * frame replays retained reasoning so the model does not re-reason the
330
+ * whole transcript (client.rs full-request path).
331
+ * - fresh entry + no reasoning items → virgin session: nothing to
332
+ * replay; the chain locks to today's wire (subsequent frames strip).
333
+ * - entry with prior chain state (pooled reuse / completed warmup) → STRIP:
334
+ * re-sending an rs_* item a live server chain already saw is rejected as a
335
+ * duplicate.
336
+ *
337
+ * `suppress` (rejection safety net) forces the strip convention for the rest
338
+ * of the send. Returns the body unchanged when nothing needs stripping.
339
+ */
340
+ export function _applyReasoningReplayPolicy(entry, body, { suppress = false } = {}) {
341
+ const input = Array.isArray(body?.input) ? body.input : null;
342
+ if (!entry || !input) return body;
343
+ const hasReasoning = input.some((item) => item?.type === 'reasoning');
344
+ if (suppress) {
345
+ entry.replayReasoning = false;
346
+ } else if (entry.replayReasoning == null) {
347
+ entry.replayReasoning = hasReasoning
348
+ && entry.lastResponseId == null
349
+ && entry.lastRequestInput == null;
350
+ }
351
+ if (!hasReasoning || entry.replayReasoning === true) return body;
352
+ return { ...body, input: input.filter((item) => item?.type !== 'reasoning') };
353
+ }
354
+
355
+ /**
356
+ * Server rejection of a replayed reasoning item (duplicate rs_* inside a
357
+ * stateful chain). Deliberately narrow: generic transport/5xx errors must not
358
+ * trip the replay-suppression retry.
359
+ */
360
+ export function _isReasoningReplayRejection(err) {
361
+ const msg = String(err?.payload?.message || err?.message || '');
362
+ if (!msg) return false;
363
+ if (/\brs_[A-Za-z0-9]/i.test(msg) && /duplicate|already|exists|repeated/i.test(msg)) return true;
364
+ return /reasoning/i.test(msg) && /duplicate|already exists|repeated|invalid item/i.test(msg);
365
+ }
366
+
316
367
  export async function _acquireWithRetry({
317
368
  auth,
318
369
  poolKey,
@@ -577,6 +628,10 @@ export async function sendViaWebSocket({
577
628
  // backs xAI, whose existing 429 handshake retry behavior must remain.
578
629
  const retry429 = traceProvider !== 'openai-oauth';
579
630
 
631
+ // Armed by the rejection safety net below: once a server rejects a
632
+ // replayed reasoning item, every later attempt of THIS send strips them.
633
+ let suppressReasoningReplay = false;
634
+
580
635
  for (let attemptIndex = 0; attemptIndex <= MAX_MIDSTREAM_RETRIES; attemptIndex++) {
581
636
  const handshakeStart = performance.now();
582
637
  let acquired;
@@ -739,6 +794,11 @@ export async function sendViaWebSocket({
739
794
  if (carryForwardCache && auth?.type === 'xai' && attemptIndex > 0 && !body.previous_response_id) {
740
795
  requestBody = { ...body, previous_response_id: carryForwardCache.lastResponseId };
741
796
  }
797
+ // Recovery-only reasoning replay: decide per entry whether retained
798
+ // reasoning items ride this chain (see _applyReasoningReplayPolicy).
799
+ // Must run BEFORE _computeDelta and the post-send bookkeeping so
800
+ // entry.lastRequestInput always records the post-policy input.
801
+ requestBody = _applyReasoningReplayPolicy(entry, requestBody, { suppress: suppressReasoningReplay });
742
802
  let warmupResult = null;
743
803
  // midState is shared between warmup and the main stream so warmup
744
804
  // failures (first-byte timeout, send-failure, ws_4000) flow through
@@ -1008,6 +1068,27 @@ export async function sendViaWebSocket({
1008
1068
  continuation: midState.sawCompleted !== true,
1009
1069
  });
1010
1070
  } catch { /* stamping is best-effort */ }
1071
+ // Reasoning-replay rejection safety net: a duplicate-rs_ rejection
1072
+ // on a frame that carried replayed reasoning gets ONE strip-and-
1073
+ // retry within the existing attempt budget instead of failing the
1074
+ // recovery turn outright. Unsafe outcomes (live text/tool output)
1075
+ // keep their normal no-replay handling.
1076
+ if (!suppressReasoningReplay
1077
+ && entry.replayReasoning === true
1078
+ && err?.unsafeToRetry !== true
1079
+ && attemptIndex < MAX_MIDSTREAM_RETRIES
1080
+ && _isReasoningReplayRejection(err)) {
1081
+ suppressReasoningReplay = true;
1082
+ firstAttemptError = err;
1083
+ firstAttemptClassifier = 'reasoning_replay_rejected';
1084
+ try { err.midstreamClassifier = 'reasoning_replay_rejected'; } catch {}
1085
+ emitReconnectProgress({
1086
+ attempt: attemptIndex + 1,
1087
+ max: MAX_MIDSTREAM_RETRIES,
1088
+ classifier: 'reasoning_replay_rejected',
1089
+ });
1090
+ continue;
1091
+ }
1011
1092
  const classifier = err?.unsafeToRetry === true
1012
1093
  ? null
1013
1094
  : _classifyMidstreamError(err, midState);
@@ -19,6 +19,8 @@ import { makeModelCache } from './model-cache.mjs';
19
19
 
20
20
  import { sendViaWebSocket } from './openai-oauth-ws.mjs';
21
21
  import { _combineUsageWithWarmup } from './openai-ws-events.mjs';
22
+ import { acquireWebSocket, releaseWebSocket, hasPooledWebSocket } from './openai-ws-pool.mjs';
23
+ import { _codexWsCompatibilityHeaders } from './openai-codex-metadata.mjs';
22
24
  import { resolveOpenAiTransportPolicy } from './openai-transport-policy.mjs';
23
25
  import {
24
26
  buildStableProviderPromptCacheKey,
@@ -769,6 +771,68 @@ export class OpenAIOAuthProvider {
769
771
  throw err;
770
772
  }
771
773
  }
774
+ /**
775
+ * Spawn-time transport prewarm (codex prewarm_websocket parity): open the
776
+ * session's WS socket while the caller is still assembling the session /
777
+ * first prompt, so the first real request reuses an already-open pooled
778
+ * socket instead of paying TLS + upgrade on its critical path.
779
+ *
780
+ * Best-effort by contract: every failure returns false and leaves the
781
+ * lazy per-send handshake untouched. Only runs when the thread-scoped
782
+ * prompt_cache_key derivation is active — with it disabled the real
783
+ * cacheKey depends on the built request body (instructions/tools hash),
784
+ * which does not exist yet at spawn prep, and a mismatched socket would
785
+ * just be evicted as incompatible on first acquire.
786
+ */
787
+ async prewarmWsTransportForSession(opts = {}, seams = {}) {
788
+ const _acquire = seams._acquire || acquireWebSocket;
789
+ const _release = seams._release || releaseWebSocket;
790
+ const _hasPooled = seams._hasPooled || hasPooledWebSocket;
791
+ const _warmVersion = seams._warmVersion || warmCodexClientVersion;
792
+ const poolKey = opts.sessionId || null;
793
+ if (!poolKey) return false;
794
+ try {
795
+ const transportPolicy = resolveOpenAiTransportPolicy();
796
+ if (transportPolicy.transport === 'http'
797
+ || _envFlag('MIXDOG_OPENAI_OAUTH_FORCE_HTTP_FALLBACK', false)) return false;
798
+ const threadKeyGate = String(process.env.MIXDOG_OAI_CODEX_THREAD_CACHE_KEY || '').toLowerCase();
799
+ if (threadKeyGate === '0' || threadKeyGate === 'false') return false;
800
+ if (_hasPooled(poolKey)) return true;
801
+ // Identical derivation to buildRequestBody's prompt_cache_key
802
+ // (thread-scoped branch), so the prewarmed socket's handshake
803
+ // session_id and pool-compatibility key match the first real send.
804
+ const cacheKey = buildStableProviderPromptCacheKey('openai-oauth', opts);
805
+ const [auth] = await Promise.all([this.ensureAuth(), _warmVersion()]);
806
+ const codexHeaders = _codexWsCompatibilityHeaders({ poolKey, cacheKey, sendOpts: opts, handshake: true });
807
+ const _t0 = Date.now();
808
+ const acquired = await _acquire({
809
+ auth,
810
+ poolKey,
811
+ cacheKey,
812
+ codexHeaders,
813
+ externalSignal: opts.signal || null,
814
+ });
815
+ _release({ entry: acquired.entry, poolKey, keep: true });
816
+ try {
817
+ appendAgentTrace({
818
+ sessionId: poolKey,
819
+ kind: 'spawn_ws_prewarm',
820
+ provider: 'openai-oauth',
821
+ transport: 'websocket',
822
+ payload: { elapsed_ms: Date.now() - _t0, reused: acquired.reused === true },
823
+ });
824
+ } catch {}
825
+ if (process.env.MIXDOG_DEBUG_AGENT) {
826
+ process.stderr.write(`[agent-trace] spawn-ws-prewarm ok poolKey=${createHash('sha256').update(String(poolKey)).digest('hex').slice(0, 8)} elapsed=${Date.now() - _t0}ms\n`);
827
+ }
828
+ return true;
829
+ } catch (err) {
830
+ if (process.env.MIXDOG_DEBUG_AGENT) {
831
+ process.stderr.write(`[agent-trace] spawn-ws-prewarm failed err=${String(err?.message || err).slice(0, 160)}\n`);
832
+ }
833
+ return false;
834
+ }
835
+ }
772
836
  async listModels() {
773
837
  // Dynamic lookup via /backend-api/codex/models. Cached 24h.
774
838
  // Endpoint returns rich metadata (context_window, reasoning levels,