canopy-ui 0.7.0 → 0.9.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.
@@ -0,0 +1,302 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import fixture from './agui.fixture.json'
6
+ import { fromAgui, resetAguiState } from './agui'
7
+ import { sessionReducer } from './sessionReducer'
8
+ import type { SessionState, WsEvent } from './protocol'
9
+
10
+ /** The hook's own source. Read from disk rather than imported, because what is
11
+ * being checked is that the WIRING exists — which no behavioural test of a
12
+ * pure module can see. */
13
+ function hookSource(): string {
14
+ return readFileSync(new URL('./useSessionSocket.ts', import.meta.url), 'utf8')
15
+ }
16
+
17
+ /**
18
+ * The inverse projection, checked against the forward one.
19
+ *
20
+ * `agui.fixture.json` is GENERATED by `apps/canopy_sessions/agui.py` and a
21
+ * Python test asserts it is current. So the round trip is proved across two
22
+ * languages with a shared artifact — which is the only thing that can catch the
23
+ * projection and its inverse drifting apart, since neither codebase can see the
24
+ * other.
25
+ */
26
+
27
+ beforeEach(() => resetAguiState())
28
+
29
+ type FixtureEntry = { canopy: WsEvent; agui: Array<Record<string, unknown>> }
30
+ const entries = fixture as unknown as FixtureEntry[]
31
+
32
+ /** Replay a fixture entry's AG-UI events through the inverse. */
33
+ function roundTrip(entry: FixtureEntry): WsEvent[] {
34
+ return entry.agui.flatMap((event) => fromAgui(event))
35
+ }
36
+
37
+ describe('the round trip, against the server’s own output', () => {
38
+ it('covers a real conversation, not just the easy frames', () => {
39
+ // A fixture of two trivial events would pass forever while proving nothing
40
+ // about a streamed reply or a tool call.
41
+ const covered = new Set(entries.map((e) => e.canopy.event))
42
+ expect(covered).toContain('chat.stream_start')
43
+ expect(covered).toContain('chat.tool_use')
44
+ expect(covered).toContain('chat.tool_result')
45
+ expect(entries.length).toBeGreaterThanOrEqual(10)
46
+ })
47
+
48
+ it.each(entries.map((e) => [e.canopy.event, e] as const))(
49
+ 'recovers the original canopy frame for %s',
50
+ (_name, entry) => {
51
+ expect(roundTrip(entry)).toEqual([entry.canopy])
52
+ },
53
+ )
54
+ })
55
+
56
+ describe('the reducer cannot tell which protocol it was fed', () => {
57
+ /** The property that actually matters: a client reading AG-UI must end up in
58
+ * exactly the state a client reading canopy's own frames ends up in. Field
59
+ * equality per frame is necessary but not sufficient — ordering and the
60
+ * reducer's own dedupe are where a subtle difference would show. */
61
+ function reduceAll(frames: WsEvent[]): SessionState {
62
+ const initial: SessionState = {
63
+ messages: [],
64
+ active_draft: null,
65
+ participants: [],
66
+ presence_user_ids: [],
67
+ current_user_id: 0,
68
+ }
69
+ return frames.reduce(sessionReducer, initial)
70
+ }
71
+
72
+ it('reaches identical state via canopy frames and via AG-UI', () => {
73
+ // Time is frozen because the reducer stamps `created_at`/`started_at` with
74
+ // the wall clock as it runs, so two reductions a millisecond apart differ
75
+ // in a field that has nothing to do with the protocol. Without this the
76
+ // test fails intermittently for a reason that would send the next reader
77
+ // hunting a mapping bug that is not there.
78
+ vi.useFakeTimers()
79
+ vi.setSystemTime(new Date('2026-09-15T12:00:00Z'))
80
+ try {
81
+ const direct = entries.map((e) => e.canopy)
82
+ const viaAgui = entries.flatMap(roundTrip)
83
+
84
+ expect(reduceAll(viaAgui)).toEqual(reduceAll(direct))
85
+ } finally {
86
+ vi.useRealTimers()
87
+ }
88
+ })
89
+
90
+ it('and that state is not vacuously empty', () => {
91
+ // Guard: if the fixture stopped producing messages this test would pass
92
+ // while comparing nothing to nothing.
93
+ expect(reduceAll(entries.map((e) => e.canopy)).messages.length).toBeGreaterThan(0)
94
+ })
95
+ })
96
+
97
+ describe('a tool call spans three events, so it is assembled not truncated', () => {
98
+ it('emits nothing until the arguments arrive', () => {
99
+ // canopy carries a tool's input on one frame; AG-UI streams it on the ARGS
100
+ // event that follows. Emitting on START would render a tool row with no
101
+ // input — the half-empty tool call the ACP notes warn about.
102
+ const start = fromAgui({
103
+ type: 'TOOL_CALL_START',
104
+ toolCallId: 't1',
105
+ toolCallName: 'list_insights',
106
+ })
107
+
108
+ expect(start).toEqual([])
109
+ })
110
+
111
+ it('restores the runner’s own block verbatim', () => {
112
+ // canopy passes the block straight through from the transcript and never
113
+ // interprets it, so its shape belongs to the PRODUCER — Anthropic's on the
114
+ // laptop, the cloud runner's over ACP. Rebuilding one from the AG-UI fields
115
+ // would invent an `id` and a `type` that the real payload may not have had,
116
+ // and hand the renderer something no runner ever sent.
117
+ const block = { type: 'tool_use', id: 'toolu_01ABC', name: 'list_insights', input: { limit: 5 } }
118
+ fromAgui({
119
+ type: 'TOOL_CALL_START',
120
+ toolCallId: 't1',
121
+ toolCallName: 'list_insights',
122
+ parentMessageId: 'm1',
123
+ metadata: { canopy: { turn_index: 5, block } },
124
+ })
125
+
126
+ const [frame] = fromAgui({ type: 'TOOL_CALL_ARGS', toolCallId: 't1', delta: '{"limit":5}' })
127
+
128
+ expect(frame).toEqual({
129
+ event: 'chat.tool_use',
130
+ data: {
131
+ parent_message_id: 'm1',
132
+ tool_message_id: 't1',
133
+ turn_index: 5,
134
+ block,
135
+ },
136
+ })
137
+ })
138
+
139
+ it('falls back to the protocol’s own fields for a non-canopy producer', () => {
140
+ // An AG-UI stream from something that is not canopy carries no block. The
141
+ // row is still renderable from name + arguments, which is the minimum the
142
+ // protocol guarantees — better than dropping the call entirely.
143
+ fromAgui({ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'search' })
144
+ const [frame] = fromAgui({ type: 'TOOL_CALL_ARGS', toolCallId: 't1', delta: '{"q":"x"}' })
145
+
146
+ expect((frame.data as unknown as { block: unknown }).block).toEqual({ name: 'search', input: { q: 'x' } })
147
+ })
148
+
149
+ it('ignores arguments for a call it never saw start', () => {
150
+ // A reconnect mid-call, or a stream that began before this client attached.
151
+ expect(fromAgui({ type: 'TOOL_CALL_ARGS', toolCallId: 'unknown', delta: '{}' })).toEqual([])
152
+ })
153
+
154
+ it('does not carry a half-read call across a reconnect', () => {
155
+ fromAgui({ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'x' })
156
+ resetAguiState()
157
+
158
+ expect(fromAgui({ type: 'TOOL_CALL_ARGS', toolCallId: 't1', delta: '{}' })).toEqual([])
159
+ })
160
+
161
+ it('survives malformed arguments with an empty input rather than no row', () => {
162
+ fromAgui({ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'x' })
163
+ const [frame] = fromAgui({ type: 'TOOL_CALL_ARGS', toolCallId: 't1', delta: 'not json' })
164
+
165
+ expect((frame.data as unknown as { block: { input: unknown } }).block.input).toEqual({})
166
+ })
167
+ })
168
+
169
+ describe('the blocked agent survives the trip', () => {
170
+ const INTERRUPT = {
171
+ type: 'RUN_FINISHED',
172
+ threadId: 't1',
173
+ runId: 'r1',
174
+ outcome: {
175
+ type: 'interrupt',
176
+ interrupts: [
177
+ {
178
+ id: 'i1',
179
+ reason: 'transcript',
180
+ message: 'Phase gate',
181
+ expiresAt: '2026-09-15T10:00:00+00:00',
182
+ metadata: {
183
+ canopy: {
184
+ body: 'Phase 4 is test-gated.',
185
+ questions: [
186
+ {
187
+ index: 0,
188
+ question: 'Proceed?',
189
+ multi_select: false,
190
+ options: [{ number: 1, label: 'Yes' }],
191
+ },
192
+ ],
193
+ },
194
+ },
195
+ },
196
+ ],
197
+ },
198
+ }
199
+
200
+ it('becomes a canopy menu a client can render', () => {
201
+ const [frame] = fromAgui(INTERRUPT)
202
+ const menu = (frame.data as unknown as { menu: Record<string, unknown> }).menu
203
+
204
+ expect(frame.event).toBe('session.menu')
205
+ expect(menu.question).toBe('Proceed?')
206
+ // The body is often the only thing that makes a dialog answerable away from
207
+ // the keyboard: "Do you want to proceed?" says nothing without it.
208
+ expect(menu.body).toBe('Phase 4 is test-gated.')
209
+ })
210
+
211
+ it('keeps every question, not only the first', () => {
212
+ // The TUI draws them as tabs and will not submit until each has an answer,
213
+ // so a client showing one cannot complete the ask however it is pressed.
214
+ const [frame] = fromAgui(INTERRUPT)
215
+ const menu = (frame.data as unknown as { menu: { questions: unknown[] } }).menu
216
+
217
+ expect(menu.questions).toHaveLength(1)
218
+ })
219
+
220
+ it('carries the staleness back as observed_at', () => {
221
+ const [frame] = fromAgui(INTERRUPT)
222
+ const menu = (frame.data as unknown as { menu: { observed_at: number } }).menu
223
+
224
+ expect(menu.observed_at).toBe(Date.parse('2026-09-15T10:00:00+00:00') / 1000)
225
+ })
226
+
227
+ it('retracts a menu when someone answered at the keyboard', () => {
228
+ const [frame] = fromAgui({ type: 'CUSTOM', name: 'canopy.menu.retracted', value: {} })
229
+
230
+ expect(frame).toEqual({ event: 'session.menu', data: { menu: null } })
231
+ })
232
+ })
233
+
234
+ describe('stopping is not erroring', () => {
235
+ it('reads a cancelled run as a cancelled stream', () => {
236
+ const [frame] = fromAgui({
237
+ type: 'RUN_FINISHED',
238
+ threadId: 't1',
239
+ runId: 'r1',
240
+ result: { cancelled: true, partial_len: 12 },
241
+ })
242
+
243
+ expect(frame.event).toBe('chat.stream_cancelled')
244
+ })
245
+
246
+ it('reads a plain finish as nothing at all', () => {
247
+ // canopy has no frame for "the run ended cleanly" — the stream_complete
248
+ // already said so. Inventing one would double-count the turn.
249
+ expect(fromAgui({ type: 'RUN_FINISHED', threadId: 't1', runId: 'r1' })).toEqual([])
250
+ })
251
+ })
252
+
253
+ describe('an unknown event cannot break a client', () => {
254
+ it('ignores an event type this version has never heard of', () => {
255
+ // AG-UI is 0.x and gains event types. A client that throws on one it does
256
+ // not know is a client that breaks on the protocol improving.
257
+ expect(fromAgui({ type: 'SOMETHING_NEW_IN_1_0', foo: 1 })).toEqual([])
258
+ })
259
+
260
+ it('ignores a CUSTOM event from someone else’s namespace', () => {
261
+ expect(fromAgui({ type: 'CUSTOM', name: 'vendor.thing', value: {} })).toEqual([])
262
+ })
263
+
264
+ it.each([{}, { type: null }, { type: 'TEXT_MESSAGE_CONTENT' }])(
265
+ 'does not throw on malformed input %#',
266
+ (frame) => {
267
+ expect(() => fromAgui(frame as Record<string, unknown>)).not.toThrow()
268
+ },
269
+ )
270
+ })
271
+
272
+ describe('the socket actually asks for it', () => {
273
+ /** A pure module nothing calls is a design document with a test suite — the
274
+ * mistake `apps/mcp/page_tools.py` made expensive once. These assert the
275
+ * option reaches the URL and the message handler, not just that the mapping
276
+ * is correct in isolation. */
277
+ it('is wired to the hook’s public option', () => {
278
+ const source = hookSource()
279
+
280
+ // NOT `expect(source).toContain('protocol=ag-ui')`, which this used to say.
281
+ // It held the whole time the flag was being dropped by any URL builder that
282
+ // ignores its path — i.e. by canopy-web's widget and by ace-web. Whether the
283
+ // flag reaches the socket is asserted where it can be seen, against the URL
284
+ // a socket really opens: `useSessionSocket.protocol.test.tsx`.
285
+ expect(source).toContain('fromAgui')
286
+ // And the default stays canopy's own vocabulary, which is what lets an
287
+ // existing consumer — ace-web installs this package from npm — notice
288
+ // nothing at all.
289
+ expect(source).toContain('protocol = "canopy"')
290
+ })
291
+
292
+ it('clears half-read state when it opens a connection', () => {
293
+ // Tool-call ids are per-stream, so an ARGS event from a new connection must
294
+ // not complete a call the previous one left open. The BEHAVIOUR is tested
295
+ // directly above ("does not carry a half-read call across a reconnect");
296
+ // what is checked here is that `connect` actually calls it, which no
297
+ // behavioural test of the pure module can see.
298
+ const connectBody = hookSource().slice(hookSource().indexOf('const connect ='))
299
+
300
+ expect(connectBody).toContain('resetAguiState()')
301
+ })
302
+ })
@@ -0,0 +1,295 @@
1
+ /**
2
+ * AG-UI events, read back into canopy's own session frames.
3
+ *
4
+ * The inverse of `apps/canopy_sessions/agui.py`. The server projects canopy →
5
+ * AG-UI; this reads AG-UI → canopy, so `sessionReducer` never learns a second
6
+ * vocabulary and every existing surface keeps working unchanged.
7
+ *
8
+ * **Why an inverse rather than a second reducer.** Teaching the reducer AG-UI
9
+ * directly would mean two code paths producing the same `SessionState`, and
10
+ * they would diverge — the multiplayer cases especially, which AG-UI does not
11
+ * model and which only canopy's path exercises. One reducer with a translator
12
+ * in front of it has a single behaviour to test.
13
+ *
14
+ * **The round trip is proved, not assumed.** `agui.fixture.json` is generated
15
+ * by the Python projection; a Python test asserts it is current and the test
16
+ * beside this one asserts this module recovers the original canopy frame from
17
+ * it. The projection and its inverse live in different languages, so a shared
18
+ * artifact is the only thing that can catch them drifting apart.
19
+ *
20
+ * **Lossless via `metadata`.** AG-UI has no slot for canopy's transcript
21
+ * ordinal (`turn_index`) or for the settled text of a whole message
22
+ * (`plaintext`), and the reducer needs both — one to sort a live row into the
23
+ * position it will occupy after a reload, the other to tell a web send from the
24
+ * echo of the same text arriving from the runner. They ride `metadata.canopy`,
25
+ * which is the extension point AG-UI reserves ("Every other key is user
26
+ * space"). Without them the projection would be a downgrade dressed as a
27
+ * standard.
28
+ */
29
+
30
+ import type { SessionMenu, WsEvent } from "./protocol";
31
+
32
+ /** Where canopy's own fields ride. Mirrors `agui.METADATA_KEY`. */
33
+ export const METADATA_KEY = "canopy";
34
+
35
+ /** Prefix on canopy's `CUSTOM` events. Mirrors `agui.CUSTOM_PREFIX`. */
36
+ export const CUSTOM_PREFIX = "canopy.";
37
+
38
+ /** An AG-UI event as it arrives on the wire: camelCase, unknown shape. */
39
+ type AguiFrame = Record<string, unknown>;
40
+
41
+ function meta(frame: AguiFrame): Record<string, unknown> {
42
+ const m = frame.metadata as Record<string, unknown> | undefined;
43
+ const mine = m?.[METADATA_KEY] as Record<string, unknown> | undefined;
44
+ return mine ?? {};
45
+ }
46
+
47
+ function str(value: unknown): string {
48
+ return typeof value === "string" ? value : "";
49
+ }
50
+
51
+ function num(value: unknown, fallback = 0): number {
52
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
53
+ }
54
+
55
+ /**
56
+ * One AG-UI event → zero or more canopy frames.
57
+ *
58
+ * Zero is a real answer, twice over. Some AG-UI events have no canopy meaning
59
+ * (`RUN_STARTED` duplicates an activity frame canopy already sent); and an
60
+ * unknown event returns zero rather than throwing, because a protocol that
61
+ * gains an event type must not be able to break a client that has not been
62
+ * updated yet — which, for a 0.x protocol, is a matter of when rather than if.
63
+ */
64
+ export function fromAgui(frame: AguiFrame): WsEvent[] {
65
+ const type = typeof frame.type === "string" ? frame.type : "";
66
+ const m = meta(frame);
67
+
68
+ // The server's own frame, when it sent one. Where the AG-UI spelling is lossy
69
+ // for canopy — a run error with no slot for which message failed, a messages
70
+ // snapshot with none for drafts, presence or a pending dialog, an interrupt
71
+ // that re-encodes the menu — the original rides under `metadata.canopy.frame`
72
+ // (`_verbatim` in agui.py), and returning it beats rebuilding it: a rebuilt
73
+ // frame carries whatever this function guessed, not what the server said.
74
+ // One rule for every such event, so a newly lossy projection needs no new case
75
+ // here — which is how the connect snapshot went missing in the first place.
76
+ const original = m.frame;
77
+ if (original && typeof original === "object" && typeof (original as WsEvent).event === "string") {
78
+ return [original as WsEvent];
79
+ }
80
+
81
+ switch (type) {
82
+ case "TEXT_MESSAGE_START":
83
+ return [
84
+ {
85
+ event: "chat.stream_start",
86
+ data: { message_id: str(frame.messageId), turn_index: num(m.turn_index) },
87
+ },
88
+ ];
89
+
90
+ case "TEXT_MESSAGE_CONTENT":
91
+ return [
92
+ {
93
+ event: "chat.delta",
94
+ data: { message_id: str(frame.messageId), text: str(frame.delta) },
95
+ },
96
+ ];
97
+
98
+ case "TEXT_MESSAGE_END":
99
+ return [
100
+ {
101
+ event: "chat.stream_complete",
102
+ data: { message_id: str(frame.messageId), plaintext: str(m.plaintext) },
103
+ },
104
+ ];
105
+
106
+ case "TEXT_MESSAGE_CHUNK":
107
+ // Only the USER variant is canopy's `chat.user_message` — a human typing
108
+ // into emdash rather than into this page. An assistant chunk would be a
109
+ // whole reply arriving at once, which canopy's runner does not produce;
110
+ // mapping it to `stream_complete` with no preceding `stream_start` would
111
+ // hand the reducer a completion for a message it never opened.
112
+ if (str(frame.role) !== "user") return [];
113
+ return [
114
+ {
115
+ event: "chat.user_message",
116
+ data: {
117
+ message_id: str(frame.messageId),
118
+ turn_index: num(m.turn_index),
119
+ plaintext: str(frame.delta),
120
+ },
121
+ },
122
+ ];
123
+
124
+ case "TOOL_CALL_START":
125
+ // canopy carries the tool's payload on one frame; AG-UI streams the
126
+ // arguments on the ARGS event that follows. So nothing is emitted until
127
+ // ARGS arrives — a row rendered on START would have no input, which is
128
+ // the half-empty-tool-call bug the ACP notes warn about.
129
+ pendingCalls.set(str(frame.toolCallId), {
130
+ name: str(frame.toolCallName),
131
+ parentMessageId: str(frame.parentMessageId),
132
+ turnIndex: num(m.turn_index),
133
+ block: m.block as Record<string, unknown> | undefined,
134
+ });
135
+ return [];
136
+
137
+ case "TOOL_CALL_ARGS": {
138
+ const id = str(frame.toolCallId);
139
+ const pending = pendingCalls.get(id);
140
+ if (!pending) return [];
141
+ let input: unknown = {};
142
+ try {
143
+ input = JSON.parse(str(frame.delta) || "{}");
144
+ } catch {
145
+ // A fragment that is not valid JSON on its own is legal in AG-UI. The
146
+ // server sends arguments whole, so this means genuinely malformed
147
+ // input, and an empty object is a better row than a dropped one.
148
+ input = {};
149
+ }
150
+ // The runner's own payload where we have it, verbatim. canopy passes the
151
+ // block straight through from the transcript and never interprets it, so
152
+ // its shape belongs to the PRODUCER — reconstructing one from the AG-UI
153
+ // fields would invent a `type` and an `id` the real payload may not have
154
+ // carried, and hand the renderer something no runner ever sent.
155
+ const block = pending.block ?? { name: pending.name, input };
156
+ return [
157
+ {
158
+ event: "chat.tool_use",
159
+ data: {
160
+ parent_message_id: pending.parentMessageId || null,
161
+ tool_message_id: id,
162
+ turn_index: pending.turnIndex,
163
+ block,
164
+ },
165
+ },
166
+ ];
167
+ }
168
+
169
+ case "TOOL_CALL_END":
170
+ pendingCalls.delete(str(frame.toolCallId));
171
+ return [];
172
+
173
+ case "TOOL_CALL_RESULT":
174
+ return [
175
+ {
176
+ event: "chat.tool_result",
177
+ data: {
178
+ parent_message_id: (m.parent_message_id as string | undefined) ?? null,
179
+ tool_message_id: str(frame.toolCallId),
180
+ turn_index: num(m.turn_index),
181
+ block: (m.block as Record<string, unknown> | undefined) ?? { content: frame.content },
182
+ },
183
+ },
184
+ ];
185
+
186
+ case "ACTIVITY_SNAPSHOT": {
187
+ const content = (frame.content as Record<string, unknown>) ?? {};
188
+ const state = str(content.state);
189
+ if (!state) return [];
190
+ return [{ event: "session.activity", data: { state: state as "working" | "idle" | "blocked" } }];
191
+ }
192
+
193
+ case "RUN_FINISHED": {
194
+ const outcome = frame.outcome as Record<string, unknown> | undefined;
195
+ if (outcome?.type === "interrupt") {
196
+ const interrupts = (outcome.interrupts as Array<Record<string, unknown>>) ?? [];
197
+ return interrupts.map((i) => ({
198
+ event: "session.menu" as const,
199
+ data: { menu: interruptToMenu(i) },
200
+ }));
201
+ }
202
+ const result = frame.result as Record<string, unknown> | undefined;
203
+ if (result?.cancelled) {
204
+ return [
205
+ {
206
+ event: "chat.stream_cancelled",
207
+ data: { message_id: null, partial_len: num(result.partial_len) },
208
+ },
209
+ ];
210
+ }
211
+ return [];
212
+ }
213
+
214
+ case "RUN_ERROR":
215
+ return [
216
+ {
217
+ event: "session.error",
218
+ data: { code: str(frame.code) || "run_error", message: str(frame.message) },
219
+ },
220
+ ];
221
+
222
+ case "STATE_DELTA": {
223
+ // Only the one patch canopy sends. A general JSON-Patch applier would be
224
+ // a second source of truth for session state, which is the reducer's job.
225
+ const ops = (frame.delta as Array<Record<string, unknown>>) ?? [];
226
+ const title = ops.find((op) => op.path === "/title");
227
+ if (!title) return [];
228
+ return [{ event: "session.title_updated", data: { title: str(title.value) } }];
229
+ }
230
+
231
+ case "CUSTOM": {
232
+ // canopy's own vocabulary, coming home. Drafts, presence and placement
233
+ // have no AG-UI spelling because the protocol models one user and one
234
+ // agent; they were namespaced on the way out and are unwrapped here.
235
+ const name = str(frame.name);
236
+ if (!name.startsWith(CUSTOM_PREFIX)) return [];
237
+ const event = name.slice(CUSTOM_PREFIX.length);
238
+ if (event === "menu.retracted") return [{ event: "session.menu", data: { menu: null } }];
239
+ if (event === "menu") {
240
+ return [{ event: "session.menu", data: { menu: frame.value as never } }];
241
+ }
242
+ return [{ event, data: frame.value } as unknown as WsEvent];
243
+ }
244
+
245
+ default:
246
+ return [];
247
+ }
248
+ }
249
+
250
+ /** Arguments arrive on a later event than the tool's name, so the head of a
251
+ * call is held until they do. Module-level because a socket is a stream and
252
+ * the pairing spans events; `resetAguiState` exists so a test — and a
253
+ * reconnect — can start from nothing rather than inheriting a half-read call
254
+ * from the connection before. */
255
+ const pendingCalls = new Map<
256
+ string,
257
+ {
258
+ name: string;
259
+ parentMessageId: string;
260
+ turnIndex: number;
261
+ block?: Record<string, unknown>;
262
+ }
263
+ >();
264
+
265
+ export function resetAguiState(): void {
266
+ pendingCalls.clear();
267
+ }
268
+
269
+ /** An AG-UI interrupt back into canopy's menu shape.
270
+ *
271
+ * The fields line up because the protocol independently arrived at the same
272
+ * ones: `expiresAt` is canopy's `observed_at` (a dialog lives on a terminal
273
+ * and this is a copy, so it has to carry its own age), and the option list
274
+ * lives in `metadata.canopy.questions` because a response schema can say a
275
+ * field takes a list but not that the TUI draws it as checkboxes a number key
276
+ * TOGGLES rather than answers. */
277
+ function interruptToMenu(interrupt: Record<string, unknown>): SessionMenu {
278
+ const m = (interrupt.metadata as Record<string, unknown> | undefined)?.[METADATA_KEY] as
279
+ | Record<string, unknown>
280
+ | undefined;
281
+ const questions = (m?.questions as Array<Record<string, unknown>>) ?? [];
282
+ const first = questions[0] ?? {};
283
+ return {
284
+ question: str(first.question) || str(interrupt.message),
285
+ title: str(interrupt.message),
286
+ body: str(m?.body),
287
+ source: str(interrupt.reason),
288
+ questions: questions as unknown as SessionMenu["questions"],
289
+ options: (first.options as unknown as SessionMenu["options"]) ?? [],
290
+ answer_error: str(m?.answer_error) || undefined,
291
+ answer_note: str(m?.answer_note) || undefined,
292
+ restored: Boolean(m?.restored),
293
+ observed_at: interrupt.expiresAt ? Date.parse(str(interrupt.expiresAt)) / 1000 : undefined,
294
+ };
295
+ }
package/src/chat/index.ts CHANGED
@@ -17,6 +17,10 @@ export type {
17
17
  WsEvent,
18
18
  } from "./protocol";
