@data-fair/lib-agents-sim 0.4.1 → 0.6.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/chat-driver.d.ts CHANGED
@@ -24,11 +24,23 @@ export declare function chatDriverStrings(locale: ChatDriverLocale): {
24
24
  };
25
25
  export declare const TURN_TIMEOUT_MS: number;
26
26
  export declare const SEND_TIMEOUT_MS = 15000;
27
+ /**
28
+ * How a turn ended. `waiting` means the assistant declared
29
+ * `wait_for_user_action` and is holding the turn open for the person — the
30
+ * caller's cue to let them act, then wait again for the turn it resumes.
31
+ */
32
+ export type TurnOutcome = 'ended' | 'waiting';
33
+ /**
34
+ * Matched on the activity's kind, not its label: the label is the model's own
35
+ * words interpolated into a translated string, so any text match would be both
36
+ * locale-dependent and at the mercy of what the assistant wrote.
37
+ */
38
+ export declare const WAITING_SELECTOR = "[data-testid=\"chat-activity\"][data-activity=\"waiting\"]";
27
39
  export declare function createChatDriver(root: ChatRoot, opts?: {
28
40
  locale?: ChatDriverLocale;
29
41
  }): {
30
42
  sendMessage(text: string): Promise<void>;
31
- waitForTurn(timeoutMs?: number): Promise<void>;
43
+ waitForTurn(timeoutMs?: number): Promise<TurnOutcome>;
32
44
  readConversation(): Promise<{
33
45
  role: "user" | "assistant";
34
46
  text: string;
package/chat-driver.js CHANGED
@@ -46,6 +46,12 @@ export const TURN_TIMEOUT_MS = 10 * 60 * 1000;
46
46
  // later with no diagnosis. Unrelated to TURN_TIMEOUT_MS, which bounds a
47
47
  // legitimately long model turn once the message has actually been sent.
48
48
  export const SEND_TIMEOUT_MS = 15000;
49
+ /**
50
+ * Matched on the activity's kind, not its label: the label is the model's own
51
+ * words interpolated into a translated string, so any text match would be both
52
+ * locale-dependent and at the mercy of what the assistant wrote.
53
+ */
54
+ export const WAITING_SELECTOR = '[data-testid="chat-activity"][data-activity="waiting"]';
49
55
  export function createChatDriver(root, opts = {}) {
50
56
  const strings = chatDriverStrings(opts.locale ?? 'en');
51
57
  return {
@@ -77,10 +83,23 @@ export function createChatDriver(root, opts = {}) {
77
83
  },
78
84
  async waitForTurn(timeoutMs = TURN_TIMEOUT_MS) {
79
85
  const stop = root.getByRole('button', { name: strings.stop });
86
+ const waiting = root.locator(WAITING_SELECTOR);
80
87
  // The turn may already be finished by the time we look, so a missing Stop
81
88
  // button is not an error — only one that never goes away is.
82
89
  await stop.waitFor({ state: 'visible', timeout: 15000 }).catch(() => { });
83
- await expect(stop).toHaveCount(0, { timeout: timeoutMs });
90
+ // A turn can finish two ways, and only one of them is the assistant being
91
+ // done. `wait_for_user_action` holds the turn open on purpose, having handed
92
+ // control back to the person — and a simulated person only acts between
93
+ // turns, so a harness that waited for the Stop button alone could never let
94
+ // them act on it. Every declared wait then ran its whole window and was
95
+ // recorded as a wedged turn; at a wait window as long as the harness's own
96
+ // ceiling, that is every run.
97
+ const ended = expect(stop).toHaveCount(0, { timeout: timeoutMs }).then(() => 'ended');
98
+ const armed = expect(waiting).toHaveCount(1, { timeout: timeoutMs }).then(() => 'waiting',
99
+ // Never rejects: a wait that is simply not what this turn did must not be
100
+ // the error a caller sees. The Stop arm owns the timeout message.
101
+ () => new Promise(() => { }));
102
+ return await Promise.race([ended, armed]);
84
103
  },
85
104
  async readConversation() {
86
105
  // evaluateAll, not page.evaluate: FrameLocator has no evaluate, and this
@@ -22,10 +22,58 @@ export type GatewayExchange = {
22
22
  name: string;
23
23
  arguments: string;
24
24
  }>;
25
+ /** Characters the application injected as `<host-state>` / `<host-events>` /
26
+ * `<hidden-context>` blocks, across every message of this request. They land in
27
+ * tool results and hidden context, neither of which survives into this summary,
28
+ * so the size of what the host reports can only be measured here. Cumulative
29
+ * like the history, so the last request of a conversation carries its total.
30
+ */
31
+ hostBlockChars: number;
32
+ /** What each tool ANSWERED, paired with the call that asked. Cumulative like
33
+ * `toolCalls`. Without these a judge sees only what the assistant asked a tool
34
+ * and can never check a claim about the answer against it — "the form data is
35
+ * valid and saved" was unfalsifiable in a recorded run for exactly this reason.
36
+ * Each result is capped: one large payload must not swallow the evidence file.
37
+ */
38
+ toolResults: Array<{
39
+ id: string;
40
+ name: string;
41
+ result: string;
42
+ }>;
25
43
  /** When true, this exchange was received but postData could not be parsed. Evidence of
26
44
  * a failed capture rather than an absent request.
27
45
  */
28
46
  unparsed?: true;
29
47
  };
48
+ export declare const TOOL_RESULT_MAX_CHARS = 2000;
49
+ /** Only closed blocks count: an unterminated open tag would otherwise swallow
50
+ * the whole rest of the message and report it as host overhead. */
51
+ export declare function countHostBlockChars(messages: unknown[]): number;
52
+ type Body = {
53
+ model?: string;
54
+ messages?: Array<{
55
+ role?: string;
56
+ content?: unknown;
57
+ tool_call_id?: string;
58
+ tool_calls?: Array<{
59
+ id?: string;
60
+ function?: {
61
+ name?: string;
62
+ arguments?: string;
63
+ };
64
+ }>;
65
+ }>;
66
+ tools?: Array<{
67
+ function?: {
68
+ name?: string;
69
+ };
70
+ }>;
71
+ };
72
+ export declare function extractToolResults(messages: NonNullable<Body['messages']>): {
73
+ id: string;
74
+ name: string;
75
+ result: string;
76
+ }[];
30
77
  export declare function summariseRequest(body: unknown): GatewayExchange | null;
31
78
  export declare function captureGateway(page: Page): GatewayExchange[];
79
+ export {};
@@ -1,3 +1,53 @@
1
+ export const TOOL_RESULT_MAX_CHARS = 2000;
2
+ const HOST_BLOCKS = ['host-state', 'host-events', 'hidden-context'];
3
+ /** Only closed blocks count: an unterminated open tag would otherwise swallow
4
+ * the whole rest of the message and report it as host overhead. */
5
+ export function countHostBlockChars(messages) {
6
+ let total = 0;
7
+ for (const message of messages) {
8
+ const content = message?.content;
9
+ const text = typeof content === 'string' ? content : JSON.stringify(content ?? '');
10
+ for (const name of HOST_BLOCKS) {
11
+ const open = `<${name}>`;
12
+ const close = `</${name}>`;
13
+ let from = 0;
14
+ for (;;) {
15
+ const start = text.indexOf(open, from);
16
+ if (start === -1)
17
+ break;
18
+ const end = text.indexOf(close, start);
19
+ if (end === -1)
20
+ break;
21
+ total += end + close.length - start;
22
+ from = end + close.length;
23
+ }
24
+ }
25
+ }
26
+ return total;
27
+ }
28
+ function renderToolResult(content) {
29
+ const text = typeof content === 'string' ? content : JSON.stringify(content ?? '');
30
+ return text.length <= TOOL_RESULT_MAX_CHARS ? text : text.slice(0, TOOL_RESULT_MAX_CHARS) + '…[truncated]';
31
+ }
32
+ export function extractToolResults(messages) {
33
+ // The call that asked may live in any earlier message, so the name is looked
34
+ // up by id across the whole history rather than by adjacency.
35
+ const names = new Map();
36
+ for (const m of messages) {
37
+ for (const call of m.tool_calls ?? []) {
38
+ if (call.id)
39
+ names.set(call.id, call.function?.name ?? '');
40
+ }
41
+ }
42
+ return messages
43
+ .filter(m => m.role === 'tool')
44
+ .map(m => ({
45
+ id: m.tool_call_id ?? '',
46
+ // An unpaired result is still evidence; recording it nameless beats dropping it.
47
+ name: names.get(m.tool_call_id ?? '') ?? '',
48
+ result: renderToolResult(m.content)
49
+ }));
50
+ }
1
51
  function extractUserMessageText(content) {
2
52
  if (typeof content === 'string')
3
53
  return content;
@@ -29,6 +79,8 @@ export function summariseRequest(body) {
29
79
  toolNames: (b.tools ?? []).map(t => t.function?.name ?? '').filter(Boolean),
30
80
  messageCount: b.messages.length,
31
81
  lastUserMessage: extractUserMessageText(last?.content),
82
+ hostBlockChars: countHostBlockChars(b.messages),
83
+ toolResults: extractToolResults(b.messages),
32
84
  toolCalls: b.messages.flatMap(m => (m.tool_calls ?? []).map(c => ({
33
85
  name: c.function?.name ?? '',
34
86
  arguments: c.function?.arguments ?? ''
@@ -52,6 +104,8 @@ export function captureGateway(page) {
52
104
  messageCount: 0,
53
105
  lastUserMessage: '',
54
106
  toolCalls: [],
107
+ hostBlockChars: 0,
108
+ toolResults: [],
55
109
  unparsed: true
56
110
  });
57
111
  return;
package/index.d.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  export type { SimulationCase, Transcript, RunSidecar } from './types.ts';
2
2
  export { createNeutralCwd, scrubEnv, isolationOptions, type Env } from './isolation.ts';
3
3
  export { captureGateway, summariseRequest, type GatewayExchange } from './gateway-capture.ts';
4
- export { nextUserMessage, personaSystemPrompt, personaPrompt, isDone, DONE, PERSONA_MAX_TURNS, PERCEPTION_INSTRUCTIONS } from './persona.ts';
4
+ export { nextUserMessage, personaSystemPrompt, personaPrompt, isDone, DONE, PERSONA_MAX_TURNS, PERCEPTION_INSTRUCTIONS, DEFAULT_USER_MODEL, resolveUserModel } from './persona.ts';
5
5
  export { writeEvidence, evidenceDir } from './transcript.ts';
6
+ export { computeMetrics, type RunMetrics } from './metrics.ts';
6
7
  export { selectCases } from './cases.ts';
7
8
  export { reportCases } from './report.ts';
8
- export { createChatDriver, chatDriverStrings, type ChatRoot, type ChatDriverLocale, TURN_TIMEOUT_MS, SEND_TIMEOUT_MS } from './chat-driver.ts';
9
+ export { createChatDriver, chatDriverStrings, type ChatRoot, type ChatDriverLocale, type TurnOutcome, TURN_TIMEOUT_MS, SEND_TIMEOUT_MS, WAITING_SELECTOR } from './chat-driver.ts';
9
10
  export { createPagePerception, truncate, SNAPSHOT_CAP, ACTION_TIMEOUT_MS, MCP_SERVER_NAME as PAGE_MCP_SERVER_NAME } from './page-perception.ts';
10
11
  export type { PerceptionRoot, Observation, PagePerception } from './page-perception.ts';
package/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  export { createNeutralCwd, scrubEnv, isolationOptions } from "./isolation.js";
2
2
  export { captureGateway, summariseRequest } from "./gateway-capture.js";
3
- export { nextUserMessage, personaSystemPrompt, personaPrompt, isDone, DONE, PERSONA_MAX_TURNS, PERCEPTION_INSTRUCTIONS } from "./persona.js";
3
+ export { nextUserMessage, personaSystemPrompt, personaPrompt, isDone, DONE, PERSONA_MAX_TURNS, PERCEPTION_INSTRUCTIONS, DEFAULT_USER_MODEL, resolveUserModel } from "./persona.js";
4
4
  export { writeEvidence, evidenceDir } from "./transcript.js";
5
+ export { computeMetrics } from "./metrics.js";
5
6
  export { selectCases } from "./cases.js";
6
7
  export { reportCases } from "./report.js";
7
- export { createChatDriver, chatDriverStrings, TURN_TIMEOUT_MS, SEND_TIMEOUT_MS } from "./chat-driver.js";
8
+ export { createChatDriver, chatDriverStrings, TURN_TIMEOUT_MS, SEND_TIMEOUT_MS, WAITING_SELECTOR } from "./chat-driver.js";
8
9
  export { createPagePerception, truncate, SNAPSHOT_CAP, ACTION_TIMEOUT_MS, MCP_SERVER_NAME as PAGE_MCP_SERVER_NAME } from "./page-perception.js";
package/metrics.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Facts derived from a recorded run, handed to the judge as evidence.
3
+ *
4
+ * Nothing here scores anything. There is no threshold, no enum, no pass mark —
5
+ * a metric that decided whether a run was good would narrow the judge's
6
+ * attention to the things that happen to be countable, which is the opposite of
7
+ * what the judge is for. These exist because a few quantities are tedious and
8
+ * error-prone to count by reading a transcript, and two of them are outright
9
+ * traps: `toolCalls` is cumulative, and a sub-agent's requests are interleaved
10
+ * with the lead's.
11
+ */
12
+ import type { Transcript } from './types.ts';
13
+ export type RunMetrics = {
14
+ /** Messages the person actually sent. */
15
+ userMessages: number;
16
+ assistantBubbles: number;
17
+ /**
18
+ * Bubbles that rendered no prose. Almost always a tool-call bubble, which DOES
19
+ * render a chip naming the tool on screen — so this is not a count of silence,
20
+ * and three judges in a row misread it as one. What it measures is how much of
21
+ * a run the person watched as tool names rather than sentences.
22
+ */
23
+ textlessAssistantBubbles: number;
24
+ avgVisibleReplyChars: number | null;
25
+ /** Requests to the gateway, every conversation included. */
26
+ modelRequests: number;
27
+ /** The role that served the person's own conversation (the first request's). */
28
+ leadModel: string;
29
+ leadRequests: number;
30
+ nonLeadRequests: number;
31
+ /**
32
+ * Requests per model role, when the record names one. This is the authority:
33
+ * inferring roles from message counts reported three `summarizer` compaction
34
+ * calls as sub-agent dispatches in a run that made no sub-agent call at all.
35
+ * Null when no request carries a role, where the interleaving split is all
36
+ * there is.
37
+ */
38
+ requestsByModel: Record<string, number> | null;
39
+ /** `modelRequests / userMessages`, to one decimal; null when nobody spoke. */
40
+ requestsPerUserMessage: number | null;
41
+ /** The biggest prompt handed to a role other than the lead's, and which role
42
+ * took it; null when every request was the lead's. A summarizer legitimately
43
+ * carries the conversation, so read the role before reading the size. */
44
+ largestNonLeadPromptChars: number | null;
45
+ largestNonLeadPromptModel: string | null;
46
+ /** Tool calls issued twice with identical arguments, across every conversation. */
47
+ duplicateToolCalls: number;
48
+ /** Characters the host injected as `<host-state>` / `<host-events>` blocks;
49
+ * null for a run recorded before the capture measured them. */
50
+ hostBlockChars: number | null;
51
+ };
52
+ export declare function computeMetrics(transcript: Transcript): RunMetrics;
package/metrics.js ADDED
@@ -0,0 +1,95 @@
1
+ const signature = (e) => JSON.stringify([...e.toolNames].sort());
2
+ /**
3
+ * Split the gateway record back into the conversations that produced it.
4
+ *
5
+ * One conversation's `messageCount` only ever grows; a sub-agent starts a fresh
6
+ * one at 2 while the lead is already deep, and the lead then resumes. The tool
7
+ * set is a hint rather than the rule, because page tools come and go with the
8
+ * route — a recorded lead went 14 to 19 to 26 tools without ever restarting.
9
+ */
10
+ function splitConversations(gateway) {
11
+ // `streams` is kept in most-recently-used order, so `opened` is what restores
12
+ // the order the conversations actually started in — which is what makes the
13
+ // first one the lead's.
14
+ const streams = [];
15
+ for (const exchange of gateway) {
16
+ // Most recently used first: when the tool set gives no answer, the lead is
17
+ // the stream that spoke last.
18
+ const open = streams.filter(s => s.last < exchange.messageCount).reverse();
19
+ const target = open.find(s => s.tools === signature(exchange)) ?? open[0];
20
+ if (target) {
21
+ target.exchanges.push(exchange);
22
+ target.last = exchange.messageCount;
23
+ target.tools = signature(exchange);
24
+ // Move to the back, so "most recently used" stays true.
25
+ streams.splice(streams.indexOf(target), 1);
26
+ streams.push(target);
27
+ }
28
+ else {
29
+ streams.push({ exchanges: [exchange], last: exchange.messageCount, tools: signature(exchange), opened: streams.length });
30
+ }
31
+ }
32
+ return [...streams].sort((a, b) => a.opened - b.opened).map(s => s.exchanges);
33
+ }
34
+ /** Within one conversation the tool-call list is cumulative, so the longest is the whole of it. */
35
+ function finalToolCalls(stream) {
36
+ let longest = [];
37
+ for (const e of stream)
38
+ if (e.toolCalls.length > longest.length)
39
+ longest = e.toolCalls;
40
+ return longest;
41
+ }
42
+ function countDuplicates(calls) {
43
+ const seen = new Set(calls.map(c => JSON.stringify([c.name, c.arguments])));
44
+ return calls.length - seen.size;
45
+ }
46
+ export function computeMetrics(transcript) {
47
+ const userMessages = transcript.conversation.filter(m => m.role === 'user').length;
48
+ const assistant = transcript.conversation.filter(m => m.role === 'assistant');
49
+ const visible = assistant.filter(m => m.text.trim().length > 0);
50
+ // Prefer what the record says over what the shape implies.
51
+ const roled = transcript.gateway.filter(e => e.model);
52
+ const leadModel = transcript.gateway[0]?.model ?? '';
53
+ const requestsByModel = roled.length
54
+ ? roled.reduce((acc, e) => { acc[e.model] = (acc[e.model] ?? 0) + 1; return acc; }, {})
55
+ : null;
56
+ const conversations = splitConversations(transcript.gateway);
57
+ let lead;
58
+ let nonLead;
59
+ if (requestsByModel && leadModel) {
60
+ lead = transcript.gateway.filter(e => e.model === leadModel);
61
+ nonLead = transcript.gateway.filter(e => e.model !== leadModel);
62
+ }
63
+ else {
64
+ // No role in the record: the first request answers the person's first
65
+ // message, so the conversation it opened is the lead's.
66
+ const [first = [], ...rest] = conversations;
67
+ lead = first;
68
+ nonLead = rest.flat();
69
+ }
70
+ const biggest = nonLead.reduce((best, e) => (!best || e.lastUserMessage.length > best.lastUserMessage.length ? e : best), null);
71
+ const measuredHostBlocks = transcript.gateway.some(e => typeof e.hostBlockChars === 'number');
72
+ return {
73
+ userMessages,
74
+ assistantBubbles: assistant.length,
75
+ textlessAssistantBubbles: assistant.length - visible.length,
76
+ avgVisibleReplyChars: visible.length
77
+ ? Math.round(visible.reduce((sum, m) => sum + m.text.trim().length, 0) / visible.length)
78
+ : null,
79
+ modelRequests: transcript.gateway.length,
80
+ leadModel,
81
+ leadRequests: lead.length,
82
+ nonLeadRequests: nonLead.length,
83
+ requestsByModel,
84
+ requestsPerUserMessage: userMessages
85
+ ? Math.round((transcript.gateway.length / userMessages) * 10) / 10
86
+ : null,
87
+ largestNonLeadPromptChars: biggest ? biggest.lastUserMessage.length : null,
88
+ largestNonLeadPromptModel: biggest ? (biggest.model || null) : null,
89
+ duplicateToolCalls: conversations.reduce((sum, s) => sum + countDuplicates(finalToolCalls(s)), 0),
90
+ hostBlockChars: measuredHostBlocks
91
+ // Cumulative like the history, so a conversation's last request holds its total.
92
+ ? conversations.reduce((sum, s) => sum + Math.max(0, ...s.map(e => e.hostBlockChars ?? 0)), 0)
93
+ : null
94
+ };
95
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@data-fair/lib-agents-sim",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
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
5
  "main": "index.js",
6
6
  "type": "module",
@@ -31,6 +31,15 @@ export type PagePerception = {
31
31
  */
32
32
  offLimits: string[];
33
33
  };
34
+ /**
35
+ * Head AND tail, because overlays live at the end.
36
+ *
37
+ * A dialog is teleported to the end of the DOM, so a head-only cut removes
38
+ * precisely what a person is being asked to look at. A judged run turned on it:
39
+ * the persona clicked "Ajouter une colonne", its snapshot was cut at the same
40
+ * point before and after the click, and whether the dialog ever opened was not
41
+ * decidable from the record — the judge had to say so instead of ruling.
42
+ */
34
43
  export declare function truncate(text: string): string;
35
44
  export declare function createPagePerception(roots: PerceptionRoot[], opts?: {
36
45
  offLimits?: string[];
@@ -34,8 +34,22 @@ export const SNAPSHOT_CAP = 4000;
34
34
  // must surface through the existing try/catch as a recorded observation
35
35
  // instead of an unrecorded hang.
36
36
  export const ACTION_TIMEOUT_MS = 15000;
37
+ /**
38
+ * Head AND tail, because overlays live at the end.
39
+ *
40
+ * A dialog is teleported to the end of the DOM, so a head-only cut removes
41
+ * precisely what a person is being asked to look at. A judged run turned on it:
42
+ * the persona clicked "Ajouter une colonne", its snapshot was cut at the same
43
+ * point before and after the click, and whether the dialog ever opened was not
44
+ * decidable from the record — the judge had to say so instead of ruling.
45
+ */
37
46
  export function truncate(text) {
38
- return text.length <= SNAPSHOT_CAP ? text : text.slice(0, SNAPSHOT_CAP) + '…[truncated]';
47
+ if (text.length <= SNAPSHOT_CAP)
48
+ return text;
49
+ const marker = '\n…[truncated]\n';
50
+ const head = Math.floor(SNAPSHOT_CAP * 0.6);
51
+ const tail = SNAPSHOT_CAP - head;
52
+ return text.slice(0, head) + marker + text.slice(-tail);
39
53
  }
40
54
  const TOOLS = [
41
55
  {
@@ -93,19 +107,38 @@ export function createPagePerception(roots, opts = {}) {
93
107
  }
94
108
  return null;
95
109
  };
110
+ /** As above, but says WHICH finder matched — `click` reports it. */
111
+ const firstMatchKind = async (finders) => {
112
+ for (const [kind, find] of finders) {
113
+ try {
114
+ const loc = find();
115
+ if (await loc.count() > 0)
116
+ return { kind, loc };
117
+ }
118
+ catch { /* a finder that throws simply does not match */ }
119
+ }
120
+ return null;
121
+ };
96
122
  const click = async (name) => {
97
123
  if (isOffLimits(name))
98
124
  return OFF_LIMITS_RESULT;
99
125
  for (const { root } of roots) {
100
- const loc = await firstMatch([
101
- () => root.getByRole('button', { name }).first(),
102
- () => root.getByRole('link', { name }).first(),
103
- () => root.getByText(name).first()
126
+ const match = await firstMatchKind([
127
+ ['control', () => root.getByRole('button', { name }).first()],
128
+ ['control', () => root.getByRole('link', { name }).first()],
129
+ ['text', () => root.getByText(name).first()]
104
130
  ]);
105
- if (loc) {
131
+ if (match) {
106
132
  try {
107
- await loc.click({ timeout: ACTION_TIMEOUT_MS });
108
- return `clicked "${name}"`;
133
+ await match.loc.click({ timeout: ACTION_TIMEOUT_MS });
134
+ // Playwright clicks whatever is visible, so the text fallback succeeds
135
+ // on a paragraph as readily as on a button. Saying which one it was is
136
+ // the difference between a person learning nothing happened and a
137
+ // person concluding the product is broken — a recorded run did exactly
138
+ // that, and the judge filed it as a product failure.
139
+ return match.kind === 'control'
140
+ ? `clicked "${name}"`
141
+ : `clicked the text "${name}", which is not a button or a link — nothing may happen`;
109
142
  }
110
143
  catch (err) {
111
144
  return `could not click "${name}": ${err instanceof Error ? err.message : String(err)}`;
package/persona.d.ts CHANGED
@@ -1,6 +1,26 @@
1
1
  import type { SimulationCase } from './types.ts';
2
2
  import type { PagePerception } from './page-perception.ts';
3
3
  export declare const DONE = "DONE";
4
+ /**
5
+ * The model the simulated person runs on unless the caller overrides it.
6
+ *
7
+ * Exported because hosts record which model ran, in a sidecar whose whole point
8
+ * is that verdicts from different tiers are never compared silently — and a host
9
+ * that re-derived this with its own literal recorded `haiku` for a run that
10
+ * actually used `sonnet`, the moment this default changed. One value, read by
11
+ * both the runner and the recorder.
12
+ *
13
+ * Sonnet, not haiku: on haiku the persona stopped enforcing its own goal —
14
+ * accepting a chat-only answer for a goal that demanded the result on screen,
15
+ * and asserting it could see nothing but the chat while its own look had just
16
+ * returned the page. A persona that lets the product off the hook produces green
17
+ * runs that prove nothing.
18
+ */
19
+ export declare const DEFAULT_USER_MODEL = "sonnet";
20
+ /** What `nextUserMessage` will actually run on, given an environment. */
21
+ export declare function resolveUserModel(env?: {
22
+ SIM_USER_MODEL?: string;
23
+ }): string;
4
24
  export declare const PERSONA_MAX_TURNS = 25;
5
25
  export declare const PERCEPTION_INSTRUCTIONS = "You can look at the screen yourself with the look tool, and you can click and type\non the page. Before you say anything about what is or is not on the screen, look.\nNever claim you cannot see something you have not looked for.";
6
26
  export declare function isDone(message: string): boolean;
package/persona.js CHANGED
@@ -11,6 +11,26 @@ import { createNeutralCwd, isolationOptions } from "./isolation.js";
11
11
  import { MISSING_SDK_MESSAGE, isMissingSdkError } from "./missing-sdk.js";
12
12
  import { MCP_SERVER_NAME } from "./page-perception.js";
13
13
  export const DONE = 'DONE';
14
+ /**
15
+ * The model the simulated person runs on unless the caller overrides it.
16
+ *
17
+ * Exported because hosts record which model ran, in a sidecar whose whole point
18
+ * is that verdicts from different tiers are never compared silently — and a host
19
+ * that re-derived this with its own literal recorded `haiku` for a run that
20
+ * actually used `sonnet`, the moment this default changed. One value, read by
21
+ * both the runner and the recorder.
22
+ *
23
+ * Sonnet, not haiku: on haiku the persona stopped enforcing its own goal —
24
+ * accepting a chat-only answer for a goal that demanded the result on screen,
25
+ * and asserting it could see nothing but the chat while its own look had just
26
+ * returned the page. A persona that lets the product off the hook produces green
27
+ * runs that prove nothing.
28
+ */
29
+ export const DEFAULT_USER_MODEL = 'sonnet';
30
+ /** What `nextUserMessage` will actually run on, given an environment. */
31
+ export function resolveUserModel(env = process.env) {
32
+ return env.SIM_USER_MODEL || DEFAULT_USER_MODEL;
33
+ }
14
34
  let neutralCwd;
15
35
  // The persona looks and acts before replying, so one turn is not enough:
16
36
  // look → act → look → reply, with room to spare.
@@ -47,8 +67,15 @@ reply with your message; the runner types and sends it for you.`;
47
67
  export function isDone(message) {
48
68
  if (!message)
49
69
  return false;
50
- // Normalize the message: trim, strip quotes/backticks, strip trailing punctuation, uppercase
51
- let normalized = message.trim();
70
+ // The LAST line, not the whole message. The persona is asked for DONE and
71
+ // nothing else, and a capable one still signs off first ("That matches what
72
+ // I'm seeing — good.\n\nDONE"). Strict equality on the whole message missed
73
+ // that, so the runner sent the sign-off to the assistant and paid a full model
74
+ // request for a pleasantry nobody reads — once per case, every suite. It still
75
+ // has to be a line of its own, or "let me know when it is DONE" would end the
76
+ // run on the person's own words.
77
+ const lines = message.trim().split('\n').map(l => l.trim()).filter(Boolean);
78
+ let normalized = lines[lines.length - 1] ?? '';
52
79
  // Strip surrounding quotes or backticks
53
80
  if ((normalized.startsWith('"') && normalized.endsWith('"')) ||
54
81
  (normalized.startsWith("'") && normalized.endsWith("'")) ||
@@ -124,7 +151,7 @@ export async function nextUserMessage(c, conversation, turnsLeft, opts) {
124
151
  prompt: personaPrompt(conversation, turnsLeft),
125
152
  options: {
126
153
  ...isolationOptions(neutralCwd),
127
- model: process.env.SIM_USER_MODEL ?? 'haiku',
154
+ model: resolveUserModel(),
128
155
  systemPrompt: personaSystemPrompt(c, !!opts?.perception, !!opts?.perception?.offLimits.length),
129
156
  // Unconditional: a caller with no perception registers no mcpServers, so
130
157
  // the persona has no tool to call and the loop still ends after the one
@@ -146,11 +173,18 @@ export async function nextUserMessage(c, conversation, turnsLeft, opts) {
146
173
  : {})
147
174
  }
148
175
  })) {
176
+ // The LAST assistant message that carried text, not every one of them. With
177
+ // perception wired in the SDK emits an assistant message per reasoning step
178
+ // between tool calls, and appending them all sent the persona's inner
179
+ // monologue to the assistant as the person's own words — in one recorded run
180
+ // naming the tool the assistant should call, in another welding DONE onto the
181
+ // end of a sentence so isDone() missed it and the run ran on for an extra turn.
149
182
  if (msg.type === 'assistant') {
150
- for (const block of msg.message?.content ?? []) {
151
- if (block.type === 'text' && block.text)
152
- text += block.text;
153
- }
183
+ const said = (msg.message?.content ?? [])
184
+ .filter((block) => block.type === 'text' && block.text)
185
+ .map((block) => block.text);
186
+ if (said.length)
187
+ text = said.join('');
154
188
  }
155
189
  }
156
190
  return text.trim();
package/report.js CHANGED
@@ -4,6 +4,32 @@
4
4
  */
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
+ /**
8
+ * The counted facts, printed as facts. No threshold turns any of these into a
9
+ * failure: what they cost is a judgement, and the judge makes it with the
10
+ * transcript in hand.
11
+ */
12
+ function metricsLine(run) {
13
+ const m = run?.metrics;
14
+ if (!m)
15
+ return '';
16
+ const parts = [
17
+ run?.toolsModel ? `[${run.assistantModel} / tools ${run.toolsModel}]` : '',
18
+ `${m.modelRequests} model requests for ${m.userMessages} user messages`,
19
+ m.requestsPerUserMessage === null ? '' : `(${m.requestsPerUserMessage}/message)`,
20
+ m.requestsByModel
21
+ ? `(${Object.entries(m.requestsByModel).map(([role, n]) => `${role} ${n}`).join(', ')})`
22
+ : (m.nonLeadRequests ? `· ${m.nonLeadRequests} non-lead` : ''),
23
+ m.largestNonLeadPromptChars
24
+ ? `· largest ${m.largestNonLeadPromptModel ?? 'non-lead'} prompt ${m.largestNonLeadPromptChars} chars`
25
+ : '',
26
+ `· ${m.textlessAssistantBubbles}/${m.assistantBubbles} bubbles tool-chips only`,
27
+ m.avgVisibleReplyChars === null ? '' : `· avg reply ${m.avgVisibleReplyChars} chars`,
28
+ m.duplicateToolCalls ? `· ${m.duplicateToolCalls} repeated tool calls` : '',
29
+ m.hostBlockChars === null ? '' : `· ${m.hostBlockChars} chars of host blocks`
30
+ ];
31
+ return parts.filter(Boolean).join(' ');
32
+ }
7
33
  // The verdict is written by a model and is untrusted by construction — a
8
34
  // well-known slip is a stringified boolean ("false" instead of false), which
9
35
  // would otherwise pass truthiness checks and silently report success.
@@ -56,12 +82,21 @@ export function reportCases(cases, evidenceDir) {
56
82
  for (const row of rows)
57
83
  console.log(row.map((cell, i) => cell.padEnd(widths[i])).join(' '));
58
84
  for (const c of cases) {
85
+ const run = read(`sim-${c.name}.run.json`);
59
86
  const verdict = read(`sim-${c.name}.verdict.json`);
60
- if (!isValidVerdict(verdict, c.name) || verdict.frictions.length === 0)
87
+ const line = metricsLine(run);
88
+ const hasDetail = isValidVerdict(verdict, c.name) && (verdict.frictions.length > 0 || (verdict.findings?.length ?? 0) > 0);
89
+ if (!line && !hasDetail)
90
+ continue;
91
+ console.log(`\n${c.name}${isValidVerdict(verdict, c.name) ? `: ${verdict.summary}` : ''}`);
92
+ if (line)
93
+ console.log(` ${line}`);
94
+ if (!isValidVerdict(verdict, c.name))
61
95
  continue;
62
- console.log(`\n${c.name}: ${verdict.summary}`);
63
96
  for (const f of verdict.frictions)
64
97
  console.log(` - turn ${f.turn}: ${f.what} → ${f.effect}`);
98
+ for (const f of verdict.findings ?? [])
99
+ console.log(` · [${f.severity}] ${f.area}: ${f.what} (${f.evidence})`);
65
100
  }
66
101
  console.log(failures === 0 ? '\nall cases satisfied' : `\n${failures} case(s) need attention`);
67
102
  return failures;
@@ -45,9 +45,54 @@ Ask the user to start anything that is down. Never start or stop dev processes y
45
45
  SIM_CASES=air-quality npm run simulate # one case
46
46
  ```
47
47
 
48
- Models are pinned by `SIM_ASSISTANT_MODEL` (default `sonnet`) and
49
- `SIM_USER_MODEL` (default `haiku`), and recorded per run, so verdicts from
50
- different tiers are never compared silently.
48
+ Models are pinned by `SIM_ASSISTANT_MODEL` (default `sonnet`),
49
+ `SIM_TOOLS_MODEL` (default `haiku`, for sub-agents, compaction and the
50
+ moderation guard) and `SIM_USER_MODEL` (default `sonnet`), and recorded per
51
+ run, so verdicts from different tiers are never compared silently.
52
+
53
+ The persona is on `sonnet` deliberately. On `haiku` it stopped enforcing its
54
+ own goal: a case whose goal said the answer had to be shown on screen was
55
+ ended with a chat-only reply and marked done, and another persona asserted it
56
+ could see nothing but the chat while its own `look` had just returned the
57
+ page. A persona that lets the product off the hook produces green runs that
58
+ prove nothing. It is the most expensive knob here, so lower it deliberately,
59
+ not by default.
60
+
61
+ **Confirm it started.** A suite takes minutes, so you will want to background
62
+ it — and a run that never launched looks exactly like a run still going. Check
63
+ the runner's own log first, and the sidecar as the stronger signal:
64
+
65
+ ```bash
66
+ tail -5 sim.log # state-setup passing means it is going
67
+ ls simulations/tmp/*.run.json # a file here means a case really started
68
+ ```
69
+
70
+ The sidecar is written before each case's body, but AFTER the login fixture,
71
+ which can take more than a minute on a cold page — so an empty
72
+ `simulations/tmp/` on its own is not proof of a stall. A log with no progress
73
+ at all is. Check the directory you are looking in, too: a backgrounded
74
+ `cd X && … &` runs the `cd` in a subshell, so a later `ls` reads whatever
75
+ directory you started from. One session read another repo's stale evidence
76
+ that way and called a dead run healthy.
77
+
78
+ **Wait on the process id, never on a text pattern.** `pgrep -f` matches full
79
+ command lines, including the command line of the waiter you are writing — so
80
+
81
+ ```bash
82
+ until ! pgrep -f "playwright.sim.config"; do sleep 15; done # WRONG
83
+ ```
84
+
85
+ matches itself and waits forever, silently, producing nothing. One session lost
86
+ an hour and three quarters to exactly this, and then repeated it while trying to
87
+ fix it. Capture the pid and watch that instead:
88
+
89
+ ```bash
90
+ nohup npm run simulate > sim.log 2>&1 &
91
+ SIMPID=$!
92
+ while kill -0 "$SIMPID" 2>/dev/null; do sleep 20; done
93
+ ```
94
+
95
+ For the same reason, never `pkill -f` a pattern taken from your own script.
51
96
 
52
97
  4. **Ignore the runner's own account of how it went.** The transcript at
53
98
  `simulations/tmp/sim-<case>.json` is the evidence. A Playwright `passed` line
@@ -61,8 +106,16 @@ Ask the user to start anything that is down. Never start or stop dev processes y
61
106
 
62
107
  - the case name and its goal
63
108
  - the transcript path, `simulations/tmp/sim-<case>.json`
109
+ - the sidecar path, `simulations/tmp/sim-<case>.run.json`
64
110
  - ask for the JSON verdict its own definition specifies
65
111
 
112
+ **Dispatch them all the same way.** What to look at, what matters, what you
113
+ suspect is wrong this time — none of that goes in the dispatch. The judge's
114
+ own definition sets its mandate, and it reads `docs/architecture/` itself. A
115
+ briefing you write per run steers the verdict toward what you already
116
+ believed and makes two runs' verdicts incomparable. If a judge is looking in
117
+ the wrong place, fix its definition, not one dispatch.
118
+
66
119
  6. **Write each verdict** to `simulations/tmp/sim-<case>.verdict.json` as raw JSON.
67
120
  Strip any code fence the judge added. A malformed verdict reports as
68
121
  `not judged`, which is deliberate — check the file rather than being surprised.
@@ -73,14 +126,23 @@ Ask the user to start anything that is down. Never start or stop dev processes y
73
126
  npm run simulate:report
74
127
  ```
75
128
 
76
- Relay the summary and the friction list. Exit code is non-zero if any case was
77
- unsatisfactory, invalid, not judged, or never ran.
129
+ Relay the summary, the friction list and the findings. Exit code is non-zero
130
+ if any case was unsatisfactory, invalid, not judged, or never ran.
131
+
132
+ 8. **Root-cause what it found.** The judge reads a record, not the source: it
133
+ says a tool result misled the assistant, not which line built that result.
134
+ That half is yours, and it is where a run becomes a change. For each friction
135
+ point and each finding worth acting on, go into the code, find what produced
136
+ it, and say so — a defect you confirmed, a design decision the judge could
137
+ not see, or a harness artefact. Report that, not the verdict verbatim.
78
138
 
79
139
  ## Reading the result
80
140
 
81
- The friction list is the point. "Unsatisfactory" tells you a run went badly; a
82
- friction point names the reply or tool result that misled the person and what they
83
- did next that is what turns a run into a concrete change.
141
+ The friction list is the person's side, and it is what "unsatisfactory" is
142
+ actually about: which reply or tool result misled them and what they did next.
143
+ The findings are everything else the run exposed cost, what the conversation
144
+ was like to read, tools, the product, the harness. Both are claims about a
145
+ record; both need confirming against the code before anyone acts on them.
84
146
 
85
147
  Rate limits are the practical ceiling: three Claude roles per case on one
86
148
  subscription. A run cut short by a rate limit is an **invalid run**, not a product
@@ -4,46 +4,82 @@ description: Judge one scenario simulation transcript and return a JSON verdict.
4
4
  tools: Read
5
5
  ---
6
6
 
7
- You are judging whether a chat assistant actually served a person, from the
8
- transcript of one simulated conversation.
7
+ You are judging one simulated conversation between a person and a chat
8
+ assistant, from the record the run left behind.
9
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.
10
+ Two questions, in this order. **Did the person get what they came for** that
11
+ is the one that decides `satisfied`, and nothing else does. Then: **what was
12
+ wrong with how it went**, anywhere in what the record shows you. The second
13
+ question is open. Cost and efficiency, the readability of what the person saw,
14
+ tool descriptions and tool results, the product's own behaviour, the chat
15
+ harness itself — all of it is in scope, and so is anything you notice that this
16
+ list does not name. You are not filling in a form; you are the one reader who
17
+ saw the whole run.
12
18
 
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
- - `observations` — what the person actually looked at and did, recorded per turn:
19
- `{ turn, tool, args, result }`. `look` returns the accessibility outline of the
20
- screen at that moment.
19
+ ## What you are given
20
+
21
+ A case name, the person's goal, and paths to the evidence. Read the files with
22
+ `Read`; do not ask for anything to be pasted.
23
+
24
+ - `simulations/tmp/sim-<case>.json` — the transcript:
25
+ - `conversation` what the person and the assistant said, as rendered on screen
26
+ - `gateway` every request the page made, carrying the tools it offered, the
27
+ tool calls the assistant actually made, and in `toolResults` what each of
28
+ those calls answered. Use `toolResults` to check an assistant's claim against
29
+ what the tool really returned; large results are truncated, and both lists are
30
+ cumulative.
31
+ - `consoleErrors` — browser errors during the run
32
+ - `observations` — what the person actually looked at and did, per turn:
33
+ `{ turn, tool, args, result }`. `look` returns the accessibility outline of
34
+ the screen at that moment.
35
+ - `simulations/tmp/sim-<case>.run.json` — the sidecar: which models ran, how long
36
+ it took, and `metrics`, a few counts derived from the transcript. Read them as
37
+ facts, not as a score: nothing in `metrics` is a pass mark, and a number only
38
+ means something once you have seen in the transcript what produced it.
39
+
40
+ **Read the architecture documentation before judging.** `docs/architecture/` in
41
+ this repository describes what the assistant is supposed to be, what the tools
42
+ promise, and what the chat harness does on its own. Start with the document
43
+ covering the agent or chat integration, then read whatever looks relevant to
44
+ what you saw. Without it you will report intended design as a defect and miss
45
+ the places the implementation diverges from what was written down.
46
+
47
+ ## How to read the record, and where it misleads
48
+
49
+ `gateway` records what the browser SENT, and each request resends the whole
50
+ conversation so far — so the assistant's FINAL reply never appears there,
51
+ because no later request carries it. Read the last assistant turn from
52
+ `conversation`. Never conclude "the assistant never answered" from `gateway`.
53
+
54
+ `gateway[].toolCalls` is CUMULATIVE: exchange N holds every tool call from
55
+ 1..N. That is a history, not repetition. `metrics.duplicateToolCalls` already
56
+ accounts for this; if you count repetition yourself, compare across exchanges
57
+ first.
58
+
59
+ Not every request is the assistant answering the person. Compaction, moderation
60
+ and sub-agents each run on their own model role, with their own short history
61
+ interleaved among the lead's. `metrics.requestsByModel` says which role served
62
+ how many — read it before attributing spend, and never call a request a
63
+ sub-agent dispatch unless a `subagent_*` call appears in `toolCalls`. A judge
64
+ once reported three `summarizer` compaction calls as sub-agent work in a run
65
+ that made no sub-agent call at all.
21
66
 
22
67
  A claim about what is on screen must be supported by a preceding `look` in
23
- `observations`. A persona asserting a visual fact it never observed is a HARNESS
24
- fault, not product friction — say so plainly in `notes` and do not count it as a
25
- friction point. This has happened: a run once had the person insist a panel was
26
- closed having never looked, and the judge reported it as a product failure.
27
-
28
- `gateway` records what the browser SENT to the server, and each request carries
29
- the whole conversation so far — so the assistant's FINAL reply of a conversation
30
- never appears there, because no later request resends it. Read the final
31
- assistant turn from `conversation`, not `gateway`, and never conclude "the
32
- assistant never answered" from its absence in `gateway`.
33
-
34
- `gateway[].toolCalls` is CUMULATIVE: exchange N contains every tool call from
35
- turns 1..N, not just that turn's. Do not treat this as the same tool call being
36
- repeated — compare call counts across exchanges before filing repetition as a
37
- friction point, or you will report calls that never actually recurred.
68
+ `observations`. A persona asserting a visual fact it never observed is a
69
+ HARNESS fault, not product friction — say so in `notes` and do not count it as
70
+ friction. This has happened: a run had the person insist a panel was closed
71
+ having never looked, and the judge reported it as a product failure.
72
+
73
+ ## `satisfied` and `frictions` the person's side
38
74
 
39
75
  Judge the run against the goal, not against your idea of a good answer. The
40
- person is not a tester: if they had to ask three times, that is a finding even
41
- if the final answer was correct.
76
+ person is not a tester: if they had to ask three times, that is friction even
77
+ when the final answer was right.
78
+
79
+ `satisfied` is true only if the goal was actually met and visibly so.
42
80
 
43
- **The friction list is the point.** A score says a run went badly; a friction
44
- point says which reply or tool result misled the person and what they concluded.
45
- That is what turns a run into a concrete change to a prompt or a tool
46
- description. Look especially for:
81
+ A friction point names the reply or tool result that misled the person and what
82
+ they did next. Look especially for:
47
83
  - the assistant claiming it did something the `gateway` record shows it never did
48
84
  - a tool offered but never used when it was obviously needed, or called with
49
85
  arguments that misread the person's words
@@ -51,6 +87,56 @@ description. Look especially for:
51
87
  - the person having to supply information the assistant could have looked up
52
88
  - an answer that is correct but never shown where the person asked for it
53
89
 
90
+ A friction's `turn` is the 1-based index of the USER turn it occurred on — the
91
+ Nth message the person sent, counting only their messages in `conversation`.
92
+ A friction caused by the reply to the person's 3rd message is `"turn": 3`.
93
+ Count this way so two judges reading the same transcript agree.
94
+
95
+ An empty `frictions` array is a real answer when a run went cleanly.
96
+
97
+ ## `findings` — everything else the run exposed
98
+
99
+ Separate from friction, because these are for whoever maintains the system
100
+ rather than about what the person lived through. Anything the record supports:
101
+
102
+ - **cost** — how much work the run took for what it delivered. `metrics` gives
103
+ you requests per user message, the split by model role, the largest prompt
104
+ handed to a role other than the lead's, and how many characters the
105
+ application injected into the conversation. A summarizer legitimately carries
106
+ the whole conversation, so read which role took a big prompt before calling
107
+ it waste. Then go look: which turns spent the requests,
108
+ and was the spending doing anything?
109
+ - **conversation** — what the person had to read. Length, repetition, hedging,
110
+ restating what is already on their screen, silence where a word was owed.
111
+ `metrics.textlessAssistantBubbles` counts bubbles with no prose in them. These
112
+ are almost always tool-call bubbles, and the page DOES render a chip naming the
113
+ tool — so the count is not evidence of silence, and the `observations` outlines
114
+ will show you the chips. What it tells you is how much of the run the person
115
+ watched as tool names rather than sentences.
116
+ - **tools** — a description that invites the wrong call, a result the model
117
+ visibly misread, a required argument the model had no way to know, an error
118
+ the assistant swallowed.
119
+ - **product** — what the application did, or failed to do, behind the
120
+ conversation. `consoleErrors` and `observations` are the evidence.
121
+ - **harness** — the chat itself: turn handling, streaming, the simulated
122
+ person's own tooling, anything in how the run was conducted rather than in
123
+ what was said.
124
+
125
+ Severity is your call: `high` for something that cost the person the goal or
126
+ would in a nearby run, `medium` for real waste or real confusion, `low` for
127
+ things worth knowing. Evidence is a pointer a maintainer can check — a turn
128
+ number, a tool name, a count, a quoted line.
129
+
130
+ **Say what is wrong, not why the code does it.** You have the record, not the
131
+ source. Naming a file or a function you did not read is a guess that sends
132
+ someone down the wrong path; the agent that dispatched you has the codebase and
133
+ does the root-causing. Describe the defect precisely enough for them to find it.
134
+
135
+ Report nothing you cannot point at in the record. A short `findings` list from a
136
+ clean run is worth more than a padded one.
137
+
138
+ ## Output
139
+
54
140
  Return ONLY raw JSON, no code fence, in exactly this shape:
55
141
 
56
142
  {
@@ -60,15 +146,11 @@ Return ONLY raw JSON, no code fence, in exactly this shape:
60
146
  "frictions": [
61
147
  { "turn": <number>, "what": "<what the assistant or a tool did>", "effect": "<what the person concluded or had to do>" }
62
148
  ],
63
- "notes": "<anything a maintainer should know, or empty>"
149
+ "findings": [
150
+ { "area": "cost" | "conversation" | "tools" | "product" | "harness" | "<your own>", "severity": "high" | "medium" | "low", "what": "<the defect>", "evidence": "<where in the record>" }
151
+ ],
152
+ "notes": "<the overall opinion a maintainer should hear, or empty>"
64
153
  }
65
154
 
66
- `satisfied` is true only if the person's goal was actually met and visibly so.
67
- An empty `frictions` array is a real answer when a run went cleanly.
68
-
69
- A friction's `turn` is the 1-based index of the USER turn it occurred on — the
70
- Nth message the person sent, counting only the person's messages in
71
- `conversation` and not the assistant's. So a friction caused by the
72
- assistant's reply to the person's 3rd message is still `"turn": 3`. Count
73
- consistently this way so that two judges reading the same transcript would
74
- agree on the number.
155
+ `notes` is where the judgement that fits no list goes: how the run read, what
156
+ you would change first, what you are unsure about.
package/transcript.js CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
+ import { computeMetrics } from "./metrics.js";
11
12
  /**
12
13
  * Default evidence location, resolved against the host repo's cwd. Kept as the
13
14
  * default rather than baked in: `reportCases(cases, evidenceDir)` already lets
@@ -17,5 +18,9 @@ export const evidenceDir = path.join(process.cwd(), 'simulations', 'tmp');
17
18
  export function writeEvidence(name, transcript, sidecar, dir = evidenceDir) {
18
19
  fs.mkdirSync(dir, { recursive: true });
19
20
  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
+ // Derived here rather than asked of the host: every host would compute the
22
+ // same thing from the same transcript, and two of these counts are easy to
23
+ // get wrong (see metrics.ts).
24
+ const withMetrics = { ...sidecar, metrics: computeMetrics(transcript) };
25
+ fs.writeFileSync(path.join(dir, `sim-${name}.run.json`), JSON.stringify(withMetrics, null, 2));
21
26
  }
package/types.d.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import type { GatewayExchange } from './gateway-capture.ts';
5
5
  import type { Observation } from './page-perception.ts';
6
+ import type { RunMetrics } from './metrics.ts';
6
7
  export type SimulationCase = {
7
8
  /** Evidence files are named after this; keep it filesystem-safe. */
8
9
  name: string;
@@ -32,8 +33,14 @@ export type RunSidecar = {
32
33
  valid: boolean;
33
34
  error?: string;
34
35
  assistantModel: string;
36
+ /** The tier the background roles ran on (sub-agents, compaction, moderation),
37
+ * when the host pins it separately from the assistant's. */
38
+ toolsModel?: string;
35
39
  userModel: string;
36
40
  turns: number;
37
41
  durationMs: number;
38
42
  finishedAt: string;
43
+ /** Derived from the transcript by `writeEvidence`, so a host gets them without
44
+ * asking. Evidence for the judge, never a score. */
45
+ metrics?: RunMetrics;
39
46
  };