mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 02:56:17 +00:00
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>
This commit is contained in:
parent
f0cb67b223
commit
94e69d6ff7
@ -1545,7 +1545,10 @@ continuation, so the agent can read the rest; it asks for the missing part only
|
||||
if that read is unavailable.
|
||||
SDK clients that cannot add top-level request fields may send the same list as
|
||||
`context.conversation_references`, and `GET /api/features` reports whether the
|
||||
tool is enabled. There is no frontend selector or automatic history search. See
|
||||
tool is enabled. When it is, the web composer shows a "Reference a conversation"
|
||||
button next to the attachment button: pick up to three of your recent
|
||||
conversations, and they are attached to the next message only, shown as chips
|
||||
in the composer and in the transcript. There is no automatic history search. See
|
||||
[configuration](backend/docs/CONFIGURATION.md#reading-referenced-conversations)
|
||||
and the [request contract](backend/docs/API.md#referencing-a-previous-conversation).
|
||||
|
||||
|
||||
@ -102,6 +102,8 @@ The workspace-change card follows the same rule: it is resolved from `(threadId,
|
||||
|
||||
Composer drafts are tab-scoped browser state. `core/threads/composer-draft.ts` stores only text plus the selected slash-skill name in `sessionStorage`, keyed by user, agent, and logical conversation scope. New-chat pages pass the stable scope `"new"` because their runtime `threadId` is a fresh UUID on every reload; established conversations use their real thread ID. `InputBox` waits for enabled skills before restoring a skill chip, degrades a missing/disabled skill back to editable slash text, and clears the stored draft through `SendMessageOptions.onSent` only after the send passes the in-flight guard. Attachments, sidecar quotes, voice state, and polish undo state are not persisted.
|
||||
|
||||
Conversation references (`read_conversation`, opt-in on the backend) are attached from the composer. `ReferenceConversationsButton` (`components/workspace/conversation-references/`) renders only while `/api/features` reports `conversation_references.enabled`, opens a picker over the same `useThreads()` list the sidebar uses (the current thread excluded, capped at `max_references`), and shows removable chips in the composer header. On submit the thread IDs ride `InputBoxSubmitOptions.conversationReferences` → `SendMessageOptions.conversationReferences` → run `context.conversation_references`, which the Gateway consumes at admission; the LangGraph SDK drops unknown top-level body fields, so the top-level request field is not reachable from the web UI. `core/conversation-references` also writes display-only `additional_kwargs.conversation_references` (`{thread_id, title, agent_name?}`) on the visible human message so `message-list-item.tsx` can render read-only chips linking to the source — through `pathOfThread`, so custom-agent sources route to `/workspace/agents/{agent}/chats/{id}`; that metadata grants nothing. References are per message: they are not persisted with the draft, clear on send or thread switch, and regenerate/edit of a turn runs without them unless attached again.
|
||||
|
||||
Auth UI note: the login page's "keep me signed in" option submits only `remember_me` to the Gateway and may persist only the email address through `core/auth/remember-login.ts`. Passwords and tokens must never be stored in frontend storage; the `HttpOnly access_token` and readable `csrf_token` cookies remain Gateway-owned.
|
||||
|
||||
`/goal` and `/compact` are built-in composer commands, not skill activations. `src/components/workspace/input-box.tsx` intercepts `/goal`, `/goal clear`, and `/goal <condition>` before normal chat submission, calling Gateway `GET/PUT/DELETE /api/threads/{thread_id}/goal`. Setting `/goal <condition>` also submits the condition text as the next user task so the agent starts running immediately; status and clear do not start a run. On a project-scoped new chat (`/workspace/chats/new?project=…`), the chat page's project pre-create runs before the goal PUT via the composer's `onPrepareThread` callback: the goal endpoint materializes a missing thread row itself, and an unassigned row would make the later idempotent thread create return it without assigning the project. Goal and compact requests are tied to the current `threadId` with an `AbortController`, so switching threads or unmounting the composer aborts in-flight requests and stale responses cannot update the new thread's composer state. The chat pages render `GoalStatus` above the composer from `AgentThreadState.goal`, with local optimistic state until an incremental goal update or final state reload arrives. `/compact` calls `POST /api/threads/{thread_id}/compact` to summarize older active context while leaving the full visible chat history intact; it is skipped on new/empty threads and blocked server-side while a run is in flight. Thread rename uses the same serialized state-write route; the rename dialog stays open and surfaces the server error when an active run returns 409.
|
||||
|
||||
@ -0,0 +1,77 @@
|
||||
import { MessagesSquareIcon, XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Shared visual for an attached conversation: a removable chip in the
|
||||
* composer, and a read-only chip (linking to the source) in the transcript.
|
||||
*/
|
||||
const CHIP_BASE_CLASS =
|
||||
"border-border bg-muted text-foreground inline-flex h-6 max-w-60 shrink-0 items-center gap-1 rounded-md border px-1.5 text-xs leading-none font-medium shadow-xs";
|
||||
|
||||
export function ConversationReferenceChip({
|
||||
title,
|
||||
href,
|
||||
className,
|
||||
onRemove,
|
||||
removeLabel,
|
||||
}: {
|
||||
title: string;
|
||||
/** When provided (and the chip is not removable), the chip links to the source conversation. */
|
||||
href?: string;
|
||||
className?: string;
|
||||
/** When provided, the chip renders as a removable button with a close icon. */
|
||||
onRemove?: () => void;
|
||||
removeLabel?: string;
|
||||
}) {
|
||||
const body = (
|
||||
<>
|
||||
<MessagesSquareIcon className="text-muted-foreground size-3 shrink-0" />
|
||||
<span className="min-w-0 truncate">{title}</span>
|
||||
</>
|
||||
);
|
||||
if (onRemove) {
|
||||
return (
|
||||
<button
|
||||
aria-label={removeLabel ?? `Remove ${title}`}
|
||||
className={cn(
|
||||
CHIP_BASE_CLASS,
|
||||
"hover:bg-accent cursor-pointer transition-colors",
|
||||
className,
|
||||
)}
|
||||
data-testid="conversation-reference-chip"
|
||||
onClick={onRemove}
|
||||
type="button"
|
||||
>
|
||||
{body}
|
||||
<XIcon className="text-muted-foreground size-2.5 shrink-0" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
className={cn(
|
||||
CHIP_BASE_CLASS,
|
||||
"hover:bg-accent transition-colors",
|
||||
className,
|
||||
)}
|
||||
data-testid="conversation-reference-chip"
|
||||
href={href}
|
||||
title={title}
|
||||
>
|
||||
{body}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={cn(CHIP_BASE_CLASS, className)}
|
||||
data-testid="conversation-reference-chip"
|
||||
title={title}
|
||||
>
|
||||
{body}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon } from "lucide-react";
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { ConversationReference } from "@/core/conversation-references";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useThreads } from "@/core/threads/hooks";
|
||||
import { agentNameOfThread, titleOfThread } from "@/core/threads/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ConversationReferenceListProps = {
|
||||
/** The conversation being composed in; it is never offered as a reference. */
|
||||
currentThreadId: string;
|
||||
selected: ConversationReference[];
|
||||
maxReferences: number;
|
||||
onToggle: (reference: ConversationReference) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recent conversations with a title filter. Selecting a row toggles it; once
|
||||
* the per-message cap is reached, unselected rows are disabled while selected
|
||||
* rows stay clickable so they can be removed.
|
||||
*/
|
||||
export function ConversationReferenceList({
|
||||
currentThreadId,
|
||||
selected,
|
||||
maxReferences,
|
||||
onToggle,
|
||||
}: ConversationReferenceListProps) {
|
||||
const { t } = useI18n();
|
||||
const { data: threads, isPending } = useThreads();
|
||||
const selectedIds = new Set(selected.map((reference) => reference.threadId));
|
||||
const atCap = selected.length >= maxReferences;
|
||||
const candidates = (threads ?? []).filter(
|
||||
(thread) => thread.thread_id !== currentThreadId,
|
||||
);
|
||||
|
||||
return (
|
||||
<Command className="[&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-2">
|
||||
<CommandInput placeholder={t.inputBox.referenceConversationsSearch} />
|
||||
<CommandList>
|
||||
{isPending ? (
|
||||
// Never claim there are no conversations before the list has loaded.
|
||||
<div
|
||||
className="text-muted-foreground py-6 text-center text-sm"
|
||||
data-testid="conversation-reference-loading"
|
||||
>
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : (
|
||||
<CommandEmpty>{t.inputBox.referenceConversationsEmpty}</CommandEmpty>
|
||||
)}
|
||||
<CommandGroup>
|
||||
{candidates.map((thread) => {
|
||||
const title = titleOfThread(thread);
|
||||
const isSelected = selectedIds.has(thread.thread_id);
|
||||
return (
|
||||
<CommandItem
|
||||
key={thread.thread_id}
|
||||
className={cn("gap-2", isSelected && "text-accent-foreground")}
|
||||
data-testid="conversation-reference-option"
|
||||
disabled={atCap && !isSelected}
|
||||
onSelect={() =>
|
||||
onToggle({
|
||||
threadId: thread.thread_id,
|
||||
title,
|
||||
// Preserve the source's agent identity so transcript chips
|
||||
// link back to custom-agent conversations, not the default.
|
||||
agentName: agentNameOfThread(thread),
|
||||
})
|
||||
}
|
||||
value={`${title} ${thread.thread_id}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{title}</span>
|
||||
{isSelected ? (
|
||||
<CheckIcon className="size-4 shrink-0" />
|
||||
) : (
|
||||
<span className="size-4 shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
<p className="text-muted-foreground border-t px-3 py-2 text-xs">
|
||||
{t.inputBox.referenceConversationsLimit(maxReferences)}
|
||||
</p>
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConversationReferencePicker({
|
||||
open,
|
||||
onOpenChange,
|
||||
...listProps
|
||||
}: ConversationReferenceListProps & {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<DialogTitle className="sr-only">
|
||||
{t.inputBox.referenceConversations}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{t.inputBox.referenceConversationsLimit(listProps.maxReferences)}
|
||||
</DialogDescription>
|
||||
<ConversationReferenceList {...listProps} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { MessagesSquareIcon } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { PromptInputButton } from "@/components/ai-elements/prompt-input";
|
||||
import type { ConversationReference } from "@/core/conversation-references";
|
||||
import { useConversationReferencesCapability } from "@/core/features/hooks";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
import { ConversationReferencePicker } from "./conversation-reference-picker";
|
||||
|
||||
/**
|
||||
* Composer entry point for attaching conversations to the next message.
|
||||
* Renders nothing unless `/api/features` reports `read_conversation` enabled,
|
||||
* so deployments without the tool see no change.
|
||||
*/
|
||||
export function ReferenceConversationsButton({
|
||||
className,
|
||||
currentThreadId,
|
||||
disabled,
|
||||
references,
|
||||
onChange,
|
||||
}: {
|
||||
className?: string;
|
||||
currentThreadId: string;
|
||||
disabled?: boolean;
|
||||
references: ConversationReference[];
|
||||
onChange: (references: ConversationReference[]) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { enabled, maxReferences } = useConversationReferencesCapability();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const toggle = useCallback(
|
||||
(reference: ConversationReference) => {
|
||||
if (references.some((item) => item.threadId === reference.threadId)) {
|
||||
onChange(
|
||||
references.filter((item) => item.threadId !== reference.threadId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (references.length >= maxReferences) {
|
||||
return;
|
||||
}
|
||||
onChange([...references, reference]);
|
||||
},
|
||||
[references, maxReferences, onChange],
|
||||
);
|
||||
|
||||
if (!enabled || maxReferences <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip content={t.inputBox.referenceConversations}>
|
||||
<PromptInputButton
|
||||
aria-label={t.inputBox.referenceConversations}
|
||||
className={cn("gap-1 px-2!", className)}
|
||||
data-testid="reference-conversations-button"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<MessagesSquareIcon className="size-3" />
|
||||
{references.length > 0 && (
|
||||
<span className="text-xs">{references.length}</span>
|
||||
)}
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
<ConversationReferencePicker
|
||||
currentThreadId={currentThreadId}
|
||||
maxReferences={maxReferences}
|
||||
onOpenChange={setOpen}
|
||||
onToggle={toggle}
|
||||
open={open}
|
||||
selected={references}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -71,6 +71,10 @@ import {
|
||||
import { fetch } from "@/core/api/fetcher";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import {
|
||||
buildConversationReferenceMetadata,
|
||||
type ConversationReference,
|
||||
} from "@/core/conversation-references";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { polishInputDraft } from "@/core/input-polish/api";
|
||||
import {
|
||||
@ -128,6 +132,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
import { ConversationReferenceChip } from "./conversation-references/conversation-reference-chip";
|
||||
import { ReferenceConversationsButton } from "./conversation-references/reference-conversations-button";
|
||||
import {
|
||||
abortGoalRequest,
|
||||
beginGoalRequest,
|
||||
@ -226,6 +232,8 @@ function escapeXmlAttribute(value: string) {
|
||||
export type InputBoxSubmitOptions = {
|
||||
additionalKwargs?: Record<string, unknown>;
|
||||
additionalInputMessages?: Message[];
|
||||
/** Thread IDs attached through the conversation picker; sent as run context. */
|
||||
conversationReferences?: string[];
|
||||
onSent?: () => void;
|
||||
};
|
||||
|
||||
@ -381,6 +389,14 @@ export function InputBox({
|
||||
const setTextInput = textInput.setInput;
|
||||
const sidecar = useMaybeSidecar();
|
||||
const attachmentParts = attachments.files;
|
||||
// Conversations attached for the next message only. Not persisted with the
|
||||
// draft; cleared once a send proceeds or the composer moves to another thread.
|
||||
const [conversationReferences, setConversationReferences] = useState<
|
||||
ConversationReference[]
|
||||
>([]);
|
||||
useEffect(() => {
|
||||
setConversationReferences([]);
|
||||
}, [threadId]);
|
||||
const removeAttachment = attachments.remove;
|
||||
// Project documents attached from the shelf arrive already ingested
|
||||
// thread-side (spec §9): the composer shows them as completed attachments
|
||||
@ -1125,6 +1141,9 @@ export function InputBox({
|
||||
const quoteIds = quotes.map((quote) => quote.id);
|
||||
const quoteContexts = quotes.map((quote) => quote.context);
|
||||
pendingDraftSubmissionKeyRef.current = draftKey;
|
||||
const referenceIds = conversationReferences.map(
|
||||
(reference) => reference.threadId,
|
||||
);
|
||||
// Project-shelf attachments are already ingested thread-side (§9):
|
||||
// they join ``additional_kwargs.files`` as completed uploads without a
|
||||
// re-upload, and merge with any files uploaded in this send
|
||||
@ -1137,18 +1156,15 @@ export function InputBox({
|
||||
status: "uploaded" as const,
|
||||
}),
|
||||
);
|
||||
const quoteKwargs = quotes.length
|
||||
? buildReferenceMessageMetadata(quoteContexts)
|
||||
: {};
|
||||
const submitOptions: InputBoxSubmitOptions = {
|
||||
...(quotes.length || stagedFiles.length > 0
|
||||
? {
|
||||
additionalKwargs: {
|
||||
...quoteKwargs,
|
||||
...(stagedFiles.length > 0 ? { files: stagedFiles } : {}),
|
||||
},
|
||||
}
|
||||
const additionalKwargs = {
|
||||
...(quotes.length ? buildReferenceMessageMetadata(quoteContexts) : {}),
|
||||
...(referenceIds.length
|
||||
? buildConversationReferenceMetadata(conversationReferences)
|
||||
: {}),
|
||||
...(stagedFiles.length > 0 ? { files: stagedFiles } : {}),
|
||||
};
|
||||
const submitOptions: InputBoxSubmitOptions = {
|
||||
...(Object.keys(additionalKwargs).length ? { additionalKwargs } : {}),
|
||||
...(quotes.length
|
||||
? {
|
||||
additionalInputMessages: [
|
||||
@ -1158,6 +1174,9 @@ export function InputBox({
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
...(referenceIds.length
|
||||
? { conversationReferences: referenceIds }
|
||||
: {}),
|
||||
// Clear one-time state only once the send genuinely proceeds. If the
|
||||
// send is dropped by the in-flight guard, `onSent` never fires.
|
||||
onSent: () => {
|
||||
@ -1168,6 +1187,7 @@ export function InputBox({
|
||||
clearComposerDraft(getSessionComposerDraftStorage(), draftKey);
|
||||
}
|
||||
sidecar?.clearConversationQuotes(quoteIds);
|
||||
setConversationReferences([]);
|
||||
setProjectAttachments([]);
|
||||
},
|
||||
};
|
||||
@ -1198,6 +1218,7 @@ export function InputBox({
|
||||
},
|
||||
[
|
||||
context,
|
||||
conversationReferences,
|
||||
draftKey,
|
||||
invalidateDraftSaveTimer,
|
||||
onContextChange,
|
||||
@ -2342,6 +2363,22 @@ export function InputBox({
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{conversationReferences.map((reference) => (
|
||||
<ConversationReferenceChip
|
||||
key={reference.threadId}
|
||||
onRemove={() =>
|
||||
setConversationReferences((current) =>
|
||||
current.filter(
|
||||
(item) => item.threadId !== reference.threadId,
|
||||
),
|
||||
)
|
||||
}
|
||||
removeLabel={t.inputBox.referenceConversationsRemove(
|
||||
reference.title,
|
||||
)}
|
||||
title={reference.title}
|
||||
/>
|
||||
))}
|
||||
{polishingInput && (
|
||||
<div
|
||||
aria-live="polite"
|
||||
@ -2437,6 +2474,13 @@ export function InputBox({
|
||||
disabled={composerLocked}
|
||||
uploadLimits={uploadLimits}
|
||||
/>
|
||||
<ReferenceConversationsButton
|
||||
className="px-2!"
|
||||
currentThreadId={threadId}
|
||||
disabled={composerLocked}
|
||||
onChange={setConversationReferences}
|
||||
references={conversationReferences}
|
||||
/>
|
||||
<VoiceInputButton
|
||||
disabled={composerLocked}
|
||||
listening={voiceListening}
|
||||
|
||||
@ -41,6 +41,7 @@ import {
|
||||
resolveMessageImageURL,
|
||||
} from "@/core/artifacts/utils";
|
||||
import { extractCitationSources } from "@/core/citations/sources";
|
||||
import { readConversationReferences } from "@/core/conversation-references";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
@ -57,10 +58,12 @@ import {
|
||||
} from "@/core/skills";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { SafeReasoningContent } from "@/core/streamdown/components";
|
||||
import { pathOfThread } from "@/core/threads/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { WorkspaceChangeBadge } from "../changes";
|
||||
import { CitationSourcesPanel } from "../citations/citation-sources-panel";
|
||||
import { ConversationReferenceChip } from "../conversation-references/conversation-reference-chip";
|
||||
import { CopyButton } from "../copy-button";
|
||||
import { ReferenceAttachmentSummary } from "../sidecar/reference-attachments";
|
||||
import { SlashSkillChip } from "../slash-skill-chip";
|
||||
@ -451,6 +454,10 @@ function MessageContent_({
|
||||
),
|
||||
[message.additional_kwargs],
|
||||
);
|
||||
const conversationReferences = useMemo(
|
||||
() => readConversationReferences(message.additional_kwargs),
|
||||
[message.additional_kwargs],
|
||||
);
|
||||
|
||||
const contentToDisplay = useMemo(() => {
|
||||
if (isHuman) {
|
||||
@ -515,6 +522,24 @@ function MessageContent_({
|
||||
testId="message-reference-attachment"
|
||||
/>
|
||||
)}
|
||||
{conversationReferences.length > 0 && (
|
||||
<div
|
||||
aria-label={t.inputBox.referencedConversations}
|
||||
className="flex max-w-full flex-wrap justify-end gap-1"
|
||||
data-testid="message-conversation-references"
|
||||
role="group"
|
||||
>
|
||||
{conversationReferences.map((reference) => (
|
||||
<ConversationReferenceChip
|
||||
href={pathOfThread(reference.threadId, {
|
||||
agent_name: reference.agentName,
|
||||
})}
|
||||
key={reference.threadId}
|
||||
title={reference.title || "Untitled"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{filesList}
|
||||
{editState ? (
|
||||
<div className="bg-background border-border flex w-full min-w-0 flex-col gap-2 rounded-lg border p-2 shadow-sm">
|
||||
|
||||
@ -56,6 +56,7 @@ export async function staticApiResponse(
|
||||
worker_running: false,
|
||||
max_running: 0,
|
||||
},
|
||||
conversation_references: { enabled: false, max_references: 0 },
|
||||
} satisfies FeaturesResponse;
|
||||
break;
|
||||
case "channels/providers":
|
||||
|
||||
1
frontend/src/core/conversation-references/index.ts
Normal file
1
frontend/src/core/conversation-references/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./metadata";
|
||||
75
frontend/src/core/conversation-references/metadata.ts
Normal file
75
frontend/src/core/conversation-references/metadata.ts
Normal file
@ -0,0 +1,75 @@
|
||||
export const CONVERSATION_REFERENCES_KWARG = "conversation_references";
|
||||
|
||||
/** A conversation the user attached to the next message. Display data only. */
|
||||
export type ConversationReference = {
|
||||
threadId: string;
|
||||
title: string;
|
||||
/** Custom agent owning the source conversation; omitted for the default agent. */
|
||||
agentName?: string;
|
||||
};
|
||||
|
||||
type ConversationReferenceMetadata = {
|
||||
thread_id: string;
|
||||
title: string;
|
||||
agent_name?: string;
|
||||
};
|
||||
|
||||
export type ConversationReferencesMetadata = {
|
||||
[CONVERSATION_REFERENCES_KWARG]: ConversationReferenceMetadata[];
|
||||
};
|
||||
|
||||
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display-only metadata stored on the visible human message so the transcript
|
||||
* can show which conversations were attached. It grants nothing: read access
|
||||
* comes only from `conversation_references` in the run request context, which
|
||||
* the Gateway consumes at admission and never persists in the chat history.
|
||||
*/
|
||||
export function buildConversationReferenceMetadata(
|
||||
references: ConversationReference[],
|
||||
): ConversationReferencesMetadata {
|
||||
return {
|
||||
[CONVERSATION_REFERENCES_KWARG]: references.map((reference) => ({
|
||||
thread_id: reference.threadId,
|
||||
title: reference.title,
|
||||
...(reference.agentName ? { agent_name: reference.agentName } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function readConversationReferences(
|
||||
additionalKwargs: unknown,
|
||||
): ConversationReference[] {
|
||||
if (!isObjectRecord(additionalKwargs)) {
|
||||
return [];
|
||||
}
|
||||
const raw = additionalKwargs[CONVERSATION_REFERENCES_KWARG];
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const references: ConversationReference[] = [];
|
||||
for (const entry of raw) {
|
||||
if (
|
||||
!isObjectRecord(entry) ||
|
||||
typeof entry.thread_id !== "string" ||
|
||||
entry.thread_id.length === 0 ||
|
||||
seen.has(entry.thread_id)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
seen.add(entry.thread_id);
|
||||
const reference: ConversationReference = {
|
||||
threadId: entry.thread_id,
|
||||
title: typeof entry.title === "string" ? entry.title : "",
|
||||
};
|
||||
if (typeof entry.agent_name === "string" && entry.agent_name.length > 0) {
|
||||
reference.agentName = entry.agent_name;
|
||||
}
|
||||
references.push(reference);
|
||||
}
|
||||
return references;
|
||||
}
|
||||
@ -11,6 +11,15 @@ export interface FeaturesResponse {
|
||||
worker_running?: boolean;
|
||||
max_running?: number;
|
||||
};
|
||||
conversation_references?: {
|
||||
enabled?: boolean;
|
||||
max_references?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConversationReferencesCapability {
|
||||
enabled: boolean;
|
||||
maxReferences: number;
|
||||
}
|
||||
|
||||
export interface SubagentBatchesCapability {
|
||||
@ -48,3 +57,18 @@ export async function fetchSubagentBatchesCapability(): Promise<SubagentBatchesC
|
||||
maxRunning: feature?.max_running ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchConversationReferencesCapability(): Promise<ConversationReferencesCapability> {
|
||||
const features = await fetchFeatures();
|
||||
const capability = features.conversation_references;
|
||||
const maxReferences = capability?.max_references;
|
||||
return {
|
||||
enabled: capability?.enabled === true,
|
||||
maxReferences:
|
||||
typeof maxReferences === "number" &&
|
||||
Number.isInteger(maxReferences) &&
|
||||
maxReferences > 0
|
||||
? maxReferences
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
fetchBrowserControlEnabled,
|
||||
fetchConversationReferencesCapability,
|
||||
fetchMcpTasksEnabled,
|
||||
fetchSubagentBatchesCapability,
|
||||
} from "./api";
|
||||
@ -51,3 +52,18 @@ export function useSubagentBatchesCapability() {
|
||||
isLoading: isPending,
|
||||
};
|
||||
}
|
||||
|
||||
export function useConversationReferencesCapability() {
|
||||
const { data, isPending } = useQuery({
|
||||
queryKey: ["features", "conversation_references"],
|
||||
queryFn: () => fetchConversationReferencesCapability(),
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
retry: false,
|
||||
});
|
||||
return {
|
||||
enabled: data?.enabled ?? false,
|
||||
maxReferences: data?.maxReferences ?? 0,
|
||||
isLoading: isPending,
|
||||
};
|
||||
}
|
||||
|
||||
@ -254,6 +254,14 @@ export const enUS: Translations = {
|
||||
createSkillPrompt:
|
||||
"We're going to build a new skill step by step with `skill-creator`. To start, what do you want this skill to do?",
|
||||
addAttachments: "Add attachments",
|
||||
referenceConversations: "Reference a conversation",
|
||||
referenceConversationsSearch: "Search conversations",
|
||||
referenceConversationsEmpty: "No conversations found",
|
||||
referenceConversationsLimit: (max: number) =>
|
||||
`Up to ${max} conversations per message`,
|
||||
referenceConversationsRemove: (title: string) =>
|
||||
`Remove reference to ${title}`,
|
||||
referencedConversations: "Referenced conversations",
|
||||
removeProjectAttachment: "Remove attached document",
|
||||
inputPolish: "Polish input",
|
||||
inputPolishing: "Polishing input...",
|
||||
|
||||
@ -218,6 +218,12 @@ export interface Translations {
|
||||
disclaimer: string;
|
||||
createSkillPrompt: string;
|
||||
addAttachments: string;
|
||||
referenceConversations: string;
|
||||
referenceConversationsSearch: string;
|
||||
referenceConversationsEmpty: string;
|
||||
referenceConversationsLimit: (max: number) => string;
|
||||
referenceConversationsRemove: (title: string) => string;
|
||||
referencedConversations: string;
|
||||
removeProjectAttachment: string;
|
||||
inputPolish: string;
|
||||
inputPolishing: string;
|
||||
|
||||
@ -242,6 +242,13 @@ export const zhCN: Translations = {
|
||||
createSkillPrompt:
|
||||
"我们一起用 skill-creator 技能来创建一个技能吧。先问问我希望这个技能能做什么。",
|
||||
addAttachments: "添加附件",
|
||||
referenceConversations: "引用会话",
|
||||
referenceConversationsSearch: "搜索会话",
|
||||
referenceConversationsEmpty: "没有找到会话",
|
||||
referenceConversationsLimit: (max: number) =>
|
||||
`每条消息最多引用 ${max} 个会话`,
|
||||
referenceConversationsRemove: (title: string) => `移除对「${title}」的引用`,
|
||||
referencedConversations: "引用的会话",
|
||||
removeProjectAttachment: "移除附加文档",
|
||||
inputPolish: "优化输入",
|
||||
inputPolishing: "正在优化输入...",
|
||||
|
||||
@ -91,6 +91,14 @@ export type ThreadStreamOptions = {
|
||||
type SendMessageOptions = {
|
||||
additionalKwargs?: Record<string, unknown>;
|
||||
additionalInputMessages?: Message[];
|
||||
/**
|
||||
* Thread IDs of conversations the user attached for this run. They ride in
|
||||
* `context.conversation_references`, which the Gateway consumes at admission;
|
||||
* the LangGraph SDK drops unknown top-level body fields, so the top-level
|
||||
* request field is not reachable from here. Display metadata for the
|
||||
* transcript travels separately in `additionalKwargs`.
|
||||
*/
|
||||
conversationReferences?: string[];
|
||||
/**
|
||||
* Invoked exactly once when the send passes the in-flight guard and is
|
||||
* genuinely dispatched. It never fires on the early-return path, so callers
|
||||
@ -201,6 +209,52 @@ export function buildThreadSubmitMessages({
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run context sent with `thread.submit`. Both submit paths (send, and the
|
||||
* regenerate/edit replay) build it here so the client half of the Gateway
|
||||
* contract stays in one place: conversation references travel only as a plain
|
||||
* `string[]` under `context.conversation_references`, only when the caller
|
||||
* attached them, and never from local settings. A stray key in settings is
|
||||
* dropped rather than forwarded, so a stale value can never grant access.
|
||||
*/
|
||||
export function buildRunContext({
|
||||
settings,
|
||||
threadId,
|
||||
extraContext,
|
||||
conversationReferences,
|
||||
}: {
|
||||
settings: LocalSettings["context"];
|
||||
threadId: string;
|
||||
extraContext?: Record<string, unknown>;
|
||||
conversationReferences?: string[];
|
||||
}): Record<string, unknown> {
|
||||
const ownedSettings = Object.fromEntries(
|
||||
Object.entries(settings).filter(
|
||||
([key]) => key !== "conversation_references",
|
||||
),
|
||||
);
|
||||
return {
|
||||
...extraContext,
|
||||
...ownedSettings,
|
||||
...(conversationReferences?.length
|
||||
? { conversation_references: [...conversationReferences] }
|
||||
: {}),
|
||||
thinking_enabled: settings.mode !== "flash",
|
||||
is_plan_mode: settings.mode === "pro" || settings.mode === "ultra",
|
||||
subagent_enabled: settings.mode === "ultra",
|
||||
reasoning_effort:
|
||||
settings.reasoning_effort ??
|
||||
(settings.mode === "ultra"
|
||||
? "high"
|
||||
: settings.mode === "pro"
|
||||
? "medium"
|
||||
: settings.mode === "thinking"
|
||||
? "low"
|
||||
: undefined),
|
||||
thread_id: threadId,
|
||||
};
|
||||
}
|
||||
|
||||
// Stable identity for "no optimistic messages" so the merged-messages memo
|
||||
// below is not invalidated by a fresh empty array on every render.
|
||||
const EMPTY_MESSAGES: Message[] = [];
|
||||
@ -2364,23 +2418,12 @@ export function useThreadStream({
|
||||
config: {
|
||||
recursion_limit: 1000,
|
||||
},
|
||||
context: {
|
||||
...extraContext,
|
||||
...context,
|
||||
thinking_enabled: context.mode !== "flash",
|
||||
is_plan_mode: context.mode === "pro" || context.mode === "ultra",
|
||||
subagent_enabled: context.mode === "ultra",
|
||||
reasoning_effort:
|
||||
context.reasoning_effort ??
|
||||
(context.mode === "ultra"
|
||||
? "high"
|
||||
: context.mode === "pro"
|
||||
? "medium"
|
||||
: context.mode === "thinking"
|
||||
? "low"
|
||||
: undefined),
|
||||
thread_id: threadId,
|
||||
},
|
||||
context: buildRunContext({
|
||||
settings: context,
|
||||
threadId,
|
||||
extraContext,
|
||||
conversationReferences: options?.conversationReferences,
|
||||
}),
|
||||
},
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
@ -2514,22 +2557,10 @@ export function useThreadStream({
|
||||
config: {
|
||||
recursion_limit: 1000,
|
||||
},
|
||||
context: {
|
||||
...context,
|
||||
thinking_enabled: context.mode !== "flash",
|
||||
is_plan_mode: context.mode === "pro" || context.mode === "ultra",
|
||||
subagent_enabled: context.mode === "ultra",
|
||||
reasoning_effort:
|
||||
context.reasoning_effort ??
|
||||
(context.mode === "ultra"
|
||||
? "high"
|
||||
: context.mode === "pro"
|
||||
? "medium"
|
||||
: context.mode === "thinking"
|
||||
? "low"
|
||||
: undefined),
|
||||
thread_id: threadId,
|
||||
},
|
||||
// Replaying a turn never carries conversation references: the grant
|
||||
// is per send, so a regenerate or edit runs without them unless the
|
||||
// user attaches them again.
|
||||
context: buildRunContext({ settings: context, threadId }),
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: ["thread", threadId] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
|
||||
@ -32,24 +32,32 @@ type ThreadRouteTarget =
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The custom agent owning a thread, from its run context first and then its
|
||||
* stored metadata; undefined for default-agent conversations.
|
||||
*/
|
||||
export function agentNameOfThread(thread: {
|
||||
context?: Pick<AgentThreadContext, "agent_name"> | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}): string | undefined {
|
||||
const contextAgent = thread.context?.agent_name;
|
||||
if (contextAgent) {
|
||||
return contextAgent;
|
||||
}
|
||||
const metaAgent = thread.metadata?.agent_name;
|
||||
return typeof metaAgent === "string" && metaAgent ? metaAgent : undefined;
|
||||
}
|
||||
|
||||
export function pathOfThread(
|
||||
thread: ThreadRouteTarget,
|
||||
context?: Pick<AgentThreadContext, "agent_name"> | null,
|
||||
) {
|
||||
const threadId = typeof thread === "string" ? thread : thread.thread_id;
|
||||
const encodedThreadId = encodeURIComponent(threadId);
|
||||
let agentName: string | undefined;
|
||||
if (typeof thread === "string") {
|
||||
agentName = context?.agent_name;
|
||||
} else {
|
||||
agentName = thread.context?.agent_name;
|
||||
if (!agentName) {
|
||||
const metaAgent = thread.metadata?.agent_name;
|
||||
if (typeof metaAgent === "string") {
|
||||
agentName = metaAgent;
|
||||
}
|
||||
}
|
||||
}
|
||||
const agentName =
|
||||
typeof thread === "string"
|
||||
? context?.agent_name
|
||||
: agentNameOfThread(thread);
|
||||
|
||||
return agentName
|
||||
? `/workspace/agents/${encodeURIComponent(agentName)}/chats/${encodedThreadId}`
|
||||
|
||||
@ -0,0 +1,191 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,135 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
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 { MessageListItem } from "@/components/workspace/messages/message-list-item";
|
||||
import {
|
||||
buildConversationReferenceMetadata,
|
||||
type ConversationReference,
|
||||
} from "@/core/conversation-references";
|
||||
import { enUS } from "@/core/i18n/locales/en-US";
|
||||
import type { AgentThread } from "@/core/threads/types";
|
||||
|
||||
// The source conversation belongs to a custom agent; metadata.agent_name is
|
||||
// what the sidebar's thread list carries for it.
|
||||
const threads = [
|
||||
{
|
||||
thread_id: "t-current",
|
||||
updated_at: "2026-09-16T09:00:00Z",
|
||||
values: { title: "This conversation" },
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
thread_id: "source-1",
|
||||
updated_at: "2026-09-16T08:00:00Z",
|
||||
values: { title: "Writer brief" },
|
||||
metadata: { agent_name: "writer" },
|
||||
},
|
||||
] as unknown as AgentThread[];
|
||||
|
||||
rs.mock("@/core/threads/hooks", () => ({
|
||||
useThreads: () => ({ data: threads, isPending: false, isError: false }),
|
||||
}));
|
||||
|
||||
rs.mock("@/core/i18n/hooks", () => ({
|
||||
useI18n: () => ({
|
||||
locale: "en-US",
|
||||
setLocale: () => undefined,
|
||||
t: enUS,
|
||||
}),
|
||||
}));
|
||||
|
||||
// The transcript body is not under test; stubbing the markdown pipeline also
|
||||
// avoids its first-render suspension.
|
||||
rs.mock("@/components/workspace/messages/markdown-content", () => ({
|
||||
MarkdownContent: () => null,
|
||||
}));
|
||||
|
||||
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);
|
||||
|
||||
describe("conversation reference picker-to-transcript flow", () => {
|
||||
it("links the transcript chip to a custom-agent source conversation", () => {
|
||||
// 1. Select the custom-agent conversation in the picker.
|
||||
let selected: ConversationReference | undefined;
|
||||
render(
|
||||
<ConversationReferenceList
|
||||
currentThreadId="t-current"
|
||||
maxReferences={3}
|
||||
onToggle={(reference) => {
|
||||
selected = reference;
|
||||
}}
|
||||
selected={[]}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("Writer brief"));
|
||||
expect(selected).toEqual({
|
||||
threadId: "source-1",
|
||||
title: "Writer brief",
|
||||
agentName: "writer",
|
||||
});
|
||||
|
||||
// 2. The send path stores the display-only metadata on the human message.
|
||||
const message = {
|
||||
id: "human-1",
|
||||
type: "human",
|
||||
content: "Summarize the referenced brief",
|
||||
additional_kwargs: buildConversationReferenceMetadata([selected!]),
|
||||
} as unknown as Message;
|
||||
|
||||
// 3. The transcript chip routes back to the custom-agent conversation.
|
||||
cleanup();
|
||||
render(
|
||||
<MessageListItem
|
||||
message={message}
|
||||
threadId="t-current"
|
||||
showCopyButton={false}
|
||||
isLoading={false}
|
||||
/>,
|
||||
);
|
||||
const chip = screen.getByTestId("conversation-reference-chip");
|
||||
expect(chip.getAttribute("href")).toBe(
|
||||
"/workspace/agents/writer/chats/source-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("links a default-agent source to the plain chats path", () => {
|
||||
const message = {
|
||||
id: "human-2",
|
||||
type: "human",
|
||||
content: "Summarize the referenced chat",
|
||||
additional_kwargs: buildConversationReferenceMetadata([
|
||||
{ threadId: "source-2", title: "Plain chat" },
|
||||
]),
|
||||
} as unknown as Message;
|
||||
|
||||
render(
|
||||
<MessageListItem
|
||||
message={message}
|
||||
threadId="t-current"
|
||||
showCopyButton={false}
|
||||
isLoading={false}
|
||||
/>,
|
||||
);
|
||||
const chip = screen.getByTestId("conversation-reference-chip");
|
||||
expect(chip.getAttribute("href")).toBe("/workspace/chats/source-2");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,72 @@
|
||||
import { expect, test } from "@rstest/core";
|
||||
|
||||
import {
|
||||
CONVERSATION_REFERENCES_KWARG,
|
||||
buildConversationReferenceMetadata,
|
||||
readConversationReferences,
|
||||
type ConversationReference,
|
||||
} from "@/core/conversation-references";
|
||||
|
||||
const references: ConversationReference[] = [
|
||||
{ threadId: "thread-a", title: "Requirements review" },
|
||||
{ threadId: "thread-b", title: "Design notes" },
|
||||
];
|
||||
|
||||
test("metadata carries thread id and title only, under one display-only key", () => {
|
||||
expect(buildConversationReferenceMetadata(references)).toEqual({
|
||||
[CONVERSATION_REFERENCES_KWARG]: [
|
||||
{ thread_id: "thread-a", title: "Requirements review" },
|
||||
{ thread_id: "thread-b", title: "Design notes" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("reading round-trips what build wrote", () => {
|
||||
expect(
|
||||
readConversationReferences(buildConversationReferenceMetadata(references)),
|
||||
).toEqual(references);
|
||||
});
|
||||
|
||||
test("metadata round-trips the source's custom agent when present", () => {
|
||||
const withAgent: ConversationReference[] = [
|
||||
{ threadId: "thread-w", title: "Writer notes", agentName: "writer" },
|
||||
];
|
||||
expect(buildConversationReferenceMetadata(withAgent)).toEqual({
|
||||
[CONVERSATION_REFERENCES_KWARG]: [
|
||||
{ thread_id: "thread-w", title: "Writer notes", agent_name: "writer" },
|
||||
],
|
||||
});
|
||||
expect(
|
||||
readConversationReferences(buildConversationReferenceMetadata(withAgent)),
|
||||
).toEqual(withAgent);
|
||||
});
|
||||
|
||||
test("reading tolerates missing, malformed and duplicate entries", () => {
|
||||
expect(readConversationReferences(undefined)).toEqual([]);
|
||||
expect(readConversationReferences({})).toEqual([]);
|
||||
expect(readConversationReferences({ conversation_references: "x" })).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
readConversationReferences({
|
||||
conversation_references: [
|
||||
{ thread_id: "thread-a", title: "Kept" },
|
||||
{ thread_id: "thread-a", title: "Duplicate of the first" },
|
||||
{ thread_id: 7, title: "Bad id" },
|
||||
{ thread_id: "", title: "Empty id" },
|
||||
{ thread_id: "thread-c" },
|
||||
{ thread_id: "thread-d", title: "" },
|
||||
{ thread_id: "thread-f", agent_name: 42 },
|
||||
{ thread_id: "thread-g", agent_name: "" },
|
||||
null,
|
||||
"thread-e",
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{ threadId: "thread-a", title: "Kept" },
|
||||
{ threadId: "thread-c", title: "" },
|
||||
{ threadId: "thread-d", title: "" },
|
||||
{ threadId: "thread-f", title: "" },
|
||||
{ threadId: "thread-g", title: "" },
|
||||
]);
|
||||
});
|
||||
@ -4,7 +4,10 @@ rs.mock("@/core/api/fetcher", () => ({ fetch: rs.fn() }));
|
||||
rs.mock("@/core/config", () => ({ getBackendBaseURL: () => "" }));
|
||||
|
||||
import { fetch } from "@/core/api/fetcher";
|
||||
import { fetchSubagentBatchesCapability } from "@/core/features/api";
|
||||
import {
|
||||
fetchConversationReferencesCapability,
|
||||
fetchSubagentBatchesCapability,
|
||||
} from "@/core/features/api";
|
||||
|
||||
const mockedFetch = rs.mocked(fetch);
|
||||
|
||||
@ -55,3 +58,44 @@ describe("subagent batch feature capability", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation references feature capability", () => {
|
||||
it("reports the flag and the per-run cap", async () => {
|
||||
mockedFetch.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
agents_api: { enabled: true },
|
||||
conversation_references: { enabled: true, max_references: 3 },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(fetchConversationReferencesCapability()).resolves.toEqual({
|
||||
enabled: true,
|
||||
maxReferences: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a backend without the field as disabled", async () => {
|
||||
mockedFetch.mockResolvedValueOnce(
|
||||
jsonResponse({ agents_api: { enabled: true } }),
|
||||
);
|
||||
|
||||
await expect(fetchConversationReferencesCapability()).resolves.toEqual({
|
||||
enabled: false,
|
||||
maxReferences: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("never reports a cap below zero or a non-numeric one", async () => {
|
||||
mockedFetch.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
agents_api: { enabled: true },
|
||||
conversation_references: { enabled: true, max_references: "3" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(fetchConversationReferencesCapability()).resolves.toEqual({
|
||||
enabled: true,
|
||||
maxReferences: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
69
frontend/tests/unit/core/threads/run-context.test.ts
Normal file
69
frontend/tests/unit/core/threads/run-context.test.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "@rstest/core";
|
||||
|
||||
import type { LocalSettings } from "@/core/settings";
|
||||
import { buildRunContext } from "@/core/threads/hooks";
|
||||
|
||||
const settings = {
|
||||
mode: "pro",
|
||||
model_name: "gemma4",
|
||||
reasoning_effort: undefined,
|
||||
} as unknown as LocalSettings["context"];
|
||||
|
||||
describe("buildRunContext", () => {
|
||||
it("sends attached references as a plain string[] under context.conversation_references", () => {
|
||||
const context = buildRunContext({
|
||||
settings,
|
||||
threadId: "t-1",
|
||||
extraContext: { agent_name: "writer" },
|
||||
conversationReferences: ["source-a", "source-b"],
|
||||
});
|
||||
expect(context.conversation_references).toEqual(["source-a", "source-b"]);
|
||||
expect(context.agent_name).toBe("writer");
|
||||
expect(context.thread_id).toBe("t-1");
|
||||
expect(context.is_plan_mode).toBe(true);
|
||||
});
|
||||
|
||||
it("omits the key when nothing is attached, including on the replay path", () => {
|
||||
expect(
|
||||
"conversation_references" in
|
||||
buildRunContext({ settings, threadId: "t-1" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
"conversation_references" in
|
||||
buildRunContext({
|
||||
settings,
|
||||
threadId: "t-1",
|
||||
conversationReferences: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("never forwards a stray conversation_references key from local settings", () => {
|
||||
const stale = {
|
||||
...settings,
|
||||
conversation_references: ["stale-source"],
|
||||
} as unknown as LocalSettings["context"];
|
||||
expect(
|
||||
"conversation_references" in
|
||||
buildRunContext({ settings: stale, threadId: "t-1" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
buildRunContext({
|
||||
settings: stale,
|
||||
threadId: "t-1",
|
||||
conversationReferences: ["source-a"],
|
||||
}).conversation_references,
|
||||
).toEqual(["source-a"]);
|
||||
});
|
||||
|
||||
it("copies the list so later mutation of the caller's array cannot change the request", () => {
|
||||
const references = ["source-a"];
|
||||
const context = buildRunContext({
|
||||
settings,
|
||||
threadId: "t-1",
|
||||
conversationReferences: references,
|
||||
});
|
||||
references.push("source-b");
|
||||
expect(context.conversation_references).toEqual(["source-a"]);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user