mixdog 0.9.53 → 0.9.55

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 (77) hide show
  1. package/package.json +2 -1
  2. package/scripts/agent-model-liveness-test.mjs +68 -0
  3. package/scripts/anthropic-transport-policy-test.mjs +260 -0
  4. package/scripts/desktop-session-bridge-test.mjs +47 -0
  5. package/scripts/gemini-provider-test.mjs +396 -1
  6. package/scripts/grok-oauth-refresh-race-test.mjs +76 -1
  7. package/scripts/interrupted-turn-history-test.mjs +28 -0
  8. package/scripts/openai-oauth-ws-1006-retry-test.mjs +81 -1
  9. package/scripts/pending-completion-drop-test.mjs +160 -4
  10. package/scripts/pending-messages-lock-nonblocking-test.mjs +62 -0
  11. package/scripts/process-lifecycle-test.mjs +18 -4
  12. package/scripts/prompt-input-parity-test.mjs +145 -0
  13. package/scripts/provider-admission-scheduler-test.mjs +4 -5
  14. package/scripts/provider-contract-test.mjs +91 -33
  15. package/scripts/provider-toolcall-test.mjs +97 -17
  16. package/scripts/streaming-tail-window-test.mjs +146 -0
  17. package/scripts/tui-runtime-load-bench-entry.jsx +608 -0
  18. package/scripts/tui-runtime-load-bench.mjs +45 -0
  19. package/scripts/tui-store-frame-batch-test.mjs +99 -0
  20. package/scripts/tui-transcript-perf-test.mjs +367 -2
  21. package/scripts/write-backpressure-test.mjs +147 -0
  22. package/src/cli.mjs +5 -5
  23. package/src/lib/keychain-cjs.cjs +114 -25
  24. package/src/repl.mjs +11 -0
  25. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -25
  26. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -2
  27. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +50 -23
  28. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +15 -4
  29. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +136 -27
  30. package/src/runtime/agent/orchestrator/providers/gemini.mjs +104 -20
  31. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +96 -75
  32. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +40 -1
  33. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +5 -0
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +35 -4
  35. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +3 -1
  36. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +49 -9
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +14 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +9 -4
  39. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +53 -13
  40. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +145 -23
  41. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +20 -4
  42. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +316 -63
  43. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +23 -1
  44. package/src/runtime/agent/orchestrator/session/store.mjs +46 -1
  45. package/src/runtime/agent/orchestrator/stall-policy.mjs +6 -13
  46. package/src/runtime/memory/lib/trace-store.mjs +66 -4
  47. package/src/runtime/shared/buffered-appender.mjs +83 -4
  48. package/src/runtime/shared/memory-snapshot.mjs +45 -36
  49. package/src/runtime/shared/process-lifecycle.mjs +166 -45
  50. package/src/runtime/shared/process-shutdown.mjs +2 -1
  51. package/src/session-runtime/model-route-api.mjs +4 -1
  52. package/src/session-runtime/native-search.mjs +4 -2
  53. package/src/session-runtime/provider-auth-api.mjs +8 -1
  54. package/src/session-runtime/provider-models.mjs +8 -3
  55. package/src/session-runtime/resource-api.mjs +2 -1
  56. package/src/session-runtime/runtime-core.mjs +32 -0
  57. package/src/session-runtime/session-turn-api.mjs +1 -0
  58. package/src/session-runtime/warmup-schedulers.mjs +5 -1
  59. package/src/standalone/agent-tool.mjs +19 -1
  60. package/src/tui/App.jsx +10 -4
  61. package/src/tui/app/text-layout.mjs +9 -1
  62. package/src/tui/app/transcript-window.mjs +146 -60
  63. package/src/tui/app/use-mouse-input.mjs +16 -8
  64. package/src/tui/app/use-transcript-scroll.mjs +71 -19
  65. package/src/tui/app/use-transcript-window.mjs +21 -10
  66. package/src/tui/components/PromptInput.jsx +13 -6
  67. package/src/tui/dist/index.mjs +1094 -232
  68. package/src/tui/engine/context-state.mjs +31 -31
  69. package/src/tui/engine/frame-batched-store.mjs +75 -0
  70. package/src/tui/engine/session-api-ext.mjs +6 -1
  71. package/src/tui/engine/session-api.mjs +9 -3
  72. package/src/tui/engine/session-flow.mjs +54 -27
  73. package/src/tui/engine/turn.mjs +44 -3
  74. package/src/tui/engine.mjs +515 -36
  75. package/src/tui/input-editing.mjs +33 -13
  76. package/src/tui/markdown/measure-rendered-rows.mjs +117 -5
  77. package/vendor/ink/build/ink.js +8 -16
