canopy-ui 0.15.3 → 0.16.1
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/LICENSE
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Dimagi Inc., and individual contributors.
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/package.json
CHANGED
|
@@ -50,7 +50,7 @@ export function ToolCallPair({ use, result, forceOpen }: Props) {
|
|
|
50
50
|
{name}
|
|
51
51
|
</span>
|
|
52
52
|
{preview && (
|
|
53
|
-
<span className="truncate text-xs
|
|
53
|
+
<span className="truncate font-mono text-xs text-muted-foreground">
|
|
54
54
|
· {preview}
|
|
55
55
|
</span>
|
|
56
56
|
)}
|
|
@@ -0,0 +1,75 @@
|
|
|
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 } from './useSessionSocket'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* An async URL builder: a fresh one-time ticket per connection, so a token
|
|
9
|
+
* never rides the socket URL (URLs land in access logs). A ticket works once,
|
|
10
|
+
* so a reconnect must ask again rather than reuse the first URL.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
class FakeSocket {
|
|
14
|
+
static opened: FakeSocket[] = []
|
|
15
|
+
onopen: (() => void) | null = null
|
|
16
|
+
onmessage: ((e: { data: string }) => void) | null = null
|
|
17
|
+
onclose: (() => void) | null = null
|
|
18
|
+
onerror: (() => void) | null = null
|
|
19
|
+
readyState = 0
|
|
20
|
+
constructor(public url: string) {
|
|
21
|
+
FakeSocket.opened.push(this)
|
|
22
|
+
}
|
|
23
|
+
send() {}
|
|
24
|
+
close() {}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
FakeSocket.opened = []
|
|
29
|
+
vi.stubGlobal('WebSocket', FakeSocket)
|
|
30
|
+
vi.useFakeTimers()
|
|
31
|
+
})
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.useRealTimers()
|
|
34
|
+
vi.unstubAllGlobals()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('an async socket URL', () => {
|
|
38
|
+
it('opens the socket once the ticket arrives, and asks again on reconnect', async () => {
|
|
39
|
+
let n = 0
|
|
40
|
+
const wsUrl = vi.fn(async () => `wss://h/ws/canopy-sessions/s1/?ticket=t${++n}`)
|
|
41
|
+
renderHook(() => useSessionSocket({ sessionId: 's1', wsUrl }))
|
|
42
|
+
await act(async () => { await Promise.resolve() })
|
|
43
|
+
expect(FakeSocket.opened.map((s) => s.url)).toEqual(['wss://h/ws/canopy-sessions/s1/?ticket=t1'])
|
|
44
|
+
|
|
45
|
+
await act(async () => {
|
|
46
|
+
FakeSocket.opened[0].onclose?.()
|
|
47
|
+
await vi.runAllTimersAsync()
|
|
48
|
+
})
|
|
49
|
+
expect(FakeSocket.opened.map((s) => s.url)).toEqual([
|
|
50
|
+
'wss://h/ws/canopy-sessions/s1/?ticket=t1',
|
|
51
|
+
'wss://h/ws/canopy-sessions/s1/?ticket=t2',
|
|
52
|
+
])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('opens nothing for a session it has already left', async () => {
|
|
56
|
+
let release: (url: string) => void = () => {}
|
|
57
|
+
const wsUrl = () => new Promise<string>((resolve) => { release = resolve })
|
|
58
|
+
const { unmount } = renderHook(() => useSessionSocket({ sessionId: 's1', wsUrl }))
|
|
59
|
+
unmount()
|
|
60
|
+
await act(async () => { release('wss://h/late'); await Promise.resolve() })
|
|
61
|
+
expect(FakeSocket.opened).toHaveLength(0)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('retries when fetching the ticket fails', async () => {
|
|
65
|
+
let calls = 0
|
|
66
|
+
const wsUrl = async () => {
|
|
67
|
+
calls += 1
|
|
68
|
+
if (calls === 1) throw new Error('network')
|
|
69
|
+
return 'wss://h/ok'
|
|
70
|
+
}
|
|
71
|
+
renderHook(() => useSessionSocket({ sessionId: 's1', wsUrl }))
|
|
72
|
+
await act(async () => { await vi.runAllTimersAsync() })
|
|
73
|
+
expect(FakeSocket.opened.map((s) => s.url)).toEqual(['wss://h/ok'])
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -69,8 +69,13 @@ export interface UseSessionSocketOptions {
|
|
|
69
69
|
* App-injected WebSocket URL builder. The kit never imports app routing/base
|
|
70
70
|
* helpers; the container passes one (e.g. canopy's `wsUrl`). Called with the
|
|
71
71
|
* relative path `ws/canopy-sessions/${sessionId}/`.
|
|
72
|
+
*
|
|
73
|
+
* May return a Promise: it is called before EVERY connection, reconnects
|
|
74
|
+
* included, so a container can fetch a one-time ticket each time and keep
|
|
75
|
+
* its token off the URL (canopy-client's `sessionSocketTicketUrl`). A
|
|
76
|
+
* rejected promise is treated like a dropped connection and retried.
|
|
72
77
|
*/
|
|
73
|
-
wsUrl: (path: string) => string
|
|
78
|
+
wsUrl: (path: string) => string | Promise<string>;
|
|
74
79
|
/**
|
|
75
80
|
* Optional side-effect callback fired when the server broadcasts a
|
|
76
81
|
* `session.title_updated` (replaces ace's `notifySessionsUpdated`). The kit
|
|
@@ -238,6 +243,7 @@ export function useSessionSocket({
|
|
|
238
243
|
const draftDebounceRef = useRef<number | null>(null);
|
|
239
244
|
const pendingDraftBodyRef = useRef<string | null>(null);
|
|
240
245
|
const closedByUserRef = useRef(false);
|
|
246
|
+
const generationRef = useRef(0);
|
|
241
247
|
const onTitleUpdatedRef = useRef(onTitleUpdated);
|
|
242
248
|
const onUnknownEventRef = useRef(onUnknownEvent);
|
|
243
249
|
// A ref, like the callbacks above: `connect` is a stable callback with empty
|
|
@@ -426,11 +432,36 @@ export function useSessionSocket({
|
|
|
426
432
|
|
|
427
433
|
const connect = useCallback(() => {
|
|
428
434
|
if (closedByUserRef.current) return;
|
|
435
|
+
// Which mount this attempt belongs to. An async URL may resolve after the
|
|
436
|
+
// hook re-mounted for another session; it must not open a socket then.
|
|
437
|
+
const generation = generationRef.current;
|
|
429
438
|
// A half-read tool call from the previous connection must not be completed
|
|
430
439
|
// by an ARGS event from this one — the ids are per-stream.
|
|
431
440
|
resetAguiState();
|
|
432
441
|
const path = `ws/canopy-sessions/${sessionId}/`;
|
|
433
442
|
const built = wsUrl(path);
|
|
443
|
+
if (typeof built !== "string") {
|
|
444
|
+
// An async builder (a fresh one-time ticket per connection). If it fails,
|
|
445
|
+
// retry on the same backoff a dropped socket uses; if the hook was torn
|
|
446
|
+
// down meanwhile, open nothing.
|
|
447
|
+
built.then(
|
|
448
|
+
(url) => {
|
|
449
|
+
if (!closedByUserRef.current && generation === generationRef.current) openSocket(url);
|
|
450
|
+
},
|
|
451
|
+
() => {
|
|
452
|
+
if (closedByUserRef.current || generation !== generationRef.current) return;
|
|
453
|
+
const attempt = reconnectAttemptRef.current;
|
|
454
|
+
reconnectAttemptRef.current = attempt + 1;
|
|
455
|
+
window.setTimeout(connect,
|
|
456
|
+
RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)]);
|
|
457
|
+
},
|
|
458
|
+
);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
openSocket(built);
|
|
462
|
+
|
|
463
|
+
// Declared inside `connect` so it shares exactly this render's handlers.
|
|
464
|
+
function openSocket(built: string) {
|
|
434
465
|
const ws = new WebSocket(
|
|
435
466
|
protocolRef.current === "ag-ui" ? withAguiProtocol(built) : built,
|
|
436
467
|
);
|
|
@@ -506,6 +537,7 @@ export function useSessionSocket({
|
|
|
506
537
|
ws.onerror = () => {
|
|
507
538
|
// onclose will fire next; nothing to do here.
|
|
508
539
|
};
|
|
540
|
+
}
|
|
509
541
|
}, [applyEvent, rescueSend, send, sessionId, wsUrl]);
|
|
510
542
|
|
|
511
543
|
useEffect(() => {
|
|
@@ -513,6 +545,7 @@ export function useSessionSocket({
|
|
|
513
545
|
reconnectAttemptRef.current = 0;
|
|
514
546
|
connect();
|
|
515
547
|
return () => {
|
|
548
|
+
generationRef.current += 1;
|
|
516
549
|
closedByUserRef.current = true;
|
|
517
550
|
if (heartbeatTimerRef.current != null) {
|
|
518
551
|
window.clearInterval(heartbeatTimerRef.current);
|