@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,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner-side runtime decoders for the wire message unions (issue #2045 / TRANS-005 adjacent).
|
|
3
|
+
*
|
|
4
|
+
* `wire-messages.ts` owns the TypeScript unions; this file owns turning `JSON.parse` output into one
|
|
5
|
+
* of them. Before it, the server did `JSON.parse(data) as TClientMessage` and each client did the
|
|
6
|
+
* same for `TServerMessage`, so valid JSON with an invalid shape crossed the boundary wearing a
|
|
7
|
+
* protocol type. Carriers now implement `raw string → owner decoder → typed message` and answer a
|
|
8
|
+
* refusal with the protocol's own `protocol_error` (server side) or client error (client side).
|
|
9
|
+
*
|
|
10
|
+
* `decodeClientMessage` is TOTAL: every variant and every nested field it carries is validated.
|
|
11
|
+
* `decodeServerMessage` validates every variant's discriminator and declared top-level fields; a
|
|
12
|
+
* payload OWNED BY ANOTHER PACKAGE (`IToolState`, `IExecutionResult`, background events, …) is
|
|
13
|
+
* checked as a non-array record (or array of records) here — its own total decoding belongs to its
|
|
14
|
+
* owner and is tracked there, not duplicated in a transport package.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
SESSION_CHANGE_REFUSAL_CODES,
|
|
19
|
+
SESSION_DELETE_REFUSAL_CODES,
|
|
20
|
+
} from '@robota-sdk/agent-interface-session';
|
|
21
|
+
|
|
22
|
+
import type { TBackgroundControlAction, TClientMessage, TServerMessage } from './wire-messages.js';
|
|
23
|
+
|
|
24
|
+
export type TMessageDecodeResult<TMessage> =
|
|
25
|
+
| { readonly ok: true; readonly message: TMessage }
|
|
26
|
+
| { readonly ok: false; readonly reason: string };
|
|
27
|
+
|
|
28
|
+
/** Serialized frames larger than this are refused before any parse (a bounded inbound budget). */
|
|
29
|
+
export const MAX_INBOUND_FRAME_BYTES = 4 * 1024 * 1024;
|
|
30
|
+
|
|
31
|
+
type TRecord = Record<string, unknown>;
|
|
32
|
+
|
|
33
|
+
function isRecord(value: unknown): value is TRecord {
|
|
34
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
35
|
+
}
|
|
36
|
+
const isString = (v: unknown): v is string => typeof v === 'string';
|
|
37
|
+
const isNonEmptyString = (v: unknown): v is string => typeof v === 'string' && v.length > 0;
|
|
38
|
+
const isBoolean = (v: unknown): v is boolean => typeof v === 'boolean';
|
|
39
|
+
const isFiniteNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
|
|
40
|
+
const isCount = (v: unknown): v is number => Number.isSafeInteger(v) && (v as number) >= 0;
|
|
41
|
+
const isOptional =
|
|
42
|
+
(check: (v: unknown) => boolean) =>
|
|
43
|
+
(v: unknown): boolean =>
|
|
44
|
+
v === undefined || check(v);
|
|
45
|
+
const isRecordArray = (v: unknown): boolean => Array.isArray(v) && v.every(isRecord);
|
|
46
|
+
const isNullableRecord = (v: unknown): boolean => v === null || isRecord(v);
|
|
47
|
+
const oneOf =
|
|
48
|
+
(members: readonly string[]) =>
|
|
49
|
+
(v: unknown): boolean =>
|
|
50
|
+
typeof v === 'string' && members.includes(v);
|
|
51
|
+
/** A record, not an array, so a turn source added to the union without one here fails to compile. */
|
|
52
|
+
const TURN_SOURCES: Readonly<
|
|
53
|
+
Record<Extract<TServerMessage, { type: 'turn_source' }>['source'], true>
|
|
54
|
+
> = { user: true, 'agent-wakeup': true, peer: true, external: true };
|
|
55
|
+
/** A history entry's envelope is this package's (`IWireHistoryEntry`); its `data` is the recorder's. */
|
|
56
|
+
const isWireHistoryEntry = (v: unknown): boolean =>
|
|
57
|
+
isRecord(v) &&
|
|
58
|
+
isString(v['id']) &&
|
|
59
|
+
isString(v['timestamp']) &&
|
|
60
|
+
isString(v['category']) &&
|
|
61
|
+
isString(v['type']);
|
|
62
|
+
const isWireHistoryEntries = (v: unknown): boolean =>
|
|
63
|
+
Array.isArray(v) && v.every(isWireHistoryEntry);
|
|
64
|
+
/** #3282 §4: one row of `agent_definitions` — name, one-line description, plain-words location. */
|
|
65
|
+
const isWireAgentDefinition = (v: unknown): boolean =>
|
|
66
|
+
isRecord(v) && isString(v['name']) && isString(v['description']) && isString(v['definedIn']);
|
|
67
|
+
const isWireAgentDefinitions = (v: unknown): boolean =>
|
|
68
|
+
Array.isArray(v) && v.every(isWireAgentDefinition);
|
|
69
|
+
|
|
70
|
+
type TFieldCheck = (value: unknown) => boolean;
|
|
71
|
+
type TVariantShape = Readonly<Record<string, TFieldCheck>>;
|
|
72
|
+
|
|
73
|
+
const BACKGROUND_TASK_KINDS = ['agent', 'process', 'scheduled'] as const;
|
|
74
|
+
const BACKGROUND_TASK_MODES = ['foreground', 'background'] as const;
|
|
75
|
+
const BACKGROUND_TASK_STATUSES = [
|
|
76
|
+
'queued',
|
|
77
|
+
'running',
|
|
78
|
+
'waiting_permission',
|
|
79
|
+
'sleeping',
|
|
80
|
+
'paused',
|
|
81
|
+
'completed',
|
|
82
|
+
'failed',
|
|
83
|
+
'cancelled',
|
|
84
|
+
] as const;
|
|
85
|
+
const BACKGROUND_CONTROL_ACTIONS: readonly TBackgroundControlAction[] = ['cancel', 'close', 'send'];
|
|
86
|
+
|
|
87
|
+
const isBackgroundTaskListFilter: TFieldCheck = (v) =>
|
|
88
|
+
isRecord(v) &&
|
|
89
|
+
isOptional(oneOf(BACKGROUND_TASK_KINDS))(v['kind']) &&
|
|
90
|
+
isOptional(oneOf(BACKGROUND_TASK_STATUSES))(v['status']) &&
|
|
91
|
+
isOptional(oneOf(BACKGROUND_TASK_MODES))(v['mode']) &&
|
|
92
|
+
isOptional(isBoolean)(v['includeClosed']);
|
|
93
|
+
const isBackgroundTaskInput: TFieldCheck = (v) =>
|
|
94
|
+
isRecord(v) && isOptional(isString)(v['prompt']) && isOptional(isString)(v['stdin']);
|
|
95
|
+
const isBackgroundTaskLogCursor: TFieldCheck = (v) => isRecord(v) && isFiniteNumber(v['offset']);
|
|
96
|
+
const isExecutionDetailCursor: TFieldCheck = (v) => isRecord(v) && isCount(v['offset']);
|
|
97
|
+
const isPermissionResultValue: TFieldCheck = (v) =>
|
|
98
|
+
isBoolean(v) || v === 'allow-session' || v === 'allow-project';
|
|
99
|
+
const isActionResponse: TFieldCheck = (v) =>
|
|
100
|
+
isRecord(v) &&
|
|
101
|
+
((v['type'] === 'answer' &&
|
|
102
|
+
Array.isArray(v['values']) &&
|
|
103
|
+
v['values'].every(isString) &&
|
|
104
|
+
isOptional(isString)(v['text'])) ||
|
|
105
|
+
(v['type'] === 'cancelled' && v['values'] === undefined && v['text'] === undefined));
|
|
106
|
+
|
|
107
|
+
type TSettingsPatch = Extract<TClientMessage, { type: 'update-settings' }>['patch'];
|
|
108
|
+
/** Keyed by `field`, so a patch variant added to the union without a shape here fails to compile. */
|
|
109
|
+
const SETTINGS_PATCH_SHAPES: Readonly<Record<TSettingsPatch['field'], TVariantShape>> = {
|
|
110
|
+
language: { language: isNonEmptyString },
|
|
111
|
+
outputStyle: { styleId: isNonEmptyString },
|
|
112
|
+
preset: { presetId: isNonEmptyString },
|
|
113
|
+
permissionMode: { mode: isNonEmptyString },
|
|
114
|
+
sandbox: { enabled: isBoolean },
|
|
115
|
+
removePermissionRule: {
|
|
116
|
+
scope: isNonEmptyString,
|
|
117
|
+
kind: oneOf(['allow', 'deny', 'ask']),
|
|
118
|
+
pattern: isNonEmptyString,
|
|
119
|
+
},
|
|
120
|
+
mcpServerEnabled: { serverId: isNonEmptyString, enabled: isBoolean },
|
|
121
|
+
reloadMcpServers: {},
|
|
122
|
+
pluginEnabled: { pluginId: isNonEmptyString, enabled: isBoolean },
|
|
123
|
+
reloadPlugins: {},
|
|
124
|
+
installPlugin: { pluginId: isNonEmptyString },
|
|
125
|
+
uninstallPlugin: { pluginId: isNonEmptyString },
|
|
126
|
+
// #3282 §4b: "Providers & Models" — Use, Model, Delete.
|
|
127
|
+
providerProfile: { profileName: isNonEmptyString },
|
|
128
|
+
providerModel: { profileName: isNonEmptyString, modelId: isNonEmptyString },
|
|
129
|
+
deleteProviderProfile: { profileName: isNonEmptyString },
|
|
130
|
+
};
|
|
131
|
+
const isSettingsPatch: TFieldCheck = (v) => {
|
|
132
|
+
if (!isRecord(v) || !isString(v['field'])) return false;
|
|
133
|
+
const field = v['field'];
|
|
134
|
+
if (!Object.prototype.hasOwnProperty.call(SETTINGS_PATCH_SHAPES, field)) return false;
|
|
135
|
+
const shape = SETTINGS_PATCH_SHAPES[field as TSettingsPatch['field']];
|
|
136
|
+
return Object.entries(shape).every(([key, check]) => check(v[key]));
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
type TWaitingLoopStopOutcome = Extract<TServerMessage, { type: 'waiting_loop_stop' }>['outcome'];
|
|
140
|
+
/** Keyed by `kind`, so an outcome added to the union without a shape here fails to compile. */
|
|
141
|
+
const WAITING_LOOP_STOP_OUTCOME_SHAPES: Readonly<
|
|
142
|
+
Record<TWaitingLoopStopOutcome['kind'], TVariantShape>
|
|
143
|
+
> = {
|
|
144
|
+
none: {},
|
|
145
|
+
several: { message: isString },
|
|
146
|
+
stopped: { loopId: isNonEmptyString, message: isOptional(isString) },
|
|
147
|
+
failed: { loopId: isOptional(isNonEmptyString), message: isString },
|
|
148
|
+
};
|
|
149
|
+
const isWaitingLoopStopOutcome: TFieldCheck = (v) => {
|
|
150
|
+
if (!isRecord(v) || !isString(v['kind'])) return false;
|
|
151
|
+
const kind = v['kind'];
|
|
152
|
+
if (!Object.prototype.hasOwnProperty.call(WAITING_LOOP_STOP_OUTCOME_SHAPES, kind)) return false;
|
|
153
|
+
const shape = WAITING_LOOP_STOP_OUTCOME_SHAPES[kind as TWaitingLoopStopOutcome['kind']];
|
|
154
|
+
return Object.entries(shape).every(([field, check]) => check(v[field]));
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** Decode a `kind`-discriminated result union (the `TWaitingLoopStopOutcome` convention) from a shape
|
|
158
|
+
* map keyed by `kind`. */
|
|
159
|
+
function isKindDiscriminated(shapes: Readonly<Record<string, TVariantShape>>): TFieldCheck {
|
|
160
|
+
return (v) => {
|
|
161
|
+
if (!isRecord(v) || !isString(v['kind'])) return false;
|
|
162
|
+
const shape = Object.prototype.hasOwnProperty.call(shapes, v['kind']) ? shapes[v['kind']] : undefined;
|
|
163
|
+
if (shape === undefined) return false;
|
|
164
|
+
return Object.entries(shape).every(([field, check]) => check(v[field]));
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// #3282 §4c: the Project panel's reads. Each result is the session method's own return value, crossing
|
|
169
|
+
// the wire unchanged (the `TWaitingLoopStopOutcome` convention above) — decoded from a `kind`-keyed
|
|
170
|
+
// shape map the same way.
|
|
171
|
+
const PROJECT_FILE_STATUSES = [
|
|
172
|
+
'Added',
|
|
173
|
+
'Modified',
|
|
174
|
+
'Deleted',
|
|
175
|
+
'Renamed',
|
|
176
|
+
'Copied',
|
|
177
|
+
'Untracked',
|
|
178
|
+
'Conflicted',
|
|
179
|
+
] as const;
|
|
180
|
+
const isProjectStatusFile: TFieldCheck = (v) =>
|
|
181
|
+
isRecord(v) &&
|
|
182
|
+
isNonEmptyString(v['path']) &&
|
|
183
|
+
oneOf(PROJECT_FILE_STATUSES)(v['status']) &&
|
|
184
|
+
isOptional(isNonEmptyString)(v['renamedFrom']) &&
|
|
185
|
+
isOptional(isFiniteNumber)(v['added']) &&
|
|
186
|
+
isOptional(isFiniteNumber)(v['removed']);
|
|
187
|
+
const isProjectStatusFiles: TFieldCheck = (v) => Array.isArray(v) && v.every(isProjectStatusFile);
|
|
188
|
+
|
|
189
|
+
type TProjectStatusReadResult = Extract<TServerMessage, { type: 'project_status' }>['result'];
|
|
190
|
+
const PROJECT_STATUS_READ_SHAPES: Readonly<Record<TProjectStatusReadResult['kind'], TVariantShape>> = {
|
|
191
|
+
status: {
|
|
192
|
+
branch: isOptional(isString),
|
|
193
|
+
unborn: isBoolean,
|
|
194
|
+
files: isProjectStatusFiles,
|
|
195
|
+
truncated: isBoolean,
|
|
196
|
+
},
|
|
197
|
+
'not-a-repository': {},
|
|
198
|
+
failed: { message: isString },
|
|
199
|
+
};
|
|
200
|
+
const isProjectStatusRead = isKindDiscriminated(PROJECT_STATUS_READ_SHAPES);
|
|
201
|
+
|
|
202
|
+
const DIFF_LINE_TYPES = ['add', 'remove', 'context', 'hunk'] as const;
|
|
203
|
+
const isDiffLine: TFieldCheck = (v) =>
|
|
204
|
+
isRecord(v) && oneOf(DIFF_LINE_TYPES)(v['type']) && isString(v['text']) && isFiniteNumber(v['lineNumber']);
|
|
205
|
+
const isDiffLines: TFieldCheck = (v) => Array.isArray(v) && v.every(isDiffLine);
|
|
206
|
+
|
|
207
|
+
type TProjectDiffReadResult = Extract<TServerMessage, { type: 'project_diff' }>['result'];
|
|
208
|
+
const PROJECT_DIFF_READ_SHAPES: Readonly<Record<TProjectDiffReadResult['kind'], TVariantShape>> = {
|
|
209
|
+
diff: { diffLines: isDiffLines, truncated: isBoolean },
|
|
210
|
+
'not-a-repository': {},
|
|
211
|
+
'outside-workspace': {},
|
|
212
|
+
failed: { message: isString },
|
|
213
|
+
};
|
|
214
|
+
const isProjectDiffRead = isKindDiscriminated(PROJECT_DIFF_READ_SHAPES);
|
|
215
|
+
|
|
216
|
+
type TProjectMemoryReadResult = Extract<TServerMessage, { type: 'project_memory' }>['result'];
|
|
217
|
+
const PROJECT_MEMORY_READ_SHAPES: Readonly<Record<TProjectMemoryReadResult['kind'], TVariantShape>> = {
|
|
218
|
+
memory: { content: isString, path: isNonEmptyString, truncated: isBoolean },
|
|
219
|
+
unavailable: { message: isString },
|
|
220
|
+
};
|
|
221
|
+
const isProjectMemoryRead = isKindDiscriminated(PROJECT_MEMORY_READ_SHAPES);
|
|
222
|
+
|
|
223
|
+
/** One entry per `TClientMessage` variant — a variant added to the union without one fails the test. */
|
|
224
|
+
export const CLIENT_MESSAGE_SHAPES: Readonly<Record<TClientMessage['type'], TVariantShape>> = {
|
|
225
|
+
submit: { prompt: isNonEmptyString },
|
|
226
|
+
command: {
|
|
227
|
+
name: isNonEmptyString,
|
|
228
|
+
args: isOptional(isString),
|
|
229
|
+
requestId: isOptional(isNonEmptyString),
|
|
230
|
+
},
|
|
231
|
+
abort: {},
|
|
232
|
+
'cancel-queue': {},
|
|
233
|
+
'get-messages': {},
|
|
234
|
+
'get-history': { fromIndex: isOptional(isCount) },
|
|
235
|
+
'get-prompts': {},
|
|
236
|
+
'get-context': {},
|
|
237
|
+
'get-commands': {},
|
|
238
|
+
'get-status': {},
|
|
239
|
+
'list-models': { requestId: isNonEmptyString },
|
|
240
|
+
'get-agent-definitions': { requestId: isNonEmptyString },
|
|
241
|
+
'list-sessions': { requestId: isNonEmptyString },
|
|
242
|
+
'new-session': { requestId: isOptional(isNonEmptyString) },
|
|
243
|
+
'switch-session': { sessionId: isNonEmptyString, requestId: isOptional(isNonEmptyString) },
|
|
244
|
+
'rename-session': {
|
|
245
|
+
sessionId: isNonEmptyString,
|
|
246
|
+
name: isNonEmptyString,
|
|
247
|
+
requestId: isNonEmptyString,
|
|
248
|
+
},
|
|
249
|
+
'delete-session': { sessionId: isNonEmptyString, requestId: isNonEmptyString },
|
|
250
|
+
'get-usage-report': {},
|
|
251
|
+
'get-personal-usage-report': {
|
|
252
|
+
requestId: isNonEmptyString,
|
|
253
|
+
period: oneOf(['7d', '30d']),
|
|
254
|
+
timezone: isNonEmptyString,
|
|
255
|
+
},
|
|
256
|
+
'get-stored-session-usage-report': {
|
|
257
|
+
requestId: isNonEmptyString,
|
|
258
|
+
sessionId: isNonEmptyString,
|
|
259
|
+
},
|
|
260
|
+
'get-executing': {},
|
|
261
|
+
'get-pending': {},
|
|
262
|
+
'get-execution-workspace': {},
|
|
263
|
+
'read-execution-detail': {
|
|
264
|
+
requestId: isNonEmptyString,
|
|
265
|
+
entryId: isNonEmptyString,
|
|
266
|
+
cursor: isOptional(isExecutionDetailCursor),
|
|
267
|
+
},
|
|
268
|
+
'stop-waiting-loop': { requestId: isNonEmptyString },
|
|
269
|
+
'project-status': { requestId: isNonEmptyString },
|
|
270
|
+
'project-diff': { requestId: isNonEmptyString, path: isNonEmptyString },
|
|
271
|
+
'project-memory': { requestId: isNonEmptyString },
|
|
272
|
+
'call-app-tool': {
|
|
273
|
+
requestId: isNonEmptyString,
|
|
274
|
+
viewId: isNonEmptyString,
|
|
275
|
+
name: isNonEmptyString,
|
|
276
|
+
arguments: isRecord,
|
|
277
|
+
},
|
|
278
|
+
'read-app-view': { requestId: isNonEmptyString, viewId: isNonEmptyString },
|
|
279
|
+
'get-background-tasks': { filter: isOptional(isBackgroundTaskListFilter) },
|
|
280
|
+
'get-background-task': { taskId: isNonEmptyString },
|
|
281
|
+
'get-background-job-groups': {},
|
|
282
|
+
'get-background-job-group': { groupId: isNonEmptyString },
|
|
283
|
+
'wait-background-job-group': { groupId: isNonEmptyString },
|
|
284
|
+
'cancel-background-task': { taskId: isNonEmptyString, reason: isOptional(isString) },
|
|
285
|
+
'close-background-task': { taskId: isNonEmptyString },
|
|
286
|
+
'send-background-task': { taskId: isNonEmptyString, input: isBackgroundTaskInput },
|
|
287
|
+
'read-background-task-log': {
|
|
288
|
+
taskId: isNonEmptyString,
|
|
289
|
+
cursor: isOptional(isBackgroundTaskLogCursor),
|
|
290
|
+
},
|
|
291
|
+
'permission-response': { id: isNonEmptyString, result: isPermissionResultValue },
|
|
292
|
+
'ask-response': { id: isNonEmptyString, response: isActionResponse },
|
|
293
|
+
resume: { lastSeq: isFiniteNumber },
|
|
294
|
+
ack: { seq: isFiniteNumber },
|
|
295
|
+
'get-settings': { requestId: isNonEmptyString },
|
|
296
|
+
'update-settings': { requestId: isNonEmptyString, patch: isSettingsPatch },
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const authored: TVariantShape = { driverId: isOptional(isString) };
|
|
300
|
+
|
|
301
|
+
/** One entry per `TServerMessage` variant. Foreign-owned payloads are checked as records (see header). */
|
|
302
|
+
export const SERVER_MESSAGE_SHAPES: Readonly<Record<TServerMessage['type'], TVariantShape>> = {
|
|
303
|
+
text_delta: { delta: isString, ...authored },
|
|
304
|
+
user_message: { content: isString, ...authored },
|
|
305
|
+
tool_start: { state: isRecord, ...authored },
|
|
306
|
+
tool_end: { state: isRecord, ...authored },
|
|
307
|
+
thinking: { isThinking: isBoolean, ...authored },
|
|
308
|
+
complete: { result: isRecord, ...authored },
|
|
309
|
+
interrupted: { result: isRecord, ...authored },
|
|
310
|
+
error: {
|
|
311
|
+
message: isString,
|
|
312
|
+
code: isOptional(oneOf(['auth', 'rate_limit', 'model_unavailable', 'network', 'provider'])),
|
|
313
|
+
provider: isOptional(isString),
|
|
314
|
+
retryAfterSeconds: isOptional(isFiniteNumber),
|
|
315
|
+
model: isOptional(isString),
|
|
316
|
+
...authored,
|
|
317
|
+
},
|
|
318
|
+
command_result: {
|
|
319
|
+
name: isString,
|
|
320
|
+
message: isString,
|
|
321
|
+
success: isBoolean,
|
|
322
|
+
requestId: isOptional(isString),
|
|
323
|
+
},
|
|
324
|
+
messages: { messages: isRecordArray, display: isOptional(isRecordArray), ...authored },
|
|
325
|
+
history: { startIndex: isCount, total: isCount, entries: isWireHistoryEntries },
|
|
326
|
+
context: { state: isRecord },
|
|
327
|
+
history_changed: {
|
|
328
|
+
compact: isOptional(
|
|
329
|
+
(value) =>
|
|
330
|
+
isRecord(value) &&
|
|
331
|
+
oneOf(['auto', 'manual'])(value['trigger']) &&
|
|
332
|
+
isFiniteNumber(value['beforeUsedPercentage']) &&
|
|
333
|
+
isFiniteNumber(value['afterUsedPercentage']),
|
|
334
|
+
),
|
|
335
|
+
},
|
|
336
|
+
turn_source: { source: oneOf(Object.keys(TURN_SOURCES)) },
|
|
337
|
+
commands: { commands: isRecordArray, skills: isRecordArray },
|
|
338
|
+
session_status: { status: isRecord },
|
|
339
|
+
model_list: {
|
|
340
|
+
requestId: isNonEmptyString,
|
|
341
|
+
groups: isRecordArray,
|
|
342
|
+
currentProfile: isOptional(isString),
|
|
343
|
+
currentModel: isString,
|
|
344
|
+
},
|
|
345
|
+
agent_definitions: {
|
|
346
|
+
requestId: isNonEmptyString,
|
|
347
|
+
agents: isWireAgentDefinitions,
|
|
348
|
+
current: isNonEmptyString,
|
|
349
|
+
},
|
|
350
|
+
sessions: { requestId: isNonEmptyString, listing: isRecord },
|
|
351
|
+
sessions_error: {
|
|
352
|
+
requestId: isNonEmptyString,
|
|
353
|
+
code: oneOf(['not_available', 'list_failed']),
|
|
354
|
+
message: isString,
|
|
355
|
+
},
|
|
356
|
+
session_switched: { event: isRecord },
|
|
357
|
+
session_change_failed: {
|
|
358
|
+
code: oneOf(SESSION_CHANGE_REFUSAL_CODES),
|
|
359
|
+
message: isString,
|
|
360
|
+
requestId: isOptional(isString),
|
|
361
|
+
},
|
|
362
|
+
session_renamed_in_list: { requestId: isNonEmptyString, sessionId: isString, name: isString },
|
|
363
|
+
session_rename_failed: { requestId: isNonEmptyString, message: isString },
|
|
364
|
+
session_deleted: { requestId: isNonEmptyString, sessionId: isString },
|
|
365
|
+
session_delete_failed: {
|
|
366
|
+
requestId: isNonEmptyString,
|
|
367
|
+
code: oneOf(SESSION_DELETE_REFUSAL_CODES),
|
|
368
|
+
message: isString,
|
|
369
|
+
},
|
|
370
|
+
usage_report: { report: isRecord },
|
|
371
|
+
personal_usage_report: { requestId: isNonEmptyString, report: isRecord },
|
|
372
|
+
personal_usage_report_error: {
|
|
373
|
+
requestId: isNonEmptyString,
|
|
374
|
+
code: oneOf(['not_available', 'report_failed']),
|
|
375
|
+
message: isString,
|
|
376
|
+
},
|
|
377
|
+
stored_session_usage_report: {
|
|
378
|
+
requestId: isNonEmptyString,
|
|
379
|
+
sessionId: isNonEmptyString,
|
|
380
|
+
report: isRecord,
|
|
381
|
+
},
|
|
382
|
+
stored_session_usage_report_error: {
|
|
383
|
+
requestId: isNonEmptyString,
|
|
384
|
+
sessionId: isNonEmptyString,
|
|
385
|
+
code: oneOf(['not_available', 'report_failed']),
|
|
386
|
+
message: isString,
|
|
387
|
+
},
|
|
388
|
+
executing: { executing: isBoolean },
|
|
389
|
+
// An older host sends no count; a client then counts the one prompt it can see.
|
|
390
|
+
pending: { pending: (v) => v === null || isString(v), pendingCount: isOptional(isCount) },
|
|
391
|
+
execution_workspace_event: { snapshot: isRecord },
|
|
392
|
+
execution_detail: { requestId: isNonEmptyString, page: isRecord },
|
|
393
|
+
execution_detail_error: { requestId: isNonEmptyString, message: isString },
|
|
394
|
+
waiting_loop_stop: { requestId: isNonEmptyString, outcome: isWaitingLoopStopOutcome },
|
|
395
|
+
project_status: { requestId: isNonEmptyString, result: isProjectStatusRead },
|
|
396
|
+
project_diff: { requestId: isNonEmptyString, result: isProjectDiffRead },
|
|
397
|
+
project_memory: { requestId: isNonEmptyString, result: isProjectMemoryRead },
|
|
398
|
+
// `data` is whatever JSON the tool returned, so any value passes, null included; absent is fine.
|
|
399
|
+
app_tool_result: {
|
|
400
|
+
requestId: isNonEmptyString,
|
|
401
|
+
success: isBoolean,
|
|
402
|
+
data: () => true,
|
|
403
|
+
parts: isOptional(isRecordArray),
|
|
404
|
+
error: isOptional(isString),
|
|
405
|
+
},
|
|
406
|
+
app_view: { requestId: isNonEmptyString, view: isRecord },
|
|
407
|
+
background_task_event: { event: isRecord },
|
|
408
|
+
background_job_group_event: { event: isRecord },
|
|
409
|
+
plan_event: { event: isRecord },
|
|
410
|
+
context_file_refreshed: { event: isRecord },
|
|
411
|
+
branch_event: { event: isRecord },
|
|
412
|
+
background_tasks: { tasks: isRecordArray },
|
|
413
|
+
background_task: { taskId: isString, task: isNullableRecord },
|
|
414
|
+
background_job_groups: { groups: isRecordArray },
|
|
415
|
+
background_job_group: { groupId: isString, group: isNullableRecord },
|
|
416
|
+
background_task_log: { taskId: isString, page: isRecord },
|
|
417
|
+
permission_request: { event: isRecord },
|
|
418
|
+
ask_request: { event: isRecord },
|
|
419
|
+
prompt_resolved: { event: isRecord },
|
|
420
|
+
ui_intent: { event: isRecord },
|
|
421
|
+
session_renamed: { event: isRecord },
|
|
422
|
+
history_cleared: {},
|
|
423
|
+
background_task_control_result: {
|
|
424
|
+
action: oneOf(BACKGROUND_CONTROL_ACTIONS),
|
|
425
|
+
taskId: isString,
|
|
426
|
+
success: isBoolean,
|
|
427
|
+
message: isOptional(isString),
|
|
428
|
+
},
|
|
429
|
+
protocol_error: { message: isString, requestId: isOptional(isString) },
|
|
430
|
+
resume_gap: {},
|
|
431
|
+
settings: { requestId: isNonEmptyString, settings: isRecord },
|
|
432
|
+
settings_error: {
|
|
433
|
+
requestId: isNonEmptyString,
|
|
434
|
+
code: oneOf(['not_available', 'invalid', 'refused', 'update_failed']),
|
|
435
|
+
message: isString,
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
function decodeVariant<TMessage extends { type: string }>(
|
|
440
|
+
value: unknown,
|
|
441
|
+
shapes: Readonly<Record<string, TVariantShape>>,
|
|
442
|
+
reasonFor: (field: string) => string,
|
|
443
|
+
): TMessageDecodeResult<TMessage> {
|
|
444
|
+
if (!isRecord(value)) return { ok: false, reason: 'message must be a JSON object' };
|
|
445
|
+
const type = value['type'];
|
|
446
|
+
if (!isString(type)) return { ok: false, reason: 'message type must be a string' };
|
|
447
|
+
const shape = Object.prototype.hasOwnProperty.call(shapes, type) ? shapes[type] : undefined;
|
|
448
|
+
if (shape === undefined) return { ok: false, reason: `Unknown message type: ${type}` };
|
|
449
|
+
for (const [field, check] of Object.entries(shape)) {
|
|
450
|
+
if (!check(value[field])) return { ok: false, reason: reasonFor(field) };
|
|
451
|
+
}
|
|
452
|
+
return { ok: true, message: value as unknown as TMessage };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Total decode of a parsed client frame. */
|
|
456
|
+
export function decodeClientMessage(value: unknown): TMessageDecodeResult<TClientMessage> {
|
|
457
|
+
return decodeVariant<TClientMessage>(value, CLIENT_MESSAGE_SHAPES, (field) =>
|
|
458
|
+
field === 'prompt' ? 'prompt must be a non-empty string' : `${field} has an invalid shape`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Variant-level decode of a parsed server frame (foreign-owned payloads as records, see header). */
|
|
463
|
+
export function decodeServerMessage(value: unknown): TMessageDecodeResult<TServerMessage> {
|
|
464
|
+
return decodeVariant<TServerMessage>(
|
|
465
|
+
value,
|
|
466
|
+
SERVER_MESSAGE_SHAPES,
|
|
467
|
+
(field) => `${field} has an invalid shape`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** `raw string → JSON → owner decoder`, the one path a carrier implements. */
|
|
472
|
+
export function decodeFrame<TMessage>(
|
|
473
|
+
data: string,
|
|
474
|
+
decode: (value: unknown) => TMessageDecodeResult<TMessage>,
|
|
475
|
+
): TMessageDecodeResult<TMessage> {
|
|
476
|
+
if (data.length > MAX_INBOUND_FRAME_BYTES) return { ok: false, reason: 'Frame too large' };
|
|
477
|
+
let parsed: unknown;
|
|
478
|
+
try {
|
|
479
|
+
parsed = JSON.parse(data);
|
|
480
|
+
// allow-fallback: the parse failure is returned as a typed refusal the carrier answers on the wire
|
|
481
|
+
} catch {
|
|
482
|
+
return { ok: false, reason: 'Invalid JSON' };
|
|
483
|
+
}
|
|
484
|
+
return decode(parsed);
|
|
485
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { decodeClientMessage, decodeFrame } from './message-decoders.js';
|
|
2
|
+
import { malformedUsageRequest } from './usage-messages.js';
|
|
3
|
+
|
|
4
|
+
import type { TOutboundDeliver } from './outbound-delivery.js';
|
|
5
|
+
import type { TClientMessage } from './wire-messages.js';
|
|
6
|
+
|
|
7
|
+
/** Decode a client frame and preserve validated usage-request correlation on malformed payloads. */
|
|
8
|
+
export function parseClientMessage(data: string, deliver: TOutboundDeliver): TClientMessage | null {
|
|
9
|
+
const decoded = decodeFrame(data, decodeClientMessage);
|
|
10
|
+
if (decoded.ok) return decoded.message;
|
|
11
|
+
const malformedUsage = malformedUsageRequest(data);
|
|
12
|
+
if (malformedUsage?.type === 'personal') {
|
|
13
|
+
deliver({
|
|
14
|
+
type: 'personal_usage_report_error',
|
|
15
|
+
requestId: malformedUsage.requestId,
|
|
16
|
+
code: 'report_failed',
|
|
17
|
+
message: 'Invalid personal usage report request.',
|
|
18
|
+
});
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
if (malformedUsage?.type === 'stored-session') {
|
|
22
|
+
deliver({
|
|
23
|
+
type: 'stored_session_usage_report_error',
|
|
24
|
+
requestId: malformedUsage.requestId,
|
|
25
|
+
sessionId: malformedUsage.sessionId,
|
|
26
|
+
code: 'report_failed',
|
|
27
|
+
message: 'Invalid stored-session usage report request.',
|
|
28
|
+
});
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
deliver({ type: 'protocol_error', message: decoded.reason });
|
|
32
|
+
return null;
|
|
33
|
+
}
|