@@ -103,8 +103,8 @@ const HANDSHAKE_MAX_ATTEMPTS = MIDSTREAM_WS_TRANSIENT_RETRY_LIMIT + 1;
103
103
  const HANDSHAKE_BACKOFF_BASE_MS = 200;
104
104
  const HANDSHAKE_BACKOFF_CAP_MS = 3200;
105
105
 
106
- export function _classifyHandshakeError(err) {
107
- return classifyHandshakeError(err);
106
+ export function _classifyHandshakeError(err, { retry429 = true } = {}) {
107
+ return classifyHandshakeError(err, { retry429 });
108
108
  }
109
109
 
110
110
  /**
@@ -518,6 +518,7 @@ export async function _acquireWithRetry({
518
518
  _acquire = acquireWebSocket,
519
519
  _sleepFn = _defaultSleep,
520
520
  maxAttempts = HANDSHAKE_MAX_ATTEMPTS,
521
+ retry429 = true,
521
522
  } = {}) {
522
523
  let lastErr = null;
523
524
  let lastClassifier = null;
@@ -538,7 +539,7 @@ export async function _acquireWithRetry({
538
539
  return await _acquire({ auth, poolKey, cacheKey, codexHeaders, forceFresh, externalSignal });
539
540
  } catch (err) {
540
541
  lastErr = err;
541
- const classifier = _classifyHandshakeError(err);
542
+ const classifier = _classifyHandshakeError(err, { retry429 });
542
543
  lastClassifier = classifier;
543
544
  // Permanent (or unknown → default-deny): stop immediately.
544
545
  if (!classifier) {
@@ -762,6 +763,9 @@ export async function sendViaWebSocket({
762
763
  });
763
764
  } catch {}
764
765
  };
766
+ // Only Codex OAuth follows retry_429:false. This shared transport also
767
+ // backs xAI, whose existing 429 handshake retry behavior must remain.
768
+ const retry429 = traceProvider !== 'openai-oauth';
765
769
 
766
770
  for (let attemptIndex = 0; attemptIndex <= MAX_MIDSTREAM_RETRIES; attemptIndex++) {
767
771
  const handshakeStart = performance.now();
@@ -783,6 +787,7 @@ export async function sendViaWebSocket({
783
787
  // No nested connect retry budget: the outer stream loop owns
784
788
  // all retries for this logical request.
785
789
  maxAttempts: 1,
790
+ retry429,
786
791
  onRetry: (info) => {
787
792
  handshakeRetries += 1;
788
793
  sendSpan.handshakeRetries += 1;
@@ -801,7 +806,7 @@ export async function sendViaWebSocket({
801
806
  // application error carrying the same HTTP status.
802
807
  try { err.wsFailurePhase = 'handshake'; } catch {}
803
808
  const classifier = err?.retryClassifier
804
- || _classifyHandshakeError(err)
809
+ || _classifyHandshakeError(err, { retry429 })
805
810
  || (err?.code === 'EWSACQUIRETIMEOUT' ? 'acquire_timeout' : null);
806
811
  const classifiers = [...handshakeRetryClassifiers];
807
812
  if (classifier && !classifiers.includes(classifier)) classifiers.push(classifier);
@@ -224,11 +224,35 @@ export function retryAfterMsFromError(err) {
224
224
  if (Number.isFinite(n) && n >= 0) return n
225
225
  }
226
226
  const retryAfter = _headerValue(headers, 'retry-after')
227
- if (retryAfter == null || retryAfter === '') return null
228
- const seconds = Number(retryAfter)
229
- if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds * 1000)
230
- const dateMs = Date.parse(String(retryAfter))
231
- if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now())
227
+ if (retryAfter != null && retryAfter !== '') {
228
+ const seconds = Number(retryAfter)
229
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds * 1000)
230
+ const dateMs = Date.parse(String(retryAfter))
231
+ if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now())
232
+ }
233
+ // Google RPC RetryInfo encodes retryDelay as a protobuf Duration. Preserve
234
+ // the same precedence as Retry-After: a server-provided retry window means
235
+ // RESOURCE_EXHAUSTED is request-local, not a permanent quota refusal.
236
+ const detailLists = [
237
+ err?.details,
238
+ err?.error?.details,
239
+ err?.data?.error?.details,
240
+ ]
241
+ for (const details of detailLists) {
242
+ if (!Array.isArray(details)) continue
243
+ for (const detail of details) {
244
+ const delay = detail?.retryDelay
245
+ if (typeof delay === 'string') {
246
+ const match = delay.trim().match(/^(\d+(?:\.\d+)?)s$/)
247
+ if (match) return Math.ceil(Number(match[1]) * 1000)
248
+ } else if (delay && typeof delay === 'object') {
249
+ const seconds = Number(delay.seconds || 0)
250
+ const nanos = Number(delay.nanos || 0)
251
+ const ms = seconds * 1000 + nanos / 1_000_000
252
+ if (Number.isFinite(ms) && ms >= 0) return Math.ceil(ms)
253
+ }
254
+ }
255
+ }
232
256
  return null
233
257
  }
234
258
 
@@ -615,15 +639,16 @@ function _sleepChunkWithAbort(ms, signal, sleepFn, abortMessage) {
615
639
 
616
640
  // E) Handshake classifier (moved here from openai-oauth-ws). Default-deny:
617
641
  // anything not recognized as transient returns null. HTTP 401 is reserved
618
- // for auth recovery and 426 for immediate HTTPS fallback; every other HTTP
619
- // handshake status follows Codex's retryable UnexpectedStatus policy.
620
- export function classifyHandshakeError(err) {
642
+ // for auth recovery and 426 for immediate HTTPS fallback. The OpenAI OAuth
643
+ // caller opts out of 429 retries (Codex retry_429:false); all other callers
644
+ // retain the historical retryable UnexpectedStatus policy.
645
+ export function classifyHandshakeError(err, { retry429 = true } = {}) {
621
646
  if (!err) return null
622
647
  const code = err.code || ''
623
648
  const msg = String(err.message || '')
624
649
  const status = Number(err.httpStatus || 0)
625
650
 
626
- if (status === 401 || status === 426) return null
651
+ if (status === 401 || status === 426 || (status === 429 && !retry429)) return null
627
652
  if (status > 0) {
628
653
  return `http_${status}`
629
654
  }
@@ -729,6 +754,20 @@ export async function withRetry(fn, opts = {}) {
729
754
  'x-should-retry',
730
755
  )
731
756
  if (String(shouldRetryHeader || '').toLowerCase() === 'false') throw caught
757
+ // Anthropic's non-standard positive override outranks ordinary status
758
+ // classification. Keep subscription OAuth 429 fail-fast ownership:
759
+ // retry429:false is the Max/Pro gate and must not wait for that window.
760
+ if (opts.provider === 'anthropic'
761
+ && String(shouldRetryHeader || '').toLowerCase() === 'true'
762
+ && !(status === 429 && opts.retry429 === false)) {
763
+ if (attempt === maxAttempts - 1) throw caught
764
+ const retryAfterMs = retryAfterMsFromError(caught)
765
+ if (retryAfterMs != null) {
766
+ nextDelayMs = Math.max(0, retryAfterMs)
767
+ nextDelayReason = 'retry-after'
768
+ }
769
+ continue
770
+ }
732
771
  // Claude Code's optional model fallback fires on the third 529. This
733
772
  // remains opt-in: providers pass fallbackModel only when the caller set
734
773
  // one. The hard progress veto above must run first so fallback can never
@@ -740,15 +779,16 @@ export async function withRetry(fn, opts = {}) {
740
779
  }
741
780
  }
742
781
  if (status === 429) {
782
+ if (opts.retry429 === false) throw caught
783
+ const ra = retryAfterMsFromError(caught)
743
784
  // A deterministic quota refusal cannot recover by replaying the same
744
- // request. This check must precede generic HTTP 429 handling so shared
745
- // OpenAI-compatible/Gemini message-only quota errors remain terminal.
746
- if (isPermanentQuotaError(caught)) throw caught
785
+ // request. An explicit server retry window outranks message-text quota
786
+ // heuristics: RESOURCE_EXHAUSTED + Retry-After/RetryInfo is transient.
787
+ if (ra == null && isPermanentQuotaError(caught)) throw caught
747
788
  // Retry only this request. Admission concurrency is fixed and is never
748
789
  // reduced by rate limits. Respect Retry-After when present; otherwise
749
790
  // use the ordinary jittered backoff. Output/tool stamps above remain a
750
791
  // hard replay boundary.
751
- const ra = retryAfterMsFromError(caught)
752
792
  if (attempt === maxAttempts - 1) throw caught
753
793
  if (ra != null) {
754
794
  nextDelayMs = Math.max(0, ra)
@@ -6,7 +6,7 @@
6
6
  import { createHash } from 'crypto';
7
7
  import { getProvider } from '../../providers/registry.mjs';
8
8
  import { normalizeCompactType, DEFAULT_COMPACT_TYPE } from '../compact.mjs';
9
- import { loadSession, saveSessionAsync } from '../store.mjs';
9
+ import { loadSession, saveSessionAsync, saveSessionAsyncDeferred } from '../store.mjs';
10
10
  import { createAbortController } from '../../../../shared/abort-controller.mjs';
11
11
  import { logLlmCall } from '../../../../shared/llm/usage-log.mjs';
12
12
  import { appendAgentTrace } from '../../agent-trace.mjs';
@@ -23,7 +23,15 @@ import {
23
23
  buildSessionStartBlock,
24
24
  hasUserConversationMessage,
25
25
  } from './prompt-utils.mjs';
26
- import { _mergePendingMessageEntries, drainPendingMessages } from './pending-messages.mjs';
26
+ import {
27
+ _mergePendingMessageEntries,
28
+ acknowledgePendingMessages,
29
+ finalizePendingMessageDelivery,
30
+ drainPendingMessages,
31
+ hydratePendingMessages,
32
+ recordPendingMessageDelivery,
33
+ releasePendingMessages,
34
+ } from './pending-messages.mjs';
27
35
  import { persistIterationMetrics, applyAskTerminalUsageTotals } from './usage-metrics.mjs';
28
36
  import {
29
37
  updateSessionStage,
@@ -102,6 +110,9 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
102
110
  process.stderr.write(`[agent-trace] t0-ask-start sessionHash=${createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 8)} role=? iteration=0 promptSrc=${_promptSrc} prefetchFiles=${_prefetchFiles} callers=${_prefetchCallers} references=${_prefetchRefs}\n`);
103
111
  }
104
112
  const unlock = await acquireSessionLock(sessionId);
113
+ // Start crash-spool hydration without delaying the user turn. A completed
114
+ // background hydration joins the id-deduped memory drain below.
115
+ const takeoverHydration = hydratePendingMessages(sessionId);
105
116
  const _lockWaitedMs = Date.now() - _askStartedAt;
106
117
  if (process.env.MIXDOG_DEBUG_AGENT) {
107
118
  process.stderr.write(`[agent-trace] lock-acquired waitedMs=${_lockWaitedMs}\n`);
@@ -126,11 +137,14 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
126
137
  // preserved and the spawn/connecting startup race disappears.
127
138
  for (;;) {
128
139
  let _pwstTurnDrained = null;
140
+ let _turnPendingEntries = [];
129
141
  // After the first turn, the next prompt comes from the drained queue.
130
142
  // (On the first iteration _pendingTail is empty and `prompt` is the
131
143
  // caller's original message.)
132
144
  if (_pendingTail.length > 0) {
133
- prompt = _pendingTail.shift();
145
+ const pendingTurn = _pendingTail.shift();
146
+ prompt = pendingTurn.content;
147
+ _turnPendingEntries = pendingTurn.entries;
134
148
  // Queued follow-ups are plain user turns — no caller context /
135
149
  // prefetch is re-applied (those belonged to the original ask).
136
150
  context = null;
@@ -144,12 +158,19 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
144
158
  const _mergedPre = _mergePendingMessageEntries(_preDrained);
145
159
  if (_mergedPre?.content) {
146
160
  prompt = _mergedPre.content;
161
+ _turnPendingEntries = _preDrained;
147
162
  context = null;
148
163
  explicitPrefetch = null;
149
164
  }
150
165
  }
151
166
  }
152
167
  if (!hasModelVisiblePromptContent(prompt)) {
168
+ void takeoverHydration.then((count) => {
169
+ if (count <= 0) return;
170
+ setImmediate(() => {
171
+ askSession(sessionId, '', null, onToolCall, cwdOverride, null, askOpts).catch(() => {});
172
+ });
173
+ });
153
174
  _unlinkParentAbortListener(_getRuntimeEntry(sessionId));
154
175
  return _result;
155
176
  }
@@ -334,6 +355,22 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
334
355
  _turnInterruption.recordTextDelta(chunk);
335
356
  if (typeof askOpts?.onTextDelta === 'function') askOpts.onTextDelta(chunk);
336
357
  };
358
+ const _trackTextReset = async (detail) => {
359
+ // Replacement is opt-in and transactional: a delta-only
360
+ // consumer cannot retract already exposed bytes, so absence,
361
+ // false, or rejection must preserve the original partial and
362
+ // force the provider's terminal no-replay behavior.
363
+ if (typeof askOpts?.onTextReset !== 'function') return false;
364
+ let acknowledged = false;
365
+ try {
366
+ acknowledged = await askOpts.onTextReset(detail) === true;
367
+ } catch {
368
+ return false;
369
+ }
370
+ if (!acknowledged) return false;
371
+ _turnInterruption.tombstoneText(detail?.chars);
372
+ return true;
373
+ };
337
374
  const _trackReasoningDelta = (chunk) => {
338
375
  _turnInterruption.recordReasoningDelta(chunk);
339
376
  if (typeof askOpts?.onReasoningDelta === 'function') askOpts.onReasoningDelta(chunk);
@@ -363,6 +400,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
363
400
  fast: session.fast === true,
364
401
  sessionId,
365
402
  onTextDelta: _trackTextDelta,
403
+ onTextReset: _trackTextReset,
366
404
  onReasoningDelta: _trackReasoningDelta,
367
405
  onAssistantText: _trackAssistantText,
368
406
  onAssistantMessageCommitted: () => _turnInterruption.markAssistantMessageCommitted(),
@@ -625,8 +663,14 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
625
663
  trimmed: messagesDropped > 0,
626
664
  messagesDropped,
627
665
  };
666
+ // The provider accepted this queued turn: only now remove its
667
+ // durable ids. A crash before here leaves them for at-least-once
668
+ // replay; duplicate memory/spool copies share the same id.
669
+ recordPendingMessageDelivery(session, _turnPendingEntries);
628
670
  _pwstTurnDrained = drainPendingMessages(sessionId);
671
+ let terminalRelayed = false;
629
672
  if (_pwstTurnDrained.length === 0 && typeof askOpts?.onTerminalResult === 'function') {
673
+ terminalRelayed = true;
630
674
  try {
631
675
  askOpts.onTerminalResult(terminalResultPreview, {
632
676
  sessionId,
@@ -640,16 +684,24 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
640
684
  // answer. This lets queued follow-up prompts resume immediately;
641
685
  // if they need compaction, their own spinner shows compacting first.
642
686
  // Fire-and-forget terminal save. The result is already produced and
643
- // (for agent surfaces) relayed via onTerminalResult above, and
644
- // saveSessionAsync() has already published the in-memory snapshot via
645
- // setLiveSession(), so read-your-writes holds in-process without
646
- // awaiting disk. Never block the terminal unwind on the write — that
647
- // would strand the owning background task in `running` and suppress
648
- // its completion notification. A slow write finishes in the
649
- // background.
650
- saveSessionAsync(session, { expectedGeneration: askGeneration }).catch((err) => {
651
- try { process.stderr.write(`[session] terminal save failed: ${err?.message || err}\n`); } catch {}
687
+ // (for agent surfaces) relayed via onTerminalResult above. When
688
+ // completion was relayed to the UI, yield before postMessage
689
+ // structured-clones the full session. Queued follow-up turns retain
690
+ // the original immediate-save ordering; they did not inject a
691
+ // terminal card and may mutate this same session on the next loop.
692
+ const saveTerminalSession = terminalRelayed
693
+ ? saveSessionAsyncDeferred
694
+ : saveSessionAsync;
695
+ const terminalSave = saveTerminalSession(session, { expectedGeneration: askGeneration });
696
+ finalizePendingMessageDelivery(
697
+ session,
698
+ _turnPendingEntries,
699
+ terminalSave,
700
+ () => saveSessionAsync(session, { expectedGeneration: askGeneration }),
701
+ ).catch((err) => {
702
+ try { process.stderr.write(`[session] terminal save failed: ${err?.message || err}\n`); } catch {}
652
703
  });
704
+ _turnPendingEntries = [];
653
705
  activeSession = session;
654
706
  runtime.session = session;
655
707
  // Tag empty-synthesis BEFORE markSessionDone so the watchdog
@@ -666,6 +718,11 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
666
718
  // live-turn alias so contextStatus() stops estimating from the
667
719
  // stale in-flight array once the turn unwinds.
668
720
  if (activeSession) activeSession.liveTurnMessages = null;
721
+ // Restore before ANY finalization path. In particular, cancellation
722
+ // can race the acknowledged non-streaming restart and surface as a
723
+ // SessionClosedError; its interruption snapshot must include the
724
+ // one partial response that was already exposed.
725
+ const restoredResetText = _turnInterruption.restoreTombstonedText();
669
726
  if (err instanceof SessionClosedError) {
670
727
  const currentRuntime = _getRuntimeEntry(sessionId);
671
728
  if (!currentRuntime?.closed) {
@@ -677,8 +734,10 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
677
734
  });
678
735
  activeSession.messages = finalized.messages;
679
736
  if (!finalized.responsePreserved) {
737
+ releasePendingMessages(sessionId, _turnPendingEntries);
680
738
  activeSession.sessionStartMetaInjected = _sessionStartMetaInjectedBeforeTurn;
681
739
  } else {
740
+ recordPendingMessageDelivery(activeSession, _turnPendingEntries);
682
741
  // The opaque provider continuation now points at a
683
742
  // request that ended mid-turn. Force full transcript
684
743
  // replay on the next send instead of reusing it.
@@ -687,23 +746,77 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
687
746
  activeSession.updatedAt = Date.now();
688
747
  activeSession.lastUsedAt = Date.now();
689
748
  try {
690
- await saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
749
+ const durableSave = saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
750
+ if (finalized.responsePreserved) {
751
+ await finalizePendingMessageDelivery(
752
+ activeSession, _turnPendingEntries, durableSave,
753
+ () => saveSessionAsync(activeSession, { expectedGeneration: askGeneration }),
754
+ );
755
+ } else {
756
+ await durableSave;
757
+ }
691
758
  } catch { /* cancellation cleanup is best-effort */ }
