@robota-sdk/agent-transport 3.0.0-beta.100
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/CHANGELOG.md +1137 -0
- package/LICENSE +661 -0
- package/README.md +121 -0
- package/dist/browser/client.d.ts +2 -0
- package/dist/browser/client.js +1 -0
- package/dist/browser/index.d.ts +624 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/observer-messages-4fRQZU2D.d.ts +499 -0
- package/dist/browser/observer-messages-4fRQZU2D.d.ts.map +1 -0
- package/dist/browser/observer-messages-O8jTPEQO.js +2 -0
- package/dist/browser/observer-messages-O8jTPEQO.js.map +1 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +1115 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +1115 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/dist/node/node.cjs +1 -0
- package/dist/node/node.d.cts +317 -0
- package/dist/node/node.d.cts.map +1 -0
- package/dist/node/node.d.ts +317 -0
- package/dist/node/node.d.ts.map +1 -0
- package/dist/node/node.js +2 -0
- package/dist/node/node.js.map +1 -0
- package/package.json +122 -0
- package/src/__tests__/__fixtures__/korean-text.json +6 -0
- package/src/__tests__/access-token-verifier.test.ts +672 -0
- package/src/__tests__/admission.test.ts +130 -0
- package/src/__tests__/app-tool-messages.test.ts +463 -0
- package/src/__tests__/bearer-resource-gate.test.ts +120 -0
- package/src/__tests__/channel-frames.test.ts +215 -0
- package/src/__tests__/driver-attribution-capability.test.ts +83 -0
- package/src/__tests__/file-transfer.test.ts +281 -0
- package/src/__tests__/handoff-chunking.test.ts +261 -0
- package/src/__tests__/handoff-manifest.test.ts +52 -0
- package/src/__tests__/history-attached-budget.test.ts +216 -0
- package/src/__tests__/message-decoders.test.ts +870 -0
- package/src/__tests__/outbound-delivery.test.ts +558 -0
- package/src/__tests__/peer-message-ledger.test.ts +200 -0
- package/src/__tests__/permission-summary.test.ts +18 -0
- package/src/__tests__/personal-usage-report.test.ts +193 -0
- package/src/__tests__/project-read-messages.test.ts +118 -0
- package/src/__tests__/resume-buffer.test.ts +93 -0
- package/src/__tests__/session-detail-loop-status.test.ts +169 -0
- package/src/__tests__/session-directory-messages.test.ts +322 -0
- package/src/__tests__/session-error-classification.test.ts +113 -0
- package/src/__tests__/session-event-delivery.test.ts +101 -0
- package/src/__tests__/session-history-wire.test.ts +364 -0
- package/src/__tests__/session-message-handler-e5.test.ts +99 -0
- package/src/__tests__/session-message-handler-observe.test.ts +223 -0
- package/src/__tests__/session-message-handler.test.ts +726 -0
- package/src/__tests__/session-resume-bridge.test.ts +472 -0
- package/src/__tests__/settings-messages.test.ts +193 -0
- package/src/__tests__/stdio-carrier.test.ts +85 -0
- package/src/__tests__/submit-prompt-shape.test.ts +78 -0
- package/src/__tests__/usage-report-carrier.test.ts +98 -0
- package/src/__tests__/ws-broadcast-events.test.ts +94 -0
- package/src/app-tool-messages.ts +140 -0
- package/src/background-messages.ts +169 -0
- package/src/channel-frames.ts +182 -0
- package/src/client.ts +17 -0
- package/src/handoff-chunking.ts +275 -0
- package/src/index.ts +135 -0
- package/src/loop-control-messages.ts +34 -0
- package/src/message-decoders.ts +485 -0
- package/src/message-parser.ts +33 -0
- package/src/node/access-token-verifier.ts +363 -0
- package/src/node/admission.ts +153 -0
- package/src/node/bearer-resource-gate.ts +331 -0
- package/src/node/file-transfer.ts +530 -0
- package/src/node/handoff-manifest.ts +77 -0
- package/src/node/index.ts +45 -0
- package/src/node/stdio-carrier.ts +59 -0
- package/src/observer-messages.ts +47 -0
- package/src/open-prompts.ts +76 -0
- package/src/outbound-delivery.ts +240 -0
- package/src/peer-message-ledger.ts +193 -0
- package/src/permission-summary.ts +18 -0
- package/src/project-read-messages.ts +74 -0
- package/src/protocol-session.ts +56 -0
- package/src/resume-buffer.ts +123 -0
- package/src/session-directory-messages.ts +162 -0
- package/src/session-error-classification.ts +91 -0
- package/src/session-events.ts +290 -0
- package/src/session-message-handler.ts +413 -0
- package/src/session-query-messages.ts +160 -0
- package/src/session-resume-bridge.ts +275 -0
- package/src/settings-messages.ts +103 -0
- package/src/usage-messages.ts +195 -0
- package/src/wire-messages.ts +436 -0
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
import { SESSION_CHANGE_REFUSAL_CODES } from '@robota-sdk/agent-interface-session';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
CLIENT_MESSAGE_SHAPES,
|
|
6
|
+
MAX_INBOUND_FRAME_BYTES,
|
|
7
|
+
SERVER_MESSAGE_SHAPES,
|
|
8
|
+
decodeClientMessage,
|
|
9
|
+
decodeFrame,
|
|
10
|
+
decodeServerMessage,
|
|
11
|
+
} from '../message-decoders.js';
|
|
12
|
+
|
|
13
|
+
import type { TClientMessage, TServerMessage } from '../wire-messages.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Issue #2045 — the owner-side runtime decoders. One well-formed sample per union variant round-trips
|
|
17
|
+
* through JSON; a shared malformed corpus (null, arrays, missing fields, wrong primitives, malformed
|
|
18
|
+
* nested values, oversized frames) is refused consistently. Both carriers call these same functions.
|
|
19
|
+
*/
|
|
20
|
+
const CLIENT_SAMPLES: Readonly<Record<TClientMessage['type'], TClientMessage>> = {
|
|
21
|
+
submit: { type: 'submit', prompt: 'hi' },
|
|
22
|
+
command: { type: 'command', name: 'help', args: 'x', requestId: 'command-1' },
|
|
23
|
+
abort: { type: 'abort' },
|
|
24
|
+
'cancel-queue': { type: 'cancel-queue' },
|
|
25
|
+
'get-messages': { type: 'get-messages' },
|
|
26
|
+
'get-history': { type: 'get-history', fromIndex: 40 },
|
|
27
|
+
'get-prompts': { type: 'get-prompts' },
|
|
28
|
+
'get-context': { type: 'get-context' },
|
|
29
|
+
'get-commands': { type: 'get-commands' },
|
|
30
|
+
'get-status': { type: 'get-status' },
|
|
31
|
+
'list-models': { type: 'list-models', requestId: 'request-6' },
|
|
32
|
+
'get-agent-definitions': { type: 'get-agent-definitions', requestId: 'request-7' },
|
|
33
|
+
'list-sessions': { type: 'list-sessions', requestId: 'request-3' },
|
|
34
|
+
'new-session': { type: 'new-session', requestId: 'request-4' },
|
|
35
|
+
'switch-session': { type: 'switch-session', sessionId: 'session-2', requestId: 'request-5' },
|
|
36
|
+
'rename-session': {
|
|
37
|
+
type: 'rename-session',
|
|
38
|
+
sessionId: 'session-2',
|
|
39
|
+
name: 'Renamed',
|
|
40
|
+
requestId: 'request-6',
|
|
41
|
+
},
|
|
42
|
+
'delete-session': { type: 'delete-session', sessionId: 'session-2', requestId: 'request-7' },
|
|
43
|
+
'get-usage-report': { type: 'get-usage-report' },
|
|
44
|
+
'get-personal-usage-report': {
|
|
45
|
+
type: 'get-personal-usage-report',
|
|
46
|
+
requestId: 'request-1',
|
|
47
|
+
period: '7d',
|
|
48
|
+
timezone: 'UTC',
|
|
49
|
+
},
|
|
50
|
+
'get-stored-session-usage-report': {
|
|
51
|
+
type: 'get-stored-session-usage-report',
|
|
52
|
+
requestId: 'request-2',
|
|
53
|
+
sessionId: 'session-1',
|
|
54
|
+
},
|
|
55
|
+
'get-executing': { type: 'get-executing' },
|
|
56
|
+
'get-pending': { type: 'get-pending' },
|
|
57
|
+
'get-execution-workspace': { type: 'get-execution-workspace' },
|
|
58
|
+
'read-execution-detail': {
|
|
59
|
+
type: 'read-execution-detail',
|
|
60
|
+
requestId: 'detail-1',
|
|
61
|
+
entryId: 'main',
|
|
62
|
+
cursor: { offset: 20 },
|
|
63
|
+
},
|
|
64
|
+
'stop-waiting-loop': { type: 'stop-waiting-loop', requestId: 'loop-1' },
|
|
65
|
+
'project-status': { type: 'project-status', requestId: 'project-status-1' },
|
|
66
|
+
'project-diff': { type: 'project-diff', requestId: 'project-diff-1', path: 'a.txt' },
|
|
67
|
+
'project-memory': { type: 'project-memory', requestId: 'project-memory-1' },
|
|
68
|
+
'call-app-tool': {
|
|
69
|
+
type: 'call-app-tool',
|
|
70
|
+
requestId: 'app-tool-1',
|
|
71
|
+
viewId: 'view-1',
|
|
72
|
+
name: 'mcp__files__list_files',
|
|
73
|
+
arguments: { path: 'src' },
|
|
74
|
+
},
|
|
75
|
+
'read-app-view': { type: 'read-app-view', requestId: 'app-view-1', viewId: 'view-1' },
|
|
76
|
+
'get-background-tasks': {
|
|
77
|
+
type: 'get-background-tasks',
|
|
78
|
+
filter: { kind: 'agent', includeClosed: true },
|
|
79
|
+
},
|
|
80
|
+
'get-background-task': { type: 'get-background-task', taskId: 't' },
|
|
81
|
+
'get-background-job-groups': { type: 'get-background-job-groups' },
|
|
82
|
+
'get-background-job-group': { type: 'get-background-job-group', groupId: 'g' },
|
|
83
|
+
'wait-background-job-group': { type: 'wait-background-job-group', groupId: 'g' },
|
|
84
|
+
'cancel-background-task': { type: 'cancel-background-task', taskId: 't', reason: 'r' },
|
|
85
|
+
'close-background-task': { type: 'close-background-task', taskId: 't' },
|
|
86
|
+
'send-background-task': { type: 'send-background-task', taskId: 't', input: { prompt: 'p' } },
|
|
87
|
+
'read-background-task-log': {
|
|
88
|
+
type: 'read-background-task-log',
|
|
89
|
+
taskId: 't',
|
|
90
|
+
cursor: { offset: 3 },
|
|
91
|
+
},
|
|
92
|
+
'permission-response': { type: 'permission-response', id: 'p1', result: 'allow-session' },
|
|
93
|
+
'ask-response': { type: 'ask-response', id: 'a1', response: { type: 'answer', values: ['y'] } },
|
|
94
|
+
resume: { type: 'resume', lastSeq: 4 },
|
|
95
|
+
ack: { type: 'ack', seq: 9 },
|
|
96
|
+
'get-settings': { type: 'get-settings', requestId: 'settings-1' },
|
|
97
|
+
'update-settings': {
|
|
98
|
+
type: 'update-settings',
|
|
99
|
+
requestId: 'settings-2',
|
|
100
|
+
patch: { field: 'outputStyle', styleId: 'concise' },
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const SERVER_SAMPLES: Readonly<Record<TServerMessage['type'], TServerMessage>> = {
|
|
105
|
+
text_delta: { type: 'text_delta', delta: 'd', driverId: 'drv' },
|
|
106
|
+
user_message: { type: 'user_message', content: 'c' },
|
|
107
|
+
tool_start: { type: 'tool_start', state: {} as never },
|
|
108
|
+
tool_end: { type: 'tool_end', state: {} as never },
|
|
109
|
+
thinking: { type: 'thinking', isThinking: true },
|
|
110
|
+
complete: { type: 'complete', result: {} as never },
|
|
111
|
+
interrupted: { type: 'interrupted', result: {} as never },
|
|
112
|
+
error: { type: 'error', message: 'm' },
|
|
113
|
+
command_result: {
|
|
114
|
+
type: 'command_result',
|
|
115
|
+
name: 'n',
|
|
116
|
+
message: 'm',
|
|
117
|
+
success: true,
|
|
118
|
+
requestId: 'command-1',
|
|
119
|
+
},
|
|
120
|
+
// #3288 §2: `display` is optional (an older host may not send it) and foreign-owned
|
|
121
|
+
// (`IHistoryDisplaySegment`, `agent-interface-session`) — checked shallowly here, same as
|
|
122
|
+
// `tool_start`'s `state`, per this file's own header.
|
|
123
|
+
messages: {
|
|
124
|
+
type: 'messages',
|
|
125
|
+
messages: [],
|
|
126
|
+
display: [
|
|
127
|
+
{ type: 'text', role: 'user', content: 'hi' },
|
|
128
|
+
{ type: 'tool', tool: { toolName: 'Bash', firstArg: 'ls', isRunning: false } as never },
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
history: {
|
|
132
|
+
type: 'history',
|
|
133
|
+
startIndex: 3,
|
|
134
|
+
total: 4,
|
|
135
|
+
entries: [
|
|
136
|
+
{
|
|
137
|
+
id: 'e1',
|
|
138
|
+
timestamp: '2026-09-26T01:02:03.004Z',
|
|
139
|
+
category: 'event',
|
|
140
|
+
type: 'skill-activation',
|
|
141
|
+
data: { name: 'review' },
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
context: { type: 'context', state: {} as never },
|
|
146
|
+
history_changed: { type: 'history_changed' },
|
|
147
|
+
turn_source: { type: 'turn_source', source: 'peer' },
|
|
148
|
+
commands: { type: 'commands', commands: [], skills: [] },
|
|
149
|
+
session_status: { type: 'session_status', status: {} as never },
|
|
150
|
+
model_list: {
|
|
151
|
+
type: 'model_list',
|
|
152
|
+
requestId: 'request-6',
|
|
153
|
+
groups: [
|
|
154
|
+
{ profileName: 'anthropic', providerLabel: 'Anthropic', models: [{ id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' }] },
|
|
155
|
+
],
|
|
156
|
+
currentProfile: 'anthropic',
|
|
157
|
+
currentModel: 'claude-sonnet-4-6',
|
|
158
|
+
},
|
|
159
|
+
agent_definitions: {
|
|
160
|
+
type: 'agent_definitions',
|
|
161
|
+
requestId: 'request-7',
|
|
162
|
+
agents: [
|
|
163
|
+
{ name: 'general-purpose', description: 'General-purpose task execution agent.', definedIn: 'Built-in' },
|
|
164
|
+
{ name: 'Explore', description: 'Read-only codebase exploration agent.', definedIn: 'Built-in' },
|
|
165
|
+
],
|
|
166
|
+
current: 'general-purpose',
|
|
167
|
+
},
|
|
168
|
+
sessions: { type: 'sessions', requestId: 'request-3', listing: {} as never },
|
|
169
|
+
sessions_error: {
|
|
170
|
+
type: 'sessions_error',
|
|
171
|
+
requestId: 'request-3',
|
|
172
|
+
code: 'list_failed',
|
|
173
|
+
message: 'unreadable store',
|
|
174
|
+
},
|
|
175
|
+
session_switched: { type: 'session_switched', event: { sessionId: 'session-2' } },
|
|
176
|
+
session_change_failed: {
|
|
177
|
+
type: 'session_change_failed',
|
|
178
|
+
code: 'prompt_pending',
|
|
179
|
+
message: 'Answer the pending prompt first.',
|
|
180
|
+
requestId: 'request-5',
|
|
181
|
+
},
|
|
182
|
+
session_renamed_in_list: {
|
|
183
|
+
type: 'session_renamed_in_list',
|
|
184
|
+
requestId: 'request-6',
|
|
185
|
+
sessionId: 'session-2',
|
|
186
|
+
name: 'Renamed',
|
|
187
|
+
},
|
|
188
|
+
session_rename_failed: {
|
|
189
|
+
type: 'session_rename_failed',
|
|
190
|
+
requestId: 'request-6',
|
|
191
|
+
message: 'No session session-2 in this workspace.',
|
|
192
|
+
},
|
|
193
|
+
session_deleted: { type: 'session_deleted', requestId: 'request-7', sessionId: 'session-2' },
|
|
194
|
+
session_delete_failed: {
|
|
195
|
+
type: 'session_delete_failed',
|
|
196
|
+
requestId: 'request-7',
|
|
197
|
+
code: 'live_elsewhere',
|
|
198
|
+
message: 'Another client is on this session.',
|
|
199
|
+
},
|
|
200
|
+
usage_report: { type: 'usage_report', report: {} as never },
|
|
201
|
+
personal_usage_report: {
|
|
202
|
+
type: 'personal_usage_report',
|
|
203
|
+
requestId: 'request-1',
|
|
204
|
+
report: {} as never,
|
|
205
|
+
},
|
|
206
|
+
personal_usage_report_error: {
|
|
207
|
+
type: 'personal_usage_report_error',
|
|
208
|
+
requestId: 'request-1',
|
|
209
|
+
code: 'not_available',
|
|
210
|
+
message: 'not available',
|
|
211
|
+
},
|
|
212
|
+
stored_session_usage_report: {
|
|
213
|
+
type: 'stored_session_usage_report',
|
|
214
|
+
requestId: 'request-2',
|
|
215
|
+
sessionId: 'session-1',
|
|
216
|
+
report: {} as never,
|
|
217
|
+
},
|
|
218
|
+
stored_session_usage_report_error: {
|
|
219
|
+
type: 'stored_session_usage_report_error',
|
|
220
|
+
requestId: 'request-2',
|
|
221
|
+
sessionId: 'session-1',
|
|
222
|
+
code: 'report_failed',
|
|
223
|
+
message: 'not found',
|
|
224
|
+
},
|
|
225
|
+
executing: { type: 'executing', executing: false },
|
|
226
|
+
pending: { type: 'pending', pending: null, pendingCount: 0 },
|
|
227
|
+
execution_workspace_event: { type: 'execution_workspace_event', snapshot: {} as never },
|
|
228
|
+
execution_detail: {
|
|
229
|
+
type: 'execution_detail',
|
|
230
|
+
requestId: 'detail-1',
|
|
231
|
+
page: { entryId: 'main', records: [], nextCursor: { offset: 20 } },
|
|
232
|
+
},
|
|
233
|
+
execution_detail_error: {
|
|
234
|
+
type: 'execution_detail_error',
|
|
235
|
+
requestId: 'detail-1',
|
|
236
|
+
message: 'Unknown entry: main',
|
|
237
|
+
},
|
|
238
|
+
waiting_loop_stop: {
|
|
239
|
+
type: 'waiting_loop_stop',
|
|
240
|
+
requestId: 'loop-1',
|
|
241
|
+
outcome: { kind: 'stopped', loopId: 'loop-a', message: 'Stopped loop-a.' },
|
|
242
|
+
},
|
|
243
|
+
project_status: {
|
|
244
|
+
type: 'project_status',
|
|
245
|
+
requestId: 'project-status-1',
|
|
246
|
+
result: {
|
|
247
|
+
kind: 'status',
|
|
248
|
+
branch: 'main',
|
|
249
|
+
unborn: false,
|
|
250
|
+
files: [{ path: 'a.txt', status: 'Modified', added: 1, removed: 2 }],
|
|
251
|
+
truncated: false,
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
project_diff: {
|
|
255
|
+
type: 'project_diff',
|
|
256
|
+
requestId: 'project-diff-1',
|
|
257
|
+
result: {
|
|
258
|
+
kind: 'diff',
|
|
259
|
+
diffLines: [{ type: 'add', text: 'new line', lineNumber: 3 }],
|
|
260
|
+
truncated: false,
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
project_memory: {
|
|
264
|
+
type: 'project_memory',
|
|
265
|
+
requestId: 'project-memory-1',
|
|
266
|
+
result: { kind: 'memory', content: '# Memory', path: '.fixture-state/memory/MEMORY.md', truncated: false },
|
|
267
|
+
},
|
|
268
|
+
app_tool_result: {
|
|
269
|
+
type: 'app_tool_result',
|
|
270
|
+
requestId: 'app-tool-1',
|
|
271
|
+
success: true,
|
|
272
|
+
data: { rows: 3 },
|
|
273
|
+
parts: [{ type: 'text', text: 'three rows' }],
|
|
274
|
+
error: 'ignored on success',
|
|
275
|
+
},
|
|
276
|
+
app_view: {
|
|
277
|
+
type: 'app_view',
|
|
278
|
+
requestId: 'app-view-1',
|
|
279
|
+
view: {
|
|
280
|
+
sourceId: 'files',
|
|
281
|
+
resourceUri: 'ui://files/list',
|
|
282
|
+
mimeType: 'text/html;profile=mcp-app',
|
|
283
|
+
html: '<main>rows</main>',
|
|
284
|
+
callableTools: { list_files: 'mcp__files__list_files' },
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
background_task_event: { type: 'background_task_event', event: {} as never },
|
|
288
|
+
background_job_group_event: { type: 'background_job_group_event', event: {} as never },
|
|
289
|
+
plan_event: { type: 'plan_event', event: {} as never },
|
|
290
|
+
context_file_refreshed: { type: 'context_file_refreshed', event: {} as never },
|
|
291
|
+
branch_event: { type: 'branch_event', event: {} as never },
|
|
292
|
+
background_tasks: { type: 'background_tasks', tasks: [] },
|
|
293
|
+
background_task: { type: 'background_task', taskId: 't', task: null },
|
|
294
|
+
background_job_groups: { type: 'background_job_groups', groups: [] },
|
|
295
|
+
background_job_group: { type: 'background_job_group', groupId: 'g', group: null },
|
|
296
|
+
background_task_log: { type: 'background_task_log', taskId: 't', page: {} as never },
|
|
297
|
+
permission_request: { type: 'permission_request', event: {} as never },
|
|
298
|
+
ask_request: { type: 'ask_request', event: {} as never },
|
|
299
|
+
prompt_resolved: { type: 'prompt_resolved', event: {} as never },
|
|
300
|
+
ui_intent: { type: 'ui_intent', event: {} as never },
|
|
301
|
+
session_renamed: { type: 'session_renamed', event: {} as never },
|
|
302
|
+
history_cleared: { type: 'history_cleared' },
|
|
303
|
+
background_task_control_result: {
|
|
304
|
+
type: 'background_task_control_result',
|
|
305
|
+
action: 'cancel',
|
|
306
|
+
taskId: 't',
|
|
307
|
+
success: true,
|
|
308
|
+
},
|
|
309
|
+
protocol_error: { type: 'protocol_error', message: 'm', requestId: 'command-1' },
|
|
310
|
+
resume_gap: { type: 'resume_gap' },
|
|
311
|
+
settings: { type: 'settings', requestId: 'settings-1', settings: {} as never },
|
|
312
|
+
settings_error: {
|
|
313
|
+
type: 'settings_error',
|
|
314
|
+
requestId: 'settings-1',
|
|
315
|
+
code: 'not_available',
|
|
316
|
+
message: 'Settings are not available on this host.',
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const MALFORMED_CLIENT: ReadonlyArray<[string, unknown]> = [
|
|
321
|
+
['null', null],
|
|
322
|
+
[
|
|
323
|
+
'call-app-tool without requestId',
|
|
324
|
+
{ type: 'call-app-tool', viewId: 'v', name: 'n', arguments: {} },
|
|
325
|
+
],
|
|
326
|
+
[
|
|
327
|
+
'call-app-tool with an empty requestId',
|
|
328
|
+
{ type: 'call-app-tool', requestId: '', viewId: 'v', name: 'n', arguments: {} },
|
|
329
|
+
],
|
|
330
|
+
[
|
|
331
|
+
'call-app-tool without a viewId',
|
|
332
|
+
{ type: 'call-app-tool', requestId: 'r', name: 'n', arguments: {} },
|
|
333
|
+
],
|
|
334
|
+
[
|
|
335
|
+
'call-app-tool with an empty viewId',
|
|
336
|
+
{ type: 'call-app-tool', requestId: 'r', viewId: '', name: 'n', arguments: {} },
|
|
337
|
+
],
|
|
338
|
+
[
|
|
339
|
+
'call-app-tool without a name',
|
|
340
|
+
{ type: 'call-app-tool', requestId: 'r', viewId: 'v', arguments: {} },
|
|
341
|
+
],
|
|
342
|
+
[
|
|
343
|
+
'call-app-tool with a string argument bag',
|
|
344
|
+
{ type: 'call-app-tool', requestId: 'r', viewId: 'v', name: 'n', arguments: 'x' },
|
|
345
|
+
],
|
|
346
|
+
[
|
|
347
|
+
'call-app-tool with an array argument bag',
|
|
348
|
+
{ type: 'call-app-tool', requestId: 'r', viewId: 'v', name: 'n', arguments: [] },
|
|
349
|
+
],
|
|
350
|
+
[
|
|
351
|
+
'call-app-tool without arguments',
|
|
352
|
+
{ type: 'call-app-tool', requestId: 'r', viewId: 'v', name: 'n' },
|
|
353
|
+
],
|
|
354
|
+
['read-app-view without requestId', { type: 'read-app-view', viewId: 'v' }],
|
|
355
|
+
['read-app-view without viewId', { type: 'read-app-view', requestId: 'r' }],
|
|
356
|
+
['read-app-view with a numeric viewId', { type: 'read-app-view', requestId: 'r', viewId: 1 }],
|
|
357
|
+
['an array', [{ type: 'abort' }]],
|
|
358
|
+
['a string', 'abort'],
|
|
359
|
+
['no type', { prompt: 'x' }],
|
|
360
|
+
['a numeric type', { type: 1 }],
|
|
361
|
+
['an unknown type', { type: 'nope' }],
|
|
362
|
+
['a prototype-only type', { type: 'toString' }],
|
|
363
|
+
['submit without prompt', { type: 'submit' }],
|
|
364
|
+
['submit with an empty prompt', { type: 'submit', prompt: '' }],
|
|
365
|
+
['submit with an array prompt', { type: 'submit', prompt: ['x'] }],
|
|
366
|
+
['command with numeric args', { type: 'command', name: 'n', args: 1 }],
|
|
367
|
+
['get-background-tasks with an array filter', { type: 'get-background-tasks', filter: [] }],
|
|
368
|
+
[
|
|
369
|
+
'get-background-tasks with an unknown kind',
|
|
370
|
+
{ type: 'get-background-tasks', filter: { kind: 'x' } },
|
|
371
|
+
],
|
|
372
|
+
[
|
|
373
|
+
'send-background-task with a string input',
|
|
374
|
+
{ type: 'send-background-task', taskId: 't', input: 'p' },
|
|
375
|
+
],
|
|
376
|
+
[
|
|
377
|
+
'send-background-task with a numeric prompt',
|
|
378
|
+
{ type: 'send-background-task', taskId: 't', input: { prompt: 1 } },
|
|
379
|
+
],
|
|
380
|
+
[
|
|
381
|
+
'read-background-task-log with a string offset',
|
|
382
|
+
{ type: 'read-background-task-log', taskId: 't', cursor: { offset: '3' } },
|
|
383
|
+
],
|
|
384
|
+
[
|
|
385
|
+
'permission-response with a string result',
|
|
386
|
+
{ type: 'permission-response', id: 'p', result: 'yes' },
|
|
387
|
+
],
|
|
388
|
+
[
|
|
389
|
+
'ask-response with values not strings',
|
|
390
|
+
{ type: 'ask-response', id: 'a', response: { type: 'answer', values: [1] } },
|
|
391
|
+
],
|
|
392
|
+
[
|
|
393
|
+
'ask-response cancelled carrying values',
|
|
394
|
+
{ type: 'ask-response', id: 'a', response: { type: 'cancelled', values: [] } },
|
|
395
|
+
],
|
|
396
|
+
['resume with a string lastSeq', { type: 'resume', lastSeq: '4' }],
|
|
397
|
+
['ack with NaN', { type: 'ack', seq: Number.NaN }],
|
|
398
|
+
['cancel-background-task with an empty taskId', { type: 'cancel-background-task', taskId: '' }],
|
|
399
|
+
['list-sessions without requestId', { type: 'list-sessions' }],
|
|
400
|
+
['list-models without requestId', { type: 'list-models' }],
|
|
401
|
+
['list-models with an empty requestId', { type: 'list-models', requestId: '' }],
|
|
402
|
+
['get-agent-definitions without requestId', { type: 'get-agent-definitions' }],
|
|
403
|
+
[
|
|
404
|
+
'get-agent-definitions with an empty requestId',
|
|
405
|
+
{ type: 'get-agent-definitions', requestId: '' },
|
|
406
|
+
],
|
|
407
|
+
['get-history from a negative index', { type: 'get-history', fromIndex: -1 }],
|
|
408
|
+
['get-history from a fractional index', { type: 'get-history', fromIndex: 1.5 }],
|
|
409
|
+
['command with an empty requestId', { type: 'command', name: 'n', requestId: '' }],
|
|
410
|
+
['switch-session with an empty sessionId', { type: 'switch-session', sessionId: '' }],
|
|
411
|
+
[
|
|
412
|
+
'switch-session with a numeric requestId',
|
|
413
|
+
{ type: 'switch-session', sessionId: 's', requestId: 1 },
|
|
414
|
+
],
|
|
415
|
+
['new-session with an empty requestId', { type: 'new-session', requestId: '' }],
|
|
416
|
+
['read-execution-detail without requestId', { type: 'read-execution-detail', entryId: 'main' }],
|
|
417
|
+
[
|
|
418
|
+
'read-execution-detail with an empty entryId',
|
|
419
|
+
{ type: 'read-execution-detail', requestId: 'r', entryId: '' },
|
|
420
|
+
],
|
|
421
|
+
[
|
|
422
|
+
'read-execution-detail with a negative offset',
|
|
423
|
+
{ type: 'read-execution-detail', requestId: 'r', entryId: 'main', cursor: { offset: -1 } },
|
|
424
|
+
],
|
|
425
|
+
[
|
|
426
|
+
'read-execution-detail with a string offset',
|
|
427
|
+
{ type: 'read-execution-detail', requestId: 'r', entryId: 'main', cursor: { offset: '2' } },
|
|
428
|
+
],
|
|
429
|
+
['stop-waiting-loop without requestId', { type: 'stop-waiting-loop' }],
|
|
430
|
+
['stop-waiting-loop with an empty requestId', { type: 'stop-waiting-loop', requestId: '' }],
|
|
431
|
+
['get-settings without requestId', { type: 'get-settings' }],
|
|
432
|
+
['get-settings with an empty requestId', { type: 'get-settings', requestId: '' }],
|
|
433
|
+
['update-settings without a patch', { type: 'update-settings', requestId: 'r' }],
|
|
434
|
+
[
|
|
435
|
+
'update-settings with an unknown patch field',
|
|
436
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'nope' } },
|
|
437
|
+
],
|
|
438
|
+
[
|
|
439
|
+
'update-settings with a prototype-only patch field',
|
|
440
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'toString' } },
|
|
441
|
+
],
|
|
442
|
+
[
|
|
443
|
+
'update-settings language patch with a numeric language',
|
|
444
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'language', language: 1 } },
|
|
445
|
+
],
|
|
446
|
+
[
|
|
447
|
+
'update-settings outputStyle patch missing styleId',
|
|
448
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'outputStyle' } },
|
|
449
|
+
],
|
|
450
|
+
[
|
|
451
|
+
'update-settings preset patch with an empty presetId',
|
|
452
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'preset', presetId: '' } },
|
|
453
|
+
],
|
|
454
|
+
[
|
|
455
|
+
'update-settings permissionMode patch missing mode',
|
|
456
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'permissionMode' } },
|
|
457
|
+
],
|
|
458
|
+
[
|
|
459
|
+
'update-settings sandbox patch with a string enabled',
|
|
460
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'sandbox', enabled: 'true' } },
|
|
461
|
+
],
|
|
462
|
+
[
|
|
463
|
+
'update-settings removePermissionRule patch with an unknown kind',
|
|
464
|
+
{
|
|
465
|
+
type: 'update-settings',
|
|
466
|
+
requestId: 'r',
|
|
467
|
+
patch: { field: 'removePermissionRule', scope: 'user', kind: 'nope', pattern: 'Bash(*)' },
|
|
468
|
+
},
|
|
469
|
+
],
|
|
470
|
+
[
|
|
471
|
+
'update-settings removePermissionRule patch missing pattern',
|
|
472
|
+
{
|
|
473
|
+
type: 'update-settings',
|
|
474
|
+
requestId: 'r',
|
|
475
|
+
patch: { field: 'removePermissionRule', scope: 'user', kind: 'allow' },
|
|
476
|
+
},
|
|
477
|
+
],
|
|
478
|
+
[
|
|
479
|
+
'update-settings mcpServerEnabled patch missing serverId',
|
|
480
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'mcpServerEnabled', enabled: true } },
|
|
481
|
+
],
|
|
482
|
+
[
|
|
483
|
+
'update-settings mcpServerEnabled patch with a string enabled',
|
|
484
|
+
{
|
|
485
|
+
type: 'update-settings',
|
|
486
|
+
requestId: 'r',
|
|
487
|
+
patch: { field: 'mcpServerEnabled', serverId: 'docs', enabled: 'true' },
|
|
488
|
+
},
|
|
489
|
+
],
|
|
490
|
+
[
|
|
491
|
+
'update-settings pluginEnabled patch with an empty pluginId',
|
|
492
|
+
{
|
|
493
|
+
type: 'update-settings',
|
|
494
|
+
requestId: 'r',
|
|
495
|
+
patch: { field: 'pluginEnabled', pluginId: '', enabled: true },
|
|
496
|
+
},
|
|
497
|
+
],
|
|
498
|
+
[
|
|
499
|
+
'update-settings installPlugin patch missing pluginId',
|
|
500
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'installPlugin' } },
|
|
501
|
+
],
|
|
502
|
+
[
|
|
503
|
+
'update-settings uninstallPlugin patch with a numeric pluginId',
|
|
504
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'uninstallPlugin', pluginId: 1 } },
|
|
505
|
+
],
|
|
506
|
+
[
|
|
507
|
+
'update-settings providerProfile patch missing profileName',
|
|
508
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'providerProfile' } },
|
|
509
|
+
],
|
|
510
|
+
[
|
|
511
|
+
'update-settings providerProfile patch with an empty profileName',
|
|
512
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'providerProfile', profileName: '' } },
|
|
513
|
+
],
|
|
514
|
+
[
|
|
515
|
+
'update-settings providerModel patch missing modelId',
|
|
516
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'providerModel', profileName: 'anthropic' } },
|
|
517
|
+
],
|
|
518
|
+
[
|
|
519
|
+
'update-settings providerModel patch missing profileName',
|
|
520
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'providerModel', modelId: 'claude-haiku-4-5' } },
|
|
521
|
+
],
|
|
522
|
+
[
|
|
523
|
+
'update-settings deleteProviderProfile patch missing profileName',
|
|
524
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'deleteProviderProfile' } },
|
|
525
|
+
],
|
|
526
|
+
];
|
|
527
|
+
|
|
528
|
+
const MALFORMED_SERVER: ReadonlyArray<[string, unknown]> = [
|
|
529
|
+
['null', null],
|
|
530
|
+
['app_tool_result without requestId', { type: 'app_tool_result', success: true }],
|
|
531
|
+
['app_tool_result with a string flag', { type: 'app_tool_result', requestId: 'r', success: 'yes' }],
|
|
532
|
+
['app_tool_result without a flag', { type: 'app_tool_result', requestId: 'r' }],
|
|
533
|
+
[
|
|
534
|
+
'app_tool_result with non-record parts',
|
|
535
|
+
{ type: 'app_tool_result', requestId: 'r', success: true, parts: ['x'] },
|
|
536
|
+
],
|
|
537
|
+
[
|
|
538
|
+
'app_tool_result with a numeric error',
|
|
539
|
+
{ type: 'app_tool_result', requestId: 'r', success: false, error: 1 },
|
|
540
|
+
],
|
|
541
|
+
['app_view without requestId', { type: 'app_view', view: {} }],
|
|
542
|
+
['app_view with a string view', { type: 'app_view', requestId: 'r', view: '<main/>' }],
|
|
543
|
+
['app_view without a view', { type: 'app_view', requestId: 'r' }],
|
|
544
|
+
['an array', [{ type: 'resume_gap' }]],
|
|
545
|
+
['an unknown type', { type: 'nope' }],
|
|
546
|
+
['text_delta without delta', { type: 'text_delta' }],
|
|
547
|
+
['text_delta with a numeric driverId', { type: 'text_delta', delta: 'd', driverId: 1 }],
|
|
548
|
+
['tool_start with an array state', { type: 'tool_start', state: [] }],
|
|
549
|
+
['thinking with a string flag', { type: 'thinking', isThinking: 'yes' }],
|
|
550
|
+
['messages with a non-array', { type: 'messages', messages: {} }],
|
|
551
|
+
['messages with a primitive entry', { type: 'messages', messages: ['x'] }],
|
|
552
|
+
['messages with a numeric driverId', { type: 'messages', messages: [], driverId: 12345 }],
|
|
553
|
+
['messages with a non-array display', { type: 'messages', messages: [], display: {} }],
|
|
554
|
+
['messages with a display holding a primitive entry', { type: 'messages', messages: [], display: ['x'] }],
|
|
555
|
+
['pending with a number', { type: 'pending', pending: 1, pendingCount: 1 }],
|
|
556
|
+
['pending with a negative count', { type: 'pending', pending: null, pendingCount: -1 }],
|
|
557
|
+
['pending with a fractional count', { type: 'pending', pending: 'p', pendingCount: 1.5 }],
|
|
558
|
+
['background_task with an array task', { type: 'background_task', taskId: 't', task: [] }],
|
|
559
|
+
[
|
|
560
|
+
'control result with an unknown action',
|
|
561
|
+
{ type: 'background_task_control_result', action: 'nuke', taskId: 't', success: true },
|
|
562
|
+
],
|
|
563
|
+
['command_result without success', { type: 'command_result', name: 'n', message: 'm' }],
|
|
564
|
+
[
|
|
565
|
+
'sessions_error with an unknown code',
|
|
566
|
+
{ type: 'sessions_error', requestId: 'r', code: 'nope', message: 'm' },
|
|
567
|
+
],
|
|
568
|
+
['session_switched without event', { type: 'session_switched' }],
|
|
569
|
+
['model_list without requestId', { type: 'model_list', groups: [], currentModel: 'm' }],
|
|
570
|
+
[
|
|
571
|
+
'model_list with a non-array groups',
|
|
572
|
+
{ type: 'model_list', requestId: 'r', groups: {}, currentModel: 'm' },
|
|
573
|
+
],
|
|
574
|
+
['model_list without currentModel', { type: 'model_list', requestId: 'r', groups: [] }],
|
|
575
|
+
[
|
|
576
|
+
'agent_definitions without requestId',
|
|
577
|
+
{ type: 'agent_definitions', agents: [], current: 'general-purpose' },
|
|
578
|
+
],
|
|
579
|
+
[
|
|
580
|
+
'agent_definitions with a non-array agents',
|
|
581
|
+
{ type: 'agent_definitions', requestId: 'r', agents: {}, current: 'general-purpose' },
|
|
582
|
+
],
|
|
583
|
+
[
|
|
584
|
+
'agent_definitions with an agent missing definedIn',
|
|
585
|
+
{
|
|
586
|
+
type: 'agent_definitions',
|
|
587
|
+
requestId: 'r',
|
|
588
|
+
agents: [{ name: 'a', description: 'd' }],
|
|
589
|
+
current: 'a',
|
|
590
|
+
},
|
|
591
|
+
],
|
|
592
|
+
['agent_definitions without current', { type: 'agent_definitions', requestId: 'r', agents: [] }],
|
|
593
|
+
[
|
|
594
|
+
'session_change_failed with an unknown code',
|
|
595
|
+
{ type: 'session_change_failed', code: 'nope', message: 'm' },
|
|
596
|
+
],
|
|
597
|
+
['session_change_failed without message', { type: 'session_change_failed', code: 'limit' }],
|
|
598
|
+
[
|
|
599
|
+
'session_change_failed with a numeric requestId',
|
|
600
|
+
{ type: 'session_change_failed', code: 'limit', message: 'm', requestId: 1 },
|
|
601
|
+
],
|
|
602
|
+
['history without entries', { type: 'history', startIndex: 0, total: 0 }],
|
|
603
|
+
['history without its place', { type: 'history', entries: [] }],
|
|
604
|
+
['history with a negative total', { type: 'history', startIndex: 0, total: -1, entries: [] }],
|
|
605
|
+
['history with a primitive entry', { type: 'history', startIndex: 0, total: 1, entries: ['x'] }],
|
|
606
|
+
[
|
|
607
|
+
'history with a numeric timestamp',
|
|
608
|
+
{
|
|
609
|
+
type: 'history',
|
|
610
|
+
startIndex: 0,
|
|
611
|
+
total: 1,
|
|
612
|
+
entries: [{ id: 'e', timestamp: 1, category: 'chat', type: 'user' }],
|
|
613
|
+
},
|
|
614
|
+
],
|
|
615
|
+
[
|
|
616
|
+
'history with an entry missing its category',
|
|
617
|
+
{
|
|
618
|
+
type: 'history',
|
|
619
|
+
startIndex: 0,
|
|
620
|
+
total: 1,
|
|
621
|
+
entries: [{ id: 'e', timestamp: 't', type: 'user' }],
|
|
622
|
+
},
|
|
623
|
+
],
|
|
624
|
+
[
|
|
625
|
+
'protocol_error with a numeric requestId',
|
|
626
|
+
{ type: 'protocol_error', message: 'm', requestId: 1 },
|
|
627
|
+
],
|
|
628
|
+
['turn_source with an unknown source', { type: 'turn_source', source: 'robot' }],
|
|
629
|
+
['turn_source without source', { type: 'turn_source' }],
|
|
630
|
+
['execution_detail without page', { type: 'execution_detail', requestId: 'r' }],
|
|
631
|
+
['execution_detail with an array page', { type: 'execution_detail', requestId: 'r', page: [] }],
|
|
632
|
+
['execution_detail without requestId', { type: 'execution_detail', page: {} }],
|
|
633
|
+
['execution_detail_error without message', { type: 'execution_detail_error', requestId: 'r' }],
|
|
634
|
+
['waiting_loop_stop without outcome', { type: 'waiting_loop_stop', requestId: 'r' }],
|
|
635
|
+
[
|
|
636
|
+
'waiting_loop_stop with an unknown kind',
|
|
637
|
+
{ type: 'waiting_loop_stop', requestId: 'r', outcome: { kind: 'paused' } },
|
|
638
|
+
],
|
|
639
|
+
[
|
|
640
|
+
'waiting_loop_stop stopped without loopId',
|
|
641
|
+
{ type: 'waiting_loop_stop', requestId: 'r', outcome: { kind: 'stopped' } },
|
|
642
|
+
],
|
|
643
|
+
[
|
|
644
|
+
'waiting_loop_stop several without message',
|
|
645
|
+
{ type: 'waiting_loop_stop', requestId: 'r', outcome: { kind: 'several' } },
|
|
646
|
+
],
|
|
647
|
+
[
|
|
648
|
+
'waiting_loop_stop failed with a numeric loopId',
|
|
649
|
+
{
|
|
650
|
+
type: 'waiting_loop_stop',
|
|
651
|
+
requestId: 'r',
|
|
652
|
+
outcome: { kind: 'failed', loopId: 1, message: 'm' },
|
|
653
|
+
},
|
|
654
|
+
],
|
|
655
|
+
[
|
|
656
|
+
'waiting_loop_stop with a prototype-only kind',
|
|
657
|
+
{ type: 'waiting_loop_stop', requestId: 'r', outcome: { kind: 'toString' } },
|
|
658
|
+
],
|
|
659
|
+
['settings without requestId', { type: 'settings', settings: {} }],
|
|
660
|
+
['settings with an array settings', { type: 'settings', requestId: 'r', settings: [] }],
|
|
661
|
+
[
|
|
662
|
+
'settings_error with an unknown code',
|
|
663
|
+
{ type: 'settings_error', requestId: 'r', code: 'nope', message: 'm' },
|
|
664
|
+
],
|
|
665
|
+
['settings_error without message', { type: 'settings_error', requestId: 'r', code: 'invalid' }],
|
|
666
|
+
];
|
|
667
|
+
|
|
668
|
+
describe('decodeClientMessage (issue #2045)', () => {
|
|
669
|
+
it('has a shape for every union variant and every sample decodes through JSON', () => {
|
|
670
|
+
for (const [type, sample] of Object.entries(CLIENT_SAMPLES)) {
|
|
671
|
+
expect(CLIENT_MESSAGE_SHAPES).toHaveProperty(type);
|
|
672
|
+
expect(decodeClientMessage(JSON.parse(JSON.stringify(sample)))).toEqual({
|
|
673
|
+
ok: true,
|
|
674
|
+
message: sample,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it.each(MALFORMED_CLIENT)('refuses %s', (_label, value) => {
|
|
680
|
+
expect(decodeClientMessage(value).ok).toBe(false);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
// #3282 §4b: "Providers & Models" — Use, Model and Delete each add one `update-settings` patch
|
|
684
|
+
// field; `CLIENT_SAMPLES` above only round-trips one field (`outputStyle`) per variant, so these
|
|
685
|
+
// three get their own explicit round trip.
|
|
686
|
+
it.each([
|
|
687
|
+
{ field: 'providerProfile', profileName: 'backup' },
|
|
688
|
+
{ field: 'providerModel', profileName: 'backup', modelId: 'claude-haiku-4-5' },
|
|
689
|
+
{ field: 'deleteProviderProfile', profileName: 'backup' },
|
|
690
|
+
])('decodes an update-settings patch with field %j', (patch) => {
|
|
691
|
+
const message = { type: 'update-settings', requestId: 'r', patch };
|
|
692
|
+
expect(decodeClientMessage(JSON.parse(JSON.stringify(message)))).toEqual({
|
|
693
|
+
ok: true,
|
|
694
|
+
message,
|
|
695
|
+
});
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it('keeps the protocol wording for a bad submit prompt', () => {
|
|
699
|
+
expect(decodeClientMessage({ type: 'submit', prompt: 1 })).toEqual({
|
|
700
|
+
ok: false,
|
|
701
|
+
reason: 'prompt must be a non-empty string',
|
|
702
|
+
});
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it.each([
|
|
706
|
+
{ field: 'mcpServerEnabled', serverId: 'docs', enabled: true },
|
|
707
|
+
{ field: 'reloadMcpServers' },
|
|
708
|
+
{ field: 'pluginEnabled', pluginId: 'formatter@fixture-agent', enabled: false },
|
|
709
|
+
{ field: 'reloadPlugins' },
|
|
710
|
+
{ field: 'installPlugin', pluginId: 'linter@fixture-agent' },
|
|
711
|
+
{ field: 'uninstallPlugin', pluginId: 'formatter@fixture-agent' },
|
|
712
|
+
] as const)('accepts the $field settings patch (#3282 §4 part b-2)', (patch) => {
|
|
713
|
+
const message = { type: 'update-settings' as const, requestId: 'r', patch };
|
|
714
|
+
expect(decodeClientMessage(JSON.parse(JSON.stringify(message)))).toEqual({
|
|
715
|
+
ok: true,
|
|
716
|
+
message,
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
describe('the app tool and app view frames', () => {
|
|
722
|
+
it('accepts an app tool result whatever JSON the tool returned, and one without data', () => {
|
|
723
|
+
for (const data of [null, 0, 'text', [1, 2], { nested: { ok: true } }]) {
|
|
724
|
+
expect(
|
|
725
|
+
decodeServerMessage({ type: 'app_tool_result', requestId: 'r', success: true, data }).ok,
|
|
726
|
+
).toBe(true);
|
|
727
|
+
}
|
|
728
|
+
expect(decodeServerMessage({ type: 'app_tool_result', requestId: 'r', success: false }).ok).toBe(
|
|
729
|
+
true,
|
|
730
|
+
);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it('keeps an app tool call with no arguments as an empty bag, not as a missing one', () => {
|
|
734
|
+
expect(
|
|
735
|
+
decodeClientMessage({
|
|
736
|
+
type: 'call-app-tool',
|
|
737
|
+
requestId: 'r',
|
|
738
|
+
viewId: 'v',
|
|
739
|
+
name: 'n',
|
|
740
|
+
arguments: {},
|
|
741
|
+
}).ok,
|
|
742
|
+
).toBe(true);
|
|
743
|
+
});
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
describe('decodeServerMessage (issue #2045)', () => {
|
|
747
|
+
it('has a shape for every union variant and every sample decodes through JSON', () => {
|
|
748
|
+
for (const [type, sample] of Object.entries(SERVER_SAMPLES)) {
|
|
749
|
+
expect(SERVER_MESSAGE_SHAPES).toHaveProperty(type);
|
|
750
|
+
expect(decodeServerMessage(JSON.parse(JSON.stringify(sample)))).toEqual({
|
|
751
|
+
ok: true,
|
|
752
|
+
message: sample,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it.each(MALFORMED_SERVER)('refuses %s', (_label, value) => {
|
|
758
|
+
expect(decodeServerMessage(value).ok).toBe(false);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it('accepts every refusal code the session contracts declare, and a refusal without requestId', () => {
|
|
762
|
+
for (const code of SESSION_CHANGE_REFUSAL_CODES) {
|
|
763
|
+
expect(decodeServerMessage({ type: 'session_change_failed', code, message: 'm' }).ok).toBe(
|
|
764
|
+
true,
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it('accepts every waiting-loop stop outcome', () => {
|
|
770
|
+
for (const outcome of [
|
|
771
|
+
{ kind: 'none' },
|
|
772
|
+
{ kind: 'several', message: 'Use /loop stop <id>.' },
|
|
773
|
+
{ kind: 'stopped', loopId: 'loop-a' },
|
|
774
|
+
{ kind: 'failed', message: 'store unavailable' },
|
|
775
|
+
{ kind: 'failed', loopId: 'loop-a', message: 'store unavailable' },
|
|
776
|
+
]) {
|
|
777
|
+
expect(decodeServerMessage({ type: 'waiting_loop_stop', requestId: 'r', outcome }).ok).toBe(
|
|
778
|
+
true,
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it('accepts a pending frame without its count, as a host from before the count sends it', () => {
|
|
784
|
+
// Refusing it would cut a newer client off from an older daemon on every prompt it queues.
|
|
785
|
+
expect(decodeFrame('{"type":"pending","pending":null}', decodeServerMessage)).toEqual({
|
|
786
|
+
ok: true,
|
|
787
|
+
message: { type: 'pending', pending: null },
|
|
788
|
+
});
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
it('accepts an error frame carrying its classification (#3289 §3), and one without it', () => {
|
|
792
|
+
const classified = {
|
|
793
|
+
type: 'error',
|
|
794
|
+
message: 'Authentication Error: invalid x-api-key',
|
|
795
|
+
code: 'rate_limit',
|
|
796
|
+
provider: 'anthropic',
|
|
797
|
+
retryAfterSeconds: 30,
|
|
798
|
+
};
|
|
799
|
+
expect(decodeServerMessage(classified)).toEqual({ ok: true, message: classified });
|
|
800
|
+
expect(decodeServerMessage({ type: 'error', message: 'boom' })).toEqual({
|
|
801
|
+
ok: true,
|
|
802
|
+
message: { type: 'error', message: 'boom' },
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
it('accepts an error frame naming the model a model_unavailable failure tried', () => {
|
|
807
|
+
const classified = {
|
|
808
|
+
type: 'error',
|
|
809
|
+
message: 'No model available for provider "anthropic"',
|
|
810
|
+
code: 'model_unavailable',
|
|
811
|
+
provider: 'anthropic',
|
|
812
|
+
model: 'claude-x',
|
|
813
|
+
};
|
|
814
|
+
expect(decodeServerMessage(classified)).toEqual({ ok: true, message: classified });
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it('refuses an error frame with an unrecognized code', () => {
|
|
818
|
+
expect(
|
|
819
|
+
decodeServerMessage({ type: 'error', message: 'm', code: 'not-a-real-code' }).ok,
|
|
820
|
+
).toBe(false);
|
|
821
|
+
});
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
describe('decodeFrame — the one carrier path', () => {
|
|
825
|
+
it('refuses invalid JSON with the protocol wording and an oversized frame before parsing', () => {
|
|
826
|
+
expect(decodeFrame('not json', decodeClientMessage)).toEqual({
|
|
827
|
+
ok: false,
|
|
828
|
+
reason: 'Invalid JSON',
|
|
829
|
+
});
|
|
830
|
+
const huge = `{"type":"submit","prompt":"${'x'.repeat(MAX_INBOUND_FRAME_BYTES)}"}`;
|
|
831
|
+
expect(decodeFrame(huge, decodeClientMessage)).toEqual({
|
|
832
|
+
ok: false,
|
|
833
|
+
reason: 'Frame too large',
|
|
834
|
+
});
|
|
835
|
+
expect(decodeFrame('{"type":"abort"}', decodeClientMessage)).toEqual({
|
|
836
|
+
ok: true,
|
|
837
|
+
message: { type: 'abort' },
|
|
838
|
+
});
|
|
839
|
+
});
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
describe('completed compaction metadata', () => {
|
|
843
|
+
it('accepts both a legacy history change and the optional typed completion', () => {
|
|
844
|
+
for (const message of [
|
|
845
|
+
{ type: 'history_changed' },
|
|
846
|
+
{
|
|
847
|
+
type: 'history_changed',
|
|
848
|
+
compact: { trigger: 'auto', beforeUsedPercentage: 90.5, afterUsedPercentage: 20 },
|
|
849
|
+
},
|
|
850
|
+
{
|
|
851
|
+
type: 'history_changed',
|
|
852
|
+
compact: { trigger: 'manual', beforeUsedPercentage: 10, afterUsedPercentage: 2 },
|
|
853
|
+
},
|
|
854
|
+
]) {
|
|
855
|
+
expect(decodeServerMessage(message)).toEqual({ ok: true, message });
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
it.each([
|
|
860
|
+
null,
|
|
861
|
+
[],
|
|
862
|
+
{},
|
|
863
|
+
{ trigger: 'unknown', beforeUsedPercentage: 90, afterUsedPercentage: 20 },
|
|
864
|
+
{ trigger: 'auto', beforeUsedPercentage: '90', afterUsedPercentage: 20 },
|
|
865
|
+
{ trigger: 'auto', beforeUsedPercentage: 90, afterUsedPercentage: NaN },
|
|
866
|
+
{ trigger: 'auto', beforeUsedPercentage: Infinity, afterUsedPercentage: 20 },
|
|
867
|
+
])('refuses malformed completion metadata %j', (compact) => {
|
|
868
|
+
expect(decodeServerMessage({ type: 'history_changed', compact }).ok).toBe(false);
|
|
869
|
+
});
|
|
870
|
+
});
|