canopy-ui 0.8.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canopy-ui",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -231,5 +231,350 @@
231
231
  }
232
232
  }
233
233
  ]
234
+ },
235
+ {
236
+ "canopy": {
237
+ "event": "session.state",
238
+ "data": {
239
+ "messages": [
240
+ {
241
+ "id": "41",
242
+ "turn_index": 0,
243
+ "role": "user",
244
+ "content": {},
245
+ "plaintext": "what is stale?",
246
+ "status": "complete",
247
+ "error_detail": null,
248
+ "started_at": null,
249
+ "completed_at": null,
250
+ "created_at": "2026-09-18T12:00:00+00:00"
251
+ },
252
+ {
253
+ "id": "42",
254
+ "turn_index": 1,
255
+ "role": "assistant",
256
+ "content": {},
257
+ "plaintext": "Three insights are.",
258
+ "status": "complete",
259
+ "error_detail": null,
260
+ "started_at": null,
261
+ "completed_at": null,
262
+ "created_at": "2026-09-18T12:00:05+00:00"
263
+ }
264
+ ],
265
+ "active_draft": null,
266
+ "participants": [
267
+ {
268
+ "user_id": 7,
269
+ "role": "editor"
270
+ }
271
+ ],
272
+ "presence_user_ids": [
273
+ 7
274
+ ],
275
+ "current_user_id": 7,
276
+ "menu": null
277
+ }
278
+ },
279
+ "agui": [
280
+ {
281
+ "metadata": {
282
+ "canopy": {
283
+ "frame": {
284
+ "event": "session.state",
285
+ "data": {
286
+ "messages": [
287
+ {
288
+ "id": "41",
289
+ "turn_index": 0,
290
+ "role": "user",
291
+ "content": {},
292
+ "plaintext": "what is stale?",
293
+ "status": "complete",
294
+ "error_detail": null,
295
+ "started_at": null,
296
+ "completed_at": null,
297
+ "created_at": "2026-09-18T12:00:00+00:00"
298
+ },
299
+ {
300
+ "id": "42",
301
+ "turn_index": 1,
302
+ "role": "assistant",
303
+ "content": {},
304
+ "plaintext": "Three insights are.",
305
+ "status": "complete",
306
+ "error_detail": null,
307
+ "started_at": null,
308
+ "completed_at": null,
309
+ "created_at": "2026-09-18T12:00:05+00:00"
310
+ }
311
+ ],
312
+ "active_draft": null,
313
+ "participants": [
314
+ {
315
+ "user_id": 7,
316
+ "role": "editor"
317
+ }
318
+ ],
319
+ "presence_user_ids": [
320
+ 7
321
+ ],
322
+ "current_user_id": 7,
323
+ "menu": null
324
+ }
325
+ }
326
+ }
327
+ },
328
+ "type": "MESSAGES_SNAPSHOT",
329
+ "messages": [
330
+ {
331
+ "id": "41",
332
+ "role": "user",
333
+ "content": "what is stale?"
334
+ },
335
+ {
336
+ "id": "42",
337
+ "role": "assistant",
338
+ "content": "Three insights are."
339
+ }
340
+ ]
341
+ }
342
+ ]
343
+ },
344
+ {
345
+ "canopy": {
346
+ "event": "session.page_action",
347
+ "data": {
348
+ "id": "9f1c",
349
+ "name": "scrollToRow",
350
+ "args": {
351
+ "id": 4471
352
+ }
353
+ }
354
+ },
355
+ "agui": [
356
+ {
357
+ "type": "CUSTOM",
358
+ "name": "canopy.session.page_action",
359
+ "value": {
360
+ "id": "9f1c",
361
+ "name": "scrollToRow",
362
+ "args": {
363
+ "id": 4471
364
+ }
365
+ }
366
+ }
367
+ ]
368
+ },
369
+ {
370
+ "canopy": {
371
+ "event": "session.stop",
372
+ "data": {
373
+ "state": "failed"
374
+ }
375
+ },
376
+ "agui": [
377
+ {
378
+ "type": "CUSTOM",
379
+ "name": "canopy.session.stop",
380
+ "value": {
381
+ "state": "failed"
382
+ }
383
+ }
384
+ ]
385
+ },
386
+ {
387
+ "canopy": {
388
+ "event": "session.activity",
389
+ "data": {
390
+ "state": "working"
391
+ }
392
+ },
393
+ "agui": [
394
+ {
395
+ "type": "ACTIVITY_SNAPSHOT",
396
+ "messageId": "t1",
397
+ "activityType": "canopy.session",
398
+ "content": {
399
+ "state": "working"
400
+ },
401
+ "replace": true
402
+ }
403
+ ]
404
+ },
405
+ {
406
+ "canopy": {
407
+ "event": "session.menu",
408
+ "data": {
409
+ "menu": {
410
+ "source": "hook",
411
+ "question": "Proceed?",
412
+ "observed_at": 1758196800,
413
+ "options": [
414
+ {
415
+ "label": "Yes"
416
+ },
417
+ {
418
+ "label": "No"
419
+ }
420
+ ]
421
+ }
422
+ }
423
+ },
424
+ "agui": [
425
+ {
426
+ "type": "CUSTOM",
427
+ "name": "canopy.menu",
428
+ "value": {
429
+ "source": "hook",
430
+ "question": "Proceed?",
431
+ "observed_at": 1758196800,
432
+ "options": [
433
+ {
434
+ "label": "Yes"
435
+ },
436
+ {
437
+ "label": "No"
438
+ }
439
+ ]
440
+ }
441
+ }
442
+ ]
443
+ },
444
+ {
445
+ "canopy": {
446
+ "event": "session.error",
447
+ "data": {
448
+ "code": "draft_conflict",
449
+ "message": "stale version"
450
+ }
451
+ },
452
+ "agui": [
453
+ {
454
+ "type": "RUN_ERROR",
455
+ "message": "stale version",
456
+ "code": "draft_conflict"
457
+ }
458
+ ]
459
+ },
460
+ {
461
+ "canopy": {
462
+ "event": "chat.stream_cancelled",
463
+ "data": {
464
+ "message_id": "m1",
465
+ "partial_len": 12
466
+ }
467
+ },
468
+ "agui": [
469
+ {
470
+ "type": "CUSTOM",
471
+ "name": "canopy.chat.stream_cancelled",
472
+ "value": {
473
+ "message_id": "m1",
474
+ "partial_len": 12
475
+ }
476
+ }
477
+ ]
478
+ },
479
+ {
480
+ "canopy": {
481
+ "event": "chat.stream_error",
482
+ "data": {
483
+ "message_id": "m1",
484
+ "detail": "runner went away"
485
+ }
486
+ },
487
+ "agui": [
488
+ {
489
+ "metadata": {
490
+ "canopy": {
491
+ "frame": {
492
+ "event": "chat.stream_error",
493
+ "data": {
494
+ "message_id": "m1",
495
+ "detail": "runner went away"
496
+ }
497
+ }
498
+ }
499
+ },
500
+ "type": "RUN_ERROR",
501
+ "message": "runner went away",
502
+ "code": "stream_error"
503
+ }
504
+ ]
505
+ },
506
+ {
507
+ "canopy": {
508
+ "event": "draft.committed",
509
+ "data": {
510
+ "draft_id": "d1",
511
+ "user_message_id": "u2"
512
+ }
513
+ },
514
+ "agui": [
515
+ {
516
+ "type": "CUSTOM",
517
+ "name": "canopy.draft.committed",
518
+ "value": {
519
+ "draft_id": "d1",
520
+ "user_message_id": "u2"
521
+ }
522
+ }
523
+ ]
524
+ },
525
+ {
526
+ "canopy": {
527
+ "event": "draft.discarded",
528
+ "data": {
529
+ "draft_id": "d1"
530
+ }
531
+ },
532
+ "agui": [
533
+ {
534
+ "type": "CUSTOM",
535
+ "name": "canopy.draft.discarded",
536
+ "value": {
537
+ "draft_id": "d1"
538
+ }
539
+ }
540
+ ]
541
+ },
542
+ {
543
+ "canopy": {
544
+ "event": "draft.lock_changed",
545
+ "data": {
546
+ "draft_id": "d1",
547
+ "holder_user_id": 7,
548
+ "expires_at": "2026-09-18T12:01:00+00:00"
549
+ }
550
+ },
551
+ "agui": [
552
+ {
553
+ "type": "CUSTOM",
554
+ "name": "canopy.draft.lock_changed",
555
+ "value": {
556
+ "draft_id": "d1",
557
+ "holder_user_id": 7,
558
+ "expires_at": "2026-09-18T12:01:00+00:00"
559
+ }
560
+ }
561
+ ]
562
+ },
563
+ {
564
+ "canopy": {
565
+ "event": "page.invalidate",
566
+ "data": {
567
+ "uri": "item://"
568
+ }
569
+ },
570
+ "agui": [
571
+ {
572
+ "type": "CUSTOM",
573
+ "name": "canopy.page.invalidate",
574
+ "value": {
575
+ "uri": "item://"
576
+ }
577
+ }
578
+ ]
234
579
  }
