ravensight-playtest 0.2.6 → 0.2.7

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
@@ -223,11 +223,41 @@ from the page: the browser driver sets `window.__RAVENSIGHT_PLAYTEST__` and
223
223
  `localStorage['ravensight.playtest']` before the game's scripts run. It is never
224
224
  sent as a request header, which would reach your game's own API too.
225
225
 
226
+ With no ingest key configured, a Godot run says "no ingest key configured, so
227
+ no synthetic telemetry was sent". A web run says "synthetic telemetry comes from
228
+ the game's own SDK on the page, sent with the run's playtest token" instead,
229
+ because the page's SDK already sends the game's events marked synthetic.
230
+
226
231
  When a run reaches its model budget before the report step, nothing more is
227
232
  asked of the model. The persona's notes are written to a partial `report.json`
228
233
  (`partial: true`, one unreviewed finding per note with the nearest screenshot as
229
234
  evidence) and uploaded, so the job's aggregate still counts them.
230
235
 
236
+ When the budget runs out during the report step itself, the same partial report
237
+ is uploaded and the run is completed as `budget_exceeded`. The job does not exit 2 for it, and the CLI
238
+ prints "Run ran out of budget; the partial report was uploaded and the refund
239
+ decision is the server's". When the server's answer says a refund is
240
+ expected, the line ends "a refund of $X.XX is expected when the job settles"
241
+ instead, and "the run was refunded" once the job has settled and the run was
242
+ refunded. If the server refused the partial report, it says so and where the
243
+ notes are instead. An older API that refuses that completion prints its own
244
+ message, the run is completed as `failed`, and the job exits 1.
245
+
246
+ How the report step reads what happened:
247
+
248
+ - Console errors are recorded as automatic notes. A 4xx on a request (`Failed
249
+ to load resource`, `HTTP 404`, `status: 401`, `401 Unauthorized` and similar)
250
+ is a network note: context, not a bug. A finding whose only evidence is
251
+ automatic network notes is capped at `minor` by the CLI, and its description
252
+ says so.
253
+ - Every note carries `after`, the URL and title of the screen that followed
254
+ it. A blocker or major finding has to cite the observation where the failure
255
+ is visible and must not be contradicted by that next screen or by a later
256
+ persona note.
257
+ - The model is told never to write calendar dates. `report.md` carries the
258
+ CLI's own `Generated at` timestamp under its title, and the aggregate report's
259
+ header uses its `generated_at`.
260
+
231
261
  ### fix
232
262
 
233
263
  Takes one finding, attempts the change on a branch of its own, replays the
@@ -383,7 +413,7 @@ start avoids that.
383
413
  |---|---|
384
414
  | 0 | Success |
385
415
  | 1 | Failure |
386
- | 2 | Budget exceeded. Partial results were written and uploaded. |
416
+ | 2 | Budget exceeded during play. Partial results were written and uploaded. A run that ran out during the report step and was completed does not make the job exit 2. |
387
417
  | 3 | Environment check failed |
388
418
  | 4 | Authentication failed |
