From 2999128baedceabc738e9a5aa69bfcfec39a55f1 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 19 Oct 2026 17:49:35 -0700 Subject: [PATCH] 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 --- .../EnvironmentStateExtraction.ts | 10 ++++- .../analysisView/environmentStateGate.ts | 7 +++- .../analysisView/environmentStateMerge.ts | 3 +- .../analysisView/environmentStateView.test.ts | 38 +++++++++++++++++++ 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/domain/analysisView/EnvironmentStateExtraction.ts b/src/domain/analysisView/EnvironmentStateExtraction.ts index 19302030ce..54bbc32917 100644 --- a/src/domain/analysisView/EnvironmentStateExtraction.ts +++ b/src/domain/analysisView/EnvironmentStateExtraction.ts @@ -286,7 +286,9 @@ export class EnvironmentStateExtraction { for (const record of recordings) { const found = this.#withBody(record.ordinal, (body) => { if (body === undefined) return null; - const located = pointer ?? locateList(body, proved); + // Every recording must locate the same list, so a later entity envelope is not taken as one. + const located = locateList(body, proved); + if (pointer !== undefined && located !== pointer) return undefined; const items = located === undefined ? undefined : listItems(body, located); if (items === undefined) return undefined; @@ -359,6 +361,12 @@ export class EnvironmentStateExtraction { observations: [], }; this.#entities.set(key, entity); + } else { + // Plans are observed item tools first, so keep the earliest sighting in timeline order. + const position = this.#position.get(record.ordinal)!; + const [first, at] = entity.order; + if (position < first || (position === first && index < at)) + entity.order = [position, index]; } // Only a repeated request line shows a value changing; other lines may differ by query. const line = requestLine(record); diff --git a/src/domain/analysisView/environmentStateGate.ts b/src/domain/analysisView/environmentStateGate.ts index 5683b5fe93..759d2efd44 100644 --- a/src/domain/analysisView/environmentStateGate.ts +++ b/src/domain/analysisView/environmentStateGate.ts @@ -53,11 +53,16 @@ export interface StateGateContext { /** Ordered key union and always-present keys, grown one object at a time. */ class FieldUnion { readonly fields: string[] = []; + /** Membership beside the ordered list, so a wide object is not scanned per key. */ + readonly #seen = new Set(); #required: Set | undefined; add(object: JsonObject): void { for (const key of Object.keys(object)) - if (!this.fields.includes(key)) this.fields.push(key); + if (!this.#seen.has(key)) { + this.#seen.add(key); + this.fields.push(key); + } const keys = new Set(Object.keys(object)); this.#required = this.#required === undefined diff --git a/src/domain/analysisView/environmentStateMerge.ts b/src/domain/analysisView/environmentStateMerge.ts index ffcb0e768f..f5df110d28 100644 --- a/src/domain/analysisView/environmentStateMerge.ts +++ b/src/domain/analysisView/environmentStateMerge.ts @@ -21,7 +21,8 @@ export interface ObservedEntity { readonly collection: string; readonly id: string; /** Timeline position of the first observation, then its index within that body. */ - readonly order: readonly [number, number]; + /** The earliest recorded sighting: timeline position, then position in its list. */ + order: readonly [number, number]; readonly observations: StateObservation[]; } diff --git a/src/domain/analysisView/environmentStateView.test.ts b/src/domain/analysisView/environmentStateView.test.ts index 501aa62021..581f369138 100644 --- a/src/domain/analysisView/environmentStateView.test.ts +++ b/src/domain/analysisView/environmentStateView.test.ts @@ -393,3 +393,41 @@ it("does not call a value volatile when it differs between request lines", () => ), ).toEqual([["get_items_by_id", "fields-differ"]]); }); + +it("orders entities by their earliest sighting, so a complete list keeps all membership", () => { + const state = stateOf( + captureOf([ + [ + "GET", + "/api/items", + [ + { id: 1, title: "Granite Ledger" }, + { id: 2, title: "Blue Heron" }, + ], + ], + ["GET", "/api/items/2", { id: 2, title: "Blue Heron" }], + ["GET", "/api/items/1", { id: 1, title: "Granite Ledger" }], + ]), + ); + expect(state.entities.map((entity) => entity.id)).toEqual(["1", "2"]); + expect(state.views).toContainEqual( + expect.objectContaining({ tool: "get_items", membership: "all" }), + ); +}); + +it("does not take a later recording that is itself an entity as the list", () => { + const state = stateOf( + captureOf([ + ["GET", "/api/items", { items: [{ id: 1, title: "Granite Ledger" }] }], + [ + "GET", + "/api/items", + { id: 99, items: [{ id: 2, title: "Blue Heron" }] }, + ], + ]), + ); + expect(state.views).toContainEqual( + expect.objectContaining({ tool: "get_items", shape: "recorded" }), + ); + expect(state.entities).toEqual([]); +});