@robota-sdk/agent-transport 3.0.0-beta.82 → 3.0.0-beta.83
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 +752 -0
- package/dist/browser/client.d.ts +2 -2
- package/dist/browser/client.js +1 -1
- package/dist/browser/index.d.ts +31 -11
- 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-Bx-xReUQ.js +2 -0
- package/dist/browser/observer-messages-Bx-xReUQ.js.map +1 -0
- package/dist/browser/{message-decoders-BB8n-H2X.d.ts → observer-messages-DC_8VHk9.d.ts} +104 -7
- package/dist/browser/observer-messages-DC_8VHk9.d.ts.map +1 -0
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.d.cts +131 -14
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +131 -14
- 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 +13 -11
- 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 +201 -5
- package/src/__tests__/session-detail-loop-status.test.ts +169 -0
- package/src/__tests__/session-directory-messages.test.ts +192 -0
- package/src/__tests__/session-history-wire.test.ts +342 -0
- package/src/__tests__/session-message-handler-observe.test.ts +193 -0
- package/src/__tests__/session-message-handler.test.ts +82 -1
- package/src/__tests__/ws-broadcast-events.test.ts +10 -0
- package/src/client.ts +9 -1
- package/src/index.ts +14 -2
- package/src/loop-control-messages.ts +34 -0
- package/src/message-decoders.ts +82 -4
- package/src/node/bearer-resource-gate.ts +291 -0
- package/src/node/index.ts +13 -0
- package/src/observer-messages.ts +36 -0
- package/src/open-prompts.ts +76 -0
- package/src/outbound-delivery.ts +11 -2
- package/src/protocol-session.ts +7 -1
- package/src/session-directory-messages.ts +85 -0
- package/src/session-events.ts +93 -18
- package/src/session-message-handler.ts +53 -8
- package/src/session-query-messages.ts +105 -4
- package/src/wire-messages.ts +105 -7
- 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,21 @@
|
|
|
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
|
+
|
|
9
11
|
import type { TOutboundDeliver } from './outbound-delivery.js';
|
|
10
12
|
import type { IProtocolSession } from './protocol-session.js';
|
|
13
|
+
import type { TWireExecutionResult } from './wire-messages.js';
|
|
11
14
|
import type {
|
|
12
15
|
IExecutionWorkspaceEvent,
|
|
13
16
|
TBackgroundJobGroupEvent,
|
|
14
17
|
TBackgroundTaskEvent,
|
|
15
18
|
} from '@robota-sdk/agent-interface-execution';
|
|
16
|
-
import type {
|
|
19
|
+
import type {
|
|
20
|
+
TDriverId,
|
|
21
|
+
TInteractiveEventName,
|
|
22
|
+
TTurnSource,
|
|
23
|
+
} from '@robota-sdk/agent-interface-session';
|
|
17
24
|
import type {
|
|
18
25
|
IAskRequestEvent,
|
|
19
26
|
IBranchEvent,
|
|
@@ -23,6 +30,8 @@ import type {
|
|
|
23
30
|
IPromptResolvedEvent,
|
|
24
31
|
IPlanApprovalEvent,
|
|
25
32
|
ISessionRenamedEvent,
|
|
33
|
+
ISessionStatusSnapshot,
|
|
34
|
+
ISessionSwitchedEvent,
|
|
26
35
|
IToolState,
|
|
27
36
|
IUiIntentEvent,
|
|
28
37
|
} from '@robota-sdk/agent-interface-session';
|
|
@@ -37,17 +46,17 @@ export const PROTOCOL_SESSION_EVENT_CLASSIFICATION = {
|
|
|
37
46
|
thinking: 'forwarded',
|
|
38
47
|
complete: 'forwarded',
|
|
39
48
|
error: 'forwarded',
|
|
40
|
-
context_update: '
|
|
41
|
-
compact: '
|
|
49
|
+
context_update: 'forwarded',
|
|
50
|
+
compact: 'forwarded',
|
|
42
51
|
interrupted: 'forwarded',
|
|
43
|
-
skill_activation: '
|
|
52
|
+
skill_activation: 'forwarded',
|
|
44
53
|
background_task_event: 'forwarded',
|
|
45
54
|
background_job_group_event: 'forwarded',
|
|
46
55
|
execution_workspace_event: 'forwarded',
|
|
47
56
|
user_message: 'forwarded',
|
|
48
|
-
turn_source: '
|
|
57
|
+
turn_source: 'forwarded',
|
|
49
58
|
context_file_refreshed: 'forwarded',
|
|
50
|
-
memory_event: '
|
|
59
|
+
memory_event: 'forwarded',
|
|
51
60
|
goal_event: 'non-surface',
|
|
52
61
|
plan_event: 'forwarded',
|
|
53
62
|
branch_event: 'forwarded',
|
|
@@ -57,6 +66,8 @@ export const PROTOCOL_SESSION_EVENT_CLASSIFICATION = {
|
|
|
57
66
|
ui_intent: 'requester-routed',
|
|
58
67
|
session_renamed: 'forwarded',
|
|
59
68
|
history_cleared: 'forwarded',
|
|
69
|
+
session_switched: 'forwarded',
|
|
70
|
+
status_changed: 'forwarded',
|
|
60
71
|
} as const satisfies Record<TInteractiveEventName, TProtocolSessionEventClassification>;
|
|
61
72
|
|
|
62
73
|
/**
|
|
@@ -72,6 +83,13 @@ export interface ISubscribeSessionEventsOptions {
|
|
|
72
83
|
* requester-routed against it (lazy because the resume bridge binds the id only after pairing).
|
|
73
84
|
*/
|
|
74
85
|
getSurfaceDriverId?: () => TDriverId | undefined;
|
|
86
|
+
/**
|
|
87
|
+
* `false` leaves `permission_request` and `ask_request` unsubscribed. The session parks a prompt
|
|
88
|
+
* only while someone listens for it, so a surface that may not answer must not listen: otherwise an
|
|
89
|
+
* unattended session would hold a prompt nobody can settle instead of failing it closed at once.
|
|
90
|
+
* Defaults to `true`.
|
|
91
|
+
*/
|
|
92
|
+
receivePrompts?: boolean;
|
|
75
93
|
}
|
|
76
94
|
|
|
77
95
|
/**
|
|
@@ -89,8 +107,8 @@ export function subscribeSessionEvents(
|
|
|
89
107
|
): () => void {
|
|
90
108
|
// ARCH-030: no local guard wrapper any more, and no event name passed alongside the message. The name
|
|
91
109
|
// existed so the local guard could label a failure with it; the boundary labels with `message.type`,
|
|
92
|
-
//
|
|
93
|
-
//
|
|
110
|
+
// the frame that could not be delivered. Keeping the argument would have been a parameter that
|
|
111
|
+
// documents nothing and is read by nobody.
|
|
94
112
|
// REMOTE-014 E5: stamp the ACTIVE turn's driver id onto TURN-AUTHORED events (co-drive authorship,
|
|
95
113
|
// display-only), read at emit time. Only these events — background/goal/memory/execution-workspace events
|
|
96
114
|
// are NOT authored by a driver turn and carry no `driverId`. `undefined` when idle or unattributed.
|
|
@@ -109,9 +127,9 @@ export function subscribeSessionEvents(
|
|
|
109
127
|
const onThinking = (isThinking: boolean): void =>
|
|
110
128
|
deliver({ type: 'thinking', isThinking, ...attr() });
|
|
111
129
|
const onComplete = (result: IExecutionResult): void =>
|
|
112
|
-
deliver({ type: 'complete', result, ...attr() });
|
|
130
|
+
deliver({ type: 'complete', result: withoutHistory(result), ...attr() });
|
|
113
131
|
const onInterrupted = (result: IExecutionResult): void =>
|
|
114
|
-
deliver({ type: 'interrupted', result, ...attr() });
|
|
132
|
+
deliver({ type: 'interrupted', result: withoutHistory(result), ...attr() });
|
|
115
133
|
const onError = (error: Error): void =>
|
|
116
134
|
deliver({ type: 'error', message: error.message, ...attr() });
|
|
117
135
|
const onBackgroundTaskEvent = (event: TBackgroundTaskEvent): void =>
|
|
@@ -129,11 +147,23 @@ export function subscribeSessionEvents(
|
|
|
129
147
|
const onBranchEvent = (event: IBranchEvent): void => deliver({ type: 'branch_event', event });
|
|
130
148
|
// REMOTE-007: forward the transport-neutral prompt events so a remote surface can render + answer the
|
|
131
149
|
// SAME permission/ask prompt; `prompt_resolved` dismisses it when another surface answered first.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
const
|
|
150
|
+
// #3189: kept while open, so a client that attaches later can ask for it with `get-prompts`.
|
|
151
|
+
const receivePrompts = options.receivePrompts ?? true;
|
|
152
|
+
const openPrompts = receivePrompts ? holdOpenPrompts(session) : undefined;
|
|
153
|
+
const onPermissionRequest = (event: IPermissionRequestEvent): void => {
|
|
154
|
+
const frame = { type: 'permission_request', event } as const;
|
|
155
|
+
openPrompts?.record(frame);
|
|
156
|
+
deliver(frame);
|
|
157
|
+
};
|
|
158
|
+
const onAskRequest = (event: IAskRequestEvent): void => {
|
|
159
|
+
const frame = { type: 'ask_request', event } as const;
|
|
160
|
+
openPrompts?.record(frame);
|
|
161
|
+
deliver(frame);
|
|
162
|
+
};
|
|
163
|
+
const onPromptResolved = (event: IPromptResolvedEvent): void => {
|
|
164
|
+
openPrompts?.forget(event.id);
|
|
136
165
|
deliver({ type: 'prompt_resolved', event });
|
|
166
|
+
};
|
|
137
167
|
// CMD-004 Stage D: `ui_intent` is REQUESTER-ROUTED — delivered only to the surface whose
|
|
138
168
|
// server-assigned driver id issued the command. An UNATTRIBUTED intent (no requester id, e.g. an
|
|
139
169
|
// idle model-invoked command) is unroutable and reaches every surface — never a silent drop.
|
|
@@ -151,6 +181,26 @@ export function subscribeSessionEvents(
|
|
|
151
181
|
const onSessionRenamed = (event: ISessionRenamedEvent): void =>
|
|
152
182
|
deliver({ type: 'session_renamed', event });
|
|
153
183
|
const onHistoryCleared = (): void => deliver({ type: 'history_cleared' });
|
|
184
|
+
// #3189: this surface's session is now another one; it re-reads what it shows. Only surfaces
|
|
185
|
+
// subscribed to the session that moved hear it: a host that binds each connection to its own session
|
|
186
|
+
// sends it to the connection that switched, never to the others.
|
|
187
|
+
const onSessionSwitched = (event: ISessionSwitchedEvent): void => {
|
|
188
|
+
// This connection no longer answers the previous session's prompts; it reads the new session's anew.
|
|
189
|
+
openPrompts?.clear();
|
|
190
|
+
deliver({ type: 'session_switched', event });
|
|
191
|
+
};
|
|
192
|
+
// #3189: what a client that renders the whole session (the TUI) needs beside the streamed turn.
|
|
193
|
+
// The context window is pushed in the frame `get-context` answers with. A compaction, a skill
|
|
194
|
+
// activation or a memory event adds history entries no streamed frame carries, so the client is
|
|
195
|
+
// told the history changed and re-reads it with `get-history`; the entries themselves are not sent.
|
|
196
|
+
const onContextUpdate = (state: ReturnType<IProtocolSession['getContextState']>): void =>
|
|
197
|
+
deliver({ type: 'context', state });
|
|
198
|
+
const onHistoryChanged = (): void => deliver({ type: 'history_changed' });
|
|
199
|
+
const onTurnSource = (source: TTurnSource): void => deliver({ type: 'turn_source', source });
|
|
200
|
+
// One client's `/mode` or `/model` shows on every client on this session, in the frame
|
|
201
|
+
// `get-status` answers with.
|
|
202
|
+
const onStatusChanged = (status: ISessionStatusSnapshot): void =>
|
|
203
|
+
deliver({ type: 'session_status', status });
|
|
154
204
|
|
|
155
205
|
session.on('user_message', onUserMessage);
|
|
156
206
|
session.on('text_delta', onTextDelta);
|
|
@@ -166,12 +216,21 @@ export function subscribeSessionEvents(
|
|
|
166
216
|
session.on('plan_event', onPlanEvent);
|
|
167
217
|
session.on('context_file_refreshed', onContextFileRefreshed);
|
|
168
218
|
session.on('branch_event', onBranchEvent);
|
|
169
|
-
|
|
170
|
-
|
|
219
|
+
if (receivePrompts) {
|
|
220
|
+
session.on('permission_request', onPermissionRequest);
|
|
221
|
+
session.on('ask_request', onAskRequest);
|
|
222
|
+
}
|
|
171
223
|
session.on('prompt_resolved', onPromptResolved);
|
|
172
224
|
session.on('ui_intent', onUiIntent);
|
|
173
225
|
session.on('session_renamed', onSessionRenamed);
|
|
174
226
|
session.on('history_cleared', onHistoryCleared);
|
|
227
|
+
session.on('session_switched', onSessionSwitched);
|
|
228
|
+
session.on('context_update', onContextUpdate);
|
|
229
|
+
session.on('compact', onHistoryChanged);
|
|
230
|
+
session.on('skill_activation', onHistoryChanged);
|
|
231
|
+
session.on('memory_event', onHistoryChanged);
|
|
232
|
+
session.on('turn_source', onTurnSource);
|
|
233
|
+
session.on('status_changed', onStatusChanged);
|
|
175
234
|
|
|
176
235
|
return (): void => {
|
|
177
236
|
session.off('user_message', onUserMessage);
|
|
@@ -188,11 +247,27 @@ export function subscribeSessionEvents(
|
|
|
188
247
|
session.off('plan_event', onPlanEvent);
|
|
189
248
|
session.off('context_file_refreshed', onContextFileRefreshed);
|
|
190
249
|
session.off('branch_event', onBranchEvent);
|
|
191
|
-
|
|
192
|
-
|
|
250
|
+
if (receivePrompts) {
|
|
251
|
+
session.off('permission_request', onPermissionRequest);
|
|
252
|
+
session.off('ask_request', onAskRequest);
|
|
253
|
+
}
|
|
193
254
|
session.off('prompt_resolved', onPromptResolved);
|
|
194
255
|
session.off('ui_intent', onUiIntent);
|
|
195
256
|
session.off('session_renamed', onSessionRenamed);
|
|
196
257
|
session.off('history_cleared', onHistoryCleared);
|
|
258
|
+
session.off('session_switched', onSessionSwitched);
|
|
259
|
+
session.off('context_update', onContextUpdate);
|
|
260
|
+
session.off('compact', onHistoryChanged);
|
|
261
|
+
session.off('skill_activation', onHistoryChanged);
|
|
262
|
+
session.off('memory_event', onHistoryChanged);
|
|
263
|
+
session.off('turn_source', onTurnSource);
|
|
264
|
+
session.off('status_changed', onStatusChanged);
|
|
265
|
+
openPrompts?.release();
|
|
197
266
|
};
|
|
198
267
|
}
|
|
268
|
+
|
|
269
|
+
/** The turn's result without the session's history: that only grows, and `get-history` pages it. */
|
|
270
|
+
function withoutHistory(result: IExecutionResult): TWireExecutionResult {
|
|
271
|
+
const { history: _history, ...rest } = result;
|
|
272
|
+
return rest;
|
|
273
|
+
}
|
|
@@ -12,9 +12,19 @@ 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 {
|
|
19
|
+
handleSessionDirectoryMessage,
|
|
20
|
+
isSessionDirectoryMessage,
|
|
21
|
+
} from './session-directory-messages.js';
|
|
16
22
|
import { subscribeSessionEvents } from './session-events.js';
|
|
17
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
handleSessionQueryMessage,
|
|
25
|
+
isSessionQueryMessage,
|
|
26
|
+
pendingFrame,
|
|
27
|
+
} from './session-query-messages.js';
|
|
18
28
|
import { handleUsageQueryMessage } from './usage-messages.js';
|
|
19
29
|
|
|
20
30
|
import type { TOutboundDeliver } from './outbound-delivery.js';
|
|
@@ -22,7 +32,7 @@ import type { IProtocolSession } from './protocol-session.js';
|
|
|
22
32
|
import type { IUsageQueryReporters } from './usage-messages.js';
|
|
23
33
|
import type { TClientMessage } from './wire-messages.js';
|
|
24
34
|
import type { TUsageSurface } from '@robota-sdk/agent-interface-analytics';
|
|
25
|
-
import type { TDriverId } from '@robota-sdk/agent-interface-session';
|
|
35
|
+
import type { ISessionDirectory, TDriverId } from '@robota-sdk/agent-interface-session';
|
|
26
36
|
|
|
27
37
|
// Outbound session→TServerMessage fan-out (incl. CMD-004 requester-routed `ui_intent`) lives in
|
|
28
38
|
// `session-events.ts`; re-exported here for the bridge and existing importers.
|
|
@@ -30,6 +40,13 @@ export { subscribeSessionEvents } from './session-events.js';
|
|
|
30
40
|
export type { ISubscribeSessionEventsOptions } from './session-events.js';
|
|
31
41
|
export { parseClientMessage } from './message-parser.js';
|
|
32
42
|
|
|
43
|
+
/**
|
|
44
|
+
* What a connected surface may do. `drive` sends prompts, answers questions and controls the session.
|
|
45
|
+
* `observe` is read-only: it follows this session's conversation and state, but never submits, answers,
|
|
46
|
+
* controls, or reads another session's records, and it never counts as a surface that can answer.
|
|
47
|
+
*/
|
|
48
|
+
export type TSessionSurfaceRole = 'drive' | 'observe';
|
|
49
|
+
|
|
33
50
|
export interface ISessionMessageHandlerOptions {
|
|
34
51
|
/** IProtocolSession to expose. */
|
|
35
52
|
session: IProtocolSession;
|
|
@@ -49,12 +66,16 @@ export interface ISessionMessageHandlerOptions {
|
|
|
49
66
|
driverId?: TDriverId;
|
|
50
67
|
/** Trusted carrier-owned product surface, kept separate from driver identity. */
|
|
51
68
|
surface?: TUsageSurface;
|
|
69
|
+
/** Carrier-decided role of this connection; defaults to `drive`. The client never chooses it here. */
|
|
70
|
+
role?: TSessionSurfaceRole;
|
|
52
71
|
/** Host-owned cross-session read model. The protocol only correlates and carries its result. */
|
|
53
72
|
personalUsageReporter?: NonNullable<IUsageQueryReporters['personalUsageReporter']>;
|
|
54
73
|
/** Host-owned current-session trace/cost producer for the pre-existing message family. */
|
|
55
74
|
usageReporter?: NonNullable<IUsageQueryReporters['usageReporter']>;
|
|
56
75
|
/** Host-owned stored-session producer used by cross-session drill-down. */
|
|
57
76
|
storedSessionUsageReporter?: NonNullable<IUsageQueryReporters['storedSessionUsageReporter']>;
|
|
77
|
+
/** Host-owned session directory (#3189): list, start and switch the host's sessions. */
|
|
78
|
+
sessionDirectory?: ISessionDirectory;
|
|
58
79
|
}
|
|
59
80
|
|
|
60
81
|
/**
|
|
@@ -80,8 +101,10 @@ export function createSessionMessageHandler(options: ISessionMessageHandlerOptio
|
|
|
80
101
|
onMessage: (data: string) => void;
|
|
81
102
|
cleanup: () => void;
|
|
82
103
|
} {
|
|
104
|
+
const role = options.role ?? 'drive';
|
|
83
105
|
const cleanup = subscribeSessionEvents(options.session, options.deliver, {
|
|
84
106
|
getSurfaceDriverId: () => options.driverId,
|
|
107
|
+
receivePrompts: role === 'drive',
|
|
85
108
|
});
|
|
86
109
|
const onMessage = createMessageHandler(
|
|
87
110
|
options.session,
|
|
@@ -93,6 +116,8 @@ export function createSessionMessageHandler(options: ISessionMessageHandlerOptio
|
|
|
93
116
|
storedSessionUsageReporter: options.storedSessionUsageReporter,
|
|
94
117
|
},
|
|
95
118
|
options.surface,
|
|
119
|
+
role,
|
|
120
|
+
options.sessionDirectory,
|
|
96
121
|
);
|
|
97
122
|
|
|
98
123
|
return { onMessage, cleanup };
|
|
@@ -104,11 +129,17 @@ function createMessageHandler(
|
|
|
104
129
|
driverId?: TDriverId,
|
|
105
130
|
reporters: IUsageQueryReporters = EMPTY_USAGE_REPORTERS,
|
|
106
131
|
surface?: TUsageSurface,
|
|
132
|
+
role: TSessionSurfaceRole = 'drive',
|
|
133
|
+
sessionDirectory?: ISessionDirectory,
|
|
107
134
|
): (data: string) => void {
|
|
108
135
|
return (data: string): void => {
|
|
109
136
|
const msg = parseClientMessage(data, deliver);
|
|
110
137
|
if (!msg) return;
|
|
111
|
-
|
|
138
|
+
if (role === 'observe' && !isObserverMessageType(msg.type)) {
|
|
139
|
+
deliver({ type: 'protocol_error', message: `Not permitted for an observer: ${msg.type}` });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
handleClientMessage(session, deliver, msg, driverId, reporters, surface, sessionDirectory);
|
|
112
143
|
};
|
|
113
144
|
}
|
|
114
145
|
|
|
@@ -129,14 +160,23 @@ export function handleClientMessage(
|
|
|
129
160
|
driverId?: TDriverId,
|
|
130
161
|
reporters: IUsageQueryReporters = EMPTY_USAGE_REPORTERS,
|
|
131
162
|
surface?: TUsageSurface,
|
|
163
|
+
sessionDirectory?: ISessionDirectory,
|
|
132
164
|
): void {
|
|
133
165
|
if (handleUsageQueryMessage(session, deliver, msg, reporters)) {
|
|
134
166
|
return;
|
|
135
167
|
}
|
|
168
|
+
if (isSessionDirectoryMessage(msg)) {
|
|
169
|
+
handleSessionDirectoryMessage(deliver, msg, sessionDirectory);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
136
172
|
if (isSessionControlMessage(msg)) {
|
|
137
173
|
handleSessionControlMessage(session, deliver, msg, driverId, surface);
|
|
138
174
|
return;
|
|
139
175
|
}
|
|
176
|
+
if (isLoopControlMessage(msg)) {
|
|
177
|
+
handleLoopControlMessage(session, deliver, msg);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
140
180
|
if (isSessionQueryMessage(msg)) {
|
|
141
181
|
handleSessionQueryMessage(session, deliver, msg);
|
|
142
182
|
return;
|
|
@@ -250,12 +290,16 @@ function handleSessionControlMessage(
|
|
|
250
290
|
undefined,
|
|
251
291
|
Object.keys(submitOptions).length > 0 ? submitOptions : undefined,
|
|
252
292
|
)
|
|
253
|
-
.
|
|
254
|
-
|
|
255
|
-
|
|
293
|
+
.then(
|
|
294
|
+
// #3189: a prompt submitted during a turn resolves once it is queued. A `get-pending` sent
|
|
295
|
+
// beside the `submit` would be answered before that, so the queue is reported from here.
|
|
296
|
+
() => deliver(pendingFrame(session)),
|
|
297
|
+
(error: Error) => deliver({ type: 'protocol_error', message: error.message }),
|
|
298
|
+
);
|
|
256
299
|
} else if (msg.type === 'command') {
|
|
300
|
+
const request = msg.requestId !== undefined ? { requestId: msg.requestId } : {};
|
|
257
301
|
if (!msg.name) {
|
|
258
|
-
deliver({ type: 'protocol_error', message: 'name is required' });
|
|
302
|
+
deliver({ type: 'protocol_error', message: 'name is required', ...request });
|
|
259
303
|
return;
|
|
260
304
|
}
|
|
261
305
|
// REMOTE-003: a transport-origin command is tagged `'remote'` (optional policy, allow-by-default;
|
|
@@ -268,10 +312,11 @@ function handleSessionControlMessage(
|
|
|
268
312
|
message: result?.message ?? `Unknown command: ${msg.name}`,
|
|
269
313
|
success: result?.success ?? false,
|
|
270
314
|
data: result?.data,
|
|
315
|
+
...request,
|
|
271
316
|
});
|
|
272
317
|
},
|
|
273
318
|
(error: Error) => {
|
|
274
|
-
deliver({ type: 'protocol_error', message: error.message });
|
|
319
|
+
deliver({ type: 'protocol_error', message: error.message, ...request });
|
|
275
320
|
},
|
|
276
321
|
);
|
|
277
322
|
} else if (msg.type === 'abort') {
|
|
@@ -1,22 +1,47 @@
|
|
|
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
|
+
| 'get-executing'
|
|
18
|
+
| 'get-pending'
|
|
19
|
+
| 'get-execution-workspace'
|
|
20
|
+
| 'read-execution-detail';
|
|
10
21
|
}
|
|
11
22
|
>;
|
|
12
23
|
|
|
24
|
+
/**
|
|
25
|
+
* #3189: the most serialized history one `history` frame carries. Well under the tightest outbound
|
|
26
|
+
* budget (an attached terminal's, 1 MiB), so a client reading page after page is never mistaken for
|
|
27
|
+
* one that stopped reading.
|
|
28
|
+
*/
|
|
29
|
+
export const HISTORY_PAGE_MAX_BYTES = 256 * 1024;
|
|
30
|
+
|
|
31
|
+
const utf8 = new TextEncoder();
|
|
32
|
+
|
|
13
33
|
export function isSessionQueryMessage(msg: TClientMessage): msg is TSessionQueryMessage {
|
|
14
34
|
return (
|
|
15
35
|
msg.type === 'get-messages' ||
|
|
36
|
+
msg.type === 'get-history' ||
|
|
37
|
+
msg.type === 'get-prompts' ||
|
|
16
38
|
msg.type === 'get-context' ||
|
|
39
|
+
msg.type === 'get-commands' ||
|
|
40
|
+
msg.type === 'get-status' ||
|
|
17
41
|
msg.type === 'get-executing' ||
|
|
18
42
|
msg.type === 'get-pending' ||
|
|
19
|
-
msg.type === 'get-execution-workspace'
|
|
43
|
+
msg.type === 'get-execution-workspace' ||
|
|
44
|
+
msg.type === 'read-execution-detail'
|
|
20
45
|
);
|
|
21
46
|
}
|
|
22
47
|
|
|
@@ -27,8 +52,16 @@ export function handleSessionQueryMessage(
|
|
|
27
52
|
): void {
|
|
28
53
|
if (msg.type === 'get-messages') {
|
|
29
54
|
deliver({ type: 'messages', messages: session.getMessages() });
|
|
55
|
+
} else if (msg.type === 'get-history') {
|
|
56
|
+
deliver(historyPage(session, msg.fromIndex ?? 0));
|
|
57
|
+
} else if (msg.type === 'get-prompts') {
|
|
58
|
+
for (const frame of listOpenPrompts(session)) deliver(frame);
|
|
30
59
|
} else if (msg.type === 'get-context') {
|
|
31
60
|
deliver({ type: 'context', state: session.getContextState() });
|
|
61
|
+
} else if (msg.type === 'get-commands') {
|
|
62
|
+
deliver({ type: 'commands', commands: session.listCommands(), skills: session.listSkills() });
|
|
63
|
+
} else if (msg.type === 'get-status') {
|
|
64
|
+
deliver({ type: 'session_status', status: session.getStatusSnapshot() });
|
|
32
65
|
} else if (msg.type === 'get-executing') {
|
|
33
66
|
deliver({ type: 'executing', executing: session.isExecuting() });
|
|
34
67
|
} else if (msg.type === 'get-execution-workspace') {
|
|
@@ -36,7 +69,75 @@ export function handleSessionQueryMessage(
|
|
|
36
69
|
type: 'execution_workspace_event',
|
|
37
70
|
snapshot: session.getExecutionWorkspaceSnapshot(),
|
|
38
71
|
});
|
|
72
|
+
} else if (msg.type === 'read-execution-detail') {
|
|
73
|
+
readExecutionDetail(session, deliver, msg);
|
|
39
74
|
} else {
|
|
40
|
-
deliver(
|
|
75
|
+
deliver(pendingFrame(session));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One page of an execution-workspace entry's detail, answered under the request's id. */
|
|
80
|
+
function readExecutionDetail(
|
|
81
|
+
session: IProtocolSession,
|
|
82
|
+
deliver: TOutboundDeliver,
|
|
83
|
+
msg: Extract<TClientMessage, { type: 'read-execution-detail' }>,
|
|
84
|
+
): void {
|
|
85
|
+
const { requestId } = msg;
|
|
86
|
+
session.readExecutionWorkspaceDetail(msg.entryId, msg.cursor).then(
|
|
87
|
+
(page) => deliver({ type: 'execution_detail', requestId, page }),
|
|
88
|
+
(error: unknown) =>
|
|
89
|
+
deliver({
|
|
90
|
+
type: 'execution_detail_error',
|
|
91
|
+
requestId,
|
|
92
|
+
message: error instanceof Error ? error.message : String(error),
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The next queued prompt and how many wait, as the session reports them now. */
|
|
98
|
+
export function pendingFrame(
|
|
99
|
+
session: IProtocolSession,
|
|
100
|
+
): Extract<TServerMessage, { type: 'pending' }> {
|
|
101
|
+
return {
|
|
102
|
+
type: 'pending',
|
|
103
|
+
pending: session.getPendingPrompt(),
|
|
104
|
+
pendingCount: session.getPendingCount(),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The history from `fromIndex`, as many entries as fit {@link HISTORY_PAGE_MAX_BYTES}. At least one
|
|
110
|
+
* entry is sent while any remain, so a client always moves forward, even past one larger entry.
|
|
111
|
+
*/
|
|
112
|
+
function historyPage(
|
|
113
|
+
session: IProtocolSession,
|
|
114
|
+
fromIndex: number,
|
|
115
|
+
): Extract<TServerMessage, { type: 'history' }> {
|
|
116
|
+
const history = session.getFullHistory();
|
|
117
|
+
const total = history.length;
|
|
118
|
+
const startIndex = Math.min(fromIndex, total);
|
|
119
|
+
const entries: IWireHistoryEntry[] = [];
|
|
120
|
+
let bytes = 0;
|
|
121
|
+
for (let index = startIndex; index < total; index += 1) {
|
|
122
|
+
const entry = toWireHistoryEntry(history[index]!);
|
|
123
|
+
const size = utf8.encode(JSON.stringify(entry)).byteLength;
|
|
124
|
+
if (entries.length > 0 && bytes + size > HISTORY_PAGE_MAX_BYTES) break;
|
|
125
|
+
entries.push(entry);
|
|
126
|
+
bytes += size;
|
|
41
127
|
}
|
|
128
|
+
return { type: 'history', startIndex, total, entries };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function toWireHistoryEntry(
|
|
132
|
+
entry: ReturnType<IProtocolSession['getFullHistory']>[number],
|
|
133
|
+
): IWireHistoryEntry {
|
|
134
|
+
return { ...entry, timestamp: toIsoTimestamp(entry.timestamp) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The declared type is `Date`, but a resumed session's entries were read back from JSON unrevived, so
|
|
139
|
+
* their `timestamp` is already the ISO string the store wrote. Either way the wire carries that string.
|
|
140
|
+
*/
|
|
141
|
+
function toIsoTimestamp(timestamp: Date | string): string {
|
|
142
|
+
return typeof timestamp === 'string' ? timestamp : timestamp.toISOString();
|
|
42
143
|
}
|