mixdog 0.9.39 → 0.9.40

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 (29) hide show
  1. package/package.json +1 -1
  2. package/scripts/agent-terminal-reap-test.mjs +6 -6
  3. package/scripts/execution-resume-esc-integration-test.mjs +4 -2
  4. package/scripts/steering-drain-buckets-test.mjs +140 -5
  5. package/scripts/tui-transcript-perf-test.mjs +279 -0
  6. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +7 -0
  7. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -0
  8. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +26 -0
  9. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
  10. package/src/runtime/agent/orchestrator/session/store.mjs +9 -1
  11. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +43 -1
  12. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +27 -13
  13. package/src/runtime/shared/buffered-appender.mjs +13 -2
  14. package/src/session-runtime/config-helpers.mjs +6 -6
  15. package/src/session-runtime/lifecycle-api.mjs +4 -0
  16. package/src/session-runtime/runtime-core.mjs +1 -0
  17. package/src/session-runtime/session-turn-api.mjs +12 -0
  18. package/src/standalone/agent-tool.mjs +8 -1
  19. package/src/tui/App.jsx +2 -1
  20. package/src/tui/app/transcript-window.mjs +9 -10
  21. package/src/tui/app/use-transcript-window.mjs +38 -56
  22. package/src/tui/dist/index.mjs +354 -163
  23. package/src/tui/engine/agent-job-feed.mjs +76 -6
  24. package/src/tui/engine/session-api.mjs +7 -1
  25. package/src/tui/engine/session-flow.mjs +3 -1
  26. package/src/tui/engine/tool-card-results.mjs +10 -5
  27. package/src/tui/engine/turn.mjs +96 -36
  28. package/src/tui/engine.mjs +136 -37
  29. package/src/tui/index.jsx +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.39",
3
+ "version": "0.9.40",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -33,16 +33,16 @@ try {
33
33
  AUTO_CLEAR_PROVIDER_IDLE_MS.anthropic,
34
34
  'global idleMs and default row are ignored for listed providers',
35
35
  );
36
- assert.equal(resolveAgentTerminalReapMs(overrideConfig, 'default'), null, 'default row is ignored');
37
- assert.equal(resolveAgentTerminalReapMs(overrideConfig, 'unlisted'), null, 'unlisted override is ignored');
38
- assert.equal(resolveAgentTerminalReapMs(overrideConfig, 'unknown'), null, 'unknown provider is ignored');
36
+ assert.equal(resolveAgentTerminalReapMs(overrideConfig, 'default'), 90_000, 'default row applies to default provider');
37
+ assert.equal(resolveAgentTerminalReapMs(overrideConfig, 'unlisted'), 90_000, 'default row applies to unlisted provider');
38
+ assert.equal(resolveAgentTerminalReapMs({ autoClear: {} }, 'unknown'), AUTO_CLEAR_PROVIDER_IDLE_MS.default, 'unknown provider uses built-in default');
39
39
 
40
40
  mkdirSync(join(root, 'sessions'), { recursive: true });
41
41
  writeFileSync(join(root, 'mixdog-config.json'), JSON.stringify({
42
42
  agent: { autoClear: overrideConfig.autoClear },
43
43
  }));
44
44
  const { sweepStaleSessions } = await import('../src/runtime/agent/orchestrator/session/store.mjs');
45
- const old = Date.now() - 61_000;
45
+ const old = Date.now() - 181_000;
46
46
  const known = {
47
47
  id: 'sess_known_reap',
48
48
  owner: 'agent',
@@ -81,7 +81,7 @@ try {
81
81
  'short provider override bypasses the default sweep freshness gate',
82
82
  );
83
83
  assert.ok(defaultSweep.details.some((detail) => detail.id === known.id), 'store reaps a listed provider at its Advanced duration');
84
- assert.ok(!defaultSweep.details.some((detail) => detail.id === unknown.id), 'store never sweeps an unlisted provider');
84
+ assert.ok(defaultSweep.details.some((detail) => detail.id === unknown.id), 'store reaps an unlisted provider at the default duration');
85
85
 
86
86
  const dataDir = join(root, 'worker-index');
87
87
  mkdirSync(dataDir, { recursive: true });
@@ -116,7 +116,7 @@ try {
116
116
  });
