@data-fair/lib-agents-sim 0.3.0 → 0.4.1

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/README.md CHANGED
@@ -51,8 +51,8 @@ Agent SDK may hoist zod 4 and break `ai`'s type inference. Add
51
51
  npx df-agents-sim-init [--force]
52
52
  ```
53
53
 
54
- Copies the `/simulate` skill and the `simulation-judge` sub-agent definition
55
- into your repo's `.claude/skills/simulate/SKILL.md` and
54
+ Copies the `/agents-sim` skill and the `simulation-judge` sub-agent definition
55
+ into your repo's `.claude/skills/agents-sim/SKILL.md` and
56
56
  `.claude/agents/simulation-judge.md`. These cannot be loaded from
57
57
  `node_modules` — Claude Code reads them from the repository — so they are
58
58
  copied, not referenced, and **can drift** from the version in this package.
@@ -73,11 +73,18 @@ session's locale. Pass the locale your application runs in — `createChatDriver
73
73
  { locale: 'fr' })` — or the run dies as a 15-minute "element not found" with
74
74
  nothing pointing at the cause. The default is `'en'`.
75
75
 
76
+ **Send recovery.** `sendMessage` bounds its fill/click at `SEND_TIMEOUT_MS`
77
+ (15s) rather than waiting indefinitely. If the first attempt fails — typically
78
+ an overlay left open over the composer — it presses Escape (the ordinary way a
79
+ person dismisses something in their way) and retries once before throwing. It
80
+ never passes `{ force: true }`: punching through an overlay a real user could
81
+ not reach would report a success a person could never have had.
82
+
76
83
  ```ts
77
84
  import { test } from '@playwright/test'
78
85
  import {
79
- createChatDriver, captureGateway, nextUserMessage, isDone,
80
- writeEvidence, selectCases, type Transcript, type SimulationCase
86
+ createChatDriver, chatDriverStrings, captureGateway, nextUserMessage, isDone,
87
+ writeEvidence, selectCases, createPagePerception, type Transcript, type SimulationCase
81
88
  } from '@data-fair/lib-agents-sim'
82
89
 
