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.
- package/package.json +1 -1
- package/scripts/agent-terminal-reap-test.mjs +6 -6
- package/scripts/execution-resume-esc-integration-test.mjs +4 -2
- package/scripts/steering-drain-buckets-test.mjs +140 -5
- package/scripts/tui-transcript-perf-test.mjs +279 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +43 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +27 -13
- package/src/runtime/shared/buffered-appender.mjs +13 -2
- package/src/session-runtime/config-helpers.mjs +6 -6
- package/src/session-runtime/lifecycle-api.mjs +4 -0
- package/src/session-runtime/runtime-core.mjs +1 -0
- package/src/session-runtime/session-turn-api.mjs +12 -0
- package/src/standalone/agent-tool.mjs +8 -1
- package/src/tui/App.jsx +2 -1
- package/src/tui/app/transcript-window.mjs +9 -10
- package/src/tui/app/use-transcript-window.mjs +38 -56
- package/src/tui/dist/index.mjs +354 -163
- package/src/tui/engine/agent-job-feed.mjs +76 -6
- package/src/tui/engine/session-api.mjs +7 -1
- package/src/tui/engine/session-flow.mjs +3 -1
- package/src/tui/engine/tool-card-results.mjs +10 -5
- package/src/tui/engine/turn.mjs +96 -36
- package/src/tui/engine.mjs +136 -37
- package/src/tui/index.jsx +2 -2
package/package.json
CHANGED
|
@@ -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'),
|
|
37
|
-
assert.equal(resolveAgentTerminalReapMs(overrideConfig, 'unlisted'),
|
|
38
|
-
assert.equal(resolveAgentTerminalReapMs(
|
|
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() -
|
|
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(
|
|
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
|
|
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.
|
|
149
|
+
harness.state.streamingTail?.kind === 'assistant'
|
|
150
|
+
&& /partial response/.test(harness.state.streamingTail.text),
|
|
150
151
|
true,
|
|
151
|
-
'response progress reached the live
|
|
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
|
|
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' }),
|
|
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
|
|
146
|
+
runtime,
|
|
139
147
|
nextId: () => `id_${++seq}`,
|
|
140
148
|
tuiDebug: () => {},
|
|
141
|
-
LEAD_TURN_TIMEOUT_MS:
|
|
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
|
|