Files
reverse-engineering-anything/tests/boundary/providers/desktopTaskPython.test.ts
T
d2f9b8b7de feat(desktop): build several tasks from one capture, and measure each task's pass rate within a budget (#9216)
* docs(desktop): clarify gzip scrub limits

* fix(setup): own desktop build process cleanup

* fix(doctor): compare desktop settings with resolved defaults

* fix(desktop): refuse a build runtime whose export takes no task ends

Several tasks pass --task-end to the build computer's export, so a runtime
packed before it now fails at the start of the capture with the repack fix,
as a runtime without synthetic seeds already did, rather than at export.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style(desktop): format the noisy-record draft files with the pinned oxfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(desktop): give the root-array draft test its own describe

The records describe grew past the 150-line lint limit with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(doctor): inspect client-recorded desktop runners

* fix(doctor): reuse matching runner probes

* fix(setup): propagate cancellation to desktop actions

* fix(desktop): rewrite a download's launch keys written with spaces around =

The desktop entry parser accepts `Exec = notes %F` and an indented
`TryExec =notes`, so the install script now matches those keys the same way
instead of leaving the original program in place. Adds the feature's tasks.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): keep why a measured run failed in its solve record

A solve that returned an outcome dropped the runner's error, so a run that
failed to deploy, solve or verify was retried with no reason recorded. The
record, the measure result and difficulty.json now carry it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(setup): stop Hopper on cancel, never report a cancelled setup ready, and keep a failed action's client setting

- The Hopper install gets setup's signal: Linux already honoured it, and the
  macOS installer now stops its downloads and stops before mounting or copying.
- runSetup checks the signal after the final doctor and close, so a
  cancellation there raises instead of returning ready.
- A failed pack or agentenv install no longer writes its archive or runner to
  client configs, so the setting the client already had stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(setup): keep the completed setup result in a cancellation after cleanup

A cancellation during the final doctor or host close carried only the applied
actions, dropping each client's result and backup path and the doctor report.
It now carries the whole result as setup_result, as doctor's cancellation
carries its report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(desktop): move launch execution to boundary suite

* fix(desktop): redact build schema failure output

* refactor(desktop): normalize schema failure once

* fix(desktop): omit unsafe schema parser snippets

* fix(desktop): preserve draft change and selected path evidence

* test(desktop): group retained dconf note regressions

* fix(desktop): respect archive entry format in draft checks

* test(desktop): separate unowned-file draft note cases

* fix(desktop): preserve explicitly selected journal documents

* fix(xml): avoid scanning selected ancestors per tag

* fix(desktop): exclude covered text runs from draft counts

* fix: preserve text when drafting desktop checks

* fix: restore nested desktop state aliases

* refactor: share UUID recognition across draft checks

* fix: retain multiline XML attribute context

* fix: retain relationship identifiers in task drafts

* docs: clarify desktop draft identifier handling

* test(desktop): type formatting run cases as pairs

* fix(desktop): carry document format into selector evaluation

* test(desktop): separate markup boundary regression group

* fix(desktop): propagate formats through draft selectors

* fix(desktop): switch selector format within archive entries

* test(desktop): remove duplicate seed filename case

* fix(setup): retain diagnostics when cancellation overlaps failure

* refactor(setup): keep command cancellation at its owner

* test(setup): verify cancellation at the application boundary

* test(desktop): pass JSON format in exported index parity

* docs(desktop): clarify task checks before scratch export

* fix(desktop): respell selected paths in file URLs

* test(desktop): group captured document spelling cases

* test(desktop): run installer scripts at boundary

* style(desktop): remove trailing blank line

* fix(desktop): bound draft preview serialization

* fix(desktop): restore selected state aliases

* fix(desktop): respect entry format in draft noise

* test(desktop): match exported alias evidence at its JSON boundary

* fix(desktop): retain app state logs in exported seeds

* fix: align desktop numeric seed comparisons

* test(desktop): keep one draft exclusion fixture import

* test(desktop): retain log eligibility across draft test groups

* fix(doctor): resolve empty registered desktop runner

* style: fix desktop launch test formatting

* style: format native command path schema

* fix(desktop): map selected external launch paths

* style(desktop): format seed document regressions

* test(desktop): group launch URL regressions

* fix(desktop): omit redundant nested state aliases

* fix(desktop): compare added free text as a whole

* fix(desktop): check recompressed seeds as shipped

