@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,192 @@
|
|
|
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
|
+
} from '@robota-sdk/agent-interface-session';
|
|
18
|
+
|
|
19
|
+
const listing: ISessionListing = {
|
|
20
|
+
currentSessionId: 'session-1',
|
|
21
|
+
sessions: [
|
|
22
|
+
{
|
|
23
|
+
id: 'session-1',
|
|
24
|
+
cwd: '/repo',
|
|
25
|
+
updatedAt: '2026-09-26T00:00:00.000Z',
|
|
26
|
+
messageCount: 2,
|
|
27
|
+
preview: 'hello',
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
unreadableSessionIds: ['session-broken'],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function createDirectory(overrides: Partial<ISessionDirectory> = {}): ISessionDirectory {
|
|
34
|
+
return {
|
|
35
|
+
listSessions: vi.fn(() => listing),
|
|
36
|
+
switchSession: vi.fn(async () => undefined),
|
|
37
|
+
newSession: vi.fn(async () => undefined),
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function attach(options: { directory?: ISessionDirectory; role?: 'drive' | 'observe' }): {
|
|
43
|
+
sent: TServerMessage[];
|
|
44
|
+
send: (message: TClientMessage) => void;
|
|
45
|
+
} {
|
|
46
|
+
const sent: TServerMessage[] = [];
|
|
47
|
+
const { onMessage } = createSessionMessageHandler({
|
|
48
|
+
session: createTestInteractiveSession(),
|
|
49
|
+
deliver: createOutboundDelivery((message) => sent.push(message), vi.fn()),
|
|
50
|
+
...(options.directory ? { sessionDirectory: options.directory } : {}),
|
|
51
|
+
...(options.role ? { role: options.role } : {}),
|
|
52
|
+
});
|
|
53
|
+
return { sent, send: (message) => onMessage(JSON.stringify(message)) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The shape `SessionChangeRefusal` (agent-framework) has; this package cannot import the class. */
|
|
57
|
+
function refusal(code: TSessionChangeRefusalCode, message: string): Error {
|
|
58
|
+
return Object.assign(new Error(message), { name: 'SessionChangeRefusal', code });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
|
62
|
+
|
|
63
|
+
describe('session directory messages (#3189)', () => {
|
|
64
|
+
it('answers list-sessions with the host listing, correlated by requestId', () => {
|
|
65
|
+
const client = attach({ directory: createDirectory() });
|
|
66
|
+
client.send({ type: 'list-sessions', requestId: 'r1' });
|
|
67
|
+
expect(client.sent).toEqual([{ type: 'sessions', requestId: 'r1', listing }]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('answers not_available when the host has no session directory', () => {
|
|
71
|
+
const client = attach({});
|
|
72
|
+
client.send({ type: 'list-sessions', requestId: 'r1' });
|
|
73
|
+
expect(client.sent).toEqual([
|
|
74
|
+
{
|
|
75
|
+
type: 'sessions_error',
|
|
76
|
+
requestId: 'r1',
|
|
77
|
+
code: 'not_available',
|
|
78
|
+
message: 'Session listing is not available on this host.',
|
|
79
|
+
},
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('answers list_failed with the reason when the listing throws', () => {
|
|
84
|
+
const directory = createDirectory({
|
|
85
|
+
listSessions: vi.fn(() => {
|
|
86
|
+
throw new Error('session store unreadable');
|
|
87
|
+
}),
|
|
88
|
+
});
|
|
89
|
+
const client = attach({ directory });
|
|
90
|
+
client.send({ type: 'list-sessions', requestId: 'r1' });
|
|
91
|
+
expect(client.sent).toEqual([
|
|
92
|
+
{
|
|
93
|
+
type: 'sessions_error',
|
|
94
|
+
requestId: 'r1',
|
|
95
|
+
code: 'list_failed',
|
|
96
|
+
message: 'session store unreadable',
|
|
97
|
+
},
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('switches by id and answers nothing itself — session_switched from its session is the signal', async () => {
|
|
102
|
+
const directory = createDirectory();
|
|
103
|
+
const client = attach({ directory });
|
|
104
|
+
client.send({ type: 'switch-session', sessionId: 'session-2', requestId: 'r2' });
|
|
105
|
+
await flush();
|
|
106
|
+
expect(directory.switchSession).toHaveBeenCalledExactlyOnceWith('session-2');
|
|
107
|
+
expect(client.sent).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('delivers a declared refusal as session_change_failed with its code and the requestId', async () => {
|
|
111
|
+
const directory = createDirectory({
|
|
112
|
+
switchSession: vi.fn(async () => {
|
|
113
|
+
throw refusal('prompt_pending', 'Answer the pending prompt first.');
|
|
114
|
+
}),
|
|
115
|
+
});
|
|
116
|
+
const client = attach({ directory });
|
|
117
|
+
client.send({ type: 'switch-session', sessionId: 'session-2', requestId: 'r2' });
|
|
118
|
+
await flush();
|
|
119
|
+
expect(client.sent).toEqual([
|
|
120
|
+
{
|
|
121
|
+
type: 'session_change_failed',
|
|
122
|
+
code: 'prompt_pending',
|
|
123
|
+
message: 'Answer the pending prompt first.',
|
|
124
|
+
requestId: 'r2',
|
|
125
|
+
},
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('delivers any other error as code failed with its message, and omits an absent requestId', async () => {
|
|
130
|
+
const directory = createDirectory({
|
|
131
|
+
switchSession: vi.fn(async () => {
|
|
132
|
+
throw new Error('disk full');
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
const client = attach({ directory });
|
|
136
|
+
client.send({ type: 'switch-session', sessionId: 'session-2' });
|
|
137
|
+
await flush();
|
|
138
|
+
expect(client.sent).toEqual([
|
|
139
|
+
{ type: 'session_change_failed', code: 'failed', message: 'disk full' },
|
|
140
|
+
]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('starts a new session, and reports a refusal the same way', async () => {
|
|
144
|
+
const directory = createDirectory();
|
|
145
|
+
const client = attach({ directory });
|
|
146
|
+
client.send({ type: 'new-session' });
|
|
147
|
+
await flush();
|
|
148
|
+
expect(directory.newSession).toHaveBeenCalledTimes(1);
|
|
149
|
+
expect(client.sent).toEqual([]);
|
|
150
|
+
|
|
151
|
+
vi.mocked(directory.newSession).mockRejectedValueOnce(
|
|
152
|
+
refusal('limit', 'Four sessions are live already.'),
|
|
153
|
+
);
|
|
154
|
+
client.send({ type: 'new-session', requestId: 'r3' });
|
|
155
|
+
await flush();
|
|
156
|
+
expect(client.sent).toEqual([
|
|
157
|
+
{
|
|
158
|
+
type: 'session_change_failed',
|
|
159
|
+
code: 'limit',
|
|
160
|
+
message: 'Four sessions are live already.',
|
|
161
|
+
requestId: 'r3',
|
|
162
|
+
},
|
|
163
|
+
]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('answers not_available to a switch or new session on a host without a directory', () => {
|
|
167
|
+
const client = attach({});
|
|
168
|
+
client.send({ type: 'switch-session', sessionId: 'session-2', requestId: 'r4' });
|
|
169
|
+
client.send({ type: 'new-session' });
|
|
170
|
+
const message = 'Sessions cannot be switched on this host.';
|
|
171
|
+
expect(client.sent).toEqual([
|
|
172
|
+
{ type: 'session_change_failed', code: 'not_available', message, requestId: 'r4' },
|
|
173
|
+
{ type: 'session_change_failed', code: 'not_available', message },
|
|
174
|
+
]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('lets an observer list sessions but never start or switch one', async () => {
|
|
178
|
+
const directory = createDirectory();
|
|
179
|
+
const observer = attach({ directory, role: 'observe' });
|
|
180
|
+
observer.send({ type: 'list-sessions', requestId: 'r1' });
|
|
181
|
+
observer.send({ type: 'switch-session', sessionId: 'session-2' });
|
|
182
|
+
observer.send({ type: 'new-session' });
|
|
183
|
+
await flush();
|
|
184
|
+
expect(observer.sent).toEqual([
|
|
185
|
+
{ type: 'sessions', requestId: 'r1', listing },
|
|
186
|
+
{ type: 'protocol_error', message: 'Not permitted for an observer: switch-session' },
|
|
187
|
+
{ type: 'protocol_error', message: 'Not permitted for an observer: new-session' },
|
|
188
|
+
]);
|
|
189
|
+
expect(directory.switchSession).not.toHaveBeenCalled();
|
|
190
|
+
expect(directory.newSession).not.toHaveBeenCalled();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3189: what a client that renders the whole session (the TUI) needs over the wire, beside the
|
|
3
|
+
* streamed turn — the full history, the context window as it changes, a signal that the history
|
|
4
|
+
* changed, and where each turn came from.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
8
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
9
|
+
|
|
10
|
+
import { createOutboundDelivery } from '../outbound-delivery.js';
|
|
11
|
+
import { createSessionMessageHandler } from '../session-message-handler.js';
|
|
12
|
+
import { HISTORY_PAGE_MAX_BYTES } from '../session-query-messages.js';
|
|
13
|
+
|
|
14
|
+
import type { IWireHistoryEntry, TClientMessage, TServerMessage } from '../wire-messages.js';
|
|
15
|
+
import type { IHistoryEntry } from '@robota-sdk/agent-core';
|
|
16
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
17
|
+
|
|
18
|
+
type TListener = (...args: unknown[]) => void;
|
|
19
|
+
type TEmittingSession = IInteractiveSession & { emit: (event: string, payload?: unknown) => void };
|
|
20
|
+
|
|
21
|
+
const RECORDED_AT = new Date('2026-09-26T01:02:03.004Z');
|
|
22
|
+
|
|
23
|
+
function createSession(overrides: Partial<IInteractiveSession> = {}): TEmittingSession {
|
|
24
|
+
const listeners = new Map<string, Set<TListener>>();
|
|
25
|
+
const session = createTestInteractiveSession({
|
|
26
|
+
on: ((event: string, handler: TListener) => {
|
|
27
|
+
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
28
|
+
listeners.get(event)!.add(handler);
|
|
29
|
+
}) as IInteractiveSession['on'],
|
|
30
|
+
off: ((event: string, handler: TListener) => {
|
|
31
|
+
listeners.get(event)?.delete(handler);
|
|
32
|
+
}) as IInteractiveSession['off'],
|
|
33
|
+
...overrides,
|
|
34
|
+
});
|
|
35
|
+
return Object.assign(session, {
|
|
36
|
+
emit: (event: string, payload?: unknown) => {
|
|
37
|
+
listeners.get(event)?.forEach((handler) => handler(payload));
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function attach(
|
|
43
|
+
session: IInteractiveSession,
|
|
44
|
+
role: 'drive' | 'observe' = 'drive',
|
|
45
|
+
): { sent: TServerMessage[]; send: (message: TClientMessage) => void; leave: () => void } {
|
|
46
|
+
const sent: TServerMessage[] = [];
|
|
47
|
+
const { onMessage, cleanup } = createSessionMessageHandler({
|
|
48
|
+
session,
|
|
49
|
+
role,
|
|
50
|
+
deliver: createOutboundDelivery((message) => sent.push(message), vi.fn()),
|
|
51
|
+
});
|
|
52
|
+
return { sent, send: (message) => onMessage(JSON.stringify(message)), leave: cleanup };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type THistoryFrame = Extract<TServerMessage, { type: 'history' }>;
|
|
56
|
+
|
|
57
|
+
function lastHistory(sent: readonly TServerMessage[]): THistoryFrame {
|
|
58
|
+
const frame = sent.at(-1);
|
|
59
|
+
if (frame?.type !== 'history') throw new Error(`expected a history frame, got ${frame?.type}`);
|
|
60
|
+
return frame;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Entries of about `bytes` serialized bytes each. */
|
|
64
|
+
function historyOf(count: number, bytes: number): IHistoryEntry[] {
|
|
65
|
+
return Array.from({ length: count }, (_, index) => ({
|
|
66
|
+
id: `e${index}`,
|
|
67
|
+
timestamp: RECORDED_AT,
|
|
68
|
+
category: 'chat',
|
|
69
|
+
type: 'assistant',
|
|
70
|
+
data: { role: 'assistant', content: 'x'.repeat(bytes) },
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Read the whole history the way a client does: the next page only after the previous one. */
|
|
75
|
+
function readAll(client: ReturnType<typeof attach>): {
|
|
76
|
+
pages: THistoryFrame[];
|
|
77
|
+
entries: IWireHistoryEntry[];
|
|
78
|
+
} {
|
|
79
|
+
const pages: THistoryFrame[] = [];
|
|
80
|
+
const entries: IWireHistoryEntry[] = [];
|
|
81
|
+
client.send({ type: 'get-history' });
|
|
82
|
+
for (;;) {
|
|
83
|
+
const page = lastHistory(client.sent);
|
|
84
|
+
pages.push(page);
|
|
85
|
+
entries.push(...page.entries);
|
|
86
|
+
const next = page.startIndex + page.entries.length;
|
|
87
|
+
if (next >= page.total) return { pages, entries };
|
|
88
|
+
client.send({ type: 'get-history', fromIndex: next });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe('get-history (#3189)', () => {
|
|
93
|
+
it('answers with the full history, each timestamp an ISO 8601 string', () => {
|
|
94
|
+
const session = createSession({
|
|
95
|
+
getFullHistory: () => [
|
|
96
|
+
{
|
|
97
|
+
id: 'm1',
|
|
98
|
+
timestamp: RECORDED_AT,
|
|
99
|
+
category: 'chat',
|
|
100
|
+
type: 'user',
|
|
101
|
+
data: { role: 'user', content: 'hi' },
|
|
102
|
+
},
|
|
103
|
+
{ id: 'e1', timestamp: RECORDED_AT, category: 'event', type: 'skill-activation' },
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
const client = attach(session);
|
|
107
|
+
|
|
108
|
+
client.send({ type: 'get-history' });
|
|
109
|
+
|
|
110
|
+
expect(client.sent).toEqual([
|
|
111
|
+
{
|
|
112
|
+
type: 'history',
|
|
113
|
+
startIndex: 0,
|
|
114
|
+
total: 2,
|
|
115
|
+
entries: [
|
|
116
|
+
{
|
|
117
|
+
id: 'm1',
|
|
118
|
+
timestamp: '2026-09-26T01:02:03.004Z',
|
|
119
|
+
category: 'chat',
|
|
120
|
+
type: 'user',
|
|
121
|
+
data: { role: 'user', content: 'hi' },
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: 'e1',
|
|
125
|
+
timestamp: '2026-09-26T01:02:03.004Z',
|
|
126
|
+
category: 'event',
|
|
127
|
+
type: 'skill-activation',
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('carries a resumed entry, whose timestamp came back from JSON as a string, unchanged', () => {
|
|
135
|
+
const resumed = JSON.parse(
|
|
136
|
+
JSON.stringify([{ id: 'm1', timestamp: RECORDED_AT, category: 'chat', type: 'user' }]),
|
|
137
|
+
) as ReturnType<IInteractiveSession['getFullHistory']>;
|
|
138
|
+
const client = attach(createSession({ getFullHistory: () => resumed }));
|
|
139
|
+
|
|
140
|
+
client.send({ type: 'get-history' });
|
|
141
|
+
|
|
142
|
+
expect(client.sent).toEqual([
|
|
143
|
+
{
|
|
144
|
+
type: 'history',
|
|
145
|
+
startIndex: 0,
|
|
146
|
+
total: 1,
|
|
147
|
+
entries: [
|
|
148
|
+
{ id: 'm1', timestamp: '2026-09-26T01:02:03.004Z', category: 'chat', type: 'user' },
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('is a read, so an observer may send it', () => {
|
|
155
|
+
const session = createSession({
|
|
156
|
+
getFullHistory: () => [{ id: 'm1', timestamp: RECORDED_AT, category: 'chat', type: 'user' }],
|
|
157
|
+
});
|
|
158
|
+
const observer = attach(session, 'observe');
|
|
159
|
+
|
|
160
|
+
observer.send({ type: 'get-history' });
|
|
161
|
+
|
|
162
|
+
expect(observer.sent).toEqual([
|
|
163
|
+
{
|
|
164
|
+
type: 'history',
|
|
165
|
+
startIndex: 0,
|
|
166
|
+
total: 1,
|
|
167
|
+
entries: [
|
|
168
|
+
{ id: 'm1', timestamp: '2026-09-26T01:02:03.004Z', category: 'chat', type: 'user' },
|
|
169
|
+
],
|
|
170
|
+
},
|
|
171
|
+
]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('sends a long history in pages under the page budget, which read back to the whole of it', () => {
|
|
175
|
+
const history = historyOf(40, 20_000);
|
|
176
|
+
const client = attach(createSession({ getFullHistory: () => history }));
|
|
177
|
+
|
|
178
|
+
const { pages, entries } = readAll(client);
|
|
179
|
+
|
|
180
|
+
expect(pages.length).toBeGreaterThan(1);
|
|
181
|
+
for (const page of pages) {
|
|
182
|
+
expect(new TextEncoder().encode(JSON.stringify(page.entries)).byteLength).toBeLessThanOrEqual(
|
|
183
|
+
HISTORY_PAGE_MAX_BYTES,
|
|
184
|
+
);
|
|
185
|
+
expect(page.total).toBe(40);
|
|
186
|
+
}
|
|
187
|
+
expect(entries.map((entry) => entry.id)).toEqual(history.map((entry) => entry.id));
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('sends an entry larger than the page budget alone, so a reader still moves forward', () => {
|
|
191
|
+
const history = [...historyOf(1, 10), ...historyOf(1, HISTORY_PAGE_MAX_BYTES + 1)];
|
|
192
|
+
history[1] = { ...history[1]!, id: 'big' };
|
|
193
|
+
const client = attach(createSession({ getFullHistory: () => history }));
|
|
194
|
+
|
|
195
|
+
const { pages } = readAll(client);
|
|
196
|
+
|
|
197
|
+
expect(pages.map((page) => page.entries.map((entry) => entry.id))).toEqual([['e0'], ['big']]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('answers from the index asked, and says where the history ends when that is past it', () => {
|
|
201
|
+
const history = historyOf(3, 10);
|
|
202
|
+
const client = attach(createSession({ getFullHistory: () => history }));
|
|
203
|
+
|
|
204
|
+
client.send({ type: 'get-history', fromIndex: 2 });
|
|
205
|
+
expect(lastHistory(client.sent)).toMatchObject({ startIndex: 2, total: 3 });
|
|
206
|
+
expect(lastHistory(client.sent).entries.map((entry) => entry.id)).toEqual(['e2']);
|
|
207
|
+
|
|
208
|
+
// A client that knows more than the session holds learns it from `total`, and reads again.
|
|
209
|
+
client.send({ type: 'get-history', fromIndex: 7 });
|
|
210
|
+
expect(lastHistory(client.sent)).toEqual({
|
|
211
|
+
type: 'history',
|
|
212
|
+
startIndex: 3,
|
|
213
|
+
total: 3,
|
|
214
|
+
entries: [],
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('turn results on the wire (#3189)', () => {
|
|
220
|
+
it.each(['complete', 'interrupted'] as const)(
|
|
221
|
+
'sends %s without the session history, which only grows',
|
|
222
|
+
(event) => {
|
|
223
|
+
const session = createSession();
|
|
224
|
+
const client = attach(session);
|
|
225
|
+
const contextState = { usedTokens: 1, maxTokens: 10, usedPercentage: 10 };
|
|
226
|
+
|
|
227
|
+
session.emit(event, {
|
|
228
|
+
response: 'done',
|
|
229
|
+
history: historyOf(50, 50_000),
|
|
230
|
+
toolSummaries: [],
|
|
231
|
+
contextState,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
expect(client.sent).toEqual([
|
|
235
|
+
{ type: event, result: { response: 'done', toolSummaries: [], contextState } },
|
|
236
|
+
]);
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('get-prompts (#3189)', () => {
|
|
242
|
+
const permission = { id: 'p1', toolName: 'Bash', toolArgs: { command: 'ls' } };
|
|
243
|
+
const ask = { id: 'a1', request: { kind: 'text', title: 'Name?' } };
|
|
244
|
+
|
|
245
|
+
it('sends a client that attached later the prompts still open, as the frames that asked them', () => {
|
|
246
|
+
const session = createSession();
|
|
247
|
+
const first = attach(session);
|
|
248
|
+
session.emit('permission_request', permission);
|
|
249
|
+
session.emit('ask_request', ask);
|
|
250
|
+
session.emit('prompt_resolved', { id: 'a1' });
|
|
251
|
+
|
|
252
|
+
const later = attach(session);
|
|
253
|
+
later.send({ type: 'get-prompts' });
|
|
254
|
+
|
|
255
|
+
expect(first.sent.map((frame) => frame.type)).toEqual([
|
|
256
|
+
'permission_request',
|
|
257
|
+
'ask_request',
|
|
258
|
+
'prompt_resolved',
|
|
259
|
+
]);
|
|
260
|
+
expect(later.sent).toEqual([{ type: 'permission_request', event: permission }]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("forgets the previous session's prompts on a switch, and all of them once nobody can answer", () => {
|
|
264
|
+
const session = createSession();
|
|
265
|
+
const first = attach(session);
|
|
266
|
+
session.emit('permission_request', permission);
|
|
267
|
+
session.emit('session_switched', { sessionId: 'session-2' });
|
|
268
|
+
first.send({ type: 'get-prompts' });
|
|
269
|
+
expect(first.sent.at(-1)).toEqual({
|
|
270
|
+
type: 'session_switched',
|
|
271
|
+
event: { sessionId: 'session-2' },
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
session.emit('permission_request', { ...permission, id: 'p2' });
|
|
275
|
+
first.leave();
|
|
276
|
+
const next = attach(session);
|
|
277
|
+
next.send({ type: 'get-prompts' });
|
|
278
|
+
expect(next.sent).toEqual([]);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('is not a read an observer may send: an observer never receives prompts', () => {
|
|
282
|
+
const session = createSession();
|
|
283
|
+
attach(session);
|
|
284
|
+
session.emit('permission_request', permission);
|
|
285
|
+
const observer = attach(session, 'observe');
|
|
286
|
+
|
|
287
|
+
observer.send({ type: 'get-prompts' });
|
|
288
|
+
|
|
289
|
+
expect(observer.sent).toEqual([
|
|
290
|
+
{ type: 'protocol_error', message: 'Not permitted for an observer: get-prompts' },
|
|
291
|
+
]);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe('pushed session state (#3189)', () => {
|
|
296
|
+
it('delivers a context_update as the context frame get-context answers with', () => {
|
|
297
|
+
const session = createSession();
|
|
298
|
+
const client = attach(session);
|
|
299
|
+
const state = { usedTokens: 900, maxTokens: 1000, usedPercentage: 90, remainingPercentage: 10 };
|
|
300
|
+
|
|
301
|
+
session.emit('context_update', state);
|
|
302
|
+
|
|
303
|
+
expect(client.sent).toEqual([{ type: 'context', state }]);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it.each(['compact', 'skill_activation', 'memory_event'])(
|
|
307
|
+
'tells the client the history changed on %s, without sending the entries',
|
|
308
|
+
(event) => {
|
|
309
|
+
const session = createSession();
|
|
310
|
+
const client = attach(session);
|
|
311
|
+
|
|
312
|
+
session.emit(event, {});
|
|
313
|
+
|
|
314
|
+
expect(client.sent).toEqual([{ type: 'history_changed' }]);
|
|
315
|
+
},
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
it('delivers where a turn came from', () => {
|
|
319
|
+
const session = createSession();
|
|
320
|
+
const client = attach(session);
|
|
321
|
+
|
|
322
|
+
session.emit('turn_source', 'agent-wakeup');
|
|
323
|
+
|
|
324
|
+
expect(client.sent).toEqual([{ type: 'turn_source', source: 'agent-wakeup' }]);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('pushes the same frames to an observer, which follows the session read-only', () => {
|
|
328
|
+
const session = createSession();
|
|
329
|
+
const observer = attach(session, 'observe');
|
|
330
|
+
const state = { usedTokens: 1, maxTokens: 10, usedPercentage: 10, remainingPercentage: 90 };
|
|
331
|
+
|
|
332
|
+
session.emit('context_update', state);
|
|
333
|
+
session.emit('compact', {});
|
|
334
|
+
session.emit('turn_source', 'user');
|
|
335
|
+
|
|
336
|
+
expect(observer.sent).toEqual([
|
|
337
|
+
{ type: 'context', state },
|
|
338
|
+
{ type: 'history_changed' },
|
|
339
|
+
{ type: 'turn_source', source: 'user' },
|
|
340
|
+
]);
|
|
341
|
+
});
|
|
342
|
+
});
|