webui/tests/lib/chat-events.test.mjs
Rama main · réplica de lectura del repositorio original.
import assert from "node:assert/strict";
import { before, test } from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { server } from "../helpers/vite.mjs";
import { approvalFrame } from "../fixtures/approval.mjs";
let toolActivityList;
let assistantMessage;
let chatEvents;
before(async () => {
toolActivityList = await server.ssrLoadModule("/src/components/chat/ToolActivityList.tsx");
assistantMessage = await server.ssrLoadModule("/src/components/chat/AssistantMessage.tsx");
chatEvents = await server.ssrLoadModule("/src/lib/chat-events.ts");
});
function toolEvent(kind, callId = "call-1", error) {
return {
type: "event", kind, run_id: "run-1", session_id: "session-1",
iteration: 0, timestamp: 1,
payload: kind === "tool.call.started"
? { tool_call: { call_id: callId, name: "write_file", arguments: { path: "/outside.txt", content: "hello" } } }
: { tool_result: { tool_call_id: callId, name: "write_file" }, ok: !error, error },
};
}
function emptyAssistant() {
return [{ id: "assistant", role: "assistant", content: "", iterations: [] }];
}
function allTools(messages) {
return messages[0].iterations.flatMap((item) => item.tools ?? []);
}
test("historyMessages restores assistant iterations and tool results", () => {
const messages = chatEvents.historyMessages([
{ role: "user", content: "检查项目" },
{
role: "assistant",
content: "",
reasoning_content: "先读取目录",
tool_calls: [
{
id: "call-1",
function: { name: "list_dir", arguments: '{"path":"."}' },
},
],
},
{
role: "tool",
tool_call_id: "call-1",
content: { entries: ["README.md"] },
},
{ role: "assistant", content: "项目检查完成" },
]);
assert.equal(messages.length, 2);
assert.equal(messages[0].content, "检查项目");
assert.equal(messages[1].iterations.length, 2);
assert.equal(messages[1].iterations[0].reasoning, "先读取目录");
assert.equal(messages[1].iterations[0].tools[0].status, "completed");
assert.equal(messages[1].iterations[1].content, "项目检查完成");
});
test("stream frames are merged, budgeted, and applied in order", () => {
const first = {
type: "event",
kind: "model.stream.content_delta",
run_id: "run-1",
payload: { delta: "你好" },
iteration: 0,
session_id: "session-1",
timestamp: 1,
};
const second = {
...first,
payload: { delta: ",Bee" },
timestamp: 2,
};
const queued = [first];
chatEvents.appendPendingAgentFrame(queued, second);
assert.equal(queued.length, 1);
assert.equal(queued[0].payload.delta, "你好,Bee");
const { painted, textCount } = chatEvents.takeAgentFramesForPaint(queued, 3);
assert.equal(textCount, 3);
assert.equal(painted[0].payload.delta, "你好,");
assert.equal(queued[0].payload.delta, "Bee");
const updated = chatEvents.applyAgentEventFrames(
[
{
id: "assistant-1",
role: "assistant",
content: "",
iterations: [],
},
],
"assistant-1",
painted,
);
assert.equal(updated[0].iterations[0].content, "你好,");
});
test("approval survives a delayed start and rejection remains terminal", () => {
const request = approvalFrame();
let messages = chatEvents.applyApprovalFrame(emptyAssistant(), "assistant", request);
messages = chatEvents.applyAgentEventFrames(messages, "assistant", [toolEvent("tool.call.started")]);
assert.equal(allTools(messages).length, 1);
assert.equal(allTools(messages)[0].status, "waiting_approval");
messages = chatEvents.applyApprovalFrame(messages, "assistant", { ...request, type: "approval_resolved", approved: false });
messages = chatEvents.applyAgentEventFrames(messages, "assistant", [
toolEvent("tool.call.finished", "call-1", { code: "tool_approval_denied", message: "拒绝" }),
toolEvent("tool.call.started"),
]);
assert.equal(allTools(messages)[0].status, "rejected");
assert.equal(chatEvents.completeIterationTools(messages[0].iterations, "completed")[0].tools[0].status, "rejected");
const html = renderToStaticMarkup(createElement(toolActivityList.ToolSteps, { tools: allTools(messages) }));
assert.match(html, /已拒绝/);
});
test("approvals correlate parallel calls by id and fast completion wins over acknowledgement", () => {
let messages = chatEvents.applyAgentEventFrames(emptyAssistant(), "assistant", [toolEvent("tool.call.started", "call-1")]);
messages = chatEvents.applyApprovalFrame(messages, "assistant", approvalFrame("call-2"));
messages = chatEvents.applyApprovalFrame(messages, "assistant", approvalFrame("call-1"));
assert.deepEqual(allTools(messages).map((tool) => [tool.id, tool.status]), [
["call-1", "waiting_approval"], ["call-2", "waiting_approval"],
]);
messages = chatEvents.applyApprovalFrame(messages, "assistant", { ...approvalFrame("call-1"), type: "approval_resolved", approved: true });
assert.equal(allTools(messages)[0].status, "running");
messages = chatEvents.applyAgentEventFrames(messages, "assistant", [toolEvent("tool.call.finished", "call-1")]);
messages = chatEvents.applyApprovalFrame(messages, "assistant", { ...approvalFrame("call-1"), type: "approval_resolved", approved: true });
assert.equal(allTools(messages)[0].status, "completed");
assert.equal(allTools(messages)[1].status, "waiting_approval");
assert.equal(chatEvents.completeIterationTools(messages[0].iterations, "cancelled")[0].tools[1].status, "cancelled");
});
test("history restores rejected tool calls", () => {
const messages = chatEvents.historyMessages([
{ role: "assistant", tool_calls: [{ id: "call-1", function: { name: "write_file", arguments: "{}" } }] },
{ role: "tool", tool_call_id: "call-1", content: JSON.stringify({ error: { code: "tool_approval_denied", message: "用户拒绝" } }) },
]);
assert.equal(allTools(messages)[0].status, "rejected");
});
test("history shows interrupted calls as stopped while preserving recorded results", () => {
const messages = chatEvents.historyMessages([
{ role: "user", content: "Write files" },
{
role: "assistant",
tool_calls: ["finished", "failed", "interrupted"].map((id) => ({
id,
function: {
name: "write_file",
arguments: JSON.stringify({ path: `${id}.txt`, content: "hello" }),
},
})),
},
{ role: "tool", tool_call_id: "finished", content: { success: true } },
{ role: "tool", tool_call_id: "failed", content: { error: "Permission denied" } },
{ role: "user", content: "Continue after reconnecting" },
{ role: "assistant", content: "Ready" },
]);
const tools = messages[1].iterations[0].tools;
assert.deepEqual(tools.map((tool) => [tool.id, tool.status]), [
["finished", "completed"],
["failed", "error"],
["interrupted", "cancelled"],
]);
const html = renderToStaticMarkup(
createElement(toolActivityList.ToolSteps, { tools: [tools[2]] }),
);
assert.match(html, /写入文件已停止/);
assert.doesNotMatch(html, /正在写入文件/);
});
test("history keeps a stopped approval cancelled after recovery fills the missing result", () => {
const messages = chatEvents.historyMessages([
{ role: "user", content: "Run a command" },
{ role: "assistant", tool_calls: [{ id: "stopped", function: {
name: "exec", arguments: JSON.stringify({ command: "printf STOP_SHOULD_NOT_RUN" }),
} }] },
{ role: "tool", tool_call_id: "stopped", name: "exec",
content: "[Tool result unavailable - call was interrupted or lost]" },
{ role: "assistant", content: "[Previous turn was interrupted after tool execution.]" },
{ role: "user", content: "Continue without running the command" },
{ role: "assistant", content: "Ready" },
]);
const tools = messages[1].iterations.flatMap((iteration) => iteration.tools ?? []);
assert.equal(tools.length, 1);
assert.equal(tools[0].status, "cancelled");
const html = renderToStaticMarkup(createElement(toolActivityList.ToolSteps, { tools }));
assert.match(html, /Shell 命令已停止/);
assert.doesNotMatch(html, /已运行 Shell 命令/);
});
test("reconnect restores live tools, elapsed time and approval without duplicating the original input", () => {
const original = "查询课程";
const stored = [
{ role: "user", content: original },
{ role: "assistant", tool_calls: [{ id: "live", function: { name: "query", arguments: "{}" } }] },
{ role: "user", content: "后来插入的引导" },
];
const ready = {
type: "ready", session_id: "one", running: true, started_at: 123,
active_message: { message_id: "A", content: original }, approvals: [],
};
const restored = chatEvents.restoreRunningMessages(chatEvents.historyMessages(stored), ready);
assert.equal(restored.filter((message) => message.content === original).length, 1);
assert.equal(restored.at(-1).startedAt, 123000);
const waitingHistory = chatEvents.historyMessages(stored.slice(0, 2));
const running = chatEvents.restoreRunningMessages(waitingHistory, ready);
assert.equal(running.at(-1).iterations[0].tools[0].status, "running");
const approval = { ...approvalFrame(), call_id: "live" };
const waiting = chatEvents.restoreRunningMessages(waitingHistory, { ...ready, approvals: [approval] });
assert.equal(waiting.at(-1).iterations[0].tools[0].status, "waiting_approval");
assert.equal(waiting.at(-1).startedAt, 123000);
});
test("steering after tools stays in one execution process while follow-ups remain separate turns", () => {
const stored = [
{ role: "user", content: "你好" },
{ role: "assistant", content: "我先看看目录", tool_calls: [
{ id: "dir", function: { name: "list_dir", arguments: '{"path":"."}' } },
] },
{ role: "tool", tool_call_id: "dir", content: { entries: [] } },
{ role: "user", content: "在干嘛" },
{ role: "user", content: "用中文简短说" },
{ role: "assistant", content: "目录检查完成" },
{ role: "user", content: "下一轮:继续检查" },
{ role: "assistant", content: "下一轮完成" },
];
const messages = chatEvents.historyMessages(stored);
assert.deepEqual(messages.map((message) => message.role), ["user", "assistant", "user", "assistant"]);
assert.equal(messages[2].content, "下一轮:继续检查");
const firstRun = messages[1];
assert.deepEqual(firstRun.iterations.map((item) => item.kind ?? "model"), ["model", "steering", "steering", "model"]);
assert.deepEqual(firstRun.iterations.slice(1, 3).map((item) => item.content), ["在干嘛", "用中文简短说"]);
const closed = renderToStaticMarkup(createElement(assistantMessage.MessageView, { message: firstRun, active: false }));
assert.match(closed, /已结束/);
assert.match(closed, /目录检查完成/);
assert.doesNotMatch(closed, /在干嘛|用中文简短说|我先看看目录/);
assert.equal((closed.match(/execution-process-toggle/g) ?? []).length, 1);
const opened = renderToStaticMarkup(createElement(assistantMessage.MessageView, { message: firstRun, active: true }));
assert.match(opened, /aria-label="引导消息"/);
assert.ok(opened.indexOf("我先看看目录") < opened.indexOf("在干嘛"));
assert.ok(opened.indexOf("在干嘛") < opened.indexOf("用中文简短说"));
assert.ok(opened.indexOf("用中文简短说") < opened.indexOf("目录检查完成"));
assert.equal(stored[3].role, "user");
assert.equal(stored[3].content, "在干嘛");
});
test("merged steering and repeated tool calls stay ordered across history reloads", () => {
const stored = [
{ role: "user", content: "分析项目" },
{ role: "assistant", tool_calls: [{ id: "first", function: { name: "list_dir", arguments: "{}" } }] },
{ role: "tool", tool_call_id: "first", content: "first result" },
{ role: "user", content: "第一条引导\n\n第二条引导" },
{ role: "assistant", tool_calls: [{ id: "second", function: { name: "read_file", arguments: "{}" } }] },
{ role: "tool", tool_call_id: "second", content: "second result" },
{ role: "user", content: "第三条引导" },
{ role: "assistant", content: "最终回答", _bumblehive_ui: { duration_s: 11 } },
];
const restored = chatEvents.historyMessages(JSON.parse(JSON.stringify(stored)));
assert.equal(restored.length, 2);
assert.equal(restored[1].durationSeconds, 11);
assert.deepEqual(restored[1].iterations.map((item) => item.kind ?? "model"), ["model", "steering", "model", "steering", "model"]);
assert.equal(restored[1].iterations[0].tools[0].status, "completed");
assert.equal(restored[1].iterations[2].tools[0].status, "completed");
});
test("restored steering receipts reinsert original IDs at their queue positions", () => {
const queued = (message_id, position) => ({ type: "message_status", session_id: "one", status: "queued", message_id, content: "same text", position });
let items = [
{ message_id: "left", content: "first follow-up" },
{ message_id: "tail", content: "last follow-up" },
{ message_id: "new", content: "optimistic new input", submitting: true },
];
items = chatEvents.applyPendingMessageStatus(items, queued("B", 1));
items = chatEvents.applyPendingMessageStatus(items, queued("C", 2));
assert.deepEqual(items.map((item) => item.message_id), ["left", "B", "C", "tail", "new"]);
assert.equal(items[1].content, items[2].content);
assert.equal(items[1].submitting, undefined);
items = chatEvents.applyPendingMessageStatus(items, queued("C", 2));
assert.deepEqual(items.map((item) => item.message_id), ["left", "B", "C", "tail", "new"]);
items = chatEvents.applyPendingMessageStatus(items, { ...queued("B"), status: "started" });
assert.deepEqual(items.map((item) => item.message_id), ["left", "C", "tail", "new"]);
items = chatEvents.applyPendingMessageStatus(items, { ...queued("new"), content: "acknowledged" });
assert.deepEqual(items.map((item) => item.message_id), ["left", "C", "tail", "new"]);
assert.equal(items.at(-1).submitting, undefined);
});
test("sub-agent history keeps task text separate from model-only handoff instructions", () => {
const messages = chatEvents.historyMessages([
{ role: "user", content: "Internal instructions\nTask:\nReview", _bumblehive_ui: { content: "Review" } },
{ role: "assistant", content: "Conclusion" },
]);
assert.equal(messages[0].content, "Review");
const restored = chatEvents.restoreRunningMessages(messages.slice(0, 1), {
type: "ready", session_id: "child", running: true, started_at: 123,
active_message: { message_id: "child", content: "Review" },
});
assert.equal(restored.filter((message) => message.role === "user").length, 1);
assert.equal(restored.at(-1).startedAt, 123000);
});