ravensight-playtest 0.2.8 → 0.2.9

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
@@ -254,7 +254,15 @@ How the report step reads what happened:
254
254
  - Every note carries `after`, the URL and title of the screen that followed
255
255
  it. A blocker or major finding has to cite the observation where the failure
256
256
  is visible and must not be contradicted by that next screen or by a later
257
- persona note.
257
+ persona note. It also has to cite the screenshot taken at or after that
258
+ observation (the prompt says which observation each screenshot followed),
259
+ and that screenshot must not show what the finding says is missing.
260
+ - Every evidence entry is `{"type": ..., "ref": ...}`. A screenshot path the
261
+ model put under `path` or `file` is moved to `ref` before the report is
262
+ checked; the file still has to exist in the run directory. If the server
263
+ still refuses `report.json` at `complete`, the CLI prints every validation
264
+ message it sent, the run is not counted as succeeded or in the aggregate,
265
+ and the job exits 1.
258
266
  - The model is told never to write calendar dates. `report.md` carries the
259
267
  CLI's own `Generated at` timestamp under its title, and the aggregate report's
260
268
  header uses its `generated_at`.
@@ -285,6 +293,12 @@ macOS, Windows and Linux; ordinary typing is keystrokes. An editing shortcut
285
293
  such as select all uses the platform's own modifier (Command on a Mac, Control
286
294
  elsewhere, or `--key-modifier`).
287
295
 
296
+ On a long web page the persona's text view of the screen is capped at 4,000
297
+ characters. Over the cap, buttons, links and fields are kept first, a long run
298
+ of look-alike items (a list of 249 cards) is shown as its first two and "and
299
+ 247 more like it", and what changed since the last look comes next, so a form
300
+ after a long list still reaches the persona.
301
+
288
302
  Each persona starts at the game's URL as a fresh player with no account, so a
289
303
  sign-up form with validation can use up a whole unit before the game itself is
290
304
  reached. Point `--build-url` at a guest or demo path, or say where to start in
@@ -440,6 +454,10 @@ lost to it. The CLI lints locally first and feeds any miss back into one repair
440
454
  turn, which costs a model turn; a report written with the four headings from the
441
455
  start avoids that.
442
456
 
457
+ The repository's tests check rendered reports against `test/fixtures/server/`, a
458
+ byte copy of the server's report schema from game_tracker commit 864027e,
459
+ which has to be refreshed by hand whenever the server's schema changes.
460
+
443
461
  ## Exit codes
444
462
 