235
580
  ]
@@ -277,7 +277,11 @@ describe('the socket actually asks for it', () => {
277
277
  it('is wired to the hook’s public option', () => {
278
278
  const source = hookSource()
279
279
 
280
- expect(source).toContain('protocol=ag-ui')
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`.
281
285
  expect(source).toContain('fromAgui')
282
286
  // And the default stays canopy's own vocabulary, which is what lets an
283
287
  // existing consumer — ace-web installs this package from npm — notice
package/src/chat/agui.ts CHANGED
@@ -65,6 +65,19 @@ export function fromAgui(frame: AguiFrame): WsEvent[] {
65
65
  const type = typeof frame.type === "string" ? frame.type : "";
66
66
  const m = meta(frame);
67
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
+
68
81
  switch (type) {
69
82
  case "TEXT_MESSAGE_START":
70
83
  return [
@@ -0,0 +1,124 @@
1
+ // @vitest-environment jsdom
2
+ import { act, renderHook } from '@testing-library/react'
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import { useSessionSocket, withAguiProtocol } from './useSessionSocket'
6
+
7
+ /**
8
+ * What the socket REALLY opens, and what it does with what comes back.
9
+ *
10
+ * This replaced an assertion that the hook's source contained `protocol=ag-ui`.
11
+ * That was true the whole time the flag was being dropped: the hook put it on
12
+ * the PATH it handed the caller's URL builder, and two of the first three
13
+ * builders ignore their path. Only opening a socket and reading its URL can see
14
+ * that — so that is what these do.
15
+ */
16
+
17
+ class FakeSocket {
18
+ static opened: FakeSocket[] = []
19
+ onopen: (() => void) | null = null
20
+ onmessage: ((e: { data: string }) => void) | null = null
21
+ onclose: (() => void) | null = null
22
+ onerror: (() => void) | null = null
23
+ readyState = 0
24
+ constructor(public url: string) {
25
+ FakeSocket.opened.push(this)
26
+ }
27
+ send() {}
28
+ close() {}
29
+ receive(frame: unknown) {
30
+ this.onmessage?.({ data: JSON.stringify(frame) })
31
+ }
32
+ }
33
+
34
+ beforeEach(() => {
35
+ FakeSocket.opened = []
36
+ vi.stubGlobal('WebSocket', FakeSocket)
37
+ })
38
+ afterEach(() => vi.unstubAllGlobals())
39
+
40
+ function lastUrl(): string {
41
+ return FakeSocket.opened.at(-1)!.url
42
+ }
43
+
44
+ describe('the flag survives whatever URL the caller builds', () => {
45
+ it('with a builder that uses the path it is given', () => {
46
+ renderHook(() =>
47
+ useSessionSocket({ sessionId: 's1', wsUrl: (p) => `wss://h/${p}`, protocol: 'ag-ui' }),
48
+ )
49
+ expect(lastUrl()).toBe('wss://h/ws/canopy-sessions/s1/?protocol=ag-ui')
50
+ })
51
+
52
+ it('with a builder that IGNORES its path — canopy-web’s widget', () => {
53
+ // `() => client.sessionSocketUrl(sessionId)`: the widget's real shape.
54
+ renderHook(() =>
55
+ useSessionSocket({ sessionId: 's1', wsUrl: () => 'wss://h/ws/canopy-sessions/s1/', protocol: 'ag-ui' }),
56
+ )
57
+ expect(lastUrl()).toContain('protocol=ag-ui')
58
+ })
59
+
60
+ it('with a builder that carries its own token — ace-web', () => {
61
+ // `buildCanopyWsUrl(base, id)` puts a token in the query, so the flag has
62
+ // to JOIN the query rather than start a second one.
63
+ renderHook(() =>
64
+ useSessionSocket({ sessionId: 's1', wsUrl: () => 'wss://h/ws/canopy-sessions/s1/?token=abc', protocol: 'ag-ui' }),
65
+ )
66
+ expect(lastUrl()).toBe('wss://h/ws/canopy-sessions/s1/?token=abc&protocol=ag-ui')
67
+ })
68
+
69
+ it('asks for nothing when the caller asks for nothing', () => {
70
+ // The default is canopy's own frames, which is what an un-upgraded consumer
71
+ // must keep getting.
72
+ renderHook(() => useSessionSocket({ sessionId: 's1', wsUrl: () => 'wss://h/x/?token=abc' }))
73
+ expect(lastUrl()).toBe('wss://h/x/?token=abc')
74
+ })
75
+ })
76
+
77
+ describe('withAguiProtocol', () => {
78
+ it('does not add the flag twice', () => {
79
+ expect(withAguiProtocol('wss://h/x/?protocol=ag-ui')).toBe('wss://h/x/?protocol=ag-ui')
80
+ })
81
+
82
+ it('leaves an empty URL empty — the caller’s "not yet"', () => {
83
+ expect(withAguiProtocol('')).toBe('')
84
+ })
85
+ })
86
+
87
+ describe('a server that answers in canopy frames anyway', () => {
88
+ it('is understood, not decoded to nothing', () => {
89
+ // Asked for AG-UI, got native: an old server, or a flag lost on the way.
90
+ // Decoding these as AG-UI yields zero frames — a blank chat with no error.
91
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
92
+ const onTitleUpdated = vi.fn()
93
+ renderHook(() =>
94
+ useSessionSocket({ sessionId: 's1', wsUrl: (p) => `wss://h/${p}`, protocol: 'ag-ui', onTitleUpdated }),
95
+ )
96
+
97
+ act(() => {
98
+ FakeSocket.opened.at(-1)!.receive({
99
+ event: 'session.title_updated',
100
+ data: { title: 'Still readable' },
101
+ })
102
+ })
103
+
104
+ expect(onTitleUpdated).toHaveBeenCalledTimes(1)
105
+ expect(warn).toHaveBeenCalledTimes(1)
106
+ warn.mockRestore()
107
+ })
108
+
109
+ it('still decodes real AG-UI events as AG-UI', () => {
110
+ const onTitleUpdated = vi.fn()
111
+ renderHook(() =>
112
+ useSessionSocket({ sessionId: 's1', wsUrl: (p) => `wss://h/${p}`, protocol: 'ag-ui', onTitleUpdated }),
113
+ )
114
+
115
+ act(() => {
116
+ FakeSocket.opened.at(-1)!.receive({
117
+ type: 'STATE_DELTA',
118
+ delta: [{ op: 'replace', path: '/title', value: 'Via AG-UI' }],
119
+ })
120
+ })
121
+
122
+ expect(onTitleUpdated).toHaveBeenCalledTimes(1)
123
+ })
124
+ })
@@ -58,6 +58,27 @@ export interface UseSessionSocketOptions {
58
58
  protocol?: "canopy" | "ag-ui";
59
59
  }
