diff --git a/docs/agent-environments.md b/docs/agent-environments.md index ab05c16fa9..631c3bd725 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -442,6 +442,11 @@ A read with repeated query values uses replay, because the state query model does not infer repeated-parameter semantics. A repeated-query write is refused before it can change state for the same reason. +List counters retain their recorded number or digit-string representation. +Changing a digit-string counter preserves its observed zero-padding width; +query-total inference remains limited to canonical decimal counts. Path matching +uses the same segment parser as extraction, preserving empty internal segments. + An item or list view that always spells a clock field otherwise than the entity's stored value names that spelling in `clock_formats`, and a value it never recorded, such as a created entity's clock, is rendered in it: a list that diff --git a/src/application/environment/environmentStateAnswer.test.ts b/src/application/environment/environmentStateAnswer.test.ts index 53e1dff00c..df70fc3471 100644 --- a/src/application/environment/environmentStateAnswer.test.ts +++ b/src/application/environment/environmentStateAnswer.test.ts @@ -52,6 +52,21 @@ const answer = ( return result.value; }; +it("finds state entities using the recorded path's repeated separators", () => { + const serving = servingFrom([ + ["GET", "/api//books/1", { id: 1, title: "Book" }], + ]); + const tool = serving.source.manifest.tools.find( + (candidate) => candidate.path_template === "/api//books/{id}", + ); + if (tool === undefined) throw new Error("Missing double-separator item tool"); + const result = answerFromState(serving, tool, { id: "1" }); + expect(result).toMatchObject({ + ok: true, + value: { body: { id: 1, title: "Book" }, state: { source: "state" } }, + }); +}); + const refusal = ( serving: StateServing, name: string, diff --git a/src/application/environment/environmentStateCallLines.ts b/src/application/environment/environmentStateCallLines.ts index 16709cd655..b2a0d3c9e7 100644 --- a/src/application/environment/environmentStateCallLines.ts +++ b/src/application/environment/environmentStateCallLines.ts @@ -2,6 +2,7 @@ import { allLines, collectionPathOf, collectionMatch, + segmentsOf, type SuppliedSegment, type StateListView, } from "../../domain/analysisView/environmentStateRules.js"; @@ -27,13 +28,11 @@ export const callSegments = ( const names = environmentToolArguments(tool).path; const params = new Map(tool.params.map((param) => [param.index, param])); const segments: SuppliedSegment[] = []; - const template = tool.path_template - .slice(tool.path_template.indexOf("/") + 1) - .split("/"); + const template = segmentsOf(tool.path_template); for (const [index, text] of template.entries()) { const param = params.get(index); if (param === undefined) { - if (text !== "") segments.push({ text, supplied: false }); + segments.push({ text, supplied: false }); continue; } const value = args[names.get(param.name) ?? param.name]; diff --git a/src/domain/analysisView/WebNetworkProjectionAccumulator.ts b/src/domain/analysisView/WebNetworkProjectionAccumulator.ts index 2f9563f019..b7e770b3ee 100644 --- a/src/domain/analysisView/WebNetworkProjectionAccumulator.ts +++ b/src/domain/analysisView/WebNetworkProjectionAccumulator.ts @@ -13,6 +13,7 @@ import { } from "../webNetworkCapture.js"; import { admitAnalysisViewData } from "./analysisViewDataAdmission.js"; import { analysisViewInputError } from "./analysisViewErrors.js"; +import { analysisViewResultInputError } from "./analysisViewParent.js"; import type { AnalysisViewParent } from "./analysisView.js"; import { ENTRY_BYTES, @@ -124,14 +125,7 @@ export const admitHarCaptureParent = ( const parsed = safeParseOwnedWebNetworkCapture(parent.normalizedResult); if (!parsed.success) return err( - analysisViewInputError([ - { - path: ["source"], - reason: "invalid_value", - message: - "Parent Evidence result does not match inspect_web_network_capture.", - }, - ]), + analysisViewResultInputError(parent.normalizedResult, parsed.error), ); if (parsed.data.format !== "har") return unsupported( diff --git a/src/domain/analysisView/environmentStateGate.ts b/src/domain/analysisView/environmentStateGate.ts index b1662e1e48..938efd7536 100644 --- a/src/domain/analysisView/environmentStateGate.ts +++ b/src/domain/analysisView/environmentStateGate.ts @@ -7,6 +7,7 @@ import { isObject, lastSegmentValue, listItems, + recordedItemCount, renderStateEntity, renderStateList, replaceAt, @@ -280,17 +281,22 @@ const listFacts = ( items.forEach((item) => union.add(item)); const parent = plan.pointer === "" || !isObject(body) ? {} : body; const counted = Object.keys(parent).filter( - (name) => isItemTotal(name) && countOf(parent[name]) !== undefined, + (name) => + isItemTotal(name) && recordedItemCount(parent[name]) !== undefined, ); counting.set( record.ordinal, - counted.filter((name) => parent[name] === items.length), + counted.filter( + (name) => recordedItemCount(parent[name]) === items.length, + ), ); // Only a line that pages at most can hold its whole collection; a total that differs from // the number listed, or a next page, shows it is partial. if ( !startsAtBeginning(record.query) || - counted.some((name) => countOf(parent[name]) !== items.length) || + counted.some( + (name) => recordedItemCount(parent[name]) !== items.length, + ) || Object.keys(parent).some((name) => continues(name, parent[name])) ) return; @@ -332,14 +338,6 @@ const isItemTotal = (name: string): boolean => { ); }; -/** A count recorded as a number or as a string of digits. */ -const countOf = (value: JsonValue | undefined): number | undefined => - typeof value === "number" - ? value - : typeof value === "string" && /^\d+$/u.test(value) - ? Number(value) - : undefined; - const CONTINUATION_NAMES = new Set([ "hasmore", "next", diff --git a/src/domain/analysisView/environmentStateListQuery.ts b/src/domain/analysisView/environmentStateListQuery.ts index 280d3325a3..b79a78be34 100644 --- a/src/domain/analysisView/environmentStateListQuery.ts +++ b/src/domain/analysisView/environmentStateListQuery.ts @@ -3,6 +3,8 @@ import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js import { literalSubstringPredicate } from "../analysisSearch.js"; import { isObject, + recordedItemCount, + formatItemCount, pointerTo, renderStateList, replaceAt, @@ -569,13 +571,11 @@ export const learnQuery = ( export const queryTotalCount = ( value: JsonValue | undefined, ): number | undefined => { - const count = - typeof value === "number" - ? value - : typeof value === "string" && /^(?:0|[1-9]\d*)$/u.test(value) - ? Number(value) - : undefined; - return count !== undefined && Number.isSafeInteger(count) && count >= 0 + const count = recordedItemCount(value); + return count !== undefined && + Number.isSafeInteger(count) && + count >= 0 && + (typeof value !== "string" || String(count) === value) ? count : undefined; }; @@ -594,11 +594,7 @@ export const renderQueryList = ( isObject(base) && Object.hasOwn(base, name) && queryTotalCount(base[name]) !== undefined - ? replaceAt( - body, - pointerTo(name), - typeof base[name] === "string" ? String(total) : total, - ) + ? replaceAt(body, pointerTo(name), formatItemCount(total, base[name]!)) : body, renderStateList(view, base, items), ); diff --git a/src/domain/analysisView/environmentStateRules.ts b/src/domain/analysisView/environmentStateRules.ts index 3f53ffcc9d..7346c6b2ed 100644 --- a/src/domain/analysisView/environmentStateRules.ts +++ b/src/domain/analysisView/environmentStateRules.ts @@ -596,6 +596,25 @@ export const listItems = ( return Array.isArray(items) && items.every(isObject) ? items : undefined; }; +/** An observed item count, retaining the original value separately for rendering. */ +export const recordedItemCount = ( + value: JsonValue | undefined, +): number | undefined => + typeof value === "number" + ? value + : typeof value === "string" && /^\d+$/u.test(value) + ? Number(value) + : undefined; + +/** Spell a changed count with the recorded numeric/string type and digit width. */ +export const formatItemCount = ( + count: number, + observed: JsonValue, +): JsonValue => + typeof observed === "string" + ? String(count).padStart(observed.length, "0") + : count; + /** One entity as a view shows it: the view's fields it has, view-specific values first; volatile fields take `observed` values when given. */ export const renderStateEntity = ( entity: StateEntity, @@ -650,10 +669,13 @@ export const renderStateList = ( ): JsonValue => { const listed = listItems(base, view.pointer)?.length; return view.counters.reduce( - (body, name) => - at(base, pointerTo(name)) === listed - ? replaceAt(body, pointerTo(name), items.length) - : body, + (body, name) => { + const observed = at(base, pointerTo(name)); + if (listed === undefined || recordedItemCount(observed) !== listed) + return body; + const count = formatItemCount(items.length, observed!); + return replaceAt(body, pointerTo(name), count); + }, replaceAt(base, view.pointer, [...items]), ); }; diff --git a/src/domain/analysisView/environmentStateView.test.ts b/src/domain/analysisView/environmentStateView.test.ts index 04cbcdf9a3..f1b5e7342d 100644 --- a/src/domain/analysisView/environmentStateView.test.ts +++ b/src/domain/analysisView/environmentStateView.test.ts @@ -23,6 +23,7 @@ import { at, identityCandidates, replaceAt, + renderStateList, type JsonObject, } from "./environmentStateRules.js"; import { environmentStateFields } from "./environmentStateView.js"; @@ -332,6 +333,25 @@ it("lists the whole collection with its counter only when every recording did", ); }); +it.each([1, "1", "001"])( + "retains item count representation %j in extracted list plans", + (total) => { + const body = { total, items: [{ id: 1, title: "One" }] }; + const state = stateOf(captureOf([["GET", "/api/items", body]])); + const view = state.views.find((candidate) => candidate.shape === "list"); + if (view?.shape !== "list") throw new Error("Expected an item list view"); + expect(view.counters).toEqual(["total"]); + expect(renderStateList(view, body, body.items)).toEqual(body); + const added = renderStateList(view, body, [ + ...body.items, + { id: 2, title: "Two" }, + ]); + expect(added).toMatchObject({ + total: typeof total === "string" ? "2".padStart(total.length, "0") : 2, + }); + }, +); + it("leaves recorded the views it cannot reproduce, with the reason", () => { const state = stateOf( captureOf([ diff --git a/src/domain/analysisView/webNetworkApiSurfaceView.test.ts b/src/domain/analysisView/webNetworkApiSurfaceView.test.ts index 0c5e0b5bd2..d2e9662bd7 100644 --- a/src/domain/analysisView/webNetworkApiSurfaceView.test.ts +++ b/src/domain/analysisView/webNetworkApiSurfaceView.test.ts @@ -3,7 +3,11 @@ import { analysisViewCaptureFixture, analysisViewCaptureEvidence, } from "../../../tests/fixtures/analysisView.js"; -import { projectAnalysisView } from "./analysisView.js"; +import { + analysisViewRequestSchema, + projectAnalysisView, +} from "./analysisView.js"; +import { projectAnalysisError } from "../analysisErrorProjection.js"; const project = ( capture = analysisViewCaptureFixture(), @@ -208,6 +212,39 @@ it("rejects native captures and malformed parents with typed errors", () => { ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); }); +it.each(["api-surface", "environment-manifest", "environment-state"])( + "preserves nested capture-schema diagnostics for %s", + (kind) => { + const capture = analysisViewCaptureFixture(); + const result = projectAnalysisView( + { + evidenceId: `ev_${"a".repeat(64)}`, + operation: "inspect_web_network_capture", + limitations: [], + parameters: { sensitive_value_count: 0 }, + normalizedResult: { + ...capture, + artifact: { ...capture.artifact, bytes: "invalid" }, + }, + }, + analysisViewRequestSchema.parse({ kind }), + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected parent admission to fail"); + expect(result.error.cause).toMatchObject({ name: "ZodError" }); + expect(projectAnalysisError(result.error)).toMatchObject({ + details: { + issues: [ + expect.objectContaining({ + path: ["source", "normalized_result", "artifact", "bytes"], + reason: "invalid_type", + }), + ], + }, + }); + }, +); + it("preserves partial response facts and explains unavailable requests", () => { const capture = analysisViewCaptureFixture(); capture.records = capture.records.slice(0, 5);