445
463
  | Code | Meaning |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ravensight-playtest",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
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",
@@ -445,7 +445,10 @@ export async function playJob({
445
445
  const reportBudgetRefused = results.some(result => result.reportStepBudget === 'refused');
446
446
  const reportBudgetDelivered = results.some(result => result.reportStepBudget === 'delivered' || result.reportStepBudget === 'dry_upload');
447
447
  const canceled = results.some(result => result.state === 'canceled');
448
- const anySucceeded = results.some(result => result.state === 'succeeded');
448
+ // A run whose report the server refused played, but the server never
449
+ // completed it: it is not a success, and CI should hear about it.
450
+ const anySucceeded = results.some(result => result.state === 'succeeded' && !result.reportRefused);
451
+ const reportRefused = results.some(result => result.reportRefused);
449
452
 
450
453
  if (finish) {
451
454
  const finished = await api.jobs.finish(gameId, jobId, {
@@ -459,7 +462,7 @@ export async function playJob({
459
462
  log(`Reports: ${directory}`);
460
463
 
461
464
  if (canceled) return EXIT.CANCELED;
462
- if (reportBudgetRefused) return EXIT.FAILED;
465
+ if (reportBudgetRefused || reportRefused) return EXIT.FAILED;
463
466
  if (playBudgetHit) return EXIT.BUDGET;
464
467
  return anySucceeded || reportBudgetDelivered ? EXIT.OK : EXIT.FAILED;
465
468
  }
@@ -620,7 +623,15 @@ export async function playPersonaRun({
620
623
  budget_warned: outcome.budgetWarned
621
624
  });
622
625
 
623
- await deliver({ deps, api, gameId, jobId, runId, directory, outcome, options, log, journal, repoRoot });
626
+ const delivered = {};
627
+ await deliver({ deps, api, gameId, jobId, runId, directory, outcome, options, log, journal, repoRoot, delivery: delivered });
628
+
629
+ if (delivered.reportInvalid) {
630
+ // No synthetic telemetry for findings the server does not hold, and no
631
+ // "succeeded" line for a run the server never completed.
632
+ log(`run ${runId} (${persona.slug}) played ${outcome.actionsTaken} actions (${outcome.quitReason}), but the server refused its report, so the run is not complete.`);
633
+ return { runId, persona: persona.slug, state: outcome.state, quitReason: outcome.quitReason, reportRefused: true };
634
+ }
624
635
 
625
636
  if (outcome.report) {
626
637
  const emitted = await emitRunEvents({
@@ -713,7 +724,7 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
713
724
  const { report: ignored, report_md: alsoIgnored, ...withoutReport } = body;
714
725
  answer = await api.runs.complete(gameId, jobId, runId, withoutReport);
715
726
  }
716
- if (journal) await stateFile.patchRun(repoRoot, journal, runId, { uploaded: true, completed: true });
727
+ if (journal) await stateFile.patchRun(repoRoot, journal, runId, { uploaded: true, completed: true, report_refused: false });
717
728
  if (answer && answer.dropped_findings > 0) {
718
729
  log(`run ${runId}: the server dropped ${answer.dropped_findings} finding(s) with no usable evidence.`);
719
730
  await markDropped({ directory, jobId, runId, repoRoot, outcome, droppedCount: answer.dropped_findings, droppedIds: answer.dropped_finding_ids, options, log });
@@ -728,11 +739,36 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
728
739
  delivery.status = error && error.status;
729
740
  delivery.code = error && error.code;
730
741
  }
742
+ if (error && error.status === 422 && error.code === 'invalid_report') {
743
+ // Every validation message first, one per line, before anything says
744
+ // the run did not complete: the summary line alone ("does not match
745
+ // the playtest report schema") is not something anyone can act on.
746
+ const details = refusalDetails(error);
747
+ log(`run ${runId}: the server refused report.json (${details.length} problem(s)):\n${details.length > 0 ? details.map(line => ` - ${line}`).join('\n') : ' - (the server named no problem)'}`);
748
+ if (delivery) delivery.reportInvalid = true;
749
+ if (journal) await stateFile.patchRun(repoRoot, journal, runId, { report_refused: true });
750
+ log(`run ${runId}: not completed, so the server keeps no findings from it; whether its unit is refunded is decided when the job settles. report.json and the screenshots stay in ${directory}.`);
751
+ return null;
752
+ }
731
753
  log(`run ${runId}: upload or complete was refused (${String(error && error.message)}). The artifacts are in the run directory and "ravensight-playtest resume" can retry it.`);
732
754
  return null;
733
755
  }
734
756
  }
735
757
 
758
+ /**
759
+ * The validation messages a 422 carried, as `pointer: message` lines. Read
760
+ * from `details` (the API client's error) or `body.details` (the envelope).
761
+ *
762
+ * @param {any} error
763
+ * @returns {Array<string>}
764
+ */
765
+ export function refusalDetails(error) {
766
+ const list = Array.isArray(error && error.details) && error.details.length > 0
767
+ ? error.details
768
+ : (error && error.body && Array.isArray(error.body.details) ? error.body.details : []);
769
+ return list.map(detail => `${(detail && detail.instancePath) || '/'}: ${(detail && detail.message) || 'is invalid'}`);
770
+ }
771
+
736
772
  /**
737
773
  * Bring the local report into line with what the server kept, so report.md,
738
774
  * report.json, the aggregate and the telemetry count what the dashboard
@@ -843,7 +879,7 @@ async function aggregatePhase({
843
879
 
844
880
  // The aggregate reads reports off disk, so a resumed job whose earlier runs
845
881
  // were played by a previous process is covered too.
846
- const reports = await loadRunReports(repoRoot, jobId, personaRuns);
882
+ const reports = await loadRunReports(repoRoot, jobId, personaRuns, journal);
847
883
  let aggregate;
848
884
  try {
849
885
  aggregate = await buildAggregate({
@@ -1107,9 +1143,16 @@ export function resolveKeyModifier(value, env = process.env) {
1107
1143
  * from the notes when the money ran out) is loaded as a `budget_exceeded`
1108
1144
  * run: its findings count, and the aggregate says the job is partial.
1109
1145
  */
1110
- export async function loadRunReports(repoRoot, jobId, personaRuns) {
1146
+ export async function loadRunReports(repoRoot, jobId, personaRuns, journal = null) {
1111
1147
  const out = [];
1112
1148
  for (const entry of personaRuns) {
1149
+ // A report the server refused is on disk but not in the record: the
1150
+ // summary must not count findings the dashboard does not have.
1151
+ const recorded = journal && journal.runs ? journal.runs[entry.run_id] : null;
1152
+ if (recorded && recorded.report_refused === true) {
1153
+ out.push({ runId: entry.run_id, persona: entry.persona, state: 'failed', report: null, reportRefused: true });
1154
+ continue;
1155
+ }
1113
1156
  const file = path.join(runDirFor(repoRoot, jobId, entry.run_id), RUN_FILES.reportJson);
1114
1157
  try {
1115
1158
  const report = JSON.parse(await readFile(file, 'utf8'));
@@ -77,9 +77,22 @@ const INTERACTIVE_ROLES = new Set([
77
77
  * @returns {string}
78
78
  */
79
79
  export function compactAriaSnapshot(text) {
80
+ const nodes = compactAriaNodes(text);
81
+ return nodes === null ? String(text == null ? '' : text) : nodes.map(node => node.line).join('\n');
82
+ }
83
+
84
+ /**
85
+ * The kept nodes of an aria snapshot with the depth each one had, or null for
86
+ * text that is not an aria snapshot. The depth is not shown to the model; it
87
+ * is what tells repeated siblings apart from a panel that follows them.
88
+ *
89
+ * @param {string} text
90
+ * @returns {Array<{depth: number, line: string, body: string, role: string}>|null}
91
+ */
92
+ export function compactAriaNodes(text) {
80
93
  const value = String(text == null ? '' : text);
81
94
  const lines = value.split('\n');
82
- if (!lines.some(line => /^\s*- /.test(line))) return value;
95
+ if (!lines.some(line => /^\s*- /.test(line))) return null;
83
96
  const kept = [];
84
97
  for (const line of lines) {
85
98
  const trimmed = line.trim();
@@ -89,9 +102,163 @@ export function compactAriaSnapshot(text) {
89
102
  const role = (body.match(/^([a-z]+)/) || [])[1] || '';
90
103
  const named = /^[a-z]+ "[^"]*"/.test(body);
91
104
  const inlineText = /^[a-z]+(?: "[^"]*")?(?: \[[^\]]*\])*: \S/.test(body);
92
- if (INTERACTIVE_ROLES.has(role) || named || inlineText) kept.push(`- ${body}`);
105
+ if (INTERACTIVE_ROLES.has(role) || named || inlineText) {
106
+ kept.push({ depth: line.length - line.trimStart().length, line: `- ${body}`, body, role });
107
+ }
108
+ }
109
+ return kept;
110
+ }
111
+
112
+ /** Roles a player types into or sets, kept first when an observation has to be cut. */
113
+ const INPUT_ROLES = new Set(['textbox', 'searchbox', 'combobox', 'checkbox', 'radio', 'slider', 'spinbutton', 'switch', 'listbox']);
114
+
115
+ /** The share of interactive nodes, counted from the end of the page, kept ahead of the middle when a page is cut. */
116
+ export const TAIL_FRACTION = 0.1;
117
+
118
+ /** The tail window is never smaller than this many interactive nodes. */
119
+ const TAIL_MIN = 3;
120
+
121
+ /** A run of this many look-alike siblings or more is collapsed when the page is over the cap. */
122
+ export const COLLAPSE_MIN_RUN = 4;
123
+
124
+ /** How many of a collapsed run are still shown, so the model sees what the items look like. */
125
+ export const COLLAPSE_KEEP = 2;
126
+
127
+ /**
128
+ * The shape of one node, without the words that make it unique: its role,
129
+ * its state attributes, whether it has a name, whether the name carries
130
+ * digits and how many separators. Two school cards share a shape; the card
131
+ * list and a "TAKE THE JOB" button after it do not.
132
+ *
133
+ * @param {string} body
134
+ * @returns {string}
135
+ */
136
+ function nodeShape(body) {
137
+ const role = (body.match(/^([a-z]+)/) || [])[1] || '';
138
+ const name = (body.match(/^[a-z]+ "([^"]*)"/) || [])[1];
139
+ const attributes = [...body.matchAll(/\[([a-z-]+)(?:=[^\]]*)?\]/g)].map(match => match[1]).filter(key => key !== 'ref').join(',');
140
+ const nameShape = name === undefined ? '' : `n${/\d/.test(name) ? 'd' : ''}${(name.match(/[·•|/]/g) || []).length}`;
141
+ const inline = /: \S/.test(body.replace(/^[a-z]+(?: "[^"]*")?/, '')) ? 't' : '';
142
+ return `${role}|${nameShape}|${attributes}|${inline}`;
143
+ }
144
+
145
+ /**
146
+ * Collapse runs of look-alike siblings (a node and its kept descendants,
147
+ * compared by shape) into the first {@link COLLAPSE_KEEP} plus one line that
148
+ * says how many more there are.
149
+ *
150
+ * @param {Array<{depth: number, line: string, body: string, role: string}>} nodes
151
+ * @returns {{entries: Array<{line: string, role: string, marker?: boolean}>, collapsed: number}}
152
+ */
153
+ export function collapseRepeatedSiblings(nodes) {
154
+ const unitEnd = start => {
155
+ let end = start + 1;
156
+ while (end < nodes.length && nodes[end].depth > nodes[start].depth) end += 1;
157
+ return end;
158
+ };
159
+ const signature = (start, end) => nodes.slice(start, end).map(node => `${node.depth - nodes[start].depth}:${nodeShape(node.body)}`).join(';');
160
+ const entries = [];
161
+ let collapsed = 0;
162
+ let i = 0;
163
+ while (i < nodes.length) {
164
+ const units = [[i, unitEnd(i)]];
165
+ const sig = signature(i, units[0][1]);
166
+ let next = units[0][1];
167
+ while (next < nodes.length && nodes[next].depth === nodes[i].depth) {
168
+ const end = unitEnd(next);
169
+ if (signature(next, end) !== sig) break;
170
+ units.push([next, end]);
171
+ next = end;
172
+ }
173
+ if (units.length >= COLLAPSE_MIN_RUN) {
174
+ for (const [start, end] of units.slice(0, COLLAPSE_KEEP)) {
175
+ for (const node of nodes.slice(start, end)) entries.push({ line: node.line, role: node.role });
176
+ }
177
+ const more = units.length - COLLAPSE_KEEP;
178
+ collapsed += more;
179
+ entries.push({ line: `- ... and ${more} more like it (${nodes[i].role || 'items'}; target one by its name)`, role: '', marker: true });
180
+ i = next;
181
+ } else {
182
+ entries.push({ line: nodes[i].line, role: nodes[i].role });
183
+ i += 1;
184
+ }
93
185
  }
94
- return kept.join('\n');
186
+ return { entries, collapsed };
187
+ }
188
+
189
+ /**
190
+ * Fit a compacted aria snapshot under the web ceiling without losing the
191
+ * part of the page the persona has to act on.
192
+ *
193
+ * Under the cap nothing changes. Over it, in this order: interactive nodes
194
+ * are what is kept (the last tenth of them on the page first, since that is
195
+ * where a form after a long list sits, then inputs, then buttons and links,
196
+ * then any role with a name); runs of look-alike siblings collapse to their
197
+ * first two and a count; nodes that are new since the previous observation
198
+ * come next; then
199
+ * the rest, and only what still does not fit is trimmed. Kept lines stay in
200
+ * page order, and a first line says how many items were collapsed and how
201
+ * many lines were left out, so the model knows the screen was cut.
202
+ *
203
+ * @param {string} text the raw aria snapshot
204
+ * @param {{max?: number, previous?: Set<string>|null}} [options]
205
+ * @returns {{text: string, lines: Array<string>}|null} null when the text is not an aria snapshot
206
+ */
207
+ export function fitAriaSnapshot(text, { max = WEB_OBSERVATION_MAX_CHARS, previous = null } = {}) {
208
+ const nodes = compactAriaNodes(text);
209
+ if (nodes === null) return null;
210
+ const lines = nodes.map(node => node.line);
211
+ const full = lines.join('\n');
212
+ if (full.length <= max) return { text: full, lines };
213
+
214
+ const { entries, collapsed } = collapseRepeatedSiblings(nodes);
215
+ const header = (left, out) => {
216
+ const parts = [];
217
+ if (left > 0) parts.push(`${left} similar items collapsed`);
218
+ if (out > 0) parts.push(`${out} lines left out to fit`);
219
+ return parts.length > 0 ? `[${parts.join('; ')}]` : '';
220
+ };
221
+ const collapsedText = entries.map(entry => entry.line).join('\n');
222
+ const withHeader = `${header(collapsed, 0)}\n${collapsedText}`;
223
+ if (withHeader.length <= max) return { text: withHeader, lines };
224
+
225
+ const rank = entry => {
226
+ if (entry.marker) return 0;
227
+ if (INPUT_ROLES.has(entry.role)) return 1;
228
+ if (entry.role === 'button' || entry.role === 'link') return 2;
229
+ if (INTERACTIVE_ROLES.has(entry.role) || /^- [a-z]+ "/.test(entry.line)) return 3;
230
+ return 5;
231
+ };
232
+ const isNew = entry => Boolean(previous) && !entry.marker && !previous.has(entry.line);
233
+ // The last interactive nodes on the page are usually its form or its
234
+ // confirm button (a panel after a list). When the list could not be
235
+ // collapsed they would otherwise lose to the list on page order, so the
236
+ // tail window, the final tenth of interactive nodes, ranks above the middle.
237
+ const interactive = entries.map((entry, index) => ({ entry, index })).filter(({ entry }) => !entry.marker && rank(entry) < 5);
238
+ const tailSize = Math.max(TAIL_MIN, Math.ceil(interactive.length * TAIL_FRACTION));
239
+ const tail = new Set(interactive.slice(-tailSize).map(({ index }) => index));
240
+ const tierOf = (entry, index) => {
241
+ if (entry.marker) return 0;
242
+ if (tail.has(index)) return 1 + (isNew(entry) ? 0 : 0.5);
243
+ if (rank(entry) < 5) return 2 + rank(entry) * 2 + (isNew(entry) ? 0 : 1);
244
+ return isNew(entry) ? 20 : 21;
245
+ };
246
+ const order = entries
247
+ .map((entry, index) => ({ entry, index, tier: tierOf(entry, index) }))
248
+ .sort((a, b) => a.tier - b.tier || a.index - b.index);
249
+ // Room for the header line, whose figures are not known until the end.
250
+ const budget = max - 80;
251
+ const chosen = new Set();
252
+ let used = 0;
253
+ for (const candidate of order) {
254
+ const cost = candidate.entry.line.length + 1;
255
+ if (used + cost > budget) continue;
256
+ chosen.add(candidate.index);
257
+ used += cost;
258
+ }
259
+ const kept = entries.filter((entry, index) => chosen.has(index)).map(entry => entry.line);
260
+ const out = entries.length - kept.length;
261
+ return { text: truncateObservation(`${header(collapsed, out)}\n${kept.join('\n')}`, max), lines };
95
262
  }
96
263
 
97
264
  /**
@@ -323,6 +490,8 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
323
490
  let shots = 0;
324
491
  let lastHash = null;
325
492
  let lastObservedHash = null;
493
+ /** The compacted lines of the last observation, so a cut can prefer what is new. */
494
+ let lastObservedLines = null;
326
495
  const consoleBuffer = [];
327
496
 
328
497
  /** @returns {any} */
@@ -367,13 +536,33 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
367
536
  }
368
537
 
369
538
  /**
370
- * What the model is shown for a raw snapshot. `observe` and `act` both use
371
- * this, so the two hashes compare like with like.
539
+ * What the model is shown for a raw snapshot, and the lines it was built
540
+ * from. A page over the web ceiling is fitted rather than cut at the
541
+ * character limit (see fitAriaSnapshot), so a panel after a long list
542
+ * still reaches the model.
372
543
  * @param {{text: string, aria: boolean}} snap
373
- * @returns {string}
544
+ * @returns {{text: string, lines: Array<string>|null}}
374
545
  */
375
546
  function shownText(snap) {
376
- return truncateObservation(snap.aria ? compactAriaSnapshot(snap.text) : snap.text, WEB_OBSERVATION_MAX_CHARS);
547
+ if (snap.aria) {
548
+ const fitted = fitAriaSnapshot(snap.text, { max: WEB_OBSERVATION_MAX_CHARS, previous: lastObservedLines ? new Set(lastObservedLines) : null });
549
+ if (fitted) return fitted;
550
+ }
551
+ return { text: truncateObservation(snap.text, WEB_OBSERVATION_MAX_CHARS), lines: null };
552
+ }
553
+
554
+ /**
555
+ * What `changed` and `unchanged` compare. For an aria snapshot that is the
556
+ * whole compacted tree, not the fitted text: the fit depends on the
557
+ * previous observation, so hashing it would read the same screen as
558
+ * changed. For the innerText fallback it is the truncated text, so a
559
+ * difference the model never saw is not a change.
560
+ * @param {{text: string, aria: boolean}} snap
561
+ * @param {{text: string, lines: Array<string>|null}} shown
562
+ * @returns {string}
563
+ */
564
+ function hashText(snap, shown) {
565
+ return shown.lines ? shown.lines.join('\n') : shown.text;
377
566
  }
378
567
 
379
568
  /**
@@ -468,9 +657,10 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
468
657
  step += 1;
469
658
  const snap = await snapshotText();
470
659
  const text = snap.text;
660
+ const shown = shownText(snap);
471
661
  const observation = {
472
662
  step,
473
- text: shownText(snap),
663
+ text: shown.text,
474
664
  url: current.url(),
475
665
  elapsedMs: Date.now() - started
476
666
  };
@@ -497,11 +687,11 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
497
687
  // read it as a blank screen, which is what it is.
498
688
  }
499
689
  }
500
- // Hash what was REPORTED, not the untruncated snapshot: `act` compares
501
- // against this, so two screens that differ only past the truncation
502
- // point would otherwise read as changed to `act` and identical to
503
- // anything reading the observation.
504
- lastHash = observationHash(observation);
690
+ // Hash what hashText says, which `act` hashes the same way: the whole
691
+ // compacted tree for an aria snapshot (a change anywhere in it is shown,
692
+ // because a fit keeps new nodes first), the truncated text otherwise.
693
+ lastHash = observationHash({ text: hashText(snap, shown), screenshotBase64: observation.screenshotBase64 });
694
+ lastObservedLines = shown.lines;
505
695
  // The same screen twice is sent as a marker rather than in full
506
696
  // (./tools.js), which is most of what a stuck persona would otherwise
507
697
  // pay for.
@@ -554,10 +744,10 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
554
744
 
555
745
  let changed = false;
556
746
  try {
557
- // The same truncation observe() applies, so the two hashes are
558
- // comparable. Without it a long screen is always "changed".
747
+ // The same basis observe() hashes, so the two are comparable.
748
+ // Without it a long screen is always "changed".
559
749
  const snap = await snapshotText();
560
- const after = { text: shownText(snap) };
750
+ const after = { text: hashText(snap, shownText(snap)) };
561
751
  if (String(snap.text).trim().length < THIN_OBSERVATION_CHARS) {
562
752
  const frame = await current.screenshot({ type: 'png', timeout: ACTION_TIMEOUT_MS });
563
753
  after.screenshotBase64 = frame.toString('base64');
@@ -58,6 +58,11 @@ export function createHeartbeat({
58
58
  let canceled = false;
59
59
  let failures = 0;
60
60
  let inFlight = false;
61
+ // Set by stop(). A beat already on the wire when the run ends must not
62
+ // write its progress after the run's final state: in 0.2.8 one landed
63
+ // after the outcome and put a finished run back to `playing` in the
64
+ // journal.
65
+ let stopped = false;
61
66
  // The progress the loop last reported. The interval beat carries it too,
62
67
  // rather than resetting the server's and the journal's idea of the
63
68
  // checkpoint to zero every five seconds.
@@ -78,7 +83,7 @@ export function createHeartbeat({
78
83
  try {
79
84
  const answer = await api.runs.heartbeat(gameId, jobId, runId, { heartbeat: true, ...progress });
80
85
  failures = 0;
81
- if (onBeat) await onBeat({ ...progress });
86
+ if (onBeat && !stopped) await onBeat({ ...progress });
82
87
  if (answer && answer.run && answer.run.usage && onUsage) onUsage(answer.run.usage);
83
88
  if (answer && answer.cancel_requested && !canceled) {
84
89
  canceled = true;
@@ -111,6 +116,7 @@ export function createHeartbeat({
111
116
  },
112
117
 
113
118
  start() {
119
+ stopped = false;
114
120
  if (timer) return this;
115
121
  timer = setIntervalImpl(() => {
116
122
  beat().catch(() => {});
@@ -121,6 +127,7 @@ export function createHeartbeat({
121
127
  },
122
128
 
123
129
  stop() {
130
+ stopped = true;
124
131
  if (timer) clearIntervalImpl(timer);
125
132
  timer = null;
126
133
  return this;
@@ -2,11 +2,10 @@ import { selectAction, JEV_CONTROLLER_PROMPT } from './actionSelector.js';
2
2
  import { mkdir, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { prepareMessages, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS, shrinkImages } from './context.js';
5
- import { observationHash } from './drivers/driver.js';
6
5
  import { withRollingCacheBreakpoint } from './fixLoop.js';
7
6
  import { callStepWithRetry, ProxyError, STEPS } from './model.js';
8
7
  import { RUN_FILES } from './paths.js';
9
- import { checkEvidence, createReportValidator, lintMarkdownSections, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
8
+ import { checkEvidence, createReportValidator, lintMarkdownSections, normaliseEvidence, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
10
9
  import { assertNoSecrets } from './secretScan.js';
11
10
  import { ACTION_TOOLS, PLAY_TOOLS, SWIPE_TOOL, REPORT_TOOLS, executeTool } from './tools.js';
12
11
  import { createTranscript } from './transcript.js';
@@ -388,7 +387,7 @@ async function playLoop({
388
387
  latestObservation = first.text;
389
388
  visualObservation = Boolean(first.screenshotBase64);
390
389
  transcript.write({ kind: 'observation', step: first.step, text: first.text, url: first.url });
391
- const landing = await safeScreenshot(driver, 'landing', screenshots, transcript);
390
+ const landing = await safeScreenshot(driver, 'landing', screenshots, transcript, observations.length);
392
391
  messages.push({
393
392
  role: 'user',
394
393
  content: [
@@ -503,7 +502,7 @@ async function playLoop({
503
502
  transcript.write({ kind: 'note', tool: toolUse.name, note });
504
503
  },
505
504
  onScreenshot: shot => {
506
- screenshots.push({ ...shot, order: nextOrder() });
505
+ screenshots.push({ ...shot, observation: observations.length, order: nextOrder() });
507
506
  transcript.write({ kind: 'screenshot', path: shot.relativePath, reason: shot.reason });
508
507
  }
509
508
  });
@@ -552,7 +551,7 @@ async function playLoop({
552
551
  // with the persona's view of the screen in front of it. A 4xx on a
553
552
  // resource is a `network` note, which the report step reads as context
554
553
  // rather than as a bug (a missing favicon is not a broken game).
555
- const shot = await safeScreenshot(driver, 'console-error', screenshots, transcript);
554
+ const shot = await safeScreenshot(driver, 'console-error', screenshots, transcript, observations.length);
556
555
  for (const entry of consoleErrors) {
557
556
  const kind = RESOURCE_4XX.test(String(entry.text)) ? 'network' : 'bug';
558
557
  const note = { kind, text: `${entry.type}: ${entry.text}`, automatic: true, ...(shot ? { screenshot: shot.relativePath } : {}) };
@@ -592,7 +591,7 @@ async function playLoop({
592
591
  quitDetail = `the action budget of ${maxActions} ran out`;
593
592
  }
594
593
 
595
- const final = await safeScreenshot(driver, 'final', screenshots, transcript);
594
+ const final = await safeScreenshot(driver, 'final', screenshots, transcript, observations.length);
596
595
  return {
597
596
  actionsTaken: actions,
598
597
  turns,
@@ -651,12 +650,15 @@ async function reportPhase({
651
650
  observationLines(observations),
652
651
  '',
653
652
  '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"}.',
653
+ '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.',
654
+ '',
655
+ 'Every evidence entry is an object with exactly two keys, "type" and "ref", and nothing else. A screenshot is {"type": "screenshot", "ref": "screenshots/04-example.png"}, never "path" or "file".',
654
656
  '',
655
657
  'Never write calendar dates or times in the prose of report.md or report.json. The runner stamps when the report was generated, so leave the template\'s Date out rather than writing a placeholder for it.',
656
658
  'Replace every <...> slot in the template with real content. Never copy a <...> placeholder into the report.',
657
659
  '',
658
660
  'Screenshots available as evidence (use these exact paths in evidence refs):',
659
- screenshots.length > 0 ? screenshots.map(shot => `- ${shot.relativePath}${shot.reason ? ` (${shot.reason})` : ''}`).join('\n') : '(none)',
661
+ screenshots.length > 0 ? screenshots.map(screenshotLine).join('\n') : '(none)',
660
662
  '',
661
663
  `report.md must contain these section headings, spelled exactly: ${REQUIRED_MARKDOWN_SECTIONS.map(section => `"${section}"`).join(', ')}.`,
662
664
  'Write report.md first, then report.json. Leave usage as zeros; the runner fills it in.'
@@ -667,12 +669,20 @@ async function reportPhase({
667
669
  let report = null;
668
670
  let markdown = null;
669
671
  let lint = [];
670
-
671
- // Two attempts at most: the first, and one repair if the schema or the
672
- // evidence refs refuse it. Each attempt is a metered question, and the
673
- // dogfood gate asks for "first or the repair attempt", not an
674
- // open ended loop.
675
- for (let attempt = 1; attempt <= 2; attempt += 1) {
672
+ // The newest report.json the model wrote, across turns: a repair turn that
673
+ // rewrites only report.md keeps the report.json it already wrote.
674
+ let jsonText = null;
675
+ let repaired = false;
676
+ let continued = false;
677
+
678
+ // One repair at most when the schema or the evidence refs refuse what was
679
+ // written: each turn is a metered question, and the dogfood gate asks for
680
+ // "first or the repair attempt", not an open ended loop. A turn that wrote
681
+ // report.md and stopped before report.json is not a refused report, so it
682
+ // gets one plain "now write report.json" that does not spend the repair.
683
+ // In 0.2.8 it did, and the report.json that came back had its five
684
+ // screenshot refs under "path" with no turn left to fix them.
685
+ for (let attempt = 1; ; attempt += 1) {
676
686
  const answer = await callStepWithRetry({
677
687
  client,
678
688
  jobId,
@@ -695,12 +705,13 @@ async function reportPhase({
695
705
  files.set(name, String(toolUse.input.content || ''));
696
706
  }
697
707
  if (files.has(RUN_FILES.reportMd)) markdown = files.get(RUN_FILES.reportMd);
708
+ if (files.has(RUN_FILES.reportJson)) jsonText = files.get(RUN_FILES.reportJson);
698
709
 
699
710
  let parsed = null;
700
711
  let parseError = null;
701
- if (files.has(RUN_FILES.reportJson)) {
712
+ if (jsonText !== null) {
702
713
  try {
703
- parsed = JSON.parse(files.get(RUN_FILES.reportJson));
714
+ parsed = JSON.parse(jsonText);
704
715
  } catch (error) {
705
716
  parseError = String(error && error.message);
706
717
  }
@@ -720,7 +731,13 @@ async function reportPhase({
720
731
  quitReason: play.quitReason,
721
732
  quitDetail: play.quitDetail
722
733
  };
723
- const candidate = parsed ? capNetworkOnlyFindings(stampReport(parsed, facts), { notes, screenshots }) : null;
734
+ // An evidence path under the wrong key is moved to `ref` before anything
735
+ // judges the report; see normaliseEvidence.
736
+ const normalised = parsed ? normaliseEvidence(parsed, runDirectory) : null;
737
+ if (normalised && normalised.changed > 0) {
738
+ transcript.write({ kind: 'report_evidence_normalised', attempt, entries: normalised.changed });
739
+ }
740
+ const candidate = normalised ? capNetworkOnlyFindings(stampReport(normalised.report, facts), { notes, screenshots }) : null;
724
741
  const validation = candidate ? validator.validate(candidate) : { ok: false, details: [{ instancePath: '/', message: parseError || 'report.json was not written' }] };
725
742
  const evidenceProblems = candidate ? await checkEvidence(candidate, runDirectory) : [];
726
743
  // The server lints the same four sections at `complete` and records the
@@ -742,24 +759,39 @@ async function reportPhase({
742
759
  ...evidenceProblems,
743
760
  ...missingSections.map(section => `report.md has no "${section}" section`)
744
761
  ];
745
- if (attempt === 2) {
762
+
763
+ // Every tool_use the next request echoes back needs its tool_result, or
764
+ // the API refuses the request. A write cut off at the token limit is
765
+ // malformed: it was not saved, and it is answered so.
766
+ const toolResults = answer.toolUses.map(toolUse => (toolUse.malformed
767
+ ? { type: 'tool_result', tool_use_id: toolUse.id, is_error: true, content: [{ type: 'text', text: CUT_OFF_WRITE }] }
768
+ : { type: 'tool_result', tool_use_id: toolUse.id, content: [{ type: 'text', text: 'saved' }] }));
769
+
770
+ if (jsonText === null && files.has(RUN_FILES.reportMd) && !continued) {
771
+ continued = true;
772
+ messages.push({ role: 'assistant', content: assistantContent(answer) });
773
+ messages.push({ role: 'user', content: toolResults.concat([{ type: 'text', text: CONTINUE_WITH_JSON }]) });
774
+ continue;
775
+ }
776
+
777
+ if (repaired) {
746
778
  if (!candidate) throw new Error(`the report step did not produce a valid report.json: ${lint.join('; ')}`);
747
- // Second attempt still imperfect: keep it, record the lint, and let
779
+ // Still imperfect after the repair: keep it, record the lint, and let
748
780
  // the server have the final word. It validates the same schema and
749
781
  // drops unevidenced findings itself, so this is a report with known
750
- // problems rather than no report at all.
782
+ // problems rather than no report at all. Every problem on its own line,
783
+ // before anything is uploaded, so a refusal at complete is never the
784
+ // first the developer hears of it.
751
785
  report = candidate;
752
- log(`report.json still has problems after one repair: ${lint.join('; ')}`);
786
+ log(`report.json still has problems after one repair (${lint.length}); the server will refuse a report that fails its schema:\n${lint.map(line => ` - ${line}`).join('\n')}`);
753
787
  break;
754
788
  }
755
789
 
790
+ repaired = true;
756
791
  messages.push({ role: 'assistant', content: assistantContent(answer) });
757
792
  messages.push({
758
793
  role: 'user',
759
- content: answer.toolUses
760
- .filter(toolUse => !toolUse.malformed)
761
- .map(toolUse => ({ type: 'tool_result', tool_use_id: toolUse.id, content: [{ type: 'text', text: 'saved' }] }))
762
- .concat([{ type: 'text', text: repairInstruction(validation.details, evidenceProblems, missingSections) }])
794
+ content: toolResults.concat([{ type: 'text', text: repairInstruction(validation.details, evidenceProblems, missingSections) }])
763
795
  });
764
796
  }
765
797
 
@@ -901,10 +933,10 @@ function redactToolInput(toolUse) {
901
933
  return rest;
902
934
  }
903
935
 
904
- async function safeScreenshot(driver, name, screenshots, transcript) {
936
+ async function safeScreenshot(driver, name, screenshots, transcript, observation = null) {
905
937
  try {
906
938
  const shot = await driver.screenshot(name);
907
- screenshots.push({ ...shot, reason: name, order: nextOrder() });
939
+ screenshots.push({ ...shot, reason: name, ...(Number.isInteger(observation) ? { observation } : {}), order: nextOrder() });
908
940
  transcript.write({ kind: 'screenshot', path: shot.relativePath, reason: name });
909
941
  return shot;
910
942
  } catch (error) {
@@ -1122,6 +1154,28 @@ export function noteLine(note, i) {
1122
1154
  return `${i + 1}. [${note.kind}${note.severity ? `/${note.severity}` : ''}] ${note.text}${tags.length > 0 ? ` (${tags.join(', ')})` : ''}${after}`;
1123
1155
  }
1124
1156
 
1157
+ /** The tool_result for a write_file call that was cut off before its input was complete. */
1158
+ export const CUT_OFF_WRITE = 'That write_file call was cut off before it finished, so nothing was saved. Write that file again, shorter if it has to be.';
1159
+
1160
+ /** The one follow-up a report turn gets for writing report.md and stopping. */
1161
+ export const CONTINUE_WITH_JSON = 'report.md is saved. Now write report.json with write_file. Every evidence entry is exactly {"type": ..., "ref": ...}; a screenshot is {"type": "screenshot", "ref": "screenshots/04-example.png"}.';
1162
+
1163
+ /**
1164
+ * A screenshot as one line of the report prompt: its path, the observation
1165
+ * it followed (what a blocker or major has to be checked against) and why
1166
+ * it was taken.
1167
+ *
1168
+ * @param {Object} shot
1169
+ * @returns {string}
1170
+ */
1171
+ export function screenshotLine(shot) {
1172
+ const tags = [
1173
+ Number.isInteger(shot.observation) && shot.observation > 0 ? `after observation ${shot.observation}` : null,
1174
+ shot.reason ? String(shot.reason) : null
1175
+ ].filter(Boolean);
1176
+ return `- ${shot.relativePath}${tags.length > 0 ? ` (${tags.join('; ')})` : ''}`;
1177
+ }
1178
+
1125
1179
  /** The most observations listed in the report prompt; the newest are kept. */
1126
1180
  export const MAX_OBSERVATIONS_IN_REPORT = 200;
1127
1181
 
package/src/run/report.js CHANGED
@@ -127,6 +127,86 @@ export function stampReport(report, facts) {
127
127
  return stamped;
128
128
  }
129
129
 
130
+ /** Keys a model has been seen to put an evidence path under instead of `ref`. */
131
+ const REF_ALIASES = ['path', 'file', 'screenshot', 'src', 'href', 'location', 'value'];
132
+
133
+ /** The four evidence types the schema allows. */
134
+ const EVIDENCE_TYPES = new Set(['screenshot', 'video_ts', 'transcript_step', 'file_line']);
135
+
136
+ /**
137
+ * One evidence entry in the schema's shape, `{type, ref}` and nothing else,
138
+ * when the model's own words say unambiguously what it meant.
139
+ *
140
+ * The 0.2.8 report that failed upload wrote
141
+ * `{"type": "screenshot", "path": "screenshots/04-04-school-selected.png"}`
142
+ * five times: a real file, under the wrong key. The schema refuses the extra
143
+ * key and the missing `ref`, so the whole report was refused and the run
144
+ * failed over a key name. This moves the value, never invents one: a path
145
+ * still has to name a file in the run directory (`checkEvidence`), and the
146
+ * server still checks it against what was uploaded.
147
+ *
148
+ * Also: a bare string becomes a screenshot or a transcript step by its look;
149
+ * a missing type on a `screenshots/...` ref becomes `screenshot`; a ref
150
+ * written `./screenshots/x.png` or as an absolute path inside the run
151
+ * directory becomes the run relative path the server's manifest uses.
152
+ *
153
+ * @param {any} item
154
+ * @param {string|null} runDirectory
155
+ * @returns {any} the normalised entry, or the input unchanged when it is not an object it can read
156
+ */
157
+ export function normaliseEvidenceItem(item, runDirectory = null) {
158
+ if (typeof item === 'string') {
159
+ const text = item.trim();
160
+ if (/^(?:\.\/)?screenshots\//.test(text)) return normaliseEvidenceItem({ type: 'screenshot', ref: text }, runDirectory);
161
+ if (/^(?:observation|note|step|action)\s*#?\s*\d+$/i.test(text)) return { type: 'transcript_step', ref: text };
162
+ return item;
163
+ }
164
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
165
+ let ref = typeof item.ref === 'string' && item.ref.trim() ? item.ref : null;
166
+ if (ref === null) {
167
+ const alias = REF_ALIASES.find(key => typeof item[key] === 'string' && item[key].trim());
168
+ if (alias) ref = item[alias];
169
+ }
170
+ let type = typeof item.type === 'string' ? item.type : null;
171
+ if (!type && ref && /^(?:\.\/)?screenshots\//.test(ref.trim())) type = 'screenshot';
172
+ if (type === null || ref === null || !EVIDENCE_TYPES.has(type)) return item;
173
+ ref = ref.trim();
174
+ if (type === 'screenshot') ref = runRelative(ref, runDirectory);
175
+ return { type, ref };
176
+ }
177
+
178
+ function runRelative(ref, runDirectory) {
179
+ let value = ref.replace(/\\/g, '/');
180
+ if (runDirectory && path.isAbsolute(ref)) {
181
+ const relative = path.relative(path.resolve(runDirectory), path.resolve(ref));
182
+ if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) value = relative.split(path.sep).join('/');
183
+ }
184
+ while (value.startsWith('./')) value = value.slice(2);
185
+ return value;
186
+ }
187
+
188
+ /**
189
+ * Every finding's evidence through {@link normaliseEvidenceItem}.
190
+ *
191
+ * @param {Object} report
192
+ * @param {string|null} [runDirectory]
193
+ * @returns {{report: Object, changed: number}} a new report, and how many entries were rewritten
194
+ */
195
+ export function normaliseEvidence(report, runDirectory = null) {
196
+ if (!report || typeof report !== 'object' || !Array.isArray(report.findings)) return { report, changed: 0 };
197
+ let changed = 0;
198
+ const findings = report.findings.map(finding => {
199
+ if (!finding || typeof finding !== 'object' || !Array.isArray(finding.evidence)) return finding;
200
+ const evidence = finding.evidence.map(item => {
201
+ const out = normaliseEvidenceItem(item, runDirectory);
202
+ if (JSON.stringify(out) !== JSON.stringify(item)) changed += 1;
203
+ return out;
204
+ });
205
+ return { ...finding, evidence };
206
+ });
207
+ return { report: { ...report, findings }, changed };
208
+ }
209
+
130
210
  /**
131
211
  * Check every `screenshot` evidence ref against the files actually on disk.
132
212
  *
@@ -151,8 +231,17 @@ export async function checkEvidence(report, runDirectory) {
151
231
  continue;
152
232
  }
153
233
  for (const item of evidence) {
154
- if (!item || item.type !== 'screenshot') continue;
155
- const ref = String(item.ref || '');
234
+ if (!item || typeof item !== 'object') {
235
+ problems.push(`${finding.id}: an evidence entry is not an object; write {"type": ..., "ref": ...}`);
236
+ continue;
237
+ }
238
+ if (typeof item.ref !== 'string' || !item.ref.trim()) {
239
+ const keys = Object.keys(item).filter(key => key !== 'type');
240
+ problems.push(`${finding.id}: a ${item.type || 'typeless'} evidence entry has no "ref"${keys.length > 0 ? ` (it has ${keys.map(key => `"${key}"`).join(', ')})` : ''}; every entry is exactly {"type": ..., "ref": ...}`);
241
+ continue;
242
+ }
243
+ if (item.type !== 'screenshot') continue;
244
+ const ref = String(item.ref);
156
245
  const candidate = path.resolve(runDirectory, ref);
157
246
  // Refuse a ref that climbs out of the run directory before touching
158
247
  // the filesystem with it.
@@ -214,6 +303,8 @@ export default {
214
303
  createReportValidator,
215
304
  stampReport,
216
305
  checkEvidence,
306
+ normaliseEvidence,
307
+ normaliseEvidenceItem,
217
308
  writeReportFiles,
218
309
  repairInstruction,
219
310
  lintMarkdownSections,
package/src/run/resume.js CHANGED
@@ -62,6 +62,15 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
62
62
  return EXIT.ENVIRONMENT;
63
63
  }
64
64
 
65
+ // A run whose report the server refused is done here, whatever the server
66
+ // says its state is: it is never finalised from its transcript (which would
67
+ // rewrite the kept report.json and report.md) and never replayed.
68
+ for (const [runId, entry] of Object.entries(journal.runs || {})) {
69
+ if (entry && entry.report_refused === true) {
70
+ log(`run ${runId}: the server refused its report, so it is not replayed or rebuilt. Its report.json, report.md and screenshots are in ${runDirFor(repoRoot, jobId, runId)}.`);
71
+ }
72
+ }
73
+
65
74
  // Reconcile the journal with what the server actually thinks: a run the sweep
66
75
  // marked interrupted, or one that finished after the journal was last
67
76
  // written, is the server's word against a stale file, and the server wins.
package/src/run/state.js CHANGED
@@ -192,7 +192,10 @@ export async function patchRun(repoRoot, state, runId, patch) {
192
192
  * @returns {boolean}
193
193
  */
194
194
  export function isRunDone(entry) {
195
- return Boolean(entry) && (entry.completed === true || RUN_DONE_STATES.includes(entry.state));
195
+ // A run whose report the server refused was played to the end: replaying
196
+ // it would spend a unit again, and rebuilding its report from the
197
+ // transcript would overwrite the one the developer is meant to read.
198
+ return Boolean(entry) && (entry.completed === true || entry.report_refused === true || RUN_DONE_STATES.includes(entry.state));
196
199
  }
197
200
 
198
201
  export default { SCHEMA_VERSION, newState, newRunEntry, save, load, patchRun, isRunDone, stateFile };