@robota-sdk/agent-transport 3.0.0-beta.82 → 3.0.0-beta.84
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 +795 -0
- package/README.md +80 -118
- package/dist/browser/client.d.ts +2 -2
- package/dist/browser/client.js +1 -1
- package/dist/browser/index.d.ts +87 -13
- 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-CEeisRaF.js +2 -0
- package/dist/browser/observer-messages-CEeisRaF.js.map +1 -0
- package/dist/browser/observer-messages-Dnff2Tdb.d.ts +471 -0
- package/dist/browser/observer-messages-Dnff2Tdb.d.ts.map +1 -0
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.d.cts +308 -16
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +308 -16
- 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 +17 -14
- 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 +496 -6
- package/src/__tests__/project-read-messages.test.ts +118 -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-history-wire.test.ts +342 -0
- package/src/__tests__/session-message-handler-observe.test.ts +217 -0
- package/src/__tests__/session-message-handler.test.ts +139 -4
- package/src/__tests__/settings-messages.test.ts +193 -0
- package/src/__tests__/ws-broadcast-events.test.ts +10 -0
- package/src/client.ts +9 -1
- package/src/index.ts +18 -2
- package/src/loop-control-messages.ts +34 -0
- package/src/message-decoders.ts +238 -6
- package/src/node/bearer-resource-gate.ts +291 -0
- package/src/node/index.ts +13 -0
- package/src/observer-messages.ts +47 -0
- package/src/open-prompts.ts +76 -0
- package/src/outbound-delivery.ts +11 -2
- package/src/project-read-messages.ts +74 -0
- package/src/protocol-session.ts +23 -1
- package/src/session-directory-messages.ts +162 -0
- package/src/session-error-classification.ts +91 -0
- package/src/session-events.ts +100 -19
- package/src/session-message-handler.ts +110 -15
- package/src/session-query-messages.ts +123 -5
- package/src/session-resume-bridge.ts +8 -0
- package/src/settings-messages.ts +103 -0
- package/src/wire-messages.ts +238 -11
- package/dist/browser/message-decoders-BB8n-H2X.d.ts +0 -252
- 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,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3282 §4c — the Project panel's reads dispatch: success answers the matching `project_*` message
|
|
3
|
+
* under the request's `requestId`; a rejected read becomes a `failed`/`unavailable` result (never an
|
|
4
|
+
* uncaught rejection — the exact class of bug the `/memory` red-toast fix addresses at the command
|
|
5
|
+
* layer, guarded again here at the wire boundary); a session that does not implement the capability
|
|
6
|
+
* (`Partial<ISessionProjectRead>` — see `protocol-session.ts`) answers `protocol_error`, never a throw.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
9
|
+
|
|
10
|
+
import { createOutboundDelivery } from '../outbound-delivery.js';
|
|
11
|
+
import { handleProjectReadMessage, isProjectReadMessage } from '../project-read-messages.js';
|
|
12
|
+
|
|
13
|
+
import type { TOutboundDeliver } from '../outbound-delivery.js';
|
|
14
|
+
import type { TProjectReadCapableSession } from '../protocol-session.js';
|
|
15
|
+
import type { TClientMessage, TServerMessage } from '../wire-messages.js';
|
|
16
|
+
|
|
17
|
+
function collect(): { sent: TServerMessage[]; deliver: TOutboundDeliver } {
|
|
18
|
+
const sent: TServerMessage[] = [];
|
|
19
|
+
return { sent, deliver: createOutboundDelivery((m) => sent.push(m), vi.fn()) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('isProjectReadMessage', () => {
|
|
23
|
+
it('recognizes exactly the three Project panel read types', () => {
|
|
24
|
+
expect(isProjectReadMessage({ type: 'project-status', requestId: 'r' })).toBe(true);
|
|
25
|
+
expect(isProjectReadMessage({ type: 'project-diff', requestId: 'r', path: 'a.txt' })).toBe(true);
|
|
26
|
+
expect(isProjectReadMessage({ type: 'project-memory', requestId: 'r' })).toBe(true);
|
|
27
|
+
expect(isProjectReadMessage({ type: 'get-status' } as TClientMessage)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('handleProjectReadMessage', () => {
|
|
32
|
+
it('answers project_status with the session result under the same requestId', async () => {
|
|
33
|
+
const { sent, deliver } = collect();
|
|
34
|
+
const session = {
|
|
35
|
+
readProjectStatus: vi.fn().mockResolvedValue({ kind: 'not-a-repository' }),
|
|
36
|
+
} as unknown as TProjectReadCapableSession;
|
|
37
|
+
handleProjectReadMessage(session, deliver, { type: 'project-status', requestId: 'r1' });
|
|
38
|
+
await Promise.resolve();
|
|
39
|
+
expect(sent).toEqual([
|
|
40
|
+
{ type: 'project_status', requestId: 'r1', result: { kind: 'not-a-repository' } },
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('passes the path through to readProjectDiff and answers project_diff', async () => {
|
|
45
|
+
const { sent, deliver } = collect();
|
|
46
|
+
const readProjectDiff = vi.fn().mockResolvedValue({ kind: 'diff', diffLines: [], truncated: false });
|
|
47
|
+
const session = { readProjectDiff } as unknown as TProjectReadCapableSession;
|
|
48
|
+
handleProjectReadMessage(session, deliver, {
|
|
49
|
+
type: 'project-diff',
|
|
50
|
+
requestId: 'r2',
|
|
51
|
+
path: 'src/a.ts',
|
|
52
|
+
});
|
|
53
|
+
await Promise.resolve();
|
|
54
|
+
expect(readProjectDiff).toHaveBeenCalledExactlyOnceWith('src/a.ts');
|
|
55
|
+
expect(sent).toEqual([
|
|
56
|
+
{ type: 'project_diff', requestId: 'r2', result: { kind: 'diff', diffLines: [], truncated: false } },
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('answers project_memory with the session result', async () => {
|
|
61
|
+
const { sent, deliver } = collect();
|
|
62
|
+
const session = {
|
|
63
|
+
readProjectMemory: vi
|
|
64
|
+
.fn()
|
|
65
|
+
.mockResolvedValue({ kind: 'memory', content: 'x', path: 'MEMORY.md', truncated: false }),
|
|
66
|
+
} as unknown as TProjectReadCapableSession;
|
|
67
|
+
handleProjectReadMessage(session, deliver, { type: 'project-memory', requestId: 'r3' });
|
|
68
|
+
await Promise.resolve();
|
|
69
|
+
expect(sent).toEqual([
|
|
70
|
+
{
|
|
71
|
+
type: 'project_memory',
|
|
72
|
+
requestId: 'r3',
|
|
73
|
+
result: { kind: 'memory', content: 'x', path: 'MEMORY.md', truncated: false },
|
|
74
|
+
},
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('turns a rejected readProjectStatus into a failed result, never an uncaught rejection', async () => {
|
|
79
|
+
const { sent, deliver } = collect();
|
|
80
|
+
const session = {
|
|
81
|
+
readProjectStatus: vi.fn().mockRejectedValue(new Error('git not found')),
|
|
82
|
+
} as unknown as TProjectReadCapableSession;
|
|
83
|
+
handleProjectReadMessage(session, deliver, { type: 'project-status', requestId: 'r4' });
|
|
84
|
+
await Promise.resolve();
|
|
85
|
+
await Promise.resolve();
|
|
86
|
+
expect(sent).toEqual([
|
|
87
|
+
{ type: 'project_status', requestId: 'r4', result: { kind: 'failed', message: 'git not found' } },
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('turns a rejected readProjectMemory into an unavailable result', async () => {
|
|
92
|
+
const { sent, deliver } = collect();
|
|
93
|
+
const session = {
|
|
94
|
+
readProjectMemory: vi.fn().mockRejectedValue(new Error('no store')),
|
|
95
|
+
} as unknown as TProjectReadCapableSession;
|
|
96
|
+
handleProjectReadMessage(session, deliver, { type: 'project-memory', requestId: 'r5' });
|
|
97
|
+
await Promise.resolve();
|
|
98
|
+
await Promise.resolve();
|
|
99
|
+
expect(sent).toEqual([
|
|
100
|
+
{ type: 'project_memory', requestId: 'r5', result: { kind: 'unavailable', message: 'no store' } },
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('answers protocol_error, never a throw, when the session has no project-read capability', () => {
|
|
105
|
+
const { sent, deliver } = collect();
|
|
106
|
+
const session = {} as unknown as TProjectReadCapableSession;
|
|
107
|
+
expect(() =>
|
|
108
|
+
handleProjectReadMessage(session, deliver, { type: 'project-status', requestId: 'r6' }),
|
|
109
|
+
).not.toThrow();
|
|
110
|
+
expect(sent).toEqual([
|
|
111
|
+
{
|
|
112
|
+
type: 'protocol_error',
|
|
113
|
+
message: 'This host does not support the Project panel.',
|
|
114
|
+
requestId: 'r6',
|
|
115
|
+
},
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -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
|
+
});
|