ravensight-playtest 0.2.4 → 0.2.6

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
@@ -87,6 +87,18 @@ content pack verified by sha256, disk, recommended concurrency, and the two
87
87
  optional tools. Exit code 3 when any row fails; a missing optional tool is a
88
88
  warning and never a failure, so CI does not break on it.
89
89
 
90
+ For a web game there is also a `browser` row: `check` imports Playwright,
91
+ launches a headless Chromium and closes it, which is exactly how a web run
92
+ starts. A missing browser fails the check when the linked build is a URL or you
93
+ pass `--web`. When no build is linked yet and the configured driver is the
94
+ default `playwright_web`, it is only a warning, so a Godot repo that never
95
+ changed the default is not failed over a browser it will not use. A repo linked
96
+ to a Godot project or a driver config gets no browser row. The fix is:
97
+
98
+ ```bash
99
+ npm i -D playwright && npx playwright install chromium
100
+ ```
101
+
90
102
  Past `--personas` in `POST /jobs`, requesting more than 100 personas or the same
91
103
  slug twice is refused with 400 `too_many_personas`, and a slug this game does
92
104
  not have is 400 `unknown_persona`; `run` checks the count against the same
@@ -101,6 +113,7 @@ automatically. `check` shows that same warning as a row when it applies.
101
113
  | `--modules <list>` | Price this module list, comma separated. |
102
114
  | `--personas <list>` | Price this persona list. Without it the balance row prices no personas, so the quote is lower than a real job's. |
103
115
  | `--godot-path <path>` | Where Godot is, if it is not on the PATH. |
116
+ | `--web` | Check the browser even when this repo is not linked to a web build. |
104
117
 
105
118
  ### init
106
119
 
@@ -108,9 +121,15 @@ Links this repo to a game and writes `.ravensight/config.json`. Refuses a game
108
121
  your token cannot see, so the first job does not fail with a 404. Adds
109
122
  `.ravensight/jobs/` to an existing `.gitignore`.
110
123
 
124
+ Without `--game`, one visible game is linked as is. With several, `init` lists
125
+ them numbered and asks `Which game? [1-N]` when it is running in a terminal; in
126
+ CI or a pipe it prints the list and asks you to pass `--game` instead. A token
127
+ that can see no games is told so, with the fix (approve a game for the token at
128
+ login).
129
+
111
130
  | Flag | Effect |
112
131
  |---|---|
113
- | `--game <gameId>` | The game to link. With one visible game it is optional. |
132
+ | `--game <gameId>` | The game to link. Optional with one visible game, or in a terminal. |
114
133
  | `--force` | Relink a repo that is already linked. |
115
134
 
116
135
  ### brief
@@ -130,6 +149,30 @@ warning `run` shows. A brief is required before `run`; the server answers 409
130
149
  | `--game <gameId>` | The game, when this repo is not linked. |
131
150
  | `--modules <list>`, `--personas <list>` | Price this module and persona list in the estimate it prints. |
132
151
 
152
+ #### The `brief --from` file
153
+
154
+ A JSON object of brief fields, or the same object wrapped as
155
+ `{ "fields": { ... } }`, which is how a saved brief carries them. The first four
156
+ are what makes a brief complete; the rest are optional.
157
+
158
+ | Field | Shape | Notes |
159
+ |---|---|---|
160
+ | `goals` | `[{ "goal": "...", "priority": 1 }]` | At least one. `goal` is one of `find_bugs`, `onboarding`, `balance`, `level_flow`, `narrative`, `accessibility`, `performance`. `priority` is 1 (highest) to 5. |
161
+ | `success_criteria` | string | What success looks like, one criterion per line. The aggregate report scores each line. |
162
+ | `build_notes.controls` | string | How to play: keys, buttons, the verbs a persona needs. |
163
+ | `build_notes.how_to_start` | string | Where a persona begins: the URL, the menu path, the first thing to press. |
164
+ | `design_intent_notes` | array of strings | Deliberate choices a persona must not report as bugs. |
165
+ | `known_issues` | array of strings | Issues you already know about, so they are not reported again. |
166
+
167
+ ```json
168
+ {
169
+ "goals": [{ "goal": "onboarding", "priority": 1 }, { "goal": "find_bugs", "priority": 2 }],
170
+ "success_criteria": "A new player reaches level 2\nNobody gets stuck in the menu",
171
+ "build_notes": { "controls": "Arrow keys to move, space to jump", "how_to_start": "Press Start on the title screen" },
172
+ "design_intent_notes": ["The fog on level 1 is intentional"]
173
+ }
174
+ ```
175
+
133
176
  ### profile
134
177
 
135
178
  Builds the game profile and the capability report: what your game is, what can
@@ -158,7 +201,7 @@ splitting the job in two is what gets everything played.
158
201
  | `--build-url <url>` | The web build to play. |
159
202
  | `--godot-project <dir>` | The Godot project directory. |
160
203
  | `--cli <command>` | The command that starts a text build. |
161
- | `--max-actions <n>` | Cap the actions one persona may take. |
204
+ | `--max-actions <n>` | Lower the actions one persona may take. It can only lower the persona's own patience (and the server's per run ceiling), never raise it. |
162
205
  | `--concurrency <n>` | Personas at once. Default 2; `check` recommends a number for this machine. |
163
206
  | `--upload-video` | Upload `session.webm` as well as the reports. |
164
207
  | `--upload-transcript` | Upload `transcript.jsonl` as well as the reports. |
@@ -166,10 +209,24 @@ splitting the job in two is what gets everything played.
166
209
  | `--headed`, `--headless` | Show or hide the browser. |
167
210
  | `--verbose` | Print each turn. |
168
211
 
169
- Four flags are accepted and reported as not applied by this release, rather than
170
- silently ignored: `--budget-usd` and `--quality` (the budget and the model tier
171
- are the server's, enforced at the proxy), `--offline-upload` (uploads already
172
- queue) and `--build-dir` (serve the build and pass `--build-url`).
212
+ Three flags are accepted and reported as not applied by this release, rather
213
+ than silently ignored: `--quality` (the model tier is the server's, enforced at
214
+ the proxy), `--offline-upload` (uploads already queue) and `--build-dir` (serve
215
+ the build and pass `--build-url`). There is no spending flag: each run's model
216
+ budget is set by the server from what the job was charged, and `--budget-usd` is
217
+ an unknown option (the refusal says where the budget comes from).
218
+
219
+ Each run hands its playtest identity to the game, so the game's own Ravensight
220
+ events from a playtest are marked synthetic rather than counted as a player. A
221
+ Godot game reads it from its environment. A web game's JavaScript SDK reads it
222
+ from the page: the browser driver sets `window.__RAVENSIGHT_PLAYTEST__` and
223
+ `localStorage['ravensight.playtest']` before the game's scripts run. It is never
224
+ sent as a request header, which would reach your game's own API too.
225
+
226
+ When a run reaches its model budget before the report step, nothing more is
227
+ asked of the model. The persona's notes are written to a partial `report.json`
228
+ (`partial: true`, one unreviewed finding per note with the nearest screenshot as
229
+ evidence) and uploaded, so the job's aggregate still counts them.
173
230
 
174
231
  ### fix
175
232
 
@@ -338,7 +395,7 @@ CI can treat 2 as a success with a warning.
338
395
 
339
396
  | Variable | Effect |
340
397
  |---|---|
341
- | `RAVENSIGHT_TOKEN` | Use this token instead of the keychain. The CI path. |
398
+ | `RAVENSIGHT_TOKEN` | Use this token instead of the keychain. The CI path. It must be a `gt_cli_` token, the kind `login` stores. A `gt_mcp_` token is an MCP personal access token and a `gt_live_` token is a game's ingest key; `check` refuses both and says which one it saw. |
342
399
  | `RAVENSIGHT_API_URL` | The API base URL. Defaults to `https://api.ravensight.io`. |
343
400
  | `RAVENSIGHT_DASHBOARD_URL` | Where the dashboard is, if it is not derivable from the API URL. |
344
401
  | `RAVENSIGHT_GAME_ID` | The game, when there is no linked repo. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ravensight-playtest",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
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",
@@ -68,6 +68,10 @@ export function looksLikeIngestKey(token) {
68
68
  return typeof token === 'string' && token.startsWith('gt_live_');
69
69
  }
70
70
 
