@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,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3189: a history larger than an attached terminal's backpressure budget reaches that terminal
|
|
3
|
+
* whole, over a real socket, without the connection being cut.
|
|
4
|
+
*
|
|
5
|
+
* The carrier here is wired as the supervised attach carrier is: every reply goes through
|
|
6
|
+
* `createOutboundDelivery` measuring the socket's `writableLength` against
|
|
7
|
+
* `ATTACHED_SURFACE_MAX_PENDING_BYTES`, and a failure destroys the socket. The client sends what an
|
|
8
|
+
* attached terminal sends when it starts, all at once, and pages the history as it arrives.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
12
|
+
import { createConnection, createServer } from 'node:net';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
17
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
ATTACHED_SURFACE_MAX_PENDING_BYTES,
|
|
21
|
+
createOutboundDelivery,
|
|
22
|
+
} from '../outbound-delivery.js';
|
|
23
|
+
import { createSessionMessageHandler } from '../session-message-handler.js';
|
|
24
|
+
|
|
25
|
+
import type { TClientMessage, TServerMessage } from '../wire-messages.js';
|
|
26
|
+
import type { IHistoryEntry } from '@robota-sdk/agent-core';
|
|
27
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
28
|
+
import type { Server, Socket } from 'node:net';
|
|
29
|
+
|
|
30
|
+
type TListener = (...args: unknown[]) => void;
|
|
31
|
+
|
|
32
|
+
const ENTRY_BYTES = 20_000;
|
|
33
|
+
/** Half as much again as the budget, so one frame holding it all would trip it. */
|
|
34
|
+
const HISTORY_BYTES = ATTACHED_SURFACE_MAX_PENDING_BYTES * 1.5;
|
|
35
|
+
|
|
36
|
+
const STARTUP: readonly TClientMessage[] = [
|
|
37
|
+
{ type: 'get-history' },
|
|
38
|
+
{ type: 'get-context' },
|
|
39
|
+
{ type: 'get-commands' },
|
|
40
|
+
{ type: 'get-status' },
|
|
41
|
+
{ type: 'get-executing' },
|
|
42
|
+
{ type: 'get-pending' },
|
|
43
|
+
{ type: 'get-execution-workspace' },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function longHistory(): IHistoryEntry[] {
|
|
47
|
+
return Array.from({ length: Math.ceil(HISTORY_BYTES / ENTRY_BYTES) }, (_, index) => ({
|
|
48
|
+
id: `e${index}`,
|
|
49
|
+
timestamp: new Date('2026-09-26T00:00:00.000Z'),
|
|
50
|
+
category: 'chat',
|
|
51
|
+
type: 'assistant',
|
|
52
|
+
data: { role: 'assistant', content: 'x'.repeat(ENTRY_BYTES) },
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function emittingSession(
|
|
57
|
+
history: IHistoryEntry[],
|
|
58
|
+
): IInteractiveSession & { emit: (event: string, payload: unknown) => void } {
|
|
59
|
+
const listeners = new Map<string, Set<TListener>>();
|
|
60
|
+
const session = createTestInteractiveSession({
|
|
61
|
+
getFullHistory: () => history,
|
|
62
|
+
on: ((event: string, handler: TListener) => {
|
|
63
|
+
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
64
|
+
listeners.get(event)!.add(handler);
|
|
65
|
+
}) as IInteractiveSession['on'],
|
|
66
|
+
off: ((event: string, handler: TListener) => {
|
|
67
|
+
listeners.get(event)?.delete(handler);
|
|
68
|
+
}) as IInteractiveSession['off'],
|
|
69
|
+
});
|
|
70
|
+
return Object.assign(session, {
|
|
71
|
+
emit: (event: string, payload: unknown) => {
|
|
72
|
+
for (const handler of listeners.get(event) ?? []) handler(payload);
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One connection's carrier, as the supervised attach carrier builds it. */
|
|
78
|
+
function serve(session: IInteractiveSession, socket: Socket, onCut: (error: Error) => void): void {
|
|
79
|
+
const deliver = createOutboundDelivery(
|
|
80
|
+
(message) => {
|
|
81
|
+
socket.write(`${JSON.stringify(message)}\n`);
|
|
82
|
+
},
|
|
83
|
+
(error) => {
|
|
84
|
+
onCut(error);
|
|
85
|
+
socket.destroy();
|
|
86
|
+
},
|
|
87
|
+
() => socket.writableLength,
|
|
88
|
+
ATTACHED_SURFACE_MAX_PENDING_BYTES,
|
|
89
|
+
);
|
|
90
|
+
const { onMessage, cleanup } = createSessionMessageHandler({ session, deliver });
|
|
91
|
+
let buffered = '';
|
|
92
|
+
socket.setEncoding('utf8');
|
|
93
|
+
socket.on('data', (chunk: string) => {
|
|
94
|
+
buffered += chunk;
|
|
95
|
+
// Every complete line of a chunk is handled in one pass, as the carrier's drain loop does.
|
|
96
|
+
for (let end = buffered.indexOf('\n'); end !== -1; end = buffered.indexOf('\n')) {
|
|
97
|
+
const line = buffered.slice(0, end);
|
|
98
|
+
buffered = buffered.slice(end + 1);
|
|
99
|
+
onMessage(line);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
socket.on('close', cleanup);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface IClient {
|
|
106
|
+
readonly frames: TServerMessage[];
|
|
107
|
+
readonly closed: Promise<void>;
|
|
108
|
+
send(message: TClientMessage): void;
|
|
109
|
+
next(predicate: (frame: TServerMessage) => boolean): Promise<TServerMessage>;
|
|
110
|
+
end(): void;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function connect(path: string): Promise<IClient> {
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
const socket = createConnection(path);
|
|
116
|
+
const frames: TServerMessage[] = [];
|
|
117
|
+
const waiters: {
|
|
118
|
+
predicate: (frame: TServerMessage) => boolean;
|
|
119
|
+
found: (f: TServerMessage) => void;
|
|
120
|
+
lost: (error: Error) => void;
|
|
121
|
+
}[] = [];
|
|
122
|
+
let buffered = '';
|
|
123
|
+
let markClosed: () => void = () => undefined;
|
|
124
|
+
const closed = new Promise<void>((done) => {
|
|
125
|
+
markClosed = done;
|
|
126
|
+
});
|
|
127
|
+
socket.setEncoding('utf8');
|
|
128
|
+
socket.on('data', (chunk: string) => {
|
|
129
|
+
buffered += chunk;
|
|
130
|
+
for (let end = buffered.indexOf('\n'); end !== -1; end = buffered.indexOf('\n')) {
|
|
131
|
+
const frame = JSON.parse(buffered.slice(0, end)) as TServerMessage;
|
|
132
|
+
buffered = buffered.slice(end + 1);
|
|
133
|
+
frames.push(frame);
|
|
134
|
+
for (const waiter of waiters.splice(0)) {
|
|
135
|
+
if (waiter.predicate(frame)) waiter.found(frame);
|
|
136
|
+
else waiters.push(waiter);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
socket.on('close', () => {
|
|
141
|
+
markClosed();
|
|
142
|
+
for (const waiter of waiters.splice(0))
|
|
143
|
+
waiter.lost(new Error('the host closed the connection'));
|
|
144
|
+
});
|
|
145
|
+
socket.on('error', reject);
|
|
146
|
+
socket.on('connect', () =>
|
|
147
|
+
resolve({
|
|
148
|
+
frames,
|
|
149
|
+
closed,
|
|
150
|
+
send: (message) => socket.write(`${JSON.stringify(message)}\n`),
|
|
151
|
+
next: (predicate) =>
|
|
152
|
+
new Promise((found, lost) => {
|
|
153
|
+
waiters.push({ predicate, found, lost });
|
|
154
|
+
}),
|
|
155
|
+
end: () => socket.end(),
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isHistory(frame: TServerMessage): frame is Extract<TServerMessage, { type: 'history' }> {
|
|
162
|
+
return frame.type === 'history';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
describe('a history larger than the attached-surface budget (#3189)', () => {
|
|
166
|
+
let server: Server | undefined;
|
|
167
|
+
let dir: string | undefined;
|
|
168
|
+
|
|
169
|
+
afterEach(async () => {
|
|
170
|
+
await new Promise<void>((done) => (server ? server.close(() => done()) : done()));
|
|
171
|
+
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('reaches an attached terminal whole, and the turn after it does not cut the terminal off', async () => {
|
|
175
|
+
const history = longHistory();
|
|
176
|
+
const session = emittingSession(history);
|
|
177
|
+
const cuts: Error[] = [];
|
|
178
|
+
dir = mkdtempSync(join(tmpdir(), 'rh-'));
|
|
179
|
+
const path = join(dir, 's.sock');
|
|
180
|
+
server = createServer((socket) => serve(session, socket, (error) => cuts.push(error)));
|
|
181
|
+
await new Promise<void>((done) => server!.listen(path, done));
|
|
182
|
+
const client = await connect(path);
|
|
183
|
+
|
|
184
|
+
// What an attached terminal sends when it starts, in one burst.
|
|
185
|
+
for (const message of STARTUP) client.send(message);
|
|
186
|
+
const received: string[] = [];
|
|
187
|
+
let page = (await client.next(isHistory)) as Extract<TServerMessage, { type: 'history' }>;
|
|
188
|
+
for (;;) {
|
|
189
|
+
received.push(...page.entries.map((entry) => entry.id));
|
|
190
|
+
const next = page.startIndex + page.entries.length;
|
|
191
|
+
if (next >= page.total) break;
|
|
192
|
+
const arriving = client.next(isHistory);
|
|
193
|
+
client.send({ type: 'get-history', fromIndex: next });
|
|
194
|
+
page = (await arriving) as Extract<TServerMessage, { type: 'history' }>;
|
|
195
|
+
}
|
|
196
|
+
expect(received).toEqual(history.map((entry) => entry.id));
|
|
197
|
+
|
|
198
|
+
// A turn ends: its result, then more frames at once, as the host sends them.
|
|
199
|
+
const thinking = client.next((frame) => frame.type === 'thinking');
|
|
200
|
+
session.emit('complete', {
|
|
201
|
+
response: 'done',
|
|
202
|
+
history,
|
|
203
|
+
toolSummaries: [],
|
|
204
|
+
contextState: { usedTokens: 1, maxTokens: 10, usedPercentage: 10 },
|
|
205
|
+
});
|
|
206
|
+
session.emit('thinking', false);
|
|
207
|
+
await thinking;
|
|
208
|
+
|
|
209
|
+
expect(cuts).toEqual([]);
|
|
210
|
+
expect(client.frames.map((frame) => frame.type)).toEqual(
|
|
211
|
+
expect.arrayContaining(['context', 'commands', 'session_status', 'pending', 'complete']),
|
|
212
|
+
);
|
|
213
|
+
client.end();
|
|
214
|
+
await client.closed;
|
|
215
|
+
}, 20_000);
|
|
216
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SESSION_CHANGE_REFUSAL_CODES } from '@robota-sdk/agent-interface-session';
|
|
1
2
|
import { describe, expect, it } from 'vitest';
|
|
2
3
|
|
|
3
4
|
import {
|
|
@@ -18,11 +19,18 @@ import type { TClientMessage, TServerMessage } from '../wire-messages.js';
|
|
|
18
19
|
*/
|
|
19
20
|
const CLIENT_SAMPLES: Readonly<Record<TClientMessage['type'], TClientMessage>> = {
|
|
20
21
|
submit: { type: 'submit', prompt: 'hi' },
|
|
21
|
-
command: { type: 'command', name: 'help', args: 'x' },
|
|
22
|
+
command: { type: 'command', name: 'help', args: 'x', requestId: 'command-1' },
|
|
22
23
|
abort: { type: 'abort' },
|
|
23
24
|
'cancel-queue': { type: 'cancel-queue' },
|
|
24
25
|
'get-messages': { type: 'get-messages' },
|
|
26
|
+
'get-history': { type: 'get-history', fromIndex: 40 },
|
|
27
|
+
'get-prompts': { type: 'get-prompts' },
|
|
25
28
|
'get-context': { type: 'get-context' },
|
|
29
|
+
'get-commands': { type: 'get-commands' },
|
|
30
|
+
'get-status': { type: 'get-status' },
|
|
31
|
+
'list-sessions': { type: 'list-sessions', requestId: 'request-3' },
|
|
32
|
+
'new-session': { type: 'new-session', requestId: 'request-4' },
|
|
33
|
+
'switch-session': { type: 'switch-session', sessionId: 'session-2', requestId: 'request-5' },
|
|
26
34
|
'get-usage-report': { type: 'get-usage-report' },
|
|
27
35
|
'get-personal-usage-report': {
|
|
28
36
|
type: 'get-personal-usage-report',
|
|
@@ -38,6 +46,13 @@ const CLIENT_SAMPLES: Readonly<Record<TClientMessage['type'], TClientMessage>> =
|
|
|
38
46
|
'get-executing': { type: 'get-executing' },
|
|
39
47
|
'get-pending': { type: 'get-pending' },
|
|
40
48
|
'get-execution-workspace': { type: 'get-execution-workspace' },
|
|
49
|
+
'read-execution-detail': {
|
|
50
|
+
type: 'read-execution-detail',
|
|
51
|
+
requestId: 'detail-1',
|
|
52
|
+
entryId: 'main',
|
|
53
|
+
cursor: { offset: 20 },
|
|
54
|
+
},
|
|
55
|
+
'stop-waiting-loop': { type: 'stop-waiting-loop', requestId: 'loop-1' },
|
|
41
56
|
'get-background-tasks': {
|
|
42
57
|
type: 'get-background-tasks',
|
|
43
58
|
filter: { kind: 'agent', includeClosed: true },
|
|
@@ -69,9 +84,47 @@ const SERVER_SAMPLES: Readonly<Record<TServerMessage['type'], TServerMessage>> =
|
|
|
69
84
|
complete: { type: 'complete', result: {} as never },
|
|
70
85
|
interrupted: { type: 'interrupted', result: {} as never },
|
|
71
86
|
error: { type: 'error', message: 'm' },
|
|
72
|
-
command_result: {
|
|
87
|
+
command_result: {
|
|
88
|
+
type: 'command_result',
|
|
89
|
+
name: 'n',
|
|
90
|
+
message: 'm',
|
|
91
|
+
success: true,
|
|
92
|
+
requestId: 'command-1',
|
|
93
|
+
},
|
|
73
94
|
messages: { type: 'messages', messages: [] },
|
|
95
|
+
history: {
|
|
96
|
+
type: 'history',
|
|
97
|
+
startIndex: 3,
|
|
98
|
+
total: 4,
|
|
99
|
+
entries: [
|
|
100
|
+
{
|
|
101
|
+
id: 'e1',
|
|
102
|
+
timestamp: '2026-09-26T01:02:03.004Z',
|
|
103
|
+
category: 'event',
|
|
104
|
+
type: 'skill-activation',
|
|
105
|
+
data: { name: 'review' },
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
},
|
|
74
109
|
context: { type: 'context', state: {} as never },
|
|
110
|
+
history_changed: { type: 'history_changed' },
|
|
111
|
+
turn_source: { type: 'turn_source', source: 'peer' },
|
|
112
|
+
commands: { type: 'commands', commands: [], skills: [] },
|
|
113
|
+
session_status: { type: 'session_status', status: {} as never },
|
|
114
|
+
sessions: { type: 'sessions', requestId: 'request-3', listing: {} as never },
|
|
115
|
+
sessions_error: {
|
|
116
|
+
type: 'sessions_error',
|
|
117
|
+
requestId: 'request-3',
|
|
118
|
+
code: 'list_failed',
|
|
119
|
+
message: 'unreadable store',
|
|
120
|
+
},
|
|
121
|
+
session_switched: { type: 'session_switched', event: { sessionId: 'session-2' } },
|
|
122
|
+
session_change_failed: {
|
|
123
|
+
type: 'session_change_failed',
|
|
124
|
+
code: 'prompt_pending',
|
|
125
|
+
message: 'Answer the pending prompt first.',
|
|
126
|
+
requestId: 'request-5',
|
|
127
|
+
},
|
|
75
128
|
usage_report: { type: 'usage_report', report: {} as never },
|
|
76
129
|
personal_usage_report: {
|
|
77
130
|
type: 'personal_usage_report',
|
|
@@ -98,8 +151,23 @@ const SERVER_SAMPLES: Readonly<Record<TServerMessage['type'], TServerMessage>> =
|
|
|
98
151
|
message: 'not found',
|
|
99
152
|
},
|
|
100
153
|
executing: { type: 'executing', executing: false },
|
|
101
|
-
pending: { type: 'pending', pending: null },
|
|
154
|
+
pending: { type: 'pending', pending: null, pendingCount: 0 },
|
|
102
155
|
execution_workspace_event: { type: 'execution_workspace_event', snapshot: {} as never },
|
|
156
|
+
execution_detail: {
|
|
157
|
+
type: 'execution_detail',
|
|
158
|
+
requestId: 'detail-1',
|
|
159
|
+
page: { entryId: 'main', records: [], nextCursor: { offset: 20 } },
|
|
160
|
+
},
|
|
161
|
+
execution_detail_error: {
|
|
162
|
+
type: 'execution_detail_error',
|
|
163
|
+
requestId: 'detail-1',
|
|
164
|
+
message: 'Unknown entry: main',
|
|
165
|
+
},
|
|
166
|
+
waiting_loop_stop: {
|
|
167
|
+
type: 'waiting_loop_stop',
|
|
168
|
+
requestId: 'loop-1',
|
|
169
|
+
outcome: { kind: 'stopped', loopId: 'loop-a', message: 'Stopped loop-a.' },
|
|
170
|
+
},
|
|
103
171
|
background_task_event: { type: 'background_task_event', event: {} as never },
|
|
104
172
|
background_job_group_event: { type: 'background_job_group_event', event: {} as never },
|
|
105
173
|
plan_event: { type: 'plan_event', event: {} as never },
|
|
@@ -122,7 +190,7 @@ const SERVER_SAMPLES: Readonly<Record<TServerMessage['type'], TServerMessage>> =
|
|
|
122
190
|
taskId: 't',
|
|
123
191
|
success: true,
|
|
124
192
|
},
|
|
125
|
-
protocol_error: { type: 'protocol_error', message: 'm' },
|
|
193
|
+
protocol_error: { type: 'protocol_error', message: 'm', requestId: 'command-1' },
|
|
126
194
|
resume_gap: { type: 'resume_gap' },
|
|
127
195
|
};
|
|
128
196
|
|
|
@@ -170,6 +238,31 @@ const MALFORMED_CLIENT: ReadonlyArray<[string, unknown]> = [
|
|
|
170
238
|
['resume with a string lastSeq', { type: 'resume', lastSeq: '4' }],
|
|
171
239
|
['ack with NaN', { type: 'ack', seq: Number.NaN }],
|
|
172
240
|
['cancel-background-task with an empty taskId', { type: 'cancel-background-task', taskId: '' }],
|
|
241
|
+
['list-sessions without requestId', { type: 'list-sessions' }],
|
|
242
|
+
['get-history from a negative index', { type: 'get-history', fromIndex: -1 }],
|
|
243
|
+
['get-history from a fractional index', { type: 'get-history', fromIndex: 1.5 }],
|
|
244
|
+
['command with an empty requestId', { type: 'command', name: 'n', requestId: '' }],
|
|
245
|
+
['switch-session with an empty sessionId', { type: 'switch-session', sessionId: '' }],
|
|
246
|
+
[
|
|
247
|
+
'switch-session with a numeric requestId',
|
|
248
|
+
{ type: 'switch-session', sessionId: 's', requestId: 1 },
|
|
249
|
+
],
|
|
250
|
+
['new-session with an empty requestId', { type: 'new-session', requestId: '' }],
|
|
251
|
+
['read-execution-detail without requestId', { type: 'read-execution-detail', entryId: 'main' }],
|
|
252
|
+
[
|
|
253
|
+
'read-execution-detail with an empty entryId',
|
|
254
|
+
{ type: 'read-execution-detail', requestId: 'r', entryId: '' },
|
|
255
|
+
],
|
|
256
|
+
[
|
|
257
|
+
'read-execution-detail with a negative offset',
|
|
258
|
+
{ type: 'read-execution-detail', requestId: 'r', entryId: 'main', cursor: { offset: -1 } },
|
|
259
|
+
],
|
|
260
|
+
[
|
|
261
|
+
'read-execution-detail with a string offset',
|
|
262
|
+
{ type: 'read-execution-detail', requestId: 'r', entryId: 'main', cursor: { offset: '2' } },
|
|
263
|
+
],
|
|
264
|
+
['stop-waiting-loop without requestId', { type: 'stop-waiting-loop' }],
|
|
265
|
+
['stop-waiting-loop with an empty requestId', { type: 'stop-waiting-loop', requestId: '' }],
|
|
173
266
|
];
|
|
174
267
|
|
|
175
268
|
const MALFORMED_SERVER: ReadonlyArray<[string, unknown]> = [
|
|
@@ -182,13 +275,86 @@ const MALFORMED_SERVER: ReadonlyArray<[string, unknown]> = [
|
|
|
182
275
|
['thinking with a string flag', { type: 'thinking', isThinking: 'yes' }],
|
|
183
276
|
['messages with a non-array', { type: 'messages', messages: {} }],
|
|
184
277
|
['messages with a primitive entry', { type: 'messages', messages: ['x'] }],
|
|
185
|
-
['pending with a number', { type: 'pending', pending: 1 }],
|
|
278
|
+
['pending with a number', { type: 'pending', pending: 1, pendingCount: 1 }],
|
|
279
|
+
['pending with a negative count', { type: 'pending', pending: null, pendingCount: -1 }],
|
|
280
|
+
['pending with a fractional count', { type: 'pending', pending: 'p', pendingCount: 1.5 }],
|
|
186
281
|
['background_task with an array task', { type: 'background_task', taskId: 't', task: [] }],
|
|
187
282
|
[
|
|
188
283
|
'control result with an unknown action',
|
|
189
284
|
{ type: 'background_task_control_result', action: 'nuke', taskId: 't', success: true },
|
|
190
285
|
],
|
|
191
286
|
['command_result without success', { type: 'command_result', name: 'n', message: 'm' }],
|
|
287
|
+
[
|
|
288
|
+
'sessions_error with an unknown code',
|
|
289
|
+
{ type: 'sessions_error', requestId: 'r', code: 'nope', message: 'm' },
|
|
290
|
+
],
|
|
291
|
+
['session_switched without event', { type: 'session_switched' }],
|
|
292
|
+
[
|
|
293
|
+
'session_change_failed with an unknown code',
|
|
294
|
+
{ type: 'session_change_failed', code: 'nope', message: 'm' },
|
|
295
|
+
],
|
|
296
|
+
['session_change_failed without message', { type: 'session_change_failed', code: 'limit' }],
|
|
297
|
+
[
|
|
298
|
+
'session_change_failed with a numeric requestId',
|
|
299
|
+
{ type: 'session_change_failed', code: 'limit', message: 'm', requestId: 1 },
|
|
300
|
+
],
|
|
301
|
+
['history without entries', { type: 'history', startIndex: 0, total: 0 }],
|
|
302
|
+
['history without its place', { type: 'history', entries: [] }],
|
|
303
|
+
['history with a negative total', { type: 'history', startIndex: 0, total: -1, entries: [] }],
|
|
304
|
+
['history with a primitive entry', { type: 'history', startIndex: 0, total: 1, entries: ['x'] }],
|
|
305
|
+
[
|
|
306
|
+
'history with a numeric timestamp',
|
|
307
|
+
{
|
|
308
|
+
type: 'history',
|
|
309
|
+
startIndex: 0,
|
|
310
|
+
total: 1,
|
|
311
|
+
entries: [{ id: 'e', timestamp: 1, category: 'chat', type: 'user' }],
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
[
|
|
315
|
+
'history with an entry missing its category',
|
|
316
|
+
{
|
|
317
|
+
type: 'history',
|
|
318
|
+
startIndex: 0,
|
|
319
|
+
total: 1,
|
|
320
|
+
entries: [{ id: 'e', timestamp: 't', type: 'user' }],
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
[
|
|
324
|
+
'protocol_error with a numeric requestId',
|
|
325
|
+
{ type: 'protocol_error', message: 'm', requestId: 1 },
|
|
326
|
+
],
|
|
327
|
+
['turn_source with an unknown source', { type: 'turn_source', source: 'robot' }],
|
|
328
|
+
['turn_source without source', { type: 'turn_source' }],
|
|
329
|
+
['execution_detail without page', { type: 'execution_detail', requestId: 'r' }],
|
|
330
|
+
['execution_detail with an array page', { type: 'execution_detail', requestId: 'r', page: [] }],
|
|
331
|
+
['execution_detail without requestId', { type: 'execution_detail', page: {} }],
|
|
332
|
+
['execution_detail_error without message', { type: 'execution_detail_error', requestId: 'r' }],
|
|
333
|
+
['waiting_loop_stop without outcome', { type: 'waiting_loop_stop', requestId: 'r' }],
|
|
334
|
+
[
|
|
335
|
+
'waiting_loop_stop with an unknown kind',
|
|
336
|
+
{ type: 'waiting_loop_stop', requestId: 'r', outcome: { kind: 'paused' } },
|
|
337
|
+
],
|
|
338
|
+
[
|
|
339
|
+
'waiting_loop_stop stopped without loopId',
|
|
340
|
+
{ type: 'waiting_loop_stop', requestId: 'r', outcome: { kind: 'stopped' } },
|
|
341
|
+
],
|
|
342
|
+
[
|
|
343
|
+
'waiting_loop_stop several without message',
|
|
344
|
+
{ type: 'waiting_loop_stop', requestId: 'r', outcome: { kind: 'several' } },
|
|
345
|
+
],
|
|
346
|
+
[
|
|
347
|
+
'waiting_loop_stop failed with a numeric loopId',
|
|
348
|
+
{
|
|
349
|
+
type: 'waiting_loop_stop',
|
|
350
|
+
requestId: 'r',
|
|
351
|
+
outcome: { kind: 'failed', loopId: 1, message: 'm' },
|
|
352
|
+
},
|
|
353
|
+
],
|
|
354
|
+
[
|
|
355
|
+
'waiting_loop_stop with a prototype-only kind',
|
|
356
|
+
{ type: 'waiting_loop_stop', requestId: 'r', outcome: { kind: 'toString' } },
|
|
357
|
+
],
|
|
192
358
|
];
|
|
193
359
|
|
|
194
360
|
describe('decodeClientMessage (issue #2045)', () => {
|
|
@@ -228,6 +394,36 @@ describe('decodeServerMessage (issue #2045)', () => {
|
|
|
228
394
|
it.each(MALFORMED_SERVER)('refuses %s', (_label, value) => {
|
|
229
395
|
expect(decodeServerMessage(value).ok).toBe(false);
|
|
230
396
|
});
|
|
397
|
+
|
|
398
|
+
it('accepts every refusal code the session contracts declare, and a refusal without requestId', () => {
|
|
399
|
+
for (const code of SESSION_CHANGE_REFUSAL_CODES) {
|
|
400
|
+
expect(decodeServerMessage({ type: 'session_change_failed', code, message: 'm' }).ok).toBe(
|
|
401
|
+
true,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('accepts every waiting-loop stop outcome', () => {
|
|
407
|
+
for (const outcome of [
|
|
408
|
+
{ kind: 'none' },
|
|
409
|
+
{ kind: 'several', message: 'Use /loop stop <id>.' },
|
|
410
|
+
{ kind: 'stopped', loopId: 'loop-a' },
|
|
411
|
+
{ kind: 'failed', message: 'store unavailable' },
|
|
412
|
+
{ kind: 'failed', loopId: 'loop-a', message: 'store unavailable' },
|
|
413
|
+
]) {
|
|
414
|
+
expect(decodeServerMessage({ type: 'waiting_loop_stop', requestId: 'r', outcome }).ok).toBe(
|
|
415
|
+
true,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('accepts a pending frame without its count, as a host from before the count sends it', () => {
|
|
421
|
+
// Refusing it would cut a newer client off from an older daemon on every prompt it queues.
|
|
422
|
+
expect(decodeFrame('{"type":"pending","pending":null}', decodeServerMessage)).toEqual({
|
|
423
|
+
ok: true,
|
|
424
|
+
message: { type: 'pending', pending: null },
|
|
425
|
+
});
|
|
426
|
+
});
|
|
231
427
|
});
|
|
232
428
|
|
|
233
429
|
describe('decodeFrame — the one carrier path', () => {
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3189 step 3b: an attached client reads a workspace entry's detail, stops a waiting self-paced
|
|
3
|
+
* loop, and follows status changes another client made.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
7
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import { isObserverMessageType as isObserverMessageTypeFromClient } from '../client.js';
|
|
10
|
+
import { isObserverMessageType } from '../index.js';
|
|
11
|
+
import { createOutboundDelivery } from '../outbound-delivery.js';
|
|
12
|
+
import { createSessionMessageHandler } from '../session-message-handler.js';
|
|
13
|
+
|
|
14
|
+
import type { TClientMessage, TServerMessage } from '../wire-messages.js';
|
|
15
|
+
import type { IExecutionDetailPage } from '@robota-sdk/agent-interface-execution';
|
|
16
|
+
import type {
|
|
17
|
+
IInteractiveSession,
|
|
18
|
+
ISessionStatusSnapshot,
|
|
19
|
+
} from '@robota-sdk/agent-interface-session';
|
|
20
|
+
|
|
21
|
+
type TListener = (...args: unknown[]) => void;
|
|
22
|
+
|
|
23
|
+
const page: IExecutionDetailPage = {
|
|
24
|
+
entryId: 'main',
|
|
25
|
+
cursor: { offset: 20 },
|
|
26
|
+
nextCursor: { offset: 40 },
|
|
27
|
+
records: [{ id: 'main:20:0', kind: 'message', text: 'hello' }],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function createSession(overrides: Partial<IInteractiveSession> = {}): IInteractiveSession & {
|
|
31
|
+
emit: (event: string, ...args: unknown[]) => void;
|
|
32
|
+
} {
|
|
33
|
+
const listeners = new Map<string, Set<TListener>>();
|
|
34
|
+
const session = createTestInteractiveSession({
|
|
35
|
+
readExecutionWorkspaceDetail: vi.fn().mockResolvedValue(page),
|
|
36
|
+
stopWaitingSelfPacedLoop: vi.fn().mockResolvedValue({ kind: 'stopped', loopId: 'loop-a' }),
|
|
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
|
+
...overrides,
|
|
45
|
+
});
|
|
46
|
+
return Object.assign(session, {
|
|
47
|
+
emit: (event: string, ...args: unknown[]) => {
|
|
48
|
+
listeners.get(event)?.forEach((handler) => handler(...args));
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function attach(
|
|
54
|
+
session: IInteractiveSession,
|
|
55
|
+
role: 'drive' | 'observe' = 'drive',
|
|
56
|
+
): { sent: TServerMessage[]; send: (message: TClientMessage) => void } {
|
|
57
|
+
const sent: TServerMessage[] = [];
|
|
58
|
+
const { onMessage } = createSessionMessageHandler({
|
|
59
|
+
session,
|
|
60
|
+
role,
|
|
61
|
+
deliver: createOutboundDelivery((message) => sent.push(message), vi.fn()),
|
|
62
|
+
});
|
|
63
|
+
return { sent, send: (message) => onMessage(JSON.stringify(message)) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
|
67
|
+
|
|
68
|
+
describe('read-execution-detail', () => {
|
|
69
|
+
it('answers with the page the session read, under the request id', async () => {
|
|
70
|
+
const session = createSession();
|
|
71
|
+
const client = attach(session);
|
|
72
|
+
client.send({
|
|
73
|
+
type: 'read-execution-detail',
|
|
74
|
+
requestId: 'detail-1',
|
|
75
|
+
entryId: 'main',
|
|
76
|
+
cursor: { offset: 20 },
|
|
77
|
+
});
|
|
78
|
+
await flush();
|
|
79
|
+
expect(session.readExecutionWorkspaceDetail).toHaveBeenCalledExactlyOnceWith('main', {
|
|
80
|
+
offset: 20,
|
|
81
|
+
});
|
|
82
|
+
expect(client.sent).toEqual([{ type: 'execution_detail', requestId: 'detail-1', page }]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('answers with an error reply when the session cannot read the entry', async () => {
|
|
86
|
+
const session = createSession({
|
|
87
|
+
readExecutionWorkspaceDetail: vi.fn().mockRejectedValue(new Error('Unknown entry: gone')),
|
|
88
|
+
});
|
|
89
|
+
const client = attach(session);
|
|
90
|
+
client.send({ type: 'read-execution-detail', requestId: 'detail-2', entryId: 'gone' });
|
|
91
|
+
await flush();
|
|
92
|
+
expect(client.sent).toEqual([
|
|
93
|
+
{ type: 'execution_detail_error', requestId: 'detail-2', message: 'Unknown entry: gone' },
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('stop-waiting-loop', () => {
|
|
99
|
+
it('answers with the outcome the session reports', async () => {
|
|
100
|
+
const session = createSession();
|
|
101
|
+
const client = attach(session);
|
|
102
|
+
client.send({ type: 'stop-waiting-loop', requestId: 'loop-1' });
|
|
103
|
+
await flush();
|
|
104
|
+
expect(session.stopWaitingSelfPacedLoop).toHaveBeenCalledOnce();
|
|
105
|
+
expect(client.sent).toEqual([
|
|
106
|
+
{
|
|
107
|
+
type: 'waiting_loop_stop',
|
|
108
|
+
requestId: 'loop-1',
|
|
109
|
+
outcome: { kind: 'stopped', loopId: 'loop-a' },
|
|
110
|
+
},
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('reports a session that throws as a failed stop', async () => {
|
|
115
|
+
const session = createSession({
|
|
116
|
+
stopWaitingSelfPacedLoop: vi.fn().mockRejectedValue(new Error('store unavailable')),
|
|
117
|
+
});
|
|
118
|
+
const client = attach(session);
|
|
119
|
+
client.send({ type: 'stop-waiting-loop', requestId: 'loop-2' });
|
|
120
|
+
await flush();
|
|
121
|
+
expect(client.sent).toEqual([
|
|
122
|
+
{
|
|
123
|
+
type: 'waiting_loop_stop',
|
|
124
|
+
requestId: 'loop-2',
|
|
125
|
+
outcome: { kind: 'failed', message: 'store unavailable' },
|
|
126
|
+
},
|
|
127
|
+
]);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('observers', () => {
|
|
132
|
+
it('may read an entry detail but may not stop a loop', async () => {
|
|
133
|
+
const session = createSession();
|
|
134
|
+
const observer = attach(session, 'observe');
|
|
135
|
+
observer.send({ type: 'read-execution-detail', requestId: 'detail-3', entryId: 'main' });
|
|
136
|
+
observer.send({ type: 'stop-waiting-loop', requestId: 'loop-3' });
|
|
137
|
+
await flush();
|
|
138
|
+
expect(session.stopWaitingSelfPacedLoop).not.toHaveBeenCalled();
|
|
139
|
+
expect(observer.sent).toEqual([
|
|
140
|
+
{ type: 'protocol_error', message: 'Not permitted for an observer: stop-waiting-loop' },
|
|
141
|
+
{ type: 'execution_detail', requestId: 'detail-3', page },
|
|
142
|
+
]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('share one allowlist between the host and both package entries', () => {
|
|
146
|
+
expect(isObserverMessageTypeFromClient).toBe(isObserverMessageType);
|
|
147
|
+
expect(isObserverMessageType('read-execution-detail')).toBe(true);
|
|
148
|
+
expect(isObserverMessageType('get-status')).toBe(true);
|
|
149
|
+
expect(isObserverMessageType('stop-waiting-loop')).toBe(false);
|
|
150
|
+
expect(isObserverMessageType('get-prompts')).toBe(false);
|
|
151
|
+
expect(isObserverMessageType('submit')).toBe(false);
|
|
152
|
+
expect(isObserverMessageType('not-a-message')).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('status_changed', () => {
|
|
157
|
+
it('pushes the new status to every attached client as session_status', () => {
|
|
158
|
+
const session = createSession();
|
|
159
|
+
const drive = attach(session);
|
|
160
|
+
const observer = attach(session, 'observe');
|
|
161
|
+
const status: ISessionStatusSnapshot = {
|
|
162
|
+
...session.getStatusSnapshot(),
|
|
163
|
+
permissionMode: 'plan',
|
|
164
|
+
};
|
|
165
|
+
session.emit('status_changed', status);
|
|
166
|
+
expect(drive.sent).toEqual([{ type: 'session_status', status }]);
|
|
167
|
+
expect(observer.sent).toEqual([{ type: 'session_status', status }]);
|
|
168
|
+
});
|
|
169
|
+
});
|