692
759
  if (currentRuntime) currentRuntime.session = activeSession;
693
- }
760
+ } else releasePendingMessages(sessionId, _turnPendingEntries);
694
761
  markSessionCancelled(sessionId);
695
- }
762
+ } else releasePendingMessages(sessionId, _turnPendingEntries);
696
763
  // Cancellation is not an error; propagate silently so callers
697
764
  // can render it as "cancelled" rather than a red failure.
698
765
  throw err;
699
766
  }
700
- await persistCompactedOutgoingAfterAskFailure({
701
- sessionId,
702
- activeSession,
703
- askGeneration,
704
- turnOutgoing: _turnOutgoing,
705
- error: err,
706
- });
767
+ // A reset acknowledgement removes the live partial before the
768
+ // non-streaming request starts. If that restart fails, restore the
769
+ // tombstone and persist the one exposed partial as interruption
770
+ // history. Failed/absent acknowledgements never tombstoned it.
771
+ const preserveProviderPartial = restoredResetText
772
+ || err?.liveTextEmitted === true
773
+ || err?.unsafeToRetry === true;
774
+ if (preserveProviderPartial && activeSession && _turnInterruption.hasResponseStarted()) {
775
+ const finalized = _turnInterruption.finalize({
776
+ turnOutgoing: _turnOutgoing || activeSession.messages,
777
+ currentUserContent: cancelledUserTurnContent,
778
+ abortReason: 'provider-error',
779
+ });
780
+ activeSession.messages = finalized.messages;
781
+ if (finalized.responsePreserved) recordPendingMessageDelivery(activeSession, _turnPendingEntries);
782
+ else releasePendingMessages(sessionId, _turnPendingEntries);
783
+ activeSession.providerState = undefined;
784
+ activeSession.updatedAt = Date.now();
785
+ activeSession.lastUsedAt = Date.now();
786
+ try {
787
+ const durableSave = saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
788
+ if (finalized.responsePreserved) {
789
+ await finalizePendingMessageDelivery(
790
+ activeSession, _turnPendingEntries, durableSave,
791
+ () => saveSessionAsync(activeSession, { expectedGeneration: askGeneration }),
792
+ );
793
+ } else {
794
+ await durableSave;
795
+ }
796
+ } catch { /* provider-failure history persistence is best-effort */ }
797
+ const currentRuntime = _getRuntimeEntry(sessionId);
798
+ if (currentRuntime) currentRuntime.session = activeSession;
799
+ } else {
800
+ const promptPersisted = await persistCompactedOutgoingAfterAskFailure({
801
+ sessionId,
802
+ activeSession,
803
+ askGeneration,
804
+ turnOutgoing: _turnOutgoing,
805
+ error: err,
806
+ });
807
+ if (promptPersisted) {
808
+ recordPendingMessageDelivery(activeSession, _turnPendingEntries);
809
+ try {
810
+ const durableSave = saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
811
+ await finalizePendingMessageDelivery(
812
+ activeSession, _turnPendingEntries, durableSave,
813
+ () => saveSessionAsync(activeSession, { expectedGeneration: askGeneration }),
814
+ );
815
+ } catch {}
816
+ } else {
817
+ releasePendingMessages(sessionId, _turnPendingEntries);
818
+ }
819
+ }
707
820
  markSessionError(sessionId, err && err.message ? err.message : String(err));