71
+ export function looksLikeMcpToken(token) {
72
+ return typeof token === 'string' && token.startsWith('gt_mcp_');
73
+ }
74
+
71
75
  /**
72
76
  * What to do when the server refuses the credential.
73
77
  *
package/src/cli.js CHANGED
@@ -50,7 +50,7 @@ async function loadRunner(name, exportName) {
50
50
  * Kept in the same order as `VALUED` and `BOOLEAN` in src/run/args.js, which is
51
51
  * the list it has to agree with.
52
52
  */
53
- const RUNNER_HELP = `
53
+ export const RUNNER_HELP = `
54
54
  Run options (parsed by the runner, see src/run/args.js):
55
55
  --personas <list> comma separated persona slugs, default the pack's set
56
56
  --driver <name> playwright_web, godot_driver, desktop_driver, unity_driver, unreal_driver, android_driver, ios_driver or cli_stdio
@@ -58,7 +58,7 @@ Run options (parsed by the runner, see src/run/args.js):
58
58
  --build-url <url> the web build to play
59
59
  --godot-project <dir> the Godot project directory
60
60
  --driver-config <file> local WebDriver or JSON-RPC bridge configuration
61
- --max-actions <n> cap the actions one persona may take
61
+ --max-actions <n> lower the actions one persona may take; it can only lower the persona's own patience, never raise it
62
62
  --concurrency <n> personas at once, default 2
63
63
  --upload-video upload session.webm as well as the reports
64
64
  --upload-transcript upload transcript.jsonl as well as the reports
@@ -68,7 +68,10 @@ Run options (parsed by the runner, see src/run/args.js):
68
68
  --verbose print each turn
69
69
 
70
70
  Accepted and reported as not applied by this release:
