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
|
@@ -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
|
]
|
package/src/chat/agui.test.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|