fix(environment): alias long query names, bound reply line metadata, keep empty queries

A captured query name longer than 64 characters is advertised under a
short, collision-safe `query_<n>` alias, with the captured name sampled
in its description; replay maps the alias back, and the manifest keeps
the full name.

Replies cut `recorded_path` and `recorded_query` to 1 KiB and set
`recorded_line_truncated`, so a megabyte-scale recorded URL is not
echoed into every reply; selection still uses the full recorded line.

An explicit empty query (`/api?`) keeps its `?` in `recorded_query`. The
shared WHATWG URL parser reports it as "", like no query, so the replay
exchange reads the delimiter from the recorded URL instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Edgar Arakelyan
2026-10-19 10:26:07 -07:00
parent 130bc4326a
commit b217929a81
8 changed files with 175 additions and 18 deletions
+7 -3
View File
@@ -68,6 +68,7 @@ where the answer came from:
"exact_match": true,
"recorded_path": "/api/users/2",
"recorded_query": null,
"recorded_line_truncated": false,
"body_recorded": true,
"body_encoding": "utf-8",
"truncated": false
@@ -79,7 +80,9 @@ where the answer came from:
equals the recorded one; it says nothing about parameters the caller omitted.
A path value matches a recorded segment as written or after percent-decoding;
when both kinds of match are available, the record whose segment equals the
value as written is chosen.
value as written is chosen. `recorded_query` keeps the `?` of an explicit empty
query, and a recorded path or query longer than 1 KiB is cut in the reply with
`recorded_line_truncated: true`; selection always uses the full recorded line.
When no record matches, the tool still answers from the tool's first cited
record and reports `exact_match: false`. `body` is parsed JSON when the
recorded content type is JSON-like (`application/json`, `application/*+json`,
@@ -194,8 +197,9 @@ values (sampled within the list budget above; the manifest keeps them all);
query parameter names are optional strings; write methods accept an
optional `body` object described by the recorded top-level key types. Each
input keeps its own argument: a query name equal to a path parameter's name is
advertised as `query_<name>`, and the write body as `request_body` when a query
parameter is named `body`. Every
advertised as `query_<name>`, a query name longer than 64 characters as
`query_<n>` (its position) with the captured name sampled in its description,
and the write body as `request_body` when a query parameter is named `body`. Every
schema closes with `additionalProperties: false`, a key the echo example's
schema does not carry.
`extensions`, `operations` and `children_environments` are fixed to `null`,
@@ -28,7 +28,8 @@ later increment.
every path parameter as a required string described by its segment index,
rule and observed values, every observed query name as an optional string,
and an optional `body` object for `POST`, `PUT` and `PATCH` (a query name
that collides with a path parameter becomes `query_<name>`, and the body
that collides with a path parameter becomes `query_<name>`, a query name
longer than 64 characters becomes `query_<n>`, and the body
becomes `request_body` when a query parameter is named `body`), closed with
`additionalProperties: false` so unknown arguments are refused; the
description reads `METHOD template; replays retained records [ordinals];
@@ -48,7 +49,8 @@ later increment.
`content_type`, `body` (parsed JSON for JSON-like content types that parse
without changing a number, else text; bodies over 256 KiB cut at a UTF-8 boundary with
`truncated: true`) and `replay` with `record_ordinal`, `evidence_id`,
`exact_match`, `recorded_path`, `recorded_query`, `body_recorded` (false
`exact_match`, `recorded_path` and `recorded_query` (each cut to 1 KiB, with
`recorded_line_truncated`; an explicit empty query is `?`), `body_recorded` (false
when the capture kept no response body, so a `null` body is not mistaken for
a recorded one), `body_encoding` (`base64` when the retained bytes are not
UTF-8, so they are returned exactly rather than decoded lossily) and
@@ -180,6 +180,30 @@ describe("replayEnvironmentTool selection", () => {
);
});
it("replays an aliased query argument against the captured long name", () => {
const longName = "n".repeat(100);
const search = {
...tool("get_search"),
query_parameter_names: [longName],
record_ordinals: [901, 902],
};
const exchange = (ordinal: number, value: string): RecordedExchange => ({
ordinal,
path: "/api/search",
query: `?${longName}=${value}`,
status: 200,
contentType: null,
bodyText: null,
});
const exchanges = new Map<number, RecordedExchange>([
[901, exchange(901, "a")],
[902, exchange(902, "b")],
]);
expect(
selectReplayExchange(search, exchanges, { query_1: "b" }),
).toMatchObject({ exchange: { ordinal: 902 }, exact: true });
});
it("prefers path matches over query matches and keeps manifest order on ties", () => {
const search = tool("get_search");
const [first, second] = search.record_ordinals;
@@ -404,3 +428,38 @@ describe("replayEnvironmentTool bodies", () => {
});
});
});
describe("replayEnvironmentTool reply metadata", () => {
it("cuts a long recorded path or query in the reply and flags it, while matching on the full line", () => {
const users = tool("get_users_by_id");
const ordinal = users.record_ordinals[0] ?? 0;
const long: EnvironmentSource = {
...source,
exchanges: new Map([
[
ordinal,
{
ordinal,
path: `/api/users/2/${"p".repeat(4000)}`,
query: `?${"q".repeat(4000)}`,
status: 200,
contentType: null,
bodyText: null,
},
],
]),
};
const cut = replay("get_users_by_id", { id: "2" }, long).replay;
expect(cut).toMatchObject({
exact_match: true,
recorded_line_truncated: true,
});
expect(Buffer.byteLength(cut.recorded_path)).toBeLessThanOrEqual(1024);
expect(Buffer.byteLength(cut.recorded_query ?? "")).toBeLessThanOrEqual(
1024,
);
expect(
replay("get_users_by_id", { id: "2" }).replay.recorded_line_truncated,
).toBe(false);
});
});
@@ -29,6 +29,8 @@ export interface ReplayResult {
readonly exact_match: boolean;
readonly recorded_path: string;
readonly recorded_query: string | null;
/** Set when the recorded path or query was cut to REPLAY_METADATA_LIMIT_BYTES; the Evidence record keeps it whole. */
readonly recorded_line_truncated: boolean;
readonly body_recorded: boolean;
readonly body_encoding: "utf-8" | "base64";
readonly truncated: boolean;
@@ -193,6 +195,9 @@ const parseExactJson = (text: string): unknown => {
}
};
/** Recorded paths and queries are cut to this many UTF-8 bytes in replies. */
export const REPLAY_METADATA_LIMIT_BYTES = 1024;
/** Replay one tool call from the retained record that best matches its arguments. */
export const replayEnvironmentTool = (
source: EnvironmentSource,
@@ -212,6 +217,11 @@ export const replayEnvironmentTool = (
);
const { exchange, exact } = selected;
const { body, truncated } = replayBody(exchange);
const recordedPath = utf8Prefix(exchange.path, REPLAY_METADATA_LIMIT_BYTES);
const recordedQuery =
exchange.query === null
? null
: utf8Prefix(exchange.query, REPLAY_METADATA_LIMIT_BYTES);
return ok({
status: exchange.status,
content_type: exchange.contentType,
@@ -220,8 +230,11 @@ export const replayEnvironmentTool = (
record_ordinal: exchange.ordinal,
evidence_id: source.evidenceId,
exact_match: exact,
recorded_path: exchange.path,
recorded_query: exchange.query,
recorded_path: recordedPath,
recorded_query: recordedQuery,
recorded_line_truncated:
recordedPath.length < exchange.path.length ||
(recordedQuery?.length ?? 0) < (exchange.query?.length ?? 0),
body_recorded: exchange.bodyText !== null,
body_encoding: exchange.bodyEncoding ?? "utf-8",
truncated,
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
analysisViewCaptureEvidence,
analysisViewCaptureFixture,
environmentManifestCaptureFixture,
} from "../../../tests/fixtures/analysisView.js";
import {
environmentServeFixture,
@@ -163,3 +164,33 @@ describe("loadEnvironmentSource", () => {
});
});
});
describe("loadEnvironmentSource recorded request lines", () => {
it("keeps the delimiter of an explicit empty query in the recorded exchange", () => {
const capture = environmentManifestCaptureFixture();
const record = capture.records.find((entry) =>
JSON.stringify(entry.reported).includes("/api/users/2"),
);
if (record === undefined) throw new Error("Fixture lacks /api/users/2");
const reported = structuredClone(record.reported) as {
request: { url: string };
};
reported.request.url = `${reported.request.url}?`;
record.reported = reported;
const evidence = analysisViewCaptureEvidence(capture);
const manifest = inspectAnalysisView({
source: { kind: "inline", evidence },
view: { kind: "environment-manifest" },
});
if (!manifest.ok) throw manifest.error;
const loaded = loadEnvironmentSource(manifest.value, evidence);
if (!loaded.ok) throw loaded.error;
const exchanges = [...loaded.value.exchanges.values()];
expect(
exchanges.find((entry) => entry.path === "/api/users/2")?.query,
).toBe("?");
expect(
exchanges.find((entry) => entry.path === "/api/users/3")?.query,
).toBeNull();
});
});
@@ -135,6 +135,29 @@ const recordedBody = (
}
};
/** WHATWG reports an explicit empty query (`/api?`) as "", like no query; the recorded URL keeps the delimiter. */
const recordedQuery = (
record: WebNetworkCaptureRecord,
query: string | undefined,
): string | null => {
if (query === undefined) return null;
if (query !== "") return query;
const reported = record.reported;
const request =
typeof reported === "object" &&
reported !== null &&
!Array.isArray(reported)
? reported.request
: undefined;
const url =
typeof request === "object" && request !== null && !Array.isArray(request)
? request.url
: undefined;
return typeof url === "string" && url.split("#", 1)[0]?.includes("?") === true
? "?"
: null;
};
const recordedExchange = (
record: WebNetworkCaptureRecord,
): RecordedExchange | undefined => {
@@ -143,7 +166,7 @@ const recordedExchange = (
return {
ordinal: record.ordinal,
path: line.path,
query: line.query === undefined || line.query === "" ? null : line.query,
query: recordedQuery(record, line.query),
status: line.status,
contentType: line.rawMime === "" ? null : line.rawMime,
...recordedBody(record),
@@ -4,6 +4,7 @@ import { environmentServeSource } from "../../../tests/fixtures/environmentServe
import type { EnvironmentManifestTool } from "./environmentSource.js";
import {
environmentCard,
environmentToolArguments,
environmentToolDefinitions,
environmentToolInputSchema,
REPLAY_CATALOG_INSTRUCTIONS,
@@ -148,6 +149,23 @@ describe("environment catalog description budgets", () => {
expect(definition?.tool.path_template).toBe(long.path_template);
});
it("advertises an oversized query name under a short, collision-safe alias", () => {
const longName = "n".repeat(100_000);
const search = {
...tool("get_search"),
query_parameter_names: [longName, "query_1"],
};
const args = environmentToolArguments(search);
expect(args.query.get(longName)).toBe("query_1");
expect(args.query.get("query_1")).toBe("query_query_1");
const schema = environmentToolInputSchema(search);
expect(Buffer.byteLength(JSON.stringify(schema))).toBeLessThan(4096);
expect(schema.properties.query_1?.description).toContain(
"… (full name in manifest Evidence)",
);
expect(search.query_parameter_names[0]).toBe(longName);
});
it("reports an omitted oversized value instead of publishing an unbounded or partial observation", () => {
const users = tool("get_users_by_id");
const schema = environmentToolInputSchema({
+17 -10
View File
@@ -61,18 +61,21 @@ const describeList = <Value>(
: `${listed}${sample.length === 0 ? "" : ", "}… (${String(sample.length)}/${String(values.length)} shown; full list in manifest Evidence)`;
};
/** The first DESCRIPTION_LIST_SAMPLE_BYTES of a captured path, cut on a character boundary, with a pointer to the rest. */
const describePath = (path: string): string => {
/** The first DESCRIPTION_LIST_SAMPLE_BYTES of captured text, cut on a character boundary, with a pointer to the rest. */
const describeText = (text: string, what: string): string => {
// encodeInto stops before the first character that does not fit, so `read` ends on a boundary.
const { read } = new TextEncoder().encodeInto(
path,
text,
new Uint8Array(DESCRIPTION_LIST_SAMPLE_BYTES),
);
return read === path.length
? path
: `${path.slice(0, read)}… (full path in manifest Evidence)`;
return read === text.length
? text
: `${text.slice(0, read)}… (full ${what} in manifest Evidence)`;
};
/** Captured query names longer than this are advertised under a short alias; the manifest keeps the name. */
export const MAX_QUERY_ARGUMENT_CHARS = 64;
const bodyDescription = (tool: EnvironmentManifestTool): string =>
tool.body_shape === null
? "Request body; the capture retained no JSON body shape for this tool."
@@ -107,8 +110,12 @@ export const environmentToolArguments = (
): EnvironmentToolArguments => {
const taken = new Set(tool.params.map((param) => param.name));
const query = new Map<string, string>();
for (const name of tool.query_parameter_names) {
const argument = freeName(taken, name, "query_");
for (const [index, name] of tool.query_parameter_names.entries()) {
const preferred =
name.length <= MAX_QUERY_ARGUMENT_CHARS
? name
: `query_${String(index + 1)}`;
const argument = freeName(taken, preferred, "query_");
taken.add(argument);
query.set(name, argument);
}
@@ -135,7 +142,7 @@ export const environmentToolInputSchema = (
for (const [name, argument] of args.query)
properties.set(argument, {
type: "string",
description: `Query parameter ${name}; optional, matched against the recorded query on replay.`,
description: `Query parameter ${describeText(name, "name")}; optional, matched against the recorded query on replay.`,
});
if (args.body !== undefined)
properties.set(args.body, {
@@ -157,7 +164,7 @@ export const environmentToolDefinitions = (
): readonly EnvironmentToolDefinition[] =>
manifest.tools.map((tool) => ({
name: tool.name,
description: `${REPLAY_CATALOG_INSTRUCTIONS} ${tool.method} ${describePath(tool.path_template)}; replays retained records [${describeList(tool.record_ordinals)}]; evidence ${evidenceId}`,
description: `${REPLAY_CATALOG_INSTRUCTIONS} ${tool.method} ${describeText(tool.path_template, "path")}; replays retained records [${describeList(tool.record_ordinals)}]; evidence ${evidenceId}`,
inputSchema: environmentToolInputSchema(tool),
tool,
}));