2a29104412
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com> Co-authored-by: Henry <nova28@users.noreply.github.com> Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: João Salavisa <joao.salavisa@gmail.com> Co-authored-by: Kandev Agent <agent@kandev.dev>
805 lines
26 KiB
TypeScript
805 lines
26 KiB
TypeScript
/* eslint-disable max-lines -- ChatMessage integration coverage stays in one spec. */
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import type { ReactNode } from "react";
|
|
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
import { StateProvider } from "@/components/state-provider";
|
|
import { ToastProvider } from "@/components/toast-provider";
|
|
import { ChatMessage } from "./chat-message";
|
|
import { entityReferenceMarkdown } from "@/lib/entity-references/message-references";
|
|
import type { EntityReference } from "@/lib/types/entity-reference";
|
|
import { activateLocale } from "@/lib/i18n";
|
|
import {
|
|
sessionId as toSessionId,
|
|
taskId as toTaskId,
|
|
type CustomPrompt,
|
|
type Message,
|
|
type TaskSession,
|
|
type Turn,
|
|
} from "@/lib/types/http";
|
|
|
|
const SENDER_TASK_ID = "task-sender";
|
|
const SENDER_TITLE = "Fix login bug";
|
|
const SENDER_BADGE_SELECTOR = "[data-testid='sender-task-badge']";
|
|
const MESSAGE_TIMESTAMP = "2026-05-04T00:00:00Z";
|
|
const TURN_MODEL = "gpt-5.6-sol";
|
|
const PNG_BASE64 =
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
|
|
const OPEN_ATTACHMENT_1_LABEL = "Open Attachment 1";
|
|
const FULL_SIZE_ATTACHMENT_1_ALT = "Full size Attachment 1";
|
|
const PROMPT_MENTION_TESTID = "custom-prompt-mention";
|
|
const ENTITY_REFERENCE_TESTID = "entity-reference-chip";
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
/** Builds a user Message with default test fields, merged with the given overrides. */
|
|
function userMessage(overrides: Partial<Message>): Message {
|
|
return {
|
|
id: "msg-1",
|
|
session_id: toSessionId("sess-1"),
|
|
task_id: toTaskId("task-target"),
|
|
author_type: "user",
|
|
content: "hello",
|
|
type: "message",
|
|
created_at: MESSAGE_TIMESTAMP,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** Builds a non-builtin CustomPrompt named after the given name. */
|
|
function customPrompt(name: string): CustomPrompt {
|
|
return {
|
|
id: `prompt-${name}`,
|
|
name,
|
|
content: `${name} content`,
|
|
builtin: false,
|
|
created_at: MESSAGE_TIMESTAMP,
|
|
updated_at: MESSAGE_TIMESTAMP,
|
|
};
|
|
}
|
|
|
|
/** Builds a Jira issue EntityReference with default fields, merged with the given overrides. */
|
|
function issueReference(overrides: Partial<EntityReference> = {}): EntityReference {
|
|
return {
|
|
version: 1,
|
|
ref: "mention:v1:jira:issue:https%3A%2F%2Fjira.example:10001",
|
|
provider: "jira",
|
|
kind: "issue",
|
|
id: "10001",
|
|
key: "ENG[1]",
|
|
title: "Fix the login flow",
|
|
url: "https://jira.example/browse/ENG-1?q=hello world(test)",
|
|
scope: "https://jira.example",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** Returns a StateProvider wrapper seeding the given kanban tasks and saved prompts. */
|
|
function wrapper(tasks: Array<{ id: string; title: string }> = [], prompts: CustomPrompt[] = []) {
|
|
/** Renders children inside a StateProvider preloaded with the wrapper's tasks and prompts. */
|
|
return function Wrapper({ children }: { children: ReactNode }) {
|
|
return (
|
|
<StateProvider
|
|
initialState={{
|
|
// Seed the kanban slice so useTaskById can resolve sender tasks for
|
|
// live-title resolution; tests that exercise the deleted-sender
|
|
// fallback simply omit the sender from this list.
|
|
// The full Task shape isn't required by useTaskById — only id+title.
|
|
kanban: {
|
|
tasks: tasks.map((t) => ({
|
|
id: t.id,
|
|
title: t.title,
|
|
workflow_step_id: "",
|
|
priority: "medium",
|
|
parent_id: undefined,
|
|
})),
|
|
} as unknown as never,
|
|
prompts: { items: prompts, loaded: true, loading: false },
|
|
}}
|
|
>
|
|
{children}
|
|
</StateProvider>
|
|
);
|
|
};
|
|
}
|
|
|
|
describe("ChatMessage prompt mentions", () => {
|
|
it("renders saved prompt mentions as visual chips", () => {
|
|
const Wrapper = wrapper([], [customPrompt("hello")]);
|
|
|
|
render(
|
|
<Wrapper>
|
|
<ChatMessage
|
|
comment={userMessage({ content: "@hello and @missing" })}
|
|
label="Message"
|
|
className=""
|
|
/>
|
|
</Wrapper>,
|
|
);
|
|
|
|
const chips = screen.getAllByTestId(PROMPT_MENTION_TESTID);
|
|
expect(chips).toHaveLength(1);
|
|
const [chip] = chips;
|
|
expect(chip.textContent).toBe("@hello");
|
|
expect(screen.getByText(/and @missing/)).not.toBeNull();
|
|
});
|
|
|
|
it("exposes the prompt contents on hover when the prompt is loaded", () => {
|
|
const Wrapper = wrapper([], [customPrompt("hello")]);
|
|
|
|
render(
|
|
<Wrapper>
|
|
<ChatMessage comment={userMessage({ content: "@hello" })} label="Message" className="" />
|
|
</Wrapper>,
|
|
);
|
|
|
|
const [chip] = screen.getAllByTestId(PROMPT_MENTION_TESTID);
|
|
// The chip becomes a hover-card trigger so its contents surface on hover,
|
|
// rather than relying on the plain browser title tooltip. `data-slot` is a
|
|
// shadcn/Radix implementation detail (slot-based CSS targeting), used here
|
|
// as a jsdom proxy for "chip is wired as a HoverCard trigger" since we
|
|
// can't reliably fire hover in jsdom.
|
|
expect(chip.getAttribute("data-slot")).toBe("hover-card-trigger");
|
|
expect(chip.getAttribute("title")).toBeNull();
|
|
});
|
|
it.each(["Enter", " "])("opens a saved prompt preview with the %s key", (key) => {
|
|
const Wrapper = wrapper([], [customPrompt("hello")]);
|
|
|
|
render(
|
|
<Wrapper>
|
|
<ChatMessage comment={userMessage({ content: "@hello" })} label="Message" className="" />
|
|
</Wrapper>,
|
|
);
|
|
|
|
const [chip] = screen.getAllByTestId(PROMPT_MENTION_TESTID);
|
|
fireEvent.keyDown(chip, { key });
|
|
|
|
expect(screen.getByText("hello content")).toBeTruthy();
|
|
expect(chip.getAttribute("aria-expanded")).toBe("true");
|
|
});
|
|
});
|
|
|
|
describe("Prompt mention touch previews", () => {
|
|
it("uses a touch-sized drawer trigger for prompt previews on coarse pointers", () => {
|
|
const originalWidth = window.innerWidth;
|
|
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => {
|
|
const listeners = new Set<() => void>();
|
|
return {
|
|
media: query,
|
|
matches: false,
|
|
onchange: null,
|
|
addEventListener: (_event: string, listener: () => void) => listeners.add(listener),
|
|
removeEventListener: (_event: string, listener: () => void) => listeners.delete(listener),
|
|
dispatchEvent: () => true,
|
|
} as unknown as MediaQueryList;
|
|
});
|
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 375 });
|
|
|
|
try {
|
|
const Wrapper = wrapper([], [customPrompt("hello")]);
|
|
render(
|
|
<Wrapper>
|
|
<ChatMessage comment={userMessage({ content: "@hello" })} label="Message" className="" />
|
|
</Wrapper>,
|
|
);
|
|
|
|
const [chip] = screen.getAllByTestId(PROMPT_MENTION_TESTID);
|
|
expect(chip.tagName).toBe("BUTTON");
|
|
expect(chip.getAttribute("data-slot")).toBe("drawer-trigger");
|
|
expect(chip.className).toContain("h-11");
|
|
fireEvent.click(chip);
|
|
expect(chip.getAttribute("aria-expanded")).toBe("true");
|
|
} finally {
|
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalWidth });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("ChatMessage prompt mention fallbacks", () => {
|
|
it("falls back to a plain chip with a title when the prompt has no contents", () => {
|
|
// A prompt with empty content has nothing to reveal on hover, so keep the
|
|
// lightweight title tooltip instead of a hover card.
|
|
const Wrapper = wrapper([], [{ ...customPrompt("hollow"), content: "" }]);
|
|
|
|
render(
|
|
<Wrapper>
|
|
<ChatMessage comment={userMessage({ content: "@hollow" })} label="Message" className="" />
|
|
</Wrapper>,
|
|
);
|
|
|
|
const [chip] = screen.getAllByTestId(PROMPT_MENTION_TESTID);
|
|
expect(chip.getAttribute("data-slot")).not.toBe("hover-card-trigger");
|
|
expect(chip.getAttribute("title")).toBe("Custom prompt: hollow");
|
|
});
|
|
|
|
it("preserves GFM attributes while highlighting prompt mentions", () => {
|
|
const Wrapper = wrapper([], [customPrompt("hello")]);
|
|
|
|
render(
|
|
<Wrapper>
|
|
<ChatMessage
|
|
comment={userMessage({
|
|
content: "- [x] @hello\n\n| Name |\n| :---: |\n| @hello |",
|
|
})}
|
|
label="Message"
|
|
className=""
|
|
/>
|
|
</Wrapper>,
|
|
);
|
|
|
|
const checkbox = screen.getByRole("checkbox") as HTMLInputElement;
|
|
expect(screen.getAllByTestId(PROMPT_MENTION_TESTID)).toHaveLength(2);
|
|
expect(checkbox.checked).toBe(true);
|
|
expect(checkbox.closest("li")?.className).toContain("task-list-item");
|
|
expect(screen.getByRole("cell").getAttribute("style")).toContain("text-align: center");
|
|
});
|
|
it("renders aliases nested in formatted Markdown nodes", () => {
|
|
const Wrapper = wrapper([], [customPrompt("hello")]);
|
|
render(
|
|
<ToastProvider>
|
|
<Wrapper>
|
|
<ChatMessage
|
|
comment={userMessage({
|
|
content: "**@hello** and _@hello_ and [**@hello**](https://example.com) and `@hello`",
|
|
})}
|
|
label="Message"
|
|
className=""
|
|
/>
|
|
</Wrapper>
|
|
</ToastProvider>,
|
|
);
|
|
|
|
expect(screen.getAllByTestId(PROMPT_MENTION_TESTID)).toHaveLength(3);
|
|
const linkedMention = screen
|
|
.getByRole("link", { name: "@hello" })
|
|
.querySelector<HTMLElement>(`[data-testid="${PROMPT_MENTION_TESTID}"]`);
|
|
expect(linkedMention).not.toBeNull();
|
|
expect(linkedMention?.getAttribute("role")).toBeNull();
|
|
expect(linkedMention?.getAttribute("tabindex")).toBeNull();
|
|
});
|
|
it("does not chip an alias that follows formatted text without a boundary", () => {
|
|
const Wrapper = wrapper([], [customPrompt("hello")]);
|
|
render(
|
|
<Wrapper>
|
|
<ChatMessage
|
|
comment={userMessage({ content: "**bold**@hello" })}
|
|
label="Message"
|
|
className=""
|
|
/>
|
|
</Wrapper>,
|
|
);
|
|
|
|
expect(screen.queryByTestId(PROMPT_MENTION_TESTID)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("ChatMessage entity references", () => {
|
|
it("renders an exact generated Markdown link as an accessible clickable chip", () => {
|
|
const reference = issueReference();
|
|
const Wrapper = wrapper();
|
|
|
|
render(
|
|
<Wrapper>
|
|
<ChatMessage
|
|
comment={userMessage({
|
|
content: entityReferenceMarkdown(reference),
|
|
metadata: { entity_references: [reference] },
|
|
})}
|
|
label="Message"
|
|
className=""
|
|
onOpenFile={vi.fn()}
|
|
/>
|
|
</Wrapper>,
|
|
);
|
|
|
|
const chip = screen.getByTestId(ENTITY_REFERENCE_TESTID);
|
|
expect(chip.getAttribute("aria-label")).toBe("Open #ENG[1]: Fix the login flow");
|
|
expect(chip.getAttribute("href")).toBe(
|
|
"https://jira.example/browse/ENG-1?q=hello%20world%28test%29",
|
|
);
|
|
expect(chip.getAttribute("target")).toBe("_blank");
|
|
});
|
|
|
|
it.each([
|
|
["missing metadata", undefined, "[#ENG\\[1\\]](https://jira.example/browse/ENG-1)"],
|
|
[
|
|
"malformed metadata",
|
|
{ entity_references: [{ ...issueReference(), version: 2 }] },
|
|
entityReferenceMarkdown(issueReference()),
|
|
],
|
|
[
|
|
"wrong label",
|
|
{ entity_references: [issueReference()] },
|
|
"[#Wrong](https://jira.example/browse/ENG-1?q=hello%20world%28test%29)",
|
|
],
|
|
[
|
|
"wrong URL",
|
|
{ entity_references: [issueReference()] },
|
|
"[#ENG\\[1\\]](https://evil.example/ENG-1)",
|
|
],
|
|
[
|
|
"formatted lookalike label",
|
|
{ entity_references: [issueReference()] },
|
|
"[**#ENG\\[1\\]**](https://jira.example/browse/ENG-1?q=hello%20world%28test%29)",
|
|
],
|
|
])("keeps %s as ordinary Markdown", (_name, metadata, content) => {
|
|
const Wrapper = wrapper();
|
|
|
|
render(
|
|
<Wrapper>
|
|
<ChatMessage
|
|
comment={userMessage({ content, metadata })}
|
|
label="Message"
|
|
className=""
|
|
onOpenFile={vi.fn()}
|
|
/>
|
|
</Wrapper>,
|
|
);
|
|
|
|
expect(screen.queryByTestId(ENTITY_REFERENCE_TESTID)).toBeNull();
|
|
expect(screen.getByRole("link")).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
/** Renders a user ChatMessage wrapped with the given sender tasks and message metadata. */
|
|
function renderWithSender(
|
|
tasks: Array<{ id: string; title: string }>,
|
|
metadata: Partial<Message["metadata"] & object>,
|
|
) {
|
|
const Wrapper = wrapper(tasks);
|
|
return render(
|
|
<Wrapper>
|
|
<ChatMessage comment={userMessage({ metadata })} label="Message" className="" />
|
|
</Wrapper>,
|
|
);
|
|
}
|
|
|
|
/** Renders an agent ChatMessage seeded with the given session and optional turn metadata. */
|
|
function renderAgentMessageWithSession(
|
|
session: Partial<TaskSession>,
|
|
metadata = {},
|
|
turnMetadata?: Record<string, unknown>,
|
|
) {
|
|
const taskSession: TaskSession = {
|
|
id: toSessionId("sess-1"),
|
|
task_id: toTaskId("task-target"),
|
|
state: "COMPLETED",
|
|
started_at: MESSAGE_TIMESTAMP,
|
|
updated_at: MESSAGE_TIMESTAMP,
|
|
...session,
|
|
};
|
|
const turn: Turn | null =
|
|
turnMetadata === undefined
|
|
? null
|
|
: {
|
|
id: "turn-1",
|
|
session_id: toSessionId("sess-1"),
|
|
task_id: toTaskId("task-target"),
|
|
started_at: MESSAGE_TIMESTAMP,
|
|
metadata: turnMetadata,
|
|
created_at: MESSAGE_TIMESTAMP,
|
|
updated_at: MESSAGE_TIMESTAMP,
|
|
};
|
|
/** Renders children inside a StateProvider seeded with the test session and its turns. */
|
|
const Wrapper = ({ children }: { children: ReactNode }) => (
|
|
<StateProvider
|
|
initialState={{
|
|
taskSessions: { items: { "sess-1": taskSession } },
|
|
turns: {
|
|
bySession: { "sess-1": turn ? [turn] : [] },
|
|
activeBySession: { "sess-1": turn?.id ?? null },
|
|
loadedBySession: {},
|
|
reconcileEpochBySession: {},
|
|
settledBoundaryBySession: {},
|
|
},
|
|
}}
|
|
>
|
|
{children}
|
|
</StateProvider>
|
|
);
|
|
|
|
return render(
|
|
<Wrapper>
|
|
<ChatMessage
|
|
comment={userMessage({ author_type: "agent", metadata, turn_id: turn?.id })}
|
|
label="Message"
|
|
className=""
|
|
/>
|
|
</Wrapper>,
|
|
);
|
|
}
|
|
|
|
describe("ChatMessage context file badges", () => {
|
|
it("renders a folder icon for directory context metadata", () => {
|
|
const Wrapper = wrapper();
|
|
|
|
render(
|
|
<Wrapper>
|
|
<ChatMessage
|
|
comment={userMessage({
|
|
metadata: {
|
|
context_files: [
|
|
{ path: "src", name: "src", is_directory: true },
|
|
{ path: "src/app.ts", name: "app.ts" },
|
|
],
|
|
},
|
|
})}
|
|
label="Message"
|
|
className=""
|
|
/>
|
|
</Wrapper>,
|
|
);
|
|
|
|
expect(screen.getByTestId("message-context-directory-icon")).not.toBeNull();
|
|
expect(screen.getByTestId("message-context-file-icon")).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("ChatMessage sender badge", () => {
|
|
it("renders the sender badge when sender_task_id is present in metadata", () => {
|
|
const { container } = renderWithSender([{ id: SENDER_TASK_ID, title: SENDER_TITLE }], {
|
|
sender_task_id: SENDER_TASK_ID,
|
|
sender_task_title: SENDER_TITLE,
|
|
sender_session_id: "sender-sess",
|
|
});
|
|
|
|
const badge = container.querySelector(SENDER_BADGE_SELECTOR);
|
|
expect(badge).not.toBeNull();
|
|
expect(badge?.getAttribute("data-sender-task-id")).toBe(SENDER_TASK_ID);
|
|
expect(badge?.textContent).toContain(SENDER_TITLE);
|
|
});
|
|
|
|
it("links the badge to the source task when the sender is loaded", () => {
|
|
const { container } = renderWithSender([{ id: SENDER_TASK_ID, title: SENDER_TITLE }], {
|
|
sender_task_id: SENDER_TASK_ID,
|
|
sender_task_title: SENDER_TITLE,
|
|
});
|
|
|
|
const link = container.querySelector(`a[href='/t/${SENDER_TASK_ID}']`);
|
|
expect(link).not.toBeNull();
|
|
});
|
|
|
|
it("renders a non-clickable greyed badge when sender task is unknown", () => {
|
|
// No tasks seeded — sender task is "deleted" or cross-workspace.
|
|
const { container } = renderWithSender([], {
|
|
sender_task_id: "task-deleted",
|
|
sender_task_title: "Old title",
|
|
});
|
|
|
|
const badge = container.querySelector(SENDER_BADGE_SELECTOR);
|
|
expect(badge).not.toBeNull();
|
|
expect(container.querySelector("a[href='/t/task-deleted']")).toBeNull();
|
|
// Falls back to the snapshotted title rather than blanking the badge.
|
|
expect(badge?.textContent).toContain("Old title");
|
|
});
|
|
|
|
it("localizes the fallback when no sender task title is available", async () => {
|
|
await activateLocale("en");
|
|
const { container } = renderWithSender([], {
|
|
sender_task_id: "task-deleted",
|
|
sender_task_title: "",
|
|
});
|
|
|
|
const badge = container.querySelector(SENDER_BADGE_SELECTOR);
|
|
expect(badge?.textContent).toContain("(unknown task)");
|
|
|
|
await act(async () => {
|
|
await activateLocale("pseudo");
|
|
});
|
|
|
|
expect(badge?.textContent).toContain("(ũńķńōŵń ţàśķ)");
|
|
await act(async () => {
|
|
await activateLocale("en");
|
|
});
|
|
});
|
|
|
|
it("uses the live title when it differs from the snapshot", () => {
|
|
// The badge re-resolves the title from the kanban store so renames are
|
|
// reflected without re-sending the message.
|
|
const { container } = renderWithSender([{ id: SENDER_TASK_ID, title: "Renamed task" }], {
|
|
sender_task_id: SENDER_TASK_ID,
|
|
sender_task_title: "Old name",
|
|
});
|
|
|
|
const badge = container.querySelector(SENDER_BADGE_SELECTOR);
|
|
expect(badge?.textContent).toContain("Renamed task");
|
|
expect(badge?.textContent).not.toContain("Old name");
|
|
});
|
|
|
|
it("truncates very long titles for display", () => {
|
|
const longTitle = "This is a really long task title that should be truncated";
|
|
const { container } = renderWithSender([{ id: SENDER_TASK_ID, title: longTitle }], {
|
|
sender_task_id: SENDER_TASK_ID,
|
|
sender_task_title: longTitle,
|
|
});
|
|
|
|
const badge = container.querySelector(SENDER_BADGE_SELECTOR);
|
|
expect(badge).not.toBeNull();
|
|
// The badge text must contain the ellipsis (truncated) and not the full title.
|
|
expect(badge?.textContent).toContain("…");
|
|
expect(badge?.textContent ?? "").not.toContain(longTitle);
|
|
});
|
|
|
|
it("does not render a sender badge when metadata has no sender_task_id", () => {
|
|
const { container } = renderWithSender([], { plan_mode: true });
|
|
|
|
expect(container.querySelector(SENDER_BADGE_SELECTOR)).toBeNull();
|
|
});
|
|
|
|
it("renders the workflow step badge when workflow metadata is present", () => {
|
|
const { container } = renderWithSender([], {
|
|
workflow_message: true,
|
|
workflow_step_name: "Review",
|
|
workflow_step_color: "bg-emerald-500",
|
|
});
|
|
|
|
const badge = container.querySelector("[data-testid='workflow-message-badge']");
|
|
expect(badge).not.toBeNull();
|
|
expect(badge?.textContent).toContain("Review");
|
|
expect(container.querySelector("[data-testid='workflow-message-dot']")?.className).toContain(
|
|
"bg-emerald-500",
|
|
);
|
|
});
|
|
|
|
it("falls back when workflow metadata has an unknown color class", () => {
|
|
const { container } = renderWithSender([], {
|
|
workflow_message: true,
|
|
workflow_step_name: "Review",
|
|
workflow_step_color: "unknown-step-color",
|
|
});
|
|
|
|
expect(container.querySelector("[data-testid='workflow-message-dot']")?.className).toContain(
|
|
"bg-neutral-400",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("ChatMessage raw view", () => {
|
|
it("shows user raw_content with hidden kandev-system blocks when raw view is enabled", () => {
|
|
const raw = `<kandev-system>This message was sent by an agent working in task "Sender" (${SENDER_TASK_ID}).</kandev-system>
|
|
|
|
@improve-task
|
|
|
|
<kandev-system>EXPANDED PROMPT REFERENCES: The message above references saved prompts by @name.
|
|
|
|
### @improve-task
|
|
Review this task for durable improvements.</kandev-system>`;
|
|
const Wrapper = wrapper([{ id: SENDER_TASK_ID, title: SENDER_TITLE }]);
|
|
|
|
render(
|
|
<Wrapper>
|
|
<ChatMessage
|
|
comment={userMessage({
|
|
content: "@improve-task",
|
|
raw_content: raw,
|
|
metadata: {
|
|
sender_task_id: SENDER_TASK_ID,
|
|
sender_task_title: SENDER_TITLE,
|
|
has_hidden_prompts: true,
|
|
},
|
|
})}
|
|
label="Message"
|
|
className=""
|
|
/>
|
|
</Wrapper>,
|
|
);
|
|
|
|
expect(screen.queryByText(/EXPANDED PROMPT REFERENCES/)).toBeNull();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Show raw text" }));
|
|
|
|
expect(screen.getByText(/This message was sent by an agent/)).not.toBeNull();
|
|
expect(screen.getByText(/EXPANDED PROMPT REFERENCES/)).not.toBeNull();
|
|
expect(screen.getByText(/### @improve-task/)).not.toBeNull();
|
|
expect(screen.getByText(/Review this task for durable improvements/)).not.toBeNull();
|
|
});
|
|
|
|
it("shows agent raw_content with hidden kandev-system blocks when raw view is enabled", () => {
|
|
const raw = `<kandev-system>Hidden agent context.</kandev-system>
|
|
|
|
Visible agent response.`;
|
|
const Wrapper = wrapper([]);
|
|
|
|
render(
|
|
<Wrapper>
|
|
<ChatMessage
|
|
comment={userMessage({
|
|
author_type: "agent",
|
|
content: "Visible agent response.",
|
|
raw_content: raw,
|
|
})}
|
|
label="Message"
|
|
className=""
|
|
/>
|
|
</Wrapper>,
|
|
);
|
|
|
|
expect(screen.queryByText(/Hidden agent context/)).toBeNull();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Show raw text" }));
|
|
|
|
expect(screen.getByText(/Hidden agent context/)).not.toBeNull();
|
|
expect(screen.getByText(/Visible agent response/)).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("ChatMessage agent session config metadata", () => {
|
|
it("opens markdown file links with the provided chat file opener", () => {
|
|
const onOpenFile = vi.fn();
|
|
const Wrapper = wrapper([]);
|
|
|
|
render(
|
|
<Wrapper>
|
|
<ChatMessage
|
|
comment={userMessage({
|
|
author_type: "agent",
|
|
content: "[spec.md](/root/.kandev/tasks/example/kandev/docs/specs/native/spec.md)",
|
|
})}
|
|
label="Message"
|
|
className=""
|
|
worktreePath="/root/.kandev/tasks/example/kandev"
|
|
onOpenFile={onOpenFile}
|
|
/>
|
|
</Wrapper>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("link", { name: "spec.md" }));
|
|
|
|
expect(onOpenFile).toHaveBeenCalledWith("docs/specs/native/spec.md");
|
|
});
|
|
});
|
|
|
|
describe("ChatMessage agent session config metadata overrides", () => {
|
|
it("renders changed options from the immutable turn snapshot", () => {
|
|
const { container } = renderAgentMessageWithSession(
|
|
{
|
|
metadata: {
|
|
runtime_config: {
|
|
model: TURN_MODEL,
|
|
mode: "agent",
|
|
config_options: {
|
|
collaboration_mode: "default",
|
|
reasoning_effort: "low",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{ model: TURN_MODEL },
|
|
{
|
|
runtime_config_snapshot: {
|
|
model: TURN_MODEL,
|
|
mode: "agent",
|
|
config_options: [
|
|
{
|
|
id: "collaboration_mode",
|
|
name: "Collaboration mode",
|
|
value: "default",
|
|
value_name: "Default",
|
|
},
|
|
{
|
|
id: "reasoning_effort",
|
|
name: "Reasoning effort",
|
|
value: "high",
|
|
value_name: "High",
|
|
},
|
|
],
|
|
config_baseline: {
|
|
collaboration_mode: "default",
|
|
reasoning_effort: "medium",
|
|
},
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(screen.getByText(`${TURN_MODEL} · Reasoning effort: High`)).not.toBeNull();
|
|
expect(container.textContent).not.toContain("Reasoning effort: low");
|
|
expect(container.textContent).not.toContain("Collaboration mode");
|
|
expect(container.textContent).not.toContain("Mode: agent");
|
|
});
|
|
|
|
it("does not borrow mutable session options for a legacy turn", () => {
|
|
const { container } = renderAgentMessageWithSession(
|
|
{
|
|
metadata: {
|
|
runtime_config: {
|
|
model: TURN_MODEL,
|
|
config_options: { reasoning_effort: "high" },
|
|
},
|
|
},
|
|
},
|
|
{ model: "gpt-5.5-mini" },
|
|
{},
|
|
);
|
|
|
|
expect(screen.getByText("gpt-5.5-mini")).not.toBeNull();
|
|
expect(container.textContent).not.toContain("Reasoning effort");
|
|
});
|
|
});
|
|
|
|
describe("ChatMessage image attachments", () => {
|
|
it("opens image attachments in an in-app preview dialog", () => {
|
|
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
|
renderWithSender([], {
|
|
attachments: [{ type: "image", data: PNG_BASE64, mime_type: "image/png" }],
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: OPEN_ATTACHMENT_1_LABEL }));
|
|
|
|
expect(openSpy).not.toHaveBeenCalled();
|
|
const dialog = screen.getByRole("dialog");
|
|
const preview = screen.getByAltText(FULL_SIZE_ATTACHMENT_1_ALT);
|
|
expect(dialog.className).toContain("w-fit");
|
|
expect(dialog.className).toContain("max-w-[calc(100vw-1rem)]");
|
|
expect(preview.className).toContain("w-[min(92vw,1100px)]");
|
|
expect(preview.className).toContain("max-h-[calc(100dvh-5rem)]");
|
|
expect(preview.getAttribute("src")).toBe(`data:image/png;base64,${PNG_BASE64}`);
|
|
});
|
|
|
|
it("renders staged image descriptors through the authenticated content URL", () => {
|
|
renderWithSender([], {
|
|
attachments: [
|
|
{
|
|
type: "image",
|
|
attachment_id: "attachment-staged-1",
|
|
mime_type: "image/png",
|
|
name: "diagram.png",
|
|
size_bytes: 1024,
|
|
},
|
|
],
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: OPEN_ATTACHMENT_1_LABEL }));
|
|
|
|
const preview = screen.getByAltText(FULL_SIZE_ATTACHMENT_1_ALT);
|
|
expect(preview.getAttribute("src")).toContain(
|
|
"/api/v1/attachments/attachment-staged-1/content",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("ChatMessage favorite toggle", () => {
|
|
it("toggles the star and applies a light-yellow background on the user message bubble", () => {
|
|
render(
|
|
<StateProvider>
|
|
<ChatMessage comment={userMessage({ content: "hello" })} label="Message" className="" />
|
|
</StateProvider>,
|
|
);
|
|
|
|
const star = screen.getByRole("button", { name: /mark message as favorite/i });
|
|
const bubble = screen.getByTestId("user-message-bubble");
|
|
expect(bubble.className).not.toMatch(/yellow/);
|
|
|
|
fireEvent.click(star);
|
|
|
|
expect(screen.getByRole("button", { name: /remove message from favorites/i })).toBe(star);
|
|
expect(bubble.className).toMatch(/yellow/);
|
|
|
|
fireEvent.click(star);
|
|
|
|
expect(screen.getByRole("button", { name: /mark message as favorite/i })).toBe(star);
|
|
expect(bubble.className).not.toMatch(/yellow/);
|
|
});
|
|
|
|
it("toggles the star and applies a light-yellow background on an agent message", () => {
|
|
render(
|
|
<StateProvider>
|
|
<ChatMessage
|
|
comment={userMessage({ author_type: "agent", content: "hello from agent" })}
|
|
label="Message"
|
|
className=""
|
|
/>
|
|
</StateProvider>,
|
|
);
|
|
|
|
const star = screen.getByRole("button", { name: /mark message as favorite/i });
|
|
const highlight = screen.getByTestId("agent-message-highlight");
|
|
expect(highlight.className).not.toMatch(/yellow/);
|
|
|
|
fireEvent.click(star);
|
|
|
|
expect(highlight.className).toMatch(/yellow/);
|
|
});
|
|
});
|