Files
rea/src/server/environment/serveEnvironmentState.test.ts
T
76dbc32f39 fix(environment): bound state reads and retain cleanup ownership (#6158)
* feat(environment): extract the initial state a capture's reads observed

Add the environment-state analysis view. From the parents the environment
manifest accepts, it extracts the data the capture's reads observed before
the first related write: entity types with an identity field (proved by a
path parameter or inferred from an identity name), entities with their
recorded values and provenance, documents for written non-entity paths, and
a render plan per read tool that is kept only when it reproduces the tool's
latest initial recordings from the state; every other read tool is a
recorded view with the reason. The Evidence is always inferred.

Response-body decoding moves to a domain owner shared with replay, exact
JSON parsing moves to the owner of the exact-number policy, the manifest and
state views share parent admission and record grouping, and
projectAnalysisView takes its accumulators as one object.

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

* fix(environment): bound state at redacted writes; infer identity only from items

A scenario write whose URL was redacted has no request line, so it was
dropped and reads after it counted as initial state. Such a successful
write now joins the timeline with an empty path, whose root relates to
every type, so it ends every type's state; a limitation counts them.

A list whose recordings were all empty passed the "every listed item
carries it" check vacuously and published an identity field nothing
showed; it now needs at least one item, else the view stays recorded
as no-identity.

A value that differed between two request lines of one item tool, such
as a query variant, was called volatile, and the gate then substituted
each recording's own value; only repeats of one request line now show
volatility, so differing lines go through fields-differ.

List membership is compared only while a list can still be the whole
collection, and merged entities are indexed by type and collection once,
so paged captures no longer cost pages times entities (8,000 pages of
40,000 entities: 5.7 s to 0.23 s).

Docs and the tool contract say scenario state keeps the capture's
redaction markers and how redacted writes bound it.

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

* fix(environment): keep first sightings, one list pointer and linear field unions

Entities took the order of the first plan observed, and item plans run
before list plans, so a complete list read before its detail reads came
out reordered and lost "all" membership; each entity now keeps its
earliest recorded sighting.

A list plan reused the first recording's pointer for later recordings,
so a later response that was itself an entity wrapping a list was read
as that list; every recording must now locate the same list.

The field union scanned its ordered list for every key, quadratic in an
object's width; a set now answers membership.

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

* fix(environment): keep post-write values and partial lists out of the initial state

A value that changed between repeats of one request line was named
volatile whenever no write under the type's own root lay between, so a
write under another root (for example POST /api/dashboards/db before a
second GET /api/search) left its effect in the initial state. Any
recorded write between the two observations now leaves every tool of the
type recorded with reason fields-differ instead.

A list was published with membership "all" even when a total or count
envelope member exceeded the number it listed. Such a counter now makes
the membership "recorded".

Also drop a stale doc comment on ObservedEntity.order.

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

* docs(environment): scope item-shaped and deleted written paths out of documents

State documents are held only for list-shaped paths a POST, PUT or PATCH
tool writes. Reads of item-shaped written paths and of paths that are only
deleted stay recorded views; the guide now says so and the spec defers them
to a later increment of #4933.

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

* fix(environment): count writes in flight when a value changes between reads

The volatility check compared write start positions with the two reads,
so a write already in flight when the earlier read started was missed and
its effect was named volatile. A write now counts when its interval
overlaps the span from the earlier read's start to the later read's end.

The check also ran once per changed field and scanned every write. It now
runs once per pair of observations, answered by a binary search over the
writes in timeline order and the latest end among each prefix.

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

* fix(environment): catch cross-tool changes across writes; read only item totals

A value that changed across a write was caught only between repeats of
one tool's request line, so a list read before a write and an item read
after it put the post-write value into the initial state. Changes are
now classified per entity in timeline order after observation: across
any recorded write, a change between repeats of a line or between two
lines of the type rewrites the type (every tool recorded as
fields-differ); with no write between, a repeat's change is volatile.
The classification lives in environmentStateMerge.ts.

Any numeric member whose name contained "total" or "count" counted as
an item total, so total_pages, account_id or page_count made complete
lists partial. Item totals are now members whose name tokens include
total or count and none of page, pages or per; a string of digits counts
as a number; and a set has_more, next or next-page token member also
marks the list partial.

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

* fix(environment): keep post-write observations out of initial state

* fix(environment): retain field provenance and bound state inference work

* feat(environment): serve tool calls from the environment state

`environment-serve --state <state.json>` serves an environment from the
initial state an environment-state view extracted. Read tools whose views
are state-backed answer from a live state store: item views render the held
entity into the recorded envelope, list views render the collection or the
recorded members, and document views return the held value; a missing entity
or one lacking a required field is refused, and requests the state cannot
answer fall back to their recordings with the reason. The /agentenv data
plane implements data/reset, data/add (a rea-state/v1 document as a data
part, base64 bytes, a file:// path or an https URL, validated and replaced
atomically) and data/get, and the card advertises those operations and an
intake declaration. Stage 1 is read-only: write tools are not served in
state mode. Without --state nothing changes.

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

* docs(environment): record verification after merging the state view's review fixes

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

* feat(environment)!: serve the environment state by default

environment-serve derives the environment state at startup by running the
environment-state view in-process on the capture with the manifest's
template rules, so an environment comes with its state and a user supplies
nothing extra. The derived Evidence is the same one inspect-analysis-view
produces by hand. --state is removed; --replay-only serves exactly the
stateless replay of main. When the state cannot be derived, or its catalog
does not fit the discovery budget, the server starts in replay mode, logs
the reason and reports it in the stop summary's `state`. Exported bundles
serve the state by default too: export derives it the same way, so the
verifier's tool count and checked answer match what is served, and the
README describes the served mode.

BREAKING CHANGE: environment-serve answers read tools from the derived
state and does not serve write tools unless --replay-only is given; the
stop summary reports `state` instead of `state_evidence_id`.

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

* fix(environment): bound data-plane replies, keep batch order, cancel intake on stop

- One /agentenv response is held to 64 MiB: replies are measured as they
  are produced and one past the budget becomes a -32000 error stating it.
- Every message of a batch enters the arrival-order queue on arrival, so
  another request's reset never lands between them.
- Stopping the server, or a caller leaving, cancels the active download
  and answers queued calls `cancelled` without running them.
- file:// URIs name only this host: another host or a network share is
  refused before anything is opened.
- A loaded view value may not give an entity another identity.
- List answers refuse a member lacking a field every recording carried,
  as item answers do.

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

* fix(environment): keep bodies too deep for the state's nesting out of the state

A recorded body at the JSON depth limit was accepted by extraction, then
nested a few levels deeper as a document or entity value, so sealing the
state Evidence threw and environment-serve and environment-export stopped
instead of falling back. stateBodyJson, the one rule extraction and
serving share, now treats a body nesting deeper than the limit minus 16
levels as unusable, so its tool stays a recorded view.

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

* fix(environment): measure data-plane replies before building them

83fcc739 bounded one /agentenv response to 64 MiB, but it still
serialized each reply whole before measuring it, so a batch of data/get
calls on a large state built every refused reply in full only to drop
it. Each reply is now measured in bounded JSON parts (the shared
jsonParts encoder), its parts kept only while it fits; a reply past the
budget is counted for its exact reply_bytes but never assembled. A test
checks that a refused read is never handed to JSON.stringify whole and
that its reply_bytes is exact.

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

* fix(environment): release batch replies as they are encoded; judge exact numbers by value

answerBatch kept every settled reply in its pending array until the
batch ended, so a batch alternating data/add and data/get held each
loaded state alive; each reply now leaves the queue as it is encoded.

The exact-number policy judged a number by its recorded spelling, so a
state loaded with 1e20 came back from data/get as
100000000000000000000, which data/add refused, breaking the promise
that data/get output loads back unchanged. A number is exact only when
both its recorded spelling and the one serialization writes are safe,
so integers from 2^53 below 1e21 are refused in any spelling; replay
keeps such bodies as recorded text. A domain test checks that every
accepted number is accepted again once written back.

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

* fix(environment): keep room for refusals and framing in the reply budget

The response budget counted accepted replies only: a budget error that
replaced a reply, and a batch's brackets and commas, were not charged,
so near 64 MiB later refusals pushed the body past the advertised
limit. Every reply still to come now keeps room for its own budget
error, and a batch keeps room for its framing, so a refusal always fits
and the body never passes the budget. Reply ids and which messages are
answered come from one place shared by the replies and the reserve.

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

* fix(environment): refuse a loaded state that data/get would write past the intake limit

data/add measured only the document it was given, while data/get
writes the state back with the members a document may omit (types, the
state Evidence id, view fields and record citations). A document just
under 32 MiB could therefore load and be reported larger than any intake
accepts, breaking the data/get then data/add round trip. replace now
measures the state as data/get writes it, part by part, and refuses it
past the limit, leaving the state unchanged. The limit moves to the
state document module, so the application store can read it; the
intake re-exports it.

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

* fix(environment): keep merged export checks on the shared test graph

Main's export tests installed module mocks for the whole services
project, which reuses one module graph. A tarball read failure then
missed its importer, and a one-shot heap mock could be taken by another
file. Read those through bindings the tests replace for one case.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(environment): keep a state file whose close failed owned until it closes

A file:// data/add that could not close its descriptor dropped the handle,
and an admission failure whose cleanup close failed dropped its owner, so
nothing could retry the close or report it.

The read now runs through withOwnedRegularFile, and a failed close is
retained in a server-lifetime ArtifactResourceScope. The next file read
retries it first and answers -32000 while it is still open; inline and
https documents still load. Closing the served environment retries it again
and rejects with the unfinished close.

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

* fix(environment): attempt all state cleanup on close

* fix(environment): bound wide state JSON traversal

* fix(environment): scope cached state reply sizes to requests

* fix(domain): preserve JSON depth diagnostic guidance

---------

Co-authored-by: Edgar Arakelyan <edgar.arakelyan@scale.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: gensokyo-dev <gensokyo-dev@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-22 06:06:36 +08:00

1051 lines
36 KiB
TypeScript

import {
Client,
StreamableHTTPClientTransport,
} from "@modelcontextprotocol/client";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
const closeFailureControl = vi.hoisted(() => ({
failure: null as Error | null,
}));
vi.mock("../http/serveStreamableHttp.js", async (importOriginal) => {
const original =
await importOriginal<typeof import("../http/serveStreamableHttp.js")>();
return {
...original,
serveStreamableHttp: async (
...args: Parameters<typeof original.serveStreamableHttp>
) => {
const served = await original.serveStreamableHttp(...args);
return {
...served,
close: async () => {
await served.close();
const failure = closeFailureControl.failure;
closeFailureControl.failure = null;
if (failure !== null) throw failure;
},
};
},
};
});
import { timedCaptureEvidence } from "../../../tests/fixtures/analysisView.js";
import {
environmentStateServeFixture,
environmentStateServeInputs,
} from "../../../tests/fixtures/environmentServe.js";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { EnvironmentStateStore } from "../../application/environment/environmentStateStore.js";
import { ArtifactResourceRetryFailure } from "../../artifacts/ArtifactResourceScope.js";
import { OwnedFileHandle } from "../../filesystem/OwnedFileHandle.js";
import {
environmentCard,
STATE_INTAKE_EXTENSION,
STATE_OPERATIONS,
} from "../../application/environment/environmentTools.js";
import { stateDataPlane } from "./environmentDataPlane.js";
import {
ENVIRONMENT_CARD_PATH,
ENVIRONMENT_RPC_PATH,
environmentRoutes,
MAX_RPC_RESPONSE_BYTES,
serveEnvironment,
type ServedEnvironment,
type ServeEnvironmentOptions,
} from "./serveEnvironment.js";
import {
DEFAULT_STATE_INTAKE,
STATE_INTAKE_LIMIT_BYTES,
type StateIntake,
} from "./stateIntake.js";
const { source, state } = environmentStateServeInputs();
/** The initial state as a rea-state/v1 document with user 2 renamed. */
const renamedDocument = () => {
const document = structuredClone(new EnvironmentStateStore(state).snapshot());
return {
rea_state: {
...document.rea_state,
entities: document.rea_state.entities.map((entity) =>
entity.type === "/api/users" && entity.id === "2"
? { ...entity, fields: { ...entity.fields, login: "renamed" } }
: entity,
),
},
};
};
/** A data plane over a fresh store, posting raw bodies to its route; `stopped` is the server's stop signal. */
const dataPlane = (
intake: Partial<StateIntake> = {},
stopped?: AbortSignal,
) => {
const store = new EnvironmentStateStore(state);
const route = environmentRoutes(
environmentCard("grafana-example", [], "state"),
stateDataPlane(store, { ...DEFAULT_STATE_INTAKE, ...intake }, stopped),
).find((entry) => entry.path === ENVIRONMENT_RPC_PATH);
if (route === undefined) throw new Error("No data-plane route");
const post = async (body: unknown, signal?: AbortSignal) => {
const response = await route.handle(
new Request(`http://127.0.0.1${ENVIRONMENT_RPC_PATH}`, {
method: "POST",
body: typeof body === "string" ? body : JSON.stringify(body),
...(signal === undefined ? {} : { signal }),
}),
);
return {
status: response.status,
json: response.status === 202 ? undefined : await response.json(),
};
};
const call = async (method: string, params?: unknown) =>
(
await post({
jsonrpc: "2.0",
id: 1,
method,
...(params === undefined ? {} : { params }),
})
).json;
const add = (part: unknown) => call("data/add", { parts: [part] });
return { store, post, call, add };
};
const loginOf = (store: EnvironmentStateStore) =>
store.entity("/api/users", "/api/users", "2")?.fields.login;
/** The `file://` uri of a fresh file holding the renamed document. */
const renamedStateUri = async (): Promise<string> => {
const root = await createTestTempDirectory("rea-environment-state-close-");
const path = join(root, "state.json");
await writeFile(path, JSON.stringify(renamedDocument()));
return pathToFileURL(path).href;
};
/** Fail every close of the first state file opened while `failing()` holds, recording its owner; restored when the test ends. */
const failFirstFileClose = (failing: () => boolean = () => true) => {
const owners: OwnedFileHandle[] = [];
const close = OwnedFileHandle.prototype.close;
const spy = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(async function (this: OwnedFileHandle) {
if (owners.length === 0) owners.push(this);
if (this === owners[0] && failing())
throw new Error("injected state file close failure");
await close.call(this);
});
onTestFinished(async () => {
spy.mockRestore();
for (const owner of owners) if (owner.handle.fd >= 0) await owner.close();
});
return owners;
};
/** A `data/add` message loading the document an https uri names. */
const addByUri = (
id: number,
uri = "https://state.example.test/state.json",
) => ({
jsonrpc: "2.0",
id,
method: "data/add",
params: { parts: [{ kind: "file", file: { uri } }] },
});
/** A fetcher that reports each call and the signal it was given, then answers only when `answer` settles. */
const heldFetcher = (answer: () => Promise<Response>) => {
const signals: AbortSignal[] = [];
let started: () => void = () => undefined;
const fetching = new Promise<void>((resolve) => {
started = resolve;
});
const fetchState = (_: URL, signal: AbortSignal) => {
signals.push(signal);
started();
return answer();
};
return { signals, fetching, fetchState };
};
describe("state data plane", () => {
it("answers data/get, data/add and data/reset in the AgentEnv shapes", async () => {
const plane = dataPlane();
await expect(plane.call("data/get")).resolves.toEqual({
jsonrpc: "2.0",
id: 1,
result: {
parts: [
{
kind: "data",
data: JSON.parse(JSON.stringify(plane.store.snapshot())),
},
],
},
});
await expect(
plane.add({ kind: "data", data: renamedDocument() }),
).resolves.toEqual({ jsonrpc: "2.0", id: 1, result: {} });
expect(loginOf(plane.store)).toBe("renamed");
await expect(plane.call("data/reset")).resolves.toEqual({
jsonrpc: "2.0",
id: 1,
result: {},
});
expect(loginOf(plane.store)).toBe("probe");
await expect(plane.call("data/remove")).resolves.toMatchObject({
error: { code: -32601, message: "method not found: data/remove" },
});
});
it("refuses anything but exactly one data or file part as invalid params", async () => {
const plane = dataPlane();
for (const params of [
{ parts: [] },
undefined,
{
parts: [
{ kind: "data", data: {} },
{ kind: "data", data: {} },
],
},
{ parts: [{ kind: "text", text: "{}" }] },
{ parts: [{ kind: "file", file: { bytes: "e30=", uri: "file:///x" } }] },
{ parts: [{ kind: "file", file: { bytes: "not base64!" } }] },
{
parts: [
{ kind: "file", file: { bytes: "e30=", mimeType: "text/plain" } },
],
},
{ parts: [{ kind: "file", file: { uri: "http://example.test/x" } }] },
])
await expect(plane.call("data/add", params)).resolves.toMatchObject({
error: { code: -32602 },
});
expect(loginOf(plane.store)).toBe("probe");
});
it("names the first invalid member by JSON pointer and changes nothing", async () => {
const plane = dataPlane();
const document = renamedDocument();
const entities = document.rea_state.entities.map((entity, index) =>
index === 3 ? { ...entity, collection: "/api/elsewhere" } : entity,
);
const reply = await plane.add({
kind: "data",
data: { rea_state: { ...document.rea_state, entities } },
});
expect(reply).toMatchObject({
error: {
code: -32602,
message: expect.stringContaining("/rea_state/entities/3/collection"),
data: { path: "/rea_state/entities/3/collection" },
},
});
expect(loginOf(plane.store)).toBe("probe");
// Values parsing would alter are refused where they sit, never rounded or turned into a prototype.
const body = JSON.stringify({
jsonrpc: "2.0",
id: 2,
method: "data/add",
params: { parts: [{ kind: "data", data: renamedDocument() }] },
});
for (const altered of [
'"login":9007199254740993',
// Exact in this spelling, but data/get would write it as digits it could not load again.
'"login":1e20',
'"__proto__":{"polluted":true},"login":"renamed"',
]) {
const reply = await plane.post(
body.replace('"login":"renamed"', altered),
);
expect(reply.json).toMatchObject({
error: {
code: -32602,
data: { path: expect.stringMatching(/\/fields$/u) },
},
});
expect(JSON.stringify(reply.json)).not.toContain("polluted");
}
expect(loginOf(plane.store)).toBe("probe");
});
});
describe("state data plane intake", () => {
it("loads file parts from bytes and from a file uri", async () => {
const plane = dataPlane();
const text = JSON.stringify(renamedDocument());
await expect(
plane.add({
kind: "file",
file: {
bytes: Buffer.from(text).toString("base64"),
mimeType: "application/json; charset=utf-8",
},
}),
).resolves.toMatchObject({ result: {} });
expect(loginOf(plane.store)).toBe("renamed");
plane.store.reset();
const root = await createTestTempDirectory("rea-environment-state-file-");
const path = join(root, "state.json");
await writeFile(path, text);
await expect(
plane.add({ kind: "file", file: { uri: pathToFileURL(path).href } }),
).resolves.toMatchObject({ result: {} });
expect(loginOf(plane.store)).toBe("renamed");
await mkdir(join(root, "directory"));
for (const [name, reason] of [
["missing.json", "not-found"],
["directory", "not-regular-file"],
] as const) {
const uri = pathToFileURL(join(root, name)).href;
const reply = await plane.add({ kind: "file", file: { uri } });
expect(reply).toMatchObject({
error: { code: -32602, data: { reason } },
});
expect(JSON.stringify(reply)).not.toContain(root);
}
});
it("downloads https uris through the injected fetcher within its limits, never echoing the uri", async () => {
const requested: string[] = [];
let answer = (): Response =>
new Response(JSON.stringify(renamedDocument()));
const plane = dataPlane({
fetchState: (url, signal) => {
requested.push(url.href);
expect(signal).toBeInstanceOf(AbortSignal);
return Promise.resolve(answer());
},
});
const uri = "https://state.example.test/state.json?token=secret-token";
const add = () => plane.add({ kind: "file", file: { uri } });
await expect(add()).resolves.toMatchObject({ result: {} });
expect(requested).toEqual([uri]);
expect(loginOf(plane.store)).toBe("renamed");
answer = () => new Response("missing", { status: 404 });
const missing = await add();
expect(missing).toMatchObject({
error: { code: -32000, data: { reason: "http-status" } },
});
expect(JSON.stringify(missing)).not.toContain("secret-token");
answer = () =>
new Response("{}", {
headers: { "content-length": String(STATE_INTAKE_LIMIT_BYTES + 1) },
});
await expect(add()).resolves.toMatchObject({ error: { code: -32602 } });
const chunk = new Uint8Array(1024 * 1024);
answer = () => {
let sent = 0;
return new Response(
new ReadableStream<Uint8Array>({
pull(controller) {
sent += chunk.length;
controller.enqueue(chunk);
if (sent > STATE_INTAKE_LIMIT_BYTES + chunk.length)
controller.close();
},
}),
);
};
await expect(add()).resolves.toMatchObject({
error: { code: -32602, message: expect.stringContaining("exceeds") },
});
answer = () => new Response("not json");
await expect(add()).resolves.toMatchObject({ error: { code: -32602 } });
});
it("opens file uris only on this host, refusing another host or a network share before opening anything", async () => {
const plane = dataPlane();
for (const uri of [
"file://remote.example.test/share/state.json",
"file:////remote.example.test/share/state.json",
]) {
const reply = await plane.add({ kind: "file", file: { uri } });
expect(reply).toEqual({
jsonrpc: "2.0",
id: 1,
error: {
code: -32602,
message:
"invalid params: a file:// uri names a path on this host, not a network share",
},
});
}
const root = await createTestTempDirectory("rea-environment-state-host-");
const path = join(root, "state.json");
await writeFile(path, JSON.stringify(renamedDocument()));
await expect(
plane.add({
kind: "file",
file: { uri: `file://localhost${pathToFileURL(path).pathname}` },
}),
).resolves.toMatchObject({ result: {} });
expect(loginOf(plane.store)).toBe("renamed");
});
it("runs batches in order and runs notifications without replying", async () => {
const plane = dataPlane();
const batch = await plane.post([
{
jsonrpc: "2.0",
id: 1,
method: "data/add",
params: { parts: [{ kind: "data", data: renamedDocument() }] },
},
{ jsonrpc: "2.0", id: 2, method: "data/get" },
]);
expect(batch.json).toMatchObject([
{ id: 1, result: {} },
{ id: 2, result: { parts: [{ kind: "data" }] } },
]);
expect(JSON.stringify(batch.json)).toContain('"login":"renamed"');
const notified = await plane.post({ jsonrpc: "2.0", method: "data/reset" });
expect(notified.status).toBe(202);
expect(loginOf(plane.store)).toBe("probe");
});
});
describe("state data plane file closes", () => {
it("keeps a state file whose close failed and closes it before the next file read", async () => {
const plane = dataPlane();
const uri = await renamedStateUri();
let failing = true;
const owners = failFirstFileClose(() => failing);
const failed = await plane.add({ kind: "file", file: { uri } });
expect(failed).toMatchObject({
error: {
code: -32000,
message: expect.stringContaining("could not be closed"),
},
});
expect(JSON.stringify(failed)).not.toContain(uri);
expect(loginOf(plane.store)).toBe("probe");
expect(owners[0]?.handle.fd).toBeGreaterThanOrEqual(0);
failing = false;
await expect(
plane.add({ kind: "file", file: { uri } }),
).resolves.toMatchObject({ result: {} });
expect(owners[0]?.handle.fd).toBe(-1);
expect(loginOf(plane.store)).toBe("renamed");
});
});
describe("state data plane timing and order", () => {
it("ends a download that outlives its time limit, whether or not the fetcher honours the signal", async () => {
const uri = "https://state.example.test/slow.json?token=secret-token";
const waitForAbort = (signal: AbortSignal) =>
new Promise<Response>((_, reject) => {
signal.addEventListener("abort", () => reject(signal.reason));
});
const stalledBody = () =>
Promise.resolve(
new Response(new ReadableStream<Uint8Array>({ pull: () => undefined })),
);
for (const fetchState of [
(_: URL, signal: AbortSignal) => waitForAbort(signal),
stalledBody,
() => new Promise<Response>(() => undefined),
]) {
const plane = dataPlane({ fetchState, timeoutMs: 20 });
const reply = await plane.add({ kind: "file", file: { uri } });
expect(reply).toMatchObject({
error: { code: -32000, data: { reason: "timeout" } },
});
expect(JSON.stringify(reply)).not.toContain("secret-token");
expect(loginOf(plane.store)).toBe("probe");
}
const failing = dataPlane({
fetchState: () => Promise.reject(new TypeError("fetch failed")),
});
await expect(
failing.add({ kind: "file", file: { uri } }),
).resolves.toMatchObject({
error: { code: -32000, data: { reason: "network" } },
});
});
it("applies data-plane calls from separate requests in arrival order", async () => {
let release: () => void = () => undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const plane = dataPlane({
fetchState: async () => {
await gate;
return new Response(JSON.stringify(renamedDocument()));
},
});
const slowAdd = plane.add({
kind: "file",
file: { uri: "https://state.example.test/state.json" },
});
const reset = plane.call("data/reset");
release();
await expect(slowAdd).resolves.toMatchObject({ result: {} });
await expect(reset).resolves.toMatchObject({ result: {} });
// The reset arrived after the add, so it wins even though the add finished loading later.
expect(loginOf(plane.store)).toBe("probe");
});
it("queues every message of a batch at arrival, so a later request's reset never lands between them", async () => {
let release: () => void = () => undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const held = heldFetcher(async () => {
await gate;
return new Response(JSON.stringify(renamedDocument()));
});
const plane = dataPlane({ fetchState: held.fetchState });
const batch = plane.post([
addByUri(1),
{ jsonrpc: "2.0", id: 2, method: "data/get" },
]);
await held.fetching;
const reset = plane.call("data/reset");
release();
const replies = (await batch).json;
expect(replies).toMatchObject([
{ id: 1, result: {} },
{ id: 2, result: { parts: [{ kind: "data" }] } },
]);
// The get arrived with the add, before the reset, so it reports the loaded state.
expect(JSON.stringify(replies)).toContain('"login":"renamed"');
await expect(reset).resolves.toMatchObject({ result: {} });
expect(loginOf(plane.store)).toBe("probe");
});
});
describe("state data plane cancellation", () => {
/** A download that never answers on its own and outlives any test. */
const stalled = () =>
heldFetcher(() => new Promise<Response>(() => undefined));
it("ends the running download and skips queued calls once the server stops", async () => {
const stopping = new AbortController();
const held = stalled();
const plane = dataPlane(
{ fetchState: held.fetchState, timeoutMs: 600_000 },
stopping.signal,
);
await plane.add({ kind: "data", data: renamedDocument() });
const batch = plane.post([
addByUri(1),
{ jsonrpc: "2.0", id: 2, method: "data/reset" },
]);
await held.fetching;
stopping.abort();
expect((await batch).json).toEqual([
{
jsonrpc: "2.0",
id: 1,
error: {
code: -32000,
message: "the state document could not be downloaded",
data: { reason: "cancelled" },
},
},
{
jsonrpc: "2.0",
id: 2,
error: {
code: -32000,
message: "the call was cancelled and left the state unchanged",
data: { reason: "cancelled" },
},
},
]);
expect(held.signals.map((signal) => signal.aborted)).toEqual([true]);
// The queued reset never ran, and nothing runs after the stop.
expect(loginOf(plane.store)).toBe("renamed");
await expect(plane.call("data/reset")).resolves.toMatchObject({
error: { data: { reason: "cancelled" } },
});
expect(loginOf(plane.store)).toBe("renamed");
});
it("ends a download whose caller left, leaving the state unchanged", async () => {
const held = stalled();
const plane = dataPlane({
fetchState: held.fetchState,
timeoutMs: 600_000,
});
const leaving = new AbortController();
const add = plane.post(addByUri(1), leaving.signal);
await held.fetching;
leaving.abort();
expect((await add).json).toMatchObject({
error: { code: -32000, data: { reason: "cancelled" } },
});
expect(held.signals[0]?.aborted).toBe(true);
expect(loginOf(plane.store)).toBe("probe");
// Other callers' calls still run.
await expect(
plane.add({ kind: "data", data: renamedDocument() }),
).resolves.toMatchObject({ result: {} });
});
});
describe("state data plane response budget", () => {
it("answers replies past the response budget with an error stating it instead of building the body", async () => {
const document = renamedDocument();
const large = {
rea_state: {
...document.rea_state,
entities: document.rea_state.entities.map((entity) =>
entity.type === "/api/users" && entity.id === "2"
? {
...entity,
fields: {
...entity.fields,
login: "x".repeat(Math.ceil(MAX_RPC_RESPONSE_BYTES / 3)),
},
}
: entity,
),
},
};
const root = await createTestTempDirectory("rea-environment-state-budget-");
const path = join(root, "large.json");
await writeFile(path, JSON.stringify(large));
const plane = dataPlane();
await expect(
plane.add({ kind: "file", file: { uri: pathToFileURL(path).href } }),
).resolves.toMatchObject({ result: {} });
const get = (id: number) => ({ jsonrpc: "2.0", id, method: "data/get" });
const batch = await plane.post([
get(1),
get(2),
get(3),
get(4),
{ jsonrpc: "2.0", id: 5, method: "data/reset" },
]);
// Two reads fit; the third and fourth would pass the budget, and the reset's small reply is kept.
expect(batch.json).toMatchObject([
{ id: 1, result: { parts: [{ kind: "data" }] } },
{ id: 2, result: { parts: [{ kind: "data" }] } },
{
id: 3,
error: {
code: -32000,
data: {
max_response_bytes: MAX_RPC_RESPONSE_BYTES,
reply_bytes: expect.any(Number),
},
},
},
{ id: 4, error: { code: -32000 } },
{ id: 5, result: {} },
]);
expect(Buffer.byteLength(JSON.stringify(batch.json))).toBeLessThanOrEqual(
MAX_RPC_RESPONSE_BYTES,
);
expect(loginOf(plane.store)).toBe("probe");
});
it("measures each reply before building it, so one past the budget is never serialized whole", async () => {
const document = renamedDocument();
const login = "x".repeat(Math.ceil(MAX_RPC_RESPONSE_BYTES / 3));
const large = {
rea_state: {
...document.rea_state,
entities: document.rea_state.entities.map((entity) =>
entity.type === "/api/users" && entity.id === "2"
? { ...entity, fields: { ...entity.fields, login } }
: entity,
),
},
};
const root = await createTestTempDirectory(
"rea-environment-state-measure-",
);
const path = join(root, "large.json");
await writeFile(path, JSON.stringify(large));
const plane = dataPlane();
await plane.add({ kind: "file", file: { uri: pathToFileURL(path).href } });
const reply = (id: number) => ({
jsonrpc: "2.0",
id,
result: { parts: [{ kind: "data", data: plane.store.snapshot() }] },
});
const replyBytes = Buffer.byteLength(JSON.stringify(reply(3)));
const stringify = vi.spyOn(JSON, "stringify");
try {
const batch = await plane.post(
[1, 2, 3].map((id) => ({ jsonrpc: "2.0", id, method: "data/get" })),
);
// A refused read is measured exactly, from bounded parts.
expect(batch.json).toMatchObject([
{ id: 1 },
{ id: 2 },
{
id: 3,
error: {
data: { reply_bytes: replyBytes },
},
},
]);
// No whole reply was handed to JSON.stringify by the server.
expect(
stringify.mock.calls.some(
([value]) =>
typeof value === "object" &&
value !== null &&
"result" in value &&
typeof (value as { result?: unknown }).result === "object" &&
(value as { id?: unknown }).id === 3,
),
).toBe(false);
} finally {
stringify.mockRestore();
}
});
});
describe("repeated state reads", () => {
it("reuses the encoded size of a state for refused repeated data/get calls", async () => {
const plane = dataPlane();
const document = renamedDocument();
const large = {
rea_state: {
...document.rea_state,
entities: document.rea_state.entities.map((entity) =>
entity.type === "/api/users" && entity.id === "2"
? {
...entity,
fields: { ...entity.fields, large: "x".repeat(1024 * 1024) },
}
: entity,
),
},
};
await plane.add({ kind: "data", data: large });
const fields = plane.store
.snapshot()
.rea_state.entities.find((entity) => entity.id === "2")?.fields;
if (fields === undefined) throw new Error("Expected the loaded entity");
const originalKeys = Object.keys;
let fieldEnumerations = 0;
const keys = vi.spyOn(Object, "keys").mockImplementation((value) => {
if (value === fields) fieldEnumerations += 1;
return originalKeys(value);
});
try {
const batch = await plane.post(
Array.from({ length: 100 }, (_, index) => ({
jsonrpc: "2.0",
id: index + 1,
method: "data/get",
})),
);
expect(
(batch.json as { error?: unknown }[]).filter((reply) => reply.error),
).not.toHaveLength(0);
expect(fieldEnumerations).toBeLessThan(65);
expect(Buffer.byteLength(JSON.stringify(batch.json))).toBeLessThanOrEqual(
MAX_RPC_RESPONSE_BYTES,
);
} finally {
keys.mockRestore();
}
});
});
describe("state data plane response budget reserve", () => {
it("keeps room for every later refusal and the batch's framing, so the body never passes the budget", async () => {
const document = renamedDocument();
const withLogin = (login: string) => ({
rea_state: {
...document.rea_state,
entities: document.rea_state.entities.map((entity) =>
entity.type === "/api/users" && entity.id === "2"
? { ...entity, fields: { ...entity.fields, login } }
: entity,
),
},
});
const plane = dataPlane();
await plane.add({ kind: "data", data: withLogin("") });
const base = Buffer.byteLength(
JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: { parts: [{ kind: "data", data: plane.store.snapshot() }] },
}),
);
// Three reads that alone fall 100 bytes short of the budget.
const login = "x".repeat(
Math.floor((MAX_RPC_RESPONSE_BYTES - 100) / 3) - base,
);
const root = await createTestTempDirectory(
"rea-environment-state-reserve-",
);
const path = join(root, "large.json");
await writeFile(path, JSON.stringify(withLogin(login)));
await plane.add({ kind: "file", file: { uri: pathToFileURL(path).href } });
const ids = Array.from({ length: 23 }, (_, index) => index + 1);
const batch = await plane.post(
ids.map((id) => ({ jsonrpc: "2.0", id, method: "data/get" })),
);
// The third read would leave no room for the twenty refusals after it.
expect((batch.json as unknown[]).slice(0, 3)).toMatchObject([
{ id: 1, result: {} },
{ id: 2, result: {} },
{ id: 3, error: { code: -32000 } },
]);
expect((batch.json as { id: number }[]).map((reply) => reply.id)).toEqual(
ids,
);
expect(Buffer.byteLength(JSON.stringify(batch.json))).toBeLessThanOrEqual(
MAX_RPC_RESPONSE_BYTES,
);
});
});
const served: ServedEnvironment[] = [];
afterEach(async () => {
await Promise.all(served.splice(0).map((entry) => entry.close()));
});
/** Serve a state on a loopback port until the test ends. */
const serveState = async (
inputs: ReturnType<typeof environmentStateServeInputs>,
options: Pick<ServeEnvironmentOptions, "fetchState"> = {},
): Promise<ServedEnvironment> => {
const entry = await serveEnvironment({
...inputs,
...options,
host: "127.0.0.1",
port: 0,
});
if (!entry.ok) throw entry.error;
served.push(entry.value);
return entry.value;
};
/** Run `use` with an MCP client, after checking it lists the card's tools, then end its session. */
const withClient = async (
environment: ServedEnvironment,
use: (client: Client) => Promise<void>,
): Promise<void> => {
const transport = new StreamableHTTPClientTransport(new URL(environment.url));
const client = new Client({ name: "environment-state", version: "1" });
await client.connect(transport);
try {
const { tools } = await client.listTools();
expect(tools.map(({ name }) => name)).toEqual(
environment.card.capabilities.tools.map(({ name }) => name),
);
await use(client);
} finally {
await transport.terminateSession();
await client.close();
}
};
describe("serveEnvironment with a state", () => {
it("declares the data plane, serves read tools from the state and reflects data/add in the next answer", async () => {
const environment = await serveState({ source, state });
const url = new URL(environment.url);
const card: unknown = await (
await fetch(new URL(ENVIRONMENT_CARD_PATH, url))
).json();
expect(card).toEqual(JSON.parse(JSON.stringify(environment.card)));
const { capabilities } = environment.card;
expect(capabilities.extensions).toEqual([STATE_INTAKE_EXTENSION]);
expect(capabilities.operations).toEqual(STATE_OPERATIONS);
const names = capabilities.tools.map((tool) => tool.name);
expect(names).toContain("get_users_by_id");
for (const write of [
"post_annotations",
"delete_dashboards_by_uid",
"post_folders",
"put_folders_by_uid",
"delete_teams_by_id",
])
expect(names).not.toContain(write);
await withClient(environment, async (client) => {
const before = await client.callTool({
name: "get_users_by_id",
arguments: { id: "2" },
});
expect(before.structuredContent).toMatchObject({
body: { login: "probe" },
state: { source: "state", view: "item", id: "2" },
});
const added = await fetch(new URL(ENVIRONMENT_RPC_PATH, url), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "data/add",
params: { parts: [{ kind: "data", data: renamedDocument() }] },
}),
});
await expect(added.json()).resolves.toMatchObject({ result: {} });
const after = await client.callTool({
name: "get_users_by_id",
arguments: { id: "2" },
});
expect(after.structuredContent).toMatchObject({
body: { login: "renamed" },
});
const missing = await client.callTool({
name: "get_users_by_id",
arguments: { id: "999" },
});
expect(missing.isError).toBe(true);
});
});
it("stops without waiting for a stalled download", async () => {
const held = heldFetcher(() => new Promise<Response>(() => undefined));
const environment = await serveState(
{ source, state },
{ fetchState: held.fetchState },
);
const answered = fetch(new URL(ENVIRONMENT_RPC_PATH, environment.url), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(addByUri(1)),
}).then(
(response) => response.status,
() => "closed",
);
await held.fetching;
// The download's own limit is 30 s; stopping must not wait for it.
const started = performance.now();
await environment.close();
expect(performance.now() - started).toBeLessThan(10_000);
expect(held.signals[0]?.aborted).toBe(true);
await answered;
});
it("refuses file reads while an earlier state file stays open, and reports it on close until it closes", async () => {
const environment = await serveState({ source, state });
const uri = await renamedStateUri();
let failing = true;
const owners = failFirstFileClose(() => failing);
const add = async (part: unknown) =>
(
await fetch(new URL(ENVIRONMENT_RPC_PATH, environment.url), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "data/add",
params: { parts: [part] },
}),
})
).json();
await expect(add({ kind: "file", file: { uri } })).resolves.toMatchObject({
error: {
code: -32000,
message: expect.stringContaining("could not be closed"),
},
});
await expect(add({ kind: "file", file: { uri } })).resolves.toMatchObject({
error: { code: -32000, message: expect.stringContaining("still open") },
});
// Documents that need no file still load.
await expect(
add({ kind: "data", data: renamedDocument() }),
).resolves.toMatchObject({ result: {} });
await expect(environment.close()).rejects.toBeInstanceOf(
ArtifactResourceRetryFailure,
);
expect(owners[0]?.handle.fd).toBeGreaterThanOrEqual(0);
failing = false;
await expect(environment.close()).resolves.toBeUndefined();
expect(owners[0]?.handle.fd).toBe(-1);
});
});
describe("serveEnvironment cleanup retries", () => {
it("attempts state-file cleanup after listener failure and keeps both failures retryable", async () => {
const environment = await serveState({ source, state });
const uri = await renamedStateUri();
let failing = true;
const owners = failFirstFileClose(() => failing);
const response = await fetch(
new URL(ENVIRONMENT_RPC_PATH, environment.url),
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(addByUri(1, uri)),
},
);
await expect(response.json()).resolves.toMatchObject({
error: {
code: -32000,
message: expect.stringContaining("could not be closed"),
},
});
const listenerFailure = new Error("injected listener cleanup failure");
closeFailureControl.failure = listenerFailure;
await expect(environment.close()).rejects.toSatisfy((cause: unknown) => {
if (!(cause instanceof AggregateError)) return false;
return (
cause.errors[0] === listenerFailure &&
cause.errors[1] instanceof ArtifactResourceRetryFailure
);
});
expect(owners[0]?.handle.fd).toBeGreaterThanOrEqual(0);
failing = false;
await expect(environment.close()).resolves.toBeUndefined();
expect(owners[0]?.handle.fd).toBe(-1);
});
});
describe("serveEnvironment with a state", () => {
it("serves query POSTs from their recordings and leaves state-changing writes out", async () => {
const environment = await serveState(
environmentStateServeInputs(
environmentStateServeFixture(
timedCaptureEvidence([
["GET", "/api/items", [{ id: 1, name: "a" }]],
["POST", "/api/search", [{ id: 1, name: "a" }]],
["POST", "/api/items", { id: 2, name: "b" }],
]),
),
),
);
const { tools } = environment.card.capabilities;
expect(tools.map(({ name }) => name)).toEqual(["get_items", "post_search"]);
expect(
tools.find(({ name }) => name === "post_search")?.description,
).toContain(
"answers from the served environment state where the state backs this tool",
);
await withClient(environment, async (client) => {
const searched = await client.callTool({
name: "post_search",
arguments: {},
});
expect(searched.structuredContent).toMatchObject({
body: [{ id: 1, name: "a" }],
state: { source: "recording", reason: "query-body" },
});
});
});
});
it("keeps the stateless card's data plane empty", () => {
expect(environmentCard("x", []).capabilities).toEqual({
extensions: null,
tools: [],
operations: [],
});
});