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
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 [
|
package/src/chat/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ export type {
|
|
|
13
13
|
Participant,
|
|
14
14
|
SessionMenu,
|
|
15
15
|
SessionState,
|
|
16
|
+
TurnStatus,
|
|
16
17
|
WsAction,
|
|
17
18
|
WsEvent,
|
|
18
19
|
} from "./protocol";
|
|
@@ -33,6 +34,17 @@ export {
|
|
|
33
34
|
} from "./useSessionSocket";
|
|
34
35
|
export { useStickyBottom } from "./useStickyBottom";
|
|
35
36
|
|
|
37
|
+
// Where the ask stands — the kit's voice for the server-side turn status that
|
|
38
|
+
// Slack, this panel and the embedded widget all render from. Exported because
|
|
39
|
+
// a host may want the same wording outside the panel (a session list, a
|
|
40
|
+
// placement banner) rather than inventing a second vocabulary for it.
|
|
41
|
+
export {
|
|
42
|
+
agentHasFloor,
|
|
43
|
+
pendingLabel,
|
|
44
|
+
turnNotice,
|
|
45
|
+
type TurnNotice,
|
|
46
|
+
} from "./turnStatus";
|
|
47
|
+
|
|
36
48
|
// Draft idle helpers
|
|
37
49
|
export { IDLE_THRESHOLD_MS, isDraftIdle, msUntilDraftIdle } from "./drafts";
|
|
38
50
|
|
package/src/chat/protocol.ts
CHANGED
|
@@ -120,6 +120,51 @@ export interface SessionMenu {
|
|
|
120
120
|
observed_at?: number;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Where the ask this session is waiting on currently stands.
|
|
125
|
+
*
|
|
126
|
+
* Computed on the SERVER (`apps/harness/turn_status.py`) and rendered
|
|
127
|
+
* identically by every channel — a Slack thread, this chat kit, the embedded
|
|
128
|
+
* widget. It exists because three of its states cannot be derived by a client
|
|
129
|
+
* at all: its runner is offline, nothing is routed that could run it, the box
|
|
130
|
+
* it was on died mid-turn. canopy's chat page used to guess at the first by
|
|
131
|
+
* fetching the whole fleet and matching the runner BY NAME, which failed open
|
|
132
|
+
* whenever a retired runner was omitted from that list; the widget never had
|
|
133
|
+
* any of it and simply showed an empty panel.
|
|
134
|
+
*/
|
|
135
|
+
export interface TurnStatus {
|
|
136
|
+
/** `picking_up` | `waiting_runner` | `unrouted` | `working` | `blocked`
|
|
137
|
+
* | `paused` | `done` | `cancelled` | `missed` | `failed` | `lost`.
|
|
138
|
+
*
|
|
139
|
+
* Deliberately a string rather than a union: an older client meeting a
|
|
140
|
+
* state a newer server added should fall back to its generic wording, not
|
|
141
|
+
* fail to type-check. `settled`/`stuck` are what code should branch on. */
|
|
142
|
+
state: string;
|
|
143
|
+
agent_slug: string | null;
|
|
144
|
+
/** The runners this state is ABOUT — the ones that would pick it up while it
|
|
145
|
+
* is queued, or the one holding it. */
|
|
146
|
+
runners: string[];
|
|
147
|
+
claimed_by: string | null;
|
|
148
|
+
/** Directed at this runner rather than offered to the cascade. */
|
|
149
|
+
pinned: boolean;
|
|
150
|
+
/** A runner it could be moved to right now, when moving it would help. Only
|
|
151
|
+
* ever an offer; acting on it re-checks everything server-side. */
|
|
152
|
+
cloud_runner: string | null;
|
|
153
|
+
cloud_runner_id: string | null;
|
|
154
|
+
/** When the holding runner was last heard from. Only meaningful for
|
|
155
|
+
* `paused`, where it is what tells you whether to keep waiting. */
|
|
156
|
+
last_seen_at: string | null;
|
|
157
|
+
/** The agent is sitting on a dialog. A separate axis from `state`: a turn
|
|
158
|
+
* blocked on a question is still RUNNING as far as the turn is concerned. */
|
|
159
|
+
menu_pending: boolean;
|
|
160
|
+
/** Nothing more will happen without someone asking for it. */
|
|
161
|
+
settled: boolean;
|
|
162
|
+
/** Nothing is moving and only a person can change that. The one question a
|
|
163
|
+
* "waiting on you" surface actually asks — derived once on the server so
|
|
164
|
+
* four clients cannot disagree about it. */
|
|
165
|
+
stuck: boolean;
|
|
166
|
+
}
|
|
167
|
+
|
|
123
168
|
export interface SessionState {
|
|
124
169
|
messages: Message[];
|
|
125
170
|
/** Live agent activity, from the runner's turn-boundary hooks. Undefined when
|
|
@@ -146,6 +191,12 @@ export interface SessionState {
|
|
|
146
191
|
* only if it was already connected when the agent blocked — which is exactly
|
|
147
192
|
* the case that fails, because you go and look BECAUSE it stopped. */
|
|
148
193
|
menu?: SessionMenu;
|
|
194
|
+
/** Where the ask stands. In the CONNECT SNAPSHOT as well as in live frames,
|
|
195
|
+
* for the same reason `menu` is: you open the page BECAUSE it went quiet, so
|
|
196
|
+
* the client that most needs this is the one that was not connected when it
|
|
197
|
+
* changed. Null/undefined when nothing has been asked on this session yet —
|
|
198
|
+
* which is distinct from "finished", and must stay distinct. */
|
|
199
|
+
turn_status?: TurnStatus | null;
|
|
149
200
|
active_draft: Draft | null;
|
|
150
201
|
participants: Participant[];
|
|
151
202
|
presence_user_ids: number[];
|
|
@@ -179,6 +230,13 @@ export type WsEvent =
|
|
|
179
230
|
// to carry a menu would report an agent as idle or blocked on the wrong one.
|
|
180
231
|
// `menu: null` is the retraction — somebody answered at the keyboard.
|
|
181
232
|
| { event: "session.menu"; data: { menu: SessionMenu | null } }
|
|
233
|
+
// Where the ask stands, re-derived server-side on every transition. Its own
|
|
234
|
+
// frame rather than a field on `session.activity`: activity is the runner's
|
|
235
|
+
// fast hook clock reporting whether the agent is PRODUCING, while this is the
|
|
236
|
+
// turn's placement in the fleet — they change on different clocks and for
|
|
237
|
+
// different reasons, and the states that matter most here (queued behind an
|
|
238
|
+
// offline box, unroutable) are ones activity has no way to spell.
|
|
239
|
+
| { event: "session.turn_status"; data: { status: TurnStatus | null } }
|
|
182
240
|
// A human typed into emdash rather than into this page. No client echoed it,
|
|
183
241
|
// so this is the only way it reaches the browser before a reload.
|
|
184
242
|
| { event: "chat.user_message"; data: { message_id: string; turn_index: number; plaintext: string } }
|
|
@@ -105,6 +105,18 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
105
105
|
// retraction, and has to be honoured — somebody answered at the keyboard.
|
|
106
106
|
return { ...prev, menu: frame.data.menu ?? undefined };
|
|
107
107
|
|
|
108
|
+
case "session.turn_status":
|
|
109
|
+
// Wholesale, never merged. The server re-derives the whole status from
|
|
110
|
+
// the turn on every transition, so a partial update could only ever
|
|
111
|
+
// describe a state the server does not believe in — and a client that
|
|
112
|
+
// just connected has no correct prior to merge INTO. Same argument as
|
|
113
|
+
// the ACP rule in reverse: `tool_call_update` is a patch because it is
|
|
114
|
+
// specified as one; this is not.
|
|
115
|
+
//
|
|
116
|
+
// A null status is a real answer ("nothing has been asked here"), not a
|
|
117
|
+
// gap to preserve, so it is stored as such.
|
|
118
|
+
return { ...prev, turn_status: frame.data.status ?? null };
|
|
119
|
+
|
|
108
120
|
case "chat.user_message": {
|
|
109
121
|
// Someone typed into emdash, OR into this page. Both reach here, and that
|
|
110
122
|
// is why matching on turn_index alone is not enough: a web send writes its
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { TurnStatus } from "./protocol";
|
|
4
|
+
import { agentHasFloor, pendingLabel, turnNotice } from "./turnStatus";
|
|
5
|
+
|
|
6
|
+
/** A status in the shape the server actually sends (`TurnStatus.as_dict`). */
|
|
7
|
+
function status(over: Partial<TurnStatus> = {}): TurnStatus {
|
|
8
|
+
return {
|
|
9
|
+
state: "working",
|
|
10
|
+
agent_slug: "hal",
|
|
11
|
+
runners: ["jj-mbp"],
|
|
12
|
+
claimed_by: "jj-mbp",
|
|
13
|
+
pinned: false,
|
|
14
|
+
cloud_runner: null,
|
|
15
|
+
cloud_runner_id: null,
|
|
16
|
+
last_seen_at: null,
|
|
17
|
+
menu_pending: false,
|
|
18
|
+
settled: false,
|
|
19
|
+
stuck: false,
|
|
20
|
+
...over,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("agentHasFloor", () => {
|
|
25
|
+
it("shows the bubble the instant you press send, before any frame comes back", () => {
|
|
26
|
+
// Nothing server-side can answer here yet — the turn is not enqueued.
|
|
27
|
+
expect(agentHasFloor({ awaitingReply: true })).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("keeps it up across the gaps between an assistant's text blocks", () => {
|
|
31
|
+
expect(agentHasFloor({ activity: "working", awaitingReply: false })).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("never shows it for an agent waiting on YOU", () => {
|
|
35
|
+
expect(agentHasFloor({ activity: "blocked", awaitingReply: true })).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("withdraws it once the server says nothing will pick the turn up", () => {
|
|
39
|
+
// The regression this whole projection exists for: a spinner over an ask
|
|
40
|
+
// queued behind a closed laptop, which used to spin until the tab closed.
|
|
41
|
+
const s = status({ state: "waiting_runner", stuck: true, claimed_by: null });
|
|
42
|
+
expect(agentHasFloor({ status: s, awaitingReply: true })).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("withdraws it for an unroutable turn even while the client still thinks it is waiting", () => {
|
|
46
|
+
const s = status({ state: "unrouted", stuck: true, runners: [], claimed_by: null });
|
|
47
|
+
expect(agentHasFloor({ status: s, awaitingReply: true })).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("withdraws it once the turn is settled, even if a stream frame was missed", () => {
|
|
51
|
+
// `awaitingReply` only clears on a stream frame. A dropped socket used to
|
|
52
|
+
// leave it true forever; the server's settled flag is the backstop.
|
|
53
|
+
const s = status({ state: "done", settled: true });
|
|
54
|
+
expect(agentHasFloor({ status: s, awaitingReply: true })).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("shows it for a queued turn a live runner is taking", () => {
|
|
58
|
+
expect(agentHasFloor({ status: status({ state: "picking_up" }) })).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("lets the server override a stale client belief that nothing is happening", () => {
|
|
62
|
+
// Someone else sent on this session from another device.
|
|
63
|
+
expect(agentHasFloor({ status: status({ state: "working" }), awaitingReply: false })).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("pendingLabel", () => {
|
|
68
|
+
it("says where the delay IS, not merely that there is one", () => {
|
|
69
|
+
expect(pendingLabel({ status: status({ state: "picking_up" }) })).toBe(
|
|
70
|
+
"Picking this up on jj-mbp…",
|
|
71
|
+
);
|
|
72
|
+
expect(pendingLabel({ status: status({ state: "working" }) })).toBe("Thinking on jj-mbp…");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("names a pinned runner as a decision, not a queue position", () => {
|
|
76
|
+
const s = status({ state: "picking_up", pinned: true, runners: ["cloud-1"] });
|
|
77
|
+
expect(pendingLabel({ status: s })).toBe("Sent to cloud-1…");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("falls back to the coarse wording with no status in hand", () => {
|
|
81
|
+
expect(pendingLabel({ activity: "working" })).toBe("Thinking…");
|
|
82
|
+
expect(pendingLabel({})).toBe("Queued…");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("turnNotice", () => {
|
|
87
|
+
it("says nothing when there is nothing a person needs to do", () => {
|
|
88
|
+
expect(turnNotice(null)).toBeNull();
|
|
89
|
+
expect(turnNotice(status({ state: "working" }))).toBeNull();
|
|
90
|
+
expect(turnNotice(status({ state: "picking_up" }))).toBeNull();
|
|
91
|
+
expect(turnNotice(status({ state: "done", settled: true }))).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("leaves a blocked agent to the menu, which has buttons", () => {
|
|
95
|
+
// A sentence beside the dialog would say the same thing twice and compete
|
|
96
|
+
// with the thing you can actually press.
|
|
97
|
+
expect(turnNotice(status({ state: "blocked", stuck: true }))).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("names the offline box, because the fix is to go and open it", () => {
|
|
101
|
+
const n = turnNotice(status({ state: "waiting_runner", stuck: true, claimed_by: null }));
|
|
102
|
+
expect(n?.tone).toBe("warn");
|
|
103
|
+
expect(n?.text).toContain("jj-mbp is offline");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("reads differently for the state waiting cannot fix", () => {
|
|
107
|
+
const n = turnNotice(status({ state: "unrouted", stuck: true, runners: [], claimed_by: null }));
|
|
108
|
+
// Not a warning you can wait out — an error somebody has to go and fix.
|
|
109
|
+
expect(n?.tone).toBe("error");
|
|
110
|
+
expect(n?.text).toContain("no runner is set up to run hal");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("carries the rescue offer when moving it would help", () => {
|
|
114
|
+
const n = turnNotice(
|
|
115
|
+
status({
|
|
116
|
+
state: "waiting_runner",
|
|
117
|
+
stuck: true,
|
|
118
|
+
cloud_runner: "cloud-1",
|
|
119
|
+
cloud_runner_id: "8f2e",
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
expect(n?.offer).toEqual({ name: "cloud-1", id: "8f2e" });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("offers nothing when the server named no runner to move it to", () => {
|
|
126
|
+
const n = turnNotice(status({ state: "waiting_runner", stuck: true }));
|
|
127
|
+
expect(n?.offer).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("reports a box that died mid-turn, which the turn itself still calls running", () => {
|
|
131
|
+
const n = turnNotice(status({ state: "paused", stuck: true }));
|
|
132
|
+
expect(n?.text).toContain("jj-mbp went offline mid-turn");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("degrades to no notice for a state it has never heard of", () => {
|
|
136
|
+
// An older client meeting a newer server must fall silent, not crash.
|
|
137
|
+
expect(turnNotice(status({ state: "some_future_state" }))).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { TurnStatus } from "./protocol";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The chat kit's VOICE for `harness.turn_status` — the counterpart of
|
|
5
|
+
* `apps/slack/status.py::render`, which says the same eleven states in Slack's
|
|
6
|
+
* words. The states are computed once on the server; only the wording is here.
|
|
7
|
+
*
|
|
8
|
+
* Pure functions, no React, so both the wording and the spinner-vs-statement
|
|
9
|
+
* decision are unit-testable without mounting a panel.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Does the agent have the floor — i.e. should a "working on it" bubble show?
|
|
14
|
+
*
|
|
15
|
+
* Three sources, in order of authority:
|
|
16
|
+
*
|
|
17
|
+
* 1. `status` is the server's answer and wins whenever it exists. It is the
|
|
18
|
+
* only one that knows a turn is queued behind an offline box, and the only
|
|
19
|
+
* one that survives a reload.
|
|
20
|
+
* 2. `awaitingReply` is client-side and answers INSTANTLY, with no round trip.
|
|
21
|
+
* Nothing server-side can: the turn has to be enqueued before anything
|
|
22
|
+
* could report on it. It covers the gap between pressing send and the
|
|
23
|
+
* first frame coming back.
|
|
24
|
+
* 3. `activity` is the runner's hook clock, which keeps the bubble up across
|
|
25
|
+
* the gaps between an assistant's separate text blocks.
|
|
26
|
+
*
|
|
27
|
+
* A `blocked` agent never has the floor: one waiting on YOU must not render as
|
|
28
|
+
* one working. Neither does a stuck or settled turn — a spinner over an ask
|
|
29
|
+
* nothing is going to pick up is the exact lie this whole projection exists to
|
|
30
|
+
* stop.
|
|
31
|
+
*/
|
|
32
|
+
export function agentHasFloor({
|
|
33
|
+
status,
|
|
34
|
+
activity,
|
|
35
|
+
awaitingReply,
|
|
36
|
+
}: {
|
|
37
|
+
status?: TurnStatus | null;
|
|
38
|
+
activity?: "working" | "idle" | "blocked";
|
|
39
|
+
awaitingReply?: boolean;
|
|
40
|
+
}): boolean {
|
|
41
|
+
if (activity === "blocked") return false;
|
|
42
|
+
if (status) {
|
|
43
|
+
if (status.settled || status.stuck) return false;
|
|
44
|
+
// `picking_up` and `working` are the two live states, and both mean a
|
|
45
|
+
// reply is genuinely coming.
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
return Boolean(awaitingReply) || activity === "working";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The label inside the pending bubble.
|
|
53
|
+
*
|
|
54
|
+
* "Queued" vs "Thinking" is the useful distinction — where the delay IS, not
|
|
55
|
+
* that there is one — and with a status in hand it can name the box, which
|
|
56
|
+
* turns a spinner into something you can act on when it lasts too long.
|
|
57
|
+
*/
|
|
58
|
+
export function pendingLabel({
|
|
59
|
+
status,
|
|
60
|
+
activity,
|
|
61
|
+
}: {
|
|
62
|
+
status?: TurnStatus | null;
|
|
63
|
+
activity?: "working" | "idle" | "blocked";
|
|
64
|
+
}): string {
|
|
65
|
+
if (status?.state === "working") {
|
|
66
|
+
return status.claimed_by ? `Thinking on ${status.claimed_by}…` : "Thinking…";
|
|
67
|
+
}
|
|
68
|
+
if (status?.state === "picking_up") {
|
|
69
|
+
const [first] = status.runners;
|
|
70
|
+
if (status.pinned && first) return `Sent to ${first}…`;
|
|
71
|
+
return first ? `Picking this up on ${first}…` : "Queued…";
|
|
72
|
+
}
|
|
73
|
+
return activity === "working" ? "Thinking…" : "Queued…";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface TurnNotice {
|
|
77
|
+
/** `warn` — nothing is moving but waiting may still resolve it.
|
|
78
|
+
* `error` — waiting will never resolve it; something has to change. */
|
|
79
|
+
tone: "warn" | "error";
|
|
80
|
+
text: string;
|
|
81
|
+
/** The runner this could be moved to, when moving it would help. The kit
|
|
82
|
+
* only reports the offer; acting on it belongs to the host, which knows
|
|
83
|
+
* whether this user may place a turn. */
|
|
84
|
+
offer: { name: string; id: string } | null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The sentence for a turn that is going nowhere, or null when there is nothing
|
|
89
|
+
* a person needs to know.
|
|
90
|
+
*
|
|
91
|
+
* Only the states a spinner would MISREPRESENT get one. `picking_up` and
|
|
92
|
+
* `working` are covered by the bubble; the terminal states are covered by the
|
|
93
|
+
* reply (or its absence) already being on screen.
|
|
94
|
+
*
|
|
95
|
+
* `blocked` deliberately returns null: the dialog itself is the notice, and
|
|
96
|
+
* the host renders it as a menu with buttons. A sentence beside it would say
|
|
97
|
+
* the same thing twice and compete with the thing you can actually press.
|
|
98
|
+
*/
|
|
99
|
+
export function turnNotice(status?: TurnStatus | null): TurnNotice | null {
|
|
100
|
+
if (!status) return null;
|
|
101
|
+
const offer = status.cloud_runner && status.cloud_runner_id
|
|
102
|
+
? { name: status.cloud_runner, id: status.cloud_runner_id }
|
|
103
|
+
: null;
|
|
104
|
+
const runners = status.runners.join(", ");
|
|
105
|
+
switch (status.state) {
|
|
106
|
+
case "waiting_runner":
|
|
107
|
+
return {
|
|
108
|
+
tone: "warn",
|
|
109
|
+
// Names the box, because the fix is physical: go and open it.
|
|
110
|
+
text: runners
|
|
111
|
+
? `Queued — ${runners} is offline, so nothing is working on this yet. It runs when the runner is back.`
|
|
112
|
+
: "Queued — its runner is offline, so nothing is working on this yet.",
|
|
113
|
+
offer,
|
|
114
|
+
};
|
|
115
|
+
case "unrouted":
|
|
116
|
+
return {
|
|
117
|
+
tone: "error",
|
|
118
|
+
// The one state waiting cannot fix, and it has to READ differently
|
|
119
|
+
// from the one waiting can.
|
|
120
|
+
text: status.agent_slug
|
|
121
|
+
? `Queued, but no runner is set up to run ${status.agent_slug} — nothing will pick this up until its routing is fixed.`
|
|
122
|
+
: "Queued, but no runner is set up to run this — nothing will pick it up until its routing is fixed.",
|
|
123
|
+
offer,
|
|
124
|
+
};
|
|
125
|
+
case "paused":
|
|
126
|
+
return {
|
|
127
|
+
tone: "warn",
|
|
128
|
+
text: status.claimed_by
|
|
129
|
+
? `Paused — ${status.claimed_by} went offline mid-turn. It carries on if the runner comes back.`
|
|
130
|
+
: "Paused — the runner went offline mid-turn.",
|
|
131
|
+
offer,
|
|
132
|
+
};
|
|
133
|
+
case "lost":
|
|
134
|
+
return {
|
|
135
|
+
tone: "error",
|
|
136
|
+
text: status.claimed_by
|
|
137
|
+
? `Could not finish — ${status.claimed_by} went away before it was done.`
|
|
138
|
+
: "Could not finish — the runner went away before it was done.",
|
|
139
|
+
offer,
|
|
140
|
+
};
|
|
141
|
+
case "failed":
|
|
142
|
+
return { tone: "error", text: "Could not finish.", offer: null };
|
|
143
|
+
case "missed":
|
|
144
|
+
return { tone: "warn", text: "Missed — nothing picked it up in time.", offer: null };
|
|
145
|
+
default:
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -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
|
+
})
|