83
90
  const cases: SimulationCase[] = [
@@ -89,13 +96,25 @@ for (const simCase of selectCases(cases, [])) {
89
96
  const gateway = captureGateway(page)
90
97
  await page.goto(simCase.route)
91
98
 
92
- const chat = createChatDriver(page.frameLocator('iframe'))
99
+ const locale = 'en' as const
100
+ const strings = chatDriverStrings(locale)
101
+ const chat = createChatDriver(page.frameLocator('iframe'), { locale })
93
102
  const conversation: Array<{ role: string, text: string }> = []
103
+ // Lets the persona look at, click and type into the real page instead of
104
+ // guessing at what is on screen — see "Give the persona eyes" below.
105
+ // offLimits is not optional in practice: without it the persona can (and
106
+ // will) click into the composer and press Send itself, double-sending its
107
+ // message on top of the runner's own send below.
108
+ const perception = createPagePerception(
109
+ [{ label: 'page', root: page }],
110
+ { offLimits: [strings.input, strings.send, strings.stop, strings.reset] }
111
+ )
94
112
  let error: string | undefined
95
113
 
96
114
  try {
97
115
  for (let i = 0; i < simCase.maxTurns; i++) {
98
- const message = await nextUserMessage(simCase, conversation, simCase.maxTurns - i)
116
+ perception.setTurn(i + 1)
117
+ const message = await nextUserMessage(simCase, conversation, simCase.maxTurns - i, { perception })
99
118
  if (isDone(message)) break
100
119
  await chat.sendMessage(message)
101
120
  await chat.waitForTurn()
@@ -110,7 +129,7 @@ for (const simCase of selectCases(cases, [])) {
110
129
  error = err instanceof Error ? err.message : String(err)
111
130
  }
112
131
 
113
- const transcript: Transcript = { case: simCase.name, goal: simCase.goal, persona: simCase.persona, route: simCase.route, conversation, gateway, consoleErrors: [] }
132
+ const transcript: Transcript = { case: simCase.name, goal: simCase.goal, persona: simCase.persona, route: simCase.route, conversation, gateway, consoleErrors: [], observations: perception.observations }
114
133
  // `valid` is derived, never hardcoded: the sidecar exists to tell a run that
115
134
  // really happened apart from one that fell over, so that `reportCases` says
116
135
  // "invalid (…)" instead of re-reporting the previous run's verdict.
@@ -125,11 +144,71 @@ for (const simCase of selectCases(cases, [])) {
125
144
  ```
126
145
 
127
146
  Then judge each written transcript with the `simulation-judge` sub-agent (via
128
- the copied `/simulate` skill), and turn the evidence directory into a pass/fail
147
+ the copied `/agents-sim` skill), and turn the evidence directory into a pass/fail
129
148
  summary with `reportCases(cases, evidenceDir)` — the host repo's own report
130
149
  script decides where cases live and what to do with the failure count it
131
150
  returns.
132
151
 
152
+ ### Give the persona eyes
153
+
154
+ `createPagePerception(roots, opts?)` gives the simulated user a
155
+ `look`/`click`/`type` MCP tool set over the real Playwright page(s), so it can
156
+ check what is actually on screen instead of guessing. Pass one root per
157
+ visible surface — a chat embedded in an iframe has both the host page and the
158
+ frame:
159
+
160
+ ```ts
161
+ const perception = createPagePerception(
162
+ [
163
+ { label: 'page', root: page },
164
+ { label: 'chat panel', root: page.frameLocator('iframe') }
165
+ ],
166
+ { offLimits: [strings.input, strings.send, strings.stop, strings.reset] }
167
+ )
168
+ ```
169
+
170
+ **Pass `offLimits`.** It is the second argument's only option, and it is not
171
+ optional in practice: it is the list of accessible names — typically the
172
+ composer's input, send, stop and reset controls, from `chatDriverStrings` — that
173
+ `click`/`type` refuse before even looking the element up. The persona's system
174
+ prompt is told *"the composer will refuse you"* only when this list is
175
+ non-empty (an empty or omitted `offLimits` refuses nothing, silently). Without
176
+ it, the persona can and will type its own message into the composer and press
177
+ Send itself — double-sending on top of the runner's own `chat.sendMessage`
178
+ below, and usually invalidating the run. A `reset` control is worth including
179
+ too: a mid-run click erases the transcript the run exists to produce.
180
+
181
+ Before each turn, tell it which turn is starting — this stamps every
182
+ observation the persona records during that turn — then pass it through
183
+ `nextUserMessage`'s options so the persona's query gets the tool set:
184
+
185
+ ```ts
186
+ perception.setTurn(i + 1)
187
+ const message = await nextUserMessage(simCase, conversation, simCase.maxTurns - i, { perception })
188
+ ```
189
+
190
+ Every `look`/`click`/`type` call is recorded into `perception.observations` as
191
+ `{ turn, tool, args, result }`; put that array into the transcript's
192
+ `observations` field so the judge can check a visual claim against what was
193
+ actually seen. **Without `perception`, the persona cannot see the page at
194
+ all** — do not write a case or a judge prompt that expects it to notice or
195
+ react to anything visual (a panel opening, a chart rendering, a result
196
+ appearing) unless perception is wired in.
197
+
198
+ `df-agents-sim-init` copies the `simulation-judge` definition into your repo,
199
+ and the copied version now includes the instruction to check visual claims
200
+ against `observations`. If you already ran `df-agents-sim-init` before this
201
+ was added, re-run `npx df-agents-sim-init --force` to pick it up — otherwise
202
+ your judge keeps trusting unverified visual claims.
203
+
204
+ **Breaking change in 0.3.0.** `Transcript.observations` is a required field,
205
+ not an optional one — deliberately: an optional field would let a host wire up
206
+ `perception` and forget to add `observations` to its transcript object, and
207
+ ship runs that look valid while the judge sees no evidence at all. Upgrading
208
+ from 0.2.0 means adding `observations: perception?.observations ?? []` (or
209
+ `[]` where perception is not used) to the transcript you build; a TypeScript
210
+ compile error will name the field for you.
211
+
133
212
  ### Where the evidence goes
134
213
 
135
214
  `writeEvidence(name, transcript, sidecar, dir?)` writes `sim-<name>.json` (the
@@ -141,7 +220,7 @@ from the repository root. Pass `dir` explicitly to put evidence anywhere else,
141
220
  and hand the same directory to `reportCases(cases, dir)` so the reader and the
142
221
  writer agree.
143
222
 
144
- ## Scripts the copied `/simulate` skill expects
223
+ ## Scripts the copied `/agents-sim` skill expects
145
224
 
146
225
  `df-agents-sim-init` copies the skill **verbatim**, and the skill refers to npm
147
226
  scripts by the names the origin repository uses. It cannot know yours, so define
@@ -166,5 +245,5 @@ these three in your `package.json` (adjust the paths to your layout):
166
245
  on the failure count it returns.
167
246
 
168
247
  If you prefer different names, edit the copied
169
- `.claude/skills/simulate/SKILL.md` to match — but remember that a later
248
+ `.claude/skills/agents-sim/SKILL.md` to match — but remember that a later
170
249
  `df-agents-sim-init --force` overwrites it.
package/bin/init.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Copies the judge definition and the /simulate skill into the consuming repo's
3
+ * Copies the judge definition and the /agents-sim skill into the consuming repo's
4
4
  * .claude/ directory. They cannot be loaded from node_modules — Claude Code reads
5
5
  * them from the repository — so they are copied and can drift. The version is
6
6
  * printed so drift is at least detectable.
@@ -14,7 +14,7 @@ const pkg = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'u
14
14
  const cwd = process.cwd();
15
15
  const targets = [
16
16
  { from: 'simulation-judge.md', to: path.join(cwd, '.claude', 'agents', 'simulation-judge.md') },
17
- { from: 'simulate-skill.md', to: path.join(cwd, '.claude', 'skills', 'simulate', 'SKILL.md') }
17
+ { from: 'agents-sim-skill.md', to: path.join(cwd, '.claude', 'skills', 'agents-sim', 'SKILL.md') }
18
18
  ];
19
19
  for (const { from, to } of targets) {
20
20
  fs.mkdirSync(path.dirname(to), { recursive: true });
package/bridge/index.js CHANGED
File without changes
package/chat-driver.d.ts CHANGED
@@ -20,8 +20,10 @@ export declare function chatDriverStrings(locale: ChatDriverLocale): {
20
20
  input: string;
21
21
  send: string;
22
22
  stop: string;
23
+ reset: string;
23
24
  };
24
25
  export declare const TURN_TIMEOUT_MS: number;
26
+ export declare const SEND_TIMEOUT_MS = 15000;
25
27
  export declare function createChatDriver(root: ChatRoot, opts?: {
26
28
  locale?: ChatDriverLocale;
27
29
  }): {
package/chat-driver.js CHANGED
@@ -16,12 +16,16 @@
16
16
  import { expect } from '@playwright/test';
17
17
  /**
18
18
  * The chat takes its locale from the session (`i18n_lang`), so a host
19
- * application running in French renders a French composer. These are the only
20
- * locale-dependent selectors: `readConversation` matches on classes.
19
+ * application running in French renders a French composer. `reset` is the
20
+ * header's "Reset conversation" icon button (AgentChatHeader.vue) — its
21
+ * accessible name comes from the same `t()`-into-`title` pattern as the
22
+ * composer's own strings, so it is sourced here rather than as a literal at
23
+ * the call site. These are the only locale-dependent selectors:
24
+ * `readConversation` matches on classes.
21
25
  */
22
26
  const STRINGS = {
23
- en: { input: 'Type your message...', send: 'Send', stop: 'Stop' },
24
- fr: { input: 'Tapez votre message...', send: 'Envoyer', stop: 'Arrêter' }
27
+ en: { input: 'Type your message...', send: 'Send', stop: 'Stop', reset: 'Reset conversation' },
28
+ fr: { input: 'Tapez votre message...', send: 'Envoyer', stop: 'Arrêter', reset: 'Réinitialiser la conversation' }
25
29
  };
26
30
  export function chatDriverStrings(locale) {
27
31
  const strings = STRINGS[locale];
@@ -35,12 +39,41 @@ export function chatDriverStrings(locale) {
35
39
  // legitimate multi-step turn has no fixed ceiling on total time. This bounds the
36
40
  // harness generously rather than recording a slow-but-working turn as a failure.
37
41
  export const TURN_TIMEOUT_MS = 10 * 60 * 1000;
42
+ // Bounds a wedged page — e.g. the persona's own click/look/type tools
43
+ // (page-perception.ts) left an overlay open over the composer — so a stuck
44
+ // send fails in seconds rather than retrying against a stable-but-unreachable
45
+ // element until the test runner's own timeout kills the whole case 15 minutes
46
+ // later with no diagnosis. Unrelated to TURN_TIMEOUT_MS, which bounds a
47
+ // legitimately long model turn once the message has actually been sent.
48
+ export const SEND_TIMEOUT_MS = 15000;
38
49
  export function createChatDriver(root, opts = {}) {
39
50
  const strings = chatDriverStrings(opts.locale ?? 'en');
40
51
  return {
41
52
  async sendMessage(text) {
42
- await root.getByPlaceholder(strings.input).fill(text);
43
- await root.getByRole('button', { name: strings.send }).click();
53
+ const fillAndSend = async () => {
54
+ await root.getByPlaceholder(strings.input).fill(text, { timeout: SEND_TIMEOUT_MS });
55
+ await root.getByRole('button', { name: strings.send }).click({ timeout: SEND_TIMEOUT_MS });
56
+ };
57
+ try {
58
+ await fillAndSend();
59
+ }
60
+ catch {
61
+ // Not { force: true }: punching through an overlay a real user could
62
+ // not reach would make the harness report successes a person could
63
+ // never have had. Escape is the ordinary way a person dismisses a
64
+ // dialog that is in their way, so try that and retry once — on a
65
+ // locator, not `root.keyboard`, since root is a FrameLocator (no
66
+ // keyboard) when the chat is embedded.
67
+ await root.locator('body').press('Escape', { timeout: SEND_TIMEOUT_MS }).catch(() => { });
68
+ try {
69
+ await fillAndSend();
70
+ }
71
+ catch (secondErr) {
72
+ const detail = secondErr instanceof Error ? secondErr.message : String(secondErr);
73
+ throw new Error('could not operate the chat composer (fill/send) even after pressing Escape — ' +
74
+ `the page may have been left in a modal or scrolled state by the simulated user. Underlying error: ${detail}`);
75
+ }
76
+ }
44
77
  },
45
78
  async waitForTurn(timeoutMs = TURN_TIMEOUT_MS) {
46
79
  const stop = root.getByRole('button', { name: strings.stop });
package/index.d.ts CHANGED
@@ -1,8 +1,10 @@
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 } from './persona.ts';
4
+ export { nextUserMessage, personaSystemPrompt, personaPrompt, isDone, DONE, PERSONA_MAX_TURNS, PERCEPTION_INSTRUCTIONS } from './persona.ts';
5
5
  export { writeEvidence, evidenceDir } from './transcript.ts';
6
6
  export { selectCases } from './cases.ts';
7
7
  export { reportCases } from './report.ts';
8
- export { createChatDriver, type ChatRoot, TURN_TIMEOUT_MS } from './chat-driver.ts';
8
+ export { createChatDriver, chatDriverStrings, type ChatRoot, type ChatDriverLocale, TURN_TIMEOUT_MS, SEND_TIMEOUT_MS } from './chat-driver.ts';
9
+ export { createPagePerception, truncate, SNAPSHOT_CAP, ACTION_TIMEOUT_MS, MCP_SERVER_NAME as PAGE_MCP_SERVER_NAME } from './page-perception.ts';
10
+ export type { PerceptionRoot, Observation, PagePerception } from './page-perception.ts';
package/index.js CHANGED
@@ -1,7 +1,8 @@
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 } from "./persona.js";
3
+ export { nextUserMessage, personaSystemPrompt, personaPrompt, isDone, DONE, PERSONA_MAX_TURNS, PERCEPTION_INSTRUCTIONS } from "./persona.js";
4
4
  export { writeEvidence, evidenceDir } from "./transcript.js";
5
5
  export { selectCases } from "./cases.js";
6
6
  export { reportCases } from "./report.js";
7
- export { createChatDriver, TURN_TIMEOUT_MS } from "./chat-driver.js";
7
+ export { createChatDriver, chatDriverStrings, TURN_TIMEOUT_MS, SEND_TIMEOUT_MS } from "./chat-driver.js";
8
+ export { createPagePerception, truncate, SNAPSHOT_CAP, ACTION_TIMEOUT_MS, MCP_SERVER_NAME as PAGE_MCP_SERVER_NAME } from "./page-perception.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@data-fair/lib-agents-sim",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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",
@@ -0,0 +1,37 @@
1
+ import type { ChatRoot } from './chat-driver.ts';
2
+ export declare const MCP_SERVER_NAME = "page";
3
+ export declare const SNAPSHOT_CAP = 4000;
4
+ export declare const ACTION_TIMEOUT_MS = 15000;
5
+ export type PerceptionRoot = {
6
+ label: string;
7
+ root: ChatRoot;
8
+ };
9
+ export type Observation = {
10
+ turn: number;
11
+ tool: string;
12
+ args: unknown;
13
+ result: string;
14
+ };
15
+ export type PagePerception = {
16
+ server: {
17
+ type: 'sdk';
18
+ name: string;
19
+ instance: unknown;
20
+ alwaysLoad: true;
21
+ };
22
+ observations: Observation[];
23
+ setTurn: (turn: number) => void;
24
+ toolNames: string[];
25
+ call: (tool: string, args: Record<string, unknown>) => Promise<string>;
26
+ /**
27
+ * The names passed as `opts.offLimits`, verbatim (empty when none were).
28
+ * `persona.ts` reads this to decide whether the composer-refusal sentence in
29
+ * its system prompt is a true statement — with no offLimits, nothing refuses
30
+ * anything, so the prompt must not claim otherwise.
31
+ */
32
+ offLimits: string[];
33
+ };
34
+ export declare function truncate(text: string): string;
35
+ export declare function createPagePerception(roots: PerceptionRoot[], opts?: {
36
+ offLimits?: string[];
37
+ }): PagePerception;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * What the simulated user can perceive and do.
3
+ *
4
+ * Three tools, shaped like a person rather than like a test framework: look at
5
+ * the screen, click something by its visible name, type into a named field.
6
+ * There is deliberately no evaluate, no raw selector and no DOM access — a
7
+ * persona that can run JavaScript verifies outcomes no human could, which would
8
+ * make verdicts wrongly optimistic in exactly the way a blind persona makes them
9
+ * wrongly pessimistic.
10
+ *
11
+ * The handlers run in-process against the runner's live Playwright roots, so
12
+ * there is one browser and one page. Every call is recorded as an observation,
13
+ * because a judge cannot otherwise tell a real complaint from an invented one —
14
+ * including a failed one: `look`/`click`/`type` are all bounded by
15
+ * `ACTION_TIMEOUT_MS`, so a stuck element is caught and recorded rather than
16
+ * hanging the case with no evidence ever written.
17
+ *
18
+ * `click` and `type` also accept an `offLimits` list of names (e.g. the chat
19
+ * composer's own input/send/stop): a request naming one is refused, structurally,
20
+ * before the element is even looked up, instead of relying on an instruction the
21
+ * persona is free to ignore. This is what keeps "look and act freely, but talk by
22
+ * replying" (spec §3) an invariant rather than a suggestion.
23
+ */
24
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
25
+ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
26
+ export const MCP_SERVER_NAME = 'page';
27
+ export const SNAPSHOT_CAP = 4000;
28
+ // Bounds every Playwright call the persona itself makes (look/click/type).
29
+ // Without this, an element Playwright calls visible/enabled/stable but stuck
30
+ // outside the viewport — the exact case a live run hit — waits with no ceiling
31
+ // of its own, wedging the case until the test runner's 15-minute timeout kills
32
+ // it with no diagnosis. 15s mirrors chat-driver.ts's SEND_TIMEOUT_MS: a person
33
+ // does not wait minutes for something to become clickable, and a timeout here
34
+ // must surface through the existing try/catch as a recorded observation
35
+ // instead of an unrecorded hang.
36
+ export const ACTION_TIMEOUT_MS = 15000;
37
+ export function truncate(text) {
38
+ return text.length <= SNAPSHOT_CAP ? text : text.slice(0, SNAPSHOT_CAP) + '…[truncated]';
39
+ }
40
+ const TOOLS = [
41
+ {
42
+ name: 'look',
43
+ description: 'Look at the screen. Returns what is currently visible, as an accessibility outline of the page. Use this before saying anything about what you can or cannot see.',
44
+ inputSchema: { type: 'object', properties: {} }
45
+ },
46
+ {
47
+ name: 'click',
48
+ description: 'Click something by the visible text or label on it, exactly as a person would point at it.',
49
+ inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'The visible text or accessible name of what to click' } }, required: ['name'] }
50
+ },
51
+ {
52
+ name: 'type',
53
+ description: 'Type into a field identified by its visible label.',
54
+ inputSchema: { type: 'object', properties: { name: { type: 'string' }, text: { type: 'string' } }, required: ['name', 'text'] }
55
+ }
56
+ ];
57
+ export function createPagePerception(roots, opts = {}) {
58
+ const observations = [];
59
+ let turn = 0;
60
+ // Equality, not substring: the persona copies names verbatim out of the aria
61
+ // snapshot it just looked at, so an exact (trimmed, case-insensitive) match is
62
+ // enough to catch it — while substring matching on a short off-limits word
63
+ // like "Send" would also block an unrelated "Send report" button.
64
+ const offLimits = new Set((opts.offLimits ?? []).map(n => n.trim().toLowerCase()));
65
+ const isOffLimits = (name) => offLimits.has(name.trim().toLowerCase());
66
+ const OFF_LIMITS_RESULT = 'the composer is not yours to operate — reply with your message and the runner will send it for you';
67
+ const look = async () => {
68
+ const parts = [];
69
+ for (const { label, root } of roots) {
70
+ let snap = '';
71
+ try {
72
+ snap = await root.locator('body').ariaSnapshot({ timeout: ACTION_TIMEOUT_MS });
73
+ }
74
+ catch (err) {
75
+ snap = `(could not read: ${err instanceof Error ? err.message : String(err)})`;
76
+ }
77
+ // Capped per root, not on the joined result: otherwise a large first
78
+ // root can consume the whole budget and a second root (e.g. an embedded
79
+ // `## chat panel`) disappears from the log entirely, with no marker
80
+ // hinting it was ever there.
81
+ parts.push(truncate(`## ${label}\n${snap}`));
82
+ }
83
+ return parts.join('\n\n');
84
+ };
85
+ const firstMatch = async (finders) => {
86
+ for (const find of finders) {
87
+ try {
88
+ const loc = find();
89
+ if (await loc.count() > 0)
90
+ return loc;
91
+ }
92
+ catch { /* a finder that throws simply does not match */ }
93
+ }
94
+ return null;
95
+ };
96
+ const click = async (name) => {
97
+ if (isOffLimits(name))
98
+ return OFF_LIMITS_RESULT;
99
+ 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()
104
+ ]);
105
+ if (loc) {
106
+ try {
107
+ await loc.click({ timeout: ACTION_TIMEOUT_MS });
108
+ return `clicked "${name}"`;
109
+ }
110
+ catch (err) {
111
+ return `could not click "${name}": ${err instanceof Error ? err.message : String(err)}`;
112
+ }
113
+ }
114
+ }
115
+ return `could not find anything called "${name}" to click`;
116
+ };
117
+ const type = async (name, text) => {
118
+ if (isOffLimits(name))
119
+ return OFF_LIMITS_RESULT;
120
+ for (const { root } of roots) {
121
+ const loc = await firstMatch([
122
+ () => root.getByRole('textbox', { name }).first(),
123
+ () => root.getByLabel(name).first()
124
+ ]);
125
+ if (loc) {
126
+ try {
127
+ await loc.fill(text, { timeout: ACTION_TIMEOUT_MS });
128
+ return `typed into "${name}"`;
129
+ }
130
+ catch (err) {
131
+ return `could not type into "${name}": ${err instanceof Error ? err.message : String(err)}`;
132
+ }
133
+ }
134
+ }
135
+ return `could not find a field called "${name}"`;
136
+ };
137
+ const call = async (tool, args) => {
138
+ let result;
139
+ if (tool === 'look')
140
+ result = await look();
141
+ else if (tool === 'click')
142
+ result = await click(String(args.name ?? ''));
143
+ else if (tool === 'type')
144
+ result = await type(String(args.name ?? ''), String(args.text ?? ''));
145
+ else
146
+ result = `unknown tool: ${tool}`;
147
+ observations.push({ turn, tool, args, result });
148
+ return result;
149
+ };
150
+ const instance = new Server({ name: MCP_SERVER_NAME, version: '1.0.0' }, { capabilities: { tools: {} } });
151
+ instance.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
152
+ instance.setRequestHandler(CallToolRequestSchema, async (req) => ({
153
+ content: [{ type: 'text', text: await call(req.params.name, (req.params.arguments ?? {})) }]
154
+ }));
155
+ return {
156
+ server: { type: 'sdk', name: MCP_SERVER_NAME, instance, alwaysLoad: true },
157
+ observations,
158
+ setTurn: (n) => { turn = n; },
159
+ toolNames: TOOLS.map(t => t.name),
160
+ call,
161
+ offLimits: opts.offLimits ?? []
162
+ };
163
+ }
package/persona.d.ts CHANGED
@@ -1,7 +1,17 @@
1
1
  import type { SimulationCase } from './types.ts';
