@almyty/acp-server 1.2.0 → 1.5.0
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/README.md +5 -3
- package/dist/__tests__/dispatch.test.d.ts +1 -0
- package/dist/__tests__/dispatch.test.js +129 -0
- package/dist/__tests__/integration.test.js +166 -65
- package/dist/dispatch.d.ts +34 -0
- package/dist/dispatch.js +72 -0
- package/dist/index.js +27 -39
- package/dist/version.d.ts +2 -0
- package/dist/version.js +24 -0
- package/package.json +5 -2
- package/dist/__tests__/events.test.d.ts +0 -0
- package/dist/__tests__/events.test.js +0 -1
package/README.md
CHANGED
|
@@ -54,8 +54,10 @@ almyty is the full-stack platform for AI agents, agnostic by design: any LLM, an
|
|
|
54
54
|
API turned into tools, served over MCP, A2A, UTCP, and Agent Skills. Open source,
|
|
55
55
|
no lock-in.
|
|
56
56
|
|
|
57
|
-
- Website
|
|
58
|
-
- Docs
|
|
59
|
-
- Source
|
|
57
|
+
- Website: https://almyty.com
|
|
58
|
+
- Docs: https://docs.almyty.com
|
|
59
|
+
- Source: https://github.com/almyty-inc/almyty
|
|
60
|
+
|
|
61
|
+
This CLI is part of the `@almyty/*` suite (versioned together at 1.x) and works with the almyty platform 0.1 and later.
|
|
60
62
|
|
|
61
63
|
Apache-2.0 © Almyty Inc.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the stdio line dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* The behaviour under test is the one readline makes easy to get wrong:
|
|
5
|
+
* every line already sitting in the stdin pipe is delivered in a single
|
|
6
|
+
* synchronous burst, so the lazy agent resolution has to be guarded by the
|
|
7
|
+
* in-flight promise rather than by the settled value.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
10
|
+
import { createLineHandler } from '../dispatch.js';
|
|
11
|
+
/** A stand-in agent that records everything it is handed. */
|
|
12
|
+
class RecordingAgent {
|
|
13
|
+
seen = [];
|
|
14
|
+
async handleMessage(msg) {
|
|
15
|
+
this.seen.push(msg);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const req = (id, method) => JSON.stringify(id === null ? { jsonrpc: '2.0', method } : { jsonrpc: '2.0', id, method });
|
|
19
|
+
describe('createLineHandler', () => {
|
|
20
|
+
it('resolves the agent once for a burst of lines and shares it', async () => {
|
|
21
|
+
const agents = [];
|
|
22
|
+
const resolveAgent = vi.fn(async () => {
|
|
23
|
+
// A real resolution is at least one HTTP round-trip away.
|
|
24
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
25
|
+
const agent = new RecordingAgent();
|
|
26
|
+
agents.push(agent);
|
|
27
|
+
return agent;
|
|
28
|
+
});
|
|
29
|
+
const handleLine = createLineHandler({ resolveAgent, send: vi.fn() });
|
|
30
|
+
// readline delivers a buffered burst synchronously, one call per line,
|
|
31
|
+
// without awaiting any of them.
|
|
32
|
+
await Promise.all([
|
|
33
|
+
handleLine(req(1, 'initialize')),
|
|
34
|
+
handleLine(req(2, 'session/new')),
|
|
35
|
+
handleLine(req(3, 'session/list')),
|
|
36
|
+
]);
|
|
37
|
+
expect(resolveAgent).toHaveBeenCalledTimes(1);
|
|
38
|
+
expect(agents).toHaveLength(1);
|
|
39
|
+
expect(agents[0].seen.map((m) => m.id)).toEqual(expect.arrayContaining([1, 2, 3]));
|
|
40
|
+
expect(agents[0].seen).toHaveLength(3);
|
|
41
|
+
});
|
|
42
|
+
it('reuses the resolved agent for later lines', async () => {
|
|
43
|
+
const agent = new RecordingAgent();
|
|
44
|
+
const resolveAgent = vi.fn(async () => agent);
|
|
45
|
+
const handleLine = createLineHandler({ resolveAgent, send: vi.fn() });
|
|
46
|
+
await handleLine(req(1, 'initialize'));
|
|
47
|
+
await handleLine(req(2, 'session/new'));
|
|
48
|
+
expect(resolveAgent).toHaveBeenCalledTimes(1);
|
|
49
|
+
expect(agent.seen).toHaveLength(2);
|
|
50
|
+
});
|
|
51
|
+
it('answers every line of a burst when resolution fails, and retries later', async () => {
|
|
52
|
+
const send = vi.fn();
|
|
53
|
+
const resolveAgent = vi
|
|
54
|
+
.fn()
|
|
55
|
+
.mockRejectedValueOnce(new Error('No auth token. Run: npx @almyty/auth login'))
|
|
56
|
+
.mockResolvedValueOnce(new RecordingAgent());
|
|
57
|
+
const handleLine = createLineHandler({ resolveAgent, send });
|
|
58
|
+
await Promise.all([handleLine(req(1, 'initialize')), handleLine(req(2, 'session/new'))]);
|
|
59
|
+
expect(resolveAgent).toHaveBeenCalledTimes(1);
|
|
60
|
+
expect(send).toHaveBeenCalledTimes(2);
|
|
61
|
+
for (const [msg] of send.mock.calls) {
|
|
62
|
+
expect(msg.error.code).toBe(-32603);
|
|
63
|
+
expect(msg.error.message).toBe('No auth token. Run: npx @almyty/auth login');
|
|
64
|
+
}
|
|
65
|
+
expect(send.mock.calls.map(([m]) => m.id)).toEqual([1, 2]);
|
|
66
|
+
// The failed memo is cleared, so a later message tries again.
|
|
67
|
+
send.mockClear();
|
|
68
|
+
await handleLine(req(3, 'initialize'));
|
|
69
|
+
expect(resolveAgent).toHaveBeenCalledTimes(2);
|
|
70
|
+
expect(send).not.toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
it('ignores blank lines without resolving an agent', async () => {
|
|
73
|
+
const resolveAgent = vi.fn(async () => new RecordingAgent());
|
|
74
|
+
const send = vi.fn();
|
|
75
|
+
const handleLine = createLineHandler({ resolveAgent, send });
|
|
76
|
+
await handleLine('');
|
|
77
|
+
await handleLine(' \t ');
|
|
78
|
+
expect(resolveAgent).not.toHaveBeenCalled();
|
|
79
|
+
expect(send).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
it('answers unparseable input with a parse error and no agent resolution', async () => {
|
|
82
|
+
const resolveAgent = vi.fn(async () => new RecordingAgent());
|
|
83
|
+
const send = vi.fn();
|
|
84
|
+
const handleLine = createLineHandler({ resolveAgent, send });
|
|
85
|
+
await handleLine('{not json');
|
|
86
|
+
expect(resolveAgent).not.toHaveBeenCalled();
|
|
87
|
+
expect(send).toHaveBeenCalledWith({
|
|
88
|
+
jsonrpc: '2.0',
|
|
89
|
+
id: null,
|
|
90
|
+
error: { code: -32700, message: 'Parse error' },
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
it('rejects a malformed envelope that still carries an id', async () => {
|
|
94
|
+
const send = vi.fn();
|
|
95
|
+
const handleLine = createLineHandler({ resolveAgent: vi.fn(), send });
|
|
96
|
+
await handleLine(JSON.stringify({ jsonrpc: '1.0', id: 7, method: 'initialize' }));
|
|
97
|
+
await handleLine(JSON.stringify({ jsonrpc: '2.0', id: 8 })); // no method
|
|
98
|
+
expect(send.mock.calls.map(([m]) => m)).toEqual([
|
|
99
|
+
{ jsonrpc: '2.0', id: 7, error: { code: -32600, message: 'Invalid request' } },
|
|
100
|
+
{ jsonrpc: '2.0', id: 8, error: { code: -32600, message: 'Invalid request' } },
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
it('stays silent for a malformed notification', async () => {
|
|
104
|
+
const send = vi.fn();
|
|
105
|
+
const handleLine = createLineHandler({ resolveAgent: vi.fn(), send });
|
|
106
|
+
await handleLine(JSON.stringify({ jsonrpc: '2.0' }));
|
|
107
|
+
expect(send).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
it('turns a thrown handler into an internal error for requests only', async () => {
|
|
110
|
+
const send = vi.fn();
|
|
111
|
+
const log = vi.fn();
|
|
112
|
+
const agent = {
|
|
113
|
+
handleMessage: vi.fn(async () => {
|
|
114
|
+
throw new Error('boom');
|
|
115
|
+
}),
|
|
116
|
+
};
|
|
117
|
+
const handleLine = createLineHandler({ resolveAgent: async () => agent, send, log });
|
|
118
|
+
await handleLine(req(9, 'session/new'));
|
|
119
|
+
expect(send).toHaveBeenCalledWith({
|
|
120
|
+
jsonrpc: '2.0',
|
|
121
|
+
id: 9,
|
|
122
|
+
error: { code: -32603, message: 'Internal error' },
|
|
123
|
+
});
|
|
124
|
+
send.mockClear();
|
|
125
|
+
await handleLine(req(null, 'session/cancel')); // notification: no id
|
|
126
|
+
expect(send).not.toHaveBeenCalled();
|
|
127
|
+
expect(log).toHaveBeenCalledTimes(2);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -3,51 +3,66 @@
|
|
|
3
3
|
* connect via stdin/stdout ndjson, run the full ACP lifecycle.
|
|
4
4
|
*
|
|
5
5
|
* Uses a mock HTTP server standing in for the almyty backend.
|
|
6
|
+
*
|
|
7
|
+
* No sleep before the first request: stdin is a pipe, so anything written
|
|
8
|
+
* before the child finishes booting is buffered by the OS and delivered to
|
|
9
|
+
* readline the moment the loop attaches. Readiness is the handshake reply
|
|
10
|
+
* itself, and every read goes through one persistent reader so a response
|
|
11
|
+
* can never land between two short-lived ones.
|
|
6
12
|
*/
|
|
7
|
-
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
13
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
|
|
8
14
|
import { spawn } from 'node:child_process';
|
|
9
15
|
import { createServer } from 'node:http';
|
|
10
|
-
import { createInterface } from 'node:readline';
|
|
11
16
|
import { resolve } from 'node:path';
|
|
12
17
|
// ── Mock backend ────────────────────────────────────────────────
|
|
18
|
+
/**
|
|
19
|
+
* Agent ids are UUIDs everywhere in almyty; the server only takes its
|
|
20
|
+
* `GET /agents/:id` fast path for a UUID and otherwise resolves the
|
|
21
|
+
* argument as a name or slug against `GET /agents`.
|
|
22
|
+
*/
|
|
23
|
+
const AGENT_ID = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
|
13
24
|
const MOCK_AGENT = {
|
|
14
|
-
id:
|
|
25
|
+
id: AGENT_ID,
|
|
15
26
|
name: 'Test Agent',
|
|
27
|
+
slug: 'test-agent',
|
|
16
28
|
description: 'A test agent',
|
|
17
29
|
mode: 'autonomous',
|
|
18
30
|
status: 'active',
|
|
19
31
|
pipeline: { nodes: [], edges: [] },
|
|
20
32
|
};
|
|
21
33
|
function createMockBackend() {
|
|
22
|
-
return new Promise((
|
|
34
|
+
return new Promise((resolveReady) => {
|
|
35
|
+
const requests = [];
|
|
23
36
|
const server = createServer((req, res) => {
|
|
24
37
|
let body = '';
|
|
25
38
|
req.on('data', (chunk) => (body += chunk));
|
|
26
39
|
req.on('end', () => {
|
|
27
40
|
const url = req.url || '';
|
|
41
|
+
requests.push(`${req.method} ${url}`);
|
|
28
42
|
res.setHeader('Content-Type', 'application/json');
|
|
29
43
|
// Auth profile check
|
|
30
44
|
if (url === '/auth/profile') {
|
|
31
45
|
res.end(JSON.stringify({ id: 'user-1', email: 'test@test.com', organizations: [{ id: 'org-1', name: 'Test', role: 'owner' }] }));
|
|
32
46
|
return;
|
|
33
47
|
}
|
|
34
|
-
// List agents
|
|
35
|
-
|
|
48
|
+
// List agents — "/agents", optionally with a query string, but not
|
|
49
|
+
// a sub-resource such as "/agents/:id".
|
|
50
|
+
if (req.method === 'GET' && /^\/agents(\?|$)/.test(url)) {
|
|
36
51
|
res.end(JSON.stringify({ agents: [MOCK_AGENT] }));
|
|
37
52
|
return;
|
|
38
53
|
}
|
|
39
54
|
// Get agent by ID
|
|
40
|
-
if (
|
|
55
|
+
if (req.method === 'GET' && new RegExp(`^/agents/${AGENT_ID}(\\?|$)`).test(url)) {
|
|
41
56
|
res.end(JSON.stringify(MOCK_AGENT));
|
|
42
57
|
return;
|
|
43
58
|
}
|
|
44
59
|
// Start run
|
|
45
|
-
if (
|
|
60
|
+
if (req.method === 'POST' && new RegExp(`^/agents/${AGENT_ID}/runs$`).test(url)) {
|
|
46
61
|
res.end(JSON.stringify({ id: 'run-1', status: 'completed', output: { message: 'Hello from the agent' } }));
|
|
47
62
|
return;
|
|
48
63
|
}
|
|
49
64
|
// Get run
|
|
50
|
-
if (
|
|
65
|
+
if (req.method === 'GET' && new RegExp(`^/agents/${AGENT_ID}/runs/run-1$`).test(url)) {
|
|
51
66
|
res.end(JSON.stringify({ id: 'run-1', status: 'completed', output: { message: 'Hello from the agent' } }));
|
|
52
67
|
return;
|
|
53
68
|
}
|
|
@@ -58,87 +73,173 @@ function createMockBackend() {
|
|
|
58
73
|
});
|
|
59
74
|
server.listen(0, '127.0.0.1', () => {
|
|
60
75
|
const addr = server.address();
|
|
61
|
-
|
|
76
|
+
resolveReady({ server, port: addr.port, requests });
|
|
62
77
|
});
|
|
63
78
|
});
|
|
64
79
|
}
|
|
65
|
-
// ── ndjson
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
80
|
+
// ── ndjson client ────────────────────────────────────────────────
|
|
81
|
+
const ENTRY_POINT = resolve(__dirname, '../../dist/index.js');
|
|
82
|
+
/**
|
|
83
|
+
* One reader over the child's stdout for the whole test: responses are
|
|
84
|
+
* matched by id, notifications are collected separately, and nothing is
|
|
85
|
+
* dropped between requests.
|
|
86
|
+
*/
|
|
87
|
+
class AcpClient {
|
|
88
|
+
proc;
|
|
89
|
+
notifications = [];
|
|
90
|
+
stderr = [];
|
|
91
|
+
buffer = '';
|
|
92
|
+
responses = new Map();
|
|
93
|
+
waiters = new Map();
|
|
94
|
+
exited = null;
|
|
95
|
+
constructor(port, agentRef = 'test-agent') {
|
|
96
|
+
this.proc = spawn('node', [ENTRY_POINT, agentRef], {
|
|
97
|
+
env: {
|
|
98
|
+
...process.env,
|
|
99
|
+
ALMYTY_URL: `http://127.0.0.1:${port}`,
|
|
100
|
+
ALMYTY_TOKEN: 'test-token',
|
|
101
|
+
},
|
|
102
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
103
|
+
});
|
|
104
|
+
this.proc.stdout.on('data', (chunk) => this.onStdout(String(chunk)));
|
|
105
|
+
this.proc.stderr.on('data', (chunk) => this.stderr.push(String(chunk)));
|
|
106
|
+
this.proc.on('exit', (code, signal) => {
|
|
107
|
+
this.exited = `child exited (code=${code}, signal=${signal})`;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
onStdout(chunk) {
|
|
111
|
+
this.buffer += chunk;
|
|
112
|
+
let nl;
|
|
113
|
+
while ((nl = this.buffer.indexOf('\n')) >= 0) {
|
|
114
|
+
const line = this.buffer.slice(0, nl).trim();
|
|
115
|
+
this.buffer = this.buffer.slice(nl + 1);
|
|
116
|
+
if (!line)
|
|
117
|
+
continue;
|
|
118
|
+
let msg;
|
|
79
119
|
try {
|
|
80
|
-
|
|
81
|
-
if (results.length >= count) {
|
|
82
|
-
clearTimeout(timer);
|
|
83
|
-
rl.close();
|
|
84
|
-
resolve(results);
|
|
85
|
-
}
|
|
120
|
+
msg = JSON.parse(line);
|
|
86
121
|
}
|
|
87
122
|
catch {
|
|
88
|
-
// skip malformed lines
|
|
123
|
+
continue; // skip malformed lines
|
|
89
124
|
}
|
|
125
|
+
if (msg.id === undefined || msg.id === null) {
|
|
126
|
+
this.notifications.push(msg);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const waiter = this.waiters.get(msg.id);
|
|
130
|
+
if (waiter) {
|
|
131
|
+
this.waiters.delete(msg.id);
|
|
132
|
+
waiter(msg);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
this.responses.set(msg.id, msg);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
send(method, params, id) {
|
|
140
|
+
this.proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
|
|
141
|
+
}
|
|
142
|
+
/** Write several requests as a single chunk, so they arrive together. */
|
|
143
|
+
sendBurst(requests) {
|
|
144
|
+
const payload = requests
|
|
145
|
+
.map((r) => JSON.stringify({ jsonrpc: '2.0', id: r.id, method: r.method, params: r.params ?? {} }))
|
|
146
|
+
.join('\n');
|
|
147
|
+
this.proc.stdin.write(payload + '\n');
|
|
148
|
+
}
|
|
149
|
+
response(id, timeoutMs = 20_000) {
|
|
150
|
+
const already = this.responses.get(id);
|
|
151
|
+
if (already) {
|
|
152
|
+
this.responses.delete(id);
|
|
153
|
+
return Promise.resolve(already);
|
|
154
|
+
}
|
|
155
|
+
return new Promise((resolveMsg, reject) => {
|
|
156
|
+
const timer = setTimeout(() => {
|
|
157
|
+
this.waiters.delete(id);
|
|
158
|
+
reject(new Error(`Timed out after ${timeoutMs}ms waiting for response id=${id}. ` +
|
|
159
|
+
`${this.exited ?? 'child still running'}. stderr: ${this.stderr.join('')}`));
|
|
160
|
+
}, timeoutMs);
|
|
161
|
+
this.waiters.set(id, (msg) => {
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
resolveMsg(msg);
|
|
164
|
+
});
|
|
90
165
|
});
|
|
91
|
-
}
|
|
166
|
+
}
|
|
167
|
+
/** Send a request and wait for its response. */
|
|
168
|
+
request(method, params, id, timeoutMs = 20_000) {
|
|
169
|
+
this.send(method, params, id);
|
|
170
|
+
return this.response(id, timeoutMs);
|
|
171
|
+
}
|
|
172
|
+
kill() {
|
|
173
|
+
this.proc.kill('SIGTERM');
|
|
174
|
+
}
|
|
92
175
|
}
|
|
93
176
|
// ── Tests ───────────────────────────────────────────────────────
|
|
94
177
|
describe('ACP server integration', () => {
|
|
95
|
-
let
|
|
96
|
-
let
|
|
97
|
-
let proc;
|
|
178
|
+
let backend;
|
|
179
|
+
let client;
|
|
98
180
|
beforeAll(async () => {
|
|
99
|
-
|
|
100
|
-
mockBackend = backend.server;
|
|
101
|
-
backendPort = backend.port;
|
|
181
|
+
backend = await createMockBackend();
|
|
102
182
|
});
|
|
103
183
|
afterAll(() => {
|
|
104
|
-
|
|
105
|
-
mockBackend?.close();
|
|
184
|
+
backend?.server.close();
|
|
106
185
|
});
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
// 1. Initialize
|
|
120
|
-
sendRequest(proc, 'initialize', { protocolVersion: 1, clientCapabilities: {} }, 1);
|
|
121
|
-
const [initResp] = await readResponses(proc, 1, 5000);
|
|
186
|
+
beforeEach(() => {
|
|
187
|
+
backend.requests.length = 0;
|
|
188
|
+
});
|
|
189
|
+
afterEach(() => {
|
|
190
|
+
client?.kill();
|
|
191
|
+
client = undefined;
|
|
192
|
+
});
|
|
193
|
+
it('completes full lifecycle: initialize -> authenticate -> session/new -> session/close', async () => {
|
|
194
|
+
client = new AcpClient(backend.port);
|
|
195
|
+
// 1. Initialize — the reply is the readiness signal.
|
|
196
|
+
const initResp = await client.request('initialize', { protocolVersion: 1, clientCapabilities: {} }, 1);
|
|
197
|
+
expect(initResp.error).toBeUndefined();
|
|
122
198
|
expect(initResp.id).toBe(1);
|
|
123
199
|
expect(initResp.result).toBeDefined();
|
|
124
200
|
expect(initResp.result.protocolVersion).toBe(1);
|
|
201
|
+
expect(initResp.result.agentInfo.name).toBe('Test Agent');
|
|
125
202
|
// 2. Authenticate
|
|
126
|
-
|
|
127
|
-
const [authResp] = await readResponses(proc, 1, 5000);
|
|
203
|
+
const authResp = await client.request('authenticate', { methodId: 'almyty_token' }, 2);
|
|
128
204
|
expect(authResp.id).toBe(2);
|
|
129
205
|
expect(authResp.error).toBeUndefined();
|
|
206
|
+
expect(authResp.result.authenticated).toBe(true);
|
|
130
207
|
// 3. Session new
|
|
131
|
-
|
|
132
|
-
const [sessionResp] = await readResponses(proc, 1, 5000);
|
|
208
|
+
const sessionResp = await client.request('session/new', { cwd: '/tmp' }, 3);
|
|
133
209
|
expect(sessionResp.id).toBe(3);
|
|
210
|
+
expect(sessionResp.error).toBeUndefined();
|
|
134
211
|
expect(sessionResp.result?.sessionId).toBeDefined();
|
|
135
212
|
const sessionId = sessionResp.result.sessionId;
|
|
136
|
-
// 4. Close session
|
|
137
|
-
|
|
138
|
-
const [closeResp] = await readResponses(proc, 1, 5000);
|
|
213
|
+
// 4. Close session — the session must still exist on the same agent.
|
|
214
|
+
const closeResp = await client.request('session/close', { sessionId }, 4);
|
|
139
215
|
expect(closeResp.id).toBe(4);
|
|
140
216
|
expect(closeResp.error).toBeUndefined();
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
217
|
+
expect(closeResp.result.closed).toBe(true);
|
|
218
|
+
}, 60_000);
|
|
219
|
+
/**
|
|
220
|
+
* Regression guard for the lazy-init race: readline delivers every line
|
|
221
|
+
* already buffered in the pipe in one synchronous burst, so a batch of
|
|
222
|
+
* requests arriving before the first agent resolution settles used to
|
|
223
|
+
* build one agent — and one SessionManager — per line. Sessions created
|
|
224
|
+
* by one message were then invisible to the next.
|
|
225
|
+
*/
|
|
226
|
+
it('shares one agent across requests that arrive in a single burst', async () => {
|
|
227
|
+
client = new AcpClient(backend.port);
|
|
228
|
+
client.sendBurst([
|
|
229
|
+
{ id: 1, method: 'initialize', params: { protocolVersion: 1, clientCapabilities: {} } },
|
|
230
|
+
{ id: 2, method: 'session/new', params: { cwd: '/tmp' } },
|
|
231
|
+
]);
|
|
232
|
+
const initResp = await client.response(1);
|
|
233
|
+
const sessionResp = await client.response(2);
|
|
234
|
+
expect(initResp.result?.protocolVersion).toBe(1);
|
|
235
|
+
const sessionId = sessionResp.result?.sessionId;
|
|
236
|
+
expect(sessionId).toBeDefined();
|
|
237
|
+
// The agent was resolved exactly once, not once per buffered line.
|
|
238
|
+
const listCalls = backend.requests.filter((r) => r === 'GET /agents');
|
|
239
|
+
expect(listCalls).toHaveLength(1);
|
|
240
|
+
// And the session lives on the agent that later requests reach.
|
|
241
|
+
const closeResp = await client.request('session/close', { sessionId }, 3);
|
|
242
|
+
expect(closeResp.error).toBeUndefined();
|
|
243
|
+
expect(closeResp.result.closed).toBe(true);
|
|
244
|
+
}, 60_000);
|
|
144
245
|
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stdio line dispatch for the ACP server.
|
|
3
|
+
*
|
|
4
|
+
* Owns the one piece of state the readline loop cannot own safely: the
|
|
5
|
+
* lazily-resolved agent. `readline` delivers every complete line already
|
|
6
|
+
* sitting in the stdin pipe in a single synchronous burst, so an async
|
|
7
|
+
* `if (!agent) agent = await resolveAgent()` guard inside the line handler
|
|
8
|
+
* is checked by every line in that burst before any of them assigns — each
|
|
9
|
+
* line then builds its own agent, with its own SessionManager and its own
|
|
10
|
+
* (empty) cached agent info. Sessions created by one message are invisible
|
|
11
|
+
* to the next, `initialize` caches the agent mode on an instance nobody
|
|
12
|
+
* keeps, and the backend is asked for the agent list once per line.
|
|
13
|
+
*
|
|
14
|
+
* Resolution is therefore memoised on the in-flight promise, not on the
|
|
15
|
+
* settled value. A failed resolution clears the memo so a later message
|
|
16
|
+
* can retry (the backend may simply have been down).
|
|
17
|
+
*/
|
|
18
|
+
import type { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from './agent.js';
|
|
19
|
+
/** The surface of AlmytyAcpAgent the dispatcher depends on. */
|
|
20
|
+
export interface MessageHandler {
|
|
21
|
+
handleMessage(msg: JsonRpcRequest): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export interface LineHandlerOptions {
|
|
24
|
+
/** Build the agent. Called at most once per successful resolution. */
|
|
25
|
+
resolveAgent: () => Promise<MessageHandler>;
|
|
26
|
+
/** Write a JSON-RPC message back to the client. */
|
|
27
|
+
send: (msg: JsonRpcResponse | JsonRpcNotification) => void;
|
|
28
|
+
/** Diagnostic sink. Defaults to stderr — stdout is the protocol. */
|
|
29
|
+
log?: (text: string) => void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Build the `line` handler for the ndjson stdio loop.
|
|
33
|
+
*/
|
|
34
|
+
export declare function createLineHandler({ resolveAgent, send, log, }: LineHandlerOptions): (line: string) => Promise<void>;
|
package/dist/dispatch.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stdio line dispatch for the ACP server.
|
|
3
|
+
*
|
|
4
|
+
* Owns the one piece of state the readline loop cannot own safely: the
|
|
5
|
+
* lazily-resolved agent. `readline` delivers every complete line already
|
|
6
|
+
* sitting in the stdin pipe in a single synchronous burst, so an async
|
|
7
|
+
* `if (!agent) agent = await resolveAgent()` guard inside the line handler
|
|
8
|
+
* is checked by every line in that burst before any of them assigns — each
|
|
9
|
+
* line then builds its own agent, with its own SessionManager and its own
|
|
10
|
+
* (empty) cached agent info. Sessions created by one message are invisible
|
|
11
|
+
* to the next, `initialize` caches the agent mode on an instance nobody
|
|
12
|
+
* keeps, and the backend is asked for the agent list once per line.
|
|
13
|
+
*
|
|
14
|
+
* Resolution is therefore memoised on the in-flight promise, not on the
|
|
15
|
+
* settled value. A failed resolution clears the memo so a later message
|
|
16
|
+
* can retry (the backend may simply have been down).
|
|
17
|
+
*/
|
|
18
|
+
const PARSE_ERROR = -32700;
|
|
19
|
+
const INVALID_REQUEST = -32600;
|
|
20
|
+
const INTERNAL_ERROR = -32603;
|
|
21
|
+
/**
|
|
22
|
+
* Build the `line` handler for the ndjson stdio loop.
|
|
23
|
+
*/
|
|
24
|
+
export function createLineHandler({ resolveAgent, send, log = (text) => void process.stderr.write(text), }) {
|
|
25
|
+
let pending = null;
|
|
26
|
+
const agentOnce = () => {
|
|
27
|
+
if (!pending) {
|
|
28
|
+
pending = resolveAgent().catch((err) => {
|
|
29
|
+
pending = null;
|
|
30
|
+
throw err;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return pending;
|
|
34
|
+
};
|
|
35
|
+
return async function handleLine(line) {
|
|
36
|
+
const trimmed = line.trim();
|
|
37
|
+
if (!trimmed)
|
|
38
|
+
return;
|
|
39
|
+
let msg;
|
|
40
|
+
try {
|
|
41
|
+
msg = JSON.parse(trimmed);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
send({ jsonrpc: '2.0', id: null, error: { code: PARSE_ERROR, message: 'Parse error' } });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!msg || msg.jsonrpc !== '2.0' || !msg.method) {
|
|
48
|
+
if (msg && msg.id !== undefined) {
|
|
49
|
+
send({ jsonrpc: '2.0', id: msg.id, error: { code: INVALID_REQUEST, message: 'Invalid request' } });
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
let agent;
|
|
54
|
+
try {
|
|
55
|
+
agent = await agentOnce();
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
59
|
+
send({ jsonrpc: '2.0', id: msg.id ?? null, error: { code: INTERNAL_ERROR, message } });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
await agent.handleMessage(msg);
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
log(`[acp] Error: ${err}\n`);
|
|
67
|
+
if (msg.id !== undefined && msg.id !== null) {
|
|
68
|
+
send({ jsonrpc: '2.0', id: msg.id, error: { code: INTERNAL_ERROR, message: 'Internal error' } });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -17,18 +17,32 @@ import * as readline from 'node:readline';
|
|
|
17
17
|
import { resolveCredentials } from './auth.js';
|
|
18
18
|
import { AlmytyProxy } from './proxy.js';
|
|
19
19
|
import { AlmytyAcpAgent } from './agent.js';
|
|
20
|
+
import { createLineHandler } from './dispatch.js';
|
|
21
|
+
import { VERSION } from './version.js';
|
|
20
22
|
// ── Argument parsing ─────────────────────────────────────────────
|
|
21
23
|
const args = process.argv.slice(2);
|
|
22
24
|
const HELP_FLAGS = ['--help', '-h'];
|
|
25
|
+
const VERSION_FLAGS = ['--version', '-v'];
|
|
26
|
+
// Checked before help so `almyty-acp --version --help` answers the
|
|
27
|
+
// narrower question, and before the no-args branch so asking the server
|
|
28
|
+
// what it is never exits non-zero.
|
|
29
|
+
if (args.some((a) => VERSION_FLAGS.includes(a))) {
|
|
30
|
+
process.stdout.write(`${VERSION}\n`);
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
23
33
|
if (args.some((a) => HELP_FLAGS.includes(a)) || args.length === 0) {
|
|
24
34
|
const text = `
|
|
25
|
-
@almyty/acp-server — Expose any almyty agent via the Agent Client Protocol
|
|
35
|
+
@almyty/acp-server v${VERSION} — Expose any almyty agent via the Agent Client Protocol
|
|
26
36
|
|
|
27
37
|
Usage:
|
|
28
38
|
almyty-acp <agent>
|
|
29
39
|
almyty-acp my-agent
|
|
30
40
|
almyty-acp acme/my-agent
|
|
31
41
|
|
|
42
|
+
Options:
|
|
43
|
+
-h, --help Show this help.
|
|
44
|
+
-v, --version Print the version.
|
|
45
|
+
|
|
32
46
|
Authentication:
|
|
33
47
|
npx @almyty/auth login
|
|
34
48
|
|
|
@@ -89,46 +103,20 @@ async function main() {
|
|
|
89
103
|
// Start listening on stdin IMMEDIATELY — Zed expects the process
|
|
90
104
|
// to be ready for JSON-RPC as soon as it spawns. Agent resolution
|
|
91
105
|
// and auth happen lazily on first `initialize` message.
|
|
106
|
+
//
|
|
107
|
+
// Resolution is memoised inside createLineHandler: readline hands us
|
|
108
|
+
// every line already buffered in the pipe in one burst, so the guard
|
|
109
|
+
// has to be the in-flight promise, not the settled agent.
|
|
92
110
|
let agent = null;
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
try {
|
|
100
|
-
msg = JSON.parse(trimmed);
|
|
101
|
-
}
|
|
102
|
-
catch {
|
|
103
|
-
send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
if (!msg.jsonrpc || msg.jsonrpc !== '2.0' || !msg.method) {
|
|
107
|
-
if (msg.id !== undefined) {
|
|
108
|
-
send({ jsonrpc: '2.0', id: msg.id, error: { code: -32600, message: 'Invalid request' } });
|
|
109
|
-
}
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
// Lazy init: resolve credentials + agent on first message
|
|
113
|
-
if (!agent) {
|
|
114
|
-
try {
|
|
115
|
-
agent = await resolveAgent(agentArg, send);
|
|
116
|
-
}
|
|
117
|
-
catch (err) {
|
|
118
|
-
send({ jsonrpc: '2.0', id: msg.id ?? null, error: { code: -32603, message: err.message } });
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
try {
|
|
123
|
-
await agent.handleMessage(msg);
|
|
124
|
-
}
|
|
125
|
-
catch (err) {
|
|
126
|
-
process.stderr.write(`[acp] Error: ${err}\n`);
|
|
127
|
-
if (msg.id !== undefined && msg.id !== null) {
|
|
128
|
-
send({ jsonrpc: '2.0', id: msg.id, error: { code: -32603, message: 'Internal error' } });
|
|
129
|
-
}
|
|
130
|
-
}
|
|
111
|
+
const handleLine = createLineHandler({
|
|
112
|
+
resolveAgent: async () => {
|
|
113
|
+
agent = await resolveAgent(agentArg, send);
|
|
114
|
+
return agent;
|
|
115
|
+
},
|
|
116
|
+
send,
|
|
131
117
|
});
|
|
118
|
+
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
119
|
+
rl.on('line', handleLine);
|
|
132
120
|
// Handle stdin close (client disconnected)
|
|
133
121
|
rl.on('close', () => {
|
|
134
122
|
process.stderr.write('[acp] Client disconnected. Shutting down.\n');
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server's own version, read from its package.json at startup.
|
|
3
|
+
*
|
|
4
|
+
* Every other CLI in the suite answers `--version`; this one shipped
|
|
5
|
+
* without it, so the only way to tell which build a client had loaded
|
|
6
|
+
* was to diff the file. Reading package.json rather than restating the
|
|
7
|
+
* number keeps it honest across a publish: a literal does not move when
|
|
8
|
+
* the release workflow bumps the manifest. Both `dist/version.js` and
|
|
9
|
+
* `src/version.ts` sit one directory below the package root, so the same
|
|
10
|
+
* relative path resolves for the built bin and for `tsx src/index.ts`.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
export function readVersion(fallback = '0.0.0') {
|
|
14
|
+
try {
|
|
15
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
|
|
16
|
+
return typeof pkg.version === 'string' && pkg.version.length > 0
|
|
17
|
+
? pkg.version
|
|
18
|
+
: fallback;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return fallback;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export const VERSION = readVersion();
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@almyty/acp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
-
"description": "Expose any almyty agent over the Agent Client Protocol (ACP)
|
|
7
|
+
"description": "Expose any almyty agent over the Agent Client Protocol (ACP) — drop your agent into ACP-compatible editors and clients.",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"main": "dist/index.js",
|
|
10
10
|
"bin": {
|
|
@@ -48,5 +48,8 @@
|
|
|
48
48
|
},
|
|
49
49
|
"bugs": {
|
|
50
50
|
"url": "https://github.com/almyty-inc/almyty/issues"
|
|
51
|
+
},
|
|
52
|
+
"overrides": {
|
|
53
|
+
"postcss": "^8.5.23"
|
|
51
54
|
}
|
|
52
55
|
}
|
|
File without changes
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"use strict";
|