diff --git a/CHANGELOG.md b/CHANGELOG.md index fea4ea1f..dc76b9e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2026-09-05 + +### Changes + +- [Prima] The `network.jsonl` artifact now holds the requests the browser was seen to make. It was + reading the requests an API run sends itself, a list nothing fills during a browser command, so the + file was never written and no envelope ever pointed at it — while the page's own traffic was being + captured the whole time. ## 2026-09-07 ### New CLI Options @@ -219,6 +227,12 @@ inline drawer or a split-pane form. - The area the agent is told to stay inside is called an overlay when it floats above the page and a region when it sits in it. Logs and the supervisor's notes now use that wording throughout. +- Prima: The three envelope builders (`pw`/`do`/`go`, failures, and `check`/`ask`/`verify`/`research`) + now share one tail builder instead of each repeating the instance/status/artifacts block. Artifact + paths flow back from the write as a return value rather than through a mutable field that had to be + read in the right order, so an envelope can no longer come back missing its Artifacts section. + Per-step file names (`aria.yaml`/`html`/`diff.yaml`) written during a `do` run now have a single + owner shared with the doc line the envelope advertises, so the two can no longer drift apart. ## 2026-09-01 diff --git a/boat/prima/src/cli.ts b/boat/prima/src/cli.ts index e4a1723f..7b84dd39 100644 --- a/boat/prima/src/cli.ts +++ b/boat/prima/src/cli.ts @@ -67,7 +67,8 @@ function primaFor(options: any): Prima { return new Prima(buildOptions(options)); } -async function runPrima(options: any, command: string, run: (prima: Prima) => Promise): Promise { +async function runPrima(options: any, command: string, run: (prima: Prima) => Promise, opts: { browser?: boolean; record?: boolean } = {}): Promise { + const { browser = true, record = true } = opts; setQuietMode(!isVerboseMode()); trackActivityLine(); const prima = primaFor(options); @@ -75,13 +76,13 @@ async function runPrima(options: any, command: string, run: (prima: Prima) => Pr let envelope: EnvelopeData; try { - await prima.start(); + if (browser) await prima.start(); envelope = await run(prima); } catch (error) { envelope = await prima.toolFailureEnvelope(command, error); } - prima.record(envelope, Date.now() - startedAt); + if (record) prima.record(envelope, Date.now() - startedAt); clearActivityLine(); console.log(renderEnvelope(envelope)); await prima.stop().catch(() => {}); @@ -163,11 +164,7 @@ export function createPrimaCommands(name = 'prima'): Command { addCommonOptions(cmd.command('status ').description('Show the artifacts and page detail recorded for an earlier command')) .addHelpText('after', `\n${statusHelp}`) .action(async (hash, options) => { - setQuietMode(!isVerboseMode()); - const prima = primaFor(options); - const envelope = await prima.status(hash).catch((error: unknown) => prima.toolFailureEnvelope(`status ${hash}`, error)); - console.log(renderEnvelope(envelope)); - process.exit(envelope.ok ? 0 : 1); + await runPrima(options, `status ${hash}`, (prima) => prima.status(hash), { browser: false, record: false }); }); addCommonOptions(cmd.command('report').description('Turn every command of a session into one html and markdown report')) diff --git a/boat/prima/src/envelope.ts b/boat/prima/src/envelope.ts index c1e6d477..9250f310 100644 --- a/boat/prima/src/envelope.ts +++ b/boat/prima/src/envelope.ts @@ -5,6 +5,8 @@ export const STATUS_FILE = 'status.json'; const ARTIFACT_FILES = { aria: 'aria.yml', html: 'page.html', screenshot: 'page.png', network: 'network.jsonl' }; +export const STEP_FILES = { aria: 'aria.yaml', html: 'html', diff: 'diff.yaml' }; + const EXPECTATION_LABELS = { passed: 'PASSED ', failed: 'FAILED ', @@ -117,7 +119,7 @@ function renderSteps(data: EnvelopeData): string | null { lines.push(`${index + 1}. ${mark} ${step.label}`); for (const line of (step.proof || '').split('\n').filter(Boolean)) lines.push(` ${line}`); }); - if (data.stepFiles) lines.push('', `page after each step: ${data.stepFiles}/-.{aria.yaml,html,diff.yaml}`); + if (data.stepFiles) lines.push('', `page after each step: ${data.stepFiles}/-.{${Object.values(STEP_FILES).join(',')}}`); return section('Steps', lines.join('\n')); } diff --git a/boat/prima/src/prima.ts b/boat/prima/src/prima.ts index 10680534..abbccbf7 100644 --- a/boat/prima/src/prima.ts +++ b/boat/prima/src/prima.ts @@ -25,7 +25,7 @@ import { browserErrorMessage } from '../../../src/utils/browser-errors.ts'; import { pluralize } from '../../../src/utils/logger.ts'; import { mdq } from '../../../src/utils/markdown-query.ts'; import { safeFilename } from '../../../src/utils/strings.ts'; -import { type EnvelopeData, type InstanceInfo, STATUS_FILE, readArtifacts, writeArtifacts } from './envelope.ts'; +import { type ArtifactPaths, type EnvelopeData, type InstanceInfo, STATUS_FILE, STEP_FILES, readArtifacts, writeArtifacts } from './envelope.ts'; import { isFunctionExpression, takePwValue, toCodeceptWrapper } from './pw-parser.ts'; import { type PwServerDescriptor, readDescriptors, selectDescriptor } from './pw-registry.ts'; import { type SessionRun, latestSessionFile, readSession, recordCommand, sessionFile, sessionsDir } from './session-log.ts'; @@ -73,7 +73,6 @@ export class Prima { private server: { close: () => Promise } | null = null; private attached: string | null = null; private session: SessionRun | null = null; - private artifacts?: EnvelopeData['artifacts']; constructor(options: PrimaOptions = {}) { this.options = options; @@ -977,16 +976,13 @@ export class Prima { private async successEnvelope(command: string, used: string[], result: ActionResult, previousState: WebPageState | null): Promise { const changes = await this.pageChanges(result, previousState, used[0]); - const status = await this.saveStatus(result); return { ok: true, command, used, page: this.pageBlock(result, previousState), changes, - instance: await this.instanceInfo(), - status, - artifacts: this.artifacts, + ...(await this.envelopeTail(result)), }; } @@ -995,31 +991,30 @@ export class Prima { const failure: EnvelopeData['failure'] = { error: browserErrorMessage(error) }; if (result.ariaSnapshot) failure.compactAria = compactAriaSnapshot(result.ariaSnapshot, true); - const status = await this.saveStatus(result); return { ok: false, command, page: this.pageBlock(result, previousState), failure, - instance: await this.instanceInfo(), - status, - artifacts: this.artifacts, + ...(await this.envelopeTail(result)), }; } private async reportEnvelope(command: string, result: ActionResult, previousState: WebPageState | null, outcome: Partial): Promise { - const status = await this.saveStatus(result); return { ok: true, command, page: this.pageBlock(result, previousState), ...outcome, - instance: await this.instanceInfo(), - status, - artifacts: this.artifacts, + ...(await this.envelopeTail(result)), }; } + private async envelopeTail(result: ActionResult): Promise> { + const { hash, artifacts } = await this.saveStatus(result); + return { instance: await this.instanceInfo(), status: hash, artifacts }; + } + private async capturedResult(previousState: WebPageState | null, opts: { screenshot?: boolean } = {}): Promise { const captured = await this.bot .getExplorer() @@ -1081,11 +1076,11 @@ export class Prima { }; } - private async saveStatus(result: ActionResult): Promise { + private async saveStatus(result: ActionResult): Promise<{ hash: string; artifacts: ArtifactPaths }> { const hash = this.statusHash(); - await this.writeSnapshot(result); + const artifacts = await this.writeSnapshot(result); writeFileSync(path.join(this.statusDir(hash), STATUS_FILE), JSON.stringify({ page: this.pageBlock(result, null) }), 'utf-8'); - return hash; + return { hash, artifacts }; } private async writeStepFiles(index: number, label: string, diff: string): Promise { @@ -1097,17 +1092,17 @@ export class Prima { const stem = path.join(dir, `${index}-${safeFilename(label.slice(0, 60))}`); const result = ActionResult.fromState(state); - writeFileSync(`${stem}.aria.yaml`, result.ariaSnapshot ?? '', 'utf-8'); - writeFileSync(`${stem}.html`, await result.combinedHtml(), 'utf-8'); - if (diff) writeFileSync(`${stem}.diff.yaml`, diff, 'utf-8'); + writeFileSync(`${stem}.${STEP_FILES.aria}`, result.ariaSnapshot ?? '', 'utf-8'); + writeFileSync(`${stem}.${STEP_FILES.html}`, await result.combinedHtml(), 'utf-8'); + if (diff) writeFileSync(`${stem}.${STEP_FILES.diff}`, diff, 'utf-8'); } - private async writeSnapshot(result: ActionResult): Promise { - this.artifacts = writeArtifacts(this.statusDir(), { + private async writeSnapshot(result: ActionResult): Promise { + return writeArtifacts(this.statusDir(), { aria: result.ariaSnapshot, html: await result.combinedHtml(), screenshot: result.screenshot, - requests: this.bot.requestStore().getMadeRequests(), + requests: this.bot.requestStore().getCapturedRequests(), }); } diff --git a/boat/prima/tests/envelope.test.ts b/boat/prima/tests/envelope.test.ts index c2ae6532..2d400f5a 100644 --- a/boat/prima/tests/envelope.test.ts +++ b/boat/prima/tests/envelope.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { type EnvelopeData, readArtifacts, renderEnvelope, writeArtifacts } from '../src/envelope.ts'; +import { type EnvelopeData, STEP_FILES, readArtifacts, renderEnvelope, writeArtifacts } from '../src/envelope.ts'; const base: EnvelopeData = { ok: true, @@ -180,6 +180,12 @@ describe('renderEnvelope', () => { expect(out).toContain('page after each step: /tmp/x/-.{aria.yaml,html,diff.yaml}'); }); + test('the advertised step-file line names every STEP_FILES extension', () => { + const out = renderEnvelope({ ...base, steps: [{ label: "I.click('Add')", ok: true, proof: '' }], stepFiles: '/tmp/x' }); + const line = out.split('\n').find((entry) => entry.includes('page after each step'))!; + for (const extension of Object.values(STEP_FILES)) expect(line).toContain(extension); + }); + test('open tabs alone are evidence enough for a running browser', () => { const out = renderEnvelope({ ...base, instance: { name: 'default', tabs: 2, others: [] } }); expect(out).toContain('| running'); diff --git a/boat/prima/tests/prima.test.ts b/boat/prima/tests/prima.test.ts index 45d68d3a..b74e27c4 100644 --- a/boat/prima/tests/prima.test.ts +++ b/boat/prima/tests/prima.test.ts @@ -95,7 +95,7 @@ function fakePrima(options: Record = {}) { }), getCurrentState: () => fakeState(), getConfig: () => ({}), - requestStore: () => ({ getMadeRequests: () => [] }), + requestStore: () => ({ getCapturedRequests: () => [] }), getProvider: () => ({ chat: async () => '' }), agentResearcher: () => ({ enable: () => {}, disable: () => {} }), }; @@ -142,6 +142,16 @@ describe('Prima.pw', () => { expect(envelope.instance.name).toBe('default'); }); + test('the network artifact holds the requests the browser was seen to make', async () => { + const { prima } = fakePrima(); + (prima as any).bot.requestStore = () => ({ getCapturedRequests: () => [{ method: 'POST', path: '/api/session', status: 201 }] }); + const envelope = await prima.pw("({ page }) => page.click('text=Login')"); + + const lines = readFileSync(envelope.artifacts!.network!, 'utf-8').trim().split('\n'); + expect(lines.length).toBe(1); + expect(JSON.parse(lines[0]).path).toBe('/api/session'); + }); + test('status points at the artifacts instead of printing the page tree back', async () => { const { prima } = fakePrima(); const first = await prima.pw("({ page }) => page.click('text=Login')"); diff --git a/tests/integration/prima-do.test.ts b/tests/integration/prima-do.test.ts index 8fa0de56..75be099f 100644 --- a/tests/integration/prima-do.test.ts +++ b/tests/integration/prima-do.test.ts @@ -90,7 +90,7 @@ describe('Prima.do with aimock', () => { getExplorer: () => ({ action: () => action, capture: async () => null }), stateManager: () => ({ getCurrentState: () => boardState, getVisitCount: () => 1 }), getCurrentState: () => boardState, - requestStore: () => ({ getMadeRequests: () => [] }), + requestStore: () => ({ getCapturedRequests: () => [] }), getProvider: () => provider, experienceTracker: () => ({ renderExperienceTocFor: () => '' }), agentResearcher: () => ({}),