@executablemd/test-agent 0.0.0-bootstrap.0 → 0.5.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/LICENSE +21 -0
- package/esm/mod.js +21 -0
- package/esm/package.json +3 -0
- package/esm/src/controller.js +295 -0
- package/esm/src/net.js +140 -0
- package/esm/src/protocol.js +144 -0
- package/esm/src/state.js +57 -0
- package/esm/src/template.js +138 -0
- package/esm/src/vocabulary.js +287 -0
- package/esm/src/worker/acp-server.js +138 -0
- package/esm/src/worker/bridge.js +84 -0
- package/esm/src/worker/profile.js +86 -0
- package/esm/src/worker/run.js +291 -0
- package/esm/src/worker/when-prompt.js +147 -0
- package/package.json +35 -8
- package/types/mod.d.ts +28 -0
- package/types/src/controller.d.ts +50 -0
- package/types/src/net.d.ts +51 -0
- package/types/src/protocol.d.ts +89 -0
- package/types/src/state.d.ts +27 -0
- package/types/src/template.d.ts +41 -0
- package/types/src/vocabulary.d.ts +29 -0
- package/types/src/worker/acp-server.d.ts +27 -0
- package/types/src/worker/bridge.d.ts +42 -0
- package/types/src/worker/profile.d.ts +20 -0
- package/types/src/worker/run.d.ts +20 -0
- package/types/src/worker/when-prompt.d.ts +11 -0
- package/README.md +0 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Taras Mankovski
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/esm/mod.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* Build reliable ACP integration tests with deterministic,
|
|
4
|
+
* document-driven agent behavior. In place of a probabilistic coding
|
|
5
|
+
* agent, the test agent answers ACP prompts by advancing through a
|
|
6
|
+
* Markdown behavior document, so an integration can be tested against
|
|
7
|
+
* scripted, repeatable responses (specs/test-agent-spec.md).
|
|
8
|
+
*
|
|
9
|
+
* This entry point exposes the `<TestAgent>` vocabulary, the
|
|
10
|
+
* behavior-document engine, and the controller that registers scenarios
|
|
11
|
+
* and serves them to workers over the wire protocol.
|
|
12
|
+
*/
|
|
13
|
+
export { parseTemplate, matchPrompt } from "./src/template.js";
|
|
14
|
+
export { createLineSplitter, encodeMessage, formatRoute, parseControllerMessage, parseRoute, parseWorkerMessage, PROBE_INSTANCE, } from "./src/protocol.js";
|
|
15
|
+
export { useTestAgentController } from "./src/controller.js";
|
|
16
|
+
export { installTestAgentVocabulary } from "./src/vocabulary.js";
|
|
17
|
+
export { createMemorySessionStore, useTestAgentAcpx } from "./src/state.js";
|
|
18
|
+
export { runTestAgentWorker } from "./src/worker/run.js";
|
|
19
|
+
export { installWhenPromptVocabulary } from "./src/worker/when-prompt.js";
|
|
20
|
+
export { installWorkerProfile } from "./src/worker/profile.js";
|
|
21
|
+
export { collectTurn, createTurnBridge } from "./src/worker/bridge.js";
|
package/esm/package.json
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The test-agent controller (specs/test-agent-spec.md §Controller and
|
|
3
|
+
* worker): a localhost line-protocol server owned by the `<TestAgent>`
|
|
4
|
+
* scope. It serves behavior documents, Markdown dependencies (reads
|
|
5
|
+
* restricted to Markdown files whose canonical path stays inside the
|
|
6
|
+
* scenario root), and behavior journals to workers, and records journal
|
|
7
|
+
* appends and turn-failure diagnostics per scenario instance.
|
|
8
|
+
*
|
|
9
|
+
* Each instance admits one worker connection at a time. Unregistering an
|
|
10
|
+
* instance — or tearing the controller down — revokes and awaits its active
|
|
11
|
+
* connection before discarding state, so a revoked worker can no longer
|
|
12
|
+
* append, report failures, or read.
|
|
13
|
+
*/
|
|
14
|
+
import { each, ensure, race, resource, until, withResolvers } from "effection";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
17
|
+
// @effectionx/fs has no realpath, so canonical symlink resolution uses the
|
|
18
|
+
// node:fs/promises primitive directly.
|
|
19
|
+
import { realpath } from "node:fs/promises";
|
|
20
|
+
import { readTextFile, stat } from "@executablemd/runtime";
|
|
21
|
+
import { encodeMessage, formatRoute, parseWorkerMessage, PROBE_INSTANCE } from "./protocol.js";
|
|
22
|
+
import { useLineServer } from "./net.js";
|
|
23
|
+
function send(connection, message) {
|
|
24
|
+
connection.send(encodeMessage(message));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Map a worker's virtual path onto the scenario directory. Workers see a
|
|
28
|
+
* virtual root at the scenario directory; anything resolving outside it
|
|
29
|
+
* lexically is answered as missing rather than surfaced as an error, so
|
|
30
|
+
* component fallback continues normally.
|
|
31
|
+
*/
|
|
32
|
+
function scenarioPath(instance, path) {
|
|
33
|
+
const virtual = isAbsolute(path) ? relative("/", path) : path;
|
|
34
|
+
const real = resolve(instance.scenarioDir, virtual);
|
|
35
|
+
if (real !== instance.scenarioDir && !real.startsWith(instance.scenarioDir + sep)) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
return real;
|
|
39
|
+
}
|
|
40
|
+
export function useTestAgentController() {
|
|
41
|
+
return resource(function* (provide) {
|
|
42
|
+
const token = randomUUID();
|
|
43
|
+
const instances = new Map();
|
|
44
|
+
const active = new Map();
|
|
45
|
+
const canonicalRoots = new Map();
|
|
46
|
+
// The canonical scenario root, resolving symlinks, memoized per
|
|
47
|
+
// instance. A dependency read/stat is served only when its canonical
|
|
48
|
+
// path stays inside this root.
|
|
49
|
+
function* canonicalRoot(instance) {
|
|
50
|
+
const cached = canonicalRoots.get(instance.id);
|
|
51
|
+
if (cached !== undefined) {
|
|
52
|
+
return cached;
|
|
53
|
+
}
|
|
54
|
+
let root;
|
|
55
|
+
try {
|
|
56
|
+
root = yield* until(realpath(instance.scenarioDir));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
root = instance.scenarioDir;
|
|
60
|
+
}
|
|
61
|
+
canonicalRoots.set(instance.id, root);
|
|
62
|
+
return root;
|
|
63
|
+
}
|
|
64
|
+
function* resolveContained(instance, path) {
|
|
65
|
+
const real = scenarioPath(instance, path);
|
|
66
|
+
if (real === undefined) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const root = yield* canonicalRoot(instance);
|
|
70
|
+
let canonical;
|
|
71
|
+
try {
|
|
72
|
+
canonical = yield* until(realpath(real));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// A path that does not exist has no symlink to escape through, so
|
|
76
|
+
// the lexical containment check above is sufficient.
|
|
77
|
+
return real;
|
|
78
|
+
}
|
|
79
|
+
if (canonical !== root && !canonical.startsWith(root + sep)) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
return canonical;
|
|
83
|
+
}
|
|
84
|
+
// The controller never spawns or touches a raw socket: it hands this
|
|
85
|
+
// per-connection handler to the server adapter, which owns the accept
|
|
86
|
+
// loop and the socket lifecycle.
|
|
87
|
+
function* handleConnection(connection) {
|
|
88
|
+
const revoke = withResolvers();
|
|
89
|
+
// Revocation cancels the serve loop; connection.closed covers an
|
|
90
|
+
// abrupt worker disconnect that never ends the line stream. Either
|
|
91
|
+
// way the server task's teardown then destroys the socket.
|
|
92
|
+
yield* race([revoke.operation, connection.closed, serve(connection, revoke)]);
|
|
93
|
+
}
|
|
94
|
+
function* serve(connection, revoke) {
|
|
95
|
+
let attached;
|
|
96
|
+
for (const line of yield* each(connection.lines)) {
|
|
97
|
+
const parsed = parseWorkerMessage(line);
|
|
98
|
+
if (!parsed.ok) {
|
|
99
|
+
send(connection, { t: "error", message: parsed.error });
|
|
100
|
+
connection.end();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const message = parsed.message;
|
|
104
|
+
if (attached === undefined) {
|
|
105
|
+
if (message.t !== "attach" || message.token !== token) {
|
|
106
|
+
send(connection, { t: "error", message: "unauthorized or out-of-order attach" });
|
|
107
|
+
connection.end();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (message.instance === PROBE_INSTANCE) {
|
|
111
|
+
attached = "probe";
|
|
112
|
+
send(connection, { t: "config", mode: "probe" });
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
const instance = instances.get(message.instance);
|
|
116
|
+
if (!instance) {
|
|
117
|
+
send(connection, { t: "error", message: `unknown instance "${message.instance}"` });
|
|
118
|
+
connection.end();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
// One worker per instance: a second concurrent attach is
|
|
122
|
+
// refused so two workers never mutate the same journal.
|
|
123
|
+
if (active.has(instance.id)) {
|
|
124
|
+
send(connection, {
|
|
125
|
+
t: "error",
|
|
126
|
+
message: `instance "${instance.id}" already has an active connection`,
|
|
127
|
+
});
|
|
128
|
+
connection.end();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const ended = withResolvers();
|
|
132
|
+
const registration = {
|
|
133
|
+
revoke: revoke.resolve,
|
|
134
|
+
closed: ended.operation,
|
|
135
|
+
};
|
|
136
|
+
active.set(instance.id, registration);
|
|
137
|
+
yield* ensure(() => {
|
|
138
|
+
if (active.get(instance.id) === registration) {
|
|
139
|
+
active.delete(instance.id);
|
|
140
|
+
}
|
|
141
|
+
ended.resolve();
|
|
142
|
+
});
|
|
143
|
+
attached = instance;
|
|
144
|
+
send(connection, {
|
|
145
|
+
t: "config",
|
|
146
|
+
mode: "scenario",
|
|
147
|
+
doc: instance.doc,
|
|
148
|
+
journal: instance.journal,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
yield* each.next();
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
yield* handleMessage(connection, attached, message);
|
|
155
|
+
yield* each.next();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function* handleMessage(connection, attached, message) {
|
|
159
|
+
if (attached === "probe") {
|
|
160
|
+
send(connection, { t: "error", message: `probe workers may not send "${message.t}"` });
|
|
161
|
+
connection.end();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
// A worker whose instance was unregistered mid-connection is cut off
|
|
165
|
+
// here even before its socket finishes closing: nothing it sends
|
|
166
|
+
// reaches the discarded journal, failure, or filesystem.
|
|
167
|
+
if (instances.get(attached.id) !== attached) {
|
|
168
|
+
send(connection, { t: "error", message: "instance is no longer registered" });
|
|
169
|
+
connection.end();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
switch (message.t) {
|
|
173
|
+
case "journal": {
|
|
174
|
+
if (message.seq !== attached.journal.length) {
|
|
175
|
+
send(connection, {
|
|
176
|
+
t: "error",
|
|
177
|
+
message: `journal out of order: expected seq ${attached.journal.length}, got ${message.seq}`,
|
|
178
|
+
});
|
|
179
|
+
connection.end();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
attached.journal.push(message.event);
|
|
183
|
+
send(connection, { t: "ack", seq: message.seq });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
case "read": {
|
|
187
|
+
// Reads serve Markdown dependencies only. A .ts candidate is
|
|
188
|
+
// never read — its existence is surfaced through stat so the
|
|
189
|
+
// worker can emit the unsupported-TypeScript diagnostic.
|
|
190
|
+
if (!message.path.endsWith(".md")) {
|
|
191
|
+
send(connection, { t: "read", path: message.path, missing: true });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const real = yield* resolveContained(attached, message.path);
|
|
195
|
+
if (real === undefined) {
|
|
196
|
+
send(connection, { t: "read", path: message.path, missing: true });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const existing = yield* stat(real);
|
|
200
|
+
if (!existing.exists || !existing.isFile) {
|
|
201
|
+
send(connection, { t: "read", path: message.path, missing: true });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const source = yield* readTextFile(real);
|
|
205
|
+
send(connection, { t: "read", path: message.path, source, missing: false });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
case "stat": {
|
|
209
|
+
const real = yield* resolveContained(attached, message.path);
|
|
210
|
+
if (real === undefined) {
|
|
211
|
+
send(connection, { t: "stat", path: message.path, exists: false, isFile: false });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const existing = yield* stat(real);
|
|
215
|
+
send(connection, {
|
|
216
|
+
t: "stat",
|
|
217
|
+
path: message.path,
|
|
218
|
+
exists: existing.exists,
|
|
219
|
+
isFile: existing.isFile,
|
|
220
|
+
});
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
case "turn-failure": {
|
|
224
|
+
const failure = { kind: message.kind, actual: message.actual };
|
|
225
|
+
if (message.expected !== undefined) {
|
|
226
|
+
failure.expected = message.expected;
|
|
227
|
+
}
|
|
228
|
+
// Record before acknowledging: the worker awaits this ack before
|
|
229
|
+
// surfacing the ACP error, so the diagnostic is already observable
|
|
230
|
+
// once that error is seen.
|
|
231
|
+
attached.failure = failure;
|
|
232
|
+
send(connection, { t: "recorded" });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
case "fatal": {
|
|
236
|
+
attached.fatal = message.message;
|
|
237
|
+
send(connection, { t: "recorded" });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
case "attach": {
|
|
241
|
+
send(connection, { t: "error", message: "duplicate attach" });
|
|
242
|
+
connection.end();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const server = yield* useLineServer("127.0.0.1", handleConnection);
|
|
248
|
+
const port = server.port;
|
|
249
|
+
// A scenario instance is a resource: setup adds it to the routing index;
|
|
250
|
+
// its finalizer removes it from the index, revokes and awaits its active
|
|
251
|
+
// worker, and always clears its state. The index maps are lookups only —
|
|
252
|
+
// membership owns no lifecycle, so ending an instance's scope is the only
|
|
253
|
+
// way it is torn down.
|
|
254
|
+
function useInstance(config) {
|
|
255
|
+
return resource(function* (provide) {
|
|
256
|
+
const id = randomUUID();
|
|
257
|
+
const instance = {
|
|
258
|
+
id,
|
|
259
|
+
route: formatRoute({ host: "127.0.0.1", port, token, instance: id }),
|
|
260
|
+
scenarioDir: resolve(config.scenarioDir),
|
|
261
|
+
doc: config.doc,
|
|
262
|
+
journal: [],
|
|
263
|
+
};
|
|
264
|
+
instances.set(id, instance);
|
|
265
|
+
yield* ensure(function* () {
|
|
266
|
+
// Remove from the routing index first so in-flight messages are
|
|
267
|
+
// rejected and no new worker can attach.
|
|
268
|
+
instances.delete(id);
|
|
269
|
+
try {
|
|
270
|
+
const registration = active.get(id);
|
|
271
|
+
if (registration) {
|
|
272
|
+
registration.revoke();
|
|
273
|
+
yield* registration.closed;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
// Always clear state — even if worker revocation failed.
|
|
278
|
+
instance.journal.length = 0;
|
|
279
|
+
instance.failure = undefined;
|
|
280
|
+
instance.fatal = undefined;
|
|
281
|
+
canonicalRoots.delete(id);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
yield* provide(instance);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
yield* provide({
|
|
288
|
+
probeRoute: formatRoute({ host: "127.0.0.1", port, token, instance: PROBE_INSTANCE }),
|
|
289
|
+
useInstance,
|
|
290
|
+
instance(id) {
|
|
291
|
+
return instances.get(id);
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
}
|
package/esm/src/net.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Node↔Effection boundary for line-framed TCP (specs/test-agent-spec.md
|
|
3
|
+
* §Controller and worker). A server that owns its listener and dispatches
|
|
4
|
+
* connections, a socket whose inbound lines are an Effection stream, and a
|
|
5
|
+
* client that shares the same socket adapter — so callers work with
|
|
6
|
+
* operations and streams, never raw `.on`/`.once`/Promise plumbing.
|
|
7
|
+
*/
|
|
8
|
+
import { createChannel, each, ensure, race, resource, spawn, withResolvers } from "effection";
|
|
9
|
+
import { fromReadable, on } from "@effectionx/node";
|
|
10
|
+
import { lines } from "@effectionx/stream-helpers";
|
|
11
|
+
import { connect, createServer } from "node:net";
|
|
12
|
+
/** Adapt a connected socket: inbound bytes become a line stream; the socket
|
|
13
|
+
* is destroyed on teardown. */
|
|
14
|
+
export function useLineSocket(socket) {
|
|
15
|
+
return resource(function* (provide) {
|
|
16
|
+
const closed = withResolvers();
|
|
17
|
+
socket.once("close", () => closed.resolve());
|
|
18
|
+
yield* ensure(() => {
|
|
19
|
+
socket.destroy();
|
|
20
|
+
closed.resolve(); // settle on cancellation even if no 'close' follows
|
|
21
|
+
});
|
|
22
|
+
yield* provide({
|
|
23
|
+
lines: lines()(fromReadable(socket)),
|
|
24
|
+
send(line) {
|
|
25
|
+
socket.write(line);
|
|
26
|
+
},
|
|
27
|
+
end() {
|
|
28
|
+
socket.end();
|
|
29
|
+
},
|
|
30
|
+
closed: closed.operation,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A localhost line-protocol server as a resource. It listens, then accepts
|
|
36
|
+
* each connection, adapts it with {@link useLineSocket}, and runs
|
|
37
|
+
* `onConnection` for it in its own task — so a caller never spawns or
|
|
38
|
+
* touches a raw socket. The per-connection task is what lets simultaneous
|
|
39
|
+
* connections run concurrently (`each` itself is sequential).
|
|
40
|
+
*/
|
|
41
|
+
export function useLineServer(host, onConnection) {
|
|
42
|
+
return resource(function* (provide) {
|
|
43
|
+
const server = createServer();
|
|
44
|
+
let acceptor;
|
|
45
|
+
// Installed before listen so a setup failure is torn down too. The
|
|
46
|
+
// acceptor's spawned per-connection handlers are its children, so halting
|
|
47
|
+
// it awaits their full teardown — each socket destroyed by useLineSocket's
|
|
48
|
+
// ensure — and nothing holds the listener open when we close it.
|
|
49
|
+
yield* ensure(function* () {
|
|
50
|
+
if (acceptor) {
|
|
51
|
+
yield* acceptor.halt();
|
|
52
|
+
}
|
|
53
|
+
// Attach the close listener before close() (no missed-event race), and
|
|
54
|
+
// only when the server actually came up, so a never-started or
|
|
55
|
+
// already-closed server leaves no pending operation.
|
|
56
|
+
if (server.listening) {
|
|
57
|
+
const closed = withResolvers();
|
|
58
|
+
server.once("close", () => closed.resolve());
|
|
59
|
+
server.close();
|
|
60
|
+
yield* closed.operation;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
// Attach the readiness listeners before listen, so the event is never
|
|
64
|
+
// missed by a later subscription.
|
|
65
|
+
const listening = withResolvers();
|
|
66
|
+
server.once("listening", () => listening.resolve());
|
|
67
|
+
server.once("error", (error) => {
|
|
68
|
+
listening.reject(error instanceof Error ? error : new Error(String(error)));
|
|
69
|
+
});
|
|
70
|
+
server.listen(0, host);
|
|
71
|
+
yield* listening.operation;
|
|
72
|
+
const address = server.address();
|
|
73
|
+
if (!address || typeof address !== "object") {
|
|
74
|
+
throw new Error("useLineServer: unexpected server address");
|
|
75
|
+
}
|
|
76
|
+
const port = address.port;
|
|
77
|
+
acceptor = yield* spawn(function* () {
|
|
78
|
+
for (const [socket] of yield* each(on(server, "connection"))) {
|
|
79
|
+
yield* spawn(function* () {
|
|
80
|
+
const connection = yield* useLineSocket(socket);
|
|
81
|
+
yield* onConnection(connection);
|
|
82
|
+
});
|
|
83
|
+
yield* each.next();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
yield* provide({ port });
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Connect to a line-protocol server. Shares {@link useLineSocket}, adds the
|
|
91
|
+
* connect handshake and a parsed inbound queue behind `next()` — all as
|
|
92
|
+
* operations. `parse` returning `undefined` drops an unparseable line.
|
|
93
|
+
*/
|
|
94
|
+
export function useLineClient(host, port, parse) {
|
|
95
|
+
return resource(function* (provide) {
|
|
96
|
+
const socket = connect(port, host);
|
|
97
|
+
// Attach connect/error listeners before yielding, so neither is missed.
|
|
98
|
+
const connected = withResolvers();
|
|
99
|
+
socket.once("connect", () => connected.resolve());
|
|
100
|
+
socket.once("error", (error) => {
|
|
101
|
+
connected.reject(error instanceof Error ? error : new Error(String(error)));
|
|
102
|
+
});
|
|
103
|
+
const connection = yield* useLineSocket(socket);
|
|
104
|
+
yield* connected.operation;
|
|
105
|
+
const inbound = createChannel();
|
|
106
|
+
const subscription = yield* inbound; // subscribe before the pump: no loss
|
|
107
|
+
yield* spawn(function* () {
|
|
108
|
+
// Close inbound on every exit — EOF, abrupt socket close, or
|
|
109
|
+
// cancellation — so a pending next() always settles.
|
|
110
|
+
yield* ensure(function* () {
|
|
111
|
+
yield* inbound.close();
|
|
112
|
+
});
|
|
113
|
+
yield* race([
|
|
114
|
+
connection.closed,
|
|
115
|
+
(function* () {
|
|
116
|
+
for (const line of yield* each(connection.lines)) {
|
|
117
|
+
const message = parse(line);
|
|
118
|
+
if (message !== undefined) {
|
|
119
|
+
yield* inbound.send(message);
|
|
120
|
+
}
|
|
121
|
+
yield* each.next();
|
|
122
|
+
}
|
|
123
|
+
})(),
|
|
124
|
+
]);
|
|
125
|
+
});
|
|
126
|
+
yield* provide({
|
|
127
|
+
send(line) {
|
|
128
|
+
connection.send(line);
|
|
129
|
+
},
|
|
130
|
+
closed: connection.closed,
|
|
131
|
+
*next() {
|
|
132
|
+
const result = yield* subscription.next();
|
|
133
|
+
if (result.done) {
|
|
134
|
+
throw new Error("connection closed before a reply");
|
|
135
|
+
}
|
|
136
|
+
return result.value;
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Controller ↔ worker wire protocol (specs/test-agent-spec.md
|
|
3
|
+
* §Controller and worker): newline-delimited JSON over localhost TCP.
|
|
4
|
+
* Every inbound line is parsed into a validated type — malformed,
|
|
5
|
+
* unknown, and directionally invalid messages are rejected, never cast.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
const json = z.lazy(() => z.union([
|
|
9
|
+
z.null(),
|
|
10
|
+
z.boolean(),
|
|
11
|
+
z.number(),
|
|
12
|
+
z.string(),
|
|
13
|
+
z.array(json),
|
|
14
|
+
z.record(z.string(), json),
|
|
15
|
+
]));
|
|
16
|
+
const durableResult = z.union([
|
|
17
|
+
z.object({ status: z.literal("ok"), value: json.optional() }),
|
|
18
|
+
z.object({
|
|
19
|
+
status: z.literal("err"),
|
|
20
|
+
error: z.object({
|
|
21
|
+
message: z.string(),
|
|
22
|
+
name: z.string().optional(),
|
|
23
|
+
stack: z.string().optional(),
|
|
24
|
+
}),
|
|
25
|
+
}),
|
|
26
|
+
z.object({ status: z.literal("cancelled") }),
|
|
27
|
+
]);
|
|
28
|
+
const durableEvent = z.union([
|
|
29
|
+
z
|
|
30
|
+
.object({
|
|
31
|
+
type: z.literal("yield"),
|
|
32
|
+
coroutineId: z.string(),
|
|
33
|
+
description: z.object({ type: z.string(), name: z.string() }).catchall(json),
|
|
34
|
+
result: durableResult,
|
|
35
|
+
})
|
|
36
|
+
.catchall(json),
|
|
37
|
+
z
|
|
38
|
+
.object({
|
|
39
|
+
type: z.literal("close"),
|
|
40
|
+
coroutineId: z.string(),
|
|
41
|
+
result: durableResult,
|
|
42
|
+
})
|
|
43
|
+
.catchall(json),
|
|
44
|
+
]);
|
|
45
|
+
const workerMessage = z.discriminatedUnion("t", [
|
|
46
|
+
z.object({ t: z.literal("attach"), token: z.string(), instance: z.string() }),
|
|
47
|
+
z.object({ t: z.literal("journal"), seq: z.number().int().nonnegative(), event: durableEvent }),
|
|
48
|
+
z.object({ t: z.literal("read"), path: z.string() }),
|
|
49
|
+
z.object({ t: z.literal("stat"), path: z.string() }),
|
|
50
|
+
z.object({
|
|
51
|
+
t: z.literal("turn-failure"),
|
|
52
|
+
kind: z.enum(["mismatch", "exhausted", "config"]),
|
|
53
|
+
expected: z.string().optional(),
|
|
54
|
+
actual: z.string(),
|
|
55
|
+
}),
|
|
56
|
+
z.object({ t: z.literal("fatal"), message: z.string() }),
|
|
57
|
+
]);
|
|
58
|
+
const configMessage = z.discriminatedUnion("mode", [
|
|
59
|
+
z.object({ t: z.literal("config"), mode: z.literal("probe") }),
|
|
60
|
+
z.object({
|
|
61
|
+
t: z.literal("config"),
|
|
62
|
+
mode: z.literal("scenario"),
|
|
63
|
+
doc: z.object({ path: z.string(), source: z.string() }),
|
|
64
|
+
journal: z.array(durableEvent),
|
|
65
|
+
}),
|
|
66
|
+
]);
|
|
67
|
+
const controllerMessage = z.union([
|
|
68
|
+
configMessage,
|
|
69
|
+
z.object({ t: z.literal("ack"), seq: z.number().int().nonnegative() }),
|
|
70
|
+
z.object({ t: z.literal("recorded") }),
|
|
71
|
+
z.object({
|
|
72
|
+
t: z.literal("read"),
|
|
73
|
+
path: z.string(),
|
|
74
|
+
source: z.string().optional(),
|
|
75
|
+
missing: z.boolean(),
|
|
76
|
+
}),
|
|
77
|
+
z.object({
|
|
78
|
+
t: z.literal("stat"),
|
|
79
|
+
path: z.string(),
|
|
80
|
+
exists: z.boolean(),
|
|
81
|
+
isFile: z.boolean(),
|
|
82
|
+
}),
|
|
83
|
+
z.object({ t: z.literal("error"), message: z.string() }),
|
|
84
|
+
]);
|
|
85
|
+
export function encodeMessage(message) {
|
|
86
|
+
return JSON.stringify(message) + "\n";
|
|
87
|
+
}
|
|
88
|
+
function parseLine(schema, line, direction) {
|
|
89
|
+
let value;
|
|
90
|
+
try {
|
|
91
|
+
value = JSON.parse(line);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
error: `malformed ${direction} message (invalid JSON): ${error instanceof Error ? error.message : String(error)}`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const parsed = schema.safeParse(value);
|
|
100
|
+
if (!parsed.success) {
|
|
101
|
+
return { ok: false, error: `invalid ${direction} message: ${parsed.error.message}` };
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, message: parsed.data };
|
|
104
|
+
}
|
|
105
|
+
export function parseWorkerMessage(line) {
|
|
106
|
+
return parseLine(workerMessage, line, "worker");
|
|
107
|
+
}
|
|
108
|
+
export function parseControllerMessage(line) {
|
|
109
|
+
return parseLine(controllerMessage, line, "controller");
|
|
110
|
+
}
|
|
111
|
+
/** Incremental newline splitter: feed chunks, receive complete lines. */
|
|
112
|
+
export function createLineSplitter() {
|
|
113
|
+
let buffered = "";
|
|
114
|
+
return {
|
|
115
|
+
feed(chunk) {
|
|
116
|
+
buffered += chunk;
|
|
117
|
+
const lines = buffered.split("\n");
|
|
118
|
+
buffered = lines.pop() ?? "";
|
|
119
|
+
return lines.filter((line) => line.trim().length > 0);
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Routes are opaque to ACPX and workers alike; this is the one place
|
|
125
|
+
* that knows the encoding: host:port/token/instance.
|
|
126
|
+
*/
|
|
127
|
+
export function formatRoute(route) {
|
|
128
|
+
return `${route.host}:${route.port}/${route.token}/${route.instance}`;
|
|
129
|
+
}
|
|
130
|
+
export function parseRoute(value) {
|
|
131
|
+
const match = /^([^:/]+):(\d+)\/([^/]+)\/(.+)$/.exec(value);
|
|
132
|
+
if (!match) {
|
|
133
|
+
return { ok: false, error: `malformed controller route: ${value}` };
|
|
134
|
+
}
|
|
135
|
+
const port = Number.parseInt(match[2], 10);
|
|
136
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65_535) {
|
|
137
|
+
return { ok: false, error: `malformed controller route port: ${value}` };
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
ok: true,
|
|
141
|
+
message: { host: match[1], port, token: match[3], instance: match[4] },
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
export const PROBE_INSTANCE = "probe";
|
package/esm/src/state.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-boundary ACPX state for `<TestAgent>` (specs/test-agent-spec.md
|
|
3
|
+
* §Scenario instances): the production provider state composed with an
|
|
4
|
+
* in-memory session store and a dynamic registry whose resolve() embeds
|
|
5
|
+
* the pending instance route into the worker command. Routing flows
|
|
6
|
+
* through the provider's `sessionRouting` seam: the global route slot is
|
|
7
|
+
* held only across the provider's registry-dependent work (preparation
|
|
8
|
+
* and ensure/session validation + turn start), released while the
|
|
9
|
+
* provider waits on the per-session queue and during turn consumption.
|
|
10
|
+
*/
|
|
11
|
+
import { useAcpxProviderState, useSerialQueues } from "@executablemd/acp";
|
|
12
|
+
export function createMemorySessionStore() {
|
|
13
|
+
const records = new Map();
|
|
14
|
+
return {
|
|
15
|
+
load(sessionId) {
|
|
16
|
+
return Promise.resolve(records.get(sessionId));
|
|
17
|
+
},
|
|
18
|
+
save(record) {
|
|
19
|
+
records.set(record.acpxRecordId, record);
|
|
20
|
+
return Promise.resolve();
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function* useTestAgentAcpx(options) {
|
|
25
|
+
let pendingRoute;
|
|
26
|
+
const routeQueue = yield* useSerialQueues();
|
|
27
|
+
// ACPX tokenizes the command on whitespace with quote support, so
|
|
28
|
+
// command segments containing spaces (e.g. a binary path) are quoted.
|
|
29
|
+
const quote = (segment) => (/\s/.test(segment) ? `"${segment}"` : segment);
|
|
30
|
+
const registry = {
|
|
31
|
+
resolve() {
|
|
32
|
+
const route = pendingRoute ?? options.probeRoute;
|
|
33
|
+
return [...options.workerCommand.map(quote), "--connect", route].join(" ");
|
|
34
|
+
},
|
|
35
|
+
list() {
|
|
36
|
+
return options.agents;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
const state = yield* useAcpxProviderState({ defaultAgent: options.defaultAgent, permissionMode: "deny-all" }, {
|
|
40
|
+
sessionStore: createMemorySessionStore(),
|
|
41
|
+
agentRegistry: registry,
|
|
42
|
+
// withSlot bounds the route mutex to the seam's op without a scope
|
|
43
|
+
// of its own — op's acquisitions (turn resources) belong to the
|
|
44
|
+
// provider's subscriber scope and outlive the critical section.
|
|
45
|
+
sessionRouting: (context, op) => routeQueue.withSlot("route", function* () {
|
|
46
|
+
pendingRoute = options.routeFor(context);
|
|
47
|
+
try {
|
|
48
|
+
return yield* op();
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
pendingRoute = undefined;
|
|
52
|
+
}
|
|
53
|
+
}),
|
|
54
|
+
...(options.seams?.createRuntime ? { createRuntime: options.seams.createRuntime } : {}),
|
|
55
|
+
});
|
|
56
|
+
return { state };
|
|
57
|
+
}
|