@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
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read-only `observe` role: an observer reads the session's own conversation and state, never
|
|
3
|
+
* counts as a surface that can answer a prompt, and cannot submit, answer, or control anything.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
7
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
ATTACHED_SURFACE_MAX_PENDING_BYTES,
|
|
11
|
+
createOutboundDelivery,
|
|
12
|
+
} from '../outbound-delivery.js';
|
|
13
|
+
import { createSessionMessageHandler } from '../session-message-handler.js';
|
|
14
|
+
|
|
15
|
+
import type { TClientMessage, TServerMessage } from '../wire-messages.js';
|
|
16
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
17
|
+
|
|
18
|
+
type TListener = (...args: unknown[]) => void;
|
|
19
|
+
|
|
20
|
+
function createSession(): IInteractiveSession & {
|
|
21
|
+
emit: (event: string, ...args: unknown[]) => void;
|
|
22
|
+
listenerCount: (event: string) => number;
|
|
23
|
+
} {
|
|
24
|
+
const listeners = new Map<string, Set<TListener>>();
|
|
25
|
+
const session = createTestInteractiveSession({
|
|
26
|
+
submit: vi.fn(),
|
|
27
|
+
abort: vi.fn(),
|
|
28
|
+
cancelQueue: vi.fn(),
|
|
29
|
+
executeCommand: vi.fn(),
|
|
30
|
+
resolvePermission: vi.fn(),
|
|
31
|
+
resolveAsk: vi.fn(),
|
|
32
|
+
cancelBackgroundTask: vi.fn(),
|
|
33
|
+
closeBackgroundTask: vi.fn(),
|
|
34
|
+
sendBackgroundTask: vi.fn(),
|
|
35
|
+
getMessages: vi.fn().mockReturnValue([{ role: 'user', content: 'hi' }]),
|
|
36
|
+
isExecuting: vi.fn().mockReturnValue(true),
|
|
37
|
+
on: ((event: string, handler: TListener) => {
|
|
38
|
+
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
39
|
+
listeners.get(event)!.add(handler);
|
|
40
|
+
}) as IInteractiveSession['on'],
|
|
41
|
+
off: ((event: string, handler: TListener) => {
|
|
42
|
+
listeners.get(event)?.delete(handler);
|
|
43
|
+
}) as IInteractiveSession['off'],
|
|
44
|
+
});
|
|
45
|
+
return Object.assign(session, {
|
|
46
|
+
emit: (event: string, ...args: unknown[]) => {
|
|
47
|
+
listeners.get(event)?.forEach((handler) => handler(...args));
|
|
48
|
+
},
|
|
49
|
+
listenerCount: (event: string) => listeners.get(event)?.size ?? 0,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function attach(
|
|
54
|
+
session: IInteractiveSession,
|
|
55
|
+
role?: 'drive' | 'observe',
|
|
56
|
+
): { sent: TServerMessage[]; send: (message: TClientMessage) => void; cleanup: () => void } {
|
|
57
|
+
const sent: TServerMessage[] = [];
|
|
58
|
+
const { onMessage, cleanup } = createSessionMessageHandler({
|
|
59
|
+
session,
|
|
60
|
+
deliver: createOutboundDelivery((message) => sent.push(message), vi.fn()),
|
|
61
|
+
driverId: role === 'observe' ? 'attach:2' : 'attach:1',
|
|
62
|
+
...(role === undefined ? {} : { role }),
|
|
63
|
+
});
|
|
64
|
+
return { sent, send: (message) => onMessage(JSON.stringify(message)), cleanup };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const MUTATING: readonly TClientMessage[] = [
|
|
68
|
+
{ type: 'submit', prompt: 'hello' },
|
|
69
|
+
{ type: 'command', name: 'exit' },
|
|
70
|
+
{ type: 'abort' },
|
|
71
|
+
{ type: 'cancel-queue' },
|
|
72
|
+
{ type: 'cancel-background-task', taskId: 't1' },
|
|
73
|
+
{ type: 'close-background-task', taskId: 't1' },
|
|
74
|
+
{ type: 'send-background-task', taskId: 't1', input: { prompt: 'x' } },
|
|
75
|
+
{ type: 'permission-response', id: 'p1', result: true },
|
|
76
|
+
{ type: 'ask-response', id: 'a1', response: { type: 'answer', values: ['React'] } } as TClientMessage,
|
|
77
|
+
{ type: 'get-personal-usage-report', requestId: 'r1', period: '7d', timezone: 'UTC' },
|
|
78
|
+
{ type: 'get-stored-session-usage-report', requestId: 'r2', sessionId: 'other' },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
describe('observe role', () => {
|
|
82
|
+
it('refuses every mutating or cross-session message with protocol_error and touches nothing', () => {
|
|
83
|
+
const session = createSession();
|
|
84
|
+
const observer = attach(session, 'observe');
|
|
85
|
+
for (const message of MUTATING) {
|
|
86
|
+
observer.sent.length = 0;
|
|
87
|
+
observer.send(message);
|
|
88
|
+
expect(observer.sent).toEqual([
|
|
89
|
+
{ type: 'protocol_error', message: `Not permitted for an observer: ${message.type}` },
|
|
90
|
+
]);
|
|
91
|
+
}
|
|
92
|
+
expect(session.submit).not.toHaveBeenCalled();
|
|
93
|
+
expect(session.executeCommand).not.toHaveBeenCalled();
|
|
94
|
+
expect(session.abort).not.toHaveBeenCalled();
|
|
95
|
+
expect(session.cancelQueue).not.toHaveBeenCalled();
|
|
96
|
+
expect(session.cancelBackgroundTask).not.toHaveBeenCalled();
|
|
97
|
+
expect(session.closeBackgroundTask).not.toHaveBeenCalled();
|
|
98
|
+
expect(session.sendBackgroundTask).not.toHaveBeenCalled();
|
|
99
|
+
expect(session.resolvePermission).not.toHaveBeenCalled();
|
|
100
|
+
expect(session.resolveAsk).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('keeps the read queries for this session', () => {
|
|
104
|
+
const session = createSession();
|
|
105
|
+
const observer = attach(session, 'observe');
|
|
106
|
+
observer.send({ type: 'get-messages' });
|
|
107
|
+
observer.send({ type: 'get-executing' });
|
|
108
|
+
expect(observer.sent).toEqual([
|
|
109
|
+
{ type: 'messages', messages: [{ role: 'user', content: 'hi' }] },
|
|
110
|
+
{ type: 'executing', executing: true },
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('#3186: reads the session status and the command catalog, which every client shows', () => {
|
|
115
|
+
const session = createSession();
|
|
116
|
+
const observer = attach(session, 'observe');
|
|
117
|
+
observer.send({ type: 'get-status' });
|
|
118
|
+
observer.send({ type: 'get-commands' });
|
|
119
|
+
expect(observer.sent).toEqual([
|
|
120
|
+
{ type: 'session_status', status: session.getStatusSnapshot() },
|
|
121
|
+
{ type: 'commands', commands: [], skills: [] },
|
|
122
|
+
]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('never subscribes to prompt events, so it does not count as a surface that can answer', () => {
|
|
126
|
+
const session = createSession();
|
|
127
|
+
const observer = attach(session, 'observe');
|
|
128
|
+
expect(session.listenerCount('permission_request')).toBe(0);
|
|
129
|
+
expect(session.listenerCount('ask_request')).toBe(0);
|
|
130
|
+
expect(session.listenerCount('prompt_resolved')).toBe(1);
|
|
131
|
+
expect(session.listenerCount('text_delta')).toBe(1);
|
|
132
|
+
session.emit('permission_request', { id: 'p1', toolName: 'Bash', toolArgs: {} });
|
|
133
|
+
session.emit('ask_request', { id: 'a1', request: { id: 'r', title: 'Pick' } });
|
|
134
|
+
session.emit('prompt_resolved', { id: 'p1' });
|
|
135
|
+
expect(observer.sent).toEqual([{ type: 'prompt_resolved', event: { id: 'p1' } }]);
|
|
136
|
+
observer.cleanup();
|
|
137
|
+
expect(session.listenerCount('prompt_resolved')).toBe(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('cannot answer a prompt that a drive surface holds', () => {
|
|
141
|
+
const session = createSession();
|
|
142
|
+
const drive = attach(session, 'drive');
|
|
143
|
+
const observer = attach(session, 'observe');
|
|
144
|
+
session.emit('permission_request', { id: 'p1', toolName: 'Bash', toolArgs: {} });
|
|
145
|
+
expect(drive.sent).toContainEqual({
|
|
146
|
+
type: 'permission_request',
|
|
147
|
+
event: { id: 'p1', toolName: 'Bash', toolArgs: {} },
|
|
148
|
+
});
|
|
149
|
+
observer.send({ type: 'permission-response', id: 'p1', result: true });
|
|
150
|
+
expect(session.resolvePermission).not.toHaveBeenCalled();
|
|
151
|
+
drive.send({ type: 'permission-response', id: 'p1', result: false });
|
|
152
|
+
expect(session.resolvePermission).toHaveBeenCalledExactlyOnceWith('p1', false, 'attach:1');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('leaves the drive role unchanged', () => {
|
|
156
|
+
const session = createSession();
|
|
157
|
+
const drive = attach(session);
|
|
158
|
+
expect(session.listenerCount('permission_request')).toBe(1);
|
|
159
|
+
expect(session.listenerCount('ask_request')).toBe(1);
|
|
160
|
+
drive.send({ type: 'abort' });
|
|
161
|
+
drive.send({ type: 'permission-response', id: 'p1', result: true });
|
|
162
|
+
expect(session.abort).toHaveBeenCalledTimes(1);
|
|
163
|
+
expect(session.resolvePermission).toHaveBeenCalledExactlyOnceWith('p1', true, 'attach:1');
|
|
164
|
+
expect(drive.sent).toEqual([]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('disconnects an observer that stops reading while the session keeps streaming to others', () => {
|
|
168
|
+
const session = createSession();
|
|
169
|
+
const drive = attach(session, 'drive');
|
|
170
|
+
let pending = 0;
|
|
171
|
+
const onDeliveryError = vi.fn();
|
|
172
|
+
const received: TServerMessage[] = [];
|
|
173
|
+
createSessionMessageHandler({
|
|
174
|
+
session,
|
|
175
|
+
role: 'observe',
|
|
176
|
+
deliver: createOutboundDelivery(
|
|
177
|
+
(message) => {
|
|
178
|
+
received.push(message);
|
|
179
|
+
pending += 64 * 1024;
|
|
180
|
+
},
|
|
181
|
+
onDeliveryError,
|
|
182
|
+
() => pending,
|
|
183
|
+
ATTACHED_SURFACE_MAX_PENDING_BYTES,
|
|
184
|
+
),
|
|
185
|
+
});
|
|
186
|
+
for (let index = 0; index < 64; index++) {
|
|
187
|
+
expect(() => session.emit('text_delta', `delta ${index}`)).not.toThrow();
|
|
188
|
+
}
|
|
189
|
+
expect(onDeliveryError).toHaveBeenCalledTimes(1);
|
|
190
|
+
expect(received.length).toBeLessThan(64);
|
|
191
|
+
expect(drive.sent.filter((message) => message.type === 'text_delta')).toHaveLength(64);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -10,6 +10,7 @@ import { createSessionMessageHandler } from '../session-message-handler.js';
|
|
|
10
10
|
import { PROTOCOL_SESSION_EVENT_CLASSIFICATION } from '../session-events.js';
|
|
11
11
|
import type { TServerMessage } from '../wire-messages.js';
|
|
12
12
|
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
13
|
+
import type { ICommandListEntry } from '@robota-sdk/agent-interface-command';
|
|
13
14
|
import type {
|
|
14
15
|
IBackgroundJobGroupState,
|
|
15
16
|
IExecutionWorkspaceEvent,
|
|
@@ -157,6 +158,35 @@ describe('WebSocket Transport Handler', () => {
|
|
|
157
158
|
expect(sent[0]!.type).toBe('context');
|
|
158
159
|
});
|
|
159
160
|
|
|
161
|
+
it('#3186: get-commands sends the commands and skills a client can offer', () => {
|
|
162
|
+
const { onMessage, session, sent } = setup();
|
|
163
|
+
const command: ICommandListEntry = {
|
|
164
|
+
name: 'help',
|
|
165
|
+
description: 'Show commands',
|
|
166
|
+
modelInvocable: false,
|
|
167
|
+
runner: 'runtime',
|
|
168
|
+
};
|
|
169
|
+
const skill = {
|
|
170
|
+
name: 'parity-demo',
|
|
171
|
+
description: 'Demo',
|
|
172
|
+
source: 'project',
|
|
173
|
+
modelInvocable: true,
|
|
174
|
+
userInvocable: true,
|
|
175
|
+
};
|
|
176
|
+
Object.assign(session, {
|
|
177
|
+
listCommands: vi.fn().mockReturnValue([command]),
|
|
178
|
+
listSkills: vi.fn().mockReturnValue([skill]),
|
|
179
|
+
});
|
|
180
|
+
onMessage(JSON.stringify({ type: 'get-commands' }));
|
|
181
|
+
expect(sent).toEqual([{ type: 'commands', commands: [command], skills: [skill] }]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('#3186: get-status sends the session status snapshot', () => {
|
|
185
|
+
const { onMessage, session, sent } = setup();
|
|
186
|
+
onMessage(JSON.stringify({ type: 'get-status' }));
|
|
187
|
+
expect(sent).toEqual([{ type: 'session_status', status: session.getStatusSnapshot() }]);
|
|
188
|
+
});
|
|
189
|
+
|
|
160
190
|
it('get-executing sends executing status', () => {
|
|
161
191
|
const { onMessage, sent } = setup();
|
|
162
192
|
onMessage(JSON.stringify({ type: 'get-executing' }));
|
|
@@ -166,7 +196,35 @@ describe('WebSocket Transport Handler', () => {
|
|
|
166
196
|
it('get-pending sends pending prompt', () => {
|
|
167
197
|
const { onMessage, sent } = setup();
|
|
168
198
|
onMessage(JSON.stringify({ type: 'get-pending' }));
|
|
169
|
-
expect(sent[0]).toEqual({ type: 'pending', pending: null });
|
|
199
|
+
expect(sent[0]).toEqual({ type: 'pending', pending: null, pendingCount: 0 });
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('#3189: answers a submit with the queue once the prompt is taken, so a queued prompt shows', async () => {
|
|
203
|
+
const { onMessage, sent, session } = setup();
|
|
204
|
+
const mock = session as unknown as {
|
|
205
|
+
submit: ReturnType<typeof vi.fn>;
|
|
206
|
+
getPendingPrompt: ReturnType<typeof vi.fn>;
|
|
207
|
+
getPendingCount: ReturnType<typeof vi.fn>;
|
|
208
|
+
};
|
|
209
|
+
let queue: () => void = () => undefined;
|
|
210
|
+
// A turn is running: the session queues the prompt only after its own initialization yields.
|
|
211
|
+
mock.submit.mockImplementationOnce(
|
|
212
|
+
() =>
|
|
213
|
+
new Promise<void>((resolve) => {
|
|
214
|
+
queue = () => {
|
|
215
|
+
mock.getPendingPrompt.mockReturnValue('follow-up');
|
|
216
|
+
mock.getPendingCount.mockReturnValue(1);
|
|
217
|
+
resolve();
|
|
218
|
+
};
|
|
219
|
+
}),
|
|
220
|
+
);
|
|
221
|
+
onMessage(JSON.stringify({ type: 'submit', prompt: 'follow-up' }));
|
|
222
|
+
// A `get-pending` sent beside the submit is answered before the prompt is queued.
|
|
223
|
+
onMessage(JSON.stringify({ type: 'get-pending' }));
|
|
224
|
+
expect(sent).toEqual([{ type: 'pending', pending: null, pendingCount: 0 }]);
|
|
225
|
+
queue();
|
|
226
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
227
|
+
expect(sent.at(-1)).toEqual({ type: 'pending', pending: 'follow-up', pendingCount: 1 });
|
|
170
228
|
});
|
|
171
229
|
|
|
172
230
|
it('get-background-tasks sends current background task list', () => {
|
|
@@ -447,6 +505,29 @@ describe('WebSocket Transport Handler', () => {
|
|
|
447
505
|
expect(sent).toEqual([{ type: 'protocol_error', message: 'command failed' }]);
|
|
448
506
|
});
|
|
449
507
|
|
|
508
|
+
it("#3189: echoes a command's requestId on its result and on its failure, and on nothing else", async () => {
|
|
509
|
+
const { onMessage, sent, session } = setup();
|
|
510
|
+
const { executeCommand, submit } = session as unknown as {
|
|
511
|
+
executeCommand: ReturnType<typeof vi.fn>;
|
|
512
|
+
submit: ReturnType<typeof vi.fn>;
|
|
513
|
+
};
|
|
514
|
+
executeCommand.mockResolvedValueOnce({ message: 'done', success: true });
|
|
515
|
+
executeCommand.mockRejectedValueOnce(new Error('command failed'));
|
|
516
|
+
submit.mockRejectedValueOnce(new Error('A turn is running.'));
|
|
517
|
+
onMessage(JSON.stringify({ type: 'command', name: 'clear', requestId: 'c1' }));
|
|
518
|
+
onMessage(JSON.stringify({ type: 'command', name: 'broken', requestId: 'c2' }));
|
|
519
|
+
onMessage(JSON.stringify({ type: 'submit', prompt: 'hello' }));
|
|
520
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
521
|
+
expect(sent).toEqual(
|
|
522
|
+
expect.arrayContaining([
|
|
523
|
+
expect.objectContaining({ type: 'command_result', name: 'clear', requestId: 'c1' }),
|
|
524
|
+
{ type: 'protocol_error', message: 'command failed', requestId: 'c2' },
|
|
525
|
+
{ type: 'protocol_error', message: 'A turn is running.' },
|
|
526
|
+
]),
|
|
527
|
+
);
|
|
528
|
+
expect(sent).toHaveLength(3);
|
|
529
|
+
});
|
|
530
|
+
|
|
450
531
|
it('forwards InteractiveSession events to client', () => {
|
|
451
532
|
const { session, sent } = setup();
|
|
452
533
|
(session as unknown as { _emit: (e: string, ...args: unknown[]) => void })._emit(
|
|
@@ -81,4 +81,14 @@ describe('CMD-004 Stage E — session_renamed / history_cleared broadcast to EVE
|
|
|
81
81
|
expect(sentA).toEqual([{ type: 'history_cleared' }]);
|
|
82
82
|
expect(sentB).toEqual([{ type: 'history_cleared' }]);
|
|
83
83
|
});
|
|
84
|
+
|
|
85
|
+
it('#3189: session_switched reaches both attached surfaces (each re-reads the new session)', () => {
|
|
86
|
+
const { session, sentA, sentB } = setupTwoSurfaces();
|
|
87
|
+
|
|
88
|
+
session._emit('session_switched', { sessionId: 'session-2' });
|
|
89
|
+
|
|
90
|
+
const expected = { type: 'session_switched', event: { sessionId: 'session-2' } };
|
|
91
|
+
expect(sentA).toEqual([expected]);
|
|
92
|
+
expect(sentB).toEqual([expected]);
|
|
93
|
+
});
|
|
84
94
|
});
|
package/src/client.ts
CHANGED
|
@@ -6,4 +6,12 @@ export {
|
|
|
6
6
|
decodeServerMessage,
|
|
7
7
|
} from './message-decoders.js';
|
|
8
8
|
export type { TMessageDecodeResult } from './message-decoders.js';
|
|
9
|
-
|
|
9
|
+
// The one list of messages an observer may send: the host refuses the rest, a client sends no others.
|
|
10
|
+
export { isObserverMessageType } from './observer-messages.js';
|
|
11
|
+
export type {
|
|
12
|
+
IWireHistoryEntry,
|
|
13
|
+
TClientMessage,
|
|
14
|
+
TServerMessage,
|
|
15
|
+
TSeqServerMessage,
|
|
16
|
+
TWireExecutionResult,
|
|
17
|
+
} from './wire-messages.js';
|
package/src/index.ts
CHANGED
|
@@ -33,9 +33,13 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
export { createSessionMessageHandler } from './session-message-handler.js';
|
|
36
|
-
export type {
|
|
36
|
+
export type {
|
|
37
|
+
ISessionMessageHandlerOptions,
|
|
38
|
+
TSessionSurfaceRole,
|
|
39
|
+
} from './session-message-handler.js';
|
|
37
40
|
// ARCH-030: the connection-scoped outbound delivery boundary every carrier builds and passes down.
|
|
38
41
|
export {
|
|
42
|
+
ATTACHED_SURFACE_MAX_PENDING_BYTES,
|
|
39
43
|
createOutboundDelivery,
|
|
40
44
|
createPendingStallClock,
|
|
41
45
|
isOverPendingBudget,
|
|
@@ -48,9 +52,17 @@ export type {
|
|
|
48
52
|
TOutboundDeliver,
|
|
49
53
|
} from './outbound-delivery.js';
|
|
50
54
|
export { PROTOCOL_SESSION_EVENT_CLASSIFICATION } from './session-events.js';
|
|
55
|
+
// The one list of messages an observer may send: the host refuses the rest, a client sends no others.
|
|
56
|
+
export { isObserverMessageType } from './observer-messages.js';
|
|
51
57
|
export type { TProtocolSessionEventClassification } from './session-events.js';
|
|
52
58
|
export type { IProtocolSession } from './protocol-session.js';
|
|
53
|
-
export type {
|
|
59
|
+
export type {
|
|
60
|
+
IWireHistoryEntry,
|
|
61
|
+
TClientMessage,
|
|
62
|
+
TServerMessage,
|
|
63
|
+
TSeqServerMessage,
|
|
64
|
+
TWireExecutionResult,
|
|
65
|
+
} from './wire-messages.js';
|
|
54
66
|
// Issue #2045: the owner-side runtime decoders; carriers implement `raw → decodeFrame → typed`.
|
|
55
67
|
export {
|
|
56
68
|
MAX_INBOUND_FRAME_BYTES,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { TOutboundDeliver } from './outbound-delivery.js';
|
|
2
|
+
import type { IProtocolSession } from './protocol-session.js';
|
|
3
|
+
import type { TClientMessage } from './wire-messages.js';
|
|
4
|
+
|
|
5
|
+
type TLoopControlMessage = Extract<TClientMessage, { type: 'stop-waiting-loop' }>;
|
|
6
|
+
|
|
7
|
+
export function isLoopControlMessage(msg: TClientMessage): msg is TLoopControlMessage {
|
|
8
|
+
return msg.type === 'stop-waiting-loop';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Stop the self-paced loop that is waiting for its next wake. The session decides which loop, if
|
|
13
|
+
* any; the reply carries its outcome, and a session that throws is reported as a failed stop.
|
|
14
|
+
*/
|
|
15
|
+
export function handleLoopControlMessage(
|
|
16
|
+
session: IProtocolSession,
|
|
17
|
+
deliver: TOutboundDeliver,
|
|
18
|
+
msg: TLoopControlMessage,
|
|
19
|
+
): void {
|
|
20
|
+
const { requestId } = msg;
|
|
21
|
+
session.stopWaitingSelfPacedLoop().then(
|
|
22
|
+
(outcome) => deliver({ type: 'waiting_loop_stop', requestId, outcome }),
|
|
23
|
+
(error: unknown) =>
|
|
24
|
+
deliver({
|
|
25
|
+
type: 'waiting_loop_stop',
|
|
26
|
+
requestId,
|
|
27
|
+
outcome: { kind: 'failed', message: errorMessage(error) },
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function errorMessage(error: unknown): string {
|
|
33
|
+
return error instanceof Error ? error.message : String(error);
|
|
34
|
+
}
|
package/src/message-decoders.ts
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* owner and is tracked there, not duplicated in a transport package.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { SESSION_CHANGE_REFUSAL_CODES } from '@robota-sdk/agent-interface-session';
|
|
18
|
+
|
|
17
19
|
import type { TBackgroundControlAction, TClientMessage, TServerMessage } from './wire-messages.js';
|
|
18
20
|
|
|
19
21
|
export type TMessageDecodeResult<TMessage> =
|
|
@@ -32,6 +34,7 @@ const isString = (v: unknown): v is string => typeof v === 'string';
|
|
|
32
34
|
const isNonEmptyString = (v: unknown): v is string => typeof v === 'string' && v.length > 0;
|
|
33
35
|
const isBoolean = (v: unknown): v is boolean => typeof v === 'boolean';
|
|
34
36
|
const isFiniteNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
|
|
37
|
+
const isCount = (v: unknown): v is number => Number.isSafeInteger(v) && (v as number) >= 0;
|
|
35
38
|
const isOptional =
|
|
36
39
|
(check: (v: unknown) => boolean) =>
|
|
37
40
|
(v: unknown): boolean =>
|
|
@@ -42,6 +45,19 @@ const oneOf =
|
|
|
42
45
|
(members: readonly string[]) =>
|
|
43
46
|
(v: unknown): boolean =>
|
|
44
47
|
typeof v === 'string' && members.includes(v);
|
|
48
|
+
/** A record, not an array, so a turn source added to the union without one here fails to compile. */
|
|
49
|
+
const TURN_SOURCES: Readonly<
|
|
50
|
+
Record<Extract<TServerMessage, { type: 'turn_source' }>['source'], true>
|
|
51
|
+
> = { user: true, 'agent-wakeup': true, peer: true, external: true };
|
|
52
|
+
/** A history entry's envelope is this package's (`IWireHistoryEntry`); its `data` is the recorder's. */
|
|
53
|
+
const isWireHistoryEntry = (v: unknown): boolean =>
|
|
54
|
+
isRecord(v) &&
|
|
55
|
+
isString(v['id']) &&
|
|
56
|
+
isString(v['timestamp']) &&
|
|
57
|
+
isString(v['category']) &&
|
|
58
|
+
isString(v['type']);
|
|
59
|
+
const isWireHistoryEntries = (v: unknown): boolean =>
|
|
60
|
+
Array.isArray(v) && v.every(isWireHistoryEntry);
|
|
45
61
|
|
|
46
62
|
type TFieldCheck = (value: unknown) => boolean;
|
|
47
63
|
type TVariantShape = Readonly<Record<string, TFieldCheck>>;
|
|
@@ -69,6 +85,7 @@ const isBackgroundTaskListFilter: TFieldCheck = (v) =>
|
|
|
69
85
|
const isBackgroundTaskInput: TFieldCheck = (v) =>
|
|
70
86
|
isRecord(v) && isOptional(isString)(v['prompt']) && isOptional(isString)(v['stdin']);
|
|
71
87
|
const isBackgroundTaskLogCursor: TFieldCheck = (v) => isRecord(v) && isFiniteNumber(v['offset']);
|
|
88
|
+
const isExecutionDetailCursor: TFieldCheck = (v) => isRecord(v) && isCount(v['offset']);
|
|
72
89
|
const isPermissionResultValue: TFieldCheck = (v) =>
|
|
73
90
|
isBoolean(v) || v === 'allow-session' || v === 'allow-project';
|
|
74
91
|
const isActionResponse: TFieldCheck = (v) =>
|
|
@@ -79,14 +96,43 @@ const isActionResponse: TFieldCheck = (v) =>
|
|
|
79
96
|
isOptional(isString)(v['text'])) ||
|
|
80
97
|
(v['type'] === 'cancelled' && v['values'] === undefined && v['text'] === undefined));
|
|
81
98
|
|
|
99
|
+
type TWaitingLoopStopOutcome = Extract<TServerMessage, { type: 'waiting_loop_stop' }>['outcome'];
|
|
100
|
+
/** Keyed by `kind`, so an outcome added to the union without a shape here fails to compile. */
|
|
101
|
+
const WAITING_LOOP_STOP_OUTCOME_SHAPES: Readonly<
|
|
102
|
+
Record<TWaitingLoopStopOutcome['kind'], TVariantShape>
|
|
103
|
+
> = {
|
|
104
|
+
none: {},
|
|
105
|
+
several: { message: isString },
|
|
106
|
+
stopped: { loopId: isNonEmptyString, message: isOptional(isString) },
|
|
107
|
+
failed: { loopId: isOptional(isNonEmptyString), message: isString },
|
|
108
|
+
};
|
|
109
|
+
const isWaitingLoopStopOutcome: TFieldCheck = (v) => {
|
|
110
|
+
if (!isRecord(v) || !isString(v['kind'])) return false;
|
|
111
|
+
const kind = v['kind'];
|
|
112
|
+
if (!Object.prototype.hasOwnProperty.call(WAITING_LOOP_STOP_OUTCOME_SHAPES, kind)) return false;
|
|
113
|
+
const shape = WAITING_LOOP_STOP_OUTCOME_SHAPES[kind as TWaitingLoopStopOutcome['kind']];
|
|
114
|
+
return Object.entries(shape).every(([field, check]) => check(v[field]));
|
|
115
|
+
};
|
|
116
|
+
|
|
82
117
|
/** One entry per `TClientMessage` variant — a variant added to the union without one fails the test. */
|
|
83
118
|
export const CLIENT_MESSAGE_SHAPES: Readonly<Record<TClientMessage['type'], TVariantShape>> = {
|
|
84
119
|
submit: { prompt: isNonEmptyString },
|
|
85
|
-
command: {
|
|
120
|
+
command: {
|
|
121
|
+
name: isNonEmptyString,
|
|
122
|
+
args: isOptional(isString),
|
|
123
|
+
requestId: isOptional(isNonEmptyString),
|
|
124
|
+
},
|
|
86
125
|
abort: {},
|
|
87
126
|
'cancel-queue': {},
|
|
88
127
|
'get-messages': {},
|
|
128
|
+
'get-history': { fromIndex: isOptional(isCount) },
|
|
129
|
+
'get-prompts': {},
|
|
89
130
|
'get-context': {},
|
|
131
|
+
'get-commands': {},
|
|
132
|
+
'get-status': {},
|
|
133
|
+
'list-sessions': { requestId: isNonEmptyString },
|
|
134
|
+
'new-session': { requestId: isOptional(isNonEmptyString) },
|
|
135
|
+
'switch-session': { sessionId: isNonEmptyString, requestId: isOptional(isNonEmptyString) },
|
|
90
136
|
'get-usage-report': {},
|
|
91
137
|
'get-personal-usage-report': {
|
|
92
138
|
requestId: isNonEmptyString,
|
|
@@ -100,6 +146,12 @@ export const CLIENT_MESSAGE_SHAPES: Readonly<Record<TClientMessage['type'], TVar
|
|
|
100
146
|
'get-executing': {},
|
|
101
147
|
'get-pending': {},
|
|
102
148
|
'get-execution-workspace': {},
|
|
149
|
+
'read-execution-detail': {
|
|
150
|
+
requestId: isNonEmptyString,
|
|
151
|
+
entryId: isNonEmptyString,
|
|
152
|
+
cursor: isOptional(isExecutionDetailCursor),
|
|
153
|
+
},
|
|
154
|
+
'stop-waiting-loop': { requestId: isNonEmptyString },
|
|
103
155
|
'get-background-tasks': { filter: isOptional(isBackgroundTaskListFilter) },
|
|
104
156
|
'get-background-task': { taskId: isNonEmptyString },
|
|
105
157
|
'get-background-job-groups': {},
|
|
@@ -130,9 +182,31 @@ export const SERVER_MESSAGE_SHAPES: Readonly<Record<TServerMessage['type'], TVar
|
|
|
130
182
|
complete: { result: isRecord, ...authored },
|
|
131
183
|
interrupted: { result: isRecord, ...authored },
|
|
132
184
|
error: { message: isString, ...authored },
|
|
133
|
-
command_result: {
|
|
185
|
+
command_result: {
|
|
186
|
+
name: isString,
|
|
187
|
+
message: isString,
|
|
188
|
+
success: isBoolean,
|
|
189
|
+
requestId: isOptional(isString),
|
|
190
|
+
},
|
|
134
191
|
messages: { messages: isRecordArray },
|
|
192
|
+
history: { startIndex: isCount, total: isCount, entries: isWireHistoryEntries },
|
|
135
193
|
context: { state: isRecord },
|
|
194
|
+
history_changed: {},
|
|
195
|
+
turn_source: { source: oneOf(Object.keys(TURN_SOURCES)) },
|
|
196
|
+
commands: { commands: isRecordArray, skills: isRecordArray },
|
|
197
|
+
session_status: { status: isRecord },
|
|
198
|
+
sessions: { requestId: isNonEmptyString, listing: isRecord },
|
|
199
|
+
sessions_error: {
|
|
200
|
+
requestId: isNonEmptyString,
|
|
201
|
+
code: oneOf(['not_available', 'list_failed']),
|
|
202
|
+
message: isString,
|
|
203
|
+
},
|
|
204
|
+
session_switched: { event: isRecord },
|
|
205
|
+
session_change_failed: {
|
|
206
|
+
code: oneOf(SESSION_CHANGE_REFUSAL_CODES),
|
|
207
|
+
message: isString,
|
|
208
|
+
requestId: isOptional(isString),
|
|
209
|
+
},
|
|
136
210
|
usage_report: { report: isRecord },
|
|
137
211
|
personal_usage_report: { requestId: isNonEmptyString, report: isRecord },
|
|
138
212
|
personal_usage_report_error: {
|
|
@@ -152,8 +226,12 @@ export const SERVER_MESSAGE_SHAPES: Readonly<Record<TServerMessage['type'], TVar
|
|
|
152
226
|
message: isString,
|
|
153
227
|
},
|
|
154
228
|
executing: { executing: isBoolean },
|
|
155
|
-
|
|
229
|
+
// An older host sends no count; a client then counts the one prompt it can see.
|
|
230
|
+
pending: { pending: (v) => v === null || isString(v), pendingCount: isOptional(isCount) },
|
|
156
231
|
execution_workspace_event: { snapshot: isRecord },
|
|
232
|
+
execution_detail: { requestId: isNonEmptyString, page: isRecord },
|
|
233
|
+
execution_detail_error: { requestId: isNonEmptyString, message: isString },
|
|
234
|
+
waiting_loop_stop: { requestId: isNonEmptyString, outcome: isWaitingLoopStopOutcome },
|
|
157
235
|
background_task_event: { event: isRecord },
|
|
158
236
|
background_job_group_event: { event: isRecord },
|
|
159
237
|
plan_event: { event: isRecord },
|
|
@@ -176,7 +254,7 @@ export const SERVER_MESSAGE_SHAPES: Readonly<Record<TServerMessage['type'], TVar
|
|
|
176
254
|
success: isBoolean,
|
|
177
255
|
message: isOptional(isString),
|
|
178
256
|
},
|
|
179
|
-
protocol_error: { message: isString },
|
|
257
|
+
protocol_error: { message: isString, requestId: isOptional(isString) },
|
|
180
258
|
resume_gap: {},
|
|
181
259
|
};
|
|
182
260
|
|