Files
next-tjwater-agent/tests/chat/answer-ui.test.ts
T
jiang b8f7edb3ee
Generic Container CI/CD / test-build-publish (push) Successful in 1m53s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 1m53s
feat(agent): improve investigation contracts and runtime readiness
2026-11-07 19:43:57 +08:00

180 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
import { describe, expect, it } from "bun:test";
import { parseAnswerUiInput } from "../../src/chat/answer-ui.js";
import { streamPromptResponse } from "../../src/routes/chatStream.js";
import { toCollaborationMessages } from "../../src/routes/chatStreamRoute.js";
import type { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
import type { UIMessage } from "ai";
const input = { block_id: "pressure", fallback_text: "演练压力 0.3 MPa", spec: { root: "metric", elements: { metric: { type: "Metric", props: { label: "压力", value: "0.3", unit: "MPa", source: "演练数据" }, children: [] } } } };
describe("locally rendered answers", () => {
it("validates catalog props instead of accepting arbitrary model components", () => {
expect(parseAnswerUiInput(input)?.version).toBe("answer-ui@1");
for (const metric of [
{ ...input.spec.elements.metric, type: "Script" },
{ ...input.spec.elements.metric, props: { ...input.spec.elements.metric.props, execute: "alert(1)" } },
{ ...input.spec.elements.metric, children: ["metric"] },
]) expect(parseAnswerUiInput({ ...input, spec: { root: "metric", elements: { metric } } })).toBeNull();
});
it("accepts semantic presentation hints and legacy props while keeping results bounded and passive", () => {
const result = { type: "Result", props: { title: "记录已保存", summary: "演练记录已保存,现场状态待核验。", status: "success", source: "保存工具回执" }, children: [] as string[] };
const section = { type: "Section", props: { title: "处理结果", description: null, role: "result" }, children: ["result"] };
const stack = { type: "Stack", props: { columns: "one", gap: "normal", intent: "response", density: "comfortable" }, children: ["section"] };
const spec = { root: "stack", elements: { stack, section, result } };
expect(parseAnswerUiInput({ ...input, spec })).not.toBeNull();
const leaf = (node: unknown) => parseAnswerUiInput({ ...input, spec: { root: "leaf", elements: { leaf: node } } });
for (const status of ["success", "partial", "unconfirmed", "error"]) {
expect(leaf({ ...result, props: { ...result.props, status, source: null } })).not.toBeNull();
}
expect(leaf({ type: "Text", props: { title: "说明", text: "已有事实", tone: "neutral" }, children: [] })).not.toBeNull();
expect(parseAnswerUiInput({ ...input, spec: { ...spec, elements: { ...spec.elements,
stack: { ...stack, props: { columns: "one", gap: "normal" } },
section: { ...section, props: { title: "处理结果", description: null } },
} } })).not.toBeNull();
for (const props of [
{ ...result.props, status: "done" }, { ...result.props, source: undefined },
{ ...result.props, title: "a".repeat(201) }, { ...result.props, summary: "a".repeat(4001) },
{ ...result.props, source: "a".repeat(201) }, { ...result.props, style: { color: "red" } },
{ ...result.props, action: "execute" },
]) expect(leaf({ ...result, props })).toBeNull();
expect(leaf({ ...result, children: ["leaf"] })).toBeNull();
for (const props of [
{ ...stack.props, intent: "custom" }, { ...stack.props, density: "tiny" },
{ intent: "response", density: "compact" }, { ...stack.props, className: "grid" },
]) expect(parseAnswerUiInput({ ...input, spec: { ...spec, elements: { ...spec.elements, stack: { ...stack, props } } } })).toBeNull();
expect(leaf({ type: "Text", props: { title: "a".repeat(201), text: "说明", tone: "neutral" }, children: [] })).toBeNull();
expect(parseAnswerUiInput({ ...input, spec: { ...spec, elements: { ...spec.elements,
section: { ...section, props: { ...section.props, role: "custom" } },
} } })).toBeNull();
});
it("rejects chart misalignment, nonfinite numbers and unreachable nodes", () => {
const node = { type: "Chart", props: { title: "演练", chart_type: "line", source: "演练数据", x_data: ["08:00"], series: [{ name: "压力", data: [0.3] }] }, children: [] };
const withNode = (value: unknown) => ({ ...input, spec: { root: "chart", elements: { chart: value } } });
expect(parseAnswerUiInput(withNode(node))).not.toBeNull();
for (const data of [[0.3, 0.4], [Infinity], [NaN]]) expect(parseAnswerUiInput(withNode({ ...node, props: { ...node.props, series: [{ name: "压力", data }] } }))).toBeNull();
expect(parseAnswerUiInput({ ...input, spec: { ...input.spec, elements: { ...input.spec.elements, unused: node } } })).toBeNull();
});
it("preserves historical scatter timestamps and missing slots in comparison charts", () => {
const props = {
title: "压力七天对比", chart_type: "line", source: "冻结SCADA观测",
x_data: ["19:30", "19:45", "20:00"], y_axis_name: "压力(m)",
series: [
{ name: "当前", type: "line", data: [32.1, null, 31.9] },
{ name: "前一天", type: "scatter", data: [null, 32.4, 32.2],
sample_times: [null, "2026-06-13T19:45:00+08:00", "2026-06-13T20:00:00+08:00"] },
],
};
const withProps = (value: unknown) => ({ ...input, spec: { root: "chart", elements: {
chart: { type: "Chart", props: value, children: [] },
} } });
expect(parseAnswerUiInput(withProps(props))?.spec.elements.chart.props).toEqual(props);
for (const sample_times of [
[null],
["2026-06-13T19:30:00+08:00", null, null],
[null, "2026-06-13T19:45:00", null],
[null, "2026-02-30T19:45:00+08:00", null],
]) expect(parseAnswerUiInput(withProps({ ...props, series: [{ ...props.series[1], sample_times }] }))).toBeNull();
});
it("flushes prose before the interface, publishes once per tool call, and suppresses background UI", async () => {
const part = { id: "ui-call", type: "tool", tool: "present_answer", sessionID: "s", messageID: "m", state: { status: "completed", input, output: "{}", time: { start: 1, end: 2 } } };
const events = [
{ type: "message.part.updated", properties: { sessionID: "s", part: { id: "text", type: "text", text: "先看演练资料。", sessionID: "s", messageID: "m" } } },
...[part, part].map((value) => ({ type: "message.part.updated", properties: { sessionID: "s", part: value } })),
{ type: "session.idle", properties: { sessionID: "s" } },
];
const runtime = { prompt: async () => undefined, messages: async () => [], subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() { for (const event of events) yield event; } }) } as unknown as OpencodeRuntimeAdapter;
for (const suppressAnswerUi of [false, true]) {
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({ runtime, sessionId: "s", clientSessionId: "client", message: "演练", suppressAnswerUi, write: (event, data) => emitted.push({ event, data }) });
expect(emitted.filter((event) => event.event === "answer_ui")).toHaveLength(suppressAnswerUi ? 0 : 1);
if (!suppressAnswerUi) expect(emitted.findIndex((event) => event.event === "token")).toBeLessThan(emitted.findIndex((event) => event.event === "answer_ui"));
}
});
it("persists rich parts in the owned chat store and restores them without cross-scope access", async () => {
const root = await mkdtemp(join(tmpdir(), "tjwater-rich-answer-"));
try {
const owner = { actorKey: "actor-answer", projectKey: "project-answer", sessionId: "answer-session" };
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
const store = new SessionUiStateStore(join(root, "legacy"), workspaces);
const parts: UIMessage["parts"] = [{ type: "text", text: "演练:" }, { type: "data-answer_ui", id: "pressure", data: parseAnswerUiInput(input) }, { type: "text", text: "仍待核验。" }];
const messages = [{ id: "a", role: "assistant", parts }];
await store.write(owner, { sessionId: owner.sessionId, messages });
const restored = new SessionUiStateStore(join(root, "legacy"), new SessionWorkspaceStore(workspaces.root));
expect((await restored.read(owner))?.messages).toEqual(messages);
expect(await restored.read({ ...owner, actorKey: "actor-other" })).toBeNull();
expect(await restored.read({ ...owner, projectKey: "project-other" })).toBeNull();
} finally { await rm(root, { recursive: true, force: true }); }
});
it("preserves UI-only answers and ordered rich parts in task collaboration history", () => {
const parts: UIMessage["parts"] = [{ type: "text", text: "演练:" }, { type: "data-answer_ui", id: "pressure", data: parseAnswerUiInput(input) }, { type: "text", text: "仍需核验。" }, { type: "data-final_answer", data: { content: "结论待确认。" } }];
const messages = [{ id: "a", role: "assistant", parts: [...parts, { type: "data-progress", data: { title: "查询" } }] }, { id: "b", role: "assistant", parts: [parts[1]] }] as unknown as UIMessage[];
const restored = toCollaborationMessages(messages);
expect(restored[0].parts).toEqual(parts);
expect(restored[1].parts).toEqual([parts[1]]);
});
});
it("finishes a rich chat turn at final_answer and prevents later automatic UI revisions", async () => {
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
const interrupted: string[] = [];
const toolPart = (id: string, tool: string, args: unknown, output = "{}") => ({ type: "message.part.updated", properties: { sessionID: "s", part: { id, type: "tool", tool, sessionID: "s", messageID: "m", state: { status: "completed", input: args, output, time: { start: 1, end: 2 } } } } });
const events = [toolPart("ui", "present_answer", { ...input, phase: "final" }), toolPart("final", "final_answer", { answer: "演练结论仍待核验。" }, JSON.stringify({ answer: "演练结论仍待核验。", ui_block_ids: ["pressure", "unknown", "pressure"] })), toolPart("late", "present_answer", { ...input, block_id: "late" }), { type: "session.idle", properties: { sessionID: "s" } }];
const runtime = { prompt: async () => undefined, messages: async () => [], abortSession: async (id: string) => { interrupted.push(id); }, waitForSessionIdle: async () => undefined, subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() { for (const event of events) yield event; } }) } as unknown as OpencodeRuntimeAdapter;
const result = await streamPromptResponse({ runtime, sessionId: "s", clientSessionId: "client", message: "演练", write: (event, data) => emitted.push({ event, data }) });
expect(result).toMatchObject({ failed: false, aborted: false });
expect(emitted.filter((event) => event.event === "answer_ui")).toHaveLength(1);
expect(interrupted).toEqual(["s"]);
expect(emitted.find((event) => event.event === "final_answer")?.data.content).toBe("演练结论仍待核验。");
expect(emitted.find((event) => event.event === "final_answer")?.data.ui_block_ids).toEqual(["pressure"]);
expect(emitted.filter((event) => event.event === "done")).toHaveLength(1);
interrupted.length = 0;
emitted.length = 0;
await streamPromptResponse({ runtime, sessionId: "s", clientSessionId: "client", message: "后台执行", suppressAnswerUi: true, write: (event, data) => emitted.push({ event, data }) });
expect(interrupted).toEqual([]);
expect(emitted.filter((event) => event.event === "answer_ui")).toHaveLength(0);
expect(emitted.filter((event) => event.event === "done")).toHaveLength(1);
});
it("normalizes new publication phases and preserves immutable phases and finalization", async () => {
const { AnswerUiPublication, answerUiAnchors } = await import("../../src/chat/answer-ui-publication.js");
const state = new AnswerUiPublication();
const block = parseAnswerUiInput(input)!;
expect(block.phase).toBe("process");
expect(parseAnswerUiInput({ ...input, phase: "unknown" })).toBeNull();
expect(state.publish(block)).toBeUndefined();
expect(state.publish({ ...block, fallback_text: "更新" })).toBeUndefined();
expect(state.publish({ ...block, phase: "final" })).toContain("不能变更");
expect(state.publish({ ...block, block_id: "final", phase: "final" })).toBeUndefined();
expect(state.publish({ ...block, block_id: "legacy", phase: undefined })).toBeUndefined();
expect(state.finalize(["pressure", "final", "unknown", "final", "legacy"])).toEqual(["final", "legacy"]);
expect(state.publish(block)).toContain("最终答复已提交");
expect(answerUiAnchors("[[ui:final]]\n```md\n[[ui:code]]\n```\n [[ui:indented]]\n> [[ui:quote]]\n~~~\n[[ui:tilde]]\n~~~\n[[ui:legacy]]")).toEqual(["final", "legacy"]);
});
it("filters process refs in the stream and refuses changing an existing block phase", async () => {
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
const event = (id: string, tool: string, args: unknown, output = "{}") => ({ type: "message.part.updated", properties: { sessionID: "s", part: { id, type: "tool", tool, sessionID: "s", messageID: "m", state: { status: "completed", input: args, output, time: { start: 1, end: 2 } } } } });
const events = [event("p", "present_answer", input), event("p2", "present_answer", { ...input, fallback_text: "更新过程" }), event("bad", "present_answer", { ...input, phase: "final" }), event("f", "present_answer", { ...input, phase: "final", block_id: "report" }), event("end", "final_answer", {}, JSON.stringify({ answer: "最终正文\n[[ui:report]]", ui_block_ids: ["pressure", "report"] })), { type: "session.idle", properties: { sessionID: "s" } }];
const runtime = { prompt: async () => undefined, messages: async () => [], abortSession: async () => undefined, waitForSessionIdle: async () => undefined, subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() { for (const value of events) yield value; } }) } as unknown as OpencodeRuntimeAdapter;
await streamPromptResponse({ runtime, sessionId: "s", clientSessionId: "client", message: "演练", write: (event, data) => emitted.push({ event, data }) });
expect(emitted.filter((event) => event.event === "answer_ui").map((event) => event.data.phase)).toEqual(["process", "process", "final"]);
expect(emitted.some((event) => event.event === "progress" && String(event.data.title).includes("不能变更"))).toBe(true);
expect(emitted.find((event) => event.event === "final_answer")?.data.ui_block_ids).toEqual(["report"]);
});
it("validates new final-answer anchors without treating code examples as references", async () => {
const { default: finalAnswer } = await import("../../.opencode/tools/final_answer.js");
const { default: presentAnswer } = await import("../../.opencode/tools/present_answer.js");
const context = { sessionID: "s" };
const published = JSON.parse(await presentAnswer.execute(input, context));
expect(published.phase).toBe("process");
const answer = "说明\n[[ui:report]]\n```\n[[ui:example]]\n```";
expect(JSON.parse(await finalAnswer.execute({ answer, ui_block_ids: ["report"] }, context)).ui_block_ids).toEqual(["report"]);
await expect(finalAnswer.execute({ answer, ui_block_ids: ["example"] }, context)).rejects.toThrow("锚点");
await expect(finalAnswer.execute({ answer: "文字", ui_block_ids: ["report"] }, context)).rejects.toThrow("锚点");
});