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 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 browser suite, then the bundle budgets
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 ui:test # browser suite, then the bundle budgets
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
- 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":"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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":";;;;;;;;;;;;;;;;;;;;;;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,UAAkB,cAAc,OAAa;EAC3C,MAAM,UAAU,KAAK,OAAO;EAC5B,KAAK,KAAK;GACR,OAAO,KAAK;GACZ,YAAY,KAAK;GACjB,KAAK,EAAE,KAAK;GACZ,OAAO;GAGP,GAAI,cAAc,EAAE,aAAa,KAAK,IAAI,CAAC;GAC3C,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;IAOH,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,GAAI,MAAM,WAAW,EAAE,UAAU,MAAM,SAAS,IAAI,CAAC;MACrD,aAAa,KAAK,IAAI;KACxB;IACF,CAAC;IACD;GACF,KAAK;IACH,KAAK,aAAa,KAAK;IACvB;GACF,KAAK,gBAAgB;IACnB,KAAK,WAAW;IAChB,MAAM,cAAc,MAAM,SAAS,SAAS;IAC5C,IAAI,aACF,KAAK,kBAAkB,KAAK,GAAI,MAAM,SAAS,cAAc,CAAC,CAAE;IAElE,KAAK,UAAU,WAAW;IAC1B;GACF;GACA,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,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