Files
NetMesh/components/ai/ChatMessageList.test.tsx
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

588 lines
16 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
import type { ApprovalRequest } from "../../infrastructure/ai/shared/approvalGate.ts";
import ChatMessageList, {
buildCodexApprovalRenderPlan,
pruneResolvedApprovals,
shouldProvideVaultArtifactNavigation,
shouldRenderAssistantAsPlainText,
} from "./ChatMessageList.tsx";
import { TooltipProvider } from "../ui/tooltip.tsx";
const makeMessage = (index: number): ChatMessage => ({
id: `msg-${index}`,
role: index % 2 === 0 ? "user" : "assistant",
content: `message-${index}`,
timestamp: index,
});
test("resolved approval state retains only tool calls still present in messages", () => {
const previous = new Map<string, boolean>();
for (let index = 0; index < 1_000; index += 1) previous.set(`old-${index}`, true);
previous.set("live-call", false);
const messages: ChatMessage[] = [{
id: "assistant-live",
role: "assistant",
content: "",
timestamp: 1,
toolCalls: [{ id: "live-call", name: "terminal_execute", arguments: {} }],
}];
const next = pruneResolvedApprovals(previous, messages);
assert.deepEqual([...next], [["live-call", false]]);
});
test("assistant content stays plain only when markdown is hidden", () => {
assert.equal(shouldRenderAssistantAsPlainText({
hideMarkdown: false,
}), false);
assert.equal(shouldRenderAssistantAsPlainText({
hideMarkdown: true,
}), true);
});
test("ChatMessageList renders Streamdown for the streaming assistant message", () => {
const messages: ChatMessage[] = [
{
id: "user-1",
role: "user",
content: "hello",
timestamp: 1,
},
{
id: "assistant-1",
role: "assistant",
content: "streaming-body",
timestamp: 2,
},
];
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(ChatMessageList, { messages, isStreaming: true }),
),
),
);
assert.match(markup, /data-ai-content="markdown"/);
assert.match(markup, /streaming-body/);
assert.doesNotMatch(markup, /data-ai-content="plain"/);
});
test("streaming assistant content keeps Streamdown live with isAnimating", () => {
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
assert.match(source, /isAnimating=\{!!isThisStreaming\}/);
assert.doesNotMatch(source, /isStreaming: !!isThisStreaming/);
});
test("ChatMessageList hydrates markdown after streaming settles", () => {
const messages: ChatMessage[] = [{
id: "assistant-1",
role: "assistant",
content: "settled-body",
timestamp: 1,
}];
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(ChatMessageList, { messages, isStreaming: false }),
),
),
);
assert.match(markup, /data-ai-content="markdown"/);
assert.match(markup, /settled-body/);
assert.doesNotMatch(markup, /data-ai-content="plain"/);
});
test("ChatMessageList only renders the recent message batch by default", () => {
const messages = Array.from({ length: 60 }, (_value, index) => makeMessage(index));
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(ChatMessageList, { messages }),
),
),
);
assert.match(markup, /Load earlier messages \(10 more\)/);
assert.doesNotMatch(markup, /message-0/);
assert.match(markup, /message-10/);
assert.match(markup, /message-59/);
});
test("ChatMessageList exposes jump navigation once there are enough user turns", () => {
const messages: ChatMessage[] = [
{ id: "u1", role: "user", content: "first turn", timestamp: 1 },
{ id: "a1", role: "assistant", content: "ok", timestamp: 2 },
{ id: "u2", role: "user", content: "second turn", timestamp: 3 },
{ id: "a2", role: "assistant", content: "ok", timestamp: 4 },
{ id: "u3", role: "user", content: "third turn", timestamp: 5 },
{ id: "a3", role: "assistant", content: "ok", timestamp: 6 },
];
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(ChatMessageList, { messages }),
),
),
);
assert.match(markup, /aria-label="Jump to message"/);
assert.match(markup, /id="ai-chat-msg-u1"/);
assert.match(markup, /id="ai-chat-msg-u3"/);
});
test("jump pin release does not reset the loaded message tail", () => {
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
const releaseHandler = source.match(
/const handleReleaseJumpPin = useCallback\(\(\) => \{[\s\S]*?\}, \[\]\);/,
)?.[0] ?? "";
assert.match(releaseHandler, /setActiveJumpMessageId\(null\)/);
assert.match(releaseHandler, /setPendingJumpMessageId\(null\)/);
assert.doesNotMatch(releaseHandler, /setRenderedTailCount/);
});
test("load earlier advances from the effective pinned tail", () => {
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
assert.match(
source,
/setRenderedTailCount\(\(count\) =>\s*Math\.max\(count, effectiveTailCount\) \+ MESSAGE_RENDER_STEP\)/,
);
});
test("jump pin ignores streaming isAtBottom flips and releases on scroll button", () => {
const jumpSource = readFileSync(new URL("./ChatJumpNav.tsx", import.meta.url), "utf8");
const listSource = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
assert.match(jumpSource, /isStreaming\?: boolean/);
assert.match(jumpSource, /if \(isStreaming\) return;/);
assert.match(jumpSource, /window\.setTimeout/);
assert.match(listSource, /isStreaming=\{!!isStreaming\}/);
assert.match(listSource, /<ConversationScrollButton onClick=\{handleReleaseJumpPin\} \/>/);
});
test("ChatMessageList renders Codex activities and actual usage", () => {
const messages: ChatMessage[] = [{
id: "assistant-activity",
role: "assistant",
content: "Done",
timestamp: 1,
agentActivities: [
{
id: "plan-1",
type: "plan_update",
status: "running",
items: [{ text: "Map Codex events", completed: false }],
},
{
id: "search-1",
type: "web_search",
status: "completed",
query: "Codex SDK event types",
},
{
id: "patch-1",
type: "file_change",
status: "completed",
changes: [{ path: "src/app.ts", kind: "update" }],
},
{
id: "warning-1",
type: "warning",
status: "completed",
message: "A recoverable warning",
},
],
usage: {
inputTokens: 100,
cachedInputTokens: 40,
outputTokens: 25,
reasoningTokens: 10,
totalTokens: 125,
},
}];
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(ChatMessageList, { messages, isStreaming: true }),
),
),
);
assert.match(markup, /Agent activity/);
assert.match(markup, /Map Codex events/);
assert.match(markup, /Codex SDK event types/);
assert.match(markup, /src\/app\.ts/);
assert.match(markup, /A recoverable warning/);
assert.match(markup, /Tokens 125/);
assert.match(markup, /cached 40/);
assert.match(markup, /reasoning 10/);
});
test("ChatMessageList renders external MCP vault tool results as artifact cards", () => {
const messages: ChatMessage[] = [
{
id: "tool-1",
role: "tool",
content: "",
timestamp: 1,
toolResults: [
{
toolCallId: "external-call-1",
toolName: "mcp__netcatty__vault_notes_create",
content: JSON.stringify({
ok: true,
note: { id: "note-1", title: "Deploy Runbook", group: "ops" },
}),
},
],
},
];
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(ChatMessageList, { messages }),
),
),
);
assert.match(markup, /Deploy Runbook/);
assert.match(markup, /ops/);
assert.doesNotMatch(markup, /external-call-1/);
});
test("ChatMessageList renders Netcatty CLI vault results as artifact cards", () => {
const messages: ChatMessage[] = [
{
id: "assistant-1",
role: "assistant",
content: "",
timestamp: 1,
toolCalls: [
{
id: "cli-call-1",
name: "shell",
arguments: {
command: `/bin/zsh -lc '"/Applications/Netcatty.app/netcatty-tool-cli" vault host get --host-id host_1 --json'`,
},
},
],
executionStatus: "completed",
},
{
id: "tool-1",
role: "tool",
content: "",
timestamp: 2,
toolResults: [
{
toolCallId: "cli-call-1",
toolName: "shell",
content: JSON.stringify({
ok: true,
host: { id: "host_1", label: "Prod", hostname: "prod.example.com", port: 22 },
}),
},
],
},
];
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(ChatMessageList, { messages }),
),
),
);
assert.match(markup, /Prod/);
assert.match(markup, /prod\.example\.com:22/);
assert.doesNotMatch(markup, /cli-call-1/);
});
test("ChatMessageList renders Claude MCP list envelopes as summary cards", () => {
const messages: ChatMessage[] = [
{
id: "assistant-1",
role: "assistant",
content: "",
timestamp: 1,
toolCalls: [
{
id: "notes-call-1",
name: "mcp__netcatty-remote-hosts__vault_notes_list",
arguments: {},
},
],
executionStatus: "completed",
},
{
id: "tool-1",
role: "tool",
content: "",
timestamp: 2,
toolResults: [
{
toolCallId: "notes-call-1",
content: JSON.stringify([
{
type: "text",
text: JSON.stringify({
ok: true,
notes: Array.from({ length: 8 }, (_value, index) => ({
id: `note-${index}`,
title: `Note ${index}`,
})),
}),
},
]),
},
],
},
];
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(ChatMessageList, { messages }),
),
),
);
assert.match(markup, /8 notes in Vault/);
assert.doesNotMatch(markup, /notes-call-1/);
});
test("ChatMessageList renders OpenCode MCP-prefixed vault results as artifact cards", () => {
const messages: ChatMessage[] = [
{
id: "tool-1",
role: "tool",
content: "",
timestamp: 1,
toolResults: [
{
toolCallId: "opencode-call-1",
toolName: "netcatty-remote-hosts_vault_notes_get",
content: JSON.stringify({
ok: true,
note: {
id: "note-1",
title: "2026-06-25 Host Import Report",
group: "infra/imports",
},
}),
},
],
},
];
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(ChatMessageList, { messages }),
),
),
);
assert.match(markup, /2026-06-25 Host Import Report/);
assert.match(markup, /infra\/imports/);
assert.doesNotMatch(markup, /opencode-call-1/);
});
test("ChatMessageList renders Copilot MCP-prefixed wrapped vault results as artifact cards", () => {
const payload = {
ok: true,
notes: Array.from({ length: 8 }, (_value, index) => ({
id: `note-${index}`,
title: `Note ${index}`,
})),
};
const messages: ChatMessage[] = [
{
id: "tool-1",
role: "tool",
content: "",
timestamp: 1,
toolResults: [
{
toolCallId: "copilot-call-1",
toolName: "netcatty-remote-hosts-vault_notes_list",
content: JSON.stringify({
content: JSON.stringify(payload),
detailedContent: JSON.stringify(payload),
contents: [
{
type: "text",
text: JSON.stringify(payload),
},
],
}),
},
],
},
];
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(ChatMessageList, { messages }),
),
),
);
assert.match(markup, /8 notes in Vault/);
assert.doesNotMatch(markup, /copilot-call-1/);
});
test('ChatMessageList renders script create tool results as artifact cards', () => {
const messages: ChatMessage[] = [
{
id: 'tool-script-1',
role: 'tool',
content: '',
timestamp: 1,
toolResults: [
{
toolCallId: 'call-script-1',
toolName: 'scripts_create',
content: {
ok: true,
script: {
id: 'script-1',
label: 'Disk cleanup',
language: 'javascript',
},
},
},
],
},
];
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: 'en' },
React.createElement(
TooltipProvider,
null,
React.createElement(ChatMessageList, { messages }),
),
),
);
assert.match(markup, /Disk cleanup/);
assert.match(markup, /javascript script/);
assert.doesNotMatch(markup, /call-script-1/);
});
test("ChatMessageList wires vault artifact navigation when only note open is available", () => {
assert.equal(shouldProvideVaultArtifactNavigation({
onOpenVaultNote: () => {},
}), true);
});
test("ChatMessageList leaves vault artifact navigation disabled without open actions", () => {
assert.equal(shouldProvideVaultArtifactNavigation({}), false);
});
test("Codex approval render plan preserves every approval for the same item", () => {
const codexRequest = (
toolCallId: string,
itemId: string,
command: string,
chatSessionId = "chat-1",
): ApprovalRequest => ({
toolCallId,
itemId,
toolName: "codex.command",
args: { command },
chatSessionId,
source: "codex-app-server",
approvalType: "command",
allowSession: false,
});
const pendingApprovals = new Map<string, ApprovalRequest>([
["approval-1", codexRequest("approval-1", "item-1", "echo one")],
["approval-2", codexRequest("approval-2", "item-1", "echo two")],
["approval-3", codexRequest("approval-3", "item-2", "echo standalone")],
["approval-other-session", codexRequest("approval-other-session", "item-1", "echo hidden", "chat-2")],
["regular-approval", {
toolCallId: "regular-approval",
toolName: "terminal_execute",
args: { command: "pwd" },
chatSessionId: "chat-1",
}],
]);
const plan = buildCodexApprovalRenderPlan(
pendingApprovals,
new Set(["item-1"]),
"chat-1",
);
assert.deepEqual(
plan.byItemId.get("item-1")?.map(({ approvalId }) => approvalId),
["approval-1", "approval-2"],
);
assert.deepEqual(
plan.standalone.map(({ approvalId }) => approvalId),
["approval-3"],
);
});