60
60
 
61
+ /**
62
+ * Ask for AG-UI on the URL the caller built — whatever it looks like.
63
+ *
64
+ * The hook OWNS this flag rather than handing the caller a path with it already
65
+ * attached, because that contract was invisible and two of the first three
66
+ * callers broke it: canopy-web's widget builds `client.sessionSocketUrl(id)` and
67
+ * ace-web builds `buildCanopyWsUrl(base, id)`, and both ignore the path they are
68
+ * given (they need their own token in the query). The flag was dropped, the
69
+ * server answered in native frames, and every one of them decoded as AG-UI to
70
+ * nothing — a blank chat with no error, caught only in review before it shipped.
71
+ *
72
+ * Exported for the test, which asserts on the URL a socket really opens.
73
+ */
74
+ export function withAguiProtocol(url: string): string {
75
+ // Empty is a caller saying "no URL yet" (the widget before it has a
76
+ // session); decorating it would turn a deliberate no-op into a bad request.
77
+ if (!url) return url;
78
+ if (/[?&]protocol=/.test(url)) return url;
79
+ return `${url}${url.includes("?") ? "&" : "?"}protocol=ag-ui`;
80
+ }
81
+
61
82
  export interface UseSessionSocketResult {
62
83
  state: SessionState;
63
84
  connected: boolean;
@@ -121,6 +142,7 @@ export function useSessionSocket({
121
142
  // vocabulary mid-session.
122
143
  const protocolRef = useRef(protocol);
123
144
  protocolRef.current = protocol;
145
+ const warnedNativeRef = useRef(false);
124
146
  // Control frames that must not be lost across a reconnect (currently
125
147
  // only chat.stop). The WS-world analogue of an abortable chat transport.
126
148
  const pendingFramesRef = useRef<{ action: string; data: unknown }[]>([]);
@@ -202,8 +224,9 @@ export function useSessionSocket({
202
224
  // by an ARGS event from this one — the ids are per-stream.
203
225
  resetAguiState();
204
226
  const path = `ws/canopy-sessions/${sessionId}/`;
227
+ const built = wsUrl(path);
205
228
  const ws = new WebSocket(
206
- wsUrl(protocolRef.current === "ag-ui" ? `${path}?protocol=ag-ui` : path),
229
+ protocolRef.current === "ag-ui" ? withAguiProtocol(built) : built,
207
230
  );
208
231
  socketRef.current = ws;
209
232
 
@@ -229,6 +252,22 @@ export function useSessionSocket({
229
252
  try {
230
253
  const raw = JSON.parse(e.data);
231
254
  if (protocolRef.current === "ag-ui") {
255
+ // We asked for AG-UI and the server answered in canopy's own frames:
256
+ // a server that predates the negotiation, or a URL that lost the flag
257
+ // on the way (see `withAguiProtocol`). The two are unambiguous — every
258
+ // AG-UI event has a `type`, and no canopy frame does — so apply it as
259
+ // what it is. Decoding it as AG-UI yields nothing, which is a blank
260
+ // chat with no error: the worst possible way to find out.
261
+ if (typeof raw?.type !== "string" && typeof raw?.event === "string") {
262
+ if (!warnedNativeRef.current) {
263
+ warnedNativeRef.current = true;
264
+ console.warn(
265
+ "canopy-ui: asked for protocol=ag-ui but the server sent canopy frames; handling them as canopy frames.",
266
+ );
267
+ }
268
+ applyEvent(raw as WsEvent);
269
+ return;
270
+ }
232
271
  // One AG-UI event can be several canopy frames (a tool call is three
233
272
  // events) or none, so this is a fan-out rather than a rename.
234
273
  for (const frame of fromAgui(raw)) applyEvent(frame);