canopy-ui 0.7.0 → 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/agui.fixture.json +235 -0
- package/src/chat/agui.test.ts +298 -0
- package/src/chat/agui.ts +282 -0
- package/src/chat/index.ts +7 -0
- package/src/chat/restMessage.test.ts +56 -0
- package/src/chat/restMessage.ts +54 -0
- 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/package.json
CHANGED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"canopy": {
|
|
4
|
+
"event": "chat.stream_start",
|
|
5
|
+
"data": {
|
|
6
|
+
"message_id": "m1",
|
|
7
|
+
"turn_index": 4
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
"agui": [
|
|
11
|
+
{
|
|
12
|
+
"metadata": {
|
|
13
|
+
"canopy": {
|
|
14
|
+
"turn_index": 4
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"type": "TEXT_MESSAGE_START",
|
|
18
|
+
"messageId": "m1",
|
|
19
|
+
"role": "assistant"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"canopy": {
|
|
25
|
+
"event": "chat.delta",
|
|
26
|
+
"data": {
|
|
27
|
+
"message_id": "m1",
|
|
28
|
+
"text": "hello"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"agui": [
|
|
32
|
+
{
|
|
33
|
+
"type": "TEXT_MESSAGE_CONTENT",
|
|
34
|
+
"messageId": "m1",
|
|
35
|
+
"delta": "hello"
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"canopy": {
|
|
41
|
+
"event": "chat.stream_complete",
|
|
42
|
+
"data": {
|
|
43
|
+
"message_id": "m1",
|
|
44
|
+
"plaintext": "hello"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"agui": [
|
|
48
|
+
{
|
|
49
|
+
"metadata": {
|
|
50
|
+
"canopy": {
|
|
51
|
+
"plaintext": "hello"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"type": "TEXT_MESSAGE_END",
|
|
55
|
+
"messageId": "m1"
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"canopy": {
|
|
61
|
+
"event": "chat.user_message",
|
|
62
|
+
"data": {
|
|
63
|
+
"message_id": "u1",
|
|
64
|
+
"turn_index": 3,
|
|
65
|
+
"plaintext": "hi there"
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"agui": [
|
|
69
|
+
{
|
|
70
|
+
"metadata": {
|
|
71
|
+
"canopy": {
|
|
72
|
+
"turn_index": 3
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
"type": "TEXT_MESSAGE_CHUNK",
|
|
76
|
+
"messageId": "u1",
|
|
77
|
+
"role": "user",
|
|
78
|
+
"delta": "hi there"
|
|
79
|
+
}
|
|
80
|
+
]
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"canopy": {
|
|
84
|
+
"event": "chat.tool_use",
|
|
85
|
+
"data": {
|
|
86
|
+
"tool_message_id": "t9",
|
|
87
|
+
"parent_message_id": "m1",
|
|
88
|
+
"turn_index": 5,
|
|
89
|
+
"block": {
|
|
90
|
+
"name": "list_insights",
|
|
91
|
+
"input": {
|
|
92
|
+
"limit": 5
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"agui": [
|
|
98
|
+
{
|
|
99
|
+
"metadata": {
|
|
100
|
+
"canopy": {
|
|
101
|
+
"turn_index": 5,
|
|
102
|
+
"block": {
|
|
103
|
+
"name": "list_insights",
|
|
104
|
+
"input": {
|
|
105
|
+
"limit": 5
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
"type": "TOOL_CALL_START",
|
|
111
|
+
"toolCallId": "t9",
|
|
112
|
+
"toolCallName": "list_insights",
|
|
113
|
+
"parentMessageId": "m1"
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"type": "TOOL_CALL_ARGS",
|
|
117
|
+
"toolCallId": "t9",
|
|
118
|
+
"delta": "{\"limit\": 5}"
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"type": "TOOL_CALL_END",
|
|
122
|
+
"toolCallId": "t9"
|
|
123
|
+
}
|
|
124
|
+
]
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"canopy": {
|
|
128
|
+
"event": "chat.tool_result",
|
|
129
|
+
"data": {
|
|
130
|
+
"tool_message_id": "t9",
|
|
131
|
+
"parent_message_id": "m1",
|
|
132
|
+
"turn_index": 6,
|
|
133
|
+
"block": {
|
|
134
|
+
"type": "tool_result",
|
|
135
|
+
"tool_use_id": "toolu_01ABC",
|
|
136
|
+
"content": "[]"
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
"agui": [
|
|
141
|
+
{
|
|
142
|
+
"metadata": {
|
|
143
|
+
"canopy": {
|
|
144
|
+
"turn_index": 6,
|
|
145
|
+
"parent_message_id": "m1",
|
|
146
|
+
"block": {
|
|
147
|
+
"type": "tool_result",
|
|
148
|
+
"tool_use_id": "toolu_01ABC",
|
|
149
|
+
"content": "[]"
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
"type": "TOOL_CALL_RESULT",
|
|
154
|
+
"messageId": "t9",
|
|
155
|
+
"toolCallId": "t9",
|
|
156
|
+
"content": "[]"
|
|
157
|
+
}
|
|
158
|
+
]
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
"canopy": {
|
|
162
|
+
"event": "session.title_updated",
|
|
163
|
+
"data": {
|
|
164
|
+
"title": "Insights triage"
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
"agui": [
|
|
168
|
+
{
|
|
169
|
+
"type": "STATE_DELTA",
|
|
170
|
+
"delta": [
|
|
171
|
+
{
|
|
172
|
+
"op": "replace",
|
|
173
|
+
"path": "/title",
|
|
174
|
+
"value": "Insights triage"
|
|
175
|
+
}
|
|
176
|
+
]
|
|
177
|
+
}
|
|
178
|
+
]
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
"canopy": {
|
|
182
|
+
"event": "draft.updated",
|
|
183
|
+
"data": {
|
|
184
|
+
"id": "d1",
|
|
185
|
+
"body": "x",
|
|
186
|
+
"version": 2
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
"agui": [
|
|
190
|
+
{
|
|
191
|
+
"type": "CUSTOM",
|
|
192
|
+
"name": "canopy.draft.updated",
|
|
193
|
+
"value": {
|
|
194
|
+
"id": "d1",
|
|
195
|
+
"body": "x",
|
|
196
|
+
"version": 2
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
]
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
"canopy": {
|
|
203
|
+
"event": "presence.joined",
|
|
204
|
+
"data": {
|
|
205
|
+
"user_id": 7
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
"agui": [
|
|
209
|
+
{
|
|
210
|
+
"type": "CUSTOM",
|
|
211
|
+
"name": "canopy.presence.joined",
|
|
212
|
+
"value": {
|
|
213
|
+
"user_id": 7
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
]
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
"canopy": {
|
|
220
|
+
"event": "presence.left",
|
|
221
|
+
"data": {
|
|
222
|
+
"user_id": 7
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
"agui": [
|
|
226
|
+
{
|
|
227
|
+
"type": "CUSTOM",
|
|
228
|
+
"name": "canopy.presence.left",
|
|
229
|
+
"value": {
|
|
230
|
+
"user_id": 7
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
]
|
|
234
|
+
}
|
|
235
|
+
]
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import fixture from './agui.fixture.json'
|
|
6
|
+
import { fromAgui, resetAguiState } from './agui'
|
|
7
|
+
import { sessionReducer } from './sessionReducer'
|
|
8
|
+
import type { SessionState, WsEvent } from './protocol'
|
|
9
|
+
|
|
10
|
+
/** The hook's own source. Read from disk rather than imported, because what is
|
|
11
|
+
* being checked is that the WIRING exists — which no behavioural test of a
|
|
12
|
+
* pure module can see. */
|
|
13
|
+
function hookSource(): string {
|
|
14
|
+
return readFileSync(new URL('./useSessionSocket.ts', import.meta.url), 'utf8')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The inverse projection, checked against the forward one.
|
|
19
|
+
*
|
|
20
|
+
* `agui.fixture.json` is GENERATED by `apps/canopy_sessions/agui.py` and a
|
|
21
|
+
* Python test asserts it is current. So the round trip is proved across two
|
|
22
|
+
* languages with a shared artifact — which is the only thing that can catch the
|
|
23
|
+
* projection and its inverse drifting apart, since neither codebase can see the
|
|
24
|
+
* other.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
beforeEach(() => resetAguiState())
|
|
28
|
+
|
|
29
|
+
type FixtureEntry = { canopy: WsEvent; agui: Array<Record<string, unknown>> }
|
|
30
|
+
const entries = fixture as unknown as FixtureEntry[]
|
|
31
|
+
|
|
32
|
+
/** Replay a fixture entry's AG-UI events through the inverse. */
|
|
33
|
+
function roundTrip(entry: FixtureEntry): WsEvent[] {
|
|
34
|
+
return entry.agui.flatMap((event) => fromAgui(event))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('the round trip, against the server’s own output', () => {
|
|
38
|
+
it('covers a real conversation, not just the easy frames', () => {
|
|
39
|
+
// A fixture of two trivial events would pass forever while proving nothing
|
|
40
|
+
// about a streamed reply or a tool call.
|
|
41
|
+
const covered = new Set(entries.map((e) => e.canopy.event))
|
|
42
|
+
expect(covered).toContain('chat.stream_start')
|
|
43
|
+
expect(covered).toContain('chat.tool_use')
|
|
44
|
+
expect(covered).toContain('chat.tool_result')
|
|
45
|
+
expect(entries.length).toBeGreaterThanOrEqual(10)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it.each(entries.map((e) => [e.canopy.event, e] as const))(
|
|
49
|
+
'recovers the original canopy frame for %s',
|
|
50
|
+
(_name, entry) => {
|
|
51
|
+
expect(roundTrip(entry)).toEqual([entry.canopy])
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('the reducer cannot tell which protocol it was fed', () => {
|
|
57
|
+
/** The property that actually matters: a client reading AG-UI must end up in
|
|
58
|
+
* exactly the state a client reading canopy's own frames ends up in. Field
|
|
59
|
+
* equality per frame is necessary but not sufficient — ordering and the
|
|
60
|
+
* reducer's own dedupe are where a subtle difference would show. */
|
|
61
|
+
function reduceAll(frames: WsEvent[]): SessionState {
|
|
62
|
+
const initial: SessionState = {
|
|
63
|
+
messages: [],
|
|
64
|
+
active_draft: null,
|
|
65
|
+
participants: [],
|
|
66
|
+
presence_user_ids: [],
|
|
67
|
+
current_user_id: 0,
|
|
68
|
+
}
|
|
69
|
+
return frames.reduce(sessionReducer, initial)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
it('reaches identical state via canopy frames and via AG-UI', () => {
|
|
73
|
+
// Time is frozen because the reducer stamps `created_at`/`started_at` with
|
|
74
|
+
// the wall clock as it runs, so two reductions a millisecond apart differ
|
|
75
|
+
// in a field that has nothing to do with the protocol. Without this the
|
|
76
|
+
// test fails intermittently for a reason that would send the next reader
|
|
77
|
+
// hunting a mapping bug that is not there.
|
|
78
|
+
vi.useFakeTimers()
|
|
79
|
+
vi.setSystemTime(new Date('2026-09-15T12:00:00Z'))
|
|
80
|
+
try {
|
|
81
|
+
const direct = entries.map((e) => e.canopy)
|
|
82
|
+
const viaAgui = entries.flatMap(roundTrip)
|
|
83
|
+
|
|
84
|
+
expect(reduceAll(viaAgui)).toEqual(reduceAll(direct))
|
|
85
|
+
} finally {
|
|
86
|
+
vi.useRealTimers()
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('and that state is not vacuously empty', () => {
|
|
91
|
+
// Guard: if the fixture stopped producing messages this test would pass
|
|
92
|
+
// while comparing nothing to nothing.
|
|
93
|
+
expect(reduceAll(entries.map((e) => e.canopy)).messages.length).toBeGreaterThan(0)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('a tool call spans three events, so it is assembled not truncated', () => {
|
|
98
|
+
it('emits nothing until the arguments arrive', () => {
|
|
99
|
+
// canopy carries a tool's input on one frame; AG-UI streams it on the ARGS
|
|
100
|
+
// event that follows. Emitting on START would render a tool row with no
|
|
101
|
+
// input — the half-empty tool call the ACP notes warn about.
|
|
102
|
+
const start = fromAgui({
|
|
103
|
+
type: 'TOOL_CALL_START',
|
|
104
|
+
toolCallId: 't1',
|
|
105
|
+
toolCallName: 'list_insights',
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
expect(start).toEqual([])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('restores the runner’s own block verbatim', () => {
|
|
112
|
+
// canopy passes the block straight through from the transcript and never
|
|
113
|
+
// interprets it, so its shape belongs to the PRODUCER — Anthropic's on the
|
|
114
|
+
// laptop, the cloud runner's over ACP. Rebuilding one from the AG-UI fields
|
|
115
|
+
// would invent an `id` and a `type` that the real payload may not have had,
|
|
116
|
+
// and hand the renderer something no runner ever sent.
|
|
117
|
+
const block = { type: 'tool_use', id: 'toolu_01ABC', name: 'list_insights', input: { limit: 5 } }
|
|
118
|
+
fromAgui({
|
|
119
|
+
type: 'TOOL_CALL_START',
|
|
120
|
+
toolCallId: 't1',
|
|
121
|
+
toolCallName: 'list_insights',
|
|
122
|
+
parentMessageId: 'm1',
|
|
123
|
+
metadata: { canopy: { turn_index: 5, block } },
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const [frame] = fromAgui({ type: 'TOOL_CALL_ARGS', toolCallId: 't1', delta: '{"limit":5}' })
|
|
127
|
+
|
|
128
|
+
expect(frame).toEqual({
|
|
129
|
+
event: 'chat.tool_use',
|
|
130
|
+
data: {
|
|
131
|
+
parent_message_id: 'm1',
|
|
132
|
+
tool_message_id: 't1',
|
|
133
|
+
turn_index: 5,
|
|
134
|
+
block,
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('falls back to the protocol’s own fields for a non-canopy producer', () => {
|
|
140
|
+
// An AG-UI stream from something that is not canopy carries no block. The
|
|
141
|
+
// row is still renderable from name + arguments, which is the minimum the
|
|
142
|
+
// protocol guarantees — better than dropping the call entirely.
|
|
143
|
+
fromAgui({ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'search' })
|
|
144
|
+
const [frame] = fromAgui({ type: 'TOOL_CALL_ARGS', toolCallId: 't1', delta: '{"q":"x"}' })
|
|
145
|
+
|
|
146
|
+
expect((frame.data as unknown as { block: unknown }).block).toEqual({ name: 'search', input: { q: 'x' } })
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('ignores arguments for a call it never saw start', () => {
|
|
150
|
+
// A reconnect mid-call, or a stream that began before this client attached.
|
|
151
|
+
expect(fromAgui({ type: 'TOOL_CALL_ARGS', toolCallId: 'unknown', delta: '{}' })).toEqual([])
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('does not carry a half-read call across a reconnect', () => {
|
|
155
|
+
fromAgui({ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'x' })
|
|
156
|
+
resetAguiState()
|
|
157
|
+
|
|
158
|
+
expect(fromAgui({ type: 'TOOL_CALL_ARGS', toolCallId: 't1', delta: '{}' })).toEqual([])
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('survives malformed arguments with an empty input rather than no row', () => {
|
|
162
|
+
fromAgui({ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'x' })
|
|
163
|
+
const [frame] = fromAgui({ type: 'TOOL_CALL_ARGS', toolCallId: 't1', delta: 'not json' })
|
|
164
|
+
|
|
165
|
+
expect((frame.data as unknown as { block: { input: unknown } }).block.input).toEqual({})
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe('the blocked agent survives the trip', () => {
|
|
170
|
+
const INTERRUPT = {
|
|
171
|
+
type: 'RUN_FINISHED',
|
|
172
|
+
threadId: 't1',
|
|
173
|
+
runId: 'r1',
|
|
174
|
+
outcome: {
|
|
175
|
+
type: 'interrupt',
|
|
176
|
+
interrupts: [
|
|
177
|
+
{
|
|
178
|
+
id: 'i1',
|
|
179
|
+
reason: 'transcript',
|
|
180
|
+
message: 'Phase gate',
|
|
181
|
+
expiresAt: '2026-09-15T10:00:00+00:00',
|
|
182
|
+
metadata: {
|
|
183
|
+
canopy: {
|
|
184
|
+
body: 'Phase 4 is test-gated.',
|
|
185
|
+
questions: [
|
|
186
|
+
{
|
|
187
|
+
index: 0,
|
|
188
|
+
question: 'Proceed?',
|
|
189
|
+
multi_select: false,
|
|
190
|
+
options: [{ number: 1, label: 'Yes' }],
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
it('becomes a canopy menu a client can render', () => {
|
|
201
|
+
const [frame] = fromAgui(INTERRUPT)
|
|
202
|
+
const menu = (frame.data as unknown as { menu: Record<string, unknown> }).menu
|
|
203
|
+
|
|
204
|
+
expect(frame.event).toBe('session.menu')
|
|
205
|
+
expect(menu.question).toBe('Proceed?')
|
|
206
|
+
// The body is often the only thing that makes a dialog answerable away from
|
|
207
|
+
// the keyboard: "Do you want to proceed?" says nothing without it.
|
|
208
|
+
expect(menu.body).toBe('Phase 4 is test-gated.')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('keeps every question, not only the first', () => {
|
|
212
|
+
// The TUI draws them as tabs and will not submit until each has an answer,
|
|
213
|
+
// so a client showing one cannot complete the ask however it is pressed.
|
|
214
|
+
const [frame] = fromAgui(INTERRUPT)
|
|
215
|
+
const menu = (frame.data as unknown as { menu: { questions: unknown[] } }).menu
|
|
216
|
+
|
|
217
|
+
expect(menu.questions).toHaveLength(1)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('carries the staleness back as observed_at', () => {
|
|
221
|
+
const [frame] = fromAgui(INTERRUPT)
|
|
222
|
+
const menu = (frame.data as unknown as { menu: { observed_at: number } }).menu
|
|
223
|
+
|
|
224
|
+
expect(menu.observed_at).toBe(Date.parse('2026-09-15T10:00:00+00:00') / 1000)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('retracts a menu when someone answered at the keyboard', () => {
|
|
228
|
+
const [frame] = fromAgui({ type: 'CUSTOM', name: 'canopy.menu.retracted', value: {} })
|
|
229
|
+
|
|
230
|
+
expect(frame).toEqual({ event: 'session.menu', data: { menu: null } })
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
describe('stopping is not erroring', () => {
|
|
235
|
+
it('reads a cancelled run as a cancelled stream', () => {
|
|
236
|
+
const [frame] = fromAgui({
|
|
237
|
+
type: 'RUN_FINISHED',
|
|
238
|
+
threadId: 't1',
|
|
239
|
+
runId: 'r1',
|
|
240
|
+
result: { cancelled: true, partial_len: 12 },
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
expect(frame.event).toBe('chat.stream_cancelled')
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('reads a plain finish as nothing at all', () => {
|
|
247
|
+
// canopy has no frame for "the run ended cleanly" — the stream_complete
|
|
248
|
+
// already said so. Inventing one would double-count the turn.
|
|
249
|
+
expect(fromAgui({ type: 'RUN_FINISHED', threadId: 't1', runId: 'r1' })).toEqual([])
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
describe('an unknown event cannot break a client', () => {
|
|
254
|
+
it('ignores an event type this version has never heard of', () => {
|
|
255
|
+
// AG-UI is 0.x and gains event types. A client that throws on one it does
|
|
256
|
+
// not know is a client that breaks on the protocol improving.
|
|
257
|
+
expect(fromAgui({ type: 'SOMETHING_NEW_IN_1_0', foo: 1 })).toEqual([])
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('ignores a CUSTOM event from someone else’s namespace', () => {
|
|
261
|
+
expect(fromAgui({ type: 'CUSTOM', name: 'vendor.thing', value: {} })).toEqual([])
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it.each([{}, { type: null }, { type: 'TEXT_MESSAGE_CONTENT' }])(
|
|
265
|
+
'does not throw on malformed input %#',
|
|
266
|
+
(frame) => {
|
|
267
|
+
expect(() => fromAgui(frame as Record<string, unknown>)).not.toThrow()
|
|
268
|
+
},
|
|
269
|
+
)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
describe('the socket actually asks for it', () => {
|
|
273
|
+
/** A pure module nothing calls is a design document with a test suite — the
|
|
274
|
+
* mistake `apps/mcp/page_tools.py` made expensive once. These assert the
|
|
275
|
+
* option reaches the URL and the message handler, not just that the mapping
|
|
276
|
+
* is correct in isolation. */
|
|
277
|
+
it('is wired to the hook’s public option', () => {
|
|
278
|
+
const source = hookSource()
|
|
279
|
+
|
|
280
|
+
expect(source).toContain('protocol=ag-ui')
|
|
281
|
+
expect(source).toContain('fromAgui')
|
|
282
|
+
// And the default stays canopy's own vocabulary, which is what lets an
|
|
283
|
+
// existing consumer — ace-web installs this package from npm — notice
|
|
284
|
+
// nothing at all.
|
|
285
|
+
expect(source).toContain('protocol = "canopy"')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('clears half-read state when it opens a connection', () => {
|
|
289
|
+
// Tool-call ids are per-stream, so an ARGS event from a new connection must
|
|
290
|
+
// not complete a call the previous one left open. The BEHAVIOUR is tested
|
|
291
|
+
// directly above ("does not carry a half-read call across a reconnect");
|
|
292
|
+
// what is checked here is that `connect` actually calls it, which no
|
|
293
|
+
// behavioural test of the pure module can see.
|
|
294
|
+
const connectBody = hookSource().slice(hookSource().indexOf('const connect ='))
|
|
295
|
+
|
|
296
|
+
expect(connectBody).toContain('resetAguiState()')
|
|
297
|
+
})
|
|
298
|
+
})
|
package/src/chat/agui.ts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AG-UI events, read back into canopy's own session frames.
|
|
3
|
+
*
|
|
4
|
+
* The inverse of `apps/canopy_sessions/agui.py`. The server projects canopy →
|
|
5
|
+
* AG-UI; this reads AG-UI → canopy, so `sessionReducer` never learns a second
|
|
6
|
+
* vocabulary and every existing surface keeps working unchanged.
|
|
7
|
+
*
|
|
8
|
+
* **Why an inverse rather than a second reducer.** Teaching the reducer AG-UI
|
|
9
|
+
* directly would mean two code paths producing the same `SessionState`, and
|
|
10
|
+
* they would diverge — the multiplayer cases especially, which AG-UI does not
|
|
11
|
+
* model and which only canopy's path exercises. One reducer with a translator
|
|
12
|
+
* in front of it has a single behaviour to test.
|
|
13
|
+
*
|
|
14
|
+
* **The round trip is proved, not assumed.** `agui.fixture.json` is generated
|
|
15
|
+
* by the Python projection; a Python test asserts it is current and the test
|
|
16
|
+
* beside this one asserts this module recovers the original canopy frame from
|
|
17
|
+
* it. The projection and its inverse live in different languages, so a shared
|
|
18
|
+
* artifact is the only thing that can catch them drifting apart.
|
|
19
|
+
*
|
|
20
|
+
* **Lossless via `metadata`.** AG-UI has no slot for canopy's transcript
|
|
21
|
+
* ordinal (`turn_index`) or for the settled text of a whole message
|
|
22
|
+
* (`plaintext`), and the reducer needs both — one to sort a live row into the
|
|
23
|
+
* position it will occupy after a reload, the other to tell a web send from the
|
|
24
|
+
* echo of the same text arriving from the runner. They ride `metadata.canopy`,
|
|
25
|
+
* which is the extension point AG-UI reserves ("Every other key is user
|
|
26
|
+
* space"). Without them the projection would be a downgrade dressed as a
|
|
27
|
+
* standard.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { SessionMenu, WsEvent } from "./protocol";
|
|
31
|
+
|
|
32
|
+
/** Where canopy's own fields ride. Mirrors `agui.METADATA_KEY`. */
|
|
33
|
+
export const METADATA_KEY = "canopy";
|
|
34
|
+
|
|
35
|
+
/** Prefix on canopy's `CUSTOM` events. Mirrors `agui.CUSTOM_PREFIX`. */
|
|
36
|
+
export const CUSTOM_PREFIX = "canopy.";
|
|
37
|
+
|
|
38
|
+
/** An AG-UI event as it arrives on the wire: camelCase, unknown shape. */
|
|
39
|
+
type AguiFrame = Record<string, unknown>;
|
|
40
|
+
|
|
41
|
+
function meta(frame: AguiFrame): Record<string, unknown> {
|
|
42
|
+
const m = frame.metadata as Record<string, unknown> | undefined;
|
|
43
|
+
const mine = m?.[METADATA_KEY] as Record<string, unknown> | undefined;
|
|
44
|
+
return mine ?? {};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function str(value: unknown): string {
|
|
48
|
+
return typeof value === "string" ? value : "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function num(value: unknown, fallback = 0): number {
|
|
52
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One AG-UI event → zero or more canopy frames.
|
|
57
|
+
*
|
|
58
|
+
* Zero is a real answer, twice over. Some AG-UI events have no canopy meaning
|
|
59
|
+
* (`RUN_STARTED` duplicates an activity frame canopy already sent); and an
|
|
60
|
+
* unknown event returns zero rather than throwing, because a protocol that
|
|
61
|
+
* gains an event type must not be able to break a client that has not been
|
|
62
|
+
* updated yet — which, for a 0.x protocol, is a matter of when rather than if.
|
|
63
|
+
*/
|
|
64
|
+
export function fromAgui(frame: AguiFrame): WsEvent[] {
|
|
65
|
+
const type = typeof frame.type === "string" ? frame.type : "";
|
|
66
|
+
const m = meta(frame);
|
|
67
|
+
|
|
68
|
+
switch (type) {
|
|
69
|
+
case "TEXT_MESSAGE_START":
|
|
70
|
+
return [
|
|
71
|
+
{
|
|
72
|
+
event: "chat.stream_start",
|
|
73
|
+
data: { message_id: str(frame.messageId), turn_index: num(m.turn_index) },
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
case "TEXT_MESSAGE_CONTENT":
|
|
78
|
+
return [
|
|
79
|
+
{
|
|
80
|
+
event: "chat.delta",
|
|
81
|
+
data: { message_id: str(frame.messageId), text: str(frame.delta) },
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
case "TEXT_MESSAGE_END":
|
|
86
|
+
return [
|
|
87
|
+
{
|
|
88
|
+
event: "chat.stream_complete",
|
|
89
|
+
data: { message_id: str(frame.messageId), plaintext: str(m.plaintext) },
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
case "TEXT_MESSAGE_CHUNK":
|
|
94
|
+
// Only the USER variant is canopy's `chat.user_message` — a human typing
|
|
95
|
+
// into emdash rather than into this page. An assistant chunk would be a
|
|
96
|
+
// whole reply arriving at once, which canopy's runner does not produce;
|
|
97
|
+
// mapping it to `stream_complete` with no preceding `stream_start` would
|
|
98
|
+
// hand the reducer a completion for a message it never opened.
|
|
99
|
+
if (str(frame.role) !== "user") return [];
|
|
100
|
+
return [
|
|
101
|
+
{
|
|
102
|
+
event: "chat.user_message",
|
|
103
|
+
data: {
|
|
104
|
+
message_id: str(frame.messageId),
|
|
105
|
+
turn_index: num(m.turn_index),
|
|
106
|
+
plaintext: str(frame.delta),
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
case "TOOL_CALL_START":
|
|
112
|
+
// canopy carries the tool's payload on one frame; AG-UI streams the
|
|
113
|
+
// arguments on the ARGS event that follows. So nothing is emitted until
|
|
114
|
+
// ARGS arrives — a row rendered on START would have no input, which is
|
|
115
|
+
// the half-empty-tool-call bug the ACP notes warn about.
|
|
116
|
+
pendingCalls.set(str(frame.toolCallId), {
|
|
117
|
+
name: str(frame.toolCallName),
|
|
118
|
+
parentMessageId: str(frame.parentMessageId),
|
|
119
|
+
turnIndex: num(m.turn_index),
|
|
120
|
+
block: m.block as Record<string, unknown> | undefined,
|
|
121
|
+
});
|
|
122
|
+
return [];
|
|
123
|
+
|
|
124
|
+
case "TOOL_CALL_ARGS": {
|
|
125
|
+
const id = str(frame.toolCallId);
|
|
126
|
+
const pending = pendingCalls.get(id);
|
|
127
|
+
if (!pending) return [];
|
|
128
|
+
let input: unknown = {};
|
|
129
|
+
try {
|
|
130
|
+
input = JSON.parse(str(frame.delta) || "{}");
|
|
131
|
+
} catch {
|
|
132
|
+
// A fragment that is not valid JSON on its own is legal in AG-UI. The
|
|
133
|
+
// server sends arguments whole, so this means genuinely malformed
|
|
134
|
+
// input, and an empty object is a better row than a dropped one.
|
|
135
|
+
input = {};
|
|
136
|
+
}
|
|
137
|
+
// The runner's own payload where we have it, verbatim. canopy passes the
|
|
138
|
+
// block straight through from the transcript and never interprets it, so
|
|
139
|
+
// its shape belongs to the PRODUCER — reconstructing one from the AG-UI
|
|
140
|
+
// fields would invent a `type` and an `id` the real payload may not have
|
|
141
|
+
// carried, and hand the renderer something no runner ever sent.
|
|
142
|
+
const block = pending.block ?? { name: pending.name, input };
|
|
143
|
+
return [
|
|
144
|
+
{
|
|
145
|
+
event: "chat.tool_use",
|
|
146
|
+
data: {
|
|
147
|
+
parent_message_id: pending.parentMessageId || null,
|
|
148
|
+
tool_message_id: id,
|
|
149
|
+
turn_index: pending.turnIndex,
|
|
150
|
+
block,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
case "TOOL_CALL_END":
|
|
157
|
+
pendingCalls.delete(str(frame.toolCallId));
|
|
158
|
+
return [];
|
|
159
|
+
|
|
160
|
+
case "TOOL_CALL_RESULT":
|
|
161
|
+
return [
|
|
162
|
+
{
|
|
163
|
+
event: "chat.tool_result",
|
|
164
|
+
data: {
|
|
165
|
+
parent_message_id: (m.parent_message_id as string | undefined) ?? null,
|
|
166
|
+
tool_message_id: str(frame.toolCallId),
|
|
167
|
+
turn_index: num(m.turn_index),
|
|
168
|
+
block: (m.block as Record<string, unknown> | undefined) ?? { content: frame.content },
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
case "ACTIVITY_SNAPSHOT": {
|
|
174
|
+
const content = (frame.content as Record<string, unknown>) ?? {};
|
|
175
|
+
const state = str(content.state);
|
|
176
|
+
if (!state) return [];
|
|
177
|
+
return [{ event: "session.activity", data: { state: state as "working" | "idle" | "blocked" } }];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
case "RUN_FINISHED": {
|
|
181
|
+
const outcome = frame.outcome as Record<string, unknown> | undefined;
|
|
182
|
+
if (outcome?.type === "interrupt") {
|
|
183
|
+
const interrupts = (outcome.interrupts as Array<Record<string, unknown>>) ?? [];
|
|
184
|
+
return interrupts.map((i) => ({
|
|
185
|
+
event: "session.menu" as const,
|
|
186
|
+
data: { menu: interruptToMenu(i) },
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
const result = frame.result as Record<string, unknown> | undefined;
|
|
190
|
+
if (result?.cancelled) {
|
|
191
|
+
return [
|
|
192
|
+
{
|
|
193
|
+
event: "chat.stream_cancelled",
|
|
194
|
+
data: { message_id: null, partial_len: num(result.partial_len) },
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
}
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
case "RUN_ERROR":
|
|
202
|
+
return [
|
|
203
|
+
{
|
|
204
|
+
event: "session.error",
|
|
205
|
+
data: { code: str(frame.code) || "run_error", message: str(frame.message) },
|
|
206
|
+
},
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
case "STATE_DELTA": {
|
|
210
|
+
// Only the one patch canopy sends. A general JSON-Patch applier would be
|
|
211
|
+
// a second source of truth for session state, which is the reducer's job.
|
|
212
|
+
const ops = (frame.delta as Array<Record<string, unknown>>) ?? [];
|
|
213
|
+
const title = ops.find((op) => op.path === "/title");
|
|
214
|
+
if (!title) return [];
|
|
215
|
+
return [{ event: "session.title_updated", data: { title: str(title.value) } }];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
case "CUSTOM": {
|
|
219
|
+
// canopy's own vocabulary, coming home. Drafts, presence and placement
|
|
220
|
+
// have no AG-UI spelling because the protocol models one user and one
|
|
221
|
+
// agent; they were namespaced on the way out and are unwrapped here.
|
|
222
|
+
const name = str(frame.name);
|
|
223
|
+
if (!name.startsWith(CUSTOM_PREFIX)) return [];
|
|
224
|
+
const event = name.slice(CUSTOM_PREFIX.length);
|
|
225
|
+
if (event === "menu.retracted") return [{ event: "session.menu", data: { menu: null } }];
|
|
226
|
+
if (event === "menu") {
|
|
227
|
+
return [{ event: "session.menu", data: { menu: frame.value as never } }];
|
|
228
|
+
}
|
|
229
|
+
return [{ event, data: frame.value } as unknown as WsEvent];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
default:
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Arguments arrive on a later event than the tool's name, so the head of a
|
|
238
|
+
* call is held until they do. Module-level because a socket is a stream and
|
|
239
|
+
* the pairing spans events; `resetAguiState` exists so a test — and a
|
|
240
|
+
* reconnect — can start from nothing rather than inheriting a half-read call
|
|
241
|
+
* from the connection before. */
|
|
242
|
+
const pendingCalls = new Map<
|
|
243
|
+
string,
|
|
244
|
+
{
|
|
245
|
+
name: string;
|
|
246
|
+
parentMessageId: string;
|
|
247
|
+
turnIndex: number;
|
|
248
|
+
block?: Record<string, unknown>;
|
|
249
|
+
}
|
|
250
|
+
>();
|
|
251
|
+
|
|
252
|
+
export function resetAguiState(): void {
|
|
253
|
+
pendingCalls.clear();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** An AG-UI interrupt back into canopy's menu shape.
|
|
257
|
+
*
|
|
258
|
+
* The fields line up because the protocol independently arrived at the same
|
|
259
|
+
* ones: `expiresAt` is canopy's `observed_at` (a dialog lives on a terminal
|
|
260
|
+
* and this is a copy, so it has to carry its own age), and the option list
|
|
261
|
+
* lives in `metadata.canopy.questions` because a response schema can say a
|
|
262
|
+
* field takes a list but not that the TUI draws it as checkboxes a number key
|
|
263
|
+
* TOGGLES rather than answers. */
|
|
264
|
+
function interruptToMenu(interrupt: Record<string, unknown>): SessionMenu {
|
|
265
|
+
const m = (interrupt.metadata as Record<string, unknown> | undefined)?.[METADATA_KEY] as
|
|
266
|
+
| Record<string, unknown>
|
|
267
|
+
| undefined;
|
|
268
|
+
const questions = (m?.questions as Array<Record<string, unknown>>) ?? [];
|
|
269
|
+
const first = questions[0] ?? {};
|
|
270
|
+
return {
|
|
271
|
+
question: str(first.question) || str(interrupt.message),
|
|
272
|
+
title: str(interrupt.message),
|
|
273
|
+
body: str(m?.body),
|
|
274
|
+
source: str(interrupt.reason),
|
|
275
|
+
questions: questions as unknown as SessionMenu["questions"],
|
|
276
|
+
options: (first.options as unknown as SessionMenu["options"]) ?? [],
|
|
277
|
+
answer_error: str(m?.answer_error) || undefined,
|
|
278
|
+
answer_note: str(m?.answer_note) || undefined,
|
|
279
|
+
restored: Boolean(m?.restored),
|
|
280
|
+
observed_at: interrupt.expiresAt ? Date.parse(str(interrupt.expiresAt)) / 1000 : undefined,
|
|
281
|
+
};
|
|
282
|
+
}
|
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";
|
|
@@ -67,3 +71,6 @@ export {
|
|
|
67
71
|
type PlacementBannerProps,
|
|
68
72
|
type PlacementRunner,
|
|
69
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";
|
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
|
|
3
|
+
import { fromAgui, resetAguiState } from "./agui";
|
|
3
4
|
import type { Message, SessionState, WsEvent } from "./protocol";
|
|
4
5
|
import { shouldSyncDraftLive } from "./drafts";
|
|
5
6
|
import { prependHistory } from "./history";
|
|
@@ -32,6 +33,29 @@ export interface UseSessionSocketOptions {
|
|
|
32
33
|
* has no opinion on what to do with it.
|
|
33
34
|
*/
|
|
34
35
|
onTitleUpdated?: () => void;
|
|
36
|
+
/**
|
|
37
|
+
* A frame the kit does not understand.
|
|
38
|
+
*
|
|
39
|
+
* The kit stays agnostic: canopy grew `session.page_action` (an agent asking
|
|
40
|
+
* the embedded page to do something) and ace-web will grow its own. Teaching
|
|
41
|
+
* the reducer about each would make a shared kit carry one app's vocabulary.
|
|
42
|
+
* Same shape as `onTitleUpdated` — handed over, no opinion taken.
|
|
43
|
+
*/
|
|
44
|
+
onUnknownEvent?: (frame: WsEvent) => void;
|
|
45
|
+
/**
|
|
46
|
+
* Which vocabulary to ask the server for.
|
|
47
|
+
*
|
|
48
|
+
* `"canopy"` (the default) is this kit's own frames, unchanged — the reason
|
|
49
|
+
* an existing consumer, including ace-web installing `canopy-ui` from npm,
|
|
50
|
+
* notices nothing. `"ag-ui"` asks the server to project the same conversation
|
|
51
|
+
* into AG-UI and translates it back here, so the reducer never learns a
|
|
52
|
+
* second vocabulary and there is one behaviour to test rather than two.
|
|
53
|
+
*
|
|
54
|
+
* Opting in buys interoperability, not features: a canopy frame and its
|
|
55
|
+
* AG-UI projection reduce to identical state (`agui.test.ts` asserts exactly
|
|
56
|
+
* that against a fixture the server generates).
|
|
57
|
+
*/
|
|
58
|
+
protocol?: "canopy" | "ag-ui";
|
|
35
59
|
}
|
|
36
60
|
|
|
37
61
|
export interface UseSessionSocketResult {
|
|
@@ -51,10 +75,27 @@ export interface UseSessionSocketResult {
|
|
|
51
75
|
lastError: string | null;
|
|
52
76
|
}
|
|
53
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Frames `sessionReducer` understands. Anything else is handed to
|
|
80
|
+
* `onUnknownEvent` rather than dropped — the reducer ignores what it does not
|
|
81
|
+
* recognise, which silently swallows an app-specific frame and leaves the
|
|
82
|
+
* container wondering why its feature never fires.
|
|
83
|
+
*
|
|
84
|
+
* Keep in step with sessionReducer's own switch.
|
|
85
|
+
*/
|
|
86
|
+
const KNOWN_EVENTS = new Set([
|
|
87
|
+
"chat.delta", "chat.stream_cancelled", "chat.stream_complete",
|
|
88
|
+
"chat.stream_error", "chat.stream_start", "chat.tool_result",
|
|
89
|
+
"chat.tool_use", "chat.user_message", "session.activity", "session.error",
|
|
90
|
+
"session.menu", "session.state", "session.stop", "session.title_updated",
|
|
91
|
+
]);
|
|
92
|
+
|
|
54
93
|
export function useSessionSocket({
|
|
55
94
|
sessionId,
|
|
56
95
|
wsUrl,
|
|
57
96
|
onTitleUpdated,
|
|
97
|
+
onUnknownEvent,
|
|
98
|
+
protocol = "canopy",
|
|
58
99
|
}: UseSessionSocketOptions): UseSessionSocketResult {
|
|
59
100
|
const [state, setState] = useState<SessionState>(INITIAL_STATE);
|
|
60
101
|
const [connected, setConnected] = useState(false);
|
|
@@ -73,6 +114,13 @@ export function useSessionSocket({
|
|
|
73
114
|
const pendingDraftBodyRef = useRef<string | null>(null);
|
|
74
115
|
const closedByUserRef = useRef(false);
|
|
75
116
|
const onTitleUpdatedRef = useRef(onTitleUpdated);
|
|
117
|
+
const onUnknownEventRef = useRef(onUnknownEvent);
|
|
118
|
+
// A ref, like the callbacks above: `connect` is a stable callback with empty
|
|
119
|
+
// deps, so reading the prop directly would pin whatever it was on first
|
|
120
|
+
// render — and a socket that reconnects would silently drop back to the other
|
|
121
|
+
// vocabulary mid-session.
|
|
122
|
+
const protocolRef = useRef(protocol);
|
|
123
|
+
protocolRef.current = protocol;
|
|
76
124
|
// Control frames that must not be lost across a reconnect (currently
|
|
77
125
|
// only chat.stop). The WS-world analogue of an abortable chat transport.
|
|
78
126
|
const pendingFramesRef = useRef<{ action: string; data: unknown }[]>([]);
|
|
@@ -85,6 +133,10 @@ export function useSessionSocket({
|
|
|
85
133
|
onTitleUpdatedRef.current = onTitleUpdated;
|
|
86
134
|
}, [onTitleUpdated]);
|
|
87
135
|
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
onUnknownEventRef.current = onUnknownEvent;
|
|
138
|
+
}, [onUnknownEvent]);
|
|
139
|
+
|
|
88
140
|
const send = useCallback((frame: { action: string; data: unknown }) => {
|
|
89
141
|
const ws = socketRef.current;
|
|
90
142
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
@@ -134,12 +186,25 @@ export function useSessionSocket({
|
|
|
134
186
|
}
|
|
135
187
|
}
|
|
136
188
|
}
|
|
189
|
+
// The reducer ignores anything it does not know, which silently drops an
|
|
190
|
+
// app-specific frame. Hand it over instead, so the container can act on
|
|
191
|
+
// vocabulary the shared kit deliberately does not carry.
|
|
192
|
+
if (!KNOWN_EVENTS.has(frame.event)) {
|
|
193
|
+
onUnknownEventRef.current?.(frame);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
137
196
|
setState((prev) => sessionReducer(prev, frame));
|
|
138
197
|
}, []);
|
|
139
198
|
|
|
140
199
|
const connect = useCallback(() => {
|
|
141
200
|
if (closedByUserRef.current) return;
|
|
142
|
-
|
|
201
|
+
// A half-read tool call from the previous connection must not be completed
|
|
202
|
+
// by an ARGS event from this one — the ids are per-stream.
|
|
203
|
+
resetAguiState();
|
|
204
|
+
const path = `ws/canopy-sessions/${sessionId}/`;
|
|
205
|
+
const ws = new WebSocket(
|
|
206
|
+
wsUrl(protocolRef.current === "ag-ui" ? `${path}?protocol=ag-ui` : path),
|
|
207
|
+
);
|
|
143
208
|
socketRef.current = ws;
|
|
144
209
|
|
|
145
210
|
ws.onopen = () => {
|
|
@@ -162,8 +227,14 @@ export function useSessionSocket({
|
|
|
162
227
|
|
|
163
228
|
ws.onmessage = (e) => {
|
|
164
229
|
try {
|
|
165
|
-
const
|
|
166
|
-
|
|
230
|
+
const raw = JSON.parse(e.data);
|
|
231
|
+
if (protocolRef.current === "ag-ui") {
|
|
232
|
+
// One AG-UI event can be several canopy frames (a tool call is three
|
|
233
|
+
// events) or none, so this is a fan-out rather than a rename.
|
|
234
|
+
for (const frame of fromAgui(raw)) applyEvent(frame);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
applyEvent(raw as WsEvent);
|
|
167
238
|
} catch {
|
|
168
239
|
// ignore malformed frames
|
|
169
240
|
}
|
|
@@ -12,8 +12,10 @@ export function workbenchNavItemClass({
|
|
|
12
12
|
variant === 'neutral'
|
|
13
13
|
? 'bg-accent border-transparent text-foreground font-medium'
|
|
14
14
|
: 'bg-primary/10 border-primary/30 text-primary font-medium'
|
|
15
|
+
// `min-h-11` below `sm` is the 44px touch minimum; from `sm` up the rail keeps
|
|
16
|
+
// its original density, where the pointer is precise and vertical space is dear.
|
|
15
17
|
return cn(
|
|
16
|
-
'flex items-center justify-between gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors',
|
|
18
|
+
'flex min-h-11 items-center justify-between gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors sm:min-h-0',
|
|
17
19
|
active
|
|
18
20
|
? activeClass
|
|
19
21
|
: 'border-transparent text-muted-foreground hover:bg-accent hover:text-foreground',
|
package/src/ui/button.tsx
CHANGED
|
@@ -4,6 +4,11 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|
|
4
4
|
import { cn } from "../lib/cn"
|
|
5
5
|
|
|
6
6
|
const buttonVariants = cva(
|
|
7
|
+
// Every size variant sets a fixed height (default h-8, sm h-7, xs h-6), all of
|
|
8
|
+
// them below the 44px touch minimum. Rather than teach each variant about
|
|
9
|
+
// touch, the floor lives here once and lifts off at `sm`, where a pointer is
|
|
10
|
+
// precise and the density is the point.
|
|
11
|
+
"min-h-11 min-w-11 sm:min-h-0 sm:min-w-0 " +
|
|
7
12
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
8
13
|
{
|
|
9
14
|
variants: {
|
package/src/ui/input.tsx
CHANGED
|
@@ -9,7 +9,9 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|
|
9
9
|
type={type}
|
|
10
10
|
data-slot="input"
|
|
11
11
|
className={cn(
|
|
12
|
-
|
|
12
|
+
// Touch floor, matching Button: `h-8` is 32px, under the 44px minimum. Lifts
|
|
13
|
+
// off at `sm` so desktop density is unchanged.
|
|
14
|
+
"min-h-11 sm:min-h-0 sm:h-8 h-11 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
|
13
15
|
className
|
|
14
16
|
)}
|
|
15
17
|
{...props}
|
package/src/ui/tabs.tsx
CHANGED
|
@@ -23,8 +23,17 @@ function Tabs({
|
|
|
23
23
|
)
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// Touch first, then shrink to the desktop density at `sm`.
|
|
27
|
+
//
|
|
28
|
+
// `w-fit` + a fixed `h-8` was two bugs at once on a phone: the list sized itself
|
|
29
|
+
// to its content, so a fourth tab ran off the viewport with no scroll and no
|
|
30
|
+
// wrap (measured on /supervisor at 375px: scrollWidth 302 vs clientWidth 293,
|
|
31
|
+
// clipping "Runners"), and the height capped every trigger at 23px — roughly
|
|
32
|
+
// half the 44px touch minimum, on the surface that ships as the phone PWA.
|
|
33
|
+
// Below `sm` the list is now full-width and wraps; from `sm` up it is exactly
|
|
34
|
+
// what it was.
|
|
26
35
|
const tabsListVariants = cva(
|
|
27
|
-
"group/tabs-list
|
|
36
|
+
"group/tabs-list grid w-full max-w-full grid-cols-2 items-center justify-center rounded-lg p-[3px] text-muted-foreground sm:inline-flex sm:w-fit sm:grid-cols-none group-data-horizontal/tabs:h-auto sm:group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
|
28
37
|
{
|
|
29
38
|
variants: {
|
|
30
39
|
variant: {
|
|
@@ -58,7 +67,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
|
|
58
67
|
<TabsPrimitive.Tab
|
|
59
68
|
data-slot="tabs-trigger"
|
|
60
69
|
className={cn(
|
|
61
|
-
"relative inline-flex h-
|
|
70
|
+
"relative inline-flex min-h-11 flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-0.5 text-sm font-medium whitespace-nowrap text-muted-foreground transition-all sm:h-[calc(100%-1px)] sm:min-h-0 sm:px-2.5 group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground-secondary focus-visible:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:outline-1 focus-visible:outline-primary/40 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
62
71
|
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent group-data-[variant=line]/tabs-list:data-active:border-transparent",
|
|
63
72
|
"data-active:bg-background data-active:text-foreground data-active:border-border",
|
|
64
73
|
"after:absolute after:bg-primary after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|