mixdog 0.9.19 → 0.9.20
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/LICENSE +21 -0
- package/README.md +93 -29
- package/package.json +3 -2
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/build-tui.mjs +6 -0
- package/scripts/hook-bus-test.mjs +8 -0
- package/scripts/log-writer-guard-smoke.mjs +131 -0
- package/scripts/reactive-compact-persist-smoke.mjs +22 -6
- package/scripts/recall-bench-cases.json +0 -1
- package/scripts/recall-quality-cases.json +1 -2
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/session-ingest-compaction-smoke.mjs +241 -0
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/scripts/tool-smoke.mjs +150 -45
- package/src/help.mjs +2 -5
- package/src/lib/mixdog-debug.cjs +13 -0
- package/src/mixdog-session-runtime.mjs +7 -3328
- package/src/rules/lead/02-channels.md +3 -3
- package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
- package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
- package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
- package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
- package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
- package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
- package/src/runtime/channels/index.mjs +6 -2183
- package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
- package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
- package/src/runtime/channels/lib/network-retry.mjs +23 -0
- package/src/runtime/channels/lib/owned-runtime.mjs +547 -0
- package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
- package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
- package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
- package/src/runtime/channels/lib/worker-main.mjs +771 -0
- package/src/runtime/memory/index.mjs +73 -1725
- package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
- package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
- package/src/runtime/memory/lib/http-router.mjs +772 -0
- package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
- package/src/runtime/memory/lib/memory-embed.mjs +28 -7
- package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
- package/src/runtime/memory/lib/query-handlers.mjs +2 -2
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
- package/src/session-runtime/boot-profile.mjs +36 -0
- package/src/session-runtime/channel-config-api.mjs +70 -0
- package/src/session-runtime/context-status.mjs +181 -0
- package/src/session-runtime/env.mjs +17 -0
- package/src/session-runtime/lifecycle-api.mjs +242 -0
- package/src/session-runtime/model-route-api.mjs +198 -0
- package/src/session-runtime/provider-auth-api.mjs +135 -0
- package/src/session-runtime/resource-api.mjs +282 -0
- package/src/session-runtime/runtime-core.mjs +2046 -0
- package/src/session-runtime/session-turn-api.mjs +274 -0
- package/src/session-runtime/tool-catalog.mjs +18 -264
- package/src/session-runtime/tool-defs.mjs +2 -2
- package/src/session-runtime/workflow-agents-api.mjs +238 -0
- package/src/standalone/agent-tool.mjs +2 -2
- package/src/standalone/channel-worker.mjs +4 -0
- package/src/standalone/memory-runtime-proxy.mjs +56 -6
- package/src/tui/App.jsx +39 -28
- package/src/tui/app/core-memory-picker.mjs +1 -1
- package/src/tui/app/use-mouse-input.mjs +6 -0
- package/src/tui/app/use-transcript-scroll.mjs +77 -3
- package/src/tui/dist/index.mjs +1990 -1527
- package/src/tui/engine/context-state.mjs +145 -0
- package/src/tui/engine/prompt-history.mjs +27 -0
- package/src/tui/engine/session-api-ext.mjs +478 -0
- package/src/tui/engine/session-api.mjs +545 -0
- package/src/tui/engine/session-flow.mjs +485 -0
- package/src/tui/engine/turn.mjs +1078 -0
- package/src/tui/engine.mjs +68 -2620
- package/src/tui/index.jsx +7 -0
- package/vendor/ink/build/ink.js +16 -1
- package/vendor/ink/build/output.js +30 -4
- package/vendor/ink/build/render.js +5 -0
- package/src/workflows/sequential/WORKFLOW.md +0 -51
|
@@ -0,0 +1,1078 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/tui/engine/turn.mjs - lead TUI turn engine (createRunTurn). Extracted from engine.mjs.
|
|
3
|
+
*/
|
|
4
|
+
import { aggregateToolCategoryEntry, classifyToolCategory, formatAggregateDetail, summarizeToolResult } from '../../runtime/shared/tool-surface.mjs';
|
|
5
|
+
import { applyUsageDelta } from './session-stats.mjs';
|
|
6
|
+
import { pickVerb, pickDoneVerb, compactEventLabel, compactEventDetail } from './labels.mjs';
|
|
7
|
+
import { toolResultText, toolErrorDisplay } from './tool-result-text.mjs';
|
|
8
|
+
import { toolCallId, toolResultCallId, toolCallName, toolCallArgs } from './tool-call-fields.mjs';
|
|
9
|
+
import { toolResultStatus, isErrorToolStatus } from './agent-envelope.mjs';
|
|
10
|
+
import { promptDisplayText } from './queue-helpers.mjs';
|
|
11
|
+
import { yieldToRenderer } from './render-timing.mjs';
|
|
12
|
+
import { aggregateRawResult, aggregateBucketForCategory, aggregateSummaries, assignAggregateSummaryOrder } from './tool-result-status.mjs';
|
|
13
|
+
|
|
14
|
+
export function createRunTurn(bag) {
|
|
15
|
+
const {
|
|
16
|
+
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,
|
|
17
|
+
} = bag;
|
|
18
|
+
|
|
19
|
+
async function runTurn(userText, options = {}) {
|
|
20
|
+
const turnIndex = getState().stats.turns || 0;
|
|
21
|
+
const startedAt = Date.now();
|
|
22
|
+
// Per-turn epoch. Force-release (watchdog grace) bumps the shared counter so
|
|
23
|
+
// this turn's own eventual `finally` — which may run LONG after force-release
|
|
24
|
+
// already started a new turn that reuses the per-session mutex — can detect
|
|
25
|
+
// it is stale and skip all shared-getState() writes (busy, flags.activePromptRestore,
|
|
26
|
+
// turndone, drain kick). Neutralizes the stale unwind without touching the mutex.
|
|
27
|
+
const turnEpoch = ++flags.leadTurnEpoch;
|
|
28
|
+
const inputBaseline = getState().stats.inputTokens;
|
|
29
|
+
const outputBaseline = getState().stats.outputTokens;
|
|
30
|
+
const submittedIds = Array.isArray(options.submittedIds) ? options.submittedIds : [];
|
|
31
|
+
const displayText = promptDisplayText(userText, options);
|
|
32
|
+
let promptCommittedCallbackCalled = false;
|
|
33
|
+
flags.activePromptRestore = {
|
|
34
|
+
text: String(displayText || '').trim(),
|
|
35
|
+
pastedImages: options.pastedImages && typeof options.pastedImages === 'object' ? options.pastedImages : null,
|
|
36
|
+
pastedTexts: options.pastedTexts && typeof options.pastedTexts === 'object' ? options.pastedTexts : null,
|
|
37
|
+
onCommitted: typeof options.onCommitted === 'function' ? options.onCommitted : null,
|
|
38
|
+
restorable: options.restorable !== false,
|
|
39
|
+
submittedIds,
|
|
40
|
+
reclaimed: false,
|
|
41
|
+
committed: false,
|
|
42
|
+
requeueEntries: Array.isArray(options.requeueOnAbort) ? options.requeueOnAbort.slice() : [],
|
|
43
|
+
};
|
|
44
|
+
set({ busy: true, lastTurn: null, spinner: { active: true, verb: pickVerb(turnIndex), startedAt, responseLength: 0, inputTokens: 0, outputTokens: 0, mode: 'requesting' } });
|
|
45
|
+
|
|
46
|
+
let assistantText = '';
|
|
47
|
+
let currentAssistantId = null;
|
|
48
|
+
|
|
49
|
+
tuiDebug(`runTurn start turn=${turnIndex} pending=${pending.length} timeoutMs=${LEAD_TURN_TIMEOUT_MS}`);
|
|
50
|
+
// ── Wall-clock watchdog ───────────────────────────────────────────────
|
|
51
|
+
// If this turn never unwinds (provider call stuck), trip after the cap:
|
|
52
|
+
// abort the in-flight run via the existing interrupt path (runtime.abort,
|
|
53
|
+
// the same one Esc uses), which rejects the pending runtime.ask() with a
|
|
54
|
+
// SessionClosedError so the normal cancelled-turn teardown runs. If even
|
|
55
|
+
// that unwind is starved, forceReleaseTurn() in the timer hard-clears busy
|
|
56
|
+
// and kicks the drain so input is never permanently dead.
|
|
57
|
+
let watchdogTripped = false;
|
|
58
|
+
let watchdogTimer = null;
|
|
59
|
+
let watchdogGraceTimer = null;
|
|
60
|
+
const clearWatchdog = () => {
|
|
61
|
+
if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
|
|
62
|
+
if (watchdogGraceTimer) { clearTimeout(watchdogGraceTimer); watchdogGraceTimer = null; }
|
|
63
|
+
};
|
|
64
|
+
watchdogTimer = setTimeout(() => {
|
|
65
|
+
if (flags.disposed) return;
|
|
66
|
+
watchdogTripped = true;
|
|
67
|
+
const elapsed = Date.now() - startedAt;
|
|
68
|
+
tuiDebug(`runTurn WATCHDOG TRIP turn=${turnIndex} elapsedMs=${elapsed} — aborting stuck turn`);
|
|
69
|
+
pushNotice(`Turn timed out after ${Math.round(elapsed / 1000)}s — aborting stuck request. Input is available again.`, 'warn', { transcript: true });
|
|
70
|
+
try { runtime.abort('cli-react-abort-watchdog'); } catch {}
|
|
71
|
+
// Belt-and-suspenders: if runtime.abort() did not reject runtime.ask()
|
|
72
|
+
// (unwind starved), hard-release the turn after a short grace so the
|
|
73
|
+
// React store is never left with busy=true and no drain in flight.
|
|
74
|
+
watchdogGraceTimer = setTimeout(() => {
|
|
75
|
+
if (flags.disposed) return;
|
|
76
|
+
if (!getState().busy) return;
|
|
77
|
+
if (flags.leadTurnEpoch !== turnEpoch) return; // a newer turn already owns the store
|
|
78
|
+
tuiDebug(`runTurn WATCHDOG FORCE-RELEASE turn=${turnIndex} — abort unwind starved`);
|
|
79
|
+
// Bump the epoch FIRST so this (still-stuck) turn's later finally becomes
|
|
80
|
+
// a no-op for shared getState() and cannot corrupt the turn we hand off to.
|
|
81
|
+
flags.leadTurnEpoch++;
|
|
82
|
+
set({ busy: false, spinner: null, thinking: null, lastTurn: null });
|
|
83
|
+
flags.activePromptRestore = null;
|
|
84
|
+
if (flags.draining) flags.draining = false;
|
|
85
|
+
if (pending.length > 0) void drain();
|
|
86
|
+
}, 5_000);
|
|
87
|
+
watchdogGraceTimer.unref?.();
|
|
88
|
+
}, LEAD_TURN_TIMEOUT_MS);
|
|
89
|
+
watchdogTimer.unref?.();
|
|
90
|
+
let currentAssistantText = '';
|
|
91
|
+
let thinkingText = '';
|
|
92
|
+
let thinkingStartedAt = 0;
|
|
93
|
+
let thinkingSegmentStartedAt = 0;
|
|
94
|
+
let accumulatedThinkingMs = 0;
|
|
95
|
+
let cancelled = false;
|
|
96
|
+
let askResult = null;
|
|
97
|
+
let turnFinishedNormally = false;
|
|
98
|
+
const itemsAtTurnStart = getState().items.length;
|
|
99
|
+
const cardByCallId = new Map();
|
|
100
|
+
const toolCards = [];
|
|
101
|
+
const toolGroups = new Map();
|
|
102
|
+
const resultsDone = new Set();
|
|
103
|
+
// Streaming providers can deliver eager onToolResult before onToolCall registers
|
|
104
|
+
// cards (send() still in flight). Hold those by callId until the batch lands.
|
|
105
|
+
const earlyResultBuffer = new Map();
|
|
106
|
+
const aggregateCards = []; // active aggregate cards in the current consecutive tool block
|
|
107
|
+
let tailAggregate = null; // most recently touched aggregate card; only the tail may absorb the next same-bucket call
|
|
108
|
+
|
|
109
|
+
// ── Deferred tool-card push (scroll/text sync) ────────────────────────────
|
|
110
|
+
// A tool card used to enter the transcript the instant onToolCall fired,
|
|
111
|
+
// reserving its estimated height (margin+header+detail) while ToolExecution
|
|
112
|
+
// only painted blank placeholder rows for TOOL_PENDING_SHOW_DELAY_MS. With a
|
|
113
|
+
// bottom-fixed viewport that shoved the body up BEFORE any glyph appeared, so
|
|
114
|
+
// the scroll ran ahead of the text. We now hold each card off-screen for the
|
|
115
|
+
// same delay and push it only when its real header/detail will paint (delay
|
|
116
|
+
// elapsed) OR a result lands first (fast tool → completed card, no pending
|
|
117
|
+
// flicker). Either way the pushed spec is stamped `deferredDisplayReady` so
|
|
118
|
+
// ToolExecution renders the real header + 'Running' detail immediately
|
|
119
|
+
// instead of the blank pre-delay placeholder — this matters for the
|
|
120
|
+
// result-forced chain push (flushDeferredUpTo), where earlier-seq sibling
|
|
121
|
+
// cards are pushed alongside the result-bearing one before their own delay
|
|
122
|
+
// elapses and would otherwise paint an empty reserved band.
|
|
123
|
+
// Mirrors components/ToolExecution.jsx TOOL_PENDING_SHOW_DELAY_MS.
|
|
124
|
+
// [jitter fix] Reserve the tool-card row immediately instead of after a
|
|
125
|
+
// 1000ms delay. Delayed insertion made rows appear late and shove the body
|
|
126
|
+
// vertically; pushing now (with the real 'Running' header height via
|
|
127
|
+
// deferredDisplayReady) keeps the layout stable. The timer path is kept as a
|
|
128
|
+
// 0ms microtask fallback for entries that are registered but not explicitly
|
|
129
|
+
// surfaced, preserving call-order flush semantics.
|
|
130
|
+
const TOOL_CARD_PUSH_DELAY_MS = 0;
|
|
131
|
+
let deferredSeqCounter = 0;
|
|
132
|
+
const deferredEntries = []; // creation-order list; each is pushed at most once
|
|
133
|
+
// Push this entry AND every earlier-created still-deferred entry, in order,
|
|
134
|
+
// so transcript order always matches call order even when a later card's
|
|
135
|
+
// result/timer fires before an earlier one's.
|
|
136
|
+
const flushDeferredUpTo = (entry) => {
|
|
137
|
+
if (!entry) return;
|
|
138
|
+
for (const e of deferredEntries) {
|
|
139
|
+
if (e.seq > entry.seq) break;
|
|
140
|
+
if (e.pushed) continue;
|
|
141
|
+
e.pushed = true;
|
|
142
|
+
if (e.timer) { clearTimeout(e.timer); e.timer = null; }
|
|
143
|
+
try { e.push(); } catch {}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
flags.flushDeferredBeforeImmediatePush = () => {
|
|
147
|
+
if (!deferredEntries.length) return;
|
|
148
|
+
const last = deferredEntries[deferredEntries.length - 1];
|
|
149
|
+
if (last) flushDeferredUpTo(last);
|
|
150
|
+
};
|
|
151
|
+
const registerDeferredCard = (card) => {
|
|
152
|
+
const entry = {
|
|
153
|
+
seq: deferredSeqCounter++,
|
|
154
|
+
pushed: false,
|
|
155
|
+
timer: null,
|
|
156
|
+
// Mark the card visible and return its spec WITHOUT emitting, so a
|
|
157
|
+
// batched turn-close flush can commit many specs in one set().
|
|
158
|
+
materialize: () => {
|
|
159
|
+
card.pushed = true;
|
|
160
|
+
if (!card.spec) return null;
|
|
161
|
+
card.spec.deferredDisplayReady = true;
|
|
162
|
+
return card.spec;
|
|
163
|
+
},
|
|
164
|
+
push: () => {
|
|
165
|
+
const spec = entry.materialize();
|
|
166
|
+
if (!spec) return;
|
|
167
|
+
flags.pushingFromDeferredEntry = true;
|
|
168
|
+
try { pushItem(spec); } finally { flags.pushingFromDeferredEntry = false; }
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
card.deferred = entry;
|
|
172
|
+
card.ensureVisible = () => flushDeferredUpTo(entry);
|
|
173
|
+
deferredEntries.push(entry);
|
|
174
|
+
entry.timer = setTimeout(() => {
|
|
175
|
+
entry.timer = null;
|
|
176
|
+
if (flags.disposed) return;
|
|
177
|
+
flushDeferredUpTo(entry);
|
|
178
|
+
}, TOOL_CARD_PUSH_DELAY_MS);
|
|
179
|
+
entry.timer.unref?.();
|
|
180
|
+
};
|
|
181
|
+
const registerDeferredAggregate = (aggregate) => {
|
|
182
|
+
const entry = {
|
|
183
|
+
seq: deferredSeqCounter++,
|
|
184
|
+
pushed: false,
|
|
185
|
+
timer: null,
|
|
186
|
+
materialize: () => {
|
|
187
|
+
aggregate.pushed = true;
|
|
188
|
+
if (!aggregate.pendingSpec) return null;
|
|
189
|
+
aggregate.pendingSpec.deferredDisplayReady = true;
|
|
190
|
+
return aggregate.pendingSpec;
|
|
191
|
+
},
|
|
192
|
+
push: () => {
|
|
193
|
+
const spec = entry.materialize();
|
|
194
|
+
if (!spec) return;
|
|
195
|
+
flags.pushingFromDeferredEntry = true;
|
|
196
|
+
try { pushItem(spec); } finally { flags.pushingFromDeferredEntry = false; }
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
aggregate.deferred = entry;
|
|
200
|
+
aggregate.ensureVisible = () => flushDeferredUpTo(entry);
|
|
201
|
+
deferredEntries.push(entry);
|
|
202
|
+
entry.timer = setTimeout(() => {
|
|
203
|
+
entry.timer = null;
|
|
204
|
+
if (flags.disposed) return;
|
|
205
|
+
flushDeferredUpTo(entry);
|
|
206
|
+
}, TOOL_CARD_PUSH_DELAY_MS);
|
|
207
|
+
entry.timer.unref?.();
|
|
208
|
+
};
|
|
209
|
+
const clearDeferredTimers = () => {
|
|
210
|
+
for (const e of deferredEntries) {
|
|
211
|
+
if (e.timer) { clearTimeout(e.timer); e.timer = null; }
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
// Collect (mark pushed + cancel timers) every still-deferred entry up to
|
|
215
|
+
// `entry` in creation order, returning their specs WITHOUT emitting — the
|
|
216
|
+
// caller commits them (optionally alongside a trailing turndone item) in a
|
|
217
|
+
// single set() so turn-close writes land as ONE visible commit.
|
|
218
|
+
const collectDeferredUpTo = (entry) => {
|
|
219
|
+
const specs = [];
|
|
220
|
+
if (!entry) return specs;
|
|
221
|
+
for (const e of deferredEntries) {
|
|
222
|
+
if (e.seq > entry.seq) break;
|
|
223
|
+
if (e.pushed) continue;
|
|
224
|
+
e.pushed = true;
|
|
225
|
+
if (e.timer) { clearTimeout(e.timer); e.timer = null; }
|
|
226
|
+
const spec = e.materialize?.();
|
|
227
|
+
if (spec) specs.push(spec);
|
|
228
|
+
}
|
|
229
|
+
return specs;
|
|
230
|
+
};
|
|
231
|
+
// Append pre-built items (deferred cards + turndone) in ONE set(). None are
|
|
232
|
+
// 'user' kind, so no promptHistory rebuild is needed.
|
|
233
|
+
const appendItemsBatch = (newItems, extra = {}) => {
|
|
234
|
+
if (!newItems || !newItems.length) { set(extra); return; }
|
|
235
|
+
const base = getState().items.length;
|
|
236
|
+
const items = [...getState().items, ...newItems];
|
|
237
|
+
for (let i = 0; i < newItems.length; i++) {
|
|
238
|
+
const it = newItems[i];
|
|
239
|
+
if (it?.id != null) itemIndexById.set(it.id, base + i);
|
|
240
|
+
}
|
|
241
|
+
set({ items, ...extra });
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const markPromptCommitted = () => {
|
|
245
|
+
if (flags.activePromptRestore) {
|
|
246
|
+
if (!promptCommittedCallbackCalled && typeof flags.activePromptRestore.onCommitted === 'function') {
|
|
247
|
+
promptCommittedCallbackCalled = true;
|
|
248
|
+
try { flags.activePromptRestore.onCommitted(); } catch {}
|
|
249
|
+
}
|
|
250
|
+
flags.activePromptRestore.restorable = false;
|
|
251
|
+
flags.activePromptRestore.committed = true;
|
|
252
|
+
flags.activePromptRestore.requeueEntries = [];
|
|
253
|
+
flags.activePromptRestore.pastedImages = null;
|
|
254
|
+
flags.activePromptRestore.pastedTexts = null;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const finalizeToolHeaders = () => {
|
|
259
|
+
const ids = new Set();
|
|
260
|
+
for (const card of toolCards || []) {
|
|
261
|
+
if (card?.itemId) ids.add(card.itemId);
|
|
262
|
+
// Seal not-yet-pushed specs too, so a card that pushes later (timer)
|
|
263
|
+
// enters already-finalized instead of flashing the active header form.
|
|
264
|
+
if (card && card.pushed === false && card.spec) card.spec.headerFinalized = true;
|
|
265
|
+
}
|
|
266
|
+
for (const aggregate of aggregateCards || []) {
|
|
267
|
+
if (aggregate?.itemId) ids.add(aggregate.itemId);
|
|
268
|
+
if (aggregate && aggregate.pushed === false && aggregate.pendingSpec) aggregate.pendingSpec.headerFinalized = true;
|
|
269
|
+
}
|
|
270
|
+
if (ids.size === 0) return false;
|
|
271
|
+
let changed = false;
|
|
272
|
+
const items = getState().items.map((item) => {
|
|
273
|
+
if (!ids.has(item?.id) || item.kind !== 'tool' || item.headerFinalized !== false) return item;
|
|
274
|
+
changed = true;
|
|
275
|
+
return { ...item, headerFinalized: true };
|
|
276
|
+
});
|
|
277
|
+
if (changed) set({ items });
|
|
278
|
+
return changed;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const completeAggregateVisual = () => {
|
|
282
|
+
for (const aggregate of aggregateCards) {
|
|
283
|
+
const allCalls = [...aggregate.calls.values()];
|
|
284
|
+
if (allCalls.length === 0) continue;
|
|
285
|
+
aggregate.ensureVisible?.();
|
|
286
|
+
const errors = allCalls.filter((r) => r.isError).length;
|
|
287
|
+
const completed = allCalls.filter((r) => r.resolved).length;
|
|
288
|
+
const succeeded = completed - errors;
|
|
289
|
+
const rawResult = aggregateRawResult(allCalls);
|
|
290
|
+
// Merged count summary (see patchToolCardResult); failures keep
|
|
291
|
+
// 'N Ok · N Failed'. Raw preserved for ctrl+o expansion.
|
|
292
|
+
const displayDetail = errors > 0
|
|
293
|
+
? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
|
|
294
|
+
: formatAggregateDetail(aggregateSummaries(aggregate));
|
|
295
|
+
patchItem(aggregate.itemId, {
|
|
296
|
+
result: displayDetail,
|
|
297
|
+
text: displayDetail,
|
|
298
|
+
rawResult: rawResult || null,
|
|
299
|
+
isError: errors > 0,
|
|
300
|
+
count: allCalls.length,
|
|
301
|
+
completedCount: allCalls.length,
|
|
302
|
+
completedAt: Date.now(),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const clearAggregateContinuation = () => {
|
|
308
|
+
completeAggregateVisual();
|
|
309
|
+
finalizeToolHeaders();
|
|
310
|
+
aggregateCards.length = 0;
|
|
311
|
+
// Seal the block: same-bucket calls after this point must open a fresh
|
|
312
|
+
// card, never continue one from before the seal (assistant text/turn
|
|
313
|
+
// end boundary).
|
|
314
|
+
tailAggregate = null;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const rememberActiveAggregate = (aggregate) => {
|
|
318
|
+
if (!aggregate) return;
|
|
319
|
+
if (!aggregateCards.includes(aggregate)) aggregateCards.push(aggregate);
|
|
320
|
+
tailAggregate = aggregate;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const ensureAggregateCard = (bucket) => {
|
|
324
|
+
// Only the TAIL aggregate (most recent card) may absorb the next call,
|
|
325
|
+
// and only when the bucket matches. Any different-bucket aggregate or
|
|
326
|
+
// standalone card in between breaks the run, so Search, Memory, Search
|
|
327
|
+
// renders as three cards in call order — a new call never merges into
|
|
328
|
+
// an earlier card above the current tail (which read as out-of-order
|
|
329
|
+
// count changes in the transcript). clearAggregateContinuation seals
|
|
330
|
+
// the block at assistant-text/turn boundaries.
|
|
331
|
+
const cached = tailAggregate && tailAggregate.bucket === bucket ? tailAggregate : null;
|
|
332
|
+
if (cached) {
|
|
333
|
+
rememberActiveAggregate(cached);
|
|
334
|
+
return cached;
|
|
335
|
+
}
|
|
336
|
+
const itemId = nextId();
|
|
337
|
+
const aggregate = {
|
|
338
|
+
itemId,
|
|
339
|
+
bucket,
|
|
340
|
+
categories: new Map(),
|
|
341
|
+
categoryOrder: [],
|
|
342
|
+
calls: new Map(),
|
|
343
|
+
nextSummarySeq: 0,
|
|
344
|
+
pushed: false,
|
|
345
|
+
startedAt: Date.now(),
|
|
346
|
+
};
|
|
347
|
+
// Arm the deferred push once at creation; syncAggregateHeader only keeps
|
|
348
|
+
// pendingSpec current until the timer/result flushes it in call order.
|
|
349
|
+
registerDeferredAggregate(aggregate);
|
|
350
|
+
rememberActiveAggregate(aggregate);
|
|
351
|
+
return aggregate;
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const syncAggregateHeader = (aggregate) => {
|
|
355
|
+
if (!aggregate?.itemId) return;
|
|
356
|
+
const patch = {
|
|
357
|
+
args: { categoryOrder: aggregate.categoryOrder.slice() },
|
|
358
|
+
count: aggregate.calls.size,
|
|
359
|
+
completedCount: [...aggregate.calls.values()].filter((r) => r.resolved || r.completedEarly).length,
|
|
360
|
+
categories: Object.fromEntries(aggregate.categories),
|
|
361
|
+
};
|
|
362
|
+
if (aggregate.pushed) {
|
|
363
|
+
patchItem(aggregate.itemId, patch);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
// Not yet visible: keep the latest header spec current. The deferred entry
|
|
367
|
+
// (armed at creation) pushes pendingSpec when its timer fires or a result
|
|
368
|
+
// forces it visible, preserving call order via flushDeferredUpTo.
|
|
369
|
+
aggregate.pendingSpec = {
|
|
370
|
+
kind: 'tool',
|
|
371
|
+
id: aggregate.itemId,
|
|
372
|
+
name: '__aggregate__',
|
|
373
|
+
...patch,
|
|
374
|
+
aggregate: true,
|
|
375
|
+
result: null,
|
|
376
|
+
rawResult: null,
|
|
377
|
+
isError: false,
|
|
378
|
+
expanded: false,
|
|
379
|
+
headerFinalized: false,
|
|
380
|
+
startedAt: aggregate.startedAt || Date.now(),
|
|
381
|
+
};
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const ensureAssistant = (initialText = '') => {
|
|
385
|
+
if (!currentAssistantId) {
|
|
386
|
+
currentAssistantId = nextId();
|
|
387
|
+
// Do NOT reset currentAssistantText here. The first onTextDelta has
|
|
388
|
+
// already accumulated the opening chunk before this batched flush runs;
|
|
389
|
+
// wiping it dropped the leading characters and forced a later set() to
|
|
390
|
+
// re-add them. Segment resets are owned by closeAssistantSegment().
|
|
391
|
+
// Seed the new row with the already-visible text so the ● gutter and the
|
|
392
|
+
// first body line appear in the SAME set()/emit() — no empty "●-only"
|
|
393
|
+
// row that scrolls once on its own and again when the body lands.
|
|
394
|
+
pushItem({ kind: 'assistant', id: currentAssistantId, text: String(initialText || ''), streaming: true });
|
|
395
|
+
}
|
|
396
|
+
return currentAssistantId;
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const closeAssistantSegment = () => {
|
|
400
|
+
currentAssistantId = null;
|
|
401
|
+
currentAssistantText = '';
|
|
402
|
+
// Reset incremental-flush getState() so the next segment rescans from scratch.
|
|
403
|
+
_streamScanLen = 0;
|
|
404
|
+
_lastNewlineIdx = -1;
|
|
405
|
+
_emittedNewlineIdx = -2;
|
|
406
|
+
_emittedVisibleText = '';
|
|
407
|
+
_cachedAssistantIndex = -1;
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const commitAssistantSegment = ({ sealToolBlock = false } = {}) => {
|
|
411
|
+
const text = currentAssistantText || '';
|
|
412
|
+
if (!text.trim()) {
|
|
413
|
+
closeAssistantSegment();
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
if (sealToolBlock) clearAggregateContinuation();
|
|
417
|
+
const id = currentAssistantId || ensureAssistant(text);
|
|
418
|
+
patchItem(id, { text, streaming: false });
|
|
419
|
+
closeAssistantSegment();
|
|
420
|
+
return true;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const startThinkingSegment = () => {
|
|
424
|
+
const now = Date.now();
|
|
425
|
+
if (!thinkingStartedAt) thinkingStartedAt = now;
|
|
426
|
+
if (!thinkingSegmentStartedAt) thinkingSegmentStartedAt = now;
|
|
427
|
+
return now;
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const closeThinkingSegment = () => {
|
|
431
|
+
if (!thinkingSegmentStartedAt) return;
|
|
432
|
+
const now = Date.now();
|
|
433
|
+
accumulatedThinkingMs += Math.max(0, now - thinkingSegmentStartedAt);
|
|
434
|
+
thinkingSegmentStartedAt = 0;
|
|
435
|
+
return now;
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
// --- Streaming-delta batcher ---
|
|
439
|
+
// onTextDelta and onReasoningDelta fire on every tiny chunk (often <10 chars).
|
|
440
|
+
// Each call previously called set() → emit() → full React reconcile. We
|
|
441
|
+
// batch accumulated text and flush at most once per STREAM_BATCH_INTERVAL_MS
|
|
442
|
+
// (≈16ms / 60fps cap). A forced flush happens before any tool call,
|
|
443
|
+
// finalization, or error so those code paths see the correct text getState().
|
|
444
|
+
// Flush cadence for streamed text/thinking. 8ms (~120fps) matches the Ink
|
|
445
|
+
// render maxFps (index.jsx render({ maxFps: 120 })), so a queued batch is
|
|
446
|
+
// never held back waiting for the next Ink frame. 16ms (~60fps) left every
|
|
447
|
+
// other Ink frame idle, which made fast provider streams visibly land in
|
|
448
|
+
// coarse chunks ("10 chars at a time").
|
|
449
|
+
const STREAM_BATCH_INTERVAL_MS = 16;
|
|
450
|
+
let _batchTimer = null;
|
|
451
|
+
let _pendingTextFlush = false; // true when a text/spinner update is queued
|
|
452
|
+
let _pendingThinkFlush = false; // true when a thinking update is queued
|
|
453
|
+
let _pendingThinkingLastEndedAt = 0;
|
|
454
|
+
let compactingActive = false;
|
|
455
|
+
// Incremental streaming-flush getState(): avoids rescanning the full accumulated
|
|
456
|
+
// assistant text (lastIndexOf) and re-finding the row index on every flush.
|
|
457
|
+
let _streamScanLen = 0; // chars of currentAssistantText already scanned for '\n'
|
|
458
|
+
let _lastNewlineIdx = -1; // offset of the last completed-line '\n' found so far
|
|
459
|
+
let _emittedNewlineIdx = -2; // newline offset backing _emittedVisibleText (-2 forces first compute)
|
|
460
|
+
let _emittedVisibleText = ''; // cached visible slice for the current newline offset
|
|
461
|
+
let _cachedAssistantIndex = -1;// cached getState().items index of the streaming assistant row
|
|
462
|
+
// Engine-local streaming scalars. Neither responseLength nor thinkingText is
|
|
463
|
+
// rendered per-token by any consumer: App reads getState().thinking only as a
|
|
464
|
+
// boolean (App.jsx `!!(getState().thinking || liveSpinner?.thinking)`) and the
|
|
465
|
+
// Spinner takes outputTokens, not responseLength. So we keep these growing
|
|
466
|
+
// values in engine-local vars and publish to the store only on a visible
|
|
467
|
+
// transition (thinking on↔off), a completed visible text line, tool/usage
|
|
468
|
+
// updates, or finalization — not on every 8ms streaming flush.
|
|
469
|
+
let _publishedThinkingActive = false; // last thinking boolean pushed to store
|
|
470
|
+
// responseLength is only consumed at finalize as an outputTokens fallback
|
|
471
|
+
// (Math.round(responseLength/4)); we refresh getState().spinner.responseLength on
|
|
472
|
+
// visible-line flush and finalize so that fallback stays valid.
|
|
473
|
+
|
|
474
|
+
const flushStreamBatch = () => {
|
|
475
|
+
if (_batchTimer !== null) {
|
|
476
|
+
clearTimeout(_batchTimer);
|
|
477
|
+
_batchTimer = null;
|
|
478
|
+
}
|
|
479
|
+
if (_pendingTextFlush) {
|
|
480
|
+
_pendingTextFlush = false;
|
|
481
|
+
// Show only COMPLETED lines while streaming. The in-progress trailing
|
|
482
|
+
// line stays hidden until its '\n' arrives, so the visible text never
|
|
483
|
+
// grows a glyph at a time (no "Wh"→pause→"What happened…" partial reveal, no
|
|
484
|
+
// CJK-width reflow jitter). The final non-streaming patch
|
|
485
|
+
// (streaming:false) always carries the full text, so the tail line that
|
|
486
|
+
// never got a newline still lands once at finalize.
|
|
487
|
+
// Incrementally track the last completed-line '\n' offset instead of
|
|
488
|
+
// rescanning the whole accumulated text every flush. Each char is
|
|
489
|
+
// examined once across the stream (amortized O(n) total, not O(n) per
|
|
490
|
+
// flush); when the newline offset hasn't advanced the visible text is
|
|
491
|
+
// byte-identical to the last flush, so the slice below is skipped and
|
|
492
|
+
// reused. Reveal semantics are unchanged: still only completed lines.
|
|
493
|
+
const textLen = currentAssistantText.length;
|
|
494
|
+
if (textLen < _streamScanLen) { _streamScanLen = 0; _lastNewlineIdx = -1; }
|
|
495
|
+
for (let i = _streamScanLen; i < textLen; i++) {
|
|
496
|
+
if (currentAssistantText.charCodeAt(i) === 10) _lastNewlineIdx = i;
|
|
497
|
+
}
|
|
498
|
+
_streamScanLen = textLen;
|
|
499
|
+
let streamingVisibleText;
|
|
500
|
+
if (_lastNewlineIdx === _emittedNewlineIdx) {
|
|
501
|
+
streamingVisibleText = _emittedVisibleText;
|
|
502
|
+
} else {
|
|
503
|
+
streamingVisibleText = _lastNewlineIdx >= 0
|
|
504
|
+
? currentAssistantText.slice(0, _lastNewlineIdx + 1)
|
|
505
|
+
: '';
|
|
506
|
+
_emittedNewlineIdx = _lastNewlineIdx;
|
|
507
|
+
_emittedVisibleText = streamingVisibleText;
|
|
508
|
+
}
|
|
509
|
+
const patch = {};
|
|
510
|
+
// Do NOT create the assistant row (and scroll the transcript) before
|
|
511
|
+
// there is a completed line with VISIBLE content to show. Until the
|
|
512
|
+
// first '\n' the only pending getState() is the spinner; the row appears
|
|
513
|
+
// together with its first visible line, so no empty "●-only" row
|
|
514
|
+
// flashes/scrolls ahead of text. `.trim()` also guards the
|
|
515
|
+
// whitespace-only case: a response that opens with leading newlines
|
|
516
|
+
// ("\n\n# …") completes a blank line first, whose estimated height
|
|
517
|
+
// still reserves rows and scrolls the transcript, but Markdown trims
|
|
518
|
+
// the body to nothing — so the scroll advances onto an empty band for
|
|
519
|
+
// a few seconds until a non-blank line lands. Don't create the row
|
|
520
|
+
// until there is real content to paint.
|
|
521
|
+
if (currentAssistantId || streamingVisibleText.trim()) {
|
|
522
|
+
const id = ensureAssistant(streamingVisibleText);
|
|
523
|
+
// Emit the accumulated assistant text and spinner update together so a
|
|
524
|
+
// streaming batch costs one set() → one emit() → one React reconcile.
|
|
525
|
+
// Cache the streaming row's index; re-find only on a cache miss (row
|
|
526
|
+
// added/removed) instead of a findIndex scan on every flush.
|
|
527
|
+
let index = _cachedAssistantIndex;
|
|
528
|
+
if (index < 0 || getState().items[index]?.id !== id) {
|
|
529
|
+
index = getState().items.findIndex((it) => it.id === id);
|
|
530
|
+
_cachedAssistantIndex = index;
|
|
531
|
+
}
|
|
532
|
+
if (index >= 0) {
|
|
533
|
+
const current = getState().items[index];
|
|
534
|
+
if (!Object.is(current.text, streamingVisibleText) || current.streaming !== true) {
|
|
535
|
+
const items = getState().items.slice();
|
|
536
|
+
items[index] = { ...current, text: streamingVisibleText, streaming: true };
|
|
537
|
+
patch.items = items;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
// Only touch the spinner when there is a real reason: a visible-line
|
|
542
|
+
// change (patch.items set above), a thinking→responding transition, or a
|
|
543
|
+
// pending thinking end timestamp. Refresh responseLength here so the
|
|
544
|
+
// finalize outputTokens fallback stays valid without a per-token push.
|
|
545
|
+
const responseLengthVal = assistantText.length + thinkingText.length;
|
|
546
|
+
const visibleLineChanged = patch.items !== undefined;
|
|
547
|
+
const thinkingTransition = _publishedThinkingActive === true; // was thinking, now responding
|
|
548
|
+
if (getState().spinner && (visibleLineChanged || thinkingTransition || _pendingThinkingLastEndedAt)) {
|
|
549
|
+
patch.spinner = { ...getState().spinner, responseLength: responseLengthVal, thinking: false, thinkingLastEndedAt: _pendingThinkingLastEndedAt || getState().spinner.thinkingLastEndedAt, mode: compactingActive ? 'compacting' : 'responding' };
|
|
550
|
+
_publishedThinkingActive = false;
|
|
551
|
+
}
|
|
552
|
+
if (Object.keys(patch).length > 0) set(patch);
|
|
553
|
+
_pendingThinkingLastEndedAt = 0;
|
|
554
|
+
}
|
|
555
|
+
if (_pendingThinkFlush) {
|
|
556
|
+
_pendingThinkFlush = false;
|
|
557
|
+
// App only consumes getState().thinking as a boolean and the Spinner only
|
|
558
|
+
// reads the thinking flag + timing anchors — none of them render the
|
|
559
|
+
// growing thinkingText. So publish the thinking boolean only on the
|
|
560
|
+
// OFF→ON transition (or when compacting toggles the flag), not on every
|
|
561
|
+
// 8ms reasoning chunk. The full thinkingText stays engine-local and is
|
|
562
|
+
// emitted at finalize via the normal spinner/thinking teardown.
|
|
563
|
+
const nextThinkingActive = !compactingActive;
|
|
564
|
+
// Skip the push when the published thinking boolean is unchanged: neither
|
|
565
|
+
// the growing thinkingText nor responseLength is rendered per-token, and
|
|
566
|
+
// the Spinner derives its live elapsed from the (already-published)
|
|
567
|
+
// thinkingSegmentStartedAt anchor. Applies to both thinking and
|
|
568
|
+
// compacting steady getState().
|
|
569
|
+
if (nextThinkingActive === _publishedThinkingActive) {
|
|
570
|
+
// no-op: boolean unchanged
|
|
571
|
+
} else {
|
|
572
|
+
const responseLengthVal = assistantText.length + thinkingText.length;
|
|
573
|
+
const thinkingElapsedMs = accumulatedThinkingMs + (thinkingSegmentStartedAt ? Math.max(0, Date.now() - thinkingSegmentStartedAt) : 0);
|
|
574
|
+
// getState().thinking stays a truthy sentinel while active; consumers read it
|
|
575
|
+
// as a boolean. Keep the value stable (thinkingText) so a late consumer
|
|
576
|
+
// still sees real text, but only push on transition.
|
|
577
|
+
const patch = { thinking: compactingActive ? null : thinkingText };
|
|
578
|
+
if (getState().spinner) {
|
|
579
|
+
patch.spinner = compactingActive
|
|
580
|
+
? { ...getState().spinner, responseLength: responseLengthVal, thinking: false, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingElapsedMs, thinkingLastEndedAt: getState().spinner.thinkingLastEndedAt || 0, mode: 'compacting' }
|
|
581
|
+
: { ...getState().spinner, responseLength: responseLengthVal, thinking: true, thinkingStartedAt, thinkingSegmentStartedAt, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingElapsedMs, thinkingLastEndedAt: 0, mode: 'thinking' };
|
|
582
|
+
}
|
|
583
|
+
set(patch);
|
|
584
|
+
_publishedThinkingActive = nextThinkingActive;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
const scheduleStreamFlush = () => {
|
|
590
|
+
if (_batchTimer !== null) return; // already scheduled; do not re-arm
|
|
591
|
+
_batchTimer = setTimeout(flushStreamBatch, STREAM_BATCH_INTERVAL_MS);
|
|
592
|
+
if (_batchTimer?.unref) _batchTimer.unref(); // don't prevent process exit
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
// __earlyNotify: show 1-line summary + completedCount immediately; defer
|
|
596
|
+
// rawResult/expand and resultsDone to the history flush.
|
|
597
|
+
const markToolCardCompletedState = (callId, message) => {
|
|
598
|
+
const card = cardByCallId.get(callId);
|
|
599
|
+
if (!card) return;
|
|
600
|
+
// Early completion also clears the active-summary entry.
|
|
601
|
+
markToolCallDone(card.callId);
|
|
602
|
+
const aggregate = card.aggregate;
|
|
603
|
+
if (aggregate && card.itemId === aggregate.itemId) {
|
|
604
|
+
const callRec = aggregate.calls.get(callId);
|
|
605
|
+
if (!callRec || callRec.resolved || callRec.completedEarly) return;
|
|
606
|
+
aggregate.ensureVisible?.();
|
|
607
|
+
const rawText = toolResultText(message?.content);
|
|
608
|
+
const isError = message?.isError === true || message?.toolKind === 'error' || /^\s*\[?error/i.test(rawText) || isErrorToolStatus(toolResultStatus(rawText));
|
|
609
|
+
const text = isError ? toolErrorDisplay(rawText, callRec.name || 'tool') : rawText;
|
|
610
|
+
callRec.summary = !isError ? summarizeToolResult(callRec.name, callRec.args, rawText, isError) : null;
|
|
611
|
+
assignAggregateSummaryOrder(aggregate, callRec);
|
|
612
|
+
callRec.isError = isError;
|
|
613
|
+
callRec.resultText = text;
|
|
614
|
+
callRec.completedEarly = true;
|
|
615
|
+
const allCalls = [...aggregate.calls.values()];
|
|
616
|
+
const completedCount = allCalls.filter((r) => r.resolved || r.completedEarly).length;
|
|
617
|
+
const errors = allCalls.filter((r) => r.isError).length;
|
|
618
|
+
const succeeded = completedCount - errors;
|
|
619
|
+
const rawResult = aggregateRawResult(allCalls);
|
|
620
|
+
// Collapsed detail carries the merged per-call count summary even on
|
|
621
|
+
// the early-notify path; patching '' here flipped the detail row back
|
|
622
|
+
// to the 'Running' placeholder between count updates (the visible
|
|
623
|
+
// jitter). Failures keep 'N Failed'. Raw preserved for ctrl+o expansion.
|
|
624
|
+
const displayDetail = errors > 0
|
|
625
|
+
? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
|
|
626
|
+
: formatAggregateDetail(aggregateSummaries(aggregate));
|
|
627
|
+
const currentItem = getState().items.find((it) => it.id === card.itemId);
|
|
628
|
+
const visualCompleted = Math.max(
|
|
629
|
+
completedCount,
|
|
630
|
+
Math.min(allCalls.length, Number(currentItem?.completedCount || 0)),
|
|
631
|
+
);
|
|
632
|
+
const patch = {
|
|
633
|
+
result: displayDetail,
|
|
634
|
+
text: displayDetail,
|
|
635
|
+
isError: errors > 0,
|
|
636
|
+
errorCount: errors,
|
|
637
|
+
count: allCalls.length,
|
|
638
|
+
completedCount: visualCompleted,
|
|
639
|
+
};
|
|
640
|
+
if (visualCompleted >= allCalls.length) {
|
|
641
|
+
patch.completedAt = Number(currentItem?.completedAt) || Date.now();
|
|
642
|
+
}
|
|
643
|
+
patchItem(card.itemId, patch);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
// Non-aggregate eager tools are rare; flipping completedCount without
|
|
647
|
+
// result changes pending/detail rendering and risks row jitter — wait for
|
|
648
|
+
// the real history flush (unchanged behavior).
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
const deliverToolResultMessage = (message) => {
|
|
652
|
+
if (message?.__earlyNotify === true) {
|
|
653
|
+
const earlyCallId = toolResultCallId(message);
|
|
654
|
+
if (earlyCallId) markToolCardCompletedState(earlyCallId, message);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
flushToolResults([message], toolCards, cardByCallId, toolGroups, resultsDone);
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
try {
|
|
661
|
+
const { result, session } = await runtime.ask(userText, {
|
|
662
|
+
drainSteering: () => drainPendingSteering(),
|
|
663
|
+
onSteerMessage: (text) => {
|
|
664
|
+
// Steering can be injected after a terminal no-tool response has
|
|
665
|
+
// already streamed but before runTurn finalizes. Seal the current
|
|
666
|
+
// assistant segment first so the steered user turn and the next
|
|
667
|
+
// assistant response do not get visually merged into one bubble.
|
|
668
|
+
flushStreamBatch();
|
|
669
|
+
if (currentAssistantId) {
|
|
670
|
+
patchItem(currentAssistantId, { text: currentAssistantText || assistantText, streaming: false });
|
|
671
|
+
closeAssistantSegment();
|
|
672
|
+
}
|
|
673
|
+
assistantText = '';
|
|
674
|
+
const value = String(text || '').trim();
|
|
675
|
+
if (value) {
|
|
676
|
+
// Any non-tool transcript item is a block boundary: seal the
|
|
677
|
+
// aggregate continuation (not just finalize headers) so a later
|
|
678
|
+
// same-category tool call opens a fresh card instead of reusing
|
|
679
|
+
// one whose count would then change ABOVE this steered user item.
|
|
680
|
+
clearAggregateContinuation();
|
|
681
|
+
pushUserOrSyntheticItem(value, undefined, 'injected');
|
|
682
|
+
}
|
|
683
|
+
},
|
|
684
|
+
onToolCall: async (_iter, calls) => {
|
|
685
|
+
markPromptCommitted();
|
|
686
|
+
// Always flush any buffered mid-turn assistant text before the tool
|
|
687
|
+
// card appears. Without this, when neither a thinking panel nor a
|
|
688
|
+
// spinner is active the buffered text was dropped by the following
|
|
689
|
+
// closeAssistantSegment(), so the message above the tool card vanished.
|
|
690
|
+
flushStreamBatch();
|
|
691
|
+
if (thinkingText && getState().thinking) {
|
|
692
|
+
const thinkingLastEndedAt = closeThinkingSegment();
|
|
693
|
+
set({ thinking: null, spinner: getState().spinner ? { ...getState().spinner, thinking: false, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingLastEndedAt, mode: 'tool-use' } : getState().spinner });
|
|
694
|
+
_publishedThinkingActive = false;
|
|
695
|
+
} else if (getState().spinner) {
|
|
696
|
+
set({ spinner: { ...getState().spinner, mode: 'tool-use' } });
|
|
697
|
+
}
|
|
698
|
+
const batchCalls = (calls || []).filter(Boolean);
|
|
699
|
+
if (batchCalls.length === 0) return;
|
|
700
|
+
const committedAssistantSegment = commitAssistantSegment({ sealToolBlock: true });
|
|
701
|
+
if (committedAssistantSegment) {
|
|
702
|
+
// Let the pre-tool assistant preamble paint as its own frame before
|
|
703
|
+
// the tool card reserves/pushes rows. When both enter the transcript
|
|
704
|
+
// in the same render, the bottom-pinned viewport can appear to jump
|
|
705
|
+
// upward by the combined height ("preamble + tool card" at once).
|
|
706
|
+
await yieldToRenderer();
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const touchedAggregates = new Set();
|
|
710
|
+
// [jitter fix] Last standalone (Agent) card in this batch to reserve a
|
|
711
|
+
// row for. Flushed AFTER the syncAggregateHeader loop so any earlier-seq
|
|
712
|
+
// aggregate it would flush-through already has its pendingSpec built.
|
|
713
|
+
let standaloneReserve = null;
|
|
714
|
+
for (let i = 0; i < batchCalls.length; i++) {
|
|
715
|
+
const c = batchCalls[i];
|
|
716
|
+
const name = toolCallName(c);
|
|
717
|
+
const args = toolCallArgs(c);
|
|
718
|
+
// Category drives the aggregate bucket so only same-category calls
|
|
719
|
+
// merge into one card; classify first, then bucket by it.
|
|
720
|
+
const category = classifyToolCategory(name, args);
|
|
721
|
+
// Agent/bridge calls always get their own standalone card so
|
|
722
|
+
// ToolExecution's isAgentTool/agentActionTitle path renders a
|
|
723
|
+
// "Spawn <Agent> (model, tag)" header instead of being folded
|
|
724
|
+
// into the "Called N agent(s)" category-aggregate card.
|
|
725
|
+
const bucket = category === 'Agent' ? null : aggregateBucketForCategory(category);
|
|
726
|
+
const callId = toolCallId(c);
|
|
727
|
+
const callKey = callId || `__tool_${toolCards.length}_${i}`;
|
|
728
|
+
// The old App scan counted multi-pattern calls via
|
|
729
|
+
// aggregateToolCategoryEntry(...).count, not a flat 1. Derive the same
|
|
730
|
+
// count here so the incremental Explore/Search summary matches.
|
|
731
|
+
const activeCount = Number(aggregateToolCategoryEntry(name, args, category)?.count || 1);
|
|
732
|
+
// Track Explore/Search calls as active for the incremental prompt-
|
|
733
|
+
// line summary; cleared when their result lands or the turn ends.
|
|
734
|
+
markToolCallActive(callKey, category, activeCount, Date.now());
|
|
735
|
+
|
|
736
|
+
if (!bucket) {
|
|
737
|
+
const itemId = nextId();
|
|
738
|
+
// Defer the visible push: hold the spec and only enter the
|
|
739
|
+
// transcript when the real header/detail will paint (delay
|
|
740
|
+
// elapsed) or its result lands first. Avoids reserving blank
|
|
741
|
+
// placeholder height that scrolls the body ahead of the glyphs.
|
|
742
|
+
const card = {
|
|
743
|
+
itemId,
|
|
744
|
+
callId: callKey,
|
|
745
|
+
done: false,
|
|
746
|
+
pushed: false,
|
|
747
|
+
spec: {
|
|
748
|
+
kind: 'tool',
|
|
749
|
+
id: itemId,
|
|
750
|
+
name,
|
|
751
|
+
args,
|
|
752
|
+
result: null,
|
|
753
|
+
isError: false,
|
|
754
|
+
expanded: false,
|
|
755
|
+
headerFinalized: false,
|
|
756
|
+
count: 1,
|
|
757
|
+
completedCount: 0,
|
|
758
|
+
startedAt: Date.now(),
|
|
759
|
+
},
|
|
760
|
+
};
|
|
761
|
+
registerDeferredCard(card);
|
|
762
|
+
if (callId) {
|
|
763
|
+
cardByCallId.set(callId, card);
|
|
764
|
+
}
|
|
765
|
+
toolCards.push(card);
|
|
766
|
+
// [jitter fix] Immediate row-reserve is deferred to after the
|
|
767
|
+
// syncAggregateHeader loop below (see standaloneReserve): calling
|
|
768
|
+
// ensureVisible() here would flushDeferredUpTo() every earlier-seq
|
|
769
|
+
// entry — including an aggregate whose pendingSpec syncAggregateHeader
|
|
770
|
+
// hasn't built yet — marking it pushed without inserting (lost/
|
|
771
|
+
// out-of-order card). Record it and flush once headers exist.
|
|
772
|
+
standaloneReserve = card;
|
|
773
|
+
// A standalone card (Agent) breaks the consecutive run too: a
|
|
774
|
+
// later same-bucket call must open a fresh card BELOW it, not
|
|
775
|
+
// merge into an aggregate above it.
|
|
776
|
+
tailAggregate = null;
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const categoryEntry = aggregateToolCategoryEntry(name, args, category);
|
|
781
|
+
const aggregateCard = ensureAggregateCard(bucket);
|
|
782
|
+
if (!aggregateCard.categories.has(categoryEntry.key)) aggregateCard.categoryOrder.push(categoryEntry.key);
|
|
783
|
+
const prevCategory = aggregateCard.categories.get(categoryEntry.key);
|
|
784
|
+
aggregateCard.categories.set(categoryEntry.key, {
|
|
785
|
+
...categoryEntry,
|
|
786
|
+
count: Number(prevCategory?.count || 0) + Number(categoryEntry.count || 1),
|
|
787
|
+
});
|
|
788
|
+
aggregateCard.calls.set(callKey, { name, args, category, summary: null, summarySeq: null, isError: false, resultText: null, resolved: false, completedEarly: false });
|
|
789
|
+
touchedAggregates.add(aggregateCard);
|
|
790
|
+
const card = { itemId: aggregateCard.itemId, callId: callKey, done: false, aggregate: aggregateCard };
|
|
791
|
+
if (callId) {
|
|
792
|
+
cardByCallId.set(callId, card);
|
|
793
|
+
}
|
|
794
|
+
toolCards.push(card);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
for (const aggregateCard of touchedAggregates) {
|
|
798
|
+
syncAggregateHeader(aggregateCard);
|
|
799
|
+
}
|
|
800
|
+
// [jitter fix] Now that every touched aggregate has its pendingSpec,
|
|
801
|
+
// reserve the standalone (Agent) card's row immediately. Its
|
|
802
|
+
// ensureVisible() flushes earlier-seq entries too, but those aggregates
|
|
803
|
+
// are now push-ready so none is marked pushed without inserting.
|
|
804
|
+
standaloneReserve?.ensureVisible?.();
|
|
805
|
+
if (committedAssistantSegment) {
|
|
806
|
+
// A pre-tool assistant preamble has already had one render frame to
|
|
807
|
+
// settle. Do not let the first grouped tool card sit off-screen until
|
|
808
|
+
// the normal 1s deferred timer: when it later inserts its real 3 rows,
|
|
809
|
+
// the already-wrapped preamble visibly jumps. Surface the first card
|
|
810
|
+
// now via the existing deferredDisplayReady path, so the post-
|
|
811
|
+
// preamble frame contains the intended Running tool card immediately
|
|
812
|
+
// (no blank placeholder, no delayed row insertion).
|
|
813
|
+
const firstTouchedAggregate = [...touchedAggregates][0] || null;
|
|
814
|
+
firstTouchedAggregate?.ensureVisible?.();
|
|
815
|
+
}
|
|
816
|
+
for (const [bufferedCallId, bufferedMessage] of earlyResultBuffer) {
|
|
817
|
+
if (!cardByCallId.has(bufferedCallId)) continue;
|
|
818
|
+
deliverToolResultMessage(bufferedMessage);
|
|
819
|
+
earlyResultBuffer.delete(bufferedCallId);
|
|
820
|
+
}
|
|
821
|
+
await yieldToRenderer();
|
|
822
|
+
},
|
|
823
|
+
onToolResult: (message) => {
|
|
824
|
+
const callId = toolResultCallId(message);
|
|
825
|
+
if (callId && !cardByCallId.has(callId) && !resultsDone.has(callId)) {
|
|
826
|
+
earlyResultBuffer.set(callId, message);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
deliverToolResultMessage(message);
|
|
830
|
+
},
|
|
831
|
+
onToolApproval: async (request) => {
|
|
832
|
+
markPromptCommitted();
|
|
833
|
+
flushStreamBatch();
|
|
834
|
+
if (getState().spinner) set({ spinner: { ...getState().spinner, mode: 'tool-approval' } });
|
|
835
|
+
return await requestToolApproval(request);
|
|
836
|
+
},
|
|
837
|
+
onCompactEvent: (event) => {
|
|
838
|
+
flushStreamBatch();
|
|
839
|
+
// Non-tool transcript item — same block-boundary rule as the
|
|
840
|
+
// steered user item above: seal any live aggregate first so a
|
|
841
|
+
// later same-category tool call doesn't reuse a card whose count
|
|
842
|
+
// would then change above this statusdone item.
|
|
843
|
+
clearAggregateContinuation();
|
|
844
|
+
pushItem({
|
|
845
|
+
kind: 'statusdone',
|
|
846
|
+
id: nextId(),
|
|
847
|
+
label: compactEventLabel(event),
|
|
848
|
+
detail: compactEventDetail(event),
|
|
849
|
+
});
|
|
850
|
+
},
|
|
851
|
+
onStageChange: async (stage) => {
|
|
852
|
+
if (!getState().spinner) return;
|
|
853
|
+
const value = String(stage || '');
|
|
854
|
+
if (value === 'compacting') {
|
|
855
|
+
compactingActive = true;
|
|
856
|
+
const thinkingLastEndedAt = closeThinkingSegment();
|
|
857
|
+
_pendingThinkFlush = false;
|
|
858
|
+
_publishedThinkingActive = false; // compacting cleared the thinking flag
|
|
859
|
+
set({
|
|
860
|
+
thinking: null,
|
|
861
|
+
spinner: {
|
|
862
|
+
...getState().spinner,
|
|
863
|
+
thinking: false,
|
|
864
|
+
thinkingSegmentStartedAt: 0,
|
|
865
|
+
thinkingAccumulatedMs: accumulatedThinkingMs,
|
|
866
|
+
thinkingLastEndedAt: thinkingLastEndedAt || getState().spinner.thinkingLastEndedAt || 0,
|
|
867
|
+
mode: 'compacting',
|
|
868
|
+
},
|
|
869
|
+
});
|
|
870
|
+
await yieldToRenderer();
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
if (value === 'requesting' || value === 'streaming') compactingActive = false;
|
|
874
|
+
const mode = value === 'requesting'
|
|
875
|
+
? 'requesting'
|
|
876
|
+
: value === 'streaming'
|
|
877
|
+
? (getState().spinner.thinking ? 'thinking' : 'responding')
|
|
878
|
+
: null;
|
|
879
|
+
if (!mode || getState().spinner.mode === mode) return;
|
|
880
|
+
set({ spinner: { ...getState().spinner, mode } });
|
|
881
|
+
},
|
|
882
|
+
onTextDelta: (chunk) => {
|
|
883
|
+
const textChunk = String(chunk ?? '');
|
|
884
|
+
if (!textChunk) return;
|
|
885
|
+
markPromptCommitted();
|
|
886
|
+
const thinkingLastEndedAt = closeThinkingSegment();
|
|
887
|
+
// Drop any queued think-flush too: it would otherwise re-publish
|
|
888
|
+
// spinner.thinking:true from flushStreamBatch and resurrect the
|
|
889
|
+
// indicator after we cleared it here.
|
|
890
|
+
_pendingThinkFlush = false;
|
|
891
|
+
if (getState().thinking) { set({ thinking: null }); _publishedThinkingActive = false; } // collapse thinking panel immediately, no batch delay
|
|
892
|
+
assistantText += textChunk;
|
|
893
|
+
currentAssistantText += textChunk;
|
|
894
|
+
// Accumulate text and schedule a batched flush (≤1 render per
|
|
895
|
+
// STREAM_BATCH_INTERVAL_MS). Without scheduling, mid-turn text only
|
|
896
|
+
// surfaced via the tool-call/finalize flush, so a text→tool segment
|
|
897
|
+
// with no spinner/thinking dropped the message above the tool card.
|
|
898
|
+
_pendingTextFlush = true;
|
|
899
|
+
if (thinkingLastEndedAt) _pendingThinkingLastEndedAt = thinkingLastEndedAt;
|
|
900
|
+
scheduleStreamFlush();
|
|
901
|
+
},
|
|
902
|
+
onAssistantText: (text) => {
|
|
903
|
+
// Mid-turn assistant text that precedes a tool call. Providers that
|
|
904
|
+
// stream via onTextDelta already accumulated it into assistantText;
|
|
905
|
+
// providers that only return the final response.content (no deltas)
|
|
906
|
+
// never fired onTextDelta, so without this the preamble shows nothing
|
|
907
|
+
// before the tool card. De-dup against already-streamed text so the
|
|
908
|
+
// streaming path is unaffected.
|
|
909
|
+
const full = String(text ?? '');
|
|
910
|
+
if (!full.trim()) return;
|
|
911
|
+
// If the streaming path already produced text for THIS segment,
|
|
912
|
+
// onTextDelta owns the render — content is the same accumulated text
|
|
913
|
+
// (or a superset), so skip to avoid double-printing the preamble.
|
|
914
|
+
// Do not check turn-global assistantText: earlier closed preambles stay
|
|
915
|
+
// there across tool calls, and would suppress later non-streaming
|
|
916
|
+
// preambles even though currentAssistantText has been reset.
|
|
917
|
+
if (currentAssistantText.trim()) return;
|
|
918
|
+
markPromptCommitted();
|
|
919
|
+
closeThinkingSegment();
|
|
920
|
+
_pendingThinkFlush = false; // see onTextDelta: prevent a stale think flush resurrecting the indicator
|
|
921
|
+
if (getState().thinking) { set({ thinking: null }); _publishedThinkingActive = false; }
|
|
922
|
+
assistantText += full;
|
|
923
|
+
currentAssistantText += full;
|
|
924
|
+
_pendingTextFlush = true;
|
|
925
|
+
flushStreamBatch();
|
|
926
|
+
},
|
|
927
|
+
onReasoningDelta: (chunk) => {
|
|
928
|
+
if (String(chunk ?? '')) markPromptCommitted();
|
|
929
|
+
startThinkingSegment();
|
|
930
|
+
thinkingText += String(chunk ?? '');
|
|
931
|
+
// Accumulate reasoning text; fire at most one render per STREAM_BATCH_INTERVAL_MS.
|
|
932
|
+
_pendingThinkFlush = true;
|
|
933
|
+
scheduleStreamFlush();
|
|
934
|
+
},
|
|
935
|
+
onUsageDelta: (delta) => {
|
|
936
|
+
applyUsageDelta(getState().stats, delta);
|
|
937
|
+
syncContextStats({ allowEstimated: true });
|
|
938
|
+
const currentTurnInput = Math.max(0, getState().stats.inputTokens - inputBaseline);
|
|
939
|
+
const currentTurnOutput = Math.max(0, getState().stats.outputTokens - outputBaseline);
|
|
940
|
+
if (getState().spinner) {
|
|
941
|
+
set({ stats: { ...getState().stats }, spinner: { ...getState().spinner, inputTokens: currentTurnInput, outputTokens: currentTurnOutput } });
|
|
942
|
+
} else {
|
|
943
|
+
set({ stats: { ...getState().stats } });
|
|
944
|
+
}
|
|
945
|
+
},
|
|
946
|
+
});
|
|
947
|
+
askResult = result;
|
|
948
|
+
markPromptCommitted();
|
|
949
|
+
|
|
950
|
+
flushToolResults(session?.messages || [], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true });
|
|
951
|
+
finalizeToolHeaders();
|
|
952
|
+
flushStreamBatch(); // force-flush any batched streaming text before finalization writes
|
|
953
|
+
syncContextStats({ allowEstimated: true });
|
|
954
|
+
|
|
955
|
+
const finalText = result?.content != null ? String(result.content) : '';
|
|
956
|
+
if (finalText.trim()) {
|
|
957
|
+
// The persisted transcript is written from the provider's final content,
|
|
958
|
+
// while the live TUI row is fed by streaming deltas. If a provider/parser
|
|
959
|
+
// misses or suppresses an early delta, keeping the streamed buffer here
|
|
960
|
+
// leaves the final on-screen assistant row missing leading characters even
|
|
961
|
+
// though the transcript is correct. Always reconcile the active segment to
|
|
962
|
+
// the final provider text when it is available.
|
|
963
|
+
const id = currentAssistantId || ensureAssistant(finalText);
|
|
964
|
+
currentAssistantText = finalText;
|
|
965
|
+
patchItem(id, { text: finalText, streaming: false });
|
|
966
|
+
} else if (currentAssistantId && (currentAssistantText.trim() || assistantText.trim())) {
|
|
967
|
+
const streamedText = currentAssistantText || assistantText;
|
|
968
|
+
patchItem(currentAssistantId, { text: streamedText, streaming: false });
|
|
969
|
+
}
|
|
970
|
+
turnFinishedNormally = true;
|
|
971
|
+
} catch (error) {
|
|
972
|
+
flushStreamBatch(); // ensure any batched text lands before the error notice
|
|
973
|
+
if (error?.name === 'SessionClosedError') {
|
|
974
|
+
cancelled = true;
|
|
975
|
+
if (assistantText.trim() && currentAssistantId) {
|
|
976
|
+
patchItem(currentAssistantId, { text: currentAssistantText || assistantText, streaming: false });
|
|
977
|
+
}
|
|
978
|
+
// Finalize pending tool cards so they don't stay "Running..." forever
|
|
979
|
+
// after cancellation. Without this, the spinner vanishes and TurnDone
|
|
980
|
+
// shows "cancelled", but in-flight tool cards remain in a perpetual
|
|
981
|
+
// pending/blinking getState() because the normal finalize path (line 992)
|
|
982
|
+
// was skipped when the error interrupted the try block.
|
|
983
|
+
flushToolResults([], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true, cancelled: true });
|
|
984
|
+
finalizeToolHeaders();
|
|
985
|
+
} else {
|
|
986
|
+
finalizeToolHeaders();
|
|
987
|
+
pushNotice(toolErrorDisplay(error, 'turn'), 'error');
|
|
988
|
+
}
|
|
989
|
+
} finally {
|
|
990
|
+
denyAllToolApprovals(cancelled ? 'turn cancelled' : 'turn finished');
|
|
991
|
+
// Turn is unwinding normally (or via abort) — cancel the wall-clock
|
|
992
|
+
// watchdog + its force-release grace so they never fire on a live turn.
|
|
993
|
+
clearWatchdog();
|
|
994
|
+
// If the watchdog force-release already fired, a NEWER turn now owns the
|
|
995
|
+
// shared store (busy, flags.activePromptRestore, turndone, drain). This stale
|
|
996
|
+
// unwind must NOT write shared getState() or it corrupts that turn. It still
|
|
997
|
+
// runs its own turn-local teardown (approvals, deferred cards) below.
|
|
998
|
+
const isStaleUnwind = flags.leadTurnEpoch !== turnEpoch;
|
|
999
|
+
// Flush any still-deferred tool cards into the transcript and cancel their
|
|
1000
|
+
// pending push timers so nothing fires (or leaks) after the turn ends. The
|
|
1001
|
+
// finalize path above already patches results onto visible cards; this just
|
|
1002
|
+
// guarantees every registered card is materialized before the turn closes.
|
|
1003
|
+
// Collect (don't emit) the still-deferred cards so the turn-close flush and
|
|
1004
|
+
// the turndone item append in ONE set() below instead of one render bounce
|
|
1005
|
+
// per row. Order/ids are preserved (creation order, then turndone last).
|
|
1006
|
+
let closingItems = [];
|
|
1007
|
+
if (deferredEntries.length) {
|
|
1008
|
+
const last = deferredEntries[deferredEntries.length - 1];
|
|
1009
|
+
closingItems = collectDeferredUpTo(last);
|
|
1010
|
+
clearDeferredTimers();
|
|
1011
|
+
}
|
|
1012
|
+
flags.flushDeferredBeforeImmediatePush = null;
|
|
1013
|
+
const producedTranscriptItem =
|
|
1014
|
+
getState().items.length + closingItems.length > itemsAtTurnStart;
|
|
1015
|
+
const reclaimed = cancelled && flags.activePromptRestore?.reclaimed === true;
|
|
1016
|
+
if (!isStaleUnwind) flags.activePromptRestore = null;
|
|
1017
|
+
closeThinkingSegment();
|
|
1018
|
+
const elapsedMs = Date.now() - startedAt;
|
|
1019
|
+
const thinkingElapsedMs = thinkingStartedAt ? accumulatedThinkingMs : 0;
|
|
1020
|
+
// responseLength is engine-local now (not pushed per-token), so compute the
|
|
1021
|
+
// fallback from the live accumulator instead of the possibly-stale
|
|
1022
|
+
// getState().spinner.responseLength. Final-only / non-streaming turns never
|
|
1023
|
+
// accumulate `assistantText` (only currentAssistantText is set at the
|
|
1024
|
+
// finalize reconcile above), so take the larger of the two text sources so
|
|
1025
|
+
// a no-usage turn still estimates tokens from the final content.
|
|
1026
|
+
const finalAssistantLen = Math.max(assistantText.length, currentAssistantText.length);
|
|
1027
|
+
const finalResponseLength = finalAssistantLen + thinkingText.length;
|
|
1028
|
+
const finalOutputTokens = Math.max(0, Number(getState().spinner?.outputTokens || 0), Math.round(finalResponseLength / 4));
|
|
1029
|
+
const turnStatus = cancelled ? 'cancelled' : 'done';
|
|
1030
|
+
const resultContent = askResult?.content != null ? String(askResult.content).trim() : '';
|
|
1031
|
+
const assistantOutput = (currentAssistantText || assistantText || '').trim();
|
|
1032
|
+
// Suppress only true pending-resume no-ops: no transcript items added and no model output; cancelled/error turns and any visible turn stay marked.
|
|
1033
|
+
const isNoOpTurn = turnFinishedNormally
|
|
1034
|
+
&& !cancelled
|
|
1035
|
+
&& toolCards.length === 0
|
|
1036
|
+
&& !resultContent
|
|
1037
|
+
&& !assistantOutput
|
|
1038
|
+
&& !producedTranscriptItem;
|
|
1039
|
+
if (isStaleUnwind) {
|
|
1040
|
+
// Preserve prior behavior: a stale unwind still materializes its own
|
|
1041
|
+
// deferred cards (turn-local teardown) but writes no other shared getState().
|
|
1042
|
+
if (closingItems.length) appendItemsBatch(closingItems);
|
|
1043
|
+
tuiDebug(`runTurn STALE UNWIND turn=${turnIndex} — force-released; skipping shared-getState() writes`);
|
|
1044
|
+
} else {
|
|
1045
|
+
if (!isNoOpTurn) {
|
|
1046
|
+
getState().stats.turns = (getState().stats.turns || 0) + 1;
|
|
1047
|
+
}
|
|
1048
|
+
// Pin the post-think summary into the transcript right after this turn's
|
|
1049
|
+
// output so it scrolls up with the answer and stays in the scrollback,
|
|
1050
|
+
// in scrollback. (Previously TurnDone rendered only in the
|
|
1051
|
+
// bottom-fixed live-status slot and vanished on the next turn.)
|
|
1052
|
+
if (!reclaimed && !isNoOpTurn) {
|
|
1053
|
+
closingItems.push({ kind: 'turndone', id: nextId(), elapsedMs, status: turnStatus, outputTokens: finalOutputTokens, thinkingElapsedMs, verb: pickDoneVerb(turnIndex) });
|
|
1054
|
+
}
|
|
1055
|
+
// Deferred cards + turndone + status all land in ONE set() (one commit).
|
|
1056
|
+
appendItemsBatch(closingItems, {
|
|
1057
|
+
busy: false,
|
|
1058
|
+
spinner: null,
|
|
1059
|
+
thinking: null,
|
|
1060
|
+
lastTurn: null,
|
|
1061
|
+
stats: { ...getState().stats },
|
|
1062
|
+
...routeState(),
|
|
1063
|
+
toolMode: runtime.toolMode,
|
|
1064
|
+
...agentStatusState({ force: true }),
|
|
1065
|
+
});
|
|
1066
|
+
flushDeferredExecutionPendingResumeKick();
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
// Shared UI getState(): a stale unwind must not wipe a newer turn's live
|
|
1070
|
+
// tool-summary line (same epoch rule as the shared-getState() block above).
|
|
1071
|
+
if (flags.leadTurnEpoch === turnEpoch) clearActiveToolSummary();
|
|
1072
|
+
_publishedThinkingActive = false; // turn teardown cleared getState().thinking
|
|
1073
|
+
tuiDebug(`runTurn end turn=${turnIndex} status=${cancelled ? 'cancelled' : 'done'} elapsedMs=${Date.now() - startedAt}${watchdogTripped ? ' watchdogTripped=1' : ''} pending=${pending.length}`);
|
|
1074
|
+
return cancelled ? 'cancelled' : 'done';
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
return runTurn;
|
|
1078
|
+
}
|