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