Share project JSON transport, interaction replies and report selectors. Settle tool progress consistently for direct streams, reconnects and history, preserving failure reasons and stopped state.
201 lines
13 KiB
TypeScript
201 lines
13 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { applySessionStreamEvent, toAgentChatMessages, toAgentUiMessages } from "./agent-session-message-state";
|
|
|
|
describe("task context in persisted answers", () => {
|
|
it("restores submitted answer cards only from snapshots bound to their user message", () => {
|
|
const reply = { version: "agent-interaction-request@1", interaction_id: "question", message_id: "source", turn_id: "turn", call_id: "call",
|
|
status: "answered", revision: 1, draft_revision: 0, draft: [], created_at: "2026-10-28T10:00:00Z",
|
|
questions: [{ question_id: "q", title: "核查时段", body: "请选择", mode: "single", required: true, allow_custom: false, options: [{ id: "a", label: "前一天" }] }],
|
|
answers: [{ question_id: "q", selected_option_ids: ["a"], selected_options: [{ id: "a", label: "前一天" }], custom_text: "" }],
|
|
delivery: { native_message_id: "reply", revision: 2, status: "delivered" } };
|
|
const parts = [{ type: "text", text: "核查时段: 前一天" }, { type: "data-interaction_reply", data: reply }];
|
|
const messages = toAgentChatMessages(toAgentUiMessages([
|
|
{ id: "reply", role: "user", parts }, { id: "other", role: "user", parts },
|
|
{ id: "reply", role: "assistant", parts },
|
|
{ id: "pending", role: "user", parts: [{ type: "data-interaction_reply", data: { ...reply, status: "pending", delivery: { ...reply.delivery, native_message_id: "pending" } } }] }
|
|
]), {}, {});
|
|
expect(messages[0].interactionReply).toEqual(reply);
|
|
expect(messages.slice(1).every((message) => message.interactionReply === undefined)).toBe(true);
|
|
});
|
|
it("inserts consumed user input once before the active assistant in live and resumed streams", () => {
|
|
const messages = toAgentUiMessages([
|
|
{ id: "original", role: "user", parts: [{ type: "text", text: "原问题" }] },
|
|
{ id: "answer", role: "assistant", parts: [{ type: "text", text: "处理中" }] }
|
|
]);
|
|
const data = { id: "new-input", text: "追加线索", delivery: "steer", status: "pending" };
|
|
expect(applySessionStreamEvent(messages, { type: "user_input", data })).toBe(messages);
|
|
const delivered = { type: "user_input" as const, data: { ...data, status: "delivered" } };
|
|
const next = applySessionStreamEvent(messages, delivered);
|
|
expect(next.map(({ id }) => id)).toEqual(["original", "new-input", "answer"]);
|
|
expect(applySessionStreamEvent(next, delivered)).toBe(next);
|
|
});
|
|
|
|
it("keeps the report reference of each answer when history is restored", () => {
|
|
const reference = {
|
|
task_id: "22222222-2222-4222-8222-222222222222",
|
|
execution_generation: 1,
|
|
report_revision: 3
|
|
};
|
|
const stored = [
|
|
{
|
|
id: "answer-1",
|
|
role: "assistant",
|
|
parts: [
|
|
{ type: "data-task_context", data: reference },
|
|
{ type: "text", text: "该判断仅覆盖投诉时段的上游记录。" }
|
|
]
|
|
}
|
|
];
|
|
const messages = toAgentChatMessages(toAgentUiMessages(stored), {}, {});
|
|
expect(messages[0]?.taskContext).toEqual(reference);
|
|
expect(messages[0]?.content).toBe("该判断仅覆盖投诉时段的上游记录。");
|
|
});
|
|
it("keeps the answer readable when a report reference is invalid", () => {
|
|
const messages = toAgentChatMessages(
|
|
toAgentUiMessages([
|
|
{
|
|
id: "answer-1",
|
|
role: "assistant",
|
|
parts: [
|
|
{
|
|
type: "data-task_context",
|
|
data: { task_id: "unknown", execution_generation: -1, report_revision: 0 }
|
|
},
|
|
{ type: "text", text: "报告引用未记录。" }
|
|
]
|
|
}
|
|
]),
|
|
{},
|
|
{}
|
|
);
|
|
expect(messages[0]?.taskContext).toBeUndefined();
|
|
expect(messages[0]?.content).toBe("报告引用未记录。");
|
|
});
|
|
});
|
|
|
|
describe("rich answer replay", () => {
|
|
const block = { version: "answer-ui@1", block_id: "reading", fallback_text: "演练读数", spec: { root: "metric", elements: { metric: { type: "Metric", props: { label: "压力", value: "0.3", unit: "MPa", source: "演练数据" }, children: [] } } } };
|
|
it("restores anchored final content without process UI, duplicate blocks or internal reference markers", () => {
|
|
const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [
|
|
{ type: "data-answer_ui", data: { ...block, block_id: "query", phase: "process", fallback_text: "查询过程资料" } },
|
|
{ type: "data-answer_ui", data: { ...block, phase: "final" } },
|
|
{ type: "data-answer_ui", data: { ...block, phase: "final", fallback_text: "更新后的最终读数" } },
|
|
{ type: "data-final_answer", data: { content: "最终说明\n\n[[ui:reading]]\n\n后续解读\n\n[[ui:query]]", ui_block_ids: ["reading", "query"] } },
|
|
] }]);
|
|
const restored = toAgentChatMessages(messages, {}, {})[0];
|
|
expect(restored.answerParts?.filter((part) => part.type === "answer-ui")).toHaveLength(2);
|
|
expect(restored.content).toContain("更新后的最终读数");
|
|
expect(restored.content).not.toContain("查询过程资料");
|
|
expect(restored.content).not.toContain("[[ui:");
|
|
expect(restored.content.indexOf("更新后的最终读数")).toBeLessThan(restored.content.indexOf("后续解读"));
|
|
});
|
|
it("keeps text, component and summary in their original order across history restoration", () => {
|
|
const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [
|
|
{ type: "text", text: "下面是演练数据。" },
|
|
{ type: "data-answer_ui", id: "reading", data: block },
|
|
{ type: "text", text: "需要现场核验。" },
|
|
{ type: "data-final_answer", data: { content: "结论仍待确认。", ui_block_ids: ["reading"] } },
|
|
] }]);
|
|
const restored = toAgentChatMessages(messages, {}, {})[0];
|
|
expect(restored.answerParts?.map((part) => part.type)).toEqual(["text", "answer-ui", "text"]);
|
|
expect(restored.content).toContain("演练读数");
|
|
expect(restored.answerParts).toContainEqual({ type: "text", text: "需要现场核验。" });
|
|
expect(restored.content).not.toContain("需要现场核验。");
|
|
});
|
|
it("updates a block once and appends later tokens after it without duplicating final text", () => {
|
|
let messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [{ type: "text", text: "演练:" }] }]);
|
|
messages = applySessionStreamEvent(messages, { type: "answer_ui", data: block });
|
|
messages = applySessionStreamEvent(messages, { type: "answer_ui", data: { ...block, fallback_text: "新读数" } });
|
|
messages = applySessionStreamEvent(messages, { type: "token", data: { content: "仍待核验。", ui_block_ids: ["reading"] } });
|
|
messages = applySessionStreamEvent(messages, { type: "final_answer", data: { content: "仍待核验。", ui_block_ids: ["reading"] } });
|
|
const answer = toAgentChatMessages(messages, {}, {})[0];
|
|
expect(answer.answerParts?.map((part) => part.type)).toEqual(["text", "answer-ui", "text"]);
|
|
expect(answer.content.match(/新读数/g)).toHaveLength(1);
|
|
expect(answer.content.match(/仍待核验/g)).toHaveLength(1);
|
|
});
|
|
it("falls back to text when an old or malformed UI block cannot be rendered", () => {
|
|
const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [{ type: "data-answer_ui", data: { ...block, version: "unknown" } }] }]);
|
|
expect(toAgentChatMessages(messages, {}, {})[0].answerParts).toEqual([{ type: "text", text: "演练读数" }]);
|
|
});
|
|
});
|
|
|
|
it("keeps a malformed persisted data payload from crashing the conversation", () => {
|
|
for (const data of [null, undefined, "invalid"]) {
|
|
const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [{ type: "data-answer_ui", data }] }]);
|
|
expect(toAgentChatMessages(messages, {}, {})[0].content).toBe("这段结构化内容无法显示。");
|
|
}
|
|
});
|
|
|
|
it("restores an aborted run's terminal state from canonical history", () => {
|
|
const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [
|
|
{ type: "text", text: "已核查的部分结果" },
|
|
{ type: "data-progress", data: { id: "query", title: "查询", status: "running" } },
|
|
{ type: "data-run_status", data: { status: "aborted", message: "请求已中断" } }
|
|
] }]);
|
|
expect(toAgentChatMessages(messages, {}, {})[0]).toMatchObject({ content: "已核查的部分结果", progress: [{ status: "cancelled" }] });
|
|
});
|
|
|
|
describe("terminal tool progress", () => {
|
|
const parts = [
|
|
{ type: "text", text: "已核查的部分结果" },
|
|
{ type: "data-progress", data: { id: "tool", phase: "tool", title: "正在处理", status: "running", tool: { name: "query", input: { id: 12 } } } },
|
|
{ type: "data-todo_update", data: { session_id: "s", todos: [{ id: "todo", content: "核查", status: "in_progress" }] } },
|
|
{ type: "data-permission_request", data: { request_id: "permission", session_id: "s", permission: "read", patterns: [] } },
|
|
{ type: "data-question_request", data: { request_id: "question", session_id: "s", questions: [{ question: "继续吗?", options: [] }] } }
|
|
];
|
|
const initial = () => toAgentUiMessages([{ id: "answer", role: "assistant", parts }]);
|
|
|
|
it.each(["done", "error", "aborted"] as const)("settles running tool rows after resumed %s", (type) => {
|
|
const messages = applySessionStreamEvent(initial(), { type, data: { message: "模型请求失败" } });
|
|
const answer = toAgentChatMessages(messages, {}, {})[0];
|
|
expect(answer.progress?.[0]).toMatchObject({ status: type === "done" ? "completed" : type === "aborted" ? "cancelled" : "error", tool: { name: "query", input: { id: 12 } } });
|
|
expect(answer.todos?.todos[0].status).toBe("cancelled");
|
|
expect(answer.content).toBe("已核查的部分结果");
|
|
if (type === "error") expect(answer.runError).toBe("模型请求失败");
|
|
});
|
|
|
|
it("preserves the reason when reopening a failed answer with partial structured output", () => {
|
|
const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [...parts,
|
|
{ type: "data-final_answer", data: { content: "已核查的部分结果" } },
|
|
{ type: "data-run_status", data: { status: "error", message: "模型请求失败" } }
|
|
] }]);
|
|
const answer = toAgentChatMessages(messages, {}, {})[0];
|
|
expect(answer).toMatchObject({ content: "已核查的部分结果", runError: "模型请求失败", progress: [{ status: "error", detail: "模型请求失败" }] });
|
|
});
|
|
|
|
it("does not let an old reply override revive stopped cards", () => {
|
|
const messages = applySessionStreamEvent(initial(), { type: "run_status", data: { status: "aborted" } });
|
|
const answer = toAgentChatMessages(messages, { permission: { status: "error", error: "late HTTP response" } },
|
|
{ question: { status: "submitting" } })[0];
|
|
expect(answer.permissions?.[0].status).toBe("aborted");
|
|
expect(answer.questions?.[0].status).toBe("rejected");
|
|
const stopped = applySessionStreamEvent(messages, { type: "error", data: { message: "请求已中断" } });
|
|
expect(toAgentChatMessages(stopped, {}, {})[0].runError).toBeUndefined();
|
|
expect(toAgentChatMessages(stopped, {}, {})[0].progress?.[0].status).toBe("cancelled");
|
|
});
|
|
|
|
it("settles legacy history using the session status while leaving active tools running", () => {
|
|
for (const status of ["completed", "aborted", "error", "waiting_input", "running"] as const) {
|
|
const messages = toAgentUiMessages(initial(), { runStatus: status, isStreaming: status === "running" });
|
|
expect(toAgentChatMessages(messages, {}, {})[0].progress?.[0].status)
|
|
.toBe(status === "running" ? "running" : status === "error" ? "error" : status === "aborted" ? "cancelled" : "completed");
|
|
}
|
|
const state = applySessionStreamEvent(initial(), { type: "state", sessionId: "s", isStreaming: false, messages: initial() });
|
|
expect(toAgentChatMessages(state, {}, {})[0].progress?.[0].status).toBe("completed");
|
|
});
|
|
|
|
it("keeps a saved failure authoritative when a legacy session defaults to completed", () => {
|
|
const saved = applySessionStreamEvent(initial(), { type: "error", data: { message: "历史错误" } });
|
|
const answer = toAgentChatMessages(toAgentUiMessages(saved, { runStatus: "completed", isStreaming: false }), {}, {})[0];
|
|
expect(answer.runError).toBe("历史错误");
|
|
});
|
|
|
|
it("routes historical question replies to the owning assistant without changing the current turn", () => {
|
|
const messages = [...initial(), ...toAgentUiMessages([{ id: "current", role: "assistant", parts: [{ type: "text", text: "新一轮" }] }])];
|
|
const updated = applySessionStreamEvent(messages, { type: "question_response", data: { request_id: "question", rejected: true } });
|
|
const answers = toAgentChatMessages(updated, {}, {});
|
|
expect(answers[0].questions?.[0].status).toBe("rejected");
|
|
expect(updated[1]).toEqual(messages[1]);
|
|
});
|
|
});
|