4fa4f13558
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
339 lines
10 KiB
TypeScript
339 lines
10 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
buildAgentChatItems,
|
|
buildAgentChatRenderBlocks,
|
|
buildFinalAgentChatItems,
|
|
summarizeToolLabel,
|
|
} from "@/features/agents/components/chatItems";
|
|
import { formatMetaMarkdown, formatThinkingMarkdown, formatToolCallMarkdown, formatToolResultMarkdown } from "@/lib/text/message-extract";
|
|
|
|
describe("buildAgentChatItems", () => {
|
|
it("keeps thinking traces aligned with each assistant turn", () => {
|
|
const items = buildAgentChatItems({
|
|
outputLines: [
|
|
"> first question",
|
|
formatThinkingMarkdown("first plan"),
|
|
"first answer",
|
|
"> second question",
|
|
formatThinkingMarkdown("second plan"),
|
|
"second answer",
|
|
],
|
|
streamText: null,
|
|
liveThinkingTrace: "",
|
|
showThinkingTraces: true,
|
|
toolCallingEnabled: true,
|
|
});
|
|
|
|
expect(items.map((item) => item.kind)).toEqual([
|
|
"user",
|
|
"thinking",
|
|
"assistant",
|
|
"user",
|
|
"thinking",
|
|
"assistant",
|
|
]);
|
|
expect(items[1]).toMatchObject({ kind: "thinking", text: "_first plan_" });
|
|
expect(items[4]).toMatchObject({ kind: "thinking", text: "_second plan_" });
|
|
});
|
|
|
|
it("does not include saved traces when thinking traces are disabled", () => {
|
|
const items = buildAgentChatItems({
|
|
outputLines: [
|
|
"> first question",
|
|
formatThinkingMarkdown("first plan"),
|
|
"first answer",
|
|
],
|
|
streamText: null,
|
|
liveThinkingTrace: "live plan",
|
|
showThinkingTraces: false,
|
|
toolCallingEnabled: true,
|
|
});
|
|
|
|
expect(items.map((item) => item.kind)).toEqual(["user", "assistant"]);
|
|
});
|
|
|
|
it("adds a live trace before the live assistant stream", () => {
|
|
const items = buildAgentChatItems({
|
|
outputLines: ["first answer"],
|
|
streamText: "stream answer",
|
|
liveThinkingTrace: "first plan",
|
|
showThinkingTraces: true,
|
|
toolCallingEnabled: true,
|
|
});
|
|
|
|
expect(items.map((item) => item.kind)).toEqual(["assistant", "thinking", "assistant"]);
|
|
expect(items[1]).toMatchObject({ kind: "thinking", text: "_first plan_", live: true });
|
|
});
|
|
|
|
it("merges adjacent thinking traces into a single item", () => {
|
|
const items = buildAgentChatItems({
|
|
outputLines: [formatThinkingMarkdown("first plan"), formatThinkingMarkdown("second plan"), "answer"],
|
|
streamText: null,
|
|
liveThinkingTrace: "",
|
|
showThinkingTraces: true,
|
|
toolCallingEnabled: true,
|
|
});
|
|
|
|
expect(items.map((item) => item.kind)).toEqual(["thinking", "assistant"]);
|
|
expect(items[0]).toMatchObject({
|
|
kind: "thinking",
|
|
text: "_first plan_\n\n_second plan_",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("buildFinalAgentChatItems", () => {
|
|
it("does not include live thinking or live assistant items", () => {
|
|
const items = buildFinalAgentChatItems({
|
|
outputLines: ["> question", formatThinkingMarkdown("plan"), "answer"],
|
|
showThinkingTraces: true,
|
|
toolCallingEnabled: true,
|
|
});
|
|
|
|
expect(items.map((item) => item.kind)).toEqual(["user", "thinking", "assistant"]);
|
|
});
|
|
|
|
it("propagates meta timestamps and thinking duration into subsequent items", () => {
|
|
const items = buildFinalAgentChatItems({
|
|
outputLines: [
|
|
formatMetaMarkdown({ role: "user", timestamp: 1700000000000 }),
|
|
"> hello",
|
|
formatMetaMarkdown({ role: "assistant", timestamp: 1700000001234, thinkingDurationMs: 1800 }),
|
|
formatThinkingMarkdown("plan"),
|
|
"answer",
|
|
],
|
|
showThinkingTraces: true,
|
|
toolCallingEnabled: true,
|
|
});
|
|
|
|
expect(items[0]).toMatchObject({ kind: "user", text: "hello", timestampMs: 1700000000000 });
|
|
expect(items[1]).toMatchObject({
|
|
kind: "thinking",
|
|
text: "_plan_",
|
|
timestampMs: 1700000001234,
|
|
thinkingDurationMs: 1800,
|
|
});
|
|
expect(items[2]).toMatchObject({
|
|
kind: "assistant",
|
|
text: "answer",
|
|
timestampMs: 1700000001234,
|
|
thinkingDurationMs: 1800,
|
|
});
|
|
});
|
|
|
|
it("collapses adjacent duplicate user items when optimistic and persisted turns match", () => {
|
|
const items = buildFinalAgentChatItems({
|
|
outputLines: [
|
|
"> hello\n\nworld",
|
|
formatMetaMarkdown({ role: "user", timestamp: 1700000000000 }),
|
|
"> hello world",
|
|
],
|
|
showThinkingTraces: true,
|
|
toolCallingEnabled: true,
|
|
});
|
|
|
|
expect(items).toEqual([
|
|
{
|
|
kind: "user",
|
|
text: "hello world",
|
|
timestampMs: 1700000000000,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("does_not_collapse_repeated_user_message_when_second_turn_is_only_optimistic", () => {
|
|
const items = buildFinalAgentChatItems({
|
|
outputLines: [
|
|
formatMetaMarkdown({ role: "user", timestamp: 1700000000000 }),
|
|
"> repeat",
|
|
"> repeat",
|
|
],
|
|
showThinkingTraces: true,
|
|
toolCallingEnabled: true,
|
|
});
|
|
|
|
expect(items).toEqual([
|
|
{
|
|
kind: "user",
|
|
text: "repeat",
|
|
timestampMs: 1700000000000,
|
|
},
|
|
{
|
|
kind: "user",
|
|
text: "repeat",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("keeps assistant markdown as assistant content", () => {
|
|
const items = buildFinalAgentChatItems({
|
|
outputLines: ["- first item\n- second item"],
|
|
showThinkingTraces: true,
|
|
toolCallingEnabled: true,
|
|
});
|
|
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0]).toMatchObject({ kind: "assistant" });
|
|
expect(items[0]?.text).toContain("- first item");
|
|
});
|
|
|
|
it("classifies tool markdown as tool items when tool calling is enabled", () => {
|
|
const callLine = formatToolCallMarkdown({
|
|
id: "call_123",
|
|
name: "exec",
|
|
arguments: { command: "pwd" },
|
|
});
|
|
const toolLine = formatToolResultMarkdown({
|
|
toolCallId: "call_123",
|
|
toolName: "exec",
|
|
details: { status: "completed", exitCode: 0 },
|
|
text: "pwd",
|
|
isError: false,
|
|
});
|
|
const items = buildFinalAgentChatItems({
|
|
outputLines: [callLine, toolLine],
|
|
showThinkingTraces: true,
|
|
toolCallingEnabled: true,
|
|
});
|
|
|
|
expect(items).toEqual([
|
|
{
|
|
kind: "tool",
|
|
text: callLine,
|
|
},
|
|
{
|
|
kind: "tool",
|
|
text: toolLine,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("hides tool results when tool calling is disabled", () => {
|
|
const toolLine = formatToolResultMarkdown({
|
|
toolCallId: "call_456",
|
|
toolName: "exec",
|
|
details: { status: "completed", exitCode: 0 },
|
|
text: "pwd",
|
|
isError: false,
|
|
});
|
|
const items = buildFinalAgentChatItems({
|
|
outputLines: [toolLine],
|
|
showThinkingTraces: true,
|
|
toolCallingEnabled: false,
|
|
});
|
|
|
|
expect(items).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("summarizeToolLabel", () => {
|
|
it("hides long tool call ids and prefers showing the command/path/url value", () => {
|
|
const toolCallLine = formatToolCallMarkdown({
|
|
id: "call_ABC123|fc_456",
|
|
name: "functions.exec",
|
|
arguments: { command: "gh auth status" },
|
|
});
|
|
|
|
const { summaryText: callSummary } = summarizeToolLabel(toolCallLine);
|
|
expect(callSummary).toContain("gh auth status");
|
|
expect(callSummary).not.toContain("call_");
|
|
|
|
const toolResultLine = formatToolResultMarkdown({
|
|
toolCallId: "call_ABC123|fc_456",
|
|
toolName: "functions.exec",
|
|
details: { status: "completed", exitCode: 0, durationMs: 168 },
|
|
isError: false,
|
|
text: "ok",
|
|
});
|
|
|
|
const { summaryText: resultSummary } = summarizeToolLabel(toolResultLine);
|
|
expect(resultSummary).toContain("completed");
|
|
expect(resultSummary).toContain("exit 0");
|
|
expect(resultSummary).not.toContain("call_");
|
|
});
|
|
|
|
it("renders read file calls as inline path labels without JSON body", () => {
|
|
const toolCallLine = formatToolCallMarkdown({
|
|
id: "call_read_1",
|
|
name: "read",
|
|
arguments: { file_path: "/path/to/openclaw-agent-home/README.md" },
|
|
});
|
|
|
|
const summary = summarizeToolLabel(toolCallLine);
|
|
expect(summary.summaryText).toBe(
|
|
"read /path/to/openclaw-agent-home/README.md"
|
|
);
|
|
expect(summary.inlineOnly).toBe(true);
|
|
expect(summary.body).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("buildAgentChatRenderBlocks", () => {
|
|
it("groups thinking and tool events into one assistant block in original order", () => {
|
|
const toolCallLine = formatToolCallMarkdown({
|
|
id: "call_1",
|
|
name: "exec",
|
|
arguments: { command: "pwd" },
|
|
});
|
|
const toolResultLine = formatToolResultMarkdown({
|
|
toolCallId: "call_1",
|
|
toolName: "exec",
|
|
details: { status: "completed", exitCode: 0 },
|
|
text: "/repo",
|
|
isError: false,
|
|
});
|
|
|
|
const blocks = buildAgentChatRenderBlocks([
|
|
{ kind: "thinking", text: "_plan before tool_", timestampMs: 100 },
|
|
{ kind: "tool", text: toolCallLine, timestampMs: 101 },
|
|
{ kind: "thinking", text: "_plan after tool_", timestampMs: 102 },
|
|
{ kind: "tool", text: toolResultLine, timestampMs: 103 },
|
|
{ kind: "assistant", text: "done", timestampMs: 104 },
|
|
]);
|
|
|
|
expect(blocks).toEqual([
|
|
{
|
|
kind: "assistant",
|
|
text: "done",
|
|
timestampMs: 100,
|
|
traceEvents: [
|
|
{ kind: "thinking", text: "_plan before tool_" },
|
|
{ kind: "tool", text: toolCallLine },
|
|
{ kind: "thinking", text: "_plan after tool_" },
|
|
{ kind: "tool", text: toolResultLine },
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("starts a new assistant block after a user turn", () => {
|
|
const blocks = buildAgentChatRenderBlocks([
|
|
{ kind: "thinking", text: "_first plan_", timestampMs: 10 },
|
|
{ kind: "assistant", text: "first answer", timestampMs: 11 },
|
|
{ kind: "user", text: "next question", timestampMs: 12 },
|
|
{ kind: "thinking", text: "_second plan_", timestampMs: 13 },
|
|
{ kind: "assistant", text: "second answer", timestampMs: 14 },
|
|
]);
|
|
|
|
expect(blocks.map((block) => block.kind)).toEqual(["assistant", "user", "assistant"]);
|
|
});
|
|
|
|
it("merges adjacent incremental thinking updates", () => {
|
|
const blocks = buildAgentChatRenderBlocks([
|
|
{ kind: "thinking", text: "_a_", timestampMs: 10 },
|
|
{ kind: "thinking", text: "_a_\n\n_b_", timestampMs: 10 },
|
|
{ kind: "assistant", text: "answer", timestampMs: 10 },
|
|
]);
|
|
|
|
expect(blocks).toEqual([
|
|
{
|
|
kind: "assistant",
|
|
text: "answer",
|
|
timestampMs: 10,
|
|
traceEvents: [{ kind: "thinking", text: "_a_\n\n_b_" }],
|
|
},
|
|
]);
|
|
});
|
|
});
|