@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
package/dist/matchers.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { AssertionError } from "node:assert";
|
|
2
|
+
import { createSafeRegexTester } from "./regex-safety.js";
|
|
3
|
+
function fail(message) {
|
|
4
|
+
throw new AssertionError({ message });
|
|
5
|
+
}
|
|
6
|
+
function isSubsequence(actual, wanted) {
|
|
7
|
+
let i = 0;
|
|
8
|
+
for (const a of actual)
|
|
9
|
+
if (i < wanted.length && a === wanted[i])
|
|
10
|
+
i++;
|
|
11
|
+
return i === wanted.length;
|
|
12
|
+
}
|
|
13
|
+
function containsContiguous(actual, wanted) {
|
|
14
|
+
if (wanted.length === 0)
|
|
15
|
+
return true;
|
|
16
|
+
for (let s = 0; s + wanted.length <= actual.length; s++) {
|
|
17
|
+
let ok = true;
|
|
18
|
+
for (let j = 0; j < wanted.length; j++) {
|
|
19
|
+
if (actual[s + j] !== wanted[j]) {
|
|
20
|
+
ok = false;
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (ok)
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
function isSubset(subset, actual) {
|
|
30
|
+
if (typeof actual !== "object" || actual === null)
|
|
31
|
+
return false;
|
|
32
|
+
const a = actual;
|
|
33
|
+
return Object.entries(subset).every(([k, v]) => typeof v === "object" && v !== null ? isSubset(v, a[k]) : a[k] === v);
|
|
34
|
+
}
|
|
35
|
+
export function expectToolCalled(run, name) {
|
|
36
|
+
const calls = run.toolCalls.filter((c) => c.name === name);
|
|
37
|
+
if (calls.length === 0) {
|
|
38
|
+
fail(`expected tool "${name}" to be called; tools called: ${run.toolCalls.map((c) => c.name).join(", ") || "(none)"}`);
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
withArgs(partial) {
|
|
42
|
+
if (!calls.some((c) => isSubset(partial, c.args))) {
|
|
43
|
+
fail(`expected "${name}" called withArgs >= ${JSON.stringify(partial)}; got ${JSON.stringify(calls.map((c) => c.args))}`);
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
times(n) {
|
|
47
|
+
if (calls.length !== n)
|
|
48
|
+
fail(`expected "${name}" called ${n}x but was ${calls.length}x`);
|
|
49
|
+
},
|
|
50
|
+
never() {
|
|
51
|
+
fail(`expected "${name}" to NOT be called, but it was ${calls.length}x`);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Assert the named tools were called IN ORDER. By default a subsequence (other
|
|
57
|
+
* tool calls may appear between them); `{ strict: true }` requires them to be
|
|
58
|
+
* contiguous.
|
|
59
|
+
*/
|
|
60
|
+
export function expectToolSequence(run, names, opts) {
|
|
61
|
+
const actual = run.toolCalls.map((c) => c.name);
|
|
62
|
+
const ok = opts?.strict ? containsContiguous(actual, names) : isSubsequence(actual, names);
|
|
63
|
+
if (!ok) {
|
|
64
|
+
const missing = names.filter((n) => !actual.includes(n));
|
|
65
|
+
fail(`expected tool sequence ${names.join(" → ")}${opts?.strict ? " (contiguous)" : ""}; ` +
|
|
66
|
+
`got ${actual.join(" → ") || "(none)"}` +
|
|
67
|
+
(missing.length ? ` (missing: ${missing.join(", ")})` : ""));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function expectFinalMessage(run) {
|
|
71
|
+
return {
|
|
72
|
+
toContain(s) {
|
|
73
|
+
if (!run.finalMessage.includes(s))
|
|
74
|
+
fail(`final message ${JSON.stringify(run.finalMessage)} does not contain ${JSON.stringify(s)}`);
|
|
75
|
+
},
|
|
76
|
+
toMatch(re) {
|
|
77
|
+
if (!createSafeRegexTester(re)(run.finalMessage))
|
|
78
|
+
fail(`final message ${JSON.stringify(run.finalMessage)} does not match ${re}`);
|
|
79
|
+
},
|
|
80
|
+
toEqual(s) {
|
|
81
|
+
if (run.finalMessage !== s)
|
|
82
|
+
fail(`final message ${JSON.stringify(run.finalMessage)} !== ${JSON.stringify(s)}`);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export function expectStreamedTokens(run) {
|
|
87
|
+
if (run.tokens.length === 0)
|
|
88
|
+
fail("expected >=1 streamed token, got none");
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Resolve a message object to the `content` string, handling both the
|
|
92
|
+
* JSON-serialized AP shape (`{ id: [...], kwargs: { content } }`) and the
|
|
93
|
+
* raw LangChain BaseMessage instance shape (`{ lc_id: [...], content }`).
|
|
94
|
+
*/
|
|
95
|
+
function resolveMessageContent(m) {
|
|
96
|
+
// JSON-serialized AP shape: { id: ["langchain_core","messages","ToolMessage"], kwargs: { content } }
|
|
97
|
+
const kwContent = m.kwargs?.content;
|
|
98
|
+
if (typeof kwContent === "string")
|
|
99
|
+
return kwContent;
|
|
100
|
+
// Raw BaseMessage instance shape: { lc_id: [...], content }
|
|
101
|
+
const rawContent = m.content;
|
|
102
|
+
if (typeof rawContent === "string")
|
|
103
|
+
return rawContent;
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Return true if `m` is a ToolMessage for the given tool name, regardless
|
|
108
|
+
* of whether the message is a raw LangChain BaseMessage instance or its
|
|
109
|
+
* JSON-serialized form (as produced by the AP endpoint).
|
|
110
|
+
*/
|
|
111
|
+
function isToolMessage(m, toolName) {
|
|
112
|
+
// JSON-serialized AP shape
|
|
113
|
+
const id = m.id;
|
|
114
|
+
const kw = m.kwargs;
|
|
115
|
+
if (Array.isArray(id) && id[2] === "ToolMessage" && kw?.name === toolName)
|
|
116
|
+
return true;
|
|
117
|
+
// Raw BaseMessage instance shape (in-process Layer A harness)
|
|
118
|
+
const lcId = m.lc_id;
|
|
119
|
+
const msgName = m.name;
|
|
120
|
+
if (Array.isArray(lcId) && lcId[2] === "ToolMessage" && msgName === toolName)
|
|
121
|
+
return true;
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
export function expectOffloaded(run, toolName) {
|
|
125
|
+
const msg = run.messages.find((m) => isToolMessage(m, toolName));
|
|
126
|
+
const content = (msg ? resolveMessageContent(msg) : undefined) ?? "";
|
|
127
|
+
if (!content.includes("Tool output offloaded")) {
|
|
128
|
+
fail(`expected "${toolName}" output to be offloaded (stub marker), got: ${content.slice(0, 120)}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export function expectInterrupt(run) {
|
|
132
|
+
if (run.interrupts.length === 0) {
|
|
133
|
+
fail("expected at least one interrupt, but none were captured");
|
|
134
|
+
}
|
|
135
|
+
const chain = {
|
|
136
|
+
ofKind(kind) {
|
|
137
|
+
if (!run.interrupts.some((i) => i.kind === kind)) {
|
|
138
|
+
fail(`expected an interrupt of kind "${kind}"; got: ${run.interrupts.map((i) => i.kind).join(", ") || "(none)"}`);
|
|
139
|
+
}
|
|
140
|
+
return chain;
|
|
141
|
+
},
|
|
142
|
+
withDetail(partial) {
|
|
143
|
+
const found = run.interrupts.some((i) => i.detail !== undefined && isSubset(partial, i.detail));
|
|
144
|
+
if (!found) {
|
|
145
|
+
fail(`expected an interrupt with detail >= ${JSON.stringify(partial)}; got: ${JSON.stringify(run.interrupts.map((i) => i.detail))}`);
|
|
146
|
+
}
|
|
147
|
+
return chain;
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
return chain;
|
|
151
|
+
}
|
|
152
|
+
export function expectNoInterrupt(run) {
|
|
153
|
+
if (run.interrupts.length > 0) {
|
|
154
|
+
fail(`expected no interrupts, but got ${run.interrupts.length}: ${run.interrupts.map((i) => i.kind).join(", ")}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export function expectSubagent(run, name) {
|
|
158
|
+
const sub = run.subagents.find((s) => s.name === name);
|
|
159
|
+
const chain = {
|
|
160
|
+
called() {
|
|
161
|
+
if (!sub) {
|
|
162
|
+
fail(`expected subagent "${name}" to be dispatched; subagents: ${run.subagents.map((s) => s.name).join(", ") || "(none)"}`);
|
|
163
|
+
}
|
|
164
|
+
return chain;
|
|
165
|
+
},
|
|
166
|
+
calledTool(toolName) {
|
|
167
|
+
if (!sub) {
|
|
168
|
+
fail(`expected subagent "${name}" to be dispatched (it was not)`);
|
|
169
|
+
}
|
|
170
|
+
else if (!sub.toolCalls.some((t) => t.name === toolName)) {
|
|
171
|
+
fail(`expected subagent "${name}" to call tool "${toolName}"; called: ${sub.toolCalls.map((t) => t.name).join(", ") || "(none)"}`);
|
|
172
|
+
}
|
|
173
|
+
return chain;
|
|
174
|
+
},
|
|
175
|
+
finalMessageContains(text) {
|
|
176
|
+
if (!sub) {
|
|
177
|
+
fail(`expected subagent "${name}" to be dispatched (it was not)`);
|
|
178
|
+
}
|
|
179
|
+
else if (!(sub.finalMessage ?? "").includes(text)) {
|
|
180
|
+
fail(`expected subagent "${name}" finalMessage to contain "${text}"; got: ${JSON.stringify(sub?.finalMessage)}`);
|
|
181
|
+
}
|
|
182
|
+
return chain;
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
return chain;
|
|
186
|
+
}
|
|
187
|
+
export function expectPlan(run) {
|
|
188
|
+
return {
|
|
189
|
+
toHaveLength(n) {
|
|
190
|
+
if (run.todos.length !== n)
|
|
191
|
+
fail(`expected ${n} todos, got ${run.todos.length}`);
|
|
192
|
+
},
|
|
193
|
+
toHaveTodo(content) {
|
|
194
|
+
if (!run.todos.some((t) => t.content === content)) {
|
|
195
|
+
fail(`expected a todo with content "${content}"; todos: ${run.todos.map((t) => t.content).join(", ") || "(none)"}`);
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
toHaveStatus(content, status) {
|
|
199
|
+
const todo = run.todos.find((t) => t.content === content);
|
|
200
|
+
if (!todo) {
|
|
201
|
+
fail(`expected a todo with content "${content}"; todos: ${run.todos.map((t) => t.content).join(", ") || "(none)"}`);
|
|
202
|
+
}
|
|
203
|
+
if (todo.status !== status) {
|
|
204
|
+
fail(`expected todo "${content}" to have status "${status}", but got "${todo.status}"`);
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
export function expectSystemPrompt(run) {
|
|
210
|
+
return {
|
|
211
|
+
toContain(text) {
|
|
212
|
+
if (!run.systemPrompt.includes(text)) {
|
|
213
|
+
fail(`expected systemPrompt to contain "${text}"; got: ${JSON.stringify(run.systemPrompt.slice(0, 120))}`);
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
toMatch(re) {
|
|
217
|
+
if (!createSafeRegexTester(re)(run.systemPrompt))
|
|
218
|
+
fail(`expected systemPrompt to match ${re}; got: ${JSON.stringify(run.systemPrompt.slice(0, 120))}`);
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Assert no tool returned an error result. A HITL permission interrupt is NOT a
|
|
224
|
+
* tool error — it produces no ToolMessage, so it never appears in toolResults.
|
|
225
|
+
*/
|
|
226
|
+
export function expectNoToolErrors(run) {
|
|
227
|
+
const errored = run.toolResults.filter((r) => r.isError);
|
|
228
|
+
if (errored.length > 0) {
|
|
229
|
+
const detail = errored
|
|
230
|
+
.map((r) => {
|
|
231
|
+
const first = typeof r.content === "string" ? (r.content.split("\n")[0] ?? "").slice(0, 140) : "";
|
|
232
|
+
return `"${r.name}" returned an error: ${first}`;
|
|
233
|
+
})
|
|
234
|
+
.join("; ");
|
|
235
|
+
fail(`expected no tool errors; ${detail}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
export function expectState(run) {
|
|
239
|
+
const messages = Array.isArray(run.state.messages) ? run.state.messages : [];
|
|
240
|
+
return {
|
|
241
|
+
messages: {
|
|
242
|
+
toHaveLength(n) {
|
|
243
|
+
if (messages.length !== n)
|
|
244
|
+
fail(`expected state.messages length ${n}, got ${messages.length}`);
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
field(name) {
|
|
248
|
+
const value = run.state[name];
|
|
249
|
+
return {
|
|
250
|
+
toBeTruthy() {
|
|
251
|
+
if (!value)
|
|
252
|
+
fail(`expected state.${name} to be truthy, got ${JSON.stringify(value)}`);
|
|
253
|
+
},
|
|
254
|
+
toEqual(expected) {
|
|
255
|
+
if (JSON.stringify(value) !== JSON.stringify(expected)) {
|
|
256
|
+
fail(`expected state.${name} = ${JSON.stringify(expected)}, got ${JSON.stringify(value)}`);
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type MemoryStore } from "@b4run/memory";
|
|
2
|
+
/**
|
|
3
|
+
* The contract every MemoryStore must satisfy. Run against sqlite (in-process,
|
|
4
|
+
* always) and pgvector (real Postgres, gated) so backends cannot drift. Pass
|
|
5
|
+
* vitest's `describe`; `makeStore` returns a FRESH empty store per call.
|
|
6
|
+
*/
|
|
7
|
+
export declare function runMemoryStoreConformance(opts: {
|
|
8
|
+
readonly name: string;
|
|
9
|
+
readonly makeStore: () => Promise<MemoryStore> | MemoryStore;
|
|
10
|
+
readonly describe: (name: string, fn: () => void) => void;
|
|
11
|
+
readonly close?: (store: MemoryStore) => Promise<void> | void;
|
|
12
|
+
}): void;
|
|
13
|
+
//# sourceMappingURL=memory-conformance.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"memory-conformance.d.ts","sourceRoot":"","sources":["../src/memory-conformance.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,WAAW,EACjB,MAAM,eAAe,CAAA;AAqDtB;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE;IAC9C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,SAAS,EAAE,MAAM,OAAO,CAAC,WAAW,CAAC,GAAG,WAAW,CAAA;IAC5D,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,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAA;CAC9D,GAAG,IAAI,CAi1CP"}
|