mixdog 0.9.38 → 0.9.39
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 +9 -4
- package/scripts/abort-recovery-test.mjs +43 -2
- package/scripts/agent-tag-reuse-smoke.mjs +146 -6
- package/scripts/agent-terminal-reap-test.mjs +127 -0
- package/scripts/agent-trace-io-test.mjs +69 -0
- package/scripts/code-graph-disk-hit-test.mjs +224 -0
- package/scripts/execution-completion-dedup-test.mjs +157 -0
- package/scripts/execution-pending-resume-kick-test.mjs +57 -2
- package/scripts/execution-resume-esc-integration-test.mjs +174 -0
- package/scripts/explore-bench.mjs +38 -2
- package/scripts/explore-prompt-policy-test.mjs +152 -11
- package/scripts/find-fuzzy-hidden-test.mjs +122 -0
- package/scripts/internal-comms-bench-test.mjs +226 -0
- package/scripts/internal-comms-bench.mjs +185 -58
- package/scripts/internal-comms-smoke.mjs +171 -23
- package/scripts/live-worker-smoke.mjs +38 -2
- package/scripts/memory-cycle-routing-test.mjs +111 -0
- package/scripts/memory-rule-contract-test.mjs +93 -0
- package/scripts/output-style-smoke.mjs +2 -2
- package/scripts/rg-runner-test.mjs +240 -0
- package/scripts/routing-corpus-test.mjs +349 -0
- package/scripts/routing-corpus.mjs +211 -32
- package/scripts/session-orphan-sweep-test.mjs +83 -0
- package/scripts/steering-drain-buckets-test.mjs +179 -0
- package/scripts/tool-smoke.mjs +21 -13
- package/scripts/tool-tui-presentation-test.mjs +202 -0
- package/src/agents/heavy-worker/AGENT.md +10 -7
- package/src/agents/reviewer/AGENT.md +6 -4
- package/src/agents/worker/AGENT.md +7 -5
- package/src/rules/agent/00-common.md +4 -4
- package/src/rules/agent/00-core.md +11 -14
- package/src/rules/agent/20-skip-protocol.md +3 -3
- package/src/rules/agent/30-explorer.md +50 -60
- package/src/rules/agent/40-cycle1-agent.md +15 -24
- package/src/rules/agent/41-cycle2-agent.md +33 -57
- package/src/rules/agent/42-cycle3-agent.md +28 -42
- package/src/rules/lead/01-general.md +7 -10
- package/src/rules/lead/lead-brief.md +11 -14
- package/src/rules/lead/lead-tool.md +6 -5
- package/src/rules/shared/01-tool.md +44 -45
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +37 -1
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +20 -5
- package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +21 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +17 -8
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +270 -78
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +123 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +15 -2
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +41 -2
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/store.mjs +223 -42
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +25 -20
- package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +6 -2
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +118 -53
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +106 -29
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +8 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +4 -2
- package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +3 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +33 -2
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +151 -64
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +37 -13
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +84 -49
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +172 -23
- package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +3 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +68 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +17 -3
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +24 -10
- package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +6 -3
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -7
- package/src/runtime/agent/orchestrator/tools/code-graph.mjs +1 -0
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -4
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +4 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +12 -2
- package/src/runtime/shared/tool-primitives.mjs +4 -1
- package/src/runtime/shared/tool-status.mjs +27 -0
- package/src/runtime/shared/tool-surface.mjs +6 -3
- package/src/session-runtime/config-helpers.mjs +14 -0
- package/src/session-runtime/context-status.mjs +1 -0
- package/src/session-runtime/effort.mjs +6 -2
- package/src/session-runtime/model-recency.mjs +5 -2
- package/src/session-runtime/runtime-core.mjs +35 -2
- package/src/session-runtime/tool-catalog.mjs +34 -0
- package/src/standalone/agent-tool/notify.mjs +13 -0
- package/src/standalone/agent-tool.mjs +45 -69
- package/src/standalone/explore-tool.mjs +6 -7
- package/src/tui/App.jsx +31 -0
- package/src/tui/app/model-options.mjs +5 -3
- package/src/tui/app/model-picker.mjs +12 -24
- package/src/tui/app/transcript-window.mjs +1 -0
- package/src/tui/components/ToolExecution.jsx +11 -6
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/components/tool-execution/surface-detail.mjs +24 -10
- package/src/tui/components/tool-execution/text-format.mjs +10 -19
- package/src/tui/dist/index.mjs +517 -142
- package/src/tui/engine/agent-job-feed.mjs +144 -17
- package/src/tui/engine/agent-response-tail.mjs +68 -0
- package/src/tui/engine/notification-plan.mjs +16 -0
- package/src/tui/engine/session-api.mjs +8 -2
- package/src/tui/engine/session-flow.mjs +19 -1
- package/src/tui/engine/tool-card-results.mjs +54 -32
- package/src/tui/engine/tool-result-status.mjs +75 -21
- package/src/tui/engine/turn.mjs +77 -42
- package/src/tui/engine.mjs +63 -2
- package/src/workflows/bench/WORKFLOW.md +25 -35
- package/src/workflows/default/WORKFLOW.md +38 -32
- package/src/workflows/solo/WORKFLOW.md +19 -22
- package/scripts/_jitter-fuzz.mjs +0 -44410
- package/scripts/_jitter-fuzz2.mjs +0 -44400
- package/scripts/_jitter-probe.mjs +0 -44397
- package/scripts/_jp2.mjs +0 -45614
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
_isCompatibleDiskCodeGraphEntry,
|
|
5
|
+
_postCodeGraphWorkerSuccess,
|
|
6
|
+
_prepareDiskCodeGraphFastPath,
|
|
7
|
+
_runDiskCodeGraphFastPath,
|
|
8
|
+
_validateDiskCodeGraphHit,
|
|
9
|
+
} from '../src/runtime/agent/orchestrator/tools/code-graph/build.mjs';
|
|
10
|
+
import { CODE_GRAPH_MAX_FILES } from '../src/runtime/agent/orchestrator/tools/code-graph/constants.mjs';
|
|
11
|
+
import { _serializeGraph } from '../src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs';
|
|
12
|
+
import { _persistDiskCodeGraphCacheNow } from '../src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs';
|
|
13
|
+
|
|
14
|
+
test('signature-validated disk hit restores memory without a Worker build', async () => {
|
|
15
|
+
const graph = { nodes: new Map() };
|
|
16
|
+
let restored = null;
|
|
17
|
+
const result = await _validateDiskCodeGraphHit({
|
|
18
|
+
graphCwd: '/project',
|
|
19
|
+
diskEntry: { signature: 'sig', maxFiles: CODE_GRAPH_MAX_FILES },
|
|
20
|
+
genAtStart: 4,
|
|
21
|
+
now: 123,
|
|
22
|
+
runManifest: async () => [{ rel: 'a.mjs', fp: '1' }],
|
|
23
|
+
computeSignature: () => 'sig',
|
|
24
|
+
deserializeGraph: () => graph,
|
|
25
|
+
getGeneration: () => 4,
|
|
26
|
+
setMemoryCache: (cwd, entry) => { restored = { cwd, entry }; },
|
|
27
|
+
});
|
|
28
|
+
assert.equal(result.graph, graph);
|
|
29
|
+
assert.deepEqual(restored, {
|
|
30
|
+
cwd: '/project',
|
|
31
|
+
entry: { ts: 123, signature: 'sig', graph },
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('disk mismatch hands one manifest to the isolated rebuild without restoring stale data', async () => {
|
|
36
|
+
let deserialized = false;
|
|
37
|
+
const manifest = [{ rel: 'changed.mjs', fp: '2' }];
|
|
38
|
+
const result = await _validateDiskCodeGraphHit({
|
|
39
|
+
graphCwd: '/project',
|
|
40
|
+
diskEntry: { signature: 'old', maxFiles: CODE_GRAPH_MAX_FILES },
|
|
41
|
+
genAtStart: 1,
|
|
42
|
+
runManifest: async () => manifest,
|
|
43
|
+
computeSignature: () => 'new',
|
|
44
|
+
deserializeGraph: () => { deserialized = true; },
|
|
45
|
+
});
|
|
46
|
+
assert.equal(result.graph, null);
|
|
47
|
+
assert.equal(result.manifest, manifest);
|
|
48
|
+
assert.equal(result.signature, 'new');
|
|
49
|
+
assert.equal(deserialized, false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('generation change rejects a validated stale disk graph', async () => {
|
|
53
|
+
const result = await _validateDiskCodeGraphHit({
|
|
54
|
+
graphCwd: '/project',
|
|
55
|
+
diskEntry: { signature: 'sig', maxFiles: CODE_GRAPH_MAX_FILES },
|
|
56
|
+
genAtStart: 1,
|
|
57
|
+
runManifest: async () => [],
|
|
58
|
+
computeSignature: () => 'sig',
|
|
59
|
+
deserializeGraph: () => ({ nodes: new Map() }),
|
|
60
|
+
getGeneration: () => 2,
|
|
61
|
+
});
|
|
62
|
+
assert.deepEqual(result, { invalidated: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
function fastProbe(overrides = {}) {
|
|
66
|
+
return { isFastPathEligible: true, maxFiles: CODE_GRAPH_MAX_FILES, bytes: 1, ...overrides };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test('validated hit releases its pre-acquired slot exactly once', async () => {
|
|
70
|
+
let releases = 0;
|
|
71
|
+
let workers = 0;
|
|
72
|
+
const result = await _runDiskCodeGraphFastPath({
|
|
73
|
+
graphCwd: '/project',
|
|
74
|
+
diskProbe: fastProbe(),
|
|
75
|
+
genAtStart: 1,
|
|
76
|
+
loadDiskEntry: () => ({ maxFiles: CODE_GRAPH_MAX_FILES }),
|
|
77
|
+
acquireSlot: async () => () => { releases++; },
|
|
78
|
+
validateDiskHit: async () => ({ graph: 'hit' }),
|
|
79
|
+
spawnWorker: () => { workers++; },
|
|
80
|
+
});
|
|
81
|
+
assert.equal(result, 'hit');
|
|
82
|
+
assert.equal(releases, 1);
|
|
83
|
+
assert.equal(workers, 0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('validation errors release the pre-acquired slot', async () => {
|
|
87
|
+
let releases = 0;
|
|
88
|
+
await assert.rejects(_runDiskCodeGraphFastPath({
|
|
89
|
+
graphCwd: '/project',
|
|
90
|
+
diskProbe: fastProbe(),
|
|
91
|
+
genAtStart: 1,
|
|
92
|
+
loadDiskEntry: () => ({ maxFiles: CODE_GRAPH_MAX_FILES }),
|
|
93
|
+
acquireSlot: async () => () => { releases++; },
|
|
94
|
+
validateDiskHit: async () => { throw new Error('manifest failed'); },
|
|
95
|
+
spawnWorker: () => assert.fail('Worker must not start'),
|
|
96
|
+
}), /manifest failed/);
|
|
97
|
+
assert.equal(releases, 1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('validated miss hands its slot to Worker exactly once', async () => {
|
|
101
|
+
let releases = 0;
|
|
102
|
+
let receivedRelease = null;
|
|
103
|
+
const result = await _runDiskCodeGraphFastPath({
|
|
104
|
+
graphCwd: '/project',
|
|
105
|
+
diskProbe: fastProbe(),
|
|
106
|
+
genAtStart: 1,
|
|
107
|
+
loadDiskEntry: () => ({ maxFiles: CODE_GRAPH_MAX_FILES }),
|
|
108
|
+
acquireSlot: async () => () => { releases++; },
|
|
109
|
+
validateDiskHit: async () => ({ graph: null, manifest: ['one'], signature: 'new' }),
|
|
110
|
+
spawnWorker: (release, manifest, signature) => {
|
|
111
|
+
receivedRelease = release;
|
|
112
|
+
assert.deepEqual(manifest, ['one']);
|
|
113
|
+
assert.equal(signature, 'new');
|
|
114
|
+
return 'worker-result';
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
assert.equal(result, 'worker-result');
|
|
118
|
+
assert.equal(releases, 0);
|
|
119
|
+
receivedRelease();
|
|
120
|
+
assert.equal(releases, 1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('abort after slot acquisition or validation releases the slot and never starts a Worker', async () => {
|
|
124
|
+
for (const abortAt of ['acquire', 'validate']) {
|
|
125
|
+
const controller = new AbortController();
|
|
126
|
+
let releases = 0;
|
|
127
|
+
let workers = 0;
|
|
128
|
+
await assert.rejects(_runDiskCodeGraphFastPath({
|
|
129
|
+
graphCwd: '/project',
|
|
130
|
+
diskProbe: fastProbe(),
|
|
131
|
+
genAtStart: 1,
|
|
132
|
+
signal: controller.signal,
|
|
133
|
+
loadDiskEntry: () => ({ maxFiles: CODE_GRAPH_MAX_FILES }),
|
|
134
|
+
acquireSlot: async () => {
|
|
135
|
+
if (abortAt === 'acquire') controller.abort();
|
|
136
|
+
return () => { releases++; };
|
|
137
|
+
},
|
|
138
|
+
validateDiskHit: async () => {
|
|
139
|
+
if (abortAt === 'validate') controller.abort();
|
|
140
|
+
return { graph: 'hit' };
|
|
141
|
+
},
|
|
142
|
+
spawnWorker: () => { workers++; },
|
|
143
|
+
}), /aborted/);
|
|
144
|
+
assert.equal(releases, 1, abortAt);
|
|
145
|
+
assert.equal(workers, 0, abortAt);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('oversized or maxFiles-incompatible entries bypass main-thread load and use Worker', async () => {
|
|
150
|
+
for (const probe of [fastProbe({ isFastPathEligible: false }), fastProbe({ maxFiles: null })]) {
|
|
151
|
+
let loaded = 0;
|
|
152
|
+
let acquired = 0;
|
|
153
|
+
const result = await _runDiskCodeGraphFastPath({
|
|
154
|
+
graphCwd: '/project',
|
|
155
|
+
diskProbe: probe,
|
|
156
|
+
genAtStart: 1,
|
|
157
|
+
loadDiskEntry: () => { loaded++; return { maxFiles: CODE_GRAPH_MAX_FILES }; },
|
|
158
|
+
acquireSlot: async () => { acquired++; return () => {}; },
|
|
159
|
+
spawnWorker: (release) => ({ release }),
|
|
160
|
+
});
|
|
161
|
+
assert.deepEqual(result, { release: null });
|
|
162
|
+
assert.equal(loaded, 0);
|
|
163
|
+
assert.equal(acquired, 0);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('persisted entries require matching maxFiles in both cache paths', () => {
|
|
168
|
+
assert.equal(_isCompatibleDiskCodeGraphEntry({ maxFiles: CODE_GRAPH_MAX_FILES }), true);
|
|
169
|
+
assert.equal(_isCompatibleDiskCodeGraphEntry({}), false);
|
|
170
|
+
assert.equal(_isCompatibleDiskCodeGraphEntry({ maxFiles: CODE_GRAPH_MAX_FILES - 1 }), false);
|
|
171
|
+
assert.equal(_serializeGraph({ nodes: new Map() }).maxFiles, CODE_GRAPH_MAX_FILES);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('large legacy cache bypasses parent migration/parser and routes directly to Worker', () => {
|
|
175
|
+
let migrationOrParserCalls = 0;
|
|
176
|
+
let probeCalls = 0;
|
|
177
|
+
const result = _prepareDiskCodeGraphFastPath({
|
|
178
|
+
graphCwd: '/project',
|
|
179
|
+
hasLegacyCache: () => true,
|
|
180
|
+
ensureDiskLoaded: () => { migrationOrParserCalls++; },
|
|
181
|
+
probeDiskEntry: () => { probeCalls++; return assert.fail('legacy probe must not run'); },
|
|
182
|
+
runFastPath: (probe) => ({ probe, route: 'worker' }),
|
|
183
|
+
});
|
|
184
|
+
assert.deepEqual(result, { probe: null, route: 'worker' });
|
|
185
|
+
assert.equal(migrationOrParserCalls, 0);
|
|
186
|
+
assert.equal(probeCalls, 0);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('Worker drains all migrated cache entries before posting success', () => {
|
|
190
|
+
const migratedEntries = new Map([
|
|
191
|
+
['/requested', { signature: 'requested' }],
|
|
192
|
+
['/legacy-project', { signature: 'legacy' }],
|
|
193
|
+
]);
|
|
194
|
+
const events = [];
|
|
195
|
+
_postCodeGraphWorkerSuccess(
|
|
196
|
+
{ signature: 'requested', nodes: new Map() },
|
|
197
|
+
(message) => {
|
|
198
|
+
events.push('post');
|
|
199
|
+
assert.equal(migratedEntries.size, 2);
|
|
200
|
+
assert.equal(message.ok, true);
|
|
201
|
+
},
|
|
202
|
+
() => {
|
|
203
|
+
events.push('drain');
|
|
204
|
+
assert.deepEqual([...migratedEntries.keys()], ['/requested', '/legacy-project']);
|
|
205
|
+
},
|
|
206
|
+
);
|
|
207
|
+
assert.deepEqual(events, ['drain', 'post']);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('strict persistence failure prevents Worker success post', () => {
|
|
211
|
+
let posted = false;
|
|
212
|
+
assert.throws(
|
|
213
|
+
() => _postCodeGraphWorkerSuccess(
|
|
214
|
+
{ signature: 'requested' },
|
|
215
|
+
() => { posted = true; },
|
|
216
|
+
() => _persistDiskCodeGraphCacheNow({
|
|
217
|
+
strict: true,
|
|
218
|
+
writeJson: () => { throw new Error('persist failed'); },
|
|
219
|
+
}),
|
|
220
|
+
),
|
|
221
|
+
/persist failed/,
|
|
222
|
+
);
|
|
223
|
+
assert.equal(posted, false);
|
|
224
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Regression: consolidated completion dedup on the delivered-completions
|
|
2
|
+
// registry (src/tui/engine/agent-job-feed.mjs execution-ui branch).
|
|
3
|
+
//
|
|
4
|
+
// Symptom guarded: after a turn completes and the lead goes IDLE, a duplicate
|
|
5
|
+
// execution completion (SAME execution_id, slightly different composite
|
|
6
|
+
// status/body key) must NOT re-enqueue a model-visible twin — otherwise
|
|
7
|
+
// post-turn drain() spawns a fresh turn. A genuinely-new completion (different
|
|
8
|
+
// execution_id) must still enqueue so the lead wakes.
|
|
9
|
+
import test from 'node:test';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
|
|
12
|
+
import { createAgentJobFeed } from '../src/tui/engine/agent-job-feed.mjs';
|
|
13
|
+
import {
|
|
14
|
+
_clearDeliveredCompletions,
|
|
15
|
+
} from '../src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs';
|
|
16
|
+
|
|
17
|
+
const completionText = 'Async agent task task_1 completed finished.\n\nResult:\n> ok';
|
|
18
|
+
const completionMeta = { type: 'agent_task_result', execution_id: 'task_1', status: 'completed' };
|
|
19
|
+
|
|
20
|
+
function makeHarness() {
|
|
21
|
+
const enqueued = []; // captured enqueue entries (spy)
|
|
22
|
+
const pending = []; // real queue depth proxy
|
|
23
|
+
let enqueueCalls = 0; // spy call-count
|
|
24
|
+
let handler = null;
|
|
25
|
+
const state = { busy: false };
|
|
26
|
+
const feed = createAgentJobFeed({
|
|
27
|
+
runtime: { onNotification: (fn) => { handler = fn; return () => {}; } },
|
|
28
|
+
getState: () => state,
|
|
29
|
+
set: () => {},
|
|
30
|
+
nextId: () => 'id',
|
|
31
|
+
getDisposed: () => false,
|
|
32
|
+
patchItem: () => {},
|
|
33
|
+
enqueue: (body, opts) => {
|
|
34
|
+
enqueueCalls += 1;
|
|
35
|
+
enqueued.push({ body, opts });
|
|
36
|
+
pending.push({ body, opts });
|
|
37
|
+
return true;
|
|
38
|
+
},
|
|
39
|
+
drain: () => Promise.resolve(),
|
|
40
|
+
pushUserOrSyntheticItem: () => {},
|
|
41
|
+
makeQueueEntry: (text, opts = {}) => ({ text, ...opts }),
|
|
42
|
+
getPending: () => pending,
|
|
43
|
+
agentStatusState: () => ({}),
|
|
44
|
+
displayedExecutionNotificationKeys: new Set(),
|
|
45
|
+
pushNotice: () => {},
|
|
46
|
+
});
|
|
47
|
+
feed.subscribeRuntimeNotifications();
|
|
48
|
+
return { enqueued, pending, enqueueCalls: () => enqueueCalls, deliver: (event) => handler(event) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('re-arriving delivered completion (same execution_id) does NOT enqueue a new turn', () => {
|
|
52
|
+
_clearDeliveredCompletions();
|
|
53
|
+
const { enqueueCalls, pending, deliver } = makeHarness();
|
|
54
|
+
|
|
55
|
+
// First arrival: brand-new completion → enqueued + ACKed.
|
|
56
|
+
const first = { content: completionText, meta: completionMeta };
|
|
57
|
+
deliver(first);
|
|
58
|
+
assert.equal(enqueueCalls(), 1, 'new completion enqueues the model-visible twin');
|
|
59
|
+
assert.equal(pending.length, 1, 'one pending entry queued');
|
|
60
|
+
assert.equal(first.modelVisibleDelivered, true, 'first delivery ACKs runtime-core mirror suppression');
|
|
61
|
+
|
|
62
|
+
// Duplicate: SAME execution_id, different composite key (meta.type differs →
|
|
63
|
+
// different notificationQueueKey AND a different model-visible text hash), but
|
|
64
|
+
// still a persistable terminal completion. Dedup must recognize it purely via
|
|
65
|
+
// the execution_id registry key, not the status/body-derived composite.
|
|
66
|
+
const dup = { content: completionText, meta: { ...completionMeta, type: 'background_task_result' } };
|
|
67
|
+
deliver(dup);
|
|
68
|
+
// END-TO-END proxy for "no idle refire": the handler returned with NO new
|
|
69
|
+
// enqueue call and NO new pending entry, so post-turn drain has nothing new to
|
|
70
|
+
// fire — yet the ack is still set so runtime-core never mirrors it either.
|
|
71
|
+
assert.equal(enqueueCalls(), 1, 'already-delivered completion triggers NO new enqueue (no idle refire)');
|
|
72
|
+
assert.equal(pending.length, 1, 'queue depth stable — dup pushed no new pending entry');
|
|
73
|
+
assert.equal(dup.modelVisibleDelivered, true, 'duplicate still ACKs so mirror/fallback stays suppressed');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('genuinely-new completion (different execution_id) still enqueues + wakes the lead', () => {
|
|
77
|
+
_clearDeliveredCompletions();
|
|
78
|
+
const { enqueueCalls, deliver } = makeHarness();
|
|
79
|
+
|
|
80
|
+
deliver({ content: completionText, meta: completionMeta });
|
|
81
|
+
const secondText = 'Async agent task task_2 completed finished.\n\nResult:\n> done';
|
|
82
|
+
deliver({ content: secondText, meta: { type: 'agent_task_result', execution_id: 'task_2', status: 'completed' } });
|
|
83
|
+
assert.equal(enqueueCalls(), 2, 'a new execution_id triggers exactly one new enqueue (lead wakeup preserved)');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// CARD dedup regression: the transcript-card first-delivery guard keys on a
|
|
87
|
+
// stable execution_id-based key (executionCardKey → card:<id>:<hasBody>), NOT
|
|
88
|
+
// the full composite notificationKey. A duplicate completion re-arriving with a
|
|
89
|
+
// different type/status (same execution_id, same hasBody) must push ONE card;
|
|
90
|
+
// a bodyless successful preview (b0) is status-only; the real body (b1) is the
|
|
91
|
+
// single visible logical response.
|
|
92
|
+
function makeCardHarness() {
|
|
93
|
+
let handler = null;
|
|
94
|
+
let cardPushes = 0;
|
|
95
|
+
const responseCalls = [];
|
|
96
|
+
const feed = createAgentJobFeed({
|
|
97
|
+
runtime: { onNotification: (fn) => { handler = fn; return () => {}; } },
|
|
98
|
+
getState: () => ({ busy: false }),
|
|
99
|
+
set: () => {},
|
|
100
|
+
nextId: () => 'id',
|
|
101
|
+
getDisposed: () => false,
|
|
102
|
+
patchItem: () => {},
|
|
103
|
+
enqueue: () => true,
|
|
104
|
+
drain: () => Promise.resolve(),
|
|
105
|
+
pushUserOrSyntheticItem: () => { cardPushes += 1; },
|
|
106
|
+
pushAsyncAgentResponse: (...args) => { cardPushes += 1; responseCalls.push(args); },
|
|
107
|
+
makeQueueEntry: (text, opts = {}) => ({ text, ...opts }),
|
|
108
|
+
getPending: () => [],
|
|
109
|
+
agentStatusState: () => ({}),
|
|
110
|
+
displayedExecutionNotificationKeys: new Set(),
|
|
111
|
+
pushNotice: () => {},
|
|
112
|
+
});
|
|
113
|
+
feed.subscribeRuntimeNotifications();
|
|
114
|
+
return { cardPushes: () => cardPushes, responseCalls, deliver: (event) => handler(event) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
test('duplicate completion (same execution_id, differing type/status) pushes ONE card', () => {
|
|
118
|
+
_clearDeliveredCompletions();
|
|
119
|
+
const { cardPushes, deliver } = makeCardHarness();
|
|
120
|
+
|
|
121
|
+
deliver({ content: completionText, meta: completionMeta });
|
|
122
|
+
assert.equal(cardPushes(), 1, 'first completion pushes a card');
|
|
123
|
+
|
|
124
|
+
deliver({ content: completionText, meta: { ...completionMeta, type: 'background_task_result', status: 'finished' } });
|
|
125
|
+
assert.equal(cardPushes(), 1, 'dup with differing type/status pushes NO second card (execution_id card key)');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('bodyless successful preview (b0) then result (b1), same execution_id, pushes ONE card', () => {
|
|
129
|
+
_clearDeliveredCompletions();
|
|
130
|
+
const { cardPushes, deliver } = makeCardHarness();
|
|
131
|
+
|
|
132
|
+
// Bodyless preview: header-only, no blank-line-separated result body → b0.
|
|
133
|
+
deliver({ content: 'Async agent task task_9 completed finished.', meta: { type: 'agent_task_result', execution_id: 'task_9', status: 'completed' } });
|
|
134
|
+
assert.equal(cardPushes(), 0, 'successful preview is status-only');
|
|
135
|
+
|
|
136
|
+
// Real result: carries a body after a blank line → b1 (upgrade preserved).
|
|
137
|
+
deliver({ content: 'Async agent task task_9 completed finished.\n\nResult:\n> ok', meta: { type: 'agent_task_result', execution_id: 'task_9', status: 'completed' } });
|
|
138
|
+
assert.equal(cardPushes(), 1, 'result body (b1) is the one logical response');
|
|
139
|
+
|
|
140
|
+
deliver({ content: 'Async agent task task_9 completed finished.\n\nResult:\n> retry', meta: { type: 'background_task_result', execution_id: 'task_9', status: 'finished' } });
|
|
141
|
+
assert.equal(cardPushes(), 1, 'same execution retry pushes no additional response');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('bodyless failure preview does not suppress its later body', () => {
|
|
145
|
+
_clearDeliveredCompletions();
|
|
146
|
+
const { cardPushes, responseCalls, deliver } = makeCardHarness();
|
|
147
|
+
deliver({ content: 'Async agent task task_fail failed.', meta: { type: 'agent_task_result', execution_id: 'task_fail', status: 'failed' } });
|
|
148
|
+
assert.equal(cardPushes(), 1, 'failure preview remains visible');
|
|
149
|
+
|
|
150
|
+
deliver({ content: 'Async agent task task_fail completed.\n\nResult:\n> recovered', meta: { type: 'agent_task_result', execution_id: 'task_fail', status: 'completed' } });
|
|
151
|
+
assert.equal(cardPushes(), 2, 'body is delivered for tail update or boundary-safe standalone card');
|
|
152
|
+
assert.equal(responseCalls[0][3].responseKey, 'task_fail');
|
|
153
|
+
assert.equal(responseCalls[1][3].responseKey, 'task_fail');
|
|
154
|
+
|
|
155
|
+
deliver({ content: 'Async agent task task_fail completed.\n\nResult:\n> retry', meta: { type: 'background_task_result', execution_id: 'task_fail', status: 'finished' } });
|
|
156
|
+
assert.equal(cardPushes(), 2, 'same-body retry remains idempotent');
|
|
157
|
+
});
|
|
@@ -13,7 +13,7 @@ import { createAgentJobFeed } from '../src/tui/engine/agent-job-feed.mjs';
|
|
|
13
13
|
|
|
14
14
|
// Minimal harness: a mutable busy flag, a pending queue, and a synchronous
|
|
15
15
|
// drain stub that "resumes" by surfacing every pending-resume entry's body.
|
|
16
|
-
function makeHarness() {
|
|
16
|
+
function makeHarness({ now, executionResumeTombstoneTtlMs, executionResumeTombstoneLimit } = {}) {
|
|
17
17
|
const pending = [];
|
|
18
18
|
const surfaced = [];
|
|
19
19
|
const state = { busy: false };
|
|
@@ -48,11 +48,20 @@ function makeHarness() {
|
|
|
48
48
|
enqueue: () => {},
|
|
49
49
|
drain,
|
|
50
50
|
pushUserOrSyntheticItem: () => {},
|
|
51
|
-
makeQueueEntry: (text, opts = {}) => ({
|
|
51
|
+
makeQueueEntry: (text, opts = {}) => ({
|
|
52
|
+
text,
|
|
53
|
+
mode: opts.mode,
|
|
54
|
+
priority: opts.priority,
|
|
55
|
+
abortDiscardOnAbort: opts.abortDiscardOnAbort,
|
|
56
|
+
resumeCompletionKeys: opts.resumeCompletionKeys,
|
|
57
|
+
}),
|
|
52
58
|
getPending: () => pending,
|
|
53
59
|
agentStatusState: () => ({}),
|
|
54
60
|
displayedExecutionNotificationKeys: new Set(),
|
|
55
61
|
pushNotice: () => {},
|
|
62
|
+
now,
|
|
63
|
+
executionResumeTombstoneTtlMs,
|
|
64
|
+
executionResumeTombstoneLimit,
|
|
56
65
|
});
|
|
57
66
|
|
|
58
67
|
return { feed, pending, surfaced, state };
|
|
@@ -94,3 +103,49 @@ test('B: deferred kick after a non-drain busy->false transition still fires', as
|
|
|
94
103
|
feed.flushDeferredExecutionPendingResumeKick();
|
|
95
104
|
assert.deepEqual(surfaced, ['only body']);
|
|
96
105
|
});
|
|
106
|
+
|
|
107
|
+
test('Esc discards one completion resume, drops its duplicate retry, and still wakes for a new completion', async () => {
|
|
108
|
+
const { feed, surfaced, state } = makeHarness();
|
|
109
|
+
state.busy = true;
|
|
110
|
+
feed.scheduleExecutionPendingResumeKick('body A', 'execution_A');
|
|
111
|
+
await microtasks();
|
|
112
|
+
|
|
113
|
+
// Esc owns and retires A while its resume is active. A delayed duplicate
|
|
114
|
+
// cannot re-create that resume, but a genuinely different completion can.
|
|
115
|
+
feed.discardExecutionPendingResume(['execution_A']);
|
|
116
|
+
state.busy = false;
|
|
117
|
+
feed.flushDeferredExecutionPendingResumeKick();
|
|
118
|
+
assert.deepEqual(surfaced, [], 'the aborted completion is not deferred-kicked');
|
|
119
|
+
|
|
120
|
+
feed.scheduleExecutionPendingResumeKick('body A retry', 'execution_A');
|
|
121
|
+
await microtasks();
|
|
122
|
+
assert.deepEqual(surfaced, [], 'duplicate retry after Esc cannot restart A');
|
|
123
|
+
|
|
124
|
+
feed.scheduleExecutionPendingResumeKick('body B', 'execution_B');
|
|
125
|
+
await microtasks();
|
|
126
|
+
assert.deepEqual(surfaced, ['body B'], 'a later new completion still wakes the lead');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('Esc tombstone expires so a later legitimate execution/body reuse can resume', async () => {
|
|
130
|
+
let clock = 1_000;
|
|
131
|
+
const { feed, surfaced, state } = makeHarness({
|
|
132
|
+
now: () => clock,
|
|
133
|
+
executionResumeTombstoneTtlMs: 20,
|
|
134
|
+
executionResumeTombstoneLimit: 2,
|
|
135
|
+
});
|
|
136
|
+
state.busy = true;
|
|
137
|
+
feed.scheduleExecutionPendingResumeKick('same body', 'execution_reused');
|
|
138
|
+
await microtasks();
|
|
139
|
+
feed.discardExecutionPendingResume(['execution_reused']);
|
|
140
|
+
state.busy = false;
|
|
141
|
+
feed.flushDeferredExecutionPendingResumeKick();
|
|
142
|
+
|
|
143
|
+
feed.scheduleExecutionPendingResumeKick('duplicate retry', 'execution_reused');
|
|
144
|
+
await microtasks();
|
|
145
|
+
assert.deepEqual(surfaced, [], 'the short-lived tombstone blocks a delayed duplicate');
|
|
146
|
+
|
|
147
|
+
clock += 21;
|
|
148
|
+
feed.scheduleExecutionPendingResumeKick('legitimate reuse', 'execution_reused');
|
|
149
|
+
await microtasks();
|
|
150
|
+
assert.deepEqual(surfaced, ['legitimate reuse'], 'expired tombstone no longer reserves the execution ID');
|
|
151
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Production-path regression: runtime completion notification -> feed enqueue
|
|
2
|
+
// -> session drain -> real runTurn -> Esc. This deliberately uses the real
|
|
3
|
+
// feed, queue, API, and turn implementations rather than fabricating restore
|
|
4
|
+
// state, so ownership survives the same path used by the TUI.
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
|
|
8
|
+
import { createAgentJobFeed } from '../src/tui/engine/agent-job-feed.mjs';
|
|
9
|
+
import { createSessionFlow } from '../src/tui/engine/session-flow.mjs';
|
|
10
|
+
import { createRunTurn } from '../src/tui/engine/turn.mjs';
|
|
11
|
+
import { createEngineApiA } from '../src/tui/engine/session-api.mjs';
|
|
12
|
+
import { _clearDeliveredCompletions } from '../src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs';
|
|
13
|
+
|
|
14
|
+
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
|
15
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
|
+
|
|
17
|
+
function completion(executionId, body) {
|
|
18
|
+
return {
|
|
19
|
+
content: `Async agent task ${executionId} completed finished.\n\nResult:\n> ${body}`,
|
|
20
|
+
meta: { type: 'agent_task_result', execution_id: executionId, status: 'completed' },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function makeHarness() {
|
|
25
|
+
let seq = 0;
|
|
26
|
+
let notify = null;
|
|
27
|
+
let activeAsk = null;
|
|
28
|
+
let feed = null;
|
|
29
|
+
const asks = [];
|
|
30
|
+
const pending = [];
|
|
31
|
+
const itemIndexById = new Map();
|
|
32
|
+
const state = {
|
|
33
|
+
items: [],
|
|
34
|
+
queued: [],
|
|
35
|
+
busy: false,
|
|
36
|
+
commandBusy: false,
|
|
37
|
+
spinner: null,
|
|
38
|
+
thinking: null,
|
|
39
|
+
lastTurn: null,
|
|
40
|
+
stats: { turns: 0, inputTokens: 0, outputTokens: 0 },
|
|
41
|
+
};
|
|
42
|
+
const runtime = {
|
|
43
|
+
id: null,
|
|
44
|
+
toolMode: 'auto',
|
|
45
|
+
onNotification: (fn) => { notify = fn; return () => {}; },
|
|
46
|
+
consumePendingSessionReset: () => null,
|
|
47
|
+
ask: async (text, options) => new Promise((resolve, reject) => {
|
|
48
|
+
activeAsk = { text, options, reject };
|
|
49
|
+
asks.push(activeAsk);
|
|
50
|
+
}),
|
|
51
|
+
abort: () => {
|
|
52
|
+
const error = new Error('interrupted');
|
|
53
|
+
error.name = 'SessionClosedError';
|
|
54
|
+
activeAsk?.reject(error);
|
|
55
|
+
return true;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const bag = {
|
|
59
|
+
runtime,
|
|
60
|
+
nextId: () => `id_${++seq}`,
|
|
61
|
+
tuiDebug: () => {},
|
|
62
|
+
LEAD_TURN_TIMEOUT_MS: 300_000,
|
|
63
|
+
flags: { leadTurnEpoch: 0, drainEpoch: 0, disposed: false, draining: false, activePromptRestore: null },
|
|
64
|
+
pending,
|
|
65
|
+
pendingNotificationKeys: new Set(),
|
|
66
|
+
displayedExecutionNotificationKeys: new Set(),
|
|
67
|
+
listeners: new Set(),
|
|
68
|
+
itemIndexById,
|
|
69
|
+
getState: () => state,
|
|
70
|
+
set: (patch) => Object.assign(state, patch),
|
|
71
|
+
pushItem: (item) => {
|
|
72
|
+
state.items = [...state.items, item];
|
|
73
|
+
if (item?.id != null) itemIndexById.set(item.id, state.items.length - 1);
|
|
74
|
+
},
|
|
75
|
+
patchItem: (id, patch) => {
|
|
76
|
+
const index = state.items.findIndex((item) => item.id === id);
|
|
77
|
+
if (index < 0) return false;
|
|
78
|
+
state.items = state.items.map((item, i) => i === index ? { ...item, ...patch } : item);
|
|
79
|
+
return true;
|
|
80
|
+
},
|
|
81
|
+
replaceItems: (items) => items,
|
|
82
|
+
pushNotice: () => {},
|
|
83
|
+
pushUserOrSyntheticItem: (text, id) => {
|
|
84
|
+
state.items = [...state.items, { kind: 'injected', id, text }];
|
|
85
|
+
},
|
|
86
|
+
autoClearState: () => ({ enabled: false }),
|
|
87
|
+
agentStatusState: () => ({}),
|
|
88
|
+
routeState: () => ({}),
|
|
89
|
+
syncContextStats: () => {},
|
|
90
|
+
denyAllToolApprovals: () => {},
|
|
91
|
+
requestToolApproval: async () => ({ approved: false }),
|
|
92
|
+
markToolCallActive: () => {},
|
|
93
|
+
markToolCallDone: () => {},
|
|
94
|
+
clearActiveToolSummary: () => {},
|
|
95
|
+
patchToolCardResult: () => {},
|
|
96
|
+
flushToolResults: () => {},
|
|
97
|
+
// session-flow captures this dependency before the feed is constructed;
|
|
98
|
+
// keep that production ordering while forwarding to the live feed.
|
|
99
|
+
flushDeferredExecutionPendingResumeKick: () => feed?.flushDeferredExecutionPendingResumeKick(),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
Object.assign(bag, createSessionFlow(bag));
|
|
103
|
+
feed = createAgentJobFeed({
|
|
104
|
+
runtime,
|
|
105
|
+
getState: bag.getState,
|
|
106
|
+
set: bag.set,
|
|
107
|
+
nextId: bag.nextId,
|
|
108
|
+
getDisposed: () => bag.flags.disposed,
|
|
109
|
+
patchItem: bag.patchItem,
|
|
110
|
+
enqueue: (...args) => bag.enqueue(...args),
|
|
111
|
+
drain: (...args) => bag.drain(...args),
|
|
112
|
+
pushUserOrSyntheticItem: bag.pushUserOrSyntheticItem,
|
|
113
|
+
makeQueueEntry: (...args) => bag.makeQueueEntry(...args),
|
|
114
|
+
getPending: () => pending,
|
|
115
|
+
agentStatusState: bag.agentStatusState,
|
|
116
|
+
displayedExecutionNotificationKeys: bag.displayedExecutionNotificationKeys,
|
|
117
|
+
pushNotice: bag.pushNotice,
|
|
118
|
+
});
|
|
119
|
+
Object.assign(bag, feed);
|
|
120
|
+
bag.runTurn = createRunTurn(bag);
|
|
121
|
+
const api = createEngineApiA(bag);
|
|
122
|
+
feed.subscribeRuntimeNotifications();
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
api,
|
|
126
|
+
state,
|
|
127
|
+
asks,
|
|
128
|
+
deliver: (event) => notify(event),
|
|
129
|
+
activeAsk: () => activeAsk,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const phase of ['before first delta', 'after response progress']) {
|
|
134
|
+
test(`runtime completion Esc ${phase} does not restart it, while a new completion wakes`, async () => {
|
|
135
|
+
_clearDeliveredCompletions();
|
|
136
|
+
const harness = makeHarness();
|
|
137
|
+
|
|
138
|
+
const first = completion('execution_A', 'first result');
|
|
139
|
+
harness.deliver(first);
|
|
140
|
+
await tick();
|
|
141
|
+
assert.equal(harness.asks.length, 1, 'runtime notification drained into a real turn');
|
|
142
|
+
assert.match(harness.activeAsk().text, /first result/, 'completion body reached runtime.ask');
|
|
143
|
+
assert.equal(first.modelVisibleDelivered, true, 'notification was acknowledged on the TUI path');
|
|
144
|
+
|
|
145
|
+
if (phase === 'after response progress') {
|
|
146
|
+
harness.activeAsk().options.onTextDelta('partial response\n');
|
|
147
|
+
await wait(40);
|
|
148
|
+
assert.equal(
|
|
149
|
+
harness.state.items.some((item) => item.kind === 'assistant' && /partial response/.test(item.text)),
|
|
150
|
+
true,
|
|
151
|
+
'response progress reached the live turn',
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
assert.equal(harness.api.abort().aborted, true, 'Esc aborts the active runTurn');
|
|
156
|
+
await tick();
|
|
157
|
+
assert.equal(harness.state.busy, false, 'busy clears after the real abort unwind');
|
|
158
|
+
assert.equal(harness.state.spinner, null, 'spinner clears after Esc');
|
|
159
|
+
assert.equal(harness.state.thinking, null, 'thinking clears after Esc');
|
|
160
|
+
|
|
161
|
+
harness.deliver(completion('execution_A', 'duplicate retry'));
|
|
162
|
+
await tick();
|
|
163
|
+
assert.equal(harness.asks.length, 1, 'duplicate execution delivery cannot restart the aborted completion');
|
|
164
|
+
|
|
165
|
+
harness.deliver(completion('execution_B', 'later result'));
|
|
166
|
+
await tick();
|
|
167
|
+
assert.equal(harness.asks.length, 2, 'a genuinely new execution still wakes a turn');
|
|
168
|
+
assert.match(harness.activeAsk().text, /later result/);
|
|
169
|
+
|
|
170
|
+
// Finish the second intentionally-open mocked provider request.
|
|
171
|
+
harness.api.abort();
|
|
172
|
+
await tick();
|
|
173
|
+
});
|
|
174
|
+
}
|