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
@@ -6,16 +6,31 @@ import { applyUsageDelta } from './session-stats.mjs';
6
6
  import { pickVerb, pickDoneVerb, compactEventLabel, compactEventDetail } from './labels.mjs';
7
7
  import { toolResultText, toolErrorDisplay } from './tool-result-text.mjs';
8
8
  import { toolCallId, toolResultCallId, toolCallName, toolCallArgs } from './tool-call-fields.mjs';
9
- import { toolResultStatus, isErrorToolStatus } from './agent-envelope.mjs';
10
9
  import { promptDisplayText, STEERING_SUPPRESSED_DISPLAY } from './queue-helpers.mjs';
11
- import { memoryCoreResultErrorText } from '../app/input-parsers.mjs';
12
10
  import { yieldToRenderer } from './render-timing.mjs';
13
- import { aggregateRawResult, aggregateBucketForCategory, aggregateSummaries, assignAggregateSummaryOrder } from './tool-result-status.mjs';
11
+ import { aggregateRawResult, aggregateBucketForCategory, aggregateSummaries, assignAggregateSummaryOrder, failureDetailText, toolCallOutcome } from './tool-result-status.mjs';
14
12
 
15
13
  export function createRunTurn(bag) {
16
14
  const {
17
- 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,
18
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
+ });
19
34
 
20
35
  async function runTurn(userText, options = {}) {
21
36
  const turnIndex = getState().stats.turns || 0;
@@ -42,6 +57,9 @@ export function createRunTurn(bag) {
42
57
  reclaimed: false,
43
58
  committed: false,
44
59
  requeueEntries: Array.isArray(options.requeueOnAbort) ? options.requeueOnAbort.slice() : [],
60
+ discardExecutionPendingResumeKeys: Array.isArray(options.discardExecutionPendingResumeKeys)
61
+ ? options.discardExecutionPendingResumeKeys.slice()
62
+ : [],
45
63
  };
46
64
  set({ busy: true, lastTurn: null, spinner: { active: true, verb: pickVerb(turnIndex), startedAt, responseLength: 0, inputTokens: 0, outputTokens: 0, mode: 'requesting' } });
47
65
 
@@ -62,22 +80,63 @@ export function createRunTurn(bag) {
62
80
  let watchdogGraceTimer = null;
63
81
  let lastProgressAt = startedAt;
64
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;
65
88
  const clearWatchdog = () => {
66
89
  if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
67
90
  if (watchdogGraceTimer) { clearTimeout(watchdogGraceTimer); watchdogGraceTimer = null; }
68
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
+ };
69
118
  const armWatchdog = () => {
70
119
  if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
71
120
  if (watchdogTripped) return;
72
- 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);
73
127
  watchdogTimer = setTimeout(() => {
74
128
  if (!isCurrentTurn()) return;
75
129
  if (watchdogTripped) return;
76
- const idleMs = Date.now() - lastProgressAt;
130
+ const now = Date.now();
131
+ const idleMs = now - lastProgressAt;
77
132
  if (idleMs < LEAD_TURN_TIMEOUT_MS) {
78
- armWatchdog();
79
- return;
80
- }
133
+ if (watchdogDeferralCeilingAt > 0 && now >= watchdogDeferralCeilingAt) {
134
+ if (refreshWatchdogFromRuntimeLiveness()) return;
135
+ } else {
136
+ armWatchdog();
137
+ return;
138
+ }
139
+ } else if (refreshWatchdogFromRuntimeLiveness()) return;
81
140
  watchdogTripped = true;
82
141
  if (_batchTimer !== null) {
83
142
  clearTimeout(_batchTimer);
@@ -88,7 +147,7 @@ export function createRunTurn(bag) {
88
147
  _pendingThinkingLastEndedAt = 0;
89
148
  const elapsed = Date.now() - startedAt;
90
149
  tuiDebug(`runTurn WATCHDOG TRIP turn=${turnIndex} elapsedMs=${elapsed} idleMs=${idleMs} lastProgress=${lastProgressLabel} — aborting stuck turn`);
91
- 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 });
92
151
  try { runtime.abort('cli-react-abort-watchdog'); } catch {}
93
152
  // Belt-and-suspenders: if runtime.abort() did not reject runtime.ask()
94
153
  // (unwind starved), hard-release the turn after a short grace so the
@@ -104,6 +163,11 @@ export function createRunTurn(bag) {
104
163
  finalizeToolHeaders();
105
164
  clearDeferredTimers();
106
165
  flags.flushDeferredBeforeImmediatePush = null;
166
+ if (currentAssistantId && currentAssistantText.trim()) {
167
+ settleStreamingTail(currentAssistantId, { text: currentAssistantText });
168
+ } else {
169
+ clearStreamingTail(currentAssistantId);
170
+ }
107
171
  // Bump the epoch FIRST so this (still-stuck) turn's later finally becomes
108
172
  // a no-op for shared getState() and cannot corrupt the turn we hand off to.
109
173
  flags.leadTurnEpoch++;
@@ -117,7 +181,7 @@ export function createRunTurn(bag) {
117
181
  flushDeferredExecutionPendingResumeKick();
118
182
  }, 5_000);
119
183
  watchdogGraceTimer.unref?.();
120
- }, remaining);
184
+ }, delay);
121
185
  watchdogTimer.unref?.();
