ravensight-playtest 0.3.0 → 0.3.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
@@ -118,7 +118,7 @@ setup with the expected rows at each step.
118
118
  "ok": true,
119
119
  "reserves_units": false,
120
120
  "note": "check reserves no units and starts no job: ...",
121
- "cli": { "installed": "0.3.0", "latest": "0.3.0", "min_supported": "0.1.0" },
121
+ "cli": { "installed": "0.3.1", "latest": "0.3.1", "min_supported": "0.1.0" },
122
122
  "login": { "present": true, "valid": true, "games": [{ "gameId": "g1", "name": "Pocket Knight" }] },
123
123
  "project": { "game": "g1", "environment": null, "in_scope": true },
124
124
  "brief": { "present": true, "complete": true, "missing": [] },
@@ -306,9 +306,21 @@ How the report step reads what happened:
306
306
 
307
307
  - Console errors are recorded as automatic notes. A 4xx on a request (`Failed
308
308
  to load resource`, `HTTP 404`, `status: 401`, `401 Unauthorized` and similar)
309
- is a network note: context, not a bug. A finding whose only evidence is
310
- automatic network notes is capped at `minor` by the CLI, and its description
311
- says so.
309
+ is a network note: context, not a bug. On the web a `Failed to load
310
+ resource` note names the request's method, URL (with its query string and
311
+ fragment removed) and status, and the report lists those requests under
312
+ network notes. A finding whose only evidence is automatic network notes is
313
+ capped at `minor` by the CLI, and its description says so.
314
+ - The report step is given every action that went through (the newest 60):
315
+ what was clicked or typed into, by name or ref, and whether the screen
316
+ changed. A claim that something was never done or did nothing has to agree
317
+ with that list. A finding that says a named control "did nothing", "does
318
+ nothing", "never kicked off", "never started", "no response" or "nothing
319
+ happened", when the most recent click
320
+ on that control changed the screen without bringing up an error or a dialog,
321
+ at or after the screen the finding cites, is downgraded one severity in
322
+ report.json and report.md with a note saying at which step. It is never
323
+ dropped.
312
324
  - Every note carries `after`, the URL and title of the screen that followed
313
325
  it. A blocker or major finding has to cite the observation where the failure
314
326
  is visible and must not be contradicted by that next screen or by a later
@@ -362,6 +374,13 @@ are the driver's, not the game's: they are recorded as `driver_error`, and a
362
374
  blocker or major resting only on them is dropped before upload, as is any
363
375
  finding resting only on the persona's "waiting for X to finish" notes.
364
376
 
377
+ Every field, checkbox, radio, slider and select the persona sees carries what
378
+ it holds now, read from the page rather than from the accessibility text:
379
+ `value="..."` (cut at 80 characters), `checked` or `not checked`, or
380
+ `selected option="..."`. A password field shows `value=(hidden)`, and a field
381
+ the page could not answer for shows no value at all, so a typed password never
382
+ reaches the model.
383
+
365
384
  On a long web page the persona's text view of the screen is capped at 4,000
366
385
  characters. Over the cap, buttons, links and fields are kept first, a long run
367
386
  of look-alike items (a list of 249 cards) is shown as its first two and "and
@@ -370,7 +389,10 @@ after a long list still reaches the persona.
370
389
 
371
390
  Each persona starts at the game's URL as a fresh player with no account, so a
372
391
  sign-up form with validation can use up a whole unit before the game itself is
373
- reached. Point `--build-url` at a guest or demo path, or say where to start in
392
+ reached. Every run gets its own identity to sign up with, an email
393
+ `persona-<persona>-<short run id>@example.test` and a display name ending in the
394
+ short run id, so a second run never hits "that email is taken" from the first.
395
+ `example.test` is a reserved name and never delivers mail. Point `--build-url` at a guest or demo path, or say where to start in
374
396
  the brief's `how_to_start` note (for example "choose Play as guest"), when the
375
397
  sign-up is not what you want tested.
376
398
 
@@ -60,7 +60,7 @@ Every device command times out after 30 seconds and responses are capped at
60
60
  ## Step 1: the account side
61
61
 
62
62
  ```bash
63
- npm install -g ravensight-playtest@0.3.0
63
+ npm install -g ravensight-playtest@0.3.1
64
64
  ravensight-playtest login
65
65
  cd path/to/your/game
66
66
  ravensight-playtest init --game <gameId>
@@ -70,7 +70,7 @@ ravensight-playtest check --driver ios_driver
70
70
  Expected rows (your email, game and version differ):
71
71
 
72
72
  ```
73
- pass cli version installed 0.3.0; server latest 0.3.0, min_supported 0.1.0
73
+ pass cli version installed 0.3.1; server latest 0.3.1, min_supported 0.1.0
74
74
  pass login you@example.com, valid, scoped to 1 game: Pocket Knight (g1)
75
75
  pass project binding linked to Pocket Knight (g1), in the token's scope
