@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,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #3550 item 4 — the editor's `clientCapabilities.fs` reaches the host as a file access bound to
|
|
3
|
+
* the session, only when the editor offers it, and goes away when the transport stops.
|
|
4
|
+
*/
|
|
5
|
+
import { PassThrough, Readable, Writable } from 'node:stream';
|
|
6
|
+
|
|
7
|
+
import { client, ndJsonStream, RequestError } from '@agentclientprotocol/sdk';
|
|
8
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
9
|
+
|
|
10
|
+
import { createAcpTransport } from '../acp-transport.js';
|
|
11
|
+
import {
|
|
12
|
+
createFakeSession,
|
|
13
|
+
createTransportOptions,
|
|
14
|
+
SESSION_ID,
|
|
15
|
+
WORKSPACE,
|
|
16
|
+
} from './acp-test-harness.js';
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
IAcpEditorFileAccess,
|
|
20
|
+
IAcpTransportOptions,
|
|
21
|
+
IAcpTransportSession,
|
|
22
|
+
} from '../acp-types.js';
|
|
23
|
+
import type {
|
|
24
|
+
ClientCapabilities,
|
|
25
|
+
ReadTextFileRequest,
|
|
26
|
+
WriteTextFileRequest,
|
|
27
|
+
} from '@agentclientprotocol/sdk';
|
|
28
|
+
|
|
29
|
+
const open: { stop(): Promise<void> }[] = [];
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
while (open.length) await open.pop()!.stop();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
interface IEditorFs {
|
|
35
|
+
read?: (params: ReadTextFileRequest, signal: AbortSignal) => Promise<string>;
|
|
36
|
+
write?: (params: WriteTextFileRequest) => Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A transport with an SDK editor that answers `fs/*` the way `fs` says, recording what it was asked. */
|
|
40
|
+
async function start(
|
|
41
|
+
fs: IEditorFs = {},
|
|
42
|
+
overrides: Partial<IAcpTransportOptions> = {},
|
|
43
|
+
session: IAcpTransportSession = createFakeSession().session,
|
|
44
|
+
) {
|
|
45
|
+
const input = new PassThrough();
|
|
46
|
+
const output = new PassThrough();
|
|
47
|
+
const offered: (IAcpEditorFileAccess | undefined)[] = [];
|
|
48
|
+
const transport = createAcpTransport(
|
|
49
|
+
createTransportOptions(input, output, {
|
|
50
|
+
...overrides,
|
|
51
|
+
onEditorFileAccess: (access) => offered.push(access),
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
transport.attach(session);
|
|
55
|
+
await transport.start();
|
|
56
|
+
const reads: ReadTextFileRequest[] = [];
|
|
57
|
+
const writes: WriteTextFileRequest[] = [];
|
|
58
|
+
const connection = client({ name: 'test-editor' })
|
|
59
|
+
.onRequest('fs/read_text_file', async ({ params, signal }) => {
|
|
60
|
+
reads.push(params);
|
|
61
|
+
return { content: await (fs.read ?? (async () => 'BUFFER'))(params, signal) };
|
|
62
|
+
})
|
|
63
|
+
.onRequest('fs/write_text_file', async ({ params }) => {
|
|
64
|
+
writes.push(params);
|
|
65
|
+
await fs.write?.(params);
|
|
66
|
+
return {};
|
|
67
|
+
})
|
|
68
|
+
.connect(
|
|
69
|
+
ndJsonStream(
|
|
70
|
+
Writable.toWeb(input) as WritableStream<Uint8Array>,
|
|
71
|
+
Readable.toWeb(output) as ReadableStream<Uint8Array>,
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
const fixture = {
|
|
75
|
+
transport,
|
|
76
|
+
agent: connection.agent,
|
|
77
|
+
offered,
|
|
78
|
+
reads,
|
|
79
|
+
writes,
|
|
80
|
+
stop: async () => {
|
|
81
|
+
connection.close();
|
|
82
|
+
await transport.stop();
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
open.push(fixture);
|
|
86
|
+
return fixture;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function openSession(
|
|
90
|
+
agent: Awaited<ReturnType<typeof start>>['agent'],
|
|
91
|
+
clientCapabilities?: ClientCapabilities,
|
|
92
|
+
): Promise<void> {
|
|
93
|
+
await agent.request('initialize', {
|
|
94
|
+
protocolVersion: 1,
|
|
95
|
+
...(clientCapabilities ? { clientCapabilities } : {}),
|
|
96
|
+
});
|
|
97
|
+
await agent.request('session/new', { cwd: WORKSPACE, mcpServers: [] });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const BOTH: ClientCapabilities = { fs: { readTextFile: true, writeTextFile: true } };
|
|
101
|
+
|
|
102
|
+
describe('the editor file system', () => {
|
|
103
|
+
it('is handed to the host once the session is bound, and carries reads and writes for it', async () => {
|
|
104
|
+
const fixture = await start();
|
|
105
|
+
await fixture.agent.request('initialize', { protocolVersion: 1, clientCapabilities: BOTH });
|
|
106
|
+
expect(fixture.offered).toEqual([]);
|
|
107
|
+
await fixture.agent.request('session/new', { cwd: WORKSPACE, mcpServers: [] });
|
|
108
|
+
expect(fixture.offered).toHaveLength(1);
|
|
109
|
+
const access = fixture.offered[0]!;
|
|
110
|
+
|
|
111
|
+
await expect(access.readTextFile!(`${WORKSPACE}/a.txt`)).resolves.toBe('BUFFER');
|
|
112
|
+
await access.writeTextFile!(`${WORKSPACE}/b.txt`, 'new text');
|
|
113
|
+
expect(fixture.reads).toEqual([{ sessionId: SESSION_ID, path: `${WORKSPACE}/a.txt` }]);
|
|
114
|
+
expect(fixture.writes).toEqual([
|
|
115
|
+
{ sessionId: SESSION_ID, path: `${WORKSPACE}/b.txt`, content: 'new text' },
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it.each(['load-first', 'new-then-load'])(
|
|
120
|
+
'offers file access after a %s binding and addresses the loaded session',
|
|
121
|
+
async (order) => {
|
|
122
|
+
const fake = createFakeSession();
|
|
123
|
+
const snapshot = fake.session.getStatusSnapshot();
|
|
124
|
+
let currentId = SESSION_ID;
|
|
125
|
+
fake.session.getStatusSnapshot = () => ({ ...snapshot, sessionId: currentId });
|
|
126
|
+
const fixture = await start(
|
|
127
|
+
{},
|
|
128
|
+
{
|
|
129
|
+
loadSession: async (id) => {
|
|
130
|
+
currentId = id;
|
|
131
|
+
return { loaded: true };
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
fake.session,
|
|
135
|
+
);
|
|
136
|
+
await fixture.agent.request('initialize', { protocolVersion: 1, clientCapabilities: BOTH });
|
|
137
|
+
if (order === 'new-then-load') {
|
|
138
|
+
await fixture.agent.request('session/new', { cwd: WORKSPACE, mcpServers: [] });
|
|
139
|
+
}
|
|
140
|
+
const previousAccess = fixture.offered.at(-1);
|
|
141
|
+
await fixture.agent.request('session/load', {
|
|
142
|
+
sessionId: 'loaded-session',
|
|
143
|
+
cwd: WORKSPACE,
|
|
144
|
+
mcpServers: [],
|
|
145
|
+
});
|
|
146
|
+
expect(fixture.offered).toHaveLength(order === 'load-first' ? 1 : 2);
|
|
147
|
+
const access = fixture.offered.at(-1);
|
|
148
|
+
await expect(access?.readTextFile?.(`${WORKSPACE}/loaded.txt`)).resolves.toBe('BUFFER');
|
|
149
|
+
await access?.writeTextFile?.(`${WORKSPACE}/loaded.txt`, 'LOADED');
|
|
150
|
+
expect(fixture.reads).toEqual([
|
|
151
|
+
{ sessionId: 'loaded-session', path: `${WORKSPACE}/loaded.txt` },
|
|
152
|
+
]);
|
|
153
|
+
expect(fixture.writes).toEqual([
|
|
154
|
+
{ sessionId: 'loaded-session', path: `${WORKSPACE}/loaded.txt`, content: 'LOADED' },
|
|
155
|
+
]);
|
|
156
|
+
if (previousAccess !== undefined) {
|
|
157
|
+
await previousAccess.readTextFile?.(`${WORKSPACE}/previous-access.txt`);
|
|
158
|
+
expect(fixture.reads.at(-1)?.sessionId).toBe('loaded-session');
|
|
159
|
+
}
|
|
160
|
+
await fixture.stop();
|
|
161
|
+
expect(fixture.offered.at(-1)).toBeUndefined();
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
it('carries only what the editor offered', async () => {
|
|
166
|
+
const fixture = await start();
|
|
167
|
+
await openSession(fixture.agent, { fs: { readTextFile: true, writeTextFile: false } });
|
|
168
|
+
expect(Object.keys(fixture.offered[0]!)).toEqual(['readTextFile']);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it.each([
|
|
172
|
+
['no client capabilities', undefined],
|
|
173
|
+
['an fs that offers nothing', { fs: { readTextFile: false, writeTextFile: false } }],
|
|
174
|
+
['terminals only', { terminal: true }],
|
|
175
|
+
])('is never handed over for an editor with %s', async (_label, capabilities) => {
|
|
176
|
+
const fixture = await start();
|
|
177
|
+
await openSession(fixture.agent, capabilities);
|
|
178
|
+
await fixture.stop();
|
|
179
|
+
expect(fixture.offered).toEqual([]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('is withdrawn when the transport stops', async () => {
|
|
183
|
+
const fixture = await start();
|
|
184
|
+
await openSession(fixture.agent, BOTH);
|
|
185
|
+
await fixture.stop();
|
|
186
|
+
expect(fixture.offered).toHaveLength(2);
|
|
187
|
+
expect(fixture.offered[1]).toBeUndefined();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("rejects with the editor's reason", async () => {
|
|
191
|
+
const fixture = await start({
|
|
192
|
+
read: async (params) => {
|
|
193
|
+
throw RequestError.resourceNotFound(params.path);
|
|
194
|
+
},
|
|
195
|
+
write: async () => {
|
|
196
|
+
throw new Error('the buffer is read-only');
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
await openSession(fixture.agent, BOTH);
|
|
200
|
+
const access = fixture.offered[0]!;
|
|
201
|
+
await expect(access.readTextFile!(`${WORKSPACE}/gone.txt`)).rejects.toThrow(
|
|
202
|
+
`Resource not found: ${WORKSPACE}/gone.txt`,
|
|
203
|
+
);
|
|
204
|
+
await expect(access.writeTextFile!(`${WORKSPACE}/a.txt`, 'x')).rejects.toThrow(
|
|
205
|
+
'Internal error: the buffer is read-only',
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('sends no request for a relative path', async () => {
|
|
210
|
+
const fixture = await start();
|
|
211
|
+
await openSession(fixture.agent, BOTH);
|
|
212
|
+
const access = fixture.offered[0]!;
|
|
213
|
+
await expect(access.readTextFile!('a.txt')).rejects.toThrow('absolute paths only');
|
|
214
|
+
await expect(access.writeTextFile!('a.txt', 'x')).rejects.toThrow('absolute paths only');
|
|
215
|
+
expect(fixture.reads).toEqual([]);
|
|
216
|
+
expect(fixture.writes).toEqual([]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("cancels the editor's request when the tool's signal aborts", async () => {
|
|
220
|
+
let editorSignal: AbortSignal | undefined;
|
|
221
|
+
const fixture = await start({
|
|
222
|
+
read: (_params, signal) =>
|
|
223
|
+
new Promise((_resolve, reject) => {
|
|
224
|
+
editorSignal = signal;
|
|
225
|
+
signal.addEventListener('abort', () => reject(RequestError.requestCancelled()));
|
|
226
|
+
}),
|
|
227
|
+
});
|
|
228
|
+
await openSession(fixture.agent, BOTH);
|
|
229
|
+
const controller = new AbortController();
|
|
230
|
+
const reading = fixture.offered[0]!.readTextFile!(`${WORKSPACE}/a.txt`, controller.signal);
|
|
231
|
+
await expect.poll(() => editorSignal).toBeDefined();
|
|
232
|
+
controller.abort();
|
|
233
|
+
await expect(reading).rejects.toThrow('Request cancelled');
|
|
234
|
+
expect(editorSignal?.aborted).toBe(true);
|
|
235
|
+
});
|
|
236
|
+
});
|