122
186
  };
123
187
  const markTurnProgress = (label) => {
@@ -125,11 +189,19 @@ export function createRunTurn(bag) {
125
189
  if (watchdogTripped) return;
126
190
  lastProgressAt = Date.now();
127
191
  lastProgressLabel = String(label || 'progress');
128
- armWatchdog();
129
192
  return true;
130
193
  };
131
194
  armWatchdog();
132
195
  let currentAssistantText = '';
196
+ // Segments sealed as their own assistant item(s) this turn via
197
+ // commitAssistantSegment (e.g. a tool preamble, then a no-newline tail
198
+ // committed by onSteerMessage before an injected steering row). Kept as an
199
+ // ordered list — NOT one concatenated string — so finalization can strip
200
+ // each committed segment out of the provider's final content individually.
201
+ // A single concatenation breaks when the provider omits an earlier segment
202
+ // (e.g. a tool preamble) from result.content: the combined prefix no longer
203
+ // matches and the tail would duplicate after the steering row.
204
+ const committedSegments = [];
133
205
  let thinkingText = '';
134
206
  let thinkingStartedAt = 0;
135
207
  let thinkingSegmentStartedAt = 0;
@@ -147,6 +219,7 @@ export function createRunTurn(bag) {
147
219
  const earlyResultBuffer = new Map();
148
220
  const aggregateCards = []; // active aggregate cards in the current consecutive tool block
149
221
  let tailAggregate = null; // most recently touched aggregate card; only the tail may absorb the next same-bucket call
222
+ let providerToolBatch = 0;
150
223
 
151
224
  // ── Deferred tool-card push (scroll/text sync) ────────────────────────────
152
225
  // A tool card used to enter the transcript the instant onToolCall fired,
@@ -163,13 +236,11 @@ export function createRunTurn(bag) {
163
236
  // cards are pushed alongside the result-bearing one before their own delay
164
237
  // elapses and would otherwise paint an empty reserved band.
165
238
  // Mirrors components/ToolExecution.jsx TOOL_PENDING_SHOW_DELAY_MS.
166
- // [jitter fix] Reserve the tool-card row immediately instead of after a
167
- // 1000ms delay. Delayed insertion made rows appear late and shove the body
168
- // vertically; pushing now (with the real 'Running' header height via
169
- // deferredDisplayReady) keeps the layout stable. The timer path is kept as a
170
- // 0ms microtask fallback for entries that are registered but not explicitly
171
- // surfaced, preserving call-order flush semantics.
172
- const TOOL_CARD_PUSH_DELAY_MS = 0;
239
+ // Keep a fast call off-screen until it either resolves or has genuinely
240
+ // been pending long enough to communicate work. This avoids a one-frame
241
+ // Running→Finished flash while preserving the deferred entry's ordered,
242
+ // result-forced materialization path.
243
+ const TOOL_CARD_PUSH_DELAY_MS = 1000;
173
244
  let deferredSeqCounter = 0;
174
245
  const deferredEntries = []; // creation-order list; each is pushed at most once
175
246
  // Push this entry AND every earlier-created still-deferred entry, in order,
@@ -281,7 +352,7 @@ export function createRunTurn(bag) {
281
352
  const it = newItems[i];
282
353
  if (it?.id != null) itemIndexById.set(it.id, base + i);
283
354
  }
284
- set({ items, ...extra });
355
+ set({ items, structureRevision: (Number(getState().structureRevision) || 0) + 1, ...extra });
285
356
  };
286
357
 
287
358
  const markPromptCommitted = () => {
@@ -317,7 +388,7 @@ export function createRunTurn(bag) {
317
388
  changed = true;
318
389
  return { ...item, headerFinalized: true };
319
390
  });
320
- if (changed) set({ items });
391
+ if (changed) set({ items, structureRevision: (Number(getState().structureRevision) || 0) + 1 });
321
392
  return changed;
322
393
  };
323
394
 
@@ -328,13 +399,15 @@ export function createRunTurn(bag) {
328
399
  aggregate.ensureVisible?.();
329
400
  const errors = allCalls.filter((r) => r.isError).length;
330
401
  const callErrors = allCalls.filter((r) => r.isCallError).length;
402
+ const exitErrors = allCalls.filter((r) => r.isExitError).length;
331
403
  const completed = allCalls.filter((r) => r.resolved).length;
332
- const succeeded = completed - errors;
404
+ const succeeded = Math.max(0, completed - errors - exitErrors);
333
405
  const rawResult = aggregateRawResult(allCalls);
334
- // Merged count summary (see patchToolCardResult); failures keep
335
- // 'N Ok · N Failed'. Raw preserved for ctrl+o expansion.
336
- const displayDetail = errors > 0
337
- ? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
406
+ // Merged count summary (see patchToolCardResult); real failures keep
407
+ // 'N Failed', shell command-exits render 'Exit N'/'Y Exit'. Raw
408
+ // preserved for ctrl+o expansion.
409
+ const displayDetail = errors > 0 || exitErrors > 0
410
+ ? failureDetailText({ succeeded, realErrors: callErrors, exitErrors, exitCode: allCalls.find((r) => r.isExitError)?.exitCode })
338
411
  : formatAggregateDetail(aggregateSummaries(aggregate));
339
412
  patchItem(aggregate.itemId, {
340
413
  result: displayDetail,
@@ -343,6 +416,7 @@ export function createRunTurn(bag) {
343
416
  isError: errors > 0,
344
417
  errorCount: errors,
345
418
  callErrorCount: callErrors,
419
+ exitErrorCount: exitErrors,
346
420
  count: allCalls.length,
347
421
  completedCount: allCalls.length,
348
422
  doneCategories: aggregateDoneCategories(allCalls),
@@ -438,7 +512,7 @@ export function createRunTurn(bag) {
438
512
  // Seed the new row with the already-visible text so the ● gutter and the
439
513
  // first body line appear in the SAME set()/emit() — no empty "●-only"
440
514
  // row that scrolls once on its own and again when the body lands.
441
- pushItem({ kind: 'assistant', id: currentAssistantId, text: String(initialText || ''), streaming: true });
515
+ updateStreamingTail(currentAssistantId, { text: String(initialText || '') });
442
516
  }
443
517
  return currentAssistantId;
444
518
  };
@@ -451,7 +525,6 @@ export function createRunTurn(bag) {
451
525
  _lastNewlineIdx = -1;
452
526
  _emittedNewlineIdx = -2;
453
527
  _emittedVisibleText = '';
454
- _cachedAssistantIndex = -1;
455
528
  };
456
529
 
457
530
  const commitAssistantSegment = ({ sealToolBlock = false } = {}) => {
@@ -462,7 +535,8 @@ export function createRunTurn(bag) {
462
535
  }
463
536
  if (sealToolBlock) clearAggregateContinuation();
464
537
  const id = currentAssistantId || ensureAssistant(text);
465
- patchItem(id, { text, streaming: false });
538
+ settleStreamingTail(id, { text });
539
+ committedSegments.push(text);
466
540
  closeAssistantSegment();
467
541
  return true;
468
542
  };
@@ -505,7 +579,6 @@ export function createRunTurn(bag) {
505
579
  let _lastNewlineIdx = -1; // offset of the last completed-line '\n' found so far
506
580
  let _emittedNewlineIdx = -2; // newline offset backing _emittedVisibleText (-2 forces first compute)
507
581
  let _emittedVisibleText = ''; // cached visible slice for the current newline offset
508
- let _cachedAssistantIndex = -1;// cached getState().items index of the streaming assistant row
509
582
  // Engine-local streaming scalars. Neither responseLength nor thinkingText is
510
583
  // rendered per-token by any consumer: App reads getState().thinking only as a
511
584
  // boolean (App.jsx `!!(getState().thinking || liveSpinner?.thinking)`) and the
@@ -573,22 +646,9 @@ export function createRunTurn(bag) {
573
646
  // until there is real content to paint.
574
647
  if (currentAssistantId || streamingVisibleText.trim()) {
575
648
  const id = ensureAssistant(streamingVisibleText);
576
- // Emit the accumulated assistant text and spinner update together so a
577
- // streaming batch costs one set() one emit() → one React reconcile.
578
- // Cache the streaming row's index; re-find only on a cache miss (row
579
- // added/removed) instead of a findIndex scan on every flush.
580
- let index = _cachedAssistantIndex;
581
- if (index < 0 || getState().items[index]?.id !== id) {
582
- index = getState().items.findIndex((it) => it.id === id);
583
- _cachedAssistantIndex = index;
584
- }
585
- if (index >= 0) {
586
- const current = getState().items[index];
587
- if (!Object.is(current.text, streamingVisibleText) || current.streaming !== true) {
588
- const items = getState().items.slice();
589
- items[index] = { ...current, text: streamingVisibleText, streaming: true };
590
- patch.items = items;
591
- }
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 };
592
652
  }
593
653
  }
594
654
  // Only touch the spinner when there is a real reason: a visible-line
@@ -596,7 +656,7 @@ export function createRunTurn(bag) {
596
656
  // pending thinking end timestamp. Refresh responseLength here so the
597
657
  // finalize outputTokens fallback stays valid without a per-token push.
598
658
  const responseLengthVal = assistantText.length + thinkingText.length;
599
- const visibleLineChanged = patch.items !== undefined;
659
+ const visibleLineChanged = patch.streamingTail !== undefined;
600
660
  const thinkingTransition = _publishedThinkingActive === true; // was thinking, now responding
601
661
  if (getState().spinner && (visibleLineChanged || thinkingTransition || _pendingThinkingLastEndedAt)) {
602
662
  patch.spinner = { ...getState().spinner, responseLength: responseLengthVal, thinking: false, thinkingLastEndedAt: _pendingThinkingLastEndedAt || getState().spinner.thinkingLastEndedAt, mode: compactingActive ? 'compacting' : 'responding' };
@@ -658,37 +718,38 @@ export function createRunTurn(bag) {
658
718
  if (!callRec || callRec.resolved || callRec.completedEarly) return;
659
719
  aggregate.ensureVisible?.();
660
720
  const rawText = toolResultText(message?.content);
661
- // Recover flattened memory "core" op failures (isError dropped upstream)
662
- // so they are excluded from the done count see memoryCoreResultErrorText.
663
- // Gated to Memory calls: the text-matcher's ^(error|failed) catch-all
664
- // would otherwise misflag legitimate non-memory success output.
665
- const isMemoryCall = classifyToolCategory(callRec.name, callRec.args) === 'Memory';
666
- // Same split as tool-card-results.mjs: a REAL tool-call error drives the
667
- // red ● dot; command/result failures only mark the card Failed in L2.
668
- const isCallError = message?.isError === true || message?.toolKind === 'error';
669
- const isResultError = /^\s*\[?error/i.test(rawText) || isErrorToolStatus(toolResultStatus(rawText)) || (isMemoryCall && memoryCoreResultErrorText(rawText) != null);
670
- const isError = isCallError || isResultError;
721
+ // Tool result text (including HTTP/domain failures, zero matches, task
722
+ // statuses, and shell output) is detail, not a failed invocation. Only
723
+ // the provider's isError/error-tool envelope drives failure counts/red.
724
+ const { exitCode, isExitError, isCallError } = toolCallOutcome(message, rawText);
725
+ const isError = isCallError;
671
726
  const text = isError ? toolErrorDisplay(rawText, callRec.name || 'tool') : rawText;
672
727
  callRec.summary = !isError ? summarizeToolResult(callRec.name, callRec.args, rawText, isError) : null;
673
728
  assignAggregateSummaryOrder(aggregate, callRec);
674
729
  callRec.isError = isError;
675
730
  callRec.isCallError = isCallError;
731
+ callRec.isExitError = isExitError;
732
+ callRec.exitCode = exitCode;
676
733
  callRec.resultText = text;
677
734
  callRec.completedEarly = true;
678
735
  const allCalls = [...aggregate.calls.values()];
679
736
  const completedCount = allCalls.filter((r) => r.resolved || r.completedEarly).length;
680
737
  const errors = allCalls.filter((r) => r.isError).length;
681
738
  const callErrors = allCalls.filter((r) => r.isCallError).length;
682
- const succeeded = completedCount - errors;
739
+ const exitErrors = allCalls.filter((r) => r.isExitError).length;
740
+ const succeeded = Math.max(0, completedCount - errors - exitErrors);
683
741
  const rawResult = aggregateRawResult(allCalls);
684
742
  // Collapsed detail carries the merged per-call count summary even on
685
743
  // the early-notify path; patching '' here flipped the detail row back
686
744
  // to the 'Running' placeholder between count updates (the visible
687
745
  // jitter). Failures keep 'N Failed'. Raw preserved for ctrl+o expansion.
688
- const displayDetail = errors > 0
689
- ? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
746
+ const displayDetail = errors > 0 || exitErrors > 0
747
+ ? failureDetailText({ succeeded, realErrors: callErrors, exitErrors, exitCode: allCalls.find((r) => r.isExitError)?.exitCode })
690
748
  : formatAggregateDetail(aggregateSummaries(aggregate));
691
- 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;
692
753
  const visualCompleted = Math.max(
693
754
  completedCount,
694
755
  Math.min(allCalls.length, Number(currentItem?.completedCount || 0)),
@@ -699,6 +760,7 @@ export function createRunTurn(bag) {
699
760
  isError: errors > 0,
700
761
  errorCount: errors,
701
762
  callErrorCount: callErrors,
763
+ exitErrorCount: exitErrors,
702
764
  count: allCalls.length,
703
765
  completedCount: visualCompleted,
704
766
  };
@@ -725,6 +787,9 @@ export function createRunTurn(bag) {
725
787
  try {
726
788
  const { result, session } = await runtime.ask(userText, {
727
789
  drainSteering: (_sessionId, drainOptions) => (isCurrentTurn() ? drainPendingSteering(drainOptions) : []),
790
+ onStreamDelta: () => {
791
+ markTurnProgress('stream-delta');
792
+ },
728
793
  onSteerMessage: (text) => {
729
794
  if (!markTurnProgress('steer-message')) return;
730
795
  // A suppressed live-completion twin is model-visible only; its
@@ -737,10 +802,12 @@ export function createRunTurn(bag) {
737
802
  // assistant segment first so the steered user turn and the next
738
803
  // assistant response do not get visually merged into one bubble.
739
804
  flushStreamBatch();
740
- if (currentAssistantId) {
741
- patchItem(currentAssistantId, { text: currentAssistantText || assistantText, streaming: false });
742
- closeAssistantSegment();
743
- }
805
+ // Commit any pending assistant segment — including a streamed tail
806
+ // that never got a trailing '\n' (no row/currentAssistantId created
807
+ // yet). Using the shared segment-commit helper ensures that tail is
808
+ // materialized as an assistant item instead of being dropped when a
809
+ // steering/agent-completion injection races turn finalization.
810
+ commitAssistantSegment({ sealToolBlock: true });
744
811
  assistantText = '';
745
812
  const value = String(text || '').trim();
746
813
  if (value) {
@@ -769,6 +836,7 @@ export function createRunTurn(bag) {
769
836
  }
770
837
  const batchCalls = (calls || []).filter(Boolean);
771
838
  if (batchCalls.length === 0) return;
839
+ const agentBatch = ++providerToolBatch;
772
840
  const committedAssistantSegment = commitAssistantSegment({ sealToolBlock: true });
773
841
  if (committedAssistantSegment) {
774
842
  // Let the pre-tool assistant preamble paint and settle before the
@@ -792,11 +860,10 @@ export function createRunTurn(bag) {
792
860
  // Category drives the aggregate bucket so only same-category calls
793
861
  // merge into one card; classify first, then bucket by it.
794
862
  const category = classifyToolCategory(name, args);
795
- // Agent/bridge calls always get their own standalone card so
796
- // ToolExecution's isAgentTool/agentActionTitle path renders a
797
- // "Spawn <Agent> (model, tag)" header instead of being folded
798
- // into the "Called N agent(s)" category-aggregate card.
799
- const bucket = category === 'Agent' ? null : aggregateBucketForCategory(category);
863
+ // Agent actions aggregate only within this provider-emitted batch.
864
+ // They stay outbound category cards; asynchronous inbound Responses
865
+ // are separately tailed by the notification feed and never mix here.
866
+ const bucket = aggregateBucketForCategory(category, { agentBatch });
800
867
  const callId = toolCallId(c);
801
868
  const callKey = callId || `__tool_${toolCards.length}_${i}`;
802
869
  // The old App scan counted multi-pattern calls via
@@ -859,7 +926,7 @@ export function createRunTurn(bag) {
859
926
  ...categoryEntry,
860
927
  count: Number(prevCategory?.count || 0) + Number(categoryEntry.count || 1),
861
928
  });
862
- aggregateCard.calls.set(callKey, { name, args, category, summary: null, summarySeq: null, isError: false, isCallError: false, resultText: null, resolved: false, completedEarly: false });
929
+ aggregateCard.calls.set(callKey, { name, args, category, summary: null, summarySeq: null, isError: false, isCallError: false, isExitError: false, exitCode: null, resultText: null, resolved: false, completedEarly: false });
863
930
  touchedAggregates.add(aggregateCard);
864
931
  const card = { itemId: aggregateCard.itemId, callId: callKey, done: false, aggregate: aggregateCard };
865
932
  if (callId) {
@@ -1043,19 +1110,40 @@ export function createRunTurn(bag) {
1043
1110
  syncContextStats({ allowEstimated: true });
1044
1111
 
1045
1112
  const finalText = result?.content != null ? String(result.content) : '';
1046
- if (finalText.trim()) {
1113
+ // Strip text already sealed as its own item(s) this turn (a tool
1114
+ // preamble, then a no-newline tail committed by onSteerMessage before an
1115
+ // injected steering row) so finalization reconciles only the uncommitted
1116
+ // remainder — never re-creating a committed segment as a duplicate item
1117
+ // that also reorders after the steering row. Walk the segments IN ORDER,
1118
+ // peeling each off the front of the remaining content; skip leading
1119
+ // whitespace/newlines between segments. A segment that does not match at
1120
+ // the current position (provider omitted it from result.content, e.g. a
1121
+ // tool preamble) is left in place and the walk moves on.
1122
+ let finalRemainder = finalText;
1123
+ for (const seg of committedSegments) {
1124
+ // Compare against the whitespace-skipped remainder AND a
1125
+ // whitespace-trimmed segment: a segment sealed with its own leading
1126
+ // newline ('\nTAIL') would otherwise never match the skipped remainder
1127
+ // ('TAIL') and duplicate after the steering row.
1128
+ const skipped = finalRemainder.replace(/^\s+/, '');
1129
+ const trimmedSeg = seg ? seg.replace(/^\s+/, '') : '';
1130
+ if (trimmedSeg && skipped.startsWith(trimmedSeg)) {
1131
+ finalRemainder = skipped.slice(trimmedSeg.length);
1132
+ }
1133
+ }
1134
+ if (finalRemainder.trim()) {
1047
1135
  // The persisted transcript is written from the provider's final content,
1048
1136
  // while the live TUI row is fed by streaming deltas. If a provider/parser
1049
1137
  // misses or suppresses an early delta, keeping the streamed buffer here
1050
1138
  // leaves the final on-screen assistant row missing leading characters even
1051
1139
  // though the transcript is correct. Always reconcile the active segment to
1052
1140
  // the final provider text when it is available.
1053
- const id = currentAssistantId || ensureAssistant(finalText);
1054
- currentAssistantText = finalText;
1055
- patchItem(id, { text: finalText, streaming: false });
1141
+ const id = currentAssistantId || ensureAssistant(finalRemainder);
1142
+ currentAssistantText = finalRemainder;
1143
+ settleStreamingTail(id, { text: finalRemainder });
1056
1144
  } else if (currentAssistantId && (currentAssistantText.trim() || assistantText.trim())) {
1057
1145
  const streamedText = currentAssistantText || assistantText;
1058
- patchItem(currentAssistantId, { text: streamedText, streaming: false });
1146
+ settleStreamingTail(currentAssistantId, { text: streamedText });
1059
1147
  }
1060
1148
  turnFinishedNormally = true;
1061
1149
  }
@@ -1068,7 +1156,7 @@ export function createRunTurn(bag) {
1068
1156
  if (error?.name === 'SessionClosedError') {
1069
1157
  cancelled = true;
1070
1158
  if (assistantText.trim() && currentAssistantId) {
1071
- patchItem(currentAssistantId, { text: currentAssistantText || assistantText, streaming: false });
1159
+ settleStreamingTail(currentAssistantId, { text: currentAssistantText || assistantText });
1072
1160
  }
1073
1161
  // Finalize pending tool cards so they don't stay "Running..." forever
1074
1162
  // after cancellation. Without this, the spinner vanishes and TurnDone
@@ -1112,6 +1200,13 @@ export function createRunTurn(bag) {
1112
1200
  if (isStaleUnwind) {
1113
1201
  tuiDebug(`runTurn STALE UNWIND turn=${turnIndex} — force-released; skipping shared UI/state writes`);
1114
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
+ }
1115
1210
  const producedTranscriptItem =
1116
1211
  getState().items.length + closingItems.length > itemsAtTurnStart;
1117
1212
  const reclaimed = cancelled && flags.activePromptRestore?.reclaimed === true;