71
- --budget-usd, --quality, --offline-upload, --build-dir
71
+ --quality, --offline-upload, --build-dir
72
+
73
+ There is no spending flag: each run's model budget is set by the server from
74
+ what the job was charged.
72
75
  `;
73
76
 
74
77
  /** Flags every command shares. */
@@ -157,13 +160,15 @@ export function buildProgram() {
157
160
  .addOption(new Option('--modules <list>', 'price this module list').argParser(list))
158
161
  .addOption(new Option('--personas <list>', 'price this persona list').argParser(list))
159
162
  .option('--godot-path <path>', 'where Godot is, if it is not on the PATH')
163
+ .option('--web', 'check the browser even when this repo is not linked to a web build')
160
164
  .action(capture(slot, async options => {
161
165
  const { check } = await import('./commands/check.js');
162
166
  return check(flagsFrom(options, {
163
167
  game: options.game,
164
168
  modules: options.modules,
165
169
  personas: options.personas,
166
- godotPath: options.godotPath
170
+ godotPath: options.godotPath,
171
+ web: Boolean(options.web)
167
172
  }));
168
173
  }));
169
174
 
@@ -1,7 +1,7 @@
1
1
  import { createClient } from '../api/client.js';
2
2
  import { ApiError } from '../api/errors.js';
3
3
  import { resolveApiUrl, paths } from '../paths.js';
4
- import { resolveToken, looksLikeIngestKey } from '../auth/session.js';
4
+ import { resolveToken, looksLikeIngestKey, looksLikeMcpToken } from '../auth/session.js';
5
5
  import { backendName } from '../auth/keychain.js';
6
6
  import { loadConfig } from '../config.js';
7
7
  import { packs } from '../packs/index.js';
@@ -19,6 +19,75 @@ const row = (name, status, detail, fix = null) => ({ name, status, detail, fix }
19
19
  /** "1 persona", "3 personas". A doctor that cannot count reads like a draft. */
20
20
  const plural = (count, noun) => `${count} ${noun}${count === 1 ? '' : 's'}`;
21
21
 
22
+ /** The two commands that make the playwright_web driver work. */
23
+ export const BROWSER_FIX = 'npm i -D playwright && npx playwright install chromium';
24
+
25
+ /**
26
+ * The repo config, for deciding whether this is a web game. A config that
27
+ * cannot be read is reported by the rows that need it, not here.
28
+ *
29
+ * @param {Object} flags
30
+ * @returns {Promise<Object|null>}
31
+ */
32
+ async function configForBrowser(flags) {
33
+ if (flags.config !== undefined) return flags.config;
34
+ try {
35
+ return await loadConfig({ repoRoot: flags.repoRoot });
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * How much a missing browser matters here, or null when the row is skipped.
43
+ *
44
+ * `fail` when `--web` asks for it or the linked build is a URL: a web run
45
+ * cannot start without one. `warn` when only the configured driver says
46
+ * `playwright_web` and no build is linked yet, because that is the untouched
47
+ * default `init` writes, and a Godot developer who never changed it must not
48
+ * get exit 3 over a browser they will never launch. Skipped otherwise.
49
+ *
50
+ * @param {Object} flags
51
+ * @param {Object|null} config
52
+ * @returns {'fail'|'warn'|null}
53
+ */
54
+ export function browserSeverity(flags, config) {
55
+ if (flags.web) return 'fail';
56
+ if (!config) return null;
57
+ const kind = config.build && config.build.kind;
58
+ if (kind === 'url') return 'fail';
59
+ if (kind) return null;
60
+ return config.driver === 'playwright_web' ? 'warn' : null;
61
+ }
62
+
63
+ /**
64
+ * Import Playwright, launch headless Chromium, close it. The same three steps
65
+ * a web run starts with, so a pass here means the run will get a browser.
66
+ *
67
+ * @param {(specifier: string) => Promise<any>} [importer] test seam
68
+ * @returns {Promise<{ok: boolean, detail: string}>}
69
+ */
70
+ export async function probeBrowser(importer) {
71
+ const load = importer || (specifier => import(specifier));
72
+ let browser = null;
73
+ try {
74
+ const playwright = await load('playwright');
75
+ browser = await playwright.chromium.launch({ headless: true });
76
+ const version = typeof browser.version === 'function' ? browser.version() : '';
77
+ return { ok: true, detail: `chromium ${version}`.trim() + ' launched headless' };
78
+ } catch (error) {
79
+ return { ok: false, detail: String(error && error.message ? error.message : error).split('\n')[0] };
80
+ } finally {
81
+ if (browser) {
82
+ try {
83
+ await browser.close();
84
+ } catch {
85
+ // A browser that will not close still launched, which is what was asked.
86
+ }
87
+ }
88
+ }
89
+ }
90
+
22
91
  /**
23
92
  * Every environment check, as data.
24
93
  *
@@ -37,6 +106,7 @@ const plural = (count, noun) => `${count} ${noun}${count === 1 ? '' : 's'}`;
37
106
  * @param {string[]} [flags.modules]
38
107
  * @param {string[]} [flags.personas]
39
108
  * @param {string} [flags.repoRoot]
109
+ * @param {boolean} [flags.web] probe the browser whatever the config says
40
110
  * @param {Object} [deps]
41
111
  * @returns {Promise<{rows: Array, ok: boolean}>}
42
112
  */
@@ -113,12 +183,19 @@ export async function runChecks(flags = {}, deps = {}) {
113
183
  'that is an ingest key, not a CLI token',
114
184
  'run login, or set RAVENSIGHT_TOKEN to a gt_cli_ token'
115
185
  ));
186
+ } else if (looksLikeMcpToken(token)) {
187
+ rows.push(row(
188
+ 'token valid',
189
+ 'fail',
190
+ 'This is an MCP personal access token (gt_mcp_). The CLI needs its own token (gt_cli_): run `ravensight-playtest login`, or set RAVENSIGHT_TOKEN to a gt_cli_ token in CI.',
191
+ 'ravensight-playtest login'
192
+ ));
116
193
  } else if (!token) {
117
194
  rows.push(row('token valid', 'fail', `not logged in to ${host}`, 'ravensight-playtest login'));
118
195
  }
119
196
 
120
197
  let whoami = null;
121
- if (token && !looksLikeIngestKey(token)) {
198
+ if (token && !looksLikeIngestKey(token) && !looksLikeMcpToken(token)) {
122
199
  const authed = deps.authedApi || createClient({ apiUrl, token });
123
200
  try {
124
201
  whoami = await authed.cli.whoami();
@@ -243,6 +320,17 @@ export async function runChecks(flags = {}, deps = {}) {
243
320
  'needed only to re-encode a session video before upload'
244
321
  ));
245
322
 
323
+ const browserNeed = browserSeverity(flags, await configForBrowser(flags));
324
+ if (browserNeed) {
325
+ const probe = await probeBrowser(deps.importer);
326
+ rows.push(row(
327
+ 'browser',
328
+ probe.ok ? 'ok' : browserNeed,
329
+ probe.detail,
330
+ probe.ok ? null : BROWSER_FIX
331
+ ));
332
+ }
333
+
246
334
  const godot = await (deps.inspectGodot || inspectGodot)(
247
335
  { repoRoot, godotPath: flags.godotPath },
248
336
  deps
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { appendFile, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { optionsFrom } from '../run/args.js';
4
+ import { optionsFrom, unknownOptionNotes } from '../run/args.js';
5
5
  import { getDeps } from '../run/deps.js';
6
6
  import { createDriver } from '../run/drivers/index.js';
7
7
  import EXIT from '../run/exit.js';
@@ -15,7 +15,7 @@ import { createRepoTools, PROFILE_TOOLS } from '../run/repoTools.js';
15
15
  import { assertNoSecrets } from '../run/secretScan.js';
16
16
  import * as stateFile from '../run/state.js';
17
17
  import { createUsageLedger, writeUsageFile } from '../run/usage.js';
18
- import { createInterruptHandler, deliver, exitCodeFor, explainRegisterRefusal, formatCents, resolveSetup, resolveTarget, skillFor } from './run.js';
18
+ import { createInterruptHandler, deliver, driverOptions, exitCodeFor, explainRegisterRefusal, formatCents, resolveSetup, resolveTarget, skillFor } from './run.js';
19
19
 
20
20
  /**
21
21
  * `ravensight-playtest fix <target>`: take one finding, change the code on a
@@ -155,6 +155,7 @@ async function fix(target, flags, injected, deps, log) {
155
155
  const { options, unknown } = optionsFrom(flags);
156
156
  if (unknown.length > 0) {
157
157
  log(`I do not know these options: ${unknown.join(', ')}. Run "ravensight-playtest fix --help".`);
158
+ for (const line of unknownOptionNotes(unknown)) log(line);
158
159
  return EXIT.ENVIRONMENT;
159
160
  }
160
161
  const parsed = parseTarget(target);
@@ -506,6 +507,7 @@ async function fix(target, flags, injected, deps, log) {
506
507
  repoRoot,
507
508
  entry: verifyRun,
508
509
  pack,
510
+ brief,
509
511
  finding,
510
512
  driverName,
511
513
  target: buildTarget,
@@ -752,6 +754,7 @@ export async function runVerify({
752
754
  repoRoot,
753
755
  entry,
754
756
  pack,
757
+ brief = null,
755
758
  finding,
756
759
  driverName,
757
760
  target,
@@ -769,7 +772,7 @@ export async function runVerify({
769
772
  return null;
770
773
  }
771
774
  const headless = driverName === 'godot_driver' ? options.headless === true : options.headed !== true;
772
- const driver = await makeDriver(driverName, { headless });
775
+ const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target, directory, entry, jobId }));
773
776
  const ledger = createUsageLedger();
774
777
  const heartbeat = createHeartbeat({
775
778
  api,
@@ -796,7 +799,8 @@ export async function runVerify({
796
799
  target,
797
800
  runDirectory: directory,
798
801
  skill: skillFor(pack, 'persona_playtest'),
799
- brief: verifyBrief(pack.brief || null, finding),
802
+ // The brief fetched above, as `run` does: the pack does not always carry one.
803
+ brief: verifyBrief(brief || pack.brief || null, finding),
800
804
  intentNotes: pack.intent_notes || [],
801
805
  schemas: pack.schemas || [],
802
806
  maxActions,
@@ -37,7 +37,8 @@ export async function ensureGitignore(repoRoot) {
37
37
  /**
38
38
  * `ravensight-playtest init --game <gameId>`
39
39
  *
40
- * Link this repo to a Ravensight game.
40
+ * Link this repo to a Ravensight game. Without `--game`, one visible game is
41
+ * used as is, and several are offered as a numbered list on a terminal.
41
42
  *
42
43
  * The game is checked against `whoami` before anything is written, because the
43
44
  * alternative is a config that looks right and fails on the first job with a
@@ -45,7 +46,7 @@ export async function ensureGitignore(repoRoot) {
45
46
  * authority: an unapproved game is invisible to it, by design.
46
47
  *
47
48
  * @param {{game?: string, apiUrl?: string, repoRoot?: string, force?: boolean, json?: boolean}} flags
48
- * @param {Object} [deps]
49
+ * @param {{context?: Object, choose?: Function}} [deps]
49
50
  * @returns {Promise<number>}
50
51
  */
51
52
  export async function init(flags = {}, deps = {}) {
@@ -58,15 +59,39 @@ export async function init(flags = {}, deps = {}) {
58
59
  // Every API call in this command goes through the one 401 handler, so a
59
60
  // refused credential is cleared and explained here the same way it is
60
61
  // everywhere else.
61
- return withAuthHandling(context, () => linkRepo(context, flags, repoRoot));
62
+ return withAuthHandling(context, () => linkRepo(context, flags, repoRoot, deps.choose || ui.choose));
62
63
  }
63
64
 
64
- async function linkRepo(context, flags, repoRoot) {
65
+ /**
66
+ * @param {Object} context
67
+ * @param {Object} flags
68
+ * @param {string} repoRoot
69
+ * @param {(question: string, labels: Array<string>) => Promise<number|null>} choose
70
+ * the picker; answers null when there is no terminal to ask on
71
+ */
72
+ async function linkRepo(context, flags, repoRoot, choose) {
65
73
  const whoami = await context.api.cli.whoami();
66
74
 
67
75
  const games = whoami.games || [];
68
76
  let gameId = flags.game;
69
77
 
78
+ if (!gameId && games.length === 0) {
79
+ throw new CliError(
80
+ 'This token can see no games, so there is nothing to link.',
81
+ 1,
82
+ { hint: 'Create a game in the dashboard, then run login again and approve it for this token.' }
83
+ );
84
+ }
85
+
86
+ // Never under --json: whatever reads that output is a script, not a person.
87
+ if (!gameId && games.length > 1 && !flags.json) {
88
+ // On a terminal, ask. Anywhere else (CI, a pipe) the picker answers null
89
+ // and the command falls through to the list and the --game error, which
90
+ // is what a script needs to see.
91
+ const picked = await choose(`Which game? [1-${games.length}]`, games.map(game => `${game.name || game.gameId} (${game.gameId})`));
92
+ if (Number.isInteger(picked) && games[picked]) gameId = games[picked].gameId;
93
+ }
94
+
70
95
  if (!gameId) {
71
96
  if (games.length === 1) {
72
97
  gameId = games[0].gameId;
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { mkdir } from 'node:fs/promises';
3
- import { optionsFrom } from '../run/args.js';
3
+ import { optionsFrom, unknownOptionNotes } from '../run/args.js';
4
4
  import { getDeps } from '../run/deps.js';
5
5
  import EXIT from '../run/exit.js';
6
6
  import { createHeartbeat } from '../run/heartbeat.js';
@@ -55,6 +55,7 @@ async function profile(flags, injected, deps, log) {
55
55
  const { options, unknown } = optionsFrom(flags);
56
56
  if (unknown.length > 0) {
57
57
  log(`I do not know these options: ${unknown.join(', ')}.`);
58
+ for (const line of unknownOptionNotes(unknown)) log(line);
58
59
  return EXIT.ENVIRONMENT;
59
60
  }
60
61
 
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { mkdir, readFile, stat } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { buildAggregate, writeAggregate } from '../run/aggregate.js';
5
- import { optionsFrom } from '../run/args.js';
5
+ import { optionsFrom, unknownOptionNotes } from '../run/args.js';
6
6
  import { getDeps } from '../run/deps.js';
7
7
  import { createDriver } from '../run/drivers/index.js';
8
8
  import EXIT from '../run/exit.js';
@@ -80,6 +80,7 @@ async function run(flags, injected, deps, log) {
80
80
  const { options, unknown } = optionsFrom(flags);
81
81
  if (unknown.length > 0) {
82
82
  log(`I do not know these options: ${unknown.join(', ')}. Run "ravensight-playtest run --help".`);
83
+ for (const line of unknownOptionNotes(unknown)) log(line);
83
84
  return EXIT.ENVIRONMENT;
84
85
  }
85
86
  for (const line of unusedFlagWarnings(options)) log(line);
@@ -294,17 +295,17 @@ export function personaCountRefusal(count, maxPersonas) {
294
295
  /**
295
296
  * Flags this release parses and then does not act on.
296
297
  *
297
- * Saying so is the point: a developer who passes `--budget-usd 1` and is not
298
- * told it did nothing will believe they capped the spend.
298
+ * Saying so is the point: a developer who passes a flag and is not told it
299
+ * did nothing will believe it did something. `--budget-usd` is not here
300
+ * because it is not parsed at all any more: the model budget is the server's,
301
+ * derived from what the job was charged and enforced per call at the proxy,
302
+ * so a spending flag the CLI cannot honour is refused rather than accepted.
299
303
  *
300
304
  * @param {Object} options
301
305
  * @returns {Array<string>}
302
306
  */
303
307
  export function unusedFlagWarnings(options) {
304
308
  const warnings = [];
305
- if (options.budgetUsd !== undefined) {
306
- warnings.push('--budget-usd is not applied by this release: the model budget is the server\'s, derived from what the job was charged, and it is enforced per call at the proxy.');
307
- }
308
309
  if (options.quality !== undefined) {
309
310
  warnings.push('--quality is not applied by this release: the model tier is the server\'s decision, and the CLI is not allowed to name one.');
310
311
  }
@@ -392,6 +393,7 @@ export async function playJob({
392
393
  journal,
393
394
  inFlight,
394
395
  makeDriver,
396
+ brief,
395
397
  log
396
398
  }));
397
399
  }
@@ -474,6 +476,7 @@ export async function playPersonaRun({
474
476
  journal,
475
477
  inFlight = null,
476
478
  makeDriver,
479
+ brief = null,
477
480
  log
478
481
  }) {
479
482
  const runId = entry.run_id;
@@ -486,7 +489,7 @@ export async function playPersonaRun({
486
489
  // Headless is right for a browser and wrong for Godot: a headless Godot
487
490
  // routes no GUI input, so it is a smoke-test mode rather than a way to play.
488
491
  const headless = driverName === 'godot_driver' ? options.headless === true : options.headed !== true;
489
- const driver = await makeDriver(driverName, { headless });
492
+ const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target, directory, entry, jobId }));
490
493
  // The ledger is created here rather than inside the loop so the heartbeat
491
494
  // can feed it the server's own figure for what this run has spent. Prices
492
495
  // are server side only, so that answer is the only real one.
@@ -535,7 +538,9 @@ export async function playPersonaRun({
535
538
  target,
536
539
  runDirectory: directory,
537
540
  skill,
538
- brief: pack.brief || null,
541
+ // The brief this command fetched and checked is complete, not the
542
+ // pack's copy, which a pack bundle does not always carry.
543
+ brief: brief || pack.brief || null,
539
544
  intentNotes: pack.intent_notes || [],
540
545
  schemas: pack.schemas || [],
541
546
  maxActions,
@@ -587,10 +592,10 @@ export async function playPersonaRun({
587
592
  * Upload the run directory and complete the run, through cli-core's
588
593
  * `finalizeRun` so the 422 `upload_unverified` repair pass lives in one place.
589
594
  *
590
- * A budget-capped or cancelled run sends no `report`: the route accepts that,
591
- * and it is the honest answer. Inventing a sentiment score nobody gave so a
592
- * placeholder report would validate would put a fabricated verdict in front of
593
- * the developer.
595
+ * A cancelled run sends no `report`. A budget-capped run sends the partial
596
+ * report built from its notes (`partial: true`, placeholder sentiment the
597
+ * report itself marks as no verdict), because the notes are what the run
598
+ * bought before the money ran out.
594
599
  *
595
600
  * A refused delivery does not take the job down. `finish` still settles it, the
596
601
  * artifacts are on disk, and `resume` can retry.
@@ -632,15 +637,27 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
632
637
  }
633
638
 
634
639
  try {
635
- const answer = await deps.finalizeRun(directory, {
636
- api,
637
- gameId,
638
- jobId,
639
- runId,
640
- includeVideo: Boolean(options.uploadVideo),
641
- includeTranscript: Boolean(options.uploadTranscript),
642
- body
643
- });
640
+ let answer;
641
+ try {
642
+ answer = await deps.finalizeRun(directory, {
643
+ api,
644
+ gameId,
645
+ jobId,
646
+ runId,
647
+ includeVideo: Boolean(options.uploadVideo),
648
+ includeTranscript: Boolean(options.uploadTranscript),
649
+ body
650
+ });
651
+ } catch (error) {
652
+ // A server older than partial reports refuses one as `invalid_report`.
653
+ // The uploads are already done by then, so the run is completed
654
+ // without it rather than left open: the notes stay on disk in
655
+ // report.json either way.
656
+ if (!(body.report && body.report.partial && error && error.status === 422 && error.code === 'invalid_report')) throw error;
657
+ log(`run ${runId}: the server refused the partial report (${String(error.message)}), so the run is completed without it. The notes are in ${directory}.`);
658
+ const { report: ignored, report_md: alsoIgnored, ...withoutReport } = body;
659
+ answer = await api.runs.complete(gameId, jobId, runId, withoutReport);
660
+ }
644
661
  if (journal) await stateFile.patchRun(repoRoot, journal, runId, { uploaded: true, completed: true });
645
662
  if (answer && answer.dropped_findings > 0) {
646
663
  log(`run ${runId}: the server dropped ${answer.dropped_findings} finding(s) with no usable evidence.`);
@@ -919,12 +936,41 @@ export async function resolveSetup(deps, options, log) {
919
936
  };
920
937
  }
921
938
 
922
- async function loadRunReports(repoRoot, jobId, personaRuns) {
939
+ /**
940
+ * What a driver is created with. The playtest identity is the run's own: the
941
+ * Godot driver passes it into the game's environment and the web driver into
942
+ * the page, and the game's SDK sends the token on `POST /session`, which is
943
+ * what makes the server force the device id and mark the session synthetic.
944
+ * Without it the game's own events from a playtest count as a real player.
945
+ *
946
+ * @returns {Object}
947
+ */
948
+ export function driverOptions({ headless, driverName, target, directory, entry, jobId }) {
949
+ return {
950
+ headless,
951
+ playtest: {
952
+ token: entry.playtest_token || null,
953
+ runId: entry.run_id,
954
+ jobId,
955
+ persona: entry.persona || null
956
+ },
957
+ projectDir: driverName === 'godot_driver' ? target : undefined,
958
+ runDir: directory
959
+ };
960
+ }
961
+
962
+ /**
963
+ * The persona reports on disk, for the aggregate. A partial report (written
964
+ * from the notes when the money ran out) is loaded as a `budget_exceeded`
965
+ * run: its findings count, and the aggregate says the job is partial.
966
+ */
967
+ export async function loadRunReports(repoRoot, jobId, personaRuns) {
923
968
  const out = [];
924
969
  for (const entry of personaRuns) {
925
970
  const file = path.join(runDirFor(repoRoot, jobId, entry.run_id), RUN_FILES.reportJson);
926
971
  try {
927
- out.push({ runId: entry.run_id, persona: entry.persona, state: 'succeeded', report: JSON.parse(await readFile(file, 'utf8')) });
972
+ const report = JSON.parse(await readFile(file, 'utf8'));
973
+ out.push({ runId: entry.run_id, persona: entry.persona, state: report && report.partial === true ? 'budget_exceeded' : 'succeeded', report });
928
974
  } catch {
929
975
  out.push({ runId: entry.run_id, persona: entry.persona, state: 'failed', report: null });
930
976
  }
@@ -344,7 +344,9 @@ export async function buildAggregate({
344
344
  const issues = clusters.flatMap(cluster => toIssues(cluster, verdicts.get(cluster.signature)));
345
345
  const ranked = rankIssues(issues, personasRun);
346
346
 
347
- const partial = runs.some(run => !run.report || ['failed', 'canceled', 'budget_exceeded'].includes(run.state));
347
+ // A run whose report is partial (the CLI wrote it from the notes when the
348
+ // money ran out) counts toward the issues, and makes the job partial.
349
+ const partial = runs.some(run => !run.report || run.report.partial === true || ['failed', 'canceled', 'budget_exceeded'].includes(run.state));
348
350
  const judgement = aggregateRunId
349
351
  ? await judgeAggregate({ client, jobId, runId: aggregateRunId, ranked, inputs, brief, intentNotes, ledger, log })
350
352
  : null;
@@ -374,8 +376,11 @@ export async function buildAggregate({
374
376
  state: run.state,
375
377
  actions_taken: run.report.actions_taken,
376
378
  quit_reason: run.report.quit_reason,
377
- sentiment: run.report.sentiment,
378
- would_continue: run.report.would_continue
379
+ // A partial report's sentiment is the schema's placeholder, not a
380
+ // persona verdict, so it is not shown as one.
381
+ sentiment: run.report.partial ? null : run.report.sentiment,
382
+ would_continue: run.report.partial ? null : run.report.would_continue,
383
+ ...(run.report.partial ? { partial: true } : {})
379
384
  })),
380
385
  issues: applied.issues,
381
386
  praise: ranked.praise,
@@ -450,7 +455,7 @@ export async function judgeAggregate({ client, jobId, runId, ranked, inputs, bri
450
455
  '',
451
456
  `issues:\n${ranked.issues.map(issue => ` ${issue.issue_id} [${issue.severity}/${issue.category}] ${issue.title}: ${String(issue.description || '').slice(0, 300)} (personas: ${issue.personas_affected.join(', ') || 'none'})`).join('\n') || ' (none)'}`,
452
457
  `praise:\n${ranked.praise.map(issue => ` ${issue.issue_id} ${issue.title}`).join('\n') || ' (none)'}`,
453
- `runs:\n${inputs.map(run => ` ${run.persona}: ${run.report.actions_taken} actions, ${run.report.quit_reason}, would_continue=${run.report.would_continue}`).join('\n') || ' (none)'}`
458
+ `runs:\n${inputs.map(run => ` ${run.persona}: ${run.report.actions_taken} actions, ${run.report.quit_reason}, ${run.report.partial ? 'partial (out of budget, no verdict)' : `would_continue=${run.report.would_continue}`}`).join('\n') || ' (none)'}`
454
459
  ].join('\n');
455
460
 
456
461
  try {
@@ -582,7 +587,7 @@ export function renderAggregateMarkdown(json) {
582
587
  lines.push('|---|---|---|---|---|---|---|');
583
588
  for (const run of json.runs) {
584
589
  const sentiment = run.sentiment || {};
585
- lines.push(`| ${run.persona} | ${run.actions_taken} | ${run.quit_reason} | ${sentiment.fun ?? '-'} | ${sentiment.clarity ?? '-'} | ${sentiment.frustration ?? '-'} | ${run.would_continue ? 'yes' : 'no'} |`);
590
+ lines.push(`| ${run.persona} | ${run.actions_taken} | ${run.quit_reason} | ${sentiment.fun ?? '-'} | ${sentiment.clarity ?? '-'} | ${sentiment.frustration ?? '-'} | ${run.would_continue === null || run.would_continue === undefined ? '-' : run.would_continue ? 'yes' : 'no'} |`);
586
591
  }
587
592
  lines.push('');
588
593
  lines.push('## Top issues (ranked)');
package/src/run/args.js CHANGED
@@ -22,7 +22,6 @@ const VALUED = new Map([
22
22
  ['--cli', 'cli'],
23
23
  ['--max-actions', 'maxActions'],
24
24
  ['--concurrency', 'concurrency'],
25
- ['--budget-usd', 'budgetUsd'],
26
25
  ['--quality', 'quality']
27
26
  ]);
28
27
 
@@ -41,7 +40,7 @@ const BOOLEAN = new Map([
41
40
  ]);
42
41
 
43
42
  /** Options whose value is a number. */
44
- const NUMERIC = new Set(['maxActions', 'concurrency', 'budgetUsd']);
43
+ const NUMERIC = new Set(['maxActions', 'concurrency']);
45
44
 
46
45
  /**
47
46
  * @param {string[]} argv the leftovers commander handed over
@@ -77,6 +76,21 @@ export function parseRunArgv(argv = []) {
77
76
  return { options, unknown };
78
77
  }
79
78
 
79
+ /**
80
+ * Extra lines for unknown options that used to exist, so a removed flag is
81
+ * explained rather than just refused.
82
+ *
83
+ * @param {string[]} unknown
84
+ * @returns {string[]}
85
+ */
86
+ export function unknownOptionNotes(unknown = []) {
87
+ const notes = [];
88
+ if (unknown.includes('--budget-usd')) {
89
+ notes.push('--budget-usd was removed: the model budget is set by the server for each run, from what the job was charged, and enforced at the proxy.');
90
+ }
91
+ return notes;
92
+ }
93
+
80
94
  /**
81
95
  * Merge the flags cli-core parsed with the ones this file parsed, flags
82
96
  * winning, and normalise the two names for the repository root.
@@ -115,4 +129,4 @@ function stripUndefined(value) {
115
129
  return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
116
130
  }
117
131
 
118
- export default { parseRunArgv, optionsFrom };
132
+ export default { parseRunArgv, optionsFrom, unknownOptionNotes };
@@ -31,6 +31,13 @@ export const MAX_IMAGES_IN_CONTEXT = 1;
31
31
  /** How many full observations stay in context (spec 03's K). */
32
32
  export const KEEP_OBSERVATIONS = 6;
33
33
 
34
+ /**
35
+ * What an observation identical to the one before it is sent as. The driver
36
+ * marks it (`observation.unchanged`) and ./tools.js writes this in place of
37
+ * the screen, keeping the `step N` line so it is still an observation.
38
+ */
39
+ export const UNCHANGED_OBSERVATION_TEXT = '(unchanged)';
40
+
34
41
  const DROPPED_IMAGE_TEXT = '[screenshot dropped from context; only the newest screen is kept]';
35
42
  const DROPPED_OBSERVATION_TEXT = '[older observation dropped from context; ask for a snapshot if you need to look again]';
36
43
 
@@ -138,7 +145,11 @@ export function pruneObservations(messages, keep = KEEP_OBSERVATIONS) {
138
145
  message.content.forEach((block, j) => {
139
146
  if (block && block.type === 'tool_result' && Array.isArray(block.content)) {
140
147
  const first = block.content.find(child => child && child.type === 'text');
141
- if (first && /^(?:[^\n]*\n)?step \d+/.test(String(first.text))) indexes.push([i, j]);
148
+ // An unchanged marker is not counted: it is a few bytes, and counting
149
+ // it would let a run of identical screens push the last REAL screen
150
+ // out of context, leaving the persona nothing to act on.
151
+ const text = String(first && first.text);
152
+ if (first && /^(?:[^\n]*\n)?step \d+/.test(text) && !text.endsWith(`\n${UNCHANGED_OBSERVATION_TEXT}`)) indexes.push([i, j]);
142
153
  }
143
154
  });
144
155
  });
@@ -2,7 +2,6 @@ import { mkdir, rename, stat, readdir } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import {
4
4
  DriverError,
5
- OBSERVATION_MAX_CHARS,
6
5
  THIN_OBSERVATION_CHARS,
7
6
  observationHash,
8
7
  screenshotName,
@@ -36,6 +35,87 @@ export const MAX_WAIT_MS = 10000;
36
35
  /** How long a single Playwright call may take before it is an error, not a hang. */
37
36
  export const ACTION_TIMEOUT_MS = 15000;
38
37
 
38
+ /**
39
+ * The web observation ceiling. An aria snapshot is re-sent on every turn the
40
+ * observation stays in context, and the run's model budget is fixed, so a busy
41
+ * page at the shared 12,000 character ceiling spends the budget on markup the
42
+ * persona never needed. Compaction (below) usually lands well under this.
43
+ */
44
+ export const WEB_OBSERVATION_MAX_CHARS = 4000;
45
+
46
+ /**
47
+ * Full observations kept in context for a web run. The newest screen is what
48
+ * the persona acts on; one more is enough to compare against, and every older
49
+ * one is a stub.
50
+ */
51
+ export const WEB_KEEP_OBSERVATIONS = 2;
52
+
53
+ /** Roles a player can act on. Kept even with no name, since they are still clickable. */
54
+ const INTERACTIVE_ROLES = new Set([
55
+ 'button', 'link', 'textbox', 'searchbox', 'checkbox', 'radio', 'combobox', 'listbox', 'option',
56
+ 'slider', 'spinbutton', 'switch', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'treeitem',
57
+ 'canvas'
58
+ ]);
59
+
60
+ /**
61
+ * Keep the nodes a player could act on or read, and drop the structure in
62
+ * between. An aria snapshot prints every wrapper (`- generic:`, `- list:`, a
63
+ * nameless `- img`) and the attribute lines under links (`- /url: ...`), none
64
+ * of which tell a persona anything, and all of which cost tokens every turn.
65
+ *
66
+ * Indentation is dropped too: the persona targets nodes by role and name, not
67
+ * by depth. Text that is not an aria snapshot (the innerText fallback for a
68
+ * thin page) is returned unchanged.
69
+ *
70
+ * @param {string} text
71
+ * @returns {string}
72
+ */
73
+ export function compactAriaSnapshot(text) {
74
+ const value = String(text == null ? '' : text);
75
+ const lines = value.split('\n');
76
+ if (!lines.some(line => /^\s*- /.test(line))) return value;
77
+ const kept = [];
78
+ for (const line of lines) {
79
+ const trimmed = line.trim();
80
+ if (!trimmed.startsWith('- ')) continue;
81
+ const body = unquoteKey(trimmed.slice(2));
82
+ if (body.startsWith('/')) continue;
83
+ const role = (body.match(/^([a-z]+)/) || [])[1] || '';
84
+ const named = /^[a-z]+ "[^"]*"/.test(body);
85
+ const inlineText = /^[a-z]+(?: "[^"]*")?(?: \[[^\]]*\])*: \S/.test(body);
86
+ if (INTERACTIVE_ROLES.has(role) || named || inlineText) kept.push(`- ${body}`);
87
+ }
88
+ return kept.join('\n');
89
+ }
90
+
91
+ /**
92
+ * Playwright writes a node key as a single quoted YAML scalar when the name
93
+ * contains `: `, ` #`, a brace or a backtick: `- 'button "Player #1"'`. Strip
94
+ * that outer pair (a doubled `''` inside is one quote) so the node is
95
+ * classified and shown like any other, keeping whatever followed the closing
96
+ * quote (a `:` for a node with children, or `: text`).
97
+ *
98
+ * @param {string} body the line after its `- `
99
+ * @returns {string}
100
+ */
101
+ export function unquoteKey(body) {
102
+ if (!body.startsWith("'")) return body;
103
+ let out = '';
104
+ for (let i = 1; i < body.length; i += 1) {
105
+ if (body[i] === "'") {
106
+ if (body[i + 1] === "'") {
107
+ out += "'";
108
+ i += 1;
109
+ continue;
110
+ }
111
+ return out + body.slice(i + 1);
112
+ }
113
+ out += body[i];
114
+ }
115
+ // No closing quote: not a quoted key after all.
116
+ return body;
117
+ }
118
+
39
119
  const KNOWN_SELECTOR_ENGINES = /^(role|text|css|xpath|id|data-testid|internal:)=/;
