291 lines
15 KiB
TypeScript
291 lines
15 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import type { AgentRunProvenance, ChatMessage, TraceEvent } from "../src/types/index.ts";
|
|
import { canCancelMessage, canRetryMessage, messageTraceId, renderSafeMarkdown, traceEventBody, traceEventLabel, traceIdentityText, visibleTraceEvents } from "../src/components/workbench/message-rendering.ts";
|
|
import { mergeRunnerTrace } from "../src/composables/workbench-trace-snapshot.ts";
|
|
import { traceDisplayRows, traceNoiseEventCount } from "../../../tools/src/hwlab-cli/trace-renderer.ts";
|
|
|
|
test("R1 markdown rendering keeps structure and strips unsafe HTML", () => {
|
|
const html = renderSafeMarkdown("# 标题\n\n- 项目\n\n<script>alert('x')</script>\n\n[link](https://example.com)");
|
|
assert.match(html, /<h1>标题<\/h1>/u);
|
|
assert.match(html, /<li>项目<\/li>/u);
|
|
assert.match(html, /href="https:\/\/example.com"/u);
|
|
assert.doesNotMatch(html, /<script|alert/u);
|
|
});
|
|
|
|
test("R1 message actions follow per-message status and trace rules", () => {
|
|
const running = agentMessage({ status: "running", traceId: "trc_running", retryInput: "retry me" });
|
|
assert.equal(messageTraceId(running), "trc_running");
|
|
assert.equal(canCancelMessage(running), true);
|
|
assert.equal(canRetryMessage(running), true);
|
|
|
|
const completed = agentMessage({ status: "completed", traceId: "trc_done", retryInput: "retry me" });
|
|
assert.equal(canCancelMessage(completed), false);
|
|
assert.equal(canRetryMessage(completed), false);
|
|
|
|
const failed = agentMessage({ status: "failed", traceId: "trc_failed", retryInput: "retry me" });
|
|
assert.equal(canCancelMessage(failed), false);
|
|
assert.equal(canRetryMessage(failed), true);
|
|
});
|
|
|
|
test("R1 trace rendering hides chunk noise without dropping meaningful events", () => {
|
|
const events: TraceEvent[] = [
|
|
{ label: "assistant:delta", message: "token" },
|
|
{ label: "command:completed", command: "npm test", exitCode: 0, elapsedMs: 42 },
|
|
{ type: "assistant_message", text: "final", final: true }
|
|
];
|
|
const compact = visibleTraceEvents(events, true);
|
|
assert.equal(compact.length, 2);
|
|
assert.equal(traceEventLabel(compact[0]!), "command:completed");
|
|
assert.equal(traceEventBody(compact[0]!), "npm test");
|
|
assert.equal(traceEventLabel(compact[1]!), "assistant_message");
|
|
assert.equal(traceEventBody(compact[1]!), "final");
|
|
assert.equal(visibleTraceEvents(events, false).length, 3);
|
|
});
|
|
|
|
test("R1 shared trace renderer suppresses pure AgentRun backend noise", () => {
|
|
const events = Array.from({ length: 12 }, (_, index) => ({
|
|
label: index % 3 === 0 ? "agentrun:backend:run-created" : index % 3 === 1 ? "agentrun:backend:resource-bundle-materialized" : "agentrun:backend:mcpServer/startupStatus/updated",
|
|
status: "running",
|
|
createdAt: "2026-06-15T02:00:00.000Z"
|
|
}));
|
|
const rows = traceDisplayRows({ traceId: "trc_noise", status: "running" }, events);
|
|
assert.equal(traceNoiseEventCount(events), 12);
|
|
assert.equal(rows.length, 0);
|
|
});
|
|
|
|
test("R1 shared trace renderer starts at the first user-meaningful event", () => {
|
|
const rows = traceDisplayRows({ traceId: "trc_ok", status: "completed" }, [
|
|
{ label: "agentrun:request:accepted", status: "accepted" },
|
|
{ label: "agentrun:runner-job:created", status: "running", message: "runner created" },
|
|
{ label: "agentrun:backend:resource-bundle-materialized", status: "running", source: "agentrun", sourceSeq: 1, message: "bundle" },
|
|
{ projectedSeq: 2, label: "agentrun:assistant:message", type: "assistant", message: "OK", source: "agentrun", sourceSeq: 2 }
|
|
]);
|
|
assert.equal(rows.length, 1);
|
|
assert.equal(rows[0]?.body, "OK");
|
|
assert.doesNotMatch(rows[0]?.header ?? "", /runner-job|resource-bundle/u);
|
|
});
|
|
|
|
test("R1 shared trace renderer suppresses AgentRun reuse diagnostics", () => {
|
|
const rows = traceDisplayRows({ traceId: "trc_reuse", status: "running" }, [
|
|
{ label: "agentrun:run:reused", status: "reused", message: "run reused" },
|
|
{ label: "agentrun:runner-job:ensured", status: "runner-job-ensured", message: "runner ensured" },
|
|
{ projectedSeq: 3, label: "agentrun:assistant:message", type: "assistant", message: "OK", source: "agentrun", sourceSeq: 3 }
|
|
]);
|
|
assert.equal(rows.length, 1);
|
|
assert.equal(rows[0]?.body, "OK");
|
|
assert.doesNotMatch(rows[0]?.header ?? "", /reused|ensured|runner-job/u);
|
|
});
|
|
|
|
test("R1 shared trace renderer keeps terminal assistant at its own event position", () => {
|
|
const rows = traceDisplayRows({ traceId: "trc_terminal_position", status: "completed", startedAt: "2026-06-20T12:00:00.000Z" }, [
|
|
{ seq: 1, projectedSeq: 1, label: "agentrun:assistant:message", type: "assistant", status: "running", message: "FINAL", createdAt: "2026-06-20T12:00:01.000Z" },
|
|
{ seq: 2, projectedSeq: 2, label: "item/commandExecution:completed", type: "tool_call", toolName: "commandExecution", status: "completed", command: "make test", createdAt: "2026-06-20T12:00:02.000Z" },
|
|
{ seq: 3, projectedSeq: 3, label: "agentrun:assistant:message", type: "assistant", status: "completed", message: "FINAL", final: true, replyAuthority: true, terminal: true, createdAt: "2026-06-20T12:00:03.000Z" }
|
|
]);
|
|
const toolIndex = rows.findIndex((row) => row.seq === 2);
|
|
const finalIndex = rows.findIndex((row) => row.seq === 3 && row.terminal === true && row.body === "FINAL");
|
|
assert.ok(toolIndex >= 0);
|
|
assert.ok(finalIndex > toolIndex);
|
|
assert.equal(rows.some((row) => row.seq === 1 && row.terminal === true), false);
|
|
});
|
|
|
|
test("R1 shared trace renderer keeps completion row last when final response is separate", () => {
|
|
const rows = traceDisplayRows({ traceId: "trc_completion_position", status: "completed", startedAt: "2026-06-20T12:10:00.000Z", finalResponse: { text: "FINAL" } }, [
|
|
{ seq: 1, projectedSeq: 1, label: "agentrun:assistant:message", type: "assistant", status: "running", message: "FINAL", createdAt: "2026-06-20T12:10:01.000Z" },
|
|
{ seq: 2, projectedSeq: 2, label: "item/commandExecution:completed", type: "tool_call", toolName: "commandExecution", status: "completed", command: "make test", createdAt: "2026-06-20T12:10:02.000Z" },
|
|
{ seq: 3, projectedSeq: 3, label: "agentrun:result:completed", type: "result", status: "completed", terminal: true, createdAt: "2026-06-20T12:10:03.000Z" },
|
|
{ seq: 4, projectedSeq: 4, label: "agentrun:assistant:message", type: "assistant", status: "completed", message: "FINAL", final: true, replyAuthority: true, terminal: true, createdAt: "2026-06-20T12:10:04.000Z" }
|
|
]);
|
|
const toolIndex = rows.findIndex((row) => row.seq === 2);
|
|
const completionIndex = rows.findIndex((row) => row.seq === 3 && row.rowId.startsWith("trace-completion:"));
|
|
assert.ok(toolIndex >= 0);
|
|
assert.equal(completionIndex, rows.length - 1);
|
|
assert.ok(completionIndex > toolIndex);
|
|
assert.equal(rows.some((row) => row.seq === 4), false);
|
|
assert.equal(rows.some((row) => row.seq === 1 && row.terminal === true), false);
|
|
});
|
|
|
|
test("R1 shared trace renderer keeps UTC by default and lets Web supply display clock", () => {
|
|
const events = [{ seq: 1, projectedSeq: 1, label: "item/commandExecution:completed", type: "tool_call", toolName: "commandExecution", status: "completed", command: "date", createdAt: "2026-06-17T06:34:14.586Z" }];
|
|
const defaultRows = traceDisplayRows({ traceId: "trc_clock" }, events);
|
|
const displayRows = traceDisplayRows({ traceId: "trc_clock" }, events, {
|
|
formatClock: (value) => new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }).format(new Date(value))
|
|
});
|
|
assert.equal(defaultRows[0]?.header, "06:34:14");
|
|
assert.equal(displayRows[0]?.header, "14:34:14");
|
|
assert.equal(defaultRows[0]?.preview, "date");
|
|
assert.equal(displayRows[0]?.preview, "date");
|
|
});
|
|
|
|
test("pure Kafka Trace renders sourceSeq-only rows in ingress order without projectedSeq", () => {
|
|
const events = [
|
|
{ sourceEventId: "evt_tool", runId: "run_live", source: "agentrun.kafka", sourceSeq: 8, label: "agentrun:tool:commandExecution", type: "tool_call", toolName: "commandExecution", status: "completed", command: "make test" },
|
|
{ sourceEventId: "evt_assistant", runId: "run_live", source: "agentrun.kafka", sourceSeq: 7, label: "agentrun:assistant:message", type: "assistant", status: "running", message: "live answer" },
|
|
{ sourceEventId: "evt_assistant", runId: "run_live", source: "agentrun.kafka", sourceSeq: 70, label: "agentrun:assistant:message", type: "assistant", status: "running", message: "duplicate should not render" },
|
|
{ sourceEventId: "evt_terminal", runId: "run_live", source: "agentrun.kafka", sourceSeq: 9, label: "agentrun:terminal:completed", type: "result", status: "completed", terminal: true }
|
|
];
|
|
|
|
const liveRows = traceDisplayRows({ traceId: "trc_live_source", eventSource: "hwlab-kafka-sse", status: "completed" }, events);
|
|
assert.deepEqual(liveRows.map((row) => row.seq), [8, 7, 9]);
|
|
assert.ok(liveRows.every((row) => row.sequenceAuthority === "source"));
|
|
assert.equal(liveRows.some((row) => row.body === "duplicate should not render"), false);
|
|
assert.equal(events.some((event) => "projectedSeq" in event), false);
|
|
|
|
const durableRows = traceDisplayRows({ traceId: "trc_durable", eventSource: "trace-api", status: "completed" }, events);
|
|
assert.deepEqual(durableRows, []);
|
|
});
|
|
|
|
test("pure Kafka Trace coalesces late assistant progress by stable item lifecycle", () => {
|
|
const events = [
|
|
{ sourceEventId: "evt_completed_a", sourceSeq: 44, label: "agentrun:assistant:message", type: "assistant", status: "running", itemId: "msg_a", assistantSource: "completed-agent-message", message: "same visible text" },
|
|
{ sourceEventId: "evt_progress_a", sourceSeq: 183, label: "agentrun:assistant:message", type: "assistant", status: "running", itemId: "msg_a", assistantSource: "agent-message-delta-progress", message: "same visible text" },
|
|
{ sourceEventId: "evt_completed_b", sourceSeq: 184, label: "agentrun:assistant:message", type: "assistant", status: "running", itemId: "msg_b", assistantSource: "completed-agent-message", message: "same visible text" },
|
|
{ sourceEventId: "evt_terminal", sourceSeq: 185, label: "agentrun:terminal:completed", type: "result", status: "completed", terminal: true }
|
|
];
|
|
|
|
const rows = traceDisplayRows({ traceId: "trc_assistant_item_lifecycle", eventSource: "hwlab-kafka-sse", status: "completed" }, events);
|
|
const assistantRows = rows.filter((row) => /助手/u.test(row.header));
|
|
|
|
assert.deepEqual(assistantRows.map((row) => row.seq), [44, 184]);
|
|
assert.equal(assistantRows.every((row) => row.body === "same visible text"), true, "distinct itemId values must not be content-deduplicated");
|
|
assert.equal(rows.some((row) => row.seq === 183), false);
|
|
assert.equal(rows.at(-1)?.rowId.startsWith("trace-completion:"), true);
|
|
});
|
|
|
|
test("pure Kafka Trace keeps item lifecycle suppression in the empty-row fallback", () => {
|
|
const rows = traceDisplayRows({ eventSource: "hwlab-kafka-sse" }, [
|
|
{ sourceEventId: "evt_completed", sourceSeq: 1, label: "agentrun:assistant:message", type: "assistant", itemId: "msg_final", assistantSource: "completed-agent-message", message: "Final response" },
|
|
{ sourceEventId: "evt_late_progress", sourceSeq: 2, label: "agentrun:assistant:message", type: "assistant", itemId: "msg_final", assistantSource: "agent-message-delta-progress", message: "Stale late progress" }
|
|
], {
|
|
finalResponsePlacement: "outer",
|
|
outerFinalResponseText: "Final response"
|
|
});
|
|
|
|
assert.deepEqual(rows, []);
|
|
});
|
|
|
|
test("R1 trace API snapshots stay authoritative over compact result traces", () => {
|
|
const previous = {
|
|
traceId: "trc_merge",
|
|
status: "running",
|
|
eventSource: "trace-api",
|
|
events: [
|
|
{ seq: 1, label: "agentrun:backend:resource-bundle-materialized" },
|
|
{ seq: 2, label: "agentrun:assistant:message", message: "OK" }
|
|
],
|
|
eventCount: 2
|
|
} as NonNullable<ChatMessage["runnerTrace"]>;
|
|
const next = {
|
|
traceId: "trc_merge",
|
|
status: "completed",
|
|
eventsCompacted: true,
|
|
events: [{ label: "trace:compacted", message: "compacted" }],
|
|
eventCount: 99
|
|
} as NonNullable<ChatMessage["runnerTrace"]>;
|
|
const merged = mergeRunnerTrace(previous, next);
|
|
assert.equal(merged.status, "completed");
|
|
assert.equal(merged.eventCount, 2);
|
|
assert.equal(merged.events?.length, 2);
|
|
assert.equal(merged.events?.[1]?.message, "OK");
|
|
});
|
|
|
|
test("R1 trace timing merge does not decrease visible duration", () => {
|
|
const previous = {
|
|
traceId: "trc_timing_merge",
|
|
status: "running",
|
|
timing: { startedAt: "2026-06-24T00:00:00.000Z", durationMs: 21_000 },
|
|
durationMs: 21_000,
|
|
events: []
|
|
} as NonNullable<ChatMessage["runnerTrace"]>;
|
|
const next = {
|
|
traceId: "trc_timing_merge",
|
|
status: "completed",
|
|
timing: { startedAt: "2026-06-24T00:00:00.000Z", finishedAt: "2026-06-24T00:00:01.000Z", durationMs: 1_000 },
|
|
durationMs: 1_000,
|
|
events: []
|
|
} as NonNullable<ChatMessage["runnerTrace"]>;
|
|
|
|
const merged = mergeRunnerTrace(previous, next);
|
|
assert.equal(merged.status, "completed");
|
|
assert.equal(merged.durationMs, 21_000);
|
|
assert.equal(merged.timing?.durationMs, 21_000);
|
|
});
|
|
|
|
test("R1 status summary keeps the restored 23-row floor", () => {
|
|
const message = agentMessage({
|
|
status: "failed",
|
|
traceId: "trc_summary",
|
|
conversationId: "cnv_summary",
|
|
sessionId: "ses_summary",
|
|
threadId: "thr_summary",
|
|
runnerTrace: {
|
|
traceId: "trc_summary",
|
|
status: "failed",
|
|
eventCount: 2,
|
|
eventsCompacted: false,
|
|
fullTraceLoaded: true,
|
|
lastEventLabel: "assistant:completed",
|
|
waitingFor: "result",
|
|
runnerKind: "agentrun",
|
|
sessionMode: "long-lived",
|
|
agentRun: {
|
|
runId: "run_1",
|
|
commandId: "cmd_1",
|
|
attemptId: "att_1",
|
|
runnerId: "runner_1",
|
|
jobName: "job_1",
|
|
namespace: "hwlab-v03",
|
|
terminalStatus: "failed"
|
|
}
|
|
},
|
|
error: { code: "provider_timeout", category: "provider", providerStatus: 504 }
|
|
});
|
|
const values = [
|
|
message.status,
|
|
message.runnerTrace?.status,
|
|
message.traceId,
|
|
message.conversationId,
|
|
message.sessionId,
|
|
message.threadId,
|
|
message.runnerTrace?.runnerKind,
|
|
message.runnerTrace?.sessionMode,
|
|
message.runnerTrace?.waitingFor,
|
|
message.runnerTrace?.lastEventLabel,
|
|
String(message.runnerTrace?.eventCount),
|
|
String(message.runnerTrace?.eventsCompacted),
|
|
message.runnerTrace?.fullTraceLoaded === true ? "loaded" : "unverified",
|
|
asAgentRun(message.runnerTrace?.agentRun)?.runId,
|
|
asAgentRun(message.runnerTrace?.agentRun)?.commandId,
|
|
asAgentRun(message.runnerTrace?.agentRun)?.attemptId,
|
|
asAgentRun(message.runnerTrace?.agentRun)?.runnerId,
|
|
asAgentRun(message.runnerTrace?.agentRun)?.jobName,
|
|
asAgentRun(message.runnerTrace?.agentRun)?.namespace,
|
|
asAgentRun(message.runnerTrace?.agentRun)?.terminalStatus,
|
|
message.error?.code,
|
|
message.error?.category,
|
|
String(message.error?.providerStatus)
|
|
];
|
|
assert.equal(values.length, 23);
|
|
assert.equal(values.every((value) => value != null && value !== ""), true);
|
|
assert.match(traceIdentityText(message), /traceId=trc_summary/u);
|
|
assert.match(traceIdentityText(message), /sessionId=ses_summary/u);
|
|
});
|
|
|
|
function agentMessage(patch: Partial<ChatMessage>): ChatMessage {
|
|
return {
|
|
id: "msg_agent",
|
|
role: "agent",
|
|
title: "Code Agent",
|
|
text: "done",
|
|
status: "completed",
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
...patch
|
|
} as ChatMessage;
|
|
}
|
|
|
|
function asAgentRun(value: unknown): AgentRunProvenance | null {
|
|
return value && typeof value === "object" ? value as AgentRunProvenance : null;
|
|
}
|