@aragon-agent/cli 0.5.6 → 0.5.9
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/CHANGELOG.md +356 -0
- package/README.md +1429 -1105
- package/dist/agent/coalesce.d.ts +13 -0
- package/dist/agent/coalesce.js +50 -11
- package/dist/agent/controller.d.ts +141 -2
- package/dist/agent/controller.js +396 -4
- package/dist/agent/entry-limits.d.ts +65 -0
- package/dist/agent/entry-limits.js +133 -0
- package/dist/agent/headless.d.ts +8 -0
- package/dist/agent/headless.js +40 -0
- package/dist/agent/reducer.d.ts +253 -3
- package/dist/agent/reducer.js +553 -28
- package/dist/agent/retry-view.d.ts +68 -0
- package/dist/agent/retry-view.js +95 -0
- package/dist/agent/system-prompt.d.ts +11 -0
- package/dist/agent/system-prompt.js +2 -0
- package/dist/cli.js +122 -4
- package/dist/commands/builtins.d.ts +1 -1
- package/dist/commands/builtins.js +313 -2
- package/dist/commands/perf.d.ts +45 -0
- package/dist/commands/perf.js +78 -0
- package/dist/config/cli-commands.d.ts +53 -2
- package/dist/config/cli-commands.js +136 -3
- package/dist/config/env.d.ts +6 -1
- package/dist/config/env.js +131 -1
- package/dist/config/load.d.ts +63 -0
- package/dist/config/load.js +125 -2
- package/dist/config/schema.d.ts +273 -10
- package/dist/config/schema.js +250 -7
- package/dist/config/store.js +20 -1
- package/dist/fast/critique.d.ts +43 -0
- package/dist/fast/critique.js +62 -0
- package/dist/fast/digest.d.ts +42 -0
- package/dist/fast/digest.js +85 -0
- package/dist/fast/frames.d.ts +99 -0
- package/dist/fast/frames.js +180 -0
- package/dist/fast/limits.d.ts +98 -0
- package/dist/fast/limits.js +98 -0
- package/dist/fast/prompt.d.ts +72 -0
- package/dist/fast/prompt.js +94 -0
- package/dist/fast/resolve.d.ts +43 -0
- package/dist/fast/resolve.js +105 -0
- package/dist/fast/review-call.d.ts +99 -0
- package/dist/fast/review-call.js +130 -0
- package/dist/fast/reviewer.d.ts +248 -0
- package/dist/fast/reviewer.js +664 -0
- package/dist/fast/types.d.ts +128 -0
- package/dist/fast/types.js +14 -0
- package/dist/fast/wiring.d.ts +170 -0
- package/dist/fast/wiring.js +316 -0
- package/dist/logging/install.d.ts +15 -1
- package/dist/logging/install.js +37 -4
- package/dist/logging/logger.d.ts +1 -1
- package/dist/session/persist.js +57 -1
- package/dist/team/normalize.d.ts +26 -1
- package/dist/team/normalize.js +23 -3
- package/dist/team/prompt.d.ts +11 -0
- package/dist/team/prompt.js +10 -0
- package/dist/team/report.d.ts +18 -0
- package/dist/team/report.js +27 -1
- package/dist/team/runtime.d.ts +13 -2
- package/dist/team/runtime.js +33 -6
- package/dist/team/subagent.d.ts +19 -1
- package/dist/team/subagent.js +72 -10
- package/dist/team/task-tool.d.ts +26 -0
- package/dist/team/task-tool.js +48 -4
- package/dist/team/types.d.ts +80 -1
- package/dist/tools/bash-tool.js +19 -3
- package/dist/tools/diff.d.ts +25 -5
- package/dist/tools/diff.js +72 -41
- package/dist/tools/file-change-store.d.ts +34 -0
- package/dist/tools/file-change-store.js +57 -0
- package/dist/tools/fs-tools.d.ts +26 -0
- package/dist/tools/fs-tools.js +62 -5
- package/dist/tools/index.d.ts +31 -0
- package/dist/tools/index.js +7 -1
- package/dist/tools/patch.d.ts +115 -0
- package/dist/tools/patch.js +453 -0
- package/dist/tools/terminal-output.d.ts +107 -0
- package/dist/tools/terminal-output.js +198 -0
- package/dist/tools/tool-output-store.d.ts +68 -0
- package/dist/tools/tool-output-store.js +102 -0
- package/dist/ui/ActivityLine.d.ts +48 -0
- package/dist/ui/ActivityLine.js +22 -0
- package/dist/ui/App.d.ts +1 -4
- package/dist/ui/App.js +357 -44
- package/dist/ui/BottomStatusRow.d.ts +43 -0
- package/dist/ui/BottomStatusRow.js +14 -0
- package/dist/ui/Composer.d.ts +1 -6
- package/dist/ui/Composer.js +2 -2
- package/dist/ui/Markdown.d.ts +7 -1
- package/dist/ui/Markdown.js +15 -48
- package/dist/ui/PromptInput.d.ts +1 -15
- package/dist/ui/PromptInput.js +1 -38
- package/dist/ui/StatusBar.d.ts +50 -0
- package/dist/ui/StatusBar.js +7 -3
- package/dist/ui/TeamPanel.d.ts +5 -4
- package/dist/ui/TeamPanel.js +1 -1
- package/dist/ui/TodoPanel.d.ts +5 -4
- package/dist/ui/Transcript.d.ts +80 -5
- package/dist/ui/Transcript.js +179 -11
- package/dist/ui/activity-phrases.d.ts +33 -0
- package/dist/ui/activity-phrases.js +50 -0
- package/dist/ui/entries/AssistantEntry.d.ts +38 -1
- package/dist/ui/entries/AssistantEntry.js +34 -3
- package/dist/ui/entries/DiffView.d.ts +67 -0
- package/dist/ui/entries/DiffView.js +103 -0
- package/dist/ui/entries/FastCard.d.ts +53 -0
- package/dist/ui/entries/FastCard.js +87 -0
- package/dist/ui/entries/RetryCard.d.ts +56 -0
- package/dist/ui/entries/RetryCard.js +60 -0
- package/dist/ui/entries/TeamCard.d.ts +9 -1
- package/dist/ui/entries/TeamCard.js +28 -2
- package/dist/ui/entries/TodoCard.d.ts +9 -1
- package/dist/ui/entries/TodoCard.js +23 -1
- package/dist/ui/entries/ToolCard.d.ts +50 -1
- package/dist/ui/entries/ToolCard.js +104 -7
- package/dist/ui/entries/ToolPreview.d.ts +2 -2
- package/dist/ui/entries/UserEntry.d.ts +9 -1
- package/dist/ui/entries/UserEntry.js +17 -1
- package/dist/ui/exit-snapshot.d.ts +7 -0
- package/dist/ui/glyphs.d.ts +9 -0
- package/dist/ui/glyphs.js +2 -0
- package/dist/ui/layout/AppShell.d.ts +13 -22
- package/dist/ui/layout/AppShell.js +3 -38
- package/dist/ui/layout/MeasuredEntry.d.ts +21 -0
- package/dist/ui/layout/MeasuredEntry.js +30 -0
- package/dist/ui/layout/ScrollViewport.js +8 -2
- package/dist/ui/layout/viewport-geometry.d.ts +28 -0
- package/dist/ui/layout/viewport-geometry.js +27 -0
- package/dist/ui/layout/virtual-window.d.ts +100 -0
- package/dist/ui/layout/virtual-window.js +308 -0
- package/dist/ui/live-clamp.d.ts +42 -0
- package/dist/ui/live-clamp.js +74 -0
- package/dist/ui/markdown-blocks.d.ts +25 -1
- package/dist/ui/markdown-blocks.js +43 -0
- package/dist/ui/overlays/HelpOverlay.js +16 -2
- package/dist/ui/overlays/SettingsScreen.d.ts +82 -1
- package/dist/ui/overlays/SettingsScreen.js +147 -1
- package/dist/ui/render-cache.d.ts +81 -0
- package/dist/ui/render-cache.js +195 -0
- package/dist/ui/render-governor.d.ts +54 -0
- package/dist/ui/render-governor.js +78 -0
- package/dist/ui/transcript-text.d.ts +11 -0
- package/dist/ui/transcript-text.js +32 -0
- package/dist/ui/use-height-store.d.ts +31 -0
- package/dist/ui/use-height-store.js +76 -0
- package/dist/ui/use-render-governor.d.ts +39 -0
- package/dist/ui/use-render-governor.js +57 -0
- package/dist/ui/use-startup-notices.d.ts +9 -1
- package/dist/ui/use-startup-notices.js +35 -1
- package/dist/ui/use-wheel-routing.d.ts +16 -32
- package/dist/ui/use-wheel-routing.js +21 -38
- package/package.json +2 -2
- package/dist/ui/layout/regions.d.ts +0 -55
- package/dist/ui/layout/regions.js +0 -52
package/dist/agent/reducer.js
CHANGED
|
@@ -10,17 +10,32 @@
|
|
|
10
10
|
* - `agent_end` after a turn that produced no `turn_end` and no error notice
|
|
11
11
|
* synthesizes a generic error notice (a swallowed throw is never a no-op).
|
|
12
12
|
*/
|
|
13
|
+
import process from 'node:process';
|
|
13
14
|
import { computeCost } from './usage.js';
|
|
15
|
+
import { ENTRY_LIMITS, appendBounded, entryRetain, trimEntries } from './entry-limits.js';
|
|
14
16
|
/** Default auto-dismiss window for a toast. */
|
|
15
17
|
export const DEFAULT_TOAST_TTL_MS = 2500;
|
|
16
|
-
|
|
18
|
+
/**
|
|
19
|
+
* The initial view state, optionally seeded from config.
|
|
20
|
+
*
|
|
21
|
+
* THE DEFAULT IS `false`, NOT `true`, AND THAT IS THE WHOLE SAFETY ARGUMENT
|
|
22
|
+
* (D-2). There is exactly one production call site — `App`'s `useReducer`, which
|
|
23
|
+
* passes `cfg.showThinking`. Every other caller is a test. A future call site
|
|
24
|
+
* that forgets the seed fails CLOSED, toward the product default, rather than
|
|
25
|
+
* silently restoring the reasoning firehose for one code path with nothing
|
|
26
|
+
* reporting it.
|
|
27
|
+
*
|
|
28
|
+
* The seed is a PARAMETER rather than a post-mount dispatch so that `/resume`
|
|
29
|
+
* cannot flash a restored thinking block on frame 1.
|
|
30
|
+
*/
|
|
31
|
+
export function initialViewState(seed = {}) {
|
|
17
32
|
return {
|
|
18
33
|
entries: [],
|
|
19
34
|
status: 'idle',
|
|
20
35
|
usageTotal: { inputTokens: 0, outputTokens: 0, costUsd: 0 },
|
|
21
36
|
contextTokens: 0,
|
|
22
37
|
overlay: null,
|
|
23
|
-
thinkingVisible:
|
|
38
|
+
thinkingVisible: seed.thinkingVisible ?? false,
|
|
24
39
|
toasts: [],
|
|
25
40
|
expandedToolIds: {},
|
|
26
41
|
// Seeded to the default posture; the App overrides it once on mount from
|
|
@@ -29,13 +44,18 @@ export function initialViewState() {
|
|
|
29
44
|
pendingAgentMode: null,
|
|
30
45
|
team: null,
|
|
31
46
|
todos: null,
|
|
47
|
+
retry: null,
|
|
48
|
+
fast: null,
|
|
32
49
|
seq: 0,
|
|
33
50
|
teamEntryId: undefined,
|
|
34
51
|
todoEntryId: undefined,
|
|
52
|
+
retryEntryId: undefined,
|
|
53
|
+
fastEntryId: undefined,
|
|
35
54
|
streamingId: undefined,
|
|
36
55
|
turnProduced: false,
|
|
37
56
|
errorNoticed: false,
|
|
38
57
|
aborted: false,
|
|
58
|
+
droppedEntries: 0,
|
|
39
59
|
};
|
|
40
60
|
}
|
|
41
61
|
// ---------------------------------------------------------------------------
|
|
@@ -77,12 +97,18 @@ export function buildToolPreview(result) {
|
|
|
77
97
|
}
|
|
78
98
|
return preview;
|
|
79
99
|
}
|
|
100
|
+
/** The first line of a stored preview, without its trailing newline. */
|
|
101
|
+
function firstLine(text) {
|
|
102
|
+
const at = text.indexOf('\n');
|
|
103
|
+
return at === -1 ? text : text.slice(0, at);
|
|
104
|
+
}
|
|
80
105
|
/**
|
|
81
106
|
* Map one core `AgentEvent` to view actions. `cost` (the active model's cost
|
|
82
107
|
* table) lets `turn_end` accumulate USD cost; omit it in tests that only assert
|
|
83
|
-
* token totals.
|
|
108
|
+
* token totals. `patches` is the CLI-local diff side channel (§3.3.7); omitting
|
|
109
|
+
* it — every existing test, and headless mode — produces byte-identical actions.
|
|
84
110
|
*/
|
|
85
|
-
export function reduceEvent(event, cost) {
|
|
111
|
+
export function reduceEvent(event, cost, patches) {
|
|
86
112
|
switch (event.type) {
|
|
87
113
|
case 'agent_start':
|
|
88
114
|
return [{ type: 'runStart' }];
|
|
@@ -100,16 +126,27 @@ export function reduceEvent(event, cost) {
|
|
|
100
126
|
return [{ type: 'runEnd' }];
|
|
101
127
|
case 'tool_execution_start':
|
|
102
128
|
return [{ type: 'toolExecStart', toolCallId: event.toolCallId }];
|
|
103
|
-
case 'tool_execution_end':
|
|
129
|
+
case 'tool_execution_end': {
|
|
130
|
+
const patch = patches?.take(event.toolCallId);
|
|
131
|
+
const preview = buildToolPreview(event.result);
|
|
104
132
|
return [
|
|
105
133
|
{
|
|
106
134
|
type: 'toolExecEnd',
|
|
107
135
|
toolCallId: event.toolCallId,
|
|
108
136
|
isError: event.isError,
|
|
109
137
|
duration: event.duration,
|
|
110
|
-
|
|
138
|
+
// AN ENTRY CARRIES A PATCH OR A FULL PREVIEW, NEVER BOTH (D-19). This
|
|
139
|
+
// is not a budget compromise, it is deleting a duplicate: `ToolCard`
|
|
140
|
+
// renders `DiffView` INSTEAD of `ToolPreview` when a patch exists, so
|
|
141
|
+
// the stored preview is never drawn on that card — and for `edit_file`
|
|
142
|
+
// it is literally the same diff, as text. Keeping the first line
|
|
143
|
+
// preserves the one thing outside `DiffView` that still reads it,
|
|
144
|
+
// `entryRevision`'s `preview?.length` term.
|
|
145
|
+
preview: patch ? firstLine(preview) : preview,
|
|
146
|
+
...(patch ? { patch } : {}),
|
|
111
147
|
},
|
|
112
148
|
];
|
|
149
|
+
}
|
|
113
150
|
case 'message_update':
|
|
114
151
|
return reduceStreamEvent(event.streamEvent);
|
|
115
152
|
case 'code_execution_start':
|
|
@@ -165,6 +202,23 @@ function reduceStreamEvent(se) {
|
|
|
165
202
|
case 'done':
|
|
166
203
|
// Authoritative usage is redundant with `turn_end`; nothing to add.
|
|
167
204
|
return [];
|
|
205
|
+
// --- API retry (llm-api-retry-backoff §6.4) ---------------------------
|
|
206
|
+
case 'retry_scheduled':
|
|
207
|
+
return [
|
|
208
|
+
{
|
|
209
|
+
type: 'retryScheduled',
|
|
210
|
+
attempt: se.attempt,
|
|
211
|
+
maxRetries: se.maxRetries,
|
|
212
|
+
delayMs: se.delayMs,
|
|
213
|
+
resumeAt: se.resumeAt,
|
|
214
|
+
errorType: se.errorType,
|
|
215
|
+
message: se.message,
|
|
216
|
+
},
|
|
217
|
+
];
|
|
218
|
+
case 'retry_attempt':
|
|
219
|
+
return [{ type: 'retryAttempt', attempt: se.attempt, maxRetries: se.maxRetries }];
|
|
220
|
+
case 'stream_restart':
|
|
221
|
+
return [{ type: 'streamRestart', discardedToolCallIds: se.discardedToolCallIds }];
|
|
168
222
|
default:
|
|
169
223
|
return [];
|
|
170
224
|
}
|
|
@@ -201,6 +255,131 @@ function nextId(state) {
|
|
|
201
255
|
function mapEntry(entries, id, fn) {
|
|
202
256
|
return entries.map((e) => (e.id === id ? fn(e) : e));
|
|
203
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* Seal `thinkingMs`, once, as a spreadable patch (§3.1.2).
|
|
260
|
+
*
|
|
261
|
+
* Returns an EMPTY object when there is nothing to seal — no reasoning happened,
|
|
262
|
+
* or the clock has already stopped — so the three call sites can spread it
|
|
263
|
+
* unconditionally and none of them can re-seal a settled duration.
|
|
264
|
+
*/
|
|
265
|
+
function sealThinkingMs(entry) {
|
|
266
|
+
if (entry.thinkingStartedAt === undefined || entry.thinkingMs !== undefined)
|
|
267
|
+
return {};
|
|
268
|
+
return { thinkingMs: Math.max(0, Date.now() - entry.thinkingStartedAt) };
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Append one entry and ring-trim the result (tui-render-performance L1).
|
|
272
|
+
*
|
|
273
|
+
* TRIMMING HAPPENS ONLY AT APPEND POINTS, and that is the whole reason this is a
|
|
274
|
+
* helper rather than a line in `viewReducer`. A mid-turn trim triggered by an
|
|
275
|
+
* UPDATE action could drop the entry `streamingId` / `teamEntryId` /
|
|
276
|
+
* `todoEntryId` points at while it is still being written to; appending is the
|
|
277
|
+
* only moment at which the tail is by definition the newest thing.
|
|
278
|
+
*
|
|
279
|
+
* I-L1-1 — the trim must never drop a live target. With `retain >= 200` and a
|
|
280
|
+
* trim that only ever removes from the head this is structurally true, but a
|
|
281
|
+
* future clamp change would break it SILENTLY (the `mapEntry` calls would simply
|
|
282
|
+
* stop matching), so development builds assert it.
|
|
283
|
+
*/
|
|
284
|
+
function appendEntry(state, entries, entry) {
|
|
285
|
+
const trimmed = trimEntries([...entries, entry], entryRetain());
|
|
286
|
+
if (trimmed.dropped > 0 && process.env.NODE_ENV !== 'production') {
|
|
287
|
+
// `retryEntryId` IS PART OF THIS LIST. A fourth live card that is not in it is
|
|
288
|
+
// a fourth card I-L1-1 does not cover — the trim would silently turn its
|
|
289
|
+
// `mapEntry` into a no-op instead of failing loudly.
|
|
290
|
+
const live = [
|
|
291
|
+
state.streamingId,
|
|
292
|
+
state.teamEntryId,
|
|
293
|
+
state.todoEntryId,
|
|
294
|
+
state.retryEntryId,
|
|
295
|
+
// `fastEntryId` IS PART OF THIS LIST for the reason the note above gives:
|
|
296
|
+
// a fifth live card left out of it is a card I-L1-1 does not cover.
|
|
297
|
+
state.fastEntryId,
|
|
298
|
+
].filter((id) => typeof id === 'string');
|
|
299
|
+
for (const id of live) {
|
|
300
|
+
if (!trimmed.entries.some((e) => e.id === id)) {
|
|
301
|
+
throw new Error(`trimEntries dropped a live entry (${id}) - see I-L1-1`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
entries: trimmed.entries,
|
|
307
|
+
droppedEntries: state.droppedEntries + trimmed.dropped,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Settle the turn's retry card, if it has one (llm-api-retry-backoff §6.4).
|
|
312
|
+
*
|
|
313
|
+
* SETTLING IS DERIVED HERE, NOT SENT BY CORE. Core knows it scheduled a retry; it
|
|
314
|
+
* does not know whether the turn eventually succeeded, was interrupted, or ran out
|
|
315
|
+
* of budget — that is the consumer's own timeline.
|
|
316
|
+
*
|
|
317
|
+
* FOUR PATHS REACH THIS, and the fourth is what makes the set exhaustive rather
|
|
318
|
+
* than enumerated: `turnEnd` (recovered), an error `notice` (exhausted),
|
|
319
|
+
* `abortMark` (interrupted) and `runEnd` (interrupted). The rule is "every path
|
|
320
|
+
* that reaches `status: 'idle'` settles the card", because a card left at
|
|
321
|
+
* `phase: 'waiting'` pins `Transcript`'s MONOTONIC settled boundary and re-renders
|
|
322
|
+
* the tail every frame for the rest of the session (R-10).
|
|
323
|
+
*
|
|
324
|
+
* Returning `retry: null` / `retryEntryId: undefined` unconditionally is
|
|
325
|
+
* deliberate: both are already that when there is no card, so every caller can
|
|
326
|
+
* spread the result without a branch of its own.
|
|
327
|
+
*/
|
|
328
|
+
function settleRetryCard(state, entries, phase) {
|
|
329
|
+
if (!state.retryEntryId)
|
|
330
|
+
return { entries, retry: null, retryEntryId: undefined };
|
|
331
|
+
const now = Date.now();
|
|
332
|
+
return {
|
|
333
|
+
entries: mapEntry(entries, state.retryEntryId, (e) => e.kind === 'retry'
|
|
334
|
+
? {
|
|
335
|
+
...e,
|
|
336
|
+
phase,
|
|
337
|
+
totalRetries: e.attempt,
|
|
338
|
+
elapsedMs: Math.max(0, now - e.startedAt),
|
|
339
|
+
resumeAt: undefined,
|
|
340
|
+
}
|
|
341
|
+
: e),
|
|
342
|
+
retry: null,
|
|
343
|
+
retryEntryId: undefined,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* EVERY PATH THAT REACHES `status: 'idle'` RELEASES THE LIVE TAIL (D-35 / AC-40).
|
|
348
|
+
*
|
|
349
|
+
* `runEnd` finalizes only the streaming ASSISTANT entry and `abortMark` maps only
|
|
350
|
+
* assistant entries, so neither touches a tool entry stranded at
|
|
351
|
+
* `status: 'running'` -- the engine's own idle watchdog, a provider error mid-call
|
|
352
|
+
* and `Esc` all produce one. Today that costs a single spinner row. With a tail
|
|
353
|
+
* attached it costs a permanent multi-row card whose stall counter is frozen at
|
|
354
|
+
* its last value, because the 200 ms ticker that feeds `nowSec` is torn down the
|
|
355
|
+
* instant `state.status` leaves `running` -- and, worse, `computeSettledCount`
|
|
356
|
+
* breaks on a non-settled tool entry, so that card pins `Transcript`'s MONOTONIC
|
|
357
|
+
* settled boundary and re-renders the tail every frame for the rest of the
|
|
358
|
+
* session. The rule and the stakes are `settleRetryCard`'s, verbatim.
|
|
359
|
+
*
|
|
360
|
+
* THE STATUS IS DELIBERATELY NOT CHANGED. That is a different decision with
|
|
361
|
+
* different consequences for `bashBadge` and for the run's own bookkeeping, and
|
|
362
|
+
* it is not this round's to make. Only the tail is released.
|
|
363
|
+
*
|
|
364
|
+
* `liveSeq` is BUMPED rather than cleared, and that is not bookkeeping: clearing
|
|
365
|
+
* `live` is a non-append mutation, and with the status untouched nothing else in
|
|
366
|
+
* the tool revision moves -- so without the bump the height cache would keep
|
|
367
|
+
* serving the multi-row measurement for a card that now draws one row (I-L3-1).
|
|
368
|
+
* Entries with no tail are returned by identity, which is what keeps
|
|
369
|
+
* `EntryView`'s memo boundary intact for the rest of the transcript.
|
|
370
|
+
*/
|
|
371
|
+
function releaseLiveTails(entries) {
|
|
372
|
+
let touched = false;
|
|
373
|
+
const next = entries.map((e) => {
|
|
374
|
+
if (e.kind !== 'tool' || !e.live)
|
|
375
|
+
return e;
|
|
376
|
+
if (e.status !== 'running' && e.status !== 'pending')
|
|
377
|
+
return e;
|
|
378
|
+
touched = true;
|
|
379
|
+
return { ...e, live: undefined, lastOutputAt: undefined, liveSeq: (e.liveSeq ?? 0) + 1 };
|
|
380
|
+
});
|
|
381
|
+
return touched ? next : entries;
|
|
382
|
+
}
|
|
204
383
|
function findToolEntryId(state, toolCallId) {
|
|
205
384
|
for (let i = state.entries.length - 1; i >= 0; i -= 1) {
|
|
206
385
|
const e = state.entries[i];
|
|
@@ -217,12 +396,16 @@ export function viewReducer(state, action) {
|
|
|
217
396
|
return {
|
|
218
397
|
...state,
|
|
219
398
|
seq,
|
|
220
|
-
|
|
399
|
+
...appendEntry(state, state.entries, entry),
|
|
221
400
|
streamingId: undefined,
|
|
222
401
|
// A new turn gets its own card, in that turn's position in the
|
|
223
402
|
// transcript. The PREVIOUS card stays exactly where it is — it is
|
|
224
403
|
// history, and history is not retracted.
|
|
225
404
|
todoEntryId: undefined,
|
|
405
|
+
// Same rule for the retry card. `runStart` clears NONE of the three (it
|
|
406
|
+
// never has), and `runEnd`'s settle rule below is what makes that safe
|
|
407
|
+
// rather than merely lucky.
|
|
408
|
+
retryEntryId: undefined,
|
|
226
409
|
turnProduced: false,
|
|
227
410
|
errorNoticed: false,
|
|
228
411
|
aborted: false,
|
|
@@ -246,14 +429,23 @@ export function viewReducer(state, action) {
|
|
|
246
429
|
thinkingOpen: false,
|
|
247
430
|
streaming: true,
|
|
248
431
|
};
|
|
249
|
-
return { ...state, seq,
|
|
432
|
+
return { ...state, seq, ...appendEntry(state, state.entries, entry), streamingId: id };
|
|
250
433
|
}
|
|
251
434
|
case 'thinkingStart': {
|
|
252
435
|
if (!state.streamingId)
|
|
253
436
|
return state;
|
|
254
437
|
return {
|
|
255
438
|
...state,
|
|
256
|
-
entries: mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant'
|
|
439
|
+
entries: mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant'
|
|
440
|
+
? {
|
|
441
|
+
...e,
|
|
442
|
+
thinking: e.thinking ?? '',
|
|
443
|
+
thinkingOpen: true,
|
|
444
|
+
// `Date.now()` in the reducer is not a new impurity class: the
|
|
445
|
+
// retry card's `startedAt` already reads the clock here.
|
|
446
|
+
thinkingStartedAt: e.thinkingStartedAt ?? Date.now(),
|
|
447
|
+
}
|
|
448
|
+
: e),
|
|
257
449
|
};
|
|
258
450
|
}
|
|
259
451
|
case 'thinkingDelta': {
|
|
@@ -262,7 +454,7 @@ export function viewReducer(state, action) {
|
|
|
262
454
|
return {
|
|
263
455
|
...state,
|
|
264
456
|
entries: mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant'
|
|
265
|
-
? { ...e, thinking: (e.thinking ?? ''
|
|
457
|
+
? { ...e, thinking: appendBounded(e.thinking ?? '', action.delta, ENTRY_LIMITS.thinking) }
|
|
266
458
|
: e),
|
|
267
459
|
};
|
|
268
460
|
}
|
|
@@ -271,7 +463,16 @@ export function viewReducer(state, action) {
|
|
|
271
463
|
return state;
|
|
272
464
|
return {
|
|
273
465
|
...state,
|
|
274
|
-
entries: mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant'
|
|
466
|
+
entries: mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant'
|
|
467
|
+
? {
|
|
468
|
+
...e,
|
|
469
|
+
text: appendBounded(e.text, action.delta, ENTRY_LIMITS.text),
|
|
470
|
+
// FIRST TEXT DELTA ONLY. Thinking is over the moment the answer
|
|
471
|
+
// starts; re-sealing on every delta would make the number climb
|
|
472
|
+
// with the answer's length.
|
|
473
|
+
...sealThinkingMs(e),
|
|
474
|
+
}
|
|
475
|
+
: e),
|
|
275
476
|
};
|
|
276
477
|
}
|
|
277
478
|
case 'toolCallStart': {
|
|
@@ -285,7 +486,7 @@ export function viewReducer(state, action) {
|
|
|
285
486
|
argsRaw: '',
|
|
286
487
|
status: 'pending',
|
|
287
488
|
};
|
|
288
|
-
return { ...state, seq,
|
|
489
|
+
return { ...state, seq, ...appendEntry(state, state.entries, entry) };
|
|
289
490
|
}
|
|
290
491
|
case 'toolCallDelta': {
|
|
291
492
|
const id = findToolEntryId(state, action.toolCallId);
|
|
@@ -293,7 +494,9 @@ export function viewReducer(state, action) {
|
|
|
293
494
|
return state;
|
|
294
495
|
return {
|
|
295
496
|
...state,
|
|
296
|
-
entries: mapEntry(state.entries, id, (e) => e.kind === 'tool'
|
|
497
|
+
entries: mapEntry(state.entries, id, (e) => e.kind === 'tool'
|
|
498
|
+
? { ...e, argsRaw: appendBounded(e.argsRaw, action.argsDelta, ENTRY_LIMITS.argsRaw) }
|
|
499
|
+
: e),
|
|
297
500
|
};
|
|
298
501
|
}
|
|
299
502
|
case 'toolCallEnd': {
|
|
@@ -316,6 +519,28 @@ export function viewReducer(state, action) {
|
|
|
316
519
|
entries: mapEntry(state.entries, id, (e) => e.kind === 'tool' ? { ...e, status: 'running' } : e),
|
|
317
520
|
};
|
|
318
521
|
}
|
|
522
|
+
/**
|
|
523
|
+
* THE STATUS GUARD IS WHAT KEEPS A LATE CHUNK FROM RESURRECTING A SETTLED
|
|
524
|
+
* CARD. The child's last write and its `close` event race, so a chunk can
|
|
525
|
+
* legitimately arrive after `tool_execution_end`; without the guard it would
|
|
526
|
+
* reattach a tail to a card whose authoritative preview is already drawn.
|
|
527
|
+
*/
|
|
528
|
+
case 'toolOutputDelta': {
|
|
529
|
+
const id = findToolEntryId(state, action.toolCallId);
|
|
530
|
+
if (!id)
|
|
531
|
+
return state;
|
|
532
|
+
return {
|
|
533
|
+
...state,
|
|
534
|
+
entries: mapEntry(state.entries, id, (e) => e.kind === 'tool' && (e.status === 'running' || e.status === 'pending')
|
|
535
|
+
? {
|
|
536
|
+
...e,
|
|
537
|
+
live: action.rows,
|
|
538
|
+
liveSeq: (e.liveSeq ?? 0) + 1,
|
|
539
|
+
lastOutputAt: action.at,
|
|
540
|
+
}
|
|
541
|
+
: e),
|
|
542
|
+
};
|
|
543
|
+
}
|
|
319
544
|
case 'toolExecEnd': {
|
|
320
545
|
const id = findToolEntryId(state, action.toolCallId);
|
|
321
546
|
if (!id)
|
|
@@ -329,6 +554,13 @@ export function viewReducer(state, action) {
|
|
|
329
554
|
isError: action.isError,
|
|
330
555
|
durationMs: action.duration,
|
|
331
556
|
preview: action.preview,
|
|
557
|
+
...(action.patch ? { patch: action.patch } : {}),
|
|
558
|
+
// THE TAIL IS NEVER MERGED INTO `preview` (D-23): the
|
|
559
|
+
// authoritative result already contains everything it held, in
|
|
560
|
+
// full, and concatenating them would double the output and break
|
|
561
|
+
// `bashBadge`'s last-line parse (`ToolPreview.tsx:35-54`).
|
|
562
|
+
live: undefined,
|
|
563
|
+
lastOutputAt: undefined,
|
|
332
564
|
}
|
|
333
565
|
: e),
|
|
334
566
|
};
|
|
@@ -336,12 +568,20 @@ export function viewReducer(state, action) {
|
|
|
336
568
|
case 'turnEnd': {
|
|
337
569
|
const entries = state.streamingId
|
|
338
570
|
? mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant'
|
|
339
|
-
? {
|
|
571
|
+
? {
|
|
572
|
+
...e,
|
|
573
|
+
streaming: false,
|
|
574
|
+
thinkingOpen: false,
|
|
575
|
+
usage: action.usage,
|
|
576
|
+
// The turn that thought and then called a tool without emitting
|
|
577
|
+
// any text never saw a `textDelta`, so this is where its clock
|
|
578
|
+
// is sealed.
|
|
579
|
+
...sealThinkingMs(e),
|
|
580
|
+
}
|
|
340
581
|
: e)
|
|
341
582
|
: state.entries;
|
|
342
583
|
return {
|
|
343
584
|
...state,
|
|
344
|
-
entries,
|
|
345
585
|
streamingId: undefined,
|
|
346
586
|
turnProduced: true,
|
|
347
587
|
contextTokens: action.usage.inputTokens + action.usage.outputTokens,
|
|
@@ -350,50 +590,91 @@ export function viewReducer(state, action) {
|
|
|
350
590
|
outputTokens: state.usageTotal.outputTokens + action.usage.outputTokens,
|
|
351
591
|
costUsd: state.usageTotal.costUsd + action.costDelta,
|
|
352
592
|
},
|
|
593
|
+
// The turn produced an answer, so whatever it had to retry, it recovered.
|
|
594
|
+
...settleRetryCard(state, entries, 'recovered'),
|
|
353
595
|
};
|
|
354
596
|
}
|
|
355
597
|
case 'runEnd': {
|
|
356
598
|
// Finalize any still-streaming entry.
|
|
357
599
|
let entries = state.streamingId
|
|
358
|
-
? mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant' ? { ...e, streaming: false } : e)
|
|
600
|
+
? mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant' ? { ...e, streaming: false, ...sealThinkingMs(e) } : e)
|
|
359
601
|
: state.entries;
|
|
360
602
|
let seq = state.seq;
|
|
603
|
+
let droppedEntries = state.droppedEntries;
|
|
361
604
|
// Silent-failure guard (R2): a swallowed throw shows nothing otherwise.
|
|
362
605
|
// A user abort is an expected empty run, not a failure — skip the guard.
|
|
363
606
|
if (!state.turnProduced && !state.errorNoticed && !state.aborted) {
|
|
364
607
|
const next = nextId({ ...state, seq });
|
|
365
608
|
seq = next.seq;
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
609
|
+
const appended = appendEntry(state, entries, {
|
|
610
|
+
id: next.id,
|
|
611
|
+
kind: 'notice',
|
|
612
|
+
level: 'error',
|
|
613
|
+
text: GENERIC_RUN_FAILURE,
|
|
614
|
+
});
|
|
615
|
+
entries = appended.entries;
|
|
616
|
+
droppedEntries = appended.droppedEntries;
|
|
370
617
|
}
|
|
371
618
|
// The card may now reach `<Static>` (C-5 / I-6): the turn that owns it is
|
|
372
619
|
// over, so nothing will rewrite it.
|
|
373
620
|
if (state.todoEntryId) {
|
|
374
621
|
entries = mapEntry(entries, state.todoEntryId, (e) => e.kind === 'todo' ? { ...e, live: false } : e);
|
|
375
622
|
}
|
|
376
|
-
return {
|
|
623
|
+
return {
|
|
624
|
+
...state,
|
|
625
|
+
seq,
|
|
626
|
+
droppedEntries,
|
|
627
|
+
status: 'idle',
|
|
628
|
+
streamingId: undefined,
|
|
629
|
+
// THE TERMINAL BACKSTOP, and it is not redundant with `abortMark`
|
|
630
|
+
// (llm-api-retry-backoff P1-8 / AC-25b). `abortMark` is dispatched only
|
|
631
|
+
// from the Esc handler, but the ENGINE also aborts itself: the idle
|
|
632
|
+
// watchdog calls `Agent.abort()` directly, the run unwinds, and the UI
|
|
633
|
+
// sees `agent_end` -> `runEnd` with no `abortMark`, no `turn_end` and no
|
|
634
|
+
// error notice. A card left at `phase: 'waiting'` then pins
|
|
635
|
+
// `Transcript`'s monotonic boundary and re-renders the tail every frame
|
|
636
|
+
// for the rest of the session. By the time this runs the three other
|
|
637
|
+
// settle paths have already cleared `retryEntryId`, so this only fires on
|
|
638
|
+
// a genuinely unsettled card.
|
|
639
|
+
//
|
|
640
|
+
// `releaseLiveTails` rides the same argument one entry kind over (D-35):
|
|
641
|
+
// a tool card stranded at `running` by this very path would otherwise
|
|
642
|
+
// keep a tail and a frozen stall clock for the rest of the session.
|
|
643
|
+
...settleRetryCard(state, releaseLiveTails(entries), 'interrupted'),
|
|
644
|
+
};
|
|
377
645
|
}
|
|
378
646
|
case 'notice': {
|
|
379
647
|
const { id, seq } = nextId(state);
|
|
380
648
|
const entry = { id, kind: 'notice', level: action.level, text: action.text };
|
|
649
|
+
const appended = appendEntry(state, state.entries, entry);
|
|
650
|
+
// A terminal error notice is what an EXHAUSTED ladder looks like from here:
|
|
651
|
+
// the retry wrapper forwards the provider's own error once it stops
|
|
652
|
+
// retrying, and that error always precedes any `turn_end` because a failed
|
|
653
|
+
// turn has none. A non-error notice leaves the card alone.
|
|
654
|
+
const settled = action.level === 'error'
|
|
655
|
+
? settleRetryCard(state, appended.entries, 'exhausted')
|
|
656
|
+
: { entries: appended.entries };
|
|
381
657
|
return {
|
|
382
658
|
...state,
|
|
383
659
|
seq,
|
|
384
|
-
|
|
660
|
+
droppedEntries: appended.droppedEntries,
|
|
385
661
|
errorNoticed: action.level === 'error' ? true : state.errorNoticed,
|
|
662
|
+
...settled,
|
|
386
663
|
};
|
|
387
664
|
}
|
|
388
665
|
case 'abortMark': {
|
|
389
666
|
// Mark the run aborted even when no assistant entry is streaming (e.g. an
|
|
390
667
|
// abort during a tool call) so the runEnd failure guard stays suppressed.
|
|
391
|
-
|
|
392
|
-
|
|
668
|
+
const marked = state.streamingId
|
|
669
|
+
? mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant' ? { ...e, streaming: false, aborted: true } : e)
|
|
670
|
+
: state.entries;
|
|
393
671
|
return {
|
|
394
672
|
...state,
|
|
395
673
|
aborted: true,
|
|
396
|
-
|
|
674
|
+
// An abort during a tool call is the COMMON way a tail is stranded --
|
|
675
|
+
// `Esc` while `npm test` runs -- so this is the clause manual row 9
|
|
676
|
+
// exercises (D-35 / AC-40).
|
|
677
|
+
...settleRetryCard(state, releaseLiveTails(marked), 'interrupted'),
|
|
397
678
|
};
|
|
398
679
|
}
|
|
399
680
|
// `teamEntryId` is dropped with the entries it pointed at. Keeping it would
|
|
@@ -403,6 +684,11 @@ export function viewReducer(state, action) {
|
|
|
403
684
|
return {
|
|
404
685
|
...state,
|
|
405
686
|
entries: [],
|
|
687
|
+
// The count describes what the RETAIN RING removed from the visible
|
|
688
|
+
// transcript. `/clear` empties it by intent, so carrying the number
|
|
689
|
+
// over would make the exported transcript claim a loss that did not
|
|
690
|
+
// happen.
|
|
691
|
+
droppedEntries: 0,
|
|
406
692
|
streamingId: undefined,
|
|
407
693
|
expandedToolIds: {},
|
|
408
694
|
teamEntryId: undefined,
|
|
@@ -411,11 +697,18 @@ export function viewReducer(state, action) {
|
|
|
411
697
|
// the conversation, so the model still believes in the plan, and a panel
|
|
412
698
|
// that disagrees with the model is worse than no panel.
|
|
413
699
|
todoEntryId: undefined,
|
|
700
|
+
// Same reasoning for the retry card: the entry is gone, so the id must go
|
|
701
|
+
// with it or `retryScheduled` would `mapEntry` an id that no longer
|
|
702
|
+
// exists. `retry` goes too — unlike `todos` it is not a belief the model
|
|
703
|
+
// holds, it is a projection of an entry that has just been erased.
|
|
704
|
+
retryEntryId: undefined,
|
|
705
|
+
retry: null,
|
|
414
706
|
};
|
|
415
707
|
case 'resetConversation':
|
|
416
708
|
return {
|
|
417
709
|
...state,
|
|
418
710
|
entries: [],
|
|
711
|
+
droppedEntries: 0,
|
|
419
712
|
streamingId: undefined,
|
|
420
713
|
expandedToolIds: {},
|
|
421
714
|
contextTokens: 0,
|
|
@@ -426,6 +719,8 @@ export function viewReducer(state, action) {
|
|
|
426
719
|
// the STORE; this clears the mirror.
|
|
427
720
|
todos: null,
|
|
428
721
|
todoEntryId: undefined,
|
|
722
|
+
retryEntryId: undefined,
|
|
723
|
+
retry: null,
|
|
429
724
|
};
|
|
430
725
|
case 'setOverlay':
|
|
431
726
|
return { ...state, overlay: action.overlay };
|
|
@@ -462,6 +757,10 @@ export function viewReducer(state, action) {
|
|
|
462
757
|
const runs = action.specs.map((spec) => ({
|
|
463
758
|
label: spec.label,
|
|
464
759
|
description: spec.description,
|
|
760
|
+
// Already downgraded by the normalizer when the tier was unavailable, so
|
|
761
|
+
// the card and the report agree from the first frame (fast-model-tier
|
|
762
|
+
// §3.4).
|
|
763
|
+
tier: spec.tier,
|
|
465
764
|
phase: 'queued',
|
|
466
765
|
turns: 0,
|
|
467
766
|
toolCalls: 0,
|
|
@@ -481,7 +780,7 @@ export function viewReducer(state, action) {
|
|
|
481
780
|
return {
|
|
482
781
|
...state,
|
|
483
782
|
seq,
|
|
484
|
-
|
|
783
|
+
...appendEntry(state, state.entries, entry),
|
|
485
784
|
teamEntryId: id,
|
|
486
785
|
team: {
|
|
487
786
|
dispatchId: action.dispatchId,
|
|
@@ -535,6 +834,12 @@ export function viewReducer(state, action) {
|
|
|
535
834
|
}
|
|
536
835
|
// --- Todo planning (todo-plan-execution §5.2) -------------------------
|
|
537
836
|
case 'todoUpdate': {
|
|
837
|
+
// TYPED AS THE TODO MEMBER, NOT AS `Entry`. The rewrite below spreads this
|
|
838
|
+
// and overrides `live: boolean`; while the return type was the whole union
|
|
839
|
+
// that spread also produced a TOOL entry carrying `live: boolean`, which
|
|
840
|
+
// stopped type-checking the moment the tool member gained its own `live`
|
|
841
|
+
// (agent-activity-presentation-live). Narrowing here is what keeps the
|
|
842
|
+
// override honest rather than accidentally legal.
|
|
538
843
|
const entry = (id) => ({
|
|
539
844
|
id,
|
|
540
845
|
kind: 'todo',
|
|
@@ -558,7 +863,7 @@ export function viewReducer(state, action) {
|
|
|
558
863
|
...state,
|
|
559
864
|
seq,
|
|
560
865
|
todos: action.snapshot,
|
|
561
|
-
|
|
866
|
+
...appendEntry(state, state.entries, entry(id)),
|
|
562
867
|
todoEntryId: id,
|
|
563
868
|
};
|
|
564
869
|
}
|
|
@@ -566,21 +871,241 @@ export function viewReducer(state, action) {
|
|
|
566
871
|
// live projection goes away, which is what unmounts the rail.
|
|
567
872
|
case 'todoCleared':
|
|
568
873
|
return { ...state, todos: null, todoEntryId: undefined };
|
|
874
|
+
// --- Fast model tier (fast-model-tier §5.4) ---------------------------
|
|
875
|
+
case 'fastStart': {
|
|
876
|
+
const { id, seq } = nextId(state);
|
|
877
|
+
const entry = {
|
|
878
|
+
id,
|
|
879
|
+
kind: 'fast',
|
|
880
|
+
reviewIndex: action.index,
|
|
881
|
+
model: action.model,
|
|
882
|
+
status: 'running',
|
|
883
|
+
turn: action.turn,
|
|
884
|
+
live: true,
|
|
885
|
+
};
|
|
886
|
+
return {
|
|
887
|
+
...state,
|
|
888
|
+
seq,
|
|
889
|
+
...appendEntry(state, state.entries, entry),
|
|
890
|
+
fastEntryId: id,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
case 'fastEnd': {
|
|
894
|
+
const { review } = action;
|
|
895
|
+
const settle = (e) => e.kind === 'fast'
|
|
896
|
+
? {
|
|
897
|
+
...e,
|
|
898
|
+
status: review.kind,
|
|
899
|
+
...(review.text !== undefined ? { text: review.text } : {}),
|
|
900
|
+
...(review.detail !== undefined ? { detail: review.detail } : {}),
|
|
901
|
+
durationMs: review.durationMs,
|
|
902
|
+
// SETTLED. `Transcript`'s boundary is monotonic, so a card that
|
|
903
|
+
// never settles is re-rendered on every frame for the rest of the
|
|
904
|
+
// session (C-5).
|
|
905
|
+
live: false,
|
|
906
|
+
}
|
|
907
|
+
: e;
|
|
908
|
+
// Rewrite the card this review opened when there is one; otherwise append
|
|
909
|
+
// a settled card. THE SECOND BRANCH IS NOT DEFENSIVE PADDING: a review
|
|
910
|
+
// that is dropped as `stranded` is emitted from `agent_end` and never had
|
|
911
|
+
// a `review_start`, and a run restored from `/resume` has no live id at
|
|
912
|
+
// all — "it did nothing" must never be the observable outcome (R-8).
|
|
913
|
+
if (state.fastEntryId) {
|
|
914
|
+
return {
|
|
915
|
+
...state,
|
|
916
|
+
entries: mapEntry(state.entries, state.fastEntryId, settle),
|
|
917
|
+
fastEntryId: undefined,
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
const { id, seq } = nextId(state);
|
|
921
|
+
const entry = {
|
|
922
|
+
id,
|
|
923
|
+
kind: 'fast',
|
|
924
|
+
reviewIndex: review.index,
|
|
925
|
+
model: review.model,
|
|
926
|
+
status: review.kind,
|
|
927
|
+
...(review.text !== undefined ? { text: review.text } : {}),
|
|
928
|
+
...(review.detail !== undefined ? { detail: review.detail } : {}),
|
|
929
|
+
turn: review.turn,
|
|
930
|
+
durationMs: review.durationMs,
|
|
931
|
+
live: false,
|
|
932
|
+
};
|
|
933
|
+
return { ...state, seq, ...appendEntry(state, state.entries, entry) };
|
|
934
|
+
}
|
|
935
|
+
case 'fastUsage':
|
|
936
|
+
// `usageTotal` AND NOTHING ELSE, exactly as `teamUsage` (§3.6 / AC-18). In
|
|
937
|
+
// particular NOT `contextTokens`: that gauge shows the LEAD's context
|
|
938
|
+
// occupancy against the lead model's window, and a review is a separate
|
|
939
|
+
// conversation with a different model entirely.
|
|
940
|
+
return {
|
|
941
|
+
...state,
|
|
942
|
+
usageTotal: {
|
|
943
|
+
inputTokens: state.usageTotal.inputTokens + action.usage.inputTokens,
|
|
944
|
+
outputTokens: state.usageTotal.outputTokens + action.usage.outputTokens,
|
|
945
|
+
costUsd: state.usageTotal.costUsd + action.costDelta,
|
|
946
|
+
},
|
|
947
|
+
};
|
|
948
|
+
case 'fastTier':
|
|
949
|
+
return { ...state, fast: action.snapshot };
|
|
950
|
+
// --- API retry (llm-api-retry-backoff §6.4) ---------------------------
|
|
951
|
+
case 'retryScheduled': {
|
|
952
|
+
const snapshot = {
|
|
953
|
+
attempt: action.attempt,
|
|
954
|
+
maxRetries: action.maxRetries,
|
|
955
|
+
resumeAt: action.resumeAt,
|
|
956
|
+
phase: 'waiting',
|
|
957
|
+
errorType: action.errorType,
|
|
958
|
+
};
|
|
959
|
+
// ONE CARD PER TURN, REWRITTEN IN PLACE. One `mapEntry` on the KNOWN id,
|
|
960
|
+
// never a scan — the same discipline `todoUpdate` follows.
|
|
961
|
+
if (state.retryEntryId) {
|
|
962
|
+
const known = state.retryEntryId;
|
|
963
|
+
return {
|
|
964
|
+
...state,
|
|
965
|
+
retry: snapshot,
|
|
966
|
+
entries: mapEntry(state.entries, known, (e) => e.kind === 'retry'
|
|
967
|
+
? {
|
|
968
|
+
...e,
|
|
969
|
+
attempt: action.attempt,
|
|
970
|
+
maxRetries: action.maxRetries,
|
|
971
|
+
errorType: action.errorType,
|
|
972
|
+
message: action.message,
|
|
973
|
+
delayMs: action.delayMs,
|
|
974
|
+
resumeAt: action.resumeAt,
|
|
975
|
+
phase: 'waiting',
|
|
976
|
+
}
|
|
977
|
+
: e),
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
const { id, seq } = nextId(state);
|
|
981
|
+
const entry = {
|
|
982
|
+
id,
|
|
983
|
+
kind: 'retry',
|
|
984
|
+
attempt: action.attempt,
|
|
985
|
+
maxRetries: action.maxRetries,
|
|
986
|
+
errorType: action.errorType,
|
|
987
|
+
message: action.message,
|
|
988
|
+
delayMs: action.delayMs,
|
|
989
|
+
resumeAt: action.resumeAt,
|
|
990
|
+
phase: 'waiting',
|
|
991
|
+
// The clock the settled card's `elapsedMs` is measured from: the FIRST
|
|
992
|
+
// retry of the turn, not the last.
|
|
993
|
+
startedAt: Date.now(),
|
|
994
|
+
};
|
|
995
|
+
return {
|
|
996
|
+
...state,
|
|
997
|
+
seq,
|
|
998
|
+
retry: snapshot,
|
|
999
|
+
...appendEntry(state, state.entries, entry),
|
|
1000
|
+
retryEntryId: id,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
case 'retryAttempt': {
|
|
1004
|
+
if (!state.retryEntryId)
|
|
1005
|
+
return state;
|
|
1006
|
+
const known = state.retryEntryId;
|
|
1007
|
+
return {
|
|
1008
|
+
...state,
|
|
1009
|
+
// `resumeAt` is CLEARED: the wait is over, so a countdown would tick
|
|
1010
|
+
// downward past zero while the request is already in flight.
|
|
1011
|
+
retry: state.retry ? { ...state.retry, phase: 'retrying', resumeAt: undefined } : null,
|
|
1012
|
+
entries: mapEntry(state.entries, known, (e) => e.kind === 'retry' ? { ...e, phase: 'retrying', resumeAt: undefined } : e),
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Rewind the view for a mid-stream restart.
|
|
1017
|
+
*
|
|
1018
|
+
* THE ONLY ACTION IN THIS REDUCER THAT SHRINKS `entries`, and it is safe for
|
|
1019
|
+
* one specific reason (§6.4 / AC-22). `Transcript` clamps its settled boundary
|
|
1020
|
+
* with a MONOTONIC high-water mark: if `highWater` ever exceeded
|
|
1021
|
+
* `entries.length` the clamp would mark EVERYTHING settled — including the
|
|
1022
|
+
* still-streaming assistant entry — and `<Static>` would duplicate it.
|
|
1023
|
+
*
|
|
1024
|
+
* It cannot, and the ORDERING is why: the entries removed here are
|
|
1025
|
+
* `kind: 'tool'` cards created by `toolCallStart` AFTER `turnStart` created the
|
|
1026
|
+
* streaming assistant entry, so they sit strictly above it in the array; and
|
|
1027
|
+
* the boundary scan already breaks at that entry. Removing only entries above
|
|
1028
|
+
* the boundary is the invariant. A future action that removed a SETTLED entry
|
|
1029
|
+
* would break `<Static>` and nothing would report it.
|
|
1030
|
+
*/
|
|
1031
|
+
case 'streamRestart': {
|
|
1032
|
+
const discarded = new Set(action.discardedToolCallIds);
|
|
1033
|
+
const entries = state.entries.filter((e) => !(e.kind === 'tool' && discarded.has(e.toolCallId)));
|
|
1034
|
+
return {
|
|
1035
|
+
...state,
|
|
1036
|
+
entries: state.streamingId
|
|
1037
|
+
? mapEntry(entries, state.streamingId, (e) => e.kind === 'assistant'
|
|
1038
|
+
? {
|
|
1039
|
+
...e,
|
|
1040
|
+
text: '',
|
|
1041
|
+
thinking: undefined,
|
|
1042
|
+
thinkingOpen: false,
|
|
1043
|
+
// A restarted stream re-thinks from scratch. Carrying the old
|
|
1044
|
+
// clock forward would report a duration for reasoning that
|
|
1045
|
+
// was discarded.
|
|
1046
|
+
thinkingStartedAt: undefined,
|
|
1047
|
+
thinkingMs: undefined,
|
|
1048
|
+
}
|
|
1049
|
+
: e)
|
|
1050
|
+
: entries,
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* The 1 Hz countdown tick. NO VALUE CHANGES — the card is refreshed by
|
|
1055
|
+
* IDENTITY so that React re-renders exactly one entry.
|
|
1056
|
+
*
|
|
1057
|
+
* A FLAT NONCE ON `ViewState` WOULD NOT WORK, and this is the trap worth
|
|
1058
|
+
* naming: `EntryView` is `React.memo`'d with an explicit comparator whose
|
|
1059
|
+
* first clause is `a.entry === b.entry`, so a state field the card does not
|
|
1060
|
+
* receive as a prop changes nothing at all — the countdown would freeze at
|
|
1061
|
+
* whatever second it was first drawn on, and the transcript would keep
|
|
1062
|
+
* re-rendering around it for no benefit. Rewriting the entry is what breaks
|
|
1063
|
+
* that boundary, and `EntryView` reads the clock for this kind only.
|
|
1064
|
+
*
|
|
1065
|
+
* `entryRevision` is deliberately NOT extended: the card is one row in every
|
|
1066
|
+
* phase, so the cached height stays correct and no re-measure is wanted.
|
|
1067
|
+
*
|
|
1068
|
+
* Returning `state` unchanged when nothing is waiting is what keeps a stray
|
|
1069
|
+
* tick from costing a render at all.
|
|
1070
|
+
*/
|
|
1071
|
+
case 'retryTick': {
|
|
1072
|
+
if (state.retry?.phase !== 'waiting' || !state.retryEntryId)
|
|
1073
|
+
return state;
|
|
1074
|
+
const known = state.retryEntryId;
|
|
1075
|
+
return {
|
|
1076
|
+
...state,
|
|
1077
|
+
entries: mapEntry(state.entries, known, (e) => (e.kind === 'retry' ? { ...e } : e)),
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
569
1080
|
case 'restoreEntries': {
|
|
570
1081
|
// Adopt a seq beyond any restored id to keep future ids unique.
|
|
571
1082
|
const maxSeq = action.entries.reduce((acc, e) => {
|
|
572
1083
|
const n = Number.parseInt(e.id.replace(/^e/, ''), 10);
|
|
573
1084
|
return Number.isFinite(n) ? Math.max(acc, n) : acc;
|
|
574
1085
|
}, state.seq);
|
|
1086
|
+
// A session file can be longer than the retain ring; trimming it here is
|
|
1087
|
+
// the one append-free place the bound still has to hold, and the count
|
|
1088
|
+
// starts from this restore rather than inheriting the previous session's.
|
|
1089
|
+
const restored = trimEntries([...action.entries], entryRetain());
|
|
575
1090
|
return {
|
|
576
1091
|
...state,
|
|
577
|
-
entries:
|
|
1092
|
+
entries: restored.entries,
|
|
1093
|
+
droppedEntries: restored.dropped,
|
|
578
1094
|
seq: maxSeq,
|
|
579
1095
|
teamEntryId: undefined,
|
|
580
1096
|
// As for `team`: the restored cards are history and no turn owns them.
|
|
581
1097
|
// The LIST itself arrives separately, through `controller.restoreTodos`
|
|
582
1098
|
// and the store's own event (§3.13).
|
|
583
1099
|
todoEntryId: undefined,
|
|
1100
|
+
// Same for the retry card. `normalizeLoadedEntries` has already forced any
|
|
1101
|
+
// in-flight phase to `interrupted`, so nothing restored is live.
|
|
1102
|
+
retryEntryId: undefined,
|
|
1103
|
+
retry: null,
|
|
1104
|
+
// Same again for the fast card: `normalizeLoadedEntries` has already
|
|
1105
|
+
// forced `live: false`, so nothing restored is live. `state.fast` is
|
|
1106
|
+
// NOT cleared — it is the session's own tier projection, which a
|
|
1107
|
+
// `/resume` does not change.
|
|
1108
|
+
fastEntryId: undefined,
|
|
584
1109
|
};
|
|
585
1110
|
}
|
|
586
1111
|
default:
|