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.
- package/package.json +1 -1
- package/src/chat/agui.fixture.json +580 -0
- package/src/chat/agui.test.ts +302 -0
- package/src/chat/agui.ts +295 -0
- package/src/chat/index.ts +7 -0
- package/src/chat/restMessage.test.ts +56 -0
- package/src/chat/restMessage.ts +54 -0
- package/src/chat/useSessionSocket.protocol.test.tsx +124 -0
- package/src/chat/useSessionSocket.ts +113 -3
- package/src/shell/WorkbenchNavItem.tsx +3 -1
- package/src/ui/button.tsx +5 -0
- package/src/ui/input.tsx +3 -1
- package/src/ui/tabs.tsx +11 -2
|
@@ -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
|
+
})
|
package/src/chat/agui.ts
ADDED
|
@@ -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
|
+
});
|