Files
tutti/packages/agent/gui/shared/agentConversation/components/AgentTranscriptItemView.spec.tsx
T
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

1681 lines
49 KiB
TypeScript

import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import {
act,
fireEvent,
render,
screen,
waitFor
} from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
resetAgentHostApiForTests,
setAgentHostApiForTests
} from "../../../agentActivityHost";
import { AgentEnvPanelActionProvider } from "../../agentEnv";
import type { AgentGUIRuntime } from "../../../agentActivityRuntime";
import { AgentMessageBlock } from "./AgentMessageBlock";
import { AgentTranscriptItemView } from "./AgentTranscriptItemView";
import type { AgentGoalControlRowVM } from "../contracts/agentGoalControlRowVM";
import type { AgentMessageContentVM } from "../contracts/agentMessageRowVM";
import type { AgentMessageRowVM } from "../contracts/agentMessageRowVM";
import type { AgentToolCallVM } from "../contracts/agentToolCallVM";
import type { AgentToolGroupRowVM } from "../contracts/agentToolGroupRowVM";
const mockState = vi.hoisted(() => ({
markdownOnLinkClicks: [] as Array<((href: string) => void) | undefined>,
markdownStreamingFlags: [] as Array<boolean | undefined>,
toolGroupOnLinkClicks: [] as Array<((href: string) => void) | undefined>
}));
const agentActivityStyles = readFileSync(
resolve(process.cwd(), "app/renderer/agentactivity.css"),
"utf8"
);
const agentActivityStyleElement = document.createElement("style");
agentActivityStyleElement.textContent = agentActivityStyles;
afterEach(() => {
agentActivityStyleElement.remove();
});
vi.mock("../../../i18n/index", () => ({
getActiveUiLanguage: () => "en",
useTranslation: () => ({
t: (key: string) => key
}),
translate: (key: string) => key
}));
vi.mock("../../AgentMessageMarkdown", () => ({
AgentMessageMarkdown: ({
content,
onLinkClick,
streaming
}: {
content: string;
onLinkClick?: (href: string) => void;
streaming?: boolean;
}) => {
mockState.markdownOnLinkClicks.push(onLinkClick);
mockState.markdownStreamingFlags.push(streaming);
return <div>{content}</div>;
}
}));
vi.mock("../../AgentRichTextReadonly", () => ({
AgentRichTextReadonly: ({
className,
onLinkClick,
value
}: {
className?: string;
onLinkClick?: (href: string) => void;
value?: string;
}) => {
mockState.markdownOnLinkClicks.push(onLinkClick);
return <div className={className}>{value ?? "User message"}</div>;
}
}));
vi.mock("./AgentToolGroupRow", () => ({
AgentToolGroupRow: ({
onLinkClick
}: {
onLinkClick?: (href: string) => void;
}) => {
mockState.toolGroupOnLinkClicks.push(onLinkClick);
return <div>Tool group</div>;
}
}));
beforeEach(() => {
mockState.markdownOnLinkClicks.length = 0;
mockState.markdownStreamingFlags.length = 0;
mockState.toolGroupOnLinkClicks.length = 0;
});
describe("AgentTranscriptItemView render stability", () => {
it("renders and copies a goal control as a dedicated transcript row", async () => {
const row: AgentGoalControlRowVM = {
kind: "goal-control",
id: "goal-control:client-submit:user:submit-goal-1",
turnId: null,
action: "set",
body: "/goal ship it",
occurredAtUnixMs: 1
};
const writeText = vi.fn(async () => undefined);
installAgentHostClipboard(writeText);
const { container } = render(
<AgentTranscriptItemView
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={row}
labels={transcriptLabels()}
/>
);
expect(screen.getByText("/goal ship it")).toBeInTheDocument();
expect(
container.querySelector('[data-agent-goal-control-action="set"]')
).toBeInTheDocument();
expect(
container.querySelector(
'[data-agent-message-footer="true"] [aria-label="agentHost.agentGui.copyMessage"]'
)
).toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", {
name: "agentHost.agentGui.copyMessage"
})
);
await waitFor(() => {
expect(writeText).toHaveBeenCalledWith("/goal ship it");
});
});
it("keeps tool link handlers stable when an unchanged tool row is reprojected", () => {
const onLinkAction = vi.fn();
const labels = transcriptLabels();
const { rerender } = render(
<AgentTranscriptItemView
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={toolGroupRow()}
labels={labels}
onLinkAction={onLinkAction}
/>
);
rerender(
<AgentTranscriptItemView
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={toolGroupRow()}
labels={labels}
onLinkAction={onLinkAction}
/>
);
expect(mockState.toolGroupOnLinkClicks).toHaveLength(2);
expect(mockState.toolGroupOnLinkClicks[1]).toBe(
mockState.toolGroupOnLinkClicks[0]
);
});
it("keeps assistant markdown link handlers stable when an unchanged message row is reprojected", () => {
const onLinkAction = vi.fn();
const { rerender } = render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={assistantMessageRow()}
onLinkAction={onLinkAction}
thinkingLabel="Thought process"
/>
);
rerender(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={assistantMessageRow()}
onLinkAction={onLinkAction}
thinkingLabel="Thought process"
/>
);
expect(mockState.markdownOnLinkClicks).toHaveLength(2);
expect(mockState.markdownOnLinkClicks[1]).toBe(
mockState.markdownOnLinkClicks[0]
);
});
it("routes a clicked app artifact reference from a sent user message to the owning app", () => {
const onLinkAction = vi.fn();
render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={userMessageRow({
kind: "message-content",
id: "user-app-reference-1",
turnId: "turn-1",
body: "[@AI Canvas](mention://workspace-reference/ai-canvas?source=app&workspaceId=workspace-1)",
occurredAtUnixMs: 1
})}
onLinkAction={onLinkAction}
thinkingLabel="Thought process"
/>
);
mockState.markdownOnLinkClicks.at(-1)?.(
"mention://workspace-reference/ai-canvas?source=app&workspaceId=workspace-1"
);
expect(onLinkAction).toHaveBeenCalledWith({
type: "open-workspace-app",
workspaceId: "workspace-1",
appId: "ai-canvas",
source: "agent-markdown"
});
});
it("toasts when a sent composer-file mention can no longer be resolved", () => {
const onLinkAction = vi.fn();
const hostToastError = vi.fn();
setAgentHostApiForTests({
toast: { error: hostToastError }
} as never);
try {
render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={userMessageRow({
kind: "message-content",
id: "user-composer-file-1",
turnId: "turn-1",
body: "[diagnostics.md](mention://composer-file/file-1?status=ready)",
occurredAtUnixMs: 1
})}
onLinkAction={onLinkAction}
thinkingLabel="Thought process"
/>
);
mockState.markdownOnLinkClicks.at(-1)?.(
"mention://composer-file/file-1?status=ready"
);
expect(onLinkAction).not.toHaveBeenCalled();
expect(hostToastError).toHaveBeenCalledWith(
"agentHost.agentGui.composerFileOpenUnavailable"
);
} finally {
resetAgentHostApiForTests();
}
});
it("keeps markdown streaming for active turns and unsettled messages", () => {
render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={assistantMessageRow({
kind: "message-content",
id: "assistant-working-1",
turnId: "turn-1",
body: "Streaming answer",
statusKind: "working",
occurredAtUnixMs: 1
})}
thinkingLabel="Thought process"
/>
);
render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={assistantMessageRow({
kind: "message-content",
id: "assistant-completed-1",
turnId: "turn-1",
body: "Completed answer",
statusKind: "completed",
occurredAtUnixMs: 1
})}
thinkingLabel="Thought process"
/>
);
render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={assistantMessageRow({
kind: "message-content",
id: "assistant-active-turn-1",
turnId: "turn-1",
body: "Tool completed, answer still growing",
statusKind: "completed",
occurredAtUnixMs: 1
})}
thinkingLabel="Thought process"
isActiveTurn
/>
);
expect(mockState.markdownStreamingFlags.at(-3)).toBe(true);
expect(mockState.markdownStreamingFlags.at(-2)).toBe(false);
expect(mockState.markdownStreamingFlags.at(-1)).toBe(true);
});
it("keeps participant presentation off by default", () => {
const { container } = render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={userMessageRow()}
thinkingLabel="Thought process"
/>
);
const flow = document.querySelector(
".agent-gui-conversation__user-message-flow"
);
expect(flow).toBeInstanceOf(HTMLElement);
expect(flow?.children).toHaveLength(1);
const group = flow?.children.item(0);
expect(group).toBeInstanceOf(HTMLElement);
if (!(group instanceof HTMLElement)) {
throw new Error("Expected user message group to render.");
}
const bubble = group.children.item(0);
expect(bubble).toBeInstanceOf(HTMLElement);
if (!(bubble instanceof HTMLElement)) {
throw new Error("Expected user message bubble to render.");
}
expect(group).toHaveAttribute("data-agent-message-speaker", "user");
expect(group).toHaveAttribute("data-agent-message-footer", "true");
expect(group).toHaveTextContent("User asks for a fix");
expect(
group.querySelector(".agent-gui-conversation__message-copy-button")
).toBeInstanceOf(HTMLButtonElement);
expect(
container.querySelector("[data-agent-conversation-participant-avatar]")
).toBeNull();
expect(
container.querySelector(
".agent-gui-conversation__participant-message-layout"
)
).toBeNull();
});
it("preserves the existing message DOM when participant presentation is disabled", () => {
const { container } = render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={userMessageRow()}
thinkingLabel="Thought process"
participantPresentation={{ enabled: false }}
/>
);
expect(
container.querySelector(
".agent-gui-conversation__participant-message-layout"
)
).toBeNull();
expect(
container.querySelector("[data-agent-conversation-participant-avatar]")
).toBeNull();
expect(
container.querySelector(".agent-gui-conversation__user-message-flow")
?.children
).toHaveLength(1);
});
it("renders fixed participant avatar loading slots without hiding messages", () => {
const participantPresentation = {
enabled: true,
status: "loading"
} as const;
const { container } = render(
<>
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={assistantMessageRow()}
thinkingLabel="Thought process"
participantPresentation={participantPresentation}
/>
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={userMessageRow()}
thinkingLabel="Thought process"
participantPresentation={participantPresentation}
/>
</>
);
const avatars = container.querySelectorAll(
"[data-agent-conversation-participant-avatar]"
);
expect(avatars).toHaveLength(2);
expect(avatars[0]).toHaveAttribute(
"data-agent-conversation-participant-avatar",
"assistant"
);
expect(avatars[1]).toHaveAttribute(
"data-agent-conversation-participant-avatar",
"user"
);
expect(avatars[0]).toHaveAttribute("data-avatar-state", "loading");
expect(avatars[1]).toHaveAttribute("data-avatar-state", "loading");
expect(screen.getByText(/Assistant answer/)).toBeInTheDocument();
expect(screen.getByText("User asks for a fix")).toBeInTheDocument();
});
it("renders the avatar and sender name in a header row above the message", () => {
const participantPresentation = {
enabled: true,
status: "ready",
agent: { name: "Codex" },
user: { name: "Alice" }
} as const;
const { container } = render(
<>
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={assistantMessageRow()}
thinkingLabel="Thought process"
participantPresentation={participantPresentation}
/>
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={userMessageRow()}
thinkingLabel="Thought process"
participantPresentation={participantPresentation}
/>
</>
);
const assistantLayout = container.querySelector(
'.agent-gui-conversation__participant-message-layout[data-agent-message-speaker="assistant"]'
);
const userLayout = container.querySelector(
'.agent-gui-conversation__participant-message-layout[data-agent-message-speaker="user"]'
);
const assistantHeader = container.querySelector(
'[data-agent-conversation-participant-header="assistant"]'
);
expect(assistantHeader).toHaveClass(
"agent-gui-conversation__participant-message-header"
);
expect(
assistantHeader?.querySelector(
'[data-agent-conversation-participant-avatar="assistant"]'
)
).toHaveAttribute("aria-label", "Codex");
expect(assistantHeader).toHaveTextContent("Codex");
// The header row renders above the thinking/tool-call info and the
// message content.
expect(
assistantHeader?.compareDocumentPosition(assistantLayout as Element)
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
const userHeader = container.querySelector(
'[data-agent-conversation-participant-header="user"]'
);
expect(userHeader).toHaveClass(
"agent-gui-conversation__participant-message-header"
);
expect(
userHeader?.querySelector(
'[data-agent-conversation-participant-avatar="user"]'
)
).toHaveAttribute("aria-label", "Alice");
expect(userHeader).toHaveTextContent("Alice");
expect(userHeader?.compareDocumentPosition(userLayout as Element)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
});
it("renders leading tool rows under the participant header above the message", () => {
const participantPresentation = {
enabled: true,
status: "ready",
agent: { name: "Codex" },
user: { name: "Alice" }
} as const;
const leadingToolGroup: AgentToolGroupRowVM = {
kind: "tool-group",
id: "leading-tools-1",
turnId: "turn-1",
grouped: true,
calls: [toolCall(), toolCall()],
entries: [],
occurredAtUnixMs: 1
};
const row: AgentMessageRowVM = {
...assistantMessageRow(),
leadingToolRows: [leadingToolGroup]
};
const { container, getByText } = render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={row}
thinkingLabel="Thought process"
participantPresentation={participantPresentation}
/>
);
const header = container.querySelector(
'[data-agent-conversation-participant-header="assistant"]'
);
const toolContent = getByText("Tool group");
const messageContent = getByText(/Assistant answer/);
expect(header).not.toBeNull();
expect(header?.compareDocumentPosition(toolContent)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
expect(toolContent.compareDocumentPosition(messageContent)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
});
it("copies user message text through the agent host clipboard", async () => {
const writeText = vi.fn(async () => undefined);
installAgentHostClipboard(writeText);
render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={userMessageRow()}
thinkingLabel="Thought process"
/>
);
fireEvent.click(
screen.getByRole("button", {
name: "agentHost.agentGui.copyMessage"
})
);
await waitFor(() => {
expect(writeText).toHaveBeenCalledWith("User asks for a fix");
});
expect(
screen.getByRole("button", {
name: "agentHost.agentGui.messageCopied"
})
).toBeTruthy();
});
it("copies assistant markdown source through the agent host clipboard", async () => {
const writeText = vi.fn(async () => undefined);
installAgentHostClipboard(writeText);
render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={assistantMessageRow({
kind: "message-content",
id: "assistant-copy-1",
turnId: "turn-1",
body: "Assistant **summary** with `code`",
copyText: "Assistant **summary** with `code`",
occurredAtUnixMs: 1
})}
thinkingLabel="Thought process"
/>
);
fireEvent.click(
screen.getByRole("button", {
name: "agentHost.agentGui.copyMessage"
})
);
await waitFor(() => {
expect(writeText).toHaveBeenCalledWith(
"Assistant **summary** with `code`"
);
});
});
it("loads user prompt image attachments from the activity runtime", async () => {
const readSessionAttachment = vi.fn(async () => ({
attachmentId: "attachment-1",
data: "aW1hZ2U=",
mimeType: "image/png" as const,
name: "screen.png"
}));
Object.defineProperty(window, "agentGUIRuntime", {
configurable: true,
value: {
readSessionAttachment
} as Partial<AgentGUIRuntime>
});
render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={userMessageRow({
kind: "message-content",
id: "user-images-1",
turnId: "turn-1",
body: "",
contentKind: "image-grid",
images: [
{
id: "attachment-1",
workspaceId: "room-1",
agentSessionId: "session-1",
attachmentId: "attachment-1",
mimeType: "image/png",
name: "screen.png"
}
],
occurredAtUnixMs: 1
})}
thinkingLabel="Thought process"
/>
);
await waitFor(() => {
expect(readSessionAttachment).toHaveBeenCalledWith({
workspaceId: "room-1",
agentSessionId: "session-1",
attachmentId: "attachment-1"
});
});
const image = await screen.findByRole("img", { name: "screen.png" });
expect(image).toHaveAttribute("src", "data:image/png;base64,aW1hZ2U=");
fireEvent.load(image);
await waitFor(() => {
expect(
screen.getByRole("button", { name: "common.expandImage" })
).toBeTruthy();
});
delete (window as { agentGUIRuntime?: unknown }).agentGUIRuntime;
});
it("offers retry for an ordinary prompt image read failure", async () => {
const readSessionAttachment = vi.fn(async () => {
throw new Error("attachment not found");
});
Object.defineProperty(window, "agentGUIRuntime", {
configurable: true,
value: {
readSessionAttachment
} as Partial<AgentGUIRuntime>
});
render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={userMessageRow({
kind: "message-content",
id: "user-images-read-failed",
turnId: "turn-1",
body: "",
contentKind: "image-grid",
images: [
{
id: "read-failed-image",
workspaceId: "room-1",
agentSessionId: "session-1",
attachmentId: "attachment-read-failed",
mimeType: "image/png",
name: "screen.png"
}
],
occurredAtUnixMs: 1
})}
thinkingLabel="Thought process"
/>
);
expect(
await screen.findByText("agentHost.agentGui.imageLoadFailed")
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "agentHost.agentGui.retryImage" })
).toBeInTheDocument();
delete (window as { agentGUIRuntime?: unknown }).agentGUIRuntime;
});
it("does not offer retry when the prompt image belongs to another VM", async () => {
const readSessionAttachment = vi.fn(async () => {
throw Object.assign(new Error("attachment is on another VM"), {
code: "agent_session_attachment_vm_unavailable"
});
});
Object.defineProperty(window, "agentGUIRuntime", {
configurable: true,
value: {
readSessionAttachment
} as Partial<AgentGUIRuntime>
});
render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={userMessageRow({
kind: "message-content",
id: "user-images-vm-unavailable",
turnId: "turn-1",
body: "",
contentKind: "image-grid",
images: [
{
id: "vm-unavailable-image",
workspaceId: "room-1",
agentSessionId: "session-1",
attachmentId: "attachment-from-another-vm",
mimeType: "image/png",
name: "screen.png"
}
],
occurredAtUnixMs: 1
})}
thinkingLabel="Thought process"
/>
);
expect(
await screen.findByText("agentHost.agentGui.imageTemporarilyUnavailable")
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "agentHost.agentGui.retryImage" })
).toBeNull();
delete (window as { agentGUIRuntime?: unknown }).agentGUIRuntime;
});
it("keeps a loaded optimistic image mounted when its durable attachment arrives", async () => {
const readPromptAsset = vi.fn(async () => ({
attachmentId: "prompt-asset-1",
data: "b3B0aW1pc3RpYw==",
mimeType: "image/png" as const,
name: "screen.png",
path: "/prompt-assets/screen.png"
}));
const readSessionAttachment = vi.fn(async () => ({
attachmentId: "attachment-1",
data: "ZHVyYWJsZQ==",
mimeType: "image/png" as const,
name: "screen.png"
}));
Object.defineProperty(window, "agentGUIRuntime", {
configurable: true,
value: {
readPromptAsset,
readSessionAttachment
} as Partial<AgentGUIRuntime>
});
const stableImageId = "client-submit:user:submit-1:image:0";
const optimisticMessage: AgentMessageContentVM = {
kind: "message-content",
id: "client-submit:user:submit-1:images:0",
turnId: "turn-1",
body: "",
presentationKind: "content",
contentKind: "image-grid",
images: [
{
id: stableImageId,
workspaceId: "room-1",
agentSessionId: "session-1",
mimeType: "image/png",
name: "screen.png",
path: "/prompt-assets/screen.png"
}
],
occurredAtUnixMs: 1
};
const durableMessage: AgentMessageContentVM = {
...optimisticMessage,
images: [
{
id: stableImageId,
workspaceId: "room-1",
agentSessionId: "session-1",
attachmentId: "attachment-1",
mimeType: "image/png",
name: "screen.png"
}
],
occurredAtUnixMs: 2
};
const { rerender } = render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={userMessageRow(optimisticMessage)}
thinkingLabel="Thought process"
/>
);
const optimisticImage = await screen.findByRole("img", {
name: "screen.png"
});
expect(optimisticImage).toHaveAttribute(
"src",
"data:image/png;base64,b3B0aW1pc3RpYw=="
);
fireEvent.load(optimisticImage);
await waitFor(() => {
expect(
screen.queryByTestId("agent-gui-message-image-loading")
).toBeNull();
});
rerender(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={userMessageRow(durableMessage)}
thinkingLabel="Thought process"
/>
);
expect(screen.getByRole("img", { name: "screen.png" })).toBe(
optimisticImage
);
expect(screen.queryByTestId("agent-gui-message-image-loading")).toBeNull();
expect(readPromptAsset).toHaveBeenCalledTimes(1);
expect(readSessionAttachment).not.toHaveBeenCalled();
delete (window as { agentGUIRuntime?: unknown }).agentGUIRuntime;
});
it("reserves a loading slot until a remote HTTPS image loads", async () => {
document.head.append(agentActivityStyleElement);
const readPromptAsset = vi.fn();
Object.defineProperty(window, "agentGUIRuntime", {
configurable: true,
value: {
readPromptAsset
} as Partial<AgentGUIRuntime>
});
render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={userMessageRow({
kind: "message-content",
id: "user-images-url-1",
turnId: "turn-1",
body: "",
contentKind: "image-grid",
images: [
{
id: "remote-image-1",
workspaceId: "room-1",
agentSessionId: "session-1",
mimeType: "image/png",
name: "screen.png",
url: "https://objects.example.test/signed/screen.png"
}
],
occurredAtUnixMs: 1
})}
thinkingLabel="Thought process"
/>
);
const image = screen.getByRole("img", { name: "screen.png" });
expect(image).toHaveAttribute(
"src",
"https://objects.example.test/signed/screen.png"
);
expect(
screen.getByTestId("agent-gui-message-image-loading")
).toBeInTheDocument();
expect(image.closest("[data-image-state]")).toHaveAttribute(
"data-image-state",
"loading"
);
const imageSlot = image.closest("[data-image-state]");
expect(imageSlot).not.toBeNull();
expect(window.getComputedStyle(imageSlot as Element).height).toBe("80px");
fireEvent.load(image);
await waitFor(() => {
expect(
screen.queryByTestId("agent-gui-message-image-loading")
).toBeNull();
expect(image.closest("[data-image-state]")).toHaveAttribute(
"data-image-state",
"loaded"
);
expect(window.getComputedStyle(imageSlot as Element).height).toBe("80px");
});
expect(readPromptAsset).not.toHaveBeenCalled();
delete (window as { agentGUIRuntime?: unknown }).agentGUIRuntime;
});
it("shows a failure placeholder and retries a remote image with the same URL", async () => {
const signedUrl = "https://objects.example.test/signed/screen.png";
render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={userMessageRow({
kind: "message-content",
id: "user-images-url-failed",
turnId: "turn-1",
body: "",
contentKind: "image-grid",
images: [
{
id: "remote-image-failed",
workspaceId: "room-1",
agentSessionId: "session-1",
mimeType: "image/png",
name: "screen.png",
url: signedUrl
}
],
occurredAtUnixMs: 1
})}
thinkingLabel="Thought process"
/>
);
const image = screen.getByRole("img", { name: "screen.png" });
expect(
screen.getByTestId("agent-gui-message-image-loading")
).toBeInTheDocument();
fireEvent.error(image);
expect(
await screen.findByTestId("agent-gui-message-image-failed")
).toBeInTheDocument();
expect(
screen.getByText("agentHost.agentGui.imageLoadFailed")
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "agentHost.agentGui.retryImage" })
).toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", { name: "agentHost.agentGui.retryImage" })
);
const retriedImage = await screen.findByRole("img", {
name: "screen.png"
});
expect(retriedImage).not.toBe(image);
expect(retriedImage).toHaveAttribute("src", signedUrl);
expect(
screen.getByTestId("agent-gui-message-image-loading")
).toBeInTheDocument();
fireEvent.load(retriedImage);
expect(screen.queryByTestId("agent-gui-message-image-failed")).toBeNull();
expect(screen.queryByTestId("agent-gui-message-image-loading")).toBeNull();
});
it("does not offer retry when a prompt image is unavailable in this host", async () => {
delete (window as { agentGUIRuntime?: unknown }).agentGUIRuntime;
render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={userMessageRow({
kind: "message-content",
id: "user-images-unavailable",
turnId: "turn-1",
body: "",
contentKind: "image-grid",
images: [
{
id: "unavailable-image",
workspaceId: "room-1",
agentSessionId: "session-1",
attachmentId: "attachment-from-another-host",
mimeType: "image/png",
name: "screen.png"
}
],
occurredAtUnixMs: 1
})}
thinkingLabel="Thought process"
/>
);
expect(
await screen.findByTestId("agent-gui-message-image-failed")
).toBeInTheDocument();
expect(
screen.getByText("agentHost.agentGui.imageTemporarilyUnavailable")
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "agentHost.agentGui.retryImage" })
).toBeNull();
});
it("starts a fresh load when a failed remote image URL changes", async () => {
const firstUrl = "https://objects.example.test/signed/screen-v1.png";
const secondUrl = "https://objects.example.test/signed/screen-v2.png";
const renderMessage = (url: string) => (
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={userMessageRow({
kind: "message-content",
id: "user-images-url-refreshed",
turnId: "turn-1",
body: "",
contentKind: "image-grid",
images: [
{
id: "remote-image-refreshed",
workspaceId: "room-1",
agentSessionId: "session-1",
mimeType: "image/png",
name: "screen.png",
url
}
],
occurredAtUnixMs: 1
})}
thinkingLabel="Thought process"
/>
);
const { rerender } = render(renderMessage(firstUrl));
const firstImage = screen.getByRole("img", { name: "screen.png" });
fireEvent.error(firstImage);
expect(
await screen.findByTestId("agent-gui-message-image-failed")
).toBeInTheDocument();
rerender(renderMessage(secondUrl));
const refreshedImage = await screen.findByRole("img", {
name: "screen.png"
});
expect(refreshedImage).not.toBe(firstImage);
expect(refreshedImage).toHaveAttribute("src", secondUrl);
expect(screen.queryByTestId("agent-gui-message-image-failed")).toBeNull();
expect(
screen.getByTestId("agent-gui-message-image-loading")
).toBeInTheDocument();
fireEvent.load(refreshedImage);
expect(screen.queryByTestId("agent-gui-message-image-loading")).toBeNull();
});
it("shows a loading spinner while a user prompt image is being read", async () => {
const readController: {
resolve: (value: {
attachmentId: string;
data: string;
mimeType: "image/png";
name: string;
}) => void;
} = {
resolve: () => {
throw new Error("readSessionAttachment was not called");
}
};
const readSessionAttachment = vi.fn(
() =>
new Promise<{
attachmentId: string;
data: string;
mimeType: "image/png";
name: string;
}>((resolvePromise) => {
readController.resolve = resolvePromise;
})
);
Object.defineProperty(window, "agentGUIRuntime", {
configurable: true,
value: {
readSessionAttachment
} as Partial<AgentGUIRuntime>
});
render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={userMessageRow({
kind: "message-content",
id: "user-images-loading",
turnId: "turn-1",
body: "",
contentKind: "image-grid",
images: [
{
id: "attachment-loading",
workspaceId: "room-1",
agentSessionId: "session-1",
attachmentId: "attachment-loading",
mimeType: "image/png",
name: "screen.png"
}
],
occurredAtUnixMs: 1
})}
thinkingLabel="Thought process"
/>
);
expect(
await screen.findByTestId("agent-gui-message-image-loading")
).toBeTruthy();
expect(screen.queryByRole("img", { name: "screen.png" })).toBeNull();
readController.resolve({
attachmentId: "attachment-loading",
data: "aW1hZ2U=",
mimeType: "image/png",
name: "screen.png"
});
const image = await screen.findByRole("img", { name: "screen.png" });
expect(image).toHaveAttribute("src", "data:image/png;base64,aW1hZ2U=");
expect(
screen.getByTestId("agent-gui-message-image-loading")
).toBeInTheDocument();
fireEvent.load(image);
await waitFor(() => {
expect(
screen.queryByTestId("agent-gui-message-image-loading")
).toBeNull();
});
delete (window as { agentGUIRuntime?: unknown }).agentGUIRuntime;
});
it("shows local agent sign-in guidance for auth errors", () => {
const onOpenAgentEnvPanel = vi.fn();
const { getByText } = render(
<AgentEnvPanelActionProvider openPanel={onOpenAgentEnvPanel}>
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={assistantMessageRow(claudeCodeAuthErrorMessage())}
thinkingLabel="Thought process"
/>
</AgentEnvPanelActionProvider>
);
expect(
getByText("agentHost.agentGui.visibleErrorAuthRequired")
).toBeTruthy();
expect(
getByText("agentHost.agentGui.visibleErrorAuthRequiredLocalAgentHint")
).toBeTruthy();
fireEvent.click(getByText("agentHost.agentGui.visibleErrorActionRelogin"));
expect(onOpenAgentEnvPanel).toHaveBeenCalledWith({
provider: "claude-code",
focus: "auth"
});
});
it("renders transport retry notice details without the generic title", () => {
const { getByText, queryByText } = render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={assistantMessageRow({
kind: "message-content",
id: "assistant-notice-1",
turnId: "turn-1",
body: "Codex connection interrupted. Reconnecting...",
occurredAtUnixMs: 1,
systemNotice: {
noticeKind: "transport_retry",
severity: "warning",
title: "Codex connection interrupted. Reconnecting...",
detail:
"Handled error during turn: Reconnecting... 1/5 Some(ResponseStreamDisconnected { http_status_code: None })",
retryable: true
}
})}
thinkingLabel="Thought process"
/>
);
expect(getByText("Reconnecting... 1/5")).toBeTruthy();
const notice = getByText("Reconnecting... 1/5");
expect(notice.tagName).toBe("DIV");
expect(queryByText("agentHost.agentGui.visibleErrorDetails")).toBeNull();
expect(
queryByText("Codex connection interrupted. Reconnecting...")
).toBeNull();
expect(
queryByText("agentHost.agentGui.systemNoticeTransportRetry")
).toBeNull();
});
it.each([
[
"plan_implementation_pending_confirmation",
"agentHost.agentGui.systemNoticePlanImplementationPendingConfirmation"
],
[
"plan_implementation_completed",
"agentHost.agentGui.systemNoticePlanImplementationCompleted"
]
])(
"renders semantic plan notice %s without transport text",
(noticeKind, key) => {
const { getByText } = render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={assistantMessageRow({
kind: "message-content",
id: `assistant-${noticeKind}`,
turnId: "turn-1",
body: "",
occurredAtUnixMs: 1,
systemNotice: {
noticeKind,
severity: "info",
title: null,
detail: null,
retryable: false
}
})}
thinkingLabel="Thought process"
/>
);
expect(getByText(key)).toBeTruthy();
}
);
it("renders transport fallback notices with the localized label", () => {
const { getByRole, getByText } = render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={assistantMessageRow({
kind: "message-content",
id: "assistant-transport-fallback",
turnId: "turn-1",
body: "Falling back from WebSockets to HTTPS transport.",
occurredAtUnixMs: 1,
systemNotice: {
noticeKind: "transport_fallback",
severity: "warning",
title: "Falling back from WebSockets to HTTPS transport.",
detail:
"stream disconnected before completion: websocket closed by server before response.completed",
retryable: true
}
})}
thinkingLabel="Thought process"
/>
);
expect(getByRole("status")).toBeTruthy();
expect(
getByText("agentHost.agentGui.systemNoticeTransportFallback")
).toBeTruthy();
});
it("renders fallback warning notices with their title", () => {
const { getByRole, getByText } = render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={assistantMessageRow({
kind: "message-content",
id: "assistant-warning-fallback",
turnId: "turn-1",
body: "Falling back from WebSockets to HTTPS transport.",
occurredAtUnixMs: 1,
systemNotice: {
noticeKind: "warning",
severity: "warning",
title: "Falling back from WebSockets to HTTPS transport.",
detail:
"stream disconnected before completion: websocket closed by server before response.completed",
retryable: true
}
})}
thinkingLabel="Thought process"
/>
);
expect(getByRole("status")).toBeTruthy();
expect(
getByText("Falling back from WebSockets to HTTPS transport.")
).toBeTruthy();
});
it("renders context compaction notices as an inline divider", () => {
const { getByRole, getByText, queryByText } = render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={assistantMessageRow({
kind: "message-content",
id: "assistant-notice-compaction",
turnId: "turn-1",
body: "Context compacted.",
occurredAtUnixMs: 1,
systemNotice: {
noticeKind: "system_notice",
severity: null,
command: "compact",
commandStatus: "completed",
title: "Context compacted.",
detail: "",
retryable: null
}
})}
thinkingLabel="Thought process"
/>
);
const notice = getByRole("status");
expect(notice.tagName).toBe("DIV");
const dividers = notice.querySelectorAll('span[aria-hidden="true"]');
expect(dividers).toHaveLength(2);
expect(
getByText("agentHost.agentGui.contextCompactionCompleted")
).toBeTruthy();
expect(queryByText("agentHost.agentGui.systemNoticeDefault")).toBeNull();
});
it("renders in-progress compaction notices as a divider with a ticking timer", () => {
vi.useFakeTimers();
try {
vi.setSystemTime(66_000);
const { getByRole, getByText } = render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={assistantMessageRow({
kind: "message-content",
id: "assistant-notice-compacting",
turnId: "turn-1",
body: "Compacting context.",
occurredAtUnixMs: 61_000,
systemNotice: {
noticeKind: "system_notice",
severity: null,
command: "compact",
commandStatus: "running",
title: "Compacting context.",
detail: "",
retryable: null
}
})}
thinkingLabel="Thought process"
/>
);
const notice = getByRole("status");
const dividers = notice.querySelectorAll('span[aria-hidden="true"]');
expect(dividers).toHaveLength(2);
expect(
getByText(/agentHost\.agentGui\.contextCompactionInProgress/)
).toBeTruthy();
expect(getByText(/· 5s/)).toBeTruthy();
act(() => {
vi.advanceTimersByTime(3_000);
});
expect(getByText(/· 8s/)).toBeTruthy();
} finally {
vi.useRealTimers();
}
});
it("renders interrupted compaction notices with a wrapping detail below the divider", () => {
const { getByRole, getByText } = render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={assistantMessageRow({
kind: "message-content",
id: "assistant-notice-compaction-interrupted",
turnId: "turn-1",
body: "Context compaction interrupted.",
occurredAtUnixMs: 1,
systemNotice: {
noticeKind: "system_notice",
severity: null,
command: "compact",
commandStatus: "failed",
title: "Provider compact failure",
detail: "Not enough messages to compact.",
retryable: null
}
})}
thinkingLabel="Thought process"
/>
);
const notice = getByRole("status");
expect(notice.querySelectorAll('span[aria-hidden="true"]')).toHaveLength(2);
expect(notice.textContent).toContain(
"agentHost.agentGui.contextCompactionInterrupted"
);
expect(notice.textContent).toContain("Not enough messages to compact.");
const detail = getByText("Not enough messages to compact.");
expect(detail.className).toContain("break-words");
expect(detail.className).not.toContain("whitespace-nowrap");
});
it("renders context overflow as an error with new-conversation handoff guidance", () => {
const { getByRole } = render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={assistantMessageRow({
kind: "message-content",
id: "assistant-notice-context-handoff",
turnId: "turn-1",
body: "Context compaction interrupted.",
occurredAtUnixMs: 1,
systemNotice: {
noticeKind: "context_handoff_required",
semanticKind: "context-handoff-required",
severity: "error",
command: "compact",
commandStatus: "failed",
title: "Provider compact failure",
detail: "Maximum context length exceeded.",
retryable: false
}
})}
thinkingLabel="Thought process"
/>
);
const notice = getByRole("alert");
expect(notice.textContent).toContain(
"agentHost.agentGui.contextHandoffRequired"
);
expect(notice.textContent).toContain(
"agentHost.agentGui.contextHandoffRequiredDetail"
);
fireEvent.click(
screen.getByRole("button", {
name: "agentHost.agentGui.visibleErrorDetails"
})
);
expect(screen.getByText("Maximum context length exceeded.")).toBeTruthy();
});
it("does not let a legacy title override canonical compact status", () => {
const { getByText, queryByText } = render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={assistantMessageRow({
kind: "message-content",
id: "assistant-notice-conflicting-compaction",
turnId: "turn-1",
body: "Context compacted.",
occurredAtUnixMs: 1,
systemNotice: {
noticeKind: "system_notice",
severity: null,
command: "compact",
commandStatus: "failed",
title: "Context compacted.",
detail: "",
retryable: null
}
})}
thinkingLabel="Thought process"
/>
);
expect(
getByText("agentHost.agentGui.contextCompactionInterrupted")
).toBeTruthy();
expect(
queryByText("agentHost.agentGui.contextCompactionCompleted")
).toBeNull();
});
it("renders plan-tagged assistant messages as a dedicated plan card", () => {
const { getByTestId } = render(
<AgentMessageBlock
workspaceRoot="/workspace/demo"
basePath="/workspace/demo"
row={assistantMessageRow({
kind: "message-content",
id: "assistant-plan-1",
turnId: "turn-1",
body: "# Repo health plan\n1. inspect\n2. fix",
contentKind: "plan",
occurredAtUnixMs: 1
})}
thinkingLabel="Thought process"
/>
);
expect(getByTestId("agent-plan-card")).toBeTruthy();
expect(getByTestId("agent-plan-card-title").textContent).toBe(
"agentHost.agentGui.planCardTitle"
);
expect(getByTestId("agent-plan-card").textContent).toContain(
"Repo health plan"
);
});
});
function transcriptLabels() {
return {
thinkingLabel: "Thought process",
toolCallsLabel: (count: number) => `Tool calls (${count})`,
processing: "Planning next moves",
turnSummary: "Changed files"
};
}
type TestAgentMessageContentVM = Omit<
AgentMessageContentVM,
"presentationKind"
> & {
presentationKind?: AgentMessageContentVM["presentationKind"];
};
function assistantMessageRow(
message: TestAgentMessageContentVM = {
kind: "message-content",
id: "assistant-1",
turnId: "turn-1",
body: "Assistant answer with [README](/workspace/demo/README.md)",
occurredAtUnixMs: 1
}
): AgentMessageRowVM {
return {
kind: "message",
id: "message-row-1",
turnId: "turn-1",
speaker: "assistant",
messages: [withDefaultPresentationKind(message)],
thinking: [],
occurredAtUnixMs: 1
};
}
function userMessageRow(
message?: TestAgentMessageContentVM
): AgentMessageRowVM {
return {
kind: "message",
id: "message-row-user-1",
turnId: "turn-1",
speaker: "user",
messages: [
withDefaultPresentationKind(
message ?? {
kind: "message-content",
id: "user-1",
turnId: "turn-1",
body: "User asks for a fix",
copyText: "User asks for a fix",
occurredAtUnixMs: 1
}
)
],
thinking: [],
occurredAtUnixMs: 1
};
}
function withDefaultPresentationKind(
message: TestAgentMessageContentVM
): AgentMessageContentVM {
return {
...message,
presentationKind: message.presentationKind ?? "content"
};
}
function installAgentHostClipboard(
writeText: (text: string) => Promise<void>
): void {
(
window as unknown as {
agentHostApi?: unknown;
}
).agentHostApi = {
clipboard: { writeText },
filesystem: {},
workspace: {}
};
}
function claudeCodeAuthErrorMessage(): AgentMessageContentVM {
return {
kind: "message-content",
id: "assistant-auth-1",
turnId: "turn-1",
body: "Claude Code needs authentication.",
presentationKind: "content",
occurredAtUnixMs: 1,
visibleError: {
code: "auth_required",
phase: "start",
provider: "claude-code",
detail: "Failed to authenticate.",
retryable: false
}
};
}
function toolGroupRow(): AgentToolGroupRowVM {
const call = toolCall();
return {
kind: "tool-group",
id: "tools-1",
turnId: "turn-1",
grouped: false,
calls: [call],
entries: [
{
kind: "tool-call",
call
}
],
occurredAtUnixMs: 1
};
}
function toolCall(): AgentToolCallVM {
return {
kind: "tool-call",
id: "call-1",
turnId: "turn-1",
name: "Read File",
toolName: "read_file",
callType: "tool",
status: "Completed",
statusKind: "completed",
summary: "/workspace/demo/README.md",
compactSummary: "/workspace/demo/README.md",
payload: null,
input: null,
output: null,
error: null,
metadata: null,
locations: null,
rendererKind: "read",
approval: null,
planMode: null,
askUserQuestion: null,
task: null,
occurredAtUnixMs: 1
};
}