mixdog 0.9.36 → 0.9.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/scripts/abort-recovery-test.mjs +118 -0
- package/scripts/compact-smoke.mjs +7 -7
- package/scripts/dispatch-persist-recovery-test.mjs +141 -0
- package/scripts/explore-bench-tmp.mjs +19 -0
- package/scripts/explore-bench.mjs +101 -14
- package/scripts/explore-prompt-policy-test.mjs +31 -0
- package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
- package/scripts/notify-completion-mirror-test.mjs +73 -0
- package/scripts/openai-ws-early-settle-test.mjs +176 -0
- package/scripts/output-style-smoke.mjs +17 -17
- package/scripts/provider-toolcall-test.mjs +333 -14
- package/scripts/recall-bench-cases.json +3 -3
- package/scripts/recall-bench.mjs +1 -1
- package/scripts/recall-quality-cases.json +4 -4
- package/scripts/recall-usecase-cases.json +2 -2
- package/scripts/session-bench.mjs +13 -13
- package/scripts/session-sweep.mjs +266 -0
- package/scripts/steering-drain-buckets-test.mjs +72 -11
- package/scripts/submit-commandbusy-race-test.mjs +114 -0
- package/scripts/tool-smoke.mjs +83 -10
- package/src/app.mjs +2 -1
- package/src/defaults/cycle3-review-prompt.md +9 -0
- package/src/defaults/skills/setup/SKILL.md +93 -293
- package/src/lib/rules-builder.cjs +3 -2
- package/src/output-styles/default.md +2 -2
- package/src/output-styles/extreme-minimal.md +1 -1
- package/src/output-styles/minimal.md +1 -1
- package/src/output-styles/simple.md +2 -3
- package/src/rules/agent/30-explorer.md +40 -14
- package/src/rules/lead/01-general.md +4 -0
- package/src/rules/shared/01-tool.md +7 -3
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
- package/src/runtime/agent/orchestrator/config.mjs +1 -1
- package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
- package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
- package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
- package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
- package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +30 -55
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
- package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +116 -21
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
- package/src/runtime/agent/orchestrator/stall-policy.mjs +55 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -9
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
- package/src/runtime/channels/lib/worker-main.mjs +5 -0
- package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
- package/src/runtime/memory/lib/ko-morph.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
- package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
- package/src/runtime/memory/lib/recall-format.mjs +3 -3
- package/src/runtime/shared/child-spawn-gate.mjs +15 -0
- package/src/runtime/shared/config.mjs +98 -12
- package/src/runtime/shared/process-listener-headroom.mjs +12 -0
- package/src/session-runtime/cwd-plugins.mjs +6 -3
- package/src/session-runtime/model-route-api.mjs +50 -2
- package/src/session-runtime/provider-auth-api.mjs +8 -1
- package/src/session-runtime/provider-models.mjs +3 -3
- package/src/session-runtime/resource-api.mjs +22 -0
- package/src/session-runtime/runtime-core.mjs +56 -10
- package/src/session-runtime/settings-api.mjs +11 -2
- package/src/session-runtime/warmup-schedulers.mjs +7 -1
- package/src/standalone/agent-tool.mjs +15 -9
- package/src/standalone/explore-tool.mjs +4 -1
- package/src/tui/App.jsx +6 -5
- package/src/tui/app/transcript-window.mjs +60 -10
- package/src/tui/app/use-transcript-window.mjs +2 -1
- package/src/tui/components/ContextPanel.jsx +4 -1
- package/src/tui/components/ToolExecution.jsx +15 -12
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
- package/src/tui/dist/index.mjs +482 -151
- package/src/tui/engine/agent-job-feed.mjs +36 -6
- package/src/tui/engine/notification-plan.mjs +3 -3
- package/src/tui/engine/queue-helpers.mjs +8 -0
- package/src/tui/engine/render-timing.mjs +104 -8
- package/src/tui/engine/session-api.mjs +58 -3
- package/src/tui/engine/session-flow.mjs +93 -29
- package/src/tui/engine/tool-card-results.mjs +28 -13
- package/src/tui/engine/turn.mjs +238 -157
- package/src/tui/engine.mjs +17 -8
- package/src/tui/index.jsx +10 -1
- package/src/workflows/default/WORKFLOW.md +8 -4
- package/src/workflows/solo/WORKFLOW.md +8 -4
package/src/tui/engine/turn.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { pickVerb, pickDoneVerb, compactEventLabel, compactEventDetail } from '.
|
|
|
7
7
|
import { toolResultText, toolErrorDisplay } from './tool-result-text.mjs';
|
|
8
8
|
import { toolCallId, toolResultCallId, toolCallName, toolCallArgs } from './tool-call-fields.mjs';
|
|
9
9
|
import { toolResultStatus, isErrorToolStatus } from './agent-envelope.mjs';
|
|
10
|
-
import { promptDisplayText } from './queue-helpers.mjs';
|
|
10
|
+
import { promptDisplayText, STEERING_SUPPRESSED_DISPLAY } from './queue-helpers.mjs';
|
|
11
11
|
import { memoryCoreResultErrorText } from '../app/input-parsers.mjs';
|
|
12
12
|
import { yieldToRenderer } from './render-timing.mjs';
|
|
13
13
|
import { aggregateRawResult, aggregateBucketForCategory, aggregateSummaries, assignAggregateSummaryOrder } from './tool-result-status.mjs';
|
|
@@ -26,6 +26,7 @@ export function createRunTurn(bag) {
|
|
|
26
26
|
// it is stale and skip all shared-getState() writes (busy, flags.activePromptRestore,
|
|
27
27
|
// turndone, drain kick). Neutralizes the stale unwind without touching the mutex.
|
|
28
28
|
const turnEpoch = ++flags.leadTurnEpoch;
|
|
29
|
+
const isCurrentTurn = () => !flags.disposed && flags.leadTurnEpoch === turnEpoch;
|
|
29
30
|
const inputBaseline = getState().stats.inputTokens;
|
|
30
31
|
const outputBaseline = getState().stats.outputTokens;
|
|
31
32
|
const submittedIds = Array.isArray(options.submittedIds) ? options.submittedIds : [];
|
|
@@ -48,50 +49,86 @@ export function createRunTurn(bag) {
|
|
|
48
49
|
let currentAssistantId = null;
|
|
49
50
|
|
|
50
51
|
tuiDebug(`runTurn start turn=${turnIndex} pending=${pending.length} timeoutMs=${LEAD_TURN_TIMEOUT_MS}`);
|
|
51
|
-
// ──
|
|
52
|
-
// If this turn
|
|
53
|
-
// abort the in-flight run via the existing interrupt
|
|
54
|
-
// the same one Esc uses), which rejects the pending
|
|
55
|
-
// SessionClosedError so the normal cancelled-turn
|
|
56
|
-
//
|
|
57
|
-
//
|
|
52
|
+
// ── Idle watchdog ─────────────────────────────────────────────────────
|
|
53
|
+
// If this turn stops making observable progress (provider call stuck),
|
|
54
|
+
// trip after the cap: abort the in-flight run via the existing interrupt
|
|
55
|
+
// path (runtime.abort, the same one Esc uses), which rejects the pending
|
|
56
|
+
// runtime.ask() with a SessionClosedError so the normal cancelled-turn
|
|
57
|
+
// teardown runs. Keep this progress-based rather than total wall-clock:
|
|
58
|
+
// long multi-tool turns can legitimately exceed 5m while still producing
|
|
59
|
+
// model/tool events.
|
|
58
60
|
let watchdogTripped = false;
|
|
59
61
|
let watchdogTimer = null;
|
|
60
62
|
let watchdogGraceTimer = null;
|
|
63
|
+
let lastProgressAt = startedAt;
|
|
64
|
+
let lastProgressLabel = 'start';
|
|
61
65
|
const clearWatchdog = () => {
|
|
62
66
|
if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
|
|
63
67
|
if (watchdogGraceTimer) { clearTimeout(watchdogGraceTimer); watchdogGraceTimer = null; }
|
|
64
68
|
};
|
|
65
|
-
|
|
66
|
-
if (
|
|
67
|
-
watchdogTripped
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
69
|
+
const armWatchdog = () => {
|
|
70
|
+
if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
|
|
71
|
+
if (watchdogTripped) return;
|
|
72
|
+
const remaining = Math.max(1, LEAD_TURN_TIMEOUT_MS - Math.max(0, Date.now() - lastProgressAt));
|
|
73
|
+
watchdogTimer = setTimeout(() => {
|
|
74
|
+
if (!isCurrentTurn()) return;
|
|
75
|
+
if (watchdogTripped) return;
|
|
76
|
+
const idleMs = Date.now() - lastProgressAt;
|
|
77
|
+
if (idleMs < LEAD_TURN_TIMEOUT_MS) {
|
|
78
|
+
armWatchdog();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
watchdogTripped = true;
|
|
82
|
+
if (_batchTimer !== null) {
|
|
83
|
+
clearTimeout(_batchTimer);
|
|
84
|
+
_batchTimer = null;
|
|
85
|
+
}
|
|
86
|
+
_pendingTextFlush = false;
|
|
87
|
+
_pendingThinkFlush = false;
|
|
88
|
+
_pendingThinkingLastEndedAt = 0;
|
|
89
|
+
const elapsed = Date.now() - startedAt;
|
|
90
|
+
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 });
|
|
92
|
+
try { runtime.abort('cli-react-abort-watchdog'); } catch {}
|
|
93
|
+
// Belt-and-suspenders: if runtime.abort() did not reject runtime.ask()
|
|
94
|
+
// (unwind starved), hard-release the turn after a short grace so the
|
|
95
|
+
// React store is never left with busy=true and no drain in flight.
|
|
96
|
+
watchdogGraceTimer = setTimeout(() => {
|
|
97
|
+
if (flags.disposed) return;
|
|
98
|
+
if (!getState().busy) return;
|
|
99
|
+
if (flags.leadTurnEpoch !== turnEpoch) return; // a newer turn already owns the store
|
|
100
|
+
tuiDebug(`runTurn WATCHDOG FORCE-RELEASE turn=${turnIndex} — abort unwind starved`);
|
|
101
|
+
denyAllToolApprovals('turn timed out');
|
|
102
|
+
clearActiveToolSummary();
|
|
103
|
+
flushToolResults([], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true, cancelled: true });
|
|
104
|
+
finalizeToolHeaders();
|
|
105
|
+
clearDeferredTimers();
|
|
106
|
+
flags.flushDeferredBeforeImmediatePush = null;
|
|
107
|
+
// Bump the epoch FIRST so this (still-stuck) turn's later finally becomes
|
|
108
|
+
// a no-op for shared getState() and cannot corrupt the turn we hand off to.
|
|
109
|
+
flags.leadTurnEpoch++;
|
|
110
|
+
set({ busy: false, spinner: null, thinking: null, lastTurn: null });
|
|
111
|
+
flags.activePromptRestore = null;
|
|
112
|
+
if (flags.draining) flags.draining = false;
|
|
113
|
+
if (pending.length > 0) void drain();
|
|
114
|
+
// busy→false here bypasses both the normal turn-end flush and the
|
|
115
|
+
// drain-finally flush, so a deferred completion kick would never re-arm.
|
|
116
|
+
// Fire it explicitly (idempotent: guarded by deferred flag + !busy).
|
|
117
|
+
flushDeferredExecutionPendingResumeKick();
|
|
118
|
+
}, 5_000);
|
|
119
|
+
watchdogGraceTimer.unref?.();
|
|
120
|
+
}, remaining);
|
|
121
|
+
watchdogTimer.unref?.();
|
|
122
|
+
};
|
|
123
|
+
const markTurnProgress = (label) => {
|
|
124
|
+
if (!isCurrentTurn()) return false;
|
|
125
|
+
if (watchdogTripped) return;
|
|
126
|
+
lastProgressAt = Date.now();
|
|
127
|
+
lastProgressLabel = String(label || 'progress');
|
|
128
|
+
armWatchdog();
|
|
129
|
+
return true;
|
|
130
|
+
};
|
|
131
|
+
armWatchdog();
|
|
95
132
|
let currentAssistantText = '';
|
|
96
133
|
let thinkingText = '';
|
|
97
134
|
let thinkingStartedAt = 0;
|
|
@@ -139,8 +176,9 @@ export function createRunTurn(bag) {
|
|
|
139
176
|
// so transcript order always matches call order even when a later card's
|
|
140
177
|
// result/timer fires before an earlier one's. Commit the collected cards in
|
|
141
178
|
// ONE state update: emitting one pushItem() per deferred card made a tool
|
|
142
|
-
// batch climb into view one row/card at a time (
|
|
179
|
+
// batch climb into view one row/card at a time (stepwise upward row jitter).
|
|
143
180
|
const flushDeferredUpTo = (entry) => {
|
|
181
|
+
if (!isCurrentTurn()) return;
|
|
144
182
|
if (!entry) return;
|
|
145
183
|
const specs = collectDeferredUpTo(entry);
|
|
146
184
|
if (!specs.length) return;
|
|
@@ -177,7 +215,7 @@ export function createRunTurn(bag) {
|
|
|
177
215
|
deferredEntries.push(entry);
|
|
178
216
|
entry.timer = setTimeout(() => {
|
|
179
217
|
entry.timer = null;
|
|
180
|
-
if (
|
|
218
|
+
if (!isCurrentTurn()) return;
|
|
181
219
|
flushDeferredUpTo(entry);
|
|
182
220
|
}, TOOL_CARD_PUSH_DELAY_MS);
|
|
183
221
|
entry.timer.unref?.();
|
|
@@ -205,7 +243,7 @@ export function createRunTurn(bag) {
|
|
|
205
243
|
deferredEntries.push(entry);
|
|
206
244
|
entry.timer = setTimeout(() => {
|
|
207
245
|
entry.timer = null;
|
|
208
|
-
if (
|
|
246
|
+
if (!isCurrentTurn()) return;
|
|
209
247
|
flushDeferredUpTo(entry);
|
|
210
248
|
}, TOOL_CARD_PUSH_DELAY_MS);
|
|
211
249
|
entry.timer.unref?.();
|
|
@@ -235,6 +273,7 @@ export function createRunTurn(bag) {
|
|
|
235
273
|
// Append pre-built items (deferred cards + turndone) in ONE set(). None are
|
|
236
274
|
// 'user' kind, so no promptHistory rebuild is needed.
|
|
237
275
|
const appendItemsBatch = (newItems, extra = {}) => {
|
|
276
|
+
if (!isCurrentTurn()) return;
|
|
238
277
|
if (!newItems || !newItems.length) { set(extra); return; }
|
|
239
278
|
const base = getState().items.length;
|
|
240
279
|
const items = [...getState().items, ...newItems];
|
|
@@ -288,6 +327,7 @@ export function createRunTurn(bag) {
|
|
|
288
327
|
if (allCalls.length === 0) continue;
|
|
289
328
|
aggregate.ensureVisible?.();
|
|
290
329
|
const errors = allCalls.filter((r) => r.isError).length;
|
|
330
|
+
const callErrors = allCalls.filter((r) => r.isCallError).length;
|
|
291
331
|
const completed = allCalls.filter((r) => r.resolved).length;
|
|
292
332
|
const succeeded = completed - errors;
|
|
293
333
|
const rawResult = aggregateRawResult(allCalls);
|
|
@@ -301,6 +341,8 @@ export function createRunTurn(bag) {
|
|
|
301
341
|
text: displayDetail,
|
|
302
342
|
rawResult: rawResult || null,
|
|
303
343
|
isError: errors > 0,
|
|
344
|
+
errorCount: errors,
|
|
345
|
+
callErrorCount: callErrors,
|
|
304
346
|
count: allCalls.length,
|
|
305
347
|
completedCount: allCalls.length,
|
|
306
348
|
doneCategories: aggregateDoneCategories(allCalls),
|
|
@@ -481,6 +523,12 @@ export function createRunTurn(bag) {
|
|
|
481
523
|
clearTimeout(_batchTimer);
|
|
482
524
|
_batchTimer = null;
|
|
483
525
|
}
|
|
526
|
+
if (!isCurrentTurn()) {
|
|
527
|
+
_pendingTextFlush = false;
|
|
528
|
+
_pendingThinkFlush = false;
|
|
529
|
+
_pendingThinkingLastEndedAt = 0;
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
484
532
|
if (_pendingTextFlush) {
|
|
485
533
|
_pendingTextFlush = false;
|
|
486
534
|
// Show only COMPLETED lines while streaming. The in-progress trailing
|
|
@@ -615,16 +663,22 @@ export function createRunTurn(bag) {
|
|
|
615
663
|
// Gated to Memory calls: the text-matcher's ^(error|failed) catch-all
|
|
616
664
|
// would otherwise misflag legitimate non-memory success output.
|
|
617
665
|
const isMemoryCall = classifyToolCategory(callRec.name, callRec.args) === 'Memory';
|
|
618
|
-
|
|
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;
|
|
619
671
|
const text = isError ? toolErrorDisplay(rawText, callRec.name || 'tool') : rawText;
|
|
620
672
|
callRec.summary = !isError ? summarizeToolResult(callRec.name, callRec.args, rawText, isError) : null;
|
|
621
673
|
assignAggregateSummaryOrder(aggregate, callRec);
|
|
622
674
|
callRec.isError = isError;
|
|
675
|
+
callRec.isCallError = isCallError;
|
|
623
676
|
callRec.resultText = text;
|
|
624
677
|
callRec.completedEarly = true;
|
|
625
678
|
const allCalls = [...aggregate.calls.values()];
|
|
626
679
|
const completedCount = allCalls.filter((r) => r.resolved || r.completedEarly).length;
|
|
627
680
|
const errors = allCalls.filter((r) => r.isError).length;
|
|
681
|
+
const callErrors = allCalls.filter((r) => r.isCallError).length;
|
|
628
682
|
const succeeded = completedCount - errors;
|
|
629
683
|
const rawResult = aggregateRawResult(allCalls);
|
|
630
684
|
// Collapsed detail carries the merged per-call count summary even on
|
|
@@ -644,6 +698,7 @@ export function createRunTurn(bag) {
|
|
|
644
698
|
text: displayDetail,
|
|
645
699
|
isError: errors > 0,
|
|
646
700
|
errorCount: errors,
|
|
701
|
+
callErrorCount: callErrors,
|
|
647
702
|
count: allCalls.length,
|
|
648
703
|
completedCount: visualCompleted,
|
|
649
704
|
};
|
|
@@ -669,8 +724,14 @@ export function createRunTurn(bag) {
|
|
|
669
724
|
|
|
670
725
|
try {
|
|
671
726
|
const { result, session } = await runtime.ask(userText, {
|
|
672
|
-
drainSteering: () => drainPendingSteering(),
|
|
727
|
+
drainSteering: (_sessionId, drainOptions) => (isCurrentTurn() ? drainPendingSteering(drainOptions) : []),
|
|
673
728
|
onSteerMessage: (text) => {
|
|
729
|
+
if (!markTurnProgress('steer-message')) return;
|
|
730
|
+
// A suppressed live-completion twin is model-visible only; its
|
|
731
|
+
// Response card was already pushed at delivery time. Skip the
|
|
732
|
+
// duplicate transcript item (progress is still marked above since
|
|
733
|
+
// the content WAS injected into the model turn).
|
|
734
|
+
if (text === STEERING_SUPPRESSED_DISPLAY) return;
|
|
674
735
|
// Steering can be injected after a terminal no-tool response has
|
|
675
736
|
// already streamed but before runTurn finalizes. Seal the current
|
|
676
737
|
// assistant segment first so the steered user turn and the next
|
|
@@ -692,6 +753,7 @@ export function createRunTurn(bag) {
|
|
|
692
753
|
}
|
|
693
754
|
},
|
|
694
755
|
onToolCall: async (_iter, calls) => {
|
|
756
|
+
if (!markTurnProgress('tool-call')) return;
|
|
695
757
|
markPromptCommitted();
|
|
696
758
|
// Always flush any buffered mid-turn assistant text before the tool
|
|
697
759
|
// card appears. Without this, when neither a thinking panel nor a
|
|
@@ -709,11 +771,13 @@ export function createRunTurn(bag) {
|
|
|
709
771
|
if (batchCalls.length === 0) return;
|
|
710
772
|
const committedAssistantSegment = commitAssistantSegment({ sealToolBlock: true });
|
|
711
773
|
if (committedAssistantSegment) {
|
|
712
|
-
// Let the pre-tool assistant preamble paint
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
|
|
774
|
+
// Let the pre-tool assistant preamble paint and settle before the
|
|
775
|
+
// tool card reserves/pushes rows. The first frame emits the sealed
|
|
776
|
+
// preamble; the second gives measured-height harvest a chance to
|
|
777
|
+
// publish any Markdown/streaming→final correction. If the settle
|
|
778
|
+
// frame is unnecessary, yieldToRenderer's fallback releases quickly.
|
|
779
|
+
await yieldToRenderer({ frames: 2 });
|
|
780
|
+
if (!isCurrentTurn()) return;
|
|
717
781
|
}
|
|
718
782
|
|
|
719
783
|
const touchedAggregates = new Set();
|
|
@@ -795,7 +859,7 @@ export function createRunTurn(bag) {
|
|
|
795
859
|
...categoryEntry,
|
|
796
860
|
count: Number(prevCategory?.count || 0) + Number(categoryEntry.count || 1),
|
|
797
861
|
});
|
|
798
|
-
aggregateCard.calls.set(callKey, { name, args, category, summary: null, summarySeq: null, isError: false, resultText: null, resolved: false, completedEarly: false });
|
|
862
|
+
aggregateCard.calls.set(callKey, { name, args, category, summary: null, summarySeq: null, isError: false, isCallError: false, resultText: null, resolved: false, completedEarly: false });
|
|
799
863
|
touchedAggregates.add(aggregateCard);
|
|
800
864
|
const card = { itemId: aggregateCard.itemId, callId: callKey, done: false, aggregate: aggregateCard };
|
|
801
865
|
if (callId) {
|
|
@@ -831,6 +895,7 @@ export function createRunTurn(bag) {
|
|
|
831
895
|
await yieldToRenderer();
|
|
832
896
|
},
|
|
833
897
|
onToolResult: (message) => {
|
|
898
|
+
if (!markTurnProgress('tool-result')) return;
|
|
834
899
|
const callId = toolResultCallId(message);
|
|
835
900
|
if (callId && !cardByCallId.has(callId) && !resultsDone.has(callId)) {
|
|
836
901
|
earlyResultBuffer.set(callId, message);
|
|
@@ -839,12 +904,16 @@ export function createRunTurn(bag) {
|
|
|
839
904
|
deliverToolResultMessage(message);
|
|
840
905
|
},
|
|
841
906
|
onToolApproval: async (request) => {
|
|
907
|
+
if (!markTurnProgress('tool-approval')) return { approved: false, reason: 'turn no longer active' };
|
|
842
908
|
markPromptCommitted();
|
|
843
909
|
flushStreamBatch();
|
|
844
910
|
if (getState().spinner) set({ spinner: { ...getState().spinner, mode: 'tool-approval' } });
|
|
845
|
-
|
|
911
|
+
const approval = await requestToolApproval(request);
|
|
912
|
+
if (!isCurrentTurn()) return { approved: false, reason: 'turn no longer active' };
|
|
913
|
+
return approval;
|
|
846
914
|
},
|
|
847
915
|
onCompactEvent: (event) => {
|
|
916
|
+
if (!markTurnProgress('compact-event')) return;
|
|
848
917
|
flushStreamBatch();
|
|
849
918
|
// Non-tool transcript item — same block-boundary rule as the
|
|
850
919
|
// steered user item above: seal any live aggregate first so a
|
|
@@ -859,6 +928,7 @@ export function createRunTurn(bag) {
|
|
|
859
928
|
});
|
|
860
929
|
},
|
|
861
930
|
onStageChange: async (stage) => {
|
|
931
|
+
if (!markTurnProgress(`stage:${String(stage || '')}`)) return;
|
|
862
932
|
if (!getState().spinner) return;
|
|
863
933
|
const value = String(stage || '');
|
|
864
934
|
if (value === 'compacting') {
|
|
@@ -892,6 +962,7 @@ export function createRunTurn(bag) {
|
|
|
892
962
|
onTextDelta: (chunk) => {
|
|
893
963
|
const textChunk = String(chunk ?? '');
|
|
894
964
|
if (!textChunk) return;
|
|
965
|
+
if (!markTurnProgress('text-delta')) return;
|
|
895
966
|
markPromptCommitted();
|
|
896
967
|
const thinkingLastEndedAt = closeThinkingSegment();
|
|
897
968
|
// Drop any queued think-flush too: it would otherwise re-publish
|
|
@@ -918,6 +989,7 @@ export function createRunTurn(bag) {
|
|
|
918
989
|
// streaming path is unaffected.
|
|
919
990
|
const full = String(text ?? '');
|
|
920
991
|
if (!full.trim()) return;
|
|
992
|
+
if (!markTurnProgress('assistant-text')) return;
|
|
921
993
|
// If the streaming path already produced text for THIS segment,
|
|
922
994
|
// onTextDelta owns the render — content is the same accumulated text
|
|
923
995
|
// (or a superset), so skip to avoid double-printing the preamble.
|
|
@@ -935,7 +1007,11 @@ export function createRunTurn(bag) {
|
|
|
935
1007
|
flushStreamBatch();
|
|
936
1008
|
},
|
|
937
1009
|
onReasoningDelta: (chunk) => {
|
|
938
|
-
if (
|
|
1010
|
+
if (!isCurrentTurn() || watchdogTripped) return;
|
|
1011
|
+
if (String(chunk ?? '')) {
|
|
1012
|
+
if (!markTurnProgress('reasoning-delta')) return;
|
|
1013
|
+
markPromptCommitted();
|
|
1014
|
+
}
|
|
939
1015
|
startThinkingSegment();
|
|
940
1016
|
thinkingText += String(chunk ?? '');
|
|
941
1017
|
// Accumulate reasoning text; fire at most one render per STREAM_BATCH_INTERVAL_MS.
|
|
@@ -943,6 +1019,7 @@ export function createRunTurn(bag) {
|
|
|
943
1019
|
scheduleStreamFlush();
|
|
944
1020
|
},
|
|
945
1021
|
onUsageDelta: (delta) => {
|
|
1022
|
+
if (!markTurnProgress('usage-delta')) return;
|
|
946
1023
|
applyUsageDelta(getState().stats, delta);
|
|
947
1024
|
syncContextStats({ allowEstimated: true });
|
|
948
1025
|
const currentTurnInput = Math.max(0, getState().stats.inputTokens - inputBaseline);
|
|
@@ -954,130 +1031,134 @@ export function createRunTurn(bag) {
|
|
|
954
1031
|
}
|
|
955
1032
|
},
|
|
956
1033
|
});
|
|
957
|
-
|
|
958
|
-
|
|
1034
|
+
if (!isCurrentTurn()) {
|
|
1035
|
+
cancelled = true;
|
|
1036
|
+
} else {
|
|
1037
|
+
askResult = result;
|
|
1038
|
+
markPromptCommitted();
|
|
959
1039
|
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1040
|
+
flushToolResults(session?.messages || [], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true });
|
|
1041
|
+
finalizeToolHeaders();
|
|
1042
|
+
flushStreamBatch(); // force-flush any batched streaming text before finalization writes
|
|
1043
|
+
syncContextStats({ allowEstimated: true });
|
|
964
1044
|
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1045
|
+
const finalText = result?.content != null ? String(result.content) : '';
|
|
1046
|
+
if (finalText.trim()) {
|
|
1047
|
+
// The persisted transcript is written from the provider's final content,
|
|
1048
|
+
// while the live TUI row is fed by streaming deltas. If a provider/parser
|
|
1049
|
+
// misses or suppresses an early delta, keeping the streamed buffer here
|
|
1050
|
+
// leaves the final on-screen assistant row missing leading characters even
|
|
1051
|
+
// though the transcript is correct. Always reconcile the active segment to
|
|
1052
|
+
// the final provider text when it is available.
|
|
1053
|
+
const id = currentAssistantId || ensureAssistant(finalText);
|
|
1054
|
+
currentAssistantText = finalText;
|
|
1055
|
+
patchItem(id, { text: finalText, streaming: false });
|
|
1056
|
+
} else if (currentAssistantId && (currentAssistantText.trim() || assistantText.trim())) {
|
|
1057
|
+
const streamedText = currentAssistantText || assistantText;
|
|
1058
|
+
patchItem(currentAssistantId, { text: streamedText, streaming: false });
|
|
1059
|
+
}
|
|
1060
|
+
turnFinishedNormally = true;
|
|
979
1061
|
}
|
|
980
|
-
turnFinishedNormally = true;
|
|
981
1062
|
} catch (error) {
|
|
982
|
-
|
|
983
|
-
if (
|
|
1063
|
+
const staleCatch = !isCurrentTurn();
|
|
1064
|
+
if (staleCatch) {
|
|
984
1065
|
cancelled = true;
|
|
985
|
-
if (assistantText.trim() && currentAssistantId) {
|
|
986
|
-
patchItem(currentAssistantId, { text: currentAssistantText || assistantText, streaming: false });
|
|
987
|
-
}
|
|
988
|
-
// Finalize pending tool cards so they don't stay "Running..." forever
|
|
989
|
-
// after cancellation. Without this, the spinner vanishes and TurnDone
|
|
990
|
-
// shows "cancelled", but in-flight tool cards remain in a perpetual
|
|
991
|
-
// pending/blinking getState() because the normal finalize path (line 992)
|
|
992
|
-
// was skipped when the error interrupted the try block.
|
|
993
|
-
flushToolResults([], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true, cancelled: true });
|
|
994
|
-
finalizeToolHeaders();
|
|
995
1066
|
} else {
|
|
996
|
-
|
|
997
|
-
|
|
1067
|
+
flushStreamBatch(); // ensure any batched text lands before the error notice
|
|
1068
|
+
if (error?.name === 'SessionClosedError') {
|
|
1069
|
+
cancelled = true;
|
|
1070
|
+
if (assistantText.trim() && currentAssistantId) {
|
|
1071
|
+
patchItem(currentAssistantId, { text: currentAssistantText || assistantText, streaming: false });
|
|
1072
|
+
}
|
|
1073
|
+
// Finalize pending tool cards so they don't stay "Running..." forever
|
|
1074
|
+
// after cancellation. Without this, the spinner vanishes and TurnDone
|
|
1075
|
+
// shows "cancelled", but in-flight tool cards remain in a perpetual
|
|
1076
|
+
// pending/blinking getState() because the normal finalize path (line 992)
|
|
1077
|
+
// was skipped when the error interrupted the try block.
|
|
1078
|
+
flushToolResults([], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true, cancelled: true });
|
|
1079
|
+
finalizeToolHeaders();
|
|
1080
|
+
} else {
|
|
1081
|
+
finalizeToolHeaders();
|
|
1082
|
+
pushNotice(toolErrorDisplay(error, 'turn'), 'error');
|
|
1083
|
+
}
|
|
998
1084
|
}
|
|
999
1085
|
} finally {
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
//
|
|
1086
|
+
const isStaleUnwind = !isCurrentTurn();
|
|
1087
|
+
if (!isStaleUnwind) denyAllToolApprovals(cancelled ? 'turn cancelled' : 'turn finished');
|
|
1088
|
+
// Turn is unwinding normally (or via abort) — cancel the idle watchdog and
|
|
1089
|
+
// its force-release grace so they never fire on a live turn.
|
|
1003
1090
|
clearWatchdog();
|
|
1004
1091
|
// If the watchdog force-release already fired, a NEWER turn now owns the
|
|
1005
1092
|
// shared store (busy, flags.activePromptRestore, turndone, drain). This stale
|
|
1006
|
-
// unwind must NOT write shared getState() or it corrupts that turn.
|
|
1007
|
-
// runs its own turn-local teardown (approvals, deferred cards) below.
|
|
1008
|
-
const isStaleUnwind = flags.leadTurnEpoch !== turnEpoch;
|
|
1009
|
-
// Flush any still-deferred tool cards into the transcript and cancel their
|
|
1010
|
-
// pending push timers so nothing fires (or leaks) after the turn ends. The
|
|
1011
|
-
// finalize path above already patches results onto visible cards; this just
|
|
1012
|
-
// guarantees every registered card is materialized before the turn closes.
|
|
1013
|
-
// Collect (don't emit) the still-deferred cards so the turn-close flush and
|
|
1014
|
-
// the turndone item append in ONE set() below instead of one render bounce
|
|
1015
|
-
// per row. Order/ids are preserved (creation order, then turndone last).
|
|
1093
|
+
// unwind must NOT write shared getState() or it corrupts that turn.
|
|
1016
1094
|
let closingItems = [];
|
|
1017
1095
|
if (deferredEntries.length) {
|
|
1018
|
-
|
|
1019
|
-
|
|
1096
|
+
if (!isStaleUnwind) {
|
|
1097
|
+
// Flush any still-deferred tool cards into the transcript and cancel
|
|
1098
|
+
// their pending push timers so nothing fires (or leaks) after the turn
|
|
1099
|
+
// ends. The finalize path above already patches results onto visible
|
|
1100
|
+
// cards; this just guarantees every registered card is materialized
|
|
1101
|
+
// before the turn closes. Collect (don't emit) the still-deferred cards
|
|
1102
|
+
// so the turn-close flush and the turndone item append in ONE set()
|
|
1103
|
+
// below instead of one render bounce per row. Order/ids are preserved
|
|
1104
|
+
// (creation order, then turndone last).
|
|
1105
|
+
const last = deferredEntries[deferredEntries.length - 1];
|
|
1106
|
+
closingItems = collectDeferredUpTo(last);
|
|
1107
|
+
}
|
|
1020
1108
|
clearDeferredTimers();
|
|
1021
1109
|
}
|
|
1022
|
-
flags.flushDeferredBeforeImmediatePush = null;
|
|
1023
|
-
const producedTranscriptItem =
|
|
1024
|
-
getState().items.length + closingItems.length > itemsAtTurnStart;
|
|
1025
|
-
const reclaimed = cancelled && flags.activePromptRestore?.reclaimed === true;
|
|
1026
|
-
if (!isStaleUnwind) flags.activePromptRestore = null;
|
|
1110
|
+
if (!isStaleUnwind) flags.flushDeferredBeforeImmediatePush = null;
|
|
1027
1111
|
closeThinkingSegment();
|
|
1028
|
-
const elapsedMs = Date.now() - startedAt;
|
|
1029
|
-
const thinkingElapsedMs = thinkingStartedAt ? accumulatedThinkingMs : 0;
|
|
1030
|
-
// responseLength is engine-local now (not pushed per-token), so compute the
|
|
1031
|
-
// fallback from the live accumulator instead of the possibly-stale
|
|
1032
|
-
// getState().spinner.responseLength. Final-only / non-streaming turns never
|
|
1033
|
-
// accumulate `assistantText` (only currentAssistantText is set at the
|
|
1034
|
-
// finalize reconcile above), so take the larger of the two text sources so
|
|
1035
|
-
// a no-usage turn still estimates tokens from the final content.
|
|
1036
|
-
const finalAssistantLen = Math.max(assistantText.length, currentAssistantText.length);
|
|
1037
|
-
const finalResponseLength = finalAssistantLen + thinkingText.length;
|
|
1038
|
-
const finalOutputTokens = Math.max(0, Number(getState().spinner?.outputTokens || 0), Math.round(finalResponseLength / 4));
|
|
1039
|
-
const turnStatus = cancelled ? 'cancelled' : 'done';
|
|
1040
|
-
const resultContent = askResult?.content != null ? String(askResult.content).trim() : '';
|
|
1041
|
-
const assistantOutput = (currentAssistantText || assistantText || '').trim();
|
|
1042
|
-
// Suppress only true pending-resume no-ops: no transcript items added and no model output; cancelled/error turns and any visible turn stay marked.
|
|
1043
|
-
const isNoOpTurn = turnFinishedNormally
|
|
1044
|
-
&& !cancelled
|
|
1045
|
-
&& toolCards.length === 0
|
|
1046
|
-
&& !resultContent
|
|
1047
|
-
&& !assistantOutput
|
|
1048
|
-
&& !producedTranscriptItem;
|
|
1049
1112
|
if (isStaleUnwind) {
|
|
1050
|
-
|
|
1051
|
-
// deferred cards (turn-local teardown) but writes no other shared getState().
|
|
1052
|
-
if (closingItems.length) appendItemsBatch(closingItems);
|
|
1053
|
-
tuiDebug(`runTurn STALE UNWIND turn=${turnIndex} — force-released; skipping shared-getState() writes`);
|
|
1054
|
-
// A stale unwind settles busy→false (via the watchdog release) without
|
|
1055
|
-
// the normal turn-end flush, so re-arm any deferred completion kick here
|
|
1056
|
-
// too (idempotent: no-ops unless a kick is deferred and nothing is busy).
|
|
1057
|
-
flushDeferredExecutionPendingResumeKick();
|
|
1113
|
+
tuiDebug(`runTurn STALE UNWIND turn=${turnIndex} — force-released; skipping shared UI/state writes`);
|
|
1058
1114
|
} else {
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1115
|
+
const producedTranscriptItem =
|
|
1116
|
+
getState().items.length + closingItems.length > itemsAtTurnStart;
|
|
1117
|
+
const reclaimed = cancelled && flags.activePromptRestore?.reclaimed === true;
|
|
1118
|
+
flags.activePromptRestore = null;
|
|
1119
|
+
const elapsedMs = Date.now() - startedAt;
|
|
1120
|
+
const thinkingElapsedMs = thinkingStartedAt ? accumulatedThinkingMs : 0;
|
|
1121
|
+
// responseLength is engine-local now (not pushed per-token), so compute the
|
|
1122
|
+
// fallback from the live accumulator instead of the possibly-stale
|
|
1123
|
+
// getState().spinner.responseLength. Final-only / non-streaming turns never
|
|
1124
|
+
// accumulate `assistantText` (only currentAssistantText is set at the
|
|
1125
|
+
// finalize reconcile above), so take the larger of the two text sources so
|
|
1126
|
+
// a no-usage turn still estimates tokens from the final content.
|
|
1127
|
+
const finalAssistantLen = Math.max(assistantText.length, currentAssistantText.length);
|
|
1128
|
+
const finalResponseLength = finalAssistantLen + thinkingText.length;
|
|
1129
|
+
const finalOutputTokens = Math.max(0, Number(getState().spinner?.outputTokens || 0), Math.round(finalResponseLength / 4));
|
|
1130
|
+
const turnStatus = cancelled ? 'cancelled' : 'done';
|
|
1131
|
+
const resultContent = askResult?.content != null ? String(askResult.content).trim() : '';
|
|
1132
|
+
const assistantOutput = (currentAssistantText || assistantText || '').trim();
|
|
1133
|
+
// Suppress only true pending-resume no-ops: no transcript items added and no model output; cancelled/error turns and any visible turn stay marked.
|
|
1134
|
+
const isNoOpTurn = turnFinishedNormally
|
|
1135
|
+
&& !cancelled
|
|
1136
|
+
&& toolCards.length === 0
|
|
1137
|
+
&& !resultContent
|
|
1138
|
+
&& !assistantOutput
|
|
1139
|
+
&& !producedTranscriptItem;
|
|
1140
|
+
if (!isNoOpTurn) {
|
|
1141
|
+
getState().stats.turns = (getState().stats.turns || 0) + 1;
|
|
1142
|
+
}
|
|
1143
|
+
// Pin the post-think summary into the transcript right after this turn's
|
|
1144
|
+
// output so it scrolls up with the answer and stays in the scrollback,
|
|
1145
|
+
// in scrollback. (Previously TurnDone rendered only in the
|
|
1146
|
+
// bottom-fixed live-status slot and vanished on the next turn.)
|
|
1147
|
+
if (!reclaimed && !isNoOpTurn) {
|
|
1148
|
+
closingItems.push({ kind: 'turndone', id: nextId(), elapsedMs, status: turnStatus, outputTokens: finalOutputTokens, thinkingElapsedMs, verb: pickDoneVerb(turnIndex) });
|
|
1149
|
+
}
|
|
1150
|
+
// Deferred cards + turndone + status all land in ONE set() (one commit).
|
|
1151
|
+
appendItemsBatch(closingItems, {
|
|
1152
|
+
busy: false,
|
|
1153
|
+
spinner: null,
|
|
1154
|
+
thinking: null,
|
|
1155
|
+
lastTurn: null,
|
|
1156
|
+
stats: { ...getState().stats },
|
|
1157
|
+
...routeState(),
|
|
1158
|
+
toolMode: runtime.toolMode,
|
|
1159
|
+
...agentStatusState({ force: true }),
|
|
1160
|
+
});
|
|
1161
|
+
flushDeferredExecutionPendingResumeKick();
|
|
1081
1162
|
}
|
|
1082
1163
|
}
|
|
1083
1164
|
// Shared UI getState(): a stale unwind must not wipe a newer turn's live
|