* docs(desktop): clarify recovered seed format

* test(desktop): group shipped gzip seed checks

* fix: retain quoted identifier text in task drafts

* fix: recognize Unicode XML in desktop state readers

* fix(desktop): bound nested draft checks

* fix(desktop): explain missing arrays in truncated views

* fix(desktop): find XML type references in archives

* test(desktop): remove duplicated capture assertions

* fix(desktop): retain nested omitted text boundaries

* style(desktop): format draft value imports

* test(desktop): group model and draft value checks

* fix(desktop): map captured launch paths once

* refactor: remove unused whole-text guard state

* refactor: stop passing unused text children

* test: align free-text checks with whole-note policy

* fix: validate recompressed desktop archive seeds

* style(desktop): format archive seed regression

* fix(desktop): retain placed-item sibling guards

* fix(desktop): preserve archive paths and bound doctor reads

* fix(doctor): refuse non-files before descriptor admission

* fix(setup): track remaining cleanup resources across retries

* test(desktop): specify XML format in heap pairing fixture

* fix(setup): skip Hopper launch after cancelled copy

* fix: preserve task measurement limits and cleanup

* fix(desktop): retain reported solve sandbox cleanup

* test(desktop): check retained log seed admission

* fix(doctor): query agentenv for model endpoint

* fix(doctor): preserve uncertainty for empty endpoint override

* fix(desktop): redact runtime schema errors

* fix(desktop): normalize captured file URLs

* fix(desktop): report seeded files by capture path

* fix(desktop): retain startup readiness failure stage

* fix(desktop): retain JSON date checks

* fix(desktop): preserve archive formatting checks

* fix(desktop): preserve explicitly selected journal files

* fix(desktop): report build command transport limits

* docs(desktop): clarify unreadable dconf versions

* test(desktop): remove duplicate history regression

* fix(desktop): align XML names with exported reader

* fix(desktop): create selected state alias targets

* fix(desktop): inspect supported runtime archives

* fix(desktop): keep ordinary text checks exact

* fix(desktop): verify registered build host paths

* fix(desktop): retain record identity in draft checks

* test(desktop): preserve text reference numbers

* fix(desktop): bound download install input reads

* fix(desktop): match placed notes by whole text

* test(desktop): match multiple task export results

* fix(desktop): retain failed measurement file closes

* fix(setup): retain completed actions on cancellation

* test(setup): retain cancelled installer result

* fix(desktop): scrub XML independently of reader compatibility

* fix(desktop): preserve file aliases and reader diagnostics

* style(desktop): format review fixes

* fix(desktop): inspect archives with registered runner

* test(desktop): verify registered archive interpreter

* style(desktop): format capture review fixes

* style(desktop): format review changes

* feat(desktop): target agentenv-framework 0.9.1464

Desktop bundles, setup's --install-agentenv and the doctor's exact-version
check now target 0.9.1464, which keeps an exported app's data/get call,
holds the computer fixture above boot dialogs, leaves an unresolved setting's
build unverified rather than failed, and mounts lock packages instead of
copying them into the image. The web environment lane stays on 0.9.1431.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): retain per-solve sandbox cleanup roots

* test(desktop): expect the solve cleanup helper among the shipped agentenv helpers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(desktop): clean up a stopped solve's sandboxes through agentenv 0.9.1482

agentenv-framework 0.9.1482 lists the local sandboxes under a root
(local_sandbox_ids) and rebuilds one killed while it wrote its port map for
teardown. The solve cleanup helper now terminates each sandbox agentenv
lists, keeping its Docker and host-process checks, and reports anything else
left under the root. Desktop bundles, setup's --install-agentenv and the
doctor's version check target 0.9.1482.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): verify a killed solve's cleanup once its sandbox root is empty

A solve's sandboxes are all local under its private root, and agentenv now
keeps a sandbox's folder until its teardown is confirmed. So when the helper
ends without a report and REA's cleanup empties that root, the cleanup is
verified: a cancelled solve ends as a cancellation and a solve past its
deadline as a failed run, rather than as cleanup the measure cannot vouch for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(desktop): target agentenv-framework 0.9.1485

0.9.1485 keeps a local sandbox's folder until its teardown is confirmed, which
the solve cleanup's verified result relies on. (0.9.1484 failed to publish.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): record a solve stopped before it reports as failed, not as a refused request

