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
@@ -0,0 +1,457 @@
1
+ // Focused coverage for the append/coalesced turn-checkpoint journal that
2
+ // replaced the 150ms full growing-turn snapshot:
3
+ // * prompt durability still lands synchronously on the first flush,
4
+ // * replay reproduces the exact full-snapshot object (messages + partial
5
+ // text/reasoning + tool pairing) at bounded write cost,
6
+ // * crash-torn tails, stale journals, wholesale transcript rewrites,
7
+ // cancellation and clear all keep latest-wins recovery semantics.
8
+ import assert from 'node:assert/strict';
9
+ import { spawnSync } from 'node:child_process';
10
+ import {
11
+ appendFileSync,
12
+ existsSync,
13
+ mkdtempSync,
14
+ readFileSync,
15
+ rmSync,
16
+ statSync,
17
+ writeFileSync,
18
+ } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import test from 'node:test';
22
+
23
+ // MIXDOG_DATA_DIR must be set before the modules resolve the checkpoint dir.
24
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-turn-journal-'));
25
+ process.env.MIXDOG_DATA_DIR = root;
26
+ process.on('exit', () => { try { rmSync(root, { recursive: true, force: true }); } catch { /* best-effort */ } });
27
+
28
+ const {
29
+ clearTurnCheckpoint,
30
+ createTurnCheckpointRecorder,
31
+ readTurnCheckpoint,
32
+ recoverTurnCheckpoint,
33
+ settleTurnCheckpointWrites,
34
+ turnMessagesForCheckpoint,
35
+ } = await import('./turn-checkpoint.mjs');
36
+ const {
37
+ _setJournalOpGateForTest,
38
+ turnCheckpointPath,
39
+ turnJournalPath,
40
+ } = await import('./turn-checkpoint-journal.mjs');
41
+ const {
42
+ createTurnInterruptionTracker,
43
+ finalizeTurnInterruptionSnapshot,
44
+ } = await import('./turn-interruption.mjs');
45
+
46
+ function startTurn(sessionId, prompt = 'do the thing') {
47
+ const tracker = createTurnInterruptionTracker();
48
+ const outgoing = [
49
+ { role: 'system', content: 'sys' },
50
+ { role: 'user', content: prompt },
51
+ ];
52
+ const recorder = createTurnCheckpointRecorder({
53
+ sessionId,
54
+ generation: 3,
55
+ turnToken: `tok-${sessionId}`,
56
+ startedAt: 1000,
57
+ });
58
+ const flush = () => recorder.record({
59
+ currentUserContent: prompt,
60
+ turnOutgoing: outgoing,
61
+ interruption: tracker,
62
+ });
63
+ return { tracker, outgoing, recorder, flush, prompt };
64
+ }
65
+
66
+ /**
67
+ * Hold every async journal operation in the write lane so reset/clear/stop/
68
+ * overflow can be driven while a write is GENUINELY in flight (entry.writing
69
+ * is true and its bytes are not on disk) instead of after settlement.
70
+ */
71
+ function holdJournalWrites() {
72
+ let open = () => {};
73
+ const gate = new Promise((resolve) => { open = resolve; });
74
+ let released = false;
75
+ _setJournalOpGateForTest(() => gate);
76
+ return {
77
+ release() {
78
+ if (released) return;
79
+ released = true;
80
+ _setJournalOpGateForTest(null);
81
+ open();
82
+ },
83
+ };
84
+ }
85
+
86
+ test('first flush anchors the prompt synchronously', () => {
87
+ const sessionId = 'sess-anchor';
88
+ const turn = startTurn(sessionId);
89
+ assert.equal(turn.flush(), true);
90
+ // No await: the header is the crash-durability anchor and must be on disk
91
+ // before the provider call returns control.
92
+ const checkpoint = readTurnCheckpoint(sessionId);
93
+ assert.equal(checkpoint.turnToken, `tok-${sessionId}`);
94
+ assert.equal(checkpoint.generation, 3);
95
+ assert.deepEqual(checkpoint.turnMessages, [{ role: 'user', content: 'do the thing' }]);
96
+ });
97
+
98
+ test('journal replay reproduces the full snapshot at bounded cost', async () => {
99
+ const sessionId = 'sess-replay';
100
+ const turn = startTurn(sessionId);
101
+ turn.flush();
102
+ const headerBytes = statSync(turnCheckpointPath(sessionId)).size;
103
+ const body = 'x'.repeat(2000);
104
+ for (let i = 0; i < 40; i += 1) {
105
+ turn.tracker.recordTextDelta(`chunk-${i} `);
106
+ const call = { id: `call-${i}`, name: 'shell', arguments: '{}' };
107
+ turn.outgoing.push({ role: 'assistant', content: `${body}${i}`, toolCalls: [call] });
108
+ turn.tracker.recordToolCalls([call]);
109
+ turn.outgoing.push({ role: 'tool', content: body, toolCallId: call.id });
110
+ turn.flush();
111
+ }
112
+ await settleTurnCheckpointWrites(sessionId);
113
+
114
+ // The header is written exactly once per turn — it still holds only the
115
+ // opening prompt, so the growing turn never rewrites it.
116
+ assert.equal(statSync(turnCheckpointPath(sessionId)).size, headerBytes);
117
+ const checkpoint = readTurnCheckpoint(sessionId);
118
+ assert.deepEqual(
119
+ checkpoint.turnMessages,
120
+ turnMessagesForCheckpoint(turn.outgoing, turn.prompt),
121
+ );
122
+ assert.deepEqual(checkpoint.interruption, turn.tracker.snapshot());
123
+
124
+ // Bounded cost: total bytes written stay proportional to the turn payload
125
+ // instead of flushes x turn size (the old writer re-serialized everything
126
+ // on every flush, ~40x this volume for the same turn).
127
+ const payloadBytes = Buffer.byteLength(JSON.stringify(checkpoint.turnMessages));
128
+ const journalBytes = statSync(turnJournalPath(sessionId)).size;
129
+ assert.ok(
130
+ journalBytes < payloadBytes * 2,
131
+ `journal ${journalBytes} bytes should stay near payload ${payloadBytes} bytes`,
132
+ );
133
+ });
134
+
135
+ test('crash recovery matches the live finalize path (text reset, tool pairing)', async () => {
136
+ const sessionId = 'sess-recover';
137
+ const prompt = 'run the tool';
138
+ const turn = startTurn(sessionId, prompt);
139
+ turn.flush();
140
+ turn.tracker.markProviderSendStarted();
141
+ turn.tracker.recordTextDelta('partial answer');
142
+ turn.tracker.recordReasoningDelta('thinking');
143
+ turn.flush();
144
+ // A UI-acknowledged reset tombstones the tail; a crash cannot complete the
145
+ // replacement, so the same visible bytes must survive.
146
+ turn.tracker.tombstoneText(7);
147
+ turn.flush();
148
+ const call = { id: 'call-a', name: 'read', arguments: '{}' };
149
+ turn.tracker.recordToolCalls([call]);
150
+ turn.outgoing.push({ role: 'assistant', content: '', toolCalls: [call] });
151
+ turn.tracker.markToolPhaseStarted();
152
+ turn.tracker.recordToolResult({ toolCallId: 'call-a', content: 'early', __earlyNotify: true });
153
+ turn.flush();
154
+ await settleTurnCheckpointWrites(sessionId);
155
+
156
+ const checkpoint = readTurnCheckpoint(sessionId);
157
+ assert.deepEqual(checkpoint.interruption, turn.tracker.snapshot());
158
+ assert.equal(checkpoint.interruption.partialAssistantContent, 'partial answer');
159
+
160
+ const session = {
161
+ id: sessionId,
162
+ generation: 3,
163
+ updatedAt: 0,
164
+ messages: [{ role: 'system', content: 'sys' }, { role: 'user', content: prompt }],
165
+ activeTurnCheckpoint: { turnToken: `tok-${sessionId}` },
166
+ _providerPrefixGuardState: { messageHashes: ['stale'], requestPrefixHash: 'stale' },
167
+ };
168
+ const recovery = recoverTurnCheckpoint(session);
169
+ assert.equal(recovery.recovered, true);
170
+ // A recovered (rewritten) transcript must never keep the mid-turn provider
171
+ // prefix snapshot: a stale one flags every following send as
172
+ // history_shrink ("Session state changed unexpectedly.").
173
+ assert.equal(Object.prototype.hasOwnProperty.call(session, '_providerPrefixGuardState'), false);
174
+ const expected = finalizeTurnInterruptionSnapshot({
175
+ turnOutgoing: checkpoint.turnMessages,
176
+ currentUserContent: checkpoint.currentUserContent,
177
+ snapshot: turn.tracker.snapshot(),
178
+ abortReason: 'process-crash',
179
+ });
180
+ assert.deepEqual(session.messages, [{ role: 'system', content: 'sys' }, ...expected.messages]);
181
+ assert.equal(session.messages.at(-1).content, '[Request interrupted by process restart]');
182
+ // Every observed call is paired with a result in the recovered transcript.
183
+ assert.ok(session.messages.some((m) => m.role === 'tool' && m.toolCallId === 'call-a'));
184
+ });
185
+
186
+ test('a crash-torn trailing record is discarded and the durable prefix survives', async () => {
187
+ const sessionId = 'sess-torn';
188
+ const turn = startTurn(sessionId);
189
+ turn.flush();
190
+ turn.tracker.recordTextDelta('durable text');
191
+ turn.outgoing.push({ role: 'assistant', content: 'committed' });
192
+ turn.flush();
193
+ await settleTurnCheckpointWrites(sessionId);
194
+ appendFileSync(turnJournalPath(sessionId), '{"t":"m","i":2,"m":{"role":"tool","con');
195
+
196
+ const checkpoint = readTurnCheckpoint(sessionId);
197
+ assert.equal(checkpoint.turnMessages.length, 2);
198
+ assert.equal(checkpoint.turnMessages[1].content, 'committed');
199
+ assert.equal(checkpoint.interruption.partialAssistantContent, 'durable text');
200
+ });
201
+
202
+ test('a journal from another turn never replays onto a new header', async () => {
203
+ const sessionId = 'sess-stale';
204
+ const turn = startTurn(sessionId);
205
+ turn.flush();
206
+ turn.tracker.recordTextDelta('old turn text');
207
+ turn.flush();
208
+ await settleTurnCheckpointWrites(sessionId);
209
+ const header = JSON.parse(readFileSync(turnCheckpointPath(sessionId), 'utf8'));
210
+ writeFileSync(
211
+ turnCheckpointPath(sessionId),
212
+ JSON.stringify({ ...header, turnToken: 'other-token' }),
213
+ 'utf8',
214
+ );
215
+
216
+ const checkpoint = readTurnCheckpoint(sessionId);
217
+ assert.equal(checkpoint.turnToken, 'other-token');
218
+ assert.equal(checkpoint.interruption.partialAssistantContent, '');
219
+ });
220
+
221
+ test('rewritten and truncated transcripts replay latest-wins', async () => {
222
+ const sessionId = 'sess-rewrite';
223
+ const turn = startTurn(sessionId);
224
+ turn.flush();
225
+ turn.outgoing.push({ role: 'assistant', content: 'first' }, { role: 'assistant', content: 'second' });
226
+ turn.flush();
227
+ // Wholesale replacement (image-strip splice / pre-send compaction).
228
+ turn.outgoing.splice(
229
+ 0,
230
+ turn.outgoing.length,
231
+ { role: 'system', content: 'sys' },
232
+ { role: 'user', content: turn.prompt },
233
+ { role: 'assistant', content: 'rewritten' },
234
+ );
235
+ turn.flush();
236
+ await settleTurnCheckpointWrites(sessionId);
237
+
238
+ const checkpoint = readTurnCheckpoint(sessionId);
239
+ assert.deepEqual(checkpoint.turnMessages, [
240
+ { role: 'user', content: turn.prompt },
241
+ { role: 'assistant', content: 'rewritten' },
242
+ ]);
243
+ });
244
+
245
+ test('clear is token-guarded and removes header + journal', async () => {
246
+ const sessionId = 'sess-clear';
247
+ const turn = startTurn(sessionId);
248
+ turn.flush();
249
+ turn.tracker.recordTextDelta('x');
250
+ turn.flush();
251
+ await settleTurnCheckpointWrites(sessionId);
252
+
253
+ assert.equal(clearTurnCheckpoint(sessionId, 'someone-elses-token'), false);
254
+ assert.ok(existsSync(turnCheckpointPath(sessionId)));
255
+ assert.equal(clearTurnCheckpoint(sessionId, `tok-${sessionId}`), true);
256
+ assert.equal(existsSync(turnCheckpointPath(sessionId)), false);
257
+ assert.equal(existsSync(turnJournalPath(sessionId)), false);
258
+ assert.equal(readTurnCheckpoint(sessionId), null);
259
+ });
260
+
261
+ // ── In-flight write races (regressions) ─────────────────────────────────────
262
+
263
+ test('a new turn fences an older IN-FLIGHT append (no stale turn under a new token)', async () => {
264
+ const sessionId = 'sess-fence';
265
+ const first = startTurn(sessionId, 'first prompt');
266
+ first.flush();
267
+ const hold = holdJournalWrites();
268
+ let during;
269
+ let second;
270
+ try {
271
+ first.outgoing.push({ role: 'assistant', content: 'STALE_OLD_TURN' });
272
+ first.tracker.recordTextDelta('STALE_OLD_TURN partial');
273
+ first.flush(); // genuinely in flight: accepted, not on disk
274
+ first.recorder.stop();
275
+
276
+ second = createTurnCheckpointRecorder({
277
+ sessionId,
278
+ generation: 3,
279
+ turnToken: 'tok-second',
280
+ startedAt: 2000,
281
+ });
282
+ const secondOutgoing = [
283
+ { role: 'system', content: 'sys' },
284
+ { role: 'user', content: 'second prompt' },
285
+ ];
286
+ assert.equal(second.record({
287
+ currentUserContent: 'second prompt',
288
+ turnOutgoing: secondOutgoing,
289
+ interruption: createTurnInterruptionTracker(),
290
+ }), true);
291
+ // Interim window: header is the new turn, the journal is still the old
292
+ // one. Replay must reject it rather than merge an old turn.
293
+ during = readTurnCheckpoint(sessionId);
294
+ } finally {
295
+ hold.release();
296
+ }
297
+ assert.equal(during.turnToken, 'tok-second');
298
+ assert.ok(!JSON.stringify(during).includes('STALE_OLD_TURN'));
299
+
300
+ await settleTurnCheckpointWrites(sessionId);
301
+ const after = readTurnCheckpoint(sessionId);
302
+ assert.equal(after.turnToken, 'tok-second');
303
+ assert.ok(!JSON.stringify(after).includes('STALE_OLD_TURN'), 'old turn replayed under the new token');
304
+ assert.deepEqual(after.turnMessages, [{ role: 'user', content: 'second prompt' }]);
305
+ const journal = readFileSync(turnJournalPath(sessionId), 'utf8').trim().split('\n');
306
+ assert.equal(JSON.parse(journal[0]).turnToken, 'tok-second');
307
+ assert.ok(!journal.some((line) => line.includes('STALE_OLD_TURN')));
308
+ });
309
+
310
+ test('overflow never writes ahead of an older IN-FLIGHT append (latest-wins holds)', async () => {
311
+ const sessionId = 'sess-overflow';
312
+ const turn = startTurn(sessionId);
313
+ turn.flush();
314
+ const headBytes = statSync(turnJournalPath(sessionId)).size;
315
+ const hold = holdJournalWrites();
316
+ try {
317
+ turn.outgoing.push({ role: 'assistant', content: 'OLD_IN_FLIGHT' });
318
+ turn.flush(); // in flight
319
+ // >4MB of NEWER records queue behind it and cross the overflow ceiling.
320
+ const filler = 'y'.repeat(512 * 1024);
321
+ for (let i = 0; i < 9; i += 1) {
322
+ turn.outgoing[2] = { role: 'assistant', content: `${filler}-${i}` };
323
+ turn.flush();
324
+ }
325
+ turn.outgoing[2] = { role: 'assistant', content: 'NEWEST' };
326
+ turn.flush();
327
+ // Nothing may reach disk while an older write is outstanding.
328
+ assert.equal(
329
+ statSync(turnJournalPath(sessionId)).size,
330
+ headBytes,
331
+ 'overflow drain overtook an in-flight append',
332
+ );
333
+ } finally {
334
+ hold.release();
335
+ }
336
+ await settleTurnCheckpointWrites(sessionId);
337
+ const checkpoint = readTurnCheckpoint(sessionId);
338
+ assert.deepEqual(checkpoint.turnMessages[1], { role: 'assistant', content: 'NEWEST' });
339
+ });
340
+
341
+ test('explicit process exit flushes in-flight AND queued deltas in order', () => {
342
+ const sessionId = 'sess-exit';
343
+ const source = `
344
+ const checkpoint = await import(${JSON.stringify(new URL('./turn-checkpoint.mjs', import.meta.url).href)});
345
+ const journal = await import(${JSON.stringify(new URL('./turn-checkpoint-journal.mjs', import.meta.url).href)});
346
+ const interruption = await import(${JSON.stringify(new URL('./turn-interruption.mjs', import.meta.url).href)});
347
+ const sessionId = ${JSON.stringify(sessionId)};
348
+ const prompt = 'exit prompt';
349
+ const tracker = interruption.createTurnInterruptionTracker();
350
+ const outgoing = [{ role: 'system', content: 'sys' }, { role: 'user', content: prompt }];
351
+ const recorder = checkpoint.createTurnCheckpointRecorder({ sessionId, generation: 3, turnToken: 'tok-exit', startedAt: 1000 });
352
+ const flush = () => recorder.record({ currentUserContent: prompt, turnOutgoing: outgoing, interruption: tracker });
353
+ flush();
354
+ // Never resolves: the next append stays in flight, the one after it queues.
355
+ journal._setJournalOpGateForTest(() => new Promise(() => {}));
356
+ tracker.recordTextDelta('IN_FLIGHT ');
357
+ outgoing.push({ role: 'assistant', content: 'IN_FLIGHT_MESSAGE' });
358
+ flush();
359
+ tracker.recordTextDelta('QUEUED');
360
+ outgoing.push({ role: 'tool', content: 'QUEUED_MESSAGE', toolCallId: 'call-q' });
361
+ flush();
362
+ process.exit(0);
363
+ `;
364
+ const result = spawnSync(process.execPath, ['--input-type=module', '-e', source], {
365
+ encoding: 'utf8',
366
+ env: { ...process.env, MIXDOG_DATA_DIR: root },
367
+ });
368
+ assert.equal(result.status, 0, `child failed: ${result.stderr}`);
369
+
370
+ const checkpoint = readTurnCheckpoint(sessionId);
371
+ assert.equal(checkpoint.turnMessages.length, 3, 'exit persisted only the journal head');
372
+ assert.equal(checkpoint.turnMessages[1].content, 'IN_FLIGHT_MESSAGE');
373
+ assert.equal(checkpoint.turnMessages[2].content, 'QUEUED_MESSAGE');
374
+ assert.equal(checkpoint.interruption.partialAssistantContent, 'IN_FLIGHT QUEUED');
375
+ });
376
+
377
+ test('clear while a write is IN FLIGHT drops the header now and the journal after', async () => {
378
+ const sessionId = 'sess-clear-race';
379
+ const turn = startTurn(sessionId);
380
+ turn.flush();
381
+ const hold = holdJournalWrites();
382
+ try {
383
+ turn.outgoing.push({ role: 'assistant', content: 'inflight' });
384
+ turn.flush();
385
+ assert.equal(clearTurnCheckpoint(sessionId, `tok-${sessionId}`), true);
386
+ // The header is the recovery gate; it must be gone immediately.
387
+ assert.equal(existsSync(turnCheckpointPath(sessionId)), false);
388
+ assert.equal(readTurnCheckpoint(sessionId), null);
389
+ } finally {
390
+ hold.release();
391
+ }
392
+ await settleTurnCheckpointWrites(sessionId);
393
+ assert.equal(existsSync(turnJournalPath(sessionId)), false, 'in-flight append resurrected the journal');
394
+ assert.equal(readTurnCheckpoint(sessionId), null);
395
+ });
396
+
397
+ test('stop while a write is IN FLIGHT keeps that write and drops only queued deltas', async () => {
398
+ const sessionId = 'sess-stop-race';
399
+ const turn = startTurn(sessionId);
400
+ turn.flush();
401
+ const hold = holdJournalWrites();
402
+ try {
403
+ turn.outgoing.push({ role: 'assistant', content: 'inflight-kept' });
404
+ turn.flush(); // in flight
405
+ turn.outgoing.push({ role: 'assistant', content: 'queued-dropped' });
406
+ turn.flush(); // queued
407
+ turn.recorder.stop();
408
+ assert.equal(turn.flush(), false);
409
+ } finally {
410
+ hold.release();
411
+ }
412
+ await settleTurnCheckpointWrites(sessionId);
413
+ const checkpoint = readTurnCheckpoint(sessionId);
414
+ assert.equal(checkpoint.turnMessages.length, 2);
415
+ assert.equal(checkpoint.turnMessages[1].content, 'inflight-kept');
416
+ assert.ok(!JSON.stringify(checkpoint).includes('queued-dropped'));
417
+ });
418
+
419
+ test('replay skips a duplicated re-flushed record and stops at a real gap', async () => {
420
+ const sessionId = 'sess-seq';
421
+ const turn = startTurn(sessionId);
422
+ turn.flush();
423
+ turn.outgoing.push({ role: 'assistant', content: 'first' });
424
+ turn.flush();
425
+ await settleTurnCheckpointWrites(sessionId);
426
+ const lines = readFileSync(turnJournalPath(sessionId), 'utf8').trim().split('\n');
427
+ // Exit-handler style duplicate of the last accepted record, then a record
428
+ // from beyond the durable prefix.
429
+ appendFileSync(turnJournalPath(sessionId), `${lines[lines.length - 1]}\n`);
430
+ appendFileSync(
431
+ turnJournalPath(sessionId),
432
+ `${JSON.stringify({ t: 'm', i: 2, m: { role: 'assistant', content: 'after-gap' }, s: 99 })}\n`,
433
+ );
434
+
435
+ const checkpoint = readTurnCheckpoint(sessionId);
436
+ assert.equal(checkpoint.turnMessages.length, 2);
437
+ assert.equal(checkpoint.turnMessages[1].content, 'first');
438
+ assert.ok(!JSON.stringify(checkpoint).includes('after-gap'));
439
+ });
440
+
441
+ test('stop retires further flushes without dropping the durable prefix', async () => {
442
+ const sessionId = 'sess-stop';
443
+ const turn = startTurn(sessionId);
444
+ turn.flush();
445
+ turn.tracker.recordTextDelta('durable');
446
+ turn.flush();
447
+ await settleTurnCheckpointWrites(sessionId);
448
+
449
+ turn.tracker.recordTextDelta('-late');
450
+ turn.recorder.stop();
451
+ assert.equal(turn.flush(), false);
452
+ await settleTurnCheckpointWrites(sessionId);
453
+
454
+ const checkpoint = readTurnCheckpoint(sessionId);
455
+ assert.equal(checkpoint.interruption.partialAssistantContent, 'durable');
456
+ assert.deepEqual(checkpoint.turnMessages, [{ role: 'user', content: 'do the thing' }]);
457
+ });