mixdog 0.9.151 → 0.9.152

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 (175) hide show
  1. package/README.md +110 -68
  2. package/package.json +3 -2
  3. package/scripts/tool-stress.mjs +0 -2
  4. package/src/defaults/skills/setup/SKILL.md +18 -27
  5. package/src/headless-exec.mjs +3 -10
  6. package/src/headless-exec.test.mjs +2 -7
  7. package/src/runtime/agent/orchestrator/config.mjs +6 -66
  8. package/src/runtime/agent/orchestrator/context/collect-skills.test.mjs +153 -0
  9. package/src/runtime/agent/orchestrator/context/collect.mjs +49 -45
  10. package/src/runtime/agent/orchestrator/mcp/client.mjs +242 -15
  11. package/src/runtime/agent/orchestrator/mcp/features.test.mjs +66 -0
  12. package/src/runtime/agent/orchestrator/mcp/security.test.mjs +36 -20
  13. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +54 -33
  14. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -0
  15. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
  16. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +7 -0
  17. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +3 -8
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +7 -0
  19. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +10 -2
  20. package/src/runtime/agent/orchestrator/providers/lib/provider-replay.mjs +50 -0
  21. package/src/runtime/agent/orchestrator/providers/lib/sse-framing.mjs +93 -0
  22. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +3 -3
  23. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +8 -0
  24. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +7 -0
  25. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +25 -2
  26. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -0
  28. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -2
  29. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +13 -0
  30. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +4 -7
  31. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +9 -0
  32. package/src/runtime/agent/orchestrator/providers/provider-replay.test.mjs +235 -0
  33. package/src/runtime/agent/orchestrator/providers/sse-framing.test.mjs +719 -0
  34. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +399 -39
  35. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +26 -0
  36. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +14 -4
  37. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +13 -0
  38. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +24 -1
  39. package/src/runtime/agent/orchestrator/session/compaction-read-reset.test.mjs +161 -0
  40. package/src/runtime/agent/orchestrator/session/context-utils.mjs +14 -4
  41. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -2
  42. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -1
  43. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +31 -18
  44. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -0
  45. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +3 -0
  46. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +6 -1
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +1 -3
  48. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.mjs +556 -0
  49. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.test.mjs +457 -0
  50. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +105 -186
  51. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +115 -0
  52. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +2 -0
  53. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -0
  54. package/src/runtime/agent/orchestrator/session/store.mjs +23 -1
  55. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool-cwd-env.test.mjs +14 -0
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +19 -10
  57. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +30 -14
  58. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +15 -4
  59. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +73 -15
  60. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +76 -2
  61. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +24 -683
  62. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +64 -72
  63. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +2 -5
  64. package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.mjs +75 -0
  65. package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.test.mjs +24 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +12 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +4 -1
  68. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +24 -5
  69. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.test.mjs +44 -0
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +12 -2
  71. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +35 -21
  72. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +28 -13
  73. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.test.mjs +54 -0
  74. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  75. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  76. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +8 -0
  77. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  78. package/src/runtime/browser-bridge/client.mjs +103 -0
  79. package/src/runtime/browser-bridge/tool-defs.mjs +42 -0
  80. package/src/runtime/computer-bridge/client.mjs +101 -0
  81. package/src/runtime/computer-bridge/tool-defs.mjs +53 -0
  82. package/src/runtime/media/store.mjs +18 -8
  83. package/src/runtime/media/store.test.mjs +14 -0
  84. package/src/runtime/memory/index.mjs +1 -1
  85. package/src/runtime/memory/lib/transcript-ingest.mjs +8 -6
  86. package/src/runtime/memory/lib/transcript-ingest.test.mjs +52 -0
  87. package/src/runtime/shared/child-spawn-gate.mjs +17 -0
  88. package/src/runtime/shared/child-spawn-remote.mjs +69 -7
  89. package/src/runtime/shared/child-spawn-remote.test.mjs +124 -0
  90. package/src/runtime/shared/pristine-execution-contract.json +2 -1
  91. package/src/runtime/shared/provider-api-key.mjs +1 -0
  92. package/src/runtime/shared/session-runtime-health.mjs +91 -0
  93. package/src/runtime/shared/session-runtime-health.test.mjs +53 -0
  94. package/src/runtime/shared/skill-document.mjs +86 -0
  95. package/src/runtime/shared/skill-document.test.mjs +81 -0
  96. package/src/runtime/shared/tool-surface.mjs +18 -0
  97. package/src/runtime/shared/turn-snapshot-store.mjs +1 -0
  98. package/src/runtime/shared/turn-snapshot.mjs +32 -9
  99. package/src/runtime/shared/user-cwd.mjs +33 -14
  100. package/src/runtime/shared/user-cwd.test.mjs +43 -0
  101. package/src/session-runtime/cwd-plugins.mjs +26 -26
  102. package/src/session-runtime/cwd-plugins.test.mjs +75 -0
  103. package/src/session-runtime/cwd-tool-routing.test.mjs +5 -0
  104. package/src/session-runtime/global-extensions.mjs +26 -0
  105. package/src/session-runtime/global-extensions.test.mjs +28 -0
  106. package/src/session-runtime/goal-runtime.mjs +721 -0
  107. package/src/session-runtime/goal-runtime.test.mjs +108 -0
  108. package/src/session-runtime/lifecycle-api.mjs +18 -4
  109. package/src/session-runtime/lifecycle-api.test.mjs +21 -0
  110. package/src/session-runtime/mcp-glue.mjs +49 -47
  111. package/src/session-runtime/plugin-mcp-standard.test.mjs +177 -0
  112. package/src/session-runtime/plugin-mcp.mjs +96 -22
  113. package/src/session-runtime/prewarm.mjs +1 -10
  114. package/src/session-runtime/resource-api-global.test.mjs +69 -0
  115. package/src/session-runtime/resource-api.mjs +173 -58
  116. package/src/session-runtime/runtime-core.mjs +148 -20
  117. package/src/session-runtime/runtime-tunables.mjs +6 -8
  118. package/src/session-runtime/session-turn-api.mjs +22 -11
  119. package/src/session-runtime/skills-api.mjs +79 -25
  120. package/src/session-runtime/skills-api.test.mjs +99 -0
  121. package/src/session-runtime/tool-catalog-schema.mjs +0 -5
  122. package/src/session-runtime/tool-catalog.mjs +4 -13
  123. package/src/session-runtime/tool-policy-surface.test.mjs +33 -11
  124. package/src/session-runtime/tool-surface.mjs +12 -0
  125. package/src/standalone/agent-dispatch-broker.mjs +5 -9
  126. package/src/standalone/agent-tool/notify.mjs +6 -8
  127. package/src/standalone/agent-tool/notify.test.mjs +37 -0
  128. package/src/standalone/daemon.mjs +31 -15
  129. package/src/standalone/hook-bus/config.mjs +1 -0
  130. package/src/standalone/plugin-admin.mjs +17 -0
  131. package/src/standalone/plugin-admin.test.mjs +7 -0
  132. package/src/standalone/provider-admin.mjs +1 -0
  133. package/src/standalone/session-protocol.mjs +5 -0
  134. package/src/standalone/session-runtime-agent-control-client.mjs +133 -0
  135. package/src/standalone/session-runtime-agent-control-client.test.mjs +71 -0
  136. package/src/standalone/session-runtime-dispatch-cancel.test.mjs +139 -0
  137. package/src/standalone/session-runtime-host-factory.mjs +17 -0
  138. package/src/standalone/session-runtime-host-factory.test.mjs +31 -0
  139. package/src/standalone/session-runtime-host-health.test.mjs +5 -0
  140. package/src/standalone/session-runtime-host.mjs +696 -58
  141. package/src/standalone/session-runtime-inline-host.mjs +351 -0
  142. package/src/standalone/session-runtime-inline-host.test.mjs +83 -0
  143. package/src/standalone/session-runtime-provider-cooldown.mjs +78 -0
  144. package/src/standalone/session-runtime-provider-cooldown.test.mjs +80 -0
  145. package/src/standalone/session-runtime-shard-host.test.mjs +569 -0
  146. package/src/standalone/session-runtime-shard-router.mjs +149 -0
  147. package/src/standalone/session-runtime-shard-router.test.mjs +101 -0
  148. package/src/standalone/session-runtime-worker.mjs +476 -18
  149. package/src/standalone/session-service.mjs +81 -6
  150. package/src/tui/app/app-view.jsx +1 -0
  151. package/src/tui/app/core-memory-picker.mjs +2 -0
  152. package/src/tui/app/extension-pickers.mjs +1 -1
  153. package/src/tui/app/maintenance-pickers.mjs +3 -3
  154. package/src/tui/app/model-options.mjs +2 -0
  155. package/src/tui/app/model-picker.mjs +14 -4
  156. package/src/tui/app/panel-handoff.test.mjs +311 -0
  157. package/src/tui/app/project-picker.mjs +1 -0
  158. package/src/tui/app/route-pickers.mjs +21 -6
  159. package/src/tui/app/settings-picker.mjs +26 -3
  160. package/src/tui/app/slash-commands.mjs +1 -0
  161. package/src/tui/app/slash-dispatch.mjs +73 -22
  162. package/src/tui/app/theme-effort-pickers.mjs +7 -2
  163. package/src/tui/components/Picker.jsx +10 -1
  164. package/src/tui/dist/index.mjs +163 -44
  165. package/src/tui/session/context-state.mjs +1 -0
  166. package/src/tui/session/goal-continuation.mjs +110 -0
  167. package/src/tui/session/goal-continuation.test.mjs +84 -0
  168. package/src/tui/session/queue-helpers.mjs +5 -2
  169. package/src/tui/session/session-action-surface.test.mjs +108 -0
  170. package/src/tui/session/session-api-ext.mjs +39 -8
  171. package/src/tui/session/session-api.mjs +58 -0
  172. package/src/tui/session/session-flow.mjs +6 -0
  173. package/src/tui/session/turn.mjs +1 -0
  174. package/src/tui/session-local.mjs +15 -1
  175. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +0 -301
