feat: upgrade investigation and presentation runtime
This commit is contained in:
@@ -20,6 +20,37 @@ describe("locally rendered answers", () => {
|
||||
{ ...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 } } });
|
||||
@@ -70,7 +101,7 @@ it("finishes a rich chat turn at final_answer and prevents later automatic UI re
|
||||
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), 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 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 });
|
||||
@@ -86,3 +117,42 @@ it("finishes a rich chat turn at final_answer and prevents later automatic UI re
|
||||
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("锚点");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user