708
821
  throw err;
709
822
  }
@@ -724,7 +837,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
724
837
  // drain enqueues for the next loop pass and is merged there.
725
838
  const _mergedTail = _mergePendingMessageEntries(_drained);
726
839
  if (_mergedTail?.content) {
727
- _pendingTail.push(_mergedTail.content);
840
+ _pendingTail.push({ content: _mergedTail.content, entries: _drained });
728
841
  // Carry the just-committed in-memory session into the follow-up
729
842
  // turn so the queued tail sees the preceding assistant/tool
730
843
  // context. loadSession() would return this same live snapshot
@@ -736,6 +849,15 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
736
849
  }
737
850
  }
738
851
  _unlinkParentAbortListener(_getRuntimeEntry(sessionId));
852
+ // Pick up cross-process sends that landed after takeover hydration.
853
+ // This never delays completion; a non-empty sweep starts an empty
854
+ // follow-up ask after the current session lock is released.
855
+ setImmediate(() => {
856
+ hydratePendingMessages(sessionId).then((count) => {
857
+ if (count <= 0) return;
858
+ askSession(sessionId, '', null, onToolCall, cwdOverride, null, askOpts).catch(() => {});
859
+ }).catch(() => {});
860
+ });
739
861
  return _result;
740
862
  }
