ravensight-playtest 0.3.1 → 0.3.2
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 +181 -20
- package/docs/NATIVE_RUNNER.md +2 -2
- package/package.json +2 -2
- package/src/api/client.js +9 -3
- package/src/briefLocal.js +126 -0
- package/src/cli.js +14 -3
- package/src/commands/brief.js +48 -9
- package/src/commands/check.js +187 -38
- package/src/commands/fix.js +12 -1
- package/src/commands/init.js +13 -6
- package/src/commands/personas.js +50 -0
- package/src/commands/run.js +240 -21
- package/src/run/args.js +18 -1
- package/src/run/drivers/web.js +389 -79
- package/src/run/noise.js +102 -0
- package/src/run/personaLoop.js +308 -24
- package/src/run/report.js +187 -0
- package/src/run/resume.js +5 -0
- package/src/run/tools.js +1 -1
- package/src/runDefaults.js +90 -0
- package/src/state/index.js +4 -1
- package/src/ui/index.js +15 -1
package/src/run/noise.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console noise a developer expects, which is never a finding.
|
|
3
|
+
*
|
|
4
|
+
* Two sources: the built in rule (a 401 on a who am I check is what a
|
|
5
|
+
* signed out page does when it asks "who am I?"; the Snowball round filed it
|
|
6
|
+
* as a bug), and the brief's own `expected_noise` patterns, which live in the
|
|
7
|
+
* local brief (see ../briefLocal.js).
|
|
8
|
+
*
|
|
9
|
+
* A pattern is matched against an automatic console note:
|
|
10
|
+
*
|
|
11
|
+
* - `/.../` is a case insensitive regular expression over the note's text and URL;
|
|
12
|
+
* - otherwise any status codes (`401`) and paths (`/auth/me`) in it must all
|
|
13
|
+
* appear in the note (its status, URL or text), and the other words are a
|
|
14
|
+
* description for the reader, such as "before login";
|
|
15
|
+
* - a pattern with neither is a case insensitive substring of the note's text.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The built in rule, as the prompt and the README state it. */
|
|
19
|
+
export const BUILT_IN_NOISE = 'a 401 on a who am I check, a request whose last path segment is /me, /whoami, /session or /current_user such as /auth/me, while signed out is not a finding';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A request whose final path segment asks who the visitor is: `/auth/me`,
|
|
23
|
+
* `/me`, `/whoami`, `/session`, `/current_user`. Only the last segment counts,
|
|
24
|
+
* so `/me/inventory`, `/user/42/save`, `/account/purchase`, `/token` and
|
|
25
|
+
* `/login` are never this rule's: a 401 there can be a real failure.
|
|
26
|
+
*/
|
|
27
|
+
const AUTH_CHECK = /\/(?:me|whoami|session|current[_-]?user)\/?(?=$|[?#\s)'",])/i;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {Object} note
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
function haystack(note) {
|
|
34
|
+
return `${note && note.text ? note.text : ''} ${note && note.url ? note.url : ''}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether a note is the built in case: a 401 on a who am I check.
|
|
39
|
+
*
|
|
40
|
+
* @param {Object} note
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
export function isAuthCheck401(note) {
|
|
44
|
+
if (!note) return false;
|
|
45
|
+
const is401 = note.status === 401 || /\b401\b/.test(String(note.text || ''));
|
|
46
|
+
return is401 && AUTH_CHECK.test(haystack(note));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether one pattern matches a note.
|
|
51
|
+
*
|
|
52
|
+
* @param {Object} note
|
|
53
|
+
* @param {string} pattern
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
export function matchesPattern(note, pattern) {
|
|
57
|
+
const value = String(pattern == null ? '' : pattern).trim();
|
|
58
|
+
if (!value || !note) return false;
|
|
59
|
+
const text = haystack(note);
|
|
60
|
+
const regex = value.match(/^\/(.+)\/([a-z]*)$/);
|
|
61
|
+
if (regex) {
|
|
62
|
+
try {
|
|
63
|
+
return new RegExp(regex[1], regex[2].includes('i') ? regex[2] : `${regex[2]}i`).test(text);
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const statuses = value.match(/\b[1-5]\d\d\b/g) || [];
|
|
69
|
+
const paths = (value.match(/(?:^|\s)(\/[^\s]*)/g) || []).map(entry => entry.trim().toLowerCase());
|
|
70
|
+
if (statuses.length === 0 && paths.length === 0) return text.toLowerCase().includes(value.toLowerCase());
|
|
71
|
+
const lower = text.toLowerCase();
|
|
72
|
+
return statuses.every(code => note.status === Number(code) || new RegExp(`\\b${code}\\b`).test(text))
|
|
73
|
+
&& paths.every(entry => lower.includes(entry));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether an automatic note is expected noise. A persona's own note never is:
|
|
78
|
+
* if the persona saw the game break, that is a finding whatever the console said.
|
|
79
|
+
*
|
|
80
|
+
* @param {Object} note
|
|
81
|
+
* @param {Array<string>} [patterns] the brief's expected_noise
|
|
82
|
+
* @returns {boolean}
|
|
83
|
+
*/
|
|
84
|
+
export function isExpectedNoise(note, patterns = []) {
|
|
85
|
+
if (!note || !note.automatic) return false;
|
|
86
|
+
if (isAuthCheck401(note)) return true;
|
|
87
|
+
return (Array.isArray(patterns) ? patterns : []).some(pattern => matchesPattern(note, pattern));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The expected noise block of the persona prompt.
|
|
92
|
+
*
|
|
93
|
+
* @param {Array<string>} [patterns]
|
|
94
|
+
* @returns {string}
|
|
95
|
+
*/
|
|
96
|
+
export function expectedNoiseForPrompt(patterns = []) {
|
|
97
|
+
const lines = [`- built in: ${BUILT_IN_NOISE}`];
|
|
98
|
+
for (const pattern of Array.isArray(patterns) ? patterns : []) if (typeof pattern === 'string' && pattern.trim()) lines.push(`- ${pattern.trim()}`);
|
|
99
|
+
return lines.join('\n');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export default { isExpectedNoise, isAuthCheck401, matchesPattern, expectedNoiseForPrompt, BUILT_IN_NOISE };
|
package/src/run/personaLoop.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { rewriteMarkdown } from './dropped.js';
|
|
2
2
|
import { selectAction, JEV_CONTROLLER_PROMPT } from './actionSelector.js';
|
|
3
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { prepareMessages, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS, shrinkImages } from './context.js';
|
|
6
6
|
import { withRollingCacheBreakpoint } from './fixLoop.js';
|
|
7
7
|
import { callStepWithRetry, ProxyError, STEPS } from './model.js';
|
|
8
8
|
import { RUN_FILES } from './paths.js';
|
|
9
|
-
import { checkEvidence, createReportValidator, lintMarkdownSections, normaliseEvidence, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
|
|
9
|
+
import { appendNormalisedMarkdown, checkEvidence, createReportValidator, lintMarkdownSections, normaliseEnums, normaliseEvidence, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
|
|
10
10
|
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
14
|
import { fillPlaceholders, fillReportPlaceholders } from './placeholders.js';
|
|
15
|
+
import { BUILT_IN_NOISE, expectedNoiseForPrompt, isExpectedNoise } from './noise.js';
|
|
16
|
+
import { MIN_PASSWORD_CHARS } from '../briefLocal.js';
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* One persona's playthrough: PLAYING then REPORTING (spec 03).
|
|
@@ -130,6 +132,9 @@ export async function runPersona(args) {
|
|
|
130
132
|
runDirectory,
|
|
131
133
|
skill,
|
|
132
134
|
brief = null,
|
|
135
|
+
// The brief fields that stay on this machine (../briefLocal.js):
|
|
136
|
+
// expected_noise and test_account.
|
|
137
|
+
localBrief = {},
|
|
133
138
|
intentNotes = [],
|
|
134
139
|
schemas,
|
|
135
140
|
maxActions,
|
|
@@ -267,6 +272,7 @@ export async function runPersona(args) {
|
|
|
267
272
|
validator,
|
|
268
273
|
skill: args.reportSkill || skill,
|
|
269
274
|
brief,
|
|
275
|
+
localBrief,
|
|
270
276
|
intentNotes,
|
|
271
277
|
transcript,
|
|
272
278
|
ledger,
|
|
@@ -330,6 +336,7 @@ async function playLoop({
|
|
|
330
336
|
target,
|
|
331
337
|
skill,
|
|
332
338
|
brief,
|
|
339
|
+
localBrief = {},
|
|
333
340
|
intentNotes,
|
|
334
341
|
maxActions,
|
|
335
342
|
transcript,
|
|
@@ -344,8 +351,10 @@ async function playLoop({
|
|
|
344
351
|
onBudgetWarning = null,
|
|
345
352
|
log = () => {}
|
|
346
353
|
}) {
|
|
347
|
-
const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions, runId });
|
|
354
|
+
const system = buildSystem({ skill, persona, brief, localBrief, intentNotes, driverName, target, maxActions, runId });
|
|
348
355
|
const identity = runIdentity({ persona, runId });
|
|
356
|
+
const secrets = secretsOf(localBrief);
|
|
357
|
+
const noisePatterns = (localBrief && Array.isArray(localBrief.expected_noise)) ? localBrief.expected_noise : [];
|
|
349
358
|
if (controller === 'jev') system.push({ type: 'text', text: JEV_CONTROLLER_PROMPT });
|
|
350
359
|
const availableTools = driver.supportedActions
|
|
351
360
|
? [...PLAY_TOOLS, ...(driver.supportedActions.includes('swipe') ? [SWIPE_TOOL] : [])].filter(tool => !ACTION_TOOLS.includes(tool.name) || driver.supportedActions.includes(tool.name))
|
|
@@ -391,7 +400,9 @@ async function playLoop({
|
|
|
391
400
|
return entry;
|
|
392
401
|
};
|
|
393
402
|
const noted = note => {
|
|
394
|
-
|
|
403
|
+
// A note never carries the test account password, in the transcript, a
|
|
404
|
+
// partial report or the report prompt.
|
|
405
|
+
const entry = { ...scrubSecrets(note, secrets), at_action: actions, observation: observations.length, order: nextOrder() };
|
|
395
406
|
notes.push(entry);
|
|
396
407
|
awaitingAfter.push(entry);
|
|
397
408
|
return entry;
|
|
@@ -520,7 +531,7 @@ async function playLoop({
|
|
|
520
531
|
// overwrite the transcript line's `kind`, and every note would
|
|
521
532
|
// read as a different sort of line.
|
|
522
533
|
noted(note);
|
|
523
|
-
transcript.write({ kind: 'note', tool: toolUse.name, note });
|
|
534
|
+
transcript.write({ kind: 'note', tool: toolUse.name, note: scrubSecrets(note, secrets) });
|
|
524
535
|
},
|
|
525
536
|
onScreenshot: shot => {
|
|
526
537
|
screenshots.push({ ...shot, observation: observations.length, order: nextOrder() });
|
|
@@ -533,8 +544,8 @@ async function playLoop({
|
|
|
533
544
|
const actionLine = {
|
|
534
545
|
kind: 'action',
|
|
535
546
|
tool: toolUse.name,
|
|
536
|
-
input: redactToolInput(toolUse),
|
|
537
|
-
thought: (toolUse.input && toolUse.input.thought_in_character) || null,
|
|
547
|
+
input: redactToolInput(toolUse, secrets),
|
|
548
|
+
thought: scrubSecrets((toolUse.input && toolUse.input.thought_in_character) || null, secrets),
|
|
538
549
|
ok: !executed.error,
|
|
539
550
|
error: executed.error,
|
|
540
551
|
changed: executed.changed,
|
|
@@ -597,8 +608,12 @@ async function playLoop({
|
|
|
597
608
|
...(entry.url && Number.isInteger(entry.status) ? { status: entry.status } : {}),
|
|
598
609
|
...(shot ? { screenshot: shot.relativePath } : {})
|
|
599
610
|
};
|
|
611
|
+
// Noise the developer said to expect (or the built in rule: a 401 on
|
|
612
|
+
// an auth check while signed out) is marked, so the report step is
|
|
613
|
+
// told and the runner can drop a finding that rests only on it.
|
|
614
|
+
if (isExpectedNoise({ ...note, status: Number.isInteger(entry.status) ? entry.status : note.status }, noisePatterns)) note.expected_noise = true;
|
|
600
615
|
noted(note);
|
|
601
|
-
transcript.write({ kind: 'note', note });
|
|
616
|
+
transcript.write({ kind: 'note', note: scrubSecrets(note, secrets) });
|
|
602
617
|
}
|
|
603
618
|
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}.` : ''}`);
|
|
604
619
|
}
|
|
@@ -664,9 +679,11 @@ async function reportPhase({
|
|
|
664
679
|
driverName,
|
|
665
680
|
target,
|
|
666
681
|
runDirectory,
|
|
682
|
+
schemas = [],
|
|
667
683
|
validator,
|
|
668
684
|
skill,
|
|
669
685
|
brief,
|
|
686
|
+
localBrief = {},
|
|
670
687
|
intentNotes,
|
|
671
688
|
transcript,
|
|
672
689
|
ledger,
|
|
@@ -678,7 +695,11 @@ async function reportPhase({
|
|
|
678
695
|
endedAt,
|
|
679
696
|
log
|
|
680
697
|
}) {
|
|
681
|
-
|
|
698
|
+
// The report step never sees the test account: it has nothing to sign in
|
|
699
|
+
// to, and the password crosses the model proxy during play only.
|
|
700
|
+
const secrets = secretsOf(localBrief);
|
|
701
|
+
const reportLocal = localBrief && Array.isArray(localBrief.expected_noise) ? { expected_noise: localBrief.expected_noise } : {};
|
|
702
|
+
const system = buildSystem({ skill, persona, brief, localBrief: reportLocal, intentNotes, driverName, target, maxActions: play.actionsTaken, runId });
|
|
682
703
|
const messages = [{
|
|
683
704
|
role: 'user',
|
|
684
705
|
content: [{
|
|
@@ -693,6 +714,7 @@ async function reportPhase({
|
|
|
693
714
|
'',
|
|
694
715
|
'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.',
|
|
695
716
|
'Driver errors are never evidence of a game bug. An observation marked [driver error: ...] or a note "written on a driver error" means the runner could not carry out the action (a target that matched two elements, locator code, a ref not on screen, a timeout on a page that did not change); the game did nothing wrong there. Never report a blocked loop, a frozen screen or a broken button from them. The runner drops any blocker or major whose only cited observations are driver errors.',
|
|
717
|
+
`Notes marked expected noise match the brief's expected_noise or the built in rule (${BUILT_IN_NOISE}); they are never a finding of any severity, and the runner drops a finding whose transcript evidence is only expected noise.`,
|
|
696
718
|
'Notes of kind waiting ("waiting for X to finish") are context, never a finding of any severity, and the runner drops a finding cited only by them. Before reporting a loading problem, check the later observations and screenshots: if they show the content loaded, it is not a finding.',
|
|
697
719
|
'',
|
|
698
720
|
'Observations the persona was shown, by index:',
|
|
@@ -707,7 +729,7 @@ async function reportPhase({
|
|
|
707
729
|
networkNoteLines(notes),
|
|
708
730
|
'',
|
|
709
731
|
'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"}.',
|
|
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.',
|
|
732
|
+
'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. After you write the report, the runner shows each blocker or major (at most three) its cited screenshot and downgrades one step any finding the image contradicts.',
|
|
711
733
|
'',
|
|
712
734
|
'Every evidence entry is an object with exactly two keys, "type" and "ref", and nothing else. A screenshot is {"type": "screenshot", "ref": "screenshots/04-example.png"}, never "path" or "file".',
|
|
713
735
|
'',
|
|
@@ -722,6 +744,9 @@ async function reportPhase({
|
|
|
722
744
|
].join('\n')
|
|
723
745
|
}]
|
|
724
746
|
}];
|
|
747
|
+
// The prompt is built from notes, observations and the action log; none
|
|
748
|
+
// of them may carry the test account password into the report step.
|
|
749
|
+
messages[0].content[0].text = scrubSecrets(messages[0].content[0].text, secrets);
|
|
725
750
|
|
|
726
751
|
let report = null;
|
|
727
752
|
let markdown = null;
|
|
@@ -733,6 +758,8 @@ async function reportPhase({
|
|
|
733
758
|
let continued = false;
|
|
734
759
|
let droppedByRunner = [];
|
|
735
760
|
let downgradedByRunner = [];
|
|
761
|
+
let normalisedByRunner = [];
|
|
762
|
+
const reportSchema = ((schemas || []).find(schema => schema && schema.name === 'playtest-report') || {}).content || null;
|
|
736
763
|
|
|
737
764
|
// One repair at most when the schema or the evidence refs refuse what was
|
|
738
765
|
// written: each turn is a metered question, and the dogfood gate asks for
|
|
@@ -833,7 +860,16 @@ async function reportPhase({
|
|
|
833
860
|
if (normalised && normalised.changed > 0) {
|
|
834
861
|
transcript.write({ kind: 'report_evidence_normalised', attempt, entries: normalised.changed });
|
|
835
862
|
}
|
|
836
|
-
|
|
863
|
+
// Known out of range values (a category written as the severity, an
|
|
864
|
+
// unknown category) are brought back into the schema before anything
|
|
865
|
+
// judges the report, so a run is never lost to them for want of a repair
|
|
866
|
+
// turn. The server's schema stays strict.
|
|
867
|
+
const enumFixed = normalised ? normaliseEnums(normalised.report, reportSchema) : { report: null, changes: [] };
|
|
868
|
+
normalisedByRunner = enumFixed.changes;
|
|
869
|
+
if (enumFixed.changes.length > 0) {
|
|
870
|
+
transcript.write({ kind: 'report_values_normalised', attempt, changes: enumFixed.changes.map(change => ({ id: change.id, field: change.field, from: change.from, to: change.to })) });
|
|
871
|
+
}
|
|
872
|
+
const capped = normalised ? capNetworkOnlyFindings(stampReport(enumFixed.report, facts), { notes, screenshots }) : null;
|
|
837
873
|
const runnerDrop = capped ? dropRunnerRejectedFindings(capped, { notes, observations }) : { report: null, dropped: [] };
|
|
838
874
|
for (const entry of runnerDrop.dropped) {
|
|
839
875
|
transcript.write({ kind: 'finding_dropped', id: entry.finding.id, severity: entry.finding.severity, title: entry.finding.title, reason: entry.reason });
|
|
@@ -906,6 +942,22 @@ async function reportPhase({
|
|
|
906
942
|
});
|
|
907
943
|
}
|
|
908
944
|
|
|
945
|
+
// One bounded look at the evidence: each blocker or major (at most
|
|
946
|
+
// MAX_CLAIM_CHECKS) is shown the screenshot it cites with its claim. It runs
|
|
947
|
+
// after the report is settled, so a refusal can cost the check and never
|
|
948
|
+
// the report.
|
|
949
|
+
report = scrubSecrets(report, secrets);
|
|
950
|
+
if (!shrunk) {
|
|
951
|
+
const checked = await checkClaimsAgainstScreenshots({ client, jobId, runId, report, screenshots, runDirectory, ledger, transcript, log });
|
|
952
|
+
if (checked.downgraded.length > 0) {
|
|
953
|
+
report = checked.report;
|
|
954
|
+
downgradedByRunner = [...downgradedByRunner, ...checked.downgraded];
|
|
955
|
+
log(`downgraded ${checked.downgraded.length} finding(s) their own screenshot contradicts: ${checked.downgraded.map(entry => `${entry.id} (${entry.from} to ${entry.to}, ${entry.screenshot})`).join(', ')}`);
|
|
956
|
+
}
|
|
957
|
+
} else {
|
|
958
|
+
transcript.write({ kind: 'claim_check_skipped', reason: 'the report turn needed a smaller output ceiling to fit the budget' });
|
|
959
|
+
}
|
|
960
|
+
|
|
909
961
|
if (!markdown) {
|
|
910
962
|
markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason: 'The report step wrote report.json but no report.md.' });
|
|
911
963
|
}
|
|
@@ -915,16 +967,152 @@ async function reportPhase({
|
|
|
915
967
|
}
|
|
916
968
|
// The uploaded pair agree: report.md says the severity report.json does.
|
|
917
969
|
if (downgradedByRunner.length > 0) markdown = rewriteDowngradedMarkdown(markdown, downgradedByRunner);
|
|
970
|
+
if (normalisedByRunner.length > 0) {
|
|
971
|
+
markdown = appendNormalisedMarkdown(markdown, normalisedByRunner);
|
|
972
|
+
log(`normalised ${normalisedByRunner.length} value(s) the report schema does not allow: ${normalisedByRunner.map(change => `${change.id ? `${change.id} ` : ''}${change.field} ${JSON.stringify(change.from)} to ${JSON.stringify(change.to)}`).join(', ')}`);
|
|
973
|
+
}
|
|
918
974
|
const generatedAt = new Date().toISOString();
|
|
919
975
|
const facts = reportFacts({ persona, driverName, target, play });
|
|
920
976
|
markdown = stampGeneratedAt(markdown, generatedAt, facts);
|
|
921
977
|
// The same slots in the prose the dashboard shows from report.json.
|
|
922
978
|
report = fillReportPlaceholders(report, { generatedAt, ...facts });
|
|
979
|
+
// Whatever the model wrote, the password does not leave in a report.
|
|
980
|
+
markdown = scrubSecrets(markdown, secrets);
|
|
981
|
+
report = scrubSecrets(report, secrets);
|
|
923
982
|
|
|
924
983
|
await writeReportFiles({ runDirectory, markdown, report });
|
|
925
984
|
return { report, markdown, lint };
|
|
926
985
|
}
|
|
927
986
|
|
|
987
|
+
/** The most blocker or major findings whose screenshot is checked against the claim. */
|
|
988
|
+
export const MAX_CLAIM_CHECKS = 3;
|
|
989
|
+
|
|
990
|
+
/** Output ceiling for one claim check: the answer is one word. */
|
|
991
|
+
export const CLAIM_CHECK_MAX_TOKENS = 32;
|
|
992
|
+
|
|
993
|
+
/** The instruction for a claim check. */
|
|
994
|
+
export const CLAIM_CHECK_SYSTEM = 'You check playtest findings against their evidence. You are shown one screenshot and one claim about it. Answer with exactly one word: yes if the image contradicts the claim, no if it does not, unclear if the image cannot settle it.';
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* The screenshot a finding's claim is checked against: of those it cites, the
|
|
998
|
+
* one taken nearest after the latest observation it cites (the later of two on
|
|
999
|
+
* the same observation), else the last screenshot it cites. Null when it cites none that exists in the list.
|
|
1000
|
+
*
|
|
1001
|
+
* @param {Object} finding
|
|
1002
|
+
* @param {Array<Object>} screenshots
|
|
1003
|
+
* @returns {Object|null}
|
|
1004
|
+
*/
|
|
1005
|
+
export function citedAfterScreenshot(finding, screenshots) {
|
|
1006
|
+
const evidence = Array.isArray(finding && finding.evidence) ? finding.evidence : [];
|
|
1007
|
+
const byPath = new Map((screenshots || []).filter(shot => shot && shot.relativePath).map(shot => [shot.relativePath, shot]));
|
|
1008
|
+
const cited = evidence.filter(item => item && item.type === 'screenshot' && byPath.has(String(item.ref))).map(item => byPath.get(String(item.ref)));
|
|
1009
|
+
if (cited.length === 0) return null;
|
|
1010
|
+
const observations = evidence
|
|
1011
|
+
.map(item => (item && item.type === 'transcript_step' ? String(item.ref || '').match(/^\s*observation\s*#?\s*(\d+)\s*$/i) : null))
|
|
1012
|
+
.filter(Boolean)
|
|
1013
|
+
.map(match => Number(match[1]));
|
|
1014
|
+
if (observations.length > 0) {
|
|
1015
|
+
const latest = Math.max(...observations);
|
|
1016
|
+
// The nearest screen after the claim: the lowest observation at or after
|
|
1017
|
+
// it, and of two taken on the same observation the later one.
|
|
1018
|
+
const order = new Map((screenshots || []).map((shot, index) => [shot, index]));
|
|
1019
|
+
const after = cited
|
|
1020
|
+
.filter(shot => Number.isInteger(shot.observation) && shot.observation >= latest)
|
|
1021
|
+
.sort((a, b) => a.observation - b.observation || order.get(b) - order.get(a))[0];
|
|
1022
|
+
if (after) return after;
|
|
1023
|
+
}
|
|
1024
|
+
return cited[cited.length - 1];
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Read a claim check answer: yes, no or unclear, from its first word.
|
|
1029
|
+
*
|
|
1030
|
+
* @param {string} text
|
|
1031
|
+
* @returns {'yes'|'no'|'unclear'}
|
|
1032
|
+
*/
|
|
1033
|
+
export function claimCheckAnswer(text) {
|
|
1034
|
+
const match = String(text || '').match(/^[^A-Za-z]*(yes|no|unclear)\b/i);
|
|
1035
|
+
return match ? match[1].toLowerCase() : 'unclear';
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* The bounded verification turn of the report step. For each blocker or
|
|
1040
|
+
* major, at most {@link MAX_CLAIM_CHECKS}, the cited `after` screenshot is
|
|
1041
|
+
* sent with the finding's claim and the question "does this image contradict
|
|
1042
|
+
* the claim: yes, no, unclear". A yes downgrades the finding one step, with a
|
|
1043
|
+
* note naming the screenshot. Texas and Snowball both filed a finding its own
|
|
1044
|
+
* screenshot contradicted.
|
|
1045
|
+
*
|
|
1046
|
+
* It goes to `persona_playtest.act`, the run's one step that accepts an image
|
|
1047
|
+
* (the report step accepts none), with no tools and a one word ceiling, and
|
|
1048
|
+
* it is spent from the same run budget the warning at 80 percent keeps for the
|
|
1049
|
+
* report step. A budget refusal, or any other refusal, ends the checks and
|
|
1050
|
+
* keeps the report as it is.
|
|
1051
|
+
*
|
|
1052
|
+
* @returns {Promise<{report: Object, downgraded: Array<Object>}>}
|
|
1053
|
+
*/
|
|
1054
|
+
export async function checkClaimsAgainstScreenshots({ client, jobId, runId, report, screenshots, runDirectory, ledger, transcript, log = () => {} }) {
|
|
1055
|
+
if (!report || !Array.isArray(report.findings)) return { report, downgraded: [] };
|
|
1056
|
+
const targets = report.findings.filter(finding => finding && ABOVE_MINOR.includes(finding.severity)).slice(0, MAX_CLAIM_CHECKS);
|
|
1057
|
+
const downgraded = [];
|
|
1058
|
+
const verdicts = new Map();
|
|
1059
|
+
for (const finding of targets) {
|
|
1060
|
+
const shot = citedAfterScreenshot(finding, screenshots);
|
|
1061
|
+
if (!shot) {
|
|
1062
|
+
transcript.write({ kind: 'claim_check', id: finding.id, screenshot: null, answer: 'skipped', reason: 'no cited screenshot' });
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
let data;
|
|
1066
|
+
try {
|
|
1067
|
+
data = (await readFile(path.join(runDirectory, shot.relativePath))).toString('base64');
|
|
1068
|
+
} catch {
|
|
1069
|
+
transcript.write({ kind: 'claim_check', id: finding.id, screenshot: shot.relativePath, answer: 'skipped', reason: 'the screenshot could not be read' });
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
let answer;
|
|
1073
|
+
const askedAt = Date.now();
|
|
1074
|
+
try {
|
|
1075
|
+
answer = await callStepWithRetry({
|
|
1076
|
+
client,
|
|
1077
|
+
jobId,
|
|
1078
|
+
runId,
|
|
1079
|
+
step: STEPS.personaAct,
|
|
1080
|
+
system: CLAIM_CHECK_SYSTEM,
|
|
1081
|
+
messages: [{
|
|
1082
|
+
role: 'user',
|
|
1083
|
+
content: [
|
|
1084
|
+
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data } },
|
|
1085
|
+
{ type: 'text', text: `Claim (${finding.severity}): ${finding.title || ''}\n${String(finding.description || '').slice(0, 1200)}\n\nDoes this image contradict the claim: yes, no, unclear?` }
|
|
1086
|
+
]
|
|
1087
|
+
}],
|
|
1088
|
+
maxTokens: CLAIM_CHECK_MAX_TOKENS,
|
|
1089
|
+
escalate: false,
|
|
1090
|
+
maxAttempts: 1
|
|
1091
|
+
});
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
const reason = error instanceof ProxyError && error.isBudget ? 'the run budget is spent' : String(error && error.message ? error.message : error);
|
|
1094
|
+
transcript.write({ kind: 'claim_check', id: finding.id, screenshot: shot.relativePath, answer: 'skipped', reason });
|
|
1095
|
+
log(`the screenshot check stopped (${reason}); the report is kept as written.`);
|
|
1096
|
+
break;
|
|
1097
|
+
}
|
|
1098
|
+
if (ledger) ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
|
|
1099
|
+
const verdict = claimCheckAnswer(answer.text);
|
|
1100
|
+
transcript.write({ kind: 'claim_check', id: finding.id, screenshot: shot.relativePath, answer: verdict });
|
|
1101
|
+
if (verdict === 'yes' && DOWNGRADE[finding.severity]) verdicts.set(finding, shot.relativePath);
|
|
1102
|
+
}
|
|
1103
|
+
if (verdicts.size === 0) return { report, downgraded };
|
|
1104
|
+
const findings = report.findings.map(finding => {
|
|
1105
|
+
if (!verdicts.has(finding)) return finding;
|
|
1106
|
+
const screenshot = verdicts.get(finding);
|
|
1107
|
+
const to = DOWNGRADE[finding.severity];
|
|
1108
|
+
const note = `its own screenshot ${screenshot} contradicts the claim`;
|
|
1109
|
+
downgraded.push({ id: finding.id, from: finding.severity, to, screenshot, note });
|
|
1110
|
+
const description = String(finding.description || '');
|
|
1111
|
+
return { ...finding, severity: to, description: description ? `${description}\n\nDowngraded by the runner: ${note}.` : `Downgraded by the runner: ${note}.` };
|
|
1112
|
+
});
|
|
1113
|
+
return { report: { ...report, findings }, downgraded };
|
|
1114
|
+
}
|
|
1115
|
+
|
|
928
1116
|
/** The smallest output ceiling worth retrying a report turn with. */
|
|
929
1117
|
export const REPORT_MIN_TOKENS = 1536;
|
|
930
1118
|
|
|
@@ -1024,8 +1212,9 @@ async function callWithImageRetry({ imageBudget, keepObservations, messages, onI
|
|
|
1024
1212
|
* product where the same few thousand tokens are re-sent tens of times. The
|
|
1025
1213
|
* proxy allowlists `system` and passes the blocks through unchanged.
|
|
1026
1214
|
*/
|
|
1027
|
-
export function buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions, runId = null }) {
|
|
1215
|
+
export function buildSystem({ skill, persona, brief, localBrief = {}, intentNotes, driverName, target, maxActions, runId = null }) {
|
|
1028
1216
|
const identity = runIdentity({ persona, runId });
|
|
1217
|
+
const account = testAccountOf(localBrief);
|
|
1029
1218
|
return [
|
|
1030
1219
|
{ type: 'text', text: skill },
|
|
1031
1220
|
{ type: 'text', text: `<persona_file>\n${persona.body}\n</persona_file>` },
|
|
@@ -1037,7 +1226,9 @@ export function buildSystem({ skill, persona, brief, intentNotes, driverName, ta
|
|
|
1037
1226
|
`max_actions: ${maxActions}`,
|
|
1038
1227
|
`<expectations_brief>\n${briefForPrompt(brief)}\n</expectations_brief>`,
|
|
1039
1228
|
`<design_intent_notes>\n${intentNotesForPrompt(brief, intentNotes)}\n</design_intent_notes>`,
|
|
1040
|
-
|
|
1229
|
+
`<expected_noise>\nConsole errors and failed requests matching these are expected and never a finding:\n${expectedNoiseForPrompt(localBrief && localBrief.expected_noise)}\n</expected_noise>`,
|
|
1230
|
+
...(account ? [`<test_account>\n${testAccountForPrompt(account)}\n</test_account>`] : []),
|
|
1231
|
+
...(identity ? [`<run_identity>\n${runIdentityForPrompt(identity, { testAccount: Boolean(account) })}\n</run_identity>`] : [])
|
|
1041
1232
|
].join('\n'),
|
|
1042
1233
|
cache_control: { type: 'ephemeral' }
|
|
1043
1234
|
}
|
|
@@ -1077,11 +1268,41 @@ export function runIdentity({ persona, runId }) {
|
|
|
1077
1268
|
* @param {{email: string, displayName: string}} identity
|
|
1078
1269
|
* @returns {string}
|
|
1079
1270
|
*/
|
|
1080
|
-
export function runIdentityForPrompt(identity) {
|
|
1271
|
+
export function runIdentityForPrompt(identity, { testAccount = false } = {}) {
|
|
1081
1272
|
return [
|
|
1082
1273
|
`email: ${identity.email}`,
|
|
1083
1274
|
`display_name: ${identity.displayName}`,
|
|
1084
|
-
|
|
1275
|
+
testAccount
|
|
1276
|
+
? 'Sign in with the test account above. Use this email and this display name only if you have to make a new account, and whenever a form asks for a display name, a coach or a player name. Never reuse an email from an earlier run.'
|
|
1277
|
+
: '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.'
|
|
1278
|
+
].join('\n');
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* The local brief's test account, or null when it has none that can be used.
|
|
1283
|
+
*
|
|
1284
|
+
* @param {Object} localBrief
|
|
1285
|
+
* @returns {{email: string, password: string, how_to_sign_in: string|null}|null}
|
|
1286
|
+
*/
|
|
1287
|
+
export function testAccountOf(localBrief) {
|
|
1288
|
+
const account = localBrief && localBrief.test_account;
|
|
1289
|
+
if (!account || typeof account !== 'object' || typeof account.email !== 'string' || !account.email || typeof account.password !== 'string' || account.password.length < MIN_PASSWORD_CHARS) return null;
|
|
1290
|
+
return { email: account.email, password: account.password, how_to_sign_in: typeof account.how_to_sign_in === 'string' && account.how_to_sign_in.trim() ? account.how_to_sign_in.trim() : null };
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* The test account block of the persona prompt: an account the developer made
|
|
1295
|
+
* so a run can start past sign up.
|
|
1296
|
+
*
|
|
1297
|
+
* @param {{email: string, password: string, how_to_sign_in: string|null}} account
|
|
1298
|
+
* @returns {string}
|
|
1299
|
+
*/
|
|
1300
|
+
export function testAccountForPrompt(account) {
|
|
1301
|
+
return [
|
|
1302
|
+
`email: ${account.email}`,
|
|
1303
|
+
`password: ${account.password}`,
|
|
1304
|
+
...(account.how_to_sign_in ? [`how_to_sign_in: ${account.how_to_sign_in}`] : []),
|
|
1305
|
+
'This is a throwaway account the developer made for this playtest. Sign in with it rather than signing up, so your actions go to the game itself. Never write the password in a note or a report.'
|
|
1085
1306
|
].join('\n');
|
|
1086
1307
|
}
|
|
1087
1308
|
|
|
@@ -1152,11 +1373,54 @@ export function assistantContent(answer) {
|
|
|
1152
1373
|
return content;
|
|
1153
1374
|
}
|
|
1154
1375
|
|
|
1155
|
-
/**
|
|
1156
|
-
|
|
1376
|
+
/** What a typed secret is written as in the transcript and the action log. */
|
|
1377
|
+
export const REDACTED_SECRET = '[test password]';
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* Every string in a value with each secret replaced by {@link REDACTED_SECRET},
|
|
1381
|
+
* through arrays and plain objects. A new value; the input is not changed.
|
|
1382
|
+
*
|
|
1383
|
+
* @param {any} value
|
|
1384
|
+
* @param {Array<string>} secrets
|
|
1385
|
+
* @returns {any}
|
|
1386
|
+
*/
|
|
1387
|
+
export function scrubSecrets(value, secrets = []) {
|
|
1388
|
+
if (!Array.isArray(secrets) || secrets.length === 0) return value;
|
|
1389
|
+
if (typeof value === 'string') {
|
|
1390
|
+
let text = value;
|
|
1391
|
+
// Whole tokens only: the password with no letter, digit or underscore
|
|
1392
|
+
// directly before or after it, so it is replaced where it was written
|
|
1393
|
+
// (alone, quoted, after a colon) and never inside a longer word.
|
|
1394
|
+
for (const secret of secrets) {
|
|
1395
|
+
if (!secret) continue;
|
|
1396
|
+
const escaped = secret.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1397
|
+
text = text.replace(new RegExp(`(?<![\\p{L}\\p{N}_])${escaped}(?![\\p{L}\\p{N}_])`, 'gu'), REDACTED_SECRET);
|
|
1398
|
+
}
|
|
1399
|
+
return text;
|
|
1400
|
+
}
|
|
1401
|
+
if (Array.isArray(value)) return value.map(entry => scrubSecrets(entry, secrets));
|
|
1402
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, scrubSecrets(entry, secrets)]));
|
|
1403
|
+
return value;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* The strings the transcript must not repeat: the local brief's test account
|
|
1408
|
+
* password. It is in the prompt (that is how a persona can type it), but the
|
|
1409
|
+
* transcript can be uploaded and the action log is shown in the report step.
|
|
1410
|
+
*
|
|
1411
|
+
* @param {Object} localBrief
|
|
1412
|
+
* @returns {Array<string>}
|
|
1413
|
+
*/
|
|
1414
|
+
export function secretsOf(localBrief) {
|
|
1415
|
+
const password = localBrief && localBrief.test_account && localBrief.test_account.password;
|
|
1416
|
+
return typeof password === 'string' && password.length >= MIN_PASSWORD_CHARS ? [password] : [];
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/** Keep the transcript readable without storing a whole screenshot in it, or a test account password. */
|
|
1420
|
+
export function redactToolInput(toolUse, secrets = []) {
|
|
1157
1421
|
if (!toolUse.input) return null;
|
|
1158
1422
|
const { thought_in_character: ignored, ...rest } = toolUse.input;
|
|
1159
|
-
return rest;
|
|
1423
|
+
return scrubSecrets(rest, secrets);
|
|
1160
1424
|
}
|
|
1161
1425
|
|
|
1162
1426
|
async function safeScreenshot(driver, name, screenshots, transcript, observation = null) {
|
|
@@ -1299,6 +1563,8 @@ export function notesToFindings(notes, screenshots) {
|
|
|
1299
1563
|
if (!category || !note.text) continue;
|
|
1300
1564
|
// A blocker or major written on a driver error is the runner's failure.
|
|
1301
1565
|
if (note.driver_error && ABOVE_MINOR.includes(note.severity)) continue;
|
|
1566
|
+
// Console noise the developer said to expect is never a finding.
|
|
1567
|
+
if (note.expected_noise) continue;
|
|
1302
1568
|
const text = String(note.text);
|
|
1303
1569
|
const shot = nearestScreenshot(note, screenshots);
|
|
1304
1570
|
findings.push({
|
|
@@ -1377,6 +1643,7 @@ export function noteLine(note, i) {
|
|
|
1377
1643
|
const tags = [
|
|
1378
1644
|
note.automatic ? 'automatic' : null,
|
|
1379
1645
|
note.driver_error ? 'written on a driver error' : null,
|
|
1646
|
+
note.expected_noise ? 'expected noise' : null,
|
|
1380
1647
|
Number.isInteger(note.observation) && note.observation > 0 ? `seen at observation ${note.observation}` : null,
|
|
1381
1648
|
note.screenshot ? `screenshot ${note.screenshot}` : null
|
|
1382
1649
|
].filter(Boolean);
|
|
@@ -1449,8 +1716,8 @@ export function observationTitle(observation) {
|
|
|
1449
1716
|
*/
|
|
1450
1717
|
/** How report.md lists what the runner dropped. */
|
|
1451
1718
|
export const RUNNER_DROPPED_SECTION = Object.freeze({
|
|
1452
|
-
heading: '## Dropped by the runner (driver errors
|
|
1453
|
-
explanation: 'The runner removes a blocker or major whose only cited observations are driver errors (the runner could not carry out an action; the game did nothing wrong), and any finding cited only by waiting notes. They are not sent to the server. They are kept here so you can judge them yourself.',
|
|
1719
|
+
heading: '## Dropped by the runner (driver errors, waiting notes or expected noise only)',
|
|
1720
|
+
explanation: 'The runner removes a blocker or major whose only cited observations are driver errors (the runner could not carry out an action; the game did nothing wrong), and any finding cited only by waiting notes or by console noise the brief says to expect. They are not sent to the server. They are kept here so you can judge them yourself.',
|
|
1454
1721
|
emptyNote: '- Nothing here that the runner kept. See the dropped findings below.'
|
|
1455
1722
|
});
|
|
1456
1723
|
|
|
@@ -1473,11 +1740,26 @@ export const RUNNER_DROPPED_SECTION = Object.freeze({
|
|
|
1473
1740
|
export function dropRunnerRejectedFindings(report, { notes = [], observations = [] } = {}) {
|
|
1474
1741
|
if (!report || !Array.isArray(report.findings)) return { report, dropped: [] };
|
|
1475
1742
|
const byIndex = new Map(observations.map(entry => [entry.index, entry]));
|
|
1743
|
+
// The runner's console-error screenshot of a moment whose every console
|
|
1744
|
+
// note was expected noise says nothing more than those notes do.
|
|
1745
|
+
const noiseShots = new Set();
|
|
1746
|
+
const shotNotes = new Map();
|
|
1747
|
+
for (const note of notes) {
|
|
1748
|
+
if (!note || !note.automatic || !note.screenshot) continue;
|
|
1749
|
+
if (!shotNotes.has(note.screenshot)) shotNotes.set(note.screenshot, []);
|
|
1750
|
+
shotNotes.get(note.screenshot).push(note);
|
|
1751
|
+
}
|
|
1752
|
+
for (const [shot, attached] of shotNotes) if (attached.every(note => note.expected_noise)) noiseShots.add(shot);
|
|
1476
1753
|
const verdict = finding => {
|
|
1477
1754
|
const cited = [];
|
|
1478
1755
|
for (const item of Array.isArray(finding.evidence) ? finding.evidence : []) {
|
|
1479
|
-
if (!item
|
|
1756
|
+
if (!item) continue;
|
|
1480
1757
|
const ref = String(item.ref || '');
|
|
1758
|
+
if (item.type === 'screenshot') {
|
|
1759
|
+
if (noiseShots.has(ref)) cited.push('noise');
|
|
1760
|
+
continue;
|
|
1761
|
+
}
|
|
1762
|
+
if (item.type !== 'transcript_step') continue;
|
|
1481
1763
|
const observation = ref.match(/^\s*observation\s*#?\s*(\d+)\s*$/i);
|
|
1482
1764
|
const note = ref.match(/^\s*note\s*#?\s*(\d+)\s*$/i);
|
|
1483
1765
|
if (observation) {
|
|
@@ -1485,7 +1767,7 @@ export function dropRunnerRejectedFindings(report, { notes = [], observations =
|
|
|
1485
1767
|
cited.push(entry && entry.driver_error ? 'driver' : 'other');
|
|
1486
1768
|
} else if (note) {
|
|
1487
1769
|
const entry = notes[Number(note[1]) - 1];
|
|
1488
|
-
cited.push(entry && entry.kind === 'waiting' ? 'waiting' : entry && entry.driver_error ? 'driver' : 'other');
|
|
1770
|
+
cited.push(entry && entry.kind === 'waiting' ? 'waiting' : entry && entry.expected_noise ? 'noise' : entry && entry.driver_error ? 'driver' : 'other');
|
|
1489
1771
|
} else {
|
|
1490
1772
|
cited.push('other');
|
|
1491
1773
|
}
|
|
@@ -1493,7 +1775,9 @@ export function dropRunnerRejectedFindings(report, { notes = [], observations =
|
|
|
1493
1775
|
if (cited.length === 0) return null;
|
|
1494
1776
|
if (ABOVE_MINOR.includes(finding.severity) && cited.every(kind => kind === 'driver')) return 'driver errors only';
|
|
1495
1777
|
if (cited.every(kind => kind === 'waiting')) return 'waiting notes only';
|
|
1496
|
-
if (
|
|
1778
|
+
if (cited.every(kind => kind === 'noise')) return 'expected console noise only';
|
|
1779
|
+
if (cited.every(kind => kind === 'waiting' || kind === 'noise')) return 'expected console noise and waiting notes only';
|
|
1780
|
+
if (ABOVE_MINOR.includes(finding.severity) && cited.every(kind => kind === 'driver' || kind === 'waiting' || kind === 'noise')) return 'driver errors, waiting notes and expected noise only';
|
|
1497
1781
|
return null;
|
|
1498
1782
|
};
|
|
1499
1783
|
const dropped = [];
|
|
@@ -1722,7 +2006,7 @@ export function downgradeContradictedFindings(report, log) {
|
|
|
1722
2006
|
}
|
|
1723
2007
|
|
|
1724
2008
|
/** How report.md lists the findings the runner downgraded. */
|
|
1725
|
-
export const DOWNGRADED_HEADING = '## Downgraded by the runner (the action log contradicts the claim)';
|
|
2009
|
+
export const DOWNGRADED_HEADING = '## Downgraded by the runner (the action log or its own screenshot contradicts the claim)';
|
|
1726
2010
|
|
|
1727
2011
|
/**
|
|
1728
2012
|
* report.md agreeing with report.json about downgraded findings: on every
|