@robota-sdk/agent-transport 3.0.0-beta.100
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 +1137 -0
- package/LICENSE +661 -0
- package/README.md +121 -0
- package/dist/browser/client.d.ts +2 -0
- package/dist/browser/client.js +1 -0
- package/dist/browser/index.d.ts +624 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/observer-messages-4fRQZU2D.d.ts +499 -0
- package/dist/browser/observer-messages-4fRQZU2D.d.ts.map +1 -0
- package/dist/browser/observer-messages-O8jTPEQO.js +2 -0
- package/dist/browser/observer-messages-O8jTPEQO.js.map +1 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +1115 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +1115 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/dist/node/node.cjs +1 -0
- package/dist/node/node.d.cts +317 -0
- package/dist/node/node.d.cts.map +1 -0
- package/dist/node/node.d.ts +317 -0
- package/dist/node/node.d.ts.map +1 -0
- package/dist/node/node.js +2 -0
- package/dist/node/node.js.map +1 -0
- package/package.json +122 -0
- package/src/__tests__/__fixtures__/korean-text.json +6 -0
- package/src/__tests__/access-token-verifier.test.ts +672 -0
- package/src/__tests__/admission.test.ts +130 -0
- package/src/__tests__/app-tool-messages.test.ts +463 -0
- package/src/__tests__/bearer-resource-gate.test.ts +120 -0
- package/src/__tests__/channel-frames.test.ts +215 -0
- package/src/__tests__/driver-attribution-capability.test.ts +83 -0
- package/src/__tests__/file-transfer.test.ts +281 -0
- package/src/__tests__/handoff-chunking.test.ts +261 -0
- package/src/__tests__/handoff-manifest.test.ts +52 -0
- package/src/__tests__/history-attached-budget.test.ts +216 -0
- package/src/__tests__/message-decoders.test.ts +870 -0
- package/src/__tests__/outbound-delivery.test.ts +558 -0
- package/src/__tests__/peer-message-ledger.test.ts +200 -0
- package/src/__tests__/permission-summary.test.ts +18 -0
- package/src/__tests__/personal-usage-report.test.ts +193 -0
- package/src/__tests__/project-read-messages.test.ts +118 -0
- package/src/__tests__/resume-buffer.test.ts +93 -0
- package/src/__tests__/session-detail-loop-status.test.ts +169 -0
- package/src/__tests__/session-directory-messages.test.ts +322 -0
- package/src/__tests__/session-error-classification.test.ts +113 -0
- package/src/__tests__/session-event-delivery.test.ts +101 -0
- package/src/__tests__/session-history-wire.test.ts +364 -0
- package/src/__tests__/session-message-handler-e5.test.ts +99 -0
- package/src/__tests__/session-message-handler-observe.test.ts +223 -0
- package/src/__tests__/session-message-handler.test.ts +726 -0
- package/src/__tests__/session-resume-bridge.test.ts +472 -0
- package/src/__tests__/settings-messages.test.ts +193 -0
- package/src/__tests__/stdio-carrier.test.ts +85 -0
- package/src/__tests__/submit-prompt-shape.test.ts +78 -0
- package/src/__tests__/usage-report-carrier.test.ts +98 -0
- package/src/__tests__/ws-broadcast-events.test.ts +94 -0
- package/src/app-tool-messages.ts +140 -0
- package/src/background-messages.ts +169 -0
- package/src/channel-frames.ts +182 -0
- package/src/client.ts +17 -0
- package/src/handoff-chunking.ts +275 -0
- package/src/index.ts +135 -0
- package/src/loop-control-messages.ts +34 -0
- package/src/message-decoders.ts +485 -0
- package/src/message-parser.ts +33 -0
- package/src/node/access-token-verifier.ts +363 -0
- package/src/node/admission.ts +153 -0
- package/src/node/bearer-resource-gate.ts +331 -0
- package/src/node/file-transfer.ts +530 -0
- package/src/node/handoff-manifest.ts +77 -0
- package/src/node/index.ts +45 -0
- package/src/node/stdio-carrier.ts +59 -0
- package/src/observer-messages.ts +47 -0
- package/src/open-prompts.ts +76 -0
- package/src/outbound-delivery.ts +240 -0
- package/src/peer-message-ledger.ts +193 -0
- package/src/permission-summary.ts +18 -0
- package/src/project-read-messages.ts +74 -0
- package/src/protocol-session.ts +56 -0
- package/src/resume-buffer.ts +123 -0
- package/src/session-directory-messages.ts +162 -0
- package/src/session-error-classification.ts +91 -0
- package/src/session-events.ts +290 -0
- package/src/session-message-handler.ts +413 -0
- package/src/session-query-messages.ts +160 -0
- package/src/session-resume-bridge.ts +275 -0
- package/src/settings-messages.ts +103 -0
- package/src/usage-messages.ts +195 -0
- package/src/wire-messages.ts +436 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3189: clients list the host's sessions, start a new one and switch to another over the wire. The
|
|
3
|
+
* host owns the directory; the protocol correlates the listing and carries a refusal's code and reason.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
7
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import { createOutboundDelivery } from '../outbound-delivery.js';
|
|
10
|
+
import { createSessionMessageHandler } from '../session-message-handler.js';
|
|
11
|
+
|
|
12
|
+
import type { TClientMessage, TServerMessage } from '../wire-messages.js';
|
|
13
|
+
import type {
|
|
14
|
+
ISessionDirectory,
|
|
15
|
+
ISessionListing,
|
|
16
|
+
TSessionChangeRefusalCode,
|
|
17
|
+
TSessionDeleteRefusalCode,
|
|
18
|
+
} from '@robota-sdk/agent-interface-session';
|
|
19
|
+
|
|
20
|
+
const listing: ISessionListing = {
|
|
21
|
+
currentSessionId: 'session-1',
|
|
22
|
+
sessions: [
|
|
23
|
+
{
|
|
24
|
+
id: 'session-1',
|
|
25
|
+
cwd: '/repo',
|
|
26
|
+
updatedAt: '2026-09-26T00:00:00.000Z',
|
|
27
|
+
messageCount: 2,
|
|
28
|
+
preview: 'hello',
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
unreadableSessionIds: ['session-broken'],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function createDirectory(overrides: Partial<ISessionDirectory> = {}): ISessionDirectory {
|
|
35
|
+
return {
|
|
36
|
+
listSessions: vi.fn(() => listing),
|
|
37
|
+
switchSession: vi.fn(async () => undefined),
|
|
38
|
+
newSession: vi.fn(async () => undefined),
|
|
39
|
+
renameSession: vi.fn(async () => undefined),
|
|
40
|
+
deleteSession: vi.fn(async () => undefined),
|
|
41
|
+
...overrides,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function attach(options: { directory?: ISessionDirectory; role?: 'drive' | 'observe' }): {
|
|
46
|
+
sent: TServerMessage[];
|
|
47
|
+
send: (message: TClientMessage) => void;
|
|
48
|
+
} {
|
|
49
|
+
const sent: TServerMessage[] = [];
|
|
50
|
+
const { onMessage } = createSessionMessageHandler({
|
|
51
|
+
session: createTestInteractiveSession(),
|
|
52
|
+
deliver: createOutboundDelivery((message) => sent.push(message), vi.fn()),
|
|
53
|
+
...(options.directory ? { sessionDirectory: options.directory } : {}),
|
|
54
|
+
...(options.role ? { role: options.role } : {}),
|
|
55
|
+
});
|
|
56
|
+
return { sent, send: (message) => onMessage(JSON.stringify(message)) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The shape `SessionChangeRefusal` (agent-framework) has; this package cannot import the class. */
|
|
60
|
+
function refusal(code: TSessionChangeRefusalCode, message: string): Error {
|
|
61
|
+
return Object.assign(new Error(message), { name: 'SessionChangeRefusal', code });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The shape `SessionDeleteRefusal` (agent-framework) has; this package cannot import the class. */
|
|
65
|
+
function deleteRefusal(code: TSessionDeleteRefusalCode, message: string): Error {
|
|
66
|
+
return Object.assign(new Error(message), { name: 'SessionDeleteRefusal', code });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
|
70
|
+
|
|
71
|
+
describe('session directory messages (#3189)', () => {
|
|
72
|
+
it('answers list-sessions with the host listing, correlated by requestId', () => {
|
|
73
|
+
const client = attach({ directory: createDirectory() });
|
|
74
|
+
client.send({ type: 'list-sessions', requestId: 'r1' });
|
|
75
|
+
expect(client.sent).toEqual([{ type: 'sessions', requestId: 'r1', listing }]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('answers not_available when the host has no session directory', () => {
|
|
79
|
+
const client = attach({});
|
|
80
|
+
client.send({ type: 'list-sessions', requestId: 'r1' });
|
|
81
|
+
expect(client.sent).toEqual([
|
|
82
|
+
{
|
|
83
|
+
type: 'sessions_error',
|
|
84
|
+
requestId: 'r1',
|
|
85
|
+
code: 'not_available',
|
|
86
|
+
message: 'Session listing is not available on this host.',
|
|
87
|
+
},
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('answers list_failed with the reason when the listing throws', () => {
|
|
92
|
+
const directory = createDirectory({
|
|
93
|
+
listSessions: vi.fn(() => {
|
|
94
|
+
throw new Error('session store unreadable');
|
|
95
|
+
}),
|
|
96
|
+
});
|
|
97
|
+
const client = attach({ directory });
|
|
98
|
+
client.send({ type: 'list-sessions', requestId: 'r1' });
|
|
99
|
+
expect(client.sent).toEqual([
|
|
100
|
+
{
|
|
101
|
+
type: 'sessions_error',
|
|
102
|
+
requestId: 'r1',
|
|
103
|
+
code: 'list_failed',
|
|
104
|
+
message: 'session store unreadable',
|
|
105
|
+
},
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('switches by id and answers nothing itself — session_switched from its session is the signal', async () => {
|
|
110
|
+
const directory = createDirectory();
|
|
111
|
+
const client = attach({ directory });
|
|
112
|
+
client.send({ type: 'switch-session', sessionId: 'session-2', requestId: 'r2' });
|
|
113
|
+
await flush();
|
|
114
|
+
expect(directory.switchSession).toHaveBeenCalledExactlyOnceWith('session-2');
|
|
115
|
+
expect(client.sent).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('delivers a declared refusal as session_change_failed with its code and the requestId', async () => {
|
|
119
|
+
const directory = createDirectory({
|
|
120
|
+
switchSession: vi.fn(async () => {
|
|
121
|
+
throw refusal('prompt_pending', 'Answer the pending prompt first.');
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
const client = attach({ directory });
|
|
125
|
+
client.send({ type: 'switch-session', sessionId: 'session-2', requestId: 'r2' });
|
|
126
|
+
await flush();
|
|
127
|
+
expect(client.sent).toEqual([
|
|
128
|
+
{
|
|
129
|
+
type: 'session_change_failed',
|
|
130
|
+
code: 'prompt_pending',
|
|
131
|
+
message: 'Answer the pending prompt first.',
|
|
132
|
+
requestId: 'r2',
|
|
133
|
+
},
|
|
134
|
+
]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('delivers any other error as code failed with its message, and omits an absent requestId', async () => {
|
|
138
|
+
const directory = createDirectory({
|
|
139
|
+
switchSession: vi.fn(async () => {
|
|
140
|
+
throw new Error('disk full');
|
|
141
|
+
}),
|
|
142
|
+
});
|
|
143
|
+
const client = attach({ directory });
|
|
144
|
+
client.send({ type: 'switch-session', sessionId: 'session-2' });
|
|
145
|
+
await flush();
|
|
146
|
+
expect(client.sent).toEqual([
|
|
147
|
+
{ type: 'session_change_failed', code: 'failed', message: 'disk full' },
|
|
148
|
+
]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('starts a new session, and reports a refusal the same way', async () => {
|
|
152
|
+
const directory = createDirectory();
|
|
153
|
+
const client = attach({ directory });
|
|
154
|
+
client.send({ type: 'new-session' });
|
|
155
|
+
await flush();
|
|
156
|
+
expect(directory.newSession).toHaveBeenCalledTimes(1);
|
|
157
|
+
expect(client.sent).toEqual([]);
|
|
158
|
+
|
|
159
|
+
vi.mocked(directory.newSession).mockRejectedValueOnce(
|
|
160
|
+
refusal('limit', 'Four sessions are live already.'),
|
|
161
|
+
);
|
|
162
|
+
client.send({ type: 'new-session', requestId: 'r3' });
|
|
163
|
+
await flush();
|
|
164
|
+
expect(client.sent).toEqual([
|
|
165
|
+
{
|
|
166
|
+
type: 'session_change_failed',
|
|
167
|
+
code: 'limit',
|
|
168
|
+
message: 'Four sessions are live already.',
|
|
169
|
+
requestId: 'r3',
|
|
170
|
+
},
|
|
171
|
+
]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('answers not_available to a switch or new session on a host without a directory', () => {
|
|
175
|
+
const client = attach({});
|
|
176
|
+
client.send({ type: 'switch-session', sessionId: 'session-2', requestId: 'r4' });
|
|
177
|
+
client.send({ type: 'new-session' });
|
|
178
|
+
const message = 'Sessions cannot be switched on this host.';
|
|
179
|
+
expect(client.sent).toEqual([
|
|
180
|
+
{ type: 'session_change_failed', code: 'not_available', message, requestId: 'r4' },
|
|
181
|
+
{ type: 'session_change_failed', code: 'not_available', message },
|
|
182
|
+
]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('lets an observer list sessions but never start or switch one', async () => {
|
|
186
|
+
const directory = createDirectory();
|
|
187
|
+
const observer = attach({ directory, role: 'observe' });
|
|
188
|
+
observer.send({ type: 'list-sessions', requestId: 'r1' });
|
|
189
|
+
observer.send({ type: 'switch-session', sessionId: 'session-2' });
|
|
190
|
+
observer.send({ type: 'new-session' });
|
|
191
|
+
await flush();
|
|
192
|
+
expect(observer.sent).toEqual([
|
|
193
|
+
{ type: 'sessions', requestId: 'r1', listing },
|
|
194
|
+
{ type: 'protocol_error', message: 'Not permitted for an observer: switch-session' },
|
|
195
|
+
{ type: 'protocol_error', message: 'Not permitted for an observer: new-session' },
|
|
196
|
+
]);
|
|
197
|
+
expect(directory.switchSession).not.toHaveBeenCalled();
|
|
198
|
+
expect(directory.newSession).not.toHaveBeenCalled();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('rename-session (#3289 §1)', () => {
|
|
203
|
+
it('renames and answers session_renamed_in_list, correlated by requestId', async () => {
|
|
204
|
+
const directory = createDirectory();
|
|
205
|
+
const client = attach({ directory });
|
|
206
|
+
client.send({ type: 'rename-session', sessionId: 'session-2', name: 'Renamed', requestId: 'r1' });
|
|
207
|
+
await flush();
|
|
208
|
+
expect(directory.renameSession).toHaveBeenCalledExactlyOnceWith('session-2', 'Renamed');
|
|
209
|
+
expect(client.sent).toEqual([
|
|
210
|
+
{ type: 'session_renamed_in_list', requestId: 'r1', sessionId: 'session-2', name: 'Renamed' },
|
|
211
|
+
]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('answers session_rename_failed with the reason when the directory refuses', async () => {
|
|
215
|
+
const directory = createDirectory({
|
|
216
|
+
renameSession: vi.fn(async () => {
|
|
217
|
+
throw new Error('No session session-2 in this workspace.');
|
|
218
|
+
}),
|
|
219
|
+
});
|
|
220
|
+
const client = attach({ directory });
|
|
221
|
+
client.send({ type: 'rename-session', sessionId: 'session-2', name: 'x', requestId: 'r1' });
|
|
222
|
+
await flush();
|
|
223
|
+
expect(client.sent).toEqual([
|
|
224
|
+
{
|
|
225
|
+
type: 'session_rename_failed',
|
|
226
|
+
requestId: 'r1',
|
|
227
|
+
message: 'No session session-2 in this workspace.',
|
|
228
|
+
},
|
|
229
|
+
]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('answers not_available on a host without a directory', () => {
|
|
233
|
+
const client = attach({});
|
|
234
|
+
client.send({ type: 'rename-session', sessionId: 'session-2', name: 'x', requestId: 'r1' });
|
|
235
|
+
expect(client.sent).toEqual([
|
|
236
|
+
{
|
|
237
|
+
type: 'session_rename_failed',
|
|
238
|
+
requestId: 'r1',
|
|
239
|
+
message: 'Sessions cannot be renamed on this host.',
|
|
240
|
+
},
|
|
241
|
+
]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('refuses an observer', async () => {
|
|
245
|
+
const directory = createDirectory();
|
|
246
|
+
const observer = attach({ directory, role: 'observe' });
|
|
247
|
+
observer.send({ type: 'rename-session', sessionId: 'session-2', name: 'x', requestId: 'r1' });
|
|
248
|
+
expect(observer.sent).toEqual([
|
|
249
|
+
{ type: 'protocol_error', message: 'Not permitted for an observer: rename-session' },
|
|
250
|
+
]);
|
|
251
|
+
expect(directory.renameSession).not.toHaveBeenCalled();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('delete-session round-trip (#3289 §1)', () => {
|
|
256
|
+
it('deletes and answers session_deleted, correlated by requestId', async () => {
|
|
257
|
+
const directory = createDirectory();
|
|
258
|
+
const client = attach({ directory });
|
|
259
|
+
client.send({ type: 'delete-session', sessionId: 'session-2', requestId: 'r1' });
|
|
260
|
+
await flush();
|
|
261
|
+
expect(directory.deleteSession).toHaveBeenCalledExactlyOnceWith('session-2');
|
|
262
|
+
expect(client.sent).toEqual([
|
|
263
|
+
{ type: 'session_deleted', requestId: 'r1', sessionId: 'session-2' },
|
|
264
|
+
]);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('answers session_delete_failed with the refusal code and reason', async () => {
|
|
268
|
+
const directory = createDirectory({
|
|
269
|
+
deleteSession: vi.fn(async () => {
|
|
270
|
+
throw deleteRefusal('live_elsewhere', 'Another client is on this session.');
|
|
271
|
+
}),
|
|
272
|
+
});
|
|
273
|
+
const client = attach({ directory });
|
|
274
|
+
client.send({ type: 'delete-session', sessionId: 'session-2', requestId: 'r1' });
|
|
275
|
+
await flush();
|
|
276
|
+
expect(client.sent).toEqual([
|
|
277
|
+
{
|
|
278
|
+
type: 'session_delete_failed',
|
|
279
|
+
requestId: 'r1',
|
|
280
|
+
code: 'live_elsewhere',
|
|
281
|
+
message: 'Another client is on this session.',
|
|
282
|
+
},
|
|
283
|
+
]);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('reports code failed for an error that is not a declared delete refusal', async () => {
|
|
287
|
+
const directory = createDirectory({
|
|
288
|
+
deleteSession: vi.fn(async () => {
|
|
289
|
+
throw new Error('disk full');
|
|
290
|
+
}),
|
|
291
|
+
});
|
|
292
|
+
const client = attach({ directory });
|
|
293
|
+
client.send({ type: 'delete-session', sessionId: 'session-2', requestId: 'r1' });
|
|
294
|
+
await flush();
|
|
295
|
+
expect(client.sent).toEqual([
|
|
296
|
+
{ type: 'session_delete_failed', requestId: 'r1', code: 'failed', message: 'disk full' },
|
|
297
|
+
]);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('answers not_available on a host without a directory', () => {
|
|
301
|
+
const client = attach({});
|
|
302
|
+
client.send({ type: 'delete-session', sessionId: 'session-2', requestId: 'r1' });
|
|
303
|
+
expect(client.sent).toEqual([
|
|
304
|
+
{
|
|
305
|
+
type: 'session_delete_failed',
|
|
306
|
+
requestId: 'r1',
|
|
307
|
+
code: 'not_available',
|
|
308
|
+
message: 'Sessions cannot be deleted on this host.',
|
|
309
|
+
},
|
|
310
|
+
]);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('refuses an observer', async () => {
|
|
314
|
+
const directory = createDirectory();
|
|
315
|
+
const observer = attach({ directory, role: 'observe' });
|
|
316
|
+
observer.send({ type: 'delete-session', sessionId: 'session-2', requestId: 'r1' });
|
|
317
|
+
expect(observer.sent).toEqual([
|
|
318
|
+
{ type: 'protocol_error', message: 'Not permitted for an observer: delete-session' },
|
|
319
|
+
]);
|
|
320
|
+
expect(directory.deleteSession).not.toHaveBeenCalled();
|
|
321
|
+
});
|
|
322
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AuthenticationError,
|
|
3
|
+
ModelNotAvailableError,
|
|
4
|
+
NetworkError,
|
|
5
|
+
ProviderError,
|
|
6
|
+
RateLimitError,
|
|
7
|
+
} from '@robota-sdk/agent-core';
|
|
8
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
9
|
+
|
|
10
|
+
import { createOutboundDelivery } from '../outbound-delivery.js';
|
|
11
|
+
import { classifySessionErrorForWire } from '../session-error-classification.js';
|
|
12
|
+
import { subscribeSessionEvents } from '../session-events.js';
|
|
13
|
+
|
|
14
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* #3289 §3: the `error` wire frame gains a typed `code`/`provider`/`retryAfterSeconds` alongside the
|
|
18
|
+
* raw `message`, so a renderer can say what happened in plain words instead of showing the vendor's
|
|
19
|
+
* raw text. Additive — an error this classification does not recognize carries no `code` at all,
|
|
20
|
+
* which is exactly today's behavior (unchanged).
|
|
21
|
+
*/
|
|
22
|
+
describe('classifySessionErrorForWire', () => {
|
|
23
|
+
it('classifies an AuthenticationError', () => {
|
|
24
|
+
expect(classifySessionErrorForWire(new AuthenticationError('bad key', 'anthropic'))).toEqual({
|
|
25
|
+
code: 'auth',
|
|
26
|
+
provider: 'anthropic',
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('classifies a RateLimitError, carrying retryAfterSeconds when the provider gave one', () => {
|
|
31
|
+
expect(
|
|
32
|
+
classifySessionErrorForWire(new RateLimitError('slow down', 30, 'openai')),
|
|
33
|
+
).toEqual({ code: 'rate_limit', provider: 'openai', retryAfterSeconds: 30 });
|
|
34
|
+
expect(classifySessionErrorForWire(new RateLimitError('slow down', undefined, 'openai'))).toEqual(
|
|
35
|
+
{ code: 'rate_limit', provider: 'openai' },
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('classifies a ModelNotAvailableError, carrying the model when the failure named one', () => {
|
|
40
|
+
expect(
|
|
41
|
+
classifySessionErrorForWire(new ModelNotAvailableError('claude-x', 'anthropic')),
|
|
42
|
+
).toEqual({ code: 'model_unavailable', provider: 'anthropic', model: 'claude-x' });
|
|
43
|
+
expect(
|
|
44
|
+
classifySessionErrorForWire(new ModelNotAvailableError(undefined, 'anthropic')),
|
|
45
|
+
).toEqual({ code: 'model_unavailable', provider: 'anthropic' });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('classifies a NetworkError, carrying the provider when the failure named one', () => {
|
|
49
|
+
expect(
|
|
50
|
+
classifySessionErrorForWire(new NetworkError('down', undefined, undefined, 'anthropic')),
|
|
51
|
+
).toEqual({
|
|
52
|
+
code: 'network',
|
|
53
|
+
provider: 'anthropic',
|
|
54
|
+
});
|
|
55
|
+
expect(classifySessionErrorForWire(new NetworkError('down'))).toEqual({ code: 'network' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('classifies a generic ProviderError as the fallback "provider" code', () => {
|
|
59
|
+
expect(classifySessionErrorForWire(new ProviderError('failed', 'gemini'))).toEqual({
|
|
60
|
+
code: 'provider',
|
|
61
|
+
provider: 'gemini',
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('classifies through a wrapping layer (e.g. a higher-level "Conversation failed")', () => {
|
|
66
|
+
const wrapped = new ProviderError(
|
|
67
|
+
'Conversation failed',
|
|
68
|
+
'conversation',
|
|
69
|
+
new AuthenticationError('bad key', 'openai'),
|
|
70
|
+
);
|
|
71
|
+
expect(classifySessionErrorForWire(wrapped)).toEqual({ code: 'auth', provider: 'openai' });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('leaves an unrelated error uncoded, so the caller keeps showing its raw message unchanged', () => {
|
|
75
|
+
expect(classifySessionErrorForWire(new Error('some internal failure'))).toEqual({});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('the error wire frame carries the classification (#3289 §3)', () => {
|
|
80
|
+
it('adds code/provider/retryAfterSeconds onto the delivered error frame', () => {
|
|
81
|
+
const send = vi.fn();
|
|
82
|
+
const session = createTestInteractiveSession({ on: vi.fn(), off: vi.fn() });
|
|
83
|
+
subscribeSessionEvents(session, createOutboundDelivery(send, vi.fn()));
|
|
84
|
+
|
|
85
|
+
const onError = vi.mocked(session.on).mock.calls.find(([name]) => name === 'error')?.[1] as (
|
|
86
|
+
error: Error,
|
|
87
|
+
) => void;
|
|
88
|
+
onError(new RateLimitError('slow down', 12, 'anthropic'));
|
|
89
|
+
|
|
90
|
+
expect(send).toHaveBeenCalledWith(
|
|
91
|
+
expect.objectContaining({
|
|
92
|
+
type: 'error',
|
|
93
|
+
message: 'Rate Limit Error: slow down',
|
|
94
|
+
code: 'rate_limit',
|
|
95
|
+
provider: 'anthropic',
|
|
96
|
+
retryAfterSeconds: 12,
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('delivers an unrelated error with no code, exactly as before', () => {
|
|
102
|
+
const send = vi.fn();
|
|
103
|
+
const session = createTestInteractiveSession({ on: vi.fn(), off: vi.fn() });
|
|
104
|
+
subscribeSessionEvents(session, createOutboundDelivery(send, vi.fn()));
|
|
105
|
+
|
|
106
|
+
const onError = vi.mocked(session.on).mock.calls.find(([name]) => name === 'error')?.[1] as (
|
|
107
|
+
error: Error,
|
|
108
|
+
) => void;
|
|
109
|
+
onError(new Error('boom'));
|
|
110
|
+
|
|
111
|
+
expect(send).toHaveBeenCalledWith({ type: 'error', message: 'boom' });
|
|
112
|
+
});
|
|
113
|
+
});
|