117
117
  const workers = agent.getStatus().workers;
118
118
  assert.ok(!workers.some((worker) => worker.tag === 'known'), 'worker row expires at the provider duration');
119
- assert.ok(workers.some((worker) => worker.tag === 'unknown'), 'unlisted worker row does not expire');
119
+ assert.ok(!workers.some((worker) => worker.tag === 'unknown'), 'unlisted worker row expires at the default duration');
120
120
  agent.closeAll('agent-terminal-reap-test');
121
121
 
122
122
  process.stdout.write(`agent terminal reap test passed (${builtIns.length} providers)\n`);
@@ -146,9 +146,10 @@ for (const phase of ['before first delta', 'after response progress']) {
146
146
  harness.activeAsk().options.onTextDelta('partial response\n');
147
147
  await wait(40);
148
148
  assert.equal(
149
- harness.state.items.some((item) => item.kind === 'assistant' && /partial response/.test(item.text)),
149
+ harness.state.streamingTail?.kind === 'assistant'
150
+ && /partial response/.test(harness.state.streamingTail.text),
150
151
  true,
151
- 'response progress reached the live turn',
152
+ 'response progress reached the dedicated live tail',
152
153
  );
153
154
  }
154
155
 
@@ -157,6 +158,7 @@ for (const phase of ['before first delta', 'after response progress']) {
157
158
  assert.equal(harness.state.busy, false, 'busy clears after the real abort unwind');
158
159
  assert.equal(harness.state.spinner, null, 'spinner clears after Esc');
159
160
  assert.equal(harness.state.thinking, null, 'thinking clears after Esc');
161
+ assert.equal(harness.state.streamingTail == null, true, 'Esc leaves no orphaned streaming tail');
160
162
 
161
163
  harness.deliver(completion('execution_A', 'duplicate retry'));
162
164
  await tick();
@@ -3,6 +3,8 @@ import assert from 'node:assert/strict';
3
3
  import { createSessionFlow } from '../src/tui/engine/session-flow.mjs';
4
4
  import { createRunTurn } from '../src/tui/engine/turn.mjs';
5
5
 
6
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
7
+
6
8
  // Minimal bag: drainPendingSteering only touches pending, the queue helpers,
7
9
  // and commitSteeringQueueEntries (which no-ops on disk when runtime.id is not
8
10
  // a valid session key). No provider/runTurn wiring needed.
@@ -71,7 +73,7 @@ test('drainPendingSteering accepts runtime callback signature', () => {
71
73
  assert.equal(bag.pending.length, 0);
72
74
  });
73
75
 
74
- test('drained notification key remains deduped and displayed key is not cleared', () => {
76
+ test('drained notification key is released while displayed key is not cleared', () => {
75
77
  const { flow, bag } = makeFlow();
76
78
  bag.getState().busy = true;
77
79
  assert.equal(flow.enqueue('task finished', { mode: 'task-notification', key: 'task-1' }), true);
@@ -80,7 +82,7 @@ test('drained notification key remains deduped and displayed key is not cleared'
80
82
  const out = flow.drainPendingSteering({ maxPriority: 'later' });
81
83
 
82
84
  assert.deepEqual(out, ['task finished']);
83
- assert.equal(flow.enqueue('task duplicate', { mode: 'task-notification', key: 'task-1' }), false);
85
+ assert.equal(flow.enqueue('task duplicate', { mode: 'task-notification', key: 'task-1' }), true);
84
86
  assert.equal(bag.displayedExecutionNotificationKeys.has('task-1'), true);
85
87
  });
86
88
 
@@ -123,7 +125,11 @@ test('post-turn drain does not send queued slash command to model', async () =>
123
125
  // Minimal store bag for createRunTurn: only the surface the streaming/steering
124
126
  // finalize path touches. runtime.ask is a caller-supplied mock that drives the
125
127
  // text-delta / steer-message callbacks.
126
- function makeTurnBag(ask) {
128
+ function makeTurnBag(ask, {
129
+ timeoutMs = 300000,
130
+ getTurnLiveness,
131
+ abort,
132
+ } = {}) {
127
133
  let seq = 0;
128
134
  const state = {
129
135
  items: [],
@@ -134,11 +140,13 @@ function makeTurnBag(ask) {
134
140
  };
135
141
  const itemIndexById = new Map();
136
142
  const findIndexById = (id) => state.items.findIndex((it) => it.id === id);
143
+ const runtime = { id: null, toolMode: 'auto', ask, abort: abort || (() => {}) };
144
+ if (typeof getTurnLiveness === 'function') runtime.getTurnLiveness = getTurnLiveness;
137
145
  const bag = {
138
- runtime: { id: null, toolMode: 'auto', ask, abort: () => {} },
146
+ runtime,
139
147
  nextId: () => `id_${++seq}`,
140
148
  tuiDebug: () => {},
141
- LEAD_TURN_TIMEOUT_MS: 300000,
149
+ LEAD_TURN_TIMEOUT_MS: timeoutMs,
142
150
  flags: { leadTurnEpoch: 0 },
143
151
  pending: [],
144
152
  itemIndexById,
@@ -176,6 +184,133 @@ function makeTurnBag(ask) {
176
184
  return { bag, getState: () => state };
177
185
  }
178
186
 
187
+ test('watchdog accepts fresh runtime liveness without tripping', async () => {
188
+ let aborts = 0;
189
+ const ask = async () => {
190
+ await wait(45);
191
+ return { result: { content: '' }, session: { messages: [] } };
192
+ };
193
+ const { bag } = makeTurnBag(ask, {
194
+ timeoutMs: 15,
195
+ abort: () => { aborts += 1; },
196
+ getTurnLiveness: () => ({
197
+ stage: 'tool_running',
198
+ lastProgressAt: Date.now(),
199
+ toolStartedAt: Date.now(),
200
+ toolSelfDeadlineMs: 0,
201
+ }),
202
+ });
203
+
204
+ assert.equal(await createRunTurn(bag)('do a thing'), 'done');
205
+ assert.equal(aborts, 0, 'fresh orchestrator heartbeats defer the watchdog');
206
+ });
207
+
208
+ test('watchdog trips at the tool ceiling despite fresh liveness', async () => {
209
+ const previousMax = process.env.MIXDOG_LEAD_TOOL_MAX_MS;
210
+ const timeoutMs = 30;
211
+ const ceilingMs = 95;
212
+ process.env.MIXDOG_LEAD_TOOL_MAX_MS = String(ceilingMs);
213
+ let rejectAsk;
214
+ let heartbeatTimer = null;
215
+ let heartbeatStartTimer = null;
216
+ let aborts = 0;
217
+ let abortAt = 0;
218
+ let livenessCalls = 0;
219
+ const toolStartedAt = Date.now();
220
+ const ask = (_userText, options) => new Promise((_resolve, reject) => {
221
+ rejectAsk = reject;
222
+ // Let the first watchdog probe defer from orchestrator liveness, then keep
223
+ // the TUI's local progress fresh so the ceiling-bounded re-arm is exercised.
224
+ heartbeatStartTimer = setTimeout(() => {
225
+ heartbeatTimer = setInterval(() => options.onStreamDelta(), 5);
226
+ }, 40);
227
+ });
228
+ const { bag } = makeTurnBag(ask, {
229
+ timeoutMs,
230
+ abort: () => {
231
+ aborts += 1;
232
+ abortAt = Date.now();
233
+ clearTimeout(heartbeatStartTimer);
234
+ clearInterval(heartbeatTimer);
235
+ const error = new Error('interrupted');
236
+ error.name = 'SessionClosedError';
237
+ rejectAsk(error);
238
+ },
239
+ getTurnLiveness: () => {
240
+ livenessCalls += 1;
241
+ return {
242
+ stage: 'tool_running',
243
+ lastProgressAt: Date.now(),
244
+ toolStartedAt,
245
+ toolSelfDeadlineMs: 0,
246
+ };
247
+ },
248
+ });
249
+
250
+ try {
251
+ const result = await Promise.race([
252
+ createRunTurn(bag)('do a thing'),
253
+ wait(180).then(() => 'watchdog did not trip at the ceiling'),
254
+ ]);
255
+ assert.equal(result, 'cancelled');
256
+ assert.equal(aborts, 1);
257
+ assert.ok(livenessCalls >= 2, 'first probe deferred, then the ceiling probe rechecked liveness');
258
+ assert.ok(abortAt - toolStartedAt < ceilingMs + timeoutMs, 'ceiling trips before an unbounded watchdog interval');
259
+ } finally {
260
+ clearTimeout(heartbeatStartTimer);
261
+ clearInterval(heartbeatTimer);
262
+ if (previousMax === undefined) delete process.env.MIXDOG_LEAD_TOOL_MAX_MS;
263
+ else process.env.MIXDOG_LEAD_TOOL_MAX_MS = previousMax;
264
+ }
265
+ });
266
+
267
+ for (const [name, getTurnLiveness] of [
268
+ ['throws', () => { throw new Error('liveness unavailable'); }],
269
+ ['returns null', () => null],
270
+ ]) {
271
+ test(`watchdog ${name} probe falls back to abort`, async () => {
272
+ let rejectAsk;
273
+ let aborts = 0;
274
+ const ask = () => new Promise((_resolve, reject) => { rejectAsk = reject; });
275
+ const { bag } = makeTurnBag(ask, {
276
+ timeoutMs: 15,
277
+ getTurnLiveness,
278
+ abort: () => {
279
+ aborts += 1;
280
+ const error = new Error('interrupted');
281
+ error.name = 'SessionClosedError';
282
+ rejectAsk(error);
283
+ },
284
+ });
285
+
286
+ const [result] = await Promise.all([createRunTurn(bag)('do a thing'), wait(40)]);
287
+ assert.equal(result, 'cancelled');
288
+ assert.equal(aborts, 1);
289
+ });
290
+ }
291
+
292
+ test('stream-delta progress keeps the watchdog alive', async () => {
293
+ let aborts = 0;
294
+ let streamDeltas = 0;
295
+ const ask = async (_userText, options) => {
296
+ options.onStreamDelta();
297
+ streamDeltas += 1;
298
+ await wait(12);
299
+ options.onStreamDelta();
300
+ streamDeltas += 1;
301
+ await wait(12);
302
+ return { result: { content: '' }, session: { messages: [] } };
303
+ };
304
+ const { bag } = makeTurnBag(ask, {
305
+ timeoutMs: 20,
306
+ abort: () => { aborts += 1; },
307
+ });
308
+
309
+ assert.equal(await createRunTurn(bag)('do a thing'), 'done');
310
+ assert.equal(streamDeltas, 2);
311
+ assert.equal(aborts, 0);
312
+ });
313
+
179
314
  test('onSteerMessage commits a streamed no-newline assistant tail into items', async () => {
180
315
  // A terminal no-tool response streams a single line WITHOUT a trailing '\n',
181
316
  // so no assistant row/currentAssistantId exists yet. A steering injection
@@ -0,0 +1,279 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { createEngineItemMutators, replaceEngineItemsState } from '../src/tui/engine.mjs';
5
+ import { createEngineApiA } from '../src/tui/engine/session-api.mjs';
6
+ import { createRunTurn } from '../src/tui/engine/turn.mjs';
7
+ import { buildTranscriptRowIndexIncremental } from '../src/tui/app/transcript-window.mjs';
8
+
9
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
+
11
+ function makeTurnHarness(ask) {
12
+ let seq = 0;
13
+ let state = {
14
+ items: [],
15
+ structureRevision: 0,
16
+ streamingTail: null,
17
+ stats: { turns: 0, inputTokens: 0, outputTokens: 0 },
18
+ busy: false,
19
+ spinner: null,
20
+ thinking: null,
21
+ };
22
+ const itemIndexById = new Map();
23
+ const set = (patch) => { state = { ...state, ...patch }; return true; };
24
+ const pushItem = (item) => {
25
+ const items = [...state.items, item];
26
+ if (item?.id != null) itemIndexById.set(item.id, items.length - 1);
27
+ set({ items, structureRevision: state.structureRevision + 1 });
28
+ };
29
+ const updateStreamingTail = (id, patch) => set({
30
+ streamingTail: {
31
+ ...(state.streamingTail?.id === id ? state.streamingTail : {}),
32
+ ...patch,
33
+ kind: 'assistant',
34
+ id,
35
+ streaming: true,
36
+ },
37
+ });
38
+ const { patchItem, settleStreamingTail } = createEngineItemMutators({
39
+ getState: () => state,
40
+ set,
41
+ itemIndexById,
42
+ });
43
+ const clearStreamingTail = (id = null) => {
44
+ if (id == null || state.streamingTail?.id === id) set({ streamingTail: null });
45
+ };
46
+ const bag = {
47
+ runtime: { id: null, toolMode: 'auto', ask, abort: () => true },
48
+ nextId: () => `id_${++seq}`,
49
+ tuiDebug: () => {},
50
+ LEAD_TURN_TIMEOUT_MS: 300_000,
51
+ flags: { leadTurnEpoch: 0, disposed: false },
52
+ pending: [],
53
+ itemIndexById,
54
+ getState: () => state,
55
+ set,
56
+ pushItem,
57
+ patchItem,
58
+ updateStreamingTail,
59
+ settleStreamingTail,
60
+ clearStreamingTail,
61
+ pushNotice: () => {},
62
+ pushUserOrSyntheticItem: () => {},
63
+ markToolCallActive: () => {},
64
+ markToolCallDone: () => {},
65
+ clearActiveToolSummary: () => {},
66
+ agentStatusState: () => ({}),
67
+ routeState: () => ({}),
68
+ syncContextStats: () => {},
69
+ denyAllToolApprovals: () => {},
70
+ requestToolApproval: async () => ({ approved: false }),
71
+ patchToolCardResult: () => {},
72
+ flushToolResults: () => {},
73
+ flushDeferredExecutionPendingResumeKick: () => {},
74
+ drain: async () => {},
75
+ drainPendingSteering: () => [],
76
+ };
77
+ return { runTurn: createRunTurn(bag), getState: () => state };
78
+ }
79
+
80
+ test('stream flush keeps settled items identity and finalize appends one assistant', async () => {
81
+ let identityStable = false;
82
+ const harness = makeTurnHarness(async (_text, options) => {
83
+ const before = harness.getState().items;
84
+ options.onTextDelta('settled line\n');
85
+ await wait(30);
86
+ identityStable = harness.getState().items === before;
87
+ assert.match(harness.getState().streamingTail?.text || '', /settled line/);
88
+ return { result: { content: 'settled line\n' }, session: { messages: [] } };
89
+ });
90
+
91
+ assert.equal(await harness.runTurn('go'), 'done');
92
+ assert.equal(identityStable, true, 'tail flush must not swap settled items');
93
+ assert.equal(harness.getState().streamingTail, null);
94
+ const assistants = harness.getState().items.filter((item) => item.kind === 'assistant');
95
+ assert.equal(assistants.length, 1);
96
+ assert.equal(assistants[0].streaming, false);
97
+ assert.equal(assistants[0].text, 'settled line\n');
98
+ });
99
+
100
+ test('abort after stream progress settles the tail and leaves no orphan', async () => {
101
+ const harness = makeTurnHarness(async (_text, options) => {
102
+ options.onTextDelta('partial line\n');
103
+ await wait(30);
104
+ const error = new Error('interrupted');
105
+ error.name = 'SessionClosedError';
106
+ throw error;
107
+ });
108
+
109
+ assert.equal(await harness.runTurn('go'), 'cancelled');
110
+ assert.equal(harness.getState().streamingTail, null);
111
+ const assistants = harness.getState().items.filter((item) => item.kind === 'assistant');
112
+ assert.equal(assistants.length, 1);
113
+ assert.equal(assistants[0].streaming, false);
114
+ assert.match(assistants[0].text, /partial line/);
115
+ });
116
+
117
+ // Codifies pre-existing parity: text had no item/currentAssistantId before its first completed line.
118
+ test('abort before a newline has no tail id and preserves prior text-drop parity', async () => {
119
+ const harness = makeTurnHarness(async (_text, options) => {
120
+ options.onTextDelta('partial without newline');
121
+ await wait(30);
122
+ const error = new Error('interrupted');
123
+ error.name = 'SessionClosedError';
124
+ throw error;
125
+ });
126
+
127
+ assert.equal(await harness.runTurn('go'), 'cancelled');
128
+ assert.equal(harness.getState().streamingTail, null);
129
+ assert.equal(
130
+ harness.getState().items.filter((item) => item.kind === 'assistant').length,
131
+ 0,
132
+ 'without a visible tail id, pre-newline text keeps the existing drop-on-abort behavior',
133
+ );
134
+ });
135
+
136
+ // Codifies pre-existing parity: the old in-items completed-line snapshot survived starved recovery.
137
+ test('starved Esc recovery settles a non-empty tail exactly once', async () => {
138
+ let state = {
139
+ items: [],
140
+ structureRevision: 0,
141
+ streamingTail: { id: 'tail_abort', kind: 'assistant', text: 'visible partial\n', streaming: true },
142
+ queued: [],
143
+ busy: true,
144
+ commandBusy: false,
145
+ spinner: { active: true },
146
+ thinking: null,
147
+ lastTurn: null,
148
+ };
149
+ const itemIndexById = new Map();
150
+ const set = (patch) => { state = { ...state, ...patch }; return true; };
151
+ const { patchItem, settleStreamingTail } = createEngineItemMutators({
152
+ getState: () => state,
153
+ set,
154
+ itemIndexById,
155
+ });
156
+ const bag = {
157
+ runtime: { abort: () => true },
158
+ nextId: () => 'notice',
159
+ flags: { leadTurnEpoch: 1, disposed: false, draining: false, manualAbortRecoveryMs: 10 },
160
+ pending: [],
161
+ listeners: new Set(),
162
+ getState: () => state,
163
+ set,
164
+ pushItem: () => {},
165
+ patchItem,
166
+ replaceItems: (items) => items,
167
+ settleStreamingTail,
168
+ clearStreamingTail: () => set({ streamingTail: null }),
169
+ pushNotice: () => {},
170
+ autoClearState: () => ({}),
171
+ agentStatusState: () => ({}),
172
+ routeState: () => ({}),
173
+ syncContextStats: () => {},
174
+ denyAllToolApprovals: () => {},
175
+ updateAgentJobCard: () => {},
176
+ requeueEntriesFront: () => {},
177
+ enqueue: () => {},
178
+ autoClearBeforeSubmit: async () => {},
179
+ restoreQueued: () => {},
180
+ resetStatsAndSyncContext: () => {},
181
+ drain: async () => {},
182
+ flushDeferredExecutionPendingResumeKick: () => {},
183
+ discardExecutionPendingResume: () => {},
184
+ };
185
+
186
+ createEngineApiA(bag).abort();
187
+ await wait(30);
188
+ assert.equal(state.streamingTail, null);
189
+ const assistants = state.items.filter((item) => item.kind === 'assistant');
190
+ assert.equal(assistants.length, 1);
191
+ assert.equal(assistants[0].id, 'tail_abort');
192
+ assert.equal(assistants[0].streaming, false);
193
+ });
194
+
195
+ test('removeNotice-style replacement preserves the live tail and later settles once', () => {
196
+ let state = {
197
+ items: [{ id: 'notice', kind: 'notice', text: 'temporary' }],
198
+ structureRevision: 4,
199
+ streamingTail: { id: 'tail_notice', kind: 'assistant', text: 'visible\n', streaming: true },
200
+ };
201
+ const itemIndexById = new Map([['notice', 0]]);
202
+ const set = (patch) => { state = { ...state, ...patch }; return true; };
203
+ const { settleStreamingTail } = createEngineItemMutators({
204
+ getState: () => state,
205
+ set,
206
+ itemIndexById,
207
+ });
208
+ state = replaceEngineItemsState({
209
+ state,
210
+ items: state.items.filter((item) => item.id !== 'notice'),
211
+ itemIndexById,
212
+ preserveStreamingTail: true,
213
+ });
214
+
215
+ assert.equal(state.streamingTail?.id, 'tail_notice');
216
+ assert.equal(settleStreamingTail('tail_notice', { text: 'complete final text' }), true);
217
+ assert.equal(state.items.length, 1);
218
+ assert.equal(state.items[0].text, 'complete final text');
219
+ assert.equal(state.items[0].streaming, false);
220
+ assert.equal(settleStreamingTail('tail_notice', { text: 'duplicate' }), false);
221
+ assert.equal(state.items.length, 1);
222
+ });
223
+
224
+ test('bulk replacement clears the tail and stale settle cannot append into reset transcript', () => {
225
+ let state = {
226
+ items: [{ id: 'old', kind: 'user', text: 'old prompt' }],
227
+ structureRevision: 8,
228
+ streamingTail: { id: 'stale_tail', kind: 'assistant', text: 'old response\n', streaming: true },
229
+ };
230
+ const itemIndexById = new Map([['old', 0]]);
231
+ const set = (patch) => { state = { ...state, ...patch }; return true; };
232
+ const { settleStreamingTail } = createEngineItemMutators({
233
+ getState: () => state,
234
+ set,
235
+ itemIndexById,
236
+ });
237
+ state = replaceEngineItemsState({ state, items: [], itemIndexById });
238
+
239
+ assert.equal(state.streamingTail, null);
240
+ assert.equal(settleStreamingTail('stale_tail', { text: 'must not reappear' }), false);
241
+ assert.deepEqual(state.items, []);
242
+ });
243
+
244
+ test('tool-card revision invalidates the incremental prefix cache', () => {
245
+ const cacheRef = { current: null };
246
+ const tail = { id: 'tail', kind: 'assistant', text: 'live\n', streaming: true };
247
+ let state = {
248
+ items: [{ id: 'tool', kind: 'tool', name: 'shell', text: 'ok', result: 'ok' }],
249
+ structureRevision: 1,
250
+ streamingTail: tail,
251
+ };
252
+ const itemIndexById = new Map([['tool', 0]]);
253
+ const set = (patch) => { state = { ...state, ...patch }; return true; };
254
+ const { patchItem } = createEngineItemMutators({
255
+ getState: () => state,
256
+ set,
257
+ itemIndexById,
258
+ });
259
+ const before = buildTranscriptRowIndexIncremental([...state.items, state.streamingTail], {
260
+ columns: 24,
261
+ cacheRef,
262
+ prefixRevision: state.structureRevision,
263
+ });
264
+ const cachedBeforePatch = cacheRef.current;
265
+ assert.equal(patchItem('tool', {
266
+ text: 'a much longer tool result '.repeat(20),
267
+ result: 'a much longer tool result '.repeat(20),
268
+ }), true);
269
+ assert.equal(state.structureRevision, 2, 'real engine patchItem bumps revision');
270
+ const after = buildTranscriptRowIndexIncremental([...state.items, state.streamingTail], {
271
+ columns: 24,
272
+ cacheRef,
273
+ prefixRevision: state.structureRevision,
274
+ });
275
+
276
+ assert.notEqual(cacheRef.current, cachedBeforePatch, 'revision bump rebuilds the prefix cache');
277
+ assert.equal(after.rows.length, before.rows.length);
278
+ assert.equal(cacheRef.current.prefixRevision, 2);
279
+ });
@@ -309,6 +309,12 @@ function traceAgentTool({ sessionId, iteration, toolName, toolKind, toolMs, tool
309
309
  const errorFirstLine = resultKind === 'error'
310
310
  ? _firstNonEmptyLine(_redactLogText(String(resultText ?? ''))).slice(0, 200) || null
311
311
  : null;
312
+ // Failure taxonomy on the tool row itself (mirrors the failure log's
313
+ // `category`) so trace-level aggregation can exclude expected command
314
+ // exits (`command-exit`) without joining tool-failures.jsonl.
315
+ const errorCategory = resultKind === 'error'
316
+ ? classifyToolFailure(String(resultText ?? ''), toolName)
317
+ : null;
312
318
  // Flat shape — fields named exactly as the agent_calls PG columns so
313
319
  // insertAgentCalls can pick them up by direct property access without
314
320
  // a payload-unwrap step. result_kind has no column and rides as plain
@@ -327,6 +333,7 @@ function traceAgentTool({ sessionId, iteration, toolName, toolKind, toolMs, tool
327
333
  tool_args_summary: summarizedArgs,
328
334
  result_kind: resultKind || null,
329
335
  result_error_first_line: errorFirstLine,
336
+ result_error_category: errorCategory,
330
337
  result_has_next_call: nextCallCount > 0,
331
338
  result_next_call_count: nextCallCount,
332
339
  result_bytes_est: resultBytesEst,
@@ -26,6 +26,9 @@ import {
26
26
  compactTypeForSession,
27
27
  } from './context-meta.mjs';
28
28
  import { uncachedInputTokensForProvider } from './usage-metrics.mjs';
29
+ import { pruneOffloadSession } from '../tool-result-offload.mjs';
30
+ import { _getPendingMessagesForSession } from './pending-messages.mjs';
31
+ import { isSessionCompactionBlocked } from './runtime-liveness.mjs';
29
32
 
30
33
  // 'compacting' is a transient in-flight stage written just before semantic /
31
34
  // recall-fasttrack compaction runs. If the process crashes or only partially
@@ -489,6 +492,18 @@ export async function runSessionCompaction(session, opts = {}) {
489
492
  const unchangedReason = changed ? null : (force ? 'nothing to compact' : 'below threshold');
490
493
  const now = Date.now();
491
494
  session.messages = compacted;
495
+ // Best-effort GC only: the 10-minute mtime gate plus this idle-only guard
496
+ // lets an in-flight turn's sidecars survive until a later compaction/close.
497
+ const pruneSessionId = opts.sessionId || session.id;
498
+ if (!isSessionCompactionBlocked(pruneSessionId)) {
499
+ try {
500
+ await pruneOffloadSession(pruneSessionId, () => [
501
+ session.messages,
502
+ session.liveTurnMessages,
503
+ _getPendingMessagesForSession(pruneSessionId),
504
+ ]);
505
+ } catch { /* best-effort */ }
506
+ }
492
507
  session.providerState = undefined;
493
508
  session.compaction = {
494
509
  ...(session.compaction || {}),
@@ -1,6 +1,7 @@
1
1
  // Steering / pending-message queue with sync buffered + atomic-file persistence.
2
2
  // Extracted verbatim from manager.mjs (behavior-preserving).
3
3
  import { join } from 'path';
4
+ import { readFileSync } from 'fs';
4
5
  import { resolvePluginData } from '../../../../shared/plugin-paths.mjs';
5
6
  import { updateJsonAtomicSync, updateJsonAtomic } from '../../../../shared/atomic-file.mjs';
6
7
  import { promptContentText, isInternalRuntimeNotificationText } from './prompt-utils.mjs';
@@ -458,6 +459,31 @@ export function drainPendingMessages(sessionId) {
458
459
  return out;
459
460
  }
460
461
 
462
+ // Snapshot queued entries without draining them. Compaction uses this to keep
463
+ // sidecars referenced by a message that is waiting for the next turn, whether
464
+ // it is still in memory, buffered for persistence, or already on disk.
465
+ export function _getPendingMessagesForSession(sessionId) {
466
+ if (!isValidPendingSessionId(sessionId)) return [];
467
+ const queued = [
468
+ ...(_sessionPendingMessages.get(sessionId) || []),
469
+ ...(_pendingPersistBuffers.get(sessionId) || []),
470
+ ];
471
+ let raw;
472
+ try {
473
+ raw = readFileSync(pendingMessagesPath(), 'utf8');
474
+ } catch (err) {
475
+ if (err?.code === 'ENOENT') return queued;
476
+ throw err;
477
+ }
478
+ try {
479
+ const persisted = normalizePendingStore(JSON.parse(raw)).sessions[sessionId];
480
+ if (Array.isArray(persisted)) queued.push(...persisted);
481
+ } catch (err) {
482
+ throw err;
483
+ }
484
+ return queued;
485
+ }
486
+
461
487
  // Cleanup hook for closeSession — drop the in-memory queue and buffered-persist
462
488
  // entry so both Maps do not accumulate one entry per closed session.
463
489
  export function _dropPendingMessageState(id, { clearPersisted = true } = {}) {
@@ -163,6 +163,8 @@ export function bumpUsageMetricsTurnId(session) {
163
163
  if (!session || typeof session !== 'object') return 0;
164
164
  const next = (Number(session.usageMetricsTurnId) || 0) + 1;
165
165
  session.usageMetricsTurnId = next;
166
+ const seen = _metricSeenIter.get(session.id);
167
+ if (seen) seen.clear();
166
168
  return next;
167
169
  }
168
170