mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-23 10:36:09 +00:00
Resolve follow-up review findings
This commit is contained in:
parent
ea6e72fc7b
commit
5f9315e944
32 changed files with 687 additions and 334 deletions
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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: () => {} },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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.'),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
43
client/src/components/Chat/ask/state.tsx
Normal file
43
client/src/components/Chat/ask/state.tsx
Normal 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);
|
||||
}
|
||||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue