mixdog 0.9.37 → 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.
Files changed (127) hide show
  1. package/package.json +9 -4
  2. package/scripts/abort-recovery-test.mjs +43 -2
  3. package/scripts/agent-tag-reuse-smoke.mjs +146 -6
  4. package/scripts/agent-terminal-reap-test.mjs +127 -0
  5. package/scripts/agent-trace-io-test.mjs +69 -0
  6. package/scripts/code-graph-disk-hit-test.mjs +224 -0
  7. package/scripts/dispatch-persist-recovery-test.mjs +141 -0
  8. package/scripts/execution-completion-dedup-test.mjs +157 -0
  9. package/scripts/execution-pending-resume-kick-test.mjs +57 -2
  10. package/scripts/execution-resume-esc-integration-test.mjs +174 -0
  11. package/scripts/explore-bench.mjs +101 -8
  12. package/scripts/explore-prompt-policy-test.mjs +156 -11
  13. package/scripts/find-fuzzy-hidden-test.mjs +122 -0
  14. package/scripts/internal-comms-bench-test.mjs +226 -0
  15. package/scripts/internal-comms-bench.mjs +185 -58
  16. package/scripts/internal-comms-smoke.mjs +171 -23
  17. package/scripts/live-worker-smoke.mjs +38 -2
  18. package/scripts/memory-cycle-routing-test.mjs +111 -0
  19. package/scripts/memory-rule-contract-test.mjs +93 -0
  20. package/scripts/notify-completion-mirror-test.mjs +73 -0
  21. package/scripts/output-style-smoke.mjs +2 -2
  22. package/scripts/rg-runner-test.mjs +240 -0
  23. package/scripts/routing-corpus-test.mjs +349 -0
  24. package/scripts/routing-corpus.mjs +211 -32
  25. package/scripts/session-orphan-sweep-test.mjs +83 -0
  26. package/scripts/session-sweep.mjs +266 -0
  27. package/scripts/steering-drain-buckets-test.mjs +179 -0
  28. package/scripts/tool-smoke.mjs +21 -13
  29. package/scripts/tool-tui-presentation-test.mjs +202 -0
  30. package/src/agents/heavy-worker/AGENT.md +10 -7
  31. package/src/agents/reviewer/AGENT.md +6 -4
  32. package/src/agents/worker/AGENT.md +7 -5
  33. package/src/rules/agent/00-common.md +4 -4
  34. package/src/rules/agent/00-core.md +11 -14
  35. package/src/rules/agent/20-skip-protocol.md +3 -3
  36. package/src/rules/agent/30-explorer.md +56 -48
  37. package/src/rules/agent/40-cycle1-agent.md +15 -24
  38. package/src/rules/agent/41-cycle2-agent.md +33 -57
  39. package/src/rules/agent/42-cycle3-agent.md +28 -42
  40. package/src/rules/lead/01-general.md +7 -10
  41. package/src/rules/lead/lead-brief.md +11 -14
  42. package/src/rules/lead/lead-tool.md +6 -5
  43. package/src/rules/shared/01-tool.md +44 -41
  44. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +37 -1
  45. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +20 -5
  46. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
  47. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
  48. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +21 -1
  49. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +6 -6
  50. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
  51. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -4
  52. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
  53. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +17 -8
  54. package/src/runtime/agent/orchestrator/session/context-utils.mjs +270 -78
  55. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +2 -1
  56. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +18 -52
  57. package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +123 -0
  58. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +15 -2
  59. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +41 -2
  60. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/session/store.mjs +339 -63
  62. package/src/runtime/agent/orchestrator/stall-policy.mjs +37 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
  64. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +25 -20
  65. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +6 -2
  66. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +118 -53
  67. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +106 -29
  68. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +8 -1
  69. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +4 -2
  70. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +3 -1
  71. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +4 -1
  72. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +33 -2
  73. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +151 -64
  74. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +37 -13
  75. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +84 -49
  76. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +172 -23
  77. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +3 -0
  78. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +68 -5
  79. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +17 -3
  80. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +24 -10
  81. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +6 -3
  82. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -7
  83. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +1 -0
  84. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  85. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -4
  86. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +4 -3
  87. package/src/runtime/memory/lib/memory-cycle3.mjs +12 -2
  88. package/src/runtime/shared/tool-primitives.mjs +4 -1
  89. package/src/runtime/shared/tool-status.mjs +27 -0
  90. package/src/runtime/shared/tool-surface.mjs +6 -3
  91. package/src/session-runtime/config-helpers.mjs +14 -0
  92. package/src/session-runtime/context-status.mjs +1 -0
  93. package/src/session-runtime/effort.mjs +6 -2
  94. package/src/session-runtime/model-recency.mjs +5 -2
  95. package/src/session-runtime/provider-models.mjs +3 -3
  96. package/src/session-runtime/runtime-core.mjs +78 -10
  97. package/src/session-runtime/tool-catalog.mjs +34 -0
  98. package/src/session-runtime/warmup-schedulers.mjs +7 -1
  99. package/src/standalone/agent-tool/notify.mjs +13 -0
  100. package/src/standalone/agent-tool.mjs +45 -69
  101. package/src/standalone/explore-tool.mjs +6 -7
  102. package/src/tui/App.jsx +31 -0
  103. package/src/tui/app/model-options.mjs +5 -3
  104. package/src/tui/app/model-picker.mjs +12 -24
  105. package/src/tui/app/transcript-window.mjs +1 -0
  106. package/src/tui/components/ToolExecution.jsx +11 -6
  107. package/src/tui/components/TranscriptItem.jsx +1 -1
  108. package/src/tui/components/tool-execution/surface-detail.mjs +24 -10
  109. package/src/tui/components/tool-execution/text-format.mjs +10 -19
  110. package/src/tui/dist/index.mjs +533 -143
  111. package/src/tui/engine/agent-job-feed.mjs +153 -16
  112. package/src/tui/engine/agent-response-tail.mjs +68 -0
  113. package/src/tui/engine/notification-plan.mjs +16 -0
  114. package/src/tui/engine/queue-helpers.mjs +8 -0
  115. package/src/tui/engine/session-api.mjs +8 -2
  116. package/src/tui/engine/session-flow.mjs +34 -2
  117. package/src/tui/engine/tool-card-results.mjs +54 -32
  118. package/src/tui/engine/tool-result-status.mjs +75 -21
  119. package/src/tui/engine/turn.mjs +83 -43
  120. package/src/tui/engine.mjs +63 -2
  121. package/src/workflows/bench/WORKFLOW.md +25 -35
  122. package/src/workflows/default/WORKFLOW.md +38 -32
  123. package/src/workflows/solo/WORKFLOW.md +19 -22
  124. package/scripts/_jitter-fuzz.mjs +0 -44410
  125. package/scripts/_jitter-fuzz2.mjs +0 -44400
  126. package/scripts/_jitter-probe.mjs +0 -44397
  127. 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,141 @@
