@intentic/sandbox-contract 1.240.0 → 1.242.0
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/README.md +52 -16
- package/dist/agent-catalog.d.ts +5 -28
- package/dist/agent-catalog.d.ts.map +1 -1
- package/dist/agent-catalog.js +21 -157
- package/dist/agent-catalog.js.map +1 -1
- package/dist/agent-run-model.d.ts +3 -2
- package/dist/agent-run-model.d.ts.map +1 -1
- package/dist/agent-run-model.js +9 -6
- package/dist/agent-run-model.js.map +1 -1
- package/dist/agent-runtimes.d.ts +27 -0
- package/dist/agent-runtimes.d.ts.map +1 -0
- package/dist/agent-runtimes.js +113 -0
- package/dist/agent-runtimes.js.map +1 -0
- package/dist/approvals-execution.d.ts +11 -0
- package/dist/approvals-execution.d.ts.map +1 -0
- package/dist/approvals-execution.js +44 -0
- package/dist/approvals-execution.js.map +1 -0
- package/dist/arrival.d.ts +151 -0
- package/dist/arrival.d.ts.map +1 -0
- package/dist/arrival.js +56 -0
- package/dist/arrival.js.map +1 -0
- package/dist/card-status.d.ts +5 -0
- package/dist/card-status.d.ts.map +1 -0
- package/dist/card-status.js +74 -0
- package/dist/card-status.js.map +1 -0
- package/dist/chores/chores.d.ts.map +1 -1
- package/dist/chores/chores.js +1 -1
- package/dist/chores/chores.js.map +1 -1
- package/dist/command-classes.d.ts +0 -1
- package/dist/command-classes.d.ts.map +1 -1
- package/dist/command-classes.js +9 -2
- package/dist/command-classes.js.map +1 -1
- package/dist/command-run.d.ts +10 -0
- package/dist/command-run.d.ts.map +1 -0
- package/dist/command-run.js +46 -0
- package/dist/command-run.js.map +1 -0
- package/dist/contracts/agent.contract.d.ts +991 -771
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.d.ts +345 -337
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.js +20 -2
- package/dist/contracts/agents.contract.js.map +1 -1
- package/dist/contracts/approvals.contract.d.ts +99 -0
- package/dist/contracts/approvals.contract.d.ts.map +1 -0
- package/dist/contracts/approvals.contract.js +32 -0
- package/dist/contracts/approvals.contract.js.map +1 -0
- package/dist/contracts/ci.contract.d.ts +1 -4
- package/dist/contracts/ci.contract.d.ts.map +1 -1
- package/dist/contracts/ci.contract.js +1 -9
- package/dist/contracts/ci.contract.js.map +1 -1
- package/dist/contracts/claude.contract.d.ts +9 -0
- package/dist/contracts/claude.contract.d.ts.map +1 -1
- package/dist/contracts/cursor.contract.d.ts +6 -0
- package/dist/contracts/cursor.contract.d.ts.map +1 -1
- package/dist/contracts/git.contract.d.ts +63 -1
- package/dist/contracts/git.contract.d.ts.map +1 -1
- package/dist/contracts/git.contract.js +22 -4
- package/dist/contracts/git.contract.js.map +1 -1
- package/dist/contracts/grok.contract.d.ts +3 -0
- package/dist/contracts/grok.contract.d.ts.map +1 -1
- package/dist/contracts/keys.contract.d.ts +81 -0
- package/dist/contracts/keys.contract.d.ts.map +1 -0
- package/dist/contracts/keys.contract.js +51 -0
- package/dist/contracts/keys.contract.js.map +1 -0
- package/dist/contracts/panels.contract.d.ts +7 -0
- package/dist/contracts/panels.contract.d.ts.map +1 -1
- package/dist/contracts/prepush.contract.js +2 -2
- package/dist/contracts/providers.contract.d.ts +2 -0
- package/dist/contracts/providers.contract.d.ts.map +1 -1
- package/dist/contracts/runner.contract.d.ts +84 -86
- package/dist/contracts/runner.contract.d.ts.map +1 -1
- package/dist/contracts/safety.contract.d.ts +35 -0
- package/dist/contracts/safety.contract.d.ts.map +1 -0
- package/dist/contracts/safety.contract.js +32 -0
- package/dist/contracts/safety.contract.js.map +1 -0
- package/dist/contracts/sessions.contract.d.ts +73 -337
- package/dist/contracts/sessions.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.d.ts +30 -32
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.d.ts +139 -337
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/contracts/translator.contract.d.ts +31 -3
- package/dist/contracts/translator.contract.d.ts.map +1 -1
- package/dist/contracts/usage.contract.d.ts +29 -20
- package/dist/contracts/usage.contract.d.ts.map +1 -1
- package/dist/contracts/usage.contract.js +13 -0
- package/dist/contracts/usage.contract.js.map +1 -1
- package/dist/contracts/workspace.contract.d.ts +8 -0
- package/dist/contracts/workspace.contract.d.ts.map +1 -1
- package/dist/conversation-ids.d.ts +2 -0
- package/dist/conversation-ids.d.ts.map +1 -1
- package/dist/conversation-ids.js +8 -0
- package/dist/conversation-ids.js.map +1 -1
- package/dist/credential-material.d.ts +1 -0
- package/dist/credential-material.d.ts.map +1 -1
- package/dist/credential-material.js +24 -2
- package/dist/credential-material.js.map +1 -1
- package/dist/definition.d.ts +51 -93
- package/dist/definition.d.ts.map +1 -1
- package/dist/definition.js +6 -28
- package/dist/definition.js.map +1 -1
- package/dist/events.d.ts +1831 -2285
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +155 -55
- package/dist/events.js.map +1 -1
- package/dist/history-state.d.ts.map +1 -1
- package/dist/history-state.js +1 -0
- package/dist/history-state.js.map +1 -1
- package/dist/index.d.ts +1959 -1903
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -5
- package/dist/index.js.map +1 -1
- package/dist/ingress-protocol.d.ts +13 -1
- package/dist/ingress-protocol.d.ts.map +1 -1
- package/dist/ingress-protocol.js +19 -0
- package/dist/ingress-protocol.js.map +1 -1
- package/dist/mentions.d.ts +3 -0
- package/dist/mentions.d.ts.map +1 -0
- package/dist/mentions.js +13 -0
- package/dist/mentions.js.map +1 -0
- package/dist/needs-action.d.ts +7 -0
- package/dist/needs-action.d.ts.map +1 -0
- package/dist/needs-action.js +3 -0
- package/dist/needs-action.js.map +1 -0
- package/dist/overlay-lint.d.ts +7 -0
- package/dist/overlay-lint.d.ts.map +1 -0
- package/dist/overlay-lint.js +68 -0
- package/dist/overlay-lint.js.map +1 -0
- package/dist/owner-ticket.d.ts +18 -0
- package/dist/owner-ticket.d.ts.map +1 -0
- package/dist/owner-ticket.js +51 -0
- package/dist/owner-ticket.js.map +1 -0
- package/dist/plan-pools.d.ts +12 -0
- package/dist/plan-pools.d.ts.map +1 -0
- package/dist/plan-pools.js +30 -0
- package/dist/plan-pools.js.map +1 -0
- package/dist/provider-specs.d.ts +228 -0
- package/dist/provider-specs.d.ts.map +1 -0
- package/dist/provider-specs.js +124 -0
- package/dist/provider-specs.js.map +1 -0
- package/dist/quick-model.d.ts.map +1 -1
- package/dist/quick-model.js +2 -1
- package/dist/quick-model.js.map +1 -1
- package/dist/runner-protocol.js +2 -2
- package/dist/runner-protocol.js.map +1 -1
- package/dist/runtime-state.d.ts +2 -2
- package/dist/runtime-state.d.ts.map +1 -1
- package/dist/runtime-state.js +1 -1
- package/dist/runtime-state.js.map +1 -1
- package/dist/safety-policy.d.ts +49 -0
- package/dist/safety-policy.d.ts.map +1 -0
- package/dist/safety-policy.js +52 -0
- package/dist/safety-policy.js.map +1 -0
- package/dist/schemas/agent.d.ts +21 -3
- package/dist/schemas/agent.d.ts.map +1 -1
- package/dist/schemas/agent.js +18 -8
- package/dist/schemas/agent.js.map +1 -1
- package/dist/schemas/agents.d.ts +19 -0
- package/dist/schemas/agents.d.ts.map +1 -1
- package/dist/schemas/agents.js +15 -0
- package/dist/schemas/agents.js.map +1 -1
- package/dist/schemas/approvals.d.ts +241 -0
- package/dist/schemas/approvals.d.ts.map +1 -0
- package/dist/schemas/approvals.js +63 -0
- package/dist/schemas/approvals.js.map +1 -0
- package/dist/schemas/automations.d.ts +5 -0
- package/dist/schemas/automations.d.ts.map +1 -1
- package/dist/schemas/ci.d.ts +5 -9
- package/dist/schemas/ci.d.ts.map +1 -1
- package/dist/schemas/ci.js +3 -10
- package/dist/schemas/ci.js.map +1 -1
- package/dist/schemas/computers.d.ts +31 -5
- package/dist/schemas/computers.d.ts.map +1 -1
- package/dist/schemas/computers.js +7 -5
- package/dist/schemas/computers.js.map +1 -1
- package/dist/schemas/environment.d.ts +11 -131
- package/dist/schemas/environment.d.ts.map +1 -1
- package/dist/schemas/environment.js +5 -42
- package/dist/schemas/environment.js.map +1 -1
- package/dist/schemas/git-history.d.ts +4 -0
- package/dist/schemas/git-history.d.ts.map +1 -1
- package/dist/schemas/git.d.ts +179 -0
- package/dist/schemas/git.d.ts.map +1 -1
- package/dist/schemas/git.js +39 -1
- package/dist/schemas/git.js.map +1 -1
- package/dist/schemas/panels.d.ts +21 -0
- package/dist/schemas/panels.d.ts.map +1 -1
- package/dist/schemas/panels.js +5 -0
- package/dist/schemas/panels.js.map +1 -1
- package/dist/schemas/plan-limits.d.ts +50 -3
- package/dist/schemas/plan-limits.d.ts.map +1 -1
- package/dist/schemas/plan-limits.js +11 -6
- package/dist/schemas/plan-limits.js.map +1 -1
- package/dist/schemas/provider-oauth.d.ts +6 -0
- package/dist/schemas/provider-oauth.d.ts.map +1 -1
- package/dist/schemas/provider-subscriptions.d.ts +2 -1
- package/dist/schemas/provider-subscriptions.d.ts.map +1 -1
- package/dist/schemas/provider-subscriptions.js +2 -1
- package/dist/schemas/provider-subscriptions.js.map +1 -1
- package/dist/schemas/settings.d.ts +29 -16
- package/dist/schemas/settings.d.ts.map +1 -1
- package/dist/schemas/settings.js +13 -20
- package/dist/schemas/settings.js.map +1 -1
- package/dist/schemas/system.d.ts +3 -0
- package/dist/schemas/system.d.ts.map +1 -1
- package/dist/schemas/system.js +5 -1
- package/dist/schemas/system.js.map +1 -1
- package/dist/schemas/workspace-repos.d.ts +14 -0
- package/dist/schemas/workspace-repos.d.ts.map +1 -1
- package/dist/schemas/workspace-repos.js +5 -0
- package/dist/schemas/workspace-repos.js.map +1 -1
- package/dist/schemas/workspace-tree.d.ts +1 -0
- package/dist/schemas/workspace-tree.d.ts.map +1 -1
- package/dist/schemas/workspace-tree.js +3 -0
- package/dist/schemas/workspace-tree.js.map +1 -1
- package/dist/transcript-fold.d.ts +31 -0
- package/dist/transcript-fold.d.ts.map +1 -0
- package/dist/transcript-fold.js +457 -0
- package/dist/transcript-fold.js.map +1 -0
- package/dist/workspace-state.d.ts +20 -3
- package/dist/workspace-state.d.ts.map +1 -1
- package/dist/workspace-state.js +22 -2
- package/dist/workspace-state.js.map +1 -1
- package/package.json +27 -5
- package/src/agent-catalog.test.ts +1 -1
- package/src/agent-catalog.ts +76 -555
- package/src/agent-run-model.test.ts +31 -15
- package/src/agent-run-model.ts +26 -12
- package/src/agent-runtimes.ts +419 -0
- package/src/approvals-execution.ts +96 -0
- package/src/arrival.ts +160 -0
- package/src/capability-ledger.test.ts +6 -4
- package/src/card-status.ts +93 -0
- package/src/chores/chores.ts +6 -3
- package/src/command-classes.test.ts +25 -0
- package/src/command-classes.ts +47 -5
- package/src/command-run.ts +78 -0
- package/src/contract-lock.test.ts +1 -1
- package/src/contract-lock.ts +2 -2
- package/src/contracts/agents.contract.ts +35 -2
- package/src/contracts/approvals.contract.ts +37 -0
- package/src/contracts/ci.contract.ts +0 -13
- package/src/contracts/git.contract.ts +28 -3
- package/src/contracts/keys.contract.ts +79 -0
- package/src/contracts/prepush.contract.ts +3 -3
- package/src/contracts/safety.contract.ts +48 -0
- package/src/contracts/usage.contract.ts +22 -0
- package/src/conversation-ids.test.ts +31 -1
- package/src/conversation-ids.ts +37 -3
- package/src/credential-material.test.ts +69 -1
- package/src/credential-material.ts +86 -5
- package/src/definition.ts +25 -53
- package/src/events.ts +374 -173
- package/src/history-state.ts +6 -0
- package/src/index.ts +24 -5
- package/src/ingress-protocol.ts +52 -1
- package/src/mentions.test.ts +22 -0
- package/src/mentions.ts +25 -0
- package/src/needs-action.ts +14 -0
- package/src/overlay-lint.test.ts +89 -0
- package/src/overlay-lint.ts +116 -0
- package/src/owner-ticket.test.ts +54 -0
- package/src/owner-ticket.ts +102 -0
- package/src/plan-pools.test.ts +71 -0
- package/src/plan-pools.ts +82 -0
- package/src/provider-specs.test.ts +130 -0
- package/src/provider-specs.ts +352 -0
- package/src/quick-model.ts +9 -5
- package/src/runner-protocol.ts +2 -2
- package/src/runtime-state.ts +6 -6
- package/src/safety-policy.ts +165 -0
- package/src/schemas/agent.ts +62 -17
- package/src/schemas/agents.ts +71 -0
- package/src/schemas/approvals.ts +174 -0
- package/src/schemas/ci.ts +19 -26
- package/src/schemas/computers.ts +52 -11
- package/src/schemas/environment.ts +21 -100
- package/src/schemas/git.ts +121 -6
- package/src/schemas/issues.ts +1 -1
- package/src/schemas/panels.ts +20 -0
- package/src/schemas/plan-limits.ts +58 -11
- package/src/schemas/provider-subscriptions.ts +7 -2
- package/src/schemas/settings.ts +67 -93
- package/src/schemas/system.ts +12 -1
- package/src/schemas/workspace-repos.ts +9 -0
- package/src/schemas/workspace-tree.ts +11 -0
- package/src/transcript-fold.test.ts +493 -0
- package/src/transcript-fold.ts +655 -0
- package/src/workspace-state.test.ts +77 -29
- package/src/workspace-state.ts +99 -31
- package/dist/contracts/drafts.contract.d.ts +0 -57
- package/dist/contracts/drafts.contract.d.ts.map +0 -1
- package/dist/contracts/drafts.contract.js +0 -32
- package/dist/contracts/drafts.contract.js.map +0 -1
- package/dist/definition-action.d.ts +0 -7
- package/dist/definition-action.d.ts.map +0 -1
- package/dist/definition-action.js +0 -3
- package/dist/definition-action.js.map +0 -1
- package/dist/publish-drafts.d.ts +0 -7
- package/dist/publish-drafts.d.ts.map +0 -1
- package/dist/publish-drafts.js +0 -20
- package/dist/publish-drafts.js.map +0 -1
- package/dist/schemas/drafts.d.ts +0 -84
- package/dist/schemas/drafts.d.ts.map +0 -1
- package/dist/schemas/drafts.js +0 -44
- package/dist/schemas/drafts.js.map +0 -1
- package/src/contracts/drafts.contract.ts +0 -35
- package/src/definition-action.ts +0 -8
- package/src/publish-drafts.ts +0 -59
- package/src/schemas/drafts.ts +0 -108
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
import { cancelledCards, settledCards } from "./card-status.js";
|
|
2
|
+
import {
|
|
3
|
+
type AgentEvent,
|
|
4
|
+
CARD_FIELDS,
|
|
5
|
+
holdsCard,
|
|
6
|
+
isAwaitingDecision,
|
|
7
|
+
type TranscriptCards,
|
|
8
|
+
type TranscriptPatch,
|
|
9
|
+
type TranscriptRow,
|
|
10
|
+
type TranscriptSubagent,
|
|
11
|
+
type TranscriptTool,
|
|
12
|
+
} from "./events.js";
|
|
13
|
+
import { mentionedPathTokens } from "./mentions.js";
|
|
14
|
+
|
|
15
|
+
/* THE FOLD: a turn's frames into the rows a conversation is made of, one rule, applied once.
|
|
16
|
+
*
|
|
17
|
+
* The daemon runs it live, frame by frame, as the turn streams (turn-runs.ts): what it produces is what every
|
|
18
|
+
* attached window draws, patch by patch, and what the record keeps once the turn settles. A reopened chat
|
|
19
|
+
* therefore shows what was on screen rather than a second arrangement of it, because there is no second
|
|
20
|
+
* arrangement: the row a window watched being typed and the row the record holds are the same row, made by
|
|
21
|
+
* the same code at the same moment.
|
|
22
|
+
*
|
|
23
|
+
* It used to run twice. The browser folded the live frames into its own bubbles, the daemon folded the same
|
|
24
|
+
* frames at settlement into the record, and a third pass turned the record back into bubbles on reopen. Each
|
|
25
|
+
* grew rules the others lacked, a card the record forgot, a steer the live view placed wrong, a notice only
|
|
26
|
+
* the window that watched ever saw, and a change to one was a bug in the other until somebody noticed. This
|
|
27
|
+
* is the one copy.
|
|
28
|
+
*
|
|
29
|
+
* `tag` names which stream of the log is being read: undefined is the main turn, a tool-call id is the
|
|
30
|
+
* subagent that call spawned. Frames carrying THAT tag are the stream's own and land at top level; frames
|
|
31
|
+
* carrying a different one belong to a child of this stream and nest under the card that spawned it, one
|
|
32
|
+
* rule for both, which is what makes depth fall out for free: a subagent that itself delegates nests one
|
|
33
|
+
* level further down, on the same pass, whichever level is being read.
|
|
34
|
+
*
|
|
35
|
+
* Rows are MUTATED in place and every patch carries a COPY of what it names. The mutation is what keeps a
|
|
36
|
+
* result that lands turns after its call cheap, no second pass; the copy is what keeps a patch true to the
|
|
37
|
+
* moment it was made rather than to whatever the row grew into by the time a slow reader took it. */
|
|
38
|
+
|
|
39
|
+
export type TurnEnding = "settled" | "stopped";
|
|
40
|
+
|
|
41
|
+
// Where a tool card lives, for the frames that reach it by id after it was drawn: its row, and the card it
|
|
42
|
+
// nests under when it is a helper's own call.
|
|
43
|
+
interface CardPlace {
|
|
44
|
+
readonly tool: TranscriptTool;
|
|
45
|
+
readonly row: number;
|
|
46
|
+
readonly parent?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The keys a frame actually carries: an optional field a frame left out must not overwrite what an earlier
|
|
50
|
+
// frame set, and a spread of the parsed frame would, with `undefined`.
|
|
51
|
+
const defined = <T extends object>(value: T): Partial<T> => Object.fromEntries(Object.entries(value).filter(([, field]) => field !== undefined)) as Partial<T>;
|
|
52
|
+
|
|
53
|
+
const cardOf = (event: Extract<AgentEvent, { kind: "tool_call" }>): TranscriptTool => ({
|
|
54
|
+
id: event.id,
|
|
55
|
+
name: event.name,
|
|
56
|
+
category: event.category,
|
|
57
|
+
status: event.status,
|
|
58
|
+
...(event.target !== undefined ? { target: event.target } : {}),
|
|
59
|
+
...(event.locations !== undefined ? { locations: event.locations } : {}),
|
|
60
|
+
...(event.content !== undefined ? { content: event.content } : {}),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Whether a bubble has anything in it: text, thinking, tools, a checklist or a card makes a row, and nothing
|
|
64
|
+
// makes none.
|
|
65
|
+
const empty = (row: TranscriptRow): boolean =>
|
|
66
|
+
row.text.length === 0 &&
|
|
67
|
+
(row.thinking?.length ?? 0) === 0 &&
|
|
68
|
+
(row.tools?.length ?? 0) === 0 &&
|
|
69
|
+
(row.todos?.length ?? 0) === 0 &&
|
|
70
|
+
row.usage === undefined &&
|
|
71
|
+
!holdsCard(row);
|
|
72
|
+
|
|
73
|
+
/* What a landed delta did to the workspace's dependencies, as a clause the landed notice ends with, or nothing
|
|
74
|
+
* at all, which is what almost every turn produces and what the reader should therefore never have to skip past.
|
|
75
|
+
*
|
|
76
|
+
* Written as a REPORT of something already done, not a request. The daemon started the install the moment the
|
|
77
|
+
* tree changed (workspace/reconcile-deps.ts), so "installing" is the true tense and there is no decision left
|
|
78
|
+
* for the reader to make; the button beside it opens the terminal it is running in, for whoever wants to watch.
|
|
79
|
+
* The deferred wording is the one case that names a wait, because a workspace with other agents still running
|
|
80
|
+
* genuinely has not started yet and saying otherwise would be a lie the terminal would immediately expose. */
|
|
81
|
+
const dependencyLine = (deps: { missing: number; started: string[]; deferred: boolean } | undefined): string => {
|
|
82
|
+
if (deps === undefined || deps.missing === 0) {
|
|
83
|
+
return ``;
|
|
84
|
+
}
|
|
85
|
+
const what = `${deps.missing} new ${deps.missing === 1 ? `dependency` : `dependencies`}`;
|
|
86
|
+
return deps.deferred
|
|
87
|
+
? ` ${what} are queued: installation starts after this turn and any other active agents finish, appears in Work terminals, then its checks and outcome land in Activity.`
|
|
88
|
+
: ` Installing ${what} it added; the project's checks run when that finishes, and the outcome lands in Activity.`;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/* WHY THIS AGENT'S BRANCH JUST MOVED, the human's half of the rebase (daemon: agents/sync.ts).
|
|
92
|
+
*
|
|
93
|
+
* A conversation goes stale while its user commits around it, so the daemon rebases the branch onto the
|
|
94
|
+
* current workspace. It is told, not asked: at the moment someone is answering their agent they have nothing
|
|
95
|
+
* to decide this with, and the alternative to rebasing is not "stay safe", it is a land conflict half an hour
|
|
96
|
+
* later. So this is one muted line with no button on it, the same weight as "Context compacted", and for the
|
|
97
|
+
* same reason. The blocked half is the line that earns its keep: a rebase that would not apply was rolled
|
|
98
|
+
* back, the agent is working from the older base, and the conflict report at the end of the turn is now
|
|
99
|
+
* EXPECTED rather than a surprise. */
|
|
100
|
+
const syncLine = (sync: { commits: number; blocked: readonly string[] }): string => {
|
|
101
|
+
const moved =
|
|
102
|
+
sync.commits > 0
|
|
103
|
+
? `Your workspace moved on while this agent waited, its branch was rebased onto your latest ${sync.commits} commit${sync.commits === 1 ? `` : `s`}.`
|
|
104
|
+
: undefined;
|
|
105
|
+
const blocked =
|
|
106
|
+
sync.blocked.length > 0
|
|
107
|
+
? `Couldn't rebase onto your workspace in ${sync.blocked.join(`, `)}: the turn is running from the older base, so its land may need a resolve.`
|
|
108
|
+
: undefined;
|
|
109
|
+
return [moved, blocked].filter((line) => line !== undefined).join(` `);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// End of a clean isolated turn: the delta auto-landed into the main tree as uncommitted changes (review = the
|
|
113
|
+
// Changes panel), was HELD on the branch because auto-land is off, or conflicted and stayed safely in the
|
|
114
|
+
// worktree. A landed delta that changed what the workspace depends on carries its reconcile too, the one
|
|
115
|
+
// consequence of this turn the Changes panel cannot show, because it happened outside the diff.
|
|
116
|
+
const landedRow = (event: Extract<AgentEvent, { kind: "landed" }>): TranscriptRow => {
|
|
117
|
+
if (event.held === true) {
|
|
118
|
+
return { role: "notice", text: `Finished: the work is on this agent's branch, ready to land from its review.` };
|
|
119
|
+
}
|
|
120
|
+
if (!event.landed) {
|
|
121
|
+
// Named, not explained: the cause is per-FILE (your edits, a moved main line, a binary), and the review
|
|
122
|
+
// is where each one is spelled out with the action that fits it.
|
|
123
|
+
const conflicts = event.conflicts ?? [];
|
|
124
|
+
return {
|
|
125
|
+
role: "notice",
|
|
126
|
+
text: `${conflicts.flatMap((conflict) => conflict.paths).length} file(s) couldn't land automatically in ${conflicts
|
|
127
|
+
.map((conflict) => conflict.repo)
|
|
128
|
+
.join(`, `)}. Open the agent's review to see what blocked them and land from there.`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// The moment-of-regret offer, on the LANDED notice only: the automatic behaviour just fired, and "stop
|
|
132
|
+
// doing that" is worth one press exactly now. An install that STARTED takes the slot instead: for as long
|
|
133
|
+
// as it runs, the one press worth offering is the terminal it is running in.
|
|
134
|
+
return {
|
|
135
|
+
role: "notice",
|
|
136
|
+
text: `Changes landed in your workspace: review them in the Changes panel.${dependencyLine(event.deps)}`,
|
|
137
|
+
noticeAction: (event.deps?.started.length ?? 0) > 0 ? "depsInstall" : "landHold",
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/* WHAT HAPPENED TO THE TURN, written down: the provider's own sentence, and the one clause the daemon can add
|
|
142
|
+
* about what comes next. The wait itself (a countdown to the allowance reopening, an outage's next attempt) is
|
|
143
|
+
* the chat's to draw beside the composer, live; this row is the record of the moment, and reads the same live
|
|
144
|
+
* and a week later. */
|
|
145
|
+
const errorRow = (event: Extract<AgentEvent, { kind: "error" }>): TranscriptRow => {
|
|
146
|
+
const { message, code } = event;
|
|
147
|
+
switch (code) {
|
|
148
|
+
case "provider-outage":
|
|
149
|
+
return event.outage === undefined
|
|
150
|
+
? { role: "notice", text: `${message} Nothing is retrying it, so the turn is waiting: keep this chat going and it continues from here.` }
|
|
151
|
+
: {
|
|
152
|
+
role: "notice",
|
|
153
|
+
text: `${message} Retrying by itself: attempt ${event.outage.attempt} of ${event.outage.maxAttempts}.`,
|
|
154
|
+
...(event.autoResume === "scheduled" ? { noticeAction: "outageOptOut" } : {}),
|
|
155
|
+
};
|
|
156
|
+
case "claude-token-refused":
|
|
157
|
+
return event.autoResume === "scheduled"
|
|
158
|
+
? { role: "notice", text: `${message} The credential is being renewed and this turn continues automatically.`, noticeWait: "credentialRenewal" }
|
|
159
|
+
: { role: "notice", text: `${message} Reconnect the account to pick this conversation back up.` };
|
|
160
|
+
case "rate_limit":
|
|
161
|
+
return { role: "notice", text: event.autoResume === "scheduled" ? `${message} This chat sends it again once the allowance comes back.` : message };
|
|
162
|
+
// Refused before anything ran: the words never reached the model, and the chat holds them for the
|
|
163
|
+
// user's own next send rather than flushing them into the same refusal.
|
|
164
|
+
case "claude-reauth":
|
|
165
|
+
case "unknown-command":
|
|
166
|
+
case "context-window-too-small":
|
|
167
|
+
case "sandbox-memory-low":
|
|
168
|
+
case "trial-unavailable":
|
|
169
|
+
case "trial-model-unavailable":
|
|
170
|
+
case "trial-exhausted":
|
|
171
|
+
return { role: "notice", text: `${message} Your message was not delivered: it is held for you to send again.` };
|
|
172
|
+
default:
|
|
173
|
+
return { role: "notice", text: message };
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/* The opening user row of a turn: what was typed, when, with what attached and what the daemon added. Built by
|
|
178
|
+
* whoever holds the prompt (the daemon strips its own layers off it first, sessions/turn-transcript.ts), and
|
|
179
|
+
* handed to the fold as the row it starts from. */
|
|
180
|
+
export const userRow = (text: string, sentAt: number, attachments: readonly string[]): TranscriptRow => {
|
|
181
|
+
/* Uploads only. A path @-mentioned inline in the text rides the same wire field (the composer sends both
|
|
182
|
+
* as attachments), and drawing it as a chip would show the reader the same path twice; an upload that the
|
|
183
|
+
* user ALSO happened to type the generated path of keeps its chip, because the chip is the thumbnail. */
|
|
184
|
+
const inline = new Set(mentionedPathTokens(text));
|
|
185
|
+
const chips = attachments.filter((path) => !inline.has(path) || path.includes(`/records/artifacts/attachments/`));
|
|
186
|
+
return { role: "user", text, sentAt, ...(chips.length > 0 ? { attachments: chips } : {}) };
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export class TranscriptFold {
|
|
190
|
+
readonly rows: TranscriptRow[] = [];
|
|
191
|
+
// The rows the user steered into the turn, by position: the daemon files each one's rewind state under it
|
|
192
|
+
// once the turn settles (agent/steer-anchors.ts), and only the fold knows where they landed.
|
|
193
|
+
readonly steerRows: number[] = [];
|
|
194
|
+
// The open assistant bubble, by index, and it is always the LAST row: every other kind of row closes it
|
|
195
|
+
// first (pushRow), so a bubble that ends empty is dropped without moving anything above it.
|
|
196
|
+
private bubble: number | undefined;
|
|
197
|
+
private readonly cards = new Map<string, CardPlace>();
|
|
198
|
+
// requestId → the row holding the interactive card it names, for the frames that land on a card after it
|
|
199
|
+
// was raised: the reply that released it, a permission's late sentence, an offer's stream and receipt.
|
|
200
|
+
private readonly parked = new Map<string, number>();
|
|
201
|
+
// The turn's own opening user row, where the checkpoint and the daemon's notes land.
|
|
202
|
+
private readonly opener: number | undefined;
|
|
203
|
+
|
|
204
|
+
constructor(
|
|
205
|
+
opening: readonly TranscriptRow[],
|
|
206
|
+
private readonly tag?: string,
|
|
207
|
+
) {
|
|
208
|
+
for (const row of opening) {
|
|
209
|
+
this.rows.push(row);
|
|
210
|
+
}
|
|
211
|
+
const opener = this.rows.findIndex((row) => row.role === "user");
|
|
212
|
+
this.opener = opener === -1 ? undefined : opener;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Fold one frame in. What changed comes back as patches, in the order it changed. */
|
|
216
|
+
apply(event: AgentEvent): TranscriptPatch[] {
|
|
217
|
+
const parent = "parentToolUseId" in event ? event.parentToolUseId : undefined;
|
|
218
|
+
if (parent !== this.tag) {
|
|
219
|
+
return this.applyChild(event, parent);
|
|
220
|
+
}
|
|
221
|
+
switch (event.kind) {
|
|
222
|
+
case "delta": {
|
|
223
|
+
if (event.text.length === 0) {
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
const [index, opened] = this.open();
|
|
227
|
+
this.rows[index]!.text += event.text;
|
|
228
|
+
return [...opened, { op: "text", index, text: event.text }];
|
|
229
|
+
}
|
|
230
|
+
case "thinking": {
|
|
231
|
+
if (event.text.length === 0) {
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
const [index, opened] = this.open();
|
|
235
|
+
const row = this.rows[index]!;
|
|
236
|
+
row.thinking = `${row.thinking ?? ""}${event.text}`;
|
|
237
|
+
return [...opened, { op: "thinking", index, text: event.text }];
|
|
238
|
+
}
|
|
239
|
+
case "text_end":
|
|
240
|
+
// The agent finished a block of prose: retire the bubble it was writing into, so what comes next,
|
|
241
|
+
// the tool calls that block introduced or the next block after they return, opens a fresh one
|
|
242
|
+
// below it. A block that wrote no prose has no boundary to draw: retiring on it would split a
|
|
243
|
+
// card away from the prose that reported it, which is a shape the user never saw.
|
|
244
|
+
if (this.bubble !== undefined && this.rows[this.bubble]!.text.length > 0) {
|
|
245
|
+
this.bubble = undefined;
|
|
246
|
+
}
|
|
247
|
+
return [];
|
|
248
|
+
case "tool_call": {
|
|
249
|
+
const [index, opened] = this.open();
|
|
250
|
+
const tool = cardOf(event);
|
|
251
|
+
const row = this.rows[index]!;
|
|
252
|
+
row.tools = [...(row.tools ?? []), tool];
|
|
253
|
+
this.cards.set(tool.id, { tool, row: index });
|
|
254
|
+
return [...opened, { op: "tool", index, tool: structuredClone(tool) }];
|
|
255
|
+
}
|
|
256
|
+
case "tool_call_update":
|
|
257
|
+
// Present fields REPLACE the prior value (snapshot semantics: Codex streams a command's growing
|
|
258
|
+
// output as whole snapshots), absent fields leave it unchanged. An update with no matching tool
|
|
259
|
+
// is dropped rather than shown loose.
|
|
260
|
+
return this.patchCard(event.id, (tool) => {
|
|
261
|
+
if (event.status !== undefined) {
|
|
262
|
+
tool.status = event.status;
|
|
263
|
+
}
|
|
264
|
+
if (event.content !== undefined) {
|
|
265
|
+
tool.content = event.content;
|
|
266
|
+
}
|
|
267
|
+
if (event.locations !== undefined) {
|
|
268
|
+
tool.locations = event.locations;
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
case "subagent": {
|
|
272
|
+
// The call just started an AGENT. The frame's id IS the spawning call's, so it lands on that card.
|
|
273
|
+
const { kind: _kind, id, subagentKind, ...rest } = event;
|
|
274
|
+
return this.patchCard(id, (tool) => {
|
|
275
|
+
tool.subagent = { ...rest, kind: subagentKind, status: "running" };
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
case "subagent_update": {
|
|
279
|
+
// Present fields REPLACE, absent ones leave the child alone, the same snapshot semantics
|
|
280
|
+
// tool_call_update has: progress arrives many times and says only what moved.
|
|
281
|
+
const { kind: _kind, id, ...patch } = event;
|
|
282
|
+
return this.patchCard(id, (tool) => {
|
|
283
|
+
if (tool.subagent !== undefined) {
|
|
284
|
+
tool.subagent = { ...tool.subagent, ...(defined(patch) as Partial<TranscriptSubagent>) };
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
case "todos": {
|
|
289
|
+
const [index, opened] = this.open();
|
|
290
|
+
this.rows[index]!.todos = [...event.items];
|
|
291
|
+
return [...opened, this.replace(index)];
|
|
292
|
+
}
|
|
293
|
+
case "usage": {
|
|
294
|
+
// End-of-turn accounting: onto the last assistant bubble rather than a fresh one, and the turn
|
|
295
|
+
// BOUNDARY, a steered conversation's stream can carry several turns, so the current bubble is
|
|
296
|
+
// retired and the next turn's frames open a fresh one below the steered user message.
|
|
297
|
+
const { kind: _kind, account: _account, cacheReadTokens: _read, cacheCreationTokens: _written, ...usage } = event;
|
|
298
|
+
const index = this.rows.findLastIndex((row) => row.role === "assistant");
|
|
299
|
+
const closed = this.closeBubble();
|
|
300
|
+
if (index === -1) {
|
|
301
|
+
return closed;
|
|
302
|
+
}
|
|
303
|
+
this.rows[index]!.usage = usage;
|
|
304
|
+
return [...closed, this.replace(index)];
|
|
305
|
+
}
|
|
306
|
+
case "steer": {
|
|
307
|
+
/* THE USER SPOKE MID-TURN, their words, at the point in the stream the daemon took them: a row of
|
|
308
|
+
* their own AND a boundary. The harness absorbs a steer between tool calls and the model keeps
|
|
309
|
+
* writing with no `result` in between, so nothing else in the stream retires the open bubble:
|
|
310
|
+
* what the agent says NEXT is its answer to this message, and left in the bubble above it the
|
|
311
|
+
* answer printed over the question. */
|
|
312
|
+
const patches = this.pushRow({
|
|
313
|
+
role: "user",
|
|
314
|
+
text: event.text,
|
|
315
|
+
sentAt: event.sentAt,
|
|
316
|
+
...(event.attachments === undefined ? {} : { attachments: [...event.attachments] }),
|
|
317
|
+
});
|
|
318
|
+
this.steerRows.push(this.rows.length - 1);
|
|
319
|
+
return patches;
|
|
320
|
+
}
|
|
321
|
+
case "checkpoint":
|
|
322
|
+
// The pre-turn workspace state's id, plus where this turn sits in the daemon's transcript, both
|
|
323
|
+
// anchored on the turn's user row, which is what the rewind affordance addresses it by.
|
|
324
|
+
return this.stampOpener((row) => {
|
|
325
|
+
row.checkpointId = event.id;
|
|
326
|
+
if (event.index !== undefined) {
|
|
327
|
+
row.rewindIndex = event.index;
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
case "preamble":
|
|
331
|
+
// What the daemon put in front of the model, as one collapsed row hung off the user's message.
|
|
332
|
+
// A frame with nothing in it is not a disclosure.
|
|
333
|
+
return event.notes.length === 0 ? [] : this.stampOpener((row) => (row.notes = [...event.notes]));
|
|
334
|
+
case "worktree":
|
|
335
|
+
return event.sync === undefined ? [] : this.pushRow({ role: "notice", text: syncLine(event.sync) });
|
|
336
|
+
case "landed":
|
|
337
|
+
return this.pushRow(landedRow(event));
|
|
338
|
+
case "compact":
|
|
339
|
+
return this.pushRow({ role: "notice", text: `Context compacted to free up space.` });
|
|
340
|
+
case "error":
|
|
341
|
+
/* WHAT HAPPENED TO THE TURN, kept, and the frame whose absence made a refused session look broken
|
|
342
|
+
* rather than refused: a provider that answers "your organization has disabled Claude subscription
|
|
343
|
+
* access" sends this and no prose, so a fold of the two speakers alone ends on the user's message. */
|
|
344
|
+
return this.pushRow(errorRow(event));
|
|
345
|
+
case "tier":
|
|
346
|
+
/* THIS TURN RAN ON A CHEAPER MODEL THAN THE ONE ASKED FOR, written down for the same reason the
|
|
347
|
+
* refusal above is: the answer below it is the cheap rung's answer, and a reader coming back
|
|
348
|
+
* tomorrow has no other way to know which of their messages were served that way. Only a turn that
|
|
349
|
+
* really moved: a verdict that changed nothing is machinery, not an event. The model is named by
|
|
350
|
+
* ID, the only name this side has. `noticeAction` carries the one press the line offers, "keep
|
|
351
|
+
* this chat on my pick". */
|
|
352
|
+
return event.routed && event.model !== undefined
|
|
353
|
+
? this.pushRow({ role: "notice", text: `This turn looked simple, so it ran on ${event.model} instead of your pick.`, noticeAction: "tierHold" })
|
|
354
|
+
: [];
|
|
355
|
+
case "plan": {
|
|
356
|
+
/* Current ExitPlanMode has no plan input: the completed prose block immediately before the call IS
|
|
357
|
+
* the plan. The daemon repeats it on this frame so the card is self-contained; when that exact
|
|
358
|
+
* block is the adjacent retired bubble, reclassify it into the card instead of drawing the same
|
|
359
|
+
* markdown once as prose and again as a plan. A distinct intro remains its own bubble. */
|
|
360
|
+
const adjacent = this.rows.at(-1);
|
|
361
|
+
const consumes =
|
|
362
|
+
this.bubble === undefined &&
|
|
363
|
+
adjacent?.role === "assistant" &&
|
|
364
|
+
!holdsCard(adjacent) &&
|
|
365
|
+
adjacent.text.trim() !== "" &&
|
|
366
|
+
adjacent.text.trim() === event.text.trim();
|
|
367
|
+
if (consumes) {
|
|
368
|
+
adjacent.text = "";
|
|
369
|
+
}
|
|
370
|
+
return this.park(
|
|
371
|
+
event.requestId,
|
|
372
|
+
{
|
|
373
|
+
plan: {
|
|
374
|
+
requestId: event.requestId,
|
|
375
|
+
text: event.text,
|
|
376
|
+
status: "pending",
|
|
377
|
+
...(event.document === undefined ? {} : { document: event.document }),
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
consumes ? this.rows.length - 1 : undefined,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
case "question":
|
|
384
|
+
return this.park(event.requestId, {
|
|
385
|
+
question: {
|
|
386
|
+
requestId: event.requestId,
|
|
387
|
+
questions: event.questions,
|
|
388
|
+
status: "pending",
|
|
389
|
+
...(event.document === undefined ? {} : { document: event.document }),
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
case "permission": {
|
|
393
|
+
const { kind: _kind, ...ask } = event;
|
|
394
|
+
return this.park(event.requestId, { permission: { ...ask, status: "pending" } });
|
|
395
|
+
}
|
|
396
|
+
case "browser_help": {
|
|
397
|
+
const { kind: _kind, ...ask } = event;
|
|
398
|
+
return this.park(event.requestId, { browserHelp: { ...ask, status: "pending" } });
|
|
399
|
+
}
|
|
400
|
+
case "terminal_help": {
|
|
401
|
+
const { kind: _kind, ...ask } = event;
|
|
402
|
+
return this.park(event.requestId, { terminalHelp: { ...ask, status: "pending" } });
|
|
403
|
+
}
|
|
404
|
+
case "service_offer":
|
|
405
|
+
return this.park(event.requestId, { serviceOffer: { requestId: event.requestId, offer: event.offer, status: "pending" } });
|
|
406
|
+
case "capability_offer":
|
|
407
|
+
return this.park(event.requestId, { capabilityOffer: { requestId: event.requestId, offer: event.offer, status: "pending" } });
|
|
408
|
+
case "payment_offer":
|
|
409
|
+
return this.park(event.requestId, { paymentOffer: { requestId: event.requestId, offer: event.offer, status: "pending" } });
|
|
410
|
+
case "resolved":
|
|
411
|
+
// The card above was released, and the frame says how. The window that answered already froze its
|
|
412
|
+
// own card the instant its reply was accepted (card-status.ts, the same derivation), so this is a
|
|
413
|
+
// no-op there and earns its keep on every other surface.
|
|
414
|
+
return this.patchParked(event.requestId, (row) => Object.assign(row, settledCards(row, event.reply)));
|
|
415
|
+
case "service_event":
|
|
416
|
+
return this.patchParked(event.requestId, (row) => {
|
|
417
|
+
if (row.serviceOffer !== undefined) {
|
|
418
|
+
row.serviceOffer.events = [...(row.serviceOffer.events ?? []), event.event];
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
case "service_receipt":
|
|
422
|
+
return this.patchParked(event.requestId, (row) => {
|
|
423
|
+
if (row.serviceOffer !== undefined) {
|
|
424
|
+
row.serviceOffer.receipt = { outcome: event.outcome, credits: event.credits, ...(event.remaining === undefined ? {} : { remaining: event.remaining }) };
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
case "capability_outcome":
|
|
428
|
+
return this.patchParked(event.requestId, (row) => {
|
|
429
|
+
if (row.capabilityOffer !== undefined) {
|
|
430
|
+
row.capabilityOffer.outcome = { outcome: event.outcome, ...(event.id === undefined ? {} : { id: event.id }) };
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
case "payment_receipt":
|
|
434
|
+
return this.patchParked(event.requestId, (row) => {
|
|
435
|
+
if (row.paymentOffer !== undefined) {
|
|
436
|
+
row.paymentOffer.receipt = {
|
|
437
|
+
outcome: event.outcome,
|
|
438
|
+
amountUsd: event.amountUsd,
|
|
439
|
+
...(event.transaction === undefined ? {} : { transaction: event.transaction }),
|
|
440
|
+
...(event.network === undefined ? {} : { network: event.network }),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
// Facts about the turn, not rows in it (TURN_FACT_KINDS): the run relays them as themselves.
|
|
445
|
+
case "session":
|
|
446
|
+
case "init":
|
|
447
|
+
case "terminal":
|
|
448
|
+
case "browser":
|
|
449
|
+
case "commands":
|
|
450
|
+
case "rate_limit_info":
|
|
451
|
+
case "fast_mode":
|
|
452
|
+
case "provider_retry":
|
|
453
|
+
case "account_usage":
|
|
454
|
+
case "context_usage":
|
|
455
|
+
case "mode":
|
|
456
|
+
case "done":
|
|
457
|
+
return [];
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** A row the daemon writes on the turn's behalf, a notice about a decision, the feedback that answered a
|
|
462
|
+
* card, at the end of what has been said so far. */
|
|
463
|
+
note(row: TranscriptRow): TranscriptPatch[] {
|
|
464
|
+
return this.pushRow(row);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** The turn is over. The open bubble is closed, every card still waiting on an answer is frozen as nobody's
|
|
468
|
+
* decision, and a turn the user stopped says so, all of it as rows, because all of it is what the reader
|
|
469
|
+
* saw. */
|
|
470
|
+
finish(ending: TurnEnding): TranscriptPatch[] {
|
|
471
|
+
const patches = this.closeBubble();
|
|
472
|
+
for (const [index, row] of this.rows.entries()) {
|
|
473
|
+
if (row.role === "assistant" && isAwaitingDecision(row)) {
|
|
474
|
+
Object.assign(row, cancelledCards(row));
|
|
475
|
+
patches.push(this.replace(index));
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (ending === "stopped") {
|
|
479
|
+
patches.push(...this.pushRow({ role: "notice", text: `Stopped.` }));
|
|
480
|
+
}
|
|
481
|
+
return patches;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/* A CHILD OF THIS STREAM. Its calls and its thinking hang off the card that spawned it; its PROSE does not,
|
|
485
|
+
* because a card has no place for prose and the child's report already arrives as that card's result
|
|
486
|
+
* content. Read at the child's own level (a fold tagged with its id) that prose is top-level and lands in
|
|
487
|
+
* full. A card this stream has never seen means the spawning call is not in the stream being read, so
|
|
488
|
+
* there is nothing to hang it off; dropping it is what keeps a nested level out of the level above it. */
|
|
489
|
+
private applyChild(event: AgentEvent, parent: string | undefined): TranscriptPatch[] {
|
|
490
|
+
const place = parent === undefined ? undefined : this.cards.get(parent);
|
|
491
|
+
if (place === undefined || parent === undefined) {
|
|
492
|
+
return [];
|
|
493
|
+
}
|
|
494
|
+
if (event.kind === "thinking") {
|
|
495
|
+
return this.patchCard(parent, (tool) => {
|
|
496
|
+
tool.thinking = `${tool.thinking ?? ""}${event.text}`;
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
if (event.kind === "tool_call") {
|
|
500
|
+
const child = cardOf(event);
|
|
501
|
+
place.tool.children = [...(place.tool.children ?? []), child];
|
|
502
|
+
this.cards.set(child.id, { tool: child, row: place.row, parent });
|
|
503
|
+
return [{ op: "tool", index: place.row, tool: structuredClone(child), parent }];
|
|
504
|
+
}
|
|
505
|
+
return [];
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// The bubble the current frame writes to, allocating a fresh assistant row when the turn's bubble was
|
|
509
|
+
// retired (a finished block of prose, a card, the end of a turn).
|
|
510
|
+
private open(): [number, TranscriptPatch[]] {
|
|
511
|
+
if (this.bubble !== undefined) {
|
|
512
|
+
return [this.bubble, []];
|
|
513
|
+
}
|
|
514
|
+
const row: TranscriptRow = { role: "assistant", text: "" };
|
|
515
|
+
this.rows.push(row);
|
|
516
|
+
this.bubble = this.rows.length - 1;
|
|
517
|
+
return [this.bubble, [{ op: "append", row: structuredClone(row) }]];
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Retire the open bubble. One that ended empty is not a row: the empty text block a model can open before
|
|
521
|
+
// going straight to a tool has nothing to keep, and it is always the last row, so dropping it moves nothing.
|
|
522
|
+
private closeBubble(): TranscriptPatch[] {
|
|
523
|
+
const index = this.bubble;
|
|
524
|
+
this.bubble = undefined;
|
|
525
|
+
if (index === undefined) {
|
|
526
|
+
return [];
|
|
527
|
+
}
|
|
528
|
+
if (!empty(this.rows[index]!)) {
|
|
529
|
+
return [];
|
|
530
|
+
}
|
|
531
|
+
this.rows.splice(index, 1);
|
|
532
|
+
return [{ op: "drop", index }];
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// A row that is not the open bubble: the bubble is closed first, so what follows lands BELOW what came
|
|
536
|
+
// before, and the new row is the last.
|
|
537
|
+
private pushRow(row: TranscriptRow): TranscriptPatch[] {
|
|
538
|
+
const closed = this.closeBubble();
|
|
539
|
+
this.rows.push(row);
|
|
540
|
+
return [...closed, { op: "append", row: structuredClone(row) }];
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/* A card takes the bubble that is open and closes it: the prose that led up to the ask stays above the card,
|
|
544
|
+
* and whatever the agent says once answered opens a fresh row beneath it. A bubble holding nothing but the
|
|
545
|
+
* card is still a row, where the card IS the bubble. `into` reuses a row already there (the plan's own
|
|
546
|
+
* prose) instead of opening one. */
|
|
547
|
+
private park(requestId: string, cards: TranscriptCards, into?: number): TranscriptPatch[] {
|
|
548
|
+
const [index, opened] = into === undefined ? this.open() : [into, []];
|
|
549
|
+
Object.assign(this.rows[index]!, cards);
|
|
550
|
+
this.bubble = undefined;
|
|
551
|
+
this.parked.set(requestId, index);
|
|
552
|
+
return [...opened, this.replace(index)];
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private patchParked(requestId: string, mutate: (row: TranscriptRow) => void): TranscriptPatch[] {
|
|
556
|
+
const index = this.parked.get(requestId);
|
|
557
|
+
if (index === undefined) {
|
|
558
|
+
return [];
|
|
559
|
+
}
|
|
560
|
+
mutate(this.rows[index]!);
|
|
561
|
+
return [this.replace(index)];
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private patchCard(id: string, mutate: (tool: TranscriptTool) => void): TranscriptPatch[] {
|
|
565
|
+
const place = this.cards.get(id);
|
|
566
|
+
if (place === undefined) {
|
|
567
|
+
return [];
|
|
568
|
+
}
|
|
569
|
+
mutate(place.tool);
|
|
570
|
+
return [{ op: "tool", index: place.row, tool: structuredClone(place.tool), ...(place.parent === undefined ? {} : { parent: place.parent }) }];
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private stampOpener(mutate: (row: TranscriptRow) => void): TranscriptPatch[] {
|
|
574
|
+
if (this.opener === undefined) {
|
|
575
|
+
return [];
|
|
576
|
+
}
|
|
577
|
+
mutate(this.rows[this.opener]!);
|
|
578
|
+
return [this.replace(this.opener)];
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
private replace(index: number): TranscriptPatch {
|
|
582
|
+
return { op: "replace", index, row: structuredClone(this.rows[index]!) };
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** A whole turn at once: the opening rows, every frame, and how it ended. What a settled turn reads back as. */
|
|
587
|
+
export const foldTurn = (opening: readonly TranscriptRow[], events: readonly AgentEvent[], ending: TurnEnding = "settled", tag?: string): TranscriptRow[] => {
|
|
588
|
+
const fold = new TranscriptFold(opening, tag);
|
|
589
|
+
for (const event of events) {
|
|
590
|
+
fold.apply(event);
|
|
591
|
+
}
|
|
592
|
+
fold.finish(ending);
|
|
593
|
+
return fold.rows;
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
/** Apply one patch to a list of rows, the client's half of the fold: what a patch names is what moves, and
|
|
597
|
+
* nothing else. `tool` upserts by id anywhere in the row's tree, so a helper's nested call and a top-level
|
|
598
|
+
* one are placed by the same rule. Returns a new list; the rows it did not touch keep their identity. */
|
|
599
|
+
export const applyTranscriptPatch = (rows: readonly TranscriptRow[], patch: TranscriptPatch): TranscriptRow[] => {
|
|
600
|
+
switch (patch.op) {
|
|
601
|
+
case "append":
|
|
602
|
+
return [...rows, patch.row];
|
|
603
|
+
case "replace":
|
|
604
|
+
return rows.map((row, index) => (index === patch.index ? patch.row : row));
|
|
605
|
+
case "drop":
|
|
606
|
+
return rows.filter((_row, index) => index !== patch.index);
|
|
607
|
+
case "text":
|
|
608
|
+
return rows.map((row, index) => (index === patch.index ? { ...row, text: `${row.text}${patch.text}` } : row));
|
|
609
|
+
case "thinking":
|
|
610
|
+
return rows.map((row, index) => (index === patch.index ? { ...row, thinking: `${row.thinking ?? ""}${patch.text}` } : row));
|
|
611
|
+
case "tool":
|
|
612
|
+
return rows.map((row, index) => (index === patch.index ? { ...row, tools: upsertTool(row.tools ?? [], patch.tool, patch.parent) } : row));
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
// Replace the tool with this id wherever it lives in the tree; failing that, add it under its parent, or at
|
|
617
|
+
// the top level when it has none (or its parent is not here, a malformed stream, where dropping the call
|
|
618
|
+
// would be worse than showing it loose).
|
|
619
|
+
export const upsertTool = (tools: readonly TranscriptTool[], tool: TranscriptTool, parent: string | undefined): TranscriptTool[] => {
|
|
620
|
+
const replaced = mapTool(tools, tool.id, () => tool);
|
|
621
|
+
if (replaced !== tools) {
|
|
622
|
+
return [...replaced];
|
|
623
|
+
}
|
|
624
|
+
if (parent !== undefined) {
|
|
625
|
+
const nested = mapTool(tools, parent, (card) => ({ ...card, children: [...(card.children ?? []), tool] }));
|
|
626
|
+
if (nested !== tools) {
|
|
627
|
+
return [...nested];
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return [...tools, tool];
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
// Apply `fn` to the tool with `id` anywhere in a row's tool tree. Returns the SAME array when the id isn't
|
|
634
|
+
// present, so an unrelated row keeps its identity (and re-renders nothing).
|
|
635
|
+
export const mapTool = (tools: readonly TranscriptTool[], id: string, fn: (tool: TranscriptTool) => TranscriptTool): readonly TranscriptTool[] => {
|
|
636
|
+
let changed = false;
|
|
637
|
+
const next = tools.map((tool) => {
|
|
638
|
+
if (tool.id === id) {
|
|
639
|
+
changed = true;
|
|
640
|
+
return fn(tool);
|
|
641
|
+
}
|
|
642
|
+
if (tool.children !== undefined) {
|
|
643
|
+
const children = mapTool(tool.children, id, fn);
|
|
644
|
+
if (children !== tool.children) {
|
|
645
|
+
changed = true;
|
|
646
|
+
return { ...tool, children: [...children] };
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return tool;
|
|
650
|
+
});
|
|
651
|
+
return changed ? next : tools;
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
// Which fields of a row are cards, exported beside the fold for readers that count rows by them.
|
|
655
|
+
export const cardFieldsOf = (row: TranscriptRow): TranscriptCards => Object.fromEntries(CARD_FIELDS.flatMap((field) => (row[field] === undefined ? [] : [[field, row[field]]])));
|