* feat(desktop): read XML with a plain DOCTYPE, and check an element's whole text - Both readers accept a DOCTYPE with only a name and an optional PUBLIC or SYSTEM identifier, record it as the view's doctype, and never fetch the DTD or expand entities beyond XML's five; an internal subset, an external entity and a parameter entity stay refused alike. - text_equals and text_contains compare the concatenated text of the element (or text node or attribute) a pointer reaches, as XPath string() does, with optional normalize_whitespace; both evaluators agree, and a text check on a truncated element is undecided. - Whitespace-only text between elements is dropped only when the element holds no other text, so text split into runs keeps its spaces. - The draft suggests text_equals for an element whose text changed. So an AbiWord .abw reads as XML, and its paragraph's text is checked whole however AbiWord splits it into <c> runs, as Writer's spans are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(desktop): draft whole-text checks on the paragraph, read only text as text, and skip asctime stamps - A changed value is read at both ends, so text that became a run still marks its paragraph, and the drafted text_equals moves up out of mixed content to the nearest ancestor named by an attribute, such as AbiWord's p by its xid. - Text checks accept only a text node or an attribute value; element names and doctype fields fail, in both evaluators. - isTimestamp also recognises asctime dates (Www Mmm dd hh:mm:ss yyyy). - XML files are drafted with the keyed diff the archive entries use, so no suggestion reaches an element by position; the shared logic moves to appTaskDraftNamed.ts. - Text-check tests assert facts, not whole sentences. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(desktop): keep checks on a changed attribute inside a paragraph whose text check replaces its runs' A drafted whole-text check on a paragraph hid every change inside it, so a link whose label and href both changed drafted only the paragraph's text and the new destination went unchecked. The text check now covers text and runs added or removed whole; a changed name or attribute of an element both versions hold keeps its own check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(desktop): read an attribute called name or attributes as an attribute when the draft keeps its check The owner of `.../attributes/name` was taken as `.../attributes`, so a changed `name` attribute inside a paragraph with a text check was dropped again. The owner is now read by segment, attributes first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(desktop): draft text checks from text in runs added whole, and name a text element by its own id before its container Text typed into a run the diff lists whole was never read, so the draft kept only a presence check on the run, which wrong text also passed. Such text now marks the nearest element both ends hold, or, when none can take the check, the added element's own text. The climb also names an element by an identifier of its own (id, xml:id, xid) before a larger container, so a headline under a long layer keeps a whole-text check that survives re-split runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(desktop): expect the whole-text check where #8988's pairing test meets text checks On this branch a changed element's text is drafted as `text_equals` on the element, so the identity pairing test now expects that check, under the same `[@role=body]` key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(desktop): keep checks for elements without text beside a text edit, and normalize DOCTYPE system id line endings A review found two gaps. - A paragraph's whole-text check covered every element added or removed inside it, including ones holding no text: an image frame, a line break. A solver who typed the text but never inserted the image passed. Such an element now keeps its own presence or absence check. - The Python reader kept a DOCTYPE system literal's CRLF where TypeScript normalizes it to LF. Both now report LF. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(desktop): import textSyntaxFormat once in the state format tests main's mixed-case JSON suffix fix and this branch both added the import, and the merge kept both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
340 lines
19 KiB
Markdown
340 lines
19 KiB
Markdown
# Desktop application tasks
|
|
|
|
A [desktop application export](desktop-application-export.md) can carry one
|
|
task: a prompt for a computer-use agent and checks that grade what the agent
|
|
left in the application's files. An agent, not REA, writes the prompt and the
|
|
checks from the change the capture recorded. REA validates the task against
|
|
the capture and keeps it only when its checks fail on the seed and all pass on
|
|
the captured end state. REA runs no model and no code the agent wrote: the
|
|
checks are declarative, and the exported verifier evaluates them the same way
|
|
REA does.
|
|
|
|
```sh
|
|
rea describe-desktop-application-task ./mousepad-capture --format json > draft.json
|
|
# edit the draft's task into task.json: a name, the user's request, the checks
|
|
rea export-desktop-application ./mousepad-capture ./mousepad-bundle --task task.json
|
|
agent-env run ./mousepad-bundle --task add-milk --agent claude-code
|
|
```
|
|
|
|
## Describe the change
|
|
|
|
`rea describe-desktop-application-task <capture-folder>` reads the capture and
|
|
its [state view](desktop-application-state.md) and returns Evidence with
|
|
operation `describe_desktop_application_task` and predicate type
|
|
`rea.desktop-application-task-draft`:
|
|
|
|
- `application`: the environment name the export would use, the title and the
|
|
window class.
|
|
- `owned_paths`: the paths the export owns, the only ones whose files the
|
|
app's `data/get` serves and checks can read.
|
|
- `checkpoints`: the two checkpoints compared, `baseline` for the seed and
|
|
`final` for the end. They are the capture's first and last unless
|
|
`--seed-checkpoint` and `--end-checkpoint` name others, as for the
|
|
[export](desktop-application-export.md#the-seed-checkpoint), which takes
|
|
the same options; a name the capture does not hold, or an end before the
|
|
seed, is refused.
|
|
- `files`: each file under them whose state differs between those
|
|
checkpoints, with its home path, role, its versions at both (`baseline` and
|
|
`final`, the last recorded at or before each) and the net change between them, from the state view's own diff (`lines`, `values`,
|
|
`keys` or `rows` with what was added, removed and changed, or `created`,
|
|
`deleted`, `format`, `content`, `unreadable`; `content` also when the
|
|
views hold only part of a file and match while the file changed). A
|
|
database is compared by its rows, since its hash leaves out the `-wal`
|
|
file. Text changes list each line
|
|
once and add `line_counts`, the lines found at both ends whose number of
|
|
occurrences changed. Views appear only for the files the suggestions cover,
|
|
with arrays past 200 items and strings past 2,000 characters cut
|
|
(`view_cut`).
|
|
- `task`: a draft with the name `change`, a placeholder prompt the export
|
|
refuses, and suggested checks derived from the net change of the files with
|
|
role `document`, or of the app's own state files when no document changed.
|
|
A text file gains `lines_present` for its new lines and `lines_absent` for
|
|
lines it lost (when either is at most ten), and `line_count` for each line
|
|
holding a letter whose count changed; JSON and key files gain
|
|
`value_equals` and `value_absent` by pointer; SQLite tables gain rows added,
|
|
changed and removed, by primary key, and their new row count. An XML file,
|
|
and the JSON, key file and XML views of a zip archive's entries, gain checks
|
|
by [selectors](#pointers-and-selectors) and never through an element by
|
|
position: in XML, first [`text_equals`](#whole-text) for each element whose
|
|
text changed, then an element added whole that it exists, one removed whole
|
|
that it is absent, then values changed, added and removed, an entry or a
|
|
JSON value added whole through its members, and an empty array or object as
|
|
it is. The text check names the paragraph rather than a run inside it: it
|
|
goes up from an element sitting in its parent's text, and from one named by
|
|
its tag or position alone to the nearest ancestor named by an attribute, as
|
|
AbiWord's `p` by its `xid`; the text nodes and runs inside it get no other
|
|
check, but an element holding no text that is added or removed there, such
|
|
as an image or a line break, keeps its own, since the text check never
|
|
reads it. Whitespace an app lays out between elements is not checked, and
|
|
values that look like a time the app stamps, in ISO 8601 or as C's
|
|
`asctime` writes it (`Thu Nov 5 09:20:31 2026`), are left out.
|
|
|
|
The suggestions are a starting point. Keep the ones that say what the user
|
|
did; drop the ones that only follow from how the app saves its file. A LibreCAD
|
|
capture that added four holes to a drawing rewrote it from DXF R12 to DXF 2007,
|
|
so most of its lines changed; the draft suggests counts of `SECTION`, `ENDSEC`
|
|
and `CIRCLE`, and only `CIRCLE` going from 5 to 9 is the user's change.
|
|
|
|
## The task file
|
|
|
|
```json
|
|
{
|
|
"name": "add-milk",
|
|
"prompt": "Add buy milk to my to-do list in Mousepad and save it.",
|
|
"checks": [
|
|
{
|
|
"id": "milk",
|
|
"kind": "lines_present",
|
|
"file": "Documents/todo.txt",
|
|
"lines": ["buy milk"]
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
`name` is a portable bundle name other than `smoke` (lowercase letters, digits
|
|
and hyphens); `prompt` is what the user asks for; `checks` holds 1 to 32
|
|
checks. Each check has a `kind`, a `file` given as a path under the home such as
|
|
`Documents/todo.txt`, and an optional `id` (`check-1`, `check-2` and so on by
|
|
position otherwise). A check reads the typed view `data/get` returns for that
|
|
file, in the shapes of the state view.
|
|
|
|
| Kind | Fields | Passes when |
|
|
| --------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------ |
|
|
| `file_present` | | The file exists |
|
|
| `file_absent` | | The file does not exist |
|
|
| `lines_present` | `lines` | Every listed line is a whole line of the text view |
|
|
| `lines_absent` | `lines` | None of the listed lines is |
|
|
| `line_count` | `exact` or `contains`; `equals`, `at_least` or `at_most` | The number of lines equal to `exact`, or holding `contains`, compares so |
|
|
| `value_equals` | `pointer`, `value`; optional `tolerance` | The pointer names a value equal to `value`, or within `tolerance` of it |
|
|
| `value_absent` | `pointer` | The pointer names no value |
|
|
| `text_equals` | `pointer`, `value`; optional `normalize_whitespace` | The XML element's [whole text](#whole-text) equals `value` |
|
|
| `text_contains` | `pointer`, `value`; optional `normalize_whitespace` | The XML element's whole text holds `value` |
|
|
| `row_present` | `table`, `where` | A row of the SQLite table has every column in `where` equal |
|
|
| `row_absent` | `table`, `where` | No row does |
|
|
| `row_count` | `table`; optional `where`; `equals`, `at_least` or `at_most` | The number of rows, or of rows matching `where`, compares so |
|
|
|
|
The rules both evaluators follow:
|
|
|
|
- An absent file reads as empty: no lines, values or rows. So `lines_absent`,
|
|
`value_absent` and `row_absent` pass on it and a count is 0.
|
|
- A file that has no view (binary, over the read limit, or unreadable) fails
|
|
every check but `file_present` and `file_absent`. Line checks need a text
|
|
view, row checks a SQLite view and text checks an XML view, in a file or an
|
|
archive entry; another format fails.
|
|
- Equality is JSON equality: numbers by value, never a boolean for a number,
|
|
objects by their keys whatever their order, arrays in order.
|
|
- Pointers follow RFC 6901 (`~1` for `/`, `~0` for `~`) and must start with
|
|
`/`; on an array a segment may be a selector, below. Key files read as
|
|
`{"groups": {"GROUP": {"KEY": "VALUE"}}}`, so a setting is
|
|
`/groups/GROUP/KEY`; XML as `{"root": {"name", "attributes", "children"}}`;
|
|
SQLite as `{"tables": {"TABLE": {"columns", "rows"}}}`; a zip archive as
|
|
`{"entries": [{"name", "format", "view", …}]}`.
|
|
- With `tolerance`, a non-negative number, `value` must be a number, and the
|
|
value found passes when it is a number, or a string that is a decimal
|
|
numeral such as `"5.0000000000000000"`, at most `tolerance` from `value`.
|
|
`tolerance: 0` compares numerically, so `"1.2500"` equals `1.25`. Anything
|
|
else fails as not a number.
|
|
- `contains` matches a line that holds it anywhere, case and all. Checks take
|
|
no regular expressions: a pattern run on what the agent wrote could stall
|
|
the verifier, and JavaScript and Python read some patterns differently.
|
|
- A check decided by what a view leaves out (`lines_absent`, `value_absent`,
|
|
`row_absent`, the counts, `value_equals` of a whole object or array, and
|
|
the text checks of an element) fails when that view is truncated; a check
|
|
satisfied by what the view holds is decided on it.
|
|
|
|
The export refuses, naming the check, a file outside the owned paths, a file
|
|
spelled with a leading slash, `.` or `..`, a pointer into a file that is text
|
|
at the seed, as the bundle ships it, or at the end checkpoint, the empty
|
|
pointer (the whole file), a value that names the captured home, or the
|
|
capture host's name or `user@host` anywhere (which the
|
|
[export removes from the seed](desktop-application-export.md#host-name-and-user)), a
|
|
`tolerance` with a value that is not a number, an empty `text_contains`
|
|
value, or one of only whitespace with `normalize_whitespace`, and a value that
|
|
looks like a date and time, in ISO 8601 or `asctime`'s form. Other volatile
|
|
content, such as a
|
|
counter the app bumps on every save, is for the author to leave out.
|
|
|
|
## Pointers and selectors
|
|
|
|
An app that rewrites a document moves things: FreeCAD 1.0 records more
|
|
properties than 0.21, so a hole's radius sits at another position among its
|
|
siblings, and a plate's entry moves once a hole is added before it. A pointer
|
|
segment applied to an array may therefore name a member by what it holds
|
|
rather than by its position:
|
|
|
|
- `[KEY=VALUE]` names the member whose string field `KEY` is `VALUE`, such as
|
|
a zip entry by `[name=Document.xml]` or an XML element by its tag,
|
|
`[name=Object]`.
|
|
- `[@KEY=VALUE]` names the member whose `attributes` hold `KEY="VALUE"`, as
|
|
the XML view keeps an element's attributes.
|
|
- Conditions can follow one another, `[name=Object][@name=Hole]`, and all must
|
|
hold. They compare strings only, the selector is escaped like any pointer
|
|
segment (`~1` for `/`), and a value cannot hold `][`.
|
|
|
|
A selector must name exactly one member. When several match, or none does
|
|
before the last segment, the check fails and says which selector matched what.
|
|
When the last segment's selector matches none, the pointer names no value:
|
|
`value_absent` passes, which is how a check says an element was removed, and
|
|
`value_equals` fails. On an object, a bracketed segment is an ordinary key.
|
|
|
|
A FreeCAD task that checks the user cut a 10 mm hole through the plate, in
|
|
FreeCAD 0.21 and 1.0 alike:
|
|
|
|
```json
|
|
{
|
|
"name": "cut-hole",
|
|
"prompt": "Cut a 10 mm hole through the middle of the plate and save it.",
|
|
"checks": [
|
|
{
|
|
"id": "cut",
|
|
"kind": "value_equals",
|
|
"file": "Documents/plate.FCStd",
|
|
"pointer": "/entries/[name=Document.xml]/view/root/children/[name=Objects]/children/[name=Object][@type=Part::Cut]/attributes/type",
|
|
"value": "Part::Cut"
|
|
},
|
|
{
|
|
"id": "radius",
|
|
"kind": "value_equals",
|
|
"file": "Documents/plate.FCStd",
|
|
"pointer": "/entries/[name=Document.xml]/view/root/children/[name=ObjectData]/children/[name=Object][@name=Hole]/children/[name=Properties]/children/[name=Property][@name=Radius]/children/[name=Float]/attributes/value",
|
|
"value": 5,
|
|
"tolerance": 0.01
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
Before the edit the archive has no `Part::Cut` and no `Hole`, so both checks
|
|
fail on the seed; after it, `Radius` is `"5.0000000000000000"` and both pass.
|
|
A check decided by what a view leaves out depends, in an archive, on the entry
|
|
its pointer goes into: it fails when that entry's view is missing or
|
|
truncated, or when the entry is not listed and the listing is truncated. A
|
|
selector must see every member to know it names one, so a check whose pointer
|
|
searches an array the view may hold only part of fails as undecided: any array
|
|
of a truncated XML view, of a truncated entry's view, or the listing when it
|
|
is cut. A position is decided on what the view holds. Like a file, an entry
|
|
that has no view (binary, not read, or a database or archive inside the
|
|
archive) fails every check into its `view`; its `null` there is no value, while
|
|
an entry holding the JSON document `null` reads as `null`. Checks on its `name`,
|
|
`size`, `sha256` and `format` still read.
|
|
|
|
XML that uses namespaces is named as the document spells it, in both
|
|
evaluators: an element or attribute by its prefixed name, such as `text:p`
|
|
or `inkscape:label`, an element in a default namespace by its bare name, such
|
|
as `svg`, and namespace declarations as the attributes `xmlns` and
|
|
`xmlns:PREFIX`. Text is a string among an element's `children`. These checks
|
|
hold a poster's headline in an Inkscape SVG and a date in a LibreOffice Writer
|
|
document:
|
|
|
|
```json
|
|
[
|
|
{
|
|
"id": "headline",
|
|
"kind": "value_equals",
|
|
"file": "Pictures/poster.svg",
|
|
"pointer": "/root/children/[name=g][@inkscape:label=Layer 1]/children/[name=text][@id=headline]/children/[name=tspan]/children/0",
|
|
"value": "Summer Bake Sale"
|
|
},
|
|
{
|
|
"id": "date",
|
|
"kind": "value_equals",
|
|
"file": "Documents/flyer.odt",
|
|
"pointer": "/entries/[name=content.xml]/view/root/children/[name=office:body]/children/[name=office:text]/children/[name=text:p][@text:style-name=Subtitle]/children/0",
|
|
"value": "Monday 24 November"
|
|
}
|
|
]
|
|
```
|
|
|
|
Inkscape writes a line of text as a `tspan` inside its `text` element, and
|
|
Writer keeps a paragraph's style in `text:style-name`. A prefix is the one the
|
|
document uses, so a check names the prefixes the app writes.
|
|
|
|
## Whole text
|
|
|
|
A text node is only the stretch of text between two pieces of markup, and
|
|
apps split a paragraph into runs as they save it. AbiWord wraps text typed
|
|
into a reopened document in `c` elements, so changing a time to `3:30` saves
|
|
`<p>The team meeting starts at <c>3</c>:<c>30</c> in room 4.</p>`; Writer,
|
|
which records revision IDs by default, writes text typed in one session as
|
|
`text:span` runs. A `value_equals` on the paragraph's `children/0` then reads
|
|
`"The team meeting starts at "` and fails. `text_equals` and `text_contains`
|
|
read the element's whole text instead:
|
|
|
|
```json
|
|
{
|
|
"id": "time",
|
|
"kind": "text_equals",
|
|
"file": "Documents/meeting.abw",
|
|
"pointer": "/root/children/[name=section]/children/[name=p][@xid=3]",
|
|
"value": "The team meeting starts at 3:30 in room 4."
|
|
}
|
|
```
|
|
|
|
- The pointer names an element of an XML view, in a file or in an archive
|
|
entry's `view`, or a text node or attribute value in one, whose text is its
|
|
value. Another format, a pointer into an archive outside an XML entry's
|
|
view, or any other value fails, an element's `name` and the `doctype`'s
|
|
fields included: they are strings of the view, not text of the document.
|
|
- An element's text is XPath's `string()` of what the view keeps: every text
|
|
node in it and in its descendants, CDATA included, in document order, with
|
|
nothing between them. The view drops whitespace-only text between child
|
|
elements only in an element that holds no other text, outside
|
|
`xml:space="preserve"`, so the space between two runs of a paragraph is
|
|
part of its text. Elements that stand for characters, such as Writer's
|
|
`text:s`, `text:tab` and `text:line-break`, add none.
|
|
- `text_equals` passes when the text equals `value` exactly, case and all;
|
|
`text_contains` when it holds `value`, as a time within a sentence, so the
|
|
check holds whatever the rest of the sentence says.
|
|
- `normalize_whitespace: true` collapses each run of XML whitespace (space,
|
|
tab, carriage return, line feed) in both the text and `value` to one space
|
|
and trims both ends before comparing, as XPath's `normalize-space()` does,
|
|
for an app that reflows text or a value written across lines. A no-break
|
|
space is not whitespace there.
|
|
|
|
## Validation and export
|
|
|
|
`rea export-desktop-application <capture> <directory> --task <task.json>`
|
|
evaluates the checks on the capture's state view: on each file as it was at
|
|
the seed checkpoint, which is the seed, as the bundle ships it with the
|
|
capture host's name and user removed, and at the end checkpoint, the
|
|
captured end state. Those are the capture's first and last checkpoints unless
|
|
`--seed-checkpoint` and `--end-checkpoint` name others.
|
|
It refuses a task whose every check passes on the seed, and a check that fails
|
|
on the end state, with that check's reason. A capture yields one task, so the
|
|
option is taken once. The result's `task` names the task, its number of checks
|
|
and those that fail on the seed. A refused task writes no bundle.
|
|
|
|
The bundle then holds, besides the app and `tasks/smoke.json`:
|
|
|
|
| Path | Contents |
|
|
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
| `tasks/<name>.json` | Deploy the app, deploy the agent and its skills, record the screen, prompt the agent, run the verifier, stop recording |
|
|
| `artifacts/<name>-check/verify.py` | Reads the app's `data/get` through the computer and evaluates the checks, one result per check |
|
|
| `evals/<env>.toml` | The task, so a run of the bundle runs it; the smoke task runs with `--task smoke` |
|
|
|
|
The verifier finds the app among the computer's child environments, calls its
|
|
`data/get` under the run's grant of the computer's control token, and reads
|
|
the `state` data part. It raises when the app cannot be reached, so the run
|
|
fails rather than scoring the agent. The README lists the task, its prompt, its
|
|
checks and the recipe below.
|
|
|
|
## Solve rate
|
|
|
|
REA does not run agents. To measure how often an agent solves the task, run it
|
|
k times, three by default, and count the runs whose summary line says
|
|
`passed`:
|
|
|
|
```sh
|
|
for run in 1 2 3; do
|
|
agent-env run ./mousepad-bundle --task add-milk --agent claude-code --model MODEL
|
|
done
|
|
```
|
|
|
|
The agent defaults to Claude Code and the model is the caller's choice (Claude
|
|
Sonnet 5.5 is a reasonable reference). A run reported as `scored below 1`
|
|
failed the checks and counts as a miss; one reported as `failed` did not finish,
|
|
as when the computer or the app could not be reached, and is run again rather
|
|
than counted. Each run's instance keeps the verifier's results per check under
|
|
`verifications`.
|