From 65168f77fb9bb1b12e1f0bfbc0fc7af22cb15ecd Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:59:18 +0800 Subject: [PATCH] fix(environment): restrict epoch normalization to clock fields Require a time-field token boundary before treating numeric values as epoch clocks, so identifiers such as media format codes remain literal. Clarify recognized field forms and clock offset and precision semantics in the fidelity guide. Fixes #6551 --- docs/agent-environments.md | 4 ++- .../environmentCompareDiff.test.ts | 15 ++++++++ .../environment/environmentCompareDiff.ts | 12 +++++-- .../environmentCompareIdsIntegration.test.ts | 36 +++++++++++++++++++ 4 files changed, 64 insertions(+), 3 deletions(-) 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( [