Ctrl K
ComunidadGuías…

webui/tests/lib/chat-events.test.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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);
});