A helper killed from outside or stopped at its deadline left no report, so the
measure refused with invalid input and told the caller to fix its model
endpoint. A helper a signal ended now gives a failed solve of unknown cost,
which the measure records and stops on; one that exits on its own without a
report still ends the measure as a solve that could not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): check verifier behavior after selector changes

* test(windows): verify rejected writes during cleanup by effect

* fix(desktop): preserve selected alias target kinds in exports

* fix(cli): retain caller capture paths in environment builds

* fix(ghidra): classify oversized endpoint records consistently

* test: align model and Evidence expectations with current contracts

* fix(desktop): reconcile upstream sandbox cleanup recovery

* fix(docs): link cleanup qualification as repository evidence

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 05:26:28 -05:00

639 lines
21 KiB
TypeScript

import { execFileSync } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import type {
DesktopTaskCheck,
DesktopTaskReader,
} from "../../../src/domain/desktop/appTask.js";
import {
evaluateTaskChecks,
type TaskFileView,
} from "../../../src/domain/desktop/appTaskChecks.js";
import {
CHECK_CASES,
CHECK_VIEWS,
} from "../../../src/domain/desktop/appTaskChecks.fixture.js";
import { SETTLE_TIMING } from "../../../src/domain/desktop/appSettle.js";
import { renderTaskVerifier } from "../../../src/domain/desktop/appTaskPython.js";
import {
readerAnswerView,
readerCommand,
} from "../../../src/domain/desktop/appTaskReaders.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
/** The protocol client calls the verifier makes, replaced so it reads the given views. */
const STUB_CLIENT = `
def find_child(card, name):
return next((child for child in card.get("children_environments") or [] if child.get("name") == name), None)
def find_extension(card, uri):
return next((ext for ext in (card.get("capabilities") or {}).get("extensions") or [] if ext.get("uri") == uri), None)
`;
/**
* Runs the verifier against an app whose files the stdin's `saves` set, each `[at, views]` from the virtual second it
* lands on: a clock only the verifier's sleeps and reads move, so its timing is exact. Read n takes `durations[n]`
* seconds, or `READ_S`, and returns the files as they are when it completes; a timeout past its limit ends it there.
*/
const DRIVER = String.raw`
import asyncio, base64, importlib.util, json, sys, types
folder, verifier = sys.argv[1], sys.argv[2]
sys.path.insert(0, folder)
from agentenv_protocol import client
scenario = json.load(sys.stdin)
saves, durations, called, reads, clock = scenario["saves"], scenario["durations"], [], [], [0.0]
answers, execs = scenario.get("answers") or {}, []
cleanup_failures = [scenario.get("cleanup_failures", 0)]
cancel_cleanup = [scenario.get("cancel_cleanup", False)]
verify_task = None
reader_started, release_reader = asyncio.Event(), asyncio.Event()
reader_calls = [0]
#: Virtual seconds each data/get takes, a binary fraction so the clock stays exact.
READ_S = 0.125
def views_at(now):
return [views for at, views in saves if at <= now][-1]
async def get_card(url, **_):
called.append(url)
extensions = [{"uri": "urn:agentenv:computer-exec/v1"}] if scenario.get("exec", True) else []
return {"name": "computer", "children_environments": [{"name": "notes", "url": "/svc/notes/agentenv"}],
"capabilities": {"extensions": extensions}}
async def invoke_extension(url, card, uri, params=None, timeout=30, verify=True, *, method=None, headers=None):
execs.append({"url": url, "uri": uri, "method": method, "timeout": timeout, **params})
if params["request_id"].startswith("rea-reader-cleanup-"):
if cancel_cleanup[0]:
cancel_cleanup[0] = False
verify_task.cancel()
await asyncio.sleep(0)
if cleanup_failures[0] > 0:
cleanup_failures[0] -= 1
return {"exit_code": 1, "stdout": "", "stderr": "permission denied", "timed_out": False}
if scenario.get("parallel_retry"):
await asyncio.sleep(0)
return {"exit_code": 0, "stdout": "", "stderr": "", "timed_out": False}
if scenario.get("concurrent"):
reader_calls[0] += 1
if reader_calls[0] == 1:
reader_started.set()
await release_reader.wait()
else:
release_reader.set()
answer = answers[json.loads("".join(params["argv"][4:]))["id"]]
stdout = answer if isinstance(answer, str) else "Python noise\n" + json.dumps(answer) + "\n"
return {"exit_code": 0, "stdout": stdout, "stderr": "", "truncated": False, "timed_out": False}
async def get_data(url, **_):
called.append(url)
reads.append(clock[0])
clock[0] += durations[len(reads) - 1] if len(reads) <= len(durations) else READ_S
views = views_at(clock[0])
files = [types.SimpleNamespace(kind="file", metadata={"urn:agentenv:data-objects/v1": {"path": path}},
file=types.SimpleNamespace(bytes=base64.b64encode(json.dumps(view).encode()).decode()))
for path, view in views.items()]
return types.SimpleNamespace(parts=[*files, types.SimpleNamespace(kind="data", data={"state": views})])
async def sleep(seconds):
clock[0] += seconds
class Cut(Exception):
"""The timeout a virtual wait_for raises."""
async def wait_for(awaitable, timeout):
limit = clock[0] + timeout
result = await awaitable
if clock[0] > limit:
clock[0] = limit
raise Cut
return result
client.get_card, client.get_data, client.invoke_extension = get_card, get_data, invoke_extension
def load_verifier():
spec = importlib.util.spec_from_file_location("verifier", verifier)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
module.time = types.SimpleNamespace(monotonic=lambda: clock[0])
module.asyncio = types.SimpleNamespace(sleep=sleep, wait_for=wait_for, TimeoutError=Cut,
create_task=asyncio.create_task, shield=asyncio.shield,
CancelledError=asyncio.CancelledError)
return module
module = load_verifier()
async def run():
global verify_task, module
first_error = None
if scenario.get("concurrent"):
first = asyncio.create_task(module.verify("http://mcp", "http://computer:8080"))
await asyncio.wait_for(reader_started.wait(), timeout=2)
other = load_verifier()
second = asyncio.create_task(other.verify("http://mcp", "http://computer:8080"))
results, _ = await asyncio.wait_for(asyncio.gather(first, second), timeout=5)
return results, first_error
if scenario.get("retry"):
try:
verify_task = asyncio.create_task(module.verify("http://mcp", "http://computer:8080"))
await verify_task
except (RuntimeError, asyncio.CancelledError) as error:
first_error = str(error) + " ".join(getattr(error, "__notes__", ()))
if scenario.get("reload_verifier"):
module = load_verifier()
if scenario.get("parallel_retry"):
other = load_verifier()
first = asyncio.create_task(module.verify("http://mcp", "http://computer:8080"))
second = asyncio.create_task(other.verify("http://mcp", "http://computer:8080"))
results, _ = await asyncio.gather(first, second)
return results, first_error
results = await module.verify("http://mcp", "http://computer:8080")
return results, first_error
results, first_error = asyncio.run(run())
print(json.dumps({"called": called, "reads": reads, "ended": clock[0], "results": results, "execs": execs, "first_error": first_error}))
`;
type VerifierOutput = {
called: string[];
execs: Record<string, unknown>[];
reads: number[];
ended: number;
first_error: string | null;
results: {
id: string;
result: boolean;
description: string;
settled?: boolean;
read_cut?: boolean;
}[];
};
/** The exported verifier's results on an app whose files change at the given virtual seconds. */
const runVerifier = async (
checks: readonly DesktopTaskCheck[],
saves: readonly (readonly [number, Record<string, unknown>])[],
durations: readonly number[] = [],
computer: {
readonly readers?: readonly DesktopTaskReader[];
readonly answers?: Readonly<Record<string, unknown>>;
readonly exec?: boolean;
readonly cleanupFailures?: number;
readonly retry?: boolean;
readonly cancelCleanup?: boolean;
readonly reloadVerifier?: boolean;
readonly concurrent?: boolean;
readonly parallelRetry?: boolean;
} = {},
): Promise<VerifierOutput> => {
const folder = await createTestTempDirectory("desktop-task-python-");
await mkdir(join(folder, "agentenv_protocol"));
await writeFile(join(folder, "agentenv_protocol/__init__.py"), "");
await writeFile(join(folder, "agentenv_protocol/client.py"), STUB_CLIENT);
await writeFile(
join(folder, "agentenv_protocol/types.py"),
'RPC_PATH = "/agentenv"\n',
);
const verifier = join(folder, "verify.py");
await writeFile(
verifier,
renderTaskVerifier(
{
name: "edit-notes",
prompt: "Edit.",
checks: [...checks],
...(computer.readers === undefined
? {}
: { readers: computer.readers }),
},
"notes",
),
);
return JSON.parse(
execFileSync("python3", ["-I", "-B", "-c", DRIVER, folder, verifier], {
input: JSON.stringify({
saves,
durations,
answers: computer.answers,
exec: computer.exec ?? true,
cleanup_failures: computer.cleanupFailures,
retry: computer.retry,
cancel_cleanup: computer.cancelCleanup,
reload_verifier: computer.reloadVerifier,
concurrent: computer.concurrent,
parallel_retry: computer.parallelRetry,
}),
encoding: "utf8",
}),
) as VerifierOutput;
};
const INTERVAL_S = SETTLE_TIMING.intervalMs / 1000;
const WINDOW_S = SETTLE_TIMING.windowMs / 1000;
const BOUND_S = SETTLE_TIMING.boundMs / 1000;
const today = (...lines: string[]) => ({
"Documents/today.txt": {
format: "text",
view: { lines, line_count: lines.length, truncated: false },
truncated: false,
},
});
const linesPresent = (...lines: string[]): DesktopTaskCheck => ({
id: "today",
kind: "lines_present",
file: "Documents/today.txt",
lines,
});
const graded = ({ results }: VerifierOutput) =>
results.map(({ result, settled, read_cut }) => [result, settled, read_cut]);
it("evaluates every kind of file check without computer-exec as REA does", async () => {
const checks: DesktopTaskCheck[] = CHECK_CASES.map(([check], index) => ({
...check,
id: `check-${String(index + 1)}`,
}));
const output = await runVerifier(checks, [[0, CHECK_VIEWS]], [], {
exec: false,
});
expect(output.execs).toEqual([]);
const [card, ...data] = output.called;
expect(card).toBe("http://computer:8080");
expect(new Set(data)).toEqual(new Set(["http://computer:8080/svc/notes"]));
expect(data).toHaveLength(output.reads.length);
const typescript = evaluateTaskChecks(
checks,
new Map(Object.entries(CHECK_VIEWS)),
);
expect(output.results.map(({ id, result }) => [id, result])).toEqual(
typescript.map(({ id, passed }) => [id, passed]),
);
expect(output.results.map(({ result }) => result)).toEqual(
CHECK_CASES.map(([, expected]) => expected),
);
expect(output.results.map(({ description }) => description)).toEqual(
typescript.map(({ description }) => description),
);
});
it("grades an app that saves seconds after the agent stops on what it saved", async () => {
// Gnote saved 2.2 s after the verifier's first read in the run this missed.
const saved = 2.2;
const output = await runVerifier(
[linesPresent("milk", "olive oil")],
[
[0, today("milk")],
[saved, today("milk", "olive oil")],
],
);
expect(graded(output)).toEqual([[true, true, false]]);
// The views graded had held a whole window after the save landed.
expect(output.reads.at(-1)).toBeGreaterThanOrEqual(saved + WINDOW_S);
expect(output.reads.at(-1)).toBeLessThanOrEqual(
saved + WINDOW_S + INTERVAL_S,
);
});
it("grades files that never stop changing at the bound, on the last read, and records that they did not settle", async () => {
const saves = Array.from(
{ length: 2 * BOUND_S + 10 },
(_, index) => [index * INTERVAL_S, today(`save ${String(index)}`)] as const,
);
// The last read starts an interval before the bound and returns the save that landed as it ran.
const last = `save ${String(BOUND_S / INTERVAL_S - 1)}`;
const output = await runVerifier([linesPresent(last)], saves);
expect(output.reads.at(-1)).toBe(BOUND_S - INTERVAL_S);
expect(output.ended).toBeLessThanOrEqual(BOUND_S);
expect(graded(output)).toEqual([[true, false, false]]);
});
it("counts a slow read's changed files as quiet only from when it returned them", async () => {
// The second read starts at 0.5 s and returns a save from 2.75 s at 2.875 s; another save follows at 4.125 s.
const output = await runVerifier(
[linesPresent("milk", "olive oil", "flour")],
[
[0, today("milk")],
[2.75, today("milk", "olive oil")],
[4.125, today("milk", "olive oil", "flour")],
],
[0.125, 2.375],
);
expect(graded(output)).toEqual([[true, true, false]]);
});
it("ends reads slower than the bound at the bound, grading the last completed read", async () => {
const slow = 8;
const output = await runVerifier(
[linesPresent("milk")],
[
[0, today("milk")],
[slow + 2, today("eggs")],
],
Array.from({ length: 4 }, () => slow),
);
expect(output.reads).toEqual([0, slow]);
expect(output.ended).toBe(BOUND_S);
expect(graded(output)).toEqual([[true, false, true]]);
});
it("grades an app that saved before the check within the window plus one interval", async () => {
const output = await runVerifier(
[linesPresent("milk", "olive oil")],
[[0, today("milk", "olive oil")]],
);
expect(graded(output)).toEqual([[true, true, false]]);
expect(output.reads.at(-1)).toBeLessThanOrEqual(WINDOW_S + INTERVAL_S);
});
const READERS: readonly DesktopTaskReader[] = [
{
id: "layers",
file: "Documents/poster.xcf",
argv: ["gimp-console-2.10", "-i", "-b", "-"],
stdin: '(gimp-file-load RUN-NONINTERACTIVE "{file}" "{file}")',
output: "lines",
},
{
id: "objects",
file: "Documents/scene.blend",
argv: ["blender", "-b", "{file}", "--python-expr", "print('REA {}')"],
output: "json",
timeout_s: 20,
},
{
id: "broken",
file: "Documents/broken.xcf",
argv: ["gimp-console-2.10", "{file}"],
output: "lines",
},
{
id: "gone",
file: "Documents/gone.xcf",
argv: ["gimp-console-2.10", "{file}"],
output: "lines",
},
];
/** What REA's reader runner answers for each reader, as its last line. */
const ANSWERS: Readonly<Record<string, unknown>> = {
layers: {
view: {
format: "text",
view: {
lines: ["layer Summer Sale", "layer Background"],
line_count: 2,
truncated: false,
},
},
},
objects: {
view: {
format: "json",
view: {
Crate: { location: [2.0004, 0, 0] },
Camera: { location: [7, -6, 5] },
},
},
},
broken: {
error: "the reader exited 1; its stderr ends: cannot open the file",
},
gone: { absent: true },
};
const READER_CHECKS: DesktopTaskCheck[] = [
{
id: "renamed",
kind: "lines_present",
file: "Documents/poster.xcf",
reader: "layers",
lines: ["layer Summer Sale"],
},
{
id: "kept",
kind: "lines_absent",
file: "Documents/poster.xcf",
reader: "layers",
lines: ["layer Headline"],
},
{
id: "moved",
kind: "value_equals",
file: "Documents/scene.blend",
reader: "objects",
pointer: "/Crate/location/0",
value: 2,
tolerance: 0.001,
},
{
id: "camera",
kind: "value_equals",
file: "Documents/scene.blend",
reader: "objects",
pointer: "/Camera/location/0",
value: 8,
},
{
id: "objects",
kind: "member_count",
file: "Documents/scene.blend",
reader: "objects",
pointer: "/Crate/location",
equals: 3,
},
{
id: "broken",
kind: "line_count",
file: "Documents/broken.xcf",
reader: "broken",
contains: "layer",
at_least: 1,
},
{
id: "gone",
kind: "lines_absent",
file: "Documents/gone.xcf",
reader: "gone",
lines: ["layer"],
},
];
it("sends large reader input in the same bounded arguments as the witness", async () => {
const reader = { ...READERS[0]!, stdin: "é".repeat(65536) };
const output = await runVerifier(READER_CHECKS, [[0, CHECK_VIEWS]], [], {
readers: [reader],
answers: ANSWERS,
});
const argv = output.execs[0]?.argv as string[];
const arguments_ = JSON.parse(argv.slice(4).join("")) as Record<
string,
unknown
>;
expect(arguments_).toMatchObject({
stdin: reader.stdin,
lease: { path: expect.stringMatching(/^\/tmp\/rea-reader-/u) },
});
expect(
argv.slice(4).every((argument) => Buffer.byteLength(argument) < 131072),
).toBe(true);
});
it("reads each reader's view through the computer's computer-exec and grades it as REA does", async () => {
const output = await runVerifier(READER_CHECKS, [[0, CHECK_VIEWS]], [], {
readers: READERS,
answers: ANSWERS,
});
expect(
output.execs
.filter(
({ request_id }) =>
!String(request_id).startsWith("rea-reader-cleanup-"),
)
.map(({ url, uri, method, argv, timeout_s }) => ({
url,
uri,
method,
argv: (argv as string[]).slice(0, 4),
timeout_s,
})),
).toEqual(
READERS.map((reader) => ({
url: "http://computer:8080",
uri: "urn:agentenv:computer-exec/v1",
method: "run",
argv: readerCommand(reader, undefined).slice(0, 4),
timeout_s: (reader.timeout_s ?? 60) + 30,
})),
);
// computer-exec answers a repeated request id with its first result, so each reader's is new.
expect(new Set(output.execs.map(({ request_id }) => request_id)).size).toBe(
READERS.length * 2,
);
const readers = new Map<string, TaskFileView>();
for (const [id, answer] of Object.entries(ANSWERS)) {
const view = readerAnswerView(JSON.stringify(answer));
if (view !== undefined) readers.set(id, view);
}
const typescript = evaluateTaskChecks(
READER_CHECKS,
new Map(Object.entries(CHECK_VIEWS)),
readers,
);
expect(
output.results.map(({ id, result, description }) => [
id,
result,
description,
]),
).toEqual(
typescript.map(({ id, passed, description }) => [id, passed, description]),
);
expect(output.results.map(({ result }) => result)).toEqual([
true,
true,
true,
false,
true,
false,
true,
]);
});
it("reports a cleanup failure with the completed view and retries the same folder on the next verification", async () => {
const output = await runVerifier(READER_CHECKS, [[0, CHECK_VIEWS]], [], {
readers: [READERS[0]!],
answers: ANSWERS,
cleanupFailures: 1,
retry: true,
reloadVerifier: true,
});
expect(output.first_error).toContain("Could not remove owned reader folder");
expect(output.first_error).toContain("completed reader views");
const cleanups = output.execs.filter(({ request_id }) =>
String(request_id).startsWith("rea-reader-cleanup-"),
);
expect(cleanups).toHaveLength(3);
const lease = (entry: Record<string, unknown>) =>
JSON.parse((entry.argv as string[])[4]!);
expect(lease(cleanups[1]!)).toEqual(lease(cleanups[0]!));
expect(cleanups[1]?.request_id).not.toBe(cleanups[0]?.request_id);
});
it("keeps cleanup owned when verification is cancelled during final cleanup", async () => {
const output = await runVerifier(READER_CHECKS, [[0, CHECK_VIEWS]], [], {
readers: [READERS[0]!],
answers: ANSWERS,
cancelCleanup: true,
retry: true,
reloadVerifier: true,
});
expect(output.first_error).toContain("Reader cleanup remains owned");
expect(output.first_error).toContain("completed reader views");
const cleanups = output.execs.filter(({ request_id }) =>
String(request_id).startsWith("rea-reader-cleanup-"),
);
expect(cleanups).toHaveLength(2);
});
it("does not adopt another active verifier's reader lease", async () => {
const output = await runVerifier(READER_CHECKS, [[0, CHECK_VIEWS]], [], {
readers: [READERS[0]!],
answers: ANSWERS,
concurrent: true,
});
expect(
output.execs
.slice(0, 2)
.every(
({ request_id }) =>
!String(request_id).startsWith("rea-reader-cleanup-"),
),
).toBe(true);
});
it("joins concurrent retries of one abandoned reader cleanup", async () => {
const output = await runVerifier(READER_CHECKS, [[0, CHECK_VIEWS]], [], {
readers: [READERS[0]!],
answers: ANSWERS,
cleanupFailures: 1,
retry: true,
parallelRetry: true,
});
expect(output.first_error).toContain("Could not remove owned reader folder");
const cleanups = output.execs.filter(({ request_id }) =>
String(request_id).startsWith("rea-reader-cleanup-"),
);
expect(cleanups).toHaveLength(4);
const lease = (entry: Record<string, unknown>) =>
JSON.parse((entry.argv as string[])[4]!);
expect(lease(cleanups[1]!)).toEqual(lease(cleanups[0]!));
});
it.each([
[{ exec: false }, "offers no urn:agentenv:computer-exec/v1"],
[
{ answers: { ...ANSWERS, layers: "Traceback (most recent call last)\n" } },
"REA's reader runner",
],
])(
"raises rather than grading when the readers cannot be run (%j)",
async (computer, message) => {
await expect(
runVerifier(READER_CHECKS, [[0, CHECK_VIEWS]], [], {
readers: READERS,
answers: ANSWERS,
...computer,
}),
).rejects.toThrow(message);
},
);