ravensight-playtest 0.2.8 → 0.3.0

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.
@@ -47,7 +47,7 @@ export async function readTranscript(runDirectory) {
47
47
  try {
48
48
  text = await readFile(path.join(runDirectory, RUN_FILES.transcript), 'utf8');
49
49
  } catch {
50
- return { header: null, notes: [], screenshots: [], observations: 0, actions: 0 };
50
+ return { header: null, notes: [], screenshots: [], observations: 0, actions: 0, firstT: null, lastT: null };
51
51
  }
52
52
 
53
53
  let header = null;
@@ -55,6 +55,12 @@ export async function readTranscript(runDirectory) {
55
55
  const screenshots = [];
56
56
  let observations = 0;
57
57
  let actions = 0;
58
+ // Notes written on the tool call the last `action` line closed, so a
59
+ // `driver_error` after it can mark them as the live run did.
60
+ let callNotes = [];
61
+ let openNotes = [];
62
+ let firstT = null;
63
+ let lastT = null;
58
64
  for (const line of text.split('\n')) {
59
65
  if (!line.trim()) continue;
60
66
  let entry;
@@ -65,13 +71,27 @@ export async function readTranscript(runDirectory) {
65
71
  // it rather than refusing to read the whole transcript.
66
72
  continue;
67
73
  }
74
+ if (Number.isFinite(Number(entry.t))) {
75
+ if (firstT === null) firstT = Number(entry.t);
76
+ lastT = Number(entry.t);
77
+ }
68
78
  if (entry.kind === 'header') header = entry;
69
- else if (entry.kind === 'note' && entry.note) notes.push(entry.note);
79
+ else if (entry.kind === 'note' && entry.note) {
80
+ const note = { ...entry.note };
81
+ notes.push(note);
82
+ if (entry.tool) openNotes.push(note);
83
+ } else if (entry.kind === 'driver_error') {
84
+ for (const note of callNotes) note.driver_error = true;
85
+ }
70
86
  else if (entry.kind === 'screenshot' && entry.path) screenshots.push({ relativePath: entry.path, reason: entry.reason || '' });
71
87
  else if (entry.kind === 'observation') observations += 1;
72
- else if (entry.kind === 'action' && Number.isFinite(Number(entry.actions))) actions = Math.max(actions, Number(entry.actions));
88
+ else if (entry.kind === 'action') {
89
+ callNotes = openNotes;
90
+ openNotes = [];
91
+ if (Number.isFinite(Number(entry.actions))) actions = Math.max(actions, Number(entry.actions));
92
+ }
73
93
  }
74
- return { header, notes, screenshots, observations, actions };
94
+ return { header, notes, screenshots, observations, actions, firstT, lastT };
75
95
  }
76
96
 
77
97
  /**
package/src/run/report.js CHANGED
@@ -127,6 +127,86 @@ export function stampReport(report, facts) {
127
127
  return stamped;
128
128
  }
129
129
 
130
+ /** Keys a model has been seen to put an evidence path under instead of `ref`. */
131
+ const REF_ALIASES = ['path', 'file', 'screenshot', 'src', 'href', 'location', 'value'];
132
+
133
+ /** The four evidence types the schema allows. */
134
+ const EVIDENCE_TYPES = new Set(['screenshot', 'video_ts', 'transcript_step', 'file_line']);
135
+
136
+ /**
137
+ * One evidence entry in the schema's shape, `{type, ref}` and nothing else,
138
+ * when the model's own words say unambiguously what it meant.
139
+ *
140
+ * The 0.2.8 report that failed upload wrote
141
+ * `{"type": "screenshot", "path": "screenshots/04-04-school-selected.png"}`
142
+ * five times: a real file, under the wrong key. The schema refuses the extra
143
+ * key and the missing `ref`, so the whole report was refused and the run
144
+ * failed over a key name. This moves the value, never invents one: a path
145
+ * still has to name a file in the run directory (`checkEvidence`), and the
146
+ * server still checks it against what was uploaded.
147
+ *
148
+ * Also: a bare string becomes a screenshot or a transcript step by its look;
149
+ * a missing type on a `screenshots/...` ref becomes `screenshot`; a ref
150
+ * written `./screenshots/x.png` or as an absolute path inside the run
151
+ * directory becomes the run relative path the server's manifest uses.
152
+ *
153
+ * @param {any} item
154
+ * @param {string|null} runDirectory
155
+ * @returns {any} the normalised entry, or the input unchanged when it is not an object it can read
156
+ */
157
+ export function normaliseEvidenceItem(item, runDirectory = null) {
158
+ if (typeof item === 'string') {
159
+ const text = item.trim();
160
+ if (/^(?:\.\/)?screenshots\//.test(text)) return normaliseEvidenceItem({ type: 'screenshot', ref: text }, runDirectory);
161
+ if (/^(?:observation|note|step|action)\s*#?\s*\d+$/i.test(text)) return { type: 'transcript_step', ref: text };
162
+ return item;
163
+ }
164
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
165
+ let ref = typeof item.ref === 'string' && item.ref.trim() ? item.ref : null;
166
+ if (ref === null) {
167
+ const alias = REF_ALIASES.find(key => typeof item[key] === 'string' && item[key].trim());
168
+ if (alias) ref = item[alias];
169
+ }
170
+ let type = typeof item.type === 'string' ? item.type : null;
171
+ if (!type && ref && /^(?:\.\/)?screenshots\//.test(ref.trim())) type = 'screenshot';
172
+ if (type === null || ref === null || !EVIDENCE_TYPES.has(type)) return item;
173
+ ref = ref.trim();
174
+ if (type === 'screenshot') ref = runRelative(ref, runDirectory);
175
+ return { type, ref };
176
+ }
177
+
178
+ function runRelative(ref, runDirectory) {
179
+ let value = ref.replace(/\\/g, '/');
180
+ if (runDirectory && path.isAbsolute(ref)) {
181
+ const relative = path.relative(path.resolve(runDirectory), path.resolve(ref));
182
+ if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) value = relative.split(path.sep).join('/');
183
+ }
184
+ while (value.startsWith('./')) value = value.slice(2);
185
+ return value;
186
+ }
187
+
188
+ /**
189
+ * Every finding's evidence through {@link normaliseEvidenceItem}.
190
+ *
191
+ * @param {Object} report
192
+ * @param {string|null} [runDirectory]
193
+ * @returns {{report: Object, changed: number}} a new report, and how many entries were rewritten
194
+ */
195
+ export function normaliseEvidence(report, runDirectory = null) {
196
+ if (!report || typeof report !== 'object' || !Array.isArray(report.findings)) return { report, changed: 0 };
197
+ let changed = 0;
198
+ const findings = report.findings.map(finding => {
199
+ if (!finding || typeof finding !== 'object' || !Array.isArray(finding.evidence)) return finding;
200
+ const evidence = finding.evidence.map(item => {
201
+ const out = normaliseEvidenceItem(item, runDirectory);
202
+ if (JSON.stringify(out) !== JSON.stringify(item)) changed += 1;
203
+ return out;
204
+ });
205
+ return { ...finding, evidence };
206
+ });
207
+ return { report: { ...report, findings }, changed };
208
+ }
209
+
130
210
  /**
131
211
  * Check every `screenshot` evidence ref against the files actually on disk.
132
212
  *
@@ -151,8 +231,17 @@ export async function checkEvidence(report, runDirectory) {
151
231
  continue;
152
232
  }
153
233
  for (const item of evidence) {
154
- if (!item || item.type !== 'screenshot') continue;
155
- const ref = String(item.ref || '');
234
+ if (!item || typeof item !== 'object') {
235
+ problems.push(`${finding.id}: an evidence entry is not an object; write {"type": ..., "ref": ...}`);
236
+ continue;
237
+ }
238
+ if (typeof item.ref !== 'string' || !item.ref.trim()) {
239
+ const keys = Object.keys(item).filter(key => key !== 'type');
240
+ problems.push(`${finding.id}: a ${item.type || 'typeless'} evidence entry has no "ref"${keys.length > 0 ? ` (it has ${keys.map(key => `"${key}"`).join(', ')})` : ''}; every entry is exactly {"type": ..., "ref": ...}`);
241
+ continue;
242
+ }
243
+ if (item.type !== 'screenshot') continue;
244
+ const ref = String(item.ref);
156
245
  const candidate = path.resolve(runDirectory, ref);
157
246
  // Refuse a ref that climbs out of the run directory before touching
158
247
  // the filesystem with it.
@@ -214,6 +303,8 @@ export default {
214
303
  createReportValidator,
215
304
  stampReport,
216
305
  checkEvidence,
306
+ normaliseEvidence,
307
+ normaliseEvidenceItem,
217
308
  writeReportFiles,
218
309
  repairInstruction,
219
310
  lintMarkdownSections,
package/src/run/resume.js CHANGED
@@ -62,6 +62,15 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
62
62
  return EXIT.ENVIRONMENT;
63
63
  }
64
64
 
65
+ // A run whose report the server refused is done here, whatever the server
66
+ // says its state is: it is never finalised from its transcript (which would
67
+ // rewrite the kept report.json and report.md) and never replayed.
68
+ for (const [runId, entry] of Object.entries(journal.runs || {})) {
69
+ if (entry && entry.report_refused === true) {
70
+ log(`run ${runId}: the server refused its report, so it is not replayed or rebuilt. Its report.json, report.md and screenshots are in ${runDirFor(repoRoot, jobId, runId)}.`);
71
+ }
72
+ }
73
+
65
74
  // Reconcile the journal with what the server actually thinks: a run the sweep
66
75
  // marked interrupted, or one that finished after the journal was last
67
76
  // written, is the server's word against a stale file, and the server wins.
@@ -235,7 +244,13 @@ async function finalizeFromTranscript({ deps, api, gameId, jobId, repoRoot, row,
235
244
  actions_taken: report.actions_taken,
236
245
  quit_reason: 'error',
237
246
  report,
238
- report_md: markdown
247
+ report_md: markdown,
248
+ // The transcript's own span: its first and last timestamps. No model
249
+ // time is known for a run rebuilt after the process died.
250
+ duration: {
251
+ wall_seconds: transcript.firstT !== null && transcript.lastT !== null ? Number(((transcript.lastT - transcript.firstT) / 1000).toFixed(3)) : null,
252
+ model_seconds: null
253
+ }
239
254
  }
240
255
  });
241
256
  if (answer && answer.dropped_findings > 0) {
package/src/run/state.js CHANGED
@@ -192,7 +192,10 @@ export async function patchRun(repoRoot, state, runId, patch) {
192
192
  * @returns {boolean}
193
193
  */
194
194
  export function isRunDone(entry) {
195
- return Boolean(entry) && (entry.completed === true || RUN_DONE_STATES.includes(entry.state));
195
+ // A run whose report the server refused was played to the end: replaying
196
+ // it would spend a unit again, and rebuilding its report from the
197
+ // transcript would overwrite the one the developer is meant to read.
198
+ return Boolean(entry) && (entry.completed === true || entry.report_refused === true || RUN_DONE_STATES.includes(entry.state));
196
199
  }
197
200
 
198
201
  export default { SCHEMA_VERSION, newState, newRunEntry, save, load, patchRun, isRunDone, stateFile };
package/src/run/tools.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { UNCHANGED_OBSERVATION_TEXT } from './context.js';
2
- import { OBSERVATION_MAX_CHARS, truncateObservation } from './drivers/driver.js';
2
+ import { OBSERVATION_MAX_CHARS, STALE_REF_MESSAGE, truncateObservation } from './drivers/driver.js';
3
+
4
+ export { STALE_REF_MESSAGE };
3
5
 
4
6
  /**
5
7
  * The client tool set for `persona_playtest.act`.
@@ -23,8 +25,12 @@ import { OBSERVATION_MAX_CHARS, truncateObservation } from './drivers/driver.js'
23
25
  * `snapshot`, `screenshot` and `wait` do not.
24
26
  */
25
27
 
26
- /** Note kinds, from spec 03's `log_note`. */
27
- export const NOTE_KINDS = Object.freeze(['bug', 'confusing', 'good', 'suggestion', 'reaction']);
28
+ /**
29
+ * Note kinds, from spec 03's `log_note`, plus `waiting`: the persona is
30
+ * waiting for something to finish (a load, an animation, a turn). A waiting
31
+ * note is context for the report and never becomes a finding.
32
+ */
33
+ export const NOTE_KINDS = Object.freeze(['bug', 'confusing', 'good', 'suggestion', 'reaction', 'waiting']);
28
34
 
29
35
  /** Severities, from the report schema's finding severity enum. */
30
36
  export const SEVERITIES = Object.freeze(['blocker', 'major', 'minor', 'cosmetic', 'praise']);
@@ -52,7 +58,7 @@ const note = {
52
58
  additionalProperties: false,
53
59
  required: ['kind', 'text'],
54
60
  properties: {
55
- kind: { type: 'string', enum: [...NOTE_KINDS] },
61
+ kind: { type: 'string', enum: [...NOTE_KINDS], description: 'waiting means you are waiting for something to finish; it is recorded as context and never reported as a finding.' },
56
62
  text: { type: 'string' },
57
63
  severity: { type: 'string', enum: [...SEVERITIES] }
58
64
  }
@@ -71,12 +77,13 @@ export const PLAY_TOOLS = Object.freeze([
71
77
  },
72
78
  {
73
79
  name: 'click',
74
- description: 'Click one thing. Spends an action. Either name the target or give x and y pixel coordinates. What a named target means depends on the driver: on playwright_web it is an accessibility node such as button "Start", a visible text fragment, or a CSS selector; on godot_driver it is a node path from the scene tree you were shown.',
80
+ description: 'Click one thing. Spends an action. On playwright_web, use the ref the screen shows next to the element, such as e12: it names exactly one element, even when two share a name. Otherwise name the target: on playwright_web an accessibility node such as button "Start" or a visible text fragment; on godot_driver a node path from the scene tree you were shown. Targets are refs or visible text, never locator code such as getByRole(...) or page.locator(...). Avoid x and y coordinates on playwright_web: they are blind to the page, and are only for a canvas with no refs, taken from the @(x,y) the screen shows or from the screenshot.',
75
81
  input_schema: {
76
82
  type: 'object',
77
83
  additionalProperties: false,
78
84
  properties: {
79
- target: { type: 'string', description: 'web: button "Start", Start Game, or #start-button. Godot: a node path such as /root/Main/UI/StartButton.' },
85
+ ref: { type: 'string', description: 'playwright_web only, the preferred target: the ref shown on the element, such as e12.' },
86
+ target: { type: 'string', description: 'web: button "Start" or Start Game. Godot: a node path such as /root/Main/UI/StartButton.' },
80
87
  x: { type: 'number' },
81
88
  y: { type: 'number' },
82
89
  thought_in_character: thought,
@@ -95,7 +102,8 @@ export const PLAY_TOOLS = Object.freeze([
95
102
  text: { type: 'string' },
96
103
  submit: { type: 'boolean' },
97
104
  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.' },
105
+ ref: { type: 'string', description: 'playwright_web only, the preferred way to name the field: its ref, such as e12.' },
106
+ 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 or a ref, the focused field.' },
99
107
  thought_in_character: thought,
100
108
  note
101
109
  }
@@ -186,6 +194,7 @@ export function actionFor(toolUse) {
186
194
  const input = toolUse.input || {};
187
195
  switch (toolUse.name) {
188
196
  case 'click':
197
+ if (typeof input.ref === 'string' && input.ref.trim()) return { kind: 'click', ref: input.ref.trim() };
189
198
  return Number.isFinite(Number(input.x)) && Number.isFinite(Number(input.y))
190
199
  ? { kind: 'click', target: { x: Number(input.x), y: Number(input.y) } }
191
200
  : { kind: 'click', target: String(input.target || '') };
@@ -193,6 +202,7 @@ export function actionFor(toolUse) {
193
202
  const action = { kind: 'type', text: String(input.text || ''), submit: Boolean(input.submit) };
194
203
  if (input.replace === true) action.replace = true;
195
204
  if (typeof input.target === 'string' && input.target.trim()) action.target = input.target;
205
+ if (typeof input.ref === 'string' && input.ref.trim()) action.ref = input.ref.trim();
196
206
  return action;
197
207
  }
198
208
  case 'key':
@@ -220,9 +230,11 @@ export function actionFor(toolUse) {
220
230
  * @param {Object} args.toolUse
221
231
  * @param {(note: Object) => void} [args.onNote]
222
232
  * @param {(shot: Object) => void} [args.onScreenshot]
233
+ * @param {boolean} [args.staleRefs] an earlier action in this turn changed the
234
+ * screen, so a ref in this call was read off a screen that is gone
223
235
  * @returns {Promise<{result: Object, observation: Object|null, spentAction: boolean, changed: boolean, error: string|null}>}
224
236
  */
225
- export async function executeTool({ driver, toolUse, onNote, onScreenshot }) {
237
+ export async function executeTool({ driver, toolUse, onNote, onScreenshot, staleRefs = false }) {
226
238
  const input = toolUse.input || {};
227
239
  if (toolUse.malformed) {
228
240
  return {
@@ -280,7 +292,15 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot }) {
280
292
  };
281
293
  }
282
294
 
283
- const outcome = await driver.act(action);
295
+ // A ref is a playwright_web target. Every other driver is told so by name,
296
+ // before anything reaches the game.
297
+ // A ref in a later tool call of a turn whose earlier call already changed
298
+ // the screen was read off a screen that is gone.
299
+ const outcome = action.ref && !driver.supportsRefs
300
+ ? { ok: false, changed: false, error: `ref targets are only available on playwright_web; ${driver.name || 'this driver'} takes a named target or coordinates`, driverError: true }
301
+ : action.ref && staleRefs
302
+ ? { ok: false, changed: false, error: STALE_REF_MESSAGE, driverError: true }
303
+ : await driver.act(action);
284
304
  const observation = await driver.observe();
285
305
  const spentAction = ACTION_TOOLS.includes(toolUse.name) && outcome.ok;
286
306
  const prefix = outcome.ok
@@ -291,7 +311,8 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot }) {
291
311
  observation,
292
312
  spentAction,
293
313
  changed: Boolean(outcome.changed),
294
- error: outcome.ok ? null : String(outcome.error || 'act failed')
314
+ error: outcome.ok ? null : String(outcome.error || 'act failed'),
315
+ driverError: !outcome.ok && Boolean(outcome.driverError)
295
316
  };
296
317
  }
297
318
 
package/src/run/usage.js CHANGED
@@ -29,6 +29,7 @@ export function createUsageLedger() {
29
29
  const byModel = new Map();
30
30
  const started = Date.now();
31
31
  let calls = 0;
32
+ let modelMs = 0;
32
33
  let serverTotalUsd = null;
33
34
  let serverCalls = null;
34
35
 
@@ -36,8 +37,10 @@ export function createUsageLedger() {
36
37
  /**
37
38
  * @param {{model: string|null, usage: Object}} turn
38
39
  */
39
- record({ model, usage }) {
40
+ record({ model, usage, ms }) {
40
41
  calls += 1;
42
+ // Time spent waiting on the model, when the caller measured it.
43
+ if (Number.isFinite(Number(ms)) && Number(ms) > 0) modelMs += Number(ms);
41
44
  const key = model || 'unknown';
42
45
  const entry = byModel.get(key) || { model: key, input_tokens: 0, cache_read_tokens: 0, output_tokens: 0, usd: 0 };
43
46
  // The report schema has no cache-creation field, so creation tokens
@@ -64,6 +67,20 @@ export function createUsageLedger() {
64
67
  return calls;
65
68
  },
66
69
 
70
+ /**
71
+ * Wall time since the ledger was made, and time spent waiting on model
72
+ * calls, in seconds. What `complete` sends as `duration`, so the run card
73
+ * does not read 0s for a run that played for minutes.
74
+ *
75
+ * @returns {{wall_seconds: number, model_seconds: number}}
76
+ */
77
+ duration() {
78
+ return {
79
+ wall_seconds: Number(((Date.now() - started) / 1000).toFixed(3)),
80
+ model_seconds: Number((modelMs / 1000).toFixed(3))
81
+ };
82
+ },
83
+
67
84
  /**
68
85
  * The `usage` object as the report schema wants it.
69
86
  *
@@ -87,6 +104,7 @@ export function createUsageLedger() {
87
104
  const report = this.toReportUsage();
88
105
  return {
89
106
  ...report,
107
+ model_seconds: Number((modelMs / 1000).toFixed(3)),
90
108
  local_model_calls: calls,
91
109
  server_model_calls: serverCalls,
92
110
  usd_source: serverTotalUsd === null ? 'unavailable' : 'server',