From 96ea32b7e4e28d3c9cd79a1de4b8e1ba3b4244e9 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:14:05 +0800 Subject: [PATCH] perf(environment): decode only selected replay query values --- .../agent-environments/state-serve/plan.md | 5 +-- .../environment/environmentReplay.test.ts | 35 +++++++++++++----- .../environment/environmentReplay.ts | 36 ++++++++++--------- .../environmentReplayCapacity.test.ts | 22 ++++++++++++ .../environmentStateCheckedLines.ts | 7 ++-- src/domain/recordedTransportUrl.test.ts | 7 ++-- src/domain/recordedTransportUrl.ts | 2 +- 7 files changed, 78 insertions(+), 36 deletions(-) diff --git a/docs/specs/agent-environments/state-serve/plan.md b/docs/specs/agent-environments/state-serve/plan.md index 05f457e403..a0e77e13a9 100644 --- a/docs/specs/agent-environments/state-serve/plan.md +++ b/docs/specs/agent-environments/state-serve/plan.md @@ -11,8 +11,9 @@ 2. Add `environmentStateDocument.ts` to validate `rea-state/v1` documents against the served types and views, and `environmentStateStore.ts` for the live store with reset, atomic replace and the `data/get` snapshot. -3. Export `selectsRecordedLine`, the body-taking `replayAnswer` and the path - rule `pathValueMatches` from `environmentReplay.ts`, export one recorded-body +3. Export `selectsRecordedLine` and the body-taking `replayAnswer` from + `environmentReplay.ts`, reuse `pathSegmentMatches` from the state rules, + export one recorded-body predicate `stateBodyJson` from the state accumulator and `changesState` from the state rules, and widen `collectionPathOf` to any value with a path, so state answers reuse selection, matching, body and write rules. diff --git a/src/application/environment/environmentReplay.test.ts b/src/application/environment/environmentReplay.test.ts index 3b6cf5bbc6..5a73d8f849 100644 --- a/src/application/environment/environmentReplay.test.ts +++ b/src/application/environment/environmentReplay.test.ts @@ -17,7 +17,6 @@ import { type RecordedExchange, } from "./environmentSource.js"; import { - pathValueMatches, replayEnvironmentTool, selectReplayExchange, } from "./environmentReplay.js"; @@ -115,14 +114,32 @@ describe("replay argument mapping and query admission", () => { }); }); -describe("pathValueMatches", () => { - it("matches one decode of either side and not a second decode of both", () => { - expect(pathValueMatches("2", "%32")).toBe(true); - expect(pathValueMatches("%32", "2")).toBe(true); - expect(pathValueMatches("book1", "book%2531")).toBe(false); - expect(pathValueMatches("book%2531", "book1")).toBe(false); - expect(pathValueMatches("book%31", "book%2531")).toBe(true); - }); +describe("encoded replay path segments", () => { + it.each([ + ["2", "%32", true], + ["%32", "2", true], + ["book1", "book%2531", false], + ["book%2531", "book1", false], + ["book%31", "book%2531", true], + ] as const)( + "matches %s against %s by at most one decode", + (segment, value, exact) => { + const users = tool("get_users_by_id"); + const ordinal = users.record_ordinals[0]!; + const exchange = { + ...source.exchanges.get(ordinal)!, + path: `/api/users/${segment}`, + }; + expect( + selectReplayExchange(users, new Map([[ordinal, exchange]]), { + id: value, + }), + ).toMatchObject({ + exchange, + exact, + }); + }, + ); }); describe("replayEnvironmentTool selection", () => { diff --git a/src/application/environment/environmentReplay.ts b/src/application/environment/environmentReplay.ts index f11bca2e48..c7210eb547 100644 --- a/src/application/environment/environmentReplay.ts +++ b/src/application/environment/environmentReplay.ts @@ -51,10 +51,6 @@ interface MatchScore { readonly exact: boolean; } -/** Replay matches verbatim segments or either spelling decoded once. */ -export const pathValueMatches = (segment: string, value: string): boolean => - pathSegmentMatches(segment, value); - /** Count how many supplied path and query values the recorded request line satisfies. */ const scoreExchange = ( tool: EnvironmentManifestTool, @@ -83,7 +79,7 @@ const scoreExchange = ( for (const { index, value } of providedPaths) { const segment = segments.get(index); if (segment === value) verbatimMatches += 1; - if (segment !== undefined && pathValueMatches(segment, value)) + if (segment !== undefined && pathSegmentMatches(segment, value)) pathMatches += 1; } const supplied = new Map(); @@ -99,9 +95,12 @@ const scoreExchange = ( const recorded = new Map(); const inspectQuery = supplied.size > 0 || observedQuery !== undefined; const recordedPairs = inspectQuery - ? [...iterateUrlQueryParameters(exchange.query ?? "")].filter(([name]) => - queryArguments.has(name), - ) + ? [ + ...iterateUrlQueryParameters( + exchange.query ?? "", + observedQuery === undefined ? supplied : queryArguments, + ), + ] : []; for (const [name, value] of recordedPairs) { const values = recorded.get(name) ?? []; @@ -124,9 +123,8 @@ const scoreExchange = ( const observedPairs = observedQuery === undefined ? undefined - : [...iterateUrlQueryParameters(observedQuery ?? "")] - .filter(([name]) => queryArguments.has(name)) - .map(([name, rawValue]) => { + : [...iterateUrlQueryParameters(observedQuery ?? "", queryArguments)].map( + ([name, rawValue]) => { const occurrence = observedOccurrences.get(name) ?? 0; observedOccurrences.set(name, occurrence + 1); const argument = queryArguments.get(name); @@ -140,7 +138,8 @@ const scoreExchange = ( ? value[occurrence] : rawValue; return [name, normalized] as const; - }); + }, + ); const observedOrderMatches = observedPairs === undefined ? 0 @@ -208,9 +207,12 @@ export const selectReplayExchange = ( const queryWasSupplied = suppliedQueryNames.size > 0; const canInspectQuery = observedQuery !== undefined || queryWasSupplied; const bestPairs = canInspectQuery - ? [...iterateUrlQueryParameters(best.exchange.query ?? "")].filter( - ([name]) => suppliedQueryNames.has(name), - ) + ? [ + ...iterateUrlQueryParameters( + best.exchange.query ?? "", + suppliedQueryNames, + ), + ] : []; const orderAmbiguous = observedQuery === undefined && @@ -237,8 +239,8 @@ export const selectReplayExchange = ( ) return false; const pairs = [ - ...iterateUrlQueryParameters(candidate.query ?? ""), - ].filter(([name]) => suppliedQueryNames.has(name)); + ...iterateUrlQueryParameters(candidate.query ?? "", suppliedQueryNames), + ]; return ( pairs.length !== bestPairs.length || pairs.some( diff --git a/src/application/environment/environmentReplayCapacity.test.ts b/src/application/environment/environmentReplayCapacity.test.ts index f7051180ed..dcfdb595b9 100644 --- a/src/application/environment/environmentReplayCapacity.test.ts +++ b/src/application/environment/environmentReplayCapacity.test.ts @@ -52,3 +52,25 @@ it("bounds content-type display without changing body interpretation or retained contentType, ); }); + +it("keeps observed query order distinct from calls supplying only selected fields", () => { + const source = environmentServeSourceWithBody("{}", "application/json"); + const original = source.manifest.tools.find( + (tool) => tool.name === "get_users_by_id", + )!; + const ordinal = original.record_ordinals[0]!; + const tool = { ...original, query_parameter_names: ["ignored", "q"] }; + const retained = { + ...source.exchanges.get(ordinal)!, + query: `?${"ignored=%FF+value&".repeat(20000)}%71=hi%74`, + }; + const exchanges = new Map([[ordinal, retained]]); + const args = { id: "2", q: "hit" }; + expect(selectReplayExchange(tool, exchanges, args)?.exact).toBe(true); + expect(selectReplayExchange(tool, exchanges, args, "?q=hit")?.exact).toBe( + false, + ); + expect( + selectReplayExchange(tool, exchanges, args, retained.query)?.exact, + ).toBe(true); +}); diff --git a/src/domain/analysisView/environmentStateCheckedLines.ts b/src/domain/analysisView/environmentStateCheckedLines.ts index 40436dd62e..a68626fbd1 100644 --- a/src/domain/analysisView/environmentStateCheckedLines.ts +++ b/src/domain/analysisView/environmentStateCheckedLines.ts @@ -4,6 +4,7 @@ import { } from "../unicodeCodePointOrder.js"; import { iterateUrlQueryParameters, + parseUrlQueryParameterNames, singleQueryValues, } from "../recordedTransportUrl.js"; import { @@ -141,9 +142,7 @@ export const servedLine = ( ): ServedLine | undefined => { const key = queryKey(record.query); if (lines === undefined || key === undefined) return undefined; - const names = [...iterateUrlQueryParameters(record.query ?? "")].map( - ([name]) => name, - ); + const names = [...parseUrlQueryParameterNames(record.query ?? "")]; const named = bestLine(lines, lines.byQuery.get(key) ?? [], record); if (named !== undefined) return { ...named, named: true }; const unfiltered = @@ -165,7 +164,7 @@ const queryMatches = ( query: string | undefined, ): number => { const matched = new Set(); - for (const [name, value] of iterateUrlQueryParameters(query ?? "")) + for (const [name, value] of iterateUrlQueryParameters(query ?? "", read)) if (read.get(name) === value) matched.add(name); return matched.size; }; diff --git a/src/domain/recordedTransportUrl.test.ts b/src/domain/recordedTransportUrl.test.ts index bb4b2eceb9..b25702bd67 100644 --- a/src/domain/recordedTransportUrl.test.ts +++ b/src/domain/recordedTransportUrl.test.ts @@ -49,9 +49,10 @@ it.each([ ["x", ""], ["", ""], ]); - expect([...iterateUrlQueryParameters(query, selected)]).toEqual( - [...expected].filter(([name]) => selected.has(name)), - ); + for (const selection of [selected, new Set(selected.keys())]) + expect([...iterateUrlQueryParameters(query, selection)]).toEqual( + [...expected].filter(([name]) => selection.has(name)), + ); }); it.each([ diff --git a/src/domain/recordedTransportUrl.ts b/src/domain/recordedTransportUrl.ts index febbfb265d..1d81f84d23 100644 --- a/src/domain/recordedTransportUrl.ts +++ b/src/domain/recordedTransportUrl.ts @@ -122,7 +122,7 @@ function* queryFields( /** Scan one field at a time; selection avoids decoding unrelated values. */ export function* iterateUrlQueryParameters( query: string, - selected?: ReadonlyMap, + selected?: ReadonlyMap | ReadonlySet, ): IterableIterator<[string, string]> { for (const [start, nameEnd, end] of queryFields(query)) { const name = formComponent(query.slice(start, nameEnd));