159 lines
14 KiB
TypeScript
159 lines
14 KiB
TypeScript
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("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("锚点");
|
|
});
|