40
120
 
41
121
  /**
@@ -127,12 +207,33 @@ export function resolveLocator(page, target) {
127
207
  return page.getByText(value, { exact: false }).first();
128
208
  }
129
209
 
210
+ /**
211
+ * Hand the run's playtest identity to the page. Runs in the browser, before
212
+ * any of the game's own scripts, so the Ravensight JS SDK finds it at
213
+ * construction. Serialised by Playwright, so it must not close over anything.
214
+ *
215
+ * @param {{token: string, runId: string, jobId: string, persona: string}} identity
216
+ */
217
+ function injectPlaytestIdentity(identity) {
218
+ window.__RAVENSIGHT_PLAYTEST__ = identity;
219
+ try {
220
+ localStorage.setItem('ravensight.playtest', JSON.stringify(identity));
221
+ } catch {
222
+ // Storage can be refused (a sandboxed frame, a privacy mode). The window
223
+ // global is the first thing the SDK reads, so this is only the fallback.
224
+ }
225
+ }
226
+
130
227
  /**
131
228
  * @param {Object} opts
132
229
  * @param {(specifier: string) => Promise<any>} [opts.importer] test seam
230
+ * @param {boolean} [opts.headless]
231
+ * @param {{token?: string, runId?: string, jobId?: string, persona?: string}} [opts.playtest]
232
+ * the run's playtest identity, handed to the page so the game's own SDK
233
+ * session is marked synthetic
133
234
  * @returns {Object} a driver
134
235
  */
