feat: upgrade investigation and presentation runtime
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
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<typeof spec>;
|
||||
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<AnswerViewStatePut> = {}): Promise<AnswerViewStatePut> => ({ ...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<string, unknown> }> = [{ 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<string, unknown>;
|
||||
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<void>((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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user