389
419
  | 5 | Model API error after retries |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ravensight-playtest",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
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",
@@ -18,13 +18,17 @@ const SKILL = ['---', 'name: persona-playtest', 'prompt_version: persona-playtes
18
18
  const PROFILE_SKILL = ['---', 'name: game-profile', 'prompt_version: game-profile@0.1.0', '---', '# Game Profile'].join('\n');
19
19
  const FIX_SKILL = ['---', 'name: fix', 'prompt_version: fix@0.1.0', '---', '# Fix'].join('\n');
20
20
 
21
- /** The server's table, verbatim. */
21
+ /**
22
+ * The server's table, verbatim, including the change that lets a run out of
23
+ * money in its report step end as budget_exceeded from `reporting` or
24
+ * `uploading`.
25
+ */
22
26
  export const RUN_TRANSITIONS = Object.freeze({
23
27
  queued: ['launching', 'canceled', 'skipped', 'failed', 'interrupted'],
24
28
  launching: ['playing', 'failed', 'canceled', 'interrupted'],
25
29
  playing: ['reporting', 'failed', 'canceled', 'budget_exceeded', 'interrupted'],
26
- reporting: ['uploading', 'failed', 'interrupted'],
27
- uploading: ['succeeded', 'failed', 'interrupted'],
30
+ reporting: ['uploading', 'failed', 'budget_exceeded', 'interrupted'],
31
+ uploading: ['succeeded', 'failed', 'budget_exceeded', 'interrupted'],
28
32
  interrupted: ['launching', 'playing', 'reporting', 'uploading', 'failed', 'canceled'],
29
33
  succeeded: [],
30
34
  failed: [],
@@ -33,6 +37,13 @@ export const RUN_TRANSITIONS = Object.freeze({
33
37
  skipped: []
34
38
  });
35
39
 
40
+ /** An API from before that change, which refuses budget_exceeded after `playing`. */
41
+ export const OLDER_RUN_TRANSITIONS = Object.freeze({
42
+ ...RUN_TRANSITIONS,
43
+ reporting: ['uploading', 'failed', 'interrupted'],
44
+ uploading: ['succeeded', 'failed', 'interrupted']
45
+ });
46
+
36
47
  export const RUN_TERMINAL_STATES = Object.freeze(['succeeded', 'failed', 'canceled', 'budget_exceeded', 'skipped']);
37
48
 
38
49
  /** Shaped like cli-core's `ApiError`: `status`, `code`, `body`. */
@@ -234,7 +245,10 @@ export function fakeCore({
234
245
  findings = [fakeFinding()],
235
246
  triage = { verdict: 'bug', confidence: 0.9, source: 'brief' },
236
247
  graphContext = { levels: [{ name: 'title', exits: ['level_1'] }] },
237
- verifyVerdict = { reproduced: 0.1, source: 'judge' }
248
+ verifyVerdict = { reproduced: 0.1, source: 'judge' },
249
+ transitions = RUN_TRANSITIONS,
250
+ completeExtras = {},
251
+ runRefund = {}
238
252
  } = {}) {
239
253
  const calls = [];
240
254
  const record = (name, payload) => calls.push({ name, ...payload });
@@ -250,7 +264,7 @@ export function fakeCore({
250
264
 
251
265
  const move = (runId, to) => {
252
266
  const from = runStates.get(runId) || 'queued';
253
- if (!(RUN_TRANSITIONS[from] || []).includes(to)) {
267
+ if (!(transitions[from] || []).includes(to)) {
254
268
  throw new FakeApiError({
255
269
  status: 409,
256
270
  code: 'invalid_transition',
@@ -347,8 +361,23 @@ export function fakeCore({
347
361
  if (RUN_TERMINAL_STATES.includes(from)) {
348
362
  throw new FakeApiError({ status: 409, code: 'run_already_complete', message: 'That run has already finished.', body: { state: from } });
349
363
  }
350
- runStates.set(runId, body.state);
351
- return { run: { run_id: runId, state: body.state }, dropped_findings: 0, report_lint: [] };
364
+ // The server's complete reads the table too, after advancing a
365
+ // `reporting` run that is succeeding to `uploading` itself.
366
+ const state = body.state || 'succeeded';
367
+ const effective = from === 'reporting' && state === 'succeeded' ? 'uploading' : from;
368
+ if (!(transitions[effective] || []).includes(state)) {
369
+ throw new FakeApiError({ status: 409, code: 'invalid_transition', message: `A run cannot go from "${effective}" to "${state}".`, body: { from: effective, to: state } });
370
+ }
371
+ runStates.set(runId, state);
372
+ // The server's refund fields ride on `run`. Before the job settles
373
+ // `refunded` is false and `refund_expected` says whether settling
374
+ // would give the run's unit back.
375
+ return {
376
+ run: { run_id: runId, state, refunded: false, refunded_cents: 0, refund_expected: false, refund_expected_cents: 0, ...runRefund },
377
+ dropped_findings: 0,
378
+ report_lint: [],
379
+ ...completeExtras
380
+ };
352
381
  },
353
382
  async list(gameId, jobId) {
354
383
  record('list', { jobId });
@@ -433,7 +433,14 @@ export async function playJob({
433
433
  log
434
434
  });
435
435
 
436
+ // A run that ran out of money in the report step, after its partial report
437
+ // was uploaded and the run completed, delivered what it bought: it does not
438
+ // make the job exit 2. One the server refused to complete exits 1, never 2,
439
+ // because 2 promises the partial results were shipped.
440
+ const playBudgetHit = results.some(result => result.state === 'budget_exceeded' && !result.reportStepBudget);
436
441
  const budgetHit = results.some(result => result.state === 'budget_exceeded');
442
+ const reportBudgetRefused = results.some(result => result.reportStepBudget === 'refused');
443
+ const reportBudgetDelivered = results.some(result => result.reportStepBudget === 'delivered' || result.reportStepBudget === 'dry_upload');
437
444
  const canceled = results.some(result => result.state === 'canceled');
438
445
  const anySucceeded = results.some(result => result.state === 'succeeded');
439
446
 
@@ -449,8 +456,9 @@ export async function playJob({
449
456
  log(`Reports: ${directory}`);
450
457
 
451
458
  if (canceled) return EXIT.CANCELED;
452
- if (budgetHit) return EXIT.BUDGET;
453
- return anySucceeded ? EXIT.OK : EXIT.FAILED;
459
+ if (reportBudgetRefused) return EXIT.FAILED;
460
+ if (playBudgetHit) return EXIT.BUDGET;
461
+ return anySucceeded || reportBudgetDelivered ? EXIT.OK : EXIT.FAILED;
454
462
  }
455
463
 
456
464
  /**
@@ -554,7 +562,47 @@ export async function playPersonaRun({
554
562
  if (inFlight) inFlight.delete(runId);
555
563
  const failed = error.outcome || { state: 'failed', quitReason: 'error', actionsTaken: 0, report: null, reportLint: [], reachedState: 'playing' };
556
564
  await stateFile.patchRun(repoRoot, journal, runId, { state: failed.state, error: String(error && error.message), quit_reason: failed.quitReason });
557
- await deliver({ deps, api, gameId, jobId, runId, directory, outcome: failed, options, log });
565
+ const delivery = {};
566
+ const answer = await deliver({ deps, api, gameId, jobId, runId, directory, outcome: failed, options, log, delivery });
567
+ if (isReportStepBudget(failed)) {
568
+ // The money ran out while the report was being written. The partial
569
+ // report went up and the run is completed as budget_exceeded from
570
+ // `uploading`; what happens to the charge is the server's decision.
571
+ if (answer) {
572
+ await stateFile.patchRun(repoRoot, journal, runId, { uploaded: true, completed: true });
573
+ // "Uploaded" only when it was: the server can refuse the partial
574
+ // report (and the run is completed without it), and the report may
575
+ // never have been written.
576
+ const message = delivery.reportRefused || !failed.report
577
+ ? `Run ran out of budget; the server refused the partial report; the persona's notes are in ${directory}`
578
+ : reportBudgetMessage(answer);
579
+ log(`run ${runId} (${persona.slug}): ${message}`);
580
+ return { runId, persona: persona.slug, state: failed.state, quitReason: failed.quitReason, reportStepBudget: 'delivered' };
581
+ }
582
+ if (options.dryUpload) {
583
+ // Nothing was sent, so the server still holds the run where it was.
584
+ // Not a refusal, so the job does not exit 1 for it.
585
+ log(`run ${runId} (${persona.slug}) ran out of budget while writing its report: dry upload, run not completed on the server.`);
586
+ return { runId, persona: persona.slug, state: failed.state, quitReason: failed.quitReason, reportStepBudget: 'dry_upload' };
587
+ }
588
+ log(`run ${runId} (${persona.slug}) ran out of budget while writing its report, and the server would not complete it: ${delivery.message || 'no reason given'}`);
589
+ // An older API refuses budget_exceeded after `playing`. The CLI moved
590
+ // the run to `uploading` itself, so it does not leave it there: `failed`
591
+ // is allowed from `uploading` on every table.
592
+ try {
593
+ await api.runs.complete(gameId, jobId, runId, {
594
+ state: 'failed',
595
+ reason: `ran out of budget while writing the report; the server refused budget_exceeded (${delivery.message || 'no reason given'})`,
596
+ actions_taken: failed.actionsTaken,
597
+ quit_reason: failed.quitReason
598
+ });
599
+ await stateFile.patchRun(repoRoot, journal, runId, { completed: true, state: 'failed' });
600
+ log(`run ${runId} (${persona.slug}): completed as failed instead.`);
601
+ } catch (fallbackError) {
602
+ log(`run ${runId} (${persona.slug}): could not be completed as failed either (${String(fallbackError && fallbackError.message)}). "ravensight-playtest resume" can retry it.`);
603
+ }
604
+ return { runId, persona: persona.slug, state: failed.state, quitReason: failed.quitReason, reportStepBudget: 'refused', error };
605
+ }
558
606
  log(`run ${runId} (${persona.slug}) failed: ${String(error && error.message)}`);
559
607
  return { runId, persona: persona.slug, state: failed.state, quitReason: failed.quitReason, error };
560
608
  }
@@ -576,6 +624,9 @@ export async function playPersonaRun({
576
624
  apiUrl,
577
625
  ingestKey: (config && config.ingest_key) || process.env.RAVENSIGHT_INGEST_KEY || null,
578
626
  playtestToken: entry.playtest_token,
627
+ // The web driver injects the playtest token into the page (see
628
+ // driverOptions), so the game's own SDK sends with it.
629
+ pageSdk: driverName === 'playwright_web' && Boolean(entry.playtest_token),
579
630
  context: { jobId, runId, persona: persona.slug, gameVersion: (config && config.game_version) || 'unknown' },
580
631
  report: outcome.report,
581
632
  driver: driverName,
@@ -604,7 +655,7 @@ export async function playPersonaRun({
604
655
  * uploaded and completed in exactly this order, including the `uploading`
605
656
  * transition the server's table allows from `reporting` and nowhere else.
606
657
  */
607
- export async function deliver({ deps, api, gameId, jobId, runId, directory, outcome, options, log, journal, repoRoot }) {
658
+ export async function deliver({ deps, api, gameId, jobId, runId, directory, outcome, options, log, journal, repoRoot, delivery = null }) {
608
659
  const body = {
609
660
  state: outcome.state,
610
661
  reason: outcome.quitDetail || '',
@@ -655,6 +706,7 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
655
706
  // report.json either way.
656
707
  if (!(body.report && body.report.partial && error && error.status === 422 && error.code === 'invalid_report')) throw error;
657
708
  log(`run ${runId}: the server refused the partial report (${String(error.message)}), so the run is completed without it. The notes are in ${directory}.`);
709
+ if (delivery) delivery.reportRefused = true;
658
710
  const { report: ignored, report_md: alsoIgnored, ...withoutReport } = body;
659
711
  answer = await api.runs.complete(gameId, jobId, runId, withoutReport);
660
712
  }
@@ -667,11 +719,54 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
667
719
  }
668
720
  return answer;
669
721
  } catch (error) {
722
+ if (delivery) {
723
+ delivery.message = String(error && error.message);
724
+ delivery.status = error && error.status;
725
+ delivery.code = error && error.code;
726
+ }
670
727
  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.`);
671
728
  return null;
672
729
  }
673
730
  }
674
731
 
732
+ /**
733
+ * Whether a run ran out of money in the report step: it is budget_exceeded
734
+ * and the server had already accepted `reporting` (or `uploading`) for it.
735
+ *
736
+ * @param {Object} outcome
737
+ * @returns {boolean}
738
+ */
739
+ export function isReportStepBudget(outcome) {
740
+ return Boolean(outcome) && outcome.state === 'budget_exceeded' && ['reporting', 'uploading'].includes(outcome.reachedState);
741
+ }
742
+
743
+ /**
744
+ * What the developer is told when a report-step budget run was completed.
745
+ * The refund is the server's decision; the CLI only repeats what the
746
+ * complete answer says about it. Before the job settles the answer carries
747
+ * `refund_expected` and `refund_expected_cents`; `refunded` is true only once
748
+ * settlement gave the unit back. Each may be on the answer or on its `run`.
749
+ *
750
+ * @param {Object} answer the complete response
751
+ * @returns {string}
752
+ */
753
+ export function reportBudgetMessage(answer) {
754
+ const read = name => {
755
+ if (!answer) return undefined;
756
+ if (answer[name] !== undefined) return answer[name];
757
+ return answer.run ? answer.run[name] : undefined;
758
+ };
759
+ const prefix = 'Run ran out of budget; the partial report was uploaded and';
760
+ if (read('refunded') === true) return `${prefix} the run was refunded`;
761
+ if (read('refund_expected') === true) {
762
+ const cents = read('refund_expected_cents');
763
+ return Number.isFinite(Number(cents)) && cents !== null && Number(cents) > 0
764
+ ? `${prefix} a refund of ${formatCents(cents)} is expected when the job settles`
765
+ : `${prefix} a refund is expected when the job settles`;
766
+ }
767
+ return `${prefix} the refund decision is the server's`;
768
+ }
769
+
675
770
  /**
676
771
  * Build the aggregate, and drive the `aggregate_report` run's own state machine
677
772
  * while doing it.
@@ -447,6 +447,7 @@ export async function judgeAggregate({ client, jobId, runId, ranked, inputs, bri
447
447
  ' "known":["iss_..."], "excluded_by_intent":[{"issue_id":"iss_...","intent":"<the stated intent it matches>"}],',
448
448
  ' "tldr":["up to five short bullets: the most important fixes and the strongest positive signal"]}',
449
449
  '',
450
+ 'Never write calendar dates or times in the tldr or anywhere else; the runner stamps generated_at on the report.',
450
451
  'Rules. A criterion no run touched is "not testable"; never invent evidence for one. An issue is "known" only when the brief already describes it. An issue is excluded by intent only when the persona experience is CONSISTENT with the stated intent; when it CONTRADICTS the intent, leave it in the list, because that is the valuable case.',
451
452
  '',
452
453
  `success criteria:\n${criteria.length > 0 ? criteria.map(row => ` ${row.criterion_id}: ${row.text}`).join('\n') : ' (none stated)'}`,
@@ -361,6 +361,16 @@ export function createWebDriver({ importer, headless = true, playtest = null } =
361
361
  url: current.url(),
362
362
  elapsedMs: Date.now() - started
363
363
  };
364
+ // The page title is what a note's "after" state is checked against in
365
+ // the report step. A page that cannot answer has no title, not an error.
366
+ try {
367
+ if (typeof current.title === 'function') {
368
+ const title = await current.title();
369
+ if (title) observation.title = String(title);
370
+ }
371
+ } catch {
372
+ // No title is still an observation.
373
+ }
364
374
  // Canvas builds (a Godot or Unity web export is one <canvas> and
365
375
  // nothing else) have no accessibility tree worth reading, so vision
366
376
  // is the only way to play them. Spend an image only then.
@@ -51,8 +51,22 @@ const nextOrder = () => {
51
51
  return ORDER;
52
52
  };
53
53
 
54
- /** `Failed to load resource: ... status of 4xx`: a missing asset or a refused call, not a crash. */
55
- const RESOURCE_4XX = /Failed to load resource: the server responded with a status of 4\d\d/;
54
+ /**
55
+ * A 4xx on a request, in the shapes a browser console and a game's own logging
56
+ * print it: `Failed to load resource: ... status of 4xx`, `HTTP 404`,
57
+ * `status: 401`, `statusCode=403`, `401 Unauthorized`. A missing asset or a
58
+ * refused call, not a crash.
59
+ */
60
+ export const RESOURCE_4XX = /Failed to load resource: the server responded with a status of 4\d\d|\bHTTP 4\d\d\b|status(?:Code)?\s*[:=]?\s*4\d\d\b|\b4\d\d (?:Unauthorized|Forbidden|Not Found|Bad Request)\b/i;
61
+
62
+ /** The screenshots the runner takes on its own, which no persona described. */
63
+ const RUNNER_SCREENSHOTS = Object.freeze(['landing', 'console-error', 'final']);
64
+
65
+ /** Severities above `minor`, which network notes alone cannot earn. */
66
+ const ABOVE_MINOR = Object.freeze(['blocker', 'major']);
67
+
68
+ /** What a capped finding's description gains, so the reader knows why. */
69
+ export const NETWORK_CAP_NOTE = 'Severity capped at minor by the runner: the only evidence is automatic network notes, and a failed request is a finding above minor only when the persona saw the game break because of it.';
56
70
 
57
71
  /** A persona note kind as a report finding category. */
58
72
  export const NOTE_CATEGORY = Object.freeze({ bug: 'bug', confusing: 'ux', suggestion: 'ux', good: 'ux', reaction: 'ux' });
@@ -127,6 +141,10 @@ export async function runPersona(args) {
127
141
  const startedAt = new Date().toISOString();
128
142
  const notes = [];
129
143
  const screenshots = [];
144
+ // Every observation the persona was shown, by index, so a note can carry
145
+ // what the screen looked like next and a finding can cite where a failure
146
+ // is visible.
147
+ const observations = [];
130
148
  let imageBudget = maxImages;
131
149
 
132
150
  await mkdir(path.join(runDirectory, 'screenshots'), { recursive: true });
@@ -186,6 +204,7 @@ export async function runPersona(args) {
186
204
  ledger,
187
205
  notes,
188
206
  screenshots,
207
+ observations,
189
208
  imageBudget,
190
209
  keepObservations,
191
210
  onImageBudget: next => {
@@ -242,6 +261,7 @@ export async function runPersona(args) {
242
261
  ledger,
243
262
  notes,
244
263
  screenshots,
264
+ observations,
245
265
  play,
246
266
  startedAt,
247
267
  endedAt,
@@ -305,6 +325,7 @@ async function playLoop({
305
325
  ledger,
306
326
  notes,
307
327
  screenshots,
328
+ observations = [],
308
329
  imageBudget,
309
330
  keepObservations,
310
331
  heartbeat,
@@ -343,7 +364,26 @@ async function playLoop({
343
364
  let wrapUpLeft = Infinity;
344
365
  const maxTurns = maxActions * 3 + 20;
345
366
 
367
+ // Notes waiting for the next observation, which is the screen they are
368
+ // checked against: a failure the next screen does not show is not a
369
+ // blocker.
370
+ let awaitingAfter = [];
371
+ const observed = observation => {
372
+ const entry = { index: observations.length + 1, step: observation.step, url: observation.url || null, title: observationTitle(observation) };
373
+ observations.push(entry);
374
+ for (const note of awaitingAfter) note.after = { url: entry.url, title: entry.title };
375
+ awaitingAfter = [];
376
+ return entry;
377
+ };
378
+ const noted = note => {
379
+ const entry = { ...note, at_action: actions, observation: observations.length, order: nextOrder() };
380
+ notes.push(entry);
381
+ awaitingAfter.push(entry);
382
+ return entry;
383
+ };
384
+
346
385
  const first = await driver.observe();
386
+ observed(first);
347
387
  latestObservation = first.text;
348
388
  visualObservation = Boolean(first.screenshotBase64);
349
389
  transcript.write({ kind: 'observation', step: first.step, text: first.text, url: first.url });
@@ -458,7 +498,7 @@ async function playLoop({
458
498
  // (bug, confusing, good, suggestion, reaction) would otherwise
459
499
  // overwrite the transcript line's `kind`, and every note would
460
500
  // read as a different sort of line.
461
- notes.push({ ...note, at_action: actions, order: nextOrder() });
501
+ noted(note);
462
502
  transcript.write({ kind: 'note', tool: toolUse.name, note });
463
503
  },
464
504
  onScreenshot: shot => {
@@ -479,6 +519,7 @@ async function playLoop({
479
519
  actions
480
520
  });
481
521
  if (executed.observation) {
522
+ observed(executed.observation);
482
523
  latestObservation = executed.observation.text;
483
524
  visualObservation = Boolean(executed.observation.screenshotBase64);
484
525
  recentActions.push(`${toolUse.name}: ${executed.changed ? 'screen changed' : 'no observed change'}`);
@@ -513,8 +554,8 @@ async function playLoop({
513
554
  const shot = await safeScreenshot(driver, 'console-error', screenshots, transcript);
514
555
  for (const entry of consoleErrors) {
515
556
  const kind = RESOURCE_4XX.test(String(entry.text)) ? 'network' : 'bug';
516
- const note = { kind, text: `${entry.type}: ${entry.text}`, automatic: true };
517
- notes.push({ ...note, at_action: actions, order: nextOrder() });
557
+ const note = { kind, text: `${entry.type}: ${entry.text}`, automatic: true, ...(shot ? { screenshot: shot.relativePath } : {}) };
558
+ noted(note);
518
559
  transcript.write({ kind: 'note', note });
519
560
  }
520
561
  asides.push(`The game logged ${consoleErrors.length} error(s): ${consoleErrors.map(entry => entry.text).join(' | ').slice(0, 800)}${shot ? ` A screenshot is saved at ${shot.relativePath}.` : ''}`);
@@ -584,6 +625,7 @@ async function reportPhase({
584
625
  ledger,
585
626
  notes,
586
627
  screenshots,
628
+ observations = [],
587
629
  play,
588
630
  startedAt,
589
631
  endedAt,
@@ -600,9 +642,16 @@ async function reportPhase({
600
642
  `actions taken: ${play.actionsTaken} stop reason: ${play.quitReason}${play.quitDetail ? ` (${play.quitDetail})` : ''}`,
601
643
  '',
602
644
  'Notes you logged, in order:',
603
- notes.length > 0 ? notes.map((note, i) => `${i + 1}. [${note.kind}${note.severity ? `/${note.severity}` : ''}] ${note.text}`).join('\n') : '(none)',
645
+ notes.length > 0 ? notes.map((note, i) => noteLine(note, i)).join('\n') : '(none)',
646
+ '',
647
+ 'Notes marked automatic were recorded by the runner from the console, with no severity: decide one from what the persona saw. Network notes are context, not bugs: a failed request is a finding only when the persona saw a broken screen because of it. A finding whose only evidence is automatic network notes is capped at minor by the runner.',
648
+ '',
649
+ 'Observations the persona was shown, by index:',
650
+ observationLines(observations),
604
651
  '',
605
- 'Notes marked automatic were recorded by the runner from the console, with no severity: decide one from what the persona saw. Network notes are context, not bugs: a failed request is a finding only when the persona saw a broken screen because of it.',
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
+ '',
654
+ 'Never write calendar dates or times in the prose of report.md or report.json. The runner stamps when the report was generated.',
606
655
  '',
607
656
  'Screenshots available as evidence (use these exact paths in evidence refs):',
608
657
  screenshots.length > 0 ? screenshots.map(shot => `- ${shot.relativePath}${shot.reason ? ` (${shot.reason})` : ''}`).join('\n') : '(none)',
@@ -669,7 +718,7 @@ async function reportPhase({
669
718
  quitReason: play.quitReason,
670
719
  quitDetail: play.quitDetail
671
720
  };
672
- const candidate = parsed ? stampReport(parsed, facts) : null;
721
+ const candidate = parsed ? capNetworkOnlyFindings(stampReport(parsed, facts), { notes, screenshots }) : null;
673
722
  const validation = candidate ? validator.validate(candidate) : { ok: false, details: [{ instancePath: '/', message: parseError || 'report.json was not written' }] };
674
723
  const evidenceProblems = candidate ? await checkEvidence(candidate, runDirectory) : [];
675
724
  // The server lints the same four sections at `complete` and records the
@@ -715,6 +764,7 @@ async function reportPhase({
715
764
  if (!markdown) {
716
765
  markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason: 'The report step wrote report.json but no report.md.' });
717
766
  }
767
+ markdown = stampGeneratedAt(markdown, new Date().toISOString());
718
768
 
719
769
  await writeReportFiles({ runDirectory, markdown, report });
720
770
  return { report, markdown, lint };
@@ -1033,17 +1083,148 @@ async function writePartialReport({ outcome, runDirectory, persona, driverName,
1033
1083
  play,
1034
1084
  reason: 'The run reached its model budget before the report step. What the persona saw is below, and report.json carries the notes as unreviewed findings.'
1035
1085
  });
1036
- await writeReportFiles({ runDirectory, markdown, report });
1086
+ const stamped = stampGeneratedAt(markdown, new Date().toISOString());
1087
+ await writeReportFiles({ runDirectory, markdown: stamped, report });
1037
1088
  outcome.report = report;
1038
- outcome.reportMarkdown = markdown;
1089
+ outcome.reportMarkdown = stamped;
1039
1090
  }
1040
1091
 
1041
1092
  async function writePartialMarkdown({ runDirectory, persona, driverName, target, notes, screenshots, play, reason }) {
1042
- const markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason });
1093
+ const markdown = stampGeneratedAt(renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason }), new Date().toISOString());
1043
1094
  assertNoSecrets('report.md', markdown);
1044
1095
  await writeFile(path.join(runDirectory, RUN_FILES.reportMd), markdown, 'utf8');
1045
1096
  }
1046
1097
 
1098
+ /**
1099
+ * A note as one numbered line of the report prompt: its kind, whether the
1100
+ * runner recorded it, the observation it was made on and the screen that
1101
+ * followed it.
1102
+ *
1103
+ * @param {Object} note
1104
+ * @param {number} i
1105
+ * @returns {string}
1106
+ */
1107
+ export function noteLine(note, i) {
1108
+ const tags = [
1109
+ note.automatic ? 'automatic' : null,
1110
+ Number.isInteger(note.observation) && note.observation > 0 ? `seen at observation ${note.observation}` : null,
1111
+ note.screenshot ? `screenshot ${note.screenshot}` : null
1112
+ ].filter(Boolean);
1113
+ const after = note.after
1114
+ ? ` after: ${JSON.stringify({ url: note.after.url ?? null, title: note.after.title ?? null })}`
1115
+ : ' after: (no later observation)';
1116
+ return `${i + 1}. [${note.kind}${note.severity ? `/${note.severity}` : ''}] ${note.text}${tags.length > 0 ? ` (${tags.join(', ')})` : ''}${after}`;
1117
+ }
1118
+
1119
+ /** The most observations listed in the report prompt; the newest are kept. */
1120
+ export const MAX_OBSERVATIONS_IN_REPORT = 200;
1121
+
1122
+ function observationLines(observations) {
1123
+ if (!observations || observations.length === 0) return '(none)';
1124
+ const shown = observations.slice(-MAX_OBSERVATIONS_IN_REPORT);
1125
+ const lines = shown.map(entry => `observation ${entry.index}: step ${entry.step}${entry.url ? ` ${entry.url}` : ''}${entry.title ? ` "${entry.title}"` : ''}`);
1126
+ if (shown.length < observations.length) lines.unshift(`(the first ${observations.length - shown.length} are left out)`);
1127
+ return lines.join('\n');
1128
+ }
1129
+
1130
+ /**
1131
+ * A short title for an observation: the page title when the driver has one,
1132
+ * otherwise the first line of what was observed.
1133
+ *
1134
+ * @param {Object} observation
1135
+ * @returns {string|null}
1136
+ */
1137
+ export function observationTitle(observation) {
1138
+ if (observation && typeof observation.title === 'string' && observation.title.trim()) return observation.title.trim().slice(0, 120);
1139
+ const line = String((observation && observation.text) || '').split('\n').map(part => part.trim()).find(Boolean);
1140
+ return line ? line.slice(0, 80) : null;
1141
+ }
1142
+
1143
+ /**
1144
+ * Cap at `minor` every finding whose evidence is only automatic network
1145
+ * notes, in code rather than by asking.
1146
+ *
1147
+ * A finding rests only on network notes when it cites at least one (a
1148
+ * `note N` ref to an automatic network note, or the runner's console-error
1149
+ * screenshot whose notes were all network notes) and nothing else: no persona
1150
+ * note, no automatic non-network note, no observation ref, no screenshot the
1151
+ * persona took, no other evidence type. Only the runner's own landing and
1152
+ * final screenshots neither count for nor against it.
1153
+ *
1154
+ * @param {Object} report a stamped report
1155
+ * @param {{notes: Array<Object>, screenshots: Array<Object>}} context
1156
+ * @returns {Object} the report, with capped findings rewritten
1157
+ */
1158
+ export function capNetworkOnlyFindings(report, { notes = [], screenshots = [] } = {}) {
1159
+ if (!report || !Array.isArray(report.findings)) return report;
1160
+ const byShot = new Map();
1161
+ for (const note of notes) {
1162
+ if (!note || !note.automatic || !note.screenshot) continue;
1163
+ if (!byShot.has(note.screenshot)) byShot.set(note.screenshot, []);
1164
+ byShot.get(note.screenshot).push(note);
1165
+ }
1166
+ const reasons = new Map(screenshots.filter(shot => shot && shot.relativePath).map(shot => [shot.relativePath, String(shot.reason || '')]));
1167
+
1168
+ const verdict = finding => {
1169
+ let network = 0;
1170
+ for (const item of Array.isArray(finding.evidence) ? finding.evidence : []) {
1171
+ if (!item) continue;
1172
+ const ref = String(item.ref || '');
1173
+ if (item.type === 'transcript_step') {
1174
+ // An observation ref, or anything that is not a network note, is
1175
+ // other evidence.
1176
+ const match = ref.match(/^\s*note\s*#?\s*(\d+)\s*$/i);
1177
+ const note = match ? notes[Number(match[1]) - 1] : null;
1178
+ if (note && note.automatic && note.kind === 'network') network += 1;
1179
+ else return false;
1180
+ } else if (item.type === 'screenshot') {
1181
+ const attached = byShot.get(ref);
1182
+ if (attached && attached.length > 0) {
1183
+ if (attached.every(note => note.kind === 'network')) network += 1;
1184
+ else return false;
1185
+ continue;
1186
+ }
1187
+ // The runner's own landing and final screenshots say nothing either
1188
+ // way; any screenshot the persona took is other evidence.
1189
+ const reason = reasons.has(ref) ? reasons.get(ref) : null;
1190
+ if (reason !== null && RUNNER_SCREENSHOTS.includes(reason)) continue;
1191
+ return false;
1192
+ } else {
1193
+ return false;
1194
+ }
1195
+ }
1196
+ return network > 0;
1197
+ };
1198
+
1199
+ return {
1200
+ ...report,
1201
+ findings: report.findings.map(finding => {
1202
+ if (!finding || !ABOVE_MINOR.includes(finding.severity) || !verdict(finding)) return finding;
1203
+ const description = String(finding.description || '');
1204
+ return { ...finding, severity: 'minor', description: description ? `${description}\n\n${NETWORK_CAP_NOTE}` : NETWORK_CAP_NOTE };
1205
+ })
1206
+ };
1207
+ }
1208
+
1209
+ /**
1210
+ * Put the runner's own timestamp at the head of a report.md, right under
1211
+ * its first heading, replacing one stamped before. Whatever the model wrote
1212
+ * in its prose is left alone; the header is what says when the report was
1213
+ * generated.
1214
+ *
1215
+ * @param {string} markdown
1216
+ * @param {string} generatedAt an ISO timestamp
1217
+ * @returns {string}
1218
+ */
1219
+ export function stampGeneratedAt(markdown, generatedAt) {
1220
+ const line = `**Generated at:** ${generatedAt}`;
1221
+ const lines = String(markdown || '').split('\n').filter(entry => !/^\*\*Generated at:\*\* /.test(entry));
1222
+ const heading = lines.findIndex(entry => /^# /.test(entry));
1223
+ if (heading === -1) return [line, '', ...lines].join('\n');
1224
+ lines.splice(heading + 1, 0, line);
1225
+ return lines.join('\n');
1226
+ }
1227
+
1047
1228
  /**
1048
1229
  * The `prompt_version` the report has to carry, read out of the skill's own
1049
1230
  * frontmatter so a content pack update moves it without a CLI release.
@@ -1056,4 +1237,4 @@ export function promptVersionOf(skill) {
1056
1237
  return match ? match[1] : null;
1057
1238
  }
1058
1239
 
1059
- export default { runPersona, buildSystem, promptVersionOf, shouldEscalate, renderFallbackMarkdown, notesToFindings, nearestScreenshot };
1240
+ export default { runPersona, buildSystem, promptVersionOf, shouldEscalate, renderFallbackMarkdown, notesToFindings, nearestScreenshot, capNetworkOnlyFindings, stampGeneratedAt, noteLine, observationTitle };
@@ -154,6 +154,9 @@ export class SyntheticSession {
154
154
  }
155
155
  }
156
156
 
157
+ /** The run summary's line when the page's own SDK sends the run's telemetry. */
158
+ export const PAGE_SDK_REASON = "synthetic telemetry comes from the game's own SDK on the page, sent with the run's playtest token";
159
+
157
160
  /**
158
161
  * Emit the three events a run produces, best effort.
159
162
  *
@@ -161,6 +164,8 @@ export class SyntheticSession {
161
164
  * @param {string} args.apiUrl
162
165
  * @param {string|null} args.ingestKey
163
166
  * @param {string|null} args.playtestToken
167
+ * @param {boolean} [args.pageSdk] the driver injected the playtest token into
168
+ * the page (the web driver), so the game's own SDK sends with it
164
169
  * @param {Object} args.context
165
170
  * @param {Object} args.report the run's report.json
166
171
  * @param {string} args.driver
@@ -168,8 +173,14 @@ export class SyntheticSession {
168
173
  * @param {(message: string) => void} [args.log]
169
174
  * @returns {Promise<{sent: boolean, accepted: number, reason?: string}>}
170
175
  */
171
- export async function emitRunEvents({ apiUrl, ingestKey, playtestToken, context, report, driver, fetchImpl, log }) {
176
+ export async function emitRunEvents({ apiUrl, ingestKey, playtestToken, pageSdk = false, context, report, driver, fetchImpl, log }) {
172
177
  if (!ingestKey) {
178
+ // A web build carries its own SDK, and the web driver injected the run's
179
+ // playtest token into the page, so the game's own events already arrive
180
+ // marked synthetic. Saying "nothing was sent" there would be wrong.
181
+ if (pageSdk && playtestToken) {
182
+ return { sent: false, accepted: 0, pageSdk: true, reason: PAGE_SDK_REASON };
183
+ }
173
184
  return { sent: false, accepted: 0, reason: 'no ingest key configured, so no synthetic telemetry was sent' };
174
185
  }
175
186
  if (!playtestToken) {
package/src/states.js CHANGED
@@ -90,8 +90,10 @@ export const RUN_TRANSITIONS = Object.freeze({
90
90
  queued: ['launching', 'canceled', 'skipped', 'failed', 'interrupted'],
91
91
  launching: ['playing', 'failed', 'canceled', 'interrupted'],
92
92
  playing: ['reporting', 'failed', 'canceled', 'budget_exceeded', 'interrupted'],
93
- reporting: ['uploading', 'failed', 'interrupted'],
94
- uploading: ['succeeded', 'failed', 'interrupted'],
93
+ // A run out of money in its report step uploads its partial report and
94
+ // ends here; the API allows it from 0.2.7's companion server change.
95
+ reporting: ['uploading', 'failed', 'budget_exceeded', 'interrupted'],
96
+ uploading: ['succeeded', 'failed', 'budget_exceeded', 'interrupted'],
95
97
  interrupted: ['launching', 'playing', 'reporting', 'uploading', 'failed', 'canceled'],
96
98
  succeeded: [],
97
99
  failed: [],