2
+ import type { PagePerception } from './page-perception.ts';
2
3
  export declare const DONE = "DONE";
4
+ export declare const PERSONA_MAX_TURNS = 25;
5
+ 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.";
3
6
  export declare function isDone(message: string): boolean;
4
- export declare function personaSystemPrompt(c: SimulationCase): string;
7
+ export declare function personaSystemPrompt(c: SimulationCase, perceptionEnabled?: boolean, offLimitsActive?: boolean): string;
8
+ /**
9
+ * The SDK's `query`, narrowed to what the persona uses. Injectable (mirrors
10
+ * `BridgeQuery` in bridge/server.ts) so a test can observe the options actually
11
+ * handed over — including the perception wiring — without a network call or a
12
+ * live model.
13
+ */
14
+ export type PersonaQuery = (typeof import('@anthropic-ai/claude-agent-sdk'))['query'];
5
15
  export declare function personaPrompt(conversation: Array<{
6
16
  role: string;
7
17
  text: string;
@@ -9,4 +19,7 @@ export declare function personaPrompt(conversation: Array<{
9
19
  export declare function nextUserMessage(c: SimulationCase, conversation: Array<{
10
20
  role: string;
11
21
  text: string;
12
- }>, turnsLeft: number): Promise<string>;
22
+ }>, turnsLeft: number, opts?: {
23
+ perception?: PagePerception;
24
+ query?: PersonaQuery;
25
+ }): Promise<string>;
package/persona.js CHANGED
@@ -9,8 +9,41 @@
9
9
  */
10
10
  import { createNeutralCwd, isolationOptions } from "./isolation.js";
11
11
  import { MISSING_SDK_MESSAGE, isMissingSdkError } from "./missing-sdk.js";
12
+ import { MCP_SERVER_NAME } from "./page-perception.js";
12
13
  export const DONE = 'DONE';
13
14
  let neutralCwd;
15
+ // The persona looks and acts before replying, so one turn is not enough:
16
+ // look → act → look → reply, with room to spare.
17
+ //
18
+ // Tuned from real runs, three times, and the number follows the shape of the
19
+ // work rather than a guess. It is the budget for ONE message: how many tool
20
+ // calls the person may make before answering.
21
+ //
22
+ // 6 was the cost of look/click/look/click/look — the most ordinary thing a
23
+ // person does on a multi-step page — leaving nothing for the reply.
24
+ //
25
+ // 12 was exactly the length of a guided workflow. In a run of the dataset
26
+ // creation case the assistant handed over the whole procedure in one message
27
+ // and the person executed it in one turn: click Create, choose the type, skip
28
+ // the init step, type a title, tick an option, continue — with a look between
29
+ // each, twelve calls of purposeful work and nothing wasted. The reply then had
30
+ // no budget left and the run was discarded.
31
+ //
32
+ // So a guided scenario costs roughly (steps × 2) + 1, and the assistant decides
33
+ // how many steps it hands over at once. 25 covers a full wizard driven in a
34
+ // single message, with the verification looks and the reply, and still stops a
35
+ // genuinely lost persona long before it could wander for minutes.
36
+ export const PERSONA_MAX_TURNS = 25;
37
+ export const PERCEPTION_INSTRUCTIONS = `You can look at the screen yourself with the look tool, and you can click and type
38
+ on the page. Before you say anything about what is or is not on the screen, look.
39
+ Never claim you cannot see something you have not looked for.`;
40
+ // Appended only when the caller actually configured createPagePerception's
41
+ // offLimits — otherwise nothing refuses the composer and this sentence would be
42
+ // a promise the harness does not keep (the persona types its message in itself,
43
+ // double-sending). See lib-sim/README.md, "Give the persona eyes".
44
+ const COMPOSER_OFF_LIMITS_INSTRUCTIONS = `The message box and its Send button will refuse you if you try to click or type into
45
+ them — that part of the page is not yours to operate. To talk to the assistant, just
46
+ reply with your message; the runner types and sends it for you.`;
14
47
  export function isDone(message) {
15
48
  if (!message)
16
49
  return false;
@@ -29,8 +62,8 @@ export function isDone(message) {
29
62
  // Only true if it is exactly DONE, not a sentence containing the word
30
63
  return normalized === DONE;
31
64
  }
32
- export function personaSystemPrompt(c) {
33
- return [
65
+ export function personaSystemPrompt(c, perceptionEnabled = false, offLimitsActive = false) {
66
+ const lines = [
34
67
  c.persona,
35
68
  '',
36
69
  `What you want: ${c.goal}`,
@@ -43,7 +76,13 @@ export function personaSystemPrompt(c) {
43
76
  '',
44
77
  'Reply with ONLY the message you would type next — no quotes, no narration, no stage directions.',
45
78
  `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');
79
+ ];
80
+ if (perceptionEnabled) {
81
+ lines.push('', PERCEPTION_INSTRUCTIONS);
82
+ if (offLimitsActive)
83
+ lines.push('', COMPOSER_OFF_LIMITS_INSTRUCTIONS);
84
+ }
85
+ return lines.join('\n');
47
86
  }
48
87
  export function personaPrompt(conversation, turnsLeft) {
49
88
  if (conversation.length === 0)
@@ -60,29 +99,51 @@ export function personaPrompt(conversation, turnsLeft) {
60
99
  `Write your next message, or ${DONE} if you are finished.${warning}`
61
100
  ].join('\n');
62
101
  }
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'));
102
+ export async function nextUserMessage(c, conversation, turnsLeft, opts) {
103
+ let runQuery;
104
+ if (opts?.query) {
105
+ runQuery = opts.query;
71
106
  }
72
- catch (err) {
73
- if (isMissingSdkError(err))
74
- throw new Error(MISSING_SDK_MESSAGE);
75
- throw err;
107
+ else {
108
+ // Loaded here, not at module top level, so importing the package barrel
109
+ // never requires the Agent SDK — it is an optional peer, and a consumer who
110
+ // only wants the harness primitives must not pay for it. This is the only
111
+ // place in the exported surface that reaches for it at runtime.
112
+ try {
113
+ ({ query: runQuery } = await import('@anthropic-ai/claude-agent-sdk'));
114
+ }
115
+ catch (err) {
116
+ if (isMissingSdkError(err))
117
+ throw new Error(MISSING_SDK_MESSAGE);
118
+ throw err;
119
+ }
76
120
  }
77
121
  neutralCwd ??= createNeutralCwd();
78
122
  let text = '';
79
- for await (const msg of query({
123
+ for await (const msg of runQuery({
80
124
  prompt: personaPrompt(conversation, turnsLeft),
81
125
  options: {
82
126
  ...isolationOptions(neutralCwd),
83
127
  model: process.env.SIM_USER_MODEL ?? 'haiku',
84
- systemPrompt: personaSystemPrompt(c),
85
- maxTurns: 1
128
+ systemPrompt: personaSystemPrompt(c, !!opts?.perception, !!opts?.perception?.offLimits.length),
129
+ // Unconditional: a caller with no perception registers no mcpServers, so
130
+ // the persona has no tool to call and the loop still ends after the one
131
+ // assistant turn a blind persona always took — the higher cap only ever
132
+ // matters once look/click/type are actually wired in below.
133
+ maxTurns: PERSONA_MAX_TURNS,
134
+ ...(opts?.perception
135
+ ? {
136
+ // page-perception.ts deliberately builds the LOW-LEVEL MCP `Server`
137
+ // (server/index.js), not the high-level `McpServer` helper the SDK's
138
+ // `McpServerConfig` type expects — the low-level API accepts raw JSON
139
+ // Schema for tool inputs, while `McpServer` demands Zod (the same
140
+ // choice bridge/tool-server.ts makes, cast at the same boundary in
141
+ // bridge/server.ts). The two classes are structurally unrelated, so
142
+ // no tighter typing of `instance` would remove this cast.
143
+ mcpServers: { [MCP_SERVER_NAME]: opts.perception.server },
144
+ allowedTools: opts.perception.toolNames.map(n => `mcp__${MCP_SERVER_NAME}__${n}`)
145
+ }
146
+ : {})
86
147
  }
87
148
  })) {
88
149
  if (msg.type === 'assistant') {
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: simulate
2
+ name: agents-sim
3
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
4
  ---
5
5
 
@@ -11,12 +11,16 @@ transcript.
11
11
 
12
12
  ## Before you start
13
13
 
14
- Three things must be true, and each fails confusingly if it is not:
14
+ Four things must be true, and each fails confusingly if it is not:
15
15
 
16
16
  1. The dev stack is up — `bash dev/status.sh`.
17
17
  2. The workspace packages are built — `ls lib-vue/*.js lib-vuetify/*.js`. If they
18
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.
19
+ 3. `lib-sim` is built — `npm -w @data-fair/lib-agents-sim run build`.
20
+ `simulations/` imports it by package name, not by relative path, so a stale
21
+ build silently runs the OLD code and still reports the run valid — the exact
22
+ failure mode this subsystem exists to catch.
23
+ 4. The bridge is running — `npm run dev-bridge`. The runner checks this and says so.
20
24
 
21
25
  Ask the user to start anything that is down. Never start or stop dev processes yourself.
22
26
 
@@ -1,6 +1,6 @@
1
1
  ---
2
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.
3
+ description: Judge one scenario simulation transcript and return a JSON verdict. Use when asked to verdict a simulation run produced by the /agents-sim skill.
4
4
  tools: Read
5
5
  ---
6
6
 
@@ -15,6 +15,15 @@ The transcript holds:
15
15
  - `gateway` — every request the page made, carrying the tools it offered and the
16
16
  tool calls the assistant actually made
17
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.
21
+
22
+ 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.
18
27
 
19
28
  `gateway` records what the browser SENT to the server, and each request carries
20
29
  the whole conversation so far — so the assistant's FINAL reply of a conversation
package/types.d.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * The shapes a host repo needs to write cases and read evidence.
3
3
  */
4
4
  import type { GatewayExchange } from './gateway-capture.ts';
5
+ import type { Observation } from './page-perception.ts';
5
6
  export type SimulationCase = {
6
7
  /** Evidence files are named after this; keep it filesystem-safe. */
7
8
  name: string;
@@ -24,6 +25,7 @@ export type Transcript = {
24
25
  }>;
25
26
  gateway: GatewayExchange[];
26
27
  consoleErrors: string[];
28
+ observations: Observation[];
27
29
  };
28
30
  export type RunSidecar = {
29
31
  case: string;