feat: upgrade investigation and presentation runtime
Generic Container CI/CD / test-build-publish (push) Successful in 1m6s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 1m7s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent 3b52a0ddab
commit 6336157ff4
104 changed files with 6541 additions and 961 deletions
+234
View File
@@ -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();
});
});