mixdog 0.9.37 → 0.9.39
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 +9 -4
- package/scripts/abort-recovery-test.mjs +43 -2
- package/scripts/agent-tag-reuse-smoke.mjs +146 -6
- package/scripts/agent-terminal-reap-test.mjs +127 -0
- package/scripts/agent-trace-io-test.mjs +69 -0
- package/scripts/code-graph-disk-hit-test.mjs +224 -0
- package/scripts/dispatch-persist-recovery-test.mjs +141 -0
- package/scripts/execution-completion-dedup-test.mjs +157 -0
- package/scripts/execution-pending-resume-kick-test.mjs +57 -2
- package/scripts/execution-resume-esc-integration-test.mjs +174 -0
- package/scripts/explore-bench.mjs +101 -8
- package/scripts/explore-prompt-policy-test.mjs +156 -11
- package/scripts/find-fuzzy-hidden-test.mjs +122 -0
- package/scripts/internal-comms-bench-test.mjs +226 -0
- package/scripts/internal-comms-bench.mjs +185 -58
- package/scripts/internal-comms-smoke.mjs +171 -23
- package/scripts/live-worker-smoke.mjs +38 -2
- package/scripts/memory-cycle-routing-test.mjs +111 -0
- package/scripts/memory-rule-contract-test.mjs +93 -0
- package/scripts/notify-completion-mirror-test.mjs +73 -0
- package/scripts/output-style-smoke.mjs +2 -2
- package/scripts/rg-runner-test.mjs +240 -0
- package/scripts/routing-corpus-test.mjs +349 -0
- package/scripts/routing-corpus.mjs +211 -32
- package/scripts/session-orphan-sweep-test.mjs +83 -0
- package/scripts/session-sweep.mjs +266 -0
- package/scripts/steering-drain-buckets-test.mjs +179 -0
- package/scripts/tool-smoke.mjs +21 -13
- package/scripts/tool-tui-presentation-test.mjs +202 -0
- package/src/agents/heavy-worker/AGENT.md +10 -7
- package/src/agents/reviewer/AGENT.md +6 -4
- package/src/agents/worker/AGENT.md +7 -5
- package/src/rules/agent/00-common.md +4 -4
- package/src/rules/agent/00-core.md +11 -14
- package/src/rules/agent/20-skip-protocol.md +3 -3
- package/src/rules/agent/30-explorer.md +56 -48
- package/src/rules/agent/40-cycle1-agent.md +15 -24
- package/src/rules/agent/41-cycle2-agent.md +33 -57
- package/src/rules/agent/42-cycle3-agent.md +28 -42
- package/src/rules/lead/01-general.md +7 -10
- package/src/rules/lead/lead-brief.md +11 -14
- package/src/rules/lead/lead-tool.md +6 -5
- package/src/rules/shared/01-tool.md +44 -41
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +37 -1
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +20 -5
- 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/codex-client-meta.mjs +21 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -4
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +17 -8
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +270 -78
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +18 -52
- package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +123 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +15 -2
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +41 -2
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/store.mjs +339 -63
- package/src/runtime/agent/orchestrator/stall-policy.mjs +37 -0
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +25 -20
- package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +6 -2
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +118 -53
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +106 -29
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +8 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +4 -2
- package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +3 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +33 -2
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +151 -64
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +37 -13
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +84 -49
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +172 -23
- package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +3 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +68 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +17 -3
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +24 -10
- package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +6 -3
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -7
- package/src/runtime/agent/orchestrator/tools/code-graph.mjs +1 -0
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -4
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +4 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +12 -2
- package/src/runtime/shared/tool-primitives.mjs +4 -1
- package/src/runtime/shared/tool-status.mjs +27 -0
- package/src/runtime/shared/tool-surface.mjs +6 -3
- package/src/session-runtime/config-helpers.mjs +14 -0
- package/src/session-runtime/context-status.mjs +1 -0
- package/src/session-runtime/effort.mjs +6 -2
- package/src/session-runtime/model-recency.mjs +5 -2
- package/src/session-runtime/provider-models.mjs +3 -3
- package/src/session-runtime/runtime-core.mjs +78 -10
- package/src/session-runtime/tool-catalog.mjs +34 -0
- package/src/session-runtime/warmup-schedulers.mjs +7 -1
- package/src/standalone/agent-tool/notify.mjs +13 -0
- package/src/standalone/agent-tool.mjs +45 -69
- package/src/standalone/explore-tool.mjs +6 -7
- package/src/tui/App.jsx +31 -0
- package/src/tui/app/model-options.mjs +5 -3
- package/src/tui/app/model-picker.mjs +12 -24
- package/src/tui/app/transcript-window.mjs +1 -0
- package/src/tui/components/ToolExecution.jsx +11 -6
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/components/tool-execution/surface-detail.mjs +24 -10
- package/src/tui/components/tool-execution/text-format.mjs +10 -19
- package/src/tui/dist/index.mjs +533 -143
- package/src/tui/engine/agent-job-feed.mjs +153 -16
- package/src/tui/engine/agent-response-tail.mjs +68 -0
- package/src/tui/engine/notification-plan.mjs +16 -0
- package/src/tui/engine/queue-helpers.mjs +8 -0
- package/src/tui/engine/session-api.mjs +8 -2
- package/src/tui/engine/session-flow.mjs +34 -2
- package/src/tui/engine/tool-card-results.mjs +54 -32
- package/src/tui/engine/tool-result-status.mjs +75 -21
- package/src/tui/engine/turn.mjs +83 -43
- package/src/tui/engine.mjs +63 -2
- package/src/workflows/bench/WORKFLOW.md +25 -35
- package/src/workflows/default/WORKFLOW.md +38 -32
- package/src/workflows/solo/WORKFLOW.md +19 -22
- package/scripts/_jitter-fuzz.mjs +0 -44410
- package/scripts/_jitter-fuzz2.mjs +0 -44400
- package/scripts/_jitter-probe.mjs +0 -44397
- package/scripts/_jp2.mjs +0 -45614
|
@@ -10,17 +10,18 @@
|
|
|
10
10
|
* the factory argument (getters/callbacks) — never stale snapshots. Every body
|
|
11
11
|
* is the original engine.mjs logic verbatim.
|
|
12
12
|
*/
|
|
13
|
-
import { summarizeToolResult, aggregateDoneCategories
|
|
13
|
+
import { summarizeToolResult, aggregateDoneCategories } from '../../runtime/shared/tool-surface.mjs';
|
|
14
14
|
import { toolResultText, toolErrorDisplay, toolGroupedDisplayFallback } from './tool-result-text.mjs';
|
|
15
15
|
import { toolResultCallId } from './tool-call-fields.mjs';
|
|
16
|
-
import {
|
|
17
|
-
import { parseAgentJob, toolResultStatus, isErrorToolStatus } from './agent-envelope.mjs';
|
|
16
|
+
import { parseAgentJob } from './agent-envelope.mjs';
|
|
18
17
|
import {
|
|
19
18
|
withCancelledResultMarker,
|
|
20
19
|
groupedToolResultText,
|
|
21
20
|
aggregateRawResult,
|
|
22
21
|
aggregateSummaries,
|
|
23
22
|
assignAggregateSummaryOrder,
|
|
23
|
+
failureDetailText,
|
|
24
|
+
toolCallOutcome,
|
|
24
25
|
} from './tool-result-status.mjs';
|
|
25
26
|
import { formatAggregateDetail } from '../../runtime/shared/tool-surface.mjs';
|
|
26
27
|
import { carryTranscriptMeasuredRowsCache } from '../app/transcript-window.mjs';
|
|
@@ -34,6 +35,17 @@ export function createToolCardResults({
|
|
|
34
35
|
buildAgentJobCardPatch,
|
|
35
36
|
agentStatusState,
|
|
36
37
|
}) {
|
|
38
|
+
// A finalized/failed non-aggregate card must never carry an empty body:
|
|
39
|
+
// an empty-body error card is classified fully-failed-with-no-body upstream
|
|
40
|
+
// (transcript-tool-failures) and null-renders (card disappears). Stamp a
|
|
41
|
+
// minimal non-empty fallback, preferring meaningful text, then Exit N, then
|
|
42
|
+
// a bare Failed status.
|
|
43
|
+
function finalizedErrorFallbackBody(body, text, exitCode) {
|
|
44
|
+
if (String(body || '').trim()) return body;
|
|
45
|
+
if (String(text || '').trim()) return text;
|
|
46
|
+
if (exitCode != null) return `Exit ${exitCode}`;
|
|
47
|
+
return 'Failed';
|
|
48
|
+
}
|
|
37
49
|
function patchToolItem(id, patch) {
|
|
38
50
|
const prev = getState().items.find((it) => it.id === id);
|
|
39
51
|
const ok = patchItem(id, patch);
|
|
@@ -60,22 +72,11 @@ export function createToolCardResults({
|
|
|
60
72
|
// Aggregate card handling — collect semantic summaries per call
|
|
61
73
|
const aggregate = card.aggregate;
|
|
62
74
|
const callRec = aggregate && callId ? aggregate.calls.get(callId) : null;
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const isMemoryCall = classifyToolCategory(callRec?.name || card?.name || '', callRec?.args || {}) === 'Memory';
|
|
69
|
-
// Split the failure signal into two:
|
|
70
|
-
// isCallError — a REAL tool-call failure (backend isError / error
|
|
71
|
-
// toolKind). ONLY this paints the ● dot red.
|
|
72
|
-
// isResultError — a command/result failure (shell exit code, [error…]
|
|
73
|
-
// text, failed status text, flattened core memory-op
|
|
74
|
-
// failure). These still mark the card Failed in the L2
|
|
75
|
-
// detail but must NOT turn the dot red.
|
|
76
|
-
const isCallError = message?.isError === true || message?.toolKind === 'error';
|
|
77
|
-
const isResultError = /^\s*\[?error/i.test(rawText) || isErrorToolStatus(toolResultStatus(rawText)) || (isMemoryCall && memoryCoreResultErrorText(rawText) != null);
|
|
78
|
-
const isError = isCallError || isResultError;
|
|
75
|
+
// Only a provider-marked invocation failure contributes to failure count,
|
|
76
|
+
// red state, or Failed aggregate copy. Tool-reported HTTP/domain/status
|
|
77
|
+
// outcomes remain successful calls with their raw/semantic result detail.
|
|
78
|
+
const { exitCode, isExitError, isCallError } = toolCallOutcome(message, rawText);
|
|
79
|
+
const isError = isCallError;
|
|
79
80
|
const text = isError ? toolErrorDisplay(rawText, card?.name || 'tool') : rawText;
|
|
80
81
|
|
|
81
82
|
if (aggregate && card.itemId === aggregate.itemId) {
|
|
@@ -89,19 +90,22 @@ export function createToolCardResults({
|
|
|
89
90
|
assignAggregateSummaryOrder(aggregate, callRec);
|
|
90
91
|
callRec.isError = isError;
|
|
91
92
|
callRec.isCallError = isCallError;
|
|
93
|
+
callRec.isExitError = isExitError;
|
|
94
|
+
callRec.exitCode = exitCode;
|
|
92
95
|
callRec.resultText = text;
|
|
93
96
|
callRec.resolved = true;
|
|
94
97
|
const allCalls = [...aggregate.calls.values()];
|
|
95
98
|
const completed = allCalls.filter((r) => r.resolved).length;
|
|
96
99
|
const errors = allCalls.filter((r) => r.isError).length;
|
|
97
100
|
const callErrors = allCalls.filter((r) => r.isCallError).length;
|
|
101
|
+
const exitErrors = allCalls.filter((r) => r.isExitError).length;
|
|
98
102
|
// Collapsed detail carries the merged per-call count summary
|
|
99
103
|
// ("512 lines, 6 matches, 3 files") so the finished card answers "how
|
|
100
104
|
// much" without ctrl+o. Failures keep a bare 'N Ok · N Failed' status so
|
|
101
105
|
// an error stays visible while collapsed.
|
|
102
|
-
const succeeded = completed - errors;
|
|
103
|
-
const detailText = errors > 0
|
|
104
|
-
? (succeeded
|
|
106
|
+
const succeeded = Math.max(0, completed - errors - exitErrors);
|
|
107
|
+
const detailText = errors > 0 || exitErrors > 0
|
|
108
|
+
? failureDetailText({ succeeded, realErrors: callErrors, exitErrors, exitCode: allCalls.find((r) => r.isExitError)?.exitCode })
|
|
105
109
|
: formatAggregateDetail(aggregateSummaries(aggregate));
|
|
106
110
|
const currentItem = getState().items.find((it) => it.id === card.itemId);
|
|
107
111
|
const earlyCompleted = allCalls.filter((r) => r.resolved || r.completedEarly).length;
|
|
@@ -116,6 +120,7 @@ export function createToolCardResults({
|
|
|
116
120
|
isError: errors > 0,
|
|
117
121
|
errorCount: errors,
|
|
118
122
|
callErrorCount: callErrors,
|
|
123
|
+
exitErrorCount: exitErrors,
|
|
119
124
|
count: allCalls.length,
|
|
120
125
|
completedCount: visualCompleted,
|
|
121
126
|
doneCategories: aggregateDoneCategories(allCalls),
|
|
@@ -127,20 +132,25 @@ export function createToolCardResults({
|
|
|
127
132
|
}
|
|
128
133
|
|
|
129
134
|
// Non-aggregate (legacy agent-job cards, etc.)
|
|
130
|
-
const group = toolGroups.get(card.itemId) || { count: 1, completed: 0, errors: 0, callErrors: 0, results: [] };
|
|
135
|
+
const group = toolGroups.get(card.itemId) || { count: 1, completed: 0, errors: 0, callErrors: 0, exitErrors: 0, results: [] };
|
|
131
136
|
group.completed = Math.min(group.count, group.completed + 1);
|
|
132
137
|
group.errors += isError ? 1 : 0;
|
|
133
138
|
group.callErrors = (group.callErrors || 0) + (isCallError ? 1 : 0);
|
|
134
|
-
group.
|
|
139
|
+
group.exitErrors = (group.exitErrors || 0) + (isExitError ? 1 : 0);
|
|
140
|
+
group.results.push({ text, isError, isExitError, exitCode });
|
|
135
141
|
toolGroups.set(card.itemId, group);
|
|
136
142
|
const resultText = groupedToolResultText(group);
|
|
137
|
-
|
|
143
|
+
let displayResult = toolGroupedDisplayFallback(resultText, text, rawText);
|
|
144
|
+
if (group.errors > 0 && !String(displayResult || '').trim()) {
|
|
145
|
+
displayResult = finalizedErrorFallbackBody(displayResult, text, exitCode);
|
|
146
|
+
}
|
|
138
147
|
const patch = {
|
|
139
148
|
result: displayResult,
|
|
140
149
|
text: displayResult,
|
|
141
150
|
isError: group.errors > 0,
|
|
142
151
|
errorCount: group.errors,
|
|
143
152
|
callErrorCount: group.callErrors || 0,
|
|
153
|
+
exitErrorCount: group.exitErrors || 0,
|
|
144
154
|
count: group.count,
|
|
145
155
|
completedCount: group.completed,
|
|
146
156
|
completedAt: Date.now(),
|
|
@@ -159,6 +169,11 @@ export function createToolCardResults({
|
|
|
159
169
|
// The agent fields win (final display) while patch keeps the completion
|
|
160
170
|
// metadata (count/completedCount/completedAt/rawResult) for expand.
|
|
161
171
|
Object.assign(patch, buildAgentJobCardPatch(card.itemId, rawText, isError));
|
|
172
|
+
// Re-apply the empty-body guard: buildAgentJobCardPatch may overwrite the
|
|
173
|
+
// stamped fallback with an empty agent-job body, letting the card vanish.
|
|
174
|
+
if (group.errors > 0 && !String(patch.result || '').trim()) {
|
|
175
|
+
patch.result = patch.text = finalizedErrorFallbackBody(patch.result, text, exitCode);
|
|
176
|
+
}
|
|
162
177
|
}
|
|
163
178
|
patchToolItem(card.itemId, patch);
|
|
164
179
|
card.done = true;
|
|
@@ -223,12 +238,14 @@ export function createToolCardResults({
|
|
|
223
238
|
const totalCompleted = completed;
|
|
224
239
|
const errors = allCalls.filter((r) => r.isError).length;
|
|
225
240
|
const callErrors = allCalls.filter((r) => r.isCallError).length;
|
|
226
|
-
const
|
|
241
|
+
const exitErrors = allCalls.filter((r) => r.isExitError).length;
|
|
242
|
+
const succeeded = Math.max(0, completed - errors - exitErrors);
|
|
227
243
|
const rawResult = aggregateRawResult(allCalls);
|
|
228
|
-
// Collapsed detail carries the merged per-call count summary;
|
|
229
|
-
// keep
|
|
230
|
-
|
|
231
|
-
|
|
244
|
+
// Collapsed detail carries the merged per-call count summary; real
|
|
245
|
+
// failures keep 'N Failed', shell command-exits show 'Exit N'/'Y Exit'.
|
|
246
|
+
// Raw is kept for ctrl+o.
|
|
247
|
+
let displayDetail = errors > 0 || exitErrors > 0
|
|
248
|
+
? failureDetailText({ succeeded, realErrors: callErrors, exitErrors, exitCode: allCalls.find((r) => r.isExitError)?.exitCode })
|
|
232
249
|
: formatAggregateDetail(aggregateSummaries(aggregate));
|
|
233
250
|
if (cancelled) {
|
|
234
251
|
// Cancelled aggregates MUST keep the [status: cancelled] marker on the
|
|
@@ -244,6 +261,7 @@ export function createToolCardResults({
|
|
|
244
261
|
isError: errors > 0,
|
|
245
262
|
errorCount: errors,
|
|
246
263
|
callErrorCount: callErrors,
|
|
264
|
+
exitErrorCount: exitErrors,
|
|
247
265
|
count: allCalls.length,
|
|
248
266
|
completedCount: totalCompleted,
|
|
249
267
|
doneCategories: aggregateDoneCategories(allCalls),
|
|
@@ -257,15 +275,19 @@ export function createToolCardResults({
|
|
|
257
275
|
continue;
|
|
258
276
|
}
|
|
259
277
|
// Non-aggregate finalize
|
|
260
|
-
const group = toolGroups.get(card.itemId) || { count: 1, completed: 0, errors: 0, results: [] };
|
|
278
|
+
const group = toolGroups.get(card.itemId) || { count: 1, completed: 0, errors: 0, exitErrors: 0, results: [] };
|
|
261
279
|
group.completed = Math.min(group.count, group.completed + 1);
|
|
262
280
|
toolGroups.set(card.itemId, group);
|
|
263
281
|
let resultText = groupedToolResultText(group);
|
|
282
|
+
if (group.errors > 0 && !String(resultText || '').trim()) {
|
|
283
|
+
const exitRec = (group.results || []).find((r) => r && r.isExitError);
|
|
284
|
+
resultText = finalizedErrorFallbackBody(resultText, exitRec?.text, exitRec?.exitCode);
|
|
285
|
+
}
|
|
264
286
|
if (cancelled) {
|
|
265
287
|
const currentItem = getState().items.find((it) => it.id === card.itemId);
|
|
266
288
|
resultText = withCancelledResultMarker(resultText, currentItem);
|
|
267
289
|
}
|
|
268
|
-
patchToolItem(card.itemId, { result: resultText, text: resultText, isError: group.errors > 0, errorCount: group.errors, callErrorCount: group.callErrors || 0, count: group.count, completedCount: group.completed, completedAt: Date.now() });
|
|
290
|
+
patchToolItem(card.itemId, { result: resultText, text: resultText, isError: group.errors > 0, errorCount: group.errors, callErrorCount: group.callErrors || 0, exitErrorCount: group.exitErrors || 0, count: group.count, completedCount: group.completed, completedAt: Date.now() });
|
|
269
291
|
card.done = true;
|
|
270
292
|
if (card.callId) done.add(card.callId);
|
|
271
293
|
}
|
|
@@ -8,27 +8,75 @@
|
|
|
8
8
|
* keeps calling them unchanged.
|
|
9
9
|
*/
|
|
10
10
|
import { toolErrorDisplay } from './tool-result-text.mjs';
|
|
11
|
+
import {
|
|
12
|
+
normalizeToolTerminalStatus,
|
|
13
|
+
toolResultTerminalStatus,
|
|
14
|
+
} from '../../runtime/shared/tool-status.mjs';
|
|
11
15
|
|
|
12
16
|
export const CANCELLED_RESULT_STATUS_LINE = '[status: cancelled]';
|
|
13
17
|
|
|
18
|
+
// Detect a shell command that RAN but exited non-zero (a process exit code)
|
|
19
|
+
// as opposed to a real tool-call failure (`[shell-tool-failed]`) or a
|
|
20
|
+
// timeout/abort. bash-tool.mjs emits `Error: [shell-run-failed] [exit code: N]`
|
|
21
|
+
// for a plain non-zero exit; timeout/signal cases carry `[timeout: …]`/
|
|
22
|
+
// `[signal: …]` instead of an `[exit code: …]` marker. Returns the numeric
|
|
23
|
+
// exit code (>= 0) for a command-exit, or null otherwise.
|
|
24
|
+
export function shellCommandExitCode(text) {
|
|
25
|
+
const body = String(text || '');
|
|
26
|
+
// Anchor to the START of the result so a success/non-shell body that merely
|
|
27
|
+
// QUOTES the marker mid-output is never misclassified. bash-tool emits
|
|
28
|
+
// `Error: [shell-run-failed] [exit code: N]` as the leading marker header.
|
|
29
|
+
if (!/^\s*(?:Error:\s*)?\[shell-run-failed\]/i.test(body)) return null;
|
|
30
|
+
// Restrict marker parsing to the header region (first line) so only the
|
|
31
|
+
// engine-emitted status header — not quoted command output below — counts.
|
|
32
|
+
const header = body.split('\n', 1)[0] || '';
|
|
33
|
+
// Timeout / signal / abort are NOT a plain command exit — keep them "Failed".
|
|
34
|
+
if (/\[timeout:|\[signal:|timed out|aborted|interrupted/i.test(header)) return null;
|
|
35
|
+
const m = header.match(/\[exit code:\s*(\d+)\]/i);
|
|
36
|
+
if (!m) return null;
|
|
37
|
+
const code = Number(m[1]);
|
|
38
|
+
return Number.isFinite(code) ? code : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Only provider envelope metadata establishes a failed invocation. Result
|
|
42
|
+
// bodies can contain "Error:"/HTTP/domain/task status text without making the
|
|
43
|
+
// call itself fail. A recognized plain shell exit wins over provider envelope
|
|
44
|
+
// error flags: adapters commonly label non-zero process exits as tool errors
|
|
45
|
+
// even though the shell tool itself ran successfully.
|
|
46
|
+
export function toolCallOutcome(message, rawText) {
|
|
47
|
+
const exitCode = shellCommandExitCode(rawText);
|
|
48
|
+
if (exitCode != null) {
|
|
49
|
+
return { isCallError: false, isExitError: true, exitCode };
|
|
50
|
+
}
|
|
51
|
+
const isCallError = message?.isError === true || message?.toolKind === 'error';
|
|
52
|
+
return {
|
|
53
|
+
isCallError,
|
|
54
|
+
isExitError: false,
|
|
55
|
+
exitCode,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Build the collapsed failure/exit detail string. Real tool-call/result
|
|
60
|
+
// failures keep the red-adjacent "Failed" wording; shell command-exits render
|
|
61
|
+
// as the distinct neutral "Exit" state ("Exit N" for a single exit, "Y Exit"
|
|
62
|
+
// grouped). A mixed group surfaces both ("1 Ok · 1 Failed · 1 Exit").
|
|
63
|
+
export function failureDetailText({ succeeded = 0, realErrors = 0, exitErrors = 0, exitCode } = {}) {
|
|
64
|
+
const parts = [];
|
|
65
|
+
if (succeeded > 0) parts.push(`${succeeded} Ok`);
|
|
66
|
+
if (realErrors > 0) parts.push(`${realErrors} Failed`);
|
|
67
|
+
if (exitErrors > 0) {
|
|
68
|
+
const solo = exitErrors === 1 && realErrors === 0 && succeeded === 0;
|
|
69
|
+
parts.push(solo && Number.isFinite(exitCode) ? `Exit ${exitCode}` : `${exitErrors} Exit`);
|
|
70
|
+
}
|
|
71
|
+
return parts.join(' · ');
|
|
72
|
+
}
|
|
73
|
+
|
|
14
74
|
export function normalizedResultStatusToken(value) {
|
|
15
|
-
|
|
16
|
-
if (!raw) return '';
|
|
17
|
-
if (/^(running|pending|queued|in_progress|in-progress)$/.test(raw)) return 'running';
|
|
18
|
-
if (/^(completed|complete|done|success|succeeded|ok)$/.test(raw)) return 'completed';
|
|
19
|
-
if (/^(failed|fail|error|errored|timeout|timed_out|killed)$/.test(raw)) return 'failed';
|
|
20
|
-
if (/^(cancelled|canceled|cancel)$/.test(raw)) return 'cancelled';
|
|
21
|
-
return '';
|
|
75
|
+
return normalizeToolTerminalStatus(value);
|
|
22
76
|
}
|
|
23
77
|
|
|
24
78
|
export function resultTextTerminalStatus(text) {
|
|
25
|
-
|
|
26
|
-
const tagged = body.match(/<status[^>]*>([\s\S]*?)<\/status>/i)?.[1]?.trim();
|
|
27
|
-
if (tagged) return normalizedResultStatusToken(tagged);
|
|
28
|
-
const bracketed = body.match(/^\[status:\s*([^\]]*)\]/mi)?.[1]?.trim();
|
|
29
|
-
if (bracketed) return normalizedResultStatusToken(bracketed);
|
|
30
|
-
const inline = body.match(/^(?:status|state):\s*([^\s·,;]+)/mi)?.[1]?.trim();
|
|
31
|
-
return normalizedResultStatusToken(inline);
|
|
79
|
+
return toolResultTerminalStatus(text);
|
|
32
80
|
}
|
|
33
81
|
|
|
34
82
|
export function itemHasKnownTerminalStatus(item, texts = []) {
|
|
@@ -57,16 +105,18 @@ export function withCancelledResultMarker(text, item) {
|
|
|
57
105
|
export function groupedToolResultText(group) {
|
|
58
106
|
const completed = Math.min(group.count, group.completed);
|
|
59
107
|
if (group.count <= 1) return group.results.at(-1)?.text ?? '';
|
|
60
|
-
|
|
61
|
-
|
|
108
|
+
const exitErrors = Number(group.exitErrors || 0);
|
|
109
|
+
if (group.errors > 0 || exitErrors > 0) {
|
|
110
|
+
const realErrors = Math.max(0, Number(group.callErrors || group.errors || 0));
|
|
111
|
+
const succeeded = Math.max(0, completed - group.errors - exitErrors);
|
|
112
|
+
const exitCode = group.results.find((result) => result?.isExitError)?.exitCode;
|
|
113
|
+
// Command-exits carry no failure reason line; only real failures do.
|
|
62
114
|
const reasons = group.results
|
|
63
|
-
.filter((result) => result?.isError)
|
|
115
|
+
.filter((result) => result?.isError && !result?.isExitError)
|
|
64
116
|
.map((result) => firstErrorLine(result?.text))
|
|
65
117
|
.filter(Boolean);
|
|
66
118
|
const uniqueReasons = [...new Set(reasons)].slice(0, 2);
|
|
67
|
-
const base = succeeded
|
|
68
|
-
? `${succeeded} Ok · ${group.errors} Failed`
|
|
69
|
-
: `${group.errors} Failed`;
|
|
119
|
+
const base = failureDetailText({ succeeded, realErrors, exitErrors, exitCode });
|
|
70
120
|
return [
|
|
71
121
|
`${base}${uniqueReasons[0] ? ` · ${uniqueReasons[0]}` : ''}`,
|
|
72
122
|
...uniqueReasons.slice(1),
|
|
@@ -102,7 +152,7 @@ export function aggregateRawResult(calls) {
|
|
|
102
152
|
return chunks.join('\n\n');
|
|
103
153
|
}
|
|
104
154
|
|
|
105
|
-
export function aggregateBucketForCategory(category) {
|
|
155
|
+
export function aggregateBucketForCategory(category, { agentBatch = '' } = {}) {
|
|
106
156
|
// Merge consecutive tool calls of the SAME category into one aggregate card;
|
|
107
157
|
// a different category opens a fresh card (no cross-category merge). The
|
|
108
158
|
// bucket key is the category itself, so a run of Search calls collapses into
|
|
@@ -117,6 +167,10 @@ export function aggregateBucketForCategory(category) {
|
|
|
117
167
|
// header still spells out both. State-changing categories (Patch/Shell/…)
|
|
118
168
|
// stay separate.
|
|
119
169
|
if (key === 'Read' || key === 'Search') return 'category:Read+Search';
|
|
170
|
+
// Agent actions are grouped only inside the provider callback that emitted
|
|
171
|
+
// them. This keeps a later provider batch from changing an older outbound
|
|
172
|
+
// card above an intervening result/transcript boundary.
|
|
173
|
+
if (key === 'Agent') return agentBatch ? `category:Agent:${agentBatch}` : 'category:Agent';
|
|
120
174
|
return key ? `category:${key}` : 'default';
|
|
121
175
|
}
|
|
122
176
|
|
package/src/tui/engine/turn.mjs
CHANGED
|
@@ -6,11 +6,9 @@ import { applyUsageDelta } from './session-stats.mjs';
|
|
|
6
6
|
import { pickVerb, pickDoneVerb, compactEventLabel, compactEventDetail } from './labels.mjs';
|
|
7
7
|
import { toolResultText, toolErrorDisplay } from './tool-result-text.mjs';
|
|
8
8
|
import { toolCallId, toolResultCallId, toolCallName, toolCallArgs } from './tool-call-fields.mjs';
|
|
9
|
-
import {
|
|
10
|
-
import { promptDisplayText } from './queue-helpers.mjs';
|
|
11
|
-
import { memoryCoreResultErrorText } from '../app/input-parsers.mjs';
|
|
9
|
+
import { promptDisplayText, STEERING_SUPPRESSED_DISPLAY } from './queue-helpers.mjs';
|
|
12
10
|
import { yieldToRenderer } from './render-timing.mjs';
|
|
13
|
-
import { aggregateRawResult, aggregateBucketForCategory, aggregateSummaries, assignAggregateSummaryOrder } from './tool-result-status.mjs';
|
|
11
|
+
import { aggregateRawResult, aggregateBucketForCategory, aggregateSummaries, assignAggregateSummaryOrder, failureDetailText, toolCallOutcome } from './tool-result-status.mjs';
|
|
14
12
|
|
|
15
13
|
export function createRunTurn(bag) {
|
|
16
14
|
const {
|
|
@@ -42,6 +40,9 @@ export function createRunTurn(bag) {
|
|
|
42
40
|
reclaimed: false,
|
|
43
41
|
committed: false,
|
|
44
42
|
requeueEntries: Array.isArray(options.requeueOnAbort) ? options.requeueOnAbort.slice() : [],
|
|
43
|
+
discardExecutionPendingResumeKeys: Array.isArray(options.discardExecutionPendingResumeKeys)
|
|
44
|
+
? options.discardExecutionPendingResumeKeys.slice()
|
|
45
|
+
: [],
|
|
45
46
|
};
|
|
46
47
|
set({ busy: true, lastTurn: null, spinner: { active: true, verb: pickVerb(turnIndex), startedAt, responseLength: 0, inputTokens: 0, outputTokens: 0, mode: 'requesting' } });
|
|
47
48
|
|
|
@@ -130,6 +131,15 @@ export function createRunTurn(bag) {
|
|
|
130
131
|
};
|
|
131
132
|
armWatchdog();
|
|
132
133
|
let currentAssistantText = '';
|
|
134
|
+
// Segments sealed as their own assistant item(s) this turn via
|
|
135
|
+
// commitAssistantSegment (e.g. a tool preamble, then a no-newline tail
|
|
136
|
+
// committed by onSteerMessage before an injected steering row). Kept as an
|
|
137
|
+
// ordered list — NOT one concatenated string — so finalization can strip
|
|
138
|
+
// each committed segment out of the provider's final content individually.
|
|
139
|
+
// A single concatenation breaks when the provider omits an earlier segment
|
|
140
|
+
// (e.g. a tool preamble) from result.content: the combined prefix no longer
|
|
141
|
+
// matches and the tail would duplicate after the steering row.
|
|
142
|
+
const committedSegments = [];
|
|
133
143
|
let thinkingText = '';
|
|
134
144
|
let thinkingStartedAt = 0;
|
|
135
145
|
let thinkingSegmentStartedAt = 0;
|
|
@@ -147,6 +157,7 @@ export function createRunTurn(bag) {
|
|
|
147
157
|
const earlyResultBuffer = new Map();
|
|
148
158
|
const aggregateCards = []; // active aggregate cards in the current consecutive tool block
|
|
149
159
|
let tailAggregate = null; // most recently touched aggregate card; only the tail may absorb the next same-bucket call
|
|
160
|
+
let providerToolBatch = 0;
|
|
150
161
|
|
|
151
162
|
// ── Deferred tool-card push (scroll/text sync) ────────────────────────────
|
|
152
163
|
// A tool card used to enter the transcript the instant onToolCall fired,
|
|
@@ -163,13 +174,11 @@ export function createRunTurn(bag) {
|
|
|
163
174
|
// cards are pushed alongside the result-bearing one before their own delay
|
|
164
175
|
// elapses and would otherwise paint an empty reserved band.
|
|
165
176
|
// Mirrors components/ToolExecution.jsx TOOL_PENDING_SHOW_DELAY_MS.
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
// surfaced, preserving call-order flush semantics.
|
|
172
|
-
const TOOL_CARD_PUSH_DELAY_MS = 0;
|
|
177
|
+
// Keep a fast call off-screen until it either resolves or has genuinely
|
|
178
|
+
// been pending long enough to communicate work. This avoids a one-frame
|
|
179
|
+
// Running→Finished flash while preserving the deferred entry's ordered,
|
|
180
|
+
// result-forced materialization path.
|
|
181
|
+
const TOOL_CARD_PUSH_DELAY_MS = 1000;
|
|
173
182
|
let deferredSeqCounter = 0;
|
|
174
183
|
const deferredEntries = []; // creation-order list; each is pushed at most once
|
|
175
184
|
// Push this entry AND every earlier-created still-deferred entry, in order,
|
|
@@ -328,13 +337,15 @@ export function createRunTurn(bag) {
|
|
|
328
337
|
aggregate.ensureVisible?.();
|
|
329
338
|
const errors = allCalls.filter((r) => r.isError).length;
|
|
330
339
|
const callErrors = allCalls.filter((r) => r.isCallError).length;
|
|
340
|
+
const exitErrors = allCalls.filter((r) => r.isExitError).length;
|
|
331
341
|
const completed = allCalls.filter((r) => r.resolved).length;
|
|
332
|
-
const succeeded = completed - errors;
|
|
342
|
+
const succeeded = Math.max(0, completed - errors - exitErrors);
|
|
333
343
|
const rawResult = aggregateRawResult(allCalls);
|
|
334
|
-
// Merged count summary (see patchToolCardResult); failures keep
|
|
335
|
-
// 'N
|
|
336
|
-
|
|
337
|
-
|
|
344
|
+
// Merged count summary (see patchToolCardResult); real failures keep
|
|
345
|
+
// 'N Failed', shell command-exits render 'Exit N'/'Y Exit'. Raw
|
|
346
|
+
// preserved for ctrl+o expansion.
|
|
347
|
+
const displayDetail = errors > 0 || exitErrors > 0
|
|
348
|
+
? failureDetailText({ succeeded, realErrors: callErrors, exitErrors, exitCode: allCalls.find((r) => r.isExitError)?.exitCode })
|
|
338
349
|
: formatAggregateDetail(aggregateSummaries(aggregate));
|
|
339
350
|
patchItem(aggregate.itemId, {
|
|
340
351
|
result: displayDetail,
|
|
@@ -343,6 +354,7 @@ export function createRunTurn(bag) {
|
|
|
343
354
|
isError: errors > 0,
|
|
344
355
|
errorCount: errors,
|
|
345
356
|
callErrorCount: callErrors,
|
|
357
|
+
exitErrorCount: exitErrors,
|
|
346
358
|
count: allCalls.length,
|
|
347
359
|
completedCount: allCalls.length,
|
|
348
360
|
doneCategories: aggregateDoneCategories(allCalls),
|
|
@@ -463,6 +475,7 @@ export function createRunTurn(bag) {
|
|
|
463
475
|
if (sealToolBlock) clearAggregateContinuation();
|
|
464
476
|
const id = currentAssistantId || ensureAssistant(text);
|
|
465
477
|
patchItem(id, { text, streaming: false });
|
|
478
|
+
committedSegments.push(text);
|
|
466
479
|
closeAssistantSegment();
|
|
467
480
|
return true;
|
|
468
481
|
};
|
|
@@ -658,35 +671,33 @@ export function createRunTurn(bag) {
|
|
|
658
671
|
if (!callRec || callRec.resolved || callRec.completedEarly) return;
|
|
659
672
|
aggregate.ensureVisible?.();
|
|
660
673
|
const rawText = toolResultText(message?.content);
|
|
661
|
-
//
|
|
662
|
-
//
|
|
663
|
-
//
|
|
664
|
-
|
|
665
|
-
const
|
|
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;
|
|
674
|
+
// Tool result text (including HTTP/domain failures, zero matches, task
|
|
675
|
+
// statuses, and shell output) is detail, not a failed invocation. Only
|
|
676
|
+
// the provider's isError/error-tool envelope drives failure counts/red.
|
|
677
|
+
const { exitCode, isExitError, isCallError } = toolCallOutcome(message, rawText);
|
|
678
|
+
const isError = isCallError;
|
|
671
679
|
const text = isError ? toolErrorDisplay(rawText, callRec.name || 'tool') : rawText;
|
|
672
680
|
callRec.summary = !isError ? summarizeToolResult(callRec.name, callRec.args, rawText, isError) : null;
|
|
673
681
|
assignAggregateSummaryOrder(aggregate, callRec);
|
|
674
682
|
callRec.isError = isError;
|
|
675
683
|
callRec.isCallError = isCallError;
|
|
684
|
+
callRec.isExitError = isExitError;
|
|
685
|
+
callRec.exitCode = exitCode;
|
|
676
686
|
callRec.resultText = text;
|
|
677
687
|
callRec.completedEarly = true;
|
|
678
688
|
const allCalls = [...aggregate.calls.values()];
|
|
679
689
|
const completedCount = allCalls.filter((r) => r.resolved || r.completedEarly).length;
|
|
680
690
|
const errors = allCalls.filter((r) => r.isError).length;
|
|
681
691
|
const callErrors = allCalls.filter((r) => r.isCallError).length;
|
|
682
|
-
const
|
|
692
|
+
const exitErrors = allCalls.filter((r) => r.isExitError).length;
|
|
693
|
+
const succeeded = Math.max(0, completedCount - errors - exitErrors);
|
|
683
694
|
const rawResult = aggregateRawResult(allCalls);
|
|
684
695
|
// Collapsed detail carries the merged per-call count summary even on
|
|
685
696
|
// the early-notify path; patching '' here flipped the detail row back
|
|
686
697
|
// to the 'Running' placeholder between count updates (the visible
|
|
687
698
|
// jitter). Failures keep 'N Failed'. Raw preserved for ctrl+o expansion.
|
|
688
|
-
const displayDetail = errors > 0
|
|
689
|
-
? (succeeded
|
|
699
|
+
const displayDetail = errors > 0 || exitErrors > 0
|
|
700
|
+
? failureDetailText({ succeeded, realErrors: callErrors, exitErrors, exitCode: allCalls.find((r) => r.isExitError)?.exitCode })
|
|
690
701
|
: formatAggregateDetail(aggregateSummaries(aggregate));
|
|
691
702
|
const currentItem = getState().items.find((it) => it.id === card.itemId);
|
|
692
703
|
const visualCompleted = Math.max(
|
|
@@ -699,6 +710,7 @@ export function createRunTurn(bag) {
|
|
|
699
710
|
isError: errors > 0,
|
|
700
711
|
errorCount: errors,
|
|
701
712
|
callErrorCount: callErrors,
|
|
713
|
+
exitErrorCount: exitErrors,
|
|
702
714
|
count: allCalls.length,
|
|
703
715
|
completedCount: visualCompleted,
|
|
704
716
|
};
|
|
@@ -727,15 +739,22 @@ export function createRunTurn(bag) {
|
|
|
727
739
|
drainSteering: (_sessionId, drainOptions) => (isCurrentTurn() ? drainPendingSteering(drainOptions) : []),
|
|
728
740
|
onSteerMessage: (text) => {
|
|
729
741
|
if (!markTurnProgress('steer-message')) return;
|
|
742
|
+
// A suppressed live-completion twin is model-visible only; its
|
|
743
|
+
// Response card was already pushed at delivery time. Skip the
|
|
744
|
+
// duplicate transcript item (progress is still marked above since
|
|
745
|
+
// the content WAS injected into the model turn).
|
|
746
|
+
if (text === STEERING_SUPPRESSED_DISPLAY) return;
|
|
730
747
|
// Steering can be injected after a terminal no-tool response has
|
|
731
748
|
// already streamed but before runTurn finalizes. Seal the current
|
|
732
749
|
// assistant segment first so the steered user turn and the next
|
|
733
750
|
// assistant response do not get visually merged into one bubble.
|
|
734
751
|
flushStreamBatch();
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
752
|
+
// Commit any pending assistant segment — including a streamed tail
|
|
753
|
+
// that never got a trailing '\n' (no row/currentAssistantId created
|
|
754
|
+
// yet). Using the shared segment-commit helper ensures that tail is
|
|
755
|
+
// materialized as an assistant item instead of being dropped when a
|
|
756
|
+
// steering/agent-completion injection races turn finalization.
|
|
757
|
+
commitAssistantSegment({ sealToolBlock: true });
|
|
739
758
|
assistantText = '';
|
|
740
759
|
const value = String(text || '').trim();
|
|
741
760
|
if (value) {
|
|
@@ -764,6 +783,7 @@ export function createRunTurn(bag) {
|
|
|
764
783
|
}
|
|
765
784
|
const batchCalls = (calls || []).filter(Boolean);
|
|
766
785
|
if (batchCalls.length === 0) return;
|
|
786
|
+
const agentBatch = ++providerToolBatch;
|
|
767
787
|
const committedAssistantSegment = commitAssistantSegment({ sealToolBlock: true });
|
|
768
788
|
if (committedAssistantSegment) {
|
|
769
789
|
// Let the pre-tool assistant preamble paint and settle before the
|
|
@@ -787,11 +807,10 @@ export function createRunTurn(bag) {
|
|
|
787
807
|
// Category drives the aggregate bucket so only same-category calls
|
|
788
808
|
// merge into one card; classify first, then bucket by it.
|
|
789
809
|
const category = classifyToolCategory(name, args);
|
|
790
|
-
// Agent
|
|
791
|
-
//
|
|
792
|
-
//
|
|
793
|
-
|
|
794
|
-
const bucket = category === 'Agent' ? null : aggregateBucketForCategory(category);
|
|
810
|
+
// Agent actions aggregate only within this provider-emitted batch.
|
|
811
|
+
// They stay outbound category cards; asynchronous inbound Responses
|
|
812
|
+
// are separately tailed by the notification feed and never mix here.
|
|
813
|
+
const bucket = aggregateBucketForCategory(category, { agentBatch });
|
|
795
814
|
const callId = toolCallId(c);
|
|
796
815
|
const callKey = callId || `__tool_${toolCards.length}_${i}`;
|
|
797
816
|
// The old App scan counted multi-pattern calls via
|
|
@@ -854,7 +873,7 @@ export function createRunTurn(bag) {
|
|
|
854
873
|
...categoryEntry,
|
|
855
874
|
count: Number(prevCategory?.count || 0) + Number(categoryEntry.count || 1),
|
|
856
875
|
});
|
|
857
|
-
aggregateCard.calls.set(callKey, { name, args, category, summary: null, summarySeq: null, isError: false, isCallError: false, resultText: null, resolved: false, completedEarly: false });
|
|
876
|
+
aggregateCard.calls.set(callKey, { name, args, category, summary: null, summarySeq: null, isError: false, isCallError: false, isExitError: false, exitCode: null, resultText: null, resolved: false, completedEarly: false });
|
|
858
877
|
touchedAggregates.add(aggregateCard);
|
|
859
878
|
const card = { itemId: aggregateCard.itemId, callId: callKey, done: false, aggregate: aggregateCard };
|
|
860
879
|
if (callId) {
|
|
@@ -1038,16 +1057,37 @@ export function createRunTurn(bag) {
|
|
|
1038
1057
|
syncContextStats({ allowEstimated: true });
|
|
1039
1058
|
|
|
1040
1059
|
const finalText = result?.content != null ? String(result.content) : '';
|
|
1041
|
-
|
|
1060
|
+
// Strip text already sealed as its own item(s) this turn (a tool
|
|
1061
|
+
// preamble, then a no-newline tail committed by onSteerMessage before an
|
|
1062
|
+
// injected steering row) so finalization reconciles only the uncommitted
|
|
1063
|
+
// remainder — never re-creating a committed segment as a duplicate item
|
|
1064
|
+
// that also reorders after the steering row. Walk the segments IN ORDER,
|
|
1065
|
+
// peeling each off the front of the remaining content; skip leading
|
|
1066
|
+
// whitespace/newlines between segments. A segment that does not match at
|
|
1067
|
+
// the current position (provider omitted it from result.content, e.g. a
|
|
1068
|
+
// tool preamble) is left in place and the walk moves on.
|
|
1069
|
+
let finalRemainder = finalText;
|
|
1070
|
+
for (const seg of committedSegments) {
|
|
1071
|
+
// Compare against the whitespace-skipped remainder AND a
|
|
1072
|
+
// whitespace-trimmed segment: a segment sealed with its own leading
|
|
1073
|
+
// newline ('\nTAIL') would otherwise never match the skipped remainder
|
|
1074
|
+
// ('TAIL') and duplicate after the steering row.
|
|
1075
|
+
const skipped = finalRemainder.replace(/^\s+/, '');
|
|
1076
|
+
const trimmedSeg = seg ? seg.replace(/^\s+/, '') : '';
|
|
1077
|
+
if (trimmedSeg && skipped.startsWith(trimmedSeg)) {
|
|
1078
|
+
finalRemainder = skipped.slice(trimmedSeg.length);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
if (finalRemainder.trim()) {
|
|
1042
1082
|
// The persisted transcript is written from the provider's final content,
|
|
1043
1083
|
// while the live TUI row is fed by streaming deltas. If a provider/parser
|
|
1044
1084
|
// misses or suppresses an early delta, keeping the streamed buffer here
|
|
1045
1085
|
// leaves the final on-screen assistant row missing leading characters even
|
|
1046
1086
|
// though the transcript is correct. Always reconcile the active segment to
|
|
1047
1087
|
// the final provider text when it is available.
|
|
1048
|
-
const id = currentAssistantId || ensureAssistant(
|
|
1049
|
-
currentAssistantText =
|
|
1050
|
-
patchItem(id, { text:
|
|
1088
|
+
const id = currentAssistantId || ensureAssistant(finalRemainder);
|
|
1089
|
+
currentAssistantText = finalRemainder;
|
|
1090
|
+
patchItem(id, { text: finalRemainder, streaming: false });
|
|
1051
1091
|
} else if (currentAssistantId && (currentAssistantText.trim() || assistantText.trim())) {
|
|
1052
1092
|
const streamedText = currentAssistantText || assistantText;
|
|
1053
1093
|
patchItem(currentAssistantId, { text: streamedText, streaming: false });
|