@b4run/testing 0.8.28
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/README.md +51 -0
- package/dist/aimock-runner.d.ts +48 -0
- package/dist/aimock-runner.d.ts.map +1 -0
- package/dist/aimock-runner.js +81 -0
- package/dist/checkpointer-conformance.d.ts +43 -0
- package/dist/checkpointer-conformance.d.ts.map +1 -0
- package/dist/checkpointer-conformance.js +381 -0
- package/dist/fake-embedder.d.ts +9 -0
- package/dist/fake-embedder.d.ts.map +1 -0
- package/dist/fake-embedder.js +38 -0
- package/dist/fixture-builder.d.ts +29 -0
- package/dist/fixture-builder.d.ts.map +1 -0
- package/dist/fixture-builder.js +43 -0
- package/dist/fixture-file.d.ts +14 -0
- package/dist/fixture-file.d.ts.map +1 -0
- package/dist/fixture-file.js +49 -0
- package/dist/harness.d.ts +35 -0
- package/dist/harness.d.ts.map +1 -0
- package/dist/harness.js +211 -0
- package/dist/http-inject.d.ts +22 -0
- package/dist/http-inject.d.ts.map +1 -0
- package/dist/http-inject.js +55 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/matchers.d.ts +56 -0
- package/dist/matchers.d.ts.map +1 -0
- package/dist/matchers.js +262 -0
- package/dist/memory-conformance.d.ts +13 -0
- package/dist/memory-conformance.d.ts.map +1 -0
- package/dist/memory-conformance.js +1333 -0
- package/dist/memory.d.ts +11 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +20 -0
- package/dist/middleware-harness.d.ts +11 -0
- package/dist/middleware-harness.d.ts.map +1 -0
- package/dist/middleware-harness.js +35 -0
- package/dist/permissions-conformance.d.ts +24 -0
- package/dist/permissions-conformance.d.ts.map +1 -0
- package/dist/permissions-conformance.js +156 -0
- package/dist/record-fixtures.d.ts +21 -0
- package/dist/record-fixtures.d.ts.map +1 -0
- package/dist/record-fixtures.js +31 -0
- package/dist/record.d.ts +12 -0
- package/dist/record.d.ts.map +1 -0
- package/dist/record.js +22 -0
- package/dist/regex-safety.d.ts +2 -0
- package/dist/regex-safety.d.ts.map +1 -0
- package/dist/regex-safety.js +66 -0
- package/dist/run-result.d.ts +111 -0
- package/dist/run-result.d.ts.map +1 -0
- package/dist/run-result.js +287 -0
- package/dist/subprocess.d.ts +25 -0
- package/dist/subprocess.d.ts.map +1 -0
- package/dist/subprocess.js +223 -0
- package/dist/thread-access-harness.d.ts +40 -0
- package/dist/thread-access-harness.d.ts.map +1 -0
- package/dist/thread-access-harness.js +42 -0
- package/dist/threads-conformance.d.ts +18 -0
- package/dist/threads-conformance.d.ts.map +1 -0
- package/dist/threads-conformance.js +315 -0
- package/dist/tool-harness.d.ts +16 -0
- package/dist/tool-harness.d.ts.map +1 -0
- package/dist/tool-harness.js +25 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/workspace-harness.d.ts +15 -0
- package/dist/workspace-harness.d.ts.map +1 -0
- package/dist/workspace-harness.js +43 -0
- package/package.json +71 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/** Extract tool results from final conversation messages.
|
|
2
|
+
*
|
|
3
|
+
* Handles two shapes:
|
|
4
|
+
* - Serialized LangChain format: `{ lc:1, type:"constructor", id:[...,"ToolMessage"], kwargs:{name,status,content} }`
|
|
5
|
+
* - Live LangChain instance format: `{ type:"tool", name, status, content, ... }`
|
|
6
|
+
*/
|
|
7
|
+
export function deriveToolResults(messages) {
|
|
8
|
+
const results = [];
|
|
9
|
+
for (const m of messages) {
|
|
10
|
+
// Serialized format: id is the class-path array, name/status/content are in kwargs.
|
|
11
|
+
const id = m.id;
|
|
12
|
+
const isSerializedToolMessage = Array.isArray(id) && id[id.length - 1] === "ToolMessage";
|
|
13
|
+
// Live LangChain instance format: type property is "tool" on the live object.
|
|
14
|
+
const isLiveToolMessage = m.type === "tool";
|
|
15
|
+
if (isSerializedToolMessage) {
|
|
16
|
+
const kwargs = (m.kwargs ?? {});
|
|
17
|
+
const status = kwargs.status === "error" || kwargs.status === "success" ? kwargs.status : undefined;
|
|
18
|
+
results.push({
|
|
19
|
+
name: typeof kwargs.name === "string" ? kwargs.name : "",
|
|
20
|
+
content: kwargs.content,
|
|
21
|
+
isError: status === "error",
|
|
22
|
+
...(status ? { status } : {}),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
else if (isLiveToolMessage) {
|
|
26
|
+
const status = m.status === "error" || m.status === "success"
|
|
27
|
+
? m.status
|
|
28
|
+
: undefined;
|
|
29
|
+
results.push({
|
|
30
|
+
name: typeof m.name === "string" ? m.name : "",
|
|
31
|
+
content: m.content,
|
|
32
|
+
isError: status === "error",
|
|
33
|
+
...(status ? { status } : {}),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return results;
|
|
38
|
+
}
|
|
39
|
+
function finalMessageFrom(state) {
|
|
40
|
+
const messages = Array.isArray(state.messages)
|
|
41
|
+
? state.messages
|
|
42
|
+
: [];
|
|
43
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
44
|
+
const m = messages[i];
|
|
45
|
+
const isAi = (Array.isArray(m.id) && m.id[2] === "AIMessage") || m.type === "ai";
|
|
46
|
+
if (!isAi)
|
|
47
|
+
continue;
|
|
48
|
+
const content = m.kwargs?.content ?? m.content;
|
|
49
|
+
if (typeof content === "string")
|
|
50
|
+
return content;
|
|
51
|
+
}
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
function normalizeToolArgs(raw) {
|
|
55
|
+
// LangChain/LangGraph may deliver tool input as:
|
|
56
|
+
// - an already-parsed object → use as-is
|
|
57
|
+
// - a JSON string → parse
|
|
58
|
+
// - { input: '<json-string>' } single-key envelope (LangGraph tool wrapper) → unwrap + parse
|
|
59
|
+
if (typeof raw === "string") {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(raw);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return raw;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (raw !== null &&
|
|
68
|
+
typeof raw === "object" &&
|
|
69
|
+
"input" in raw &&
|
|
70
|
+
Object.keys(raw).length === 1 &&
|
|
71
|
+
typeof raw.input === "string") {
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(raw.input);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return raw;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return raw;
|
|
80
|
+
}
|
|
81
|
+
function isRecord(value) {
|
|
82
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
83
|
+
}
|
|
84
|
+
function hasStringFields(value, fields) {
|
|
85
|
+
return isRecord(value) && fields.every((field) => typeof value[field] === "string");
|
|
86
|
+
}
|
|
87
|
+
function isCommandInterruptDetail(value) {
|
|
88
|
+
return hasStringFields(value, ["command", "suggestedPattern"]);
|
|
89
|
+
}
|
|
90
|
+
function isPathInterruptDetail(value) {
|
|
91
|
+
return (hasStringFields(value, ["path", "operation", "suggestedPattern"]) &&
|
|
92
|
+
(value.operation === "readFile" ||
|
|
93
|
+
value.operation === "writeFile" ||
|
|
94
|
+
value.operation === "listDir"));
|
|
95
|
+
}
|
|
96
|
+
function isToolInterruptDetail(value) {
|
|
97
|
+
return hasStringFields(value, ["toolName", "argsPreview", "suggestedPattern"]);
|
|
98
|
+
}
|
|
99
|
+
function isMemoryInterruptDetail(value) {
|
|
100
|
+
return hasStringFields(value, [
|
|
101
|
+
"namespace",
|
|
102
|
+
"identity",
|
|
103
|
+
"oldId",
|
|
104
|
+
"oldContent",
|
|
105
|
+
"newContent",
|
|
106
|
+
"suggestedPattern",
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
109
|
+
function isSubagentInterruptDetail(value) {
|
|
110
|
+
return (hasStringFields(value, [
|
|
111
|
+
"parentRouteId",
|
|
112
|
+
"subagentName",
|
|
113
|
+
"subagentRouteId",
|
|
114
|
+
"inputPreview",
|
|
115
|
+
"suggestedPattern",
|
|
116
|
+
]) &&
|
|
117
|
+
(!Object.hasOwn(value, "reason") || typeof value.reason === "string"));
|
|
118
|
+
}
|
|
119
|
+
function parseInterruptInfo(value) {
|
|
120
|
+
if (!isRecord(value))
|
|
121
|
+
throw new Error("Malformed interrupt envelope");
|
|
122
|
+
if (typeof value.interruptId !== "string" || value.interruptId === "") {
|
|
123
|
+
throw new Error("Malformed interrupt envelope: interruptId must be a non-empty string");
|
|
124
|
+
}
|
|
125
|
+
if (value.callId !== undefined && (typeof value.callId !== "string" || value.callId === "")) {
|
|
126
|
+
throw new Error("Malformed interrupt envelope: callId must be a non-empty string");
|
|
127
|
+
}
|
|
128
|
+
const base = {
|
|
129
|
+
interruptId: value.interruptId,
|
|
130
|
+
...(value.callId !== undefined ? { callId: value.callId } : {}),
|
|
131
|
+
};
|
|
132
|
+
switch (value.kind) {
|
|
133
|
+
case "command":
|
|
134
|
+
if (!isCommandInterruptDetail(value.detail)) {
|
|
135
|
+
throw new Error('Malformed "command" interrupt detail');
|
|
136
|
+
}
|
|
137
|
+
return { ...base, kind: value.kind, detail: value.detail };
|
|
138
|
+
case "path":
|
|
139
|
+
if (!isPathInterruptDetail(value.detail)) {
|
|
140
|
+
throw new Error('Malformed "path" interrupt detail');
|
|
141
|
+
}
|
|
142
|
+
return { ...base, kind: value.kind, detail: value.detail };
|
|
143
|
+
case "tool":
|
|
144
|
+
if (!isToolInterruptDetail(value.detail)) {
|
|
145
|
+
throw new Error('Malformed "tool" interrupt detail');
|
|
146
|
+
}
|
|
147
|
+
return { ...base, kind: value.kind, detail: value.detail };
|
|
148
|
+
case "memory":
|
|
149
|
+
if (!isMemoryInterruptDetail(value.detail)) {
|
|
150
|
+
throw new Error('Malformed "memory" interrupt detail');
|
|
151
|
+
}
|
|
152
|
+
return { ...base, kind: value.kind, detail: value.detail };
|
|
153
|
+
case "subagent":
|
|
154
|
+
if (!isSubagentInterruptDetail(value.detail)) {
|
|
155
|
+
throw new Error('Malformed "subagent" interrupt detail');
|
|
156
|
+
}
|
|
157
|
+
return { ...base, kind: value.kind, detail: value.detail };
|
|
158
|
+
default:
|
|
159
|
+
throw new Error(`Unsupported interrupt kind "${String(value.kind)}"`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
export async function collectRunResult(stream, threadId) {
|
|
163
|
+
const tokens = [];
|
|
164
|
+
const toolCalls = [];
|
|
165
|
+
let state = {};
|
|
166
|
+
const interrupts = [];
|
|
167
|
+
const planUpdates = [];
|
|
168
|
+
let todos = [];
|
|
169
|
+
const subagentEvents = [];
|
|
170
|
+
// In-progress subagent runs keyed by call_id
|
|
171
|
+
const subagentMap = new Map();
|
|
172
|
+
const finishedSubagents = [];
|
|
173
|
+
function subagentFor(callId) {
|
|
174
|
+
let run = subagentMap.get(callId);
|
|
175
|
+
if (!run) {
|
|
176
|
+
run = { callId, name: callId, toolCalls: [] };
|
|
177
|
+
subagentMap.set(callId, run);
|
|
178
|
+
}
|
|
179
|
+
return run;
|
|
180
|
+
}
|
|
181
|
+
for await (const chunk of stream) {
|
|
182
|
+
if (chunk.type.startsWith("subagent.")) {
|
|
183
|
+
const data = chunk.data ?? {};
|
|
184
|
+
subagentEvents.push({ type: chunk.type, data });
|
|
185
|
+
}
|
|
186
|
+
switch (chunk.type) {
|
|
187
|
+
case "chunk":
|
|
188
|
+
if (typeof chunk.data === "string")
|
|
189
|
+
tokens.push(chunk.data);
|
|
190
|
+
break;
|
|
191
|
+
case "tool_call": {
|
|
192
|
+
// chunk.name and chunk.input are typed on the tool_call variant
|
|
193
|
+
const c = chunk;
|
|
194
|
+
const entry = c.id !== undefined
|
|
195
|
+
? { name: c.name, args: normalizeToolArgs(c.input), id: c.id }
|
|
196
|
+
: { name: c.name, args: normalizeToolArgs(c.input) };
|
|
197
|
+
toolCalls.push(entry);
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
case "done": {
|
|
201
|
+
const out = chunk.output;
|
|
202
|
+
if (out && typeof out === "object")
|
|
203
|
+
state = out;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
case "interrupt": {
|
|
207
|
+
const data = chunk.data;
|
|
208
|
+
interrupts.push(parseInterruptInfo(data));
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
case "plan_update": {
|
|
212
|
+
const d = chunk.data ?? {};
|
|
213
|
+
const rawTodos = Array.isArray(d.todos) ? d.todos : [];
|
|
214
|
+
const update = { todos: rawTodos };
|
|
215
|
+
planUpdates.push(update);
|
|
216
|
+
todos = update.todos;
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case "subagent.start": {
|
|
220
|
+
const d = chunk.data ?? {};
|
|
221
|
+
const callId = String(d.call_id ?? "");
|
|
222
|
+
const run = subagentFor(callId);
|
|
223
|
+
run.name = String(d.subagent ?? callId);
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
case "subagent.tool_call": {
|
|
227
|
+
const d = chunk.data ?? {};
|
|
228
|
+
const callId = String(d.call_id ?? "");
|
|
229
|
+
const run = subagentFor(callId);
|
|
230
|
+
run.toolCalls.push({ name: String(d.tool ?? ""), args: normalizeToolArgs(d.input) });
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
case "subagent.end": {
|
|
234
|
+
const d = chunk.data ?? {};
|
|
235
|
+
const callId = String(d.call_id ?? "");
|
|
236
|
+
const run = subagentFor(callId);
|
|
237
|
+
if (d.final_message !== undefined) {
|
|
238
|
+
run.finalMessage = String(d.final_message);
|
|
239
|
+
}
|
|
240
|
+
if (d.error !== undefined) {
|
|
241
|
+
run.error = String(d.error);
|
|
242
|
+
}
|
|
243
|
+
subagentMap.delete(callId);
|
|
244
|
+
const finished = run.finalMessage !== undefined && run.error !== undefined
|
|
245
|
+
? {
|
|
246
|
+
callId: run.callId,
|
|
247
|
+
name: run.name,
|
|
248
|
+
toolCalls: run.toolCalls,
|
|
249
|
+
finalMessage: run.finalMessage,
|
|
250
|
+
error: run.error,
|
|
251
|
+
}
|
|
252
|
+
: run.finalMessage !== undefined
|
|
253
|
+
? {
|
|
254
|
+
callId: run.callId,
|
|
255
|
+
name: run.name,
|
|
256
|
+
toolCalls: run.toolCalls,
|
|
257
|
+
finalMessage: run.finalMessage,
|
|
258
|
+
}
|
|
259
|
+
: run.error !== undefined
|
|
260
|
+
? { callId: run.callId, name: run.name, toolCalls: run.toolCalls, error: run.error }
|
|
261
|
+
: { callId: run.callId, name: run.name, toolCalls: run.toolCalls };
|
|
262
|
+
finishedSubagents.push(finished);
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
default:
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const finalMessages = Array.isArray(state.messages)
|
|
270
|
+
? state.messages
|
|
271
|
+
: [];
|
|
272
|
+
return {
|
|
273
|
+
threadId,
|
|
274
|
+
tokens,
|
|
275
|
+
toolCalls,
|
|
276
|
+
toolResults: deriveToolResults(finalMessages),
|
|
277
|
+
state,
|
|
278
|
+
messages: finalMessages,
|
|
279
|
+
finalMessage: finalMessageFrom(state),
|
|
280
|
+
interrupts,
|
|
281
|
+
planUpdates,
|
|
282
|
+
todos,
|
|
283
|
+
subagents: finishedSubagents,
|
|
284
|
+
subagentEvents,
|
|
285
|
+
systemPrompt: "",
|
|
286
|
+
};
|
|
287
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type ChildProcess } from "node:child_process";
|
|
2
|
+
export interface SubprocessApp {
|
|
3
|
+
readonly baseUrl: string;
|
|
4
|
+
close(): Promise<void>;
|
|
5
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export interface TerminationTimings {
|
|
8
|
+
readonly graceMs: number;
|
|
9
|
+
readonly forceMs: number;
|
|
10
|
+
readonly probeIntervalMs: number;
|
|
11
|
+
readonly probeTimeoutMs: number;
|
|
12
|
+
}
|
|
13
|
+
interface TerminationDependencies {
|
|
14
|
+
readonly platform: NodeJS.Platform;
|
|
15
|
+
readonly taskkillProcessTree: (pid: number, timeoutMs: number) => Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export declare function terminateSubprocess(child: ChildProcess, groupPid: number, closed: Promise<void>, baseUrl: string, timings?: TerminationTimings, injectedDependencies?: Partial<TerminationDependencies>): Promise<void>;
|
|
18
|
+
export declare function createSubprocessApp(opts: {
|
|
19
|
+
readonly appRoot: string;
|
|
20
|
+
readonly env?: Record<string, string>;
|
|
21
|
+
readonly port?: number;
|
|
22
|
+
readonly readyTimeoutMs?: number;
|
|
23
|
+
}): Promise<SubprocessApp>;
|
|
24
|
+
export {};
|
|
25
|
+
//# sourceMappingURL=subprocess.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"subprocess.d.ts","sourceRoot":"","sources":["../src/subprocess.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,YAAY,EAAmB,MAAM,oBAAoB,CAAA;AAMvE,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACtB,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CACvC;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;IAChC,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;CAChC;AAsHD,UAAU,uBAAuB;IAC/B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,QAAQ,CAAA;IAClC,QAAQ,CAAC,mBAAmB,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CAChF;AA2CD,wBAAsB,mBAAmB,CACvC,KAAK,EAAE,YAAY,EACnB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,OAAO,CAAC,IAAI,CAAC,EACrB,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,kBAAgD,EACzD,oBAAoB,GAAE,OAAO,CAAC,uBAAuB,CAAM,GAC1D,OAAO,CAAC,IAAI,CAAC,CAgDf;AAED,wBAAsB,mBAAmB,CAAC,IAAI,EAAE;IAC9C,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACrC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAA;CACjC,GAAG,OAAO,CAAC,aAAa,CAAC,CAiDzB"}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { createConnection, createServer } from "node:net";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
const DEFAULT_TERMINATION_TIMINGS = {
|
|
7
|
+
graceMs: 2_000,
|
|
8
|
+
forceMs: 2_000,
|
|
9
|
+
probeIntervalMs: 25,
|
|
10
|
+
probeTimeoutMs: 100,
|
|
11
|
+
};
|
|
12
|
+
/** Bind to port 0, read the OS-assigned port, release it. */
|
|
13
|
+
async function getFreePort() {
|
|
14
|
+
return await new Promise((resolve, reject) => {
|
|
15
|
+
const srv = createServer();
|
|
16
|
+
srv.unref();
|
|
17
|
+
srv.on("error", reject);
|
|
18
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
19
|
+
const addr = srv.address();
|
|
20
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
21
|
+
srv.close(() => resolve(port));
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
async function waitReady(url, timeoutMs) {
|
|
26
|
+
const start = Date.now();
|
|
27
|
+
while (Date.now() - start < timeoutMs) {
|
|
28
|
+
try {
|
|
29
|
+
const res = await fetch(url);
|
|
30
|
+
if (res.ok) {
|
|
31
|
+
const body = (await res.json());
|
|
32
|
+
if (body.status === "ready")
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// not up yet
|
|
38
|
+
}
|
|
39
|
+
await delay(300);
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`subprocess app not ready at ${url} within ${timeoutMs}ms`);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the absolute path to the b4 CLI entry point so the subprocess can
|
|
45
|
+
* be spawned without relying on the probe-app's local node_modules or PATH.
|
|
46
|
+
* We look relative to this module's own location inside the testing package.
|
|
47
|
+
*/
|
|
48
|
+
function resolveB4CliEntry() {
|
|
49
|
+
// import.meta.url resolves to something like:
|
|
50
|
+
// .../packages/testing/src/subprocess.ts (ts-node / source)
|
|
51
|
+
// .../packages/testing/dist/subprocess.js (compiled)
|
|
52
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
53
|
+
// Climb up to packages/testing, then into node_modules/.bin b4 → ../cli/dist/index.js
|
|
54
|
+
// The shell script points at: $basedir/../@b4run/cli/dist/index.js
|
|
55
|
+
// $basedir = packages/testing/node_modules/.bin
|
|
56
|
+
// So the entry is: packages/testing/node_modules/@b4run/cli/dist/index.js
|
|
57
|
+
//
|
|
58
|
+
// "here" is either packages/testing/src or packages/testing/dist — one level below the package root.
|
|
59
|
+
const pkgRoot = resolve(here, "..");
|
|
60
|
+
return resolve(pkgRoot, "node_modules", "@b4run", "cli", "dist", "index.js");
|
|
61
|
+
}
|
|
62
|
+
function childClosePromise(child) {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
child.once("error", reject);
|
|
65
|
+
child.once("close", () => resolve());
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function portAcceptsConnections(baseUrl, timeoutMs) {
|
|
69
|
+
const url = new URL(baseUrl);
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
let settled = false;
|
|
72
|
+
const socket = createConnection({ host: url.hostname, port: Number(url.port) });
|
|
73
|
+
const finish = (accepting) => {
|
|
74
|
+
if (settled)
|
|
75
|
+
return;
|
|
76
|
+
settled = true;
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
socket.destroy();
|
|
79
|
+
resolve(accepting);
|
|
80
|
+
};
|
|
81
|
+
const timer = setTimeout(() => finish(true), timeoutMs);
|
|
82
|
+
socket.once("connect", () => finish(true));
|
|
83
|
+
socket.once("error", () => finish(false));
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
async function waitUntilStopped(childState, baseUrl, timeoutMs, timings) {
|
|
87
|
+
const deadline = Date.now() + timeoutMs;
|
|
88
|
+
while (true) {
|
|
89
|
+
const remainingMs = Math.max(0, deadline - Date.now());
|
|
90
|
+
const accepting = await portAcceptsConnections(baseUrl, Math.min(timings.probeTimeoutMs, remainingMs));
|
|
91
|
+
if (childState.failed)
|
|
92
|
+
throw childState.error;
|
|
93
|
+
if (childState.closed && !accepting)
|
|
94
|
+
return true;
|
|
95
|
+
const waitMs = Math.min(timings.probeIntervalMs, deadline - Date.now());
|
|
96
|
+
if (waitMs <= 0)
|
|
97
|
+
return false;
|
|
98
|
+
await delay(waitMs);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function taskkillProcessTree(pid, timeoutMs) {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
execFile("taskkill.exe", ["/PID", String(pid), "/T", "/F"], { windowsHide: true, timeout: Math.max(1, timeoutMs), maxBuffer: 64 * 1024 }, (error) => (error ? reject(error) : resolve()));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
async function requestProcessTreeTermination(child, childState, groupPid, signal, timeoutMs, dispatchErrors, dependencies) {
|
|
107
|
+
if (dependencies.platform === "win32") {
|
|
108
|
+
if (childState.closed || child.exitCode !== null || child.signalCode !== null)
|
|
109
|
+
return;
|
|
110
|
+
const deadline = Date.now() + timeoutMs;
|
|
111
|
+
try {
|
|
112
|
+
await dependencies.taskkillProcessTree(groupPid, timeoutMs);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
dispatchErrors.push(error);
|
|
117
|
+
if (signal !== "SIGKILL")
|
|
118
|
+
return;
|
|
119
|
+
if (Date.now() >= deadline)
|
|
120
|
+
return;
|
|
121
|
+
if (childState.closed || child.exitCode !== null || child.signalCode !== null)
|
|
122
|
+
return;
|
|
123
|
+
try {
|
|
124
|
+
child.kill(signal);
|
|
125
|
+
}
|
|
126
|
+
catch (childError) {
|
|
127
|
+
dispatchErrors.push(childError);
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
process.kill(-groupPid, signal);
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
dispatchErrors.push(error);
|
|
137
|
+
try {
|
|
138
|
+
child.kill(signal);
|
|
139
|
+
}
|
|
140
|
+
catch (childError) {
|
|
141
|
+
dispatchErrors.push(childError);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
export async function terminateSubprocess(child, groupPid, closed, baseUrl, timings = DEFAULT_TERMINATION_TIMINGS, injectedDependencies = {}) {
|
|
146
|
+
const dependencies = {
|
|
147
|
+
platform: injectedDependencies.platform ?? process.platform,
|
|
148
|
+
taskkillProcessTree: injectedDependencies.taskkillProcessTree ?? taskkillProcessTree,
|
|
149
|
+
};
|
|
150
|
+
const childState = {
|
|
151
|
+
closed: false,
|
|
152
|
+
failed: false,
|
|
153
|
+
error: undefined,
|
|
154
|
+
};
|
|
155
|
+
void closed.then(() => {
|
|
156
|
+
childState.closed = true;
|
|
157
|
+
}, (error) => {
|
|
158
|
+
childState.failed = true;
|
|
159
|
+
childState.error = error;
|
|
160
|
+
});
|
|
161
|
+
const portAccepting = await portAcceptsConnections(baseUrl, timings.probeTimeoutMs);
|
|
162
|
+
if (childState.failed)
|
|
163
|
+
throw childState.error;
|
|
164
|
+
if (childState.closed && !portAccepting)
|
|
165
|
+
return;
|
|
166
|
+
const dispatchErrors = [];
|
|
167
|
+
const waitForPhase = async (signal, phaseMs) => {
|
|
168
|
+
const deadline = Date.now() + phaseMs;
|
|
169
|
+
await requestProcessTreeTermination(child, childState, groupPid, signal, Math.max(0, deadline - Date.now()), dispatchErrors, dependencies);
|
|
170
|
+
return await waitUntilStopped(childState, baseUrl, Math.max(0, deadline - Date.now()), timings);
|
|
171
|
+
};
|
|
172
|
+
if (await waitForPhase("SIGTERM", timings.graceMs))
|
|
173
|
+
return;
|
|
174
|
+
if (await waitForPhase("SIGKILL", timings.forceMs))
|
|
175
|
+
return;
|
|
176
|
+
const message = `subprocess group ${groupPid} did not stop within ${timings.graceMs + timings.forceMs}ms after SIGTERM and SIGKILL`;
|
|
177
|
+
if (dispatchErrors.length === 0)
|
|
178
|
+
throw new Error(message);
|
|
179
|
+
const cause = dispatchErrors.length === 1
|
|
180
|
+
? dispatchErrors[0]
|
|
181
|
+
: new AggregateError(dispatchErrors, "subprocess termination signal dispatch failed");
|
|
182
|
+
throw new Error(message, { cause });
|
|
183
|
+
}
|
|
184
|
+
export async function createSubprocessApp(opts) {
|
|
185
|
+
const port = opts.port ?? (await getFreePort());
|
|
186
|
+
const cliEntry = resolveB4CliEntry();
|
|
187
|
+
const child = spawn(process.execPath, [cliEntry, "dev", "--port", String(port)], {
|
|
188
|
+
cwd: opts.appRoot,
|
|
189
|
+
env: { ...process.env, ...opts.env },
|
|
190
|
+
stdio: "pipe",
|
|
191
|
+
detached: true,
|
|
192
|
+
});
|
|
193
|
+
const groupPid = child.pid;
|
|
194
|
+
const closed = childClosePromise(child);
|
|
195
|
+
if (groupPid === undefined) {
|
|
196
|
+
await closed;
|
|
197
|
+
throw new Error("b4 dev subprocess has no process id");
|
|
198
|
+
}
|
|
199
|
+
// surface server logs for debugging on failure
|
|
200
|
+
child.stdout?.on("data", (b) => process.stdout.write(`[b4 dev] ${b}`));
|
|
201
|
+
child.stderr?.on("data", (b) => process.stderr.write(`[b4 dev] ${b}`));
|
|
202
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
203
|
+
let closePromise;
|
|
204
|
+
const close = () => {
|
|
205
|
+
closePromise ??= terminateSubprocess(child, groupPid, closed, baseUrl, DEFAULT_TERMINATION_TIMINGS);
|
|
206
|
+
return closePromise;
|
|
207
|
+
};
|
|
208
|
+
try {
|
|
209
|
+
await waitReady(`${baseUrl}/healthz`, opts.readyTimeoutMs ?? 60_000);
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
await close();
|
|
213
|
+
throw err;
|
|
214
|
+
}
|
|
215
|
+
const app = {
|
|
216
|
+
baseUrl,
|
|
217
|
+
close,
|
|
218
|
+
[Symbol.asyncDispose]() {
|
|
219
|
+
return close();
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
return app;
|
|
223
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ThreadAccessPolicy, ThreadAccessResult, ThreadAction, ThreadOperation, ThreadSubject } from "@b4run/sdk";
|
|
2
|
+
/** A partial `ThreadAccessRequest`: only `action` is required. */
|
|
3
|
+
export interface ThreadAccessCheckSpec {
|
|
4
|
+
readonly action: ThreadAction;
|
|
5
|
+
/** Defaults to the canonical operation for the action. */
|
|
6
|
+
readonly operation?: ThreadOperation;
|
|
7
|
+
readonly threadId?: string;
|
|
8
|
+
readonly thread?: ThreadSubject;
|
|
9
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
10
|
+
readonly method?: string;
|
|
11
|
+
readonly url?: string;
|
|
12
|
+
readonly requestedMetadata?: Readonly<Record<string, unknown>>;
|
|
13
|
+
/**
|
|
14
|
+
* Whether this request continues a parked turn — see
|
|
15
|
+
* `ThreadAccessRequest.resuming`. Defaults to `false`, the value every
|
|
16
|
+
* endpoint but `POST /threads/:id/resume` and a resume-carrying
|
|
17
|
+
* `POST /agui/:routeId` reports.
|
|
18
|
+
*/
|
|
19
|
+
readonly resuming?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface ThreadAccessHarness {
|
|
22
|
+
/**
|
|
23
|
+
* Run one request through the policy exactly as the runtime would: the same
|
|
24
|
+
* `handler ?? fallback` selection, and the same result normalization — so a
|
|
25
|
+
* handler that returns nothing on some branch denies here too.
|
|
26
|
+
*/
|
|
27
|
+
check(spec: ThreadAccessCheckSpec): Promise<ThreadAccessResult>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Unit-test a thread access policy without booting a server.
|
|
31
|
+
*
|
|
32
|
+
* `createAgentHarness` is blind to policies by construction — it drives
|
|
33
|
+
* `streamResolvedRoute` directly, takes no run slot and writes no threads-store
|
|
34
|
+
* row — so this and `createAgentProtocolInjector({ threadAccess })` are the two
|
|
35
|
+
* surfaces that make a policy testable.
|
|
36
|
+
*/
|
|
37
|
+
export declare function createThreadAccessHarness(options: {
|
|
38
|
+
readonly policy: ThreadAccessPolicy;
|
|
39
|
+
}): ThreadAccessHarness;
|
|
40
|
+
//# sourceMappingURL=thread-access-harness.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"thread-access-harness.d.ts","sourceRoot":"","sources":["../src/thread-access-harness.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,kBAAkB,EAElB,kBAAkB,EAClB,YAAY,EACZ,eAAe,EACf,aAAa,EACd,MAAM,YAAY,CAAA;AAEnB,kEAAkE;AAClE,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAA;IAC7B,0DAA0D;IAC1D,QAAQ,CAAC,SAAS,CAAC,EAAE,eAAe,CAAA;IACpC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAA;IAC/B,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IACnD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,iBAAiB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;IAC9D;;;;;OAKG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAC5B;AAED,MAAM,WAAW,mBAAmB;IAClC;;;;OAIG;IACH,KAAK,CAAC,IAAI,EAAE,qBAAqB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAAA;CAChE;AAeD;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,EAAE;IACjD,QAAQ,CAAC,MAAM,EAAE,kBAAkB,CAAA;CACpC,GAAG,mBAAmB,CAmBtB"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { normalizeThreadAccessResult } from "@b4run/cli/runtime";
|
|
2
|
+
const DEFAULT_OPERATION = {
|
|
3
|
+
create: "thread.create",
|
|
4
|
+
delete: "thread.delete",
|
|
5
|
+
read: "thread.get",
|
|
6
|
+
update: "thread.cancel",
|
|
7
|
+
};
|
|
8
|
+
function defaultMethod(action) {
|
|
9
|
+
if (action === "read")
|
|
10
|
+
return "GET";
|
|
11
|
+
if (action === "delete")
|
|
12
|
+
return "DELETE";
|
|
13
|
+
return "POST";
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Unit-test a thread access policy without booting a server.
|
|
17
|
+
*
|
|
18
|
+
* `createAgentHarness` is blind to policies by construction — it drives
|
|
19
|
+
* `streamResolvedRoute` directly, takes no run slot and writes no threads-store
|
|
20
|
+
* row — so this and `createAgentProtocolInjector({ threadAccess })` are the two
|
|
21
|
+
* surfaces that make a policy testable.
|
|
22
|
+
*/
|
|
23
|
+
export function createThreadAccessHarness(options) {
|
|
24
|
+
return {
|
|
25
|
+
async check(spec) {
|
|
26
|
+
const operation = spec.operation ?? DEFAULT_OPERATION[spec.action];
|
|
27
|
+
const request = {
|
|
28
|
+
action: spec.action,
|
|
29
|
+
headers: spec.headers ?? {},
|
|
30
|
+
method: spec.method ?? defaultMethod(spec.action),
|
|
31
|
+
operation,
|
|
32
|
+
requestedMetadata: spec.requestedMetadata,
|
|
33
|
+
resuming: spec.resuming ?? false,
|
|
34
|
+
thread: spec.thread,
|
|
35
|
+
threadId: spec.threadId,
|
|
36
|
+
url: spec.url ?? (spec.threadId ? `/threads/${spec.threadId}` : "/threads"),
|
|
37
|
+
};
|
|
38
|
+
const handler = options.policy[spec.action] ?? options.policy.fallback;
|
|
39
|
+
return normalizeThreadAccessResult(await handler(request), operation, spec.threadId);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ThreadsStore } from "@b4run/sqlite-storage";
|
|
2
|
+
/**
|
|
3
|
+
* The contract every ThreadsStore must satisfy. Run against the sqlite store
|
|
4
|
+
* (in-process, always) and any other backend (e.g. Postgres, gated) so they
|
|
5
|
+
* cannot drift. Pass vitest's `describe`; `makeStore` returns a FRESH empty
|
|
6
|
+
* store per call.
|
|
7
|
+
*
|
|
8
|
+
* Everything here goes through the public interface — never the tables — so a
|
|
9
|
+
* backend is free to choose its own column types (sqlite keeps `metadata` as a
|
|
10
|
+
* TEXT blob, Postgres as `jsonb`) as long as the observable behavior matches.
|
|
11
|
+
*/
|
|
12
|
+
export declare function runThreadsStoreConformance(opts: {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly makeStore: () => Promise<ThreadsStore> | ThreadsStore;
|
|
15
|
+
readonly describe: (name: string, fn: () => void) => void;
|
|
16
|
+
readonly close?: (store: ThreadsStore) => Promise<void> | void;
|
|
17
|
+
}): void;
|
|
18
|
+
//# sourceMappingURL=threads-conformance.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"threads-conformance.d.ts","sourceRoot":"","sources":["../src/threads-conformance.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAU,YAAY,EAAE,MAAM,uBAAuB,CAAA;AAYjE;;;;;;;;;GASG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE;IAC/C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,SAAS,EAAE,MAAM,OAAO,CAAC,YAAY,CAAC,GAAG,YAAY,CAAA;IAC9D,QAAQ,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,IAAI,KAAK,IAAI,CAAA;IACzD,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAA;CAC/D,GAAG,IAAI,CAsRP"}
|