@executablemd/test-agent 0.0.0-bootstrap.0 → 0.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/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
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WhenPrompt template matching (specs/test-agent-spec.md §WhenPrompt
|
|
3
|
+
* templates). Pure functions — no Effection — so every rule is unit
|
|
4
|
+
* testable: whole-prompt anchoring, `{?name}` captures, `{binding}`
|
|
5
|
+
* constraints, repeated-capture agreement, and adjacent-capture
|
|
6
|
+
* ambiguity.
|
|
7
|
+
*/
|
|
8
|
+
const CAPTURE_HOLE = /^\{\?([A-Za-z_$][A-Za-z0-9_$]*)\}/;
|
|
9
|
+
const BINDING_HOLE = /^\{([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*)\}/;
|
|
10
|
+
const MALFORMED_CAPTURE = /^\{\?/;
|
|
11
|
+
export function parseTemplate(source) {
|
|
12
|
+
const tokens = [];
|
|
13
|
+
const captureNames = [];
|
|
14
|
+
let literal = "";
|
|
15
|
+
let position = 0;
|
|
16
|
+
const flushLiteral = () => {
|
|
17
|
+
if (literal) {
|
|
18
|
+
tokens.push({ kind: "literal", text: literal });
|
|
19
|
+
literal = "";
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
while (position < source.length) {
|
|
23
|
+
const rest = source.slice(position);
|
|
24
|
+
const capture = CAPTURE_HOLE.exec(rest);
|
|
25
|
+
if (capture) {
|
|
26
|
+
flushLiteral();
|
|
27
|
+
const previous = tokens.at(-1);
|
|
28
|
+
if (previous && previous.kind !== "literal") {
|
|
29
|
+
return {
|
|
30
|
+
ok: false,
|
|
31
|
+
error: "adjacent capture holes without literal text between them are ambiguous: " +
|
|
32
|
+
`"${source}"`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const name = capture[1];
|
|
36
|
+
if (!captureNames.includes(name)) {
|
|
37
|
+
captureNames.push(name);
|
|
38
|
+
}
|
|
39
|
+
tokens.push({ kind: "capture", name });
|
|
40
|
+
position += capture[0].length;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (MALFORMED_CAPTURE.test(rest)) {
|
|
44
|
+
return { ok: false, error: `malformed capture hole at position ${position}: "${source}"` };
|
|
45
|
+
}
|
|
46
|
+
const binding = BINDING_HOLE.exec(rest);
|
|
47
|
+
if (binding) {
|
|
48
|
+
flushLiteral();
|
|
49
|
+
const previous = tokens.at(-1);
|
|
50
|
+
if (previous && previous.kind === "capture") {
|
|
51
|
+
// A binding resolves to arbitrary text before matching, so a
|
|
52
|
+
// capture directly followed by a binding has no fixed delimiter
|
|
53
|
+
// either — same ambiguity as two adjacent captures.
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
error: "a capture hole directly followed by another hole is ambiguous: " + `"${source}"`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
tokens.push({ kind: "binding", path: binding[1] });
|
|
60
|
+
position += binding[0].length;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
literal += source[position];
|
|
64
|
+
position++;
|
|
65
|
+
}
|
|
66
|
+
flushLiteral();
|
|
67
|
+
return { ok: true, template: { source, tokens, captureNames } };
|
|
68
|
+
}
|
|
69
|
+
function escapeRegExp(text) {
|
|
70
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
71
|
+
}
|
|
72
|
+
function isRecord(value) {
|
|
73
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
74
|
+
}
|
|
75
|
+
function resolveBinding(path, env) {
|
|
76
|
+
const segments = path.split(".");
|
|
77
|
+
let current = env;
|
|
78
|
+
for (const segment of segments) {
|
|
79
|
+
if (!isRecord(current)) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
current = current[segment];
|
|
83
|
+
}
|
|
84
|
+
return typeof current === "string" ? current : undefined;
|
|
85
|
+
}
|
|
86
|
+
export function matchPrompt(template, prompt, env) {
|
|
87
|
+
let pattern = "^";
|
|
88
|
+
const groupIndexByName = new Map();
|
|
89
|
+
let groupCount = 0;
|
|
90
|
+
for (const token of template.tokens) {
|
|
91
|
+
if (token.kind === "literal") {
|
|
92
|
+
pattern += escapeRegExp(token.text);
|
|
93
|
+
}
|
|
94
|
+
else if (token.kind === "capture") {
|
|
95
|
+
const existing = groupIndexByName.get(token.name);
|
|
96
|
+
if (existing === undefined) {
|
|
97
|
+
groupCount++;
|
|
98
|
+
groupIndexByName.set(token.name, groupCount);
|
|
99
|
+
pattern += "([\\s\\S]+?)";
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
// A repeated capture must match the same text as its first use.
|
|
103
|
+
pattern += `\\${existing}`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
const resolved = resolveBinding(token.path, env);
|
|
108
|
+
if (resolved === undefined) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
kind: "config",
|
|
112
|
+
expected: template.source,
|
|
113
|
+
actual: prompt,
|
|
114
|
+
message: `template "${template.source}" references "{${token.path}}", ` +
|
|
115
|
+
"which is not a bound string value — an unresolved binding is a " +
|
|
116
|
+
"configuration error, never an implicit capture",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
pattern += escapeRegExp(resolved);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
pattern += "$";
|
|
123
|
+
const match = new RegExp(pattern).exec(prompt);
|
|
124
|
+
if (!match) {
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
kind: "mismatch",
|
|
128
|
+
expected: template.source,
|
|
129
|
+
actual: prompt,
|
|
130
|
+
message: `prompt did not match the active stage.\nexpected template: ${template.source}\nactual prompt: ${prompt}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const captures = {};
|
|
134
|
+
for (const [name, index] of groupIndexByName) {
|
|
135
|
+
captures[name] = match[index];
|
|
136
|
+
}
|
|
137
|
+
return { ok: true, captures };
|
|
138
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `<TestAgent>` vocabulary (specs/test-agent-spec.md §TestAgent).
|
|
3
|
+
*
|
|
4
|
+
* `installTestAgentVocabulary` must be installed BEFORE
|
|
5
|
+
* `installAgentVocabulary` in the same scope: in-scope middleware runs
|
|
6
|
+
* in install order, so the global `<Prompt>` interceptor here sees the
|
|
7
|
+
* invocation first, forces `throwOnError` only when both `<TestAgent>`
|
|
8
|
+
* and `<Test>` are active, and otherwise delegates unchanged.
|
|
9
|
+
*
|
|
10
|
+
* Each `<Test>` receives fresh ACPX state keyed by its lease EvalScope;
|
|
11
|
+
* the state is provisioned by a suspended task spawned into that scope,
|
|
12
|
+
* so halting the lease tears the provider down (canceling turns and
|
|
13
|
+
* closing workers) and removes the map entry on normal and failure
|
|
14
|
+
* paths alike. Outside a `<Test>`, the `<TestAgent>` scope itself is
|
|
15
|
+
* the isolation boundary.
|
|
16
|
+
*
|
|
17
|
+
* Scenario instances are resources held by suspended tasks spawned into
|
|
18
|
+
* their boundary scope, so a boundary halt releases them. Because
|
|
19
|
+
* acquiring one is an operation but the provider's route resolver is a
|
|
20
|
+
* synchronous callback, every instance is provisioned by the `session`
|
|
21
|
+
* and `prompt` seams before the provider routes, and `routeFor` is a
|
|
22
|
+
* pure lookup.
|
|
23
|
+
*/
|
|
24
|
+
import { createContext, scoped, spawn, suspend, useScope, withResolvers } from "effection";
|
|
25
|
+
import { basename, dirname, isAbsolute, resolve } from "node:path";
|
|
26
|
+
import { Agent, Component, evalScope } from "@executablemd/core";
|
|
27
|
+
import { cwd as contextualCwd, readTextFile } from "@executablemd/runtime";
|
|
28
|
+
import { Test } from "@executablemd/testing";
|
|
29
|
+
import { useTestAgentController } from "./controller.js";
|
|
30
|
+
import { useTestAgentAcpx } from "./state.js";
|
|
31
|
+
const TestAgentCtx = createContext("testAgent.session", undefined);
|
|
32
|
+
function configError(source, message) {
|
|
33
|
+
return { type: "error", message: `<${source}> ${message}`, source };
|
|
34
|
+
}
|
|
35
|
+
function scenarioKey(agent, sessionName) {
|
|
36
|
+
// JSON encoding keeps the key textual and collision-safe for any
|
|
37
|
+
// agent/session values.
|
|
38
|
+
return JSON.stringify([agent, sessionName]);
|
|
39
|
+
}
|
|
40
|
+
function instanceKeyFor(agent, sessionName, dir) {
|
|
41
|
+
return JSON.stringify([scenarioKey(agent, sessionName ?? ""), dir]);
|
|
42
|
+
}
|
|
43
|
+
function describeMapping(agentName, sessionName) {
|
|
44
|
+
return `agent "${agentName}" and session "${sessionName ?? "(default)"}"`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Resolve a pinned session for the agent using it. The registry routes
|
|
48
|
+
* every agent through the same worker command, so the provider's own
|
|
49
|
+
* ownership check cannot tell two agents apart — a session stays owned
|
|
50
|
+
* by the agent that created it.
|
|
51
|
+
*/
|
|
52
|
+
function resolvePinned(bySessionKey, sessionKey, agentName) {
|
|
53
|
+
const pinned = bySessionKey.get(sessionKey);
|
|
54
|
+
if (!pinned) {
|
|
55
|
+
throw new Error(`unknown or stale agent session "${sessionKey}"`);
|
|
56
|
+
}
|
|
57
|
+
if (pinned.agent !== agentName) {
|
|
58
|
+
throw new Error(`agent "${agentName}" does not match session "${sessionKey}" (agent "${pinned.agent}")`);
|
|
59
|
+
}
|
|
60
|
+
return pinned;
|
|
61
|
+
}
|
|
62
|
+
export function* installTestAgentVocabulary(options) {
|
|
63
|
+
function* expandTestAgent(invocation, ctx) {
|
|
64
|
+
if (!(yield* Test.operations.sessionActive)) {
|
|
65
|
+
return [
|
|
66
|
+
configError("TestAgent", "is valid only in an active testing session created by xmd test or useTesting()."),
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
const agentProp = invocation.props.agent;
|
|
70
|
+
if (agentProp !== undefined && typeof agentProp !== "string") {
|
|
71
|
+
return [configError("TestAgent", 'the "agent" prop must be a string literal.')];
|
|
72
|
+
}
|
|
73
|
+
const defaultAgent = typeof agentProp === "string" ? agentProp : "test";
|
|
74
|
+
return yield* scoped(function* () {
|
|
75
|
+
const controller = yield* useTestAgentController();
|
|
76
|
+
const scenarios = new Map();
|
|
77
|
+
const boundaries = new Map();
|
|
78
|
+
function* resolveInstance(state, agentName, sessionName, dir) {
|
|
79
|
+
const scenario = scenarios.get(scenarioKey(agentName, sessionName ?? ""));
|
|
80
|
+
if (!scenario) {
|
|
81
|
+
throw new Error(`no <TestAgent.Scenario> maps ${describeMapping(agentName, sessionName)}`);
|
|
82
|
+
}
|
|
83
|
+
if (scenario.duplicate) {
|
|
84
|
+
throw new Error(`duplicate <TestAgent.Scenario> mappings for ${describeMapping(agentName, sessionName)}`);
|
|
85
|
+
}
|
|
86
|
+
const key = instanceKeyFor(agentName, sessionName, dir);
|
|
87
|
+
const existing = state.instances.get(key);
|
|
88
|
+
if (existing) {
|
|
89
|
+
return existing;
|
|
90
|
+
}
|
|
91
|
+
const inFlight = state.pending.get(key);
|
|
92
|
+
if (inFlight) {
|
|
93
|
+
return yield* inFlight;
|
|
94
|
+
}
|
|
95
|
+
// Publish the shared future before acquiring so concurrent
|
|
96
|
+
// callers await this acquisition instead of starting their own.
|
|
97
|
+
const ready = withResolvers();
|
|
98
|
+
state.pending.set(key, ready.operation);
|
|
99
|
+
yield* state.boundaryScope.spawn(function* () {
|
|
100
|
+
let instance;
|
|
101
|
+
try {
|
|
102
|
+
instance = yield* controller.useInstance({
|
|
103
|
+
doc: scenario.doc,
|
|
104
|
+
scenarioDir: scenario.scenarioDir,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
// Nothing consumes this task's failure yet, so it is
|
|
109
|
+
// reported through the future the callers are waiting on.
|
|
110
|
+
state.pending.delete(key);
|
|
111
|
+
ready.reject(error instanceof Error ? error : new Error(String(error)));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
state.instances.set(key, instance);
|
|
115
|
+
state.pending.delete(key);
|
|
116
|
+
ready.resolve(instance);
|
|
117
|
+
// Held, not caught: the future has settled, so a later
|
|
118
|
+
// failure must reach the boundary scope to fail the test.
|
|
119
|
+
yield* suspend();
|
|
120
|
+
});
|
|
121
|
+
return yield* ready.operation;
|
|
122
|
+
}
|
|
123
|
+
function* provisionState() {
|
|
124
|
+
// The maps exist before useTestAgentAcpx so the route resolver
|
|
125
|
+
// can close over them.
|
|
126
|
+
const instances = new Map();
|
|
127
|
+
const bySessionKey = new Map();
|
|
128
|
+
const routeFor = (context) => {
|
|
129
|
+
if (typeof context.session === "object") {
|
|
130
|
+
return resolvePinned(bySessionKey, context.session.sessionKey, context.agentName)
|
|
131
|
+
.instance.route;
|
|
132
|
+
}
|
|
133
|
+
const instance = instances.get(instanceKeyFor(context.agentName, context.session, context.cwd));
|
|
134
|
+
if (!instance) {
|
|
135
|
+
throw new Error(`no <TestAgent.Scenario> maps ${describeMapping(context.agentName, context.session)}`);
|
|
136
|
+
}
|
|
137
|
+
return instance.route;
|
|
138
|
+
};
|
|
139
|
+
const acpx = yield* useTestAgentAcpx({
|
|
140
|
+
defaultAgent,
|
|
141
|
+
agents: [defaultAgent],
|
|
142
|
+
workerCommand: options.workerCommand,
|
|
143
|
+
probeRoute: controller.probeRoute,
|
|
144
|
+
routeFor,
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
acpx,
|
|
148
|
+
boundaryScope: yield* useScope(),
|
|
149
|
+
instances,
|
|
150
|
+
pending: new Map(),
|
|
151
|
+
bySessionKey,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
// The <TestAgent> scope itself is the fallback isolation boundary.
|
|
155
|
+
const fallback = yield* provisionState();
|
|
156
|
+
boundaries.set("test-agent-scope", fallback);
|
|
157
|
+
function* boundary() {
|
|
158
|
+
const within = yield* Test.operations.inTest;
|
|
159
|
+
const lease = yield* evalScope;
|
|
160
|
+
const key = within && lease ? lease : "test-agent-scope";
|
|
161
|
+
const existing = boundaries.get(key);
|
|
162
|
+
if (existing) {
|
|
163
|
+
return existing;
|
|
164
|
+
}
|
|
165
|
+
if (key === "test-agent-scope") {
|
|
166
|
+
return fallback;
|
|
167
|
+
}
|
|
168
|
+
const published = withResolvers();
|
|
169
|
+
yield* key.eval(function* () {
|
|
170
|
+
return yield* spawn(function* () {
|
|
171
|
+
try {
|
|
172
|
+
const state = yield* provisionState();
|
|
173
|
+
boundaries.set(key, state);
|
|
174
|
+
published.resolve(state);
|
|
175
|
+
yield* suspend();
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
published.reject(error instanceof Error ? error : new Error(String(error)));
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
boundaries.delete(key);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
return yield* published.operation;
|
|
186
|
+
}
|
|
187
|
+
const session = { defaultAgent, controller, scenarios, boundary };
|
|
188
|
+
yield* TestAgentCtx.set(session);
|
|
189
|
+
yield* Agent.around({
|
|
190
|
+
*agent([name], _next) {
|
|
191
|
+
const state = yield* boundary();
|
|
192
|
+
return yield* state.acpx.state.agent(name);
|
|
193
|
+
},
|
|
194
|
+
*session([name], _next) {
|
|
195
|
+
const state = yield* boundary();
|
|
196
|
+
const agentName = yield* Agent.operations.agent();
|
|
197
|
+
const dir = resolve(yield* contextualCwd());
|
|
198
|
+
const instance = yield* resolveInstance(state, agentName, name, dir);
|
|
199
|
+
// The provider's session() drives the route seam itself;
|
|
200
|
+
// it maps this same context back to the instance route.
|
|
201
|
+
const resolved = yield* state.acpx.state.session(name);
|
|
202
|
+
state.bySessionKey.set(resolved.sessionKey, { agent: agentName, instance });
|
|
203
|
+
return resolved;
|
|
204
|
+
},
|
|
205
|
+
*prompt([content, promptOptions], _next) {
|
|
206
|
+
// Routing flows through the provider's sessionRouting seam,
|
|
207
|
+
// whose resolver is synchronous — so the instance it will
|
|
208
|
+
// look up is provisioned here, once the stream is subscribed.
|
|
209
|
+
return {
|
|
210
|
+
*[Symbol.iterator]() {
|
|
211
|
+
const state = yield* boundary();
|
|
212
|
+
const pinned = promptOptions?.session;
|
|
213
|
+
const agentName = yield* Agent.operations.agent(promptOptions?.agent);
|
|
214
|
+
if (typeof pinned === "object") {
|
|
215
|
+
// Already provisioned by session(); resolving it again
|
|
216
|
+
// would mis-key it as the unnamed session.
|
|
217
|
+
resolvePinned(state.bySessionKey, pinned.sessionKey, agentName);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
const dir = resolve(yield* contextualCwd());
|
|
221
|
+
yield* resolveInstance(state, agentName, pinned, dir);
|
|
222
|
+
}
|
|
223
|
+
const stream = state.acpx.state.promptStream(content, promptOptions);
|
|
224
|
+
return yield* stream;
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
},
|
|
228
|
+
}, { at: "min" });
|
|
229
|
+
const segments = yield* ctx.expand(invocation.children);
|
|
230
|
+
return segments;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
function* expandScenario(invocation) {
|
|
234
|
+
const session = yield* TestAgentCtx.expect();
|
|
235
|
+
if (session === undefined) {
|
|
236
|
+
return [configError("TestAgent.Scenario", "is valid only inside <TestAgent>.")];
|
|
237
|
+
}
|
|
238
|
+
const { agent, session: sessionProp, src } = invocation.props;
|
|
239
|
+
if (typeof src !== "string" || src.length === 0) {
|
|
240
|
+
return [configError("TestAgent.Scenario", 'requires a "src" prop.')];
|
|
241
|
+
}
|
|
242
|
+
if (agent !== undefined && typeof agent !== "string") {
|
|
243
|
+
return [configError("TestAgent.Scenario", 'the "agent" prop must be a string literal.')];
|
|
244
|
+
}
|
|
245
|
+
if (sessionProp !== undefined && typeof sessionProp !== "string") {
|
|
246
|
+
return [configError("TestAgent.Scenario", 'the "session" prop must be a string literal.')];
|
|
247
|
+
}
|
|
248
|
+
const declaredIn = invocation.position?.path;
|
|
249
|
+
const baseDir = declaredIn ? dirname(declaredIn) : ".";
|
|
250
|
+
const srcPath = isAbsolute(src) ? src : resolve(baseDir, src);
|
|
251
|
+
const source = yield* readTextFile(srcPath);
|
|
252
|
+
const agentName = typeof agent === "string" ? agent : session.defaultAgent;
|
|
253
|
+
const key = scenarioKey(agentName, typeof sessionProp === "string" ? sessionProp : "");
|
|
254
|
+
const existing = session.scenarios.get(key);
|
|
255
|
+
if (existing) {
|
|
256
|
+
existing.duplicate = true;
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
session.scenarios.set(key, {
|
|
260
|
+
agent: agentName,
|
|
261
|
+
sessionName: typeof sessionProp === "string" ? sessionProp : "",
|
|
262
|
+
scenarioDir: dirname(srcPath),
|
|
263
|
+
doc: { path: basename(srcPath), source },
|
|
264
|
+
duplicate: false,
|
|
265
|
+
});
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
yield* Component.around({
|
|
269
|
+
*expandInvocation([invocation, ctx], next) {
|
|
270
|
+
if (invocation.name === "TestAgent") {
|
|
271
|
+
return { segments: yield* expandTestAgent(invocation, ctx) };
|
|
272
|
+
}
|
|
273
|
+
if (invocation.name === "TestAgent.Scenario") {
|
|
274
|
+
return { segments: yield* expandScenario(invocation) };
|
|
275
|
+
}
|
|
276
|
+
if (invocation.name === "Prompt") {
|
|
277
|
+
const session = yield* TestAgentCtx.expect();
|
|
278
|
+
if (session !== undefined &&
|
|
279
|
+
(yield* Test.operations.inTest) &&
|
|
280
|
+
invocation.props.throwOnError !== true) {
|
|
281
|
+
return yield* next({ ...invocation, props: { ...invocation.props, throwOnError: true } }, ctx);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return yield* next(invocation, ctx);
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACP-on-stdio serving for `xmd test-agent` (specs/test-agent-spec.md
|
|
3
|
+
* §Controller and worker). stdout carries only JSON-RPC lines; all
|
|
4
|
+
* logging goes to stderr. The worker is stateless but advertises and
|
|
5
|
+
* serves session/load: all state loads from the controller, so a load
|
|
6
|
+
* simply reuses the prior session id over the rehydrated document.
|
|
7
|
+
*/
|
|
8
|
+
import { ensure, resource, until, useScope } from "effection";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import process from "node:process";
|
|
11
|
+
import * as acp from "@agentclientprotocol/sdk";
|
|
12
|
+
function extractText(prompt) {
|
|
13
|
+
let text = "";
|
|
14
|
+
for (const block of prompt) {
|
|
15
|
+
if (block.type === "text") {
|
|
16
|
+
text += block.text;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return text;
|
|
20
|
+
}
|
|
21
|
+
export function* serveAcp(worker, streams) {
|
|
22
|
+
const scope = yield* useScope();
|
|
23
|
+
// The SDK awaits the handler's return. A scope.run task that throws raises
|
|
24
|
+
// into the worker scope, so failures are trapped to a Result (task outcome
|
|
25
|
+
// stays Ok — the worker survives) and re-surfaced as a rejection, which the
|
|
26
|
+
// SDK maps to the JSON-RPC error. On halt the task's own future rejects, so
|
|
27
|
+
// the returned promise always settles — no stranded handler.
|
|
28
|
+
function handle(op) {
|
|
29
|
+
return scope
|
|
30
|
+
.run(function* () {
|
|
31
|
+
try {
|
|
32
|
+
return { ok: true, value: yield* op() };
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
return { ok: false, error };
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
.then((result) => {
|
|
39
|
+
if (result.ok) {
|
|
40
|
+
return result.value;
|
|
41
|
+
}
|
|
42
|
+
throw result.error;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const connection = yield* useAcpConnection(worker, handle, streams);
|
|
46
|
+
// Settles on EOF/error (which close the input stream, closing the
|
|
47
|
+
// connection), explicit connection closure, and worker cancellation (this
|
|
48
|
+
// scope halts, tearing the connection down).
|
|
49
|
+
yield* until(connection.closed);
|
|
50
|
+
}
|
|
51
|
+
function useAcpConnection(worker, handle, streams) {
|
|
52
|
+
return resource(function* (provide) {
|
|
53
|
+
const connection = acp
|
|
54
|
+
.agent({ name: "xmd-test-agent" })
|
|
55
|
+
.onRequest("initialize", () => ({
|
|
56
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
57
|
+
agentCapabilities: { loadSession: true },
|
|
58
|
+
}))
|
|
59
|
+
.onRequest("session/new", () => handle(function* () {
|
|
60
|
+
yield* worker.ready();
|
|
61
|
+
return { sessionId: randomUUID() };
|
|
62
|
+
}))
|
|
63
|
+
.onRequest("session/load", () => handle(function* () {
|
|
64
|
+
yield* worker.ready();
|
|
65
|
+
return {};
|
|
66
|
+
}))
|
|
67
|
+
.onRequest("session/prompt", (ctx) => handle(function* () {
|
|
68
|
+
const result = yield* worker.runTurn(extractText(ctx.params.prompt));
|
|
69
|
+
if (result.cancelled) {
|
|
70
|
+
return { stopReason: "cancelled" };
|
|
71
|
+
}
|
|
72
|
+
yield* until(ctx.client.notify(acp.methods.client.session.update, {
|
|
73
|
+
sessionId: ctx.params.sessionId,
|
|
74
|
+
update: {
|
|
75
|
+
sessionUpdate: "agent_message_chunk",
|
|
76
|
+
content: { type: "text", text: result.text },
|
|
77
|
+
},
|
|
78
|
+
}));
|
|
79
|
+
return { stopReason: "end_turn" };
|
|
80
|
+
}))
|
|
81
|
+
.onNotification("session/cancel", () => {
|
|
82
|
+
worker.cancel();
|
|
83
|
+
})
|
|
84
|
+
.connect(acp.ndJsonStream(streams.output, streams.input));
|
|
85
|
+
yield* ensure(() => connection.close());
|
|
86
|
+
yield* provide(connection);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
// The only process-coupled piece. Named handlers with explicit ownership:
|
|
90
|
+
// end/close close the input stream idempotently; error errors the input
|
|
91
|
+
// stream, which closes the SDK connection; teardown removes every owned
|
|
92
|
+
// listener. connection.closed resolves regardless of the close reason, so
|
|
93
|
+
// serveAcp completes normally on a transport error.
|
|
94
|
+
export function useProcessStdio() {
|
|
95
|
+
return resource(function* (provide) {
|
|
96
|
+
const output = new WritableStream({
|
|
97
|
+
write(chunk) {
|
|
98
|
+
process.stdout.write(chunk);
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
let controller;
|
|
102
|
+
const input = new ReadableStream({
|
|
103
|
+
start(c) {
|
|
104
|
+
controller = c;
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
const onData = (chunk) => controller?.enqueue(new Uint8Array(chunk));
|
|
108
|
+
const closeInput = () => {
|
|
109
|
+
try {
|
|
110
|
+
controller?.close();
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// already closed — end and close may both fire
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const onEnd = () => closeInput();
|
|
117
|
+
const onClose = () => closeInput();
|
|
118
|
+
const onError = (error) => {
|
|
119
|
+
try {
|
|
120
|
+
controller?.error(error);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// already settled
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
process.stdin.on("data", onData);
|
|
127
|
+
process.stdin.on("end", onEnd);
|
|
128
|
+
process.stdin.on("close", onClose);
|
|
129
|
+
process.stdin.on("error", onError);
|
|
130
|
+
yield* ensure(() => {
|
|
131
|
+
process.stdin.off("data", onData);
|
|
132
|
+
process.stdin.off("end", onEnd);
|
|
133
|
+
process.stdin.off("close", onClose);
|
|
134
|
+
process.stdin.off("error", onError);
|
|
135
|
+
});
|
|
136
|
+
yield* provide({ input, output });
|
|
137
|
+
});
|
|
138
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The worker bridge (specs/test-agent-spec.md §Behavior documents): one
|
|
3
|
+
* ordered channel carries output chunks, matcher suspensions, EOF, and
|
|
4
|
+
* document failure, so the final output chunk of a turn always precedes
|
|
5
|
+
* the suspension/EOF signal that ends it. Prompt offers flow the other
|
|
6
|
+
* way, one at a time, to whichever matcher is suspended.
|
|
7
|
+
*/
|
|
8
|
+
import { createChannel, withResolvers } from "effection";
|
|
9
|
+
export function createTurnBridge() {
|
|
10
|
+
const events = createChannel();
|
|
11
|
+
const offers = [];
|
|
12
|
+
const waiters = [];
|
|
13
|
+
function drop(queue, entry) {
|
|
14
|
+
const index = queue.indexOf(entry);
|
|
15
|
+
if (index !== -1) {
|
|
16
|
+
queue.splice(index, 1);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
events,
|
|
21
|
+
*offer(text) {
|
|
22
|
+
const outcome = withResolvers();
|
|
23
|
+
const offer = { text, respond: outcome.resolve };
|
|
24
|
+
const waiter = waiters.shift();
|
|
25
|
+
if (waiter) {
|
|
26
|
+
waiter(offer);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
offers.push(offer);
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
return yield* outcome.operation;
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
// A halted offer must not linger in the queue for a later
|
|
36
|
+
// nextOffer() to deliver as if it were live.
|
|
37
|
+
drop(offers, offer);
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
*nextOffer() {
|
|
41
|
+
const queued = offers.shift();
|
|
42
|
+
if (queued) {
|
|
43
|
+
return queued;
|
|
44
|
+
}
|
|
45
|
+
const arrival = withResolvers();
|
|
46
|
+
waiters.push(arrival.resolve);
|
|
47
|
+
try {
|
|
48
|
+
return yield* arrival.operation;
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
// A halted waiter must not be handed a later offer, which would
|
|
52
|
+
// deliver the prompt to a dead operation and hang the offerer.
|
|
53
|
+
drop(waiters, arrival.resolve);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Read bridge events until the current turn ends, concatenating output.
|
|
60
|
+
* Returns the terminal signal so callers distinguish the next matcher,
|
|
61
|
+
* EOF, and document failure.
|
|
62
|
+
*/
|
|
63
|
+
export function* collectTurn(subscription) {
|
|
64
|
+
let text = "";
|
|
65
|
+
while (true) {
|
|
66
|
+
const next = yield* subscription.next();
|
|
67
|
+
if (next.done) {
|
|
68
|
+
return { text, end: "eof" };
|
|
69
|
+
}
|
|
70
|
+
const event = next.value;
|
|
71
|
+
if (event.kind === "output") {
|
|
72
|
+
text += event.text;
|
|
73
|
+
}
|
|
74
|
+
else if (event.kind === "suspended") {
|
|
75
|
+
return { text, end: "suspended", stage: event.stage };
|
|
76
|
+
}
|
|
77
|
+
else if (event.kind === "failed") {
|
|
78
|
+
return { text, end: "failed", error: event.error };
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
return { text, end: "eof" };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|