@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,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carrier-neutral session message adapter.
|
|
3
|
+
*
|
|
4
|
+
* Framework-agnostic: works with any transport implementation via
|
|
5
|
+
* send/onMessage callbacks. No dependency on a carrier library.
|
|
6
|
+
*
|
|
7
|
+
* Protocol: JSON messages with { type, ...payload } structure.
|
|
8
|
+
* Server pushes IProtocolSession events to client in real-time.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
handleAppToolMessage,
|
|
13
|
+
handleAppViewMessage,
|
|
14
|
+
isAppToolMessage,
|
|
15
|
+
isAppViewMessage,
|
|
16
|
+
type IAppMessageOptions,
|
|
17
|
+
} from './app-tool-messages.js';
|
|
18
|
+
import {
|
|
19
|
+
handleBackgroundControlMessage,
|
|
20
|
+
handleBackgroundQueryMessage,
|
|
21
|
+
} from './background-messages.js';
|
|
22
|
+
import { handleLoopControlMessage, isLoopControlMessage } from './loop-control-messages.js';
|
|
23
|
+
import { parseClientMessage } from './message-parser.js';
|
|
24
|
+
import { isObserverMessageType } from './observer-messages.js';
|
|
25
|
+
import { handleProjectReadMessage, isProjectReadMessage } from './project-read-messages.js';
|
|
26
|
+
import {
|
|
27
|
+
handleSessionDeleteMessage,
|
|
28
|
+
handleSessionDirectoryMessage,
|
|
29
|
+
handleSessionRenameMessage,
|
|
30
|
+
isSessionDeleteMessage,
|
|
31
|
+
isSessionDirectoryMessage,
|
|
32
|
+
isSessionRenameMessage,
|
|
33
|
+
} from './session-directory-messages.js';
|
|
34
|
+
import { subscribeSessionEvents } from './session-events.js';
|
|
35
|
+
import {
|
|
36
|
+
handleSessionQueryMessage,
|
|
37
|
+
isSessionQueryMessage,
|
|
38
|
+
pendingFrame,
|
|
39
|
+
} from './session-query-messages.js';
|
|
40
|
+
import { handleSettingsMessage, isSettingsMessage } from './settings-messages.js';
|
|
41
|
+
import { handleUsageQueryMessage } from './usage-messages.js';
|
|
42
|
+
|
|
43
|
+
import type { TOutboundDeliver } from './outbound-delivery.js';
|
|
44
|
+
import type { IProtocolSession, TProjectReadCapableSession } from './protocol-session.js';
|
|
45
|
+
import type { ISettingsReporter } from './settings-messages.js';
|
|
46
|
+
import type { IUsageQueryReporters } from './usage-messages.js';
|
|
47
|
+
import type { TClientMessage } from './wire-messages.js';
|
|
48
|
+
import type { TCommandSurfaceLocality } from '@robota-sdk/agent-interface-command';
|
|
49
|
+
import type { TUsageSurface } from '@robota-sdk/agent-interface-analytics';
|
|
50
|
+
import type { ISessionDirectory, TDriverId } from '@robota-sdk/agent-interface-session';
|
|
51
|
+
|
|
52
|
+
// Outbound session→TServerMessage fan-out (incl. CMD-004 requester-routed `ui_intent`) lives in
|
|
53
|
+
// `session-events.ts`; re-exported here for the bridge and existing importers.
|
|
54
|
+
export { subscribeSessionEvents } from './session-events.js';
|
|
55
|
+
export type { ISubscribeSessionEventsOptions } from './session-events.js';
|
|
56
|
+
export { parseClientMessage } from './message-parser.js';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What a connected surface may do. `drive` sends prompts, answers questions and controls the session.
|
|
60
|
+
* `observe` is read-only: it follows this session's conversation and state, but never submits, answers,
|
|
61
|
+
* controls, or reads another session's records, and it never counts as a surface that can answer.
|
|
62
|
+
*/
|
|
63
|
+
export type TSessionSurfaceRole = 'drive' | 'observe';
|
|
64
|
+
|
|
65
|
+
export interface ISessionMessageHandlerOptions {
|
|
66
|
+
/** IProtocolSession to expose — `Partial<ISessionProjectRead>` is probed, not assumed (#3282 §4c). */
|
|
67
|
+
session: TProjectReadCapableSession;
|
|
68
|
+
/**
|
|
69
|
+
* ARCH-030: the CARRIER's connection-scoped outbound delivery boundary — not a raw `send`, and not a
|
|
70
|
+
* `send` plus an error callback for this handler to assemble into one. The carrier owns both the sink
|
|
71
|
+
* and the "what does a failed send mean for this connection" policy, so it builds the boundary and
|
|
72
|
+
* passes it down; the raw sink never crosses this parameter, which is what stops a future reply family
|
|
73
|
+
* from reaching the wire unguarded.
|
|
74
|
+
*/
|
|
75
|
+
deliver: TOutboundDeliver;
|
|
76
|
+
/**
|
|
77
|
+
* REMOTE-014 E5: the SERVER-ASSIGNED driver id for THIS remote surface (the E3 `deviceId`). Injected into
|
|
78
|
+
* every inbound `submit`/`command`/prompt-response so a co-drive turn/answer is attributed to this driver —
|
|
79
|
+
* a client-supplied driver id is NEVER trusted. Absent → unattributed (the session defaults to the owner).
|
|
80
|
+
*/
|
|
81
|
+
driverId?: TDriverId;
|
|
82
|
+
/** Trusted carrier-owned product surface, kept separate from driver identity. */
|
|
83
|
+
surface?: TUsageSurface;
|
|
84
|
+
/** Carrier-decided role of this connection; defaults to `drive`. The client never chooses it here. */
|
|
85
|
+
role?: TSessionSurfaceRole;
|
|
86
|
+
/** Host-owned cross-session read model. The protocol only correlates and carries its result. */
|
|
87
|
+
personalUsageReporter?: NonNullable<IUsageQueryReporters['personalUsageReporter']>;
|
|
88
|
+
/** Host-owned current-session trace/cost producer for the pre-existing message family. */
|
|
89
|
+
usageReporter?: NonNullable<IUsageQueryReporters['usageReporter']>;
|
|
90
|
+
/** Host-owned stored-session producer used by cross-session drill-down. */
|
|
91
|
+
storedSessionUsageReporter?: NonNullable<IUsageQueryReporters['storedSessionUsageReporter']>;
|
|
92
|
+
/** Host-owned session directory (#3189): list, start and switch the host's sessions. */
|
|
93
|
+
sessionDirectory?: ISessionDirectory;
|
|
94
|
+
/** #3282 §4a: host-owned read/write for the GUI Settings screen. */
|
|
95
|
+
settingsReporter?: ISettingsReporter;
|
|
96
|
+
/**
|
|
97
|
+
* #3282 §4 part b-2: carrier-decided, like `role` — whether THIS carrier's connections are
|
|
98
|
+
* provably on this machine. The loopback WS carrier is `'local'`; the device-mesh/WebRTC carrier
|
|
99
|
+
* sets `'remote'`. Absent → `'local'`, forwarded to `executeCommand` and the settings reporter so a
|
|
100
|
+
* command that runs code from outside the session (installing a plugin) can refuse a remote device.
|
|
101
|
+
*/
|
|
102
|
+
commandSurfaceLocality?: TCommandSurfaceLocality;
|
|
103
|
+
/**
|
|
104
|
+
* The most bytes one frame answering an app tool call or view read may take, for a carrier whose
|
|
105
|
+
* channel carries less than the inbound limit (a WebRTC data channel). An answer over it is
|
|
106
|
+
* replaced by a `protocol_error` before it reaches any buffer. Defaults to the inbound limit less
|
|
107
|
+
* a margin for a carrier that stamps frames afterwards.
|
|
108
|
+
*/
|
|
109
|
+
maxFrameBytes?: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Create a carrier-neutral message handler for an IProtocolSession.
|
|
114
|
+
*
|
|
115
|
+
* Returns:
|
|
116
|
+
* - `onMessage(data)`: call this when the WebSocket receives a message
|
|
117
|
+
* - `cleanup()`: call this when the WebSocket disconnects
|
|
118
|
+
*
|
|
119
|
+
* Usage:
|
|
120
|
+
* ```typescript
|
|
121
|
+
* const delivery = createOutboundDelivery(
|
|
122
|
+
* (msg) => ws.send(JSON.stringify(msg)),
|
|
123
|
+
* (error) => ws.close(1011, error.message),
|
|
124
|
+
* );
|
|
125
|
+
* const { onMessage, cleanup } = createSessionMessageHandler({ session: interactiveSession, deliver: delivery });
|
|
126
|
+
*
|
|
127
|
+
* ws.on('message', (data) => onMessage(String(data)));
|
|
128
|
+
* ws.on('close', cleanup);
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
export function createSessionMessageHandler(options: ISessionMessageHandlerOptions): {
|
|
132
|
+
onMessage: (data: string) => void;
|
|
133
|
+
cleanup: () => void;
|
|
134
|
+
} {
|
|
135
|
+
const role = options.role ?? 'drive';
|
|
136
|
+
const unsubscribe = subscribeSessionEvents(options.session, options.deliver, {
|
|
137
|
+
getSurfaceDriverId: () => options.driverId,
|
|
138
|
+
receivePrompts: role === 'drive',
|
|
139
|
+
});
|
|
140
|
+
// One per connection: when the connection goes, work it started for a hosted app view (a tool call
|
|
141
|
+
// waiting on its permission prompt) is cancelled, so the prompt is withdrawn instead of left open.
|
|
142
|
+
const connection = new AbortController();
|
|
143
|
+
const cleanup = (): void => {
|
|
144
|
+
connection.abort();
|
|
145
|
+
unsubscribe();
|
|
146
|
+
};
|
|
147
|
+
const onMessage = createMessageHandler(
|
|
148
|
+
options.session,
|
|
149
|
+
options.deliver,
|
|
150
|
+
options.driverId,
|
|
151
|
+
{
|
|
152
|
+
personalUsageReporter: options.personalUsageReporter,
|
|
153
|
+
usageReporter: options.usageReporter,
|
|
154
|
+
storedSessionUsageReporter: options.storedSessionUsageReporter,
|
|
155
|
+
},
|
|
156
|
+
options.surface,
|
|
157
|
+
role,
|
|
158
|
+
options.sessionDirectory,
|
|
159
|
+
options.settingsReporter,
|
|
160
|
+
options.commandSurfaceLocality,
|
|
161
|
+
{
|
|
162
|
+
signal: connection.signal,
|
|
163
|
+
...(options.maxFrameBytes !== undefined ? { maxFrameBytes: options.maxFrameBytes } : {}),
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
return { onMessage, cleanup };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function createMessageHandler(
|
|
171
|
+
session: TProjectReadCapableSession,
|
|
172
|
+
deliver: TOutboundDeliver,
|
|
173
|
+
driverId?: TDriverId,
|
|
174
|
+
reporters: IUsageQueryReporters = EMPTY_USAGE_REPORTERS,
|
|
175
|
+
surface?: TUsageSurface,
|
|
176
|
+
role: TSessionSurfaceRole = 'drive',
|
|
177
|
+
sessionDirectory?: ISessionDirectory,
|
|
178
|
+
settingsReporter?: ISettingsReporter,
|
|
179
|
+
commandSurfaceLocality?: TCommandSurfaceLocality,
|
|
180
|
+
appMessages?: IAppMessageOptions,
|
|
181
|
+
): (data: string) => void {
|
|
182
|
+
return (data: string): void => {
|
|
183
|
+
const msg = parseClientMessage(data, deliver);
|
|
184
|
+
if (!msg) return;
|
|
185
|
+
if (role === 'observe' && !isObserverMessageType(msg.type)) {
|
|
186
|
+
deliver({ type: 'protocol_error', message: `Not permitted for an observer: ${msg.type}` });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
handleClientMessage(
|
|
190
|
+
session,
|
|
191
|
+
deliver,
|
|
192
|
+
msg,
|
|
193
|
+
driverId,
|
|
194
|
+
reporters,
|
|
195
|
+
surface,
|
|
196
|
+
sessionDirectory,
|
|
197
|
+
settingsReporter,
|
|
198
|
+
commandSurfaceLocality,
|
|
199
|
+
appMessages,
|
|
200
|
+
);
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const EMPTY_USAGE_REPORTERS: IUsageQueryReporters = {
|
|
205
|
+
personalUsageReporter: undefined,
|
|
206
|
+
usageReporter: undefined,
|
|
207
|
+
storedSessionUsageReporter: undefined,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Route a parsed client message to the session (control/query/background/prompt-response). Exported for E4:
|
|
212
|
+
* the {@link SessionResumeBridge} intercepts `resume`/`ack` itself and delegates everything else here.
|
|
213
|
+
*/
|
|
214
|
+
export function handleClientMessage(
|
|
215
|
+
session: TProjectReadCapableSession,
|
|
216
|
+
deliver: TOutboundDeliver,
|
|
217
|
+
msg: TClientMessage,
|
|
218
|
+
driverId?: TDriverId,
|
|
219
|
+
reporters: IUsageQueryReporters = EMPTY_USAGE_REPORTERS,
|
|
220
|
+
surface?: TUsageSurface,
|
|
221
|
+
sessionDirectory?: ISessionDirectory,
|
|
222
|
+
settingsReporter?: ISettingsReporter,
|
|
223
|
+
commandSurfaceLocality?: TCommandSurfaceLocality,
|
|
224
|
+
appMessages?: IAppMessageOptions,
|
|
225
|
+
): void {
|
|
226
|
+
if (handleUsageQueryMessage(session, deliver, msg, reporters)) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (isSessionDirectoryMessage(msg)) {
|
|
230
|
+
handleSessionDirectoryMessage(deliver, msg, sessionDirectory);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (isSettingsMessage(msg)) {
|
|
234
|
+
handleSettingsMessage(session, deliver, msg, settingsReporter, commandSurfaceLocality);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (isSessionRenameMessage(msg)) {
|
|
238
|
+
handleSessionRenameMessage(deliver, msg, sessionDirectory);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (isSessionDeleteMessage(msg)) {
|
|
242
|
+
handleSessionDeleteMessage(deliver, msg, sessionDirectory);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (isSessionControlMessage(msg)) {
|
|
246
|
+
handleSessionControlMessage(session, deliver, msg, driverId, surface, commandSurfaceLocality);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (isLoopControlMessage(msg)) {
|
|
250
|
+
handleLoopControlMessage(session, deliver, msg);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (isSessionQueryMessage(msg)) {
|
|
254
|
+
handleSessionQueryMessage(session, deliver, msg);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (isProjectReadMessage(msg)) {
|
|
258
|
+
handleProjectReadMessage(session, deliver, msg);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (isAppToolMessage(msg)) {
|
|
262
|
+
handleAppToolMessage(session, deliver, msg, driverId, appMessages);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (isAppViewMessage(msg)) {
|
|
266
|
+
handleAppViewMessage(session, deliver, msg, appMessages);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (isBackgroundQueryMessage(msg)) {
|
|
270
|
+
handleBackgroundQueryMessage(session, deliver, msg);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (isBackgroundControlMessage(msg)) {
|
|
274
|
+
handleBackgroundControlMessage(session, deliver, msg);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (isPromptResponseMessage(msg)) {
|
|
278
|
+
handlePromptResponseMessage(session, msg, driverId);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const unknownType = (msg as { type: string }).type;
|
|
282
|
+
deliver({ type: 'protocol_error', message: `Unknown message type: ${unknownType}` });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function isSessionControlMessage(
|
|
286
|
+
msg: TClientMessage,
|
|
287
|
+
): msg is Extract<TClientMessage, { type: 'submit' | 'command' | 'abort' | 'cancel-queue' }> {
|
|
288
|
+
return (
|
|
289
|
+
msg.type === 'submit' ||
|
|
290
|
+
msg.type === 'command' ||
|
|
291
|
+
msg.type === 'abort' ||
|
|
292
|
+
msg.type === 'cancel-queue'
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function isBackgroundQueryMessage(
|
|
297
|
+
msg: TClientMessage,
|
|
298
|
+
): msg is Extract<
|
|
299
|
+
TClientMessage,
|
|
300
|
+
| { type: 'get-background-tasks' | 'get-background-task' | 'read-background-task-log' }
|
|
301
|
+
| { type: 'get-background-job-groups' | 'get-background-job-group' | 'wait-background-job-group' }
|
|
302
|
+
> {
|
|
303
|
+
return (
|
|
304
|
+
msg.type === 'get-background-tasks' ||
|
|
305
|
+
msg.type === 'get-background-task' ||
|
|
306
|
+
msg.type === 'read-background-task-log' ||
|
|
307
|
+
msg.type === 'get-background-job-groups' ||
|
|
308
|
+
msg.type === 'get-background-job-group' ||
|
|
309
|
+
msg.type === 'wait-background-job-group'
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function isBackgroundControlMessage(
|
|
314
|
+
msg: TClientMessage,
|
|
315
|
+
): msg is Extract<
|
|
316
|
+
TClientMessage,
|
|
317
|
+
{ type: 'cancel-background-task' | 'close-background-task' | 'send-background-task' }
|
|
318
|
+
> {
|
|
319
|
+
return (
|
|
320
|
+
msg.type === 'cancel-background-task' ||
|
|
321
|
+
msg.type === 'close-background-task' ||
|
|
322
|
+
msg.type === 'send-background-task'
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function isPromptResponseMessage(
|
|
327
|
+
msg: TClientMessage,
|
|
328
|
+
): msg is Extract<TClientMessage, { type: 'permission-response' | 'ask-response' }> {
|
|
329
|
+
return msg.type === 'permission-response' || msg.type === 'ask-response';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* REMOTE-007: a driving client answered a pending prompt by id. `resolvePermission`/`resolveAsk` are
|
|
334
|
+
* idempotent — a stale id (already answered by another surface, or drained) is a safe no-op, so no
|
|
335
|
+
* acknowledgement is needed; the resulting `prompt_resolved` server event is the shared signal.
|
|
336
|
+
*/
|
|
337
|
+
function handlePromptResponseMessage(
|
|
338
|
+
session: IProtocolSession,
|
|
339
|
+
msg: Extract<TClientMessage, { type: 'permission-response' | 'ask-response' }>,
|
|
340
|
+
driverId?: TDriverId,
|
|
341
|
+
): void {
|
|
342
|
+
// REMOTE-014 E5: record the SERVER-ASSIGNED answering driver (not client-sent) on `prompt_resolved`.
|
|
343
|
+
if (msg.type === 'permission-response') {
|
|
344
|
+
session.resolvePermission(msg.id, msg.result, driverId);
|
|
345
|
+
} else {
|
|
346
|
+
session.resolveAsk(msg.id, msg.response, driverId);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function handleSessionControlMessage(
|
|
351
|
+
session: IProtocolSession,
|
|
352
|
+
deliver: TOutboundDeliver,
|
|
353
|
+
msg: Extract<TClientMessage, { type: 'submit' | 'command' | 'abort' | 'cancel-queue' }>,
|
|
354
|
+
driverId?: TDriverId,
|
|
355
|
+
surface?: TUsageSurface,
|
|
356
|
+
commandSurfaceLocality?: TCommandSurfaceLocality,
|
|
357
|
+
): void {
|
|
358
|
+
if (msg.type === 'submit') {
|
|
359
|
+
// TRANS-008 (issue #2045). A TYPE check, not a falsy one: `{}`, `[]`, `42` and `true` are truthy
|
|
360
|
+
// and passed the previous `!msg.prompt` guard. It matters more here than at the other payload
|
|
361
|
+
// fields because the value does not stop at this session — it is re-emitted as `user_message` to
|
|
362
|
+
// EVERY attached client inside a frame whose `content` is declared `string`.
|
|
363
|
+
if (typeof msg.prompt !== 'string' || msg.prompt.length === 0) {
|
|
364
|
+
deliver({ type: 'protocol_error', message: 'prompt must be a non-empty string' });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
// REMOTE-014 E5: attribute this remote turn to the SERVER-ASSIGNED driver id (never a client-sent one).
|
|
368
|
+
const submitOptions = {
|
|
369
|
+
...(driverId ? { driverId } : {}),
|
|
370
|
+
...(surface ? { surface } : {}),
|
|
371
|
+
};
|
|
372
|
+
session
|
|
373
|
+
.submit(
|
|
374
|
+
msg.prompt,
|
|
375
|
+
undefined,
|
|
376
|
+
undefined,
|
|
377
|
+
Object.keys(submitOptions).length > 0 ? submitOptions : undefined,
|
|
378
|
+
)
|
|
379
|
+
.then(
|
|
380
|
+
// #3189: a prompt submitted during a turn resolves once it is queued. A `get-pending` sent
|
|
381
|
+
// beside the `submit` would be answered before that, so the queue is reported from here.
|
|
382
|
+
() => deliver(pendingFrame(session)),
|
|
383
|
+
(error: Error) => deliver({ type: 'protocol_error', message: error.message }),
|
|
384
|
+
);
|
|
385
|
+
} else if (msg.type === 'command') {
|
|
386
|
+
const request = msg.requestId !== undefined ? { requestId: msg.requestId } : {};
|
|
387
|
+
if (!msg.name) {
|
|
388
|
+
deliver({ type: 'protocol_error', message: 'name is required', ...request });
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
// REMOTE-003: a transport-origin command is tagged `'remote'` (optional policy, allow-by-default;
|
|
392
|
+
// REMOTE-006). CMD-004: the SERVER-ASSIGNED driver id (E5) is the command origin — intents route back here.
|
|
393
|
+
session.executeCommand(msg.name, msg.args ?? '', 'remote', driverId, commandSurfaceLocality).then(
|
|
394
|
+
(result) => {
|
|
395
|
+
deliver({
|
|
396
|
+
type: 'command_result',
|
|
397
|
+
name: msg.name,
|
|
398
|
+
message: result?.message ?? `Unknown command: ${msg.name}`,
|
|
399
|
+
success: result?.success ?? false,
|
|
400
|
+
data: result?.data,
|
|
401
|
+
...request,
|
|
402
|
+
});
|
|
403
|
+
},
|
|
404
|
+
(error: Error) => {
|
|
405
|
+
deliver({ type: 'protocol_error', message: error.message, ...request });
|
|
406
|
+
},
|
|
407
|
+
);
|
|
408
|
+
} else if (msg.type === 'abort') {
|
|
409
|
+
session.abort();
|
|
410
|
+
} else {
|
|
411
|
+
session.cancelQueue();
|
|
412
|
+
}
|
|
413
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { listOpenPrompts } from './open-prompts.js';
|
|
2
|
+
|
|
3
|
+
import type { TOutboundDeliver } from './outbound-delivery.js';
|
|
4
|
+
import type { IProtocolSession } from './protocol-session.js';
|
|
5
|
+
import type { IWireHistoryEntry, TClientMessage, TServerMessage } from './wire-messages.js';
|
|
6
|
+
|
|
7
|
+
type TSessionQueryMessage = Extract<
|
|
8
|
+
TClientMessage,
|
|
9
|
+
{
|
|
10
|
+
type:
|
|
11
|
+
| 'get-messages'
|
|
12
|
+
| 'get-history'
|
|
13
|
+
| 'get-prompts'
|
|
14
|
+
| 'get-context'
|
|
15
|
+
| 'get-commands'
|
|
16
|
+
| 'get-status'
|
|
17
|
+
| 'list-models'
|
|
18
|
+
| 'get-agent-definitions'
|
|
19
|
+
| 'get-executing'
|
|
20
|
+
| 'get-pending'
|
|
21
|
+
| 'get-execution-workspace'
|
|
22
|
+
| 'read-execution-detail';
|
|
23
|
+
}
|
|
24
|
+
>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* #3189: the most serialized history one `history` frame carries. Well under the tightest outbound
|
|
28
|
+
* budget (an attached terminal's, 1 MiB), so a client reading page after page is never mistaken for
|
|
29
|
+
* one that stopped reading.
|
|
30
|
+
*/
|
|
31
|
+
export const HISTORY_PAGE_MAX_BYTES = 256 * 1024;
|
|
32
|
+
|
|
33
|
+
const utf8 = new TextEncoder();
|
|
34
|
+
|
|
35
|
+
export function isSessionQueryMessage(msg: TClientMessage): msg is TSessionQueryMessage {
|
|
36
|
+
return (
|
|
37
|
+
msg.type === 'get-messages' ||
|
|
38
|
+
msg.type === 'get-history' ||
|
|
39
|
+
msg.type === 'get-prompts' ||
|
|
40
|
+
msg.type === 'get-context' ||
|
|
41
|
+
msg.type === 'get-commands' ||
|
|
42
|
+
msg.type === 'get-status' ||
|
|
43
|
+
msg.type === 'list-models' ||
|
|
44
|
+
msg.type === 'get-agent-definitions' ||
|
|
45
|
+
msg.type === 'get-executing' ||
|
|
46
|
+
msg.type === 'get-pending' ||
|
|
47
|
+
msg.type === 'get-execution-workspace' ||
|
|
48
|
+
msg.type === 'read-execution-detail'
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function handleSessionQueryMessage(
|
|
53
|
+
session: IProtocolSession,
|
|
54
|
+
deliver: TOutboundDeliver,
|
|
55
|
+
msg: TSessionQueryMessage,
|
|
56
|
+
): void {
|
|
57
|
+
if (msg.type === 'get-messages') {
|
|
58
|
+
// #3288 §2: `display` rides the same frame as `messages` — a reload/reconnect/resume replay
|
|
59
|
+
// needs both in one round trip, and there is no reason a client would ever want one without
|
|
60
|
+
// the other (the raw messages already have to be fetched to build the projection).
|
|
61
|
+
deliver({ type: 'messages', messages: session.getMessages(), display: session.getMessagesDisplay() });
|
|
62
|
+
} else if (msg.type === 'get-history') {
|
|
63
|
+
deliver(historyPage(session, msg.fromIndex ?? 0));
|
|
64
|
+
} else if (msg.type === 'get-prompts') {
|
|
65
|
+
for (const frame of listOpenPrompts(session)) deliver(frame);
|
|
66
|
+
} else if (msg.type === 'get-context') {
|
|
67
|
+
deliver({ type: 'context', state: session.getContextState() });
|
|
68
|
+
} else if (msg.type === 'get-commands') {
|
|
69
|
+
deliver({ type: 'commands', commands: session.listCommands(), skills: session.listSkills() });
|
|
70
|
+
} else if (msg.type === 'get-status') {
|
|
71
|
+
deliver({ type: 'session_status', status: session.getStatusSnapshot() });
|
|
72
|
+
} else if (msg.type === 'list-models') {
|
|
73
|
+
const snapshot = session.listModels();
|
|
74
|
+
deliver({ type: 'model_list', requestId: msg.requestId, ...snapshot });
|
|
75
|
+
} else if (msg.type === 'get-agent-definitions') {
|
|
76
|
+
deliver({
|
|
77
|
+
type: 'agent_definitions',
|
|
78
|
+
requestId: msg.requestId,
|
|
79
|
+
agents: session.listAgentDefinitions(),
|
|
80
|
+
current: session.getDefaultAgentType(),
|
|
81
|
+
});
|
|
82
|
+
} else if (msg.type === 'get-executing') {
|
|
83
|
+
deliver({ type: 'executing', executing: session.isExecuting() });
|
|
84
|
+
} else if (msg.type === 'get-execution-workspace') {
|
|
85
|
+
deliver({
|
|
86
|
+
type: 'execution_workspace_event',
|
|
87
|
+
snapshot: session.getExecutionWorkspaceSnapshot(),
|
|
88
|
+
});
|
|
89
|
+
} else if (msg.type === 'read-execution-detail') {
|
|
90
|
+
readExecutionDetail(session, deliver, msg);
|
|
91
|
+
} else {
|
|
92
|
+
deliver(pendingFrame(session));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** One page of an execution-workspace entry's detail, answered under the request's id. */
|
|
97
|
+
function readExecutionDetail(
|
|
98
|
+
session: IProtocolSession,
|
|
99
|
+
deliver: TOutboundDeliver,
|
|
100
|
+
msg: Extract<TClientMessage, { type: 'read-execution-detail' }>,
|
|
101
|
+
): void {
|
|
102
|
+
const { requestId } = msg;
|
|
103
|
+
session.readExecutionWorkspaceDetail(msg.entryId, msg.cursor).then(
|
|
104
|
+
(page) => deliver({ type: 'execution_detail', requestId, page }),
|
|
105
|
+
(error: unknown) =>
|
|
106
|
+
deliver({
|
|
107
|
+
type: 'execution_detail_error',
|
|
108
|
+
requestId,
|
|
109
|
+
message: error instanceof Error ? error.message : String(error),
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The next queued prompt and how many wait, as the session reports them now. */
|
|
115
|
+
export function pendingFrame(
|
|
116
|
+
session: IProtocolSession,
|
|
117
|
+
): Extract<TServerMessage, { type: 'pending' }> {
|
|
118
|
+
return {
|
|
119
|
+
type: 'pending',
|
|
120
|
+
pending: session.getPendingPrompt(),
|
|
121
|
+
pendingCount: session.getPendingCount(),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The history from `fromIndex`, as many entries as fit {@link HISTORY_PAGE_MAX_BYTES}. At least one
|
|
127
|
+
* entry is sent while any remain, so a client always moves forward, even past one larger entry.
|
|
128
|
+
*/
|
|
129
|
+
function historyPage(
|
|
130
|
+
session: IProtocolSession,
|
|
131
|
+
fromIndex: number,
|
|
132
|
+
): Extract<TServerMessage, { type: 'history' }> {
|
|
133
|
+
const history = session.getFullHistory();
|
|
134
|
+
const total = history.length;
|
|
135
|
+
const startIndex = Math.min(fromIndex, total);
|
|
136
|
+
const entries: IWireHistoryEntry[] = [];
|
|
137
|
+
let bytes = 0;
|
|
138
|
+
for (let index = startIndex; index < total; index += 1) {
|
|
139
|
+
const entry = toWireHistoryEntry(history[index]!);
|
|
140
|
+
const size = utf8.encode(JSON.stringify(entry)).byteLength;
|
|
141
|
+
if (entries.length > 0 && bytes + size > HISTORY_PAGE_MAX_BYTES) break;
|
|
142
|
+
entries.push(entry);
|
|
143
|
+
bytes += size;
|
|
144
|
+
}
|
|
145
|
+
return { type: 'history', startIndex, total, entries };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function toWireHistoryEntry(
|
|
149
|
+
entry: ReturnType<IProtocolSession['getFullHistory']>[number],
|
|
150
|
+
): IWireHistoryEntry {
|
|
151
|
+
return { ...entry, timestamp: toIsoTimestamp(entry.timestamp) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The declared type is `Date`, but a resumed session's entries were read back from JSON unrevived, so
|
|
156
|
+
* their `timestamp` is already the ISO string the store wrote. Either way the wire carries that string.
|
|
157
|
+
*/
|
|
158
|
+
function toIsoTimestamp(timestamp: Date | string): string {
|
|
159
|
+
return typeof timestamp === 'string' ? timestamp : timestamp.toISOString();
|
|
160
|
+
}
|