diff --git a/docs/agent-environments.md b/docs/agent-environments.md index d1b0c8e01e..5c6a1c594e 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -422,7 +422,7 @@ Each read tool answers according to its view in the state: | View | Answer | | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `item` | The entity the path arguments name, rendered into the recorded envelope of `envelope_ordinal`. Arguments match as replay matches them: each collection segment equals the held collection's segment as written or after percent-decoding, and the identity is the last argument percent-decoded, then as written; values are never re-split on `/`. Any entity the state holds answers, including one `data/add` loaded; query arguments only decide `replay.exact_match`. An entity the state does not hold answers the tool's latest recorded 400, 404 or 410 response with `state.view` `missing`, or is a tool error when none was recorded; one lacking a field every recording carried is a tool error; a body is never invented. | -| `list` | When the arguments name one of the lines the view checked exactly (its latest recordings before the first related write; every path and query value equal to the recording's, and every recorded query parameter supplied), that recorded envelope with its array rendered from the state: every entity of the collection in state order for `membership: "all"`, or the recorded members still held. A call with no query argument to a `membership: "all"` view answers from a checked line of the collection its path arguments name that only pages its list, with `replay.exact_match` false. A member lacking a field every recording carried makes the call a tool error rather than a partial list. | +| `list` | When the arguments name one of the lines the view checked exactly (its latest recordings before the first related write; every path and query value equal to the recording's, and every recorded query parameter supplied), that recorded envelope with its array rendered from the state: every entity of the collection in state order for `membership: "all"` or a line `all_record_ordinals` names, or the recorded members still held. A call with no query argument answers from such a line of the collection its path arguments name that only pages its list, with `replay.exact_match` false. A member lacking a field every recording carried makes the call a tool error rather than a partial list. | | `document` | When the arguments name the document's checked line exactly, the state's value for it; a tool error when the state holds none. | | `recorded` | The recording, as replay answers it. | diff --git a/docs/specs/agent-environments/list-lines/plan.md b/docs/specs/agent-environments/list-lines/plan.md new file mode 100644 index 0000000000..f7ca3db95e --- /dev/null +++ b/docs/specs/agent-environments/list-lines/plan.md @@ -0,0 +1,10 @@ +# Implementation plan + +1. **Gate.** Judge each latest list recording on its own for paging-only query, totals, next page and state order; + keep `all` when every line passes and otherwise name the passing lines in `all_record_ordinals`; reproduce each + line with its own membership. +2. **Counters.** Count `number`-of-items members as item totals. +3. **Consumers.** One helper says whether a view lists all on a line; serving, the write gate's list checks and the + insert retry use it, and an argument-less call may answer on any such line. +4. **Tests and docs.** Domain and serving tests as the acceptance lists; the membership row of the environment-state + docs and the list answer row of the agent-environment docs. diff --git a/docs/specs/agent-environments/list-lines/spec.md b/docs/specs/agent-environments/list-lines/spec.md new file mode 100644 index 0000000000..810d6136fe --- /dev/null +++ b/docs/specs/agent-environments/list-lines/spec.md @@ -0,0 +1,31 @@ +# Decide a list's whole-collection membership per line + +Issue: https://github.com/morluto/rea/issues/4933 + +## Outcome + +A list view reproduces its lines from the state with one of two memberships: `all`, every entity of the collection in +state order, or `recorded`, the entities each recording listed. The view was `all` only when every checked line listed +its whole collection. A list tool often also has a filtered line (`?library_id=…`, `?name=…`), and that one line kept +every line of the tool to its recorded members. An entity the environment created then never appeared in a list, +though the unfiltered line of the same tool had shown the application listing every entity. + +Each checked line is now judged on its own. A recorded-membership view names, in `all_record_ordinals`, the checked +lines that each meet the conditions `all` asks of every line: the line has no query or only paging parameters, lists +exactly the entities of its collection in state order, and shows no total that differs from the number listed and no +next page. A call answered on such a line lists every entity of the collection, the write gate checks a read on it +against the whole collection, and a create's insert rule may be retried on it, as for an `all` view. Other lines keep +their recorded members. Without such a line, or when every line is one, the view is unchanged and the member is absent, +so existing states keep their values. + +`numberOfElements` and similar number-of-items members (`number` with `elements`, `entries`, `items` or `results`) +count the listed entities, so a whole line's counter follows the state as `totalElements` does. + +## Acceptance + +- Domain: a tool with a whole line and a filtered line names the whole one; a create the whole line later lists is + confirmed; a tool whose lines are all whole, or none, names none. +- Serving: after a served create, the whole line lists the created entity with its counters, and the filtered line + keeps its recorded members. +- Measured: the six published environments compared with the application before and after, on the probes of the + fidelity comparison and the held-out sessions recorded since. diff --git a/docs/specs/agent-environments/list-lines/tasks.md b/docs/specs/agent-environments/list-lines/tasks.md new file mode 100644 index 0000000000..2f98360ecb --- /dev/null +++ b/docs/specs/agent-environments/list-lines/tasks.md @@ -0,0 +1,9 @@ +# Tasks + +- [x] Judge whole-collection membership per checked line. +- [x] Count number-of-items members. +- [x] Serve, check and place creates per line. +- [x] Add domain and serving tests. +- [x] Document per-line membership. +- [x] Measure the six published environments before and after. +- [x] Run static, formatting, duplication, documentation and test gates. diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index ebcab44e3c..f3947372f0 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -579,12 +579,12 @@ Every entity cites its `record_ordinals`. `views` lists one plan per read tool in tool order: -| Shape | Plan | -| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `item` | The entity object at `pointer` (the root, one member, or `/0` or `//0`), the ordered `fields` the view shows and the `required_fields` every recording had, and `envelope_ordinal`: the recording whose response, with the entity replaced, every item response shares | -| `list` | The array at `pointer`, its `fields`, `membership` (`all`: every entity of the collection in state order, when each recording listed exactly that, had no query or only paging parameters (`limit`, `offset`, `page`, `per_page`, `perpage`, `page_size`, `pagesize`, `size`, `start`, `_start`, `_end`), no item-total member (`total`, `count`, `totalCount`, `item_count`, `totalcount`, `itemcount`, but not `total_pages` or `per_page`; a number or a string of digits) differing from the number listed, and no `has_more`, `next` or next-page token member set; `recorded`: the entities each recording listed) and `counters` that equal the number listed | -| `document` | The latest initial value of a list tool whose responses hold no identified list, when its initial reads share one request line; held in `documents` with that `path`, whether or not the capture writes it, unless its value changed across any recorded write between those reads (`fields-differ`) | -| `recorded` | A `reason` the tool replays its recordings instead | +| Shape | Plan | +| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `item` | The entity object at `pointer` (the root, one member, or `/0` or `//0`), the ordered `fields` the view shows and the `required_fields` every recording had, and `envelope_ordinal`: the recording whose response, with the entity replaced, every item response shares | +| `list` | The array at `pointer`, its `fields`, `membership` (`all`: every entity of the collection in state order, when each recording listed exactly that, had no query or only paging parameters (`limit`, `offset`, `page`, `per_page`, `perpage`, `page_size`, `pagesize`, `size`, `start`, `_start`, `_end`), no item-total member (`total`, `count`, `totalCount`, `item_count`, `totalcount`, `itemcount`, `numberOfElements`, but not `total_pages` or `per_page`; a number or a string of digits) differing from the number listed, and no `has_more`, `next` or next-page token member set; `recorded`: the entities each recording listed, except on the lines `all_record_ordinals` names, which each meet those conditions on their own and list every entity), and `counters` that equal the number listed | +| `document` | The latest initial value of a list tool whose responses hold no identified list, when its initial reads share one request line; held in `documents` with that `path`, whether or not the capture writes it, unless its value changed across any recorded write between those reads (`fields-differ`) | +| `recorded` | A `reason` the tool replays its recordings instead | An item or list plan is kept only when it reproduces the latest initial recording of each request line from the state, comparing JSON values without diff --git a/src/application/environment/environmentStateWriteAnswer.test.ts b/src/application/environment/environmentStateWriteAnswer.test.ts index 678f764060..b69b6bd392 100644 --- a/src/application/environment/environmentStateWriteAnswer.test.ts +++ b/src/application/environment/environmentStateWriteAnswer.test.ts @@ -10,10 +10,12 @@ import { BOOK_ROWS, DELETE_NOT_FOUND, FIRST_BOOK, + LIST_LINE_ROWS, SECOND_BOOK, THIRD_BOOK, book, bookClock, + bookPage, renamedBook, type Book, } from "../../../tests/fixtures/environmentStateWrites.js"; @@ -360,6 +362,21 @@ describe("paged list lines", () => { }); }); +describe("list lines", () => { + it("lists a created book on the line that lists every book, while a filtered line keeps its recorded members", () => { + const books = servedFrom(LIST_LINE_ROWS); + const created = books.answer("post_books", CREATE_NEW_BOOK).body as Book; + expect(books.answer("get_books")).toMatchObject({ + body: bookPage([FIRST_BOOK, SECOND_BOOK, created]), + state: { source: "state", membership: "all", listed: 3 }, + }); + expect(books.answer("get_books", { name: "First Book" })).toMatchObject({ + body: bookPage([FIRST_BOOK]), + state: { source: "state", membership: "recorded", listed: 1 }, + }); + }); +}); + describe("path values", () => { const orgBook = (id: number, org: string, name: string): Book => ({ id, diff --git a/src/domain/analysisView/environmentStateWrites.test.ts b/src/domain/analysisView/environmentStateWrites.test.ts index 1a19e968cb..61b2ca9ea6 100644 --- a/src/domain/analysisView/environmentStateWrites.test.ts +++ b/src/domain/analysisView/environmentStateWrites.test.ts @@ -11,6 +11,7 @@ import { BOOK_ROWS, BOOK_WRITE_CLOCK, FIRST_BOOK, + LIST_LINE_ROWS, SECOND_BOOK, THIRD_BOOK, book, @@ -490,3 +491,35 @@ describe("observation from a later read", () => { }); }); }); + +describe("list lines", () => { + it("lists every entity on a line that lists its whole collection, though another line of the tool is filtered", () => { + const state = stateOf(LIST_LINE_ROWS); + expect(state.views.find((view) => view.tool === "get_books")).toMatchObject( + { + shape: "list", + pointer: "/content", + membership: "recorded", + all_record_ordinals: [0], + counters: ["numberOfElements", "totalElements"], + checked_record_ordinals: [0, 1], + }, + ); + // The whole list after the create shows the created book, which confirms it. + expect(planOf(state, "post_books")).toMatchObject({ + served: true, + checked_record_ordinals: [5, 6], + }); + }); + + it("names no line when every line lists its whole collection, or none does", () => { + const listOf = (rows: readonly TimedCaptureRow[]) => + stateOf(rows).views.find((view) => view.tool === "get_books"); + const whole = listOf(LIST_LINE_ROWS.filter((_, index) => index !== 1)); + expect(whole).toMatchObject({ membership: "all" }); + expect(whole).not.toHaveProperty("all_record_ordinals"); + expect(listOf(LIST_LINE_ROWS.slice(1))).not.toHaveProperty( + "all_record_ordinals", + ); + }); +}); diff --git a/tests/fixtures/environmentStateWrites.ts b/tests/fixtures/environmentStateWrites.ts index 0880edbd0d..f89a56ad3e 100644 --- a/tests/fixtures/environmentStateWrites.ts +++ b/tests/fixtures/environmentStateWrites.ts @@ -60,6 +60,8 @@ export const FIRST_BOOK = book(1, "First Book", "2026-10-01T09:00:00Z"); export const SECOND_BOOK = book(2, "Second Book", "2026-10-01T10:00:00Z"); /** The book the recorded create at exchange 3 made. */ export const THIRD_BOOK = book(3, "Third Book", bookClock(3)); +/** The book a create recorded at exchange 4 made. */ +const THIRD_BOOK_AT_4 = book(3, "Third Book", bookClock(4)); /** The first book after the recorded update at exchange 5. */ export const RENAMED_BOOK = renamedBook(FIRST_BOOK, "Renamed Book", 5); @@ -92,6 +94,26 @@ export const BOOK_ROWS: readonly TimedCaptureRow[] = [ ["POST", "/api/exports", { queued: true }, 202, { format: "csv" }], ]; +/** A page of books as a paging API wraps it, counting what it lists. */ +export const bookPage = (books: readonly Book[]) => ({ + content: [...books], + totalElements: books.length, + numberOfElements: books.length, +}); + +/** + * One list tool with a line that lists every book and a line filtered by name, item reads, then a + * create the whole list confirms. + */ +export const LIST_LINE_ROWS: readonly TimedCaptureRow[] = [ + ["GET", "/api/books", bookPage([FIRST_BOOK, SECOND_BOOK])], + ["GET", "/api/books?name=First%20Book", bookPage([FIRST_BOOK])], + ...BOOK_INIT_ROWS.slice(1), + ["POST", "/api/books", THIRD_BOOK_AT_4, 201, { name: "Third Book" }], + ["GET", "/api/books/3", THIRD_BOOK_AT_4], + ["GET", "/api/books", bookPage([FIRST_BOOK, SECOND_BOOK, THIRD_BOOK_AT_4])], +]; + /** The end of the last exchange of BOOK_ROWS, to the next whole second: where the served clock starts. */ export const BOOK_WRITE_CLOCK = "2026-10-17T18:00:12.000Z";