@data-fair/lib-agents-sim 0.2.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.
@@ -0,0 +1,64 @@
1
+ function extractUserMessageText(content) {
2
+ if (typeof content === 'string')
3
+ return content;
4
+ if (content === null || content === undefined)
5
+ return '';
6
+ if (Array.isArray(content)) {
7
+ const texts = content
8
+ .filter((part) => typeof part === 'object' && part !== null && part.type === 'text')
9
+ .map((part) => part.text)
10
+ .filter((text) => typeof text === 'string');
11
+ if (texts.length > 0)
12
+ return texts.join(' ');
13
+ if (content.length > 0)
14
+ return JSON.stringify(content);
15
+ }
16
+ return '';
17
+ }
18
+ export function summariseRequest(body) {
19
+ if (typeof body !== 'object' || body === null)
20
+ return null;
21
+ const b = body;
22
+ if (!Array.isArray(b.messages))
23
+ return null;
24
+ const users = b.messages.filter(m => m.role === 'user');
25
+ const last = users[users.length - 1];
26
+ return {
27
+ at: Date.now(),
28
+ model: b.model ?? '',
29
+ toolNames: (b.tools ?? []).map(t => t.function?.name ?? '').filter(Boolean),
30
+ messageCount: b.messages.length,
31
+ lastUserMessage: extractUserMessageText(last?.content),
32
+ toolCalls: b.messages.flatMap(m => (m.tool_calls ?? []).map(c => ({
33
+ name: c.function?.name ?? '',
34
+ arguments: c.function?.arguments ?? ''
35
+ })))
36
+ };
37
+ }
38
+ export function captureGateway(page) {
39
+ const exchanges = [];
40
+ page.on('request', req => {
41
+ if (!req.url().includes('/v1/chat/completions'))
42
+ return;
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(req.postData() ?? '');
46
+ }
47
+ catch {
48
+ exchanges.push({
49
+ at: Date.now(),
50
+ model: '',
51
+ toolNames: [],
52
+ messageCount: 0,
53
+ lastUserMessage: '',
54
+ toolCalls: [],
55
+ unparsed: true
56
+ });
57
+ return;
58
+ }
59
+ const summary = summariseRequest(parsed);
60
+ if (summary)
61
+ exchanges.push(summary);
62
+ });
63
+ return exchanges;
64
+ }
package/index.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ export type { SimulationCase, Transcript, RunSidecar } from './types.ts';
2
+ export { createNeutralCwd, scrubEnv, isolationOptions, type Env } from './isolation.ts';
3
+ export { captureGateway, summariseRequest, type GatewayExchange } from './gateway-capture.ts';
4
+ export { nextUserMessage, personaSystemPrompt, personaPrompt, isDone, DONE } from './persona.ts';
5
+ export { writeEvidence, evidenceDir } from './transcript.ts';
6
+ export { selectCases } from './cases.ts';
7
+ export { reportCases } from './report.ts';
8
+ export { createChatDriver, type ChatRoot, TURN_TIMEOUT_MS } from './chat-driver.ts';
package/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { createNeutralCwd, scrubEnv, isolationOptions } from "./isolation.js";
2
+ export { captureGateway, summariseRequest } from "./gateway-capture.js";
3
+ export { nextUserMessage, personaSystemPrompt, personaPrompt, isDone, DONE } from "./persona.js";
4
+ export { writeEvidence, evidenceDir } from "./transcript.js";
5
+ export { selectCases } from "./cases.js";
6
+ export { reportCases } from "./report.js";
7
+ export { createChatDriver, TURN_TIMEOUT_MS } from "./chat-driver.js";
package/isolation.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ export declare function createNeutralCwd(): string;
2
+ /**
3
+ * Spelled structurally rather than as `NodeJS.ProcessEnv`, which would make
4
+ * `@types/node` an undeclared type dependency of this package: a consumer
5
+ * without it hits `TS2503: Cannot find namespace 'NodeJS'` on this .d.ts.
6
+ * `process.env` satisfies it, so nothing is lost at the call sites.
7
+ */
8
+ export type Env = Record<string, string | undefined>;
9
+ export declare function scrubEnv(env: Env): Env;
10
+ export declare function isolationOptions(cwd: string, env?: Env): {
11
+ cwd: string;
12
+ env: Env;
13
+ settingSources: never[];
14
+ tools: never[];
15
+ strictMcpConfig: true;
16
+ };
package/isolation.js ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The isolation guarantee (spec §1.2).
3
+ *
4
+ * Measured, not assumed: with `settingSources: []` but the repository as cwd, the
5
+ * model answered with the project's auto-memory index — naming the bugs this
6
+ * tooling exists to find. Auto-memory is keyed to the project directory, so only
7
+ * a neutral cwd removes it. `tools: []` matters just as much: without it the SDK
8
+ * offers 27 built-in tools and the model reaches for ToolSearch instead of the
9
+ * tools the request actually declared.
10
+ *
11
+ * These options are fixed. Nothing in a request may override them.
12
+ */
13
+ import os from 'node:os';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ export function createNeutralCwd() {
17
+ // Deliberately meaningless name: ~367 tokens of SDK preamble are irreducible and
18
+ // include the cwd path, so the path itself must carry no signal about the product.
19
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'bridge-'));
20
+ }
21
+ export function scrubEnv(env) {
22
+ const scrubbed = { ...env };
23
+ for (const key of Object.keys(scrubbed)) {
24
+ if (key.startsWith('CLAUDE_CODE_'))
25
+ delete scrubbed[key];
26
+ }
27
+ return scrubbed;
28
+ }
29
+ export function isolationOptions(cwd, env = process.env) {
30
+ return {
31
+ cwd,
32
+ env: scrubEnv(env),
33
+ settingSources: [],
34
+ tools: [],
35
+ strictMcpConfig: true
36
+ };
37
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The Agent SDK and MCP SDK are optional peers (see package.json) so a
3
+ * consumer who only wants the harness primitives (createChatDriver,
4
+ * captureGateway, selectCases, reportCases, ...) is not forced to install
5
+ * them. Two places load the Agent SDK lazily, inside the function that needs
6
+ * it rather than at module top level, so importing the barrel never requires
7
+ * it: `persona.ts`'s `nextUserMessage` (the simulated user) and the
8
+ * `df-agents-bridge` bin. Both catch a resulting ERR_MODULE_NOT_FOUND here
9
+ * and surface this message instead of node's raw error, naming the install
10
+ * command.
11
+ *
12
+ * Exported as a constant (not inlined) so a unit test can assert it still
13
+ * exists and still names the right packages.
14
+ */
15
+ export declare const MISSING_SDK_MESSAGE: string;
16
+ export declare function isMissingSdkError(err: unknown): boolean;
package/missing-sdk.js ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The Agent SDK and MCP SDK are optional peers (see package.json) so a
3
+ * consumer who only wants the harness primitives (createChatDriver,
4
+ * captureGateway, selectCases, reportCases, ...) is not forced to install
5
+ * them. Two places load the Agent SDK lazily, inside the function that needs
6
+ * it rather than at module top level, so importing the barrel never requires
7
+ * it: `persona.ts`'s `nextUserMessage` (the simulated user) and the
8
+ * `df-agents-bridge` bin. Both catch a resulting ERR_MODULE_NOT_FOUND here
9
+ * and surface this message instead of node's raw error, naming the install
10
+ * command.
11
+ *
12
+ * Exported as a constant (not inlined) so a unit test can assert it still
13
+ * exists and still names the right packages.
14
+ */
15
+ export const MISSING_SDK_MESSAGE = [
16
+ '@data-fair/lib-agents-sim needs the Agent SDK and MCP SDK, which are optional peer dependencies and are not installed.',
17
+ 'Install them with:',
18
+ ' npm i -D @anthropic-ai/claude-agent-sdk @modelcontextprotocol/sdk'
19
+ ].join('\n');
20
+ export function isMissingSdkError(err) {
21
+ if (!err || typeof err !== 'object')
22
+ return false;
23
+ if (err.code !== 'ERR_MODULE_NOT_FOUND')
24
+ return false;
25
+ const message = String(err.message ?? '');
26
+ return message.includes('@anthropic-ai/claude-agent-sdk') || message.includes('@modelcontextprotocol/sdk');
27
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@data-fair/lib-agents-sim",
3
+ "version": "0.2.0",
4
+ "description": "Primitives for judged browser simulations of the data-fair agents chat, plus a Claude Code bridge exposing the Agent SDK as an OpenAI-compatible provider.",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./index.d.ts",
10
+ "default": "./index.js"
11
+ },
12
+ "./package.json": "./package.json"
13
+ },
14
+ "bin": {
15
+ "df-agents-bridge": "bridge/index.js",
16
+ "df-agents-sim-init": "bin/init.js"
17
+ },
18
+ "files": [
19
+ "**/*.js",
20
+ "**/*.d.ts",
21
+ "templates/**"
22
+ ],
23
+ "scripts": {
24
+ "build": "tsc",
25
+ "prepublishOnly": "tsc"
26
+ },
27
+ "license": "MIT",
28
+ "peerDependencies": {
29
+ "@playwright/test": "^1.58.0",
30
+ "@anthropic-ai/claude-agent-sdk": "^0.3.269",
31
+ "@modelcontextprotocol/sdk": "^1.0.0"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "@playwright/test": {
35
+ "optional": true
36
+ },
37
+ "@anthropic-ai/claude-agent-sdk": {
38
+ "optional": true
39
+ },
40
+ "@modelcontextprotocol/sdk": {
41
+ "optional": true
42
+ }
43
+ }
44
+ }
package/persona.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { SimulationCase } from './types.ts';
2
+ export declare const DONE = "DONE";
3
+ export declare function isDone(message: string): boolean;
4
+ export declare function personaSystemPrompt(c: SimulationCase): string;
5
+ export declare function personaPrompt(conversation: Array<{
6
+ role: string;
7
+ text: string;
8
+ }>, turnsLeft: number): string;
9
+ export declare function nextUserMessage(c: SimulationCase, conversation: Array<{
10
+ role: string;
11
+ text: string;
12
+ }>, turnsLeft: number): Promise<string>;
package/persona.js ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The simulated user.
3
+ *
4
+ * It is a person with a goal, not a test script: it may be vague, change its
5
+ * mind, or push back, which is what makes this a simulation rather than a
6
+ * fixture. It runs under the same isolation as every other Claude role here —
7
+ * launched from this repo it would inherit the auto-memory index and know the
8
+ * bugs the scenario exists to find (spec §1.2).
9
+ */
10
+ import { createNeutralCwd, isolationOptions } from "./isolation.js";
11
+ import { MISSING_SDK_MESSAGE, isMissingSdkError } from "./missing-sdk.js";
12
+ export const DONE = 'DONE';
13
+ let neutralCwd;
14
+ export function isDone(message) {
15
+ if (!message)
16
+ return false;
17
+ // Normalize the message: trim, strip quotes/backticks, strip trailing punctuation, uppercase
18
+ let normalized = message.trim();
19
+ // Strip surrounding quotes or backticks
20
+ if ((normalized.startsWith('"') && normalized.endsWith('"')) ||
21
+ (normalized.startsWith("'") && normalized.endsWith("'")) ||
22
+ (normalized.startsWith('`') && normalized.endsWith('`'))) {
23
+ normalized = normalized.slice(1, -1);
24
+ }
25
+ // Strip trailing punctuation
26
+ normalized = normalized.replace(/[.!,:]+$/, '');
27
+ // Uppercase and check if exactly DONE
28
+ normalized = normalized.toUpperCase().trim();
29
+ // Only true if it is exactly DONE, not a sentence containing the word
30
+ return normalized === DONE;
31
+ }
32
+ export function personaSystemPrompt(c) {
33
+ return [
34
+ c.persona,
35
+ '',
36
+ `What you want: ${c.goal}`,
37
+ '',
38
+ 'You are talking to an assistant through a chat box on a web page. Behave like a real person:',
39
+ '- Say what you want in your own words. Do not explain how the assistant should do it.',
40
+ '- If a reply is vague, unhelpful, or does not actually show you the result, say so.',
41
+ '- If you are asked a question, answer it.',
42
+ '- Do not be artificially cooperative, and do not thank the assistant for work it has not done.',
43
+ '',
44
+ 'Reply with ONLY the message you would type next — no quotes, no narration, no stage directions.',
45
+ `When you have what you wanted, or you are convinced you will not get it, reply with exactly ${DONE} and nothing else.`
46
+ ].join('\n');
47
+ }
48
+ export function personaPrompt(conversation, turnsLeft) {
49
+ if (conversation.length === 0)
50
+ return 'Write your first message to the assistant.';
51
+ const transcript = conversation.map(m => `${m.role === 'user' ? 'you' : 'assistant'}: ${m.text}`).join('\n\n');
52
+ const warning = turnsLeft <= 1
53
+ ? '\n\nThis is your last message. If you already have what you needed, reply ' + DONE + '.'
54
+ : '';
55
+ return [
56
+ 'The conversation so far:',
57
+ '',
58
+ transcript,
59
+ '',
60
+ `Write your next message, or ${DONE} if you are finished.${warning}`
61
+ ].join('\n');
62
+ }
63
+ export async function nextUserMessage(c, conversation, turnsLeft) {
64
+ // Loaded here, not at module top level, so importing the package barrel
65
+ // never requires the Agent SDK — it is an optional peer, and a consumer who
66
+ // only wants the harness primitives must not pay for it. This is the only
67
+ // place in the exported surface that reaches for it at runtime.
68
+ let query;
69
+ try {
70
+ ({ query } = await import('@anthropic-ai/claude-agent-sdk'));
71
+ }
72
+ catch (err) {
73
+ if (isMissingSdkError(err))
74
+ throw new Error(MISSING_SDK_MESSAGE);
75
+ throw err;
76
+ }
77
+ neutralCwd ??= createNeutralCwd();
78
+ let text = '';
79
+ for await (const msg of query({
80
+ prompt: personaPrompt(conversation, turnsLeft),
81
+ options: {
82
+ ...isolationOptions(neutralCwd),
83
+ model: process.env.SIM_USER_MODEL ?? 'haiku',
84
+ systemPrompt: personaSystemPrompt(c),
85
+ maxTurns: 1
86
+ }
87
+ })) {
88
+ if (msg.type === 'assistant') {
89
+ for (const block of msg.message?.content ?? []) {
90
+ if (block.type === 'text' && block.text)
91
+ text += block.text;
92
+ }
93
+ }
94
+ }
95
+ return text.trim();
96
+ }
package/report.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { SimulationCase } from './types.ts';
2
+ export declare function reportCases(cases: SimulationCase[], evidenceDir: string): number;
package/report.js ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Reads the evidence and says what happened. Returns the failure count so the
3
+ * caller sets the exit code — a suite that cannot fail is not a suite.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ // The verdict is written by a model and is untrusted by construction — a
8
+ // well-known slip is a stringified boolean ("false" instead of false), which
9
+ // would otherwise pass truthiness checks and silently report success.
10
+ function isValidVerdict(v, caseName) {
11
+ if (typeof v !== 'object' || v === null)
12
+ return false;
13
+ const o = v;
14
+ return o.case === caseName &&
15
+ typeof o.satisfied === 'boolean' &&
16
+ Array.isArray(o.frictions) &&
17
+ typeof o.summary === 'string';
18
+ }
19
+ export function reportCases(cases, evidenceDir) {
20
+ const read = (file) => {
21
+ try {
22
+ return JSON.parse(fs.readFileSync(path.join(evidenceDir, file), 'utf8'));
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ };
28
+ let failures = 0;
29
+ const rows = [['case', 'model', 'turns', 'verdict', 'frictions', 'duration']];
30
+ for (const c of cases) {
31
+ const run = read(`sim-${c.name}.run.json`);
32
+ const verdict = read(`sim-${c.name}.verdict.json`);
33
+ let state;
34
+ let frictions = '-';
35
+ if (!run) {
36
+ state = 'not run';
37
+ failures++;
38
+ }
39
+ else if (!run.valid) {
40
+ state = `invalid (${run.error ?? 'unknown'})`;
41
+ failures++;
42
+ }
43
+ else if (!isValidVerdict(verdict, c.name)) {
44
+ state = 'not judged';
45
+ failures++;
46
+ }
47
+ else {
48
+ state = verdict.satisfied === true ? 'satisfied' : 'UNSATISFACTORY';
49
+ if (verdict.satisfied !== true)
50
+ failures++;
51
+ frictions = String(verdict.frictions.length);
52
+ }
53
+ rows.push([c.name, run?.assistantModel ?? '-', String(run?.turns ?? '-'), state, frictions, run ? `${Math.round(run.durationMs / 1000)}s` : '-']);
54
+ }
55
+ const widths = rows[0].map((_, i) => Math.max(...rows.map(r => r[i].length)));
56
+ for (const row of rows)
57
+ console.log(row.map((cell, i) => cell.padEnd(widths[i])).join(' '));
58
+ for (const c of cases) {
59
+ const verdict = read(`sim-${c.name}.verdict.json`);
60
+ if (!isValidVerdict(verdict, c.name) || verdict.frictions.length === 0)
61
+ continue;
62
+ console.log(`\n${c.name}: ${verdict.summary}`);
63
+ for (const f of verdict.frictions)
64
+ console.log(` - turn ${f.turn}: ${f.what} → ${f.effect}`);
65
+ }
66
+ console.log(failures === 0 ? '\nall cases satisfied' : `\n${failures} case(s) need attention`);
67
+ return failures;
68
+ }
@@ -0,0 +1,83 @@
1
+ ---
2
+ name: simulate
3
+ description: Run the scenario simulations - drive real browser conversations with a simulated user, then dispatch a judge per transcript. Use when asked to run the simulations, or after changing a system prompt, a tool description, or the chat orchestration.
4
+ ---
5
+
6
+ # Running the scenario simulations
7
+
8
+ Unit and e2e tests answer "does this mechanism work". This answers "did a person
9
+ get what they came for", by having a simulated one try and then judging the
10
+ transcript.
11
+
12
+ ## Before you start
13
+
14
+ Three things must be true, and each fails confusingly if it is not:
15
+
16
+ 1. The dev stack is up — `bash dev/status.sh`.
17
+ 2. The workspace packages are built — `ls lib-vue/*.js lib-vuetify/*.js`. If they
18
+ are missing, e2e-style runs fail with "element not found".
19
+ 3. The bridge is running — `npm run dev-bridge`. The runner checks this and says so.
20
+
21
+ Ask the user to start anything that is down. Never start or stop dev processes yourself.
22
+
23
+ ## Steps
24
+
25
+ 1. **Read the case list** in `simulations/cases/index.ts`. Note each `name` and `goal`.
26
+
27
+ 2. **Delete evidence from earlier runs.**
28
+
29
+ ```bash
30
+ rm -f simulations/tmp/sim-*
31
+ ```
32
+
33
+ Evidence persists and is only rewritten by a case that actually runs. Without
34
+ this, a case that fails to dispatch reports the previous run's verdict as
35
+ though it were this one's. Deleting first turns that into a visible `not run`.
36
+
37
+ 3. **Run the cases.**
38
+
39
+ ```bash
40
+ npm run simulate # every case
41
+ SIM_CASES=air-quality npm run simulate # one case
42
+ ```
43
+
44
+ Models are pinned by `SIM_ASSISTANT_MODEL` (default `sonnet`) and
45
+ `SIM_USER_MODEL` (default `haiku`), and recorded per run, so verdicts from
46
+ different tiers are never compared silently.
47
+
48
+ 4. **Ignore the runner's own account of how it went.** The transcript at
49
+ `simulations/tmp/sim-<case>.json` is the evidence. A Playwright `passed` line
50
+ means the run was valid, not that the product behaved.
51
+
52
+ A case whose sidecar says `valid: false` must NOT be judged — read
53
+ `simulations/tmp/sim-<case>.run.json` for the recorded error instead.
54
+
55
+ 5. **Dispatch one `simulation-judge` subagent per valid case.** Give it paths, not
56
+ pasted content — transcripts carry every gateway request:
57
+
58
+ - the case name and its goal
59
+ - the transcript path, `simulations/tmp/sim-<case>.json`
60
+ - ask for the JSON verdict its own definition specifies
61
+
62
+ 6. **Write each verdict** to `simulations/tmp/sim-<case>.verdict.json` as raw JSON.
63
+ Strip any code fence the judge added. A malformed verdict reports as
64
+ `not judged`, which is deliberate — check the file rather than being surprised.
65
+
66
+ 7. **Report.**
67
+
68
+ ```bash
69
+ npm run simulate:report
70
+ ```
71
+
72
+ Relay the summary and the friction list. Exit code is non-zero if any case was
73
+ unsatisfactory, invalid, not judged, or never ran.
74
+
75
+ ## Reading the result
76
+
77
+ The friction list is the point. "Unsatisfactory" tells you a run went badly; a
78
+ friction point names the reply or tool result that misled the person and what they
79
+ did next — that is what turns a run into a concrete change.
80
+
81
+ Rate limits are the practical ceiling: three Claude roles per case on one
82
+ subscription. A run cut short by a rate limit is an **invalid run**, not a product
83
+ failure — check the sidecar before concluding anything.
@@ -0,0 +1,65 @@
1
+ ---
2
+ name: simulation-judge
3
+ description: Judge one scenario simulation transcript and return a JSON verdict. Use when asked to verdict a simulation run produced by the /simulate skill.
4
+ tools: Read
5
+ ---
6
+
7
+ You are judging whether a chat assistant actually served a person, from the
8
+ transcript of one simulated conversation.
9
+
10
+ You are given a case name, the person's goal, and a path to a transcript. Read
11
+ the transcript with `Read`; do not ask for it to be pasted.
12
+
13
+ The transcript holds:
14
+ - `conversation` — what the person and the assistant said, as rendered on screen
15
+ - `gateway` — every request the page made, carrying the tools it offered and the
16
+ tool calls the assistant actually made
17
+ - `consoleErrors` — browser errors during the run
18
+
19
+ `gateway` records what the browser SENT to the server, and each request carries
20
+ the whole conversation so far — so the assistant's FINAL reply of a conversation
21
+ never appears there, because no later request resends it. Read the final
22
+ assistant turn from `conversation`, not `gateway`, and never conclude "the
23
+ assistant never answered" from its absence in `gateway`.
24
+
25
+ `gateway[].toolCalls` is CUMULATIVE: exchange N contains every tool call from
26
+ turns 1..N, not just that turn's. Do not treat this as the same tool call being
27
+ repeated — compare call counts across exchanges before filing repetition as a
28
+ friction point, or you will report calls that never actually recurred.
29
+
30
+ Judge the run against the goal, not against your idea of a good answer. The
31
+ person is not a tester: if they had to ask three times, that is a finding even
32
+ if the final answer was correct.
33
+
34
+ **The friction list is the point.** A score says a run went badly; a friction
35
+ point says which reply or tool result misled the person and what they concluded.
36
+ That is what turns a run into a concrete change to a prompt or a tool
37
+ description. Look especially for:
38
+ - the assistant claiming it did something the `gateway` record shows it never did
39
+ - a tool offered but never used when it was obviously needed, or called with
40
+ arguments that misread the person's words
41
+ - the same tool called repeatedly with no progress
42
+ - the person having to supply information the assistant could have looked up
43
+ - an answer that is correct but never shown where the person asked for it
44
+
45
+ Return ONLY raw JSON, no code fence, in exactly this shape:
46
+
47
+ {
48
+ "case": "<case name>",
49
+ "satisfied": true | false,
50
+ "summary": "<one sentence: did the person get what they came for>",
51
+ "frictions": [
52
+ { "turn": <number>, "what": "<what the assistant or a tool did>", "effect": "<what the person concluded or had to do>" }
53
+ ],
54
+ "notes": "<anything a maintainer should know, or empty>"
55
+ }
56
+
57
+ `satisfied` is true only if the person's goal was actually met and visibly so.
58
+ An empty `frictions` array is a real answer when a run went cleanly.
59
+
60
+ A friction's `turn` is the 1-based index of the USER turn it occurred on — the
61
+ Nth message the person sent, counting only the person's messages in
62
+ `conversation` and not the assistant's. So a friction caused by the
63
+ assistant's reply to the person's 3rd message is still `"turn": 3`. Count
64
+ consistently this way so that two judges reading the same transcript would
65
+ agree on the number.
@@ -0,0 +1,9 @@
1
+ import type { Transcript, RunSidecar } from './types.ts';
2
+ /**
3
+ * Default evidence location, resolved against the host repo's cwd. Kept as the
4
+ * default rather than baked in: `reportCases(cases, evidenceDir)` already lets
5
+ * the host choose where evidence lives, so writing had to be able to follow it.
6
+ */
7
+ export declare const evidenceDir: string;
8
+ export type { Transcript, RunSidecar };
9
+ export declare function writeEvidence(name: string, transcript: Transcript, sidecar: RunSidecar, dir?: string): void;
package/transcript.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Evidence files, split in two on purpose.
3
+ *
4
+ * The transcript is what the judge reads. The sidecar records whether the run
5
+ * was VALID — a case that fails to dispatch must report `not run` rather than
6
+ * silently re-reporting the previous run's verdict, which is why both are
7
+ * deleted before a suite and only rewritten by a case that actually executes.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ /**
12
+ * Default evidence location, resolved against the host repo's cwd. Kept as the
13
+ * default rather than baked in: `reportCases(cases, evidenceDir)` already lets
14
+ * the host choose where evidence lives, so writing had to be able to follow it.
15
+ */
16
+ export const evidenceDir = path.join(process.cwd(), 'simulations', 'tmp');
17
+ export function writeEvidence(name, transcript, sidecar, dir = evidenceDir) {
18
+ fs.mkdirSync(dir, { recursive: true });
19
+ fs.writeFileSync(path.join(dir, `sim-${name}.json`), JSON.stringify(transcript, null, 2));
20
+ fs.writeFileSync(path.join(dir, `sim-${name}.run.json`), JSON.stringify(sidecar, null, 2));
21
+ }
package/types.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The shapes a host repo needs to write cases and read evidence.
3
+ */
4
+ import type { GatewayExchange } from './gateway-capture.ts';
5
+ export type SimulationCase = {
6
+ /** Evidence files are named after this; keep it filesystem-safe. */
7
+ name: string;
8
+ route: string;
9
+ /** Who the simulated user is. Becomes its system prompt. */
10
+ persona: string;
11
+ /** What they came for, in their own words. */
12
+ goal: string;
13
+ /** Give up after this many user turns; the judge sees how far it got. */
14
+ maxTurns: number;
15
+ };
16
+ export type Transcript = {
17
+ case: string;
18
+ goal: string;
19
+ persona: string;
20
+ route: string;
21
+ conversation: Array<{
22
+ role: string;
23
+ text: string;
24
+ }>;
25
+ gateway: GatewayExchange[];
26
+ consoleErrors: string[];
27
+ };
28
+ export type RunSidecar = {
29
+ case: string;
30
+ valid: boolean;
31
+ error?: string;
32
+ assistantModel: string;
33
+ userModel: string;
34
+ turns: number;
35
+ durationMs: number;
36
+ finishedAt: string;
37
+ };
package/types.js ADDED
@@ -0,0 +1,4 @@
1
+ /**
2
+ * The shapes a host repo needs to write cases and read evidence.
3
+ */
4
+ export {};