ravensight-playtest 0.2.6 → 0.2.8
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 +65 -1
- package/package.json +1 -1
- package/src/cli.js +1 -0
- package/src/commands/fakeCore.js +36 -7
- package/src/commands/run.js +149 -6
- package/src/commands/upload.js +10 -0
- package/src/run/aggregate.js +9 -2
- package/src/run/args.js +2 -1
- package/src/run/drivers/godot.js +5 -0
- package/src/run/drivers/stdio.js +1 -0
- package/src/run/drivers/web.js +126 -3
- package/src/run/drivers/webdriver.js +1 -0
- package/src/run/dropped.js +294 -0
- package/src/run/personaLoop.js +218 -12
- package/src/run/placeholders.js +186 -0
- package/src/run/resume.js +16 -1
- package/src/run/synthetic.js +14 -1
- package/src/run/tools.js +10 -4
- package/src/states.js +4 -2
- package/src/upload/index.js +9 -1
- package/src/upload/queue.js +13 -5
package/src/run/personaLoop.js
CHANGED
|
@@ -11,6 +11,7 @@ import { assertNoSecrets } from './secretScan.js';
|
|
|
11
11
|
import { ACTION_TOOLS, PLAY_TOOLS, SWIPE_TOOL, REPORT_TOOLS, executeTool } from './tools.js';
|
|
12
12
|
import { createTranscript } from './transcript.js';
|
|
13
13
|
import { createUsageLedger, writeUsageFile } from './usage.js';
|
|
14
|
+
import { fillPlaceholders, fillReportPlaceholders } from './placeholders.js';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* One persona's playthrough: PLAYING then REPORTING (spec 03).
|
|
@@ -51,8 +52,22 @@ const nextOrder = () => {
|
|
|
51
52
|
return ORDER;
|
|
52
53
|
};
|
|
53
54
|
|
|
54
|
-
/**
|
|
55
|
-
|
|
55
|
+
/**
|
|
56
|
+
* A 4xx on a request, in the shapes a browser console and a game's own logging
|
|
57
|
+
* print it: `Failed to load resource: ... status of 4xx`, `HTTP 404`,
|
|
58
|
+
* `status: 401`, `statusCode=403`, `401 Unauthorized`. A missing asset or a
|
|
59
|
+
* refused call, not a crash.
|
|
60
|
+
*/
|
|
61
|
+
export const RESOURCE_4XX = /Failed to load resource: the server responded with a status of 4\d\d|\bHTTP 4\d\d\b|status(?:Code)?\s*[:=]?\s*4\d\d\b|\b4\d\d (?:Unauthorized|Forbidden|Not Found|Bad Request)\b/i;
|
|
62
|
+
|
|
63
|
+
/** The screenshots the runner takes on its own, which no persona described. */
|
|
64
|
+
const RUNNER_SCREENSHOTS = Object.freeze(['landing', 'console-error', 'final']);
|
|
65
|
+
|
|
66
|
+
/** Severities above `minor`, which network notes alone cannot earn. */
|
|
67
|
+
const ABOVE_MINOR = Object.freeze(['blocker', 'major']);
|
|
68
|
+
|
|
69
|
+
/** What a capped finding's description gains, so the reader knows why. */
|
|
70
|
+
export const NETWORK_CAP_NOTE = 'Severity capped at minor by the runner: the only evidence is automatic network notes, and a failed request is a finding above minor only when the persona saw the game break because of it.';
|
|
56
71
|
|
|
57
72
|
/** A persona note kind as a report finding category. */
|
|
58
73
|
export const NOTE_CATEGORY = Object.freeze({ bug: 'bug', confusing: 'ux', suggestion: 'ux', good: 'ux', reaction: 'ux' });
|
|
@@ -127,6 +142,10 @@ export async function runPersona(args) {
|
|
|
127
142
|
const startedAt = new Date().toISOString();
|
|
128
143
|
const notes = [];
|
|
129
144
|
const screenshots = [];
|
|
145
|
+
// Every observation the persona was shown, by index, so a note can carry
|
|
146
|
+
// what the screen looked like next and a finding can cite where a failure
|
|
147
|
+
// is visible.
|
|
148
|
+
const observations = [];
|
|
130
149
|
let imageBudget = maxImages;
|
|
131
150
|
|
|
132
151
|
await mkdir(path.join(runDirectory, 'screenshots'), { recursive: true });
|
|
@@ -186,6 +205,7 @@ export async function runPersona(args) {
|
|
|
186
205
|
ledger,
|
|
187
206
|
notes,
|
|
188
207
|
screenshots,
|
|
208
|
+
observations,
|
|
189
209
|
imageBudget,
|
|
190
210
|
keepObservations,
|
|
191
211
|
onImageBudget: next => {
|
|
@@ -242,6 +262,7 @@ export async function runPersona(args) {
|
|
|
242
262
|
ledger,
|
|
243
263
|
notes,
|
|
244
264
|
screenshots,
|
|
265
|
+
observations,
|
|
245
266
|
play,
|
|
246
267
|
startedAt,
|
|
247
268
|
endedAt,
|
|
@@ -305,6 +326,7 @@ async function playLoop({
|
|
|
305
326
|
ledger,
|
|
306
327
|
notes,
|
|
307
328
|
screenshots,
|
|
329
|
+
observations = [],
|
|
308
330
|
imageBudget,
|
|
309
331
|
keepObservations,
|
|
310
332
|
heartbeat,
|
|
@@ -343,7 +365,26 @@ async function playLoop({
|
|
|
343
365
|
let wrapUpLeft = Infinity;
|
|
344
366
|
const maxTurns = maxActions * 3 + 20;
|
|
345
367
|
|
|
368
|
+
// Notes waiting for the next observation, which is the screen they are
|
|
369
|
+
// checked against: a failure the next screen does not show is not a
|
|
370
|
+
// blocker.
|
|
371
|
+
let awaitingAfter = [];
|
|
372
|
+
const observed = observation => {
|
|
373
|
+
const entry = { index: observations.length + 1, step: observation.step, url: observation.url || null, title: observationTitle(observation) };
|
|
374
|
+
observations.push(entry);
|
|
375
|
+
for (const note of awaitingAfter) note.after = { url: entry.url, title: entry.title };
|
|
376
|
+
awaitingAfter = [];
|
|
377
|
+
return entry;
|
|
378
|
+
};
|
|
379
|
+
const noted = note => {
|
|
380
|
+
const entry = { ...note, at_action: actions, observation: observations.length, order: nextOrder() };
|
|
381
|
+
notes.push(entry);
|
|
382
|
+
awaitingAfter.push(entry);
|
|
383
|
+
return entry;
|
|
384
|
+
};
|
|
385
|
+
|
|
346
386
|
const first = await driver.observe();
|
|
387
|
+
observed(first);
|
|
347
388
|
latestObservation = first.text;
|
|
348
389
|
visualObservation = Boolean(first.screenshotBase64);
|
|
349
390
|
transcript.write({ kind: 'observation', step: first.step, text: first.text, url: first.url });
|
|
@@ -458,7 +499,7 @@ async function playLoop({
|
|
|
458
499
|
// (bug, confusing, good, suggestion, reaction) would otherwise
|
|
459
500
|
// overwrite the transcript line's `kind`, and every note would
|
|
460
501
|
// read as a different sort of line.
|
|
461
|
-
|
|
502
|
+
noted(note);
|
|
462
503
|
transcript.write({ kind: 'note', tool: toolUse.name, note });
|
|
463
504
|
},
|
|
464
505
|
onScreenshot: shot => {
|
|
@@ -479,6 +520,7 @@ async function playLoop({
|
|
|
479
520
|
actions
|
|
480
521
|
});
|
|
481
522
|
if (executed.observation) {
|
|
523
|
+
observed(executed.observation);
|
|
482
524
|
latestObservation = executed.observation.text;
|
|
483
525
|
visualObservation = Boolean(executed.observation.screenshotBase64);
|
|
484
526
|
recentActions.push(`${toolUse.name}: ${executed.changed ? 'screen changed' : 'no observed change'}`);
|
|
@@ -513,8 +555,8 @@ async function playLoop({
|
|
|
513
555
|
const shot = await safeScreenshot(driver, 'console-error', screenshots, transcript);
|
|
514
556
|
for (const entry of consoleErrors) {
|
|
515
557
|
const kind = RESOURCE_4XX.test(String(entry.text)) ? 'network' : 'bug';
|
|
516
|
-
const note = { kind, text: `${entry.type}: ${entry.text}`, automatic: true };
|
|
517
|
-
|
|
558
|
+
const note = { kind, text: `${entry.type}: ${entry.text}`, automatic: true, ...(shot ? { screenshot: shot.relativePath } : {}) };
|
|
559
|
+
noted(note);
|
|
518
560
|
transcript.write({ kind: 'note', note });
|
|
519
561
|
}
|
|
520
562
|
asides.push(`The game logged ${consoleErrors.length} error(s): ${consoleErrors.map(entry => entry.text).join(' | ').slice(0, 800)}${shot ? ` A screenshot is saved at ${shot.relativePath}.` : ''}`);
|
|
@@ -584,6 +626,7 @@ async function reportPhase({
|
|
|
584
626
|
ledger,
|
|
585
627
|
notes,
|
|
586
628
|
screenshots,
|
|
629
|
+
observations = [],
|
|
587
630
|
play,
|
|
588
631
|
startedAt,
|
|
589
632
|
endedAt,
|
|
@@ -600,9 +643,17 @@ async function reportPhase({
|
|
|
600
643
|
`actions taken: ${play.actionsTaken} stop reason: ${play.quitReason}${play.quitDetail ? ` (${play.quitDetail})` : ''}`,
|
|
601
644
|
'',
|
|
602
645
|
'Notes you logged, in order:',
|
|
603
|
-
notes.length > 0 ? notes.map((note, i) =>
|
|
646
|
+
notes.length > 0 ? notes.map((note, i) => noteLine(note, i)).join('\n') : '(none)',
|
|
604
647
|
'',
|
|
605
|
-
'Notes marked automatic were recorded by the runner from the console, with no severity: decide one from what the persona saw. Network notes are context, not bugs: a failed request is a finding only when the persona saw a broken screen because of it.',
|
|
648
|
+
'Notes marked automatic were recorded by the runner from the console, with no severity: decide one from what the persona saw. Network notes are context, not bugs: a failed request is a finding only when the persona saw a broken screen because of it. A finding whose only evidence is automatic network notes is capped at minor by the runner.',
|
|
649
|
+
'',
|
|
650
|
+
'Observations the persona was shown, by index:',
|
|
651
|
+
observationLines(observations),
|
|
652
|
+
'',
|
|
653
|
+
'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"}.',
|
|
654
|
+
'',
|
|
655
|
+
'Never write calendar dates or times in the prose of report.md or report.json. The runner stamps when the report was generated, so leave the template\'s Date out rather than writing a placeholder for it.',
|
|
656
|
+
'Replace every <...> slot in the template with real content. Never copy a <...> placeholder into the report.',
|
|
606
657
|
'',
|
|
607
658
|
'Screenshots available as evidence (use these exact paths in evidence refs):',
|
|
608
659
|
screenshots.length > 0 ? screenshots.map(shot => `- ${shot.relativePath}${shot.reason ? ` (${shot.reason})` : ''}`).join('\n') : '(none)',
|
|
@@ -669,7 +720,7 @@ async function reportPhase({
|
|
|
669
720
|
quitReason: play.quitReason,
|
|
670
721
|
quitDetail: play.quitDetail
|
|
671
722
|
};
|
|
672
|
-
const candidate = parsed ? stampReport(parsed, facts) : null;
|
|
723
|
+
const candidate = parsed ? capNetworkOnlyFindings(stampReport(parsed, facts), { notes, screenshots }) : null;
|
|
673
724
|
const validation = candidate ? validator.validate(candidate) : { ok: false, details: [{ instancePath: '/', message: parseError || 'report.json was not written' }] };
|
|
674
725
|
const evidenceProblems = candidate ? await checkEvidence(candidate, runDirectory) : [];
|
|
675
726
|
// The server lints the same four sections at `complete` and records the
|
|
@@ -715,6 +766,11 @@ async function reportPhase({
|
|
|
715
766
|
if (!markdown) {
|
|
716
767
|
markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason: 'The report step wrote report.json but no report.md.' });
|
|
717
768
|
}
|
|
769
|
+
const generatedAt = new Date().toISOString();
|
|
770
|
+
const facts = reportFacts({ persona, driverName, target, play });
|
|
771
|
+
markdown = stampGeneratedAt(markdown, generatedAt, facts);
|
|
772
|
+
// The same slots in the prose the dashboard shows from report.json.
|
|
773
|
+
report = fillReportPlaceholders(report, { generatedAt, ...facts });
|
|
718
774
|
|
|
719
775
|
await writeReportFiles({ runDirectory, markdown, report });
|
|
720
776
|
return { report, markdown, lint };
|
|
@@ -1033,17 +1089,167 @@ async function writePartialReport({ outcome, runDirectory, persona, driverName,
|
|
|
1033
1089
|
play,
|
|
1034
1090
|
reason: 'The run reached its model budget before the report step. What the persona saw is below, and report.json carries the notes as unreviewed findings.'
|
|
1035
1091
|
});
|
|
1036
|
-
|
|
1092
|
+
const stamped = stampGeneratedAt(markdown, new Date().toISOString(), reportFacts({ persona, driverName, target, play }));
|
|
1093
|
+
await writeReportFiles({ runDirectory, markdown: stamped, report });
|
|
1037
1094
|
outcome.report = report;
|
|
1038
|
-
outcome.reportMarkdown =
|
|
1095
|
+
outcome.reportMarkdown = stamped;
|
|
1039
1096
|
}
|
|
1040
1097
|
|
|
1041
1098
|
async function writePartialMarkdown({ runDirectory, persona, driverName, target, notes, screenshots, play, reason }) {
|
|
1042
|
-
const markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason });
|
|
1099
|
+
const markdown = stampGeneratedAt(renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason }), new Date().toISOString(), reportFacts({ persona, driverName, target, play }));
|
|
1043
1100
|
assertNoSecrets('report.md', markdown);
|
|
1044
1101
|
await writeFile(path.join(runDirectory, RUN_FILES.reportMd), markdown, 'utf8');
|
|
1045
1102
|
}
|
|
1046
1103
|
|
|
1104
|
+
/**
|
|
1105
|
+
* A note as one numbered line of the report prompt: its kind, whether the
|
|
1106
|
+
* runner recorded it, the observation it was made on and the screen that
|
|
1107
|
+
* followed it.
|
|
1108
|
+
*
|
|
1109
|
+
* @param {Object} note
|
|
1110
|
+
* @param {number} i
|
|
1111
|
+
* @returns {string}
|
|
1112
|
+
*/
|
|
1113
|
+
export function noteLine(note, i) {
|
|
1114
|
+
const tags = [
|
|
1115
|
+
note.automatic ? 'automatic' : null,
|
|
1116
|
+
Number.isInteger(note.observation) && note.observation > 0 ? `seen at observation ${note.observation}` : null,
|
|
1117
|
+
note.screenshot ? `screenshot ${note.screenshot}` : null
|
|
1118
|
+
].filter(Boolean);
|
|
1119
|
+
const after = note.after
|
|
1120
|
+
? ` after: ${JSON.stringify({ url: note.after.url ?? null, title: note.after.title ?? null })}`
|
|
1121
|
+
: ' after: (no later observation)';
|
|
1122
|
+
return `${i + 1}. [${note.kind}${note.severity ? `/${note.severity}` : ''}] ${note.text}${tags.length > 0 ? ` (${tags.join(', ')})` : ''}${after}`;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/** The most observations listed in the report prompt; the newest are kept. */
|
|
1126
|
+
export const MAX_OBSERVATIONS_IN_REPORT = 200;
|
|
1127
|
+
|
|
1128
|
+
function observationLines(observations) {
|
|
1129
|
+
if (!observations || observations.length === 0) return '(none)';
|
|
1130
|
+
const shown = observations.slice(-MAX_OBSERVATIONS_IN_REPORT);
|
|
1131
|
+
const lines = shown.map(entry => `observation ${entry.index}: step ${entry.step}${entry.url ? ` ${entry.url}` : ''}${entry.title ? ` "${entry.title}"` : ''}`);
|
|
1132
|
+
if (shown.length < observations.length) lines.unshift(`(the first ${observations.length - shown.length} are left out)`);
|
|
1133
|
+
return lines.join('\n');
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* A short title for an observation: the page title when the driver has one,
|
|
1138
|
+
* otherwise the first line of what was observed.
|
|
1139
|
+
*
|
|
1140
|
+
* @param {Object} observation
|
|
1141
|
+
* @returns {string|null}
|
|
1142
|
+
*/
|
|
1143
|
+
export function observationTitle(observation) {
|
|
1144
|
+
if (observation && typeof observation.title === 'string' && observation.title.trim()) return observation.title.trim().slice(0, 120);
|
|
1145
|
+
const line = String((observation && observation.text) || '').split('\n').map(part => part.trim()).find(Boolean);
|
|
1146
|
+
return line ? line.slice(0, 80) : null;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* Cap at `minor` every finding whose evidence is only automatic network
|
|
1151
|
+
* notes, in code rather than by asking.
|
|
1152
|
+
*
|
|
1153
|
+
* A finding rests only on network notes when it cites at least one (a
|
|
1154
|
+
* `note N` ref to an automatic network note, or the runner's console-error
|
|
1155
|
+
* screenshot whose notes were all network notes) and nothing else: no persona
|
|
1156
|
+
* note, no automatic non-network note, no observation ref, no screenshot the
|
|
1157
|
+
* persona took, no other evidence type. Only the runner's own landing and
|
|
1158
|
+
* final screenshots neither count for nor against it.
|
|
1159
|
+
*
|
|
1160
|
+
* @param {Object} report a stamped report
|
|
1161
|
+
* @param {{notes: Array<Object>, screenshots: Array<Object>}} context
|
|
1162
|
+
* @returns {Object} the report, with capped findings rewritten
|
|
1163
|
+
*/
|
|
1164
|
+
export function capNetworkOnlyFindings(report, { notes = [], screenshots = [] } = {}) {
|
|
1165
|
+
if (!report || !Array.isArray(report.findings)) return report;
|
|
1166
|
+
const byShot = new Map();
|
|
1167
|
+
for (const note of notes) {
|
|
1168
|
+
if (!note || !note.automatic || !note.screenshot) continue;
|
|
1169
|
+
if (!byShot.has(note.screenshot)) byShot.set(note.screenshot, []);
|
|
1170
|
+
byShot.get(note.screenshot).push(note);
|
|
1171
|
+
}
|
|
1172
|
+
const reasons = new Map(screenshots.filter(shot => shot && shot.relativePath).map(shot => [shot.relativePath, String(shot.reason || '')]));
|
|
1173
|
+
|
|
1174
|
+
const verdict = finding => {
|
|
1175
|
+
let network = 0;
|
|
1176
|
+
for (const item of Array.isArray(finding.evidence) ? finding.evidence : []) {
|
|
1177
|
+
if (!item) continue;
|
|
1178
|
+
const ref = String(item.ref || '');
|
|
1179
|
+
if (item.type === 'transcript_step') {
|
|
1180
|
+
// An observation ref, or anything that is not a network note, is
|
|
1181
|
+
// other evidence.
|
|
1182
|
+
const match = ref.match(/^\s*note\s*#?\s*(\d+)\s*$/i);
|
|
1183
|
+
const note = match ? notes[Number(match[1]) - 1] : null;
|
|
1184
|
+
if (note && note.automatic && note.kind === 'network') network += 1;
|
|
1185
|
+
else return false;
|
|
1186
|
+
} else if (item.type === 'screenshot') {
|
|
1187
|
+
const attached = byShot.get(ref);
|
|
1188
|
+
if (attached && attached.length > 0) {
|
|
1189
|
+
if (attached.every(note => note.kind === 'network')) network += 1;
|
|
1190
|
+
else return false;
|
|
1191
|
+
continue;
|
|
1192
|
+
}
|
|
1193
|
+
// The runner's own landing and final screenshots say nothing either
|
|
1194
|
+
// way; any screenshot the persona took is other evidence.
|
|
1195
|
+
const reason = reasons.has(ref) ? reasons.get(ref) : null;
|
|
1196
|
+
if (reason !== null && RUNNER_SCREENSHOTS.includes(reason)) continue;
|
|
1197
|
+
return false;
|
|
1198
|
+
} else {
|
|
1199
|
+
return false;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
return network > 0;
|
|
1203
|
+
};
|
|
1204
|
+
|
|
1205
|
+
return {
|
|
1206
|
+
...report,
|
|
1207
|
+
findings: report.findings.map(finding => {
|
|
1208
|
+
if (!finding || !ABOVE_MINOR.includes(finding.severity) || !verdict(finding)) return finding;
|
|
1209
|
+
const description = String(finding.description || '');
|
|
1210
|
+
return { ...finding, severity: 'minor', description: description ? `${description}\n\n${NETWORK_CAP_NOTE}` : NETWORK_CAP_NOTE };
|
|
1211
|
+
})
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* Put the runner's own timestamp at the head of a report.md, right under
|
|
1217
|
+
* its first heading, replacing one stamped before. Whatever the model wrote
|
|
1218
|
+
* in its prose is left alone; the header is what says when the report was
|
|
1219
|
+
* generated.
|
|
1220
|
+
*
|
|
1221
|
+
* @param {string} markdown
|
|
1222
|
+
* @param {string} generatedAt an ISO timestamp
|
|
1223
|
+
* @returns {string}
|
|
1224
|
+
*/
|
|
1225
|
+
export function stampGeneratedAt(markdown, generatedAt, facts = {}) {
|
|
1226
|
+
const line = `**Generated at:** ${generatedAt}`;
|
|
1227
|
+
const lines = String(markdown || '').split('\n').filter(entry => !/^\*\*Generated at:\*\* /.test(entry));
|
|
1228
|
+
const heading = lines.findIndex(entry => /^# /.test(entry));
|
|
1229
|
+
if (heading === -1) lines.unshift(line, '');
|
|
1230
|
+
else lines.splice(heading + 1, 0, line);
|
|
1231
|
+
// The template's own slots (`**Date:** <date>`) are filled from what the
|
|
1232
|
+
// runner knows, because the prompt forbids the model a calendar date and a
|
|
1233
|
+
// model that obeys both writes the placeholder itself.
|
|
1234
|
+
return fillPlaceholders(lines.join('\n'), { generatedAt, ...facts });
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* What the runner knows about a run, for the template slots a report left
|
|
1239
|
+
* unfilled.
|
|
1240
|
+
*
|
|
1241
|
+
* @returns {{driver: string, target: string, persona: string, actionsTaken: number, quitReason: string}}
|
|
1242
|
+
*/
|
|
1243
|
+
function reportFacts({ persona, driverName, target, play }) {
|
|
1244
|
+
return {
|
|
1245
|
+
driver: driverName,
|
|
1246
|
+
target,
|
|
1247
|
+
persona: persona ? persona.name || persona.slug : undefined,
|
|
1248
|
+
actionsTaken: play ? play.actionsTaken : undefined,
|
|
1249
|
+
quitReason: play ? play.quitReason : undefined
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1047
1253
|
/**
|
|
1048
1254
|
* The `prompt_version` the report has to carry, read out of the skill's own
|
|
1049
1255
|
* frontmatter so a content pack update moves it without a CLI release.
|
|
@@ -1056,4 +1262,4 @@ export function promptVersionOf(skill) {
|
|
|
1056
1262
|
return match ? match[1] : null;
|
|
1057
1263
|
}
|
|
1058
1264
|
|
|
1059
|
-
export default { runPersona, buildSystem, promptVersionOf, shouldEscalate, renderFallbackMarkdown, notesToFindings, nearestScreenshot };
|
|
1265
|
+
export default { runPersona, buildSystem, promptVersionOf, shouldEscalate, renderFallbackMarkdown, notesToFindings, nearestScreenshot, capNetworkOnlyFindings, stampGeneratedAt, noteLine, observationTitle };
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template placeholders a model copied into a report instead of filling.
|
|
3
|
+
*
|
|
4
|
+
* The persona skill's report template reads `**Date:** <date> ...`, and the
|
|
5
|
+
* report prompt says never to write a calendar date, so a model that obeys
|
|
6
|
+
* both writes the placeholder itself (`<run date>`) and the developer reads
|
|
7
|
+
* it as the report's date.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately narrow, because angle brackets in a report are usually real:
|
|
10
|
+
* a key (`<Enter>`, `<Ctrl+A>`), an element (`<form>`, `<title>`), a type
|
|
11
|
+
* (`Array<string>`), a link, or the game's own text. So only two things are
|
|
12
|
+
* treated as a slot:
|
|
13
|
+
*
|
|
14
|
+
* 1. A slot the runner knows (date, driver, target, persona, actions, quit
|
|
15
|
+
* reason, game, version), filled from its own facts, or "not recorded"
|
|
16
|
+
* when it has no value for it.
|
|
17
|
+
* 2. Lowercase words in brackets on a `**Field:**` line, the shape of the
|
|
18
|
+
* template's header, which become "not recorded".
|
|
19
|
+
*
|
|
20
|
+
* Never touched: a `<` straight after a word character, a key name, anything
|
|
21
|
+
* quoted, HTML, autolinks, code spans and fenced blocks.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** What a slot with no value becomes. */
|
|
25
|
+
export const NOT_RECORDED = 'not recorded';
|
|
26
|
+
|
|
27
|
+
const HTML_TAGS = new Set([
|
|
28
|
+
'a', 'abbr', 'article', 'aside', 'b', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'code', 'dd', 'del',
|
|
29
|
+
'details', 'dialog', 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5',
|
|
30
|
+
'h6', 'head', 'header', 'hr', 'html', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'label', 'li', 'link', 'main', 'mark',
|
|
31
|
+
'meta', 'nav', 'ol', 'option', 'p', 'picture', 'pre', 'q', 's', 'samp', 'script', 'section', 'select', 'small',
|
|
32
|
+
'source', 'span', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea',
|
|
33
|
+
'tfoot', 'th', 'thead', 'title', 'tr', 'u', 'ul', 'video'
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const KEY_NAMES = new Set([
|
|
37
|
+
'enter', 'return', 'tab', 'space', 'spacebar', 'esc', 'escape', 'backspace', 'delete', 'del', 'insert', 'home', 'end',
|
|
38
|
+
'pageup', 'pagedown', 'up', 'down', 'left', 'right', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'shift',
|
|
39
|
+
'ctrl', 'control', 'alt', 'option', 'cmd', 'command', 'meta', 'super', 'win', 'fn', 'capslock'
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/** `<...>` with no nested bracket and no line break, short enough to be a template slot. */
|
|
43
|
+
const ANGLE = /<([^<>\n]{1,80})>/g;
|
|
44
|
+
|
|
45
|
+
/** A line in the template's header shape: `**Date:** ...`. */
|
|
46
|
+
const FIELD_LINE = /\*\*[^*\n]+:\*\*/;
|
|
47
|
+
|
|
48
|
+
const QUOTES = { '"': '"', "'": "'", '“': '”', '‘': '’', '`': '`' };
|
|
49
|
+
|
|
50
|
+
function normalise(inner) {
|
|
51
|
+
return String(inner).trim().toLowerCase().replace(/[_-]+/g, ' ').replace(/\s+/g, ' ');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether the bracket is a slot the runner knows, and its value if it has one.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} inner
|
|
58
|
+
* @param {Object} facts
|
|
59
|
+
* @returns {{known: boolean, value: string|null}}
|
|
60
|
+
*/
|
|
61
|
+
function knownSlot(inner, facts) {
|
|
62
|
+
const key = normalise(inner);
|
|
63
|
+
const date = facts.generatedAt ? String(facts.generatedAt).slice(0, 10) : null;
|
|
64
|
+
const actions = facts.actionsTaken === null || facts.actionsTaken === undefined || !Number.isFinite(Number(facts.actionsTaken))
|
|
65
|
+
? null
|
|
66
|
+
: Number(facts.actionsTaken);
|
|
67
|
+
const slot = value => ({ known: true, value: value === null || value === undefined || value === '' ? null : String(value) });
|
|
68
|
+
if (/^(run |report |session )?(date|timestamp|date and time)$/.test(key) || key === 'generated at') return slot(date);
|
|
69
|
+
if (/^driver( name)?$/.test(key)) return slot(facts.driver);
|
|
70
|
+
if (/^(target|target url|build url|game url)$/.test(key)) return slot(facts.target);
|
|
71
|
+
if (/^persona( name| slug)?$/.test(key)) return slot(facts.persona);
|
|
72
|
+
if (/^(n|number of) actions$/.test(key)) return slot(actions === null ? null : `${actions} actions`);
|
|
73
|
+
if (key === 'actions taken') return slot(actions);
|
|
74
|
+
if (/^(quit|stop) reason$/.test(key)) return slot(facts.quitReason);
|
|
75
|
+
if (key === 'game' || key === 'game name') return slot(facts.game);
|
|
76
|
+
if (key === 'version' || key === 'game version') return slot(facts.version);
|
|
77
|
+
return { known: false, value: null };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The replacement for one bracket match, or null to leave it as written.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} text the prose segment the match is in
|
|
84
|
+
* @param {string} whole the match, brackets included
|
|
85
|
+
* @param {string} inner
|
|
86
|
+
* @param {number} offset
|
|
87
|
+
* @param {string} line the whole line, for the field check
|
|
88
|
+
* @param {Object} facts
|
|
89
|
+
* @returns {string|null}
|
|
90
|
+
*/
|
|
91
|
+
function replacementFor(text, whole, inner, offset, line, facts) {
|
|
92
|
+
const before = text[offset - 1];
|
|
93
|
+
const after = text[offset + whole.length];
|
|
94
|
+
// `Array<string>`, `Map<K, V>`: a type, not a slot.
|
|
95
|
+
if (before !== undefined && /\w/.test(before)) return null;
|
|
96
|
+
// The game's own words, quoted as evidence.
|
|
97
|
+
if (before !== undefined && QUOTES[before] !== undefined && QUOTES[before] === after) return null;
|
|
98
|
+
const value = String(inner).trim();
|
|
99
|
+
if (!/^[A-Za-z]/.test(value)) return null;
|
|
100
|
+
// A key or a chord: <Enter>, <Ctrl+A>, <Shift + Tab>.
|
|
101
|
+
if (value.includes('+') || KEY_NAMES.has(value.toLowerCase())) return null;
|
|
102
|
+
const known = knownSlot(value, facts);
|
|
103
|
+
if (known.known) return known.value || NOT_RECORDED;
|
|
104
|
+
if (FIELD_LINE.test(line) && /^[a-z]+(?: [a-z]+)*$/.test(value) && !HTML_TAGS.has(value)) return NOT_RECORDED;
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Walk the prose of a markdown document (not fenced blocks, not code spans),
|
|
110
|
+
* handing each segment and its whole line to `fn`.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} markdown
|
|
113
|
+
* @param {(text: string, line: string) => string} fn
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
function mapProse(markdown, fn) {
|
|
117
|
+
let fenced = false;
|
|
118
|
+
return String(markdown == null ? '' : markdown).split('\n').map(line => {
|
|
119
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
120
|
+
fenced = !fenced;
|
|
121
|
+
return line;
|
|
122
|
+
}
|
|
123
|
+
if (fenced) return line;
|
|
124
|
+
return line.split(/(`[^`]*`)/).map(part => (part.length > 1 && part.startsWith('`') && part.endsWith('`') ? part : fn(part, line))).join('');
|
|
125
|
+
}).join('\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Fill the slots the runner knows, and the header fields it does not, from
|
|
130
|
+
* its own facts. Everything else in angle brackets is left as written.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} markdown
|
|
133
|
+
* @param {{generatedAt?: string, driver?: string, target?: string, persona?: string,
|
|
134
|
+
* actionsTaken?: number, quitReason?: string, game?: string, version?: string}} [facts]
|
|
135
|
+
* @returns {string}
|
|
136
|
+
*/
|
|
137
|
+
export function fillPlaceholders(markdown, facts = {}) {
|
|
138
|
+
return mapProse(markdown, (text, line) => text.replace(ANGLE, (whole, inner, offset) => {
|
|
139
|
+
const replacement = replacementFor(text, whole, inner, offset, line, facts);
|
|
140
|
+
return replacement === null ? whole : replacement;
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Every slot `fillPlaceholders` would still replace, for tests and a last check.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} markdown
|
|
148
|
+
* @returns {Array<string>}
|
|
149
|
+
*/
|
|
150
|
+
export function findPlaceholders(markdown) {
|
|
151
|
+
const found = [];
|
|
152
|
+
mapProse(markdown, (text, line) => {
|
|
153
|
+
for (const match of text.matchAll(ANGLE)) {
|
|
154
|
+
if (replacementFor(text, match[0], match[1], match.index, line, {}) !== null) found.push(match[0]);
|
|
155
|
+
}
|
|
156
|
+
return text;
|
|
157
|
+
});
|
|
158
|
+
return found;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The same filling over the prose fields of report.json, which the dashboard
|
|
163
|
+
* shows: titles, descriptions, repro steps and quotes.
|
|
164
|
+
*
|
|
165
|
+
* @param {Object} report
|
|
166
|
+
* @param {Object} [facts]
|
|
167
|
+
* @returns {Object}
|
|
168
|
+
*/
|
|
169
|
+
export function fillReportPlaceholders(report, facts = {}) {
|
|
170
|
+
if (!report || !Array.isArray(report.findings)) return report;
|
|
171
|
+
const fill = value => (typeof value === 'string' ? fillPlaceholders(value, facts) : value);
|
|
172
|
+
return {
|
|
173
|
+
...report,
|
|
174
|
+
findings: report.findings.map(finding => {
|
|
175
|
+
if (!finding || typeof finding !== 'object') return finding;
|
|
176
|
+
const out = { ...finding };
|
|
177
|
+
for (const field of ['title', 'description', 'persona_quote']) {
|
|
178
|
+
if (field in out) out[field] = fill(out[field]);
|
|
179
|
+
}
|
|
180
|
+
if (Array.isArray(out.repro_steps)) out.repro_steps = out.repro_steps.map(fill);
|
|
181
|
+
return out;
|
|
182
|
+
})
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export default { fillPlaceholders, findPlaceholders, fillReportPlaceholders, NOT_RECORDED };
|
package/src/run/resume.js
CHANGED
|
@@ -3,6 +3,7 @@ import { getDeps } from './deps.js';
|
|
|
3
3
|
import EXIT from './exit.js';
|
|
4
4
|
import { runDir as runDirFor } from './paths.js';
|
|
5
5
|
import { buildPartialReport, readTranscript, shouldFinalize } from './regenerate.js';
|
|
6
|
+
import { reconcileDroppedFindings } from './dropped.js';
|
|
6
7
|
import { createReportValidator, writeReportFiles } from './report.js';
|
|
7
8
|
import * as stateFile from './state.js';
|
|
8
9
|
import { playJob, resolvePersonas } from '../commands/run.js';
|
|
@@ -221,7 +222,7 @@ async function finalizeFromTranscript({ deps, api, gameId, jobId, repoRoot, row,
|
|
|
221
222
|
// `interrupted` can go to `reporting`, which is where this run actually is:
|
|
222
223
|
// it has a report now. Then `uploading`, then the delivery.
|
|
223
224
|
await api.runs.transition(gameId, jobId, row.run_id, 'reporting', { reason: 'rebuilt from transcript' });
|
|
224
|
-
await deps.finalizeRun(directory, {
|
|
225
|
+
const answer = await deps.finalizeRun(directory, {
|
|
225
226
|
api,
|
|
226
227
|
gameId,
|
|
227
228
|
jobId,
|
|
@@ -237,6 +238,20 @@ async function finalizeFromTranscript({ deps, api, gameId, jobId, repoRoot, row,
|
|
|
237
238
|
report_md: markdown
|
|
238
239
|
}
|
|
239
240
|
});
|
|
241
|
+
if (answer && answer.dropped_findings > 0) {
|
|
242
|
+
log(`run ${row.run_id}: the server dropped ${answer.dropped_findings} finding(s) with no usable evidence.`);
|
|
243
|
+
try {
|
|
244
|
+
await reconcileDroppedFindings({
|
|
245
|
+
directory,
|
|
246
|
+
droppedCount: answer.dropped_findings,
|
|
247
|
+
droppedIds: answer.dropped_finding_ids,
|
|
248
|
+
options,
|
|
249
|
+
journal: { jobId, runId: row.run_id, repoRoot }
|
|
250
|
+
});
|
|
251
|
+
} catch (markError) {
|
|
252
|
+
log(`run ${row.run_id}: could not mark the dropped finding(s) in the local report (${String(markError && markError.message)}).`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
240
255
|
} catch (error) {
|
|
241
256
|
log(`run ${row.run_id}: could not be finalised (${String(error && error.message)}); it will be replayed.`);
|
|
242
257
|
return false;
|
package/src/run/synthetic.js
CHANGED
|
@@ -154,6 +154,9 @@ export class SyntheticSession {
|
|
|
154
154
|
}
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/** The run summary's line when the page's own SDK sends the run's telemetry. */
|
|
158
|
+
export const PAGE_SDK_REASON = "synthetic telemetry comes from the game's own SDK on the page, sent with the run's playtest token";
|
|
159
|
+
|
|
157
160
|
/**
|
|
158
161
|
* Emit the three events a run produces, best effort.
|
|
159
162
|
*
|
|
@@ -161,6 +164,8 @@ export class SyntheticSession {
|
|
|
161
164
|
* @param {string} args.apiUrl
|
|
162
165
|
* @param {string|null} args.ingestKey
|
|
163
166
|
* @param {string|null} args.playtestToken
|
|
167
|
+
* @param {boolean} [args.pageSdk] the driver injected the playtest token into
|
|
168
|
+
* the page (the web driver), so the game's own SDK sends with it
|
|
164
169
|
* @param {Object} args.context
|
|
165
170
|
* @param {Object} args.report the run's report.json
|
|
166
171
|
* @param {string} args.driver
|
|
@@ -168,8 +173,14 @@ export class SyntheticSession {
|
|
|
168
173
|
* @param {(message: string) => void} [args.log]
|
|
169
174
|
* @returns {Promise<{sent: boolean, accepted: number, reason?: string}>}
|
|
170
175
|
*/
|
|
171
|
-
export async function emitRunEvents({ apiUrl, ingestKey, playtestToken, context, report, driver, fetchImpl, log }) {
|
|
176
|
+
export async function emitRunEvents({ apiUrl, ingestKey, playtestToken, pageSdk = false, context, report, driver, fetchImpl, log }) {
|
|
172
177
|
if (!ingestKey) {
|
|
178
|
+
// A web build carries its own SDK, and the web driver injected the run's
|
|
179
|
+
// playtest token into the page, so the game's own events already arrive
|
|
180
|
+
// marked synthetic. Saying "nothing was sent" there would be wrong.
|
|
181
|
+
if (pageSdk && playtestToken) {
|
|
182
|
+
return { sent: false, accepted: 0, pageSdk: true, reason: PAGE_SDK_REASON };
|
|
183
|
+
}
|
|
173
184
|
return { sent: false, accepted: 0, reason: 'no ingest key configured, so no synthetic telemetry was sent' };
|
|
174
185
|
}
|
|
175
186
|
if (!playtestToken) {
|
|
@@ -182,6 +193,8 @@ export async function emitRunEvents({ apiUrl, ingestKey, playtestToken, context,
|
|
|
182
193
|
|
|
183
194
|
session.track('playtest_run_started', { driver });
|
|
184
195
|
for (const finding of Array.isArray(report && report.findings) ? report.findings : []) {
|
|
196
|
+
// The server kept no record of a dropped finding, so neither does the game's telemetry.
|
|
197
|
+
if (finding && finding.dropped === true) continue;
|
|
185
198
|
session.track('playtest_finding', {
|
|
186
199
|
finding_id: finding.id,
|
|
187
200
|
severity: finding.severity,
|
package/src/run/tools.js
CHANGED
|
@@ -86,7 +86,7 @@ export const PLAY_TOOLS = Object.freeze([
|
|
|
86
86
|
},
|
|
87
87
|
{
|
|
88
88
|
name: 'type',
|
|
89
|
-
description: 'Type text. Spends an action. Set submit to press Enter afterwards.',
|
|
89
|
+
description: 'Type text. Spends an action. Set submit to press Enter afterwards. To correct or clear a text field, set replace: the field is emptied first and holds only this text afterwards (an empty text clears it). Do that rather than a select-all key chord, which differs between platforms.',
|
|
90
90
|
input_schema: {
|
|
91
91
|
type: 'object',
|
|
92
92
|
additionalProperties: false,
|
|
@@ -94,6 +94,8 @@ export const PLAY_TOOLS = Object.freeze([
|
|
|
94
94
|
properties: {
|
|
95
95
|
text: { type: 'string' },
|
|
96
96
|
submit: { type: 'boolean' },
|
|
97
|
+
replace: { type: 'boolean', description: 'Empty the field first, so it holds only this text. playwright_web only; other drivers refuse it.' },
|
|
98
|
+
target: { type: 'string', description: 'playwright_web only, other drivers refuse it: the field to type into, named like a click target, such as textbox "Email". Without it, the focused field.' },
|
|
97
99
|
thought_in_character: thought,
|
|
98
100
|
note
|
|
99
101
|
}
|
|
@@ -101,7 +103,7 @@ export const PLAY_TOOLS = Object.freeze([
|
|
|
101
103
|
},
|
|
102
104
|
{
|
|
103
105
|
name: 'key',
|
|
104
|
-
description: 'Press one key. Spends an action. Names are the usual ones: Enter, Space, ArrowUp, Escape, a, 1.',
|
|
106
|
+
description: 'Press one key. Spends an action. Names are the usual ones: Enter, Space, ArrowUp, Escape, a, 1. Hold a modifier with +, such as Shift+Tab; editing shortcuts such as Control+A are sent with this platform\'s own modifier. To clear a text field, use type with replace instead.',
|
|
105
107
|
input_schema: {
|
|
106
108
|
type: 'object',
|
|
107
109
|
additionalProperties: false,
|
|
@@ -187,8 +189,12 @@ export function actionFor(toolUse) {
|
|
|
187
189
|
return Number.isFinite(Number(input.x)) && Number.isFinite(Number(input.y))
|
|
188
190
|
? { kind: 'click', target: { x: Number(input.x), y: Number(input.y) } }
|
|
189
191
|
: { kind: 'click', target: String(input.target || '') };
|
|
190
|
-
case 'type':
|
|
191
|
-
|
|
192
|
+
case 'type': {
|
|
193
|
+
const action = { kind: 'type', text: String(input.text || ''), submit: Boolean(input.submit) };
|
|
194
|
+
if (input.replace === true) action.replace = true;
|
|
195
|
+
if (typeof input.target === 'string' && input.target.trim()) action.target = input.target;
|
|
196
|
+
return action;
|
|
197
|
+
}
|
|
192
198
|
case 'key':
|
|
193
199
|
return { kind: 'key', key: String(input.key || '') };
|
|
194
200
|
case 'navigate':
|
package/src/states.js
CHANGED
|
@@ -90,8 +90,10 @@ export const RUN_TRANSITIONS = Object.freeze({
|
|
|
90
90
|
queued: ['launching', 'canceled', 'skipped', 'failed', 'interrupted'],
|
|
91
91
|
launching: ['playing', 'failed', 'canceled', 'interrupted'],
|
|
92
92
|
playing: ['reporting', 'failed', 'canceled', 'budget_exceeded', 'interrupted'],
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
// A run out of money in its report step uploads its partial report and
|
|
94
|
+
// ends here; the API allows it from 0.2.7's companion server change.
|
|
95
|
+
reporting: ['uploading', 'failed', 'budget_exceeded', 'interrupted'],
|
|
96
|
+
uploading: ['succeeded', 'failed', 'budget_exceeded', 'interrupted'],
|
|
95
97
|
interrupted: ['launching', 'playing', 'reporting', 'uploading', 'failed', 'canceled'],
|
|
96
98
|
succeeded: [],
|
|
97
99
|
failed: [],
|