canopy-ui 0.6.3 → 0.8.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 +136 -0
- package/src/chat/ChatPanel.tsx +48 -2
- package/src/chat/MenuPrompt.test.tsx +24 -0
- package/src/chat/MenuPrompt.tsx +291 -18
- package/src/chat/MessageItem.tsx +21 -8
- package/src/chat/MessageList.tsx +32 -3
- package/src/chat/PlacementBanner.test.tsx +75 -0
- package/src/chat/PlacementBanner.tsx +52 -15
- package/src/chat/PresenceChips.tsx +113 -19
- package/src/chat/SendBox.test.tsx +151 -0
- package/src/chat/SendBox.tsx +139 -10
- package/src/chat/agui.fixture.json +235 -0
- package/src/chat/agui.test.ts +298 -0
- package/src/chat/agui.ts +282 -0
- package/src/chat/drafts.test.ts +120 -0
- package/src/chat/drafts.ts +134 -0
- package/src/chat/index.ts +18 -0
- package/src/chat/pairToolMessages.ts +1 -1
- package/src/chat/protocol.ts +87 -7
- package/src/chat/restMessage.test.ts +56 -0
- package/src/chat/restMessage.ts +54 -0
- package/src/chat/sessionReducer.test.ts +102 -1
- package/src/chat/sessionReducer.ts +50 -5
- package/src/chat/useSessionSocket.ts +74 -3
- package/src/shell/WorkbenchNavItem.tsx +3 -1
- package/src/ui/button.tsx +5 -0
- package/src/ui/input.tsx +3 -1
- package/src/ui/tabs.tsx +11 -2
package/src/chat/drafts.test.ts
CHANGED
|
@@ -2,10 +2,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"
|
|
|
2
2
|
|
|
3
3
|
import type { Draft } from "./protocol"
|
|
4
4
|
import {
|
|
5
|
+
DRAFT_STORAGE_TTL_MS,
|
|
5
6
|
IDLE_THRESHOLD_MS,
|
|
7
|
+
clearStoredDraft,
|
|
8
|
+
draftStorageKey,
|
|
6
9
|
isDraftIdle,
|
|
7
10
|
msUntilDraftIdle,
|
|
11
|
+
readStoredDraft,
|
|
8
12
|
shouldSyncDraftLive,
|
|
13
|
+
writeStoredDraft,
|
|
9
14
|
} from "./drafts"
|
|
10
15
|
|
|
11
16
|
const NOW = 1_700_000_000_000
|
|
@@ -88,3 +93,118 @@ describe("shouldSyncDraftLive", () => {
|
|
|
88
93
|
expect(shouldSyncDraftLive([1, 2])).toBe(true)
|
|
89
94
|
})
|
|
90
95
|
})
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Composer persistence
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
function fakeStorage(seed: Record<string, string> = {}) {
|
|
102
|
+
const map = new Map(Object.entries(seed))
|
|
103
|
+
return {
|
|
104
|
+
map,
|
|
105
|
+
getItem: (k: string) => map.get(k) ?? null,
|
|
106
|
+
setItem: (k: string, v: string) => void map.set(k, v),
|
|
107
|
+
removeItem: (k: string) => void map.delete(k),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const KEY = "sess-1"
|
|
112
|
+
|
|
113
|
+
describe("readStoredDraft", () => {
|
|
114
|
+
it("returns null when there is nothing stored", () => {
|
|
115
|
+
expect(readStoredDraft(fakeStorage(), KEY)).toBeNull()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it("round-trips a written body", () => {
|
|
119
|
+
const s = fakeStorage()
|
|
120
|
+
writeStoredDraft(s, KEY, "half a thought", NOW)
|
|
121
|
+
expect(readStoredDraft(s, KEY, NOW + 1000)).toBe("half a thought")
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("drops and prunes an entry past the TTL", () => {
|
|
125
|
+
const s = fakeStorage()
|
|
126
|
+
writeStoredDraft(s, KEY, "stale", NOW)
|
|
127
|
+
expect(readStoredDraft(s, KEY, NOW + DRAFT_STORAGE_TTL_MS + 1)).toBeNull()
|
|
128
|
+
expect(s.map.has(draftStorageKey(KEY))).toBe(false)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("keeps an entry right up to the TTL boundary", () => {
|
|
132
|
+
const s = fakeStorage()
|
|
133
|
+
writeStoredDraft(s, KEY, "fresh enough", NOW)
|
|
134
|
+
expect(readStoredDraft(s, KEY, NOW + DRAFT_STORAGE_TTL_MS)).toBe("fresh enough")
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it("drops and prunes malformed JSON", () => {
|
|
138
|
+
const s = fakeStorage({ [draftStorageKey(KEY)]: "{not json" })
|
|
139
|
+
expect(readStoredDraft(s, KEY, NOW)).toBeNull()
|
|
140
|
+
expect(s.map.has(draftStorageKey(KEY))).toBe(false)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it("drops an entry of the wrong shape", () => {
|
|
144
|
+
const s = fakeStorage({ [draftStorageKey(KEY)]: JSON.stringify({ body: 42 }) })
|
|
145
|
+
expect(readStoredDraft(s, KEY, NOW)).toBeNull()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("reports an empty stored body as nothing to restore", () => {
|
|
149
|
+
// "" must not shadow a server draft the host does want rendered.
|
|
150
|
+
const s = fakeStorage({
|
|
151
|
+
[draftStorageKey(KEY)]: JSON.stringify({ body: "", at: NOW }),
|
|
152
|
+
})
|
|
153
|
+
expect(readStoredDraft(s, KEY, NOW)).toBeNull()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it("is inert without a storage or without a key", () => {
|
|
157
|
+
expect(readStoredDraft(null, KEY)).toBeNull()
|
|
158
|
+
expect(readStoredDraft(fakeStorage(), "")).toBeNull()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("survives a storage that throws on read", () => {
|
|
162
|
+
// Safari private mode / blocked third-party storage.
|
|
163
|
+
const s = {
|
|
164
|
+
getItem: () => {
|
|
165
|
+
throw new Error("SecurityError")
|
|
166
|
+
},
|
|
167
|
+
setItem: () => {},
|
|
168
|
+
removeItem: () => {},
|
|
169
|
+
}
|
|
170
|
+
expect(() => readStoredDraft(s, KEY)).not.toThrow()
|
|
171
|
+
expect(readStoredDraft(s, KEY)).toBeNull()
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe("writeStoredDraft", () => {
|
|
176
|
+
it("clears the entry instead of storing an empty body", () => {
|
|
177
|
+
const s = fakeStorage()
|
|
178
|
+
writeStoredDraft(s, KEY, "typed", NOW)
|
|
179
|
+
writeStoredDraft(s, KEY, "", NOW)
|
|
180
|
+
expect(s.map.has(draftStorageKey(KEY))).toBe(false)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it("keeps one entry per session", () => {
|
|
184
|
+
const s = fakeStorage()
|
|
185
|
+
writeStoredDraft(s, "a", "for a", NOW)
|
|
186
|
+
writeStoredDraft(s, "b", "for b", NOW)
|
|
187
|
+
expect(readStoredDraft(s, "a", NOW)).toBe("for a")
|
|
188
|
+
expect(readStoredDraft(s, "b", NOW)).toBe("for b")
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it("swallows a quota error rather than breaking a keystroke", () => {
|
|
192
|
+
const s = {
|
|
193
|
+
getItem: () => null,
|
|
194
|
+
setItem: () => {
|
|
195
|
+
throw new Error("QuotaExceededError")
|
|
196
|
+
},
|
|
197
|
+
removeItem: () => {},
|
|
198
|
+
}
|
|
199
|
+
expect(() => writeStoredDraft(s, KEY, "x")).not.toThrow()
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe("clearStoredDraft", () => {
|
|
204
|
+
it("removes a stored draft", () => {
|
|
205
|
+
const s = fakeStorage()
|
|
206
|
+
writeStoredDraft(s, KEY, "sent now", NOW)
|
|
207
|
+
clearStoredDraft(s, KEY)
|
|
208
|
+
expect(readStoredDraft(s, KEY, NOW)).toBeNull()
|
|
209
|
+
})
|
|
210
|
+
})
|
package/src/chat/drafts.ts
CHANGED
|
@@ -35,3 +35,137 @@ export function msUntilDraftIdle(draft: Draft | null | undefined): number {
|
|
|
35
35
|
const elapsed = Date.now() - new Date(draft.last_edit_at).getTime();
|
|
36
36
|
return Math.max(0, IDLE_THRESHOLD_MS - elapsed);
|
|
37
37
|
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Composer persistence — surviving a navigation the server never hears about.
|
|
41
|
+
//
|
|
42
|
+
// The composer is local-first (see SendBox) and, alone in a session,
|
|
43
|
+
// `shouldSyncDraftLive` keeps it that way: nothing is mirrored to the server
|
|
44
|
+
// until the moment before `chat.send`. Those two facts are right on their own
|
|
45
|
+
// and, together, mean a half-typed message dies the instant SendBox unmounts —
|
|
46
|
+
// route away from /chat/:id and back and the box is empty, with no copy of the
|
|
47
|
+
// text anywhere. (Adopting your OWN server draft on mount wouldn't fix it:
|
|
48
|
+
// single-player never put one there.)
|
|
49
|
+
//
|
|
50
|
+
// So the copy has to be local too. localStorage rather than sessionStorage
|
|
51
|
+
// because "come back to it" includes closing the tab; per-browser, deliberately
|
|
52
|
+
// not synced across devices.
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/** Just the slice of `Storage` this needs — so tests can pass a plain fake and
|
|
56
|
+
* a host can pass sessionStorage if it prefers per-tab drafts. */
|
|
57
|
+
export interface DraftStorage {
|
|
58
|
+
getItem(key: string): string | null;
|
|
59
|
+
setItem(key: string, value: string): void;
|
|
60
|
+
removeItem(key: string): void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DRAFT_STORAGE_PREFIX = "canopy.chat.draft.";
|
|
64
|
+
|
|
65
|
+
/** Drafts older than this are treated as gone. A month-old half-sentence is
|
|
66
|
+
* not something you meant to come back to, and restoring it into a live
|
|
67
|
+
* session is worse than losing it. */
|
|
68
|
+
export const DRAFT_STORAGE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
69
|
+
|
|
70
|
+
export function draftStorageKey(persistKey: string): string {
|
|
71
|
+
return `${DRAFT_STORAGE_PREFIX}${persistKey}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `window.localStorage` when it is usable, else null.
|
|
76
|
+
*
|
|
77
|
+
* Merely TOUCHING the property throws in a sandboxed iframe or with
|
|
78
|
+
* third-party storage blocked, so this is a try/catch and not a truthiness
|
|
79
|
+
* check. Null disables persistence and changes nothing else — typing must
|
|
80
|
+
* never depend on storage being available.
|
|
81
|
+
*/
|
|
82
|
+
export function defaultDraftStorage(): DraftStorage | null {
|
|
83
|
+
try {
|
|
84
|
+
return typeof window === "undefined" ? null : window.localStorage;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The stored body for a session, or null when there is nothing worth
|
|
92
|
+
* restoring. Anything unreadable — absent, malformed, wrong shape, expired —
|
|
93
|
+
* is dropped and reported as null, and an expired or corrupt entry is pruned on
|
|
94
|
+
* the way past so it cannot accumulate.
|
|
95
|
+
*/
|
|
96
|
+
export function readStoredDraft(
|
|
97
|
+
storage: DraftStorage | null,
|
|
98
|
+
persistKey: string,
|
|
99
|
+
now: number = Date.now(),
|
|
100
|
+
): string | null {
|
|
101
|
+
if (!storage || !persistKey) return null;
|
|
102
|
+
const key = draftStorageKey(persistKey);
|
|
103
|
+
let raw: string | null = null;
|
|
104
|
+
try {
|
|
105
|
+
raw = storage.getItem(key);
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
if (raw == null) return null;
|
|
110
|
+
|
|
111
|
+
let parsed: unknown;
|
|
112
|
+
try {
|
|
113
|
+
parsed = JSON.parse(raw);
|
|
114
|
+
} catch {
|
|
115
|
+
remove(storage, key);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const record = parsed as { body?: unknown; at?: unknown } | null;
|
|
120
|
+
const body = record?.body;
|
|
121
|
+
const at = record?.at;
|
|
122
|
+
if (typeof body !== "string" || typeof at !== "number") {
|
|
123
|
+
remove(storage, key);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
if (now - at > DRAFT_STORAGE_TTL_MS) {
|
|
127
|
+
remove(storage, key);
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
// An empty body is not a draft. Returning "" would be indistinguishable from
|
|
131
|
+
// a real restore and would shadow a server draft the host DOES want shown.
|
|
132
|
+
return body === "" ? null : body;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Persist the composer body. Writing an empty body clears the entry rather
|
|
136
|
+
* than storing one, so an emptied box leaves nothing behind to restore. */
|
|
137
|
+
export function writeStoredDraft(
|
|
138
|
+
storage: DraftStorage | null,
|
|
139
|
+
persistKey: string,
|
|
140
|
+
body: string,
|
|
141
|
+
now: number = Date.now(),
|
|
142
|
+
): void {
|
|
143
|
+
if (!storage || !persistKey) return;
|
|
144
|
+
const key = draftStorageKey(persistKey);
|
|
145
|
+
if (body === "") {
|
|
146
|
+
remove(storage, key);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
storage.setItem(key, JSON.stringify({ body, at: now }));
|
|
151
|
+
} catch {
|
|
152
|
+
// Quota exceeded, or storage disabled mid-session. A dropped draft backup
|
|
153
|
+
// is not worth breaking a keystroke over.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function clearStoredDraft(
|
|
158
|
+
storage: DraftStorage | null,
|
|
159
|
+
persistKey: string,
|
|
160
|
+
): void {
|
|
161
|
+
if (!storage || !persistKey) return;
|
|
162
|
+
remove(storage, draftStorageKey(persistKey));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function remove(storage: DraftStorage, key: string): void {
|
|
166
|
+
try {
|
|
167
|
+
storage.removeItem(key);
|
|
168
|
+
} catch {
|
|
169
|
+
// same as above — best effort
|
|
170
|
+
}
|
|
171
|
+
}
|
package/src/chat/index.ts
CHANGED
|
@@ -17,6 +17,10 @@ export type {
|
|
|
17
17
|
WsEvent,
|
|
18
18
|
} from "./protocol";
|
|
19
19
|
|
|
20
|
+
// REST <-> kit conversion. Shared because both hosts that read a transcript over
|
|
21
|
+
// REST were writing this out by hand, against this kit's own Message type.
|
|
22
|
+
export { restToKitMessage, type RestMessage } from "./restMessage";
|
|
23
|
+
|
|
20
24
|
// Reducer (pure)
|
|
21
25
|
export { sessionReducer } from "./sessionReducer";
|
|
22
26
|
export { prependHistory } from "./history";
|
|
@@ -32,6 +36,17 @@ export { useStickyBottom } from "./useStickyBottom";
|
|
|
32
36
|
// Draft idle helpers
|
|
33
37
|
export { IDLE_THRESHOLD_MS, isDraftIdle, msUntilDraftIdle } from "./drafts";
|
|
34
38
|
|
|
39
|
+
// Composer persistence (survives unmount / tab close)
|
|
40
|
+
export {
|
|
41
|
+
DRAFT_STORAGE_TTL_MS,
|
|
42
|
+
clearStoredDraft,
|
|
43
|
+
defaultDraftStorage,
|
|
44
|
+
draftStorageKey,
|
|
45
|
+
readStoredDraft,
|
|
46
|
+
writeStoredDraft,
|
|
47
|
+
type DraftStorage,
|
|
48
|
+
} from "./drafts";
|
|
49
|
+
|
|
35
50
|
// Tool-message pairing helpers
|
|
36
51
|
export {
|
|
37
52
|
pairToolMessages,
|
|
@@ -56,3 +71,6 @@ export {
|
|
|
56
71
|
type PlacementBannerProps,
|
|
57
72
|
type PlacementRunner,
|
|
58
73
|
} from "./PlacementBanner";
|
|
74
|
+
// The AG-UI projection's inverse. Exported so a consumer can translate a stream
|
|
75
|
+
// it obtained some other way — and so the round-trip test can reach it.
|
|
76
|
+
export { fromAgui, resetAguiState, CUSTOM_PREFIX, METADATA_KEY } from "./agui";
|
|
@@ -12,7 +12,7 @@ import type { Message } from "./protocol";
|
|
|
12
12
|
* has no other way to tell which result belongs to which call.
|
|
13
13
|
*
|
|
14
14
|
* Not every producer stamps ids yet: events already in the ledger predate
|
|
15
|
-
* this field, and some runners (see `
|
|
15
|
+
* this field, and some runners (see `runner/canopy_runner`) don't emit it
|
|
16
16
|
* until updated separately. When a ``tool_result`` carries no id at all, it
|
|
17
17
|
* falls back to the FIRST still-open ``tool_use`` that *also* has no id
|
|
18
18
|
* (oldest-first, FIFO) — today's pre-correlation pairing heuristic. That
|
package/src/chat/protocol.ts
CHANGED
|
@@ -60,16 +60,64 @@ export interface Participant {
|
|
|
60
60
|
last_seen_at: string | null;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/** A dialog an agent is blocked on
|
|
63
|
+
/** A dialog an agent is blocked on.
|
|
64
64
|
*
|
|
65
65
|
* `title` and `body` are what makes it answerable away from the keyboard:
|
|
66
|
-
* "Do you want to proceed?" tells you nothing without the command it means.
|
|
66
|
+
* "Do you want to proceed?" tells you nothing without the command it means.
|
|
67
|
+
*
|
|
68
|
+
* Two producers, one shape, so this never grows a second reader: the session
|
|
69
|
+
* report derives it from the transcript (an `AskUserQuestion` tool call — what
|
|
70
|
+
* actually blocks a fleet running `bypass permissions`), and the runner can
|
|
71
|
+
* still read it off the rendered screen for the dialogs a transcript cannot
|
|
72
|
+
* see. `source` says which, and a client is free to ignore it. */
|
|
73
|
+
/** One question of an `AskUserQuestion`. The TUI draws these as TABS and will
|
|
74
|
+
* not submit until each has an answer — so a surface that renders only the
|
|
75
|
+
* first one cannot complete the ask no matter which button is pressed. That
|
|
76
|
+
* was the bug: a two-question closeout showed one question, and a tap toggled
|
|
77
|
+
* a checkbox on a dialog that then sat waiting for a Submit nobody could
|
|
78
|
+
* reach (eva, 2026-08-12). */
|
|
79
|
+
export interface MenuQuestion {
|
|
80
|
+
index: number;
|
|
81
|
+
question: string;
|
|
82
|
+
header?: string;
|
|
83
|
+
/** Whether this question takes ANY number of answers. The TUI renders it as
|
|
84
|
+
* checkboxes and a number key TOGGLES one instead of answering, which is why
|
|
85
|
+
* a client that cannot see this flag renders the wrong control AND the
|
|
86
|
+
* runner presses the wrong key. */
|
|
87
|
+
multi_select?: boolean;
|
|
88
|
+
options: { number: number; label: string; description?: string }[];
|
|
89
|
+
}
|
|
90
|
+
|
|
67
91
|
export interface SessionMenu {
|
|
68
92
|
question: string;
|
|
69
93
|
title?: string;
|
|
70
94
|
body?: string;
|
|
71
95
|
selected?: number | null;
|
|
72
|
-
|
|
96
|
+
source?: string;
|
|
97
|
+
/** Every question in the ask, in declaration order. Absent on a dialog with
|
|
98
|
+
* no tool call behind it (a permission prompt, a trust gate) and on a menu
|
|
99
|
+
* from a producer older than this — in both cases the client falls back to
|
|
100
|
+
* the single-question fields above, which still describe question 1. */
|
|
101
|
+
questions?: MenuQuestion[];
|
|
102
|
+
/** `description` is present on the transcript path and is often the only
|
|
103
|
+
* thing that distinguishes two options — "Proceed to Phase 4" does not say
|
|
104
|
+
* that Phase 4 is test-gated, and its description does. */
|
|
105
|
+
options: { number: number; label: string; description?: string }[];
|
|
106
|
+
/** Set when a human's tap was RELAYED to the runner and then refused there —
|
|
107
|
+
* a stale dialog, a shell tab selected in emdash, an unreachable box. The
|
|
108
|
+
* API answers `ok:true` the moment it relays the frame, so without this a
|
|
109
|
+
* correct refusal is indistinguishable from a press that worked, and the
|
|
110
|
+
* button reads as dead. `answer_note` is the sentence to show; the code is
|
|
111
|
+
* for logs. The menu stays up alongside it, so there is something to retry. */
|
|
112
|
+
answer_error?: string;
|
|
113
|
+
answer_note?: string;
|
|
114
|
+
/** Carried across a runner restart rather than observed this process. Nothing
|
|
115
|
+
* should branch on it — a tap verifies against the real screen either way. */
|
|
116
|
+
restored?: boolean;
|
|
117
|
+
/** Epoch seconds when a producer last SAW this dialog. The dialog lives on a
|
|
118
|
+
* terminal; this object is a copy, and without an age the only way to find
|
|
119
|
+
* out the copy is stale is to tap it and be refused. */
|
|
120
|
+
observed_at?: number;
|
|
73
121
|
}
|
|
74
122
|
|
|
75
123
|
export interface SessionState {
|
|
@@ -83,9 +131,20 @@ export interface SessionState {
|
|
|
83
131
|
* those apart — but it is the difference between "still thinking, wait" and
|
|
84
132
|
* "it is waiting on YOU", which previously rendered identically. */
|
|
85
133
|
activity?: "working" | "idle" | "blocked";
|
|
86
|
-
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
134
|
+
/** Whether a stop the human asked for actually landed. A SEPARATE axis from
|
|
135
|
+
* `activity`, deliberately: a stop that failed leaves the agent `working`,
|
|
136
|
+
* which is true and must stay true, so the outcome of the stop cannot be a
|
|
137
|
+
* value of activity without either lying or being lost.
|
|
138
|
+
*
|
|
139
|
+
* "requested" is set the moment the server publishes to the runner — nothing
|
|
140
|
+
* has pressed Escape yet. Only the runner can say "stopped" or "failed", and
|
|
141
|
+
* it only says so after verifying the terminal (see #649: an unverified
|
|
142
|
+
* Escape reported as success is what made Stop untrustworthy). */
|
|
143
|
+
stopState?: "requested" | "stopped" | "failed";
|
|
144
|
+
/** The dialog the agent is waiting on. Carried in the CONNECT SNAPSHOT, not
|
|
145
|
+
* only in live frames: `session.activity` is view-only and reaches a client
|
|
146
|
+
* only if it was already connected when the agent blocked — which is exactly
|
|
147
|
+
* the case that fails, because you go and look BECAUSE it stopped. */
|
|
89
148
|
menu?: SessionMenu;
|
|
90
149
|
active_draft: Draft | null;
|
|
91
150
|
participants: Participant[];
|
|
@@ -113,6 +172,13 @@ export type WsEvent =
|
|
|
113
172
|
// The agent started or finished a turn. Distinct from tool events: it fires
|
|
114
173
|
// while Claude is THINKING, before any content exists to show.
|
|
115
174
|
| { event: "session.activity"; data: { state: "working" | "idle" | "blocked"; menu?: SessionMenu } }
|
|
175
|
+
| { event: "session.stop"; data: { state: "requested" | "stopped" | "failed" } }
|
|
176
|
+
// The agent started, or stopped, waiting on a dialog. Its own frame rather
|
|
177
|
+
// than an overloaded `session.activity`: activity answers "is it producing",
|
|
178
|
+
// which the hook path owns on a much faster clock, and inventing a state here
|
|
179
|
+
// to carry a menu would report an agent as idle or blocked on the wrong one.
|
|
180
|
+
// `menu: null` is the retraction — somebody answered at the keyboard.
|
|
181
|
+
| { event: "session.menu"; data: { menu: SessionMenu | null } }
|
|
116
182
|
// A human typed into emdash rather than into this page. No client echoed it,
|
|
117
183
|
// so this is the only way it reaches the browser before a reload.
|
|
118
184
|
| { event: "chat.user_message"; data: { message_id: string; turn_index: number; plaintext: string } }
|
|
@@ -129,5 +195,19 @@ export type WsEvent =
|
|
|
129
195
|
| { event: "draft.lock_changed"; data: { draft_id: string; holder_user_id: number | null; expires_at: number | null } }
|
|
130
196
|
| { event: "draft.committed"; data: { draft_id: string; user_message_id: string } }
|
|
131
197
|
| { event: "draft.discarded"; data: { draft_id: string } }
|
|
132
|
-
|
|
198
|
+
// `participant` carries WHO joined, so a client can add them to its
|
|
199
|
+
// participant list. Without it a first-time joiner has an id and no name,
|
|
200
|
+
// and the presence row (which renders participants filtered by presence)
|
|
201
|
+
// cannot show them at all. Optional so an older server degrades rather than
|
|
202
|
+
// breaks. The bare `email`/`display_name` below are the vestigial shape that
|
|
203
|
+
// was declared but never sent by anything.
|
|
204
|
+
| {
|
|
205
|
+
event: "presence.joined";
|
|
206
|
+
data: {
|
|
207
|
+
user_id: number;
|
|
208
|
+
participant?: Participant;
|
|
209
|
+
email?: string;
|
|
210
|
+
display_name?: string;
|
|
211
|
+
};
|
|
212
|
+
}
|
|
133
213
|
| { event: "presence.left"; data: { user_id: number } };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { prependHistory } from "./history";
|
|
4
|
+
import { restToKitMessage, type RestMessage } from "./restMessage";
|
|
5
|
+
import type { Message } from "./protocol";
|
|
6
|
+
|
|
7
|
+
const row: RestMessage = {
|
|
8
|
+
turn_index: 7,
|
|
9
|
+
role: "assistant",
|
|
10
|
+
content: { text: "hello" },
|
|
11
|
+
plaintext: "hello",
|
|
12
|
+
created_at: "2026-09-16T10:00:00Z",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe("restToKitMessage", () => {
|
|
16
|
+
it("maps a REST row onto the kit's Message shape", () => {
|
|
17
|
+
expect(restToKitMessage(row)).toEqual({
|
|
18
|
+
id: "t7",
|
|
19
|
+
turn_index: 7,
|
|
20
|
+
role: "assistant",
|
|
21
|
+
content: { text: "hello" },
|
|
22
|
+
plaintext: "hello",
|
|
23
|
+
status: "complete",
|
|
24
|
+
error_detail: null,
|
|
25
|
+
started_at: null,
|
|
26
|
+
completed_at: "2026-09-16T10:00:00Z",
|
|
27
|
+
created_at: "2026-09-16T10:00:00Z",
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("gives a row read back from REST no streaming history", () => {
|
|
32
|
+
// A REST row was never watched arriving, so claiming a `started_at` would
|
|
33
|
+
// invent a fact. `completed_at` is what the server does know.
|
|
34
|
+
const m = restToKitMessage(row);
|
|
35
|
+
expect(m.started_at).toBeNull();
|
|
36
|
+
expect(m.completed_at).toBe(row.created_at);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("accepts a readonly generated row (what both hosts actually pass)", () => {
|
|
40
|
+
// openapi-typescript emits every field `readonly`. Property readonly-ness
|
|
41
|
+
// does not affect assignability, and this asserts that stays true — it is
|
|
42
|
+
// the reason `RestMessage` can be declared structurally instead of as
|
|
43
|
+
// either host's generated type.
|
|
44
|
+
const generated: { readonly [K in keyof RestMessage]: RestMessage[K] } = row;
|
|
45
|
+
expect(restToKitMessage(generated).id).toBe("t7");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("does not collide with the live WS row for the same turn", () => {
|
|
49
|
+
// The synthetic `t<turn_index>` id is only safe because prependHistory
|
|
50
|
+
// dedupes on turn_index. If that ever changed to dedupe on id, this fails.
|
|
51
|
+
const live: Message = { ...restToKitMessage(row), id: "1234", status: "streaming" };
|
|
52
|
+
const merged = prependHistory([live], [restToKitMessage(row)]);
|
|
53
|
+
expect(merged).toHaveLength(1);
|
|
54
|
+
expect(merged[0].id).toBe("1234");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One REST transcript row -> the kit's `Message`.
|
|
3
|
+
*
|
|
4
|
+
* This lived twice, written out by hand in both hosts that read a transcript
|
|
5
|
+
* over REST and then hand it to this kit: canopy-web's own
|
|
6
|
+
* `pages/chatPageLogic.ts` and ace-web's `canopy/CanopyChatPanel.tsx`, whose
|
|
7
|
+
* copy carried the comment "mirrors canopy-web's own
|
|
8
|
+
* `chatPageLogic.ts::restToKitMessage`" — an accurate description of a bug
|
|
9
|
+
* waiting to happen. It is a pure function OF this kit's own `Message` type
|
|
10
|
+
* against canopy's own `MessageOut` wire schema, so neither host was ever the
|
|
11
|
+
* right owner of it; both were translating between two shapes they had each
|
|
12
|
+
* imported from somewhere else.
|
|
13
|
+
*
|
|
14
|
+
* The input is declared structurally rather than as either host's generated
|
|
15
|
+
* type, because the hosts generate their own: canopy-web has
|
|
16
|
+
* `components["schemas"]["MessageOut"]`, ace-web casts an `unknown` row. Both
|
|
17
|
+
* are assignable to `RestMessage`, and a generated type's `readonly` property
|
|
18
|
+
* modifiers do not affect that.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { Message } from "./protocol";
|
|
22
|
+
|
|
23
|
+
/** canopy's `MessageOut` (apps/canopy_sessions/schemas.py), structurally. */
|
|
24
|
+
export interface RestMessage {
|
|
25
|
+
turn_index: number;
|
|
26
|
+
role: string;
|
|
27
|
+
content: Record<string, unknown>;
|
|
28
|
+
plaintext: string;
|
|
29
|
+
created_at: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Synthetic id (`t<turn_index>`) + `status: "complete"`.
|
|
34
|
+
*
|
|
35
|
+
* `prependHistory` dedupes on `turn_index`, not on `id`, so a synthetic id can
|
|
36
|
+
* never collide with the live WS row for the same turn — which is the whole
|
|
37
|
+
* reason it is safe to invent one here. `started_at` is null because a row read
|
|
38
|
+
* back from REST has no streaming history to report; `completed_at` takes
|
|
39
|
+
* `created_at`, which is when the turn finished as far as the server records it.
|
|
40
|
+
*/
|
|
41
|
+
export function restToKitMessage(m: RestMessage): Message {
|
|
42
|
+
return {
|
|
43
|
+
id: `t${m.turn_index}`,
|
|
44
|
+
turn_index: m.turn_index,
|
|
45
|
+
role: m.role as Message["role"],
|
|
46
|
+
content: m.content,
|
|
47
|
+
plaintext: m.plaintext,
|
|
48
|
+
status: "complete",
|
|
49
|
+
error_detail: null,
|
|
50
|
+
started_at: null,
|
|
51
|
+
completed_at: m.created_at,
|
|
52
|
+
created_at: m.created_at,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -617,7 +617,7 @@ describe("the dialog an agent is blocked on", () => {
|
|
|
617
617
|
expect(after.menu).toBeUndefined();
|
|
618
618
|
});
|
|
619
619
|
|
|
620
|
-
it("is cleared by a later activity frame that
|
|
620
|
+
it("is cleared by a later activity frame that says the agent is not waiting", () => {
|
|
621
621
|
const blocked = sessionReducer(makeState(), {
|
|
622
622
|
event: "session.activity",
|
|
623
623
|
data: { state: "blocked", menu: MENU },
|
|
@@ -625,4 +625,105 @@ describe("the dialog an agent is blocked on", () => {
|
|
|
625
625
|
const idle = sessionReducer(blocked, { event: "session.activity", data: { state: "idle" } });
|
|
626
626
|
expect(idle.menu).toBeUndefined();
|
|
627
627
|
});
|
|
628
|
+
|
|
629
|
+
it("survives a blocked frame that carries no menu", () => {
|
|
630
|
+
// The hook path reports `blocked` WITHOUT a menu — it deliberately does not
|
|
631
|
+
// read the screen (#510: doing so stole emdash's focus). Treating that as a
|
|
632
|
+
// retraction would erase a menu the snapshot or the session report had
|
|
633
|
+
// already supplied, which is every menu we now have.
|
|
634
|
+
const blocked = sessionReducer(makeState(), {
|
|
635
|
+
event: "session.activity",
|
|
636
|
+
data: { state: "blocked", menu: MENU },
|
|
637
|
+
});
|
|
638
|
+
const again = sessionReducer(blocked, { event: "session.activity", data: { state: "blocked" } });
|
|
639
|
+
expect(again.menu?.question).toBe("Do you want to proceed?");
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("arrives in the connect snapshot, not only in a live frame", () => {
|
|
643
|
+
// THE fix for "when I click on the session I don't see the menu". Activity
|
|
644
|
+
// frames are view-only and reach a client only if it was connected when
|
|
645
|
+
// they fired; you open the session precisely because it stopped.
|
|
646
|
+
const state = sessionReducer(makeState(), {
|
|
647
|
+
event: "session.state",
|
|
648
|
+
data: makeState({ activity: "blocked", menu: MENU }),
|
|
649
|
+
});
|
|
650
|
+
expect(state.menu?.question).toBe("Do you want to proceed?");
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("appears while you are already watching, via its own frame", () => {
|
|
654
|
+
const state = sessionReducer(makeState(), {
|
|
655
|
+
event: "session.menu",
|
|
656
|
+
data: { menu: MENU },
|
|
657
|
+
});
|
|
658
|
+
expect(state.menu?.options).toHaveLength(2);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it("is retracted when somebody answers at the keyboard", () => {
|
|
662
|
+
// `menu: null` from the session report. Buttons that outlive their dialog
|
|
663
|
+
// press a number into what is now an ordinary prompt, where the agent reads
|
|
664
|
+
// a bare "1" as an instruction.
|
|
665
|
+
const blocked = sessionReducer(makeState(), {
|
|
666
|
+
event: "session.menu",
|
|
667
|
+
data: { menu: MENU },
|
|
668
|
+
});
|
|
669
|
+
const cleared = sessionReducer(blocked, { event: "session.menu", data: { menu: null } });
|
|
670
|
+
expect(cleared.menu).toBeUndefined();
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it("keeps an option's description, which is often the whole difference", () => {
|
|
674
|
+
const state = sessionReducer(makeState(), {
|
|
675
|
+
event: "session.menu",
|
|
676
|
+
data: {
|
|
677
|
+
menu: {
|
|
678
|
+
question: "How should the run proceed?",
|
|
679
|
+
options: [
|
|
680
|
+
{ number: 1, label: "Proceed to Phase 4", description: "nothing reaches a real LLO" },
|
|
681
|
+
{ number: 2, label: "Stop the run here", description: "end at the Phase 3 boundary" },
|
|
682
|
+
],
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
});
|
|
686
|
+
expect(state.menu?.options[0].description).toContain("real LLO");
|
|
687
|
+
});
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
describe("session.stop — whether the stop actually landed", () => {
|
|
691
|
+
const base = (over: Partial<SessionState> = {}): SessionState => ({
|
|
692
|
+
messages: [],
|
|
693
|
+
active_draft: null,
|
|
694
|
+
participants: [],
|
|
695
|
+
presence_user_ids: [],
|
|
696
|
+
current_user_id: 1,
|
|
697
|
+
...over,
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it("records a requested stop without claiming it worked", () => {
|
|
701
|
+
const out = sessionReducer(base(), {
|
|
702
|
+
event: "session.stop",
|
|
703
|
+
data: { state: "requested" },
|
|
704
|
+
} as WsEvent);
|
|
705
|
+
expect(out.stopState).toBe("requested");
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
it("keeps the agent WORKING when the stop failed", () => {
|
|
709
|
+
// The whole point of a separate axis. A failed stop leaves the agent running,
|
|
710
|
+
// and both facts have to survive together — collapsing them into `activity`
|
|
711
|
+
// loses whichever one loses the race, and it was always the stop.
|
|
712
|
+
const out = sessionReducer(base({ activity: "working" }), {
|
|
713
|
+
event: "session.stop",
|
|
714
|
+
data: { state: "failed" },
|
|
715
|
+
} as WsEvent);
|
|
716
|
+
expect(out.stopState).toBe("failed");
|
|
717
|
+
expect(out.activity).toBe("working");
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it("a new turn clears a stale stop outcome", () => {
|
|
721
|
+
// "your stop did not take" pinned over fresh work is a warning about
|
|
722
|
+
// something the human has already moved on from.
|
|
723
|
+
const out = sessionReducer(base({ stopState: "failed" }), {
|
|
724
|
+
event: "chat.stream_start",
|
|
725
|
+
data: { message_id: "m1" },
|
|
726
|
+
} as WsEvent);
|
|
727
|
+
expect(out.stopState).toBeUndefined();
|
|
728
|
+
});
|
|
628
729
|
});
|