mixdog 0.9.38 → 0.9.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) 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/execution-completion-dedup-test.mjs +157 -0
  8. package/scripts/execution-pending-resume-kick-test.mjs +57 -2
  9. package/scripts/execution-resume-esc-integration-test.mjs +176 -0
  10. package/scripts/explore-bench.mjs +38 -2
  11. package/scripts/explore-prompt-policy-test.mjs +152 -11
  12. package/scripts/find-fuzzy-hidden-test.mjs +122 -0
  13. package/scripts/internal-comms-bench-test.mjs +226 -0
  14. package/scripts/internal-comms-bench.mjs +185 -58
  15. package/scripts/internal-comms-smoke.mjs +171 -23
  16. package/scripts/live-worker-smoke.mjs +38 -2
  17. package/scripts/memory-cycle-routing-test.mjs +111 -0
  18. package/scripts/memory-rule-contract-test.mjs +93 -0
  19. package/scripts/output-style-smoke.mjs +2 -2
  20. package/scripts/rg-runner-test.mjs +240 -0
  21. package/scripts/routing-corpus-test.mjs +349 -0
  22. package/scripts/routing-corpus.mjs +211 -32
  23. package/scripts/session-orphan-sweep-test.mjs +83 -0
  24. package/scripts/steering-drain-buckets-test.mjs +316 -2
  25. package/scripts/tool-smoke.mjs +21 -13
  26. package/scripts/tool-tui-presentation-test.mjs +202 -0
  27. package/scripts/tui-transcript-perf-test.mjs +279 -0
  28. package/src/agents/heavy-worker/AGENT.md +10 -7
  29. package/src/agents/reviewer/AGENT.md +6 -4
  30. package/src/agents/worker/AGENT.md +7 -5
  31. package/src/rules/agent/00-common.md +4 -4
  32. package/src/rules/agent/00-core.md +11 -14
  33. package/src/rules/agent/20-skip-protocol.md +3 -3
  34. package/src/rules/agent/30-explorer.md +50 -60
  35. package/src/rules/agent/40-cycle1-agent.md +15 -24
  36. package/src/rules/agent/41-cycle2-agent.md +33 -57
  37. package/src/rules/agent/42-cycle3-agent.md +28 -42
  38. package/src/rules/lead/01-general.md +7 -10
  39. package/src/rules/lead/lead-brief.md +11 -14
  40. package/src/rules/lead/lead-tool.md +6 -5
  41. package/src/rules/shared/01-tool.md +44 -45
  42. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +44 -1
  43. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +20 -5
  44. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +21 -1
  45. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
  46. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +17 -8
  47. package/src/runtime/agent/orchestrator/session/context-utils.mjs +270 -78
  48. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +2 -1
  49. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -0
  50. package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +123 -0
  51. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +15 -2
  52. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +67 -2
  53. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
  54. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
  55. package/src/runtime/agent/orchestrator/session/store.mjs +232 -43
  56. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +43 -1
  57. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
  58. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +27 -13
  59. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +25 -20
  60. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +6 -2
  61. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +118 -53
  62. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +106 -29
  63. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +8 -1
  64. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +4 -2
  65. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +3 -1
  66. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +4 -1
  67. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +33 -2
  68. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +151 -64
  69. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +37 -13
  70. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +84 -49
  71. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +172 -23
  72. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +3 -0
  73. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +68 -5
  74. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +17 -3
  75. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +24 -10
  76. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +6 -3
  77. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -7
  78. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +1 -0
  79. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  80. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -4
  81. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +4 -3
  82. package/src/runtime/memory/lib/memory-cycle3.mjs +12 -2
  83. package/src/runtime/shared/buffered-appender.mjs +13 -2
  84. package/src/runtime/shared/tool-primitives.mjs +4 -1
  85. package/src/runtime/shared/tool-status.mjs +27 -0
  86. package/src/runtime/shared/tool-surface.mjs +6 -3
  87. package/src/session-runtime/config-helpers.mjs +14 -0
  88. package/src/session-runtime/context-status.mjs +1 -0
  89. package/src/session-runtime/effort.mjs +6 -2
  90. package/src/session-runtime/lifecycle-api.mjs +4 -0
  91. package/src/session-runtime/model-recency.mjs +5 -2
  92. package/src/session-runtime/runtime-core.mjs +36 -2
  93. package/src/session-runtime/session-turn-api.mjs +12 -0
  94. package/src/session-runtime/tool-catalog.mjs +34 -0
  95. package/src/standalone/agent-tool/notify.mjs +13 -0
  96. package/src/standalone/agent-tool.mjs +53 -70
  97. package/src/standalone/explore-tool.mjs +6 -7
  98. package/src/tui/App.jsx +33 -1
  99. package/src/tui/app/model-options.mjs +5 -3
  100. package/src/tui/app/model-picker.mjs +12 -24
  101. package/src/tui/app/transcript-window.mjs +10 -10
  102. package/src/tui/app/use-transcript-window.mjs +38 -56
  103. package/src/tui/components/ToolExecution.jsx +11 -6
  104. package/src/tui/components/TranscriptItem.jsx +1 -1
  105. package/src/tui/components/tool-execution/surface-detail.mjs +24 -10
  106. package/src/tui/components/tool-execution/text-format.mjs +10 -19
  107. package/src/tui/dist/index.mjs +866 -300
  108. package/src/tui/engine/agent-job-feed.mjs +216 -19
  109. package/src/tui/engine/agent-response-tail.mjs +68 -0
  110. package/src/tui/engine/notification-plan.mjs +16 -0
  111. package/src/tui/engine/session-api.mjs +14 -2
  112. package/src/tui/engine/session-flow.mjs +22 -2
  113. package/src/tui/engine/tool-card-results.mjs +64 -37
  114. package/src/tui/engine/tool-result-status.mjs +75 -21
  115. package/src/tui/engine/turn.mjs +172 -77
  116. package/src/tui/engine.mjs +199 -39
  117. package/src/tui/index.jsx +2 -2
  118. package/src/workflows/bench/WORKFLOW.md +25 -35
  119. package/src/workflows/default/WORKFLOW.md +38 -32
  120. package/src/workflows/solo/WORKFLOW.md +19 -22
  121. package/scripts/_jitter-fuzz.mjs +0 -44410
  122. package/scripts/_jitter-fuzz2.mjs +0 -44400
  123. package/scripts/_jitter-probe.mjs +0 -44397
  124. package/scripts/_jp2.mjs +0 -45614
