diff --git a/docs/specs/agent-environments/state-view/spec.md b/docs/specs/agent-environments/state-view/spec.md index 259722e144..3647d154ab 100644 --- a/docs/specs/agent-environments/state-view/spec.md +++ b/docs/specs/agent-environments/state-view/spec.md @@ -35,7 +35,7 @@ later increments. and a value that changed between repeats of one request line with no recorded write is volatile and keeps the latest; a change across a write under another root, including between two tools of the type, leaves every tool of the type - recorded. + recorded. Compare list membership, including empty lists, and field presence before selecting each request line's latest recording; a change across a write also leaves the type recorded. Different query strings remain distinct. - A list has `all` membership only when no item-total envelope member (`total`, `count` and their compounds, not page counts) differs from the number of entities it lists and no `has_more`, `next` or next-page token is set. diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 9c34204ec0..8ee13b9cca 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -492,7 +492,7 @@ changed between one tool's observations with no recorded write between is named in the type's `volatile_fields`, keeping the latest value. A value that changed across a write under another root may be that write's effect, so every tool of its type is a recorded view with reason `fields-differ`; so is a value one tool -read before a write and another tool read after it. Entities are ordered +read before a write and another tool read after it. Repeated reads of one full request line also keep the type recorded when list membership changes across a write, including transitions to or from an empty list, or when entity fields are added or removed. Different query strings remain distinct request lines. Entities are ordered by type, collection and first sighting; that order is a list's state order. Every entity cites its `record_ordinals`. diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index 2f1570ce0f..ad6bf4fa89 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_analysis_view"), kind: "application", description: - 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot, inspect_il2cpp_metadata or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for written non-entity paths, and a render plan per read tool: item and list views reproduce their latest recordings from the state, and every other read tool is a recorded view with a reason. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods, with exact indices/names, fields and paging; types also select image_indices/namespaces/declaring_types and methods select declaring_types. Rows always keep their original index scoped to the parent artifact digest. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', + 'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot, inspect_il2cpp_metadata or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized non-entity paths written by POST/PUT/PATCH (item-shaped and delete-only paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state, and every other read tool is a recorded view with a reason. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods, with exact indices/names, fields and paging; types also select image_indices/namespaces/declaring_types and methods select declaring_types. Rows always keep their original index scoped to the parent artifact digest. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ diff --git a/src/domain/analysisView/EnvironmentStateExtraction.ts b/src/domain/analysisView/EnvironmentStateExtraction.ts index 56d93a8dd9..6d2df96986 100644 --- a/src/domain/analysisView/EnvironmentStateExtraction.ts +++ b/src/domain/analysisView/EnvironmentStateExtraction.ts @@ -56,6 +56,12 @@ class StateBudgetExhausted extends Error {} type Candidate = Pick; +interface ObservedMembership { + record: StateRecordFacts; + readonly ids: Set; + capacity: number; +} + const entityKey = (type: string, collection: string, id: string): string => compositeKey([type, collection, id]); @@ -332,6 +338,12 @@ export class EnvironmentStateExtraction { } #observePlan(plan: StatePlan): void { + // Only lists whose observations span a write need membership snapshots, including empty lists. + const memberships = + !plan.item && + this.#writeWithin(plan.recordings[0]!, plan.recordings.at(-1)!) + ? new Map() + : undefined; for (const record of plan.recordings) this.#withBody(record.ordinal, (body) => { const located = body === undefined ? undefined : at(body, plan.pointer); @@ -339,15 +351,49 @@ export class EnvironmentStateExtraction { ? isObject(located) ? [located] : [] - : Array.isArray(located) - ? located.filter(isObject) - : []; + : body === undefined + ? [] + : (listItems(body, plan.pointer) ?? []); + if (memberships !== undefined) + this.#observeMembership(plan, record, objects, memberships); objects.forEach((object, index) => this.#observe(plan, record, object, index), ); }); } + /** Compare one request line's membership before discarding older recordings; reuse its bounded set. */ + #observeMembership( + plan: StatePlan, + record: StateRecordFacts, + objects: readonly JsonObject[], + memberships: Map, + ): void { + const line = requestLine(record); + let previous = memberships.get(line); + const earlier = previous?.record; + if (previous === undefined) { + this.#retain(ENTRY_BYTES * 2 + textBytes(line.length)); + previous = { record, ids: new Set(), capacity: 0 }; + memberships.set(line, previous); + } + // Reserve the peak membership representation before growing the set, including numeric ID text. + if (objects.length > previous.capacity) { + this.#retain((objects.length - previous.capacity) * ENTRY_BYTES * 4); + previous.capacity = objects.length; + } + const field = this.#identity.get(plan.type)!.field; + let changed = previous.ids.size !== objects.length; + for (const object of objects) + if (!previous.ids.delete(scalarText(object[field])!)) changed = true; + if (previous.ids.size > 0) changed = true; + if (earlier !== undefined && changed && this.#writeWithin(earlier, record)) + this.#rewritten.add(plan.type); + previous.ids.clear(); + for (const object of objects) previous.ids.add(scalarText(object[field])!); + previous.record = record; + } + /** Record one observed entity object; a value that changes between one tool's own observations is volatile. */ #observe( plan: StatePlan, diff --git a/src/domain/analysisView/environmentStateMerge.ts b/src/domain/analysisView/environmentStateMerge.ts index 22977db1d0..4095be2671 100644 --- a/src/domain/analysisView/environmentStateMerge.ts +++ b/src/domain/analysisView/environmentStateMerge.ts @@ -100,11 +100,12 @@ export const classifyEntityChanges = ( timeline.forEach((after, index) => { const line = JSON.stringify(lineOf(after)); const repeat = lastOfLine.get(line); - const differs = repeat === undefined ? [] : changed(repeat, after); - if (repeat !== undefined && differs.length > 0) { - if (writeBetween(repeat, after)) rewritten.add(entity.type); - else - for (const name of differs) + if (repeat !== undefined) { + if (writeBetween(repeat, after)) { + // Key additions/removals are changes too, even when every shared field is unchanged. + if (!same(repeat.object, after.object)) rewritten.add(entity.type); + } else + for (const name of changed(repeat, after)) volatile.set( entity.type, (volatile.get(entity.type) ?? new Set()).add(name), diff --git a/src/domain/analysisView/environmentStateRules.ts b/src/domain/analysisView/environmentStateRules.ts index 5565fa2c09..ce7006be01 100644 --- a/src/domain/analysisView/environmentStateRules.ts +++ b/src/domain/analysisView/environmentStateRules.ts @@ -234,8 +234,10 @@ export const apiBaseLengths = ( const groups = new Map(); for (const template of templates) { const segments = segmentsOf(template); - const group = groups.get(segments[0] ?? "") ?? []; - groups.set(segments[0] ?? "", [...group, segments]); + const first = segments[0] ?? ""; + const group = groups.get(first); + if (group === undefined) groups.set(first, [segments]); + else group.push(segments); } return new Map( [...groups.entries()].map(([first, members]) => { diff --git a/src/domain/analysisView/environmentStateView.test.ts b/src/domain/analysisView/environmentStateView.test.ts index c41cafbd95..7f1d2977f9 100644 --- a/src/domain/analysisView/environmentStateView.test.ts +++ b/src/domain/analysisView/environmentStateView.test.ts @@ -375,6 +375,23 @@ it("holds a written path's non-entity read as a document", () => { }); }); +it.each([ + ["/api/files/123", "PUT", "get_files_by_id"], + ["/api/settings", "DELETE", "get_settings"], +] as const)( + "leaves a deferred document path recorded (%s, %s)", + (path, method, tool) => { + const state = stateOf( + captureOf([ + ["GET", path, { text: "initial document" }], + [method, path, null], + ]), + ); + expect(state.documents).toEqual([]); + expect(viewOf(state, tool)).toMatchObject({ shape: "recorded" }); + }, +); + it("orders a scenario capture by request order and needs its response bodies", () => { const state = stateOf(parentOf(scenarioCaptureEvidence())); expect(state.timeline).toBe("record-order"); @@ -531,6 +548,93 @@ it("leaves a type recorded when two of its tools read different values across a ]); }); +it.each([ + [[], [{ id: 1 }]], + [[{ id: 1 }], []], + [[{ id: 1 }], [{ id: 2 }]], +] as const)( + "keeps changed list membership across another root's write recorded (%j -> %j)", + (before, after) => { + const state = stateOf( + captureOf([ + ["GET", "/api/search", [...before]], + ["POST", "/api/items", { id: 2 }], + ["GET", "/api/search", [...after]], + ]), + ); + expect(state.types).toEqual([]); + expect(state.entities).toEqual([]); + expect(viewOf(state, "get_search")).toEqual({ + tool: "get_search", + shape: "recorded", + reason: "fields-differ", + }); + }, +); + +it("accepts stable membership across another root's write and changes confined before it", () => { + const state = stateOf( + captureOf([ + ["GET", "/api/search", []], + ["GET", "/api/search", [{ id: 1 }]], + ["POST", "/api/items", { id: 2 }], + ["GET", "/api/search", [{ id: 1 }]], + ]), + ); + expect(viewOf(state, "get_search")).toMatchObject({ shape: "list" }); + expect(state.entities.map((entity) => entity.id)).toEqual(["1"]); +}); + +it("keeps membership checks specific to the full request line", () => { + const state = stateOf( + captureOf([ + ["GET", "/api/search?page=1", [{ id: 1 }]], + ["POST", "/api/items", { id: 2 }], + ["GET", "/api/search?page=2", [{ id: 2 }]], + ]), + ); + expect(viewOf(state, "get_search")).toMatchObject({ + shape: "list", + membership: "recorded", + }); +}); + +it("does not treat a first observation as a membership change", () => { + const parent = captureOf([ + ["GET", "/api/search", [{ id: 1 }]], + ["POST", "/api/items", { id: 2 }], + ["GET", "/api/search", [{ id: 1 }]], + ]); + const data = structuredClone(parent.normalizedResult) as { + records: { reported: { time: number } }[]; + }; + data.records[0]!.reported.time = 1500; + const state = stateOf({ ...parent, normalizedResult: data }); + expect(viewOf(state, "get_search")).toMatchObject({ shape: "list" }); +}); + +it.each([ + [{ id: 1 }, { id: 1, title: "Created" }], + [{ id: 1, title: "Original" }, { id: 1 }], +])( + "keeps fields added or removed across another root's write recorded (%j -> %j)", + (before, after) => { + const state = stateOf( + captureOf([ + ["GET", "/api/search/1", before], + ["POST", "/api/items", { id: 2 }], + ["GET", "/api/search/1", after], + ]), + ); + expect(state.types).toEqual([]); + expect(state.entities).toEqual([]); + expect(viewOf(state, "get_search_by_id")).toMatchObject({ + shape: "recorded", + reason: "fields-differ", + }); + }, +); + it("reads only item totals and next-page markers as signs of a partial list", () => { const membership = (body: JsonValue) => stateOf(captureOf([["GET", "/api/items", body]])).views.map((view) => diff --git a/src/domain/analysisView/scenarioCaptureRecords.test.ts b/src/domain/analysisView/scenarioCaptureRecords.test.ts index f4260724a5..bb3a006374 100644 --- a/src/domain/analysisView/scenarioCaptureRecords.test.ts +++ b/src/domain/analysisView/scenarioCaptureRecords.test.ts @@ -206,6 +206,7 @@ it("reserves request text even when another event's body was not captured", () = normalizedResult: { ...capture, events: { ...capture.events, items } }, limitations: [], }, + "environment-manifest", new EnvironmentManifestAccumulator(8 * 1024 * 1024), ); expect(projected).toMatchObject({ diff --git a/tests/boundary/mcp/analysisViewMcp.test.ts b/tests/boundary/mcp/analysisViewMcp.test.ts index 5dc937400f..749e9665b5 100644 --- a/tests/boundary/mcp/analysisViewMcp.test.ts +++ b/tests/boundary/mcp/analysisViewMcp.test.ts @@ -1168,7 +1168,7 @@ it.each(PUBLISHED_EXAMPLES)( }, ); -it("extracts an environment state identically from retained and portable MCP Evidence", async () => { +it("extracts an environment state identically from retained, portable and file MCP Evidence", async () => { const { client, session } = await connect(); const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas( client, @@ -1177,6 +1177,11 @@ it("extracts an environment state identically from retained and portable MCP Evi const capture = environmentStateCaptureFixture(); const parent = analysisViewCaptureEvidence(capture); expect(session.recordEvidence(parent).ok).toBe(true); + const evidencePath = join( + await createTestTempDirectory("rea-state-file-mcp-"), + "capture.json", + ); + await writeFile(evidencePath, JSON.stringify(parent)); const view = { kind: "environment-state", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], @@ -1185,6 +1190,7 @@ it("extracts an environment state identically from retained and portable MCP Evi for (const source of [ { kind: "inline", evidence: parent }, { kind: "retained-evidence", evidence_id: parent.evidence_id }, + { kind: "evidence-file", path: evidencePath }, ]) { expect(ajv.validate(inputSchema, { source, view })).toBe(true); const response = await client.callTool({ @@ -1211,6 +1217,7 @@ it("extracts an environment state identically from retained and portable MCP Evi outputs.push(evidence.normalized_result); } expect(outputs[0]).toEqual(outputs[1]); + expect(outputs[2]).toEqual(outputs[0]); const hidden = createEvidence( { path: capture.artifact.path,