Files
software-mansion__argent/packages/tool-server/test/run-sequence-observation-gate.test.ts
Ignacy Łątka 1409195138 test(run-sequence): pin the one observation a sequence returns (#934)
## What

`run-sequence` tells agents:

> One screenshot is captured automatically after the whole sequence (not
per step)

The MCP-side half of that promise is already pinned — argent-mcp's
auto-screenshot tests name `run-sequence` explicitly, twice. The
tool-server half is pinned by nothing: this adds the gate that pins it,
plus the renderer-side pin in argent-mcp. **No behavior change.**

## Why it is unpinned

The steps stay silent only because no tool `ALLOWED_TOOLS` admits
returns an image or an artifact handle. Nothing checks:

- which tools the allow-list admits,
- that every admitted name still resolves to a registered tool (a stale
name still reads as allowed and stays advertised in the description, but
every step naming it dies on `ToolNotFoundError` and halts the
sequence),
- that none of them declares `outputHint: "image"`.

Admitting a frame-producing tool would go unnoticed: a sequence result
reaches `toMcpContent` whole, and its `materializeArtifacts` walk
collects handles from anywhere inside it, `steps[].result` included,
appending one image block per handle. That walk recurses into arrays
with `Promise.all`, so those frames arrive in **completion order, not
step order** — the agent could not even tell which one is the screen the
sequence ended on.

Both halves are verified against the code, not assumed:

- Putting an artifact handle in one step's result makes `toMcpContent`
emit an image block for it, confirming the walk reaches
`steps[].result`.
- Materializing three step handles whose fetches complete fastest-last
yields `[s2, s1, s0]` — exactly reversed from step order.

## What is gated

**tool-server** — `test/run-sequence-observation-gate.test.ts`, against
the real registry (`createRegistry()`, no device). `ALLOWED_TOOLS` is
exported for it:

- the allow-list admits exactly the reviewed set of 14 step tools
- every name still resolves to a registered tool
- none of them declares `outputHint: "image"`

**argent-mcp** — `test/content.test.ts`:

- a multi-step sequence result renders with no frame of its own, against
a `fetchImpl` that resolves and is asserted never to be called (without
a resolving fetch the assertion would hold equally for a sequence whose
step frames merely failed to download, since `materializeArtifacts`
swallows a failed download and renders the handle as nothing)
- the positive control that pin rests on: a handle on `steps[].result`
does surface as an image block, so the walk ceasing to reach steps
cannot turn the pin vacuous

The membership of `run-sequence` in `AUTO_SCREENSHOT_TOOLS` was already
pinned by existing assertions and needed no new test.

## Verification

Every test was mutation-checked — each fails on the broken code, not
just passes on the good:

| Mutation | Fails |
| --- | --- |
| add `screenshot` to `ALLOWED_TOOLS` | reviewed-set + `outputHint`
checks |
| rename an allow-list entry the registry lost | reviewed-set +
registration checks |
| give a step result an image artifact handle | the frameless-sequence
check, on both its fetch-call and its image-block assertion |
| stop `materializeArtifacts` recursing into arrays | the
step-carried-handle check, and nothing else in the file |

Locally green: `prettier --check`, `tsc --build`, `typecheck:tests` for
both touched packages, the full argent-mcp suite (82 tests), and the
gate test (3 tests).

## Scope note

The allow-list is spelled out rather than counted, so adding a step tool
means answering the question the file exists to ask — does it put a
frame of its own in front of the agent? The gate deliberately sits on
the allow-list rather than on the renderer: a tool that would break the
guarantee is caught before it can be dispatched as a step, rather than
having its frames filtered afterwards.

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-08-31 20:51:21 +02:00

71 lines
2.8 KiB
TypeScript

/**
* Gate on the guarantee run-sequence advertises to agents: "One screenshot is
* captured automatically after the whole sequence (not per step)".
*
* Nothing in run-sequence enforces it. The capture is appended once, by the MCP
* layer, because `run-sequence` is in that layer's AUTO_SCREENSHOT_TOOLS
* (pinned in argent-mcp's auto-screenshot test). The steps stay silent only
* because no tool the allow-list admits returns an image or an artifact handle.
*
* A step that returned one would be inlined: the sequence result reaches
* `toMcpContent` whole, and its `materializeArtifacts` walk collects handles
* from anywhere inside it — `steps[].result` included — appending one image
* block per handle. That walk recurses into arrays with `Promise.all`, so those
* frames arrive in completion order rather than step order, leaving the agent
* unable to tell which is the screen the sequence ended on. Hence the gate sits
* on the allow-list: such a tool has to be caught before it can be a step.
*/
import { describe, it, expect } from "vitest";
import { ALLOWED_TOOLS } from "../src/tools/run-sequence";
import { createRegistry } from "../src/utils/setup-registry";
// Spelled out rather than counted: adding an entry means answering the question
// this file exists to ask — does that tool put a frame of its own in front of
// the agent?
const EXPECTED_ALLOWED_TOOLS = [
"await-ui-element",
"button",
"gesture-custom",
"gesture-drag",
"gesture-pinch",
"gesture-rotate",
"gesture-scroll",
"gesture-swipe",
"gesture-tap",
"keyboard",
"paste",
"rotate",
"shake",
"tv-remote",
];
describe("run-sequence — one observation per sequence", () => {
it("admits exactly the reviewed set of step tools", () => {
expect([...ALLOWED_TOOLS].sort()).toEqual(EXPECTED_ALLOWED_TOOLS);
});
it("names only tools that are actually registered", () => {
const registry = createRegistry();
for (const id of ALLOWED_TOOLS) {
// A name the registry no longer answers to still reads as allowed, and
// the description still advertises it, but every step naming it dies on
// ToolNotFoundError and halts the sequence. It also leaves the check
// below no definition to inspect.
expect(
registry.getTool(id),
`${id} is allowed in run-sequence but not registered`
).toBeDefined();
}
});
it("admits no tool that renders as a frame of its own", () => {
const registry = createRegistry();
for (const id of ALLOWED_TOOLS) {
// `outputHint: "image"` declares a result that IS a picture — screenshot
// alone today. Such a tool also hands back the artifact behind it, so
// admitting one puts a mid-sequence frame in front of the agent.
expect(registry.getTool(id)?.outputHint, `${id}.outputHint`).not.toBe("image");
}
});
});