canopy-ui 0.9.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 +84 -9
- 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.ts +39 -0
package/package.json
CHANGED
|
@@ -15,7 +15,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
15
15
|
import { cleanup, render, screen } from "@testing-library/react";
|
|
16
16
|
|
|
17
17
|
import { ChatPanel } from "./ChatPanel";
|
|
18
|
-
import type { Message, SessionState } from "./protocol";
|
|
18
|
+
import type { Message, SessionState, TurnStatus } from "./protocol";
|
|
19
19
|
|
|
20
20
|
afterEach(cleanup);
|
|
21
21
|
|
|
@@ -134,3 +134,81 @@ describe("the pending-reply row", () => {
|
|
|
134
134
|
expect(screen.queryByText("no messages yet")).toBeNull();
|
|
135
135
|
});
|
|
136
136
|
});
|
|
137
|
+
|
|
138
|
+
function status(over: Partial<TurnStatus> = {}): TurnStatus {
|
|
139
|
+
return {
|
|
140
|
+
state: "working",
|
|
141
|
+
agent_slug: "hal",
|
|
142
|
+
runners: ["jj-mbp"],
|
|
143
|
+
claimed_by: "jj-mbp",
|
|
144
|
+
pinned: false,
|
|
145
|
+
cloud_runner: null,
|
|
146
|
+
cloud_runner_id: null,
|
|
147
|
+
last_seen_at: null,
|
|
148
|
+
menu_pending: false,
|
|
149
|
+
settled: false,
|
|
150
|
+
stuck: false,
|
|
151
|
+
...over,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
describe("the server's turn status", () => {
|
|
156
|
+
it("replaces the spinner with a reason when nothing will pick the turn up", () => {
|
|
157
|
+
// The regression the whole projection exists for. A client cannot know
|
|
158
|
+
// this: it pressed send, and from here an offline runner and a slow one
|
|
159
|
+
// are identical.
|
|
160
|
+
panel({
|
|
161
|
+
state: state({
|
|
162
|
+
turn_status: status({ state: "waiting_runner", stuck: true, claimed_by: null }),
|
|
163
|
+
}),
|
|
164
|
+
awaitingReply: true,
|
|
165
|
+
});
|
|
166
|
+
expect(screen.queryByTestId("pending-reply")).toBeNull();
|
|
167
|
+
expect(screen.getByRole("status").textContent).toContain("jj-mbp is offline");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("reads differently when waiting cannot possibly help", () => {
|
|
171
|
+
panel({
|
|
172
|
+
state: state({
|
|
173
|
+
turn_status: status({ state: "unrouted", stuck: true, runners: [], claimed_by: null }),
|
|
174
|
+
}),
|
|
175
|
+
awaitingReply: true,
|
|
176
|
+
});
|
|
177
|
+
expect(screen.getByRole("status").textContent).toContain("no runner is set up to run hal");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("names the box in the spinner once a runner really has it", () => {
|
|
181
|
+
panel({ state: state({ turn_status: status({ state: "working" }) }) });
|
|
182
|
+
expect(screen.getByText("Thinking on jj-mbp…")).not.toBeNull();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("clears a stuck spinner even if the stream frame that ends it never arrived", () => {
|
|
186
|
+
// `awaitingReply` only clears on a stream frame; a dropped socket used to
|
|
187
|
+
// leave it spinning forever. The server's settled flag is the backstop.
|
|
188
|
+
panel({
|
|
189
|
+
state: state({ turn_status: status({ state: "done", settled: true }) }),
|
|
190
|
+
awaitingReply: true,
|
|
191
|
+
});
|
|
192
|
+
expect(screen.queryByTestId("pending-reply")).toBeNull();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("stays silent when the host has its own, richer banner for the same fact", () => {
|
|
196
|
+
// canopy's chat page raises <PlacementBanner> here, with buttons to wait
|
|
197
|
+
// or continue elsewhere. Our sentence underneath would restate it as a
|
|
198
|
+
// dead end directly below the thing you can actually press.
|
|
199
|
+
panel({
|
|
200
|
+
state: state({
|
|
201
|
+
turn_status: status({ state: "waiting_runner", stuck: true, claimed_by: null }),
|
|
202
|
+
}),
|
|
203
|
+
banner: <div>continue on another runner?</div>,
|
|
204
|
+
});
|
|
205
|
+
expect(screen.queryByRole("status")).toBeNull();
|
|
206
|
+
expect(screen.getByText("continue on another runner?")).not.toBeNull();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("leaves a blocked agent entirely to the menu", () => {
|
|
210
|
+
panel({ state: state({ turn_status: status({ state: "blocked", stuck: true }) }) });
|
|
211
|
+
expect(screen.queryByRole("status")).toBeNull();
|
|
212
|
+
expect(screen.queryByTestId("pending-reply")).toBeNull();
|
|
213
|
+
});
|
|
214
|
+
});
|
package/src/chat/ChatPanel.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { MessageList } from "./MessageList";
|
|
|
7
7
|
import { PresenceChips } from "./PresenceChips";
|
|
8
8
|
import { SendBox, type PendingAttachment } from "./SendBox";
|
|
9
9
|
import { isDraftIdle, msUntilDraftIdle, type DraftStorage } from "./drafts";
|
|
10
|
+
import { agentHasFloor as computeAgentHasFloor, pendingLabel as computePendingLabel, turnNotice } from "./turnStatus";
|
|
10
11
|
import { useStickyBottom } from "./useStickyBottom";
|
|
11
12
|
|
|
12
13
|
export interface ChatPanelProps {
|
|
@@ -119,19 +120,31 @@ export function ChatPanel({
|
|
|
119
120
|
// the real runner path, and the only signal was a 12px chip in the header,
|
|
120
121
|
// which on a phone is the far corner of the screen from your thumb.
|
|
121
122
|
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
const agentHasFloor =
|
|
129
|
-
|
|
130
|
-
|
|
123
|
+
// Three sources now, in `turnStatus.ts` — see `agentHasFloor` there for the
|
|
124
|
+
// order of authority. The server's `turn_status` wins when present because
|
|
125
|
+
// it is the only one that can say a turn is queued behind an offline box or
|
|
126
|
+
// routed nowhere at all; `awaitingReply` still covers the instant between
|
|
127
|
+
// pressing send and the first frame, which nothing server-side can.
|
|
128
|
+
const status = state.turn_status ?? null;
|
|
129
|
+
const agentHasFloor = computeAgentHasFloor({
|
|
130
|
+
status,
|
|
131
|
+
activity: state.activity,
|
|
132
|
+
awaitingReply,
|
|
133
|
+
});
|
|
131
134
|
const showPendingReply = inFlightMessage == null && agentHasFloor;
|
|
132
|
-
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
+
const pendingLabel = computePendingLabel({ status, activity: state.activity });
|
|
136
|
+
// The sentence for an ask that is going NOWHERE. Rendered instead of the
|
|
137
|
+
// bubble, never beside it: a spinner over a turn nothing will pick up is the
|
|
138
|
+
// precise lie this projection exists to remove.
|
|
139
|
+
//
|
|
140
|
+
// Suppressed entirely when the host supplies its own `banner`. A host banner
|
|
141
|
+
// IS this host's richer answer for the same situation — canopy's chat page
|
|
142
|
+
// raises <PlacementBanner> for an offline runner, with buttons to wait or
|
|
143
|
+
// continue elsewhere, and <MenuPrompt> for a dialog. Rendering our sentence
|
|
144
|
+
// underneath would state the same fact twice and put a dead-end restatement
|
|
145
|
+
// directly below the thing you can actually press. The fallback is for hosts
|
|
146
|
+
// with no banner of their own, which is exactly the embedded widget.
|
|
147
|
+
const notice = banner ? null : turnNotice(status);
|
|
135
148
|
|
|
136
149
|
// Sticky-bottom scroll: dep changes on (a) new message arrival and (b)
|
|
137
150
|
// streaming text growth on the last message. length-only (cheap) instead
|
|
@@ -188,7 +201,23 @@ export function ChatPanel({
|
|
|
188
201
|
onStop={onStop}
|
|
189
202
|
stopState={state.stopState}
|
|
190
203
|
onTakeOver={onTakeOver}
|
|
191
|
-
banner={
|
|
204
|
+
banner={
|
|
205
|
+
notice ? (
|
|
206
|
+
<p
|
|
207
|
+
role="status"
|
|
208
|
+
className={
|
|
209
|
+
notice.tone === "error"
|
|
210
|
+
? "text-[12px] text-destructive"
|
|
211
|
+
: "text-[12px] text-warning"
|
|
212
|
+
}
|
|
213
|
+
>
|
|
214
|
+
{notice.text}
|
|
215
|
+
{notice.offer ? ` A runner admin can send it to ${notice.offer.name}.` : ""}
|
|
216
|
+
</p>
|
|
217
|
+
) : (
|
|
218
|
+
banner
|
|
219
|
+
)
|
|
220
|
+
}
|
|
192
221
|
disabledReason={disabledReason}
|
|
193
222
|
attachments={attachments}
|
|
194
223
|
onAttach={onAttach}
|
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
},
|
|
10
10
|
"agui": [
|
|
11
11
|
{
|
|
12
|
+
"type": "TEXT_MESSAGE_START",
|
|
12
13
|
"metadata": {
|
|
13
14
|
"canopy": {
|
|
14
15
|
"turn_index": 4
|
|
15
16
|
}
|
|
16
17
|
},
|
|
17
|
-
"type": "TEXT_MESSAGE_START",
|
|
18
18
|
"messageId": "m1",
|
|
19
19
|
"role": "assistant"
|
|
20
20
|
}
|
|
@@ -46,12 +46,12 @@
|
|
|
46
46
|
},
|
|
47
47
|
"agui": [
|
|
48
48
|
{
|
|
49
|
+
"type": "TEXT_MESSAGE_END",
|
|
49
50
|
"metadata": {
|
|
50
51
|
"canopy": {
|
|
51
52
|
"plaintext": "hello"
|
|
52
53
|
}
|
|
53
54
|
},
|
|
54
|
-
"type": "TEXT_MESSAGE_END",
|
|
55
55
|
"messageId": "m1"
|
|
56
56
|
}
|
|
57
57
|
]
|
|
@@ -67,12 +67,12 @@
|
|
|
67
67
|
},
|
|
68
68
|
"agui": [
|
|
69
69
|
{
|
|
70
|
+
"type": "TEXT_MESSAGE_CHUNK",
|
|
70
71
|
"metadata": {
|
|
71
72
|
"canopy": {
|
|
72
73
|
"turn_index": 3
|
|
73
74
|
}
|
|
74
75
|
},
|
|
75
|
-
"type": "TEXT_MESSAGE_CHUNK",
|
|
76
76
|
"messageId": "u1",
|
|
77
77
|
"role": "user",
|
|
78
78
|
"delta": "hi there"
|
|
@@ -96,6 +96,7 @@
|
|
|
96
96
|
},
|
|
97
97
|
"agui": [
|
|
98
98
|
{
|
|
99
|
+
"type": "TOOL_CALL_START",
|
|
99
100
|
"metadata": {
|
|
100
101
|
"canopy": {
|
|
101
102
|
"turn_index": 5,
|
|
@@ -107,7 +108,6 @@
|
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
110
|
},
|
|
110
|
-
"type": "TOOL_CALL_START",
|
|
111
111
|
"toolCallId": "t9",
|
|
112
112
|
"toolCallName": "list_insights",
|
|
113
113
|
"parentMessageId": "m1"
|
|
@@ -139,6 +139,7 @@
|
|
|
139
139
|
},
|
|
140
140
|
"agui": [
|
|
141
141
|
{
|
|
142
|
+
"type": "TOOL_CALL_RESULT",
|
|
142
143
|
"metadata": {
|
|
143
144
|
"canopy": {
|
|
144
145
|
"turn_index": 6,
|
|
@@ -150,7 +151,6 @@
|
|
|
150
151
|
}
|
|
151
152
|
}
|
|
152
153
|
},
|
|
153
|
-
"type": "TOOL_CALL_RESULT",
|
|
154
154
|
"messageId": "t9",
|
|
155
155
|
"toolCallId": "t9",
|
|
156
156
|
"content": "[]"
|
|
@@ -273,11 +273,27 @@
|
|
|
273
273
|
7
|
|
274
274
|
],
|
|
275
275
|
"current_user_id": 7,
|
|
276
|
-
"menu": null
|
|
276
|
+
"menu": null,
|
|
277
|
+
"turn_status": {
|
|
278
|
+
"state": "working",
|
|
279
|
+
"agent_slug": "hal",
|
|
280
|
+
"runners": [
|
|
281
|
+
"jj-mbp"
|
|
282
|
+
],
|
|
283
|
+
"claimed_by": "jj-mbp",
|
|
284
|
+
"pinned": false,
|
|
285
|
+
"cloud_runner": null,
|
|
286
|
+
"cloud_runner_id": null,
|
|
287
|
+
"last_seen_at": null,
|
|
288
|
+
"menu_pending": false,
|
|
289
|
+
"settled": false,
|
|
290
|
+
"stuck": false
|
|
291
|
+
}
|
|
277
292
|
}
|
|
278
293
|
},
|
|
279
294
|
"agui": [
|
|
280
295
|
{
|
|
296
|
+
"type": "MESSAGES_SNAPSHOT",
|
|
281
297
|
"metadata": {
|
|
282
298
|
"canopy": {
|
|
283
299
|
"frame": {
|
|
@@ -320,12 +336,26 @@
|
|
|
320
336
|
7
|
|
321
337
|
],
|
|
322
338
|
"current_user_id": 7,
|
|
323
|
-
"menu": null
|
|
339
|
+
"menu": null,
|
|
340
|
+
"turn_status": {
|
|
341
|
+
"state": "working",
|
|
342
|
+
"agent_slug": "hal",
|
|
343
|
+
"runners": [
|
|
344
|
+
"jj-mbp"
|
|
345
|
+
],
|
|
346
|
+
"claimed_by": "jj-mbp",
|
|
347
|
+
"pinned": false,
|
|
348
|
+
"cloud_runner": null,
|
|
349
|
+
"cloud_runner_id": null,
|
|
350
|
+
"last_seen_at": null,
|
|
351
|
+
"menu_pending": false,
|
|
352
|
+
"settled": false,
|
|
353
|
+
"stuck": false
|
|
354
|
+
}
|
|
324
355
|
}
|
|
325
356
|
}
|
|
326
357
|
}
|
|
327
358
|
},
|
|
328
|
-
"type": "MESSAGES_SNAPSHOT",
|
|
329
359
|
"messages": [
|
|
330
360
|
{
|
|
331
361
|
"id": "41",
|
|
@@ -366,6 +396,51 @@
|
|
|
366
396
|
}
|
|
367
397
|
]
|
|
368
398
|
},
|
|
399
|
+
{
|
|
400
|
+
"canopy": {
|
|
401
|
+
"event": "session.turn_status",
|
|
402
|
+
"data": {
|
|
403
|
+
"status": {
|
|
404
|
+
"state": "waiting_runner",
|
|
405
|
+
"agent_slug": "hal",
|
|
406
|
+
"runners": [
|
|
407
|
+
"jj-mbp"
|
|
408
|
+
],
|
|
409
|
+
"claimed_by": null,
|
|
410
|
+
"pinned": false,
|
|
411
|
+
"cloud_runner": "cloud-1",
|
|
412
|
+
"cloud_runner_id": "8f2e",
|
|
413
|
+
"last_seen_at": null,
|
|
414
|
+
"menu_pending": false,
|
|
415
|
+
"settled": false,
|
|
416
|
+
"stuck": true
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
"agui": [
|
|
421
|
+
{
|
|
422
|
+
"type": "CUSTOM",
|
|
423
|
+
"name": "canopy.session.turn_status",
|
|
424
|
+
"value": {
|
|
425
|
+
"status": {
|
|
426
|
+
"state": "waiting_runner",
|
|
427
|
+
"agent_slug": "hal",
|
|
428
|
+
"runners": [
|
|
429
|
+
"jj-mbp"
|
|
430
|
+
],
|
|
431
|
+
"claimed_by": null,
|
|
432
|
+
"pinned": false,
|
|
433
|
+
"cloud_runner": "cloud-1",
|
|
434
|
+
"cloud_runner_id": "8f2e",
|
|
435
|
+
"last_seen_at": null,
|
|
436
|
+
"menu_pending": false,
|
|
437
|
+
"settled": false,
|
|
438
|
+
"stuck": true
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
]
|
|
443
|
+
},
|
|
369
444
|
{
|
|
370
445
|
"canopy": {
|
|
371
446
|
"event": "session.stop",
|
|
@@ -486,6 +561,7 @@
|
|
|
486
561
|
},
|
|
487
562
|
"agui": [
|
|
488
563
|
{
|
|
564
|
+
"type": "RUN_ERROR",
|
|
489
565
|
"metadata": {
|
|
490
566
|
"canopy": {
|
|
491
567
|
"frame": {
|
|
@@ -497,7 +573,6 @@
|
|
|
497
573
|
}
|
|
498
574
|
}
|
|
499
575
|
},
|
|
500
|
-
"type": "RUN_ERROR",
|
|
501
576
|
"message": "runner went away",
|
|
502
577
|
"code": "stream_error"
|
|
503
578
|
}
|
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
|
+
}
|
|
@@ -93,6 +93,21 @@ export interface UseSessionSocketResult {
|
|
|
93
93
|
takeOverDraft: () => void;
|
|
94
94
|
discardDraft: () => void;
|
|
95
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;
|
|
96
111
|
lastError: string | null;
|
|
97
112
|
}
|
|
98
113
|
|
|
@@ -427,6 +442,29 @@ export function useSessionSocket({
|
|
|
427
442
|
});
|
|
428
443
|
}, []);
|
|
429
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
|
+
|
|
430
468
|
return {
|
|
431
469
|
state,
|
|
432
470
|
connected,
|
|
@@ -437,6 +475,7 @@ export function useSessionSocket({
|
|
|
437
475
|
takeOverDraft,
|
|
438
476
|
discardDraft,
|
|
439
477
|
prependMessages,
|
|
478
|
+
noteLocalSend,
|
|
440
479
|
lastError,
|
|
441
480
|
};
|
|
442
481
|
}
|