1
+ // Regression tests for recoverPending recovery bugs:
2
+ // (a) an entry must survive when notifyFn resolves false (not delivered) and
3
+ // only be removed after a confirmed (truthy) ack.
4
+ // (b) a scoped recovery that matches purely on clientHostPid must NOT stamp
5
+ // the reconnecting filter session's id onto another session's abort — it
6
+ // delivers to the true owner session, and leaves the entry persisted when
7
+ // there is no owner session to target.
8
+ import test from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import {
14
+ addPending,
15
+ recoverPending,
16
+ } from '../src/runtime/agent/orchestrator/dispatch-persist.mjs';
17
+
18
+ const FILE = 'pending-dispatches.json';
19
+
20
+ function readMap(dir) {
21
+ try {
22
+ const raw = readFileSync(join(dir, FILE), 'utf8');
23
+ return raw.trim() ? JSON.parse(raw) : {};
24
+ } catch { return {}; }
25
+ }
26
+
27
+ async function waitFor(fn, { timeout = 3000, step = 20 } = {}) {
28
+ const deadline = Date.now() + timeout;
29
+ for (;;) {
30
+ if (fn()) return true;
31
+ if (Date.now() > deadline) return false;
32
+ await new Promise((r) => setTimeout(r, step));
33
+ }
34
+ }
35
+
36
+ function tmp() {
37
+ const dir = mkdtempSync(join(tmpdir(), 'dpersist-'));
38
+ return dir;
39
+ }
40
+
41
+ test('(a) only explicit false/0 keeps entry; undefined/void resolve deletes', async () => {
42
+ const dir = tmp();
43
+ try {
44
+ addPending(dir, 'h-a', 'recall', ['q'], 'sid-owner', 1111);
45
+ await waitFor(() => 'h-a' in readMap(dir));
46
+
47
+ // Explicit false → undelivered, entry MUST survive for retry.
48
+ recoverPending(dir, () => Promise.resolve(false), {});
49
+ await new Promise((r) => setTimeout(r, 200));
50
+ assert.ok('h-a' in readMap(dir), 'entry removed despite notifyFn=false');
51
+
52
+ // Explicit 0 → also undelivered, entry survives.
53
+ recoverPending(dir, () => Promise.resolve(0), {});
54
+ await new Promise((r) => setTimeout(r, 200));
55
+ assert.ok('h-a' in readMap(dir), 'entry removed despite notifyFn=0');
56
+
57
+ // undefined/void resolve from a delivered notifyFn → entry removed.
58
+ recoverPending(dir, () => Promise.resolve(undefined), {});
59
+ const gone = await waitFor(() => !('h-a' in readMap(dir)));
60
+ assert.ok(gone, 'entry not removed after undefined (delivered) resolve');
61
+ } finally {
62
+ rmSync(dir, { recursive: true, force: true });
63
+ }
64
+ });
65
+
66
+ test('(b) hostPid-only match delivers to true owner session, never the filter session', async () => {
67
+ const dir = tmp();
68
+ try {
69
+ addPending(dir, 'h-b', 'recall', ['q'], 'owner-A', 4242);
70
+ await waitFor(() => 'h-b' in readMap(dir));
71
+
72
+ let seen = null;
73
+ // Reconnect as a DIFFERENT session that only shares the host pid.
74
+ recoverPending(dir, (content, meta) => { seen = meta; return Promise.resolve(true); }, {
75
+ sessionId: 'session-B',
76
+ clientHostPid: 4242,
77
+ });
78
+ await waitFor(() => seen != null);
79
+ assert.equal(seen.caller_session_id, 'owner-A', 'stamped filter session instead of true owner');
80
+ assert.notEqual(seen.caller_session_id, 'session-B');
81
+ } finally {
82
+ rmSync(dir, { recursive: true, force: true });
83
+ }
84
+ });
85
+
86
+ test('(b) hostPid-only match with no owner session is left persisted, not injected', async () => {
87
+ const dir = tmp();
88
+ try {
89
+ addPending(dir, 'h-c', 'recall', ['q'], null, 5353);
90
+ await waitFor(() => 'h-c' in readMap(dir));
91
+
92
+ let called = false;
93
+ recoverPending(dir, () => { called = true; return Promise.resolve(true); }, {
94
+ sessionId: 'session-C',
95
+ clientHostPid: 5353,
96
+ });
97
+ await new Promise((r) => setTimeout(r, 250));
98
+ assert.equal(called, false, 'notifyFn fired for an entry with no owner session');
99
+ assert.ok('h-c' in readMap(dir), 'entry with no owner session was not left persisted');
100
+ } finally {
101
+ rmSync(dir, { recursive: true, force: true });
102
+ }
103
+ });
104
+
105
+ test('owner-session match still stamps the reconnecting session id', async () => {
106
+ const dir = tmp();
107
+ try {
108
+ addPending(dir, 'h-d', 'recall', ['q'], 'prior-sid', 6464);
109
+ await waitFor(() => 'h-d' in readMap(dir));
110
+
111
+ let seen = null;
112
+ recoverPending(dir, (content, meta) => { seen = meta; return Promise.resolve(true); }, {
113
+ sessionId: 'new-sid',
114
+ priorSessionId: 'prior-sid',
115
+ clientHostPid: 6464,
116
+ });
117
+ await waitFor(() => seen != null);
118
+ assert.equal(seen.caller_session_id, 'new-sid', 'owner match should stamp reconnecting session id');
119
+ } finally {
120
+ rmSync(dir, { recursive: true, force: true });
121
+ }
122
+ });
123
+
124
+ test('priorSessionId match with no current sessionId keeps the entry owner cid', async () => {
125
+ const dir = tmp();
126
+ try {
127
+ addPending(dir, 'h-e', 'recall', ['q'], 'prior-sid', 7575);
128
+ await waitFor(() => 'h-e' in readMap(dir));
129
+
130
+ let seen = null;
131
+ // priorSessionId matches the owner but no current sessionId is supplied →
132
+ // filterSid is null; must fall back to the entry's known owner cid.
133
+ recoverPending(dir, (content, meta) => { seen = meta; return Promise.resolve(true); }, {
134
+ priorSessionId: 'prior-sid',
135
+ });
136
+ await waitFor(() => seen != null);
137
+ assert.equal(seen.caller_session_id, 'prior-sid', 'dropped known owner cid when sessionId absent');
138
+ } finally {
139
+ rmSync(dir, { recursive: true, force: true });
140
+ }
141
+ });
@@ -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 = {}) => ({ text, mode: opts.mode, priority: opts.priority }),
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
+ });