cortena-ui 1.5.0 → 1.7.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 ADDED
@@ -0,0 +1,183 @@
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.7.0
7
+
8
+ ### Added
9
+
10
+ - **`AgentChat` is a host-able surface** (PLATFORM-64). cortenaweb adopts the
11
+ shared component instead of running a second chat, and the pieces its chat
12
+ has that an extension's does not — a Shiki/KaTeX bubble with feedback
13
+ controls, a composer with attachments and agent/model selectors, a reading
14
+ column, the MCP App host — go in through props rather than a fork:
15
+ - `chat: UseAgentChatResult` — the host holds the state and the surface
16
+ draws it. A sidebar, a shortcut or a page that routes on the session key
17
+ can then read the same state the surface does. `AgentChatProps` is now
18
+ `AgentChatOwnedProps | AgentChatHostedProps`: with `chat` there is no
19
+ `client` and no `storageScope`, because the host owns both. The result
20
+ may come from `useAgentChat` or from a store of the host's projected onto
21
+ the same shape.
22
+ - `components?: Partial<AgentChatComponents>` — `Message`, `Streaming`,
23
+ `Empty`, `LoadOlder`, `Tools`, `Composer`; each replaces its region whole.
24
+ `Tools` receives the host's `renderToolCall` as passed, so a strip of the
25
+ host's own still mounts an MCP App through the one seam.
26
+ - `classNames?: AgentChatClassNames` for the wrappers (`header`, `scroll`,
27
+ `transcript`, `sentinel`, `tools`, `composer`); `transcript` replaces the
28
+ default spacing rather than merging with it. `hideHeader` for a host with
29
+ its own session list.
30
+ - `useAgentChat` gains `sendMcpAppAction`, `clearSession` (leave without
31
+ minting; the next send mints), `regenerate(messageId)`, `send(text,
32
+ attachments, { model, thinking })`, and options `mintSessionKey`,
33
+ `restoreSession` and `listSessions`. `AgentChatMessage.attachmentItems`
34
+ (`unknown[]`, the host's own shape) is set on the user bubble a send adds;
35
+ `attachments` keeps its `string[]` shape.
36
+ - `autoLoadOlder`: the transcript pages older history from a sentinel at the
37
+ top as the reader reaches it, as well as from the button. Off by default —
38
+ `AgentChatPopup` pages on a click — and armed only once the scroller
39
+ overflows, so a short transcript does not pull every page the server has.
40
+ Whatever asked for the page, the reader's place is held across the prepend
41
+ (`overflow-anchor: none` on the scroller; the surface owns the rule in every
42
+ browser), and a new message is followed smoothly when the reader was at the
43
+ bottom.
44
+ - `useAgentChat().loadOlder` returns `true` when a load was started and
45
+ `false` when it declined (no session, one in flight, no more), so a caller
46
+ saving scroll position knows whether a prepend is coming.
47
+
48
+ ### Changed (type-level)
49
+
50
+ - `AgentChatProps` is now the union `AgentChatOwnedProps | AgentChatHostedProps`.
51
+ A wrapper that did `interface MyProps extends AgentChatProps` no longer
52
+ compiles — an interface cannot extend a union. Extend `AgentChatOwnedProps`
53
+ (you pass `client` and `storageScope`) or `AgentChatHostedProps` (you pass
54
+ `chat`) instead, as `AgentChatPopupProps` now does.
55
+ - `UseAgentChatResult.loadOlder` is `() => boolean`, was `() => void`. A store
56
+ projected onto the shape returns whether it started a load.
57
+ - The transcript wrapper (`role="log"`) gains `relative`, and a new first child
58
+ `data-slot="agent-chat-sentinel"` (`absolute inset-x-0 top-0 h-px` by
59
+ default, or `classNames.sentinel`). A selector on the transcript's first
60
+ child, or a test counting its children, sees one more element.
61
+ - `AgentChatEmptySlotProps` is unchanged, but a host's `Empty` no longer holds
62
+ the region while `chat.error` is set: the transcript mounts with the error
63
+ banner in it instead.
64
+
65
+ ### Fixed
66
+
67
+ - **`regenerate` asks the question once.** The user turn being asked again is
68
+ moved onto the new run rather than drawn a second time, so the transcript
69
+ reads Q then A2, not Q, Q, A2.
70
+ - **A stale older page cannot land under another session.** `history/older`
71
+ is dropped for a key that is no longer the current one, the same rule
72
+ `history/loaded` follows.
73
+ - **A reopened chat brings back the surfaces the agent drew.**
74
+ `parseHistoryMessages` reads `a2ui` content parts, folds them across a turn,
75
+ and strips the injected `[Dow YYYY-MM-DD HH:MM TZ]` stamp from a user turn —
76
+ the same three rules `cortena-shared` applies, ported.
77
+ - **A stale history reply cannot change the session.** `history/loaded` for a
78
+ key that is no longer the current one — including `null`, after
79
+ `clearSession` — is dropped whole. Two quick resumes used to land the first
80
+ transcript under the second key.
81
+
82
+ ## 1.6.0
83
+
84
+ ### Added
85
+
86
+ - **`cortena-ui/agent-chat`: one plain progress line per tool call** (SKILLS-9).
87
+ Each AG-UI tool call becomes a sentence under the assistant turn — a spinner
88
+ while it runs, a tick when it lands, a cross and the error envelope's message
89
+ when it fails, and the elapsed time on anything still running after eight
90
+ seconds. The list is labelled `Progress` and is deliberately NOT a live
91
+ region of its own: it is drawn inside the transcript, which is already
92
+ `role="log" aria-live="polite"`.
93
+ - `agent-chat/step-label.ts`: `stepLabel(toolName, args, phase)`,
94
+ `confirmationRequiredLabel(args)` and `humaniseId(id)` — the wording map for
95
+ `engram_search`, `engram_describe` and `broker_invoke`, pure and with no
96
+ dependency on the result.
97
+ - `useAgentChat` gains `steps: AgentChatStep[]`, paired by AG-UI
98
+ `toolCallId`. A `409 confirmation_required` result relabels the step rather
99
+ than failing it; an aborted or errored run ends every step still running.
100
+ - Steps are kept in `sessionStorage` beside the session key, so a collapsed
101
+ pop-up, a reload or a remount mid-run does not lose them —
102
+ `chat.history` cannot bring them back, because the server never sees them.
103
+ - New exports: `AgentSteps`, `AgentStepsProps`, `AgentChatStep`,
104
+ `AgentStepPhase`, `stepLabel`, `confirmationRequiredLabel`, `humaniseId`,
105
+ `genericLabel`, `isGenericLabel`, `truncate`, `readResultMetadata`,
106
+ `AgentToolResultMetadata`, `AgentToolResultMeta`, `readStoredSteps`,
107
+ `writeStoredSteps`, `serialiseSteps`, `stepsStorageKey`, `STEP_LIMIT`.
108
+
109
+ - **The outcome of a tool call is read from `TOOL_CALL_RESULT.metadata`**
110
+ (SKILLS-9 review). `AgentChatBridge` forwards the metadata into
111
+ `AgentToolEvent.data.metadata`, and the store decides from
112
+ `{ toolName, isError, meta?: { status, code, message } }` — an error with
113
+ `meta.code === "confirmation_required"` is the broker asking a question, any
114
+ other error is worded from `meta.message` or the first 200 characters of the
115
+ result text, and everything else is done. `content` is never parsed for an
116
+ outcome: a Cortena tool result is `{ content: [...], details }`, so the
117
+ `ok: false` the client used to look for was never at the top level and every
118
+ failed call was drawn as a success.
119
+
120
+ - **A step always stops spinning, and never claims more than it knows.** A
121
+ plain `RUN_FINISHED` marks every open step done with its label untouched (the
122
+ run succeeded, so the call did); an abort marks them `stopped` — a new,
123
+ neutral status, because the user asked for it and a red cross reads as a
124
+ fault they caused; and a step restored from storage as `running` or `paused`
125
+ comes back `stopped` with no message, because the page went away, not the
126
+ tool call. A `tool.start` for a step that has already ended is a reconnect
127
+ replay and is ignored.
128
+
129
+ - **An interrupt is not a success** (SKILLS-9 review 2). `RUN_FINISHED` with
130
+ `outcome.type === "interrupt"` is cortenacore pausing the run for an approval
131
+ or a frontend tool with the tool call STILL OPEN, and ticking it told the user
132
+ a write had gone through while the card asking their permission for it was on
133
+ the screen. `AgentChatEvent` carries `interrupted`, and such a step becomes
134
+ `paused` — a second neutral status, "Waiting for your approval", no spinner
135
+ and no elapsed counter. The result on the continuation resolves it; a `Deny`
136
+ marks it `stopped`. A stored `status` is validated against the known set on
137
+ the way back in, and anything else is `stopped`.
138
+
139
+ - **A late result cannot resurrect a dropped step** (SKILLS-9 review 2). A
140
+ runaway turn pushes its first calls past `STEP_LIMIT`, and their results
141
+ arrive afterwards; the store keeps the last 100 dropped ids and ignores them
142
+ rather than appending a `Working: tool` line for the oldest call of the turn
143
+ at the bottom of the strip.
144
+
145
+ - **`isError` is believed from either witness** (SKILLS-9 review 2). A
146
+ `tool.error` whose metadata arrived without the field was read as a success.
147
+
148
+ - **The id and the tool name are bounded** (SKILLS-9 review 2) — 80 characters,
149
+ in the stored projection and on `data-tool`. Neither is written by this
150
+ package, and 9 kB of either filled the 64 kB slot on its own.
151
+
152
+ - **Bounded and sanitised labels.** Every interpolated string — an operation id,
153
+ a tool name, a server message — goes through `truncate`, which strips
154
+ `\p{Cf}` and `\p{Cc}` before the length check, so a bidi override cannot
155
+ reverse a line and 8 kB of catalogue id cannot fill the strip.
156
+
157
+ - **Only a projection is persisted.** `id`, `toolName`, `label`, `status`,
158
+ `startedAt`, `endedAt`, `errorMessage` — never `args`, which are the user's
159
+ data. The write is debounced 250 ms and flushed on unmount, the list is capped
160
+ at `STEP_LIMIT` in the reducer, and the slot is capped at 64 kB, oldest first.
161
+
162
+ - **Accessibility.** The elapsed counter is `aria-hidden` (it changed once a
163
+ second inside a live region), a running step is held out of the announcement
164
+ until its arguments have produced a real sentence rather than reading out
165
+ "Working: broker_invoke" and correcting itself, and the label span is keyed on
166
+ the words, so a re-word is a node replacement rather than a mutated text node
167
+ some screen readers never announce.
168
+
169
+ ### Unchanged
170
+
171
+ - No change to AG-UI protocol handling beyond forwarding
172
+ `TOOL_CALL_RESULT.metadata`, which the bridge already parsed and dropped.
173
+ Streamed reasoning keeps the fix that stops a later event wiping it, now with
174
+ the re-attach rewind covered by a test.
175
+ - `agent-chat` remains the only entry that can reach `@ag-ui/client`; every
176
+ other bundle probe is unchanged.
177
+
178
+ ### Bundle
179
+
180
+ - `agent-chat` grows **+6.8 kB** total (2,738.6 → 2,745.4 kB) and **+5.9 kB**
181
+ eager (843.4 → 849.3 kB): the wording map, the step reducer and the strip.
182
+ Not "unmoved" — it is a new surface, and it costs what it costs. Every other
183
+ 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
@@ -234,6 +318,42 @@ returns replaces the default card.
234
318
  />
235
319
  ```
236
320
 
321
+ ### Hosting the surface
322
+
323
+ `AgentChatPopup` owns its state. A host that already has state of its own —
324
+ cortenaweb's shell reads the current session from a store the sidebar, the
325
+ keyboard shortcuts and the Canvas panel all share — mounts `AgentChat` with
326
+ `chat` instead of `client`, and no `storageScope`:
327
+
328
+ ```tsx
329
+ <AgentChat
330
+ chat={chat} // a UseAgentChatResult: from useAgentChat, or a store projected onto it
331
+ agentName="Cortena"
332
+ hideHeader // the host has its own session list
333
+ components={{ Message, Streaming, Empty, LoadOlder, Tools, Composer }}
334
+ classNames={{ scroll: "chat-scroll", transcript: "chat-column gap-6 py-6" }}
335
+ renderToolCall={renderMcpApp} // the host's MCP App frame, through the one seam
336
+ />
337
+ ```
338
+
339
+ What stays shared is the surface: the scroller that keeps to the bottom, the
340
+ pager that holds the reader's place across a prepend (and, with
341
+ `autoLoadOlder`, asks for the next page from a sentinel at the top once the
342
+ transcript overflows), the order of the regions, the progress steps and the
343
+ tool-card seam. What each
344
+ region looks like is the host's to replace, one component per region; a
345
+ component given in `components` replaces its region whole, wrapper included.
346
+ `Tools` is handed `renderToolCall` as it was passed, so a strip of the host's
347
+ own still mounts an MCP App through the seam. `Streaming` is handed `steps`
348
+ because the default draws them inside the live bubble; a host's bubble draws
349
+ them itself. `classNames.transcript` replaces the default gutter rather than
350
+ merging with it, so a reading column owns its own spacing.
351
+
352
+ The hook has the matching options: `restoreSession: false` for a host whose URL
353
+ names the session (the key is still written, so one window stays on one
354
+ session), `listSessions: false` for a host with its own session list, and
355
+ `mintSessionKey` for a host that binds sessions some other way.
356
+
237
357
  ## Charts in tests
238
358
 
239
359
  Both chart engines size themselves from the parent box, and under jsdom every
@@ -323,7 +443,7 @@ from `@a2ui/*` is installed, so `vitest.config.ts` has no entry for it.
323
443
  pnpm check # types
324
444
  pnpm lint:design # no literals where a token exists; no dangling var()
325
445
  pnpm build # dist/ via tsdown (ESM + d.ts); also runs on prepack
326
- pnpm test # the browser suite, then the bundle budgets
446
+ pnpm test # changed-only: the suites of this package your diff affects
327
447
  pnpm test:bundle # bundle budgets alone (builds first, then real Vite apps)
328
448
  pnpm bundle:probe # the size table, printed, without asserting anything
329
449
  pnpm guide # three-theme guide on a local Vite server
@@ -353,7 +473,7 @@ guide/ Vite app: ?theme=light|dark|system frames, or all three
353
473
  ## Releasing
354
474
 
355
475
  ```bash
356
- pnpm ui:check && pnpm ui:test # browser suite, then the bundle budgets
476
+ pnpm ui:check && FORCE_FULL=1 pnpm test:all # types, token lint, every suite
357
477
  cd packages/ui && npm version minor && npm publish # prepack builds dist/
358
478
  git push --follow-tags
359
479
  ```
@@ -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
- if (event.outcome?.type === "interrupt") this.pendingInterrupts.push(...event.outcome.interrupts ?? []);
211
- this.emitFinal();
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":";;;;;;;;;;;;;;;;;;;;;;AA4CA,MAAa,oBAAoB;CAC/B,cAAc;CACd,eAAe;CACf,kBAAkB;CAClB,mBAAmB;CACnB,MAAM;AACR;AAkBA,MAAM,YAA+B;CAAC;CAAc;CAAgB;AAAM;AAE1E,IAAa,kBAAb,MAA6B;CAC3B;CACA;CAEA;CACA;CACA;CACA;CAEA,MAAc;;CAEd,OAAe;CACf,gBAAuC;CACvC,YAAoB;;;;;;;;;;CAUpB,mCAAoC,IAAI,IAAoB;;;;;;CAM5D,OAAgC,CAAC;;CAEjC,uBAAwB,IAAI,IAAoB;;;;;;;;;CAShD,2BAA4B,IAAI,IAAyB;CACzD,WAAmB;CACnB,oBAAqE,CAAC;CAEtE,YAAY,MAA8B;EACxC,KAAK,QAAQ,KAAK;EAClB,KAAK,aAAa,KAAK;EACvB,KAAK,OAAO,KAAK;EACjB,KAAK,OAAO,KAAK;EACjB,KAAK,WAAW,KAAK;EACrB,KAAK,MAAM,KAAK,cAAc,KAAK,IAAI;CACzC;;CAGA,IAAI,WAAoB;EACtB,OAAO,KAAK;CACd;;CAGA,IAAI,aAAqD;EACvD,OAAO,KAAK;CACd;CAEA,SAAiC;EAC/B,MAAM,UAA0B,CAAC;EACjC,IAAI,KAAK,WACP,QAAQ,KAAK;GAAE,MAAM;GAAY,UAAU,KAAK;EAAU,CAAC;EAE7D,IAAI,KAAK,MACP,QAAQ,KAAK;GAAE,MAAM;GAAQ,MAAM,KAAK;EAAK,CAAC;EAEhD,KAAK,MAAM,SAAS,KAAK,MACvB,QAAQ,KAAK;GAAE,MAAM;GAAQ,MAAM;EAAM,CAAC;EAE5C,OAAO;CACT;CAEA,YAA0B;EACxB,MAAM,UAAU,KAAK,OAAO;EAC5B,IAAI,QAAQ,WAAW,GACrB;EAEF,KAAK,KAAK;GACR,OAAO,KAAK;GACZ,YAAY,KAAK;GACjB,KAAK,EAAE,KAAK;GACZ,OAAO;GACP,SAAS;IAAE,MAAM;IAAa;IAAS,WAAW,KAAK,IAAI;GAAE;EAC/D,CAAC;CACH;CAEA,YAA0B;EACxB,MAAM,UAAU,KAAK,OAAO;EAC5B,KAAK,KAAK;GACR,OAAO,KAAK;GACZ,YAAY,KAAK;GACjB,KAAK,EAAE,KAAK;GACZ,OAAO;GACP,GAAI,QAAQ,SAAS,IACjB,EAAE,SAAS;IAAE,MAAM;IAAa;IAAS,WAAW,KAAK,IAAI;GAAE,EAAE,IACjE,CAAC;EACP,CAAC;CACH;;CAGA,KAAK,SAAuB;EAC1B,IAAI,KAAK,UACP;EAEF,KAAK,WAAW;EAChB,KAAK,KAAK;GACR,OAAO,KAAK;GACZ,YAAY,KAAK;GACjB,KAAK,EAAE,KAAK;GACZ,OAAO;GACP,cAAc;EAChB,CAAC;CACH;CAEA,YAAY,OAAgC;EAC1C,QAAQ,MAAM,MAAd;GACE,KAAK;IAGH,KAAK,gBAAgB;IACrB,KAAK,OAAO;IACZ,KAAK,YAAY;IACjB;GACF,KAAK,sBAAsB;IAOzB,MAAM,KAAK,MAAM,aAAa;IAC9B,KAAK,gBAAgB;IACrB,KAAK,OAAO;IACZ,IAAI,OAAO,MAAM;KACf,MAAM,SAAS,KAAK,iBAAiB,IAAI,EAAE;KAC3C,IAAI,WAAW,KAAA,GACb,KAAK,iBAAiB,IAAI,IAAI,KAAK,SAAS;UAE5C,KAAK,YAAY;IAErB;IACA;GACF;GACA,KAAK;IACH,IAAI,MAAM,aAAa,MAAM,cAAc,KAAK,eAAe;KAC7D,KAAK,gBAAgB,MAAM;KAC3B,KAAK,OAAO;IACd;IACA,KAAK,QAAQ,MAAM,SAAS;IAC5B,KAAK,UAAU;IACf;GACF,KAAK;IACH,KAAK,aAAa,MAAM,SAAS;IACjC,KAAK,UAAU;IACf;GACF,KAAK;IACH,KAAK,OAAO;KACV,OAAO;KACP,MAAM;MACJ,GAAI,MAAM,eAAe,KAAA,IAAY,CAAC,IAAI,EAAE,IAAI,MAAM,WAAW;MACjE,GAAI,MAAM,iBAAiB,KAAA,IAAY,CAAC,IAAI,EAAE,MAAM,MAAM,aAAa;MACvE,WAAW,KAAK,IAAI;KACtB;IACF,CAAC;IACD;GACF,KAAK;IACH,KAAK,eAAe,KAAK;IACzB;GACF,KAAK;IAGH,KAAK,UAAU,MAAM,UAAU;IAC/B;GACF,KAAK;IACH,KAAK,OAAO;KACV,OAAO,MAAM,UAAU,YAAY,OAAO,eAAe;KACzD,MAAM;MACJ,GAAI,MAAM,eAAe,KAAA,IAAY,CAAC,IAAI,EAAE,IAAI,MAAM,WAAW;MACjE,QAAQ,MAAM;MACd,aAAa,KAAK,IAAI;KACxB;IACF,CAAC;IACD;GACF,KAAK;IACH,KAAK,aAAa,KAAK;IACvB;GACF,KAAK;IACH,KAAK,WAAW;IAChB,IAAI,MAAM,SAAS,SAAS,aAC1B,KAAK,kBAAkB,KAAK,GAAI,MAAM,QAAQ,cAAc,CAAC,CAAE;IAEjE,KAAK,UAAU;IACf;GACF,KAAK;IACH,KAAK,WAAW;IAChB,KAAK,KAAK;KACR,OAAO,KAAK;KACZ,YAAY,KAAK;KACjB,KAAK,EAAE,KAAK;KACZ,OAAO;KACP,cAAc,MAAM,WAAW;IACjC,CAAC;EAIL;CACF;;;;;;;;;CAUA,eAAuB,OAAgC;EACrD,MAAM,KAAK,MAAM;EACjB,IAAI,CAAC,IACH;EAEF,MAAM,QAAQ,KAAK,KAAK,IAAI,EAAE,KAAK,OAAO,MAAM,SAAS;EACzD,KAAK,KAAK,IAAI,IAAI,IAAI;EACtB,KAAK,YAAY,IAAI,IAAI;CAC3B;CAEA,UAAkB,IAAmB;EACnC,IAAI,CAAC,IACH;EAEF,MAAM,MAAM,KAAK,KAAK,IAAI,EAAE;EAC5B,IAAI,QAAQ,KAAA,GACV;EAEF,KAAK,KAAK,OAAO,EAAE;EACnB,KAAK,YAAY,IAAI,GAAG;CAC1B;CAEA,YAAoB,IAAY,KAAmB;EACjD,IAAI,CAAC,KACH;EAEF,KAAK,OAAO;GAAE,OAAO;GAAa,MAAM;IAAE;IAAI,OAAO,UAAU,GAAG;GAAE;EAAE,CAAC;CACzE;;;;;CAMA,UAAkB,OAAmD;EACnE,IAAI,CAAC,OACH,OAAO;EAET,IAAI,OAAO,KAAK,SAAS,IAAI,MAAM,SAAS;EAC5C,IAAI,CAAC,MAAM;GACT,uBAAO,IAAI,IAAI;GACf,KAAK,SAAS,IAAI,MAAM,WAAW,IAAI;EACzC;EACA,MAAM,WAAsB,CAAC;EAC7B,KAAK,MAAM,WAAW,MAAM,UAAU;GACpC,MAAM,MAAM,UAAU,OAAO;GAC7B,IAAI,KAAK,IAAI,GAAG,GACd;GAEF,KAAK,IAAI,GAAG;GACZ,SAAS,KAAK,OAAO;EACvB;EAEA,IAAI,SAAS,WAAW,KAAK,CAAC,MAAM,OAClC,OAAO;EAET,OAAO;GAAE,GAAG;GAAO;EAAS;CAC9B;CAEA,aAAqB,OAAgC;EACnD,MAAM,QAAS,MAAM,SAAS,CAAC;EAC/B,MAAM,KAAK,OAAO,MAAM,OAAO,WAAW,MAAM,KAAK;EACrD,QAAQ,MAAM,MAAd;GACE,KAAK,kBAAkB;IACrB,KAAK,OAAO;KACV,OAAO;KACP,MAAM;MACJ,GAAI,OAAO,MAAM,eAAe,WAAW,EAAE,IAAI,MAAM,WAAW,IAAI,CAAC;MACvE,QAAQ,MAAM;KAChB;IACF,CAAC;IACD;GACF,KAAK,kBAAkB,kBAAkB;IACvC,MAAM,UAAW,MAAM,WAAW,CAAC;IACnC,KAAK,WAAW;KACd,MAAM;KACN,SAAS;MACP;MACA,UAAU,OAAO,QAAQ,YAAY,WAAW,QAAQ,UAAU;MAClE,MAAM;MACN,WAAW,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAK,IAAI;MAIhF,OAAO,KAAK;MACZ,YAAY,KAAK;KACnB;IACF,CAAC;IACD;GACF;GACA,KAAK,kBAAkB,MAAM;IAI3B,MAAM,QAAQ,KAAK,UAAU,cAAc,KAAK,CAAC;IACjD,IAAI,OAAO;KACT,KAAK,OAAO,eAAe,CAAC,GAAG,KAAK,MAAM,KAAK,CAAC;KAChD,KAAK,UAAU;IACjB;IACA;GACF;GACA,KAAK,kBAAkB,mBACrB,KAAK,WAAW;IACd,MAAM;IACN;IACA,UACE,OAAO,MAAM,aAAa,YAAY,UAAU,SAAS,MAAM,QAAQ,IAClE,MAAM,WACP;GACR,CAAC;EAML;CACF;AACF;;;;;;;;AASA,SAAS,UAAU,SAA0B;CAC3C,IAAI;EACF,OAAO,KAAK,UAAU,OAAO,KAAK;CACpC,QAAQ;EAEN,OAAO,GAAG,KAAK,OAAO;CACxB;AACF;;AAGA,SAAS,UAAU,KAAsB;CACvC,IAAI;EACF,OAAO,KAAK,MAAM,GAAG;CACvB,QAAQ;EACN,OAAO;CACT;AACF"}
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
  /**