135
- export function createWebDriver({ importer, headless = true } = {}) {
236
+ export function createWebDriver({ importer, headless = true, playtest = null } = {}) {
136
237
  let browser = null;
137
238
  let context = null;
138
239
  let page = null;
@@ -142,6 +243,7 @@ export function createWebDriver({ importer, headless = true } = {}) {
142
243
  let step = 0;
143
244
  let shots = 0;
144
245
  let lastHash = null;
246
+ let lastObservedHash = null;
145
247
  const consoleBuffer = [];
146
248
 
147
249
  /** @returns {any} */
@@ -150,9 +252,15 @@ export function createWebDriver({ importer, headless = true } = {}) {
150
252
  return page;
151
253
  }
152
254
 
255
+ /**
256
+ * @returns {Promise<{text: string, aria: boolean}>} the screen text, and
257
+ * whether it is the aria snapshot (compacted) or the innerText fallback
258
+ * (never compacted: a bullet list in the page is not snapshot structure)
259
+ */
153
260
  async function snapshotText() {
154
261
  const current = requirePage();
155
262
  let text = '';
263
+ let aria = true;
156
264
  const body = current.locator('body');
157
265
  if (typeof body.ariaSnapshot === 'function') {
158
266
  try {
@@ -167,17 +275,31 @@ export function createWebDriver({ importer, headless = true } = {}) {
167
275
  const el = globalThis.document && globalThis.document.body;
168
276
  return el ? el.innerText : '';
169
277
  });
170
- if (String(inner).trim().length > text.trim().length) text = String(inner);
278
+ if (String(inner).trim().length > text.trim().length) {
279
+ text = String(inner);
280
+ aria = false;
281
+ }
171
282
  } catch {
172
283
  // A page mid-navigation has no body to read. The thin observation
173
284
  // is the honest answer, and the screenshot fallback below covers it.
174
285
  }
175
286
  }
176
- return text;
287
+ return { text, aria };
288
+ }
289
+
290
+ /**
291
+ * What the model is shown for a raw snapshot. `observe` and `act` both use
292
+ * this, so the two hashes compare like with like.
293
+ * @param {{text: string, aria: boolean}} snap
294
+ * @returns {string}
295
+ */
296
+ function shownText(snap) {
297
+ return truncateObservation(snap.aria ? compactAriaSnapshot(snap.text) : snap.text, WEB_OBSERVATION_MAX_CHARS);
177
298
  }
178
299
 
179
300
  return {
180
301
  name: 'playwright_web',
302
+ keepObservations: WEB_KEEP_OBSERVATIONS,
181
303
 
182
304
  /**
183
305
  * @param {Object} opts
@@ -200,6 +322,18 @@ export function createWebDriver({ importer, headless = true } = {}) {
200
322
  viewport,
201
323
  ...(videoRequested ? { recordVideo: { dir: runDir, size: viewport } } : {})
202
324
  });
325
+ // The playtest identity goes in as an init script, not as
326
+ // `extraHTTPHeaders`: a context header rides on every request the page
327
+ // makes, including to the game's own API, and the token is only for
328
+ // Ravensight. The SDK reads it from the page and sends it itself.
329
+ if (playtest && playtest.token) {
330
+ await context.addInitScript(injectPlaytestIdentity, {
331
+ token: playtest.token,
332
+ runId: playtest.runId || null,
333
+ jobId: playtest.jobId || null,
334
+ persona: playtest.persona || null
335
+ });
336
+ }
203
337
  page = await context.newPage();
204
338
  page.on('console', message => {
205
339
  if (message.type() === 'error') consoleBuffer.push({ type: 'console', text: message.text() });
@@ -219,10 +353,11 @@ export function createWebDriver({ importer, headless = true } = {}) {
219
353
  const current = requirePage();
220
354
  const started = Date.now();
221
355
  step += 1;
222
- const text = await snapshotText();
356
+ const snap = await snapshotText();
357
+ const text = snap.text;
223
358
  const observation = {
224
359
  step,
225
- text: truncateObservation(text, OBSERVATION_MAX_CHARS),
360
+ text: shownText(snap),
226
361
  url: current.url(),
227
362
  elapsedMs: Date.now() - started
228
363
  };
@@ -244,6 +379,11 @@ export function createWebDriver({ importer, headless = true } = {}) {
244
379
  // point would otherwise read as changed to `act` and identical to
245
380
  // anything reading the observation.
246
381
  lastHash = observationHash(observation);
382
+ // The same screen twice is sent as a marker rather than in full
383
+ // (./tools.js), which is most of what a stuck persona would otherwise
384
+ // pay for.
385
+ if (lastHash === lastObservedHash) observation.unchanged = true;
386
+ lastObservedHash = lastHash;
247
387
  return observation;
248
388
  },
249
389
 
@@ -293,9 +433,9 @@ export function createWebDriver({ importer, headless = true } = {}) {
293
433
  try {
294
434
  // The same truncation observe() applies, so the two hashes are
295
435
  // comparable. Without it a long screen is always "changed".
296
- const text = truncateObservation(await snapshotText(), OBSERVATION_MAX_CHARS);
297
- const after = { text };
298
- if (text.trim().length < THIN_OBSERVATION_CHARS) {
436
+ const snap = await snapshotText();
437
+ const after = { text: shownText(snap) };
438
+ if (String(snap.text).trim().length < THIN_OBSERVATION_CHARS) {
299
439
  const frame = await current.screenshot({ type: 'png', timeout: ACTION_TIMEOUT_MS });
300
440
  after.screenshotBase64 = frame.toString('base64');
301
441
  }
@@ -3,6 +3,7 @@ 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
5
  import { observationHash } from './drivers/driver.js';
6
+ import { withRollingCacheBreakpoint } from './fixLoop.js';
6
7
  import { callStepWithRetry, ProxyError, STEPS } from './model.js';
7
8
  import { RUN_FILES } from './paths.js';
8
9
  import { checkEvidence, createReportValidator, lintMarkdownSections, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
@@ -32,11 +33,30 @@ import { createUsageLedger, writeUsageFile } from './usage.js';
32
33
  * up, so the report (which is the product) gets written while there is
33
34
  * still budget to write it with.
34
35
  * - A 402 means the money is gone. Nothing further is asked of the model,
35
- * including the report: a partial `report.md` is written locally from the
36
- * notes, `complete` is sent with `state: budget_exceeded` and no
37
- * `report`, and the process exits 2.
36
+ * including the report: a partial `report.md` and `report.json` are
37
+ * written locally from the notes (`partial: true`, each note an
38
+ * unreviewed finding), `complete` is sent with `state: budget_exceeded`
39
+ * and that report, and the process exits 2. The notes are the one thing
40
+ * the run bought before the money ran out, so they are not thrown away.
38
41
  */
39
42
 
43
+ /**
44
+ * Order across notes and screenshots, so a partial report can attach the
45
+ * screenshot nearest to each note. Module wide and only ever compared within
46
+ * one run, where it is monotonic.
47
+ */
48
+ let ORDER = 0;
49
+ const nextOrder = () => {
50
+ ORDER += 1;
51
+ return ORDER;
52
+ };
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/;
56
+
57
+ /** A persona note kind as a report finding category. */
58
+ export const NOTE_CATEGORY = Object.freeze({ bug: 'bug', confusing: 'ux', suggestion: 'ux', good: 'ux', reaction: 'ux' });
59
+
40
60
  /** Stop injecting the "nothing changed" hint after this many identical screens. */
41
61
  export const STUCK_HINT_AFTER = 5;
42
62
 
@@ -89,7 +109,9 @@ export async function runPersona(args) {
89
109
  schemas,
90
110
  maxActions,
91
111
  maxImages = MAX_IMAGES_IN_CONTEXT,
92
- keepObservations = KEEP_OBSERVATIONS,
112
+ // A driver may say how many observations it needs in context: a web
113
+ // snapshot is big and re-sent every turn, a Godot scene summary is not.
114
+ keepObservations = (driver && Number.isInteger(driver.keepObservations) ? driver.keepObservations : KEEP_OBSERVATIONS),
93
115
  heartbeat = null,
94
116
  onProgress = () => {},
95
117
  log = () => {},
@@ -188,7 +210,15 @@ export async function runPersona(args) {
188
210
  outcome.quitReason = 'cost_cap';
189
211
  // Nothing more is asked of the model: there is no budget left to ask
190
212
  // with, and a refused call would just be another 402.
191
- await writePartialMarkdown({ runDirectory, persona, driverName, target, notes, screenshots, play, reason: 'The run reached its model budget before the report step. What the persona saw is below; there is no report.json for this run.' });
213
+ // A report that cannot be written (a secret shaped note, a full disk)
214
+ // must not turn an out of budget run into a failed one.
215
+ try {
216
+ await writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, play, skill, ledger, runId, jobId, startedAt, endedAt });
217
+ } catch (writeError) {
218
+ outcome.report = null;
219
+ outcome.reportMarkdown = null;
220
+ log(`the partial report could not be written: ${String(writeError && writeError.message)}`);
221
+ }
192
222
  return finish(outcome, ledger, runDirectory, transcript, heartbeat);
193
223
  }
194
224
 
@@ -230,6 +260,14 @@ export async function runPersona(args) {
230
260
  if (error instanceof ProxyError && error.isBudget) {
231
261
  outcome.state = 'budget_exceeded';
232
262
  outcome.quitReason = 'cost_cap';
263
+ // The money ran out in the report step itself: the notes are still
264
+ // worth keeping, by the same rule as a 402 during play.
265
+ try {
266
+ const play = { actionsTaken: outcome.actionsTaken, quitReason: 'cost_cap', quitDetail: String(error.message || '') };
267
+ await writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, play, skill, ledger, runId, jobId, startedAt, endedAt: new Date().toISOString() });
268
+ } catch (writeError) {
269
+ log(`the partial report could not be written: ${String(writeError && writeError.message)}`);
270
+ }
233
271
  } else if (error instanceof ProxyError && error.isClosed) {
234
272
  outcome.state = 'canceled';
235
273
  outcome.quitReason = 'error';
@@ -420,11 +458,11 @@ async function playLoop({
420
458
  // (bug, confusing, good, suggestion, reaction) would otherwise
421
459
  // overwrite the transcript line's `kind`, and every note would
422
460
  // read as a different sort of line.
423
- notes.push({ ...note, at_action: actions });
461
+ notes.push({ ...note, at_action: actions, order: nextOrder() });
424
462
  transcript.write({ kind: 'note', tool: toolUse.name, note });
425
463
  },
426
464
  onScreenshot: shot => {
427
- screenshots.push(shot);
465
+ screenshots.push({ ...shot, order: nextOrder() });
428
466
  transcript.write({ kind: 'screenshot', path: shot.relativePath, reason: shot.reason });
429
467
  }
430
468
  });
@@ -466,13 +504,17 @@ async function playLoop({
466
504
 
467
505
  const consoleErrors = typeof driver.consoleErrors === 'function' ? await driver.consoleErrors() : [];
468
506
  if (consoleErrors.length > 0) {
469
- // An uncaught error in the game is a finding whether or not the
507
+ // An uncaught error in the game is worth recording whether or not the
470
508
  // persona noticed it, so it is logged and evidenced automatically
471
- // (spec 03). It is the model's job to decide the severity.
509
+ // (spec 03). It carries no severity: the report step decides that,
510
+ // with the persona's view of the screen in front of it. A 4xx on a
511
+ // resource is a `network` note, which the report step reads as context
512
+ // rather than as a bug (a missing favicon is not a broken game).
472
513
  const shot = await safeScreenshot(driver, 'console-error', screenshots, transcript);
473
514
  for (const entry of consoleErrors) {
474
- const note = { kind: 'bug', severity: 'major', text: `${entry.type}: ${entry.text}`, automatic: true };
475
- notes.push(note);
515
+ 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() });
476
518
  transcript.write({ kind: 'note', note });
477
519
  }
478
520
  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}.` : ''}`);
