canopy-ui 0.8.0 → 0.10.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/ChatPanel.pending.test.tsx +79 -1
- package/src/chat/ChatPanel.tsx +42 -13
- package/src/chat/agui.fixture.json +425 -5
- package/src/chat/agui.test.ts +5 -1
- package/src/chat/agui.ts +13 -0
- package/src/chat/index.ts +12 -0
- package/src/chat/protocol.ts +58 -0
- package/src/chat/sessionReducer.ts +12 -0
- package/src/chat/turnStatus.test.ts +139 -0
- package/src/chat/turnStatus.ts +148 -0
- package/src/chat/useSessionSocket.protocol.test.tsx +124 -0
- package/src/chat/useSessionSocket.ts +79 -1
|
@@ -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;
|
|
@@ -72,6 +93,21 @@ export interface UseSessionSocketResult {
|
|
|
72
93
|
takeOverDraft: () => void;
|
|
73
94
|
discardDraft: () => void;
|
|
74
95
|
prependMessages: (older: Message[]) => void;
|
|
96
|
+
/** A message was sent for this session over HTTP rather than through this
|
|
97
|
+
* socket — show it, and start waiting for a reply.
|
|
98
|
+
*
|
|
99
|
+
* Two callers need it and both are REST sends: the embedded widget's FIRST
|
|
100
|
+
* message (the session does not exist until it is sent, so there is no
|
|
101
|
+
* socket to send it on) and every message from a CONTACT (whose socket
|
|
102
|
+
* listens only — presence and the co-edited draft are keyed on a user id
|
|
103
|
+
* they do not have).
|
|
104
|
+
*
|
|
105
|
+
* Without it those sends changed nothing on screen: `awaitingReply` is set
|
|
106
|
+
* by `sendChat` alone, and the user's own line is not a server row until
|
|
107
|
+
* the agent's transcript ships it back. So you typed, pressed send, and got
|
|
108
|
+
* an empty panel — for as long as the reply took, and forever if its runner
|
|
109
|
+
* was offline. */
|
|
110
|
+
noteLocalSend: (text: string) => void;
|
|
75
111
|
lastError: string | null;
|
|
76
112
|
}
|
|
77
113
|
|
|
@@ -121,6 +157,7 @@ export function useSessionSocket({
|
|
|
121
157
|
// vocabulary mid-session.
|
|
122
158
|
const protocolRef = useRef(protocol);
|
|
123
159
|
protocolRef.current = protocol;
|
|
160
|
+
const warnedNativeRef = useRef(false);
|
|
124
161
|
// Control frames that must not be lost across a reconnect (currently
|
|
125
162
|
// only chat.stop). The WS-world analogue of an abortable chat transport.
|
|
126
163
|
const pendingFramesRef = useRef<{ action: string; data: unknown }[]>([]);
|
|
@@ -202,8 +239,9 @@ export function useSessionSocket({
|
|
|
202
239
|
// by an ARGS event from this one — the ids are per-stream.
|
|
203
240
|
resetAguiState();
|
|
204
241
|
const path = `ws/canopy-sessions/${sessionId}/`;
|
|
242
|
+
const built = wsUrl(path);
|
|
205
243
|
const ws = new WebSocket(
|
|
206
|
-
|
|
244
|
+
protocolRef.current === "ag-ui" ? withAguiProtocol(built) : built,
|
|
207
245
|
);
|
|
208
246
|
socketRef.current = ws;
|
|
209
247
|
|
|
@@ -229,6 +267,22 @@ export function useSessionSocket({
|
|
|
229
267
|
try {
|
|
230
268
|
const raw = JSON.parse(e.data);
|
|
231
269
|
if (protocolRef.current === "ag-ui") {
|
|
270
|
+
// We asked for AG-UI and the server answered in canopy's own frames:
|
|
271
|
+
// a server that predates the negotiation, or a URL that lost the flag
|
|
272
|
+
// on the way (see `withAguiProtocol`). The two are unambiguous — every
|
|
273
|
+
// AG-UI event has a `type`, and no canopy frame does — so apply it as
|
|
274
|
+
// what it is. Decoding it as AG-UI yields nothing, which is a blank
|
|
275
|
+
// chat with no error: the worst possible way to find out.
|
|
276
|
+
if (typeof raw?.type !== "string" && typeof raw?.event === "string") {
|
|
277
|
+
if (!warnedNativeRef.current) {
|
|
278
|
+
warnedNativeRef.current = true;
|
|
279
|
+
console.warn(
|
|
280
|
+
"canopy-ui: asked for protocol=ag-ui but the server sent canopy frames; handling them as canopy frames.",
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
applyEvent(raw as WsEvent);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
232
286
|
// One AG-UI event can be several canopy frames (a tool call is three
|
|
233
287
|
// events) or none, so this is a fan-out rather than a rename.
|
|
234
288
|
for (const frame of fromAgui(raw)) applyEvent(frame);
|
|
@@ -388,6 +442,29 @@ export function useSessionSocket({
|
|
|
388
442
|
});
|
|
389
443
|
}, []);
|
|
390
444
|
|
|
445
|
+
const noteLocalSend = useCallback((text: string) => {
|
|
446
|
+
setAwaitingReply(true);
|
|
447
|
+
const body = text.trim();
|
|
448
|
+
if (!body) return;
|
|
449
|
+
// Routed through the ordinary `chat.user_message` case rather than a new
|
|
450
|
+
// one, so the optimistic row goes in with the SAME dedupe the transcript
|
|
451
|
+
// echo already relies on: when the agent reads the message and the runner
|
|
452
|
+
// ships it back at its durable composite ordinal, the reducer matches on
|
|
453
|
+
// recent identical text and merges instead of rendering it twice.
|
|
454
|
+
setState((prev) =>
|
|
455
|
+
sessionReducer(prev, {
|
|
456
|
+
event: "chat.user_message",
|
|
457
|
+
data: {
|
|
458
|
+
// Local, and replaced by the server's id the moment the real row
|
|
459
|
+
// arrives. Namespaced so it can never collide with one.
|
|
460
|
+
message_id: `local:${Date.now()}`,
|
|
461
|
+
turn_index: prev.messages.reduce((acc, m) => Math.max(acc, m.turn_index), 0) + 1,
|
|
462
|
+
plaintext: body,
|
|
463
|
+
},
|
|
464
|
+
}),
|
|
465
|
+
);
|
|
466
|
+
}, []);
|
|
467
|
+
|
|
391
468
|
return {
|
|
392
469
|
state,
|
|
393
470
|
connected,
|
|
@@ -398,6 +475,7 @@ export function useSessionSocket({
|
|
|
398
475
|
takeOverDraft,
|
|
399
476
|
discardDraft,
|
|
400
477
|
prependMessages,
|
|
478
|
+
noteLocalSend,
|
|
401
479
|
lastError,
|
|
402
480
|
};
|
|
403
481
|
}
|