diff --git a/README.md b/README.md index 7a1371478..6f58b8923 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md index df5bddd2e..c42ee7b44 100644 --- a/frontend/src/AGENTS.md +++ b/frontend/src/AGENTS.md @@ -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 ` before normal chat submission, calling Gateway `GET/PUT/DELETE /api/threads/{thread_id}/goal`. Setting `/goal ` 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. diff --git a/frontend/src/components/workspace/conversation-references/conversation-reference-chip.tsx b/frontend/src/components/workspace/conversation-references/conversation-reference-chip.tsx new file mode 100644 index 000000000..e5d4a865f --- /dev/null +++ b/frontend/src/components/workspace/conversation-references/conversation-reference-chip.tsx @@ -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 = ( + <> + + {title} + + ); + if (onRemove) { + return ( + + ); + } + if (href) { + return ( + + {body} + + ); + } + return ( + + {body} + + ); +} diff --git a/frontend/src/components/workspace/conversation-references/conversation-reference-picker.tsx b/frontend/src/components/workspace/conversation-references/conversation-reference-picker.tsx new file mode 100644 index 000000000..fc2282c89 --- /dev/null +++ b/frontend/src/components/workspace/conversation-references/conversation-reference-picker.tsx @@ -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 ( + + + + {isPending ? ( + // Never claim there are no conversations before the list has loaded. +
+ {t.common.loading} +
+ ) : ( + {t.inputBox.referenceConversationsEmpty} + )} + + {candidates.map((thread) => { + const title = titleOfThread(thread); + const isSelected = selectedIds.has(thread.thread_id); + return ( + + 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}`} + > + {title} + {isSelected ? ( + + ) : ( + + )} + + ); + })} + +
+

+ {t.inputBox.referenceConversationsLimit(maxReferences)} +

+
+ ); +} + +export function ConversationReferencePicker({ + open, + onOpenChange, + ...listProps +}: ConversationReferenceListProps & { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useI18n(); + return ( + + + + {t.inputBox.referenceConversations} + + + {t.inputBox.referenceConversationsLimit(listProps.maxReferences)} + + + + + ); +} diff --git a/frontend/src/components/workspace/conversation-references/reference-conversations-button.tsx b/frontend/src/components/workspace/conversation-references/reference-conversations-button.tsx new file mode 100644 index 000000000..dc8a2085d --- /dev/null +++ b/frontend/src/components/workspace/conversation-references/reference-conversations-button.tsx @@ -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 ( + <> + + setOpen(true)} + > + + {references.length > 0 && ( + {references.length} + )} + + + + + ); +} diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 7e41c21ab..3481f2ce8 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -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; 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({ ))} + {conversationReferences.map((reference) => ( + + setConversationReferences((current) => + current.filter( + (item) => item.threadId !== reference.threadId, + ), + ) + } + removeLabel={t.inputBox.referenceConversationsRemove( + reference.title, + )} + title={reference.title} + /> + ))} {polishingInput && (
+ 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 && ( +
+ {conversationReferences.map((reference) => ( + + ))} +
+ )} {filesList} {editState ? (
diff --git a/frontend/src/core/api/static-response.ts b/frontend/src/core/api/static-response.ts index 3680e046f..267ff361a 100644 --- a/frontend/src/core/api/static-response.ts +++ b/frontend/src/core/api/static-response.ts @@ -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": diff --git a/frontend/src/core/conversation-references/index.ts b/frontend/src/core/conversation-references/index.ts new file mode 100644 index 000000000..e6c55b6c9 --- /dev/null +++ b/frontend/src/core/conversation-references/index.ts @@ -0,0 +1 @@ +export * from "./metadata"; diff --git a/frontend/src/core/conversation-references/metadata.ts b/frontend/src/core/conversation-references/metadata.ts new file mode 100644 index 000000000..32a841659 --- /dev/null +++ b/frontend/src/core/conversation-references/metadata.ts @@ -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 { + 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(); + 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; +} diff --git a/frontend/src/core/features/api.ts b/frontend/src/core/features/api.ts index d28aabd31..dc03c8e36 100644 --- a/frontend/src/core/features/api.ts +++ b/frontend/src/core/features/api.ts @@ -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 { + 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, + }; +} diff --git a/frontend/src/core/features/hooks.ts b/frontend/src/core/features/hooks.ts index 5ab574078..e36ec0309 100644 --- a/frontend/src/core/features/hooks.ts +++ b/frontend/src/core/features/hooks.ts @@ -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, + }; +} diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 5566b9845..ddc2beb16 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -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...", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index c14a8381c..58225c2e0 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -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; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index b2d129292..a408a0b75 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -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: "正在优化输入...", diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index e33806f1f..3fffb7d30 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -91,6 +91,14 @@ export type ThreadStreamOptions = { type SendMessageOptions = { additionalKwargs?: Record; 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; + conversationReferences?: string[]; +}): Record { + 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"] }); diff --git a/frontend/src/core/threads/utils.ts b/frontend/src/core/threads/utils.ts index 8f919456a..0fad7a2ef 100644 --- a/frontend/src/core/threads/utils.ts +++ b/frontend/src/core/threads/utils.ts @@ -32,24 +32,32 @@ type ThreadRouteTarget = metadata?: Record | 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 | null; + metadata?: Record | 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 | 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}` diff --git a/frontend/tests/unit/components/workspace/conversation-reference-picker.dom.test.tsx b/frontend/tests/unit/components/workspace/conversation-reference-picker.dom.test.tsx new file mode 100644 index 000000000..1a3c523fe --- /dev/null +++ b/frontend/tests/unit/components/workspace/conversation-reference-picker.dom.test.tsx @@ -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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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(); + }); +}); diff --git a/frontend/tests/unit/components/workspace/conversation-references-flow.dom.test.tsx b/frontend/tests/unit/components/workspace/conversation-references-flow.dom.test.tsx new file mode 100644 index 000000000..8b185db61 --- /dev/null +++ b/frontend/tests/unit/components/workspace/conversation-references-flow.dom.test.tsx @@ -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( + { + 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( + , + ); + 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( + , + ); + const chip = screen.getByTestId("conversation-reference-chip"); + expect(chip.getAttribute("href")).toBe("/workspace/chats/source-2"); + }); +}); diff --git a/frontend/tests/unit/core/conversation-references/metadata.test.ts b/frontend/tests/unit/core/conversation-references/metadata.test.ts new file mode 100644 index 000000000..ce721a60a --- /dev/null +++ b/frontend/tests/unit/core/conversation-references/metadata.test.ts @@ -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: "" }, + ]); +}); diff --git a/frontend/tests/unit/core/features/api.test.ts b/frontend/tests/unit/core/features/api.test.ts index 3c80810b5..a91a934e9 100644 --- a/frontend/tests/unit/core/features/api.test.ts +++ b/frontend/tests/unit/core/features/api.test.ts @@ -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, + }); + }); +}); diff --git a/frontend/tests/unit/core/threads/run-context.test.ts b/frontend/tests/unit/core/threads/run-context.test.ts new file mode 100644 index 000000000..00ebb4c96 --- /dev/null +++ b/frontend/tests/unit/core/threads/run-context.test.ts @@ -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"]); + }); +});