@@ -17,6 +17,7 @@ import {
17
17
  import { isContextOverflowError } from '../providers/retry-classifier.mjs';
18
18
  import { tryReadCached, setReadCached, invalidatePathForSession, clearReadDedupSession, extractTouchedPathsFromPatch, tryScopedToolCached, setScopedToolCached, clearScopedToolsForSession, clearScopedToolsForSessionPaths, invalidatePrefetchCache } from './read-dedup.mjs';
19
19
  import { isInvalidToolArgsMarker, formatInvalidToolArgsResult } from '../providers/openai-compat-stream.mjs';
20
+ import { cloneProviderReplay } from '../providers/lib/provider-replay.mjs';
20
21
 
21
22
  import {
22
23
  _stripMcpPrefix,
@@ -310,6 +311,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
310
311
  const reasoningItems = Array.isArray(resp.reasoningItems) && resp.reasoningItems.length
311
312
  ? resp.reasoningItems
312
313
  : null;
314
+ const providerReplay = cloneProviderReplay(resp.providerReplay);
313
315
  const thinkingBlocks = Array.isArray(resp.thinkingBlocks) && resp.thinkingBlocks.length
314
316
  ? resp.thinkingBlocks
315
317
  : null;
@@ -335,18 +337,19 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
335
337
  : null;
336
338
  // A native-only turn (server tool blocks, no flattened text) is real
337
339
  // assistant output and must stay committable — not treated as empty.
338
- if (!content && !reasoningContent && !reasoningItems && !thinkingBlocks && !assistantBlocks) return false;
340
+ if (!content && !reasoningContent && !reasoningItems && !thinkingBlocks && !assistantBlocks && !providerReplay) return false;
339
341
  const message = attachAssistantTranscriptMetadata({
340
342
  role: 'assistant',
341
343
  content,
344
+ ...(providerReplay ? { providerReplay } : {}),
342
345
  // assistantBlocks already contains the thinking blocks verbatim in
343
346
  // stream order, so thinkingBlocks is redundant (and would be
344
347
  // double-counted by the context estimator) when it is present.
345
- ...(assistantBlocks ? { assistantBlocks } : {}),
348
+ ...(assistantBlocks && !providerReplay ? { assistantBlocks } : {}),
346
349
  // Anthropic adaptive-thinking signatures must be replayed verbatim
347
350
  // before the continuation turn, just like tool-call trajectories.
348
- ...(thinkingBlocks && !assistantBlocks ? { thinkingBlocks } : {}),
349
- ...(reasoningItems ? { reasoningItems } : {}),
351
+ ...(thinkingBlocks && !assistantBlocks && !providerReplay ? { thinkingBlocks } : {}),
352
+ ...(reasoningItems && !providerReplay ? { reasoningItems } : {}),
350
353
  ...(reasoningContent ? { reasoningContent } : {}),
351
354
  ...(providerMetadata ? { providerMetadata } : {}),
352
355
  ...(stopReason ? { stopReason } : {}),
@@ -1011,6 +1014,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1011
1014
  // every assistant message body.
1012
1015
  recordToolBatch(sessionId, calls.length);
1013
1016
  await Promise.resolve(onToolCall?.(iterations, calls));
1017
+ const _providerReplay = cloneProviderReplay(response.providerReplay);
1014
1018
  // Append assistant message with tool calls. reasoningItems is the
1015
1019
  // OpenAI Responses API replay payload (encrypted_content blobs);
1016
1020
  // providers that ignore it just see an extra field and drop it,
@@ -1030,6 +1034,9 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1030
1034
  // the whole request uncached — so a model that patches twice in a
1031
1035
  // row also never sees a marker where its own last patch should be.
1032
1036
  toolCalls: compactToolCallsForHistory(calls, { deferBodies: true }),
1037
+ ...(_providerReplay
1038
+ ? { providerReplay: _providerReplay }
1039
+ : {}),
1033
1040
  // MIXED Anthropic turn (native server tools + client tool_use):
1034
1041
  // the ordered `server_tool_use` / `*_tool_result` blocks exist ONLY
1035
1042
  // in this verbatim list and are order-bound (a result block is
@@ -1040,6 +1047,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1040
1047
  // text/thinking/tool_use blocks), so nothing is emitted twice and
1041
1048
  // non-Anthropic providers simply ignore the field.
1042
1049
  ...(Array.isArray(response.assistantBlocks) && response.assistantBlocks.length
1050
+ && !_providerReplay
1043
1051
  ? { assistantBlocks: response.assistantBlocks }
1044
1052
  : {}),
1045
1053
  // Anthropic adaptive thinking: prior-turn thinking blocks must be
@@ -1051,9 +1059,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1051
1059
  // second copy would double-count in the context estimator.
1052
1060
  ...(Array.isArray(response.thinkingBlocks) && response.thinkingBlocks.length
1053
1061
  && !(Array.isArray(response.assistantBlocks) && response.assistantBlocks.length)
1062
+ && !_providerReplay
1054
1063
  ? { thinkingBlocks: response.thinkingBlocks }
1055
1064
  : {}),
1056
1065
  ...(Array.isArray(response.reasoningItems) && response.reasoningItems.length
1066
+ && !_providerReplay
1057
1067
  ? { reasoningItems: response.reasoningItems }
1058
1068
  : {}),
1059
1069
  ...(typeof response.reasoningContent === 'string' && response.reasoningContent
@@ -260,6 +260,19 @@ export function clearReadDedupSession(sessionId) {
260
260
  clearScopedCounters(sessionId);
261
261
  }
262
262
 
263
+ /**
264
+ * Start a fresh read epoch after compaction removes prior tool results from
265
+ * the active context. Both layers must reset together: retaining the result
266
+ * cache can bypass snapshot recreation, while retaining the persisted
267
+ * snapshot can make a fresh read answer only "[file unchanged]".
268
+ */
269
+ export function resetReadStateAfterCompaction(sessionId) {
270
+ if (!sessionId) return;
271
+ _bySession.delete(sessionId);
272
+ _reverseIdx.delete(sessionId);
273
+ releaseReadSnapshotScope(sessionId, { deletePersisted: true, persist: false });
274
+ }
275
+
263
276
  registerSessionPurgeHook((sessionId) => {
264
277
  clearReadDedupSession(sessionId);
265
278
  releaseReadSnapshotScope(sessionId, { deletePersisted: true, persist: false });
@@ -589,11 +589,34 @@ function withoutRecallReplayMetadata(message) {
589
589
  return copy;
590
590
  }
591
591
 
592
+ function hasAtomicReplayState(message) {
593
+ if (!message || message.role !== 'assistant') return false;
594
+ if (Array.isArray(message.providerReplay?.items) && message.providerReplay.items.length) return true;
595
+ if (Array.isArray(message.assistantBlocks) && message.assistantBlocks.length) return true;
596
+ if (Array.isArray(message.thinkingBlocks) && message.thinkingBlocks.length) return true;
597
+ if (Array.isArray(message.reasoningItems) && message.reasoningItems.length) return true;
598
+ if (Array.isArray(message.providerMetadata?.gemini?.thoughtParts)
599
+ && message.providerMetadata.gemini.thoughtParts.length) return true;
600
+ if (Array.isArray(message.providerMetadata?.gemini?.textParts)
601
+ && message.providerMetadata.gemini.textParts.length) return true;
602
+ if (Array.isArray(message.toolCalls)
603
+ && message.toolCalls.some((call) => typeof call?.thoughtSignature === 'string' && call.thoughtSignature)) {
604
+ return true;
605
+ }
606
+ if (typeof message.reasoningContent === 'string' && message.reasoningContent) return true;
607
+ const openRouter = message.providerMetadata?.openrouter;
608
+ const reasoningDetails = openRouter?.reasoning_details ?? openRouter?.reasoningDetails;
609
+ return Array.isArray(reasoningDetails) && reasoningDetails.length > 0;
610
+ }
611
+
592
612
  function fitRecallMessageToCap(message, cap, rawText = null) {
593
613
  const text = rawText == null
594
614
  ? (typeof message?.content === 'string' ? message.content : extractText(message))
595
615
  : String(rawText);
596
- for (const base of [message, withoutRecallReplayMetadata(message)]) {
616
+ const bases = hasAtomicReplayState(message)
617
+ ? [message]
618
+ : [message, withoutRecallReplayMetadata(message)];
619
+ for (const base of bases) {
597
620
  if (!base || typeof base !== 'object') continue;
598
621
  if (estimateMessagesTokens([{ ...base, content: text }]) <= cap) {
599
622
  return { ...base, content: text };
@@ -0,0 +1,161 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import { executeBuiltinTool } from '../tools/builtin.mjs';
8
+ import { COMPACT_TYPE_SEMANTIC } from './compact.mjs';
9
+ import { runSessionCompaction } from './manager/compaction-runner.mjs';
10
+ import { runPreSendCompactPass } from './pre-send-compact.mjs';
11
+ import { resetReadStateAfterCompaction } from './read-dedup.mjs';
12
+
13
+ const SUMMARY = [
14
+ '## Goal',
15
+ '- continue after compaction',
16
+ '',
17
+ '## Constraints & Preferences',
18
+ '- (none)',
19
+ '',
20
+ '## Progress',
21
+ '### Done',
22
+ '- old context summarized',
23
+ '',
24
+ '### In Progress',
25
+ '- (none)',
26
+ '',
27
+ '### Blocked',
28
+ '- (none)',
29
+ '',
30
+ '## Key Decisions',
31
+ '- (none)',
32
+ '',
33
+ '## Next Steps',
34
+ '- reread the working file',
35
+ '',
36
+ '## Critical Context',
37
+ '- active file must be readable again',
38
+ '',
39
+ '## Relevant Files',
40
+ '- working.txt',
41
+ ].join('\n');
42
+
43
+ function fixture(t, tag) {
44
+ const dir = mkdtempSync(join(tmpdir(), `mixdog-compact-read-${tag}-`));
45
+ const file = join(dir, 'working.txt');
46
+ const sessionId = `compact-read-${tag}-${process.pid}-${Date.now()}`;
47
+ writeFileSync(file, 'alpha\nbeta\n', 'utf8');
48
+ t.after(() => {
49
+ resetReadStateAfterCompaction(sessionId);
50
+ rmSync(dir, { recursive: true, force: true });
51
+ });
52
+ return { dir, file, sessionId };
53
+ }
54
+
55
+ async function establishUnchangedSnapshot({ dir, file, sessionId }) {
56
+ const first = String(await executeBuiltinTool('read', { path: file }, dir, { sessionId }));
57
+ assert.match(first, /alpha/);
58
+ const duplicate = await executeBuiltinTool('read', { path: file }, dir, { sessionId });
59
+ assert.equal(duplicate, `[file unchanged: ${file.replaceAll('\\', '/')}]`);
60
+ }
61
+
62
+ function semanticProvider(name) {
63
+ return {
64
+ name,
65
+ async send() {
66
+ return { content: SUMMARY };
67
+ },
68
+ };
69
+ }
70
+
71
+ test('manual compaction starts a fresh read epoch', async (t) => {
72
+ const fx = fixture(t, 'manual');
73
+ await establishUnchangedSnapshot(fx);
74
+
75
+ const session = {
76
+ id: fx.sessionId,
77
+ provider: 'compact-read-manual',
78
+ model: 'fake-model',
79
+ contextWindow: 100_000,
80
+ compactBoundaryTokens: 100_000,
81
+ messages: [
82
+ { role: 'system', content: 'system rules stay exact' },
83
+ { role: 'user', content: 'older request about working.txt' },
84
+ { role: 'assistant', content: 'older answer with file details' },
85
+ { role: 'user', content: 'current request remains verbatim' },
86
+ ],
87
+ tools: [],
88
+ compaction: {
89
+ type: COMPACT_TYPE_SEMANTIC,
90
+ compactType: COMPACT_TYPE_SEMANTIC,
91
+ tailTurns: 1,
92
+ },
93
+ };
94
+ const result = await runSessionCompaction(session, {
95
+ mode: 'manual',
96
+ force: true,
97
+ compactType: COMPACT_TYPE_SEMANTIC,
98
+ provider: semanticProvider('compact-read-manual'),
99
+ model: 'fake-model',
100
+ });
101
+
102
+ assert.equal(result.changed, true);
103
+ const reread = String(await executeBuiltinTool('read', { path: fx.file }, fx.dir, { sessionId: fx.sessionId }));
104
+ assert.doesNotMatch(reread, /file unchanged/);
105
+ assert.match(reread, /alpha/);
106
+ });
107
+
108
+ test('automatic pre-send compaction resets reads only after transcript change', async (t) => {
109
+ const fx = fixture(t, 'auto');
110
+ await establishUnchangedSnapshot(fx);
111
+
112
+ const session = {
113
+ id: fx.sessionId,
114
+ owner: 'agent',
115
+ provider: 'compact-read-auto',
116
+ model: 'fake-model',
117
+ cwd: fx.dir,
118
+ contextWindow: 20_000,
119
+ compactBoundaryTokens: 20_000,
120
+ messages: [
121
+ { role: 'user', content: 'small request' },
122
+ { role: 'assistant', content: 'small answer' },
123
+ ],
124
+ tools: [],
125
+ compaction: { auto: true, tailTurns: 1 },
126
+ };
127
+ const state = {
128
+ provider: semanticProvider('compact-read-auto'),
129
+ messages: session.messages,
130
+ model: session.model,
131
+ requestTools: [],
132
+ sessionRef: session,
133
+ sessionId: fx.sessionId,
134
+ cwd: fx.dir,
135
+ opts: {},
136
+ signal: null,
137
+ iterations: 1,
138
+ lastUsage: null,
139
+ firstTurnUsage: null,
140
+ providerState: null,
141
+ reactiveOverflowRetryPending: false,
142
+ loopUsageMetricsTurnId: () => 'test-turn',
143
+ loopUsageMetricsEpoch: () => 0,
144
+ };
145
+
146
+ const noChange = await runPreSendCompactPass(state);
147
+ assert.equal(noChange.compactChanged, false);
148
+ const stillUnchanged = await executeBuiltinTool('read', { path: fx.file }, fx.dir, { sessionId: fx.sessionId });
149
+ assert.equal(stillUnchanged, `[file unchanged: ${fx.file.replaceAll('\\', '/')}]`);
150
+
151
+ session.messages.unshift(
152
+ { role: 'user', content: `large old request ${'context '.repeat(12_000)}` },
153
+ { role: 'assistant', content: `large old answer ${'detail '.repeat(12_000)}` },
154
+ );
155
+ const changed = await runPreSendCompactPass({ ...state, messages: session.messages });
156
+ assert.equal(changed.compactChanged, true);
157
+
158
+ const reread = String(await executeBuiltinTool('read', { path: fx.file }, fx.dir, { sessionId: fx.sessionId }));
159
+ assert.doesNotMatch(reread, /file unchanged/);
160
+ assert.match(reread, /alpha/);
161
+ });
@@ -336,20 +336,26 @@ function messageEstimateText(m) {
336
336
  try { text += `\n${JSON.stringify(m.toolCalls)}`; }
337
337
  catch { text += `\n[${m.toolCalls.length} tool calls]`; }
338
338
  }
339
+ const providerReplayItems = m.role === 'assistant' && Array.isArray(m.providerReplay?.items)
340
+ ? m.providerReplay.items
341
+ : null;
342
+ if (providerReplayItems?.length) {
343
+ text += `\n${nativeBlocksEstimateText(providerReplayItems)}`;
344
+ }
339
345
  // Anthropic adaptive-thinking blocks round-trip verbatim (thinking text +
340
346
  // signature / redacted data) and are re-sent on tool-continuation turns, so
341
347
  // they consume real input tokens. Count them or trim/compact undercounts.
342
- if (m.role === 'assistant' && Array.isArray(m.thinkingBlocks) && m.thinkingBlocks.length) {
348
+ if (!providerReplayItems && m.role === 'assistant' && Array.isArray(m.thinkingBlocks) && m.thinkingBlocks.length) {
343
349
  text += `\n${nativeBlocksEstimateText(m.thinkingBlocks)}`;
344
350
  }
345
351
  // Some provider adapters replay their native assistant representation
346
352
  // instead of content/toolCalls. Project it through the media normalizer so
347
353
  // text/tool metadata and opaque reasoning are counted without base64 image
348
354
  // bytes dominating the estimate.
349
- if (m.role === 'assistant' && Array.isArray(m.assistantBlocks) && m.assistantBlocks.length) {
355
+ if (!providerReplayItems && m.role === 'assistant' && Array.isArray(m.assistantBlocks) && m.assistantBlocks.length) {
350
356
  text += `\n${nativeBlocksEstimateText(m.assistantBlocks)}`;
351
357
  }
352
- if (m.role === 'assistant' && Array.isArray(m.reasoningItems) && m.reasoningItems.length) {
358
+ if (!providerReplayItems && m.role === 'assistant' && Array.isArray(m.reasoningItems) && m.reasoningItems.length) {
353
359
  text += `\n${nativeBlocksEstimateText(m.reasoningItems)}`;
354
360
  }
355
361
  // Provider-scoped replay metadata is never sent to a different provider,
@@ -357,7 +363,7 @@ function messageEstimateText(m) {
357
363
  const geminiThoughtParts = m.role === 'assistant'
358
364
  ? m.providerMetadata?.gemini?.thoughtParts
359
365
  : null;
360
- if (Array.isArray(geminiThoughtParts) && geminiThoughtParts.length) {
366
+ if (!providerReplayItems && Array.isArray(geminiThoughtParts) && geminiThoughtParts.length) {
361
367
  text += `\n${nativeBlocksEstimateText(geminiThoughtParts)}`;
362
368
  }
363
369
  if (m.role === 'tool' && m.toolCallId) text += `\n${m.toolCallId}`;
@@ -384,6 +390,7 @@ function messageImageDescriptors(m) {
384
390
  return [
385
391
  ...contentImageDescriptors(m.content),
386
392
  ...(m.role === 'assistant' ? contentImageDescriptors(m.assistantBlocks) : []),
393
+ ...(m.role === 'assistant' ? contentImageDescriptors(m.providerReplay?.items) : []),
387
394
  ];
388
395
  }
389
396
  function messageImageAllowance(m) {
@@ -441,6 +448,7 @@ function contextMessageFingerprint(message) {
441
448
  assistantBlocks: contextValueFingerprint(null),
442
449
  reasoningItems: contextValueFingerprint(null),
443
450
  providerMetadata: contextValueFingerprint(null),
451
+ providerReplay: contextValueFingerprint(null),
444
452
  toolCallId: null,
445
453
  };
446
454
  }
@@ -452,6 +460,7 @@ function contextMessageFingerprint(message) {
452
460
  assistantBlocks: contextValueFingerprint(message.assistantBlocks),
453
461
  reasoningItems: contextValueFingerprint(message.reasoningItems),
454
462
  providerMetadata: contextValueFingerprint(message.providerMetadata),
463
+ providerReplay: contextValueFingerprint(message.providerReplay),
455
464
  toolCallId: message?.toolCallId || null,
456
465
  };
457
466
  }
@@ -468,6 +477,7 @@ function sameContextMessageFingerprint(a, b) {
468
477
  && sameContextValueFingerprint(a.assistantBlocks, b.assistantBlocks)
469
478
  && sameContextValueFingerprint(a.reasoningItems, b.reasoningItems)
470
479
  && sameContextValueFingerprint(a.providerMetadata, b.providerMetadata)
480
+ && sameContextValueFingerprint(a.providerReplay, b.providerReplay)
471
481
  && a.toolCallId === b.toolCallId;
472
482
  }
473
483
 
@@ -1,6 +1,6 @@
1
1
  // Recall-fasttrack compaction pipeline (digest injection).
2
- // Hydrates the session transcript into the memory pipeline (ingest_session),
3
- // then injects a small newest-first digest + recall pointer into the
2
+ // Reads the session transcript already persisted by the memory watcher, then
3
+ // injects a small newest-first digest + recall pointer into the
4
4
  // compacted messages. The former full-dump path (dump_session_roots +
5
5
  // synchronous cycle1 drain) was removed 2026-07: the drain ran memory-
6
6
  // pipeline LLM chunking calls inside the compaction (11.9s of a measured
@@ -117,6 +117,10 @@ async function runReadOnlyIoWithDeadline(name, parentSignal, run) {
117
117
 
118
118
  export { runReadOnlyIoWithDeadline as _runReadOnlyIoWithDeadlineForTest };
119
119
 
120
+ export function resolveToolCompletionSessionId({ callerSessionId } = {}) {
121
+ return String(callerSessionId || '').trim();
122
+ }
123
+
120
124
  let codeGraphRuntimePromise = null;
121
125
  async function executeCodeGraphToolLazy(name, args, cwd, signal = null, options = {}) {
122
126
  codeGraphRuntimePromise ??= import('../../tools/code-graph.mjs');
@@ -170,7 +174,14 @@ async function executeToolOwned(name, args, cwd, callerSessionId, sessionRef, ex
170
174
  const toolOpts = scopedCacheOutcome
171
175
  ? { ...executeOpts, scopedCacheOutcome }
172
176
  : executeOpts;
173
- const notificationSessionId = String(executeOpts.notifySessionId || sessionRef?.ownerSessionId || callerSessionId || '').trim();
177
+ // A background tool belongs to the session that invoked it. Subagent
178
+ // sessions carry the top-level UI session in ownerSessionId, but routing a
179
+ // shell completion there leaks the child task into the lead transcript.
180
+ const notificationSessionId = resolveToolCompletionSessionId({
181
+ callerSessionId,
182
+ ownerSessionId: sessionRef?.ownerSessionId,
183
+ requestedNotificationSessionId: executeOpts.notifySessionId,
184
+ });
174
185
  const notifyFn = typeof executeOpts.notifyFn === 'function'
175
186
  ? executeOpts.notifyFn
176
187
  : (text, meta = {}) => {
@@ -6,6 +6,7 @@
6
6
  import { createHash, randomUUID } from 'crypto';
7
7
  import { getProvider } from '../../providers/registry.mjs';
8
8
  import { readStreamOutcome } from '../../providers/lib/stream-outcome.mjs';
9
+ import { cloneProviderReplay } from '../../providers/lib/provider-replay.mjs';
9
10
  import { classifyError } from '../../providers/retry-classifier.mjs';
10
11
  import { normalizeCompactType, DEFAULT_COMPACT_TYPE } from '../compact.mjs';
11
12
  import { loadSession, saveSession, saveSessionAsync, saveSessionAsyncDeferred, readSessionLifecycleFromDisk } from '../store.mjs';
@@ -60,11 +61,9 @@ import {
60
61
  } from './message-sanitize.mjs';
61
62
  import { createTurnInterruptionTracker } from './turn-interruption.mjs';
62
63
  import {
63
- cancelPendingTurnCheckpoint,
64
64
  clearTurnCheckpoint,
65
+ createTurnCheckpointRecorder,
65
66
  recoverTurnCheckpoint,
66
- turnMessagesForCheckpoint,
67
- writeTurnCheckpoint,
68
67
  } from './turn-checkpoint.mjs';
69
68
  import { _getAgentLoop } from './runtime-loaders.mjs';
70
69
  import { getAgentRuntimeSync } from './agent-runtime-singleton.mjs';
@@ -344,29 +343,30 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
344
343
  const _turnCheckpointToken = randomUUID();
345
344
  const _turnCheckpointStartedAt = Date.now();
346
345
  const _codexTurnId = _codexWireSessionId ? mintUuidV7(_turnCheckpointStartedAt) : null;
346
+ // Coalescing window for streaming-delta flushes. It is no longer a
347
+ // throttle on a full-snapshot rewrite: each flush now journals only the
348
+ // delta since the previous one, so the window just bounds syscalls.
347
349
  const _TURN_CHECKPOINT_THROTTLE_MS = 150;
348
350
  let _turnCheckpointTimer = null;
349
351
  let _turnCheckpointStopped = false;
350
352
  let _turnCheckpointLastAt = 0;
351
353
  let _turnCheckpointWarned = false;
354
+ // The recorder writes this turn's header synchronously on its first
355
+ // flush (the crash-durability anchor for the prompt, landing before the
356
+ // provider runs) and appends bounded deltas afterwards.
357
+ const _turnCheckpointRecorder = createTurnCheckpointRecorder({
358
+ sessionId,
359
+ generation: askGeneration,
360
+ turnToken: _turnCheckpointToken,
361
+ startedAt: _turnCheckpointStartedAt,
362
+ });
352
363
  const _flushTurnCheckpoint = () => {
353
364
  if (_turnCheckpointStopped || !_turnOutgoing || !cancelledUserTurnContent) return false;
354
365
  try {
355
- const written = writeTurnCheckpoint({
356
- sessionId,
357
- generation: askGeneration,
358
- turnToken: _turnCheckpointToken,
359
- startedAt: _turnCheckpointStartedAt,
366
+ const written = _turnCheckpointRecorder.record({
360
367
  currentUserContent: cancelledUserTurnContent,
361
- turnMessages: turnMessagesForCheckpoint(_turnOutgoing, cancelledUserTurnContent),
362
- interruption: _turnInterruption.snapshot(),
363
- }, {
364
- // The turn's first checkpoint is the crash-durability
365
- // anchor for the prompt and must land before the provider
366
- // runs; every later streaming/tool-boundary flush moves to
367
- // the async latest-wins lane so serialization+disk never
368
- // stall the engine thread mid-stream.
369
- sync: _turnCheckpointLastAt === 0,
368
+ turnOutgoing: _turnOutgoing,
369
+ interruption: _turnInterruption,
370
370
  });
371
371
  if (written) _turnCheckpointLastAt = Date.now();
372
372
  return written;
@@ -398,7 +398,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
398
398
  _turnCheckpointStopped = true;
399
399
  if (_turnCheckpointTimer) clearTimeout(_turnCheckpointTimer);
400
400
  _turnCheckpointTimer = null;
401
- cancelPendingTurnCheckpoint(sessionId);
401
+ _turnCheckpointRecorder.stop();
402
402
  };
403
403
  let _interruptionSnapshot = null;
404
404
  const _prepareCloseSnapshot = (abortReason) => {
@@ -406,6 +406,9 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
406
406
  if (!activeSession) return null;
407
407
  _stopTurnCheckpoint();
408
408
  activeSession.liveTurnMessages = null;
409
+ // Interruption finalize below rewrites the transcript; the
410
+ // mid-turn provider prefix snapshot must not survive it.
411
+ delete activeSession._providerPrefixGuardState;
409
412
  _turnInterruption.restoreTombstonedText();
410
413
  const finalized = _turnInterruption.finalize({
411
414
  turnOutgoing: _turnOutgoing || activeSession.messages,
@@ -848,6 +851,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
848
851
  ? result.historyContent
849
852
  : (result.content || '');
850
853
  const _terminalStop = result?.stopReason ?? result?.stop_reason ?? null;
854
+ const _providerReplay = cloneProviderReplay(result.providerReplay);
851
855
  session.messages.push({
852
856
  role: 'assistant',
853
857
  // Keep content as-is in memory (model-visible). Image bytes,
@@ -855,6 +859,9 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
855
859
  // time inside the session store (store.mjs _sessionForDisk).
856
860
  content: persistedAssistantContent,
857
861
  ...(_assistantTranscriptMeta ? { meta: { transcript: _assistantTranscriptMeta } } : {}),
862
+ ...(_providerReplay
863
+ ? { providerReplay: _providerReplay }
864
+ : {}),
858
865
  ...(typeof result.reasoningContent === 'string' && result.reasoningContent
859
866
  ? { reasoningContent: result.reasoningContent }
860
867
  : {}),
@@ -1088,6 +1095,12 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
1088
1095
  if (activeSession) {
1089
1096
  activeSession.liveTurnMessages = null;
1090
1097
  delete activeSession.activeTurnCheckpoint;
1098
+ // The provider prefix snapshot commits per successful send
1099
+ // INSIDE the turn, while the transcript unwinds to its
1100
+ // pre-turn/finalized shape below. A surviving mid-turn
1101
+ // snapshot flags every retry as history_shrink ("Session
1102
+ // state changed unexpectedly."), so it unwinds with the turn.
1103
+ delete activeSession._providerPrefixGuardState;
1091
1104
  }
1092
1105
  // Restore before ANY finalization path. In particular, cancellation
1093
1106
  // can race the acknowledged non-streaming restart and surface as a
@@ -29,6 +29,7 @@ import { uncachedInputTokensForProvider } from './usage-metrics.mjs';
29
29
  import { pruneOffloadSession } from '../tool-result-offload.mjs';
30
30
  import { _getPendingMessagesForSession } from './pending-messages.mjs';
31
31
  import { isSessionCompactionBlocked } from './runtime-liveness.mjs';
32
+ import { resetReadStateAfterCompaction } from '../read-dedup.mjs';
32
33
  import {
33
34
  compactTargetBudget as compactTargetBudgetForPolicy,
34
35
  currentContextEstimateTokens,
@@ -449,6 +450,7 @@ export async function runSessionCompaction(session, opts = {}) {
449
450
  const unchangedReason = changed ? null : (force ? 'nothing to compact' : 'below threshold');
450
451
  const now = Date.now();
451
452
  session.messages = compacted;
453
+ if (changed) resetReadStateAfterCompaction(resolvedSessionId);
452
454
  // Best-effort GC only: the 10-minute mtime gate plus this idle-only guard
453
455
  // lets an in-flight turn's sidecars survive until a later compaction/close.
454
456
  const pruneSessionId = resolvedSessionId;
@@ -81,6 +81,9 @@ function applyCompactFailurePersistToSession(activeSession, {
81
81
  if (!messagesAdvanced) {
82
82
  activeSession.messages = sanitized;
83
83
  activeSession.providerState = undefined;
84
+ // Sanitized replacement can rewrite/shrink the transcript; the
85
+ // provider prefix snapshot must re-baseline with it.
86
+ delete activeSession._providerPrefixGuardState;
84
87
  }
85
88
  activeSession.updatedAt = Date.now();
86
89
  activeSession.lastUsedAt = Date.now();
@@ -229,6 +229,9 @@ export async function clearSessionMessages(sessionId, options = {}) {
229
229
  }
230
230
  }
231
231
  session.messages = keep;
232
+ // Clear truncates the transcript wholesale; drop the provider prefix
233
+ // snapshot so the next send re-baselines instead of history_shrink.
234
+ delete session._providerPrefixGuardState;
232
235
  resetSessionBp3Environment(session);
233
236
  session.totalInputTokens = 0;
234
237
  session.totalOutputTokens = 0;
@@ -340,8 +343,10 @@ export async function rewindSessionMessagesTo(sessionId, options = {}) {
340
343
  if (start < 0) return null;
341
344
  const removed = messages.length - start;
342
345
  session.messages = messages.slice(0, start);
343
- // The provider cache is keyed on the exact prefix we just truncated.
346
+ // The provider cache is keyed on the exact prefix we just truncated;
347
+ // the prefix-guard snapshot describes it too and must go with it.
344
348
  session.providerState = undefined;
349
+ delete session._providerPrefixGuardState;
345
350
  session.updatedAt = Date.now();
346
351
  session.lastUsedAt = Date.now();
347
352
  await saveSessionAsync(session, { expectedGeneration: session.generation });
@@ -316,9 +316,7 @@ export function createSession(opts) {
316
316
  sessionCwdLine
317
317
  ? `- Cwd: ${sessionCwdLine} — the active Project root; relative paths and shell commands resolve here.`
318
318
  : '',
319
- !ownerIsAgent
320
- ? `- Shell: ${process.platform === 'win32' ? 'PowerShell' : 'Bash'}. Use ${process.platform === 'win32' ? 'PowerShell' : 'Bash'} syntax unless the user specifies otherwise.`
321
- : '',
319
+ `- Shell: ${process.platform === 'win32' ? 'PowerShell' : 'Bash'}. Use ${process.platform === 'win32' ? 'PowerShell' : 'Bash'} syntax unless the user specifies otherwise.`,
322
320
  // A startup inventory of PATH binaries used to sit here; see
323
321
  // runtime-capabilities.mjs for why it cannot be stated truthfully
324
322
  // before a command has run. Whether the cwd is inside a repository is