mixdog 0.9.35 → 0.9.37
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/explore-bench-tmp.mjs +19 -0
- package/scripts/explore-bench.mjs +36 -6
- package/scripts/explore-prompt-policy-test.mjs +27 -0
- package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
- 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/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 +15 -7
- package/src/rules/lead/01-general.md +4 -0
- 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/mcp/client.mjs +7 -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 +4 -2
- 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 +26 -17
- 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/tool-batch.mjs +5 -2
- package/src/runtime/agent/orchestrator/stall-policy.mjs +18 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
- 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/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/resource-api.mjs +22 -0
- package/src/session-runtime/runtime-core.mjs +13 -2
- package/src/session-runtime/settings-api.mjs +11 -2
- 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 +473 -155
- package/src/tui/engine/agent-job-feed.mjs +26 -6
- package/src/tui/engine/context-state.mjs +13 -4
- package/src/tui/engine/notification-plan.mjs +3 -3
- 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 +78 -28
- package/src/tui/engine/tool-card-results.mjs +28 -13
- package/src/tui/engine/turn.mjs +232 -156
- 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
|
@@ -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,9 @@ 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;
|
|
674
730
|
// Steering can be injected after a terminal no-tool response has
|
|
675
731
|
// already streamed but before runTurn finalizes. Seal the current
|
|
676
732
|
// assistant segment first so the steered user turn and the next
|
|
@@ -692,6 +748,7 @@ export function createRunTurn(bag) {
|
|
|
692
748
|
}
|
|
693
749
|
},
|
|
694
750
|
onToolCall: async (_iter, calls) => {
|
|
751
|
+
if (!markTurnProgress('tool-call')) return;
|
|
695
752
|
markPromptCommitted();
|
|
696
753
|
// Always flush any buffered mid-turn assistant text before the tool
|
|
697
754
|
// card appears. Without this, when neither a thinking panel nor a
|
|
@@ -709,11 +766,13 @@ export function createRunTurn(bag) {
|
|
|
709
766
|
if (batchCalls.length === 0) return;
|
|
710
767
|
const committedAssistantSegment = commitAssistantSegment({ sealToolBlock: true });
|
|
711
768
|
if (committedAssistantSegment) {
|
|
712
|
-
// Let the pre-tool assistant preamble paint
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
|
|
769
|
+
// Let the pre-tool assistant preamble paint and settle before the
|
|
770
|
+
// tool card reserves/pushes rows. The first frame emits the sealed
|
|
771
|
+
// preamble; the second gives measured-height harvest a chance to
|
|
772
|
+
// publish any Markdown/streaming→final correction. If the settle
|
|
773
|
+
// frame is unnecessary, yieldToRenderer's fallback releases quickly.
|
|
774
|
+
await yieldToRenderer({ frames: 2 });
|
|
775
|
+
if (!isCurrentTurn()) return;
|
|
717
776
|
}
|
|
718
777
|
|
|
719
778
|
const touchedAggregates = new Set();
|
|
@@ -795,7 +854,7 @@ export function createRunTurn(bag) {
|
|
|
795
854
|
...categoryEntry,
|
|
796
855
|
count: Number(prevCategory?.count || 0) + Number(categoryEntry.count || 1),
|
|
797
856
|
});
|
|
798
|
-
aggregateCard.calls.set(callKey, { name, args, category, summary: null, summarySeq: null, isError: false, resultText: null, resolved: false, completedEarly: false });
|
|
857
|
+
aggregateCard.calls.set(callKey, { name, args, category, summary: null, summarySeq: null, isError: false, isCallError: false, resultText: null, resolved: false, completedEarly: false });
|
|
799
858
|
touchedAggregates.add(aggregateCard);
|
|
800
859
|
const card = { itemId: aggregateCard.itemId, callId: callKey, done: false, aggregate: aggregateCard };
|
|
801
860
|
if (callId) {
|
|
@@ -831,6 +890,7 @@ export function createRunTurn(bag) {
|
|
|
831
890
|
await yieldToRenderer();
|
|
832
891
|
},
|
|
833
892
|
onToolResult: (message) => {
|
|
893
|
+
if (!markTurnProgress('tool-result')) return;
|
|
834
894
|
const callId = toolResultCallId(message);
|
|
835
895
|
if (callId && !cardByCallId.has(callId) && !resultsDone.has(callId)) {
|
|
836
896
|
earlyResultBuffer.set(callId, message);
|
|
@@ -839,12 +899,16 @@ export function createRunTurn(bag) {
|
|
|
839
899
|
deliverToolResultMessage(message);
|
|
840
900
|
},
|
|
841
901
|
onToolApproval: async (request) => {
|
|
902
|
+
if (!markTurnProgress('tool-approval')) return { approved: false, reason: 'turn no longer active' };
|
|
842
903
|
markPromptCommitted();
|
|
843
904
|
flushStreamBatch();
|
|
844
905
|
if (getState().spinner) set({ spinner: { ...getState().spinner, mode: 'tool-approval' } });
|
|
845
|
-
|
|
906
|
+
const approval = await requestToolApproval(request);
|
|
907
|
+
if (!isCurrentTurn()) return { approved: false, reason: 'turn no longer active' };
|
|
908
|
+
return approval;
|
|
846
909
|
},
|
|
847
910
|
onCompactEvent: (event) => {
|
|
911
|
+
if (!markTurnProgress('compact-event')) return;
|
|
848
912
|
flushStreamBatch();
|
|
849
913
|
// Non-tool transcript item — same block-boundary rule as the
|
|
850
914
|
// steered user item above: seal any live aggregate first so a
|
|
@@ -859,6 +923,7 @@ export function createRunTurn(bag) {
|
|
|
859
923
|
});
|
|
860
924
|
},
|
|
861
925
|
onStageChange: async (stage) => {
|
|
926
|
+
if (!markTurnProgress(`stage:${String(stage || '')}`)) return;
|
|
862
927
|
if (!getState().spinner) return;
|
|
863
928
|
const value = String(stage || '');
|
|
864
929
|
if (value === 'compacting') {
|
|
@@ -892,6 +957,7 @@ export function createRunTurn(bag) {
|
|
|
892
957
|
onTextDelta: (chunk) => {
|
|
893
958
|
const textChunk = String(chunk ?? '');
|
|
894
959
|
if (!textChunk) return;
|
|
960
|
+
if (!markTurnProgress('text-delta')) return;
|
|
895
961
|
markPromptCommitted();
|
|
896
962
|
const thinkingLastEndedAt = closeThinkingSegment();
|
|
897
963
|
// Drop any queued think-flush too: it would otherwise re-publish
|
|
@@ -918,6 +984,7 @@ export function createRunTurn(bag) {
|
|
|
918
984
|
// streaming path is unaffected.
|
|
919
985
|
const full = String(text ?? '');
|
|
920
986
|
if (!full.trim()) return;
|
|
987
|
+
if (!markTurnProgress('assistant-text')) return;
|
|
921
988
|
// If the streaming path already produced text for THIS segment,
|
|
922
989
|
// onTextDelta owns the render — content is the same accumulated text
|
|
923
990
|
// (or a superset), so skip to avoid double-printing the preamble.
|
|
@@ -935,7 +1002,11 @@ export function createRunTurn(bag) {
|
|
|
935
1002
|
flushStreamBatch();
|
|
936
1003
|
},
|
|
937
1004
|
onReasoningDelta: (chunk) => {
|
|
938
|
-
if (
|
|
1005
|
+
if (!isCurrentTurn() || watchdogTripped) return;
|
|
1006
|
+
if (String(chunk ?? '')) {
|
|
1007
|
+
if (!markTurnProgress('reasoning-delta')) return;
|
|
1008
|
+
markPromptCommitted();
|
|
1009
|
+
}
|
|
939
1010
|
startThinkingSegment();
|
|
940
1011
|
thinkingText += String(chunk ?? '');
|
|
941
1012
|
// Accumulate reasoning text; fire at most one render per STREAM_BATCH_INTERVAL_MS.
|
|
@@ -943,6 +1014,7 @@ export function createRunTurn(bag) {
|
|
|
943
1014
|
scheduleStreamFlush();
|
|
944
1015
|
},
|
|
945
1016
|
onUsageDelta: (delta) => {
|
|
1017
|
+
if (!markTurnProgress('usage-delta')) return;
|
|
946
1018
|
applyUsageDelta(getState().stats, delta);
|
|
947
1019
|
syncContextStats({ allowEstimated: true });
|
|
948
1020
|
const currentTurnInput = Math.max(0, getState().stats.inputTokens - inputBaseline);
|
|
@@ -954,130 +1026,134 @@ export function createRunTurn(bag) {
|
|
|
954
1026
|
}
|
|
955
1027
|
},
|
|
956
1028
|
});
|
|
957
|
-
|
|
958
|
-
|
|
1029
|
+
if (!isCurrentTurn()) {
|
|
1030
|
+
cancelled = true;
|
|
1031
|
+
} else {
|
|
1032
|
+
askResult = result;
|
|
1033
|
+
markPromptCommitted();
|
|
959
1034
|
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1035
|
+
flushToolResults(session?.messages || [], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true });
|
|
1036
|
+
finalizeToolHeaders();
|
|
1037
|
+
flushStreamBatch(); // force-flush any batched streaming text before finalization writes
|
|
1038
|
+
syncContextStats({ allowEstimated: true });
|
|
964
1039
|
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1040
|
+
const finalText = result?.content != null ? String(result.content) : '';
|
|
1041
|
+
if (finalText.trim()) {
|
|
1042
|
+
// The persisted transcript is written from the provider's final content,
|
|
1043
|
+
// while the live TUI row is fed by streaming deltas. If a provider/parser
|
|
1044
|
+
// misses or suppresses an early delta, keeping the streamed buffer here
|
|
1045
|
+
// leaves the final on-screen assistant row missing leading characters even
|
|
1046
|
+
// though the transcript is correct. Always reconcile the active segment to
|
|
1047
|
+
// the final provider text when it is available.
|
|
1048
|
+
const id = currentAssistantId || ensureAssistant(finalText);
|
|
1049
|
+
currentAssistantText = finalText;
|
|
1050
|
+
patchItem(id, { text: finalText, streaming: false });
|
|
1051
|
+
} else if (currentAssistantId && (currentAssistantText.trim() || assistantText.trim())) {
|
|
1052
|
+
const streamedText = currentAssistantText || assistantText;
|
|
1053
|
+
patchItem(currentAssistantId, { text: streamedText, streaming: false });
|
|
1054
|
+
}
|
|
1055
|
+
turnFinishedNormally = true;
|
|
979
1056
|
}
|
|
980
|
-
turnFinishedNormally = true;
|
|
981
1057
|
} catch (error) {
|
|
982
|
-
|
|
983
|
-
if (
|
|
1058
|
+
const staleCatch = !isCurrentTurn();
|
|
1059
|
+
if (staleCatch) {
|
|
984
1060
|
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
1061
|
} else {
|
|
996
|
-
|
|
997
|
-
|
|
1062
|
+
flushStreamBatch(); // ensure any batched text lands before the error notice
|
|
1063
|
+
if (error?.name === 'SessionClosedError') {
|
|
1064
|
+
cancelled = true;
|
|
1065
|
+
if (assistantText.trim() && currentAssistantId) {
|
|
1066
|
+
patchItem(currentAssistantId, { text: currentAssistantText || assistantText, streaming: false });
|
|
1067
|
+
}
|
|
1068
|
+
// Finalize pending tool cards so they don't stay "Running..." forever
|
|
1069
|
+
// after cancellation. Without this, the spinner vanishes and TurnDone
|
|
1070
|
+
// shows "cancelled", but in-flight tool cards remain in a perpetual
|
|
1071
|
+
// pending/blinking getState() because the normal finalize path (line 992)
|
|
1072
|
+
// was skipped when the error interrupted the try block.
|
|
1073
|
+
flushToolResults([], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true, cancelled: true });
|
|
1074
|
+
finalizeToolHeaders();
|
|
1075
|
+
} else {
|
|
1076
|
+
finalizeToolHeaders();
|
|
1077
|
+
pushNotice(toolErrorDisplay(error, 'turn'), 'error');
|
|
1078
|
+
}
|
|
998
1079
|
}
|
|
999
1080
|
} finally {
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
//
|
|
1081
|
+
const isStaleUnwind = !isCurrentTurn();
|
|
1082
|
+
if (!isStaleUnwind) denyAllToolApprovals(cancelled ? 'turn cancelled' : 'turn finished');
|
|
1083
|
+
// Turn is unwinding normally (or via abort) — cancel the idle watchdog and
|
|
1084
|
+
// its force-release grace so they never fire on a live turn.
|
|
1003
1085
|
clearWatchdog();
|
|
1004
1086
|
// If the watchdog force-release already fired, a NEWER turn now owns the
|
|
1005
1087
|
// 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).
|
|
1088
|
+
// unwind must NOT write shared getState() or it corrupts that turn.
|
|
1016
1089
|
let closingItems = [];
|
|
1017
1090
|
if (deferredEntries.length) {
|
|
1018
|
-
|
|
1019
|
-
|
|
1091
|
+
if (!isStaleUnwind) {
|
|
1092
|
+
// Flush any still-deferred tool cards into the transcript and cancel
|
|
1093
|
+
// their pending push timers so nothing fires (or leaks) after the turn
|
|
1094
|
+
// ends. The finalize path above already patches results onto visible
|
|
1095
|
+
// cards; this just guarantees every registered card is materialized
|
|
1096
|
+
// before the turn closes. Collect (don't emit) the still-deferred cards
|
|
1097
|
+
// so the turn-close flush and the turndone item append in ONE set()
|
|
1098
|
+
// below instead of one render bounce per row. Order/ids are preserved
|
|
1099
|
+
// (creation order, then turndone last).
|
|
1100
|
+
const last = deferredEntries[deferredEntries.length - 1];
|
|
1101
|
+
closingItems = collectDeferredUpTo(last);
|
|
1102
|
+
}
|
|
1020
1103
|
clearDeferredTimers();
|
|
1021
1104
|
}
|
|
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;
|
|
1105
|
+
if (!isStaleUnwind) flags.flushDeferredBeforeImmediatePush = null;
|
|
1027
1106
|
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
1107
|
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();
|
|
1108
|
+
tuiDebug(`runTurn STALE UNWIND turn=${turnIndex} — force-released; skipping shared UI/state writes`);
|
|
1058
1109
|
} else {
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1110
|
+
const producedTranscriptItem =
|
|
1111
|
+
getState().items.length + closingItems.length > itemsAtTurnStart;
|
|
1112
|
+
const reclaimed = cancelled && flags.activePromptRestore?.reclaimed === true;
|
|
1113
|
+
flags.activePromptRestore = null;
|
|
1114
|
+
const elapsedMs = Date.now() - startedAt;
|
|
1115
|
+
const thinkingElapsedMs = thinkingStartedAt ? accumulatedThinkingMs : 0;
|
|
1116
|
+
// responseLength is engine-local now (not pushed per-token), so compute the
|
|
1117
|
+
// fallback from the live accumulator instead of the possibly-stale
|
|
1118
|
+
// getState().spinner.responseLength. Final-only / non-streaming turns never
|
|
1119
|
+
// accumulate `assistantText` (only currentAssistantText is set at the
|
|
1120
|
+
// finalize reconcile above), so take the larger of the two text sources so
|
|
1121
|
+
// a no-usage turn still estimates tokens from the final content.
|
|
1122
|
+
const finalAssistantLen = Math.max(assistantText.length, currentAssistantText.length);
|
|
1123
|
+
const finalResponseLength = finalAssistantLen + thinkingText.length;
|
|
1124
|
+
const finalOutputTokens = Math.max(0, Number(getState().spinner?.outputTokens || 0), Math.round(finalResponseLength / 4));
|
|
1125
|
+
const turnStatus = cancelled ? 'cancelled' : 'done';
|
|
1126
|
+
const resultContent = askResult?.content != null ? String(askResult.content).trim() : '';
|
|
1127
|
+
const assistantOutput = (currentAssistantText || assistantText || '').trim();
|
|
1128
|
+
// Suppress only true pending-resume no-ops: no transcript items added and no model output; cancelled/error turns and any visible turn stay marked.
|
|
1129
|
+
const isNoOpTurn = turnFinishedNormally
|
|
1130
|
+
&& !cancelled
|
|
1131
|
+
&& toolCards.length === 0
|
|
1132
|
+
&& !resultContent
|
|
1133
|
+
&& !assistantOutput
|
|
1134
|
+
&& !producedTranscriptItem;
|
|
1135
|
+
if (!isNoOpTurn) {
|
|
1136
|
+
getState().stats.turns = (getState().stats.turns || 0) + 1;
|
|
1137
|
+
}
|
|
1138
|
+
// Pin the post-think summary into the transcript right after this turn's
|
|
1139
|
+
// output so it scrolls up with the answer and stays in the scrollback,
|
|
1140
|
+
// in scrollback. (Previously TurnDone rendered only in the
|
|
1141
|
+
// bottom-fixed live-status slot and vanished on the next turn.)
|
|
1142
|
+
if (!reclaimed && !isNoOpTurn) {
|
|
1143
|
+
closingItems.push({ kind: 'turndone', id: nextId(), elapsedMs, status: turnStatus, outputTokens: finalOutputTokens, thinkingElapsedMs, verb: pickDoneVerb(turnIndex) });
|
|
1144
|
+
}
|
|
1145
|
+
// Deferred cards + turndone + status all land in ONE set() (one commit).
|
|
1146
|
+
appendItemsBatch(closingItems, {
|
|
1147
|
+
busy: false,
|
|
1148
|
+
spinner: null,
|
|
1149
|
+
thinking: null,
|
|
1150
|
+
lastTurn: null,
|
|
1151
|
+
stats: { ...getState().stats },
|
|
1152
|
+
...routeState(),
|
|
1153
|
+
toolMode: runtime.toolMode,
|
|
1154
|
+
...agentStatusState({ force: true }),
|
|
1155
|
+
});
|
|
1156
|
+
flushDeferredExecutionPendingResumeKick();
|
|
1081
1157
|
}
|
|
1082
1158
|
}
|
|
1083
1159
|
// Shared UI getState(): a stale unwind must not wipe a newer turn's live
|