ravensight-playtest 0.7.2 → 0.7.5

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
@@ -66,14 +66,20 @@ The hosted build command source adds a local check before a paid upload:
66
66
 
67
67
  ```sh
68
68
  ravensight-playtest build preflight ./game.zip --format web --entrypoint index.html
69
- ravensight-playtest build upload ./game.zip --game GAME --profile READY_PROFILE --entrypoint index.html --dry-run
69
+ ravensight-playtest build upload ./game.zip --game GAME --entrypoint index.html --dry-run
70
+ ravensight-playtest build status --game GAME
70
71
  ```
71
72
 
72
73
  `preflight` seeks through bounded ZIP metadata; it does not load a multi-GB
73
74
  archive into memory. A saved server profile can be supplied with `--capacity`
74
- for its declared limits. Upload selects a currently advertised ready profile,
75
- checks metadata and a streamed checksum, then asks for the separate $0.49
76
- validation hold before requesting it. The server’s isolated validator remains
75
+ for its declared limits. Upload uses the server's build profile when it
76
+ advertises exactly one; when it advertises several, name one with `--profile`
77
+ and the error lists them. It checks metadata and a streamed checksum, then
78
+ asks for the separate $0.49 validation hold before requesting it. Without a
79
+ terminal to ask in, pass `--yes`: a piped answer is never taken as consent.
80
+ `build status` shows each build's state and, for a build the server's check
81
+ refused, the reason in one sentence and whether its validation hold is still
82
+ to be returned after cleanup. The server’s isolated validator remains
77
83
  authoritative. A passing local check does not establish engine, GPU or license
78
84
  compatibility.
79
85
 
@@ -1109,8 +1115,16 @@ at the current $1 unit price total $2; the displayed server quote is authoritati
1109
1115
  No comparison model call or extra recording fee is added. Use `--upload-video`
1110
1116
  instead of `--record-video` only when you want the videos uploaded for private
1111
1117
  dashboard playback. Recording is silent and captures only the selected game,
1112
- not your desktop. Browser popups and navigation to another origin are blocked
1113
- while recording.
1118
+ not your desktop.
1119
+
1120
+ A web persona stays on the build, recording or not. A clicked link, a form, a
1121
+ script or a server redirect to another site, or to another port on your
1122
+ machine, is stopped (a redirect is undone), and a new window is closed. The
1123
+ persona is told "Refused: that link leaves the build under test (host). Stay
1124
+ on this page and use its controls." and the step counts as one that changed
1125
+ nothing. Each attempt is a network note in the report. `localhost`,
1126
+ `127.0.0.1` and `[::1]` on the build's own port are the build. Assets and API
1127
+ calls the page loads from elsewhere are not affected.
1114
1128
 
1115
1129
  Web recording produces WebM. Graphical Godot recording needs an existing FFmpeg
1116
1130
  installation and produces MP4; a headless Godot run or missing encoder is reported
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ravensight-playtest",
3
- "version": "0.7.2",
3
+ "version": "0.7.5",
4
4
  "description": "Local AI playtests and seeded game simulations with reports in Ravensight.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ravensight.io/docs/#playtest",
@@ -46,9 +46,9 @@
46
46
  "./api": "./src/api/index.js"
47
47
  },
48
48
  "scripts": {
49
- "test": "node --test --test-reporter=spec 'src/**/!(godot-real|web.e2e|scaffold.godot|template3d.godot).test.js'",
49
+ "test": "node --test --test-reporter=spec 'src/**/!(godot-real|web.e2e|web.containment.e2e|scaffold.godot|template3d.godot).test.js'",
50
50
  "test:godot": "node --test --test-reporter=spec src/run/drivers/godot-real.test.js src/create/scaffold.godot.test.js src/create/template3d.godot.test.js",
51
- "test:e2e": "node --test --test-reporter=spec src/run/drivers/web.e2e.test.js",
51
+ "test:e2e": "node --test --test-reporter=spec src/run/drivers/web.e2e.test.js src/run/drivers/web.containment.e2e.test.js",
52
52
  "lint": "node scripts/no-dashes.mjs"
53
53
  },
54
54
  "dependencies": {
package/src/cli.js CHANGED
@@ -211,13 +211,21 @@ export function buildProgram() {
211
211
  const { buildPreflight } = await import('./commands/build.js');
212
212
  return buildPreflight(archive, flagsFrom(command.optsWithGlobals(), { entrypoint: options.entrypoint, format: options.format, capacity: options.capacity }));
213
213
  }));
214
+ build.command('status [build-id]')
215
+ .description('show each private hosted build, why a refused one was refused, and whether its hold is still to be returned')
216
+ .option('--game <id>', 'game id, overriding this repo’s configuration')
217
+ .action(capture(slot, async (buildId, options, command) => {
218
+ const { buildStatus } = await import('./commands/build.js');
219
+ return buildStatus(buildId, flagsFrom(command.optsWithGlobals(), { game: options.game }));
220
+ }));
214
221
  build.command('upload <archive>')
215
222
  .description('preflight and stream one private build upload after validation-hold consent')
216
- .requiredOption('--profile <version>', 'one currently advertised ready server profile')
223
+ .option('--profile <version>', 'server build profile to upload under; optional when the server advertises exactly one, otherwise the error lists the choices')
217
224
  .requiredOption('--entrypoint <path>', 'entry file inside the ZIP')
218
225
  .option('--game <id>', 'game id, overriding this repo’s configuration')
219
226
  .option('--journal <file>', 'preserve this original upload intent at the specified path')
220
227
  .option('--dry-run', 'inspect and hash the archive without requesting a reservation')
228
+ .addHelpText('after', '\nThe $0.49 validation hold is asked for at a terminal. Without one, pass --yes\nto accept it; piped input is never taken as consent. See the result with: ravensight-playtest build status')
221
229
  .action(capture(slot, async (archive, options, command) => {
222
230
  const { buildUpload } = await import('./commands/build.js');
223
231
  return buildUpload(archive, flagsFrom(command.optsWithGlobals(), { entrypoint: options.entrypoint, profile: options.profile, game: options.game, journal: options.journal, dryRun: options.dryRun }));
@@ -273,7 +273,7 @@ async function editBrief(context, flags, deps) {
273
273
  parentVersion,
274
274
  onStale: async version => {
275
275
  ui.warn(`The brief moved to version ${version} while you were editing.`);
276
- return ui.confirm('Save on top of that version?', { yes: flags.yes });
276
+ return ui.confirm('Save on top of that version?', { yes: flags.yes, noTerminalMessage: 'No terminal to confirm in: pass --yes to save on top of the newer version.' });
277
277
  }
278
278
  });
279
279
 
@@ -4,6 +4,7 @@ import { resolveGameId } from '../config.js';
4
4
  import { withAuthHandling } from '../auth/session.js';
5
5
  import { preflightBuildArchive, limitsFromProfile } from '../hosted/archivePreflight.js';
6
6
  import { uploadHostedBuild } from '../hosted/buildUpload.js';
7
+ import { buildStatusLines, readHostedBuilds } from '../hosted/buildStatus.js';
7
8
  import { CliError, ExitCode } from '../errors.js';
8
9
  import { ui } from '../ui/index.js';
9
10
 
@@ -61,6 +62,7 @@ export async function buildUpload(filename, flags = {}, deps = {}) {
61
62
  onPrepared: prepared => {
62
63
  progress.stop();
63
64
  if (flags.json) return;
65
+ ui.info(`Build profile: ${prepared.profileVersion}${flags.profile ? '' : ', the only one the server advertises'}.`);
64
66
  ui.info(`ZIP metadata checked: ${prepared.entries} entries; ${ui.bytes(prepared.archiveBytes)} archive, ${ui.bytes(prepared.expandedBytes)} expanded.`);
65
67
  const limit = prepared.limits;
66
68
  ui.info(`Selected limits: archive ${ui.bytes(limit.archiveBytes)}, expanded ${ui.bytes(limit.expandedBytes)}, file ${ui.bytes(limit.fileBytes)}, ${limit.files} entries, depth ${limit.depth}, ratio ${limit.ratio}:1.`);
@@ -73,9 +75,29 @@ export async function buildUpload(filename, flags = {}, deps = {}) {
73
75
  });
74
76
  progress.stop();
75
77
  if (flags.json) ui.json(result);
76
- else if (result.state === 'awaiting_validation') ui.ok(`Uploaded ${result.buildId}; awaiting server validation. Journal: ${result.journal}`);
78
+ else if (result.state === 'awaiting_validation') {
79
+ ui.ok(`Uploaded ${result.buildId}; awaiting server validation. Journal: ${result.journal}`);
80
+ ui.info(`See the result, and the reason if it is not accepted: ravensight-playtest build status ${result.buildId}`);
81
+ }
77
82
  else ui.info(result.state === 'declined' ? 'Upload declined; no reservation requested.' : 'Preflight finished; no reservation requested.');
78
83
  return result.state === 'declined' ? ExitCode.CANCELED : ExitCode.OK;
79
84
  } finally { progress.stop(); }
80
85
  });
