canopy-ui 0.10.0 → 0.12.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/MessageItem.tsx +11 -0
- package/src/chat/protocol.ts +3 -1
- package/src/chat/sessionReducer.events.test.ts +17 -0
- package/src/chat/sessionReducer.ts +120 -2
- package/src/chat/useSessionSocket.http.test.tsx +85 -0
- package/src/chat/useSessionSocket.receipt.test.tsx +161 -0
- package/src/chat/useSessionSocket.ts +136 -39
package/package.json
CHANGED
package/src/chat/MessageItem.tsx
CHANGED
|
@@ -136,6 +136,17 @@ export function MessageItem({
|
|
|
136
136
|
{isError && message.role === "assistant" && (
|
|
137
137
|
<ErrorFooter detail={message.error_detail} hasPartial={Boolean(text)} />
|
|
138
138
|
)}
|
|
139
|
+
{/* Your own line, before canopy has confirmed it. Shown at once so a send
|
|
140
|
+
never looks like it vanished; flagged plainly if it did not go. */}
|
|
141
|
+
{message.role === "user" && isPending && (
|
|
142
|
+
<div className="mt-1 text-[11px] opacity-70" role="status">Sending…</div>
|
|
143
|
+
)}
|
|
144
|
+
{message.role === "user" && isError && (
|
|
145
|
+
<div className="mt-1 flex items-start gap-1 text-[11px] font-medium" role="alert">
|
|
146
|
+
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" aria-hidden="true" />
|
|
147
|
+
<span>{message.error_detail || "Not sent."}</span>
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
139
150
|
</div>
|
|
140
151
|
);
|
|
141
152
|
}
|
package/src/chat/protocol.ts
CHANGED
|
@@ -251,7 +251,9 @@ export type WsEvent =
|
|
|
251
251
|
| { event: "chat.stream_cancelled"; data: { message_id: string | null; partial_len: number } }
|
|
252
252
|
| { event: "draft.updated"; data: Draft }
|
|
253
253
|
| { event: "draft.lock_changed"; data: { draft_id: string; holder_user_id: number | null; expires_at: number | null } }
|
|
254
|
-
|
|
254
|
+
// `client_id` is the sender's receipt for a send that carried one (see
|
|
255
|
+
// useSessionSocket.sendChat); absent from older servers.
|
|
256
|
+
| { event: "draft.committed"; data: { draft_id: string; user_message_id: string; client_id?: string } }
|
|
255
257
|
| { event: "draft.discarded"; data: { draft_id: string } }
|
|
256
258
|
// `participant` carries WHO joined, so a client can add them to its
|
|
257
259
|
// participant list. Without it a first-time joiner has an id and no name,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { REDUCER_EVENTS } from "./sessionReducer";
|
|
6
|
+
|
|
7
|
+
/** The hook passes the reducer only the frames in REDUCER_EVENTS. A case the
|
|
8
|
+
* list lacks is dead code that looks alive, which is how `draft.committed`,
|
|
9
|
+
* `presence.*` and `session.turn_status` went unhandled from 2026-09-13 to
|
|
10
|
+
* 2026-09-23 without a single failing test. */
|
|
11
|
+
describe("REDUCER_EVENTS", () => {
|
|
12
|
+
it("names exactly the frames the reducer's switch handles", () => {
|
|
13
|
+
const src = readFileSync(fileURLToPath(new URL("./sessionReducer.ts", import.meta.url)), "utf8");
|
|
14
|
+
const cases = new Set([...src.matchAll(/case "([a-z_.]+)":/g)].map((m) => m[1]));
|
|
15
|
+
expect([...cases].sort()).toEqual([...REDUCER_EVENTS].sort());
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -27,6 +27,25 @@ const UNBLOCKING_FRAMES = new Set([
|
|
|
27
27
|
"chat.tool_result",
|
|
28
28
|
]);
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Every frame `sessionReducer` handles. The socket hook hands the reducer ONLY
|
|
32
|
+
* these, and gives anything else to `onUnknownEvent`, so a frame missing from
|
|
33
|
+
* here is a frame the reducer never sees. That happened: from #775
|
|
34
|
+
* (2026-09-13) the hook kept its own hand-written list, which lacked every
|
|
35
|
+
* `draft.*` and `presence.*` frame and `session.turn_status`. Your own line did
|
|
36
|
+
* not appear when you sent it (`draft.committed`), and the co-edited draft,
|
|
37
|
+
* the presence roster and the live turn status all went quiet, with no error.
|
|
38
|
+
* `sessionReducer.events.test.ts` fails if this and the switch below differ.
|
|
39
|
+
*/
|
|
40
|
+
export const REDUCER_EVENTS: ReadonlySet<string> = new Set([
|
|
41
|
+
"session.state", "session.activity", "session.stop", "session.menu",
|
|
42
|
+
"session.turn_status", "session.error", "session.title_updated",
|
|
43
|
+
"chat.stream_start", "chat.user_message", "chat.delta", "chat.stream_complete",
|
|
44
|
+
"chat.stream_error", "chat.stream_cancelled", "chat.tool_use", "chat.tool_result",
|
|
45
|
+
"draft.updated", "draft.lock_changed", "draft.committed", "draft.discarded",
|
|
46
|
+
"presence.joined", "presence.left",
|
|
47
|
+
]);
|
|
48
|
+
|
|
30
49
|
export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState {
|
|
31
50
|
if (frame.event === "chat.stream_start" || frame.event === "draft.committed") {
|
|
32
51
|
// A new turn is starting, so the previous turn's stop outcome is history —
|
|
@@ -41,8 +60,10 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
41
60
|
prev = { ...prev, activity: "working", menu: undefined };
|
|
42
61
|
}
|
|
43
62
|
switch (frame.event) {
|
|
44
|
-
case "session.state":
|
|
45
|
-
|
|
63
|
+
case "session.state": {
|
|
64
|
+
const messages = keepUnechoedSends(prev.messages, frame.data.messages);
|
|
65
|
+
return messages === frame.data.messages ? frame.data : { ...frame.data, messages };
|
|
66
|
+
}
|
|
46
67
|
|
|
47
68
|
case "chat.stream_start": {
|
|
48
69
|
// Upsert: if the assistant message already exists (rare — a runner that
|
|
@@ -153,6 +174,8 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
153
174
|
// is the durable one, so the row sorts where a reload puts it.
|
|
154
175
|
turn_index: frame.data.turn_index,
|
|
155
176
|
plaintext: frame.data.plaintext,
|
|
177
|
+
// The agent read it, so a pending or unconfirmed row is sent.
|
|
178
|
+
...(m.role === "user" ? { status: "complete" as const, error_detail: null } : {}),
|
|
156
179
|
}
|
|
157
180
|
: m,
|
|
158
181
|
),
|
|
@@ -333,6 +356,15 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
333
356
|
return prev;
|
|
334
357
|
|
|
335
358
|
case "draft.committed": {
|
|
359
|
+
// The sender already shows its line (a pending row stamped with this
|
|
360
|
+
// client_id, see `withPendingSend`). Confirm it rather than add a second.
|
|
361
|
+
const cid = frame.data.client_id;
|
|
362
|
+
if (cid && prev.messages.some((m) => m.content?.client_id === cid)) {
|
|
363
|
+
return {
|
|
364
|
+
...markSendDelivered(prev, cid, frame.data.user_message_id),
|
|
365
|
+
active_draft: prev.active_draft ? { ...prev.active_draft, body: "" } : prev.active_draft,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
336
368
|
// Insert the optimistic USER message from the draft body that's about
|
|
337
369
|
// to be cleared. The assistant reply is NOT inserted here — canopy's
|
|
338
370
|
// draft.committed carries no assistant id; `chat.stream_start` upserts
|
|
@@ -446,3 +478,89 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
446
478
|
return prev;
|
|
447
479
|
}
|
|
448
480
|
}
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
// A send the SENDER is waiting to hear back about.
|
|
485
|
+
//
|
|
486
|
+
// Your line goes on screen the moment you press send, as "pending", stamped
|
|
487
|
+
// with the send's client_id. It becomes "complete" on the server's receipt
|
|
488
|
+
// (`draft.committed` carrying that id), or when an HTTP resend succeeds, or when
|
|
489
|
+
// the transcript echoes it back. If none of those happen it becomes "error",
|
|
490
|
+
// and the words stay on screen. Before this the composer cleared on send and
|
|
491
|
+
// nothing showed the line until the agent read it, so a send lost on a dead
|
|
492
|
+
// socket simply vanished (2026-09-23).
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
export function withPendingSend(
|
|
496
|
+
prev: SessionState,
|
|
497
|
+
{ clientId, text }: { clientId: string; text: string },
|
|
498
|
+
): SessionState {
|
|
499
|
+
const nowIso = new Date().toISOString();
|
|
500
|
+
const row: Message = {
|
|
501
|
+
id: `local:${clientId}`,
|
|
502
|
+
turn_index: prev.messages.reduce((acc, m) => Math.max(acc, m.turn_index), 0) + 1,
|
|
503
|
+
role: "user",
|
|
504
|
+
content: { text, client_id: clientId },
|
|
505
|
+
plaintext: text,
|
|
506
|
+
status: "pending",
|
|
507
|
+
error_detail: null,
|
|
508
|
+
started_at: null,
|
|
509
|
+
completed_at: null,
|
|
510
|
+
created_at: nowIso,
|
|
511
|
+
};
|
|
512
|
+
return { ...prev, messages: [...prev.messages, row] };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function updateSend(prev: SessionState, clientId: string, patch: Partial<Message>): SessionState {
|
|
516
|
+
let changed = false;
|
|
517
|
+
const messages = prev.messages.map((m) => {
|
|
518
|
+
if (m.content?.client_id !== clientId) return m;
|
|
519
|
+
changed = true;
|
|
520
|
+
return { ...m, ...patch };
|
|
521
|
+
});
|
|
522
|
+
return changed ? { ...prev, messages } : prev;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export function markSendDelivered(prev: SessionState, clientId: string, messageId?: string): SessionState {
|
|
526
|
+
return updateSend(prev, clientId, {
|
|
527
|
+
status: "complete",
|
|
528
|
+
error_detail: null,
|
|
529
|
+
completed_at: new Date().toISOString(),
|
|
530
|
+
...(messageId ? { id: messageId } : {}),
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export function markSendFailed(prev: SessionState, clientId: string, detail: string): SessionState {
|
|
535
|
+
// Only a row still waiting can fail: a late failure must not overturn a
|
|
536
|
+
// receipt or a transcript echo that already proved the send landed.
|
|
537
|
+
const row = prev.messages.find((m) => m.content?.client_id === clientId);
|
|
538
|
+
if (!row || row.status !== "pending") return prev;
|
|
539
|
+
return updateSend(prev, clientId, { status: "error", error_detail: detail });
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Your own sends the snapshot does not hold yet, carried across it.
|
|
543
|
+
*
|
|
544
|
+
* A snapshot REPLACES the message list, and in a transcript-sourced session
|
|
545
|
+
* your line is not a server row until the agent has read it. That can be
|
|
546
|
+
* minutes if a turn is still running, since a session runs one turn at a
|
|
547
|
+
* time. So every reconnect in that window (a phone resuming, a flaky network)
|
|
548
|
+
* took the line off screen, and it came back only when the agent read it. It
|
|
549
|
+
* looked lost. A row is kept only while the snapshot lacks it: once the
|
|
550
|
+
* transcript echo is in the snapshot (same words among its recent user rows),
|
|
551
|
+
* the snapshot's copy wins.
|
|
552
|
+
*/
|
|
553
|
+
function keepUnechoedSends(previous: Message[], snapshot: Message[]): Message[] {
|
|
554
|
+
const mine = previous.filter(
|
|
555
|
+
(m) => m.role === "user" && typeof m.content?.client_id === "string" &&
|
|
556
|
+
!snapshot.some((s) => s.id === m.id),
|
|
557
|
+
);
|
|
558
|
+
if (mine.length === 0) return snapshot;
|
|
559
|
+
const recent = new Set(
|
|
560
|
+
snapshot.filter((m) => m.role === "user").slice(-12).map((m) => m.plaintext.trim()),
|
|
561
|
+
);
|
|
562
|
+
const carried = mine.filter((m) => !recent.has(m.plaintext.trim()));
|
|
563
|
+
if (carried.length === 0) return snapshot;
|
|
564
|
+
const top = snapshot.reduce((acc, m) => Math.max(acc, m.turn_index), 0);
|
|
565
|
+
return [...snapshot, ...carried.map((m, i) => ({ ...m, turn_index: top + 1 + i }))];
|
|
566
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
* A read-only principal — a canopy CONTACT — chats through the same hook: the
|
|
9
|
+
* message goes out over HTTP, the draft stays local, and nothing is written to
|
|
10
|
+
* the socket (which would refuse it).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
class FakeSocket {
|
|
14
|
+
static OPEN = 1
|
|
15
|
+
static sent: string[] = []
|
|
16
|
+
onopen: (() => void) | null = null
|
|
17
|
+
onmessage: ((e: { data: string }) => void) | null = null
|
|
18
|
+
onclose: (() => void) | null = null
|
|
19
|
+
onerror: (() => void) | null = null
|
|
20
|
+
readyState = 1
|
|
21
|
+
constructor(public url: string) {}
|
|
22
|
+
send(data: string) {
|
|
23
|
+
FakeSocket.sent.push(data)
|
|
24
|
+
}
|
|
25
|
+
close() {}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
FakeSocket.sent = []
|
|
30
|
+
vi.stubGlobal('WebSocket', FakeSocket)
|
|
31
|
+
})
|
|
32
|
+
afterEach(() => vi.unstubAllGlobals())
|
|
33
|
+
|
|
34
|
+
function contact(sendOverHttp = vi.fn().mockResolvedValue({})) {
|
|
35
|
+
const hook = renderHook(() =>
|
|
36
|
+
useSessionSocket({ sessionId: 's1', wsUrl: () => 'wss://h/x', sendOverHttp }),
|
|
37
|
+
)
|
|
38
|
+
return { hook, sendOverHttp }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('sending over HTTP', () => {
|
|
42
|
+
it('types into a local draft and sends it over HTTP, never the socket', async () => {
|
|
43
|
+
const { hook, sendOverHttp } = contact()
|
|
44
|
+
act(() => hook.result.current.updateDraft('hello ace'))
|
|
45
|
+
expect(hook.result.current.state.active_draft?.body).toBe('hello ace')
|
|
46
|
+
await act(async () => hook.result.current.sendChat())
|
|
47
|
+
expect(sendOverHttp).toHaveBeenCalledWith('hello ace')
|
|
48
|
+
expect(FakeSocket.sent.filter((f) => f.includes('chat.send') || f.includes('draft.update'))).toEqual([])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('shows the line and waits for the reply, as a user send does', async () => {
|
|
52
|
+
const { hook } = contact()
|
|
53
|
+
act(() => hook.result.current.updateDraft('are we on track?'))
|
|
54
|
+
await act(async () => hook.result.current.sendChat())
|
|
55
|
+
expect(hook.result.current.awaitingReply).toBe(true)
|
|
56
|
+
expect(hook.result.current.state.messages.at(-1)?.plaintext).toBe('are we on track?')
|
|
57
|
+
expect(hook.result.current.state.active_draft).toBeNull()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('a failed send puts the words back and says why', async () => {
|
|
61
|
+
const { hook } = contact(vi.fn().mockRejectedValue(new Error('canopy said 429')))
|
|
62
|
+
act(() => hook.result.current.updateDraft('keep me'))
|
|
63
|
+
await act(async () => {
|
|
64
|
+
hook.result.current.sendChat()
|
|
65
|
+
await Promise.resolve()
|
|
66
|
+
})
|
|
67
|
+
expect(hook.result.current.state.active_draft?.body).toBe('keep me')
|
|
68
|
+
expect(hook.result.current.lastError).toBe('canopy said 429')
|
|
69
|
+
expect(hook.result.current.awaitingReply).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('an empty draft sends nothing', async () => {
|
|
73
|
+
const { hook, sendOverHttp } = contact()
|
|
74
|
+
act(() => hook.result.current.updateDraft(' '))
|
|
75
|
+
await act(async () => hook.result.current.sendChat())
|
|
76
|
+
expect(sendOverHttp).not.toHaveBeenCalled()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('without it, a user sends over the socket exactly as before', async () => {
|
|
80
|
+
const hook = renderHook(() => useSessionSocket({ sessionId: 's1', wsUrl: () => 'wss://h/x' }))
|
|
81
|
+
act(() => hook.result.current.updateDraft('hello'))
|
|
82
|
+
await act(async () => hook.result.current.sendChat())
|
|
83
|
+
expect(FakeSocket.sent.some((f) => f.includes('chat.send'))).toBe(true)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -0,0 +1,161 @@
|
|
|
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
|
+
* A send must never vanish. A phone resuming from the background can hold a
|
|
9
|
+
* socket that reads OPEN but delivers nothing, and before this the composer
|
|
10
|
+
* cleared, no line appeared, and the prompt was simply gone (2026-09-23).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
class FakeSocket {
|
|
14
|
+
static OPEN = 1
|
|
15
|
+
static last: FakeSocket | null = null
|
|
16
|
+
sent: string[] = []
|
|
17
|
+
onopen: (() => void) | null = null
|
|
18
|
+
onmessage: ((e: { data: string }) => void) | null = null
|
|
19
|
+
onclose: (() => void) | null = null
|
|
20
|
+
onerror: (() => void) | null = null
|
|
21
|
+
readyState = 1
|
|
22
|
+
constructor(public url: string) {
|
|
23
|
+
FakeSocket.last = this
|
|
24
|
+
}
|
|
25
|
+
send(data: string) {
|
|
26
|
+
this.sent.push(data)
|
|
27
|
+
}
|
|
28
|
+
close() {}
|
|
29
|
+
receive(frame: unknown) {
|
|
30
|
+
this.onmessage?.({ data: JSON.stringify(frame) })
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DRAFT = { id: 'd1', slot: 'next', status: 'open', body: '', version: 1, last_editor: 0, last_edit_at: '' }
|
|
35
|
+
const SNAPSHOT = {
|
|
36
|
+
event: 'session.state',
|
|
37
|
+
data: { messages: [], active_draft: DRAFT, participants: [], presence_user_ids: [], current_user_id: 1 },
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
vi.useFakeTimers()
|
|
42
|
+
vi.stubGlobal('WebSocket', FakeSocket)
|
|
43
|
+
})
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
vi.useRealTimers()
|
|
46
|
+
vi.unstubAllGlobals()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// Stable, as a real caller's is: a new function every render re-keys `connect`
|
|
50
|
+
// and opens a fresh socket, which would hide what the first one was sent.
|
|
51
|
+
const wsUrl = () => 'wss://h/x'
|
|
52
|
+
|
|
53
|
+
function user(resendOverHttp?: (text: string, clientId: string) => Promise<unknown>) {
|
|
54
|
+
const hook = renderHook(() =>
|
|
55
|
+
useSessionSocket({ sessionId: 's1', wsUrl, resendOverHttp }),
|
|
56
|
+
)
|
|
57
|
+
act(() => {
|
|
58
|
+
FakeSocket.last!.onopen?.()
|
|
59
|
+
FakeSocket.last!.receive(SNAPSHOT)
|
|
60
|
+
})
|
|
61
|
+
return hook
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sentSend() {
|
|
65
|
+
const frame = FakeSocket.last!.sent.map((f) => JSON.parse(f)).find((f) => f.action === 'chat.send')
|
|
66
|
+
return frame?.data as { client_id: string; text: string }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('a send carries its own text and waits for a receipt', () => {
|
|
70
|
+
it('shows the line at once as pending, and sends the text with a client_id', () => {
|
|
71
|
+
const hook = user()
|
|
72
|
+
act(() => hook.result.current.updateDraft('deploy it'))
|
|
73
|
+
act(() => hook.result.current.sendChat())
|
|
74
|
+
const row = hook.result.current.state.messages.at(-1)!
|
|
75
|
+
expect(row.plaintext).toBe('deploy it')
|
|
76
|
+
expect(row.status).toBe('pending')
|
|
77
|
+
expect(sentSend().text).toBe('deploy it')
|
|
78
|
+
expect(sentSend().client_id).toBeTruthy()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('the receipt confirms the SAME row rather than adding a second', () => {
|
|
82
|
+
const hook = user()
|
|
83
|
+
act(() => hook.result.current.updateDraft('deploy it'))
|
|
84
|
+
act(() => hook.result.current.sendChat())
|
|
85
|
+
act(() => FakeSocket.last!.receive({
|
|
86
|
+
event: 'draft.committed',
|
|
87
|
+
data: { draft_id: 'd1', user_message_id: 'transient:abc', client_id: sentSend().client_id },
|
|
88
|
+
}))
|
|
89
|
+
const users = hook.result.current.state.messages.filter((m) => m.role === 'user')
|
|
90
|
+
expect(users).toHaveLength(1)
|
|
91
|
+
expect(users[0].status).toBe('complete')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('no receipt: resent over HTTP under the SAME client_id, so it is one turn', async () => {
|
|
95
|
+
const resend = vi.fn().mockResolvedValue({})
|
|
96
|
+
const hook = user(resend)
|
|
97
|
+
act(() => hook.result.current.updateDraft('deploy it'))
|
|
98
|
+
act(() => hook.result.current.sendChat())
|
|
99
|
+
await act(async () => {
|
|
100
|
+
vi.advanceTimersByTime(5_000)
|
|
101
|
+
await Promise.resolve()
|
|
102
|
+
})
|
|
103
|
+
expect(resend).toHaveBeenCalledWith('deploy it', sentSend().client_id)
|
|
104
|
+
expect(hook.result.current.state.messages.at(-1)?.status).toBe('complete')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('a socket that closes before the receipt resends at once', async () => {
|
|
108
|
+
const resend = vi.fn().mockResolvedValue({})
|
|
109
|
+
const hook = user(resend)
|
|
110
|
+
act(() => hook.result.current.updateDraft('deploy it'))
|
|
111
|
+
act(() => hook.result.current.sendChat())
|
|
112
|
+
await act(async () => {
|
|
113
|
+
FakeSocket.last!.onclose?.()
|
|
114
|
+
await Promise.resolve()
|
|
115
|
+
})
|
|
116
|
+
expect(resend).toHaveBeenCalledTimes(1)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('if the resend fails too, the words stay on screen marked not sent', async () => {
|
|
120
|
+
const hook = user(vi.fn().mockRejectedValue(new Error('offline')))
|
|
121
|
+
act(() => hook.result.current.updateDraft('keep me'))
|
|
122
|
+
act(() => hook.result.current.sendChat())
|
|
123
|
+
await act(async () => {
|
|
124
|
+
vi.advanceTimersByTime(5_000)
|
|
125
|
+
await Promise.resolve()
|
|
126
|
+
await Promise.resolve()
|
|
127
|
+
})
|
|
128
|
+
const row = hook.result.current.state.messages.at(-1)!
|
|
129
|
+
expect(row.plaintext).toBe('keep me')
|
|
130
|
+
expect(row.status).toBe('error')
|
|
131
|
+
expect(row.error_detail).toMatch(/Not sent/)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('a reconnect snapshot that does not hold the line yet keeps it on screen', () => {
|
|
135
|
+
const hook = user()
|
|
136
|
+
act(() => hook.result.current.updateDraft('queued behind a running turn'))
|
|
137
|
+
act(() => hook.result.current.sendChat())
|
|
138
|
+
act(() => FakeSocket.last!.receive({
|
|
139
|
+
event: 'draft.committed',
|
|
140
|
+
data: { draft_id: 'd1', user_message_id: 'transient:abc', client_id: sentSend().client_id },
|
|
141
|
+
}))
|
|
142
|
+
// Reconnect: the snapshot has no row for it yet (the agent has not read it).
|
|
143
|
+
act(() => FakeSocket.last!.receive(SNAPSHOT))
|
|
144
|
+
expect(hook.result.current.state.messages.map((m) => m.plaintext)).toEqual([
|
|
145
|
+
'queued behind a running turn',
|
|
146
|
+
])
|
|
147
|
+
// And once the transcript has it, the snapshot's copy wins: one row.
|
|
148
|
+
act(() => FakeSocket.last!.receive({
|
|
149
|
+
...SNAPSHOT,
|
|
150
|
+
data: {
|
|
151
|
+
...SNAPSHOT.data,
|
|
152
|
+
messages: [{
|
|
153
|
+
id: 'm9', turn_index: 90001, role: 'user', content: {}, plaintext: 'queued behind a running turn',
|
|
154
|
+
status: 'complete', error_detail: null, started_at: null, completed_at: null, created_at: '',
|
|
155
|
+
}],
|
|
156
|
+
},
|
|
157
|
+
}))
|
|
158
|
+
expect(hook.result.current.state.messages).toHaveLength(1)
|
|
159
|
+
expect(hook.result.current.state.messages[0].id).toBe('m9')
|
|
160
|
+
})
|
|
161
|
+
})
|
|
@@ -1,14 +1,32 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
|
|
3
3
|
import { fromAgui, resetAguiState } from "./agui";
|
|
4
|
-
import type { Message, SessionState, WsEvent } from "./protocol";
|
|
4
|
+
import type { Draft, Message, SessionState, WsEvent } from "./protocol";
|
|
5
5
|
import { shouldSyncDraftLive } from "./drafts";
|
|
6
6
|
import { prependHistory } from "./history";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
REDUCER_EVENTS,
|
|
9
|
+
markSendDelivered,
|
|
10
|
+
markSendFailed,
|
|
11
|
+
sessionReducer,
|
|
12
|
+
withPendingSend,
|
|
13
|
+
} from "./sessionReducer";
|
|
8
14
|
|
|
9
15
|
const HEARTBEAT_INTERVAL_MS = 20_000;
|
|
10
16
|
const RECONNECT_DELAYS_MS = [1_000, 2_000, 5_000, 10_000];
|
|
11
17
|
const DRAFT_UPDATE_DEBOUNCE_MS = 150;
|
|
18
|
+
/** How long a socket send may go without the server's receipt before the page
|
|
19
|
+
* resends it over HTTP. Short on purpose: the receipt normally arrives within
|
|
20
|
+
* a round trip, and a missing one means the socket is dead (a phone resuming
|
|
21
|
+
* from the background can hold one that still reads OPEN). */
|
|
22
|
+
const SEND_RECEIPT_TIMEOUT_MS = 4_000;
|
|
23
|
+
const NOT_SENT =
|
|
24
|
+
"Not sent: the connection dropped before canopy got it. Your words are kept here, so copy them and send again.";
|
|
25
|
+
|
|
26
|
+
function newClientId(): string {
|
|
27
|
+
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
|
|
28
|
+
return c?.randomUUID ? c.randomUUID() : `c${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
|
29
|
+
}
|
|
12
30
|
|
|
13
31
|
const INITIAL_STATE: SessionState = {
|
|
14
32
|
messages: [],
|
|
@@ -56,6 +74,26 @@ export interface UseSessionSocketOptions {
|
|
|
56
74
|
* that against a fixture the server generates).
|
|
57
75
|
*/
|
|
58
76
|
protocol?: "canopy" | "ag-ui";
|
|
77
|
+
/**
|
|
78
|
+
* For a principal whose socket is READ-ONLY — a canopy CONTACT, someone a
|
|
79
|
+
* site vouched for who has no canopy account. They can watch the conversation
|
|
80
|
+
* over the socket but not write to it, so their message goes out over HTTP
|
|
81
|
+
* (canopy-client's `rest.send`, which routes to `/api/contact/…`) and their
|
|
82
|
+
* draft stays local: there is no shared draft to co-edit with nobody else.
|
|
83
|
+
*
|
|
84
|
+
* Everything else is unchanged — the same `state`, the same pending row, the
|
|
85
|
+
* same "waiting for a reply" — so one chat UI serves a user and a contact
|
|
86
|
+
* without a second send path in every host. Omit for a user (the default).
|
|
87
|
+
*/
|
|
88
|
+
sendOverHttp?: (text: string) => Promise<unknown>;
|
|
89
|
+
/**
|
|
90
|
+
* The HTTP route for a USER's send, used only when a socket send gets no
|
|
91
|
+
* receipt (see `SEND_RECEIPT_TIMEOUT_MS`). Must pass `clientId` through as the
|
|
92
|
+
* send's `client_id`: the server derives the turn's idempotency key from it,
|
|
93
|
+
* so a socket frame that did arrive late and this resend make ONE turn.
|
|
94
|
+
* Without it a send on a dead socket is marked "not sent" instead.
|
|
95
|
+
*/
|
|
96
|
+
resendOverHttp?: (text: string, clientId: string) => Promise<unknown>;
|
|
59
97
|
}
|
|
60
98
|
|
|
61
99
|
/**
|
|
@@ -111,20 +149,10 @@ export interface UseSessionSocketResult {
|
|
|
111
149
|
lastError: string | null;
|
|
112
150
|
}
|
|
113
151
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
* container wondering why its feature never fires.
|
|
119
|
-
*
|
|
120
|
-
* Keep in step with sessionReducer's own switch.
|
|
121
|
-
*/
|
|
122
|
-
const KNOWN_EVENTS = new Set([
|
|
123
|
-
"chat.delta", "chat.stream_cancelled", "chat.stream_complete",
|
|
124
|
-
"chat.stream_error", "chat.stream_start", "chat.tool_result",
|
|
125
|
-
"chat.tool_use", "chat.user_message", "session.activity", "session.error",
|
|
126
|
-
"session.menu", "session.state", "session.stop", "session.title_updated",
|
|
127
|
-
]);
|
|
152
|
+
// Frames the reducer handles: its own list, so the two cannot drift (see
|
|
153
|
+
// `REDUCER_EVENTS`). Anything else goes to `onUnknownEvent` rather than being
|
|
154
|
+
// dropped.
|
|
155
|
+
const KNOWN_EVENTS = REDUCER_EVENTS;
|
|
128
156
|
|
|
129
157
|
export function useSessionSocket({
|
|
130
158
|
sessionId,
|
|
@@ -132,7 +160,11 @@ export function useSessionSocket({
|
|
|
132
160
|
onTitleUpdated,
|
|
133
161
|
onUnknownEvent,
|
|
134
162
|
protocol = "canopy",
|
|
163
|
+
sendOverHttp,
|
|
164
|
+
resendOverHttp,
|
|
135
165
|
}: UseSessionSocketOptions): UseSessionSocketResult {
|
|
166
|
+
// The local draft a read-only principal types into (see `sendOverHttp`).
|
|
167
|
+
const [localDraft, setLocalDraft] = useState<Draft | null>(null);
|
|
136
168
|
const [state, setState] = useState<SessionState>(INITIAL_STATE);
|
|
137
169
|
const [connected, setConnected] = useState(false);
|
|
138
170
|
const [lastError, setLastError] = useState<string | null>(null);
|
|
@@ -161,6 +193,10 @@ export function useSessionSocket({
|
|
|
161
193
|
// Control frames that must not be lost across a reconnect (currently
|
|
162
194
|
// only chat.stop). The WS-world analogue of an abortable chat transport.
|
|
163
195
|
const pendingFramesRef = useRef<{ action: string; data: unknown }[]>([]);
|
|
196
|
+
// Sends awaiting the server's receipt: client_id -> {text, timer}.
|
|
197
|
+
const unconfirmedRef = useRef(new Map<string, { text: string; timer: number | null }>());
|
|
198
|
+
const resendRef = useRef(resendOverHttp);
|
|
199
|
+
resendRef.current = resendOverHttp;
|
|
164
200
|
|
|
165
201
|
useEffect(() => {
|
|
166
202
|
stateRef.current = state;
|
|
@@ -178,7 +214,7 @@ export function useSessionSocket({
|
|
|
178
214
|
const ws = socketRef.current;
|
|
179
215
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
180
216
|
ws.send(JSON.stringify(frame));
|
|
181
|
-
return;
|
|
217
|
+
return true;
|
|
182
218
|
}
|
|
183
219
|
// Queue chat.stop so a stop clicked while the socket is reconnecting
|
|
184
220
|
// is delivered on next OPEN instead of silently dropped. Draft updates
|
|
@@ -187,6 +223,29 @@ export function useSessionSocket({
|
|
|
187
223
|
if (frame.action === "chat.stop") {
|
|
188
224
|
pendingFramesRef.current.push(frame);
|
|
189
225
|
}
|
|
226
|
+
return false;
|
|
227
|
+
}, []);
|
|
228
|
+
|
|
229
|
+
// A send that got no receipt: resend it over HTTP under the same client_id,
|
|
230
|
+
// or, with no HTTP route, say plainly that it did not go. Never silent.
|
|
231
|
+
const rescueSend = useCallback((clientId: string) => {
|
|
232
|
+
const entry = unconfirmedRef.current.get(clientId);
|
|
233
|
+
if (!entry) return; // the receipt got here first
|
|
234
|
+
if (entry.timer != null) window.clearTimeout(entry.timer);
|
|
235
|
+
unconfirmedRef.current.delete(clientId);
|
|
236
|
+
const resend = resendRef.current;
|
|
237
|
+
if (!resend) {
|
|
238
|
+
setAwaitingReply(false);
|
|
239
|
+
setState((prev) => markSendFailed(prev, clientId, NOT_SENT));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
resend(entry.text, clientId).then(
|
|
243
|
+
() => setState((prev) => markSendDelivered(prev, clientId)),
|
|
244
|
+
() => {
|
|
245
|
+
setAwaitingReply(false);
|
|
246
|
+
setState((prev) => markSendFailed(prev, clientId, NOT_SENT));
|
|
247
|
+
},
|
|
248
|
+
);
|
|
190
249
|
}, []);
|
|
191
250
|
|
|
192
251
|
const applyEvent = useCallback((frame: WsEvent) => {
|
|
@@ -201,6 +260,12 @@ export function useSessionSocket({
|
|
|
201
260
|
) {
|
|
202
261
|
setAwaitingReply(false);
|
|
203
262
|
}
|
|
263
|
+
// The server's receipt for one of OUR sends: stop waiting on it.
|
|
264
|
+
if (frame.event === "draft.committed" && frame.data.client_id) {
|
|
265
|
+
const entry = unconfirmedRef.current.get(frame.data.client_id);
|
|
266
|
+
if (entry?.timer != null) window.clearTimeout(entry.timer);
|
|
267
|
+
unconfirmedRef.current.delete(frame.data.client_id);
|
|
268
|
+
}
|
|
204
269
|
// Side-effect events: handle BEFORE setState so React strict-mode's
|
|
205
270
|
// double-invocation of the updater doesn't double-fire the effect.
|
|
206
271
|
if (frame.event === "session.title_updated") {
|
|
@@ -296,6 +361,8 @@ export function useSessionSocket({
|
|
|
296
361
|
|
|
297
362
|
ws.onclose = () => {
|
|
298
363
|
setConnected(false);
|
|
364
|
+
// A send still waiting for its receipt will not get one on this socket.
|
|
365
|
+
for (const clientId of [...unconfirmedRef.current.keys()]) rescueSend(clientId);
|
|
299
366
|
if (heartbeatTimerRef.current != null) {
|
|
300
367
|
window.clearInterval(heartbeatTimerRef.current);
|
|
301
368
|
heartbeatTimerRef.current = null;
|
|
@@ -311,7 +378,7 @@ export function useSessionSocket({
|
|
|
311
378
|
ws.onerror = () => {
|
|
312
379
|
// onclose will fire next; nothing to do here.
|
|
313
380
|
};
|
|
314
|
-
}, [applyEvent, send, sessionId, wsUrl]);
|
|
381
|
+
}, [applyEvent, rescueSend, send, sessionId, wsUrl]);
|
|
315
382
|
|
|
316
383
|
useEffect(() => {
|
|
317
384
|
closedByUserRef.current = false;
|
|
@@ -330,29 +397,32 @@ export function useSessionSocket({
|
|
|
330
397
|
}, [connect]);
|
|
331
398
|
|
|
332
399
|
const sendChat = useCallback(() => {
|
|
333
|
-
//
|
|
334
|
-
// draft
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
400
|
+
// The text goes IN the send, with a client_id, rather than relying on an
|
|
401
|
+
// earlier draft.update having reached the server. That frame and this one
|
|
402
|
+
// could both vanish into a socket that reads OPEN but is dead (a phone
|
|
403
|
+
// resuming from the background), and the composer had already cleared, so
|
|
404
|
+
// the prompt was simply lost (2026-09-23). Now the line shows at once as
|
|
405
|
+
// pending, the server's receipt (draft.committed + client_id) confirms it,
|
|
406
|
+
// and without one it is resent over HTTP under the same id.
|
|
339
407
|
if (draftDebounceRef.current != null) {
|
|
340
408
|
window.clearTimeout(draftDebounceRef.current);
|
|
341
409
|
draftDebounceRef.current = null;
|
|
342
410
|
}
|
|
343
|
-
|
|
344
|
-
send({
|
|
345
|
-
action: "draft.update",
|
|
346
|
-
data: {
|
|
347
|
-
version: stateRef.current.active_draft.version,
|
|
348
|
-
body: pendingDraftBodyRef.current,
|
|
349
|
-
},
|
|
350
|
-
});
|
|
351
|
-
}
|
|
411
|
+
const body = (pendingDraftBodyRef.current ?? stateRef.current.active_draft?.body ?? "").trim();
|
|
352
412
|
pendingDraftBodyRef.current = null;
|
|
413
|
+
if (!body) return;
|
|
414
|
+
const clientId = newClientId();
|
|
353
415
|
setAwaitingReply(true);
|
|
354
|
-
|
|
355
|
-
|
|
416
|
+
setState((prev) => withPendingSend(prev, { clientId, text: body }));
|
|
417
|
+
unconfirmedRef.current.set(clientId, { text: body, timer: null });
|
|
418
|
+
const sent = send({ action: "chat.send", data: { client_id: clientId, text: body } });
|
|
419
|
+
if (!sent) {
|
|
420
|
+
rescueSend(clientId);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const entry = unconfirmedRef.current.get(clientId);
|
|
424
|
+
if (entry) entry.timer = window.setTimeout(() => rescueSend(clientId), SEND_RECEIPT_TIMEOUT_MS);
|
|
425
|
+
}, [send, rescueSend]);
|
|
356
426
|
|
|
357
427
|
const stopChat = useCallback(
|
|
358
428
|
(messageId: string | null) => {
|
|
@@ -442,6 +512,15 @@ export function useSessionSocket({
|
|
|
442
512
|
});
|
|
443
513
|
}, []);
|
|
444
514
|
|
|
515
|
+
// `sendOverHttp`'s half of the hook: typing is local, sending is HTTP. Defined
|
|
516
|
+
// here, after noteLocalSend, which it reuses for the pending row.
|
|
517
|
+
const updateLocalDraft = useCallback((body: string) => {
|
|
518
|
+
setLocalDraft({
|
|
519
|
+
id: "local", slot: "next", status: "open", body, version: 0, last_editor: 0,
|
|
520
|
+
last_edit_at: new Date().toISOString(),
|
|
521
|
+
});
|
|
522
|
+
}, []);
|
|
523
|
+
|
|
445
524
|
const noteLocalSend = useCallback((text: string) => {
|
|
446
525
|
setAwaitingReply(true);
|
|
447
526
|
const body = text.trim();
|
|
@@ -465,13 +544,31 @@ export function useSessionSocket({
|
|
|
465
544
|
);
|
|
466
545
|
}, []);
|
|
467
546
|
|
|
547
|
+
const sendChatOverHttp = useCallback(() => {
|
|
548
|
+
const body = (localDraft?.body ?? "").trim();
|
|
549
|
+
if (!body || !sendOverHttp) return;
|
|
550
|
+
noteLocalSend(body);
|
|
551
|
+
setLocalDraft(null);
|
|
552
|
+
sendOverHttp(body).catch((err: unknown) => {
|
|
553
|
+
setAwaitingReply(false);
|
|
554
|
+
setLastError(err instanceof Error ? err.message : "the message could not be sent");
|
|
555
|
+
// Put the words back, so a failed send never costs what was typed.
|
|
556
|
+
updateLocalDraft(body);
|
|
557
|
+
});
|
|
558
|
+
}, [localDraft, sendOverHttp, noteLocalSend, updateLocalDraft]);
|
|
559
|
+
|
|
560
|
+
const exposedState = useMemo(
|
|
561
|
+
() => (sendOverHttp ? { ...state, active_draft: localDraft } : state),
|
|
562
|
+
[sendOverHttp, state, localDraft],
|
|
563
|
+
);
|
|
564
|
+
|
|
468
565
|
return {
|
|
469
|
-
state,
|
|
566
|
+
state: exposedState,
|
|
470
567
|
connected,
|
|
471
568
|
awaitingReply,
|
|
472
|
-
sendChat,
|
|
569
|
+
sendChat: sendOverHttp ? sendChatOverHttp : sendChat,
|
|
473
570
|
stopChat,
|
|
474
|
-
updateDraft,
|
|
571
|
+
updateDraft: sendOverHttp ? updateLocalDraft : updateDraft,
|
|
475
572
|
takeOverDraft,
|
|
476
573
|
discardDraft,
|
|
477
574
|
prependMessages,
|