mixdog 0.9.39 → 0.9.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/package.json +1 -1
  2. package/scripts/agent-terminal-reap-test.mjs +6 -6
  3. package/scripts/execution-resume-esc-integration-test.mjs +4 -2
  4. package/scripts/steering-drain-buckets-test.mjs +140 -5
  5. package/scripts/tui-transcript-perf-test.mjs +279 -0
  6. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +7 -0
  7. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -0
  8. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +26 -0
  9. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
  10. package/src/runtime/agent/orchestrator/session/store.mjs +9 -1
  11. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +43 -1
  12. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +27 -13
  13. package/src/runtime/shared/buffered-appender.mjs +13 -2
  14. package/src/session-runtime/config-helpers.mjs +6 -6
  15. package/src/session-runtime/lifecycle-api.mjs +4 -0
  16. package/src/session-runtime/runtime-core.mjs +1 -0
  17. package/src/session-runtime/session-turn-api.mjs +12 -0
  18. package/src/standalone/agent-tool.mjs +8 -1
  19. package/src/tui/App.jsx +2 -1
  20. package/src/tui/app/transcript-window.mjs +9 -10
  21. package/src/tui/app/use-transcript-window.mjs +38 -56
  22. package/src/tui/dist/index.mjs +354 -163
  23. package/src/tui/engine/agent-job-feed.mjs +76 -6
  24. package/src/tui/engine/session-api.mjs +7 -1
  25. package/src/tui/engine/session-flow.mjs +3 -1
  26. package/src/tui/engine/tool-card-results.mjs +10 -5
  27. package/src/tui/engine/turn.mjs +96 -36
  28. package/src/tui/engine.mjs +136 -37
  29. package/src/tui/index.jsx +2 -2
