@robota-sdk/agent-transport 3.0.0-beta.82 → 3.0.0-beta.84
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 +795 -0
- package/README.md +80 -118
- package/dist/browser/client.d.ts +2 -2
- package/dist/browser/client.js +1 -1
- package/dist/browser/index.d.ts +87 -13
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/index.js +1 -1
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/observer-messages-CEeisRaF.js +2 -0
- package/dist/browser/observer-messages-CEeisRaF.js.map +1 -0
- package/dist/browser/observer-messages-Dnff2Tdb.d.ts +471 -0
- package/dist/browser/observer-messages-Dnff2Tdb.d.ts.map +1 -0
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.d.cts +308 -16
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +308 -16
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -1
- package/dist/node/index.js.map +1 -1
- package/dist/node/node.cjs +1 -1
- package/dist/node/node.d.cts +69 -1
- package/dist/node/node.d.cts.map +1 -1
- package/dist/node/node.d.ts +70 -2
- package/dist/node/node.d.ts.map +1 -1
- package/dist/node/node.js +1 -1
- package/dist/node/node.js.map +1 -1
- package/package.json +17 -14
- package/src/__tests__/bearer-resource-gate.test.ts +120 -0
- package/src/__tests__/history-attached-budget.test.ts +216 -0
- package/src/__tests__/message-decoders.test.ts +496 -6
- package/src/__tests__/project-read-messages.test.ts +118 -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-history-wire.test.ts +342 -0
- package/src/__tests__/session-message-handler-observe.test.ts +217 -0
- package/src/__tests__/session-message-handler.test.ts +139 -4
- package/src/__tests__/settings-messages.test.ts +193 -0
- package/src/__tests__/ws-broadcast-events.test.ts +10 -0
- package/src/client.ts +9 -1
- package/src/index.ts +18 -2
- package/src/loop-control-messages.ts +34 -0
- package/src/message-decoders.ts +238 -6
- package/src/node/bearer-resource-gate.ts +291 -0
- package/src/node/index.ts +13 -0
- package/src/observer-messages.ts +47 -0
- package/src/open-prompts.ts +76 -0
- package/src/outbound-delivery.ts +11 -2
- package/src/project-read-messages.ts +74 -0
- package/src/protocol-session.ts +23 -1
- package/src/session-directory-messages.ts +162 -0
- package/src/session-error-classification.ts +91 -0
- package/src/session-events.ts +100 -19
- package/src/session-message-handler.ts +110 -15
- package/src/session-query-messages.ts +123 -5
- package/src/session-resume-bridge.ts +8 -0
- package/src/settings-messages.ts +103 -0
- package/src/wire-messages.ts +238 -11
- package/dist/browser/message-decoders-BB8n-H2X.d.ts +0 -252
- package/dist/browser/message-decoders-BB8n-H2X.d.ts.map +0 -1
- package/dist/browser/message-decoders-CDmDoZPR.js +0 -2
- package/dist/browser/message-decoders-CDmDoZPR.js.map +0 -1
package/src/session-events.ts
CHANGED
|
@@ -6,14 +6,22 @@
|
|
|
6
6
|
* `SessionResumeBridge` (REMOTE-013 E4 — a SINGLE subscription that outlives per-channel handlers).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { holdOpenPrompts } from './open-prompts.js';
|
|
10
|
+
import { classifySessionErrorForWire } from './session-error-classification.js';
|
|
11
|
+
|
|
9
12
|
import type { TOutboundDeliver } from './outbound-delivery.js';
|
|
10
13
|
import type { IProtocolSession } from './protocol-session.js';
|
|
14
|
+
import type { TWireExecutionResult } from './wire-messages.js';
|
|
11
15
|
import type {
|
|
12
16
|
IExecutionWorkspaceEvent,
|
|
13
17
|
TBackgroundJobGroupEvent,
|
|
14
18
|
TBackgroundTaskEvent,
|
|
15
19
|
} from '@robota-sdk/agent-interface-execution';
|
|
16
|
-
import type {
|
|
20
|
+
import type {
|
|
21
|
+
TDriverId,
|
|
22
|
+
TInteractiveEventName,
|
|
23
|
+
TTurnSource,
|
|
24
|
+
} from '@robota-sdk/agent-interface-session';
|
|
17
25
|
import type {
|
|
18
26
|
IAskRequestEvent,
|
|
19
27
|
IBranchEvent,
|
|
@@ -23,6 +31,8 @@ import type {
|
|
|
23
31
|
IPromptResolvedEvent,
|
|
24
32
|
IPlanApprovalEvent,
|
|
25
33
|
ISessionRenamedEvent,
|
|
34
|
+
ISessionStatusSnapshot,
|
|
35
|
+
ISessionSwitchedEvent,
|
|
26
36
|
IToolState,
|
|
27
37
|
IUiIntentEvent,
|
|
28
38
|
} from '@robota-sdk/agent-interface-session';
|
|
@@ -37,17 +47,17 @@ export const PROTOCOL_SESSION_EVENT_CLASSIFICATION = {
|
|
|
37
47
|
thinking: 'forwarded',
|
|
38
48
|
complete: 'forwarded',
|
|
39
49
|
error: 'forwarded',
|
|
40
|
-
context_update: '
|
|
41
|
-
compact: '
|
|
50
|
+
context_update: 'forwarded',
|
|
51
|
+
compact: 'forwarded',
|
|
42
52
|
interrupted: 'forwarded',
|
|
43
|
-
skill_activation: '
|
|
53
|
+
skill_activation: 'forwarded',
|
|
44
54
|
background_task_event: 'forwarded',
|
|
45
55
|
background_job_group_event: 'forwarded',
|
|
46
56
|
execution_workspace_event: 'forwarded',
|
|
47
57
|
user_message: 'forwarded',
|
|
48
|
-
turn_source: '
|
|
58
|
+
turn_source: 'forwarded',
|
|
49
59
|
context_file_refreshed: 'forwarded',
|
|
50
|
-
memory_event: '
|
|
60
|
+
memory_event: 'forwarded',
|
|
51
61
|
goal_event: 'non-surface',
|
|
52
62
|
plan_event: 'forwarded',
|
|
53
63
|
branch_event: 'forwarded',
|
|
@@ -57,6 +67,8 @@ export const PROTOCOL_SESSION_EVENT_CLASSIFICATION = {
|
|
|
57
67
|
ui_intent: 'requester-routed',
|
|
58
68
|
session_renamed: 'forwarded',
|
|
59
69
|
history_cleared: 'forwarded',
|
|
70
|
+
session_switched: 'forwarded',
|
|
71
|
+
status_changed: 'forwarded',
|
|
60
72
|
} as const satisfies Record<TInteractiveEventName, TProtocolSessionEventClassification>;
|
|
61
73
|
|
|
62
74
|
/**
|
|
@@ -72,6 +84,13 @@ export interface ISubscribeSessionEventsOptions {
|
|
|
72
84
|
* requester-routed against it (lazy because the resume bridge binds the id only after pairing).
|
|
73
85
|
*/
|
|
74
86
|
getSurfaceDriverId?: () => TDriverId | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* `false` leaves `permission_request` and `ask_request` unsubscribed. The session parks a prompt
|
|
89
|
+
* only while someone listens for it, so a surface that may not answer must not listen: otherwise an
|
|
90
|
+
* unattended session would hold a prompt nobody can settle instead of failing it closed at once.
|
|
91
|
+
* Defaults to `true`.
|
|
92
|
+
*/
|
|
93
|
+
receivePrompts?: boolean;
|
|
75
94
|
}
|
|
76
95
|
|
|
77
96
|
/**
|
|
@@ -89,8 +108,8 @@ export function subscribeSessionEvents(
|
|
|
89
108
|
): () => void {
|
|
90
109
|
// ARCH-030: no local guard wrapper any more, and no event name passed alongside the message. The name
|
|
91
110
|
// existed so the local guard could label a failure with it; the boundary labels with `message.type`,
|
|
92
|
-
//
|
|
93
|
-
//
|
|
111
|
+
// the frame that could not be delivered. Keeping the argument would have been a parameter that
|
|
112
|
+
// documents nothing and is read by nobody.
|
|
94
113
|
// REMOTE-014 E5: stamp the ACTIVE turn's driver id onto TURN-AUTHORED events (co-drive authorship,
|
|
95
114
|
// display-only), read at emit time. Only these events — background/goal/memory/execution-workspace events
|
|
96
115
|
// are NOT authored by a driver turn and carry no `driverId`. `undefined` when idle or unattributed.
|
|
@@ -109,11 +128,16 @@ export function subscribeSessionEvents(
|
|
|
109
128
|
const onThinking = (isThinking: boolean): void =>
|
|
110
129
|
deliver({ type: 'thinking', isThinking, ...attr() });
|
|
111
130
|
const onComplete = (result: IExecutionResult): void =>
|
|
112
|
-
deliver({ type: 'complete', result, ...attr() });
|
|
131
|
+
deliver({ type: 'complete', result: withoutHistory(result), ...attr() });
|
|
113
132
|
const onInterrupted = (result: IExecutionResult): void =>
|
|
114
|
-
deliver({ type: 'interrupted', result, ...attr() });
|
|
133
|
+
deliver({ type: 'interrupted', result: withoutHistory(result), ...attr() });
|
|
115
134
|
const onError = (error: Error): void =>
|
|
116
|
-
deliver({
|
|
135
|
+
deliver({
|
|
136
|
+
type: 'error',
|
|
137
|
+
message: error.message,
|
|
138
|
+
...classifySessionErrorForWire(error),
|
|
139
|
+
...attr(),
|
|
140
|
+
});
|
|
117
141
|
const onBackgroundTaskEvent = (event: TBackgroundTaskEvent): void =>
|
|
118
142
|
deliver({ type: 'background_task_event', event });
|
|
119
143
|
const onBackgroundJobGroupEvent = (event: TBackgroundJobGroupEvent): void =>
|
|
@@ -129,11 +153,23 @@ export function subscribeSessionEvents(
|
|
|
129
153
|
const onBranchEvent = (event: IBranchEvent): void => deliver({ type: 'branch_event', event });
|
|
130
154
|
// REMOTE-007: forward the transport-neutral prompt events so a remote surface can render + answer the
|
|
131
155
|
// SAME permission/ask prompt; `prompt_resolved` dismisses it when another surface answered first.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
const
|
|
156
|
+
// #3189: kept while open, so a client that attaches later can ask for it with `get-prompts`.
|
|
157
|
+
const receivePrompts = options.receivePrompts ?? true;
|
|
158
|
+
const openPrompts = receivePrompts ? holdOpenPrompts(session) : undefined;
|
|
159
|
+
const onPermissionRequest = (event: IPermissionRequestEvent): void => {
|
|
160
|
+
const frame = { type: 'permission_request', event } as const;
|
|
161
|
+
openPrompts?.record(frame);
|
|
162
|
+
deliver(frame);
|
|
163
|
+
};
|
|
164
|
+
const onAskRequest = (event: IAskRequestEvent): void => {
|
|
165
|
+
const frame = { type: 'ask_request', event } as const;
|
|
166
|
+
openPrompts?.record(frame);
|
|
167
|
+
deliver(frame);
|
|
168
|
+
};
|
|
169
|
+
const onPromptResolved = (event: IPromptResolvedEvent): void => {
|
|
170
|
+
openPrompts?.forget(event.id);
|
|
136
171
|
deliver({ type: 'prompt_resolved', event });
|
|
172
|
+
};
|
|
137
173
|
// CMD-004 Stage D: `ui_intent` is REQUESTER-ROUTED — delivered only to the surface whose
|
|
138
174
|
// server-assigned driver id issued the command. An UNATTRIBUTED intent (no requester id, e.g. an
|
|
139
175
|
// idle model-invoked command) is unroutable and reaches every surface — never a silent drop.
|
|
@@ -151,6 +187,26 @@ export function subscribeSessionEvents(
|
|
|
151
187
|
const onSessionRenamed = (event: ISessionRenamedEvent): void =>
|
|
152
188
|
deliver({ type: 'session_renamed', event });
|
|
153
189
|
const onHistoryCleared = (): void => deliver({ type: 'history_cleared' });
|
|
190
|
+
// #3189: this surface's session is now another one; it re-reads what it shows. Only surfaces
|
|
191
|
+
// subscribed to the session that moved hear it: a host that binds each connection to its own session
|
|
192
|
+
// sends it to the connection that switched, never to the others.
|
|
193
|
+
const onSessionSwitched = (event: ISessionSwitchedEvent): void => {
|
|
194
|
+
// This connection no longer answers the previous session's prompts; it reads the new session's anew.
|
|
195
|
+
openPrompts?.clear();
|
|
196
|
+
deliver({ type: 'session_switched', event });
|
|
197
|
+
};
|
|
198
|
+
// #3189: what a client that renders the whole session (the TUI) needs beside the streamed turn.
|
|
199
|
+
// The context window is pushed in the frame `get-context` answers with. A compaction, a skill
|
|
200
|
+
// activation or a memory event adds history entries no streamed frame carries, so the client is
|
|
201
|
+
// told the history changed and re-reads it with `get-history`; the entries themselves are not sent.
|
|
202
|
+
const onContextUpdate = (state: ReturnType<IProtocolSession['getContextState']>): void =>
|
|
203
|
+
deliver({ type: 'context', state });
|
|
204
|
+
const onHistoryChanged = (): void => deliver({ type: 'history_changed' });
|
|
205
|
+
const onTurnSource = (source: TTurnSource): void => deliver({ type: 'turn_source', source });
|
|
206
|
+
// One client's `/mode` or `/model` shows on every client on this session, in the frame
|
|
207
|
+
// `get-status` answers with.
|
|
208
|
+
const onStatusChanged = (status: ISessionStatusSnapshot): void =>
|
|
209
|
+
deliver({ type: 'session_status', status });
|
|
154
210
|
|
|
155
211
|
session.on('user_message', onUserMessage);
|
|
156
212
|
session.on('text_delta', onTextDelta);
|
|
@@ -166,12 +222,21 @@ export function subscribeSessionEvents(
|
|
|
166
222
|
session.on('plan_event', onPlanEvent);
|
|
167
223
|
session.on('context_file_refreshed', onContextFileRefreshed);
|
|
168
224
|
session.on('branch_event', onBranchEvent);
|
|
169
|
-
|
|
170
|
-
|
|
225
|
+
if (receivePrompts) {
|
|
226
|
+
session.on('permission_request', onPermissionRequest);
|
|
227
|
+
session.on('ask_request', onAskRequest);
|
|
228
|
+
}
|
|
171
229
|
session.on('prompt_resolved', onPromptResolved);
|
|
172
230
|
session.on('ui_intent', onUiIntent);
|
|
173
231
|
session.on('session_renamed', onSessionRenamed);
|
|
174
232
|
session.on('history_cleared', onHistoryCleared);
|
|
233
|
+
session.on('session_switched', onSessionSwitched);
|
|
234
|
+
session.on('context_update', onContextUpdate);
|
|
235
|
+
session.on('compact', onHistoryChanged);
|
|
236
|
+
session.on('skill_activation', onHistoryChanged);
|
|
237
|
+
session.on('memory_event', onHistoryChanged);
|
|
238
|
+
session.on('turn_source', onTurnSource);
|
|
239
|
+
session.on('status_changed', onStatusChanged);
|
|
175
240
|
|
|
176
241
|
return (): void => {
|
|
177
242
|
session.off('user_message', onUserMessage);
|
|
@@ -188,11 +253,27 @@ export function subscribeSessionEvents(
|
|
|
188
253
|
session.off('plan_event', onPlanEvent);
|
|
189
254
|
session.off('context_file_refreshed', onContextFileRefreshed);
|
|
190
255
|
session.off('branch_event', onBranchEvent);
|
|
191
|
-
|
|
192
|
-
|
|
256
|
+
if (receivePrompts) {
|
|
257
|
+
session.off('permission_request', onPermissionRequest);
|
|
258
|
+
session.off('ask_request', onAskRequest);
|
|
259
|
+
}
|
|
193
260
|
session.off('prompt_resolved', onPromptResolved);
|
|
194
261
|
session.off('ui_intent', onUiIntent);
|
|
195
262
|
session.off('session_renamed', onSessionRenamed);
|
|
196
263
|
session.off('history_cleared', onHistoryCleared);
|
|
264
|
+
session.off('session_switched', onSessionSwitched);
|
|
265
|
+
session.off('context_update', onContextUpdate);
|
|
266
|
+
session.off('compact', onHistoryChanged);
|
|
267
|
+
session.off('skill_activation', onHistoryChanged);
|
|
268
|
+
session.off('memory_event', onHistoryChanged);
|
|
269
|
+
session.off('turn_source', onTurnSource);
|
|
270
|
+
session.off('status_changed', onStatusChanged);
|
|
271
|
+
openPrompts?.release();
|
|
197
272
|
};
|
|
198
273
|
}
|
|
274
|
+
|
|
275
|
+
/** The turn's result without the session's history: that only grows, and `get-history` pages it. */
|
|
276
|
+
function withoutHistory(result: IExecutionResult): TWireExecutionResult {
|
|
277
|
+
const { history: _history, ...rest } = result;
|
|
278
|
+
return rest;
|
|
279
|
+
}
|
|
@@ -12,17 +12,35 @@ import {
|
|
|
12
12
|
handleBackgroundControlMessage,
|
|
13
13
|
handleBackgroundQueryMessage,
|
|
14
14
|
} from './background-messages.js';
|
|
15
|
+
import { handleLoopControlMessage, isLoopControlMessage } from './loop-control-messages.js';
|
|
15
16
|
import { parseClientMessage } from './message-parser.js';
|
|
17
|
+
import { isObserverMessageType } from './observer-messages.js';
|
|
18
|
+
import { handleProjectReadMessage, isProjectReadMessage } from './project-read-messages.js';
|
|
19
|
+
import {
|
|
20
|
+
handleSessionDeleteMessage,
|
|
21
|
+
handleSessionDirectoryMessage,
|
|
22
|
+
handleSessionRenameMessage,
|
|
23
|
+
isSessionDeleteMessage,
|
|
24
|
+
isSessionDirectoryMessage,
|
|
25
|
+
isSessionRenameMessage,
|
|
26
|
+
} from './session-directory-messages.js';
|
|
16
27
|
import { subscribeSessionEvents } from './session-events.js';
|
|
17
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
handleSessionQueryMessage,
|
|
30
|
+
isSessionQueryMessage,
|
|
31
|
+
pendingFrame,
|
|
32
|
+
} from './session-query-messages.js';
|
|
33
|
+
import { handleSettingsMessage, isSettingsMessage } from './settings-messages.js';
|
|
18
34
|
import { handleUsageQueryMessage } from './usage-messages.js';
|
|
19
35
|
|
|
20
36
|
import type { TOutboundDeliver } from './outbound-delivery.js';
|
|
21
|
-
import type { IProtocolSession } from './protocol-session.js';
|
|
37
|
+
import type { IProtocolSession, TProjectReadCapableSession } from './protocol-session.js';
|
|
38
|
+
import type { ISettingsReporter } from './settings-messages.js';
|
|
22
39
|
import type { IUsageQueryReporters } from './usage-messages.js';
|
|
23
40
|
import type { TClientMessage } from './wire-messages.js';
|
|
41
|
+
import type { TCommandSurfaceLocality } from '@robota-sdk/agent-interface-command';
|
|
24
42
|
import type { TUsageSurface } from '@robota-sdk/agent-interface-analytics';
|
|
25
|
-
import type { TDriverId } from '@robota-sdk/agent-interface-session';
|
|
43
|
+
import type { ISessionDirectory, TDriverId } from '@robota-sdk/agent-interface-session';
|
|
26
44
|
|
|
27
45
|
// Outbound session→TServerMessage fan-out (incl. CMD-004 requester-routed `ui_intent`) lives in
|
|
28
46
|
// `session-events.ts`; re-exported here for the bridge and existing importers.
|
|
@@ -30,9 +48,16 @@ export { subscribeSessionEvents } from './session-events.js';
|
|
|
30
48
|
export type { ISubscribeSessionEventsOptions } from './session-events.js';
|
|
31
49
|
export { parseClientMessage } from './message-parser.js';
|
|
32
50
|
|
|
51
|
+
/**
|
|
52
|
+
* What a connected surface may do. `drive` sends prompts, answers questions and controls the session.
|
|
53
|
+
* `observe` is read-only: it follows this session's conversation and state, but never submits, answers,
|
|
54
|
+
* controls, or reads another session's records, and it never counts as a surface that can answer.
|
|
55
|
+
*/
|
|
56
|
+
export type TSessionSurfaceRole = 'drive' | 'observe';
|
|
57
|
+
|
|
33
58
|
export interface ISessionMessageHandlerOptions {
|
|
34
|
-
/** IProtocolSession to expose. */
|
|
35
|
-
session:
|
|
59
|
+
/** IProtocolSession to expose — `Partial<ISessionProjectRead>` is probed, not assumed (#3282 §4c). */
|
|
60
|
+
session: TProjectReadCapableSession;
|
|
36
61
|
/**
|
|
37
62
|
* ARCH-030: the CARRIER's connection-scoped outbound delivery boundary — not a raw `send`, and not a
|
|
38
63
|
* `send` plus an error callback for this handler to assemble into one. The carrier owns both the sink
|
|
@@ -49,12 +74,25 @@ export interface ISessionMessageHandlerOptions {
|
|
|
49
74
|
driverId?: TDriverId;
|
|
50
75
|
/** Trusted carrier-owned product surface, kept separate from driver identity. */
|
|
51
76
|
surface?: TUsageSurface;
|
|
77
|
+
/** Carrier-decided role of this connection; defaults to `drive`. The client never chooses it here. */
|
|
78
|
+
role?: TSessionSurfaceRole;
|
|
52
79
|
/** Host-owned cross-session read model. The protocol only correlates and carries its result. */
|
|
53
80
|
personalUsageReporter?: NonNullable<IUsageQueryReporters['personalUsageReporter']>;
|
|
54
81
|
/** Host-owned current-session trace/cost producer for the pre-existing message family. */
|
|
55
82
|
usageReporter?: NonNullable<IUsageQueryReporters['usageReporter']>;
|
|
56
83
|
/** Host-owned stored-session producer used by cross-session drill-down. */
|
|
57
84
|
storedSessionUsageReporter?: NonNullable<IUsageQueryReporters['storedSessionUsageReporter']>;
|
|
85
|
+
/** Host-owned session directory (#3189): list, start and switch the host's sessions. */
|
|
86
|
+
sessionDirectory?: ISessionDirectory;
|
|
87
|
+
/** #3282 §4a: host-owned read/write for the GUI Settings screen. */
|
|
88
|
+
settingsReporter?: ISettingsReporter;
|
|
89
|
+
/**
|
|
90
|
+
* #3282 §4 part b-2: carrier-decided, like `role` — whether THIS carrier's connections are
|
|
91
|
+
* provably on this machine. The loopback WS carrier is `'local'`; the device-mesh/WebRTC carrier
|
|
92
|
+
* sets `'remote'`. Absent → `'local'`, forwarded to `executeCommand` and the settings reporter so a
|
|
93
|
+
* command that runs code from outside the session (installing a plugin) can refuse a remote device.
|
|
94
|
+
*/
|
|
95
|
+
commandSurfaceLocality?: TCommandSurfaceLocality;
|
|
58
96
|
}
|
|
59
97
|
|
|
60
98
|
/**
|
|
@@ -80,8 +118,10 @@ export function createSessionMessageHandler(options: ISessionMessageHandlerOptio
|
|
|
80
118
|
onMessage: (data: string) => void;
|
|
81
119
|
cleanup: () => void;
|
|
82
120
|
} {
|
|
121
|
+
const role = options.role ?? 'drive';
|
|
83
122
|
const cleanup = subscribeSessionEvents(options.session, options.deliver, {
|
|
84
123
|
getSurfaceDriverId: () => options.driverId,
|
|
124
|
+
receivePrompts: role === 'drive',
|
|
85
125
|
});
|
|
86
126
|
const onMessage = createMessageHandler(
|
|
87
127
|
options.session,
|
|
@@ -93,22 +133,44 @@ export function createSessionMessageHandler(options: ISessionMessageHandlerOptio
|
|
|
93
133
|
storedSessionUsageReporter: options.storedSessionUsageReporter,
|
|
94
134
|
},
|
|
95
135
|
options.surface,
|
|
136
|
+
role,
|
|
137
|
+
options.sessionDirectory,
|
|
138
|
+
options.settingsReporter,
|
|
139
|
+
options.commandSurfaceLocality,
|
|
96
140
|
);
|
|
97
141
|
|
|
98
142
|
return { onMessage, cleanup };
|
|
99
143
|
}
|
|
100
144
|
|
|
101
145
|
function createMessageHandler(
|
|
102
|
-
session:
|
|
146
|
+
session: TProjectReadCapableSession,
|
|
103
147
|
deliver: TOutboundDeliver,
|
|
104
148
|
driverId?: TDriverId,
|
|
105
149
|
reporters: IUsageQueryReporters = EMPTY_USAGE_REPORTERS,
|
|
106
150
|
surface?: TUsageSurface,
|
|
151
|
+
role: TSessionSurfaceRole = 'drive',
|
|
152
|
+
sessionDirectory?: ISessionDirectory,
|
|
153
|
+
settingsReporter?: ISettingsReporter,
|
|
154
|
+
commandSurfaceLocality?: TCommandSurfaceLocality,
|
|
107
155
|
): (data: string) => void {
|
|
108
156
|
return (data: string): void => {
|
|
109
157
|
const msg = parseClientMessage(data, deliver);
|
|
110
158
|
if (!msg) return;
|
|
111
|
-
|
|
159
|
+
if (role === 'observe' && !isObserverMessageType(msg.type)) {
|
|
160
|
+
deliver({ type: 'protocol_error', message: `Not permitted for an observer: ${msg.type}` });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
handleClientMessage(
|
|
164
|
+
session,
|
|
165
|
+
deliver,
|
|
166
|
+
msg,
|
|
167
|
+
driverId,
|
|
168
|
+
reporters,
|
|
169
|
+
surface,
|
|
170
|
+
sessionDirectory,
|
|
171
|
+
settingsReporter,
|
|
172
|
+
commandSurfaceLocality,
|
|
173
|
+
);
|
|
112
174
|
};
|
|
113
175
|
}
|
|
114
176
|
|
|
@@ -123,24 +185,51 @@ const EMPTY_USAGE_REPORTERS: IUsageQueryReporters = {
|
|
|
123
185
|
* the {@link SessionResumeBridge} intercepts `resume`/`ack` itself and delegates everything else here.
|
|
124
186
|
*/
|
|
125
187
|
export function handleClientMessage(
|
|
126
|
-
session:
|
|
188
|
+
session: TProjectReadCapableSession,
|
|
127
189
|
deliver: TOutboundDeliver,
|
|
128
190
|
msg: TClientMessage,
|
|
129
191
|
driverId?: TDriverId,
|
|
130
192
|
reporters: IUsageQueryReporters = EMPTY_USAGE_REPORTERS,
|
|
131
193
|
surface?: TUsageSurface,
|
|
194
|
+
sessionDirectory?: ISessionDirectory,
|
|
195
|
+
settingsReporter?: ISettingsReporter,
|
|
196
|
+
commandSurfaceLocality?: TCommandSurfaceLocality,
|
|
132
197
|
): void {
|
|
133
198
|
if (handleUsageQueryMessage(session, deliver, msg, reporters)) {
|
|
134
199
|
return;
|
|
135
200
|
}
|
|
201
|
+
if (isSessionDirectoryMessage(msg)) {
|
|
202
|
+
handleSessionDirectoryMessage(deliver, msg, sessionDirectory);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (isSettingsMessage(msg)) {
|
|
206
|
+
handleSettingsMessage(session, deliver, msg, settingsReporter, commandSurfaceLocality);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (isSessionRenameMessage(msg)) {
|
|
210
|
+
handleSessionRenameMessage(deliver, msg, sessionDirectory);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (isSessionDeleteMessage(msg)) {
|
|
214
|
+
handleSessionDeleteMessage(deliver, msg, sessionDirectory);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
136
217
|
if (isSessionControlMessage(msg)) {
|
|
137
|
-
handleSessionControlMessage(session, deliver, msg, driverId, surface);
|
|
218
|
+
handleSessionControlMessage(session, deliver, msg, driverId, surface, commandSurfaceLocality);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (isLoopControlMessage(msg)) {
|
|
222
|
+
handleLoopControlMessage(session, deliver, msg);
|
|
138
223
|
return;
|
|
139
224
|
}
|
|
140
225
|
if (isSessionQueryMessage(msg)) {
|
|
141
226
|
handleSessionQueryMessage(session, deliver, msg);
|
|
142
227
|
return;
|
|
143
228
|
}
|
|
229
|
+
if (isProjectReadMessage(msg)) {
|
|
230
|
+
handleProjectReadMessage(session, deliver, msg);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
144
233
|
if (isBackgroundQueryMessage(msg)) {
|
|
145
234
|
handleBackgroundQueryMessage(session, deliver, msg);
|
|
146
235
|
return;
|
|
@@ -228,6 +317,7 @@ function handleSessionControlMessage(
|
|
|
228
317
|
msg: Extract<TClientMessage, { type: 'submit' | 'command' | 'abort' | 'cancel-queue' }>,
|
|
229
318
|
driverId?: TDriverId,
|
|
230
319
|
surface?: TUsageSurface,
|
|
320
|
+
commandSurfaceLocality?: TCommandSurfaceLocality,
|
|
231
321
|
): void {
|
|
232
322
|
if (msg.type === 'submit') {
|
|
233
323
|
// TRANS-008 (issue #2045). A TYPE check, not a falsy one: `{}`, `[]`, `42` and `true` are truthy
|
|
@@ -250,17 +340,21 @@ function handleSessionControlMessage(
|
|
|
250
340
|
undefined,
|
|
251
341
|
Object.keys(submitOptions).length > 0 ? submitOptions : undefined,
|
|
252
342
|
)
|
|
253
|
-
.
|
|
254
|
-
|
|
255
|
-
|
|
343
|
+
.then(
|
|
344
|
+
// #3189: a prompt submitted during a turn resolves once it is queued. A `get-pending` sent
|
|
345
|
+
// beside the `submit` would be answered before that, so the queue is reported from here.
|
|
346
|
+
() => deliver(pendingFrame(session)),
|
|
347
|
+
(error: Error) => deliver({ type: 'protocol_error', message: error.message }),
|
|
348
|
+
);
|
|
256
349
|
} else if (msg.type === 'command') {
|
|
350
|
+
const request = msg.requestId !== undefined ? { requestId: msg.requestId } : {};
|
|
257
351
|
if (!msg.name) {
|
|
258
|
-
deliver({ type: 'protocol_error', message: 'name is required' });
|
|
352
|
+
deliver({ type: 'protocol_error', message: 'name is required', ...request });
|
|
259
353
|
return;
|
|
260
354
|
}
|
|
261
355
|
// REMOTE-003: a transport-origin command is tagged `'remote'` (optional policy, allow-by-default;
|
|
262
356
|
// REMOTE-006). CMD-004: the SERVER-ASSIGNED driver id (E5) is the command origin — intents route back here.
|
|
263
|
-
session.executeCommand(msg.name, msg.args ?? '', 'remote', driverId).then(
|
|
357
|
+
session.executeCommand(msg.name, msg.args ?? '', 'remote', driverId, commandSurfaceLocality).then(
|
|
264
358
|
(result) => {
|
|
265
359
|
deliver({
|
|
266
360
|
type: 'command_result',
|
|
@@ -268,10 +362,11 @@ function handleSessionControlMessage(
|
|
|
268
362
|
message: result?.message ?? `Unknown command: ${msg.name}`,
|
|
269
363
|
success: result?.success ?? false,
|
|
270
364
|
data: result?.data,
|
|
365
|
+
...request,
|
|
271
366
|
});
|
|
272
367
|
},
|
|
273
368
|
(error: Error) => {
|
|
274
|
-
deliver({ type: 'protocol_error', message: error.message });
|
|
369
|
+
deliver({ type: 'protocol_error', message: error.message, ...request });
|
|
275
370
|
},
|
|
276
371
|
);
|
|
277
372
|
} else if (msg.type === 'abort') {
|
|
@@ -1,22 +1,51 @@
|
|
|
1
|
+
import { listOpenPrompts } from './open-prompts.js';
|
|
2
|
+
|
|
1
3
|
import type { TOutboundDeliver } from './outbound-delivery.js';
|
|
2
4
|
import type { IProtocolSession } from './protocol-session.js';
|
|
3
|
-
import type { TClientMessage } from './wire-messages.js';
|
|
5
|
+
import type { IWireHistoryEntry, TClientMessage, TServerMessage } from './wire-messages.js';
|
|
4
6
|
|
|
5
7
|
type TSessionQueryMessage = Extract<
|
|
6
8
|
TClientMessage,
|
|
7
9
|
{
|
|
8
10
|
type:
|
|
9
|
-
|
|
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';
|
|
10
23
|
}
|
|
11
24
|
>;
|
|
12
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
|
+
|
|
13
35
|
export function isSessionQueryMessage(msg: TClientMessage): msg is TSessionQueryMessage {
|
|
14
36
|
return (
|
|
15
37
|
msg.type === 'get-messages' ||
|
|
38
|
+
msg.type === 'get-history' ||
|
|
39
|
+
msg.type === 'get-prompts' ||
|
|
16
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' ||
|
|
17
45
|
msg.type === 'get-executing' ||
|
|
18
46
|
msg.type === 'get-pending' ||
|
|
19
|
-
msg.type === 'get-execution-workspace'
|
|
47
|
+
msg.type === 'get-execution-workspace' ||
|
|
48
|
+
msg.type === 'read-execution-detail'
|
|
20
49
|
);
|
|
21
50
|
}
|
|
22
51
|
|
|
@@ -26,9 +55,30 @@ export function handleSessionQueryMessage(
|
|
|
26
55
|
msg: TSessionQueryMessage,
|
|
27
56
|
): void {
|
|
28
57
|
if (msg.type === 'get-messages') {
|
|
29
|
-
|
|
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);
|
|
30
66
|
} else if (msg.type === 'get-context') {
|
|
31
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
|
+
});
|
|
32
82
|
} else if (msg.type === 'get-executing') {
|
|
33
83
|
deliver({ type: 'executing', executing: session.isExecuting() });
|
|
34
84
|
} else if (msg.type === 'get-execution-workspace') {
|
|
@@ -36,7 +86,75 @@ export function handleSessionQueryMessage(
|
|
|
36
86
|
type: 'execution_workspace_event',
|
|
37
87
|
snapshot: session.getExecutionWorkspaceSnapshot(),
|
|
38
88
|
});
|
|
89
|
+
} else if (msg.type === 'read-execution-detail') {
|
|
90
|
+
readExecutionDetail(session, deliver, msg);
|
|
39
91
|
} else {
|
|
40
|
-
deliver(
|
|
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;
|
|
41
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();
|
|
42
160
|
}
|
|
@@ -28,6 +28,7 @@ import type { TOutboundDeliver } from './outbound-delivery.js';
|
|
|
28
28
|
import type { IProtocolSession } from './protocol-session.js';
|
|
29
29
|
import type { IUsageQueryReporters } from './usage-messages.js';
|
|
30
30
|
import type { TSeqServerMessage, TServerMessage } from './wire-messages.js';
|
|
31
|
+
import type { TCommandSurfaceLocality } from '@robota-sdk/agent-interface-command';
|
|
31
32
|
import type { TUsageSurface } from '@robota-sdk/agent-interface-analytics';
|
|
32
33
|
import type { TDriverId } from '@robota-sdk/agent-interface-session';
|
|
33
34
|
|
|
@@ -65,6 +66,8 @@ export interface ISessionResumeBridgeOptions {
|
|
|
65
66
|
readonly personalUsageReporter?: IUsageQueryReporters['personalUsageReporter'];
|
|
66
67
|
readonly usageReporter?: IUsageQueryReporters['usageReporter'];
|
|
67
68
|
readonly storedSessionUsageReporter?: IUsageQueryReporters['storedSessionUsageReporter'];
|
|
69
|
+
/** #3282 §4 part b-2: this bridge is the reconnectable WebRTC path, so it is always `'remote'`. */
|
|
70
|
+
readonly commandSurfaceLocality?: TCommandSurfaceLocality;
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
export class SessionResumeBridge {
|
|
@@ -73,6 +76,7 @@ export class SessionResumeBridge {
|
|
|
73
76
|
private driverId?: TDriverId;
|
|
74
77
|
private readonly reporters: IUsageQueryReporters;
|
|
75
78
|
private readonly surface?: TUsageSurface;
|
|
79
|
+
private readonly commandSurfaceLocality?: TCommandSurfaceLocality;
|
|
76
80
|
private readonly unsubscribe: () => void;
|
|
77
81
|
private onDeliveryError?: IAttachOptions['onDeliveryError'];
|
|
78
82
|
/**
|
|
@@ -105,6 +109,7 @@ export class SessionResumeBridge {
|
|
|
105
109
|
this.buffer = new ResumeBuffer(options.buffer);
|
|
106
110
|
this.driverId = options.driverId;
|
|
107
111
|
this.surface = options.surface;
|
|
112
|
+
this.commandSurfaceLocality = options.commandSurfaceLocality;
|
|
108
113
|
this.reporters = {
|
|
109
114
|
personalUsageReporter: options.personalUsageReporter,
|
|
110
115
|
usageReporter: options.usageReporter,
|
|
@@ -178,6 +183,9 @@ export class SessionResumeBridge {
|
|
|
178
183
|
this.driverId,
|
|
179
184
|
this.reporters,
|
|
180
185
|
this.surface,
|
|
186
|
+
undefined,
|
|
187
|
+
undefined,
|
|
188
|
+
this.commandSurfaceLocality,
|
|
181
189
|
);
|
|
182
190
|
}
|
|
183
191
|
|