741
863
  } finally {
@@ -55,6 +55,20 @@ function sessionMessagesAdvancedBeyondCompactedOutgoing(currentSanitized, compac
55
55
  return isCompactedOutgoingFinalAssistantMessage(currentSanitized[currentSanitized.length - 1]);
56
56
  }
57
57
 
58
+ function messageSnapshotPresent(messages, wanted) {
59
+ if (!wanted || !Array.isArray(messages)) return false;
60
+ return messages.some((message) => {
61
+ if (message === wanted) return true;
62
+ try { return JSON.stringify(message) === JSON.stringify(wanted); } catch { return false; }
63
+ });
64
+ }
65
+
66
+ export function compactedOutgoingPromptRetained(activeMessages, outgoingMessages) {
67
+ const outgoing = filterModelVisibleSessionMessages(outgoingMessages);
68
+ const prompt = [...outgoing].reverse().find((message) => message?.role === 'user');
69
+ return Boolean(prompt) && messageSnapshotPresent(filterModelVisibleSessionMessages(activeMessages), prompt);
70
+ }
71
+
58
72
  export const _sessionMessagesAdvancedBeyondCompactedOutgoing = sessionMessagesAdvancedBeyondCompactedOutgoing;
59
73
 
60
74
  function applyCompactFailurePersistToSession(activeSession, {
@@ -100,24 +114,26 @@ export async function persistCompactedOutgoingAfterAskFailure({
100
114
  turnOutgoing,
101
115
  error = null,
102
116
  }) {
103
- if (!activeSession || activeSession.closed === true) return;
104
- if (!Array.isArray(turnOutgoing) || turnOutgoing.length === 0) return;
117
+ if (!activeSession || activeSession.closed === true) return false;
118
+ if (!Array.isArray(turnOutgoing) || turnOutgoing.length === 0) return false;
105
119
  const currentRuntime = _getRuntimeEntry(sessionId);
106
- if (currentRuntime?.closed || currentRuntime?.generation !== askGeneration) return;
120
+ if (currentRuntime?.closed || currentRuntime?.generation !== askGeneration) return false;
107
121
  const sanitized = filterModelVisibleSessionMessages(turnOutgoing);
108
122
  const priorSanitized = filterModelVisibleSessionMessages(
109
123
  Array.isArray(activeSession.messages) ? activeSession.messages : [],
110
124
  );
111
125
  const messagesAdvanced = sessionMessagesAdvancedBeyondCompactedOutgoing(priorSanitized, sanitized);
126
+ const promptRetainedBefore = compactedOutgoingPromptRetained(priorSanitized, sanitized);
112
127
  const applied = applyCompactFailurePersistToSession(activeSession, {
113
128
  priorSanitized,
114
129
  sanitized,
115
130
  messagesAdvanced,
116
131
  error,
117
132
  });
118
- if (!applied) return;
133
+ if (!applied) return promptRetainedBefore;
119
134
  try {
120
135
  await saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
121
136
  } catch { /* best-effort: preserve in-memory compaction even if disk is slow */ }
122
137
  if (currentRuntime) currentRuntime.session = activeSession;
138
+ return compactedOutgoingPromptRetained(activeSession.messages, sanitized);
123
139
  }