@@ -59,11 +59,13 @@ export function createAgentJobFeed({
59
59
  getPending,
60
60
  agentStatusState,
61
61
  displayedExecutionNotificationKeys,
62
+ itemIndexById,
62
63
  pushNotice,
63
64
  now = () => Date.now(),
64
65
  executionResumeTombstoneTtlMs = 30_000,
65
66
  executionResumeTombstoneLimit = 128,
66
67
  }) {
68
+ const executionDedupLimit = 256;
67
69
  let executionResumeKickDeferred = false;
68
70
  // Completion keys explicitly abandoned by Esc. Tombstones are per-feed
69
71
  // (therefore per TUI session), short-lived, and bounded: they catch a late
@@ -74,6 +76,60 @@ export function createAgentJobFeed({
74
76
  // or has reached its final body. A preview must not permanently suppress the
75
77
  // later body, while repeated body retries remain idempotent.
76
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
+ };
77
133
 
78
134
  // FIFO accumulation of model-visible bodies from completions that arrived
79
135
  // while busy (or while a pending-resume entry was already queued). A single
@@ -182,7 +238,10 @@ export function createAgentJobFeed({
182
238
  // jitter into one visible item update.
183
239
  function buildAgentJobCardPatch(itemId, text, isError = false) {
184
240
  const parsed = parseAgentJob(text);
185
- 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;
186
245
  const rawDisplayText = agentJobResultText(text, parsed) || String(text ?? '').trim();
187
246
  const displayText = isError ? toolErrorDisplay(rawDisplayText, 'agent') : rawDisplayText;
188
247
  return {
@@ -207,13 +266,17 @@ export function createAgentJobFeed({
207
266
 
208
267
  function subscribeRuntimeNotifications() {
209
268
  if (typeof runtime.onNotification !== 'function') return null;
210
- return runtime.onNotification((event) => {
269
+ const unsubscribe = runtime.onNotification((event) => {
211
270
  if (getDisposed()) return;
212
271
  const text = String(event?.content ?? event?.text ?? event ?? '').trim();
213
272
  if (!text) return;
214
273
  const parsed = parseAgentJob(text);
215
274
  const notificationKey = notificationQueueKey(event, text, parsed);
216
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);
217
280
  if (delivery.action === 'ignore') return;
218
281
  if (delivery.action === 'notice') {
219
282
  pushNotice?.(delivery.displayText, delivery.tone || 'info', { transcript: delivery.transcript === true });
@@ -226,16 +289,19 @@ export function createAgentJobFeed({
226
289
  if (delivery.action === 'execution-ui') {
227
290
  const cardKey = executionCardKey(event, text, parsed);
228
291
  const firstDelivery = !cardKey || !displayedExecutionNotificationKeys.has(cardKey);
229
- const executionId = String(event?.meta?.execution_id || parsed?.taskId || '').trim();
230
- const status = String(event?.meta?.status || parsed?.status || '').toLowerCase();
231
292
  const hasBody = /\n\s*\n[\s\S]*\S/.test(text);
232
293
  const isFailure = /^(failed|error|timeout|killed|cancelled|canceled|denied)$/.test(status);
233
294
  const successfulPreview = !hasBody && !isFailure && /^(completed|complete|done|success|succeeded|ok)$/.test(status);
295
+ const terminal = terminalStatus;
234
296
  const responseState = executionId ? displayedExecutionResponseStates.get(executionId) : '';
235
297
  const bodyAlreadyDisplayed = responseState === 'body';
298
+ if (cardKey && terminal && displayedExecutionNotificationKeys.has(cardKey)) {
299
+ rememberDisplayedExecutionNotificationKey(cardKey, true, executionId);
300
+ }
301
+ if (terminal) promoteExecutionDedupState(executionId);
236
302
  if (firstDelivery && !successfulPreview && !bodyAlreadyDisplayed) {
237
- if (cardKey) displayedExecutionNotificationKeys.add(cardKey);
238
- if (executionId) displayedExecutionResponseStates.set(executionId, hasBody ? 'body' : 'preview');
303
+ if (cardKey) rememberDisplayedExecutionNotificationKey(cardKey, terminal, executionId);
304
+ if (executionId) rememberDisplayedExecutionResponseState(executionId, hasBody ? 'body' : 'preview', terminal);
239
305
  // Execution completions are inbound agent responses. The engine keeps
240
306
  // their aggregation tail-safe (only an immediately-adjacent inbound
241
307
  // response of the same preview/body phase can merge); the fallback
@@ -332,6 +398,9 @@ export function createAgentJobFeed({
332
398
  enqueue(modelContent, enqueueOpts);
333
399
  return true;
334
400
  });
401
+ return () => {
402
+ try { unsubscribe?.(); } finally { clearExecutionDedupState(); }
403
+ };
335
404
  }
336
405
 
337
406
  return {
@@ -342,5 +411,6 @@ export function createAgentJobFeed({
342
411
  updateAgentJobCard,
343
412
  buildAgentJobCardPatch,
344
413
  subscribeRuntimeNotifications,
414
+ clearExecutionDedupState,
345
415
  };
346
416
  }
@@ -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, discardExecutionPendingResume,
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(),
@@ -617,6 +617,12 @@ export function createEngineApiA(bag) {
617
617
  if (flags.disposed) return;
618
618
  if (!getState().busy) return; // normal abort settled
619
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
+ }
620
626
  // Bump the epoch FIRST so the still-stuck turn's eventual finally becomes
621
627
  // a no-op for shared getState() writes and cannot corrupt the handoff.
622
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
@@ -116,6 +116,7 @@ export function createSessionFlow(bag) {
116
116
  if (predicate(entry) && (entry.mode || 'prompt') === targetMode && queuePriorityValue(entry.priority) === bestPriority) {
117
117
  batch.push(entry);
118
118
  pending.splice(i, 1);
119
+ if (entry.mode === 'task-notification' && entry.key) pendingNotificationKeys.delete(entry.key);
119
120
  if (batch.length >= limit) break;
120
121
  } else {
121
122
  i += 1;
@@ -498,6 +499,7 @@ export function createSessionFlow(bag) {
498
499
  getState().busy = false;
499
500
  pendingNotificationKeys.clear();
500
501
  displayedExecutionNotificationKeys.clear();
502
+ clearExecutionDedupState?.();
501
503
  };
502
504
  // Post-success UI sync shared by the normal clear path and a late-fulfilling
503
505
  // abandoned compacting clear, so the UI always matches the cleared runtime
@@ -34,7 +34,12 @@ export function createToolCardResults({
34
34
  updateAgentJobCard,
35
35
  buildAgentJobCardPatch,
36
36
  agentStatusState,
37
+ itemIndexById,
37
38
  }) {
39
+ const itemById = (id) => {
40
+ const index = itemIndexById?.get(id);
41
+ return Number.isInteger(index) ? getState().items[index]?.id === id ? getState().items[index] : null : null;
42
+ };
38
43
  // A finalized/failed non-aggregate card must never carry an empty body:
39
44
  // an empty-body error card is classified fully-failed-with-no-body upstream
40
45
  // (transcript-tool-failures) and null-renders (card disappears). Stamp a
@@ -47,10 +52,10 @@ export function createToolCardResults({
47
52
  return 'Failed';
48
53
  }
49
54
  function patchToolItem(id, patch) {
50
- const prev = getState().items.find((it) => it.id === id);
55
+ const prev = itemById(id);
51
56
  const ok = patchItem(id, patch);
52
57
  if (!ok || !prev) return ok;
53
- const next = getState().items.find((it) => it.id === id);
58
+ const next = itemById(id);
54
59
  if (next && next !== prev) carryTranscriptMeasuredRowsCache(prev, next);
55
60
  return ok;
56
61
  }
@@ -107,7 +112,7 @@ export function createToolCardResults({
107
112
  const detailText = errors > 0 || exitErrors > 0
108
113
  ? failureDetailText({ succeeded, realErrors: callErrors, exitErrors, exitCode: allCalls.find((r) => r.isExitError)?.exitCode })
109
114
  : formatAggregateDetail(aggregateSummaries(aggregate));
110
- const currentItem = getState().items.find((it) => it.id === card.itemId);
115
+ const currentItem = itemById(card.itemId);
111
116
  const earlyCompleted = allCalls.filter((r) => r.resolved || r.completedEarly).length;
112
117
  const visualCompleted = Math.max(completed, earlyCompleted, Math.min(allCalls.length, Number(currentItem?.completedCount || 0)));
113
118
  const rawResult = aggregateRawResult(allCalls);
@@ -251,7 +256,7 @@ export function createToolCardResults({
251
256
  // Cancelled aggregates MUST keep the [status: cancelled] marker on the
252
257
  // result so terminalStatus parsing resolves to 'cancelled'. Only normal
253
258
  // completions drop the summary; cancelled ones prepend the marker.
254
- const currentItem = getState().items.find((it) => it.id === card.itemId);
259
+ const currentItem = itemById(card.itemId);
255
260
  displayDetail = withCancelledResultMarker(displayDetail, currentItem);
256
261
  }
257
262
  patchToolItem(card.itemId, {
@@ -284,7 +289,7 @@ export function createToolCardResults({
284
289
  resultText = finalizedErrorFallbackBody(resultText, exitRec?.text, exitRec?.exitCode);
285
290
  }
286
291
  if (cancelled) {
287
- const currentItem = getState().items.find((it) => it.id === card.itemId);
292
+ const currentItem = itemById(card.itemId);
288
293
  resultText = withCancelledResultMarker(resultText, currentItem);
289
294
  }
290
295
  patchToolItem(card.itemId, { result: resultText, text: resultText, isError: group.errors > 0, errorCount: group.errors, callErrorCount: group.callErrors || 0, exitErrorCount: group.exitErrors || 0, count: group.count, completedCount: group.completed, completedAt: Date.now() });
@@ -12,8 +12,25 @@ import { aggregateRawResult, aggregateBucketForCategory, aggregateSummaries, ass
12
12
 
13
13
  export function createRunTurn(bag) {
14
14
  const {
15
- runtime, nextId, tuiDebug, LEAD_TURN_TIMEOUT_MS, flags, pending, itemIndexById, getState, set, pushItem, patchItem, pushNotice, pushUserOrSyntheticItem, markToolCallActive, markToolCallDone, clearActiveToolSummary, agentStatusState, routeState, syncContextStats, denyAllToolApprovals, requestToolApproval, patchToolCardResult, flushToolResults, flushDeferredExecutionPendingResumeKick, drain, drainPendingSteering,
15
+ runtime, nextId, tuiDebug, LEAD_TURN_TIMEOUT_MS, flags, pending, itemIndexById, getState, set, pushItem, patchItem, updateStreamingTail: updateStreamingTailFromStore, settleStreamingTail: settleStreamingTailFromStore, clearStreamingTail: clearStreamingTailFromStore, pushNotice, pushUserOrSyntheticItem, markToolCallActive, markToolCallDone, clearActiveToolSummary, agentStatusState, routeState, syncContextStats, denyAllToolApprovals, requestToolApproval, patchToolCardResult, flushToolResults, flushDeferredExecutionPendingResumeKick, drain, drainPendingSteering,
16
16
  } = bag;
17
+ // Small fallbacks keep isolated createRunTurn harnesses source-compatible;
18
+ // the real engine supplies atomic implementations that also maintain revision.
19
+ const updateStreamingTail = updateStreamingTailFromStore || ((id, patch = {}) => {
20
+ set({ streamingTail: { ...(getState().streamingTail || {}), ...patch, kind: 'assistant', id, streaming: true } });
21
+ return true;
22
+ });
23
+ const settleStreamingTail = settleStreamingTailFromStore || ((id, patch = {}) => {
24
+ const tail = getState().streamingTail;
25
+ if (!tail || tail.id !== id) return false;
26
+ pushItem({ ...tail, ...patch, kind: 'assistant', id, streaming: false });
27
+ set({ streamingTail: null });
28
+ return true;
29
+ });
30
+ const clearStreamingTail = clearStreamingTailFromStore || ((id = null) => {
31
+ if (id == null || getState().streamingTail?.id === id) set({ streamingTail: null });
32
+ return true;
33
+ });
17
34
 
18
35
  async function runTurn(userText, options = {}) {
19
36
  const turnIndex = getState().stats.turns || 0;
@@ -63,22 +80,63 @@ export function createRunTurn(bag) {
63
80
  let watchdogGraceTimer = null;
64
81
  let lastProgressAt = startedAt;
65
82
  let lastProgressLabel = 'start';
83
+ let watchdogDeferralCeilingAt = 0;
84
+ const configuredLeadToolMaxMs = Number(process.env.MIXDOG_LEAD_TOOL_MAX_MS);
85
+ const leadToolMaxMs = Number.isFinite(configuredLeadToolMaxMs) && configuredLeadToolMaxMs > 0
86
+ ? configuredLeadToolMaxMs
87
+ : 30 * 60 * 1000;
66
88
  const clearWatchdog = () => {
67
89
  if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
68
90
  if (watchdogGraceTimer) { clearTimeout(watchdogGraceTimer); watchdogGraceTimer = null; }
69
91
  };
92
+ const refreshWatchdogFromRuntimeLiveness = () => {
93
+ let liveness;
94
+ try { liveness = runtime.getTurnLiveness?.(); } catch { return false; }
95
+ if (liveness?.stage !== 'tool_running') watchdogDeferralCeilingAt = 0;
96
+ const progressAt = Number(liveness?.lastProgressAt);
97
+ const now = Date.now();
98
+ if (!liveness || !Number.isFinite(progressAt) || progressAt <= now - LEAD_TURN_TIMEOUT_MS) return false;
99
+
100
+ if (liveness.stage === 'tool_running') {
101
+ watchdogDeferralCeilingAt = 0;
102
+ const toolStartedAt = Number(liveness.toolStartedAt);
103
+ if (!Number.isFinite(toolStartedAt) || toolStartedAt <= 0) return false;
104
+ const toolSelfDeadlineMs = Number(liveness.toolSelfDeadlineMs);
105
+ const toolCeilingMs = Math.max(
106
+ Number.isFinite(toolSelfDeadlineMs) && toolSelfDeadlineMs > 0 ? toolSelfDeadlineMs + 60_000 : 0,
107
+ leadToolMaxMs,
108
+ );
109
+ if (now - toolStartedAt >= toolCeilingMs) return false;
110
+ watchdogDeferralCeilingAt = toolStartedAt + toolCeilingMs;
111
+ }
112
+
113
+ lastProgressAt = progressAt;
114
+ lastProgressLabel = `orchestrator:${String(liveness.stage || 'unknown')}`;
115
+ armWatchdog();
116
+ return true;
117
+ };
70
118
  const armWatchdog = () => {
71
119
  if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
72
120
  if (watchdogTripped) return;
73
- const remaining = Math.max(1, LEAD_TURN_TIMEOUT_MS - Math.max(0, Date.now() - lastProgressAt));
121
+ const now = Date.now();
122
+ const remaining = Math.max(1, LEAD_TURN_TIMEOUT_MS - Math.max(0, now - lastProgressAt));
123
+ const ceilingRemaining = watchdogDeferralCeilingAt > 0
124
+ ? Math.max(1, watchdogDeferralCeilingAt - now)
125
+ : Infinity;
126
+ const delay = Math.min(remaining, ceilingRemaining);
74
127
  watchdogTimer = setTimeout(() => {
75
128
  if (!isCurrentTurn()) return;
76
129
  if (watchdogTripped) return;
77
- const idleMs = Date.now() - lastProgressAt;
130
+ const now = Date.now();
131
+ const idleMs = now - lastProgressAt;
78
132
  if (idleMs < LEAD_TURN_TIMEOUT_MS) {
79
- armWatchdog();
80
- return;
81
- }
133
+ if (watchdogDeferralCeilingAt > 0 && now >= watchdogDeferralCeilingAt) {
134
+ if (refreshWatchdogFromRuntimeLiveness()) return;
135
+ } else {
136
+ armWatchdog();
137
+ return;
138
+ }
139
+ } else if (refreshWatchdogFromRuntimeLiveness()) return;
82
140
  watchdogTripped = true;
83
141
  if (_batchTimer !== null) {
84
142
  clearTimeout(_batchTimer);
@@ -89,7 +147,7 @@ export function createRunTurn(bag) {
89
147
  _pendingThinkingLastEndedAt = 0;
90
148
  const elapsed = Date.now() - startedAt;
91
149
  tuiDebug(`runTurn WATCHDOG TRIP turn=${turnIndex} elapsedMs=${elapsed} idleMs=${idleMs} lastProgress=${lastProgressLabel} — aborting stuck turn`);
92
- pushNotice(`Turn timed out after ${Math.round(idleMs / 1000)}s idle — aborting stuck request. Input will be released shortly if abort does not unwind.`, 'warn', { transcript: true });
150
+ pushNotice(`Turn timed out after ${Math.round(idleMs / 1000)}s idle (last progress: ${lastProgressLabel}) — aborting stuck request. Input will be released shortly if abort does not unwind.`, 'warn', { transcript: true });
93
151
  try { runtime.abort('cli-react-abort-watchdog'); } catch {}
94
152
  // Belt-and-suspenders: if runtime.abort() did not reject runtime.ask()
95
153
  // (unwind starved), hard-release the turn after a short grace so the
@@ -105,6 +163,11 @@ export function createRunTurn(bag) {
105
163
  finalizeToolHeaders();
106
164
  clearDeferredTimers();
107
165
  flags.flushDeferredBeforeImmediatePush = null;
166
+ if (currentAssistantId && currentAssistantText.trim()) {
167
+ settleStreamingTail(currentAssistantId, { text: currentAssistantText });
168
+ } else {
169
+ clearStreamingTail(currentAssistantId);
170
+ }
108
171
  // Bump the epoch FIRST so this (still-stuck) turn's later finally becomes
109
172
  // a no-op for shared getState() and cannot corrupt the turn we hand off to.
110
173
  flags.leadTurnEpoch++;
@@ -118,7 +181,7 @@ export function createRunTurn(bag) {
118
181
  flushDeferredExecutionPendingResumeKick();
119
182
  }, 5_000);
120
183
  watchdogGraceTimer.unref?.();
121
- }, remaining);
184
+ }, delay);
122
185
  watchdogTimer.unref?.();
123
186
  };
124
187
  const markTurnProgress = (label) => {
@@ -126,7 +189,6 @@ export function createRunTurn(bag) {
126
189
  if (watchdogTripped) return;
127
190
  lastProgressAt = Date.now();
128
191
  lastProgressLabel = String(label || 'progress');
129
- armWatchdog();
130
192
  return true;
131
193
  };
132
194
  armWatchdog();
@@ -290,7 +352,7 @@ export function createRunTurn(bag) {
290
352
  const it = newItems[i];
291
353
  if (it?.id != null) itemIndexById.set(it.id, base + i);
292
354
  }
293
- set({ items, ...extra });
355
+ set({ items, structureRevision: (Number(getState().structureRevision) || 0) + 1, ...extra });
294
356
  };
295
357
 
296
358
  const markPromptCommitted = () => {
@@ -326,7 +388,7 @@ export function createRunTurn(bag) {
326
388
  changed = true;
327
389
  return { ...item, headerFinalized: true };
328
390
  });
329
- if (changed) set({ items });
391
+ if (changed) set({ items, structureRevision: (Number(getState().structureRevision) || 0) + 1 });
330
392
  return changed;
331
393
  };
332
394
 
@@ -450,7 +512,7 @@ export function createRunTurn(bag) {
450
512
  // Seed the new row with the already-visible text so the ● gutter and the
451
513
  // first body line appear in the SAME set()/emit() — no empty "●-only"
452
514
  // row that scrolls once on its own and again when the body lands.
453
- pushItem({ kind: 'assistant', id: currentAssistantId, text: String(initialText || ''), streaming: true });
515
+ updateStreamingTail(currentAssistantId, { text: String(initialText || '') });
454
516
  }
455
517
  return currentAssistantId;
456
518
  };
@@ -463,7 +525,6 @@ export function createRunTurn(bag) {
463
525
  _lastNewlineIdx = -1;
464
526
  _emittedNewlineIdx = -2;
465
527
  _emittedVisibleText = '';
466
- _cachedAssistantIndex = -1;
467
528
  };
468
529
 
469
530
  const commitAssistantSegment = ({ sealToolBlock = false } = {}) => {
@@ -474,7 +535,7 @@ export function createRunTurn(bag) {
474
535
  }
475
536
  if (sealToolBlock) clearAggregateContinuation();
476
537
  const id = currentAssistantId || ensureAssistant(text);
477
- patchItem(id, { text, streaming: false });
538
+ settleStreamingTail(id, { text });
478
539
  committedSegments.push(text);
479
540
  closeAssistantSegment();
480
541
  return true;
@@ -518,7 +579,6 @@ export function createRunTurn(bag) {
518
579
  let _lastNewlineIdx = -1; // offset of the last completed-line '\n' found so far
519
580
  let _emittedNewlineIdx = -2; // newline offset backing _emittedVisibleText (-2 forces first compute)
520
581
  let _emittedVisibleText = ''; // cached visible slice for the current newline offset
521
- let _cachedAssistantIndex = -1;// cached getState().items index of the streaming assistant row
522
582
  // Engine-local streaming scalars. Neither responseLength nor thinkingText is
523
583
  // rendered per-token by any consumer: App reads getState().thinking only as a
524
584
  // boolean (App.jsx `!!(getState().thinking || liveSpinner?.thinking)`) and the
@@ -586,22 +646,9 @@ export function createRunTurn(bag) {
586
646
  // until there is real content to paint.
587
647
  if (currentAssistantId || streamingVisibleText.trim()) {
588
648
  const id = ensureAssistant(streamingVisibleText);
589
- // Emit the accumulated assistant text and spinner update together so a
590
- // streaming batch costs one set() one emit() → one React reconcile.
591
- // Cache the streaming row's index; re-find only on a cache miss (row
592
- // added/removed) instead of a findIndex scan on every flush.
593
- let index = _cachedAssistantIndex;
594
- if (index < 0 || getState().items[index]?.id !== id) {
595
- index = getState().items.findIndex((it) => it.id === id);
596
- _cachedAssistantIndex = index;
597
- }
598
- if (index >= 0) {
599
- const current = getState().items[index];
600
- if (!Object.is(current.text, streamingVisibleText) || current.streaming !== true) {
601
- const items = getState().items.slice();
602
- items[index] = { ...current, text: streamingVisibleText, streaming: true };
603
- patch.items = items;
604
- }
649
+ const current = getState().streamingTail;
650
+ if (!current || current.id !== id || !Object.is(current.text, streamingVisibleText)) {
651
+ patch.streamingTail = { kind: 'assistant', id, text: streamingVisibleText, streaming: true };
605
652
  }
606
653
  }
607
654
  // Only touch the spinner when there is a real reason: a visible-line
@@ -609,7 +656,7 @@ export function createRunTurn(bag) {
609
656
  // pending thinking end timestamp. Refresh responseLength here so the
610
657
  // finalize outputTokens fallback stays valid without a per-token push.
611
658
  const responseLengthVal = assistantText.length + thinkingText.length;
612
- const visibleLineChanged = patch.items !== undefined;
659
+ const visibleLineChanged = patch.streamingTail !== undefined;
613
660
  const thinkingTransition = _publishedThinkingActive === true; // was thinking, now responding
614
661
  if (getState().spinner && (visibleLineChanged || thinkingTransition || _pendingThinkingLastEndedAt)) {
615
662
  patch.spinner = { ...getState().spinner, responseLength: responseLengthVal, thinking: false, thinkingLastEndedAt: _pendingThinkingLastEndedAt || getState().spinner.thinkingLastEndedAt, mode: compactingActive ? 'compacting' : 'responding' };
@@ -699,7 +746,10 @@ export function createRunTurn(bag) {
699
746
  const displayDetail = errors > 0 || exitErrors > 0
700
747
  ? failureDetailText({ succeeded, realErrors: callErrors, exitErrors, exitCode: allCalls.find((r) => r.isExitError)?.exitCode })
701
748
  : formatAggregateDetail(aggregateSummaries(aggregate));
702
- const currentItem = getState().items.find((it) => it.id === card.itemId);
749
+ const currentIndex = itemIndexById.get(card.itemId);
750
+ const currentItem = Number.isInteger(currentIndex) && getState().items[currentIndex]?.id === card.itemId
751
+ ? getState().items[currentIndex]
752
+ : null;
703
753
  const visualCompleted = Math.max(
704
754
  completedCount,
705
755
  Math.min(allCalls.length, Number(currentItem?.completedCount || 0)),
@@ -737,6 +787,9 @@ export function createRunTurn(bag) {
737
787
  try {
738
788
  const { result, session } = await runtime.ask(userText, {
739
789
  drainSteering: (_sessionId, drainOptions) => (isCurrentTurn() ? drainPendingSteering(drainOptions) : []),
790
+ onStreamDelta: () => {
791
+ markTurnProgress('stream-delta');
792
+ },
740
793
  onSteerMessage: (text) => {
741
794
  if (!markTurnProgress('steer-message')) return;
742
795
  // A suppressed live-completion twin is model-visible only; its
@@ -1087,10 +1140,10 @@ export function createRunTurn(bag) {
1087
1140
  // the final provider text when it is available.
1088
1141
  const id = currentAssistantId || ensureAssistant(finalRemainder);
1089
1142
  currentAssistantText = finalRemainder;
1090
- patchItem(id, { text: finalRemainder, streaming: false });
1143
+ settleStreamingTail(id, { text: finalRemainder });
1091
1144
  } else if (currentAssistantId && (currentAssistantText.trim() || assistantText.trim())) {
1092
1145
  const streamedText = currentAssistantText || assistantText;
1093
- patchItem(currentAssistantId, { text: streamedText, streaming: false });
1146
+ settleStreamingTail(currentAssistantId, { text: streamedText });
1094
1147
  }
1095
1148
  turnFinishedNormally = true;
1096
1149
  }
@@ -1103,7 +1156,7 @@ export function createRunTurn(bag) {
1103
1156
  if (error?.name === 'SessionClosedError') {
1104
1157
  cancelled = true;
1105
1158
  if (assistantText.trim() && currentAssistantId) {
1106
- patchItem(currentAssistantId, { text: currentAssistantText || assistantText, streaming: false });
1159
+ settleStreamingTail(currentAssistantId, { text: currentAssistantText || assistantText });
1107
1160
  }
1108
1161
  // Finalize pending tool cards so they don't stay "Running..." forever
1109
1162
  // after cancellation. Without this, the spinner vanishes and TurnDone
@@ -1147,6 +1200,13 @@ export function createRunTurn(bag) {
1147
1200
  if (isStaleUnwind) {
1148
1201
  tuiDebug(`runTurn STALE UNWIND turn=${turnIndex} — force-released; skipping shared UI/state writes`);
1149
1202
  } else {
1203
+ if (currentAssistantId && getState().streamingTail?.id === currentAssistantId) {
1204
+ if (currentAssistantText.trim()) {
1205
+ settleStreamingTail(currentAssistantId, { text: currentAssistantText });
1206
+ } else {
1207
+ clearStreamingTail(currentAssistantId);
1208
+ }
1209
+ }
1150
1210
  const producedTranscriptItem =
1151
1211
  getState().items.length + closingItems.length > itemsAtTurnStart;
1152
1212
  const reclaimed = cancelled && flags.activePromptRestore?.reclaimed === true;