@@ -21,9 +21,15 @@ import {
21
21
  import { toolErrorDisplay } from './tool-result-text.mjs';
22
22
  import {
23
23
  notificationQueueKey,
24
+ executionCardKey,
24
25
  resolveTuiRuntimeNotificationDelivery,
25
26
  } from './notification-plan.mjs';
27
+ import { shortTextFingerprint } from './queue-helpers.mjs';
26
28
  import { readImageAttachmentFromPath } from '../paste-attachments.mjs';
29
+ import {
30
+ isDeliveredCompletion,
31
+ recordDeliveredCompletion,
32
+ } from '../../runtime/agent/orchestrator/session/manager/delivered-completions.mjs';
27
33
 
28
34
  // Channel inbound images arrive as a JSON-array-of-paths meta value (stringified
29
35
  // across the notify IPC boundary). Parse defensively; a malformed value simply
@@ -48,13 +54,82 @@ export function createAgentJobFeed({
48
54
  enqueue,
49
55
  drain,
50
56
  pushUserOrSyntheticItem,
57
+ pushAsyncAgentResponse,
51
58
  makeQueueEntry,
52
59
  getPending,
53
60
  agentStatusState,
54
61
  displayedExecutionNotificationKeys,
62
+ itemIndexById,
55
63
  pushNotice,
64
+ now = () => Date.now(),
65
+ executionResumeTombstoneTtlMs = 30_000,
66
+ executionResumeTombstoneLimit = 128,
56
67
  }) {
68
+ const executionDedupLimit = 256;
57
69
  let executionResumeKickDeferred = false;
70
+ // Completion keys explicitly abandoned by Esc. Tombstones are per-feed
71
+ // (therefore per TUI session), short-lived, and bounded: they catch a late
72
+ // duplicate racing the abort without permanently reserving execution IDs or
73
+ // retaining completion bodies.
74
+ const discardedExecutionResumeKeys = new Map();
75
+ // Tracks whether an execution's visible response is only a bodyless preview
76
+ // or has reached its final body. A preview must not permanently suppress the
77
+ // later body, while repeated body retries remain idempotent.
78
+ const displayedExecutionResponseStates = new Map();
79
+ const terminalExecutionNotificationKeys = new Set();
80
+ const terminalExecutionResponseKeys = new Set();
81
+ const executionNotificationKeys = new Map();
82
+
83
+ const clearExecutionDedupState = () => {
84
+ displayedExecutionNotificationKeys.clear();
85
+ displayedExecutionResponseStates.clear();
86
+ terminalExecutionNotificationKeys.clear();
87
+ terminalExecutionResponseKeys.clear();
88
+ executionNotificationKeys.clear();
89
+ };
90
+ const rememberDisplayedExecutionNotificationKey = (key, terminal = false, executionId = '') => {
91
+ if (!key) return;
92
+ displayedExecutionNotificationKeys.delete(key);
93
+ if (executionId) executionNotificationKeys.set(key, executionId);
94
+ if (terminal) terminalExecutionNotificationKeys.add(key);
95
+ else terminalExecutionNotificationKeys.delete(key);
96
+ while (displayedExecutionNotificationKeys.size >= executionDedupLimit) {
97
+ const oldestTerminal = [...displayedExecutionNotificationKeys]
98
+ .find((candidate) => terminalExecutionNotificationKeys.has(candidate));
99
+ if (oldestTerminal == null) break;
100
+ displayedExecutionNotificationKeys.delete(oldestTerminal);
101
+ terminalExecutionNotificationKeys.delete(oldestTerminal);
102
+ executionNotificationKeys.delete(oldestTerminal);
103
+ }
104
+ displayedExecutionNotificationKeys.add(key);
105
+ };
106
+ const promoteExecutionNotificationKeys = (executionId) => {
107
+ if (!executionId) return;
108
+ for (const [key, keyExecutionId] of executionNotificationKeys) {
109
+ if (keyExecutionId !== executionId || !displayedExecutionNotificationKeys.has(key)) continue;
110
+ terminalExecutionNotificationKeys.add(key);
111
+ }
112
+ };
113
+ const promoteExecutionDedupState = (executionId) => {
114
+ if (!executionId) return;
115
+ promoteExecutionNotificationKeys(executionId);
116
+ const responseState = displayedExecutionResponseStates.get(executionId);
117
+ if (responseState) rememberDisplayedExecutionResponseState(executionId, responseState, true);
118
+ };
119
+ const rememberDisplayedExecutionResponseState = (key, value, terminal = false) => {
120
+ if (!key) return;
121
+ displayedExecutionResponseStates.delete(key);
122
+ if (terminal) terminalExecutionResponseKeys.add(key);
123
+ else terminalExecutionResponseKeys.delete(key);
124
+ while (displayedExecutionResponseStates.size >= executionDedupLimit) {
125
+ const oldestTerminal = [...displayedExecutionResponseStates.keys()]
126
+ .find((candidate) => terminalExecutionResponseKeys.has(candidate));
127
+ if (oldestTerminal == null) break;
128
+ displayedExecutionResponseStates.delete(oldestTerminal);
129
+ terminalExecutionResponseKeys.delete(oldestTerminal);
130
+ }
131
+ displayedExecutionResponseStates.set(key, value);
132
+ };
58
133
 
59
134
  // FIFO accumulation of model-visible bodies from completions that arrived
60
135
  // while busy (or while a pending-resume entry was already queued). A single
@@ -62,8 +137,49 @@ export function createAgentJobFeed({
62
137
  // the queue preserves every body and merges them into the resume turn.
63
138
  const executionResumeKickBodies = [];
64
139
 
65
- function kickExecutionPendingResume(body = '') {
66
- if (body) executionResumeKickBodies.push(body);
140
+ function executionResumeKey(body, completionKey = '') {
141
+ if (completionKey && typeof completionKey === 'object') {
142
+ completionKey = completionKey.executionId || completionKey.key || '';
143
+ }
144
+ const explicitKey = String(completionKey || '').trim();
145
+ if (explicitKey.startsWith('execution:') || explicitKey.startsWith('body:')) return explicitKey;
146
+ if (explicitKey) return `execution:${explicitKey}`;
147
+ const value = String(body || '').trim();
148
+ return value ? `body:${shortTextFingerprint(value)}` : '';
149
+ }
150
+
151
+ function pruneDiscardedExecutionResumeKeys() {
152
+ const nowMs = Number(now()) || Date.now();
153
+ for (const [key, expiresAt] of discardedExecutionResumeKeys) {
154
+ if (expiresAt <= nowMs) discardedExecutionResumeKeys.delete(key);
155
+ }
156
+ }
157
+
158
+ function isDiscardedExecutionResumeKey(key) {
159
+ if (!key) return false;
160
+ pruneDiscardedExecutionResumeKeys();
161
+ return discardedExecutionResumeKeys.has(key);
162
+ }
163
+
164
+ function rememberDiscardedExecutionResumeKey(key) {
165
+ if (!key) return;
166
+ pruneDiscardedExecutionResumeKeys();
167
+ const limit = Math.max(1, Number(executionResumeTombstoneLimit) || 128);
168
+ while (!discardedExecutionResumeKeys.has(key) && discardedExecutionResumeKeys.size >= limit) {
169
+ const oldest = discardedExecutionResumeKeys.keys().next().value;
170
+ if (oldest == null) break;
171
+ discardedExecutionResumeKeys.delete(oldest);
172
+ }
173
+ const ttlMs = Math.max(1, Number(executionResumeTombstoneTtlMs) || 30_000);
174
+ // Refresh insertion order so the bounded map evicts the oldest tombstone.
175
+ discardedExecutionResumeKeys.delete(key);
176
+ discardedExecutionResumeKeys.set(key, (Number(now()) || Date.now()) + ttlMs);
177
+ }
178
+
179
+ function kickExecutionPendingResume(body = '', completionKey = '') {
180
+ const key = executionResumeKey(body, completionKey);
181
+ if (body && isDiscardedExecutionResumeKey(key)) return;
182
+ if (body) executionResumeKickBodies.push({ body, key });
67
183
  if (getDisposed()) return;
68
184
  if (getState().busy) {
69
185
  executionResumeKickDeferred = true;
@@ -77,8 +193,15 @@ export function createAgentJobFeed({
77
193
  executionResumeKickDeferred = false;
78
194
  // Drain every accumulated body into ONE resume turn so no completion body
79
195
  // is lost when several deferred while busy.
80
- const resumeBody = executionResumeKickBodies.splice(0).filter(Boolean).join('\n\n');
81
- pending.push(makeQueueEntry(resumeBody, { mode: 'pending-resume', priority: 'next' }));
196
+ const resumeBodies = executionResumeKickBodies.splice(0);
197
+ const resumeBody = resumeBodies.map(({ body: value }) => value).filter(Boolean).join('\n\n');
198
+ const resumeCompletionKeys = resumeBodies.map(({ key }) => key).filter(Boolean);
199
+ pending.push(makeQueueEntry(resumeBody, {
200
+ mode: 'pending-resume',
201
+ priority: 'next',
202
+ abortDiscardOnAbort: true,
203
+ resumeCompletionKeys,
204
+ }));
82
205
  void drain();
83
206
  }
84
207
 
@@ -87,11 +210,25 @@ export function createAgentJobFeed({
87
210
  kickExecutionPendingResume();
88
211
  }
89
212
 
90
- function scheduleExecutionPendingResumeKick(body = '') {
213
+ function scheduleExecutionPendingResumeKick(body = '', completionKey = '') {
91
214
  // Carry the model-visible body directly into the pending-resume entry so
92
215
  // the resumed turn sends it, instead of relying on the session-pending
93
216
  // completion marker (dropped by askSession pre-drain).
94
- queueMicrotask(() => kickExecutionPendingResume(body));
217
+ queueMicrotask(() => kickExecutionPendingResume(body, completionKey));
218
+ }
219
+
220
+ function discardExecutionPendingResume(completionKeys = []) {
221
+ const keys = (Array.isArray(completionKeys) ? completionKeys : [completionKeys])
222
+ .map((key) => executionResumeKey('', key))
223
+ .filter(Boolean);
224
+ if (keys.length === 0) return;
225
+ for (const key of keys) rememberDiscardedExecutionResumeKey(key);
226
+ for (let i = executionResumeKickBodies.length - 1; i >= 0; i -= 1) {
227
+ if (isDiscardedExecutionResumeKey(executionResumeKickBodies[i].key)) {
228
+ executionResumeKickBodies.splice(i, 1);
229
+ }
230
+ }
231
+ executionResumeKickDeferred = executionResumeKickBodies.length > 0;
95
232
  }
96
233
 
97
234
  // Pure builder for the agent-job card patch. Split out so callers that are
@@ -101,7 +238,10 @@ export function createAgentJobFeed({
101
238
  // jitter into one visible item update.
102
239
  function buildAgentJobCardPatch(itemId, text, isError = false) {
103
240
  const parsed = parseAgentJob(text);
104
- const current = getState().items.find((it) => it.id === itemId);
241
+ const index = itemIndexById?.get(itemId);
242
+ const current = Number.isInteger(index) && getState().items[index]?.id === itemId
243
+ ? getState().items[index]
244
+ : null;
105
245
  const rawDisplayText = agentJobResultText(text, parsed) || String(text ?? '').trim();
106
246
  const displayText = isError ? toolErrorDisplay(rawDisplayText, 'agent') : rawDisplayText;
107
247
  return {
@@ -117,58 +257,110 @@ export function createAgentJobFeed({
117
257
  patchItem(itemId, buildAgentJobCardPatch(itemId, text, isError));
118
258
  }
119
259
 
260
+ function refreshAgentStatus(parsed) {
261
+ if (!parsed?.taskId) return;
262
+ const status = String(parsed.status || '').toLowerCase();
263
+ const terminal = /^(completed|complete|done|success|succeeded|ok|failed|error|timeout|killed|cancelled|canceled|denied)$/.test(status);
264
+ set(agentStatusState(terminal ? { force: true } : undefined));
265
+ }
266
+
120
267
  function subscribeRuntimeNotifications() {
121
268
  if (typeof runtime.onNotification !== 'function') return null;
122
- return runtime.onNotification((event) => {
269
+ const unsubscribe = runtime.onNotification((event) => {
123
270
  if (getDisposed()) return;
124
271
  const text = String(event?.content ?? event?.text ?? event ?? '').trim();
125
272
  if (!text) return;
126
273
  const parsed = parseAgentJob(text);
127
274
  const notificationKey = notificationQueueKey(event, text, parsed);
128
275
  const delivery = resolveTuiRuntimeNotificationDelivery(event, text);
276
+ const executionId = String(event?.meta?.execution_id || parsed?.taskId || '').trim();
277
+ const status = String(event?.meta?.status || parsed?.status || '').toLowerCase();
278
+ const terminalStatus = /^(completed|complete|done|success|succeeded|ok|failed|error|timeout|killed|cancelled|canceled|denied)$/.test(status);
279
+ if (terminalStatus) promoteExecutionDedupState(executionId);
129
280
  if (delivery.action === 'ignore') return;
130
281
  if (delivery.action === 'notice') {
131
282
  pushNotice?.(delivery.displayText, delivery.tone || 'info', { transcript: delivery.transcript === true });
132
283
  return true;
133
284
  }
134
285
  if (delivery.action === 'status-only') {
135
- if (parsed?.taskId) set(agentStatusState({ force: true }));
286
+ refreshAgentStatus(parsed);
136
287
  return true;
137
288
  }
138
289
  if (delivery.action === 'execution-ui') {
139
- const firstDelivery = !notificationKey || !displayedExecutionNotificationKeys.has(notificationKey);
140
- if (firstDelivery) {
141
- if (notificationKey) displayedExecutionNotificationKeys.add(notificationKey);
142
- pushUserOrSyntheticItem(delivery.displayText, nextId());
290
+ const cardKey = executionCardKey(event, text, parsed);
291
+ const firstDelivery = !cardKey || !displayedExecutionNotificationKeys.has(cardKey);
292
+ const hasBody = /\n\s*\n[\s\S]*\S/.test(text);
293
+ const isFailure = /^(failed|error|timeout|killed|cancelled|canceled|denied)$/.test(status);
294
+ const successfulPreview = !hasBody && !isFailure && /^(completed|complete|done|success|succeeded|ok)$/.test(status);
295
+ const terminal = terminalStatus;
296
+ const responseState = executionId ? displayedExecutionResponseStates.get(executionId) : '';
297
+ const bodyAlreadyDisplayed = responseState === 'body';
298
+ if (cardKey && terminal && displayedExecutionNotificationKeys.has(cardKey)) {
299
+ rememberDisplayedExecutionNotificationKey(cardKey, true, executionId);
300
+ }
301
+ if (terminal) promoteExecutionDedupState(executionId);
302
+ if (firstDelivery && !successfulPreview && !bodyAlreadyDisplayed) {
303
+ if (cardKey) rememberDisplayedExecutionNotificationKey(cardKey, terminal, executionId);
304
+ if (executionId) rememberDisplayedExecutionResponseState(executionId, hasBody ? 'body' : 'preview', terminal);
305
+ // Execution completions are inbound agent responses. The engine keeps
306
+ // their aggregation tail-safe (only an immediately-adjacent inbound
307
+ // response of the same preview/body phase can merge); the fallback
308
+ // preserves the standalone path for minimal/test harnesses.
309
+ (pushAsyncAgentResponse || pushUserOrSyntheticItem)(delivery.displayText, nextId(), 'injected', { responseKey: executionId });
143
310
  }
144
- if (parsed?.taskId) set(agentStatusState({ force: true }));
311
+ refreshAgentStatus(parsed);
145
312
  const resumeBody = String(delivery.modelContent || '').trim();
146
313
  if (resumeBody) {
147
- enqueue(resumeBody, {
314
+ const completionKey = executionResumeKey(resumeBody, executionId);
315
+ // Consolidated completion dedup keyed off execution_id (+ text hash):
316
+ // if this exact execution completion was already delivered — either by
317
+ // an earlier TUI enqueue here or by runtime-core's ack — do NOT enqueue
318
+ // a duplicate model-visible twin. A re-arriving completion while IDLE
319
+ // would otherwise re-enqueue and let post-turn drain() spawn a fresh
320
+ // turn. Still ack (modelVisibleDelivered) so runtime-core's
321
+ // mirror/fallback stays suppressed; the card first-delivery push and
322
+ // status refresh above already ran.
323
+ if (isDiscardedExecutionResumeKey(completionKey) || isDeliveredCompletion({ executionId, text: resumeBody })) {
324
+ if (event && typeof event === 'object') event.modelVisibleDelivered = true;
325
+ return true;
326
+ }
327
+ const enqueued = enqueue(resumeBody, {
148
328
  mode: 'task-notification',
149
329
  // Claude Code parity: live execution completions are queued as
150
330
  // task notifications so the active loop can attach them after the
151
331
  // next tool batch; no special pending-resume bypass.
152
332
  priority: 'next',
153
333
  key: notificationKey || undefined,
334
+ abortDiscardOnAbort: true,
335
+ resumeCompletionKeys: completionKey ? [completionKey] : [],
154
336
  displayText: delivery.displayText || text,
155
337
  // The immediate Response card was already pushed above
156
338
  // (pushUserOrSyntheticItem). Keep this queued twin model-visible
157
339
  // but suppress its drain-time transcript card to avoid a duplicate.
158
340
  suppressDisplay: true,
159
341
  });
342
+ // Self-sufficient TUI dedup: mark delivered right here (mark-once at
343
+ // delivery, keyed by execution_id) so a re-arriving completion is
344
+ // skipped above without depending solely on the runtime-core ack
345
+ // roundtrip — removes the split-brain asymmetry. Gated on a CONFIRMED
346
+ // enqueue: enqueue() returns false only when an identical-key
347
+ // task-notification twin is already queued (session-flow.mjs
348
+ // pendingNotificationKeys.has(key)), in which case the completion is
349
+ // already pending delivery and must not be double-recorded.
350
+ if (enqueued) recordDeliveredCompletion({ executionId, text: resumeBody });
160
351
  // EXPLICIT ack to the emitting runtime: the model-visible completion
161
352
  // body was injected into the active loop here, so notifyFnForSession
162
353
  // must NOT also mirror it into the pending queue (double injection).
163
354
  // A bare truthy return below is display/status handling only — this
164
- // flag is the sole model-visible-delivery signal.
355
+ // flag is the sole model-visible-delivery signal. Set for BOTH the
356
+ // real-enqueue and the enqueue===false (already-queued twin) cases:
357
+ // either way the completion is pending delivery on the TUI path, so
358
+ // runtime-core must still not mirror it.
165
359
  if (event && typeof event === 'object') event.modelVisibleDelivered = true;
166
360
  }
167
361
  return true;
168
362
  }
169
- if (parsed?.taskId) {
170
- set(agentStatusState({ force: true }));
171
- }
363
+ refreshAgentStatus(parsed);
172
364
  const modelContent = String(delivery.modelContent ?? delivery.displayText ?? text).trim();
173
365
  const imagePaths = parseInboundImagePaths(event?.meta?.image_paths);
174
366
  if (!modelContent && imagePaths.length === 0) return true;
@@ -206,14 +398,19 @@ export function createAgentJobFeed({
206
398
  enqueue(modelContent, enqueueOpts);
207
399
  return true;
208
400
  });
401
+ return () => {
402
+ try { unsubscribe?.(); } finally { clearExecutionDedupState(); }
403
+ };
209
404
  }
210
405
 
211
406
  return {
212
407
  kickExecutionPendingResume,
213
408
  flushDeferredExecutionPendingResumeKick,
214
409
  scheduleExecutionPendingResumeKick,
410
+ discardExecutionPendingResume,
215
411
  updateAgentJobCard,
216
412
  buildAgentJobCardPatch,
217
413
  subscribeRuntimeNotifications,
414
+ clearExecutionDedupState,
218
415
  };
219
416
  }
@@ -0,0 +1,68 @@
1
+ // Pure, tail-only inbound-agent response aggregation. The caller decides
2
+ // whether the transcript tail is eligible; this helper never searches older
3
+ // rows, so a retry cannot mutate across a real transcript boundary.
4
+ function responseEntry(response = {}) {
5
+ return {
6
+ key: String(response.key || ''),
7
+ raw: String(response.rawResult ?? response.raw ?? '').trim(),
8
+ result: response.result,
9
+ hasBody: response.hasBody === true,
10
+ isError: response.isError === true,
11
+ };
12
+ }
13
+
14
+ export function formatAgentResponseRaw(entries = []) {
15
+ return entries
16
+ .map((entry, index) => {
17
+ const raw = String(entry?.raw || '').trim();
18
+ return raw ? `${index + 1}. agent\n${raw}` : '';
19
+ })
20
+ .filter(Boolean)
21
+ .join('\n\n');
22
+ }
23
+
24
+ function priorEntries(previous) {
25
+ if (Array.isArray(previous?.agentResponseEntries) && previous.agentResponseEntries.length) {
26
+ return previous.agentResponseEntries.map(responseEntry);
27
+ }
28
+ return [responseEntry({
29
+ key: previous?.agentResponseKey,
30
+ rawResult: previous?.rawResult ?? previous?.result,
31
+ result: previous?.result,
32
+ hasBody: previous?.agentResponseHasBody,
33
+ isError: previous?.isError,
34
+ })];
35
+ }
36
+
37
+ export function appendAgentResponseTail(previous, response, now = Date.now()) {
38
+ if (previous?.kind !== 'tool' || previous.agentDirection !== 'inbound') return null;
39
+ const next = responseEntry(response);
40
+ const entries = priorEntries(previous);
41
+ const existingIndex = next.key ? entries.findIndex((entry) => entry.key === next.key) : -1;
42
+
43
+ if (existingIndex >= 0) {
44
+ // A preview/final/retry for the same execution is one response. Replace
45
+ // that entry in-place only because this aggregate is still the transcript
46
+ // tail; never turn it into another count or a nested raw block.
47
+ entries[existingIndex] = next;
48
+ } else {
49
+ // Distinct responses can merge only when their presentation phase matches.
50
+ // A visible bodyless failure stays a separate row from a later full body.
51
+ if (previous.agentResponseHasBody !== next.hasBody) return null;
52
+ entries.push(next);
53
+ }
54
+
55
+ return {
56
+ args: response.args,
57
+ result: response.result,
58
+ rawResult: formatAgentResponseRaw(entries) || null,
59
+ isError: entries.some((entry) => entry.isError),
60
+ count: entries.length,
61
+ completedCount: entries.length,
62
+ completedAt: now,
63
+ agentResponseEntries: entries,
64
+ agentResponseKeys: entries.map((entry) => entry.key).filter(Boolean),
65
+ agentResponseHasBody: entries.every((entry) => entry.hasBody),
66
+ agentResponseAggregate: entries.length > 1,
67
+ };
68
+ }
@@ -47,6 +47,22 @@ export function notificationQueueKey(event, text, parsed) {
47
47
  return [id, type || fallbackKind, status, hasBody].filter(Boolean).join(':');
48
48
  }
49
49
 
50
+ /**
51
+ * Stable dedup key for the execution transcript CARD. Unlike notificationQueueKey
52
+ * it drops the volatile type/status dimensions so a duplicate completion re-arriving
53
+ * with a different type/status (same execution_id) cannot push a second card. The
54
+ * hasBody dimension is preserved so a bodyless preview (b0) and the real result (b1)
55
+ * remain distinct cards (intentional preview→result upgrade). Falls back to
56
+ * notificationQueueKey when no execution_id is present.
57
+ */
58
+ export function executionCardKey(event, text, parsed) {
59
+ const meta = event?.meta && typeof event.meta === 'object' ? event.meta : {};
60
+ const executionId = String(meta.execution_id || '').trim();
61
+ if (!executionId) return notificationQueueKey(event, text, parsed);
62
+ const hasBody = /\n\s*\n[\s\S]*\S/.test(String(text || '')) ? 'b1' : 'b0';
63
+ return `card:${executionId}:${hasBody}`;
64
+ }
65
+
50
66
  export function isExecutionNotification(event, text, parsed) {
51
67
  const meta = event?.meta && typeof event.meta === 'object' ? event.meta : {};
52
68
  if (meta.execution_id || meta.execution_surface) return true;
@@ -24,7 +24,7 @@ export function createEngineApi(bag) {
24
24
 
25
25
  export function createEngineApiA(bag) {
26
26
  const {
27
- runtime, nextId, flags, pending, listeners, getState, set, pushItem, patchItem, replaceItems, pushNotice, autoClearState, agentStatusState, routeState, syncContextStats, denyAllToolApprovals, updateAgentJobCard, requeueEntriesFront, enqueue, autoClearBeforeSubmit, restoreQueued, resetStatsAndSyncContext, drain, flushDeferredExecutionPendingResumeKick,
27
+ runtime, nextId, flags, pending, listeners, getState, set, pushItem, patchItem, replaceItems, settleStreamingTail, clearStreamingTail, pushNotice, autoClearState, agentStatusState, routeState, syncContextStats, denyAllToolApprovals, updateAgentJobCard, requeueEntriesFront, enqueue, autoClearBeforeSubmit, restoreQueued, resetStatsAndSyncContext, drain, flushDeferredExecutionPendingResumeKick, discardExecutionPendingResume,
28
28
  } = bag;
29
29
  return {
30
30
  getState: () => getState(),
@@ -573,10 +573,15 @@ export function createEngineApiA(bag) {
573
573
  ? restoreState.pastedTexts
574
574
  : null;
575
575
  const requeueEntries = restoreState && !restoreState.committed && Array.isArray(restoreState.requeueEntries)
576
- ? restoreState.requeueEntries.slice()
576
+ ? restoreState.requeueEntries.filter(
577
+ (entry) => entry?.abortDiscardOnAbort !== true && entry?.mode !== 'pending-resume',
578
+ )
577
579
  : [];
578
580
  const aborted = runtime.abort('cli-react-abort');
579
581
  if (restoreState) {
582
+ if (aborted !== false && Array.isArray(restoreState.discardExecutionPendingResumeKeys)) {
583
+ discardExecutionPendingResume?.(restoreState.discardExecutionPendingResumeKeys);
584
+ }
580
585
  if ((restoreText || requeueEntries.length > 0) && aborted !== false) {
581
586
  restoreState.reclaimed = true;
582
587
  const idSet = new Set((restoreState.submittedIds || []).filter((id) => id != null));
@@ -592,6 +597,7 @@ export function createEngineApiA(bag) {
592
597
  }
593
598
  restoreState.restorable = false;
594
599
  restoreState.requeueEntries = [];
600
+ restoreState.discardExecutionPendingResumeKeys = [];
595
601
  }
596
602
  // ── Bounded manual-abort recovery ───────────────────────────────────
597
603
  // runtime.abort() above normally rejects the in-flight runtime.ask() so
@@ -611,6 +617,12 @@ export function createEngineApiA(bag) {
611
617
  if (flags.disposed) return;
612
618
  if (!getState().busy) return; // normal abort settled
613
619
  if (flags.leadTurnEpoch !== abortEpoch) return; // a newer turn owns the store
620
+ const streamingTail = getState().streamingTail;
621
+ if (streamingTail?.text?.trim()) {
622
+ settleStreamingTail?.(streamingTail.id, {});
623
+ } else {
624
+ clearStreamingTail?.();
625
+ }
614
626
  // Bump the epoch FIRST so the still-stuck turn's eventual finally becomes
615
627
  // a no-op for shared getState() writes and cannot corrupt the handoff.
616
628
  flags.leadTurnEpoch = (Number(flags.leadTurnEpoch) || 0) + 1;
@@ -8,7 +8,7 @@ import { appendTuiSteeringPersist, dropTuiSteeringPersist, drainTuiSteeringPersi
8
8
 
9
9
  export function createSessionFlow(bag) {
10
10
  const {
11
- runtime, nextId, tuiDebug, flags, pending, pendingNotificationKeys, displayedExecutionNotificationKeys, getState, set, pushItem, replaceItems, pushNotice, pushUserOrSyntheticItem, autoClearState, agentStatusState, routeState, syncContextStats, flushDeferredExecutionPendingResumeKick,
11
+ runtime, nextId, tuiDebug, flags, pending, pendingNotificationKeys, displayedExecutionNotificationKeys, clearExecutionDedupState, getState, set, pushItem, replaceItems, pushNotice, pushUserOrSyntheticItem, autoClearState, agentStatusState, routeState, syncContextStats, flushDeferredExecutionPendingResumeKick,
12
12
  } = bag;
13
13
 
14
14
  // Upper bound on the awaited compacting clear. requireCompactSuccess makes
@@ -54,6 +54,12 @@ export function createSessionFlow(bag) {
54
54
  skipSlashCommands: options.skipSlashCommands === true,
55
55
  displayText: mode === 'task-notification' ? notificationDisplayText(displayText) : String(displayText || ''),
56
56
  suppressDisplay: options.suppressDisplay === true,
57
+ // Completion resumes are consumed exactly once: Esc abandons their
58
+ // uncommitted body instead of putting it back at the queue front.
59
+ abortDiscardOnAbort: options.abortDiscardOnAbort === true,
60
+ resumeCompletionKeys: Array.isArray(options.resumeCompletionKeys)
61
+ ? options.resumeCompletionKeys.filter((key) => key != null && String(key).trim())
62
+ : [],
57
63
  steeringPersistId: options.steeringPersistId || null,
58
64
  steeringPersistRestored: options.steeringPersistRestored === true,
59
65
  };
@@ -110,6 +116,7 @@ export function createSessionFlow(bag) {
110
116
  if (predicate(entry) && (entry.mode || 'prompt') === targetMode && queuePriorityValue(entry.priority) === bestPriority) {
111
117
  batch.push(entry);
112
118
  pending.splice(i, 1);
119
+ if (entry.mode === 'task-notification' && entry.key) pendingNotificationKeys.delete(entry.key);
113
120
  if (batch.length >= limit) break;
114
121
  } else {
115
122
  i += 1;
@@ -194,6 +201,17 @@ export function createSessionFlow(bag) {
194
201
  pushUserOrSyntheticItem(entry.text, entry.id, isQueuedEntryEditable(entry) ? 'user' : 'injected');
195
202
  }
196
203
  const nonEditable = batch.filter((entry) => !isQueuedEntryEditable(entry));
204
+ // A completion resume is owned by the completion that woke it. Esc
205
+ // consumes that ownership; unlike ordinary notifications it must never
206
+ // be requeued from an uncommitted turn. Keep normal task notifications
207
+ // recoverable exactly as before.
208
+ const discardOnAbort = nonEditable.filter(
209
+ (entry) => entry?.abortDiscardOnAbort === true || entry?.mode === 'pending-resume',
210
+ );
211
+ const requeueOnAbort = nonEditable.filter((entry) => !discardOnAbort.includes(entry));
212
+ const discardExecutionPendingResumeKeys = discardOnAbort.flatMap(
213
+ (entry) => Array.isArray(entry?.resumeCompletionKeys) ? entry.resumeCompletionKeys : [],
214
+ );
197
215
  const batchPastedImages = mergePastedImages(batch);
198
216
  const batchPastedTexts = mergePastedTexts(batch);
199
217
  const turnStatus = await bag.runTurn(merged, {
@@ -203,7 +221,8 @@ export function createSessionFlow(bag) {
203
221
  onCommitted: () => commitSteeringQueueEntries(batch),
204
222
  submittedIds: [...ids],
205
223
  restorable: nonEditable.length === 0,
206
- requeueOnAbort: nonEditable,
224
+ requeueOnAbort,
225
+ discardExecutionPendingResumeKeys,
207
226
  });
208
227
  if (flags.drainEpoch !== drainEpoch) return;
209
228
  // A deferred cleared-session UI sync (from a late-settling abandoned
@@ -480,6 +499,7 @@ export function createSessionFlow(bag) {
480
499
  getState().busy = false;
481
500
  pendingNotificationKeys.clear();
482
501
  displayedExecutionNotificationKeys.clear();
502
+ clearExecutionDedupState?.();
483
503
  };
484
504
  // Post-success UI sync shared by the normal clear path and a late-fulfilling
485
505
  // abandoned compacting clear, so the UI always matches the cleared runtime