cortena-ui 1.5.0 → 1.6.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/CHANGELOG.md +107 -0
- package/README.md +86 -2
- package/dist/agent-chat/bridge.js +8 -4
- package/dist/agent-chat/bridge.js.map +1 -1
- package/dist/agent-chat/session.d.ts +22 -1
- package/dist/agent-chat/session.js +143 -1
- package/dist/agent-chat/session.js.map +1 -1
- package/dist/agent-chat/step-label.d.ts +99 -0
- package/dist/agent-chat/step-label.js +116 -0
- package/dist/agent-chat/step-label.js.map +1 -0
- package/dist/agent-chat/store.d.ts +36 -1
- package/dist/agent-chat/store.js +334 -6
- package/dist/agent-chat/store.js.map +1 -1
- package/dist/agent-chat/types.d.ts +90 -0
- package/dist/agent-chat/types.js.map +1 -1
- package/dist/agent-chat.d.ts +6 -5
- package/dist/agent-chat.js +5 -4
- package/dist/components/agent-chat.d.ts +23 -3
- package/dist/components/agent-chat.js +100 -5
- package/dist/components/agent-chat.js.map +1 -1
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/package.json +3 -2
- package/src/agent-chat/bridge.ts +17 -5
- package/src/agent-chat/session.ts +158 -1
- package/src/agent-chat/step-label.ts +177 -0
- package/src/agent-chat/store.ts +499 -3
- package/src/agent-chat/types.ts +95 -0
- package/src/components/agent-chat.tsx +167 -4
- package/src/entries/agent-chat.ts +26 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# cortena-ui
|
|
2
|
+
|
|
3
|
+
Notable changes per release. Versions before 1.6.0 are recorded in the git log
|
|
4
|
+
and in `../../CONSUMING.md`; this file starts where the changelog does.
|
|
5
|
+
|
|
6
|
+
## 1.6.0
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **`cortena-ui/agent-chat`: one plain progress line per tool call** (SKILLS-9).
|
|
11
|
+
Each AG-UI tool call becomes a sentence under the assistant turn — a spinner
|
|
12
|
+
while it runs, a tick when it lands, a cross and the error envelope's message
|
|
13
|
+
when it fails, and the elapsed time on anything still running after eight
|
|
14
|
+
seconds. The list is labelled `Progress` and is deliberately NOT a live
|
|
15
|
+
region of its own: it is drawn inside the transcript, which is already
|
|
16
|
+
`role="log" aria-live="polite"`.
|
|
17
|
+
- `agent-chat/step-label.ts`: `stepLabel(toolName, args, phase)`,
|
|
18
|
+
`confirmationRequiredLabel(args)` and `humaniseId(id)` — the wording map for
|
|
19
|
+
`engram_search`, `engram_describe` and `broker_invoke`, pure and with no
|
|
20
|
+
dependency on the result.
|
|
21
|
+
- `useAgentChat` gains `steps: AgentChatStep[]`, paired by AG-UI
|
|
22
|
+
`toolCallId`. A `409 confirmation_required` result relabels the step rather
|
|
23
|
+
than failing it; an aborted or errored run ends every step still running.
|
|
24
|
+
- Steps are kept in `sessionStorage` beside the session key, so a collapsed
|
|
25
|
+
pop-up, a reload or a remount mid-run does not lose them —
|
|
26
|
+
`chat.history` cannot bring them back, because the server never sees them.
|
|
27
|
+
- New exports: `AgentSteps`, `AgentStepsProps`, `AgentChatStep`,
|
|
28
|
+
`AgentStepPhase`, `stepLabel`, `confirmationRequiredLabel`, `humaniseId`,
|
|
29
|
+
`genericLabel`, `isGenericLabel`, `truncate`, `readResultMetadata`,
|
|
30
|
+
`AgentToolResultMetadata`, `AgentToolResultMeta`, `readStoredSteps`,
|
|
31
|
+
`writeStoredSteps`, `serialiseSteps`, `stepsStorageKey`, `STEP_LIMIT`.
|
|
32
|
+
|
|
33
|
+
- **The outcome of a tool call is read from `TOOL_CALL_RESULT.metadata`**
|
|
34
|
+
(SKILLS-9 review). `AgentChatBridge` forwards the metadata into
|
|
35
|
+
`AgentToolEvent.data.metadata`, and the store decides from
|
|
36
|
+
`{ toolName, isError, meta?: { status, code, message } }` — an error with
|
|
37
|
+
`meta.code === "confirmation_required"` is the broker asking a question, any
|
|
38
|
+
other error is worded from `meta.message` or the first 200 characters of the
|
|
39
|
+
result text, and everything else is done. `content` is never parsed for an
|
|
40
|
+
outcome: a Cortena tool result is `{ content: [...], details }`, so the
|
|
41
|
+
`ok: false` the client used to look for was never at the top level and every
|
|
42
|
+
failed call was drawn as a success.
|
|
43
|
+
|
|
44
|
+
- **A step always stops spinning, and never claims more than it knows.** A
|
|
45
|
+
plain `RUN_FINISHED` marks every open step done with its label untouched (the
|
|
46
|
+
run succeeded, so the call did); an abort marks them `stopped` — a new,
|
|
47
|
+
neutral status, because the user asked for it and a red cross reads as a
|
|
48
|
+
fault they caused; and a step restored from storage as `running` or `paused`
|
|
49
|
+
comes back `stopped` with no message, because the page went away, not the
|
|
50
|
+
tool call. A `tool.start` for a step that has already ended is a reconnect
|
|
51
|
+
replay and is ignored.
|
|
52
|
+
|
|
53
|
+
- **An interrupt is not a success** (SKILLS-9 review 2). `RUN_FINISHED` with
|
|
54
|
+
`outcome.type === "interrupt"` is cortenacore pausing the run for an approval
|
|
55
|
+
or a frontend tool with the tool call STILL OPEN, and ticking it told the user
|
|
56
|
+
a write had gone through while the card asking their permission for it was on
|
|
57
|
+
the screen. `AgentChatEvent` carries `interrupted`, and such a step becomes
|
|
58
|
+
`paused` — a second neutral status, "Waiting for your approval", no spinner
|
|
59
|
+
and no elapsed counter. The result on the continuation resolves it; a `Deny`
|
|
60
|
+
marks it `stopped`. A stored `status` is validated against the known set on
|
|
61
|
+
the way back in, and anything else is `stopped`.
|
|
62
|
+
|
|
63
|
+
- **A late result cannot resurrect a dropped step** (SKILLS-9 review 2). A
|
|
64
|
+
runaway turn pushes its first calls past `STEP_LIMIT`, and their results
|
|
65
|
+
arrive afterwards; the store keeps the last 100 dropped ids and ignores them
|
|
66
|
+
rather than appending a `Working: tool` line for the oldest call of the turn
|
|
67
|
+
at the bottom of the strip.
|
|
68
|
+
|
|
69
|
+
- **`isError` is believed from either witness** (SKILLS-9 review 2). A
|
|
70
|
+
`tool.error` whose metadata arrived without the field was read as a success.
|
|
71
|
+
|
|
72
|
+
- **The id and the tool name are bounded** (SKILLS-9 review 2) — 80 characters,
|
|
73
|
+
in the stored projection and on `data-tool`. Neither is written by this
|
|
74
|
+
package, and 9 kB of either filled the 64 kB slot on its own.
|
|
75
|
+
|
|
76
|
+
- **Bounded and sanitised labels.** Every interpolated string — an operation id,
|
|
77
|
+
a tool name, a server message — goes through `truncate`, which strips
|
|
78
|
+
`\p{Cf}` and `\p{Cc}` before the length check, so a bidi override cannot
|
|
79
|
+
reverse a line and 8 kB of catalogue id cannot fill the strip.
|
|
80
|
+
|
|
81
|
+
- **Only a projection is persisted.** `id`, `toolName`, `label`, `status`,
|
|
82
|
+
`startedAt`, `endedAt`, `errorMessage` — never `args`, which are the user's
|
|
83
|
+
data. The write is debounced 250 ms and flushed on unmount, the list is capped
|
|
84
|
+
at `STEP_LIMIT` in the reducer, and the slot is capped at 64 kB, oldest first.
|
|
85
|
+
|
|
86
|
+
- **Accessibility.** The elapsed counter is `aria-hidden` (it changed once a
|
|
87
|
+
second inside a live region), a running step is held out of the announcement
|
|
88
|
+
until its arguments have produced a real sentence rather than reading out
|
|
89
|
+
"Working: broker_invoke" and correcting itself, and the label span is keyed on
|
|
90
|
+
the words, so a re-word is a node replacement rather than a mutated text node
|
|
91
|
+
some screen readers never announce.
|
|
92
|
+
|
|
93
|
+
### Unchanged
|
|
94
|
+
|
|
95
|
+
- No change to AG-UI protocol handling beyond forwarding
|
|
96
|
+
`TOOL_CALL_RESULT.metadata`, which the bridge already parsed and dropped.
|
|
97
|
+
Streamed reasoning keeps the fix that stops a later event wiping it, now with
|
|
98
|
+
the re-attach rewind covered by a test.
|
|
99
|
+
- `agent-chat` remains the only entry that can reach `@ag-ui/client`; every
|
|
100
|
+
other bundle probe is unchanged.
|
|
101
|
+
|
|
102
|
+
### Bundle
|
|
103
|
+
|
|
104
|
+
- `agent-chat` grows **+6.8 kB** total (2,738.6 → 2,745.4 kB) and **+5.9 kB**
|
|
105
|
+
eager (843.4 → 849.3 kB): the wording map, the step reducer and the strip.
|
|
106
|
+
Not "unmoved" — it is a new surface, and it costs what it costs. Every other
|
|
107
|
+
entry is byte-identical, and `CONSUMING.md`'s table carries the numbers.
|
package/README.md
CHANGED
|
@@ -186,6 +186,90 @@ two windows on one session may both send — cortenacore serialises the runs. A
|
|
|
186
186
|
new key is minted client-side as `agent:<id>:new-<Date.now()>-<random36>` and
|
|
187
187
|
used immediately, so an in-flight first message is never lost.
|
|
188
188
|
|
|
189
|
+
### Progress steps
|
|
190
|
+
|
|
191
|
+
Every AG-UI tool call becomes **one plain sentence** under the assistant turn,
|
|
192
|
+
streamed as it happens: a spinner while it runs, a tick and muted text when it
|
|
193
|
+
lands, a cross and the failure's `message` when it fails, and a neutral
|
|
194
|
+
"Stopped" when the user pressed Stop. A step still running after eight seconds
|
|
195
|
+
appends the time it has taken. The list is an `aria-live="polite"` region, so a
|
|
196
|
+
screen reader hears the run rather than silence — the seconds are `aria-hidden`
|
|
197
|
+
(they change every second) and a step is held out of the announcement until its
|
|
198
|
+
arguments have produced a real sentence.
|
|
199
|
+
|
|
200
|
+
This is not the tool strip at the foot of the panel. The strip is the record —
|
|
201
|
+
tool name, arguments, output, the `ui://` resource `renderToolCall` mounts an
|
|
202
|
+
MCP App from — and reading it means knowing what `broker_invoke` is. The steps
|
|
203
|
+
are for the person waiting.
|
|
204
|
+
|
|
205
|
+
The wording is `stepLabel(toolName, args, phase)` in `agent-chat/step-label.ts`,
|
|
206
|
+
a pure function with no network and no result:
|
|
207
|
+
|
|
208
|
+
| the call | the line |
|
|
209
|
+
| --- | --- |
|
|
210
|
+
| `engram_search {intent}` | Looking for a way to \<intent\>, truncated at 80 characters |
|
|
211
|
+
| `engram_describe {id: "skill.…"}` | Reading skill: \<the call's `title`, or the id's last segment\> |
|
|
212
|
+
| `engram_describe {id}` | Checking how to \<id as words: `tasks.task.list` → "tasks task list"\> |
|
|
213
|
+
| `broker_invoke {operation, confirmed: true}` | Doing: \<operation\>, then Done: \<operation\> |
|
|
214
|
+
| `broker_invoke {operation}` | Fetching \<operation\> |
|
|
215
|
+
| anything else, or arguments not yet arrived | Working: \<tool name\> |
|
|
216
|
+
|
|
217
|
+
A step that finished on that last line is re-worded if its arguments arrive
|
|
218
|
+
late; a step that already has a real sentence keeps it.
|
|
219
|
+
|
|
220
|
+
Two of those need saying out loud. **An unconfirmed invoke is worded as a
|
|
221
|
+
read** — "Fetching …" — because the client cannot know read from write before
|
|
222
|
+
the reply comes back; only the catalogue knows, and the model only sets
|
|
223
|
+
`confirmed` once the user has said yes. And a result whose code is
|
|
224
|
+
`confirmation_required` is **not** drawn as a failure: the step keeps its tick
|
|
225
|
+
and is relabelled "Needs your confirmation: \<operation\>", which is what the
|
|
226
|
+
broker is actually saying.
|
|
227
|
+
|
|
228
|
+
Every string interpolated into a label — an intent, a title, an operation id, a
|
|
229
|
+
tool name, a server's message — is bounded at 80 characters (200 for a failure
|
|
230
|
+
message) and stripped of control and format characters first, so a catalogue
|
|
231
|
+
entry cannot fill the strip and a U+202E cannot reverse it.
|
|
232
|
+
|
|
233
|
+
#### How a step knows it failed
|
|
234
|
+
|
|
235
|
+
**`TOOL_CALL_RESULT.metadata`, and nothing else.** The contract cortenacore
|
|
236
|
+
emits, and the only thing this client reads:
|
|
237
|
+
|
|
238
|
+
```jsonc
|
|
239
|
+
metadata: {
|
|
240
|
+
toolName: "broker_invoke",
|
|
241
|
+
isError: true,
|
|
242
|
+
meta: { status: 409, code: "confirmation_required", message: "Set confirmed=true." }
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
`meta` is optional; where it is absent, only `isError` can be trusted. An error
|
|
247
|
+
with `meta.code === "confirmation_required"` is the question above; any other
|
|
248
|
+
error is worded from `meta.message`, or from the first 200 characters of the
|
|
249
|
+
result text when there is none.
|
|
250
|
+
|
|
251
|
+
`content` is **never** parsed for an outcome. It is the result as the model sees
|
|
252
|
+
it — for a Cortena tool, `{ content: [{ type: "text", text }], details }` — and
|
|
253
|
+
looking for an `ok: false` in it is guessing at a shape nobody promised.
|
|
254
|
+
|
|
255
|
+
Steps live in `useAgentChat`'s `steps`, are cleared when the next run starts,
|
|
256
|
+
and are kept in `sessionStorage` beside the session key. That last part is not
|
|
257
|
+
decoration: `chat.history` has no steps in it — the server never sees them — so
|
|
258
|
+
a collapsed pop-up, a reload or a host remount mid-run would otherwise lose the
|
|
259
|
+
whole strip. `AgentSteps` is exported for a host that wants to draw them
|
|
260
|
+
somewhere else.
|
|
261
|
+
|
|
262
|
+
#### What is kept, and where
|
|
263
|
+
|
|
264
|
+
Steps are held in memory with their `args`, and **only a projection is
|
|
265
|
+
persisted**: `id`, `toolName`, `label`, `status`, `startedAt`, `endedAt`,
|
|
266
|
+
`errorMessage`. A call's arguments are the user's data and do not belong in a
|
|
267
|
+
store every script on the origin can read; the label is already derived from
|
|
268
|
+
them. The write is debounced by 250 ms and flushed on unmount, the list is
|
|
269
|
+
capped at 50 steps, and the slot is capped at 64 kB with the oldest dropped
|
|
270
|
+
first. A step restored with `status: "running"` comes back as "Interrupted." —
|
|
271
|
+
the run that owned it is gone, and no result will ever arrive.
|
|
272
|
+
|
|
189
273
|
### What the extension must proxy
|
|
190
274
|
|
|
191
275
|
The pop-up talks to one origin, `baseUrl`, and every call carries the user's
|
|
@@ -323,7 +407,7 @@ from `@a2ui/*` is installed, so `vitest.config.ts` has no entry for it.
|
|
|
323
407
|
pnpm check # types
|
|
324
408
|
pnpm lint:design # no literals where a token exists; no dangling var()
|
|
325
409
|
pnpm build # dist/ via tsdown (ESM + d.ts); also runs on prepack
|
|
326
|
-
pnpm test # the
|
|
410
|
+
pnpm test # changed-only: the suites of this package your diff affects
|
|
327
411
|
pnpm test:bundle # bundle budgets alone (builds first, then real Vite apps)
|
|
328
412
|
pnpm bundle:probe # the size table, printed, without asserting anything
|
|
329
413
|
pnpm guide # three-theme guide on a local Vite server
|
|
@@ -353,7 +437,7 @@ guide/ Vite app: ?theme=light|dark|system frames, or all three
|
|
|
353
437
|
## Releasing
|
|
354
438
|
|
|
355
439
|
```bash
|
|
356
|
-
pnpm ui:check && pnpm
|
|
440
|
+
pnpm ui:check && FORCE_FULL=1 pnpm test:all # types, token lint, every suite
|
|
357
441
|
cd packages/ui && npm version minor && npm publish # prepack builds dist/
|
|
358
442
|
git push --follow-tags
|
|
359
443
|
```
|
|
@@ -120,13 +120,14 @@ var AgentChatBridge = class {
|
|
|
120
120
|
}
|
|
121
121
|
});
|
|
122
122
|
}
|
|
123
|
-
emitFinal() {
|
|
123
|
+
emitFinal(interrupted = false) {
|
|
124
124
|
const content = this.blocks();
|
|
125
125
|
this.chat({
|
|
126
126
|
runId: this.runId,
|
|
127
127
|
sessionKey: this.sessionKey,
|
|
128
128
|
seq: ++this.seq,
|
|
129
129
|
state: "final",
|
|
130
|
+
...interrupted ? { interrupted: true } : {},
|
|
130
131
|
...content.length > 0 ? { message: {
|
|
131
132
|
role: "assistant",
|
|
132
133
|
content,
|
|
@@ -198,6 +199,7 @@ var AgentChatBridge = class {
|
|
|
198
199
|
data: {
|
|
199
200
|
...event.toolCallId === void 0 ? {} : { id: event.toolCallId },
|
|
200
201
|
output: event.content,
|
|
202
|
+
...event.metadata ? { metadata: event.metadata } : {},
|
|
201
203
|
completedAt: this.now()
|
|
202
204
|
}
|
|
203
205
|
});
|
|
@@ -205,11 +207,13 @@ var AgentChatBridge = class {
|
|
|
205
207
|
case "CUSTOM":
|
|
206
208
|
this.handleCustom(event);
|
|
207
209
|
break;
|
|
208
|
-
case "RUN_FINISHED":
|
|
210
|
+
case "RUN_FINISHED": {
|
|
209
211
|
this.terminal = true;
|
|
210
|
-
|
|
211
|
-
this.
|
|
212
|
+
const interrupted = event.outcome?.type === "interrupt";
|
|
213
|
+
if (interrupted) this.pendingInterrupts.push(...event.outcome?.interrupts ?? []);
|
|
214
|
+
this.emitFinal(interrupted);
|
|
212
215
|
break;
|
|
216
|
+
}
|
|
213
217
|
case "RUN_ERROR":
|
|
214
218
|
this.terminal = true;
|
|
215
219
|
this.chat({
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bridge.js","names":[],"sources":["../../src/agent-chat/bridge.ts"],"sourcesContent":["/**\n * AG-UI events -> the three `AgentChatClient` sinks.\n *\n * Ported from `cortena-shared/src/agui/bridge.ts` (Cortena monorepo, DESIGN-37\n * step 1). `cortena-shared` is not published, so the translation lives here;\n * keep the two in step. It knows nothing about HTTP or `@ag-ui/client`, which\n * is why the tests can drive it with a literal array of events.\n *\n * One change against the original, and it is the reason this file is not a\n * copy: **TOOL_CALL_ARGS is handled.** cortenacore's mapper emits\n * `TOOL_CALL_START` -> `TOOL_CALL_ARGS { toolCallId, delta }` -> `TOOL_CALL_END`\n * at tool-start time, with the whole argument object stringified into one\n * `delta`. The original bridge had no case for it, so every tool card in the\n * AG-UI path showed a name and a spinner and never showed what the tool was\n * called with. The deltas are accumulated per call id and parsed when they\n * form valid JSON, which is also correct for a transport that streams the\n * arguments a fragment at a time.\n */\n\nimport { type A2UIChatBlock, foldA2UIBlocks, readA2UIBlock } from \"./a2ui-block\";\nimport type {\n AgentApprovalEvent,\n AgentChatEvent,\n AgentToolEvent,\n AgentApprovalDecision,\n} from \"./types\";\n\n/** The subset of an AG-UI event this bridge reads. */\nexport interface AguiIncomingEvent {\n type: string;\n messageId?: string;\n delta?: string;\n toolCallId?: string;\n toolCallName?: string;\n content?: string;\n message?: string;\n name?: string;\n value?: unknown;\n metadata?: Record<string, unknown>;\n snapshot?: unknown;\n outcome?: { type?: string; interrupts?: Array<Record<string, unknown>> };\n}\n\n/** `CUSTOM` event names cortenacore emits that this bridge acts on. */\nexport const AGUI_CUSTOM_NAMES = {\n CHAT_ABORTED: \"cortena.chat.aborted\",\n TOOL_PROGRESS: \"cortena.tool.progress\",\n APPROVAL_REQUEST: \"cortena.approval.request\",\n APPROVAL_RESOLVED: \"cortena.approval.resolved\",\n A2UI: \"cortena.a2ui\",\n} as const;\n\nexport interface AgentChatBridgeOptions {\n runId: string;\n sessionKey: string;\n chat: (event: AgentChatEvent) => void;\n tool?: (event: AgentToolEvent) => void;\n approval?: (event: AgentApprovalEvent) => void;\n now?: () => number;\n}\n\ntype ContentBlock = {\n type: \"thinking\" | \"text\" | \"a2ui\";\n thinking?: string;\n text?: string;\n a2ui?: A2UIChatBlock;\n};\n\nconst DECISIONS: readonly string[] = [\"allow-once\", \"allow-always\", \"deny\"];\n\nexport class AgentChatBridge {\n readonly runId: string;\n readonly sessionKey: string;\n\n private readonly chat: (event: AgentChatEvent) => void;\n private readonly tool: ((event: AgentToolEvent) => void) | undefined;\n private readonly approval: ((event: AgentApprovalEvent) => void) | undefined;\n private readonly now: () => number;\n\n private seq = 0;\n /** Text of the assistant message currently streaming. */\n private text = \"\";\n private textMessageId: string | null = null;\n private reasoning = \"\";\n /**\n * The reasoning accumulated before each assistant message began, by message\n * id, and the whole of the re-attach defence for the chain of thought.\n *\n * A re-attach re-streams the run from the top, so every REASONING delta\n * arrives a second time. Restoring this snapshot when a message id starts\n * again rewinds the buffer to exactly the point the first pass reached,\n * whichever side of `TEXT_MESSAGE_START` the reasoning arrived on.\n */\n private readonly reasoningAtStart = new Map<string, string>();\n /**\n * A2UI blocks seen this run. The chat treats the content array as a\n * snapshot, so every emission carries all of them — the same rule the text\n * follows, and what makes a re-attach land in the same place.\n */\n private a2ui: A2UIChatBlock[] = [];\n /** Accumulated `TOOL_CALL_ARGS` deltas, per tool call id. */\n private readonly args = new Map<string, string>();\n /**\n * A2UI messages already folded onto each surface, by their serialised form.\n *\n * A re-attach re-streams the run from the beginning, so every\n * `CUSTOM cortena.a2ui` arrives again. Folding concatenates, so without this\n * the surface's message list doubled on the first reconnection and trebled\n * on the second — a table drawn once, then drawn again underneath itself.\n */\n private readonly seenA2ui = new Map<string, Set<string>>();\n private terminal = false;\n private readonly pendingInterrupts: Array<Record<string, unknown>> = [];\n\n constructor(opts: AgentChatBridgeOptions) {\n this.runId = opts.runId;\n this.sessionKey = opts.sessionKey;\n this.chat = opts.chat;\n this.tool = opts.tool;\n this.approval = opts.approval;\n this.now = opts.now ?? (() => Date.now());\n }\n\n /** True once RUN_FINISHED or RUN_ERROR has been seen; no retry after this. */\n get finished(): boolean {\n return this.terminal;\n }\n\n /** Interrupts carried by the terminal RUN_FINISHED, if any. */\n get interrupts(): ReadonlyArray<Record<string, unknown>> {\n return this.pendingInterrupts;\n }\n\n private blocks(): ContentBlock[] {\n const content: ContentBlock[] = [];\n if (this.reasoning) {\n content.push({ type: \"thinking\", thinking: this.reasoning });\n }\n if (this.text) {\n content.push({ type: \"text\", text: this.text });\n }\n for (const block of this.a2ui) {\n content.push({ type: \"a2ui\", a2ui: block });\n }\n return content;\n }\n\n private emitDelta(): void {\n const content = this.blocks();\n if (content.length === 0) {\n return;\n }\n this.chat({\n runId: this.runId,\n sessionKey: this.sessionKey,\n seq: ++this.seq,\n state: \"delta\",\n message: { role: \"assistant\", content, timestamp: this.now() },\n });\n }\n\n private emitFinal(): void {\n const content = this.blocks();\n this.chat({\n runId: this.runId,\n sessionKey: this.sessionKey,\n seq: ++this.seq,\n state: \"final\",\n ...(content.length > 0\n ? { message: { role: \"assistant\", content, timestamp: this.now() } }\n : {}),\n });\n }\n\n /** Surface a transport failure the way a run error is surfaced. */\n fail(message: string): void {\n if (this.terminal) {\n return;\n }\n this.terminal = true;\n this.chat({\n runId: this.runId,\n sessionKey: this.sessionKey,\n seq: ++this.seq,\n state: \"error\",\n errorMessage: message,\n });\n }\n\n handleEvent(event: AguiIncomingEvent): void {\n switch (event.type) {\n case \"RUN_STARTED\":\n // The run begins here, so the streaming buffers do too. This is the\n // only place the chain of thought is cleared outright.\n this.textMessageId = null;\n this.text = \"\";\n this.reasoning = \"\";\n break;\n case \"TEXT_MESSAGE_START\": {\n // A new assistant message replaces the text buffer, and *only* the\n // text buffer. cortenacore emits the REASONING deltas before the\n // message they explain, so clearing the reasoning here threw away the\n // chain of thought a moment after it arrived — the thinking block\n // never rendered at all. Re-attach doubling is handled instead by\n // rewinding to the snapshot taken when this message id first started.\n const id = event.messageId ?? null;\n this.textMessageId = id;\n this.text = \"\";\n if (id !== null) {\n const before = this.reasoningAtStart.get(id);\n if (before === undefined) {\n this.reasoningAtStart.set(id, this.reasoning);\n } else {\n this.reasoning = before;\n }\n }\n break;\n }\n case \"TEXT_MESSAGE_CONTENT\":\n if (event.messageId && event.messageId !== this.textMessageId) {\n this.textMessageId = event.messageId;\n this.text = \"\";\n }\n this.text += event.delta ?? \"\";\n this.emitDelta();\n break;\n case \"REASONING_MESSAGE_CONTENT\":\n this.reasoning += event.delta ?? \"\";\n this.emitDelta();\n break;\n case \"TOOL_CALL_START\":\n this.tool?.({\n event: \"tool.start\",\n data: {\n ...(event.toolCallId === undefined ? {} : { id: event.toolCallId }),\n ...(event.toolCallName === undefined ? {} : { name: event.toolCallName }),\n startedAt: this.now(),\n },\n });\n break;\n case \"TOOL_CALL_ARGS\":\n this.accumulateArgs(event);\n break;\n case \"TOOL_CALL_END\":\n // Nothing more will arrive for this call's arguments; emit whatever\n // accumulated, even when it never parsed, so the card shows something.\n this.flushArgs(event.toolCallId);\n break;\n case \"TOOL_CALL_RESULT\":\n this.tool?.({\n event: event.metadata?.isError === true ? \"tool.error\" : \"tool.complete\",\n data: {\n ...(event.toolCallId === undefined ? {} : { id: event.toolCallId }),\n output: event.content,\n completedAt: this.now(),\n },\n });\n break;\n case \"CUSTOM\":\n this.handleCustom(event);\n break;\n case \"RUN_FINISHED\":\n this.terminal = true;\n if (event.outcome?.type === \"interrupt\") {\n this.pendingInterrupts.push(...(event.outcome.interrupts ?? []));\n }\n this.emitFinal();\n break;\n case \"RUN_ERROR\":\n this.terminal = true;\n this.chat({\n runId: this.runId,\n sessionKey: this.sessionKey,\n seq: ++this.seq,\n state: \"error\",\n errorMessage: event.message ?? \"An error occurred\",\n });\n break;\n default:\n break;\n }\n }\n\n /**\n * Accumulate one `TOOL_CALL_ARGS` delta and publish the best reading of it.\n *\n * Published on every delta rather than only at `TOOL_CALL_END`, because a\n * run that is interrupted between the two — an approval, an abort, a dropped\n * stream — would otherwise show a tool card with no input at all, which is\n * the state the card exists to avoid.\n */\n private accumulateArgs(event: AguiIncomingEvent): void {\n const id = event.toolCallId;\n if (!id) {\n return;\n }\n const next = (this.args.get(id) ?? \"\") + (event.delta ?? \"\");\n this.args.set(id, next);\n this.publishArgs(id, next);\n }\n\n private flushArgs(id?: string): void {\n if (!id) {\n return;\n }\n const raw = this.args.get(id);\n if (raw === undefined) {\n return;\n }\n this.args.delete(id);\n this.publishArgs(id, raw);\n }\n\n private publishArgs(id: string, raw: string): void {\n if (!raw) {\n return;\n }\n this.tool?.({ event: \"tool.args\", data: { id, input: parseArgs(raw) } });\n }\n\n /**\n * The block with any message this surface has already been sent removed;\n * `null` when nothing in it is new.\n */\n private freshA2UI(block: A2UIChatBlock | null): A2UIChatBlock | null {\n if (!block) {\n return null;\n }\n let seen = this.seenA2ui.get(block.surfaceId);\n if (!seen) {\n seen = new Set();\n this.seenA2ui.set(block.surfaceId, seen);\n }\n const messages: unknown[] = [];\n for (const message of block.messages) {\n const key = stableKey(message);\n if (seen.has(key)) {\n continue;\n }\n seen.add(key);\n messages.push(message);\n }\n // An error still deserves a card even when its messages are all repeats.\n if (messages.length === 0 && !block.error) {\n return null;\n }\n return { ...block, messages };\n }\n\n private handleCustom(event: AguiIncomingEvent): void {\n const value = (event.value ?? {}) as Record<string, unknown>;\n const id = typeof value.id === \"string\" ? value.id : \"\";\n switch (event.name) {\n case AGUI_CUSTOM_NAMES.TOOL_PROGRESS:\n this.tool?.({\n event: \"tool.progress\",\n data: {\n ...(typeof value.toolCallId === \"string\" ? { id: value.toolCallId } : {}),\n output: value.partialResult,\n },\n });\n break;\n case AGUI_CUSTOM_NAMES.APPROVAL_REQUEST: {\n const request = (value.request ?? {}) as Record<string, unknown>;\n this.approval?.({\n type: \"request\",\n request: {\n id,\n toolName: typeof request.command === \"string\" ? request.command : \"exec\",\n args: request,\n timestamp: typeof value.createdAtMs === \"number\" ? value.createdAtMs : this.now(),\n // Captured here, where the run and the session are facts about the\n // event rather than about whatever is in flight when the user\n // eventually presses a button.\n runId: this.runId,\n sessionKey: this.sessionKey,\n },\n });\n break;\n }\n case AGUI_CUSTOM_NAMES.A2UI: {\n // One event per block; folding here means two pushes to one surface\n // reach the renderer as a single message list, which is how a surface\n // the agent streams in pieces ends up drawn once.\n const block = this.freshA2UI(readA2UIBlock(value));\n if (block) {\n this.a2ui = foldA2UIBlocks([...this.a2ui, block]);\n this.emitDelta();\n }\n break;\n }\n case AGUI_CUSTOM_NAMES.APPROVAL_RESOLVED:\n this.approval?.({\n type: \"resolved\",\n id,\n decision:\n typeof value.decision === \"string\" && DECISIONS.includes(value.decision)\n ? (value.decision as AgentApprovalDecision)\n : null,\n });\n break;\n default:\n // cortena.chat.aborted needs no change here: abort() has already reset\n // the local streaming state before the request went out.\n break;\n }\n }\n}\n\n/**\n * A message's identity for de-duplication.\n *\n * `JSON.stringify` is enough: these are payloads the transport just parsed out\n * of JSON, so key order is the order the server wrote, and the same message\n * arriving twice on a re-attach serialises identically both times.\n */\nfunction stableKey(message: unknown): string {\n try {\n return JSON.stringify(message) ?? \"undefined\";\n } catch {\n // Circular, which a JSON transport cannot produce. Never equal to anything.\n return `${Math.random()}`;\n }\n}\n\n/** Arguments as an object when the accumulation parses, as the raw text otherwise. */\nfunction parseArgs(raw: string): unknown {\n try {\n return JSON.parse(raw);\n } catch {\n return raw;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"bridge.js","names":[],"sources":["../../src/agent-chat/bridge.ts"],"sourcesContent":["/**\n * AG-UI events -> the three `AgentChatClient` sinks.\n *\n * Ported from `cortena-shared/src/agui/bridge.ts` (Cortena monorepo, DESIGN-37\n * step 1). `cortena-shared` is not published, so the translation lives here;\n * keep the two in step. It knows nothing about HTTP or `@ag-ui/client`, which\n * is why the tests can drive it with a literal array of events.\n *\n * One change against the original, and it is the reason this file is not a\n * copy: **TOOL_CALL_ARGS is handled.** cortenacore's mapper emits\n * `TOOL_CALL_START` -> `TOOL_CALL_ARGS { toolCallId, delta }` -> `TOOL_CALL_END`\n * at tool-start time, with the whole argument object stringified into one\n * `delta`. The original bridge had no case for it, so every tool card in the\n * AG-UI path showed a name and a spinner and never showed what the tool was\n * called with. The deltas are accumulated per call id and parsed when they\n * form valid JSON, which is also correct for a transport that streams the\n * arguments a fragment at a time.\n */\n\nimport { type A2UIChatBlock, foldA2UIBlocks, readA2UIBlock } from \"./a2ui-block\";\nimport type {\n AgentApprovalEvent,\n AgentChatEvent,\n AgentToolEvent,\n AgentApprovalDecision,\n} from \"./types\";\n\n/** The subset of an AG-UI event this bridge reads. */\nexport interface AguiIncomingEvent {\n type: string;\n messageId?: string;\n delta?: string;\n toolCallId?: string;\n toolCallName?: string;\n content?: string;\n message?: string;\n name?: string;\n value?: unknown;\n metadata?: Record<string, unknown>;\n snapshot?: unknown;\n outcome?: { type?: string; interrupts?: Array<Record<string, unknown>> };\n}\n\n/** `CUSTOM` event names cortenacore emits that this bridge acts on. */\nexport const AGUI_CUSTOM_NAMES = {\n CHAT_ABORTED: \"cortena.chat.aborted\",\n TOOL_PROGRESS: \"cortena.tool.progress\",\n APPROVAL_REQUEST: \"cortena.approval.request\",\n APPROVAL_RESOLVED: \"cortena.approval.resolved\",\n A2UI: \"cortena.a2ui\",\n} as const;\n\nexport interface AgentChatBridgeOptions {\n runId: string;\n sessionKey: string;\n chat: (event: AgentChatEvent) => void;\n tool?: (event: AgentToolEvent) => void;\n approval?: (event: AgentApprovalEvent) => void;\n now?: () => number;\n}\n\ntype ContentBlock = {\n type: \"thinking\" | \"text\" | \"a2ui\";\n thinking?: string;\n text?: string;\n a2ui?: A2UIChatBlock;\n};\n\nconst DECISIONS: readonly string[] = [\"allow-once\", \"allow-always\", \"deny\"];\n\nexport class AgentChatBridge {\n readonly runId: string;\n readonly sessionKey: string;\n\n private readonly chat: (event: AgentChatEvent) => void;\n private readonly tool: ((event: AgentToolEvent) => void) | undefined;\n private readonly approval: ((event: AgentApprovalEvent) => void) | undefined;\n private readonly now: () => number;\n\n private seq = 0;\n /** Text of the assistant message currently streaming. */\n private text = \"\";\n private textMessageId: string | null = null;\n private reasoning = \"\";\n /**\n * The reasoning accumulated before each assistant message began, by message\n * id, and the whole of the re-attach defence for the chain of thought.\n *\n * A re-attach re-streams the run from the top, so every REASONING delta\n * arrives a second time. Restoring this snapshot when a message id starts\n * again rewinds the buffer to exactly the point the first pass reached,\n * whichever side of `TEXT_MESSAGE_START` the reasoning arrived on.\n */\n private readonly reasoningAtStart = new Map<string, string>();\n /**\n * A2UI blocks seen this run. The chat treats the content array as a\n * snapshot, so every emission carries all of them — the same rule the text\n * follows, and what makes a re-attach land in the same place.\n */\n private a2ui: A2UIChatBlock[] = [];\n /** Accumulated `TOOL_CALL_ARGS` deltas, per tool call id. */\n private readonly args = new Map<string, string>();\n /**\n * A2UI messages already folded onto each surface, by their serialised form.\n *\n * A re-attach re-streams the run from the beginning, so every\n * `CUSTOM cortena.a2ui` arrives again. Folding concatenates, so without this\n * the surface's message list doubled on the first reconnection and trebled\n * on the second — a table drawn once, then drawn again underneath itself.\n */\n private readonly seenA2ui = new Map<string, Set<string>>();\n private terminal = false;\n private readonly pendingInterrupts: Array<Record<string, unknown>> = [];\n\n constructor(opts: AgentChatBridgeOptions) {\n this.runId = opts.runId;\n this.sessionKey = opts.sessionKey;\n this.chat = opts.chat;\n this.tool = opts.tool;\n this.approval = opts.approval;\n this.now = opts.now ?? (() => Date.now());\n }\n\n /** True once RUN_FINISHED or RUN_ERROR has been seen; no retry after this. */\n get finished(): boolean {\n return this.terminal;\n }\n\n /** Interrupts carried by the terminal RUN_FINISHED, if any. */\n get interrupts(): ReadonlyArray<Record<string, unknown>> {\n return this.pendingInterrupts;\n }\n\n private blocks(): ContentBlock[] {\n const content: ContentBlock[] = [];\n if (this.reasoning) {\n content.push({ type: \"thinking\", thinking: this.reasoning });\n }\n if (this.text) {\n content.push({ type: \"text\", text: this.text });\n }\n for (const block of this.a2ui) {\n content.push({ type: \"a2ui\", a2ui: block });\n }\n return content;\n }\n\n private emitDelta(): void {\n const content = this.blocks();\n if (content.length === 0) {\n return;\n }\n this.chat({\n runId: this.runId,\n sessionKey: this.sessionKey,\n seq: ++this.seq,\n state: \"delta\",\n message: { role: \"assistant\", content, timestamp: this.now() },\n });\n }\n\n private emitFinal(interrupted = false): void {\n const content = this.blocks();\n this.chat({\n runId: this.runId,\n sessionKey: this.sessionKey,\n seq: ++this.seq,\n state: \"final\",\n // Carried through so the store can tell \"the run succeeded\" from \"the\n // run is waiting for you\". Both arrive as RUN_FINISHED.\n ...(interrupted ? { interrupted: true } : {}),\n ...(content.length > 0\n ? { message: { role: \"assistant\", content, timestamp: this.now() } }\n : {}),\n });\n }\n\n /** Surface a transport failure the way a run error is surfaced. */\n fail(message: string): void {\n if (this.terminal) {\n return;\n }\n this.terminal = true;\n this.chat({\n runId: this.runId,\n sessionKey: this.sessionKey,\n seq: ++this.seq,\n state: \"error\",\n errorMessage: message,\n });\n }\n\n handleEvent(event: AguiIncomingEvent): void {\n switch (event.type) {\n case \"RUN_STARTED\":\n // The run begins here, so the streaming buffers do too. This is the\n // only place the chain of thought is cleared outright.\n this.textMessageId = null;\n this.text = \"\";\n this.reasoning = \"\";\n break;\n case \"TEXT_MESSAGE_START\": {\n // A new assistant message replaces the text buffer, and *only* the\n // text buffer. cortenacore emits the REASONING deltas before the\n // message they explain, so clearing the reasoning here threw away the\n // chain of thought a moment after it arrived — the thinking block\n // never rendered at all. Re-attach doubling is handled instead by\n // rewinding to the snapshot taken when this message id first started.\n const id = event.messageId ?? null;\n this.textMessageId = id;\n this.text = \"\";\n if (id !== null) {\n const before = this.reasoningAtStart.get(id);\n if (before === undefined) {\n this.reasoningAtStart.set(id, this.reasoning);\n } else {\n this.reasoning = before;\n }\n }\n break;\n }\n case \"TEXT_MESSAGE_CONTENT\":\n if (event.messageId && event.messageId !== this.textMessageId) {\n this.textMessageId = event.messageId;\n this.text = \"\";\n }\n this.text += event.delta ?? \"\";\n this.emitDelta();\n break;\n case \"REASONING_MESSAGE_CONTENT\":\n this.reasoning += event.delta ?? \"\";\n this.emitDelta();\n break;\n case \"TOOL_CALL_START\":\n this.tool?.({\n event: \"tool.start\",\n data: {\n ...(event.toolCallId === undefined ? {} : { id: event.toolCallId }),\n ...(event.toolCallName === undefined ? {} : { name: event.toolCallName }),\n startedAt: this.now(),\n },\n });\n break;\n case \"TOOL_CALL_ARGS\":\n this.accumulateArgs(event);\n break;\n case \"TOOL_CALL_END\":\n // Nothing more will arrive for this call's arguments; emit whatever\n // accumulated, even when it never parsed, so the card shows something.\n this.flushArgs(event.toolCallId);\n break;\n case \"TOOL_CALL_RESULT\":\n // The metadata travels WITH the event. It carries `isError`, and the\n // `meta` a Cortena tool attaches to a failure — the status, the code\n // and the message — and it is the only trustworthy account of how the\n // call went. Dropping it here is what forced the store to guess at an\n // envelope inside `content`, and the guess was wrong for every real\n // result cortenacore emits.\n this.tool?.({\n event: event.metadata?.isError === true ? \"tool.error\" : \"tool.complete\",\n data: {\n ...(event.toolCallId === undefined ? {} : { id: event.toolCallId }),\n output: event.content,\n ...(event.metadata ? { metadata: event.metadata } : {}),\n completedAt: this.now(),\n },\n });\n break;\n case \"CUSTOM\":\n this.handleCustom(event);\n break;\n case \"RUN_FINISHED\": {\n this.terminal = true;\n const interrupted = event.outcome?.type === \"interrupt\";\n if (interrupted) {\n this.pendingInterrupts.push(...(event.outcome?.interrupts ?? []));\n }\n this.emitFinal(interrupted);\n break;\n }\n case \"RUN_ERROR\":\n this.terminal = true;\n this.chat({\n runId: this.runId,\n sessionKey: this.sessionKey,\n seq: ++this.seq,\n state: \"error\",\n errorMessage: event.message ?? \"An error occurred\",\n });\n break;\n default:\n break;\n }\n }\n\n /**\n * Accumulate one `TOOL_CALL_ARGS` delta and publish the best reading of it.\n *\n * Published on every delta rather than only at `TOOL_CALL_END`, because a\n * run that is interrupted between the two — an approval, an abort, a dropped\n * stream — would otherwise show a tool card with no input at all, which is\n * the state the card exists to avoid.\n */\n private accumulateArgs(event: AguiIncomingEvent): void {\n const id = event.toolCallId;\n if (!id) {\n return;\n }\n const next = (this.args.get(id) ?? \"\") + (event.delta ?? \"\");\n this.args.set(id, next);\n this.publishArgs(id, next);\n }\n\n private flushArgs(id?: string): void {\n if (!id) {\n return;\n }\n const raw = this.args.get(id);\n if (raw === undefined) {\n return;\n }\n this.args.delete(id);\n this.publishArgs(id, raw);\n }\n\n private publishArgs(id: string, raw: string): void {\n if (!raw) {\n return;\n }\n this.tool?.({ event: \"tool.args\", data: { id, input: parseArgs(raw) } });\n }\n\n /**\n * The block with any message this surface has already been sent removed;\n * `null` when nothing in it is new.\n */\n private freshA2UI(block: A2UIChatBlock | null): A2UIChatBlock | null {\n if (!block) {\n return null;\n }\n let seen = this.seenA2ui.get(block.surfaceId);\n if (!seen) {\n seen = new Set();\n this.seenA2ui.set(block.surfaceId, seen);\n }\n const messages: unknown[] = [];\n for (const message of block.messages) {\n const key = stableKey(message);\n if (seen.has(key)) {\n continue;\n }\n seen.add(key);\n messages.push(message);\n }\n // An error still deserves a card even when its messages are all repeats.\n if (messages.length === 0 && !block.error) {\n return null;\n }\n return { ...block, messages };\n }\n\n private handleCustom(event: AguiIncomingEvent): void {\n const value = (event.value ?? {}) as Record<string, unknown>;\n const id = typeof value.id === \"string\" ? value.id : \"\";\n switch (event.name) {\n case AGUI_CUSTOM_NAMES.TOOL_PROGRESS:\n this.tool?.({\n event: \"tool.progress\",\n data: {\n ...(typeof value.toolCallId === \"string\" ? { id: value.toolCallId } : {}),\n output: value.partialResult,\n },\n });\n break;\n case AGUI_CUSTOM_NAMES.APPROVAL_REQUEST: {\n const request = (value.request ?? {}) as Record<string, unknown>;\n this.approval?.({\n type: \"request\",\n request: {\n id,\n toolName: typeof request.command === \"string\" ? request.command : \"exec\",\n args: request,\n timestamp: typeof value.createdAtMs === \"number\" ? value.createdAtMs : this.now(),\n // Captured here, where the run and the session are facts about the\n // event rather than about whatever is in flight when the user\n // eventually presses a button.\n runId: this.runId,\n sessionKey: this.sessionKey,\n },\n });\n break;\n }\n case AGUI_CUSTOM_NAMES.A2UI: {\n // One event per block; folding here means two pushes to one surface\n // reach the renderer as a single message list, which is how a surface\n // the agent streams in pieces ends up drawn once.\n const block = this.freshA2UI(readA2UIBlock(value));\n if (block) {\n this.a2ui = foldA2UIBlocks([...this.a2ui, block]);\n this.emitDelta();\n }\n break;\n }\n case AGUI_CUSTOM_NAMES.APPROVAL_RESOLVED:\n this.approval?.({\n type: \"resolved\",\n id,\n decision:\n typeof value.decision === \"string\" && DECISIONS.includes(value.decision)\n ? (value.decision as AgentApprovalDecision)\n : null,\n });\n break;\n default:\n // cortena.chat.aborted needs no change here: abort() has already reset\n // the local streaming state before the request went out.\n break;\n }\n }\n}\n\n/**\n * A message's identity for de-duplication.\n *\n * `JSON.stringify` is enough: these are payloads the transport just parsed out\n * of JSON, so key order is the order the server wrote, and the same message\n * arriving twice on a re-attach serialises identically both times.\n */\nfunction stableKey(message: unknown): string {\n try {\n return JSON.stringify(message) ?? \"undefined\";\n } catch {\n // Circular, which a JSON transport cannot produce. Never equal to anything.\n return `${Math.random()}`;\n }\n}\n\n/** Arguments as an object when the accumulation parses, as the raw text otherwise. */\nfunction parseArgs(raw: string): unknown {\n try {\n return JSON.parse(raw);\n } catch {\n return raw;\n }\n}\n"],"mappings":"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"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { AgentChatMessage } from "./types.js";
|
|
2
|
+
import { AgentChatMessage, AgentChatStep } from "./types.js";
|
|
3
3
|
//#region src/agent-chat/session.d.ts
|
|
4
4
|
/**
|
|
5
5
|
* A new session key, minted client-side.
|
|
@@ -18,8 +18,29 @@ export declare function isSessionOfAgent(key: string, agentId: string): boolean;
|
|
|
18
18
|
export declare function sessionStorageKey(scope: string): string;
|
|
19
19
|
/** The `sessionStorage` name the open/collapsed state is kept under. */
|
|
20
20
|
export declare function viewStorageKey(scope: string): string;
|
|
21
|
+
/** The `sessionStorage` name this window's progress steps are kept under. */
|
|
22
|
+
export declare function stepsStorageKey(scope: string): string;
|
|
21
23
|
export declare function readStoredSessionKey(scope: string): string | null;
|
|
22
24
|
export declare function writeStoredSessionKey(scope: string, key: string | null): void;
|
|
25
|
+
/** Most steps kept, in memory and in `sessionStorage`. A turn that long is a runaway loop. */
|
|
26
|
+
export declare const STEP_LIMIT = 50;
|
|
27
|
+
/**
|
|
28
|
+
* The steps of the current turn, and the session they belong to.
|
|
29
|
+
*
|
|
30
|
+
* They go in the per-window store beside the session key, for the same reason
|
|
31
|
+
* the key does: a tab is reloaded mid-run, or a host remounts the surface on a
|
|
32
|
+
* route change, and both threw the whole strip away. (Collapsing the pop-up is
|
|
33
|
+
* NOT one of them — the panel is `hidden`, not unmounted, and the run streams
|
|
34
|
+
* into it either way.) Messages come back from `chat.history`; steps do not
|
|
35
|
+
* exist on the server, so this is the only place they can come back from.
|
|
36
|
+
*
|
|
37
|
+
* Scoped to a session key, and checked on read: resuming a different session
|
|
38
|
+
* must not inherit the last one's steps.
|
|
39
|
+
*/
|
|
40
|
+
export declare function readStoredSteps(scope: string, sessionKey: string): AgentChatStep[];
|
|
41
|
+
/** The slot's contents, projected and trimmed to fit the byte cap. */
|
|
42
|
+
export declare function serialiseSteps(sessionKey: string, steps: readonly AgentChatStep[]): string;
|
|
43
|
+
export declare function writeStoredSteps(scope: string, sessionKey: string | null, steps: readonly AgentChatStep[]): void;
|
|
23
44
|
/** Raw transcript records to the messages the list renders. */
|
|
24
45
|
export declare function parseHistoryMessages(raw: readonly unknown[]): AgentChatMessage[];
|
|
25
46
|
/**
|
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
"use client";
|
|
2
|
+
import { truncate } from "./step-label.js";
|
|
2
3
|
//#region src/agent-chat/session.ts
|
|
3
4
|
/**
|
|
5
|
+
* Sessions, history and the filter chain.
|
|
6
|
+
*
|
|
7
|
+
* Ported from `cortena-shared/src/stores/use-chat.ts` and
|
|
8
|
+
* `cortenaweb/components/chat/message-bubble.tsx` (Cortena monorepo).
|
|
9
|
+
* `cortena-shared` is not published, so the rules the two transports share
|
|
10
|
+
* live here; keep them in step.
|
|
11
|
+
*
|
|
12
|
+
* The one deliberate difference is where the current session key is kept.
|
|
13
|
+
* cortenaweb puts it in `localStorage`, which is shared by every tab of an
|
|
14
|
+
* origin, so a second window adopted the first window's session: it inherited
|
|
15
|
+
* a stream that was not its own and both windows then showed the same content.
|
|
16
|
+
* Sessions here are **per window** — the key lives in `sessionStorage`, under a
|
|
17
|
+
* name scoped to the extension — so opening the extension twice starts two
|
|
18
|
+
* sessions. Both stay listed and either window can resume either one; two
|
|
19
|
+
* windows on one session may both send, and cortenacore serialises the runs.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
4
22
|
* A new session key, minted client-side.
|
|
5
23
|
*
|
|
6
24
|
* `agent:<agentId>:new-<Date.now()>-<random36>`. cortenacore reads the
|
|
@@ -27,6 +45,10 @@ function sessionStorageKey(scope) {
|
|
|
27
45
|
function viewStorageKey(scope) {
|
|
28
46
|
return `cortena.agent-chat.${scope}.view`;
|
|
29
47
|
}
|
|
48
|
+
/** The `sessionStorage` name this window's progress steps are kept under. */
|
|
49
|
+
function stepsStorageKey(scope) {
|
|
50
|
+
return `cortena.agent-chat.${scope}.steps`;
|
|
51
|
+
}
|
|
30
52
|
/**
|
|
31
53
|
* `sessionStorage`, or nothing.
|
|
32
54
|
*
|
|
@@ -56,6 +78,126 @@ function writeStoredSessionKey(scope, key) {
|
|
|
56
78
|
else storage.setItem(sessionStorageKey(scope), key);
|
|
57
79
|
} catch {}
|
|
58
80
|
}
|
|
81
|
+
/** Most steps kept, in memory and in `sessionStorage`. A turn that long is a runaway loop. */
|
|
82
|
+
const STEP_LIMIT = 50;
|
|
83
|
+
/**
|
|
84
|
+
* Most bytes the steps slot may occupy.
|
|
85
|
+
*
|
|
86
|
+
* `sessionStorage` is a few megabytes for the whole origin, shared with
|
|
87
|
+
* everything else the host keeps there, and a step count alone does not bound
|
|
88
|
+
* the size: fifty steps whose labels are all near the limit, with a failure
|
|
89
|
+
* message on each, is a slot nobody budgeted for. Over the cap the OLDEST are
|
|
90
|
+
* dropped, one at a time, because the recent ones are the ones a reload has to
|
|
91
|
+
* bring back.
|
|
92
|
+
*/
|
|
93
|
+
const STEP_BYTE_LIMIT = 65536;
|
|
94
|
+
/**
|
|
95
|
+
* The steps of the current turn, and the session they belong to.
|
|
96
|
+
*
|
|
97
|
+
* They go in the per-window store beside the session key, for the same reason
|
|
98
|
+
* the key does: a tab is reloaded mid-run, or a host remounts the surface on a
|
|
99
|
+
* route change, and both threw the whole strip away. (Collapsing the pop-up is
|
|
100
|
+
* NOT one of them — the panel is `hidden`, not unmounted, and the run streams
|
|
101
|
+
* into it either way.) Messages come back from `chat.history`; steps do not
|
|
102
|
+
* exist on the server, so this is the only place they can come back from.
|
|
103
|
+
*
|
|
104
|
+
* Scoped to a session key, and checked on read: resuming a different session
|
|
105
|
+
* must not inherit the last one's steps.
|
|
106
|
+
*/
|
|
107
|
+
function readStoredSteps(scope, sessionKey) {
|
|
108
|
+
try {
|
|
109
|
+
const raw = windowSessionStorage()?.getItem(stepsStorageKey(scope));
|
|
110
|
+
if (!raw) return [];
|
|
111
|
+
const parsed = JSON.parse(raw);
|
|
112
|
+
if (parsed?.sessionKey !== sessionKey || !Array.isArray(parsed.steps)) return [];
|
|
113
|
+
return parsed.steps.filter((step) => typeof step?.id === "string" && typeof step?.label === "string").map(restoreStep).slice(-50);
|
|
114
|
+
} catch {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Every status a step may legitimately come back with. */
|
|
119
|
+
const STEP_STATUSES = /* @__PURE__ */ new Set([
|
|
120
|
+
"running",
|
|
121
|
+
"done",
|
|
122
|
+
"error",
|
|
123
|
+
"stopped",
|
|
124
|
+
"paused"
|
|
125
|
+
]);
|
|
126
|
+
/**
|
|
127
|
+
* A step as it comes back from storage.
|
|
128
|
+
*
|
|
129
|
+
* The important cases are `running` and `paused`. Neither is true any more:
|
|
130
|
+
* the run that owned the call ended when the surface went away — a reload, a
|
|
131
|
+
* remount on a route change; a collapsed pop-up only HIDES the strip and never
|
|
132
|
+
* reaches this path — and no result will arrive for it now, so a restored
|
|
133
|
+
* spinner spins until the user gives up and reloads again, which restores it.
|
|
134
|
+
*
|
|
135
|
+
* It comes back `stopped`, with no message. Neutral is the honest reading:
|
|
136
|
+
* nothing failed, the work simply did not continue, and "Interrupted." drawn in
|
|
137
|
+
* the failure colour told the user their call had broken when the page had.
|
|
138
|
+
*
|
|
139
|
+
* An unrecognised status is treated the same way. Nothing else writes this
|
|
140
|
+
* slot today, but it is per-origin `sessionStorage` and this is the only place
|
|
141
|
+
* that decides what a strip is allowed to render.
|
|
142
|
+
*/
|
|
143
|
+
function restoreStep(step) {
|
|
144
|
+
if (STEP_STATUSES.has(step.status) && step.status !== "running" && step.status !== "paused") return step;
|
|
145
|
+
const restored = {
|
|
146
|
+
...step,
|
|
147
|
+
status: "stopped",
|
|
148
|
+
endedAt: step.endedAt ?? Date.now()
|
|
149
|
+
};
|
|
150
|
+
delete restored.errorMessage;
|
|
151
|
+
return restored;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The fields of a step that are safe to keep, and the only ones kept.
|
|
155
|
+
*
|
|
156
|
+
* `args` is deliberately absent. A tool call's arguments are the user's data —
|
|
157
|
+
* what they searched for, whose record they opened, the body of what they
|
|
158
|
+
* wrote — and `sessionStorage` is readable by every script on the origin,
|
|
159
|
+
* survives the run, and is the sort of thing that ends up in a support bundle.
|
|
160
|
+
* Nothing on screen needs them after the run: the LABEL is already derived
|
|
161
|
+
* from them, and the label is what a restored strip shows.
|
|
162
|
+
*/
|
|
163
|
+
function storedStep(step) {
|
|
164
|
+
return {
|
|
165
|
+
id: truncate(step.id, 80),
|
|
166
|
+
toolName: truncate(step.toolName, 80),
|
|
167
|
+
label: step.label,
|
|
168
|
+
status: step.status,
|
|
169
|
+
startedAt: step.startedAt,
|
|
170
|
+
...step.endedAt === void 0 ? {} : { endedAt: step.endedAt },
|
|
171
|
+
...step.errorMessage === void 0 ? {} : { errorMessage: step.errorMessage }
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/** The slot's contents, projected and trimmed to fit the byte cap. */
|
|
175
|
+
function serialiseSteps(sessionKey, steps) {
|
|
176
|
+
let kept = steps.slice(-50).map(storedStep);
|
|
177
|
+
let json = JSON.stringify({
|
|
178
|
+
sessionKey,
|
|
179
|
+
steps: kept
|
|
180
|
+
});
|
|
181
|
+
while (kept.length > 1 && json.length > STEP_BYTE_LIMIT) {
|
|
182
|
+
kept = kept.slice(1);
|
|
183
|
+
json = JSON.stringify({
|
|
184
|
+
sessionKey,
|
|
185
|
+
steps: kept
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return json;
|
|
189
|
+
}
|
|
190
|
+
function writeStoredSteps(scope, sessionKey, steps) {
|
|
191
|
+
try {
|
|
192
|
+
const storage = windowSessionStorage();
|
|
193
|
+
if (!storage) return;
|
|
194
|
+
if (!sessionKey || steps.length === 0) {
|
|
195
|
+
storage.removeItem(stepsStorageKey(scope));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
storage.setItem(stepsStorageKey(scope), serialiseSteps(sessionKey, steps));
|
|
199
|
+
} catch {}
|
|
200
|
+
}
|
|
59
201
|
function generateId() {
|
|
60
202
|
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
61
203
|
}
|
|
@@ -244,6 +386,6 @@ function isHiddenMessage(message) {
|
|
|
244
386
|
return isToolResult(text) || isSkillContent(text) || isAgentDiagnostics(text) || isToolOutput(text);
|
|
245
387
|
}
|
|
246
388
|
//#endregion
|
|
247
|
-
export { isHiddenMessage, isProvisionalSessionKey, isSessionOfAgent, mintSessionKey, parseHistoryMessages, readStoredSessionKey, separateThinking, sessionStorageKey, viewStorageKey, windowSessionStorage, wouldShrinkHistory, writeStoredSessionKey };
|
|
389
|
+
export { STEP_LIMIT, isHiddenMessage, isProvisionalSessionKey, isSessionOfAgent, mintSessionKey, parseHistoryMessages, readStoredSessionKey, readStoredSteps, separateThinking, serialiseSteps, sessionStorageKey, stepsStorageKey, viewStorageKey, windowSessionStorage, wouldShrinkHistory, writeStoredSessionKey, writeStoredSteps };
|
|
248
390
|
|
|
249
391
|
//# sourceMappingURL=session.js.map
|