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/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
 
@@ -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
+ })