81
86
  }
87
+
88
+ // State of the game's private hosted builds, with the reason a refused build
89
+ // was refused and whether its validation hold is still to be returned.
90
+ export async function buildStatus(buildId, flags = {}, deps = {}) {
91
+ const context = deps.context || await createContext({ apiUrl: flags.apiUrl, repoRoot: flags.repoRoot, requireAuth: true });
92
+ return withAuthHandling(context, async () => {
93
+ const builds = await readHostedBuilds({ api: context.api, gameId: resolveGameId(flags, context.config), buildId });
94
+ if (flags.json) { ui.json({ builds }); return ExitCode.OK; }
95
+ if (!builds.length) {
96
+ if (buildId) throw new CliError(`No hosted build ${buildId} for this game. A refused build is listed until its cleanup finishes.`);
97
+ ui.info('No private hosted builds for this game.');
98
+ return ExitCode.OK;
99
+ }
100
+ for (const build of builds) buildStatusLines(build).forEach(text => ui.info(text));
101
+ return ExitCode.OK;
102
+ });
103
+ }
@@ -162,7 +162,8 @@ export async function hostedCancel(admissionId, flags = {}, deps = {}) {
162
162
  const now = deps.now || Date.now;
163
163
  return withAuthHandling(context, async () => {
164
164
  const api = context.api, apiUrl = api.apiUrl, gameId = resolveGameId(flags, context.config);
165
- const confirm = deps.confirm || (question => ui.confirm(question, { yes: Boolean(flags.yes) }));
165
+ const confirm = deps.confirm || (question => ui.confirm(question, { yes: Boolean(flags.yes),
166
+ noTerminalMessage: 'No terminal to confirm in: pass --yes to cancel this run.' }));
166
167
  if (!await confirm(`Cancel hosted run ${admissionId}? Work already completed follows the accepted policy; unstarted work is refunded during settlement.`)) {
167
168
  if (flags.json) ui.json({ state: 'kept', admissionId });
168
169
  else ui.info('Nothing was canceled.');
@@ -0,0 +1,35 @@
1
+ import { printable } from '../ui/index.js';
2
+
3
+ const base = game => `/api/v1/games/${encodeURIComponent(game)}/playtest/hosted`;
4
+
5
+ const LABELS = {
6
+ uploading: 'uploading',
7
+ awaiting_validation: 'waiting for its archive check',
8
+ ready: 'ready',
9
+ graphical_static_validated: 'archive check passed',
10
+ rejected: 'not accepted',
11
+ deleting: 'being removed'
12
+ };
13
+
14
+ // Server text is one fixed sentence; still print it as one plain line.
15
+ const line = text => printable(String(text || '')).replace(/\s+/g, ' ').trim().slice(0, 300);
16
+
17
+ /** The plain lines that describe one build: its state, why it was refused, and the hold. */
18
+ export function buildStatusLines(build) {
19
+ const state = build?.state === 'deleting' && build.rejection ? 'not accepted, being removed' : (LABELS[build?.state] || line(build?.state) || 'unknown');
20
+ const lines = [`${line(build?.buildId)}${build?.entrypoint ? ` (${line(build.entrypoint)})` : ''}: ${state}.`];
21
+ if (build?.rejection?.message) lines.push(` Why: ${line(build.rejection.message)}`);
22
+ else if (build?.state === 'awaiting_validation') lines.push(' Validation is required before a run. Check again in a minute.');
23
+ if (build?.holdReturn?.message) lines.push(` ${line(build.holdReturn.message)}`);
24
+ if (build?.safetyHold) lines.push(' A run of this build was stopped by the safety check, so it cannot be quoted until that hold is cleared.');
25
+ return lines;
26
+ }
27
+
28
+ /** The game's private hosted builds, newest first, optionally one of them. */
29
+ export async function readHostedBuilds({ api, gameId, buildId }) {
30
+ const answer = await api.request('GET', `${base(gameId)}/builds`, { idempotent: true });
31
+ const builds = (Array.isArray(answer.body?.builds) ? answer.body.builds : [])
32
+ .filter(build => build && typeof build.buildId === 'string')
33
+ .sort((a, b) => (Date.parse(b.createdAt) || 0) - (Date.parse(a.createdAt) || 0));
34
+ return buildId ? builds.filter(build => build.buildId === buildId) : builds;
35
+ }
@@ -42,6 +42,28 @@ export async function putArchive(archive, transfer, { fetchImpl = globalThis.fet
42
42
  } finally { stream.destroy(); }
43
43
  }
44
44
 
45
+ /**
46
+ * The profile to upload under: the one named, or the only one advertised. With
47
+ * several and none named, the error lists them so the person can pick one.
48
+ */
49
+ export function chooseProfile(profiles, requested) {
50
+ const advertised = profiles.filter(profile => typeof profile?.profileVersion === 'string' && profile.profileVersion);
51
+ const names = [...new Set(advertised.map(profile => profile.profileVersion))];
52
+ const list = names.map(name => {
53
+ const format = advertised.find(profile => profile.profileVersion === name).format;
54
+ return format ? `${name} (${format})` : name;
55
+ }).join(', ');
56
+ if (!names.length) fail('No build profile is advertised for this game right now. Nothing was charged.');
57
+ if (!requested) {
58
+ if (names.length === 1) return names[0];
59
+ fail(`Several build profiles are available. Choose one with --profile: ${list}. Nothing was charged.`);
60
+ }
61
+ if (advertised.filter(profile => profile.profileVersion === requested).length !== 1) {
62
+ fail(`"${requested}" is not an advertised build profile. Choose one with --profile: ${list}. Nothing was charged.`);
63
+ }
64
+ return requested;
65
+ }
66
+
45
67
  async function journalOnce(filename, value) {
46
68
  await mkdir(path.dirname(filename), { recursive: true, mode: 0o700 });
47
69
  let handle;
@@ -58,13 +80,13 @@ async function journalOnce(filename, value) {
58
80
  // the only wallet-mutating POST. No model or gameplay API is called here.
59
81
  export async function uploadHostedBuild({ filename, gameId, profileVersion, entrypoint, repoRoot, api,
60
82
  apiUrl, journalPath, dryRun = false, confirm, onPrepared = () => {}, onProgress = () => {}, fetchImpl, signal } = {}) {
61
- if (!gameId || !profileVersion || typeof confirm !== 'function') fail('Choose a game, ready profile and upload confirmation.');
83
+ if (!gameId || typeof confirm !== 'function') fail('Choose a game and upload confirmation.');
62
84
  onProgress({ phase: 'capabilities' });
63
85
  const caps = body(await api.request('GET', `${base(gameId)}/capabilities`, { idempotent: true }));
64
86
  const profiles = caps?.recorded?.profiles;
65
87
  if (!caps?.recorded?.available || !Array.isArray(profiles)) fail('Recorded game uploads are not available for this game.');
88
+ profileVersion = chooseProfile(profiles, profileVersion);
66
89
  const matching = profiles.filter(profile => profile.profileVersion === profileVersion);
67
- if (matching.length !== 1) fail('Choose exactly one currently advertised ready profile.');
68
90
  const profile = matching[0], limits = profile.capacity ? limitsFromProfile(profile) : buildLimits(caps.limits);
69
91
  const offer = profile.reservationOffer;
70
92
  if (!offer || offer.amountCents !== 49 || offer.currency !== 'USD' || offer.purpose !== 'validation_hold'
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { CliError, ExitCode } from '../errors.js';
5
5
  import { ApiError } from '../api/errors.js';
6
6
  import { stableStringify } from '../fsutil.js';
7
+ import { printable } from '../ui/index.js';
7
8
 
8
9
  const MAX_QUOTE_BYTES = 1024 * 1024;
9
10
  const CONSENT_KEYS = ['quoteId', 'planHash', 'profileVersion', 'controllers', 'includeVideo', 'includeTranscript',
@@ -259,6 +260,8 @@ export async function prepareHostedRun({ api, apiUrl, gameId, buildId, persona,
259
260
  let build;
260
261
  if (buildId) {
261
262
  build = eligible.find(row => row.buildId === buildId);
263
+ const refused = (Array.isArray(listed.body?.builds) ? listed.body.builds : []).find(row => row?.buildId === buildId && row.rejection?.message);
264
+ if (!build && refused) fail(`Build ${buildId} was not accepted: ${printable(String(refused.rejection.message)).replace(/\s+/g, ' ').slice(0, 300)} Nothing was charged.`);
262
265
  if (!build) fail(`Build ${buildId} is not ready for a hosted run: it has to pass its static archive check on a ready profile and not have expired. Nothing was charged.`);
263
266
  } else {
264
267
  build = eligible[0];
@@ -165,17 +165,136 @@ export function pruneObservations(messages, keep = KEEP_OBSERVATIONS) {
165
165
  return out;
166
166
  }
167
167
 
168
+ /** What a tool_result with nothing in it is sent as. */
169
+ export const EMPTY_TOOL_RESULT_TEXT = '(no output)';
170
+
171
+ /** The tool_result for a tool_use the runner never answered. */
172
+ export const MISSING_TOOL_RESULT_TEXT = 'Not run: the runner did not play this call.';
173
+
174
+ /**
175
+ * Make every tool_result in the conversation one the Messages API accepts.
176
+ *
177
+ * The rules the API enforces, and what each repair is:
178
+ *
179
+ * - **An `is_error` result is text only.** A result flagged `is_error` that
180
+ * also carries an image block is refused with a 400. That is the 0.7.2
181
+ * failure: a refused off-site `navigate` on a canvas build came back as
182
+ * "That did not work: ..." plus the screenshot, flagged as an error, and
183
+ * every persona of the job died on its next turn. The screenshot is the
184
+ * persona's only view of a canvas game, so the image stays and the flag
185
+ * goes: the text already says the action did not work.
186
+ * - **Content is never empty.** An empty string, an empty array or a text
187
+ * block with no text becomes {@link EMPTY_TOOL_RESULT_TEXT}; a non-string,
188
+ * non-array content becomes its string form.
189
+ * - **Exactly one result per tool_use, in the very next user message.** A
190
+ * missing result is added ({@link MISSING_TOOL_RESULT_TEXT}); a second
191
+ * result for one id, and a result whose id the assistant turn before it
192
+ * never used, are dropped. A user message left with nothing gets the empty
193
+ * text, because an empty message is refused too.
194
+ *
195
+ * A new array; the input is not mutated.
196
+ *
197
+ * @param {Array} messages
198
+ * @returns {Array}
199
+ */
200
+ export function validToolResults(messages) {
201
+ const out = messages.map(message => message);
202
+ for (let i = 0; i < out.length; i += 1) {
203
+ const message = out[i];
204
+ if (!message || message.role !== 'user' || !Array.isArray(message.content)) continue;
205
+ const previous = i > 0 ? out[i - 1] : null;
206
+ const expected = previous && previous.role === 'assistant' && Array.isArray(previous.content)
207
+ ? previous.content.filter(block => block && block.type === 'tool_use').map(block => block.id)
208
+ : [];
209
+ const seen = new Set();
210
+ const content = [];
211
+ for (const block of message.content) {
212
+ if (!block || block.type !== 'tool_result') {
213
+ content.push(block);
214
+ continue;
215
+ }
216
+ if (!expected.includes(block.tool_use_id) || seen.has(block.tool_use_id)) continue;
217
+ seen.add(block.tool_use_id);
218
+ content.push(validToolResult(block));
219
+ }
220
+ const missing = expected.filter(id => !seen.has(id))
221
+ .map(id => ({ type: 'tool_result', tool_use_id: id, content: [{ type: 'text', text: MISSING_TOOL_RESULT_TEXT }] }));
222
+ // Results go first in the message: the API wants them before any text.
223
+ const results = [...content.filter(block => block && block.type === 'tool_result'), ...missing];
224
+ const rest = content.filter(block => !(block && block.type === 'tool_result'));
225
+ const next = [...results, ...rest];
226
+ out[i] = { ...message, content: next.length > 0 ? next : [{ type: 'text', text: EMPTY_TOOL_RESULT_TEXT }] };
227
+ }
228
+ return out;
229
+ }
230
+
231
+ /**
232
+ * One tool_result made valid. See {@link validToolResults}.
233
+ *
234
+ * @param {Object} block
235
+ * @returns {Object}
236
+ */
237
+ export function validToolResult(block) {
238
+ let content = block.content;
239
+ if (typeof content === 'string') {
240
+ content = content.trim() ? content : EMPTY_TOOL_RESULT_TEXT;
241
+ } else if (Array.isArray(content)) {
242
+ content = content
243
+ .filter(child => child && (child.type !== 'text' || (typeof child.text === 'string' && child.text.trim())))
244
+ .map(child => child);
245
+ if (content.length === 0) content = [{ type: 'text', text: EMPTY_TOOL_RESULT_TEXT }];
246
+ } else {
247
+ content = content === undefined || content === null || String(content).trim() === '' ? EMPTY_TOOL_RESULT_TEXT : String(content);
248
+ }
249
+ const result = { ...block, content };
250
+ if (result.is_error && Array.isArray(content) && content.some(child => child.type !== 'text')) delete result.is_error;
251
+ return result;
252
+ }
253
+
254
+ /**
255
+ * Every tool_result in the newest user message rewritten as plain text with
256
+ * no `is_error` flag and no image: the one repair the persona loop makes
257
+ * when the provider refuses a turn it cannot otherwise explain, before it
258
+ * gives up on the conversation. The text keeps what the persona was told.
259
+ *
260
+ * @param {Array} messages
261
+ * @returns {Array} a new array; the input is not mutated
262
+ */
263
+ export function repairLastTurn(messages) {
264
+ const out = validToolResults(messages);
265
+ for (let i = out.length - 1; i >= 0; i -= 1) {
266
+ const message = out[i];
267
+ if (!message || message.role !== 'user' || !Array.isArray(message.content)) continue;
268
+ out[i] = {
269
+ ...message,
270
+ content: message.content.map(block => {
271
+ if (!block || block.type !== 'tool_result') return block;
272
+ const texts = Array.isArray(block.content)
273
+ ? block.content.filter(child => child.type === 'text').map(child => child.text)
274
+ : [String(block.content)];
275
+ const text = texts.join('\n').trim() || EMPTY_TOOL_RESULT_TEXT;
276
+ const { is_error: _flag, ...rest } = block;
277
+ return { ...rest, content: [{ type: 'text', text }] };
278
+ })
279
+ };
280
+ break;
281
+ }
282
+ return out;
283
+ }
284
+
168
285
  /**
169
286
  * Both ceilings, in the order that matters: drop stale observations first
170
287
  * (which removes their images too), then enforce the image budget on what
171
- * is left.
288
+ * is left, then make every tool_result one the API accepts (which has to
289
+ * come last: whether an `is_error` result still carries an image is only
290
+ * known once the image budget is applied).
172
291
  *
173
292
  * @param {Array} messages
174
293
  * @param {{maxImages?: number, keepObservations?: number}} [opts]
175
294
  * @returns {Array}
176
295
  */
177
296
  export function prepareMessages(messages, { maxImages = MAX_IMAGES_IN_CONTEXT, keepObservations = KEEP_OBSERVATIONS } = {}) {
178
- return pruneImages(pruneObservations(messages, keepObservations), maxImages);
297
+ return validToolResults(pruneImages(pruneObservations(messages, keepObservations), maxImages));
179
298
  }
180
299
 
181
300
  /**
@@ -189,4 +308,4 @@ export function shrinkImages(current) {
189
308
  return Math.max(0, Math.floor(current / 2));
190
309
  }
191
310
 
192
- export default { prepareMessages, pruneImages, pruneObservations, countImages, shrinkImages, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS };
311
+ export default { prepareMessages, validToolResults, validToolResult, repairLastTurn, pruneImages, pruneObservations, countImages, shrinkImages, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS };
@@ -866,21 +866,24 @@ async function loadPlaywright(importer) {
866
866
  }
867
867
 
868
868
  /**
869
- * Whether the model is allowed to navigate here. The persona may only move
870
- * around inside the build it was pointed at, plus the rest of loopback when
871
- * the build ITSELF is on loopback (a local web export is commonly served on
872
- * one port with an asset or API server on another).
869
+ * Whether the page may be at this address: the build's own origin, and
870
+ * nothing else. It decides the `navigate` tool and every top-level
871
+ * navigation the page itself starts (a clicked link, a form, a script, a
872
+ * server redirect), recording or not.
873
873
  *
874
874
  * Game text is untrusted input: a level name or a leaderboard entry can
875
- * carry "go to https://.../ and paste your token". Confining `navigate` to
875
+ * carry "go to https://.../ and paste your token". Confining the page to
876
876
  * the target's own origin means the worst such an instruction can achieve
877
877
  * is a page of the developer's own game.
878
878
  *
879
- * The loopback allowance is conditional for a reason. A hosted build has no
880
- * business steering the browser at the developer's own machine: `localhost`
881
- * there is whatever else they happen to be running, dev servers and admin
882
- * panels included, and reaching it is a genuine escalation rather than a
883
- * convenience.
879
+ * The one allowance is a loopback alias: when the build is on loopback,
880
+ * `localhost`, `127.0.0.1` and `[::1]` on the SAME scheme and port are the
881
+ * same server, so a page that links to itself by another loopback name is
882
+ * still the build. Another port is another server (a dev server, an admin
883
+ * panel, a mirror of the live site) and is off the build. Subresources are
884
+ * not decided here: a page may still load assets or call an API on another
885
+ * port. A hosted build never gets the loopback alias: `localhost` there is
886
+ * whatever else the developer happens to be running.
884
887
  *
885
888
  * @param {string} candidate
886
889
  * @param {string} target
@@ -897,7 +900,35 @@ export function isNavigationAllowed(candidate, target) {
897
900
  }
898
901
  if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
899
902
  if (url.origin === base.origin) return true;
900
- return isLoopback(base.hostname) && isLoopback(url.hostname);
903
+ return isLoopback(base.hostname) && isLoopback(url.hostname)
904
+ && url.protocol === base.protocol && effectivePort(url) === effectivePort(base);
905
+ }
906
+
907
+ /**
908
+ * @param {URL} url
909
+ * @returns {string}
910
+ */
911
+ function effectivePort(url) {
912
+ return url.port || (url.protocol === 'https:' ? '443' : '80');
913
+ }
914
+
915
+ /** The most off-site attempts one run keeps for its notes. */
916
+ export const MAX_OFF_SITE_ATTEMPTS = 50;
917
+
918
+ /**
919
+ * The host an off-site navigation asked for, for the persona's refusal text.
920
+ * Never the full address: a query string can carry anything.
921
+ *
922
+ * @param {string} candidate
923
+ * @returns {string}
924
+ */
925
+ export function offSiteHost(candidate) {
926
+ try {
927
+ const url = new URL(candidate);
928
+ return url.host || url.protocol.replace(/:$/, '');
929
+ } catch {
930
+ return 'an address that is not a web page';
931
+ }
901
932
  }
902
933
 
903
934
  const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]', '::1'];
@@ -1171,6 +1202,68 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1171
1202
  const consoleBuffer = [];
1172
1203
  /** Recent failed requests, newest last, bounded: what a console line is matched against. */
1173
1204
  const networkFailures = [];
1205
+ // Containment. The build's addresses (the target, and where its first
1206
+ // load landed), every attempt to leave them since the last drain, and
1207
+ // the last address on the build, for a recovery.
1208
+ let buildTargets = [];
1209
+ let guardArmed = false;
1210
+ const offSite = [];
1211
+ let offSiteCount = 0;
1212
+ let lastBuildUrl = null;
1213
+ let recovery = null;
1214
+
1215
+ /** @param {string} candidate */
1216
+ function onBuild(candidate) {
1217
+ return buildTargets.some(base => isNavigationAllowed(candidate, base));
1218
+ }
1219
+
1220
+ /**
1221
+ * @param {string} url
1222
+ * @param {string} via
1223
+ */
1224
+ function recordOffSite(url, via) {
1225
+ offSiteCount += 1;
1226
+ offSite.push({ host: offSiteHost(url), url: networkUrl(url), via, claimed: false });
1227
+ while (offSite.length > MAX_OFF_SITE_ATTEMPTS) offSite.shift();
1228
+ }
1229
+
1230
+ /**
1231
+ * A top-level document landed off the build anyway: a server redirect,
1232
+ * which a route never sees after its first hop. Go back, and if that does
1233
+ * not land on the build, load the last address that did.
1234
+ * @param {any} current
1235
+ */
1236
+ async function recoverToBuild(current) {
1237
+ try {
1238
+ await current.goBack({ waitUntil: 'domcontentloaded', timeout: 10000 });
1239
+ } catch {
1240
+ // Nothing to go back to, or the page would not: load the build instead.
1241
+ }
1242
+ try {
1243
+ if (!onBuild(current.url()) && lastBuildUrl) await current.goto(lastBuildUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
1244
+ } catch {
1245
+ // The next observation shows where the page is; act refuses from there.
1246
+ }
1247
+ }
1248
+
1249
+ /** Any recovery in flight, settled. */
1250
+ async function settled() {
1251
+ while (recovery) {
1252
+ const pending = recovery;
1253
+ await pending;
1254
+ if (recovery === pending) recovery = null;
1255
+ }
1256
+ }
1257
+
1258
+ /** @param {any} request */
1259
+ function isTopLevel(request) {
1260
+ try {
1261
+ return !request.frame().parentFrame();
1262
+ } catch {
1263
+ // A new window's first request is issued before its frame exists.
1264
+ return null;
1265
+ }
1266
+ }
1174
1267
 
1175
1268
  /** @returns {any} */
1176
1269
  function requirePage() {
@@ -1439,6 +1532,136 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1439
1532
  finally { inspecting = false; }
1440
1533
  }
1441
1534
 
1535
+ /**
1536
+ * @param {any} current
1537
+ * @param {import('./driver.js').Action} action
1538
+ * @returns {Promise<import('./driver.js').ActResult>}
1539
+ */
1540
+ async function actOnPage(current, action) {
1541
+ const before = lastHash;
1542
+ const urlBefore = pageUrl(current);
1543
+ const tokenBefore = await documentToken(current);
1544
+ let typed = null;
1545
+ try {
1546
+ switch (action.kind) {
1547
+ case 'key':
1548
+ if (action.holdMs > 0 || (Array.isArray(action.keys) && action.keys.length > 0)) {
1549
+ // Held keys go down together and come up in reverse, even when
1550
+ // one is refused, so nothing stays held for the rest of the run.
1551
+ const keys = [action.key, ...(action.keys || [])].map(key => normalizeChord(key, editModifier));
1552
+ const down = [];
1553
+ try {
1554
+ for (const key of keys) {
1555
+ await current.keyboard.down(key);
1556
+ down.push(key);
1557
+ }
1558
+ await current.waitForTimeout(Math.min(Math.max(Number(action.holdMs) || 0, 0), MAX_WAIT_MS));
1559
+ } finally {
1560
+ for (const key of down.reverse()) await current.keyboard.up(key).catch(() => {});
1561
+ }
1562
+ break;
1563
+ }
1564
+ await current.keyboard.press(normalizeChord(action.key, editModifier), { timeout: ACTION_TIMEOUT_MS });
1565
+ break;
1566
+ case 'type':
1567
+ typed = await typeText(current, action);
1568
+ if (action.submit) await current.keyboard.press('Enter');
1569
+ break;
1570
+ case 'click': {
1571
+ if (!action.ref && action.target && typeof action.target === 'object') {
1572
+ await current.mouse.click(Number(action.target.x), Number(action.target.y));
1573
+ break;
1574
+ }
1575
+ const resolved = await targetFor(current, action);
1576
+ if (resolved && resolved.point) await current.mouse.click(resolved.point.x, resolved.point.y);
1577
+ else await resolved.click({ timeout: ACTION_TIMEOUT_MS });
1578
+ break;
1579
+ }
1580
+ case 'navigate': {
1581
+ const url = String(action.url);
1582
+ if (!onBuild(url)) {
1583
+ // A refusal by the runner, not a failure of the game or the
1584
+ // driver: ./tools.js answers it with plain text and the persona
1585
+ // plays on (an off-site address in a brief is common).
1586
+ recordOffSite(url, 'navigate');
1587
+ offSite[offSite.length - 1].claimed = true;
1588
+ return { ok: false, changed: false, refused: 'off_site', host: offSiteHost(url), error: `navigation to ${url} is outside this build's origin (${new URL(target).origin}) and was refused` };
1589
+ }
1590
+ await current.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
1591
+ break;
1592
+ }
1593
+ case 'wait':
1594
+ await current.waitForTimeout(Math.min(Math.max(Number(action.ms) || 0, 0), MAX_WAIT_MS));
1595
+ break;
1596
+ default:
1597
+ return { ok: false, changed: false, error: `the playwright_web driver cannot do "${action.kind}"` };
1598
+ }
1599
+ } catch (error) {
1600
+ // Playwright colours its call log; the colour codes are noise here.
1601
+ const text = String(error && error.message ? error.message : error).replace(/\u001b\[[0-9;]*m/g, '');
1602
+ // Two elements under one name: Playwright's strict mode. Answered
1603
+ // with the candidates and their refs, so the next try can work.
1604
+ if (/strict mode violation/i.test(text)) {
1605
+ const count = Number((text.match(/resolved to (\d+) elements/) || [])[1]) || null;
1606
+ const candidates = action.target && typeof action.target === 'string' ? candidatesFor(lastNodes, action.target) : [];
1607
+ return { ok: false, changed: false, error: ambiguityMessage(action.target || action.ref || '', candidates, count), driverError: true };
1608
+ }
1609
+ if (error && error.driverSide) return { ok: false, changed: false, error: text, driverError: true };
1610
+ // A click the page kept intercepting is the game's: something sat on
1611
+ // top of the target and never went away. Named, never a driver error.
1612
+ const interceptor = text.match(/^\s*-?\s*(.+?)\s+(?:from .+? subtree\s+)?intercepts pointer events/m);
1613
+ const intercepted = interceptor ? interceptor[1] : null;
1614
+ if (/Timeout \d+ms exceeded/i.test(text) && intercepted) {
1615
+ return { ok: false, changed: false, error: `timed out: ${intercepted.slice(0, 160)} intercepts pointer events over the target`, driverError: false };
1616
+ }
1617
+ // A target the page shows as disabled, hidden or not editable is the
1618
+ // game refusing the action, which is what a player sees: evidence,
1619
+ // never a driver error. The resolved element is named so the report
1620
+ // step can cite it.
1621
+ const refusal = /Timeout \d+ms exceeded/i.test(text) ? gameRefusal(text) : null;
1622
+ if (refusal) {
1623
+ return { ok: false, changed: false, error: `the game did not accept this action: the target is ${refusal.state} (${refusal.element})`, driverError: false };
1624
+ }
1625
+ // The browser or page closed under the runner: the runner's failure.
1626
+ if (runnerGone(text)) return { ok: false, changed: false, error: text, driverError: true };
1627
+ // A timeout with the page unchanged while resolving a click or typing
1628
+ // target means the target was never found or never actionable: the
1629
+ // driver's failure, not the game's. Never for navigate: a game that
1630
+ // never loads is a real blocker.
1631
+ if (/Timeout \d+ms exceeded/i.test(text) && (action.kind === 'click' || action.kind === 'type')) {
1632
+ let unchanged = false;
1633
+ try {
1634
+ unchanged = (await currentHash(current)) === before;
1635
+ } catch {
1636
+ unchanged = false;
1637
+ }
1638
+ return { ok: false, changed: false, error: text, driverError: unchanged };
1639
+ }
1640
+ return { ok: false, changed: false, error: text };
1641
+ }
1642
+
1643
+ const measure = async () => {
1644
+ const facts = { urlBefore, urlAfter: pageUrl(current), tokenBefore, tokenAfter: await documentToken(current), hashBefore: before, valueBefore: typed ? typed.before : null, valueAfter: typed ? typed.after : null };
1645
+ // The same basis observe() hashes, so the two are comparable.
1646
+ // Without it a long screen is always "changed".
1647
+ facts.hashAfter = await currentHash(current);
1648
+ return computeChanged(facts);
1649
+ };
1650
+ let changed = false;
1651
+ try {
1652
+ changed = await measure();
1653
+ // A click or a submit can start a navigation that has not landed yet:
1654
+ // look once more after a short pause before calling it a no-op.
1655
+ if (!changed && SETTLE_MS > 0 && (action.kind === 'click' || action.kind === 'key' || (action.kind === 'type' && action.submit))) {
1656
+ await new Promise(resolve => setTimeout(resolve, SETTLE_MS));
1657
+ changed = await measure();
1658
+ }
1659
+ } catch {
1660
+ changed = true;
1661
+ }
1662
+ return { ok: true, changed };
1663
+ }
1664
+
1442
1665
  return {
1443
1666
  name: 'playwright_web',
1444
1667
  supportsRefs: true,
@@ -1490,22 +1713,62 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1490
1713
  // One token per document: set again by every new document, kept by
1491
1714
  // pushState and hash route changes, which do not run init scripts.
1492
1715
  if (typeof context.addInitScript === 'function') await context.addInitScript(markDocument);
1493
- if (videoRequested && typeof context.route === 'function') {
1494
- const origin = new URL(url).origin;
1495
- await context.route('**/*', route => {
1716
+ // The page never leaves the build, recording or not. A top-level
1717
+ // navigation off it (a clicked link, a form, a script) is answered with
1718
+ // an empty 204, which a browser treats as "stay where you are": the
1719
+ // game keeps its state and nothing is fetched from the other site. An
1720
+ // abort would put an error page in its place instead. Only addresses
1721
+ // off the build are routed at all; subresources pass as before, and
1722
+ // an embedded frame may still load what it loads.
1723
+ buildTargets = [url];
1724
+ if (typeof context.route === 'function') {
1725
+ await context.route(candidate => !onBuild(String(candidate && candidate.href ? candidate.href : candidate)), route => {
1496
1726
  const request = route.request();
1497
- if (request.isNavigationRequest() && !request.frame().parentFrame() && new URL(request.url()).origin !== origin) {
1498
- return route.abort('blockedbyclient');
1499
- }
1500
- return route.continue();
1727
+ if (!guardArmed || !request.isNavigationRequest()) return route.continue();
1728
+ const topLevel = isTopLevel(request);
1729
+ if (topLevel === false) return route.continue();
1730
+ // A new window's first request (no frame yet) is answered the same
1731
+ // way: the window never gets a document, so Playwright never even
1732
+ // surfaces it as a page.
1733
+ recordOffSite(request.url(), topLevel === null ? 'new window' : 'navigation');
1734
+ return route.fulfill({ status: 204, body: '' });
1501
1735
  });
1502
1736
  }
1503
1737
  captureStartedAt = videoRequested ? now() : null;
1504
1738
  page = await context.newPage();
1739
+ // A new window that did get a document (one on the build, or a blank
1740
+ // one a script means to point somewhere) is never played either: it is
1741
+ // closed at once and noted. (Context video may also briefly allocate a
1742
+ // file for it; finalization discards every non-selected file.)
1743
+ page.on('popup', popup => {
1744
+ let opened = null;
1745
+ try {
1746
+ opened = popup.url();
1747
+ } catch {
1748
+ opened = null;
1749
+ }
1750
+ recordOffSite(opened && opened !== 'about:blank' ? opened : 'about:blank', 'new window');
1751
+ if (!opened || opened === 'about:blank') offSite[offSite.length - 1].host = 'a new window';
1752
+ void popup.close().catch(() => {});
1753
+ });
1754
+ // The backstop: a top-level document that landed off the build anyway
1755
+ // (a server redirect after its first hop) is undone before anything
1756
+ // observes it. Error pages are the build's own failures, not a visit.
1757
+ page.on('framenavigated', frame => {
1758
+ try {
1759
+ if (!guardArmed || typeof page.mainFrame !== 'function' || frame !== page.mainFrame()) return;
1760
+ const landed = frame.url();
1761
+ if (onBuild(landed)) { lastBuildUrl = landed; return; }
1762
+ if (!/^https?:/i.test(landed)) return;
1763
+ recordOffSite(landed, 'redirect');
1764
+ const current = page;
1765
+ const previous = recovery;
1766
+ recovery = Promise.resolve(previous).then(() => recoverToBuild(current));
1767
+ } catch {
1768
+ // A frame that cannot be read is not a navigation worth undoing.
1769
+ }
1770
+ });
1505
1771
  if (videoRequested) {
1506
- // Context video may briefly allocate a popup file; close it immediately
1507
- // and discard all non-selected files during finalization.
1508
- page.on('popup', popup => { void popup.close().catch(() => {}); });
1509
1772
  page.on('crash', () => { void stop({ reason: 'game_crashed' }).catch(() => {}); });
1510
1773
  page.on('close', () => { if (!stopPromise) void stop({ reason: 'page_closed' }).catch(() => {}); });
1511
1774
  captureTimer = setInterval(() => { void inspectCapture(); }, 250);
@@ -1556,6 +1819,17 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1556
1819
  if (response && response.status() >= 400) {
1557
1820
  throw new DriverError(`${url} answered ${response.status()}`, { fatal: true });
1558
1821
  }
1822
+ // Where the first load landed is the build too (a hosted address that
1823
+ // redirects to its canonical one, http to https). From here on, leaving
1824
+ // is refused.
1825
+ try {
1826
+ const landed = page.url();
1827
+ if (/^https?:/i.test(landed) && !onBuild(landed)) buildTargets.push(landed);
1828
+ lastBuildUrl = /^https?:/i.test(landed) ? landed : url;
1829
+ } catch {
1830
+ lastBuildUrl = url;
1831
+ }
1832
+ guardArmed = true;
1559
1833
  } catch (error) {
1560
1834
  await stop({ reason: 'launch_failed' }).catch(() => {});
1561
1835
  if (captureDirectory) await rm(captureDirectory, { recursive: true, force: true }).catch(() => {});
@@ -1566,6 +1840,7 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1566
1840
  /** @returns {Promise<import('./driver.js').Observation>} */
1567
1841
  async observe() {
1568
1842
  const current = requirePage();
1843
+ await settled();
1569
1844
  const started = Date.now();
1570
1845
  step += 1;
1571
1846
  const snap = await snapshotText();
@@ -1621,125 +1896,43 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1621
1896
  */
1622
1897
  async act(action) {
1623
1898
  const current = requirePage();
1624
- const before = lastHash;
1625
- const urlBefore = pageUrl(current);
1626
- const tokenBefore = await documentToken(current);
1627
- let typed = null;
1628
- try {
1629
- switch (action.kind) {
1630
- case 'key':
1631
- if (action.holdMs > 0 || (Array.isArray(action.keys) && action.keys.length > 0)) {
1632
- // Held keys go down together and come up in reverse, even when
1633
- // one is refused, so nothing stays held for the rest of the run.
1634
- const keys = [action.key, ...(action.keys || [])].map(key => normalizeChord(key, editModifier));
1635
- const down = [];
1636
- try {
1637
- for (const key of keys) {
1638
- await current.keyboard.down(key);
1639
- down.push(key);
1640
- }
1641
- await current.waitForTimeout(Math.min(Math.max(Number(action.holdMs) || 0, 0), MAX_WAIT_MS));
1642
- } finally {
1643
- for (const key of down.reverse()) await current.keyboard.up(key).catch(() => {});
1644
- }
1645
- break;
1646
- }
1647
- await current.keyboard.press(normalizeChord(action.key, editModifier), { timeout: ACTION_TIMEOUT_MS });
1648
- break;
1649
- case 'type':
1650
- typed = await typeText(current, action);
1651
- if (action.submit) await current.keyboard.press('Enter');
1652
- break;
1653
- case 'click': {
1654
- if (!action.ref && action.target && typeof action.target === 'object') {
1655
- await current.mouse.click(Number(action.target.x), Number(action.target.y));
1656
- break;
1657
- }
1658
- const resolved = await targetFor(current, action);
1659
- if (resolved && resolved.point) await current.mouse.click(resolved.point.x, resolved.point.y);
1660
- else await resolved.click({ timeout: ACTION_TIMEOUT_MS });
1661
- break;
1662
- }
1663
- case 'navigate': {
1664
- const url = String(action.url);
1665
- if (!isNavigationAllowed(url, target)) {
1666
- return { ok: false, changed: false, error: `navigation to ${url} is outside this build's origin (${new URL(target).origin}) and was refused` };
1667
- }
1668
- await current.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
1669
- break;
1670
- }
1671
- case 'wait':
1672
- await current.waitForTimeout(Math.min(Math.max(Number(action.ms) || 0, 0), MAX_WAIT_MS));
1673
- break;
1674
- default:
1675
- return { ok: false, changed: false, error: `the playwright_web driver cannot do "${action.kind}"` };
1676
- }
1677
- } catch (error) {
1678
- // Playwright colours its call log; the colour codes are noise here.
1679
- const text = String(error && error.message ? error.message : error).replace(/\u001b\[[0-9;]*m/g, '');
1680
- // Two elements under one name: Playwright's strict mode. Answered
1681
- // with the candidates and their refs, so the next try can work.
1682
- if (/strict mode violation/i.test(text)) {
1683
- const count = Number((text.match(/resolved to (\d+) elements/) || [])[1]) || null;
1684
- const candidates = action.target && typeof action.target === 'string' ? candidatesFor(lastNodes, action.target) : [];
1685
- return { ok: false, changed: false, error: ambiguityMessage(action.target || action.ref || '', candidates, count), driverError: true };
1686
- }
1687
- if (error && error.driverSide) return { ok: false, changed: false, error: text, driverError: true };
1688
- // A click the page kept intercepting is the game's: something sat on
1689
- // top of the target and never went away. Named, never a driver error.
1690
- const interceptor = text.match(/^\s*-?\s*(.+?)\s+(?:from .+? subtree\s+)?intercepts pointer events/m);
1691
- const intercepted = interceptor ? interceptor[1] : null;
1692
- if (/Timeout \d+ms exceeded/i.test(text) && intercepted) {
1693
- return { ok: false, changed: false, error: `timed out: ${intercepted.slice(0, 160)} intercepts pointer events over the target`, driverError: false };
1694
- }
1695
- // A target the page shows as disabled, hidden or not editable is the
1696
- // game refusing the action, which is what a player sees: evidence,
1697
- // never a driver error. The resolved element is named so the report
1698
- // step can cite it.
1699
- const refusal = /Timeout \d+ms exceeded/i.test(text) ? gameRefusal(text) : null;
1700
- if (refusal) {
1701
- return { ok: false, changed: false, error: `the game did not accept this action: the target is ${refusal.state} (${refusal.element})`, driverError: false };
1702
- }
1703
- // The browser or page closed under the runner: the runner's failure.
1704
- if (runnerGone(text)) return { ok: false, changed: false, error: text, driverError: true };
1705
- // A timeout with the page unchanged while resolving a click or typing
1706
- // target means the target was never found or never actionable: the
1707
- // driver's failure, not the game's. Never for navigate: a game that
1708
- // never loads is a real blocker.
1709
- if (/Timeout \d+ms exceeded/i.test(text) && (action.kind === 'click' || action.kind === 'type')) {
1710
- let unchanged = false;
1711
- try {
1712
- unchanged = (await currentHash(current)) === before;
1713
- } catch {
1714
- unchanged = false;
1715
- }
1716
- return { ok: false, changed: false, error: text, driverError: unchanged };
1717
- }
1718
- return { ok: false, changed: false, error: text };
1899
+ await settled();
1900
+ const attemptsBefore = offSiteCount;
1901
+ const outcome = await actOnPage(current, action);
1902
+ if (outcome.refused) return outcome;
1903
+ // A click, a key, a submit or a script that tried to leave the build:
1904
+ // the page stayed (or was brought back), and the persona is told the
1905
+ // same as for a refused `navigate`. A step that changed nothing.
1906
+ await settled();
1907
+ if (offSiteCount > attemptsBefore) {
1908
+ const fresh = offSite.filter(attempt => !attempt.claimed).slice(-(offSiteCount - attemptsBefore));
1909
+ for (const attempt of fresh) attempt.claimed = true;
1910
+ const host = fresh.length > 0 ? fresh[fresh.length - 1].host : 'another site';
1911
+ return { ok: false, changed: false, refused: 'off_site', host, error: `the page tried to leave the build for ${host}; the runner kept it on the build` };
1719
1912
  }
1913
+ return outcome;
1914
+ },
1720
1915
 
1721
- const measure = async () => {
1722
- const facts = { urlBefore, urlAfter: pageUrl(current), tokenBefore, tokenAfter: await documentToken(current), hashBefore: before, valueBefore: typed ? typed.before : null, valueAfter: typed ? typed.after : null };
1723
- // The same basis observe() hashes, so the two are comparable.
1724
- // Without it a long screen is always "changed".
1725
- facts.hashAfter = await currentHash(current);
1726
- return computeChanged(facts);
1727
- };
1728
- let changed = false;
1916
+ /**
1917
+ * Every attempt to leave the build since the last call, claimed by an
1918
+ * action or not (a timer redirect between actions has no action), for the
1919
+ * run's notes. Hosts and addresses without their query strings only.
1920
+ * @returns {Array<{host: string, url: string, via: string}>}
1921
+ */
1922
+ offSiteAttempts() {
1923
+ return offSite.splice(0, offSite.length).map(({ host, url: address, via }) => ({ host, url: address, via }));
1924
+ },
1925
+
1926
+ /** How many pages the browser has open: one, whatever the game opened. */
1927
+ openPages() {
1729
1928
  try {
1730
- changed = await measure();
1731
- // A click or a submit can start a navigation that has not landed yet:
1732
- // look once more after a short pause before calling it a no-op.
1733
- if (!changed && SETTLE_MS > 0 && (action.kind === 'click' || action.kind === 'key' || (action.kind === 'type' && action.submit))) {
1734
- await new Promise(resolve => setTimeout(resolve, SETTLE_MS));
1735
- changed = await measure();
1736
- }
1929
+ return context && typeof context.pages === 'function' ? context.pages().length : (page ? 1 : 0);
1737
1930
  } catch {
1738
- changed = true;
1931
+ return page ? 1 : 0;
1739
1932
  }
1740
- return { ok: true, changed };
1741
1933
  },
1742
1934
 
1935
+
1743
1936
  /**
1744
1937
  * @param {string} name
1745
1938
  * @returns {Promise<{path: string, relativePath: string}>}
package/src/run/model.js CHANGED
@@ -170,7 +170,24 @@ export function toProxyError(error) {
170
170
  if (key !== 'type' && key !== 'message') figures[key] = value;
171
171
  }
172
172
  }
173
- return new ProxyError({ status, code, message, retryAfter, figures });
173
+ return new ProxyError({ status, code, message: withProviderReason(message, figures), retryAfter, figures });
174
+ }
175
+
176
+ /**
177
+ * The provider's own reason, when the proxy passed one on (a 400
178
+ * `upstream_rejected` carries `provider_type` and a cleaned
179
+ * `provider_message`), added to the message the developer is shown. Without
180
+ * it a refused body reads only "The model provider rejected this request."
181
+ *
182
+ * @param {string} message
183
+ * @param {Object} figures
184
+ * @returns {string}
185
+ */
186
+ export function withProviderReason(message, figures = {}) {
187
+ const type = typeof figures.provider_type === 'string' ? figures.provider_type : '';
188
+ const reason = typeof figures.provider_message === 'string' ? figures.provider_message : '';
189
+ if (!type && !reason) return message;
190
+ return `${message} Provider said: ${[type, reason].filter(Boolean).join(': ')}`;
174
191
  }
175
192
 
176
193
  /**
package/src/run/openPr.js CHANGED
@@ -85,7 +85,14 @@ export async function openPullRequest({ repoRoot, branch, paths, title: rawTitle
85
85
  ...paths.map(file => ` ${plainTitle(file)}`)
86
86
  ].join('\n');
87
87
  // `yes: false` on purpose: --yes agreed to the price, not to publishing.
88
- const go = await confirm(question, { yes: false });
88
+ let go;
89
+ try { go = await confirm(question, { yes: false }); }
90
+ catch (error) {
91
+ // --yes cannot agree to publishing, so with no terminal the answer is a
92
+ // plain refusal, never a piped "y".
93
+ if (error?.code === 'no_terminal') return refuse('--open-pr asks before it pushes, and there is no terminal to ask in.');
94
+ throw error;
95
+ }
89
96
  if (!go) return refuse('Not confirmed.');
90
97
 
91
98
  const added = await call('git', ['add', '--', ...paths]);
@@ -2,7 +2,7 @@ import { rewriteMarkdown } from './dropped.js';
2
2
  import { selectAction, JEV_CONTROLLER_PROMPT } from './actionSelector.js';
3
3
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
- import { prepareMessages, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS, shrinkImages } from './context.js';
5
+ import { prepareMessages, repairLastTurn, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS, shrinkImages } from './context.js';
6
6
  import { withRollingCacheBreakpoint } from './fixLoop.js';
7
7
  import { callStepWithRetry, ProxyError, STEPS } from './model.js';
8
8
  import { RUN_FILES } from './paths.js';
@@ -76,6 +76,25 @@ const ABOVE_MINOR = Object.freeze(['blocker', 'major']);
76
76
  /** What a capped finding's description gains, so the reader knows why. */
77
77
  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.';
78
78
 
79
+ /** How each way of leaving the build reads in a note. */
80
+ const OFF_SITE_VIA = Object.freeze({
81
+ navigation: 'a link, form or script on the page',
82
+ 'new window': 'a new window',
83
+ redirect: 'a redirect',
84
+ navigate: 'the persona\'s navigate'
85
+ });
86
+
87
+ /**
88
+ * The network note for one attempt to leave the build.
89
+ *
90
+ * @param {{host?: string, via?: string}} attempt
91
+ * @returns {string}
92
+ */
93
+ export function offSiteNoteText(attempt) {
94
+ const via = OFF_SITE_VIA[attempt && attempt.via] || 'the page';
95
+ return `off-site: ${via} tried to leave the build for ${(attempt && attempt.host) || 'another site'}; the runner kept the page on the build.`;
96
+ }
97
+
79
98
  /** A persona note kind as a report finding category. */
80
99
  // `waiting` has no category on purpose: a "waiting for X to finish" note is
81
100
  // context, and nothing turns it into a finding.
@@ -530,23 +549,39 @@ async function playLoop({
530
549
  turns += 1;
531
550
  let answer;
532
551
  const askedAt = Date.now();
552
+ const askAct = () => callWithImageRetry({
553
+ client,
554
+ jobId,
555
+ runId,
556
+ step: STEPS.personaAct,
557
+ system,
558
+ messages,
559
+ tools: playTools,
560
+ maxTokens: ACT_MAX_TOKENS,
561
+ escalate: shouldEscalate(persona),
562
+ imageBudget,
563
+ keepObservations,
564
+ onImageBudget: next => {
565
+ imageBudget = next;
566
+ }
567
+ });
533
568
  try {
534
- answer = await callWithImageRetry({
535
- client,
536
- jobId,
537
- runId,
538
- step: STEPS.personaAct,
539
- system,
540
- messages,
541
- tools: playTools,
542
- maxTokens: ACT_MAX_TOKENS,
543
- escalate: shouldEscalate(persona),
544
- imageBudget,
545
- keepObservations,
546
- onImageBudget: next => {
547
- imageBudget = next;
548
- }
549
- });
569
+ try {
570
+ answer = await askAct();
571
+ } catch (error) {
572
+ // The provider refused the body. Every tool_result is already made
573
+ // valid before it is sent (./context.js), so this is a shape nobody
574
+ // has seen yet: the newest turn is rewritten as plain text and asked
575
+ // once more. A second refusal ends play, and the run still reports
576
+ // from its step log (runPersona's error path), because a run that
577
+ // played never comes back empty.
578
+ if (!(error instanceof ProxyError) || error.code !== 'upstream_rejected') throw error;
579
+ log(`the model provider rejected this turn (${error.message}); retrying once with the last turn as plain text.`);
580
+ transcript.write({ kind: 'provider_rejected', retry: true, code: error.code, provider_type: error.figures.provider_type || null, provider_message: error.figures.provider_message || null });
581
+ const repaired = repairLastTurn(messages);
582
+ messages.splice(0, messages.length, ...repaired);
583
+ answer = await askAct();
584
+ }
550
585
  } catch (error) {
551
586
  if (error instanceof ProxyError && error.isBudget) {
552
587
  budgetExceeded = true;
@@ -663,6 +698,7 @@ async function playLoop({
663
698
  ok: !executed.error,
664
699
  error: executed.error,
665
700
  changed: executed.changed,
701
+ ...(executed.refused ? { refused: executed.refused } : {}),
666
702
  actions
667
703
  };
668
704
  transcript.write(actionLine);
@@ -691,6 +727,13 @@ async function playLoop({
691
727
  // declared wedged for doing the right thing.
692
728
  if (ACTION_TOOLS.includes(toolUse.name)) unchanged = executed.changed ? 0 : unchanged + 1;
693
729
  }
730
+ if (executed.refused) {
731
+ // A refused off-site navigation is a step that changed nothing: it
732
+ // counts toward the stuck limit like any other, and play goes on.
733
+ unchanged += 1;
734
+ recentActions.push(`${toolUse.name}: refused, off-site`);
735
+ if (recentActions.length > 10) recentActions.shift();
736
+ }
694
737
  onProgress({ phase: 'playing', persona: persona.slug, actions, maxActions, tool: toolUse.name });
695
738
  }
696
739
 
@@ -732,6 +775,24 @@ async function playLoop({
732
775
  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}.` : ''}`);
733
776
  }
734
777
 
778
+ // Every attempt to leave the build this turn (a clicked link, a form, a
779
+ // new window, a script or a server redirect, or a refused navigate) is a
780
+ // network note: the runner kept the page on the build, and the report
781
+ // step reads it as context. A persona told "Refused" already knows; a
782
+ // timer redirect between actions is only ever seen here.
783
+ const leaving = typeof driver.offSiteAttempts === 'function' ? driver.offSiteAttempts() : [];
784
+ for (const attempt of leaving) {
785
+ const note = {
786
+ kind: 'network',
787
+ text: offSiteNoteText(attempt),
788
+ automatic: true,
789
+ off_site: true,
790
+ url: String(attempt.url || attempt.host || '').slice(0, 200)
791
+ };
792
+ noted(note);
793
+ transcript.write({ kind: 'note', note: scrubSecrets(note, secrets) });
794
+ }
795
+
735
796
  const wedged = unchanged >= STUCK_LIMIT;
736
797
  if (!wedged && unchanged > 0 && unchanged % STUCK_HINT_AFTER === 0) {
737
798
  transcript.write({ kind: 'stuck', unchanged, fatal: false });
package/src/run/tools.js CHANGED
@@ -335,6 +335,19 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot, stale
335
335
  : action.ref && staleRefs
336
336
  ? { ok: false, changed: false, error: STALE_REF_MESSAGE, driverError: true }
337
337
  : await driver.act(action);
338
+ // The runner refused to leave the build. The persona is told so in plain
339
+ // text and plays on: it is a step that changed nothing, never an error and
340
+ // never a run failure. No observation either, because nothing moved.
341
+ if (outcome.refused === 'off_site') {
342
+ return {
343
+ result: toolResult(toolUse.id, offSiteRefusalText(outcome.host)),
344
+ observation: null,
345
+ spentAction: ACTION_TOOLS.includes(toolUse.name),
346
+ changed: false,
347
+ error: null,
348
+ refused: 'off_site'
349
+ };
350
+ }
338
351
  const observation = await driver.observe();
339
352
  const spentAction = ACTION_TOOLS.includes(toolUse.name) && outcome.ok;
340
353
  const prefix = outcome.ok
@@ -350,11 +363,25 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot, stale
350
363
  };
351
364
  }
352
365
 
366
+ /**
367
+ * What the persona is told when the runner refuses to leave the build.
368
+ *
369
+ * @param {string} [host]
370
+ * @returns {string}
371
+ */
372
+ export function offSiteRefusalText(host) {
373
+ return `Refused: that link leaves the build under test (${host || 'another site'}). Stay on this page and use its controls.`;
374
+ }
375
+
353
376
  /**
354
377
  * A tool_result whose content is the new observation, with the screenshot
355
378
  * attached as an image when the driver decided the text was too thin to
356
379
  * play from. Images are pruned from history by the caller: the act step
357
- * accepts a very small number of image blocks per request.
380
+ * accepts a very small number of image blocks per request. When the screenshot
381
+ * rides along, a failed action is NOT flagged `is_error`: the Messages API
382
+ * refuses an `is_error` result that carries an image, and the image is the
383
+ * persona's only view of a canvas game. The "That did not work" prefix
384
+ * already says what the flag would.
358
385
  *
359
386
  * @param {string} id
360
387
  * @param {Object} observation
@@ -382,7 +409,8 @@ export function observationResult(id, observation, prefix = '', isError = false)
382
409
  }
383
410
  });
384
411
  }
385
- return { type: 'tool_result', tool_use_id: id, content, ...(isError ? { is_error: true } : {}) };
412
+ const withImage = content.length > 1;
413
+ return { type: 'tool_result', tool_use_id: id, content, ...(isError && !withImage ? { is_error: true } : {}) };
386
414
  }
387
415
 
388
416
  /**
@@ -429,4 +457,4 @@ function message(error) {
429
457
  return String(error && error.message ? error.message : error);
430
458
  }
431
459
 
432
- export default { PLAY_TOOLS, REPORT_TOOLS, executeTool, actionFor, toolResult, observationResult };
460
+ export default { PLAY_TOOLS, REPORT_TOOLS, executeTool, actionFor, toolResult, observationResult, offSiteRefusalText };
package/src/ui/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createInterface } from 'node:readline/promises';
2
2
  import { stableStringify } from '../fsutil.js';
3
+ import { CliError, ExitCode } from '../errors.js';
3
4
 
4
5
  /**
5
6
  * Terminal output, in one place.
@@ -183,22 +184,36 @@ export function spinner(label) {
183
184
  };
184
185
  }
185
186
 
187
+ /** The one line printed when a question needs a terminal and there is none. */
188
+ export const NO_TERMINAL_MESSAGE = 'No terminal to confirm in: pass --yes to accept the shown price and hold.';
189
+
190
+ function noTerminal(message = NO_TERMINAL_MESSAGE) {
191
+ const error = new CliError(message, ExitCode.FAILURE);
192
+ error.code = 'no_terminal';
193
+ return error;
194
+ }
195
+
196
+ /** True for the error confirm() throws when there is no terminal to ask in. */
197
+ export const isNoTerminal = error => error instanceof CliError && error.code === 'no_terminal';
198
+
186
199
  /**
187
200
  * Ask a yes or no question.
188
201
  *
189
- * `--yes` skips it, and so does a non interactive stdin: a prompt nobody can
190
- * answer would hang a CI job forever, so the absence of a TTY is treated as a
191
- * refusal rather than as an assumed yes. That is the safe direction for a
192
- * question that is usually "shall I spend your money".
202
+ * `--yes` skips it. A non interactive stdin is an error, not an answer: a
203
+ * prompt nobody can answer would hang a CI job forever, and piped text such as
204
+ * `echo y |` is never taken as consent, because the question is usually
205
+ * "shall I spend your money". Quietly declining instead left people wondering
206
+ * why nothing happened, so the error says what to do.
193
207
  *
194
208
  * @param {string} question
195
- * @param {{yes?: boolean, input?: object, output?: object}} [options]
209
+ * @param {{yes?: boolean, input?: object, output?: object, noTerminalMessage?: string}} [options]
210
+ * noTerminalMessage replaces the line for a question that is not about money.
196
211
  * @returns {Promise<boolean>}
197
212
  */
198
213
  export async function confirm(question, options = {}) {
199
214
  if (options.yes) return true;
200
215
  const input = options.input || process.stdin;
201
- if (!input.isTTY && !options.input) return false;
216
+ if (!input.isTTY && !options.input) throw noTerminal(options.noTerminalMessage);
202
217
 
203
218
  const rl = createInterface({ input, output: options.output || stream });
204
219
  try {