Resolve follow-up review findings

This commit is contained in:
Marco Beretta 2026-09-06 03:26:56 +02:00
parent ea6e72fc7b
commit 5f9315e944
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
32 changed files with 687 additions and 334 deletions

View file

@ -8,6 +8,7 @@ jest.mock('nanoid', () => ({
jest.mock('@librechat/api', () => ({
sendEvent: jest.fn(),
writeAttachmentEvent: jest.fn(),
createOwnedToolEndHandler: jest.fn((callback) => ({ handle: callback })),
GenerationJobManager: {
emitChunk: jest.fn(),
},
@ -498,6 +499,42 @@ describe('createToolEndCallback', () => {
]);
});
it.each(['createToolEndCallback', 'createResponsesToolEndCallback'])(
'%s preserves memory payloads and their execution owner',
async (factoryName) => {
const toolEndCallback = require('../callbacks')[factoryName]({ req, res, artifactPromises });
await toolEndCallback(
{
output: {
tool_call_id: 'call_0',
artifact: {
[Tools.memory]: {
key: 'project',
type: 'update',
value: 'owned',
},
},
},
},
{
run_id: 'run456',
thread_id: 'thread789',
agent_id: 'agent-a',
stepId: 'step-memory-1',
},
);
const [attachment] = await Promise.all(artifactPromises);
expect(attachment).toMatchObject({
type: Tools.memory,
toolCallId: 'call_0',
agentId: 'agent-a',
stepId: 'step-memory-1',
[Tools.memory]: { key: 'project', type: 'update', value: 'owned' },
});
},
);
describe('ui_resources artifact handling', () => {
it('should process ui_resources artifact and return attachment when headers not sent', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });

View file

@ -194,6 +194,9 @@ jest.mock('@librechat/api', () => ({
* export or the call throws before the assertions run. */
stripActivityLabelParts: jest.fn((payload) => payload),
writeSSE: jest.fn(),
createOwnedToolEndHandler: jest.fn(
(...args) => new (require('@librechat/agents').ToolEndHandler)(...args),
),
createRun: jest.fn().mockResolvedValue({
processStream: mockProcessStream,
}),

View file

@ -203,6 +203,9 @@ jest.mock('@librechat/api', () => ({
* before SDK formatting; the mock must expose it like any other used
* export or the call throws before the assertions run. */
stripActivityLabelParts: jest.fn((payload) => payload),
createOwnedToolEndHandler: jest.fn(
(...args) => new (require('@librechat/agents').ToolEndHandler)(...args),
),
collectReachableAgents: (roots) => {
const agents = [];
const pending = [...roots];

View file

@ -13,7 +13,6 @@ const {
const {
GraphEvents,
GraphNodeKeys,
ToolEndHandler,
createContentAggregator,
summarizeEvent,
} = require('@librechat/agents');
@ -23,6 +22,7 @@ const {
GenerationJobManager,
writeAttachmentEvent,
createToolExecuteHandler,
createOwnedToolEndHandler,
createBackgroundCodeResultHandler: createCodeHarvestHandler,
HOST_FILE_AUTHORING_ARTIFACT_KEY,
isCodeSessionToolName,
@ -42,6 +42,15 @@ function isCodeArtifactToolOutput(output) {
return isCodeSessionToolName(output.name) || isHostFileAuthoringArtifact(output.artifact);
}
function getAttachmentOwnership(metadata) {
const agentId = metadata?.agent_id ?? metadata?.agentId;
const stepId = metadata?.stepId;
return {
...(typeof agentId === 'string' && agentId.length > 0 ? { agentId } : {}),
...(typeof stepId === 'string' && stepId.length > 0 ? { stepId } : {}),
};
}
function addStatefulWorkspaceChange(attachment, artifact, executionProfile) {
if (!attachment || executionProfile !== 'stateful' || !isHostFileAuthoringArtifact(artifact)) {
return attachment;
@ -506,7 +515,7 @@ function getDefaultHandlers({
collectedThoughtSignatures,
emitTokenUsage,
),
[GraphEvents.TOOL_END]: new ToolEndHandler(toolEndCallback, logger),
[GraphEvents.TOOL_END]: createOwnedToolEndHandler(toolEndCallback, logger),
[GraphEvents.ON_RUN_STEP]: {
/**
* Handle ON_RUN_STEP event.
@ -1004,9 +1013,9 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null, jo
(async () => {
const attachment = {
type: Tools.web_search,
...getAttachmentOwnership(metadata),
messageId: metadata.run_id,
toolCallId: output.tool_call_id,
agentId: metadata.agent_id,
conversationId: metadata.thread_id,
[Tools.web_search]: { ...output.artifact[Tools.web_search] },
};
@ -1027,6 +1036,7 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null, jo
(async () => {
const attachment = {
type: Tools.memory,
...getAttachmentOwnership(metadata),
messageId: metadata.run_id,
toolCallId: output.tool_call_id,
conversationId: metadata.thread_id,
@ -1368,6 +1378,7 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises })
const attachment = {
type: Tools.web_search,
toolCallId: output.tool_call_id,
...getAttachmentOwnership(metadata),
[Tools.web_search]: { ...output.artifact[Tools.web_search] },
};
// For Responses API, always emit attachment during streaming
@ -1382,6 +1393,26 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises })
);
}
if (output.artifact[Tools.memory]) {
artifactPromises.push(
(async () => {
const attachment = {
type: Tools.memory,
toolCallId: output.tool_call_id,
...getAttachmentOwnership(metadata),
[Tools.memory]: output.artifact[Tools.memory],
};
if (res.headersSent && !res.writableEnded) {
writeResponsesAttachment(res, tracker, attachment, metadata);
}
return attachment;
})().catch((error) => {
logger.error('Error processing memory artifact content:', error);
return null;
}),
);
}
if (output.artifact.content) {
/** @type {FormattedContent[]} */
const content = output.artifact.content;

View file

@ -1,6 +1,6 @@
const { nanoid } = require('nanoid');
const { logger } = require('@librechat/data-schemas');
const { Callback, ToolEndHandler, formatAgentMessages } = require('@librechat/agents');
const { Callback, formatAgentMessages } = require('@librechat/agents');
const {
EModelEndpoint,
ResourceType,
@ -56,6 +56,7 @@ const {
getUserFacingProviderError,
getRemoteAgentPermissions,
createToolExecuteHandler,
createOwnedToolEndHandler,
buildNonStreamingResponse,
createOpenAIStreamTracker,
resolveAgentScopedSkillIds,
@ -1001,7 +1002,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
},
on_run_step_completed: createHandler(),
// Use proper ToolEndHandler for processing artifacts (images, file citations, code output)
on_tool_end: new ToolEndHandler(toolEndCallback, logger),
on_tool_end: createOwnedToolEndHandler(toolEndCallback, logger),
on_chain_stream: createHandler(),
on_chain_end: createHandler(),
on_agent_update: createHandler(),

View file

@ -1,7 +1,7 @@
const { nanoid } = require('nanoid');
const { v4: uuidv4 } = require('uuid');
const { logger } = require('@librechat/data-schemas');
const { Callback, ToolEndHandler, formatAgentMessages } = require('@librechat/agents');
const { Callback, formatAgentMessages } = require('@librechat/agents');
const {
EModelEndpoint,
ResourceType,
@ -53,6 +53,7 @@ const {
getSafeErrorMetadata,
getUserFacingProviderError,
createToolExecuteHandler,
createOwnedToolEndHandler,
resolveRecursionLimit,
getRemoteAgentPermissions,
resolveAgentScopedSkillIds,
@ -1155,7 +1156,7 @@ const executeResponse = async (envelope, { req, res }) => {
}
},
},
on_tool_end: new ToolEndHandler(toolEndCallback, logger),
on_tool_end: createOwnedToolEndHandler(toolEndCallback, logger),
on_run_step_completed: { handle: () => {} },
on_chain_stream: { handle: () => {} },
on_chain_end: { handle: () => {} },
@ -1368,7 +1369,7 @@ const executeResponse = async (envelope, { req, res }) => {
}
},
},
on_tool_end: new ToolEndHandler(toolEndCallback, logger),
on_tool_end: createOwnedToolEndHandler(toolEndCallback, logger),
on_run_step_completed: { handle: () => {} },
on_chain_stream: { handle: () => {} },
on_chain_end: { handle: () => {} },

View file

@ -35,6 +35,7 @@ function createOnSearchResults(res, streamId = null, jobCreatedAt) {
searchResultData: undefined,
toolCallId: undefined,
agentId: undefined,
stepId: undefined,
attachmentName: undefined,
messageId: undefined,
conversationId: undefined,
@ -84,7 +85,8 @@ function createOnSearchResults(res, streamId = null, jobCreatedAt) {
}
context.toolCallId = runnableConfig.toolCall.id;
context.agentId = runnableConfig.metadata.agent_id;
context.agentId = runnableConfig.metadata.agent_id ?? runnableConfig.metadata.agentId;
context.stepId = runnableConfig.toolCall?.stepId;
context.messageId = runnableConfig.metadata.run_id;
context.conversationId = runnableConfig.metadata.thread_id;
context.attachmentName = `${runnableConfig.toolCall.name}_${context.toolCallId}_${nanoid()}`;
@ -135,6 +137,7 @@ function buildAttachment(context) {
messageId: context.messageId,
toolCallId: context.toolCallId,
agentId: context.agentId,
stepId: context.stepId,
conversationId: context.conversationId,
name: context.attachmentName,
type: Tools.web_search,

View file

@ -28,6 +28,7 @@ describe('createOnSearchResults', () => {
toolCall: {
id: 'tool-call-1',
name: 'web_search',
stepId: 'step-search-1',
turn: 0,
},
};
@ -53,6 +54,7 @@ describe('createOnSearchResults', () => {
data: expect.objectContaining({
messageId: 'response-1',
toolCallId: 'tool-call-1',
stepId: 'step-search-1',
conversationId: 'conversation-1',
}),
},

View file

@ -17,6 +17,7 @@ import {
import { ChatContext, AddedChatContext, ChatFormProvider, useFileMapContext } from '~/Providers';
import ConversationStarters from './Input/ConversationStarters';
import { useGetMessagesByConvoId } from '~/data-provider';
import { AskAnswerHostProvider } from './ask/state';
import MessagesView from './Messages/MessagesView';
import Presentation from './Presentation';
import ChatForm from './Input/ChatForm';
@ -41,6 +42,7 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje
const localize = useLocalize();
const rootSubmission = useRecoilValue(store.submissionByIndex(index));
const isSubmitting = useRecoilValue(store.isSubmittingFamily(index));
const saveDrafts = useRecoilValue(store.saveDrafts);
const centerFormOnLanding = useRecoilValue(store.centerFormOnLanding);
const methods = useForm<ChatFormValues>({
@ -129,63 +131,65 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje
const isSubagentThreadReadOnly = activeSubagentThread != null;
return (
<ChatFormProvider {...methods}>
<ChatContext.Provider value={chatHelpers}>
<AddedChatContext.Provider value={addedChatHelpers}>
<Presentation>
<div className="relative flex h-full w-full flex-col">
<h1 className="sr-only">{pageHeading}</h1>
<Header
parentConversationId={parentConversationId}
readOnly={isSubagentThreadReadOnly}
/>
<>
<div
className={cn(
'flex flex-col',
isLandingPage
? 'flex-1 items-center justify-end sm:justify-center'
: 'h-full overflow-y-auto',
)}
>
{content}
{/* Named + opaque so a view transition (the ask_user_question
popover ⇄ chat-card morph) paints the whole composer band
over the travelling card instead of letting it show
through below the composer. The background matches the
page, so normal rendering is unchanged. */}
<AskAnswerHostProvider saveDrafts={saveDrafts}>
<ChatFormProvider {...methods}>
<ChatContext.Provider value={chatHelpers}>
<AddedChatContext.Provider value={addedChatHelpers}>
<Presentation>
<div className="relative flex h-full w-full flex-col">
<h1 className="sr-only">{pageHeading}</h1>
<Header
parentConversationId={parentConversationId}
readOnly={isSubagentThreadReadOnly}
/>
<>
<div
className={cn(
'w-full bg-presentation [view-transition-name:chat-form]',
!isLandingPage && 'scrollbar-gutter-spacer',
isLandingPage && 'max-w-3xl transition-all duration-200 xl:max-w-4xl',
'flex flex-col',
isLandingPage
? 'flex-1 items-center justify-end sm:justify-center'
: 'h-full overflow-y-auto',
)}
>
{isLandingPage && <ConversationStarters />}
{isSubagentThreadReadOnly ? (
<div
className="mx-auto w-full max-w-3xl px-4 py-3 text-center text-sm text-text-secondary xl:max-w-4xl"
role="note"
>
{localize('com_ui_subagent_thread_read_only')}
</div>
) : (
<ChatForm
index={index}
placeholder={chatFormPlaceholder}
project={isProjectLandingPage ? project : undefined}
/>
)}
{!isLandingPage && <Footer />}
{content}
{/* Named + opaque so a view transition (the ask_user_question
popover ⇄ chat-card morph) paints the whole composer band
over the travelling card instead of letting it show
through below the composer. The background matches the
page, so normal rendering is unchanged. */}
<div
className={cn(
'w-full bg-presentation [view-transition-name:chat-form]',
!isLandingPage && 'scrollbar-gutter-spacer',
isLandingPage && 'max-w-3xl transition-all duration-200 xl:max-w-4xl',
)}
>
{isLandingPage && <ConversationStarters />}
{isSubagentThreadReadOnly ? (
<div
className="mx-auto w-full max-w-3xl px-4 py-3 text-center text-sm text-text-secondary xl:max-w-4xl"
role="note"
>
{localize('com_ui_subagent_thread_read_only')}
</div>
) : (
<ChatForm
index={index}
placeholder={chatFormPlaceholder}
project={isProjectLandingPage ? project : undefined}
/>
)}
{!isLandingPage && <Footer />}
</div>
</div>
</div>
{isLandingPage && <Footer />}
</>
</div>
</Presentation>
</AddedChatContext.Provider>
</ChatContext.Provider>
</ChatFormProvider>
{isLandingPage && <Footer />}
</>
</div>
</Presentation>
</AddedChatContext.Provider>
</ChatContext.Provider>
</ChatFormProvider>
</AskAnswerHostProvider>
);
}

View file

@ -1,49 +1,32 @@
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
import { useAtom } from 'jotai';
import { useRecoilValue } from 'recoil';
import { Constants } from 'librechat-data-provider';
import { atom, useRecoilState, useRecoilValue } from 'recoil';
import type { Agents } from 'librechat-data-provider';
import type { AskAnswerStatus } from '~/components/Chat/ask/state';
import {
useSubmitToolApprovalMutation,
useSubmitAskAnswerMutation,
type ResumeAgentFields,
} from '~/data-provider';
import { askSubmitStatusAtom } from '~/components/Chat/ask/state';
import { resolveAskUserQuestionPart } from '~/utils/approval';
import { ChatContext } from '~/Providers/ChatContext';
import { useGetEphemeralAgent } from '~/store/agents';
import store from '~/store';
/** Per-action submission lifecycle, surfaced to the cards so they can disable
* controls and explain a terminal outcome. */
type ActionStatus = 'idle' | 'submitting' | 'submitted' | 'expired' | 'error';
/**
* Ask-answer submit status keyed by `actionId`. This lives in Recoil, NOT the
* {@link ApprovalContext} React state, because the PRIMARY answer surface is
* the composer in `ChatForm` — which renders OUTSIDE `ApprovalProvider` (that
* only wraps message content). A React context read there degrades to the
* inert {@link FALLBACK}, so an in-flight lock / expired status tracked in the
* context would never engage for the composer. A global atom is visible to
* both the composer (`useAskAnswerMode`) and the message-content card
* (`AskUserQuestion`), so a fast double-submit is actually blocked and a
* terminal (expired/error) state surfaces on either surface.
*/
const askSubmitStatusAtom = atom<Record<string, ActionStatus>>({
key: 'askAnswerSubmitStatus',
default: {},
});
/** Shared read/write for the ask-answer submit status. */
export function useAskSubmitStatus(): {
getAskStatus: (actionId: string) => ActionStatus;
setAskStatus: (actionId: string, status: ActionStatus) => void;
getAskStatus: (actionId: string) => AskAnswerStatus;
setAskStatus: (actionId: string, status: AskAnswerStatus) => void;
} {
const [statusMap, setStatusMap] = useRecoilState(askSubmitStatusAtom);
const [statusMap, setStatusMap] = useAtom(askSubmitStatusAtom);
const getAskStatus = useCallback(
(actionId: string): ActionStatus => statusMap[actionId] ?? 'idle',
(actionId: string): AskAnswerStatus => statusMap[actionId] ?? 'idle',
[statusMap],
);
const setAskStatus = useCallback(
(actionId: string, status: ActionStatus) =>
(actionId: string, status: AskAnswerStatus) =>
setStatusMap((prev) => ({ ...prev, [actionId]: status })),
[setStatusMap],
);
@ -81,9 +64,9 @@ interface ApprovalContextValue {
/** True once every registered tool_call in the action has a decision. */
isReady: (actionId: string) => boolean;
/** Lifecycle status for an action (so cards can disable / show messages). */
getStatus: (actionId: string) => ActionStatus;
getStatus: (actionId: string) => AskAnswerStatus;
/** Set an action's submission status (driven by the cards' submit via `useResumeSubmit`). */
setStatus: (actionId: string, status: ActionStatus) => void;
setStatus: (actionId: string, status: AskAnswerStatus) => void;
/** Restore a free-form question answer after transient phase-slice remounts. */
getAskAnswerDraft: (actionId: string) => string;
/** Retain a free-form question answer for this response message's lifetime. */
@ -145,7 +128,7 @@ export default function ApprovalProvider({ children }: { children: React.ReactNo
const askAnswerDraftsRef = useRef(new Map<string, string>());
const [version, bump] = useState(0);
const rerender = useCallback(() => bump((v) => v + 1), []);
const [statusByAction, setStatusByAction] = useState<Record<string, ActionStatus>>({});
const [statusByAction, setStatusByAction] = useState<Record<string, AskAnswerStatus>>({});
const registerToolCall = useCallback(
(actionId: string, toolCallId: string) => {
@ -238,11 +221,11 @@ export default function ApprovalProvider({ children }: { children: React.ReactNo
}, []);
const getStatus = useCallback(
(actionId: string): ActionStatus => statusByAction[actionId] ?? 'idle',
(actionId: string): AskAnswerStatus => statusByAction[actionId] ?? 'idle',
[statusByAction],
);
const setStatus = useCallback((actionId: string, status: ActionStatus) => {
const setStatus = useCallback((actionId: string, status: AskAnswerStatus) => {
setStatusByAction((prev) => ({ ...prev, [actionId]: status }));
}, []);
@ -319,7 +302,7 @@ export function useResumeSubmit() {
* synchronous action-id guard alongside the rendered submission status. */
const submittingToolActionIdsRef = useRef(new Set<string>());
const submittingAskActionIdsRef = useRef(new Set<string>());
/** Ask status lives in Recoil so it works from the composer (outside the
/** Ask status lives in Jotai so it works from the composer (outside the
* provider); tool-approval status stays on the context. */
const { setAskStatus } = useAskSubmitStatus();
const activeGenerationCreatedAt = useRecoilValue(

View file

@ -78,9 +78,9 @@ export default function MemoryInfo({ memoryArtifacts }: { memoryArtifacts: Memor
{errorMessages.length > 0 && (
<div>
<h4 className="mb-2 text-sm font-semibold text-text-destructive">
<p className="mb-2 text-sm font-semibold text-status-error">
{localize('com_ui_memory_storage_full')}
</h4>
</p>
<div className="space-y-2">
{errorMessages.map((errorMessage) => (
<Alert key={errorMessage} variant="error" icon={false} className="rounded-md p-3">

View file

@ -1,12 +1,13 @@
import { useMemo } from 'react';
import { Brain } from 'lucide-react';
import { Tools } from 'librechat-data-provider';
import type { PartMetadata, TAttachment } from 'librechat-data-provider';
import type { PartMetadata, TAttachment, MemoryArtifact } from 'librechat-data-provider';
import ProgressText from '~/components/Chat/Messages/Content/ProgressText';
import { toolPanelSpacingClassName } from '../disclosure';
import useToolCallState from './useToolCallState';
import { AttachmentGroup } from './Attachment';
import parseJsonField from './parseJsonField';
import MemoryInfo from '../MemoryInfo';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -50,12 +51,17 @@ export default function MemoryCall({
const memoryKey = useMemo(() => parseJsonField(args, 'key'), [args]);
const memoryValue = useMemo(() => parseJsonField(args, 'value'), [args]);
const hasPanel = !!memoryKey || !!memoryValue;
const memoryFailed = useMemo(
() =>
isMemoryFailureOutput(toolName, output) ||
(attachments?.some((attachment) => attachment?.[Tools.memory]?.type === 'error') ?? false),
[attachments, toolName, output],
);
const memoryErrors = useMemo(() => {
const errors: MemoryArtifact[] = [];
for (const attachment of attachments ?? []) {
const artifact = attachment[Tools.memory];
if (artifact?.type === 'error') {
errors.push(artifact);
}
}
return errors;
}, [attachments]);
const memoryFailed = isMemoryFailureOutput(toolName, output) || memoryErrors.length > 0;
const { showCode, toggleCode, expandStyle, expandRef, phase } = useToolCallState({
initialProgress,
@ -106,9 +112,12 @@ export default function MemoryCall({
)}
>
{phase === 'failed' ? (
<pre className="whitespace-pre-wrap break-words font-mono text-xs text-status-error">
{output}
</pre>
<>
<pre className="whitespace-pre-wrap break-words font-mono text-xs text-status-error">
{output}
</pre>
<MemoryInfo memoryArtifacts={memoryErrors} />
</>
) : (
<>
{memoryKey && (

View file

@ -1,11 +1,13 @@
import React from 'react';
import { Tools } from 'librechat-data-provider';
import { render, screen } from '@testing-library/react';
import type { TAttachment } from 'librechat-data-provider';
import MemoryCall from '../MemoryCall';
jest.mock('~/hooks', () => ({
useLocalize:
() =>
(key: string): string => {
(key: string, params?: { tokens?: number }): string => {
const translations: Record<string, string> = {
com_ui_memory_saving: 'Saving memory',
com_ui_memory_saved: 'Saved memory',
@ -15,6 +17,7 @@ jest.mock('~/hooks', () => ({
com_ui_memory: 'Memory',
com_ui_cancelled: 'Cancelled',
com_ui_tool_failed: 'failed',
com_ui_memory_would_exceed: `Cannot save: remove ${params?.tokens} tokens to make space.`,
};
return translations[key] ?? key;
},
@ -167,4 +170,33 @@ describe('MemoryCall', () => {
expect(screen.getByText(/Memory set for key/)).toBeInTheDocument();
expect(screen.queryByText('requested value')).not.toBeInTheDocument();
});
it('shows the actionable overage from a linked memory error artifact', () => {
const attachment: TAttachment = {
type: Tools.memory,
toolCallId: 'call_0',
messageId: 'message-1',
[Tools.memory]: {
type: 'error',
key: 'preferences',
value: JSON.stringify({ errorType: 'would_exceed', tokenCount: 47 }),
},
};
render(
<MemoryCall
toolName="set_memory"
initialProgress={1}
isSubmitting={false}
args={{ key: 'preferences', value: 'requested value' }}
output="Memory storage would exceed limit. Cannot save this memory."
attachments={[attachment]}
hideAttachments
/>,
);
expect(screen.getByRole('alert')).toHaveTextContent(
'Cannot save: remove 47 tokens to make space.',
);
expect(screen.queryByText('requested value')).not.toBeInTheDocument();
});
});

View file

@ -169,7 +169,7 @@ export default function WebSearch({
}, [attachments]);
const allSources = useMemo((): ValidSource[] => {
if (attachments) {
if (attachments != null) {
const turnMap: Record<string, SearchResultData> = {};
for (const att of attachments) {
if (att.type === Tools.web_search && att[Tools.web_search]) {
@ -178,9 +178,7 @@ export default function WebSearch({
turnMap[key] = data;
}
}
if (Object.keys(turnMap).length > 0) {
return collectSources(turnMap);
}
return collectSources(turnMap);
}
if (searchResults?.[ownTurn]) {
return collectSources({ [ownTurn]: searchResults[ownTurn] });
@ -218,12 +216,8 @@ export default function WebSearch({
if (complete && !finalizing) {
return [];
}
const result = searchResults?.[ownTurn];
if (!result) {
return [];
}
return [...(result.organic || []), ...(result.topStories || [])];
}, [searchResults, complete, finalizing, ownTurn]);
return allSources;
}, [allSources, complete, finalizing]);
const showSources = streamingSources.length > 0;
/** Stable phase text: the live region must not re-announce the growing

View file

@ -1,6 +1,7 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { act, renderHook } from '@testing-library/react';
import { Provider as JotaiProvider, createStore } from 'jotai';
import type { MutableSnapshot } from 'recoil';
import ApprovalProvider, { useApprovalContext, useResumeSubmit } from '../ApprovalContext';
import { ChatContext } from '~/Providers/ChatContext';
@ -28,6 +29,7 @@ const chatContextValue = {
const createWrapper = (generationCreatedAt: number | null) =>
function ResumeWrapper({ children }: { children: React.ReactNode }) {
const jotaiStore = React.useRef(createStore()).current;
const initializeState = (snapshot: MutableSnapshot) => {
if (generationCreatedAt != null) {
snapshot.set(
@ -38,9 +40,11 @@ const createWrapper = (generationCreatedAt: number | null) =>
};
return (
<RecoilRoot initializeState={initializeState}>
<ChatContext.Provider value={chatContextValue}>
<ApprovalProvider>{children}</ApprovalProvider>
</ChatContext.Provider>
<JotaiProvider store={jotaiStore}>
<ChatContext.Provider value={chatContextValue}>
<ApprovalProvider>{children}</ApprovalProvider>
</ChatContext.Provider>
</JotaiProvider>
</RecoilRoot>
);
};

View file

@ -1,6 +1,6 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { ContentTypes } from 'librechat-data-provider';
import { ContentTypes, Tools } from 'librechat-data-provider';
import { fireEvent, render, screen, within } from '@testing-library/react';
import type { TAttachment, TMessageContentParts } from 'librechat-data-provider';
import ContentParts from '../ContentParts';
@ -90,6 +90,9 @@ jest.mock('../Parts', () => ({
Reasoning: () => <div data-testid="reasoning" />,
Summary: () => <div data-testid="summary" />,
Text: ({ text }: { text?: string }) => <div data-testid="text">{text}</div>,
MemoryCall: ({ attachments }: { attachments?: TAttachment[] }) => (
<div data-testid="memory-call" data-count={attachments?.length ?? 0} />
),
}));
jest.mock('../MemoryArtifacts', () => ({
@ -99,7 +102,9 @@ jest.mock('../MemoryArtifacts', () => ({
jest.mock('../WebSearch', () => ({
__esModule: true,
default: () => <div data-testid="web-search" />,
default: ({ attachments }: { attachments?: TAttachment[] }) => (
<div data-testid="web-search" data-count={attachments?.length ?? 0} />
),
}));
jest.mock('../RetrievalCall', () => ({
@ -163,6 +168,24 @@ const makeMcpToolCall = (
},
}) as unknown as TMessageContentParts;
const makeOwnedToolCall = (
name: string,
id: string,
stepId?: string,
agentId?: string,
): TMessageContentParts =>
({
type: ContentTypes.TOOL_CALL,
[ContentTypes.TOOL_CALL]: {
id,
name,
args: '{}',
output: 'completed',
...(stepId == null ? {} : { stepId }),
...(agentId == null ? {} : { agentId }),
},
}) as unknown as TMessageContentParts;
const makeMcpToolCallWithoutId = (name: string, hasOutput = true): TMessageContentParts =>
({
type: ContentTypes.TOOL_CALL,
@ -305,6 +328,75 @@ describe('ContentParts integration: MCP image hoist and grouping', () => {
expect(groups[0]).toHaveAttribute('data-count', '1');
});
it('routes producer-shaped search snapshots by agent and host step', () => {
const content = [
makeOwnedToolCall(Tools.web_search, 'call_0', 'step-search-1', 'agent-a'),
makeTextPart('between searches'),
makeOwnedToolCall(Tools.web_search, 'call_0', undefined, 'agent-a'),
];
const attachments = [
{
type: Tools.web_search,
messageId: 'm1',
toolCallId: 'call_0',
agentId: 'agent-a',
stepId: 'step-search-1',
conversationId: 'c1',
[Tools.web_search]: { turn: 0, organic: [{ link: 'https://first.example' }] },
},
{
type: Tools.web_search,
messageId: 'm1',
toolCallId: 'call_0',
agentId: 'agent-a',
stepId: 'step-search-2',
conversationId: 'c1',
[Tools.web_search]: { turn: 0, organic: [{ link: 'https://second.example' }] },
},
] as unknown as TAttachment[];
renderContentParts({ ...baseProps, content, attachments });
expect(screen.getAllByTestId('web-search').map((card) => card.dataset.count)).toEqual([
'1',
'1',
]);
});
it('routes producer-shaped memory artifacts by agent and host step', () => {
const content = [
makeOwnedToolCall('set_memory', 'call_0', 'step-memory-1', 'agent-a'),
makeTextPart('between memory writes'),
makeOwnedToolCall('set_memory', 'call_0', undefined, 'agent-a'),
];
const attachments = [
{
type: Tools.memory,
messageId: 'm1',
toolCallId: 'call_0',
agentId: 'agent-a',
stepId: 'step-memory-1',
conversationId: 'c1',
[Tools.memory]: { key: 'first', type: 'update' },
},
{
type: Tools.memory,
messageId: 'm1',
toolCallId: 'call_0',
agentId: 'agent-a',
stepId: 'step-memory-2',
conversationId: 'c1',
[Tools.memory]: { key: 'second', type: 'update' },
},
] as unknown as TAttachment[];
renderContentParts({ ...baseProps, content, attachments });
expect(screen.getAllByTestId('memory-call').map((card) => card.dataset.count)).toEqual([
'1',
'1',
]);
});
it('keeps a manually expanded completed tool group open when its content index shifts', () => {
const content = [makeMcpToolCall('t1'), makeMcpToolCall('t2')];
const nextContent = [makeTextPart('streamed preface'), ...content];

View file

@ -97,28 +97,6 @@ describe('MemoryInfo', () => {
),
).toBeInTheDocument();
});
test('applies correct styling to error messages', () => {
const memoryArtifacts: MemoryArtifact[] = [
{
type: 'error',
key: 'system',
value: JSON.stringify({ errorType: 'would_exceed', tokenCount: 50 }),
},
];
render(<MemoryInfo memoryArtifacts={memoryArtifacts} />);
const errorMessage = screen.getByText(
'Cannot save - would exceed limit by 50 tokens. Delete existing memories to make space.',
);
const errorContainer = errorMessage.closest('[role="alert"]');
expect(errorContainer).toHaveClass('bg-status-error-subtle');
expect(errorContainer).toHaveClass('text-status-error');
expect(errorContainer).toHaveClass('border-status-error-border');
expect(errorContainer).toHaveClass('p-3');
});
});
describe('Mixed Memory Types', () => {

View file

@ -220,6 +220,15 @@ describe('WebSearch', () => {
expect(hrefs).toContain('https://pi.example.com/2');
expect(hrefs).not.toContain('https://neutrino.example.com/1');
});
it('does not fall back to another execution when filtered attachments are empty', () => {
renderWebSearch({
searchResults,
attachments: [],
});
expect(screen.getByRole('button', { name: 'Searched the web' })).toBeDisabled();
});
});
describe('streaming favicons', () => {
@ -316,6 +325,7 @@ describe('WebSearch', () => {
} as unknown as TAttachment;
renderWebSearch({ attachments: [attachment] });
fireEvent.click(screen.getByRole('button', { name: /Searched the web/ }));
expect(
screen.getByText('How context windows change what assistants can do.'),

View file

@ -149,7 +149,10 @@ function ShoppingStrip({ items, label }: { items: ShoppingResult[]; label: strin
return (
<ul className="flex list-none gap-2.5 overflow-x-auto pb-1" aria-label={label}>
{items.slice(0, MAX_VERTICAL_ITEMS).map((item, i) => (
<li key={item.productId || item.link || i} className="flex w-40 shrink-0">
<li
key={`${item.productId ?? ''}|${safeUrl(item.link) ?? ''}|${i}`}
className="flex w-40 shrink-0"
>
<a
href={safeUrl(item.link)}
target="_blank"

View file

@ -0,0 +1,43 @@
import { createContext, useContext } from 'react';
import { atom } from 'jotai';
import type { ReactNode } from 'react';
/**
* Action ids the user moved into the chat: the popover is hidden and the
* composer is released, so the chat card is the question's only surface.
*/
export const collapsedAskActionsAtom = atom<string[]>([]);
/** Currently highlighted option row (keyboard cursor), or nothing. */
export const askAnswerSelectionAtom = atom<number | null>(null);
/** Checked option rows for a multi-select question. */
export const askAnswerCheckedAtom = atom<number[]>([]);
/** Free-form answers keyed by pending action id. */
export const askAnswerTextAtom = atom<Record<string, string>>({});
/** Ordinary composer text keyed by pending action id while the card owns it. */
export const releasedComposerTextAtom = atom<Record<string, string>>({});
/** Submission lifecycle shared by composer and message-content surfaces. */
export type AskAnswerStatus = 'idle' | 'submitting' | 'submitted' | 'expired' | 'error';
export const askSubmitStatusAtom = atom<Record<string, AskAnswerStatus>>({});
const AskAnswerHostContext = createContext(false);
export function AskAnswerHostProvider({
saveDrafts,
children,
}: {
saveDrafts: boolean;
children?: ReactNode;
}) {
return (
<AskAnswerHostContext.Provider value={saveDrafts}>{children}</AskAnswerHostContext.Provider>
);
}
export function useAskAnswerHost(): boolean {
return useContext(AskAnswerHostContext);
}

View file

@ -1,31 +1,29 @@
import React from 'react';
import { Provider, createStore } from 'jotai';
import { act, renderHook } from '@testing-library/react';
import {
AskAnswerHostProvider,
collapsedAskActionsAtom,
askAnswerSelectionAtom,
askAnswerCheckedAtom,
askAnswerTextAtom,
releasedComposerTextAtom,
} from '~/components/Chat/ask/state';
const mockSubmitAskAnswer = jest.fn();
const mockResetComposer = jest.fn();
const mockGetComposerText = jest.fn(() => 'answer from A');
const mockSetComposerText = jest.fn();
const mockSetCollapsedIds = jest.fn();
const mockSetSelected = jest.fn();
const mockSetChecked = jest.fn();
let mockAnswerDrafts: Record<string, string> = {};
/** Applies updater functions so the tests can assert the resulting drafts
* rather than the setter's call shape. */
const mockSetAnswerDrafts = jest.fn((update: unknown) => {
mockAnswerDrafts =
typeof update === 'function'
? (update as (current: Record<string, string>) => Record<string, string>)(mockAnswerDrafts)
: (update as Record<string, string>);
});
let mockReleasedComposerText: Record<string, string> = {};
const mockSetReleasedComposerText = jest.fn((update: unknown) => {
mockReleasedComposerText =
typeof update === 'function'
? (update as (current: Record<string, string>) => Record<string, string>)(
mockReleasedComposerText,
)
: (update as Record<string, string>);
});
const mockSetDraft = jest.fn();
let mockSaveDrafts = false;
let mockCollapsedIds: string[] = [];
let jotaiStore = createStore();
const JotaiWrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(
Provider,
{ store: jotaiStore },
React.createElement(AskAnswerHostProvider, { saveDrafts: mockSaveDrafts }, children),
);
jest.mock('~/data-provider', () => ({ useGetMessagesByConvoId: jest.fn() }));
jest.mock('~/components/Chat/Messages/Content/ApprovalContext', () => ({
@ -44,31 +42,7 @@ jest.mock('~/utils', () => ({
morphTransition: (update: () => void) => update(),
setDraft: (...args: unknown[]) => mockSetDraft(...args),
}));
jest.mock('recoil', () => ({
atom: (cfg: unknown) => cfg,
useRecoilState: (state: { key?: string }) => {
if (state.key === 'askAnswerModeCollapsedActions') {
return [mockCollapsedIds, mockSetCollapsedIds];
}
if (state.key === 'askAnswerModeSelection') {
return [null, mockSetSelected];
}
if (state.key === 'askAnswerModeChecked') {
return [[], mockSetChecked];
}
if (state.key === 'askAnswerModeText') {
return [mockAnswerDrafts, mockSetAnswerDrafts];
}
if (state.key === 'askAnswerModeReleasedComposerText') {
return [mockReleasedComposerText, mockSetReleasedComposerText];
}
return [[], jest.fn()];
},
useRecoilValue: () => mockSaveDrafts,
}));
jest.mock('~/store', () => ({ __esModule: true, default: { saveDrafts: 'saveDrafts' } }));
import { act, renderHook } from '@testing-library/react';
import { useGetMessagesByConvoId } from '~/data-provider';
import { findLiveAskUserQuestion } from '~/utils/approval';
import useAskAnswerMode from './useAskAnswerMode';
@ -92,30 +66,28 @@ describe('useAskAnswerMode', () => {
beforeEach(() => {
jest.clearAllMocks();
mockSaveDrafts = false;
mockCollapsedIds = [];
mockAnswerDrafts = {};
mockReleasedComposerText = {};
jotaiStore = createStore();
mockGetComposerText.mockReturnValue('answer from A');
});
it('projects the live ask via the findLiveAskUserQuestion select over the conversation cache', () => {
it('is active when a live ask is available', () => {
mockUseGetMessages.mockReturnValue({ data: liveAsk });
const { result } = renderHook(() => useAskAnswerMode('conversation-1'));
const { result } = renderHook(() => useAskAnswerMode('conversation-1'), {
wrapper: JotaiWrapper,
});
expect(mockUseGetMessages).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({ enabled: true, select: findLiveAskUserQuestion }),
);
expect(result.current.liveAsk).toBe(liveAsk);
expect(result.current.active).toBe(true);
expect(result.current.popoverVisible).toBe(true);
});
it('is inactive when the select finds no live ask', () => {
it('is inactive without a live ask', () => {
mockUseGetMessages.mockReturnValue({ data: undefined });
const { result } = renderHook(() => useAskAnswerMode('conversation-1'));
const { result } = renderHook(() => useAskAnswerMode('conversation-1'), {
wrapper: JotaiWrapper,
});
expect(result.current.liveAsk).toBeNull();
expect(result.current.active).toBe(false);
@ -125,7 +97,9 @@ describe('useAskAnswerMode', () => {
it('locks the composer for a batch, and hands it back the moment it collapses', () => {
mockUseGetMessages.mockReturnValue({ data: batchAsk });
const { result } = renderHook(() => useAskAnswerMode('conversation-1'));
const { result } = renderHook(() => useAskAnswerMode('conversation-1'), {
wrapper: JotaiWrapper,
});
expect(result.current.active).toBe(true);
expect(result.current.batchMode).toBe(true);
@ -142,34 +116,35 @@ describe('useAskAnswerMode', () => {
it('does not move the normal composer draft into a collapsed batch answer', () => {
mockUseGetMessages.mockReturnValue({ data: batchAsk });
const { result } = renderHook(() => useAskAnswerMode('conversation-1'));
const { result } = renderHook(() => useAskAnswerMode('conversation-1'), {
wrapper: JotaiWrapper,
});
act(() => result.current.collapse());
expect(mockSetAnswerDrafts).not.toHaveBeenCalled();
expect(jotaiStore.get(askAnswerTextAtom)).toEqual({});
expect(mockResetComposer).not.toHaveBeenCalled();
});
it('does not overwrite normal composer text when expanding a batch', () => {
mockCollapsedIds = ['a1'];
mockAnswerDrafts = { a1: 'stale batch handoff' };
jotaiStore.set(collapsedAskActionsAtom, ['a1']);
jotaiStore.set(askAnswerTextAtom, { a1: 'stale batch handoff' });
mockUseGetMessages.mockReturnValue({ data: batchAsk });
const { result } = renderHook(() => useAskAnswerMode('conversation-1'));
const { result } = renderHook(() => useAskAnswerMode('conversation-1'), {
wrapper: JotaiWrapper,
});
act(() => result.current.expand());
expect(mockSetComposerText).not.toHaveBeenCalled();
});
it('disables the query and forces liveAsk null for a new (unsaved) conversation', () => {
it('is inactive for a new unsaved conversation', () => {
mockUseGetMessages.mockReturnValue({ data: liveAsk });
const { result } = renderHook(() => useAskAnswerMode('new'));
expect(mockUseGetMessages).toHaveBeenCalledWith(
'',
expect.objectContaining({ enabled: false }),
);
const { result } = renderHook(() => useAskAnswerMode('new'), {
wrapper: JotaiWrapper,
});
expect(result.current.liveAsk).toBeNull();
expect(result.current.active).toBe(false);
});
@ -177,25 +152,31 @@ describe('useAskAnswerMode', () => {
it('forces liveAsk null when there is no conversation id', () => {
mockUseGetMessages.mockReturnValue({ data: liveAsk });
const { result } = renderHook(() => useAskAnswerMode(null));
const { result } = renderHook(() => useAskAnswerMode(null), {
wrapper: JotaiWrapper,
});
expect(result.current.liveAsk).toBeNull();
});
it('moves the composer answer into the card and clears it when drafts are disabled', () => {
mockUseGetMessages.mockReturnValue({ data: liveAsk });
const { result } = renderHook(() => useAskAnswerMode('conversation-1'));
const { result } = renderHook(() => useAskAnswerMode('conversation-1'), {
wrapper: JotaiWrapper,
});
act(() => result.current.collapse());
expect(mockAnswerDrafts).toEqual({ a1: 'answer from A' });
expect(jotaiStore.get(askAnswerTextAtom)).toEqual({ a1: 'answer from A' });
expect(mockResetComposer).toHaveBeenCalledTimes(1);
});
it('lets draft handoff restore the conversation composer when drafts are enabled', () => {
mockSaveDrafts = true;
mockUseGetMessages.mockReturnValue({ data: liveAsk });
const { result } = renderHook(() => useAskAnswerMode('conversation-1'));
const { result } = renderHook(() => useAskAnswerMode('conversation-1'), {
wrapper: JotaiWrapper,
});
act(() => result.current.collapse());
@ -203,10 +184,12 @@ describe('useAskAnswerMode', () => {
});
it('restores the card answer into the composer when drafts are disabled', () => {
mockCollapsedIds = ['a1'];
mockAnswerDrafts = { a1: 'answer edited in the card' };
jotaiStore.set(collapsedAskActionsAtom, ['a1']);
jotaiStore.set(askAnswerTextAtom, { a1: 'answer edited in the card' });
mockUseGetMessages.mockReturnValue({ data: liveAsk });
const { result } = renderHook(() => useAskAnswerMode('conversation-1'));
const { result } = renderHook(() => useAskAnswerMode('conversation-1'), {
wrapper: JotaiWrapper,
});
act(() => result.current.expand());
@ -216,11 +199,13 @@ describe('useAskAnswerMode', () => {
it('keeps the ask-specific draft current while editing the card', () => {
mockSaveDrafts = true;
mockUseGetMessages.mockReturnValue({ data: liveAsk });
const { result } = renderHook(() => useAskAnswerMode('conversation-1'));
const { result } = renderHook(() => useAskAnswerMode('conversation-1'), {
wrapper: JotaiWrapper,
});
act(() => result.current.setAnswerText('answer edited in the card'));
expect(mockAnswerDrafts).toEqual({ a1: 'answer edited in the card' });
expect(jotaiStore.get(askAnswerTextAtom)).toEqual({ a1: 'answer edited in the card' });
expect(mockSetDraft).toHaveBeenCalledWith({
id: 'draft-a1',
value: 'answer edited in the card',
@ -228,19 +213,24 @@ describe('useAskAnswerMode', () => {
});
it("preserves another paused question's answer when a second one is edited", () => {
mockAnswerDrafts = { a1: 'answer from A' };
jotaiStore.set(askAnswerTextAtom, { a1: 'answer from A' });
const otherAsk = {
actionId: 'a2',
question: { question: 'Pick one', options: [], multiSelect: false },
} as unknown as typeof liveAsk;
mockUseGetMessages.mockReturnValue({ data: otherAsk });
const { result } = renderHook(() => useAskAnswerMode('conversation-2'));
const { result } = renderHook(() => useAskAnswerMode('conversation-2'), {
wrapper: JotaiWrapper,
});
act(() => result.current.setAnswerText('answer from B'));
/** A single shared slot dropped A's unsent answer the moment B claimed
* it, and the action-scoped reader then showed A an empty box. */
expect(mockAnswerDrafts).toEqual({ a1: 'answer from A', a2: 'answer from B' });
expect(jotaiStore.get(askAnswerTextAtom)).toEqual({
a1: 'answer from A',
a2: 'answer from B',
});
});
it('hands the released composer text back when the answer is submitted', () => {
@ -248,14 +238,16 @@ describe('useAskAnswerMode', () => {
* message was typed in the released composer, then the question was moved
* back. Submitting from there never goes through `collapse`, so the
* message has to be restored here or it dies with the question. */
mockReleasedComposerText = { a1: 'an ordinary unsent message' };
jotaiStore.set(releasedComposerTextAtom, { a1: 'an ordinary unsent message' });
mockUseGetMessages.mockReturnValue({ data: liveAsk });
mockSubmitAskAnswer.mockImplementation(
(_actionId: string, _answer: string, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.();
},
);
const { result } = renderHook(() => useAskAnswerMode('conversation-1'));
const { result } = renderHook(() => useAskAnswerMode('conversation-1'), {
wrapper: JotaiWrapper,
});
act(() => {
result.current.submitText('answer from A');
@ -263,14 +255,14 @@ describe('useAskAnswerMode', () => {
expect(mockSetComposerText).toHaveBeenLastCalledWith('text', 'an ordinary unsent message');
expect(mockResetComposer).not.toHaveBeenCalled();
expect(mockReleasedComposerText).toEqual({});
expect(jotaiStore.get(releasedComposerTextAtom)).toEqual({});
});
it('hands the released text back for an option answer too', () => {
/** Only the free-text path sets `consumedComposerText`, so gating the
* restore on it discarded the stash whenever the user answered by
* clicking an option (or skipping) instead of typing. */
mockReleasedComposerText = { a1: 'an ordinary unsent message' };
jotaiStore.set(releasedComposerTextAtom, { a1: 'an ordinary unsent message' });
const askWithOptions = {
actionId: 'a1',
question: {
@ -288,7 +280,9 @@ describe('useAskAnswerMode', () => {
options?.onSuccess?.();
},
);
const { result } = renderHook(() => useAskAnswerMode('conversation-1'));
const { result } = renderHook(() => useAskAnswerMode('conversation-1'), {
wrapper: JotaiWrapper,
});
act(() => {
result.current.submitOption(0);
@ -296,7 +290,7 @@ describe('useAskAnswerMode', () => {
expect(mockSubmitAskAnswer).toHaveBeenCalledWith('a1', 'blue', expect.anything());
expect(mockSetComposerText).toHaveBeenLastCalledWith('text', 'an ordinary unsent message');
expect(mockReleasedComposerText).toEqual({});
expect(jotaiStore.get(releasedComposerTextAtom)).toEqual({});
});
it('does not let a delayed answer success clear the composer or selection after navigation', () => {
@ -309,7 +303,7 @@ describe('useAskAnswerMode', () => {
);
const { result, rerender } = renderHook(
({ conversationId }) => useAskAnswerMode(conversationId),
{ initialProps: { conversationId: 'conversation-A' } },
{ initialProps: { conversationId: 'conversation-A' }, wrapper: JotaiWrapper },
);
expect(result.current.submitText('answer from A')).toBe(true);
@ -326,14 +320,15 @@ describe('useAskAnswerMode', () => {
mockUseGetMessages.mockReturnValue({ data: nextAsk });
mockGetComposerText.mockReturnValue('draft typed in B');
rerender({ conversationId: 'conversation-B' });
mockSetSelected.mockClear();
mockSetChecked.mockClear();
finishAnswer?.();
act(() => {
jotaiStore.set(askAnswerSelectionAtom, 1);
jotaiStore.set(askAnswerCheckedAtom, [0]);
});
act(() => finishAnswer?.());
expect(mockResetComposer).not.toHaveBeenCalled();
expect(mockSetSelected).not.toHaveBeenCalled();
expect(mockSetChecked).not.toHaveBeenCalled();
expect(jotaiStore.get(askAnswerSelectionAtom)).toBe(1);
expect(jotaiStore.get(askAnswerCheckedAtom)).toEqual([0]);
});
it('keeps newer composer text when the answer settles on the same question', () => {
@ -344,18 +339,21 @@ describe('useAskAnswerMode', () => {
finishAnswer = options?.onSuccess;
},
);
const { result } = renderHook(() => useAskAnswerMode('conversation-A'));
const { result } = renderHook(() => useAskAnswerMode('conversation-A'), {
wrapper: JotaiWrapper,
});
expect(result.current.submitText('answer from A')).toBe(true);
mockGetComposerText.mockReturnValue('new text typed while resuming');
mockSetSelected.mockClear();
mockSetChecked.mockClear();
finishAnswer?.();
act(() => {
jotaiStore.set(askAnswerSelectionAtom, 1);
jotaiStore.set(askAnswerCheckedAtom, [0]);
});
act(() => finishAnswer?.());
expect(mockResetComposer).not.toHaveBeenCalled();
expect(mockSetSelected).toHaveBeenCalledWith(null);
expect(mockSetChecked).toHaveBeenCalledWith([]);
expect(jotaiStore.get(askAnswerSelectionAtom)).toBeNull();
expect(jotaiStore.get(askAnswerCheckedAtom)).toEqual([]);
});
it('clears the consumed answer when the same question and composer value still own it', () => {
@ -366,14 +364,16 @@ describe('useAskAnswerMode', () => {
finishAnswer = options?.onSuccess;
},
);
const { result } = renderHook(() => useAskAnswerMode('conversation-A'));
const { result } = renderHook(() => useAskAnswerMode('conversation-A'), {
wrapper: JotaiWrapper,
});
expect(result.current.submitText('answer from A')).toBe(true);
finishAnswer?.();
act(() => finishAnswer?.());
expect(mockResetComposer).toHaveBeenCalledTimes(1);
expect(mockSetSelected).toHaveBeenCalledWith(null);
expect(mockSetChecked).toHaveBeenCalledWith([]);
expect(jotaiStore.get(askAnswerSelectionAtom)).toBeNull();
expect(jotaiStore.get(askAnswerCheckedAtom)).toEqual([]);
});
it('ignores a delayed answer success after its answer-mode owner unmounts', () => {
@ -384,16 +384,18 @@ describe('useAskAnswerMode', () => {
finishAnswer = options?.onSuccess;
},
);
const { result, unmount } = renderHook(() => useAskAnswerMode('conversation-A'));
expect(result.current.submitText('answer from A')).toBe(true);
const { unmount } = renderHook(() => useAskAnswerMode('conversation-A'), {
wrapper: JotaiWrapper,
});
act(() => {
jotaiStore.set(askAnswerSelectionAtom, 1);
jotaiStore.set(askAnswerCheckedAtom, [0]);
});
unmount();
mockSetSelected.mockClear();
mockSetChecked.mockClear();
finishAnswer?.();
act(() => finishAnswer?.());
expect(mockResetComposer).not.toHaveBeenCalled();
expect(mockSetSelected).not.toHaveBeenCalled();
expect(mockSetChecked).not.toHaveBeenCalled();
expect(jotaiStore.get(askAnswerSelectionAtom)).toBe(1);
expect(jotaiStore.get(askAnswerCheckedAtom)).toEqual([0]);
});
});

View file

@ -1,5 +1,13 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { atom, useRecoilState, useRecoilValue } from 'recoil';
import { useAtom } from 'jotai';
import {
collapsedAskActionsAtom,
askAnswerSelectionAtom,
askAnswerCheckedAtom,
askAnswerTextAtom,
releasedComposerTextAtom,
useAskAnswerHost,
} from '~/components/Chat/ask/state';
import {
useAskSubmitStatus,
useResumeSubmit,
@ -12,57 +20,6 @@ import {
import { getAskAnswerDraftId, morphTransition, setDraft } from '~/utils';
import { useGetMessagesByConvoId } from '~/data-provider';
import { useOptionalChatFormContext } from '~/Providers';
import store from '~/store';
/**
* Action ids the user moved into the chat: the popover is hidden AND the
* composer is released (answer mode off), so the chat card is the question's
* only surface until the card's chevron moves it back. One state for one
* user-visible concept. A hidden popover whose composer stayed armed was
* indistinguishable from one whose composer did not.
*/
const collapsedAskActionsAtom = atom<string[]>({
key: 'askAnswerModeCollapsedActions',
default: [],
});
/** Currently highlighted option row (keyboard cursor), or nothing. */
const askAnswerSelectionAtom = atom<number | null>({
key: 'askAnswerModeSelection',
default: null,
});
/** Checked option rows for a multi-select question — shared across the
* popover, the composer, and the chat card so every surface shows (and
* submits) the same set. */
const askAnswerCheckedAtom = atom<number[]>({
key: 'askAnswerModeChecked',
default: [],
});
/**
* Free-form answers handed between the composer and the in-message card,
* keyed by pending action id. A single `{ actionId, text }` slot lost an
* unsent answer as soon as a second paused conversation's question claimed
* it: the reader is action-scoped, so returning to the first card showed an
* empty box with no route back to the text. Entries are dropped on submit.
*/
const askAnswerTextAtom = atom<Record<string, string>>({
key: 'askAnswerModeText',
default: {},
});
/**
* Ordinary composer text typed AFTER a question moved to the chat card, keyed
* by pending action id. Expanding hands the composer back to the answer, which
* would otherwise overwrite an unsent normal message with no route back to it.
* Only needed while Save drafts is off; with saving on the conversation draft
* already holds that text.
*/
const releasedComposerTextAtom = atom<Record<string, string>>({
key: 'askAnswerModeReleasedComposerText',
default: {},
});
/**
* First-class "answer mode" for a live `ask_user_question` pause. Clicking an
@ -94,18 +51,16 @@ export default function useAskAnswerMode(conversationId?: string | null) {
select: findLiveAskUserQuestion,
});
const liveAsk = enabled ? (liveAskData ?? null) : null;
const [collapsedIds, setCollapsedIds] = useRecoilState(collapsedAskActionsAtom);
const [selected, setSelected] = useRecoilState(askAnswerSelectionAtom);
const [checked, setChecked] = useRecoilState(askAnswerCheckedAtom);
const [answerDrafts, setAnswerDrafts] = useRecoilState(askAnswerTextAtom);
const [releasedComposerText, setReleasedComposerText] = useRecoilState(releasedComposerTextAtom);
const saveDrafts = useRecoilValue<boolean>(store.saveDrafts);
const [collapsedIds, setCollapsedIds] = useAtom(collapsedAskActionsAtom);
const [selected, setSelected] = useAtom(askAnswerSelectionAtom);
const [checked, setChecked] = useAtom(askAnswerCheckedAtom);
const [answerDrafts, setAnswerDrafts] = useAtom(askAnswerTextAtom);
const [releasedComposerText, setReleasedComposerText] = useAtom(releasedComposerTextAtom);
const saveDrafts = useAskAnswerHost();
const { submitAskAnswer } = useResumeSubmit();
/** Recoil-backed so the lock/status works from the composer, which renders
/** Jotai-backed so the lock/status works from the composer, which renders
* outside `ApprovalProvider` (where the context status would be inert). */
const { getAskStatus } = useAskSubmitStatus();
/** Absent outside ChatView (Share/search render the answer card without the
* composer form) — resets are simply skipped there. */
const formContext = useOptionalChatFormContext();
/** Resume callbacks may settle after this ChatForm has navigated to another
* conversation (the form instance is intentionally reused across routes).

View file

@ -182,6 +182,29 @@ describe('useAttachmentHandler upsert-by-file_id', () => {
expect(ctx.list).toHaveLength(2);
});
it('keeps same-agent executions separate when provider id and turn repeat', () => {
const ctx = setup();
const snapshot = (stepId: string, link: string) =>
({
messageId,
toolCallId: 'call_0',
agentId: 'agent-a',
stepId,
type: Tools.web_search,
[Tools.web_search]: { turn: 0, organic: [{ link }] },
}) as unknown as TAttachment;
ctx.handle(snapshot('step-1', 'https://first.example'));
ctx.handle(snapshot('step-2', 'https://second.example'));
ctx.handle(snapshot('step-1', 'https://first-updated.example'));
expect(ctx.list).toHaveLength(2);
expect(ctx.list.map((attachment) => attachment[Tools.web_search]?.organic?.[0]?.link)).toEqual([
'https://first-updated.example',
'https://second.example',
]);
});
it('keeps sibling tool calls separate when they share a file_id (distinct toolCallIds)', () => {
/* Two background code calls regenerated the same filename — same
* claimed file_id, different toolCallId. Each card anchors its own

View file

@ -10,12 +10,11 @@ import type { QueryClient } from '@tanstack/react-query';
import { handleMemoryArtifact } from '~/utils/memory';
import store from '~/store';
/** Identity of a re-emitted web-search snapshot: its tool call, the turn
* inside that call, and the agent that ran it. Scoped to `web_search` because
* that is the flow whose server helper rebuilds and rewrites the whole
* payload on each highlight. The agent qualifies the key for the same reason
* the rendering path filters by it: handoff agents repeat provider tool-call
* ids (`call_0`), so without it one agent's results would replace another's. */
/** Identity of a re-emitted web-search snapshot: its tool call, turn, agent,
* and host step. Scoped to `web_search` because that is the flow whose server
* helper rebuilds and rewrites the whole payload on each highlight. The
* provider reuses ids such as `call_0` across turns and the host can reuse a
* turn number within one message, so the SDK step is part of the identity. */
const searchSnapshotKeyOf = (attachment: TAttachment): string | undefined => {
if (attachment.type !== Tools.web_search) {
return undefined;
@ -26,7 +25,8 @@ const searchSnapshotKeyOf = (attachment: TAttachment): string | undefined => {
}
const agentId = (attachment as { agentId?: string }).agentId ?? '';
const turn = attachment[Tools.web_search]?.turn;
return `${Tools.web_search}:${agentId}:${toolCallId}:${turn ?? 0}`;
const stepId = attachment.stepId ?? '';
return `${Tools.web_search}:${agentId}:${toolCallId}:${turn ?? 0}:${stepId}`;
};
export default function useAttachmentHandler(queryClient?: QueryClient) {

View file

@ -2358,7 +2358,6 @@
"com_ui_subagent_trigger_truncated": "Trigger summary shortened for display.",
"com_ui_subagent_turn": "Turn",
"com_ui_subagent_running": "Running agent",
"com_ui_subagent_scroll_to_bottom": "Scroll to latest",
"com_ui_subagent_ticker_error": "Error",
"com_ui_subagent_ticker_reasoning": "Reasoning",
"com_ui_subagent_ticker_tool_done": "done",

View file

@ -12,7 +12,11 @@ import type {
} from '@librechat/agents';
import type { PtcToolCallEvent } from 'librechat-data-provider';
import type { CodeExecutionContext } from './execution';
import { createToolExecuteHandler, ToolExecuteOptions } from './handlers';
import {
createOwnedToolEndHandler,
createToolExecuteHandler,
ToolExecuteOptions,
} from './handlers';
import { markSandboxReady } from './prewarm';
import { ContentFilterError } from '../middleware/contentFilter';
import { WorkspaceToolHttpError } from '../code/workspace';
@ -124,6 +128,28 @@ function protectedToolOutputRequest() {
} as never;
}
describe('createOwnedToolEndHandler', () => {
it('forwards the graph-owned step identity to the tool callback', async () => {
const callback = jest.fn(async () => undefined);
const handler = createOwnedToolEndHandler(callback as never, logger);
const graph = {
toolCallStepIds: new Map([['call_1', 'step-1']]),
} as never;
await handler.handle(
'on_tool_end',
{ output: { tool_call_id: 'call_1', content: 'ok' } } as never,
{ agent_id: 'agent-a' },
graph,
);
expect(callback).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ agent_id: 'agent-a', stepId: 'step-1' }),
);
});
});
describe('createToolExecuteHandler', () => {
describe('code execution session context passthrough', () => {
it('passes session_id and _injected_files from codeSessionContext to toolCallConfig', async () => {

View file

@ -1,6 +1,6 @@
import yaml from 'js-yaml';
import { Types } from 'mongoose';
import { GraphEvents, Constants } from '@librechat/agents';
import { GraphEvents, Constants, ToolEndHandler } from '@librechat/agents';
import { logger, normalizeSkillFrontmatterKeys } from '@librechat/data-schemas';
import { hasActivePiiFields, hasActivePiiPatterns } from 'librechat-data-provider';
import type {
@ -14,6 +14,8 @@ import type {
ToolExecuteBatchRequest,
SubagentTaskConfig,
CallerCapabilityProjectionSnapshot,
StreamEventData,
ToolEndCallback as SdkToolEndCallback,
} from '@librechat/agents';
import type { StructuredToolInterface } from '@librechat/agents/langchain/tools';
import type { CodeEnvRef, PtcToolCallEvent } from 'librechat-data-provider';
@ -185,6 +187,35 @@ export type ToolEndCallback = (
metadata: ToolEndCallbackMetadata,
) => Promise<void>;
/**
* Preserve the SDK's event-handler contract while attaching the graph-owned
* step identity to legacy artifact callbacks. `toolCallStepIds` is populated
* by ToolNode for the actual provider tool call; this wrapper never invents a
* fallback identity.
*/
export function createOwnedToolEndHandler(
callback: SdkToolEndCallback,
loggerArg: typeof logger = logger,
): EventHandler {
const toolEndHandler = new ToolEndHandler(callback, loggerArg);
return {
handle: async (event, data: StreamEventData, metadata, graph) => {
const output = data?.output;
const toolCallId =
typeof output === 'object' && output != null
? (output as { tool_call_id?: unknown }).tool_call_id
: undefined;
const stepId =
typeof toolCallId === 'string' ? graph?.toolCallStepIds?.get(toolCallId) : undefined;
const ownedMetadata =
typeof stepId === 'string' && stepId.length > 0
? { ...(metadata ?? {}), stepId }
: metadata;
return toolEndHandler.handle(event, data, ownedMetadata, graph);
},
};
}
export interface ToolExecuteOptions {
/** Loads tools by name, using agentId to look up agent-specific context */
loadTools: (
@ -6091,6 +6122,7 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
{
...(metadata ?? {}),
executingAgentId: agentId,
stepId: tc.stepId,
} as ToolEndCallbackMetadata,
);
} catch (callbackError) {
@ -6413,6 +6445,7 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
| undefined,
...metadata,
executingAgentId: agentId,
stepId: tc.stepId,
codeExecutionContext,
},
);
@ -6697,6 +6730,7 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
| undefined,
...metadata,
executingAgentId: agentId,
stepId: tc.stepId,
codeExecutionContext,
},
);

View file

@ -50,6 +50,52 @@ describe('theme registry', () => {
expect(dark.appearance).toEqual(defaultAppearance);
});
it('derives omitted code surfaces from the mode-specific legacy canvas', () => {
const theme = fromLegacyTheme(
{
'rgb-surface-primary-alt': '12 34 56',
'rgb-presentation': '210 211 212',
},
'legacy-code-reference',
);
expect(resolveTheme(theme, 'light').colors['rgb-surface-code']).toBe('12 34 56');
expect(resolveTheme(theme, 'dark').colors['rgb-surface-code']).toBe('210 211 212');
});
it('preserves explicit code surfaces instead of deriving them', () => {
const theme: ThemeDefinition = {
version: 1,
name: 'explicit-code-reference',
modes: {
light: {
colors: {
'rgb-surface-primary-alt': '12 34 56',
'rgb-surface-code': '65 43 21',
},
},
dark: {
colors: {
'rgb-presentation': '210 211 212',
'rgb-surface-code': '98 76 54',
},
},
},
};
expect(resolveTheme(theme, 'light').colors['rgb-surface-code']).toBe('65 43 21');
expect(resolveTheme(theme, 'dark').colors['rgb-surface-code']).toBe('98 76 54');
});
it('keeps the bundled code surfaces unchanged for the default appearances', () => {
expect(resolveTheme(libreChatTheme, 'light').colors['rgb-surface-code']).toBe(
defaultTheme['rgb-surface-code'],
);
expect(resolveTheme(libreChatTheme, 'dark').colors['rgb-surface-code']).toBe(
darkTheme['rgb-surface-code'],
);
});
/** Themes predate the shimmer stops, so an omission means "not written yet",
* not "wants LibreChat's sweep". Filling it from the bundled base would light
* a white-text theme's in-flight labels in the stock near-black. */

View file

@ -349,6 +349,20 @@ export function resolveTheme(theme: ThemeDefinition, mode: ThemeMode): ResolvedT
customColors?.['rgb-surface-hover'] !== undefined
? { 'rgb-surface-composer-hover': customColors['rgb-surface-hover'] }
: {};
/**
* Code blocks are tied to the same mode-specific surfaces by the legacy CSS:
* `surface-primary-alt` in light and `presentation` in dark. Keep that
* relationship for themes created before `surface-code` was registered,
* rather than pinning their syntax colours to the bundled code surface.
*/
const codeSurfaceSource =
mode === 'dark'
? customColors?.['rgb-presentation']
: customColors?.['rgb-surface-primary-alt'];
const codeSurfaceFallback =
customColors?.['rgb-surface-code'] === undefined && codeSurfaceSource !== undefined
? { 'rgb-surface-code': codeSurfaceSource }
: {};
/**
* Themes written before the shimmer stops existed cannot name them, and
* filling the omission from the bundled base would pin their in-flight labels
@ -385,6 +399,7 @@ export function resolveTheme(theme: ThemeDefinition, mode: ThemeMode): ResolvedT
colors: {
...baseColors,
...customColors,
...codeSurfaceFallback,
...composerHoverFallback,
...shimmerBaseFallback,
...textMutedFallback,

View file

@ -71,10 +71,9 @@ const textTokens: Array<keyof IThemeRGB> = [
'rgb-text-muted',
];
/** `--surface-code` resolves to `surface-primary-alt` in light mode and
* `presentation` in dark, so asserting against both covers either mode without
* the table needing to know which one it is holding. */
const codeSurfaces: Array<keyof IThemeRGB> = ['rgb-surface-primary-alt', 'rgb-presentation'];
/** Code blocks share the mode's own high-contrast canvas rather than a bundled
* fallback that would reintroduce a grey surface. */
const codeSurfaces: Array<keyof IThemeRGB> = ['rgb-surface-code'];
const syntaxTokens: Array<keyof IThemeRGB> = [
'rgb-syntax-text',
@ -168,6 +167,17 @@ describe.each([
expect(Object.keys(theme).sort()).toEqual([...themeColorTokens].sort());
});
it('uses the mode canvas for code blocks instead of the stock grey', () => {
const expected = theme === highContrastDarkTheme ? '0 0 0' : '255 255 255';
const source =
theme === highContrastDarkTheme
? theme['rgb-presentation']
: theme['rgb-surface-primary-alt'];
expect(theme['rgb-surface-code']).toBe(expected);
expect(theme['rgb-surface-code']).toBe(source);
});
it('keeps neutral text at WCAG AAA on every canvas and hover fill', () => {
expect(below(theme, WCAG_AAA_NORMAL, textTokens, surfaces)).toEqual([]);
});

View file

@ -89,6 +89,7 @@ export const highContrastLightTheme: IThemeRGB = {
'rgb-surface-destructive': '161 0 0', // #a10000
'rgb-surface-destructive-hover': '122 0 0', // #7a0000
'rgb-surface-chat': '255 255 255', // #ffffff
'rgb-surface-code': '255 255 255', // #ffffff, matching surface-primary-alt
'rgb-surface-inverted': '0 0 0', // #000000
'rgb-surface-inverted-hover': '51 51 51', // #333333
'rgb-text-inverted': '255 255 255', // #ffffff
@ -220,6 +221,7 @@ export const highContrastDarkTheme: IThemeRGB = {
'rgb-surface-destructive': '255 143 143', // #ff8f8f
'rgb-surface-destructive-hover': '255 179 179', // #ffb3b3
'rgb-surface-chat': '0 0 0', // #000000
'rgb-surface-code': '0 0 0', // #000000, matching presentation
'rgb-surface-inverted': '255 255 255', // #ffffff
'rgb-surface-inverted-hover': '212 212 212', // #d4d4d4
'rgb-text-inverted': '0 0 0', // #000000

View file

@ -4,8 +4,8 @@ import applyTheme, {
clearAppliedTheme,
themeOwnedProperties,
} from './applyTheme';
import { highContrastTheme, resolveTheme } from '../registry';
import { defaultTheme } from '../themes/default';
import { resolveTheme } from '../registry';
const semanticProperties = [
'--link',
@ -137,6 +137,14 @@ describe('applyTheme', () => {
expect(root.style.getPropertyValue('--theme-motion-fast')).toBe('80ms');
});
it('applies the resolved high-contrast code surface instead of the stock grey', () => {
const root = document.documentElement;
applyResolvedTheme(resolveTheme(highContrastTheme, 'dark'), root);
expect(root.style.getPropertyValue('--surface-code')).toBe('0 0 0');
});
/** The sweep under an in-flight label is painted in CSS, so it is only
* themeable if its stops are theme-owned properties. A dark theme is the
* case that matters: `style.css` declares a `.dark` base outright, which a