@robota-sdk/agent-transport-acp 3.0.0-beta.96 → 3.0.1
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 +362 -0
- package/README.md +7 -3
- package/dist/node/index.cjs +3 -3
- package/dist/node/index.d.cts +39 -7
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +39 -7
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +3 -3
- package/dist/node/index.js.map +1 -1
- package/package.json +6 -6
- package/src/__tests__/acp-editor-files.test.ts +236 -0
- package/src/__tests__/acp-session-load.test.ts +649 -0
- package/src/__tests__/acp-test-harness.ts +9 -1
- package/src/acp-agent.ts +180 -42
- package/src/acp-editor-files.ts +87 -0
- package/src/acp-session-catalog.ts +65 -0
- package/src/acp-tool-calls.ts +21 -11
- package/src/acp-transport.ts +5 -0
- package/src/acp-turn.ts +2 -2
- package/src/acp-types.ts +39 -5
- package/src/index.ts +2 -0
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createFakeSession,
|
|
5
|
+
endedTurn,
|
|
6
|
+
executionResult,
|
|
7
|
+
openSession,
|
|
8
|
+
SESSION_ID,
|
|
9
|
+
settle,
|
|
10
|
+
startTransport,
|
|
11
|
+
textPrompt,
|
|
12
|
+
WORKSPACE,
|
|
13
|
+
} from './acp-test-harness.js';
|
|
14
|
+
|
|
15
|
+
import type { IFakeSession } from './acp-test-harness.js';
|
|
16
|
+
import type { IAcpTransportOptions, IAcpTransportSession } from '../acp-types.js';
|
|
17
|
+
import type { IHistoryDisplaySegment } from '@robota-sdk/agent-interface-session';
|
|
18
|
+
|
|
19
|
+
type ICommandListEntry = ReturnType<NonNullable<IAcpTransportSession['listCommands']>>[number];
|
|
20
|
+
type ICommandSkillListEntry = ReturnType<NonNullable<IAcpTransportSession['listSkills']>>[number];
|
|
21
|
+
|
|
22
|
+
const open: { stop(): Promise<void> }[] = [];
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
while (open.length) await open.pop()!.stop();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
async function start(fake: IFakeSession, overrides: Partial<IAcpTransportOptions> = {}) {
|
|
28
|
+
const fixture = await startTransport(fake.session, overrides);
|
|
29
|
+
open.push(fixture);
|
|
30
|
+
return fixture;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const command = (name: string, extra: Partial<ICommandListEntry> = {}): ICommandListEntry => ({
|
|
34
|
+
name,
|
|
35
|
+
description: `${name} command`,
|
|
36
|
+
modelInvocable: false,
|
|
37
|
+
runner: 'runtime',
|
|
38
|
+
...extra,
|
|
39
|
+
});
|
|
40
|
+
const skill = (
|
|
41
|
+
name: string,
|
|
42
|
+
extra: Partial<ICommandSkillListEntry> = {},
|
|
43
|
+
): ICommandSkillListEntry => ({
|
|
44
|
+
name,
|
|
45
|
+
description: `${name} skill`,
|
|
46
|
+
source: 'project',
|
|
47
|
+
modelInvocable: true,
|
|
48
|
+
userInvocable: true,
|
|
49
|
+
...extra,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('published narrow session compatibility', () => {
|
|
53
|
+
function narrowSession(): IAcpTransportSession {
|
|
54
|
+
const {
|
|
55
|
+
submit,
|
|
56
|
+
abort,
|
|
57
|
+
cancelQueue,
|
|
58
|
+
on,
|
|
59
|
+
off,
|
|
60
|
+
resolvePermission,
|
|
61
|
+
resolveAsk,
|
|
62
|
+
getStatusSnapshot,
|
|
63
|
+
} = createFakeSession().session;
|
|
64
|
+
return {
|
|
65
|
+
submit,
|
|
66
|
+
abort,
|
|
67
|
+
cancelQueue,
|
|
68
|
+
on,
|
|
69
|
+
off,
|
|
70
|
+
resolvePermission,
|
|
71
|
+
resolveAsk,
|
|
72
|
+
getStatusSnapshot,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
it('serves prompts and slash commands without catalog or history capabilities', async () => {
|
|
77
|
+
const fixture = await startTransport(narrowSession(), {
|
|
78
|
+
executeCommand: async () => 'NARROW_COMMAND_DONE',
|
|
79
|
+
});
|
|
80
|
+
open.push(fixture);
|
|
81
|
+
const sessionId = await openSession(fixture.editor);
|
|
82
|
+
for (const text of ['ordinary prompt', '/help']) {
|
|
83
|
+
await expect(
|
|
84
|
+
fixture.editor.connection.agent.request('session/prompt', {
|
|
85
|
+
sessionId,
|
|
86
|
+
prompt: textPrompt(text),
|
|
87
|
+
}),
|
|
88
|
+
).resolves.toEqual({ stopReason: 'end_turn' });
|
|
89
|
+
}
|
|
90
|
+
await settle();
|
|
91
|
+
expect(fixture.deliveryErrors).toEqual([]);
|
|
92
|
+
expect(fixture.editor.commandLists).toEqual([]);
|
|
93
|
+
expect(fixture.editor.updates).toContainEqual({
|
|
94
|
+
sessionUpdate: 'agent_message_chunk',
|
|
95
|
+
content: { type: 'text', text: 'NARROW_COMMAND_DONE' },
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('does not offer or invoke a load port without history capability', async () => {
|
|
100
|
+
const loadSession = vi.fn(async () => ({ loaded: true as const }));
|
|
101
|
+
const fixture = await startTransport(narrowSession(), { loadSession });
|
|
102
|
+
open.push(fixture);
|
|
103
|
+
const initialized = await fixture.editor.connection.agent.request('initialize', {
|
|
104
|
+
protocolVersion: 1,
|
|
105
|
+
});
|
|
106
|
+
expect(initialized.agentCapabilities?.loadSession).toBe(false);
|
|
107
|
+
await expect(
|
|
108
|
+
fixture.editor.connection.agent.request('session/load', {
|
|
109
|
+
sessionId: 'stored',
|
|
110
|
+
cwd: WORKSPACE,
|
|
111
|
+
mcpServers: [],
|
|
112
|
+
}),
|
|
113
|
+
).rejects.toMatchObject({ code: -32601 });
|
|
114
|
+
expect(loadSession).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('available commands', () => {
|
|
119
|
+
it('advertises the runtime commands and user-invocable skills once the session is created', async () => {
|
|
120
|
+
const fake = createFakeSession();
|
|
121
|
+
fake.session.listCommands = () => [
|
|
122
|
+
command('help'),
|
|
123
|
+
command('model', { argumentHint: '<model>' }),
|
|
124
|
+
command('exit', { runner: 'client' }),
|
|
125
|
+
];
|
|
126
|
+
fake.session.listSkills = () => [
|
|
127
|
+
skill('review', { argumentHint: '[path]' }),
|
|
128
|
+
skill('internal', { userInvocable: false }),
|
|
129
|
+
skill('help'),
|
|
130
|
+
];
|
|
131
|
+
const { editor } = await start(fake);
|
|
132
|
+
await openSession(editor);
|
|
133
|
+
await vi.waitFor(() => expect(editor.commandLists).toHaveLength(1));
|
|
134
|
+
expect(editor.commandLists[0]).toEqual([
|
|
135
|
+
{ name: 'help', description: 'help command' },
|
|
136
|
+
{ name: 'model', description: 'model command', input: { hint: '<model>' } },
|
|
137
|
+
{ name: 'review', description: 'review skill', input: { hint: '[path]' } },
|
|
138
|
+
]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('sends the list only after the reply that gives the editor the session id', async () => {
|
|
142
|
+
const fake = createFakeSession();
|
|
143
|
+
fake.session.listCommands = () => [command('help')];
|
|
144
|
+
const { editor } = await start(fake);
|
|
145
|
+
await editor.connection.agent.request('initialize', { protocolVersion: 1 });
|
|
146
|
+
let listsWhenAnswered = -1;
|
|
147
|
+
await editor.connection.agent
|
|
148
|
+
.request('session/new', { cwd: WORKSPACE, mcpServers: [] })
|
|
149
|
+
.then(() => (listsWhenAnswered = editor.commandLists.length));
|
|
150
|
+
expect(listsWhenAnswered).toBe(0);
|
|
151
|
+
await vi.waitFor(() => expect(editor.commandLists).toHaveLength(1));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('keeps serving when the command catalog cannot be read, and reports why', async () => {
|
|
155
|
+
const fake = createFakeSession();
|
|
156
|
+
fake.session.listSkills = () => {
|
|
157
|
+
throw new Error('skill roots unreadable');
|
|
158
|
+
};
|
|
159
|
+
const fixture = await start(fake, { executeCommand: async () => 'ran' });
|
|
160
|
+
const sessionId = await openSession(fixture.editor);
|
|
161
|
+
await expect(
|
|
162
|
+
fixture.editor.connection.agent.request('session/prompt', {
|
|
163
|
+
sessionId,
|
|
164
|
+
prompt: textPrompt('/x'),
|
|
165
|
+
}),
|
|
166
|
+
).resolves.toEqual({ stopReason: 'end_turn' });
|
|
167
|
+
await vi.waitFor(() =>
|
|
168
|
+
expect(fixture.deliveryErrors.map((e) => e.context)).toContain('available_commands_update'),
|
|
169
|
+
);
|
|
170
|
+
expect(fixture.editor.commandLists).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('sends the list again after a command only when the command changed it', async () => {
|
|
174
|
+
const fake = createFakeSession();
|
|
175
|
+
let skills = [skill('review')];
|
|
176
|
+
fake.session.listSkills = () => skills;
|
|
177
|
+
const executeCommand = vi.fn(async (text: string) => {
|
|
178
|
+
if (text === '/reload') skills = [skill('review'), skill('deploy')];
|
|
179
|
+
return 'done';
|
|
180
|
+
});
|
|
181
|
+
const { editor } = await start(fake, { executeCommand });
|
|
182
|
+
const sessionId = await openSession(editor);
|
|
183
|
+
await vi.waitFor(() => expect(editor.commandLists).toHaveLength(1));
|
|
184
|
+
const prompt = (text: string) =>
|
|
185
|
+
editor.connection.agent.request('session/prompt', { sessionId, prompt: textPrompt(text) });
|
|
186
|
+
await prompt('/help');
|
|
187
|
+
await settle();
|
|
188
|
+
expect(editor.commandLists).toHaveLength(1);
|
|
189
|
+
await prompt('/reload');
|
|
190
|
+
await settle();
|
|
191
|
+
expect(editor.commandLists.map((list) => list.map((c) => c.name))).toEqual([
|
|
192
|
+
['review'],
|
|
193
|
+
['review', 'deploy'],
|
|
194
|
+
]);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('available commands after a model turn', () => {
|
|
199
|
+
it('sends the list again after a prompt that is not a command when the turn changed it', async () => {
|
|
200
|
+
let skills = [skill('review')];
|
|
201
|
+
const fake = createFakeSession(async () => {
|
|
202
|
+
// A model turn can install or remove a skill, as a command can.
|
|
203
|
+
skills = [skill('review'), skill('deploy')];
|
|
204
|
+
return endedTurn(executionResult('installed'));
|
|
205
|
+
});
|
|
206
|
+
fake.session.listSkills = () => skills;
|
|
207
|
+
const { editor } = await start(fake);
|
|
208
|
+
const sessionId = await openSession(editor);
|
|
209
|
+
await vi.waitFor(() => expect(editor.commandLists).toHaveLength(1));
|
|
210
|
+
await editor.connection.agent.request('session/prompt', {
|
|
211
|
+
sessionId,
|
|
212
|
+
prompt: textPrompt('add the deploy skill'),
|
|
213
|
+
});
|
|
214
|
+
await vi.waitFor(() => expect(editor.commandLists).toHaveLength(2));
|
|
215
|
+
expect(editor.commandLists[1]!.map((c) => c.name)).toEqual(['review', 'deploy']);
|
|
216
|
+
// A turn that leaves the list as it is sends nothing.
|
|
217
|
+
await editor.connection.agent.request('session/prompt', {
|
|
218
|
+
sessionId,
|
|
219
|
+
prompt: textPrompt('thanks'),
|
|
220
|
+
});
|
|
221
|
+
await settle();
|
|
222
|
+
expect(editor.commandLists).toHaveLength(2);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('session/load', () => {
|
|
227
|
+
const history: IHistoryDisplaySegment[] = [
|
|
228
|
+
{ type: 'text', role: 'user', content: 'read the probe' },
|
|
229
|
+
{
|
|
230
|
+
type: 'tool',
|
|
231
|
+
tool: {
|
|
232
|
+
toolName: 'Read',
|
|
233
|
+
firstArg: 'PROBE.txt',
|
|
234
|
+
isRunning: false,
|
|
235
|
+
result: 'success',
|
|
236
|
+
executionId: 'call-old',
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
type: 'tool',
|
|
241
|
+
tool: { toolName: 'Shell', firstArg: 'rm -rf x', isRunning: false, result: 'denied' },
|
|
242
|
+
},
|
|
243
|
+
{ type: 'tool', tool: { toolName: 'goal', firstArg: '', isRunning: false, internal: true } },
|
|
244
|
+
{ type: 'text', role: 'assistant', content: 'It says probe.' },
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
/** A host whose load makes the attached session the stored one, with its id and history. */
|
|
248
|
+
function loadable(fake: IFakeSession, known = 'stored-1') {
|
|
249
|
+
const snapshot = fake.session.getStatusSnapshot.bind(fake.session);
|
|
250
|
+
let current: string | undefined;
|
|
251
|
+
fake.session.getStatusSnapshot = () => ({
|
|
252
|
+
...snapshot(),
|
|
253
|
+
sessionId: current ?? snapshot().sessionId,
|
|
254
|
+
});
|
|
255
|
+
fake.session.getMessagesDisplay = () => (current === undefined ? [] : history);
|
|
256
|
+
return vi.fn(async (id: string) => {
|
|
257
|
+
if (id !== known) return { loaded: false as const, message: `No session ${id}` };
|
|
258
|
+
current = id;
|
|
259
|
+
return { loaded: true as const };
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
it('is offered only when the host can load a session', async () => {
|
|
264
|
+
const without = await start(createFakeSession());
|
|
265
|
+
await expect(
|
|
266
|
+
without.editor.connection.agent.request('initialize', { protocolVersion: 1 }),
|
|
267
|
+
).resolves.toMatchObject({ agentCapabilities: { loadSession: false } });
|
|
268
|
+
const fake = createFakeSession();
|
|
269
|
+
const withPort = await start(fake, { loadSession: loadable(fake) });
|
|
270
|
+
await expect(
|
|
271
|
+
withPort.editor.connection.agent.request('initialize', { protocolVersion: 1 }),
|
|
272
|
+
).resolves.toMatchObject({ agentCapabilities: { loadSession: true } });
|
|
273
|
+
await expect(
|
|
274
|
+
without.editor.connection.agent.request('session/load', {
|
|
275
|
+
sessionId: 'stored-1',
|
|
276
|
+
cwd: WORKSPACE,
|
|
277
|
+
mcpServers: [],
|
|
278
|
+
}),
|
|
279
|
+
).rejects.toMatchObject({ code: -32601 });
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('replays the stored conversation before it answers, then serves prompts on that session', async () => {
|
|
283
|
+
const fake = createFakeSession(async () => endedTurn(executionResult('next')));
|
|
284
|
+
const loadSession = loadable(fake);
|
|
285
|
+
const { editor } = await start(fake, { loadSession });
|
|
286
|
+
const { agent } = editor.connection;
|
|
287
|
+
await agent.request('initialize', { protocolVersion: 1 });
|
|
288
|
+
let seenWhenAnswered = -1;
|
|
289
|
+
await agent
|
|
290
|
+
.request('session/load', { sessionId: 'stored-1', cwd: WORKSPACE, mcpServers: [] })
|
|
291
|
+
.then(() => (seenWhenAnswered = editor.updates.length));
|
|
292
|
+
expect(loadSession).toHaveBeenCalledWith('stored-1');
|
|
293
|
+
expect(editor.updates).toEqual([
|
|
294
|
+
{ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'read the probe' } },
|
|
295
|
+
{
|
|
296
|
+
sessionUpdate: 'tool_call',
|
|
297
|
+
toolCallId: 'call-old',
|
|
298
|
+
title: 'Read: PROBE.txt',
|
|
299
|
+
kind: 'read',
|
|
300
|
+
status: 'completed',
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
sessionUpdate: 'tool_call',
|
|
304
|
+
toolCallId: 'tool-1',
|
|
305
|
+
title: 'Shell: rm -rf x',
|
|
306
|
+
kind: 'execute',
|
|
307
|
+
status: 'failed',
|
|
308
|
+
},
|
|
309
|
+
{ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'It says probe.' } },
|
|
310
|
+
]);
|
|
311
|
+
expect(seenWhenAnswered).toBe(4);
|
|
312
|
+
await vi.waitFor(() => expect(editor.commandLists).toHaveLength(1));
|
|
313
|
+
await expect(
|
|
314
|
+
agent.request('session/prompt', { sessionId: 'stored-1', prompt: textPrompt('go on') }),
|
|
315
|
+
).resolves.toEqual({ stopReason: 'end_turn' });
|
|
316
|
+
await expect(
|
|
317
|
+
agent.request('session/new', { cwd: WORKSPACE, mcpServers: [] }),
|
|
318
|
+
).rejects.toMatchObject({ code: -32602 });
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('refuses what session/new refuses, and an id the host does not know', async () => {
|
|
322
|
+
const fake = createFakeSession();
|
|
323
|
+
const loadSession = loadable(fake);
|
|
324
|
+
let admitted = true;
|
|
325
|
+
const { editor } = await start(fake, {
|
|
326
|
+
loadSession,
|
|
327
|
+
admitWorkspace: () =>
|
|
328
|
+
admitted ? { admitted: true } : { admitted: false, message: 'Run robota trust --yes' },
|
|
329
|
+
});
|
|
330
|
+
const { agent } = editor.connection;
|
|
331
|
+
await agent.request('initialize', { protocolVersion: 1 });
|
|
332
|
+
const load = (params: Partial<{ sessionId: string; cwd: string; mcpServers: never[] }>) =>
|
|
333
|
+
agent.request('session/load', {
|
|
334
|
+
sessionId: 'stored-1',
|
|
335
|
+
cwd: WORKSPACE,
|
|
336
|
+
mcpServers: [],
|
|
337
|
+
...params,
|
|
338
|
+
} as never);
|
|
339
|
+
await expect(load({ cwd: '/elsewhere' })).rejects.toMatchObject({ code: -32602 });
|
|
340
|
+
await expect(
|
|
341
|
+
load({ mcpServers: [{ name: 'x', command: 'x', args: [], env: [] }] as never }),
|
|
342
|
+
).rejects.toMatchObject({ code: -32602 });
|
|
343
|
+
await expect(load({ sessionId: 'unknown' })).rejects.toMatchObject({
|
|
344
|
+
code: -32602,
|
|
345
|
+
message: expect.stringContaining('No session unknown'),
|
|
346
|
+
});
|
|
347
|
+
admitted = false;
|
|
348
|
+
await expect(load({})).rejects.toMatchObject({
|
|
349
|
+
code: -32001,
|
|
350
|
+
data: { reason: 'workspace-not-trusted' },
|
|
351
|
+
});
|
|
352
|
+
expect(loadSession).toHaveBeenCalledTimes(1);
|
|
353
|
+
expect(editor.updates).toEqual([]);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('refuses a load while a prompt is running', async () => {
|
|
357
|
+
let finish!: () => void;
|
|
358
|
+
const held = new Promise<void>((resolve) => (finish = resolve));
|
|
359
|
+
const fake = createFakeSession(async () => {
|
|
360
|
+
await held;
|
|
361
|
+
return endedTurn();
|
|
362
|
+
});
|
|
363
|
+
const loadSession = loadable(fake);
|
|
364
|
+
const { editor } = await start(fake, { loadSession });
|
|
365
|
+
const sessionId = await openSession(editor);
|
|
366
|
+
const running = editor.connection.agent.request('session/prompt', {
|
|
367
|
+
sessionId,
|
|
368
|
+
prompt: textPrompt('wait'),
|
|
369
|
+
});
|
|
370
|
+
await settle();
|
|
371
|
+
await expect(
|
|
372
|
+
editor.connection.agent.request('session/load', {
|
|
373
|
+
sessionId: 'stored-1',
|
|
374
|
+
cwd: WORKSPACE,
|
|
375
|
+
mcpServers: [],
|
|
376
|
+
}),
|
|
377
|
+
).rejects.toMatchObject({ code: -32602 });
|
|
378
|
+
finish();
|
|
379
|
+
await running;
|
|
380
|
+
expect(loadSession).not.toHaveBeenCalled();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('replaces the session a connection already serves, and serves only the loaded id afterwards', async () => {
|
|
384
|
+
const fake = createFakeSession(async () => endedTurn(executionResult('next')));
|
|
385
|
+
fake.session.listCommands = () => [command('help')];
|
|
386
|
+
const loadSession = loadable(fake);
|
|
387
|
+
const { editor } = await start(fake, { loadSession });
|
|
388
|
+
const first = await openSession(editor);
|
|
389
|
+
await vi.waitFor(() => expect(editor.commandLists).toHaveLength(1));
|
|
390
|
+
await editor.connection.agent.request('session/load', {
|
|
391
|
+
sessionId: 'stored-1',
|
|
392
|
+
cwd: WORKSPACE,
|
|
393
|
+
mcpServers: [],
|
|
394
|
+
});
|
|
395
|
+
// The loaded session gets its own list, even though the commands are the same.
|
|
396
|
+
await vi.waitFor(() => expect(editor.commandLists).toHaveLength(2));
|
|
397
|
+
await expect(
|
|
398
|
+
editor.connection.agent.request('session/prompt', {
|
|
399
|
+
sessionId: first,
|
|
400
|
+
prompt: textPrompt('old'),
|
|
401
|
+
}),
|
|
402
|
+
).rejects.toMatchObject({ code: -32602 });
|
|
403
|
+
await expect(
|
|
404
|
+
editor.connection.agent.request('session/prompt', {
|
|
405
|
+
sessionId: 'stored-1',
|
|
406
|
+
prompt: textPrompt('new'),
|
|
407
|
+
}),
|
|
408
|
+
).resolves.toEqual({ stopReason: 'end_turn' });
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
/** What a request settled with so far, so a request that is wrongly left waiting fails an assertion instead of hanging. */
|
|
412
|
+
function outcomeOf(request: Promise<unknown>): { readonly current: unknown } {
|
|
413
|
+
const box: { current: unknown } = { current: 'still waiting' };
|
|
414
|
+
request.then(
|
|
415
|
+
(value) => (box.current = value),
|
|
416
|
+
(error: unknown) => (box.current = error),
|
|
417
|
+
);
|
|
418
|
+
return box;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
it('refuses a load while a session/new is still waiting for its session to start', async () => {
|
|
422
|
+
const fake = createFakeSession();
|
|
423
|
+
let started!: () => void;
|
|
424
|
+
// A session that builds itself in the background names itself only once it has started.
|
|
425
|
+
const building: Pick<IAcpTransportSession, 'whenInitialized'> = {
|
|
426
|
+
whenInitialized: () => new Promise<void>((resolve) => (started = resolve)),
|
|
427
|
+
};
|
|
428
|
+
Object.assign(fake.session, building);
|
|
429
|
+
const loadSession = loadable(fake);
|
|
430
|
+
const { editor } = await start(fake, { loadSession });
|
|
431
|
+
const { agent } = editor.connection;
|
|
432
|
+
await agent.request('initialize', { protocolVersion: 1 });
|
|
433
|
+
const creating = agent.request('session/new', { cwd: WORKSPACE, mcpServers: [] });
|
|
434
|
+
await settle();
|
|
435
|
+
const loading = outcomeOf(
|
|
436
|
+
agent.request('session/load', { sessionId: 'stored-1', cwd: WORKSPACE, mcpServers: [] }),
|
|
437
|
+
);
|
|
438
|
+
try {
|
|
439
|
+
await vi.waitFor(() =>
|
|
440
|
+
expect(loading.current).toMatchObject({
|
|
441
|
+
code: -32602,
|
|
442
|
+
message: expect.stringContaining('already being opened'),
|
|
443
|
+
}),
|
|
444
|
+
);
|
|
445
|
+
// The refused load reached neither the host nor the session the new request is about to name.
|
|
446
|
+
expect(loadSession).not.toHaveBeenCalled();
|
|
447
|
+
} finally {
|
|
448
|
+
started();
|
|
449
|
+
}
|
|
450
|
+
await expect(creating).resolves.toEqual({ sessionId: SESSION_ID });
|
|
451
|
+
expect(loadSession).not.toHaveBeenCalled();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('refuses a load while another load is still in progress, and asks the host only once', async () => {
|
|
455
|
+
const fake = createFakeSession();
|
|
456
|
+
let finish!: () => void;
|
|
457
|
+
const held = new Promise<void>((resolve) => (finish = resolve));
|
|
458
|
+
const loadSession = vi.fn(async () => {
|
|
459
|
+
await held;
|
|
460
|
+
return { loaded: true as const };
|
|
461
|
+
});
|
|
462
|
+
const { editor } = await start(fake, { loadSession });
|
|
463
|
+
const { agent } = editor.connection;
|
|
464
|
+
await agent.request('initialize', { protocolVersion: 1 });
|
|
465
|
+
const params = { sessionId: 'stored-1', cwd: WORKSPACE, mcpServers: [] };
|
|
466
|
+
const first = agent.request('session/load', params);
|
|
467
|
+
await settle();
|
|
468
|
+
const second = outcomeOf(agent.request('session/load', params));
|
|
469
|
+
try {
|
|
470
|
+
await vi.waitFor(() =>
|
|
471
|
+
expect(second.current).toMatchObject({
|
|
472
|
+
code: -32602,
|
|
473
|
+
message: expect.stringContaining('already being opened'),
|
|
474
|
+
}),
|
|
475
|
+
);
|
|
476
|
+
expect(loadSession).toHaveBeenCalledTimes(1);
|
|
477
|
+
} finally {
|
|
478
|
+
finish();
|
|
479
|
+
}
|
|
480
|
+
await expect(first).resolves.toEqual({});
|
|
481
|
+
expect(loadSession).toHaveBeenCalledTimes(1);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('refuses a prompt while a load is still settling', async () => {
|
|
485
|
+
const fake = createFakeSession();
|
|
486
|
+
let finish!: () => void;
|
|
487
|
+
const held = new Promise<void>((resolve) => (finish = resolve));
|
|
488
|
+
const loadSession = vi.fn(async () => {
|
|
489
|
+
await held;
|
|
490
|
+
return { loaded: true as const };
|
|
491
|
+
});
|
|
492
|
+
const { editor } = await start(fake, { loadSession });
|
|
493
|
+
const sessionId = await openSession(editor);
|
|
494
|
+
const loading = editor.connection.agent.request('session/load', {
|
|
495
|
+
sessionId,
|
|
496
|
+
cwd: WORKSPACE,
|
|
497
|
+
mcpServers: [],
|
|
498
|
+
});
|
|
499
|
+
await settle();
|
|
500
|
+
await expect(
|
|
501
|
+
editor.connection.agent.request('session/prompt', { sessionId, prompt: textPrompt('now') }),
|
|
502
|
+
).rejects.toMatchObject({ code: -32602 });
|
|
503
|
+
finish();
|
|
504
|
+
await loading;
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it.each(['call_0', 'tool-1'])(
|
|
508
|
+
'gives a replayed call a fresh row when its id %s already named an earlier one',
|
|
509
|
+
async (executionId) => {
|
|
510
|
+
const fake = createFakeSession();
|
|
511
|
+
const repeated: IHistoryDisplaySegment[] = [
|
|
512
|
+
{
|
|
513
|
+
type: 'tool',
|
|
514
|
+
tool: {
|
|
515
|
+
toolName: 'Read',
|
|
516
|
+
firstArg: 'a',
|
|
517
|
+
isRunning: false,
|
|
518
|
+
result: 'success',
|
|
519
|
+
executionId,
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
type: 'tool',
|
|
524
|
+
tool: {
|
|
525
|
+
toolName: 'Read',
|
|
526
|
+
firstArg: 'b',
|
|
527
|
+
isRunning: false,
|
|
528
|
+
result: 'success',
|
|
529
|
+
executionId,
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
];
|
|
533
|
+
fake.session.getMessagesDisplay = () => repeated;
|
|
534
|
+
const { editor } = await start(fake, { loadSession: async () => ({ loaded: true }) });
|
|
535
|
+
await editor.connection.agent.request('initialize', { protocolVersion: 1 });
|
|
536
|
+
await editor.connection.agent.request('session/load', {
|
|
537
|
+
sessionId: 'robota-session-1',
|
|
538
|
+
cwd: WORKSPACE,
|
|
539
|
+
mcpServers: [],
|
|
540
|
+
});
|
|
541
|
+
expect(editor.updates.map((u) => (u as { toolCallId: string }).toolCallId)).toEqual([
|
|
542
|
+
executionId,
|
|
543
|
+
executionId === 'tool-1' ? 'tool-2' : 'tool-1',
|
|
544
|
+
]);
|
|
545
|
+
},
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
it.each(['unnamed', 'explicit', 'permission'])(
|
|
549
|
+
'keeps a replayed row distinct from a later %s live call',
|
|
550
|
+
async (mode) => {
|
|
551
|
+
let answered!: () => void;
|
|
552
|
+
const wasAnswered = new Promise<void>((resolve) => (answered = resolve));
|
|
553
|
+
const fake = createFakeSession(async () => {
|
|
554
|
+
const tool = {
|
|
555
|
+
toolName: 'Read',
|
|
556
|
+
firstArg: 'new.txt',
|
|
557
|
+
isRunning: true,
|
|
558
|
+
...(mode === 'unnamed' ? {} : { executionId: 'tool-1' }),
|
|
559
|
+
};
|
|
560
|
+
if (mode === 'permission') {
|
|
561
|
+
fake.emit('permission_request', {
|
|
562
|
+
id: 'permission-live',
|
|
563
|
+
toolName: 'Read',
|
|
564
|
+
toolArgs: { filePath: 'new.txt' },
|
|
565
|
+
executionId: 'tool-1',
|
|
566
|
+
});
|
|
567
|
+
await wasAnswered;
|
|
568
|
+
}
|
|
569
|
+
fake.emit('tool_start', tool);
|
|
570
|
+
fake.emit('tool_end', { ...tool, isRunning: false, result: 'success' });
|
|
571
|
+
return endedTurn();
|
|
572
|
+
});
|
|
573
|
+
fake.resolvePermission.mockImplementation(answered);
|
|
574
|
+
fake.session.getMessagesDisplay = () => [
|
|
575
|
+
{
|
|
576
|
+
type: 'tool',
|
|
577
|
+
tool: {
|
|
578
|
+
toolName: 'Read',
|
|
579
|
+
firstArg: 'old.txt',
|
|
580
|
+
isRunning: false,
|
|
581
|
+
result: 'success',
|
|
582
|
+
executionId: 'tool-1',
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
];
|
|
586
|
+
const { editor } = await start(fake, { loadSession: async () => ({ loaded: true }) });
|
|
587
|
+
editor.answerPermission(() => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } }));
|
|
588
|
+
await editor.connection.agent.request('initialize', { protocolVersion: 1 });
|
|
589
|
+
await editor.connection.agent.request('session/load', {
|
|
590
|
+
sessionId: SESSION_ID,
|
|
591
|
+
cwd: WORKSPACE,
|
|
592
|
+
mcpServers: [],
|
|
593
|
+
});
|
|
594
|
+
await editor.connection.agent.request('session/prompt', {
|
|
595
|
+
sessionId: SESSION_ID,
|
|
596
|
+
prompt: textPrompt('read the new file'),
|
|
597
|
+
});
|
|
598
|
+
const calls = editor.updates.flatMap((update) =>
|
|
599
|
+
update.sessionUpdate === 'tool_call' || update.sessionUpdate === 'tool_call_update'
|
|
600
|
+
? [{ id: update.toolCallId, status: update.status }]
|
|
601
|
+
: [],
|
|
602
|
+
);
|
|
603
|
+
expect(calls).toEqual([
|
|
604
|
+
{ id: 'tool-1', status: 'completed' },
|
|
605
|
+
{ id: 'tool-2', status: 'in_progress' },
|
|
606
|
+
{ id: 'tool-2', status: 'completed' },
|
|
607
|
+
]);
|
|
608
|
+
if (mode === 'permission')
|
|
609
|
+
expect(editor.permissionRequests[0]?.toolCall.toolCallId).toBe('tool-2');
|
|
610
|
+
},
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
it('answers a fixed error when the loaded history cannot be read, and keeps the cause local', async () => {
|
|
614
|
+
const fake = createFakeSession();
|
|
615
|
+
fake.session.getMessagesDisplay = () => {
|
|
616
|
+
throw new Error('/home/me/.robota/sessions/x.json: bad record');
|
|
617
|
+
};
|
|
618
|
+
const fixture = await start(fake, { loadSession: async () => ({ loaded: true }) });
|
|
619
|
+
await fixture.editor.connection.agent.request('initialize', { protocolVersion: 1 });
|
|
620
|
+
const failure = await fixture.editor.connection.agent
|
|
621
|
+
.request('session/load', { sessionId: 'robota-session-1', cwd: WORKSPACE, mcpServers: [] })
|
|
622
|
+
.catch((error: unknown) => error);
|
|
623
|
+
expect(failure).toMatchObject({
|
|
624
|
+
code: -32603,
|
|
625
|
+
message: 'The session history could not be shown',
|
|
626
|
+
});
|
|
627
|
+
expect(fixture.deliveryErrors.map((e) => e.context)).toContain('session/load');
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it('answers a fixed error when the host fails to load, and keeps the cause local', async () => {
|
|
631
|
+
const fake = createFakeSession();
|
|
632
|
+
const fixture = await start(fake, {
|
|
633
|
+
loadSession: async () => {
|
|
634
|
+
throw new Error('/home/me/.robota/sessions is unreadable');
|
|
635
|
+
},
|
|
636
|
+
});
|
|
637
|
+
await fixture.editor.connection.agent.request('initialize', { protocolVersion: 1 });
|
|
638
|
+
const failure = await fixture.editor.connection.agent
|
|
639
|
+
.request('session/load', { sessionId: 'stored-1', cwd: WORKSPACE, mcpServers: [] })
|
|
640
|
+
.catch((error: unknown) => error);
|
|
641
|
+
expect(failure).toMatchObject({ code: -32603, message: 'The session could not be loaded' });
|
|
642
|
+
expect(fixture.deliveryErrors).toEqual([
|
|
643
|
+
{
|
|
644
|
+
error: expect.objectContaining({ message: expect.stringContaining('unreadable') }),
|
|
645
|
+
context: 'session/load',
|
|
646
|
+
},
|
|
647
|
+
]);
|
|
648
|
+
});
|
|
649
|
+
});
|
|
@@ -12,6 +12,7 @@ import { createAcpTransport } from '../acp-transport.js';
|
|
|
12
12
|
|
|
13
13
|
import type { IAcpTransport, IAcpTransportOptions, IAcpTransportSession } from '../acp-types.js';
|
|
14
14
|
import type {
|
|
15
|
+
AvailableCommand,
|
|
15
16
|
ClientConnection,
|
|
16
17
|
RequestPermissionRequest,
|
|
17
18
|
RequestPermissionResponse,
|
|
@@ -110,7 +111,10 @@ export function createFakeSession(
|
|
|
110
111
|
|
|
111
112
|
export interface IEditor {
|
|
112
113
|
readonly connection: ClientConnection;
|
|
114
|
+
/** Every session update except the command lists, in arrival order. */
|
|
113
115
|
readonly updates: SessionUpdate[];
|
|
116
|
+
/** Each command list the agent advertised, in arrival order. */
|
|
117
|
+
readonly commandLists: AvailableCommand[][];
|
|
114
118
|
readonly permissionRequests: RequestPermissionRequest[];
|
|
115
119
|
/** What the editor answers when asked for permission. */
|
|
116
120
|
answerPermission(
|
|
@@ -161,13 +165,16 @@ export async function startTransport(
|
|
|
161
165
|
await transport.start();
|
|
162
166
|
|
|
163
167
|
const updates: SessionUpdate[] = [];
|
|
168
|
+
const commandLists: AvailableCommand[][] = [];
|
|
164
169
|
const permissionRequests: RequestPermissionRequest[] = [];
|
|
165
170
|
let answer: Parameters<IEditor['answerPermission']>[0] = () => ({
|
|
166
171
|
outcome: { outcome: 'cancelled' },
|
|
167
172
|
});
|
|
168
173
|
const app = client({ name: 'test-editor' })
|
|
169
174
|
.onNotification('session/update', ({ params }) => {
|
|
170
|
-
|
|
175
|
+
if (params.update.sessionUpdate === 'available_commands_update') {
|
|
176
|
+
commandLists.push(params.update.availableCommands);
|
|
177
|
+
} else updates.push(params.update);
|
|
171
178
|
})
|
|
172
179
|
.onRequest('session/request_permission', async ({ params }) => {
|
|
173
180
|
permissionRequests.push(params);
|
|
@@ -182,6 +189,7 @@ export async function startTransport(
|
|
|
182
189
|
const editor: IEditor = {
|
|
183
190
|
connection,
|
|
184
191
|
updates,
|
|
192
|
+
commandLists,
|
|
185
193
|
permissionRequests,
|
|
186
194
|
answerPermission: (next) => {
|
|
187
195
|
answer = next;
|