import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { lstat, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { AnswerViewStateError, AnswerViewStateStore, canonicalAnswerJson, type AnswerViewStatePut } from "../../src/sessions/answer-view-state-store.js"; import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js"; import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js"; const owner = { actorKey: "actor-a", projectKey: "project-a", sessionId: "session" }; const identity = { message_id: "assistant", block_id: "block" }; const chartProps = { title: "Chart", chart_type: "line", x_data: ["a", "b", "c"], series: [{ name: "pressure", data: [1, 2, 3] }, { name: "flow", data: [4, 5, 6] }], source: "source" }; const chart = { type: "Chart", props: chartProps, children: [] }; const table = { type: "Table", props: { title: "Table", columns: ["name", "value"], rows: [["a", "1"], ["b", "2"]] }, children: [] }; const form = { type: "FollowUpForm", props: { title: "Form", description: "description", fields: [{ name: "note", label: "Note", kind: "text", required: false }, { name: "count", label: "Count", kind: "number", required: false }] }, children: [] }; const evidence = { type: "Evidence", props: { title: "Evidence", summary: "Summary", source: "source", evidence_id: null, observed_at: null, kind: "observed" }, children: [] }; const spec = () => ({ root: "root", elements: { root: { type: "Stack", props: { columns: "one", gap: "normal" }, children: ["chart", "table", "form", "evidence"] }, chart: structuredClone(chart), table: structuredClone(table), form: structuredClone(form), evidence: structuredClone(evidence) } }); describe("answer view state", () => { let base: string; let workspaces: SessionWorkspaceStore; let messages: SessionUiStateStore; let states: AnswerViewStateStore; let currentSpec: ReturnType; const saveMessages = async (role = "assistant", parts?: unknown[]) => messages.write(owner, { sessionId: owner.sessionId, messages: [{ id: identity.message_id, role, parts: parts ?? [{ type: "data-answer_ui", data: { version: "answer-ui@1", block_id: identity.block_id, fallback_text: "Fallback", spec: currentSpec } }] }] }); const input = async (patch: Partial = {}): Promise => ({ ...identity, schema_digest: (await states.get(owner, identity)).schema_digest, expected_revision: 0, request_id: "request-1", nodes: {}, block: { evidenceFilter: "all" }, ...patch }); beforeEach(async () => { base = await mkdtemp(join(tmpdir(), "answer-state-")); workspaces = new SessionWorkspaceStore(join(base, "workspaces")); messages = new SessionUiStateStore(join(base, "unused"), workspaces); states = new AnswerViewStateStore(workspaces); currentSpec = spec(); await saveMessages(); }); afterEach(async () => { await rm(base, { recursive: true, force: true }); }); it("returns revision zero without writing and persists a private independent record", async () => { const first = await states.get(owner, identity); expect(first).toMatchObject({ version: "answer-view-state@1", ...identity, revision: 0, nodes: {}, block: { evidenceFilter: "all" }, updated_at: null }); await expect(lstat(workspaces.path(owner, "answer-view-state.json"))).rejects.toThrow(); const payload = await input({ nodes: { form: { type: "FollowUpForm", values: { note: "draft" } } } }); const saved = await states.put(owner, payload); expect(saved.revision).toBe(1); expect(await new AnswerViewStateStore(workspaces).get(owner, identity)).toEqual(saved); expect((await messages.read(owner))?.messages).not.toContain("draft"); expect((await lstat(workspaces.path(owner, "answer-view-state.json"))).mode & 0o777).toBe(0o600); const branch = { ...owner, sessionId: "branch" }; await messages.write(branch, { sessionId: branch.sessionId, messages: (await messages.read(owner))!.messages }); expect((await states.get(branch, identity)).nodes).toEqual({}); expect(canonicalAnswerJson({ b: 1, a: { y: 3, x: 2 } })).toBe(canonicalAnswerJson({ a: { x: 2, y: 3 }, b: 1 })); }); it("serializes concurrent CAS writes and preserves every idempotency response", async () => { const payload = await input(); const outcomes = await Promise.allSettled([states.put(owner, payload), states.put(owner, { ...payload, request_id: "other" })]); expect(outcomes.filter((result) => result.status === "fulfilled")).toHaveLength(1); const rejected = outcomes.find((result) => result.status === "rejected") as PromiseRejectedResult; expect(rejected.reason).toMatchObject({ status: 409, code: "ANSWER_VIEW_STATE_CONFLICT", current: { revision: 1 } }); const original = await states.put(owner, payload); await states.put(owner, { ...payload, request_id: "second", expected_revision: 1 }); expect(await states.put(owner, payload)).toEqual(original); await expect(states.put(owner, { ...payload, nodes: { evidence: { type: "Evidence", open: true } } })).rejects.toMatchObject({ status: 409, code: "ANSWER_VIEW_REQUEST_CONFLICT" }); }); it("uses saved assistant blocks and final publication rather than client component declarations", async () => { await expect(states.get(owner, { ...identity, message_id: "missing" })).rejects.toMatchObject({ status: 404 }); await saveMessages("user"); await expect(states.get(owner, identity)).rejects.toMatchObject({ status: 404 }); await saveMessages(); await expect(states.put(owner, await input({ nodes: { table: { type: "Evidence", open: true } } }))).rejects.toMatchObject({ status: 400 }); const parts: Array<{ type: string; data: Record }> = [{ type: "data-answer_ui", data: { version: "answer-ui@1", block_id: "block", fallback_text: "fallback", spec: currentSpec } }, { type: "data-final_answer", data: { content: "final", ui_block_ids: [] } }]; await saveMessages("assistant", parts); await expect(states.get(owner, identity)).rejects.toMatchObject({ status: 404 }); parts[1].data.ui_block_ids = ["block"]; await saveMessages("assistant", parts); expect((await states.get(owner, identity)).revision).toBe(0); currentSpec.elements.chart.props.series[0].data = []; await saveMessages(); await expect(states.get(owner, identity)).rejects.toMatchObject({ status: 404 }); }); it("rejects explicit process blocks even when final refs list them and retains legacy/final access", async () => { const payload = await input(); const parts = (phase?: string) => [{ type: "data-answer_ui", data: { version: "answer-ui@1", block_id: "block", phase, fallback_text: "fallback", spec: currentSpec } }, { type: "data-final_answer", data: { content: "final", ui_block_ids: ["block"] } }]; // Save a receipt first: retries must recheck current block visibility too. await states.put(owner, payload); await saveMessages("assistant", parts("process")); await expect(states.get(owner, identity)).rejects.toMatchObject({ status: 404, code: "ANSWER_VIEW_BLOCK_NOT_FOUND" }); await expect(states.put(owner, payload)).rejects.toMatchObject({ status: 404, code: "ANSWER_VIEW_BLOCK_NOT_FOUND" }); await saveMessages("assistant", parts("final")); expect((await states.get(owner, identity)).revision).toBe(1); await saveMessages("assistant", parts()); expect((await states.get(owner, identity)).revision).toBe(1); }); it("enforces exact fields, real catalog bounds and 500-character form drafts", async () => { const valid = await input(); for (const nodes of [ { chart: { type: "Chart", view: "line", hidden: [0, 1], range: [0, 2], point: 0, inspecting: false } }, { chart: { type: "Chart", view: "line", hidden: [], range: [2, 0], point: 0, inspecting: false } }, { table: { type: "Table", query: "", sort: null, selection: [2] } }, { table: { type: "Table", query: "", sort: { column: 2, descending: false }, selection: [] } }, { form: { type: "FollowUpForm", values: { note: "a".repeat(501) } } }, { form: { type: "FollowUpForm", values: { invented: "value" } } }, { evidence: { type: "Evidence", open: true, arbitrary: "field" } }, { root: { type: "Evidence", open: true } }, ]) await expect(states.put(owner, { ...valid, nodes })).rejects.toMatchObject({ status: 400 }); await expect(states.put(owner, undefined)).rejects.toMatchObject({ status: 400 }); await expect(states.put(owner, { ...valid, arbitrary: true })).rejects.toMatchObject({ status: 400 }); await expect(states.put(owner, { ...valid, nodes: { form: { type: "FollowUpForm", values: { note: "a".repeat(40000) } } } })).rejects.toMatchObject({ status: 413 }); expect((await states.put(owner, { ...valid, nodes: { form: { type: "FollowUpForm", values: { note: "a".repeat(500) } } } })).revision).toBe(1); }); it("applies the block size limit to UTF-8 bytes of otherwise valid controls", async () => { const extended = currentSpec.elements as Record; const fields = Array.from({ length: 12 }, (_, index) => ({ name: `field_${index}`, label: `Field ${index}`, kind: "text", required: false })); const nodes: AnswerViewStatePut["nodes"] = {}; for (const name of ["form", "form_two"]) { extended[name] = { type: "FollowUpForm", props: { ...form.props, fields }, children: [] }; if (name !== "form") currentSpec.elements.root.children.push(name); nodes[name] = { type: "FollowUpForm", values: Object.fromEntries(fields.map((field) => [field.name, "水".repeat(500)])) }; } await saveMessages(); const payload = await input({ nodes }); expect(JSON.stringify(payload).length).toBeLessThan(32768); expect(Buffer.byteLength(JSON.stringify(payload))).toBeGreaterThan(32768); await expect(states.put(owner, payload)).rejects.toMatchObject({ status: 413 }); expect((await states.get(owner, identity)).revision).toBe(0); }); it("migrates current spec by stable labels and field types, without copying readings", async () => { const saved = await states.put(owner, await input({ block: { evidenceFilter: "observed" }, nodes: { chart: { type: "Chart", view: "table", hidden: [1], range: [1, 2], point: 2, inspecting: true }, table: { type: "Table", query: "search", sort: { column: 1, descending: true }, selection: [1] }, form: { type: "FollowUpForm", values: { note: "draft", count: "42" } }, evidence: { type: "Evidence", open: true }, } })); const oldBytes = await readFile(workspaces.path(owner, "answer-view-state.json"), "utf8"); expect(oldBytes).not.toContain('"data"'); currentSpec.elements.chart.props.x_data = ["new", "a", "b", "c"]; currentSpec.elements.chart.props.series = [{ name: "flow", data: [11, 12, 13, 14] }, { name: "pressure", data: [15, 16, 17, 18] }]; currentSpec.elements.table.props.rows[1][1] = "changed"; currentSpec.elements.form.props.fields[1].kind = "text"; currentSpec.elements.evidence.props.kind = "inference"; await saveMessages(); const restored = await states.get(owner, identity); expect(restored.revision).toBe(1); expect(restored.schema_digest).not.toBe(saved.schema_digest); expect(restored.nodes.chart).toEqual({ type: "Chart", view: "table", hidden: [], range: [2, 3], point: 3, inspecting: true }); expect(restored.nodes.table).toMatchObject({ query: "search", sort: { column: 1, descending: true }, selection: [] }); expect(restored.nodes.form).toEqual({ type: "FollowUpForm", values: { note: "draft" } }); expect(restored.block.evidenceFilter).toBe("all"); expect(await readFile(workspaces.path(owner, "answer-view-state.json"), "utf8")).toBe(oldBytes); await expect(states.put(owner, await input({ schema_digest: saved.schema_digest, expected_revision: 1, request_id: "stale" }))).rejects.toMatchObject({ status: 409, code: "ANSWER_VIEW_SCHEMA_CHANGED", current: restored }); currentSpec.elements.chart.props.x_data = ["a", "b", "b", "c"]; currentSpec.elements.table.props.columns.reverse(); currentSpec.elements.form = { type: "Text", props: { text: "new", tone: "neutral" }, children: [] } as unknown as typeof form; await saveMessages(); const changed = await states.get(owner, identity); expect(changed.nodes.chart).toMatchObject({ range: [0, 3], inspecting: false }); expect(changed.nodes.table).toMatchObject({ sort: null, selection: [] }); expect(changed.nodes.form).toBeUndefined(); }); it("bounds retry receipts so continuous saving does not exhaust the session", async () => { const payload = await input({ nodes: { form: { type: "FollowUpForm", values: { note: "水".repeat(500) } } } }); for (let revision = 0; revision < 100; revision++) await states.put(owner, { ...payload, expected_revision: revision, request_id: `save-${revision}` }); const file = JSON.parse(await readFile(workspaces.path(owner, "answer-view-state.json"), "utf8")); expect(Object.keys(file.requests)).toHaveLength(32); expect(Buffer.byteLength(JSON.stringify(file.requests))).toBeLessThanOrEqual(131072); expect(file.entries[JSON.stringify([identity.message_id, identity.block_id])].state.revision).toBe(100); await expect(states.put(owner, { ...payload, request_id: "save-0" })).rejects.toMatchObject({ status: 409, code: "ANSWER_VIEW_STATE_CONFLICT" }); // Existing state entries cannot be evicted to make room for a new block. file.padding = "x".repeat(1048576); await writeFile(workspaces.path(owner, "answer-view-state.json"), JSON.stringify(file)); await expect(states.put(owner, { ...payload, expected_revision: 100, request_id: "over-limit" })).rejects.toMatchObject({ status: 413 }); expect((await states.get(owner, identity)).revision).toBe(100); }); it("validates nested chart tables and clears their selections when readings or derived columns change", async () => { const payload = await input({ nodes: { chart: { type: "Chart", view: "table", hidden: [], range: [0, 2], point: 1, inspecting: true, table: { query: "search", sort: { column: 2, descending: false }, selection: [1] } } } }); await states.put(owner, payload); currentSpec.elements.chart.props.series[0].data[0] = 99; await saveMessages(); expect((await states.get(owner, identity)).nodes.chart).toMatchObject({ table: { query: "search", sort: { column: 2, descending: false }, selection: [] } }); currentSpec.elements.chart.props.series.reverse(); await saveMessages(); expect((await states.get(owner, identity)).nodes.chart).toMatchObject({ table: { sort: null, selection: [] } }); await expect(states.put(owner, await input({ expected_revision: 1, request_id: "bad", nodes: { chart: { type: "Chart", view: "table", hidden: [1], range: [1, 2], point: 1, inspecting: true, table: { query: "", sort: { column: 2, descending: false }, selection: [] } } } }))).rejects.toMatchObject({ status: 400 }); }); it("resets ambiguous chart label selections when only readings change", async () => { currentSpec.elements.chart.props.x_data = ["duplicate", "duplicate", "unique"]; await saveMessages(); await states.put(owner, await input({ nodes: { chart: { type: "Chart", view: "table", hidden: [1], range: [1, 2], point: 1, inspecting: true, table: { query: "keep", sort: null, selection: [0] } } } })); currentSpec.elements.chart.props.series[0].data[0] = 99; await saveMessages(); expect((await states.get(owner, identity)).nodes.chart).toEqual({ type: "Chart", view: "table", hidden: [1], range: [0, 2], point: 0, inspecting: false, table: { query: "keep", sort: null, selection: [] } }); }); it("reads current messages inside the queue and blocks writes queued behind deletion", async () => { const payload = await input(); let release!: () => void; const barrier = new Promise((resolve) => { release = resolve; }); const pending = workspaces.updateRecord(owner, "answer-view-state.json", async () => { await barrier; return { result: undefined }; }); currentSpec.elements.table.props.rows[0][0] = "changed"; const updateMessages = workspaces.writeRecord(owner, "messages.json", { sessionId: owner.sessionId, messages: [{ id: identity.message_id, role: "assistant", parts: [{ type: "data-answer_ui", data: { version: "answer-ui@1", block_id: identity.block_id, fallback_text: "fallback", spec: currentSpec } }] }] }); const write = states.put(owner, payload); release(); await pending; await updateMessages; await expect(write).rejects.toMatchObject({ code: "ANSWER_VIEW_SCHEMA_CHANGED" }); const fresh = await input(); const deletion = workspaces.markDeleted(owner); const late = states.put(owner, fresh); await deletion; await expect(late).rejects.toThrow(); await expect(lstat(workspaces.path(owner, "answer-view-state.json"))).rejects.toThrow(); }); it("does not cross ownership, follow symlinks or revive deleted records", async () => { const payload = await input(); await expect(states.get({ ...owner, actorKey: "actor-b" }, identity)).rejects.toThrow(); await expect(states.put({ ...owner, projectKey: "project-b" }, payload)).rejects.toThrow(); const outside = join(base, "outside.json"); await writeFile(outside, '{}'); await symlink(outside, workspaces.path(owner, "answer-view-state.json")); await expect(states.get(owner, identity)).rejects.toThrow("symbolic link"); await expect(states.put(owner, payload)).rejects.toThrow("symbolic link"); await rm(workspaces.path(owner, "answer-view-state.json")); await workspaces.markDeleted(owner); await expect(states.put(owner, payload)).rejects.toThrow(); await workspaces.purge(owner); await expect(states.put(owner, payload)).rejects.toThrow(); await expect(lstat(workspaces.path(owner, "answer-view-state.json"))).rejects.toThrow(); }); });