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 <noreply@anthropic.com>
This commit is contained in:
Edgar ArakelyanandClaude Opus 5.5 committed 2026-10-19 17:49:35 -07:00
1 parent f2c9a2e446
commit 2999128bae
4 files changed
+55 -3

No files matched your search

@@ -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);
@@ -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<string>();
#required: Set<string> | 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
@@ -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[];
}
@@ -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([]);
});