@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +752 -0
  2. package/dist/browser/client.d.ts +2 -2
  3. package/dist/browser/client.js +1 -1
  4. package/dist/browser/index.d.ts +31 -11
  5. package/dist/browser/index.d.ts.map +1 -1
  6. package/dist/browser/index.js +1 -1
  7. package/dist/browser/index.js.map +1 -1
  8. package/dist/browser/observer-messages-Bx-xReUQ.js +2 -0
  9. package/dist/browser/observer-messages-Bx-xReUQ.js.map +1 -0
  10. package/dist/browser/{message-decoders-BB8n-H2X.d.ts → observer-messages-DC_8VHk9.d.ts} +104 -7
  11. package/dist/browser/observer-messages-DC_8VHk9.d.ts.map +1 -0
  12. package/dist/node/index.cjs +1 -1
  13. package/dist/node/index.d.cts +131 -14
  14. package/dist/node/index.d.cts.map +1 -1
  15. package/dist/node/index.d.ts +131 -14
  16. package/dist/node/index.d.ts.map +1 -1
  17. package/dist/node/index.js +1 -1
  18. package/dist/node/index.js.map +1 -1
  19. package/dist/node/node.cjs +1 -1
  20. package/dist/node/node.d.cts +69 -1
  21. package/dist/node/node.d.cts.map +1 -1
  22. package/dist/node/node.d.ts +70 -2
  23. package/dist/node/node.d.ts.map +1 -1
  24. package/dist/node/node.js +1 -1
  25. package/dist/node/node.js.map +1 -1
  26. package/package.json +13 -11
  27. package/src/__tests__/bearer-resource-gate.test.ts +120 -0
  28. package/src/__tests__/history-attached-budget.test.ts +216 -0
  29. package/src/__tests__/message-decoders.test.ts +201 -5
  30. package/src/__tests__/session-detail-loop-status.test.ts +169 -0
  31. package/src/__tests__/session-directory-messages.test.ts +192 -0
  32. package/src/__tests__/session-history-wire.test.ts +342 -0
  33. package/src/__tests__/session-message-handler-observe.test.ts +193 -0
  34. package/src/__tests__/session-message-handler.test.ts +82 -1
  35. package/src/__tests__/ws-broadcast-events.test.ts +10 -0
  36. package/src/client.ts +9 -1
  37. package/src/index.ts +14 -2
  38. package/src/loop-control-messages.ts +34 -0
  39. package/src/message-decoders.ts +82 -4
  40. package/src/node/bearer-resource-gate.ts +291 -0
  41. package/src/node/index.ts +13 -0
  42. package/src/observer-messages.ts +36 -0
  43. package/src/open-prompts.ts +76 -0
  44. package/src/outbound-delivery.ts +11 -2
  45. package/src/protocol-session.ts +7 -1
  46. package/src/session-directory-messages.ts +85 -0
  47. package/src/session-events.ts +93 -18
  48. package/src/session-message-handler.ts +53 -8
  49. package/src/session-query-messages.ts +105 -4
  50. package/src/wire-messages.ts +105 -7
  51. package/dist/browser/message-decoders-BB8n-H2X.d.ts.map +0 -1
  52. package/dist/browser/message-decoders-CDmDoZPR.js +0 -2
  53. package/dist/browser/message-decoders-CDmDoZPR.js.map +0 -1
@@ -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 { TDriverId, TInteractiveEventName } from '@robota-sdk/agent-interface-session';
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: 'non-surface',
41
- compact: 'non-surface',
49
+ context_update: 'forwarded',
50
+ compact: 'forwarded',
42
51
  interrupted: 'forwarded',
43
- skill_activation: 'non-surface',
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: 'non-surface',
57
+ turn_source: 'forwarded',
49
58
  context_file_refreshed: 'forwarded',
50
- memory_event: 'non-surface',
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
- // which is identical for every forwarded event (asserted in `session-event-delivery.test.ts`). Keeping
93
- // the argument would have been a parameter that documents nothing and is read by nobody.
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
- const onPermissionRequest = (event: IPermissionRequestEvent): void =>
133
- deliver({ type: 'permission_request', event });
134
- const onAskRequest = (event: IAskRequestEvent): void => deliver({ type: 'ask_request', event });
135
- const onPromptResolved = (event: IPromptResolvedEvent): void =>
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
- session.on('permission_request', onPermissionRequest);
170
- session.on('ask_request', onAskRequest);
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
- session.off('permission_request', onPermissionRequest);
192
- session.off('ask_request', onAskRequest);
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 { handleSessionQueryMessage, isSessionQueryMessage } from './session-query-messages.js';
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
- handleClientMessage(session, deliver, msg, driverId, reporters, surface);
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
- .catch((error: Error) => {
254
- deliver({ type: 'protocol_error', message: error.message });
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
- 'get-messages' | 'get-context' | 'get-executing' | 'get-pending' | 'get-execution-workspace';
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({ type: 'pending', pending: session.getPendingPrompt() });
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
  }