deer-flow/frontend/tests/unit/components/workspace/conversation-reference-picker.dom.test.tsx
Totoro 94e69d6ff7
feat(frontend): reference conversations from the composer (#5465)
* feat(frontend): reference conversations from the composer

Adds a "Reference a conversation" button next to the attachment button,
shown only while GET /api/features reports read_conversation enabled. It
opens a picker over the recent-conversation list (current thread excluded,
capped at max_references) and shows removable chips in the composer.

On send the thread IDs ride SendMessageOptions.conversationReferences into
run context.conversation_references, which the Gateway consumes at
admission; the LangGraph SDK drops unknown top-level body fields. A
display-only copy ({thread_id, title}) on the visible human message lets
the transcript render read-only chips linking to the source.

References are per message: not persisted with the draft and cleared on
send or thread switch; regenerating or editing a turn runs without them
unless they are attached again.

Related to #5398. Depends on #5463.

* fix(frontend): pin the run-context contract and finish the picker states

Both thread.submit paths now build their run context through one exported
buildRunContext helper, tested directly: attached references travel as a
plain string[] under context.conversation_references only when the caller
passed them, a stray key in local settings is dropped instead of forwarded,
and the regenerate/edit replay path never carries references.

The picker shows a loading row while the conversation list is still in
flight instead of claiming there are no conversations, and the transcript
chip group is labelled with the previously unused referencedConversations
translation.

* fix(frontend): route conversation-reference chips to custom-agent sources

The picker offered custom-agent conversations but kept only the thread ID
and title, so transcript chips always linked to /workspace/chats/{id} and
dropped the source's custom-agent context on navigation.

Preserve the agent identity end to end: the picker now attaches
agentNameOfThread() (context first, then metadata.agent_name, mirroring
pathOfThread) to the selection, the display-only additional_kwargs metadata
round-trips it as agent_name, and the transcript chip passes it to
pathOfThread so custom-agent sources resolve to
/workspace/agents/{agent}/chats/{id}.

Tests: agent_name metadata round-trip and malformed-entry tolerance, picker
toggle carrying the metadata agent with run context winning, and a
picker-to-transcript regression pinning the /workspace/agents/writer/chats/
source-1 href.

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
2026-09-16 21:19:52 +08:00

192 lines
5.5 KiB
TypeScript

import { afterEach, beforeAll, describe, expect, it, rs } from "@rstest/core";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { ConversationReferenceList } from "@/components/workspace/conversation-references/conversation-reference-picker";
import type { AgentThread } from "@/core/threads/types";
const threads = [
{
thread_id: "t-current",
updated_at: "2026-09-15T09:00:00Z",
values: { title: "This conversation" },
metadata: {},
},
{
thread_id: "t-a",
updated_at: "2026-09-15T08:00:00Z",
values: { title: "Alpha requirements" },
metadata: {},
},
{
thread_id: "t-b",
updated_at: "2026-09-15T07:00:00Z",
values: { title: "Beta design" },
metadata: {},
},
{
thread_id: "t-c",
updated_at: "2026-09-15T06:00:00Z",
values: {},
metadata: {},
},
{
thread_id: "t-writer",
updated_at: "2026-09-15T05:00:00Z",
values: { title: "Writer drafts" },
metadata: { agent_name: "writer" },
},
{
thread_id: "t-scribe",
updated_at: "2026-09-15T04:00:00Z",
values: { title: "Scribe notes" },
metadata: { agent_name: "stale-agent" },
context: { agent_name: "scribe" },
},
] as unknown as AgentThread[];
let threadsQuery: {
data?: AgentThread[];
isPending: boolean;
isError: boolean;
} = {
data: threads,
isPending: false,
isError: false,
};
rs.mock("@/core/threads/hooks", () => ({
useThreads: () => threadsQuery,
}));
rs.mock("@/core/i18n/hooks", () => ({
useI18n: () => ({
locale: "en-US",
t: {
inputBox: {
referenceConversations: "Reference a conversation",
referenceConversationsSearch: "Search conversations",
referenceConversationsEmpty: "No conversations found",
referenceConversationsLimit: (max: number) =>
`Up to ${max} conversations per message`,
referenceConversationsRemove: (title: string) => `Remove ${title}`,
referencedConversations: "Referenced conversations",
},
common: { loading: "Loading...", untitled: "Untitled" },
},
}),
}));
beforeAll(() => {
// cmdk measures its list and scrolls the active item; happy-dom has neither.
class ResizeObserverStub {
observe = rs.fn();
unobserve = rs.fn();
disconnect = rs.fn();
}
globalThis.ResizeObserver ??=
ResizeObserverStub as unknown as typeof ResizeObserver;
if (!("scrollIntoView" in Element.prototype)) {
Object.defineProperty(Element.prototype, "scrollIntoView", {
configurable: true,
value: rs.fn(),
writable: true,
});
}
});
afterEach(() => {
cleanup();
threadsQuery = { data: threads, isPending: false, isError: false };
});
describe("ConversationReferenceList", () => {
it("shows a loading row, not the empty state, while the list is still loading", () => {
threadsQuery = { data: undefined, isPending: true, isError: false };
render(
<ConversationReferenceList
currentThreadId="t-current"
maxReferences={3}
onToggle={rs.fn()}
selected={[]}
/>,
);
expect(screen.getByTestId("conversation-reference-loading")).toBeTruthy();
expect(screen.queryByText("No conversations found")).toBeNull();
});
it("lists other conversations by title and never the current one", () => {
render(
<ConversationReferenceList
currentThreadId="t-current"
maxReferences={3}
onToggle={rs.fn()}
selected={[]}
/>,
);
expect(screen.getByText("Alpha requirements")).toBeTruthy();
expect(screen.getByText("Beta design")).toBeTruthy();
expect(screen.getByText("Untitled")).toBeTruthy();
expect(screen.queryByText("This conversation")).toBeNull();
});
it("toggles a reference with its title", () => {
const onToggle = rs.fn();
render(
<ConversationReferenceList
currentThreadId="t-current"
maxReferences={3}
onToggle={onToggle}
selected={[]}
/>,
);
fireEvent.click(screen.getByText("Alpha requirements"));
expect(onToggle).toHaveBeenCalledWith({
threadId: "t-a",
title: "Alpha requirements",
});
});
it("carries the source's custom agent from metadata, with context winning", () => {
const onToggle = rs.fn();
render(
<ConversationReferenceList
currentThreadId="t-current"
maxReferences={3}
onToggle={onToggle}
selected={[]}
/>,
);
fireEvent.click(screen.getByText("Writer drafts"));
expect(onToggle).toHaveBeenCalledWith({
threadId: "t-writer",
title: "Writer drafts",
agentName: "writer",
});
fireEvent.click(screen.getByText("Scribe notes"));
expect(onToggle).toHaveBeenCalledWith({
threadId: "t-scribe",
title: "Scribe notes",
agentName: "scribe",
});
});
it("disables unselected rows at the cap but keeps selected rows removable", () => {
render(
<ConversationReferenceList
currentThreadId="t-current"
maxReferences={2}
onToggle={rs.fn()}
selected={[
{ threadId: "t-a", title: "Alpha requirements" },
{ threadId: "t-b", title: "Beta design" },
]}
/>,
);
const untitled = screen.getByText("Untitled").closest("[cmdk-item]");
const alpha = screen.getByText("Alpha requirements").closest("[cmdk-item]");
expect(untitled?.getAttribute("aria-disabled")).toBe("true");
expect(alpha?.getAttribute("aria-disabled")).not.toBe("true");
expect(screen.getByText("Up to 2 conversations per message")).toBeTruthy();
});
});