diff --git a/docs/agent-environments.md b/docs/agent-environments.md index a5350caaed..8d1f4c7554 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -755,8 +755,10 @@ recording replayed in its place and never from a recording whose body the probe two collections can reuse a live id, and an equal pair stays in that collection. A live id in a later request's path, query or body is translated before the environment answers, a path parameter by the collection the live path names (a nested collection keeps its parent values, each decoded once, and a literal route segment stays), except a query value that pages a list, such as `limit` or `page`. Each call is checked against the served tool's input schema first, as the MCP server checks it. After mapping, two clock values compare by format only (ISO-8601 strings, and epoch numbers under -time-named fields), and two different values each side generated on its own compare equal: both UUIDs, or one length of +`at`, `time`, `date`, `timestamp`, `created`, `updated` or `modified` field names, including snake-case and camel-case forms), and two different values each side generated on its own compare equal: both UUIDs, or one length of eight or more with the same character classes and a digit. The report counts each kind of normalised value. +ISO clock offsets are accepted only within RFC 3339's `00`–`23` hour and `00`–`59` minute ranges. +Timestamp ordering retains submillisecond precision when the source records it. Comparison keeps response representations distinct: JSON values compare structurally, text compares as text, and a retained base64 body compares by bytes (including with UTF-8 text carrying those same bytes), never by its base64 diff --git a/src/application/environment/environmentCompareDiff.test.ts b/src/application/environment/environmentCompareDiff.test.ts index 74d3365c37..315a0d4c90 100644 --- a/src/application/environment/environmentCompareDiff.test.ts +++ b/src/application/environment/environmentCompareDiff.test.ts @@ -166,6 +166,21 @@ describe("compareAnswers", () => { }); }); +describe("clock field names", () => { + it("keeps numeric format identifiers literal and recognizes clock field forms", () => { + expect( + compare({ format: 1196444237 }, { format: 1448695129 }).differences, + ).toMatchObject([{ category: "value", pointer: "/format" }]); + expect( + compare({ updated_at: 1760000000 }, { updated_at: 1760000999 }) + .differences, + ).toEqual([]); + expect( + compare({ createdAt: 1760000000 }, { createdAt: 1760000999 }).differences, + ).toEqual([]); + }); +}); + describe("comparison body coverage", () => { it("compares a live body over the replay limit by status only and counts it", () => { const result = compareAnswers( diff --git a/src/application/environment/environmentCompareDiff.ts b/src/application/environment/environmentCompareDiff.ts index 32cc0e2684..350363d842 100644 --- a/src/application/environment/environmentCompareDiff.ts +++ b/src/application/environment/environmentCompareDiff.ts @@ -52,7 +52,10 @@ export interface Normalised { /** At most this many differences are kept per step. */ export const STEP_DIFFERENCE_LIMIT = 20; const VALUE_CHARS = 120; -const TIME_FIELD = /(at|time|date|timestamp|created|updated|modified)$/iu; +const TIME_FIELD = + /(?:^|[_-])(?:at|time|date|timestamp|created|updated|modified)$/iu; +const TIME_FIELD_CAMEL = + /(?:At|Time|Date|Timestamp|Created|Updated|Modified)$/u; const ID_LIST_FIELD = /^(ids|uuids|guids)$/u; const isObject = (value: JsonValue | undefined): value is JsonObject => @@ -76,7 +79,12 @@ const clockFormat = ( value: JsonValue, key: string | undefined, ): string | undefined => { - if (typeof value === "number" && key !== undefined && !TIME_FIELD.test(key)) + if ( + typeof value === "number" && + key !== undefined && + !TIME_FIELD.test(key) && + !TIME_FIELD_CAMEL.test(key) + ) return undefined; const clock = readClock(value); return clock === undefined ? undefined : formatKey(clock.format); diff --git a/src/application/environment/environmentCompareIdsIntegration.test.ts b/src/application/environment/environmentCompareIdsIntegration.test.ts index 3904787063..521853d51f 100644 --- a/src/application/environment/environmentCompareIdsIntegration.test.ts +++ b/src/application/environment/environmentCompareIdsIntegration.test.ts @@ -27,6 +27,42 @@ const compareRows = ( }; describe("environment comparison identifier semantics", () => { + it("compares numeric format identifiers literally while normalizing actual clock fields", () => { + const fourcc = (value: string) => Buffer.from(value).readUInt32LE(0); + const probe = compareRows( + [ + [ + "GET", + "/api/media", + { + format: fourcc("MJPG"), + size: 1, + updated_at: "2026-10-20T10:00:00Z", + }, + ], + ], + [ + [ + "GET", + "/api/media", + { + format: fourcc("YUYV"), + size: 1, + updated_at: "2026-10-17T18:00:03Z", + }, + ], + ], + ); + expect(probe.results[0]?.equal).toBe(false); + expect( + probe.results[0]?.differences.map((difference) => [ + difference.category, + difference.pointer, + ]), + ).toEqual([["value", "/format"]]); + expect(probe.normalised.clocks).toBe(1); + }); + it("does not normalize measurements after learning an unrelated numeric ID", () => { const probe = compareRows( [