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.
- 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 +176 -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 +316 -2
- package/scripts/tool-smoke.mjs +21 -13
- package/scripts/tool-tui-presentation-test.mjs +202 -0
- package/scripts/tui-transcript-perf-test.mjs +279 -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 +44 -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/compaction-runner.mjs +15 -0
- 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 +67 -2
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +232 -43
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +43 -1
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +27 -13
- 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/buffered-appender.mjs +13 -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/lifecycle-api.mjs +4 -0
- package/src/session-runtime/model-recency.mjs +5 -2
- package/src/session-runtime/runtime-core.mjs +36 -2
- package/src/session-runtime/session-turn-api.mjs +12 -0
- package/src/session-runtime/tool-catalog.mjs +34 -0
- package/src/standalone/agent-tool/notify.mjs +13 -0
- package/src/standalone/agent-tool.mjs +53 -70
- package/src/standalone/explore-tool.mjs +6 -7
- package/src/tui/App.jsx +33 -1
- 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 +10 -10
- package/src/tui/app/use-transcript-window.mjs +38 -56
- 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 +866 -300
- package/src/tui/engine/agent-job-feed.mjs +216 -19
- 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 +14 -2
- package/src/tui/engine/session-flow.mjs +22 -2
- package/src/tui/engine/tool-card-results.mjs +64 -37
- package/src/tui/engine/tool-result-status.mjs +75 -21
- package/src/tui/engine/turn.mjs +172 -77
- package/src/tui/engine.mjs +199 -39
- package/src/tui/index.jsx +2 -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
|
@@ -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
|
|
66
|
-
if (
|
|
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
|
|
81
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
286
|
+
refreshAgentStatus(parsed);
|
|
136
287
|
return true;
|
|
137
288
|
}
|
|
138
289
|
if (delivery.action === 'execution-ui') {
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
311
|
+
refreshAgentStatus(parsed);
|
|
145
312
|
const resumeBody = String(delivery.modelContent || '').trim();
|
|
146
313
|
if (resumeBody) {
|
|
147
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|