76
76
  fail brief this game has no expectations brief, and a persona run needs one
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ravensight-playtest",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "AI personas play your game and file reports to Ravensight.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ravensight.io/docs/#playtest",
@@ -698,6 +698,18 @@ export async function playPersonaRun({
698
698
  return { runId, persona: persona.slug, state: outcome.state, quitReason: outcome.quitReason };
699
699
  }
700
700
 
701
+ /**
702
+ * A run's duration as `complete` sends it: `{ wall_seconds, model_seconds }`,
703
+ * each a number or null.
704
+ *
705
+ * @param {{wall_seconds?: number, model_seconds?: number}} duration
706
+ * @returns {{wall_seconds: number|null, model_seconds: number|null}}
707
+ */
708
+ export function durationBody(duration) {
709
+ const value = key => (duration && Number.isFinite(Number(duration[key])) && duration[key] !== null ? Number(duration[key]) : null);
710
+ return { wall_seconds: value('wall_seconds'), model_seconds: value('model_seconds') };
711
+ }
712
+
701
713
  /**
702
714
  * Upload the run directory and complete the run, through cli-core's
703
715
  * `finalizeRun` so the 422 `upload_unverified` repair pass lives in one place.
@@ -723,12 +735,7 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
723
735
  };
724
736
  // The run card's duration. The server lane accepts `duration`; an older
725
737
  // server ignores a key it does not know.
726
- if (outcome.duration) {
727
- body.duration = {
728
- wall_seconds: Number.isFinite(Number(outcome.duration.wall_seconds)) ? Number(outcome.duration.wall_seconds) : null,
729
- model_seconds: Number.isFinite(Number(outcome.duration.model_seconds)) ? Number(outcome.duration.model_seconds) : null
730
- };
731
- }
738
+ if (outcome.duration) body.duration = durationBody(outcome.duration);
732
739
  if (outcome.report) body.report = outcome.report;
733
740
  // `report_md` is what the server lints for its four required sections. Not
734
741
  // sending it means `report_lint` is silently empty on every run, which reads
@@ -910,6 +917,8 @@ async function aggregatePhase({
910
917
  log
911
918
  }) {
912
919
  const runId = aggregateRun ? aggregateRun.run_id : null;
920
+ // Created here, so its wall clock is measured from the start of the
921
+ // aggregate phase: what the summary step's card shows as its duration.
913
922
  const ledger = createUsageLedger();
914
923
  const heartbeat = runId
915
924
  ? createHeartbeat({ api, gameId, jobId, runId, onUsage: usage => ledger.recordServerUsage(usage), onError: () => {} })
@@ -966,7 +975,7 @@ async function aggregatePhase({
966
975
  log('The aggregate was built locally: this job has no aggregate_report run to spend a model turn from, so the dedup and judgement passes were skipped.');
967
976
  }
968
977
 
969
- const published = await publishAggregate({ deps, api, gameId, jobId, repoRoot, directory, options, log });
978
+ const published = await publishAggregate({ deps, api, gameId, jobId, repoRoot, directory, options, log, duration: durationBody(ledger.duration()) });
970
979
  if (published === 'finished') reached = 'succeeded';
971
980
  else if (published) await advance('uploading');
972
981
 
@@ -976,7 +985,8 @@ async function aggregatePhase({
976
985
  state: reached === 'uploading' ? 'succeeded' : 'failed',
977
986
  actions_taken: 0,
978
987
  quit_reason: reached === 'uploading' ? 'goal_reached' : 'error',
979
- reason: reached === 'uploading' ? '' : 'the aggregate was not published'
988
+ reason: reached === 'uploading' ? '' : 'the aggregate was not published',
989
+ duration: durationBody(ledger.duration())
980
990
  });
981
991
  } catch (error) {
982
992
  log(`aggregate run ${runId}: complete was refused (${String(error && error.message)}).`);
@@ -996,7 +1006,7 @@ async function aggregatePhase({
996
1006
  * sent, 'published' when the server has it, 'finished' when it also closed
997
1007
  * the aggregate run
998
1008
  */
999
- async function publishAggregate({ deps, api, gameId, jobId, repoRoot, directory, options, log }) {
1009
+ async function publishAggregate({ deps, api, gameId, jobId, repoRoot, directory, options, log, duration = null }) {
1000
1010
  if (options.dryUpload) {
1001
1011
  log('The aggregate was not published: --dry-upload.');
1002
1012
  return false;
@@ -1023,7 +1033,11 @@ async function publishAggregate({ deps, api, gameId, jobId, repoRoot, directory,
1023
1033
  md_size: markdown.size,
1024
1034
  md_sha256: markdown.sha256,
1025
1035
  json_size: json.size,
1026
- json_sha256: json.sha256
1036
+ json_sha256: json.sha256,
1037
+ // The server closes the aggregate run on this call, so the duration
1038
+ // rides here as well as on the run's own complete; an older server
1039
+ // ignores a key it does not know.
1040
+ ...(duration ? { duration } : {})
1027
1041
  });
1028
1042
  // The server finishes the aggregate run itself when it accepts the
1029
1043
  // report, and says so in `run`. A caller that then tries to move the
@@ -196,7 +196,7 @@ export function clusterFindings(runs) {
196
196
  *
197
197
  * @returns {Promise<Map<string, {same: boolean, title: string|null}>>}
198
198
  */
199
- export async function judgeClusters({ client, jobId, runId, clusters, log = () => {} }) {
199
+ export async function judgeClusters({ client, jobId, runId, clusters, ledger = null, log = () => {} }) {
200
200
  const verdicts = new Map();
201
201
  // A certain cluster is one finding written twice; nothing to ask.
202
202
  const ambiguous = clusters.filter(cluster => cluster.members.length > 1 && !cluster.certain);
@@ -214,6 +214,7 @@ export async function judgeClusters({ client, jobId, runId, clusters, log = () =
214
214
  ].join('\n');
215
215
 
216
216
  try {
217
+ const askedAt = Date.now();
217
218
  const answer = await callStepWithRetry({
218
219
  client,
219
220
  jobId,
@@ -222,6 +223,7 @@ export async function judgeClusters({ client, jobId, runId, clusters, log = () =
222
223
  messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
223
224
  maxTokens: 512
224
225
  });
226
+ if (ledger) ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
225
227
  for (const entry of parseJsonArray(answer.text)) {
226
228
  const cluster = ambiguous[Number(entry.cluster)];
227
229
  if (!cluster) continue;
@@ -400,7 +402,7 @@ export async function buildAggregate({
400
402
  const { kept, observations } = applyQualityGate(inputs);
401
403
  const clusters = clusterFindings(kept);
402
404
  const verdicts = aggregateRunId
403
- ? await judgeClusters({ client, jobId, runId: aggregateRunId, clusters, log })
405
+ ? await judgeClusters({ client, jobId, runId: aggregateRunId, clusters, ledger, log })
404
406
  : new Map();
405
407
 
406
408
  const issues = clusters.flatMap(cluster => toIssues(cluster, verdicts.get(cluster.signature)));
@@ -523,6 +525,7 @@ export async function judgeAggregate({ client, jobId, runId, ranked, inputs, bri
523
525
  ].join('\n');
524
526
 
525
527
  try {
528
+ const askedAt = Date.now();
526
529
  const answer = await callStepWithRetry({
527
530
  client,
528
531
  jobId,
@@ -531,7 +534,7 @@ export async function judgeAggregate({ client, jobId, runId, ranked, inputs, bri
531
534
  messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
532
535
  maxTokens: 8192
533
536
  });
534
- if (ledger) ledger.record({ model: answer.model, usage: answer.usage });
537
+ if (ledger) ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
535
538
  return parseJsonObject(answer.text);
536
539
  } catch (error) {
537
540
  // A budget or cancel refusal decides the caller's exit code and has to
@@ -133,6 +133,136 @@ export function visibleCentre(box, viewport = DEFAULT_VIEWPORT) {
133
133
  return { x: cx, y: cy };
134
134
  }
135
135
 
136
+ /** Roles whose current value the snapshot carries, read from the DOM. */
137
+ export const VALUE_ROLES = new Set(['textbox', 'searchbox', 'combobox', 'listbox', 'checkbox', 'radio', 'switch', 'slider', 'spinbutton', 'menuitemcheckbox', 'menuitemradio']);
138
+
139
+ /** The longest value shown for a control; a longer one is cut with "...". */
140
+ export const MAX_VALUE_CHARS = 80;
141
+
142
+ /** How many controls on one screen have their value read; a typed field past it shows no value at all. */
143
+ export const MAX_VALUE_READS = 60;
144
+
145
+ /** What a password field's value is always shown as, empty or not. */
146
+ export const MASKED_VALUE = '(hidden)';
147
+
148
+ /**
149
+ * Roles whose snapshot text can be what the user typed. Their printed value is
150
+ * removed before anything is compacted, and a value is shown only when the DOM
151
+ * answered and said the field is not a password: Playwright's snapshot prints
152
+ * a password field's contents in clear, and 0.3.0 sent that to the model.
153
+ */
154
+ export const TYPED_VALUE_ROLES = new Set(['textbox', 'searchbox', 'combobox', 'spinbutton']);
155
+
156
+ /**
157
+ * A node body with the value the snapshot printed after its name removed:
158
+ * `textbox "Pass" [ref=e6]: hunter22` becomes `textbox "Pass" [ref=e6]`. A
159
+ * trailing `:` (children follow) is kept.
160
+ *
161
+ * @param {string} body
162
+ * @returns {string}
163
+ */
164
+ export function withoutPrintedValue(body) {
165
+ const match = String(body).match(/^([a-z]+(?: "[^"]*")?(?: \[[^\]]*\])*)(:?)(.*)$/s);
166
+ if (!match) return String(body);
167
+ return match[3].trim() ? match[1] : `${match[1]}${match[2]}`;
168
+ }
169
+
170
+ /**
171
+ * A control's current state as the snapshot shows it: `value="..."` for a
172
+ * field or a slider, `checked` or `not checked` for a checkbox, radio or
173
+ * switch, `selected option="..."` for a select. A password is masked, and a
174
+ * value over {@link MAX_VALUE_CHARS} is cut.
175
+ *
176
+ * @param {{kind: string, value?: string, checked?: boolean|'mixed', password?: boolean, selected?: Array<string>}} state
177
+ * @returns {string|null}
178
+ */
179
+ export function formatControlState(state) {
180
+ if (!state || typeof state !== 'object') return null;
181
+ const quote = value => {
182
+ let text = String(value == null ? '' : value);
183
+ if (text.length > MAX_VALUE_CHARS) text = `${text.slice(0, MAX_VALUE_CHARS - 3)}...`;
184
+ return JSON.stringify(text);
185
+ };
186
+ if (state.kind === 'check') return state.checked === 'mixed' ? 'partly checked' : state.checked ? 'checked' : 'not checked';
187
+ if (state.kind === 'select') return `selected option=${quote((state.selected || []).join(', '))}`;
188
+ if (state.kind === 'value') return state.password ? `value=${MASKED_VALUE}` : `value=${quote(state.value)}`;
189
+ return null;
190
+ }
191
+
192
+ /**
193
+ * Read a control's state in the page. Runs in the browser, serialised by
194
+ * Playwright, so it must not close over anything.
195
+ *
196
+ * @param {Element} el
197
+ * @returns {{kind: string, value?: string, checked?: boolean|'mixed', password?: boolean, selected?: Array<string>}|null}
198
+ */
199
+ function readControlState(el) {
200
+ if (!el) return null;
201
+ const tag = String(el.tagName || '').toLowerCase();
202
+ const type = String(el.getAttribute('type') || '').toLowerCase();
203
+ const role = String(el.getAttribute('role') || '').toLowerCase();
204
+ if (tag === 'input' && (type === 'checkbox' || type === 'radio')) return { kind: 'check', checked: el.indeterminate ? 'mixed' : Boolean(el.checked) };
205
+ if (tag === 'select') return { kind: 'select', selected: Array.from(el.selectedOptions || []).map(option => String(option.label || option.textContent || '').trim()) };
206
+ if (tag === 'input' || tag === 'textarea') return { kind: 'value', value: String(el.value == null ? '' : el.value), password: tag === 'input' && type === 'password' };
207
+ const ariaChecked = el.getAttribute('aria-checked');
208
+ if (ariaChecked !== null || ['checkbox', 'radio', 'switch', 'menuitemcheckbox', 'menuitemradio'].includes(role)) {
209
+ return { kind: 'check', checked: ariaChecked === 'mixed' ? 'mixed' : ariaChecked === 'true' };
210
+ }
211
+ if (el.isContentEditable) return { kind: 'value', value: String(el.innerText || '').replace(/\s+/g, ' ').trim() };
212
+ const now = el.getAttribute('aria-valuetext') || el.getAttribute('aria-valuenow');
213
+ if (now !== null) return { kind: 'value', value: String(now) };
214
+ return null;
215
+ }
216
+
217
+ /**
218
+ * The refs of the controls in an AI mode snapshot whose value is worth
219
+ * reading, in page order and bounded by {@link MAX_VALUE_READS}.
220
+ *
221
+ * @param {string} text
222
+ * @returns {Array<string>}
223
+ */
224
+ export function valueRefs(text) {
225
+ const refs = [];
226
+ for (const line of String(text == null ? '' : text).split('\n')) {
227
+ const raw = unquoteKey(line.trim().replace(/^- /, ''));
228
+ const role = (raw.match(/^([a-z]+)/) || [])[1] || '';
229
+ if (!VALUE_ROLES.has(role)) continue;
230
+ const ref = (raw.match(REF_ATTRIBUTE) || [])[1];
231
+ if (ref && !refs.includes(ref)) refs.push(ref);
232
+ if (refs.length >= MAX_VALUE_READS) break;
233
+ }
234
+ return refs;
235
+ }
236
+
237
+ /** How long reading one control's value may take before it is skipped. */
238
+ export const VALUE_READ_TIMEOUT_MS = 1000;
239
+
240
+ /**
241
+ * Read the current state of every control in a snapshot from the DOM, by
242
+ * ref. A control that cannot answer (gone, detached, a frame) is left out
243
+ * and shows what the snapshot printed.
244
+ *
245
+ * @param {any} page
246
+ * @param {string} text an AI mode snapshot
247
+ * @returns {Promise<Map<string, Object>>}
248
+ */
249
+ export async function readControlValues(page, text) {
250
+ const values = new Map();
251
+ const refs = valueRefs(text);
252
+ if (refs.length === 0 || !page || typeof page.locator !== 'function') return values;
253
+ await Promise.all(refs.map(async ref => {
254
+ try {
255
+ const locator = page.locator(`aria-ref=${ref}`);
256
+ if (typeof locator.evaluate !== 'function') return;
257
+ const state = await locator.evaluate(readControlState, null, { timeout: VALUE_READ_TIMEOUT_MS });
258
+ if (state && typeof state === 'object') values.set(ref, state);
259
+ } catch {
260
+ // Not answerable now; the snapshot's own text stands for it.
261
+ }
262
+ }));
263
+ return values;
264
+ }
265
+
136
266
  /**
137
267
  * The kept nodes of an aria snapshot with the depth each one had, or null for
138
268
  * text that is not an aria snapshot. The depth is not shown to the model; it
@@ -152,7 +282,7 @@ export function visibleCentre(box, viewport = DEFAULT_VIEWPORT) {
152
282
  * @param {{viewport?: {width: number, height: number}}} [options]
153
283
  * @returns {Array<{depth: number, line: string, hashLine: string, body: string, role: string, name: string|null, ref: string|null, centre: Object|null, hint: string|null, key: string|null, duplicate: boolean}>|null}
154
284
  */
155
- export function compactAriaNodes(text, { viewport = DEFAULT_VIEWPORT } = {}) {
285
+ export function compactAriaNodes(text, { viewport = DEFAULT_VIEWPORT, values = null } = {}) {
156
286
  const value = String(text == null ? '' : text);
157
287
  const lines = value.split('\n');
158
288
  if (!lines.some(line => /^\s*- /.test(line))) return null;
@@ -170,7 +300,9 @@ export function compactAriaNodes(text, { viewport = DEFAULT_VIEWPORT } = {}) {
170
300
  const ref = (raw.match(REF_ATTRIBUTE) || [])[1] || null;
171
301
  const boxMatch = raw.match(BOX_ATTRIBUTE);
172
302
  const box = boxMatch ? boxMatch.slice(1, 5).map(Number) : null;
173
- const body = raw.replace(NOISE_ATTRIBUTE, '');
303
+ // A field's printed value goes before anything else reads the line: only
304
+ // a value the DOM confirmed (and never a password) is shown, below.
305
+ const body = TYPED_VALUE_ROLES.has(role) ? withoutPrintedValue(raw.replace(NOISE_ATTRIBUTE, '')) : raw.replace(NOISE_ATTRIBUTE, '');
174
306
  ancestors.push({ depth, role, name });
175
307
  const named = /^[a-z]+ "[^"]*"/.test(body);
176
308
  const inlineText = /^[a-z]+(?: "[^"]*")?(?: \[[^\]]*\])*: \S/.test(body);
@@ -180,11 +312,13 @@ export function compactAriaNodes(text, { viewport = DEFAULT_VIEWPORT } = {}) {
180
312
  const hint = container
181
313
  ? (container.name ? `${container.name}${LANDMARK_LABELS[container.role] && container.role !== 'region' ? ` ${LANDMARK_LABELS[container.role]}` : ''}` : LANDMARK_LABELS[container.role])
182
314
  : null;
315
+ const state = interactive && ref && values && typeof values.get === 'function' && VALUE_ROLES.has(role) ? values.get(ref) : null;
183
316
  kept.push({
184
317
  depth,
185
318
  body,
186
319
  role,
187
320
  name,
321
+ state: state ? formatControlState(state) : null,
188
322
  ref: interactive ? ref : null,
189
323
  centre: interactive ? visibleCentre(box, viewport) : null,
190
324
  hint,
@@ -199,8 +333,15 @@ export function compactAriaNodes(text, { viewport = DEFAULT_VIEWPORT } = {}) {
199
333
  for (const node of kept) {
200
334
  node.duplicate = Boolean(node.key) && counts.get(node.key) > 1;
201
335
  const split = node.body.match(/^([a-z]+(?: "[^"]*")?(?: \[[^\]]*\])*)(.*)$/s) || [null, node.body, ''];
202
- const head = split[1];
203
- const tail = split[2];
336
+ let head = split[1];
337
+ let tail = split[2];
338
+ if (node.state) {
339
+ // The value read from the DOM replaces whatever the snapshot printed
340
+ // after the name and its checked attribute, so there is one answer, and
341
+ // it is the one the page holds.
342
+ head = head.replace(/ \[(?:checked|selected)(?:=[^\]]*)?\]/g, '');
343
+ tail = ` ${node.state}`;
344
+ }
204
345
  const refPart = node.ref ? ` [${node.ref}]` : '';
205
346
  const hintPart = node.duplicate && node.hint ? ` (in ${node.hint})` : '';
206
347
  const centrePart = node.centre ? ` @(${node.centre.x},${node.centre.y})` : '';
@@ -328,8 +469,8 @@ export function collapseRepeatedSiblings(nodes) {
328
469
  * @param {{max?: number, previous?: Set<string>|null}} [options]
329
470
  * @returns {{text: string, lines: Array<string>}|null} null when the text is not an aria snapshot
330
471
  */
331
- export function fitAriaSnapshot(text, { max = WEB_OBSERVATION_MAX_CHARS, previous = null, viewport = DEFAULT_VIEWPORT } = {}) {
332
- const nodes = compactAriaNodes(text, { viewport });
472
+ export function fitAriaSnapshot(text, { max = WEB_OBSERVATION_MAX_CHARS, previous = null, viewport = DEFAULT_VIEWPORT, values = null } = {}) {
473
+ const nodes = compactAriaNodes(text, { viewport, values });
333
474
  if (nodes === null) return null;
334
475
  const lines = nodes.map(node => node.hashLine);
335
476
  const full = nodes.map(node => node.line).join('\n');
@@ -721,6 +862,8 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
721
862
  // compared with the current one before any ref is used.
722
863
  let lastNodesDocument = null;
723
864
  const consoleBuffer = [];
865
+ /** Recent failed requests, newest last, bounded: what a console line is matched against. */
866
+ const networkFailures = [];
724
867
 
725
868
  /** @returns {any} */
726
869
  function requirePage() {
@@ -737,6 +880,7 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
737
880
  const current = requirePage();
738
881
  let text = '';
739
882
  let aria = true;
883
+ let values = null;
740
884
  const body = current.locator('body');
741
885
  // The page level AI mode snapshot carries a ref per element, which
742
886
  // `aria-ref=e12` resolves, and each element's box. Older Playwright
@@ -747,6 +891,10 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
747
891
  } catch {
748
892
  text = '';
749
893
  }
894
+ // What each field, box and select holds now, from the DOM rather than
895
+ // the snapshot's text: the Texas run typed a coach name twice and
896
+ // filed a bug because the observation kept showing the field empty.
897
+ if (text) values = await readControlValues(current, text);
750
898
  }
751
899
  if (!text && typeof body.ariaSnapshot === 'function') {
752
900
  try {
@@ -770,7 +918,7 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
770
918
  // is the honest answer, and the screenshot fallback below covers it.
771
919
  }
772
920
  }
773
- return { text, aria };
921
+ return { text, aria, values };
774
922
  }
775
923
 
776
924
  /**
@@ -783,7 +931,7 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
783
931
  */
784
932
  function shownText(snap) {
785
933
  if (snap.aria) {
786
- const fitted = fitAriaSnapshot(snap.text, { max: WEB_OBSERVATION_MAX_CHARS, previous: lastObservedLines ? new Set(lastObservedLines) : null, viewport: viewportSize });
934
+ const fitted = fitAriaSnapshot(snap.text, { max: WEB_OBSERVATION_MAX_CHARS, previous: lastObservedLines ? new Set(lastObservedLines) : null, viewport: viewportSize, values: snap.values });
787
935
  if (fitted) return fitted;
788
936
  }
789
937
  return { text: truncateObservation(snap.text, WEB_OBSERVATION_MAX_CHARS), lines: null, nodes: null };
@@ -947,9 +1095,36 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
947
1095
  if (typeof context.addInitScript === 'function') await context.addInitScript(markDocument);
948
1096
  page = await context.newPage();
949
1097
  page.on('console', message => {
950
- if (message.type() === 'error') consoleBuffer.push({ type: 'console', text: message.text() });
1098
+ if (message.type() !== 'error') return;
1099
+ let location = null;
1100
+ try {
1101
+ location = typeof message.location === 'function' ? message.location() : null;
1102
+ } catch {
1103
+ location = null;
1104
+ }
1105
+ consoleBuffer.push({ type: 'console', text: message.text(), ...(location && location.url ? { locationUrl: String(location.url) } : {}) });
951
1106
  });
952
1107
  page.on('pageerror', error => consoleBuffer.push({ type: 'pageerror', text: String(error && error.message) }));
1108
+ // The failing request behind a "Failed to load resource" line, which
1109
+ // the console text alone never names.
1110
+ page.on('response', response => {
1111
+ try {
1112
+ const status = response.status();
1113
+ if (status < 400) return;
1114
+ const request = typeof response.request === 'function' ? response.request() : null;
1115
+ rememberNetworkFailure(networkFailures, { url: response.url(), status, method: request && typeof request.method === 'function' ? request.method() : null });
1116
+ } catch {
1117
+ // A response that cannot be read is not worth a note.
1118
+ }
1119
+ });
1120
+ page.on('requestfailed', request => {
1121
+ try {
1122
+ const failure = typeof request.failure === 'function' ? request.failure() : null;
1123
+ rememberNetworkFailure(networkFailures, { url: request.url(), status: null, method: typeof request.method === 'function' ? request.method() : null, error: failure && failure.errorText ? String(failure.errorText) : null });
1124
+ } catch {
1125
+ // Same: nothing to record.
1126
+ }
1127
+ });
953
1128
 
954
1129
  const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
955
1130
  // A 404 is not something to guess around: the build is not there, and
@@ -1110,7 +1285,7 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1110
1285
 
1111
1286
  /** @returns {Promise<Array<{type: string, text: string}>>} */
1112
1287
  async consoleErrors() {
1113
- return consoleBuffer.splice(0, consoleBuffer.length);
1288
+ return attachNetworkFailures(consoleBuffer.splice(0, consoleBuffer.length), networkFailures);
1114
1289
  },
1115
1290
 
1116
1291
  /** @returns {Promise<{videoPath: string|null}>} */
@@ -1143,6 +1318,72 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1143
1318
  };
1144
1319
  }
1145
1320
 
1321
+ /** How many recent failed requests are kept to match console lines against. */
1322
+ export const MAX_NETWORK_FAILURES = 50;
1323
+
1324
+ /** The longest request URL a note carries. */
1325
+ export const MAX_NETWORK_URL_CHARS = 200;
1326
+
1327
+ /**
1328
+ * A request URL as a note carries it: no query string and no fragment (they
1329
+ * carry tokens and session ids as often as anything useful), and bounded.
1330
+ *
1331
+ * @param {string} url
1332
+ * @returns {string}
1333
+ */
1334
+ export function networkUrl(url) {
1335
+ let value = String(url == null ? '' : url);
1336
+ try {
1337
+ const parsed = new URL(value);
1338
+ value = `${parsed.origin === 'null' ? `${parsed.protocol}` : parsed.origin}${parsed.pathname}`;
1339
+ } catch {
1340
+ value = value.split(/[?#]/)[0];
1341
+ }
1342
+ return value.length > MAX_NETWORK_URL_CHARS ? `${value.slice(0, MAX_NETWORK_URL_CHARS - 3)}...` : value;
1343
+ }
1344
+
1345
+ /**
1346
+ * @param {Array<Object>} list
1347
+ * @param {{url: string, status: number|null, method?: string|null, error?: string|null}} failure
1348
+ */
1349
+ export function rememberNetworkFailure(list, failure) {
1350
+ list.push({ url: networkUrl(failure.url), status: Number.isInteger(failure.status) ? failure.status : null, method: failure.method || null, error: failure.error || null, claimed: false });
1351
+ while (list.length > MAX_NETWORK_FAILURES) list.shift();
1352
+ }
1353
+
1354
+ /** The console line a browser prints for a failed request. */
1355
+ const FAILED_RESOURCE = /^Failed to load resource\b/i;
1356
+
1357
+ /**
1358
+ * Give each "Failed to load resource" console line the request it was about:
1359
+ * the one at the line's own location when the browser gave one, otherwise the
1360
+ * oldest unclaimed failure with the same status (or the same network error).
1361
+ * The line's text gains the method, the URL and the status, so the note says
1362
+ * what failed; `url` and `status` ride along for the report.
1363
+ *
1364
+ * @param {Array<{type: string, text: string, locationUrl?: string}>} entries
1365
+ * @param {Array<Object>} failures the driver's recent failed requests
1366
+ * @returns {Array<Object>}
1367
+ */
1368
+ export function attachNetworkFailures(entries, failures) {
1369
+ return entries.map(entry => {
1370
+ const { locationUrl, ...rest } = entry;
1371
+ if (!FAILED_RESOURCE.test(String(entry.text || ''))) return rest;
1372
+ const status = Number((String(entry.text).match(/status of (\d{3})/) || [])[1]) || null;
1373
+ const location = locationUrl ? networkUrl(locationUrl) : null;
1374
+ let match = location ? failures.find(failure => !failure.claimed && failure.url === location) : null;
1375
+ if (!match) {
1376
+ match = failures.find(failure => !failure.claimed && (status ? failure.status === status : failure.status === null && failure.error && String(entry.text).includes(failure.error)));
1377
+ }
1378
+ const url = match ? match.url : location;
1379
+ if (!url) return rest;
1380
+ if (match) match.claimed = true;
1381
+ const shownStatus = match && match.status ? match.status : status;
1382
+ const method = match && match.method ? `${match.method} ` : '';
1383
+ return { ...rest, text: `${rest.text} (${method}${url}${shownStatus ? ` answered ${shownStatus}` : ''})`, url, status: shownStatus || null };
1384
+ });
1385
+ }
1386
+
1146
1387
  /**
1147
1388
  * Playwright names its recording with a random id. The upload allowlist and
1148
1389
  * the server's 90 day lifecycle rule are written against `session.webm`, so
@@ -344,7 +344,8 @@ async function playLoop({
344
344
  onBudgetWarning = null,
345
345
  log = () => {}
346
346
  }) {
347
- const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions });
347
+ const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions, runId });
348
+ const identity = runIdentity({ persona, runId });
348
349
  if (controller === 'jev') system.push({ type: 'text', text: JEV_CONTROLLER_PROMPT });
349
350
  const availableTools = driver.supportedActions
350
351
  ? [...PLAY_TOOLS, ...(driver.supportedActions.includes('swipe') ? [SWIPE_TOOL] : [])].filter(tool => !ACTION_TOOLS.includes(tool.name) || driver.supportedActions.includes(tool.name))
@@ -362,6 +363,9 @@ async function playLoop({
362
363
  }
363
364
  })) : availableTools;
364
365
  const recentActions = [];
366
+ // The observation and action lines of the transcript, kept for the report
367
+ // step's "Actions you took" list.
368
+ const actionRecords = [];
365
369
  let latestObservation = '';
366
370
  let visualObservation = false;
367
371
  const messages = [];
@@ -398,6 +402,7 @@ async function playLoop({
398
402
  latestObservation = first.text;
399
403
  visualObservation = Boolean(first.screenshotBase64);
400
404
  transcript.write({ kind: 'observation', step: first.step, text: first.text, url: first.url });
405
+ actionRecords.push({ kind: 'observation', text: first.text });
401
406
  const landing = await safeScreenshot(driver, 'landing', screenshots, transcript, observations.length);
402
407
  messages.push({
403
408
  role: 'user',
@@ -508,6 +513,7 @@ async function playLoop({
508
513
  driver,
509
514
  toolUse,
510
515
  staleRefs: screenChangedThisTurn,
516
+ identity,
511
517
  onNote: note => {
512
518
  // The note is nested rather than spread: its own `kind`
513
519
  // (bug, confusing, good, suggestion, reaction) would otherwise
@@ -524,7 +530,7 @@ async function playLoop({
524
530
  results.push(executed.result);
525
531
  if (executed.spentAction) actions += 1;
526
532
  if (executed.changed && ACTION_TOOLS.includes(toolUse.name)) screenChangedThisTurn = true;
527
- transcript.write({
533
+ const actionLine = {
528
534
  kind: 'action',
529
535
  tool: toolUse.name,
530
536
  input: redactToolInput(toolUse),
@@ -533,7 +539,9 @@ async function playLoop({
533
539
  error: executed.error,
534
540
  changed: executed.changed,
535
541
  actions
536
- });
542
+ };
543
+ transcript.write(actionLine);
544
+ actionRecords.push(actionLine);
537
545
  if (executed.driverError) {
538
546
  // The driver failed, not the game: a target that matched twice,
539
547
  // locator code, a ref not on screen, a timeout on an unchanged page.
@@ -551,6 +559,7 @@ async function playLoop({
551
559
  recentActions.push(`${toolUse.name}: ${executed.changed ? 'screen changed' : 'no observed change'}`);
552
560
  if (recentActions.length > 10) recentActions.shift();
553
561
  transcript.write({ kind: 'observation', step: executed.observation.step, text: executed.observation.text, url: executed.observation.url });
562
+ actionRecords.push({ kind: 'observation', text: executed.observation.text });
554
563
  // Only an input to the game can tell us anything about being stuck. A
555
564
  // `snapshot` or a `screenshot` never changes the screen by design, so
556
565
  // counting those would have a persona that looks twice before acting
@@ -580,7 +589,14 @@ async function playLoop({
580
589
  const shot = await safeScreenshot(driver, 'console-error', screenshots, transcript, observations.length);
581
590
  for (const entry of consoleErrors) {
582
591
  const kind = RESOURCE_4XX.test(String(entry.text)) ? 'network' : 'bug';
583
- const note = { kind, text: `${entry.type}: ${entry.text}`, automatic: true, ...(shot ? { screenshot: shot.relativePath } : {}) };
592
+ const note = {
593
+ kind,
594
+ text: `${entry.type}: ${entry.text}`,
595
+ automatic: true,
596
+ ...(entry.url ? { url: String(entry.url).slice(0, 200) } : {}),
597
+ ...(entry.url && Number.isInteger(entry.status) ? { status: entry.status } : {}),
598
+ ...(shot ? { screenshot: shot.relativePath } : {})
599
+ };
584
600
  noted(note);
585
601
  transcript.write({ kind: 'note', note });
586
602
  }
@@ -631,7 +647,8 @@ async function playLoop({
631
647
  budgetExceeded,
632
648
  canceled,
633
649
  imageBudget,
634
- finalScreenshot: final ? final.relativePath : null
650
+ finalScreenshot: final ? final.relativePath : null,
651
+ actionLog: actionLogFrom(actionRecords)
635
652
  };
636
653
  }
637
654
 
@@ -661,7 +678,7 @@ async function reportPhase({
661
678
  endedAt,
662
679
  log
663
680
  }) {
664
- const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions: play.actionsTaken });
681
+ const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions: play.actionsTaken, runId });
665
682
  const messages = [{
666
683
  role: 'user',
667
684
  content: [{
@@ -681,6 +698,14 @@ async function reportPhase({
681
698
  'Observations the persona was shown, by index:',
682
699
  observationLines(observations),
683
700
  '',
701
+ 'Actions you took (every action that went through, numbered by action; "screen changed" means the screen after it differed from the screen before):',
702
+ actionLogLines(play.actionLog),
703
+ '',
704
+ 'A claim in report.md or report.json that something was never done, never worked, did nothing or could not be done must agree with this list. If the list shows you clicked a control and the screen changed, do not write that you never clicked it or that it did nothing; describe what the screen showed after it instead. The runner downgrades a finding that says otherwise.',
705
+ '',
706
+ 'Network notes (failed requests the runner recorded, with the URL and the status; list them under a "Network notes" heading in report.md when there are any):',
707
+ networkNoteLines(notes),
708
+ '',
684
709
  'Each note carries "after": the URL and title of the observation that followed it. A blocker or major finding must cite the observation index where the failure is visible, as evidence {"type": "transcript_step", "ref": "observation N"}, and must not be contradicted by its note\'s "after" state or by a later persona note: if the next screen or a later note shows the game working, it is not a blocker or a major. Cite a note as {"type": "transcript_step", "ref": "note N"}.',
685
710
  'A blocker or major finding must also cite the screenshot taken at or after that observation (the list below says which observation each one followed), and that screenshot must not contradict it: if it shows the thing the finding says is missing or broken, such as the field or the button, it is not a blocker or a major. A text observation can be cut short on a long page; the screenshot is what the player saw.',
686
711
  '',
@@ -707,6 +732,7 @@ async function reportPhase({
707
732
  let repaired = false;
708
733
  let continued = false;
709
734
  let droppedByRunner = [];
735
+ let downgradedByRunner = [];
710
736
 
711
737
  // One repair at most when the schema or the evidence refs refuse what was
712
738
  // written: each turn is a metered question, and the dogfood gate asks for
@@ -809,11 +835,19 @@ async function reportPhase({
809
835
  }
810
836
  const capped = normalised ? capNetworkOnlyFindings(stampReport(normalised.report, facts), { notes, screenshots }) : null;
811
837
  const runnerDrop = capped ? dropRunnerRejectedFindings(capped, { notes, observations }) : { report: null, dropped: [] };
812
- const candidate = runnerDrop.report;
813
838
  for (const entry of runnerDrop.dropped) {
814
839
  transcript.write({ kind: 'finding_dropped', id: entry.finding.id, severity: entry.finding.severity, title: entry.finding.title, reason: entry.reason });
815
840
  }
816
841
  droppedByRunner = runnerDrop.dropped;
842
+ const contradicted = runnerDrop.report ? downgradeContradictedFindings(runnerDrop.report, play.actionLog) : { report: null, downgraded: [] };
843
+ const candidate = contradicted.report;
844
+ for (const entry of contradicted.downgraded) {
845
+ transcript.write({ kind: 'finding_downgraded', id: entry.id, from: entry.from, to: entry.to, control: entry.control, action: entry.action });
846
+ }
847
+ downgradedByRunner = contradicted.downgraded;
848
+ if (contradicted.downgraded.length > 0) {
849
+ log(`downgraded ${contradicted.downgraded.length} finding(s) the action log contradicts: ${contradicted.downgraded.map(entry => `${entry.id} (${entry.from} to ${entry.to}, ${entry.control} clicked at step ${entry.action})`).join(', ')}`);
850
+ }
817
851
  if (candidate) lastCandidate = candidate;
818
852
  const validation = candidate ? validator.validate(candidate) : { ok: false, details: [{ instancePath: '/', message: parseError || 'report.json was not written' }] };
819
853
  const evidenceProblems = candidate ? await checkEvidence(candidate, runDirectory) : [];
@@ -879,6 +913,8 @@ async function reportPhase({
879
913
  markdown = rewriteMarkdown(markdown, droppedByRunner.map(entry => entry.finding), report.findings || [], RUNNER_DROPPED_SECTION);
880
914
  log(`dropped ${droppedByRunner.length} finding(s) the runner's evidence rules refuse: ${droppedByRunner.map(entry => `${entry.finding.id} (${entry.reason})`).join(', ')}`);
881
915
  }
916
+ // The uploaded pair agree: report.md says the severity report.json does.
917
+ if (downgradedByRunner.length > 0) markdown = rewriteDowngradedMarkdown(markdown, downgradedByRunner);
882
918
  const generatedAt = new Date().toISOString();
883
919
  const facts = reportFacts({ persona, driverName, target, play });
884
920
  markdown = stampGeneratedAt(markdown, generatedAt, facts);
@@ -988,7 +1024,8 @@ async function callWithImageRetry({ imageBudget, keepObservations, messages, onI
988
1024
  * product where the same few thousand tokens are re-sent tens of times. The
989
1025
  * proxy allowlists `system` and passes the blocks through unchanged.
990
1026
  */
991
- export function buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions }) {
1027
+ export function buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions, runId = null }) {
1028
+ const identity = runIdentity({ persona, runId });
992
1029
  return [
993
1030
  { type: 'text', text: skill },
994
1031
  { type: 'text', text: `<persona_file>\n${persona.body}\n</persona_file>` },
@@ -999,13 +1036,55 @@ export function buildSystem({ skill, persona, brief, intentNotes, driverName, ta
999
1036
  `target: ${target}`,
1000
1037
  `max_actions: ${maxActions}`,
1001
1038
  `<expectations_brief>\n${briefForPrompt(brief)}\n</expectations_brief>`,
1002
- `<design_intent_notes>\n${intentNotesForPrompt(brief, intentNotes)}\n</design_intent_notes>`
1039
+ `<design_intent_notes>\n${intentNotesForPrompt(brief, intentNotes)}\n</design_intent_notes>`,
1040
+ ...(identity ? [`<run_identity>\n${runIdentityForPrompt(identity)}\n</run_identity>`] : [])
1003
1041
  ].join('\n'),
1004
1042
  cache_control: { type: 'ephemeral' }
1005
1043
  }
1006
1044
  ];
1007
1045
  }
1008
1046
 
1047
+ /**
1048
+ * The run id cut to something short enough for an email or a display name:
1049
+ * the last eight letters and digits after the `pt_run_` prefix.
1050
+ *
1051
+ * @param {string} runId
1052
+ * @returns {string}
1053
+ */
1054
+ export function shortRunId(runId) {
1055
+ const compact = String(runId == null ? '' : runId).replace(/^pt_run_/, '').toLowerCase().replace(/[^a-z0-9]/g, '');
1056
+ return compact.slice(-8);
1057
+ }
1058
+
1059
+ /**
1060
+ * A fresh identity for every run, so a sign-up form never sees an email an
1061
+ * earlier run already registered. The Texas round lost actions to a 409 on
1062
+ * sign-up from a reused address. `example.test` is a reserved name that never
1063
+ * delivers mail.
1064
+ *
1065
+ * @param {{persona: {slug?: string, name?: string}, runId: string}} args
1066
+ * @returns {{email: string, displayName: string, short: string}|null}
1067
+ */
1068
+ export function runIdentity({ persona, runId }) {
1069
+ const short = shortRunId(runId);
1070
+ if (!short) return null;
1071
+ const slug = String((persona && persona.slug) || 'player').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'player';
1072
+ const name = String((persona && (persona.name || persona.slug)) || 'Player').replace(/\s+/g, ' ').trim().slice(0, 40) || 'Player';
1073
+ return { email: `persona-${slug}-${short}@example.test`, displayName: `${name} ${short}`, short };
1074
+ }
1075
+
1076
+ /**
1077
+ * @param {{email: string, displayName: string}} identity
1078
+ * @returns {string}
1079
+ */
1080
+ export function runIdentityForPrompt(identity) {
1081
+ return [
1082
+ `email: ${identity.email}`,
1083
+ `display_name: ${identity.displayName}`,
1084
+ 'Use this email and this display name whenever a form asks for an email, a name, a username or a coach or player name, unless the brief says otherwise. They are new for this run, so an account made with them does not exist yet. Never reuse an email from an earlier run.'
1085
+ ].join('\n');
1086
+ }
1087
+
1009
1088
  /**
1010
1089
  * The brief as the persona skill expects it: the `fields` object the
1011
1090
  * expectations-brief schema describes, with the version alongside so a report
@@ -1143,6 +1222,7 @@ async function finish(outcome, ledger, runDirectory, transcript, heartbeat) {
1143
1222
  export function renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason }) {
1144
1223
  const good = notes.filter(note => note.kind === 'good');
1145
1224
  const bad = notes.filter(note => note.kind === 'bug' || note.kind === 'confusing');
1225
+ const network = notes.filter(note => note.automatic && note.url);
1146
1226
  const list = entries => (entries.length > 0
1147
1227
  ? entries.map(note => `- **${note.kind}${note.severity ? `/${note.severity}` : ''}**: ${note.text}`).join('\n')
1148
1228
  : '- Nothing was logged.');
@@ -1170,6 +1250,7 @@ export function renderFallbackMarkdown({ persona, driverName, target, notes, scr
1170
1250
  '## Suggested next step',
1171
1251
  'Run this persona again once the reason above is resolved.',
1172
1252
  '',
1253
+ ...(network.length > 0 ? ['## Network notes', network.map(note => `- ${note.status ? `${note.status} ` : ''}${note.url}`).join('\n'), ''] : []),
1173
1254
  '## Evidence',
1174
1255
  screenshots.length > 0 ? screenshots.map(shot => `- ${shot.relativePath}`).join('\n') : '- (none)',
1175
1256
  ''
@@ -1425,6 +1506,273 @@ export function dropRunnerRejectedFindings(report, { notes = [], observations =
1425
1506
  return { report: dropped.length > 0 ? { ...report, findings } : report, dropped };
1426
1507
  }
1427
1508
 
1509
+ /** The most actions the report prompt lists; the newest are kept. */
1510
+ export const MAX_ACTIONS_IN_REPORT = 60;
1511
+
1512
+ /** The longest typed text an action line repeats. */
1513
+ const MAX_TYPED_CHARS = 40;
1514
+
1515
+ /**
1516
+ * The names of the refs a compacted snapshot shows: `- button "Start" [e12]`
1517
+ * gives e12 -> button "Start".
1518
+ *
1519
+ * @param {string} text
1520
+ * @returns {Map<string, {role: string, name: string}>}
1521
+ */
1522
+ export function refNamesFrom(text) {
1523
+ const names = new Map();
1524
+ for (const line of String(text == null ? '' : text).split('\n')) {
1525
+ const match = line.match(/^\s*- ([a-z]+) "([^"]*)"(?: \[[^\]]*\])*? \[(e\d+)\]/);
1526
+ if (match && match[2]) names.set(match[3], { role: match[1], name: match[2] });
1527
+ }
1528
+ return names;
1529
+ }
1530
+
1531
+ /**
1532
+ * Every action that went through, from the transcript's observation and
1533
+ * action lines, with the name of what it targeted when the screen before it
1534
+ * showed one.
1535
+ *
1536
+ * @param {Array<Object>} records transcript lines of kind observation and action, in order
1537
+ * @returns {Array<{action: number, kind: string, target: string, name: string|null, ref: string|null, changed: boolean}>}
1538
+ */
1539
+ export function actionLogFrom(records) {
1540
+ const log = [];
1541
+ let names = new Map();
1542
+ // Observations are numbered as the report prompt numbers them: one per
1543
+ // observation line, in order.
1544
+ let observation = 0;
1545
+ let previousText = '';
1546
+ let pending = null;
1547
+ for (const record of Array.isArray(records) ? records : []) {
1548
+ if (!record) continue;
1549
+ if (record.kind === 'observation') {
1550
+ observation += 1;
1551
+ const text = String(record.text == null ? '' : record.text);
1552
+ if (pending) {
1553
+ pending.observationAfter = observation;
1554
+ pending.introducedError = introducesError(previousText, text);
1555
+ pending = null;
1556
+ }
1557
+ previousText = text;
1558
+ const next = refNamesFrom(text);
1559
+ // An unchanged marker or a canvas shows no refs; the last names stand.
1560
+ if (next.size > 0) names = next;
1561
+ continue;
1562
+ }
1563
+ if (record.kind !== 'action') continue;
1564
+ pending = null;
1565
+ if (!record.ok || !ACTION_TOOLS.includes(record.tool)) continue;
1566
+ const input = record.input || {};
1567
+ const ref = typeof input.ref === 'string' && input.ref.trim() ? input.ref.trim() : null;
1568
+ const known = ref ? names.get(ref) : null;
1569
+ let name = known ? known.name : null;
1570
+ let target;
1571
+ if (record.tool === 'key') target = `key ${String(input.key || '')}`;
1572
+ else if (record.tool === 'navigate') target = String(input.url || '');
1573
+ else if (record.tool === 'swipe') target = `from (${input.x},${input.y}) to (${input.toX},${input.toY})`;
1574
+ else {
1575
+ const named = typeof input.target === 'string' && input.target.trim() ? input.target.trim() : null;
1576
+ if (!name && named) {
1577
+ const roleAndName = named.match(/^[a-z]+\s+"([^"]*)"$/);
1578
+ name = roleAndName ? roleAndName[1] : named;
1579
+ }
1580
+ const where = known ? `${known.role} "${known.name}" [${ref}]` : ref ? `[${ref}]` : named || (Number.isFinite(Number(input.x)) && Number.isFinite(Number(input.y)) ? `(${input.x},${input.y})` : 'the focused element');
1581
+ if (record.tool === 'type') {
1582
+ const text = String(input.text == null ? '' : input.text);
1583
+ target = `${JSON.stringify(text.length > MAX_TYPED_CHARS ? `${text.slice(0, MAX_TYPED_CHARS - 3)}...` : text)} into ${where}${input.replace ? ' (replacing)' : ''}${input.submit ? ' then Enter' : ''}`;
1584
+ } else {
1585
+ target = where;
1586
+ }
1587
+ }
1588
+ const entry = { action: Number(record.actions) || log.length + 1, kind: record.tool, target, name, ref, changed: Boolean(record.changed), observationBefore: observation, observationAfter: null, introducedError: false };
1589
+ log.push(entry);
1590
+ pending = entry;
1591
+ }
1592
+ return log;
1593
+ }
1594
+
1595
+ /** A line that reads as an error: an alert or a dialog, or error wording. */
1596
+ const ERROR_LINE = /^\s*- (?:alert|alertdialog|dialog)\b|\b(?:error|errors|failed|invalid)\b/i;
1597
+
1598
+ /**
1599
+ * Whether the screen after an action shows an alert, a dialog or error
1600
+ * wording that the screen before it did not: a change, but not the one the
1601
+ * persona wanted.
1602
+ *
1603
+ * @param {string} before
1604
+ * @param {string} after
1605
+ * @returns {boolean}
1606
+ */
1607
+ export function introducesError(before, after) {
1608
+ const seen = new Set(String(before || '').split('\n').map(line => line.trim()));
1609
+ return String(after || '').split('\n').some(line => !seen.has(line.trim()) && ERROR_LINE.test(line));
1610
+ }
1611
+
1612
+ /**
1613
+ * The action log as the report prompt's numbered list, bounded to the newest
1614
+ * {@link MAX_ACTIONS_IN_REPORT}.
1615
+ *
1616
+ * @param {Array<Object>} log
1617
+ * @returns {string}
1618
+ */
1619
+ export function actionLogLines(log) {
1620
+ if (!Array.isArray(log) || log.length === 0) return '(none)';
1621
+ const shown = log.slice(-MAX_ACTIONS_IN_REPORT);
1622
+ const lines = shown.map(entry => `${entry.action}. ${entry.kind} ${entry.target}: ${entry.changed ? 'screen changed' : 'no change'}`);
1623
+ if (shown.length < log.length) lines.unshift(`(the first ${log.length - shown.length} are left out)`);
1624
+ return lines.join('\n');
1625
+ }
1626
+
1627
+ /**
1628
+ * The failed requests the runner recorded, one line each, for the report
1629
+ * prompt's network notes.
1630
+ *
1631
+ * @param {Array<Object>} notes
1632
+ * @returns {string}
1633
+ */
1634
+ export function networkNoteLines(notes) {
1635
+ const lines = [];
1636
+ (notes || []).forEach((note, index) => {
1637
+ if (!note || !note.automatic || !note.url) return;
1638
+ lines.push(`- note ${index + 1}: ${note.status ? `${note.status} ` : ''}${note.url}`);
1639
+ });
1640
+ return lines.length > 0 ? lines.join('\n') : '(none)';
1641
+ }
1642
+
1643
+ /**
1644
+ * The "nothing happens" family: wording that says a control was used and
1645
+ * produced nothing. Deliberately narrow; "cannot" or "fails" can be true of a
1646
+ * control the persona clicked.
1647
+ */
1648
+ const NOTHING_HAPPENED = /\b(?:did nothing|does nothing|never kicked off|never started|no response|nothing happened)\b/i;
1649
+
1650
+ /** One step down the severity scale. Cosmetic and praise are never touched. */
1651
+ const DOWNGRADE = Object.freeze({ blocker: 'major', major: 'minor', minor: 'cosmetic' });
1652
+
1653
+ /**
1654
+ * Whether a text names a control as a whole word, ignoring case: "Play"
1655
+ * matches "press Play" and never "Players".
1656
+ *
1657
+ * @param {string} text
1658
+ * @param {string} name
1659
+ * @returns {boolean}
1660
+ */
1661
+ export function namesControl(text, name) {
1662
+ const value = String(name || '').trim();
1663
+ if (value.length < 3) return false;
1664
+ const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1665
+ return new RegExp(`(?<![\\p{L}\\p{N}])${escaped}(?![\\p{L}\\p{N}])`, 'iu').test(String(text || ''));
1666
+ }
1667
+
1668
+ /**
1669
+ * Downgrade, never drop, a finding the action log contradicts. All of these
1670
+ * have to hold, or the severity is left alone:
1671
+ *
1672
+ * - its title or description is in the "nothing happens" family ("did
1673
+ * nothing", "does nothing", "never kicked off", "never started", "no
1674
+ * response", "nothing happened") and names a control, whole word, of at
1675
+ * least three characters;
1676
+ * - the most recent click on that control changed the screen, and the screen
1677
+ * after it gained no alert, dialog or error wording;
1678
+ * - that click happened at or after the latest observation the finding cites
1679
+ * (a finding citing no observation is left alone).
1680
+ *
1681
+ * The Texas round's report said the season was never kicked off, with the
1682
+ * click on KICK OFF THE SEASON and a changed screen in its own transcript.
1683
+ *
1684
+ * @param {Object} report
1685
+ * @param {Array<Object>} log from {@link actionLogFrom}
1686
+ * @returns {{report: Object, downgraded: Array<{id: string, from: string, to: string, control: string, action: number, note: string}>}}
1687
+ */
1688
+ export function downgradeContradictedFindings(report, log) {
1689
+ if (!report || !Array.isArray(report.findings) || !Array.isArray(log) || log.length === 0) return { report, downgraded: [] };
1690
+ const clicks = log.filter(entry => entry.kind === 'click' && entry.name && entry.name.trim().length >= 3);
1691
+ if (clicks.length === 0) return { report, downgraded: [] };
1692
+ const downgraded = [];
1693
+ const findings = report.findings.map(finding => {
1694
+ if (!finding || !DOWNGRADE[finding.severity]) return finding;
1695
+ const text = `${finding.title || ''}\n${finding.description || ''}`;
1696
+ if (!NOTHING_HAPPENED.test(text)) return finding;
1697
+ const cited = (Array.isArray(finding.evidence) ? finding.evidence : [])
1698
+ .map(item => (item && item.type === 'transcript_step' ? String(item.ref || '').match(/^\s*observation\s*#?\s*(\d+)\s*$/i) : null))
1699
+ .filter(Boolean)
1700
+ .map(match => Number(match[1]));
1701
+ if (cited.length === 0) return finding;
1702
+ const citedLatest = Math.max(...cited);
1703
+ // The controls the finding names, each judged by its most recent click.
1704
+ const named = [...new Set(clicks.map(entry => entry.name.trim().toLowerCase()))]
1705
+ .filter(name => namesControl(text, name));
1706
+ let hit = null;
1707
+ for (const name of named) {
1708
+ const latest = clicks.filter(entry => entry.name.trim().toLowerCase() === name).at(-1);
1709
+ if (!latest.changed || latest.introducedError) continue;
1710
+ if (!Number.isInteger(latest.observationBefore) || latest.observationBefore < citedLatest) continue;
1711
+ hit = latest;
1712
+ break;
1713
+ }
1714
+ if (!hit) return finding;
1715
+ const to = DOWNGRADE[finding.severity];
1716
+ const note = `the action log shows ${hit.name.trim()} clicked at step ${hit.action} with the screen changing`;
1717
+ const description = String(finding.description || '');
1718
+ downgraded.push({ id: finding.id, from: finding.severity, to, control: hit.name.trim(), action: hit.action, note });
1719
+ return { ...finding, severity: to, description: description ? `${description}\n\nDowngraded by the runner: ${note}.` : `Downgraded by the runner: ${note}.` };
1720
+ });
1721
+ return { report: downgraded.length > 0 ? { ...report, findings } : report, downgraded };
1722
+ }
1723
+
1724
+ /** How report.md lists the findings the runner downgraded. */
1725
+ export const DOWNGRADED_HEADING = '## Downgraded by the runner (the action log contradicts the claim)';
1726
+
1727
+ /**
1728
+ * report.md agreeing with report.json about downgraded findings: on every
1729
+ * line that names a downgraded finding's id, its old severity word becomes
1730
+ * the new one, and a section lists each downgrade with the runner's note,
1731
+ * replacing one written before.
1732
+ *
1733
+ * @param {string} markdown
1734
+ * @param {Array<{id: string, from: string, to: string, note: string}>} downgraded
1735
+ * @returns {string}
1736
+ */
1737
+ export function rewriteDowngradedMarkdown(markdown, downgraded) {
1738
+ if (!Array.isArray(downgraded) || downgraded.length === 0) return markdown;
1739
+ const text = String(markdown || '');
1740
+ const at = text.indexOf(`\n${DOWNGRADED_HEADING}`);
1741
+ let body = (at === -1 ? (text.startsWith(DOWNGRADED_HEADING) ? '' : text) : text.slice(0, at)).replace(/\s+$/, '');
1742
+ const recase = (word, to) => (word === word.toUpperCase() ? to.toUpperCase() : word[0] === word[0].toUpperCase() ? to[0].toUpperCase() + to.slice(1) : to);
1743
+ const escape = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1744
+ const namesId = (line, id) => new RegExp(`(^|[^A-Za-z0-9])${escape(id)}(?![0-9])`).test(line);
1745
+ // The finding a "Severity:" line belongs to: the one named by the nearest
1746
+ // heading or list item above it. A heading naming none ends it.
1747
+ let current = null;
1748
+ body = body.split('\n').map(line => {
1749
+ let out = line;
1750
+ const named = downgraded.find(entry => entry.id && namesId(line, String(entry.id)));
1751
+ if (named) current = named;
1752
+ else if (/^#{1,6} /.test(line) || /^\s*(?:[-*+]|\d+[.)])\s+\S*\b[A-Z]\d{2,}\b/.test(line)) current = null;
1753
+ for (const entry of downgraded) {
1754
+ const id = String(entry.id || '');
1755
+ if (!id) continue;
1756
+ // Only the severity slot the writer labels: right after the id in
1757
+ // parentheses or brackets ("F001 (major)", "**F001 [Major, bug]**"), or
1758
+ // after "Severity:" within that finding. A title that happens to
1759
+ // contain the word stays as written.
1760
+ if (namesId(out, id)) {
1761
+ out = out.replace(new RegExp(`(${escape(id)}\\**\\s*[([]\\s*)(${entry.from})\\b`, 'i'), (match, lead, word) => `${lead}${recase(word, entry.to)}`);
1762
+ }
1763
+ if (current === entry) {
1764
+ out = out.replace(new RegExp(`(severity\\**\\s*:\\s*\\**\\s*)(${entry.from})\\b`, 'gi'), (match, lead, word) => `${lead}${recase(word, entry.to)}`);
1765
+ }
1766
+ }
1767
+ return out;
1768
+ }).join('\n');
1769
+ const section = [
1770
+ DOWNGRADED_HEADING,
1771
+ ...downgraded.map(entry => `- ${entry.id}: ${entry.from} to ${entry.to}, because ${entry.note}.`)
1772
+ ].join('\n');
1773
+ return `${body}\n\n${section}\n`;
1774
+ }
1775
+
1428
1776
  export function capNetworkOnlyFindings(report, { notes = [], screenshots = [] } = {}) {
1429
1777
  if (!report || !Array.isArray(report.findings)) return report;
1430
1778
  const byShot = new Map();
package/src/run/tools.js CHANGED
@@ -68,7 +68,7 @@ const note = {
68
68
  export const PLAY_TOOLS = Object.freeze([
69
69
  {
70
70
  name: 'snapshot',
71
- description: 'Look at the current screen. Does not spend an action. Use it after anything that might have changed the game state.',
71
+ description: 'Look at the current screen. Does not spend an action. Use it after anything that might have changed the game state. The result also repeats this run\'s identity (the email and display name to use whenever a form asks for one).',
72
72
  input_schema: {
73
73
  type: 'object',
74
74
  additionalProperties: false,
@@ -234,7 +234,7 @@ export function actionFor(toolUse) {
234
234
  * screen, so a ref in this call was read off a screen that is gone
235
235
  * @returns {Promise<{result: Object, observation: Object|null, spentAction: boolean, changed: boolean, error: string|null}>}
236
236
  */
237
- export async function executeTool({ driver, toolUse, onNote, onScreenshot, staleRefs = false }) {
237
+ export async function executeTool({ driver, toolUse, onNote, onScreenshot, staleRefs = false, identity = null }) {
238
238
  const input = toolUse.input || {};
239
239
  if (toolUse.malformed) {
240
240
  return {
@@ -272,8 +272,13 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot, stale
272
272
 
273
273
  if (toolUse.name === 'snapshot') {
274
274
  const observation = await driver.observe();
275
+ const result = observationResult(toolUse.id, observation);
276
+ // The run's identity rides on the one tool a persona calls to look
277
+ // around: the act step's tool allowlist has no room for a tool of its own.
278
+ const line = identityLine(identity);
279
+ if (line) result.content.push({ type: 'text', text: line });
275
280
  return {
276
- result: observationResult(toolUse.id, observation),
281
+ result,
277
282
  observation,
278
283
  spentAction: false,
279
284
  changed: false,
@@ -351,6 +356,17 @@ export function observationResult(id, observation, prefix = '', isError = false)
351
356
  return { type: 'tool_result', tool_use_id: id, content, ...(isError ? { is_error: true } : {}) };
352
357
  }
353
358
 
359
+ /**
360
+ * The run's identity as one line of a snapshot result, or '' with none.
361
+ *
362
+ * @param {{email?: string, displayName?: string}|null} identity
363
+ * @returns {string}
364
+ */
365
+ export function identityLine(identity) {
366
+ if (!identity || !identity.email) return '';
367
+ return `run identity: email ${identity.email}, display name ${identity.displayName}. Use these whenever a form asks for an email or a name.`;
368
+ }
369
+
354
370
  /**
355
371
  * @param {string} id
356
372
  * @param {string} text