19
19
 
20
+ // REST <-> kit conversion. Shared because both hosts that read a transcript over
21
+ // REST were writing this out by hand, against this kit's own Message type.
22
+ export { restToKitMessage, type RestMessage } from "./restMessage";
23
+
20
24
  // Reducer (pure)
21
25
  export { sessionReducer } from "./sessionReducer";
22
26
  export { prependHistory } from "./history";
@@ -67,3 +71,6 @@ export {
67
71
  type PlacementBannerProps,
68
72
  type PlacementRunner,
69
73
  } from "./PlacementBanner";
74
+ // The AG-UI projection's inverse. Exported so a consumer can translate a stream
75
+ // it obtained some other way — and so the round-trip test can reach it.
76
+ export { fromAgui, resetAguiState, CUSTOM_PREFIX, METADATA_KEY } from "./agui";
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { prependHistory } from "./history";
4
+ import { restToKitMessage, type RestMessage } from "./restMessage";
5
+ import type { Message } from "./protocol";
6
+
7
+ const row: RestMessage = {
8
+ turn_index: 7,
9
+ role: "assistant",
10
+ content: { text: "hello" },
11
+ plaintext: "hello",
12
+ created_at: "2026-09-16T10:00:00Z",
13
+ };
14
+
15
+ describe("restToKitMessage", () => {
16
+ it("maps a REST row onto the kit's Message shape", () => {
17
+ expect(restToKitMessage(row)).toEqual({
18
+ id: "t7",
19
+ turn_index: 7,
20
+ role: "assistant",
21
+ content: { text: "hello" },
22
+ plaintext: "hello",
23
+ status: "complete",
24
+ error_detail: null,
25
+ started_at: null,
26
+ completed_at: "2026-09-16T10:00:00Z",
27
+ created_at: "2026-09-16T10:00:00Z",
28
+ });
29
+ });
30
+
31
+ it("gives a row read back from REST no streaming history", () => {
32
+ // A REST row was never watched arriving, so claiming a `started_at` would
33
+ // invent a fact. `completed_at` is what the server does know.
34
+ const m = restToKitMessage(row);
35
+ expect(m.started_at).toBeNull();
36
+ expect(m.completed_at).toBe(row.created_at);
37
+ });
38
+
39
+ it("accepts a readonly generated row (what both hosts actually pass)", () => {
40
+ // openapi-typescript emits every field `readonly`. Property readonly-ness
41
+ // does not affect assignability, and this asserts that stays true — it is
42
+ // the reason `RestMessage` can be declared structurally instead of as
43
+ // either host's generated type.
44
+ const generated: { readonly [K in keyof RestMessage]: RestMessage[K] } = row;
45
+ expect(restToKitMessage(generated).id).toBe("t7");
46
+ });
47
+
48
+ it("does not collide with the live WS row for the same turn", () => {
49
+ // The synthetic `t<turn_index>` id is only safe because prependHistory
50
+ // dedupes on turn_index. If that ever changed to dedupe on id, this fails.
51
+ const live: Message = { ...restToKitMessage(row), id: "1234", status: "streaming" };
52
+ const merged = prependHistory([live], [restToKitMessage(row)]);
53
+ expect(merged).toHaveLength(1);
54
+ expect(merged[0].id).toBe("1234");
55
+ });
56
+ });