@@ -560,6 +602,8 @@ async function reportPhase({
560
602
  'Notes you logged, in order:',
561
603
  notes.length > 0 ? notes.map((note, i) => `${i + 1}. [${note.kind}${note.severity ? `/${note.severity}` : ''}] ${note.text}`).join('\n') : '(none)',
562
604
  '',
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.',
606
+ '',
563
607
  'Screenshots available as evidence (use these exact paths in evidence refs):',
564
608
  screenshots.length > 0 ? screenshots.map(shot => `- ${shot.relativePath}${shot.reason ? ` (${shot.reason})` : ''}`).join('\n') : '(none)',
565
609
  '',
@@ -685,7 +729,10 @@ async function reportPhase({
685
729
  async function callWithImageRetry({ imageBudget, keepObservations, messages, onImageBudget, ...rest }) {
686
730
  let budget = imageBudget;
687
731
  for (let attempt = 0; attempt < 3; attempt += 1) {
688
- const prepared = prepareMessages(messages, { maxImages: budget, keepObservations });
732
+ // One moving cache breakpoint on the newest block, as the fix loop does:
733
+ // the prefix up to it is what the next turn re-sends, so the next turn
734
+ // reads it from the cache instead of paying for it again.
735
+ const prepared = withRollingCacheBreakpoint(prepareMessages(messages, { maxImages: budget, keepObservations }));
689
736
  try {
690
737
  return await callStepWithRetry({ ...rest, messages: prepared });
691
738
  } catch (error) {
@@ -801,7 +848,7 @@ function redactToolInput(toolUse) {
801
848
  async function safeScreenshot(driver, name, screenshots, transcript) {
802
849
  try {
803
850
  const shot = await driver.screenshot(name);
804
- screenshots.push({ ...shot, reason: name });
851
+ screenshots.push({ ...shot, reason: name, order: nextOrder() });
805
852
  transcript.write({ kind: 'screenshot', path: shot.relativePath, reason: name });
806
853
  return shot;
807
854
  } catch (error) {
@@ -893,6 +940,104 @@ export function renderFallbackMarkdown({ persona, driverName, target, notes, scr
893
940
  ].join('\n');
894
941
  }
895
942
 
943
+ /**
944
+ * The screenshot nearest a note, by the order they happened in; a tie goes
945
+ * to the later one, since a persona usually notes something and then
946
+ * captures it.
947
+ *
948
+ * @param {Object} note
949
+ * @param {Array<Object>} screenshots
950
+ * @returns {Object|null}
951
+ */
952
+ export function nearestScreenshot(note, screenshots) {
953
+ let best = null;
954
+ let bestDistance = Infinity;
955
+ for (const shot of screenshots) {
956
+ if (!shot || !shot.relativePath || !Number.isFinite(shot.order) || !Number.isFinite(note.order)) continue;
957
+ const distance = Math.abs(shot.order - note.order);
958
+ if (distance < bestDistance || (distance === bestDistance && best && shot.order > best.order)) {
959
+ best = shot;
960
+ bestDistance = distance;
961
+ }
962
+ }
963
+ return best || screenshots.find(shot => shot && shot.relativePath) || null;
964
+ }
965
+
966
+ /**
967
+ * The persona's notes as report findings, for a run with no report step.
968
+ *
969
+ * Nothing is judged here, because there is no model turn to judge with:
970
+ * the persona's own kind becomes the category, its own severity is kept
971
+ * when it gave one and is `minor` otherwise (`praise` for a `good` note), and
972
+ * a network note is left out, since it is context rather than a finding.
973
+ *
974
+ * @param {Array<Object>} notes
975
+ * @param {Array<Object>} screenshots
976
+ * @returns {Array<Object>}
977
+ */
978
+ export function notesToFindings(notes, screenshots) {
979
+ const findings = [];
980
+ for (const note of notes) {
981
+ const category = NOTE_CATEGORY[note.kind];
982
+ if (!category || !note.text) continue;
983
+ const text = String(note.text);
984
+ const shot = nearestScreenshot(note, screenshots);
985
+ findings.push({
986
+ id: `F${String(findings.length + 1).padStart(3, '0')}`,
987
+ severity: note.kind === 'good' ? 'praise' : (note.severity && note.severity !== 'praise' ? note.severity : 'minor'),
988
+ category,
989
+ title: text.length > 120 ? `${text.slice(0, 117)}...` : text,
990
+ description: text,
991
+ repro_steps: [`Seen after ${Number(note.at_action) || 0} action(s) in this run.`],
992
+ evidence: shot
993
+ ? [{ type: 'screenshot', ref: shot.relativePath }]
994
+ : [{ type: 'transcript_step', ref: `action ${Number(note.at_action) || 0}` }]
995
+ });
996
+ }
997
+ return findings;
998
+ }
999
+
1000
+ /**
1001
+ * The report for a run the money ran out on: the notes as findings,
1002
+ * `partial: true`, and both files written. The sentiment is the scale's
1003
+ * midpoint and `would_continue` is false, because the schema requires both
1004
+ * and no persona verdict was recorded; `partial` is what tells a reader not
1005
+ * to read them as one.
1006
+ */
1007
+ async function writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, play, skill, ledger, runId, jobId, startedAt, endedAt }) {
1008
+ const report = stampReport({
1009
+ findings: notesToFindings(notes, screenshots),
1010
+ sentiment: { fun: 3, clarity: 3, frustration: 3 },
1011
+ would_continue: false,
1012
+ partial: true
1013
+ }, {
1014
+ runId,
1015
+ jobId,
1016
+ persona: persona.slug,
1017
+ driver: driverName,
1018
+ target,
1019
+ startedAt,
1020
+ endedAt,
1021
+ actionsTaken: play.actionsTaken,
1022
+ usage: ledger.toReportUsage(),
1023
+ promptVersion: promptVersionOf(skill) || 'unknown',
1024
+ quitReason: 'cost_cap',
1025
+ quitDetail: play.quitDetail
1026
+ });
1027
+ const markdown = renderFallbackMarkdown({
1028
+ persona,
1029
+ driverName,
1030
+ target,
1031
+ notes,
1032
+ screenshots,
1033
+ play,
1034
+ 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
+ });
1036
+ await writeReportFiles({ runDirectory, markdown, report });
1037
+ outcome.report = report;
1038
+ outcome.reportMarkdown = markdown;
1039
+ }
1040
+
896
1041
  async function writePartialMarkdown({ runDirectory, persona, driverName, target, notes, screenshots, play, reason }) {
897
1042
  const markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason });
898
1043
  assertNoSecrets('report.md', markdown);
@@ -911,4 +1056,4 @@ export function promptVersionOf(skill) {
911
1056
  return match ? match[1] : null;
912
1057
  }
913
1058
 
914
- export default { runPersona, buildSystem, promptVersionOf, shouldEscalate, renderFallbackMarkdown };
1059
+ export default { runPersona, buildSystem, promptVersionOf, shouldEscalate, renderFallbackMarkdown, notesToFindings, nearestScreenshot };
package/src/run/tools.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { UNCHANGED_OBSERVATION_TEXT } from './context.js';
1
2
  import { OBSERVATION_MAX_CHARS, truncateObservation } from './drivers/driver.js';
2
3
 
3
4
  /**
@@ -300,13 +301,17 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot }) {
300
301
  * @param {boolean} [isError]
301
302
  */
302
303
  export function observationResult(id, observation, prefix = '', isError = false) {
304
+ // The same screen as last time is a marker, not a resend: the persona
305
+ // already has it in context, and a stuck run would otherwise pay for the
306
+ // whole snapshot (and its image) on every turn it stays stuck.
307
+ const body = observation.unchanged ? UNCHANGED_OBSERVATION_TEXT : truncateObservation(observation.text, OBSERVATION_MAX_CHARS);
303
308
  const content = [
304
309
  {
305
310
  type: 'text',
306
- text: `${prefix}step ${observation.step}${observation.url ? ` (${observation.url})` : ''}${citationLine(observation)}\n${truncateObservation(observation.text, OBSERVATION_MAX_CHARS)}`
311
+ text: `${prefix}step ${observation.step}${observation.url ? ` (${observation.url})` : ''}${citationLine(observation)}\n${body}`
307
312
  }
308
313
  ];
309
- if (observation.screenshotBase64) {
314
+ if (observation.screenshotBase64 && !observation.unchanged) {
310
315
  content.push({
311
316
  type: 'image',
312
317
  source: {
package/src/ui/index.js CHANGED
@@ -157,6 +157,33 @@ export async function confirm(question, options = {}) {
157
157
  }
158
158
  }
159
159
 
160
+ /**
161
+ * Ask the person to pick one of a numbered list. Like `confirm`, it never
162
+ * asks without a terminal: with no TTY (CI, a pipe) it answers null at once,
163
+ * and so does an answer that is not one of the numbers shown.
164
+ *
165
+ * @param {string} question e.g. "Which game? [1-3]"
166
+ * @param {Array<string>} labels one line per choice, printed numbered from 1
167
+ * @param {{input?: object, output?: object}} [options]
168
+ * @returns {Promise<number|null>} the zero based index, or null
169
+ */
170
+ export async function choose(question, labels, options = {}) {
171
+ const input = options.input || process.stdin;
172
+ if (!input.isTTY) return null;
173
+ const output = options.output || stream;
174
+
175
+ labels.forEach((label, index) => output.write(` ${index + 1}. ${label}\n`));
176
+ const rl = createInterface({ input, output });
177
+ try {
178
+ const answer = (await rl.question(`${question} `)).trim();
179
+ if (!/^\d+$/.test(answer)) return null;
180
+ const index = Number(answer) - 1;
181
+ return index >= 0 && index < labels.length ? index : null;
182
+ } finally {
183
+ rl.close();
184
+ }
185
+ }
186
+
160
187
  /** Cents as dollars, the one place that formatting exists. */
161
188
  export function money(cents) {
162
189
  if (cents === null || cents === undefined) return 'unknown';
@@ -190,6 +217,7 @@ export const ui = {
190
217
  table,
191
218
  spinner,
192
219
  confirm,
220
+ choose,
193
221
  money,
194
222
  bytes
195
223
  };