From c7e8b45419eae773df5bf3c6bf6f32218aff79a1 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 24 Aug 2026 02:24:30 -0400 Subject: [PATCH 01/20] =?UTF-8?q?=F0=9F=AA=B6=20refactor:=20Polish=20Event?= =?UTF-8?q?=20Subagent=20Activity=20(#15152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: polish event subagent activity * chore: satisfy static checks * fix: close subagent activity review gaps * test: satisfy activity selection types * fix: preserve subagent group layout scope * fix: close subagent activity polish gaps * fix: narrow edited activity anchor id * feat: present subagent turns as one thread * fix: keep subagent timeline pinned * fix: render sparse assistant content * fix: retain sparse initial activity cursor * fix: bound continuous subagent history * fix: type timeline prefix * chore: sort timeline imports --- .../Chat/Messages/Content/ContentParts.tsx | 11 +- .../Chat/Messages/Content/ParallelContent.tsx | 14 +- .../Content/__tests__/ContentParts.test.tsx | 35 ++- .../Content/__tests__/ParallelContent.test.ts | 26 ++- .../components/Chat/Messages/MultiMessage.tsx | 29 ++- .../Messages/__tests__/MultiMessage.spec.tsx | 125 +++++++++- .../Chat/Messages/ui/MessageRow.tsx | 19 +- .../EventSubagentActivityGroup.test.tsx | 121 +++++++++- .../Subagents/EventSubagentActivityGroup.tsx | 150 +++++++++--- .../Chat/Subagents/SubagentActivity.test.tsx | 56 ++++- .../Chat/Subagents/SubagentActivity.tsx | 171 ++++++++------ .../Subagents/SubagentThreadPanel.test.tsx | 149 +++++++++++- .../Chat/Subagents/SubagentThreadPanel.tsx | 221 ++++++++++++++---- .../Chat/Subagents/eventSelection.ts | 7 +- client/src/locales/en/translation.json | 16 +- client/src/store/subagents.ts | 2 + .../utils/__tests__/activityLabels.spec.ts | 22 ++ client/src/utils/activityLabels.ts | 26 +++ 18 files changed, 1002 insertions(+), 198 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index 6c4a45b5332..2324486d00e 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -10,7 +10,7 @@ import type { ReactNode, ReactElement } from 'react'; import type { ToolCallGroupExpansionState } from './ToolCallGroup'; import { mapAttachments, filterAttachmentsForPart, groupSequentialToolCalls } from '~/utils'; import WorkspaceChanges, { partitionWorkspaceChanges } from './Parts/WorkspaceChanges'; -import { groupActivityPhases, lastVisibleContentIdx } from '~/utils/activityLabels'; +import { groupActivityPhases, lastCursorContentIdx } from '~/utils/activityLabels'; import { ParallelContentRenderer, type PartWithIndex } from './ParallelContent'; import MemoryArtifacts, { hasMemoryArtifacts } from './MemoryArtifacts'; import { MessageContext, SearchContext } from '~/Providers'; @@ -497,7 +497,7 @@ const ContentPartsBody = memo(function ContentPartsBody({ } if (phaseSegments != null) { - const relativeGlobalLastContentIdx = lastVisibleContentIdx(content ?? []); + const relativeGlobalLastContentIdx = lastCursorContentIdx(content ?? []); const globalLastContentIdx = relativeGlobalLastContentIdx < 0 ? -1 : absoluteIndexAt(relativeGlobalLastContentIdx); const renderSegment = ( @@ -586,10 +586,9 @@ const ContentPartsBody = memo(function ContentPartsBody({ * empty TEXT after real parts keeps its flush in-flow cursor. */ const solitaryEmptyText = safeContent.length === 1 && isEmptyTextPart(safeContent[0]); const showEmptyCursor = (safeContent.length === 0 || solitaryEmptyText) && effectiveIsSubmitting; - /** Skips trailing BLANK label reservations — they render nothing, and - * counting one as last would strip the streaming cursor from the last - * VISIBLE part until the next delta. */ - const relativeLastContentIdx = lastVisibleContentIdx(safeContent); + /** Skips trailing blank label reservations and empty provider placeholders, + * keeping the cursor attached to the last visible output. */ + const relativeLastContentIdx = lastCursorContentIdx(safeContent); const lastContentIdx = relativeLastContentIdx < 0 ? -1 : absoluteIndexAt(relativeLastContentIdx); // Parallel content: use dedicated renderer with columns (TMessageContentParts includes ContentMetadata) diff --git a/client/src/components/Chat/Messages/Content/ParallelContent.tsx b/client/src/components/Chat/Messages/Content/ParallelContent.tsx index 6b3dbbbbf57..6348e258704 100644 --- a/client/src/components/Chat/Messages/Content/ParallelContent.tsx +++ b/client/src/components/Chat/Messages/Content/ParallelContent.tsx @@ -4,7 +4,7 @@ import type { TMessageContentParts, SearchResultData, TAttachment } from 'librec import { getActivityLabelPart, getActivityLabelText, - lastVisibleContentIdx, + lastCursorContentIdx, } from '~/utils/activityLabels'; import MemoryArtifacts from './MemoryArtifacts'; import Sources from '~/components/Web/Sources'; @@ -179,6 +179,7 @@ export const ParallelColumns = memo(function ParallelColumns({ part?.type !== ContentTypes.ACTIVITY_LABEL || getActivityLabelText(getActivityLabelPart(part)).length > 0, ); + const lastColumnCursorIdx = lastParallelColumnCursorIdx(columnParts); // Show loading cursor if column has no content parts yet (empty array from placeholder) const showLoadingCursor = isSubmitting && columnParts.length === 0; @@ -200,7 +201,7 @@ export const ParallelColumns = memo(function ParallelColumns({ ) : ( columnParts.map(({ part, idx }) => { - const isLastInColumn = idx === columnParts[columnParts.length - 1]?.idx; + const isLastInColumn = idx === lastColumnCursorIdx; const isLastContent = idx === lastContentIdx; return renderPart(part, idx, isLastInColumn && isLastContent); }) @@ -212,6 +213,13 @@ export const ParallelColumns = memo(function ParallelColumns({ ); }); +export function lastParallelColumnCursorIdx( + parts: ReadonlyArray<{ part: TMessageContentParts; idx: number }>, +): number { + const relativeIdx = lastCursorContentIdx(parts.map(({ part }) => part)); + return relativeIdx < 0 ? -1 : (parts[relativeIdx]?.idx ?? -1); +} + type ParallelContentRendererProps = { content?: Array; messageId: string; @@ -261,7 +269,7 @@ export const ParallelContentRenderer = memo(function ParallelContentRenderer({ /** Same walk-back as `ContentParts`: a trailing BLANK label reservation is * filtered out of every lane, so counting it as last would leave NO * rendered part with the last-part cursor until the label fills. */ - const relativeLastContentIdx = lastVisibleContentIdx(content); + const relativeLastContentIdx = lastCursorContentIdx(content); const lastContentIdx = relativeLastContentIdx < 0 ? -1 diff --git a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx index f4e1fbc3bc1..33191b8ff99 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx @@ -115,8 +115,20 @@ jest.mock('../Container', () => ({ jest.mock('../Part', () => ({ __esModule: true, - default: ({ part, idx }: { part: TMessageContentParts; idx: number }) => ( -
+ default: ({ + part, + idx, + showCursor, + }: { + part: TMessageContentParts; + idx: number; + showCursor?: boolean; + }) => ( +
), })); @@ -453,6 +465,25 @@ describe('ContentParts — post-steer author re-attribution', () => { }); describe('ContentParts — activity phase state', () => { + it('keeps a streaming cursor on visible text when a provider appends an empty placeholder', () => { + render( + , + ); + + const textParts = screen.getAllByTestId(`real-part-${ContentTypes.TEXT}`); + expect(textParts[0]).toHaveAttribute('data-show-cursor', 'true'); + expect(textParts[1]).toHaveAttribute('data-show-cursor', 'false'); + }); + it('renders a completion-appended parent before the final root text', () => { const tool = { type: ContentTypes.TOOL_CALL, diff --git a/client/src/components/Chat/Messages/Content/__tests__/ParallelContent.test.ts b/client/src/components/Chat/Messages/Content/__tests__/ParallelContent.test.ts index 1633d7a55d1..ec1b821bfb7 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ParallelContent.test.ts +++ b/client/src/components/Chat/Messages/Content/__tests__/ParallelContent.test.ts @@ -1,6 +1,6 @@ import { ContentTypes } from 'librechat-data-provider'; import type { TMessageContentParts } from 'librechat-data-provider'; -import { groupParallelContent } from '../ParallelContent'; +import { groupParallelContent, lastParallelColumnCursorIdx } from '../ParallelContent'; describe('groupParallelContent', () => { test('reports absolute indices for a dense phase segment', () => { @@ -41,3 +41,27 @@ describe('groupParallelContent', () => { ]); }); }); + +describe('lastParallelColumnCursorIdx', () => { + test('keeps the lane cursor on visible output before an empty placeholder', () => { + const visible = { + type: ContentTypes.TEXT, + text: 'Visible answer', + groupId: 1, + agentId: 'agent-1', + } as unknown as TMessageContentParts; + const empty = { + type: ContentTypes.TEXT, + text: '', + groupId: 1, + agentId: 'agent-1', + } as unknown as TMessageContentParts; + + expect( + lastParallelColumnCursorIdx([ + { part: visible, idx: 7 }, + { part: empty, idx: 8 }, + ]), + ).toBe(7); + }); +}); diff --git a/client/src/components/Chat/Messages/MultiMessage.tsx b/client/src/components/Chat/Messages/MultiMessage.tsx index 20525561e1c..5af59b7fa9a 100644 --- a/client/src/components/Chat/Messages/MultiMessage.tsx +++ b/client/src/components/Chat/Messages/MultiMessage.tsx @@ -185,6 +185,22 @@ function MultiMessage({ } else { row = ; } + /** Event children may be persisted against the user request that launched + * the Director. Once its assistant response exists, present that activity + * after the response instead of interrupting the turn between user and + * assistant rows. Exact assistant-owned children remain in the same group. */ + let activityParentMessageIds: string[] = []; + if (message.isCreatedByUser) { + if (!message.children?.length) activityParentMessageIds = [message.messageId]; + } else { + activityParentMessageIds = [message.messageId, message.parentMessageId].filter( + (id): id is string => typeof id === 'string' && id.length > 0, + ); + } + const isEditingActivityAnchor = + typeof currentEditId === 'string' && activityParentMessageIds.includes(currentEditId); + const hasParallelContent = + !message.isCreatedByUser && message.content?.some((part) => part?.groupId != null) === true; /** * The child recursion is a sibling of the row (not rendered inside it), so a @@ -196,14 +212,13 @@ function MultiMessage({ return ( <> {row} - {rowMounted && currentEditId !== message.messageId ? ( + {rowMounted && !isEditingActivityAnchor && activityParentMessageIds.length > 0 ? (
-
- -
+
) : null} ({ __esModule: true, default: createRowStub() jest.mock('../Message', () => ({ __esModule: true, default: createRowStub() })); jest.mock('~/components/Chat/Subagents/EventSubagentActivityGroup', () => ({ __esModule: true, - default: ({ parentMessageId }: { parentMessageId: string }) => ( -
+ default: ({ + parentMessageIds, + hasParallelContent, + }: { + parentMessageIds: string[]; + hasParallelContent?: boolean; + }) => ( +
), })); @@ -81,8 +91,8 @@ describe('MultiMessage sibling selection', () => { ); expect(screen.getByTestId('event-subagent-activity')).toHaveAttribute( - 'data-parent-message-id', - 'structured', + 'data-parent-message-ids', + 'structured,parent-1', ); view.rerender( @@ -96,8 +106,111 @@ describe('MultiMessage sibling selection', () => { , ); expect(screen.getByTestId('event-subagent-activity')).toHaveAttribute( - 'data-parent-message-id', - 'legacy', + 'data-parent-message-ids', + 'legacy,parent-1', + ); + }); + + it('places a user-anchored event group after the assistant response', () => { + const assistant = msg('assistant'); + const user = { + ...msg('user'), + isCreatedByUser: true, + parentMessageId: 'root', + children: [assistant], + } as TMessage; + assistant.parentMessageId = 'user'; + + render( + + + , + ); + + expect(screen.getAllByTestId('event-subagent-activity')).toHaveLength(1); + expect(screen.getByTestId('event-subagent-activity')).toHaveAttribute( + 'data-parent-message-ids', + 'assistant,user', + ); + expect( + screen + .getByText('assistant') + .compareDocumentPosition(screen.getByTestId('event-subagent-activity')), + ).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + }); + + it('hides merged event activity while its user anchor is being edited', () => { + const assistant = { ...msg('assistant'), parentMessageId: 'user' } as TMessage; + const user = { + ...msg('user'), + isCreatedByUser: true, + parentMessageId: 'root', + children: [assistant], + } as TMessage; + + render( + + + , + ); + + expect(screen.queryByTestId('event-subagent-activity')).not.toBeInTheDocument(); + }); + + it('matches the wider layout of a parallel assistant response', () => { + const assistant = { + ...msg('assistant'), + content: [{ type: 'text', text: 'answer', groupId: 'parallel-group' }], + } as unknown as TMessage; + + render( + + + , + ); + + expect(screen.getByTestId('event-subagent-activity')).toHaveAttribute( + 'data-has-parallel-content', + 'true', + ); + }); + + it('renders assistant content containing an undefined streaming placeholder', () => { + const assistant = { + ...msg('assistant'), + content: [undefined, { type: 'text', text: 'answer' }], + } as unknown as TMessage; + + render( + + + , + ); + + expect(screen.getByTestId('row')).toHaveTextContent('assistant'); + expect(screen.getByTestId('event-subagent-activity')).toHaveAttribute( + 'data-has-parallel-content', + 'false', ); }); diff --git a/client/src/components/Chat/Messages/ui/MessageRow.tsx b/client/src/components/Chat/Messages/ui/MessageRow.tsx index ffa20b37da2..0aadb57df88 100644 --- a/client/src/components/Chat/Messages/ui/MessageRow.tsx +++ b/client/src/components/Chat/Messages/ui/MessageRow.tsx @@ -20,6 +20,18 @@ type MessageRowProps = { className?: string; }; +export function getMessageRowWidthClass({ + fullWidth = false, + hasParallelContent = false, +}: { + fullWidth?: boolean; + hasParallelContent?: boolean; +} = {}) { + if (fullWidth) return 'w-full max-w-full sm:px-2'; + if (hasParallelContent) return 'w-full sm:px-2 md:max-w-[58rem] xl:max-w-[70rem]'; + return 'w-full sm:px-2 md:max-w-3xl xl:max-w-4xl'; +} + export default function MessageRow({ id, icon, @@ -38,12 +50,7 @@ export default function MessageRow({ }: MessageRowProps) { // Same column as ChatForm: max-width plus `sm:px-2`, so the body lines // up with the composer surface rather than the form's outer box. - let widthClass = 'w-full sm:px-2 md:max-w-3xl xl:max-w-4xl'; - if (fullWidth) { - widthClass = 'w-full max-w-full sm:px-2'; - } else if (hasParallelContent) { - widthClass = 'w-full sm:px-2 md:max-w-[58rem] xl:max-w-[70rem]'; - } + const widthClass = getMessageRowWidthClass({ fullWidth, hasParallelContent }); return (
(); jest.mock('./ParentSubagentsProvider', () => ({ useParentSubagents: () => ({ - byMessageId: new Map([['parent-message', [mockChild]]]), + byMessageId: mockChildrenByMessage, byThreadId: new Map([['event-thread', mockChild]]), refresh: mockRefresh, }), })); jest.mock('~/Providers', () => ({ - useAgentsMapContext: () => ({ 'agent-1': { id: 'agent-1', name: 'Visible Agent' } }), + useAgentsMapContext: () => ({ + 'agent-1': { id: 'agent-1', name: 'Visible Agent' }, + 'agent-2': { id: 'agent-2', name: 'Completed Agent' }, + }), })); jest.mock('~/hooks', () => ({ useLocalize: () => (key: string) => key })); @@ -40,11 +54,15 @@ jest.mock('~/utils', () => ({ renderAgentAvatar: () => , })); jest.mock('@librechat/client', () => ({ + Button: ({ children, ...props }: React.ComponentProps<'button'>) => ( + + ), cn: (...values: Array) => values.filter(Boolean).join(' '), })); jest.mock('lucide-react', () => ({ AlertCircle: () => null, Bot: () => null, + ChevronDown: () => null, Check: () => null, CheckCircle2: () => null, CircleAlert: () => null, @@ -57,6 +75,7 @@ jest.mock('lucide-react', () => ({ describe('EventSubagentActivityGroup', () => { beforeEach(() => { mockRefresh.mockReset().mockResolvedValue(undefined); + mockChildrenByMessage = new Map([['parent-message', [mockChild]]]); }); it('opens the durable event child under its owning parent message', () => { @@ -70,11 +89,16 @@ describe('EventSubagentActivityGroup', () => { , ); + expect( + screen.getByRole('region', { name: 'com_ui_subagent_activity' }).parentElement, + ).toHaveClass('px-4', 'sm:px-0', 'md:max-w-3xl', 'xl:max-w-4xl'); + expect(screen.queryByRole('button', { name: /Visible Agent/ })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /com_ui_subagent_activity/ })); fireEvent.click(screen.getByRole('button', { name: /Visible Agent/ })); expect(mockRefresh).toHaveBeenCalledTimes(1); @@ -88,11 +112,99 @@ describe('EventSubagentActivityGroup', () => { event: { actorId: 'actor-a', progressKey: 'event-task:event-thread:task-1', + siblingParentMessageIds: ['parent-message'], }, }), ); }); + it('matches the width of a parallel assistant response', () => { + render( + + + , + ); + + expect( + screen.getByRole('region', { name: 'com_ui_subagent_activity' }).parentElement, + ).toHaveClass('md:max-w-[58rem]', 'xl:max-w-[70rem]'); + }); + + it('retains a merged anchor that has no children yet', () => { + let selection: ActiveSubagentPanel | null = null; + const Observer = () => { + selection = useRecoilValue(activeSubagentPanel); + return null; + }; + + render( + + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: /com_ui_subagent_activity/ })); + fireEvent.click(screen.getByRole('button', { name: /Visible Agent/ })); + + expect((selection as ActiveSubagentPanel | null)?.event?.siblingParentMessageIds).toEqual([ + 'parent-message', + 'empty-assistant-message', + ]); + }); + + it('preserves every merged message anchor and uses explicit plural status labels', () => { + mockChildrenByMessage = new Map([ + ['parent-message', [mockChild]], + [ + 'assistant-message', + [ + mockCompletedChild, + { + ...mockCompletedChild, + threadId: 'event-thread-3', + actorId: 'actor-c', + agentId: undefined, + title: 'Third actor', + }, + ], + ], + ]); + let selection: ActiveSubagentPanel | null = null; + const Observer = () => { + selection = useRecoilValue(activeSubagentPanel); + return null; + }; + + render( + + + + , + ); + + const summary = screen.getByRole('button', { name: /com_ui_subagent_activity/ }); + expect(summary).toHaveAccessibleName(/com_ui_subagent_count_running_one/); + expect(summary).toHaveAccessibleName(/com_ui_subagent_count_completed_other/); + fireEvent.click(summary); + fireEvent.click(screen.getByRole('button', { name: /Completed Agent/ })); + + expect((selection as ActiveSubagentPanel | null)?.event?.siblingParentMessageIds).toEqual([ + 'parent-message', + 'assistant-message', + ]); + }); + it('does not reopen a child after the user closes it while refresh is pending', async () => { let selection: ActiveSubagentPanel | null = null; let resolveRefresh!: (value: unknown) => void; @@ -115,11 +227,12 @@ describe('EventSubagentActivityGroup', () => { , ); + fireEvent.click(screen.getByRole('button', { name: /com_ui_subagent_activity/ })); fireEvent.click(screen.getByRole('button', { name: /Visible Agent/ })); expect(selection).toEqual( expect.objectContaining({ durable: expect.objectContaining({ taskId: 'task-1' }) }), diff --git a/client/src/components/Chat/Subagents/EventSubagentActivityGroup.tsx b/client/src/components/Chat/Subagents/EventSubagentActivityGroup.tsx index 40b1ded56e6..62e82344bbb 100644 --- a/client/src/components/Chat/Subagents/EventSubagentActivityGroup.tsx +++ b/client/src/components/Chat/Subagents/EventSubagentActivityGroup.tsx @@ -1,8 +1,9 @@ -import { useCallback } from 'react'; -import { Bot } from 'lucide-react'; -import { cn } from '@librechat/client'; -import { useResetRecoilState, useSetRecoilState } from 'recoil'; +import { useCallback, useId, useMemo, useState } from 'react'; +import { Button, cn } from '@librechat/client'; +import { Bot, ChevronDown } from 'lucide-react'; +import { useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil'; import type { ParentSubagentSummary } from 'librechat-data-provider'; +import { getMessageRowWidthClass } from '~/components/Chat/Messages/ui/MessageRow'; import { subagentStatusIcon, subagentStatusLabelKey } from './status'; import { useParentSubagents } from './ParentSubagentsProvider'; import { eventSubagentSelection } from './eventSelection'; @@ -12,33 +13,83 @@ import { renderAgentAvatar } from '~/utils'; import { useLocalize } from '~/hooks'; import store from '~/store'; +const STATUS_COUNT_LABEL_KEYS = { + dispatched: { + one: 'com_ui_subagent_count_dispatched_one', + other: 'com_ui_subagent_count_dispatched_other', + }, + running: { + one: 'com_ui_subagent_count_running_one', + other: 'com_ui_subagent_count_running_other', + }, + completed: { + one: 'com_ui_subagent_count_completed_one', + other: 'com_ui_subagent_count_completed_other', + }, + failed: { + one: 'com_ui_subagent_count_failed_one', + other: 'com_ui_subagent_count_failed_other', + }, + interrupted: { + one: 'com_ui_subagent_count_interrupted_one', + other: 'com_ui_subagent_count_interrupted_other', + }, + cancelled: { + one: 'com_ui_subagent_count_cancelled_one', + other: 'com_ui_subagent_count_cancelled_other', + }, +} as const; + export default function EventSubagentActivityGroup({ conversationId, - parentMessageId, + parentMessageIds, + hasParallelContent = false, }: { conversationId: string; - parentMessageId: string; + parentMessageIds: string[]; + hasParallelContent?: boolean; }) { const { byMessageId } = useParentSubagents(); - const children = byMessageId.get(parentMessageId) ?? []; + const children = useMemo(() => { + const seen = new Set(); + return parentMessageIds + .flatMap((messageId) => byMessageId.get(messageId) ?? []) + .filter((child) => { + if (seen.has(child.threadId)) return false; + seen.add(child.threadId); + return true; + }); + }, [byMessageId, parentMessageIds]); + const fullWidth = useRecoilValue(store.maximizeChatSpace); + const siblingParentMessageIds = useMemo( + () => Array.from(new Set(parentMessageIds)), + [parentMessageIds], + ); if (children.length === 0) return null; return ( - +
+ +
); } function EventSubagentRows({ conversationId, - parentMessageId, eventChildren, + siblingParentMessageIds, }: { conversationId: string; - parentMessageId: string; eventChildren: ParentSubagentSummary[]; + siblingParentMessageIds: string[]; }) { const localize = useLocalize(); const agentsMap = useAgentsMapContext(); @@ -46,9 +97,27 @@ function EventSubagentRows({ const setSelected = useSetRecoilState(activeSubagentPanel); const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility); const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId); + const [expanded, setExpanded] = useState(false); + const panelId = useId(); + const counts = useMemo(() => { + const result = new Map(); + eventChildren.forEach((child) => result.set(child.status, (result.get(child.status) ?? 0) + 1)); + return result; + }, [eventChildren]); + const summary = [ + localize( + eventChildren.length === 1 ? 'com_ui_subagent_agent_count' : 'com_ui_subagent_agents_count', + { 0: String(eventChildren.length) }, + ), + ...Array.from(counts.entries()).map(([status, count]) => + localize(STATUS_COUNT_LABEL_KEYS[status][count === 1 ? 'one' : 'other'], { + 0: String(count), + }), + ), + ].join(' · '); const openChild = useCallback( (child: ParentSubagentSummary) => { - const selection = eventSubagentSelection(conversationId, child); + const selection = eventSubagentSelection(conversationId, child, siblingParentMessageIds); if (selection == null) return; resetCurrentArtifactId(); setArtifactsVisible(false); @@ -56,7 +125,11 @@ function EventSubagentRows({ void refresh().then((index) => { const fresh = index?.children.find((candidate) => candidate.threadId === child.threadId); if (fresh == null || fresh.latestTaskId === child.latestTaskId) return; - const freshSelection = eventSubagentSelection(conversationId, fresh); + const freshSelection = eventSubagentSelection( + conversationId, + fresh, + siblingParentMessageIds, + ); if (freshSelection != null) { setSelected((current) => { if ( @@ -70,18 +143,43 @@ function EventSubagentRows({ } }); }, - [conversationId, refresh, resetCurrentArtifactId, setArtifactsVisible, setSelected], + [ + conversationId, + refresh, + resetCurrentArtifactId, + setArtifactsVisible, + setSelected, + siblingParentMessageIds, + ], ); return (
-
- {localize('com_ui_subagent_activity')} -
-
+ + ); - if (isLoading || (isFetching && !isFetchingNextPage)) { + if ((isLoading || (isFetching && !isFetchingNextPage)) && !hasData) { return loadingSpinner; } return mainContent; diff --git a/client/src/components/Agents/SmartLoader.tsx b/client/src/components/Agents/SmartLoader.tsx index 58e741b9367..b857dd1978c 100644 --- a/client/src/components/Agents/SmartLoader.tsx +++ b/client/src/components/Agents/SmartLoader.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { AgentListResponse } from 'librechat-data-provider'; +import type { AgentListResponse } from 'librechat-data-provider'; interface SmartLoaderProps { /** Whether the content is currently loading */ @@ -73,6 +73,12 @@ export const useHasData = (data: AgentListResponse | undefined): boolean => { // Type guard for object data if (typeof data === 'object' && data !== null) { + // Check for agent list data (AgentListResponse shape, e.g. marketplace pages) + const agents = data.data; + if (Array.isArray(agents)) { + return agents.length > 0; + } + // Check for agent list data if ('agents' in data) { const agents = (data as any).agents; diff --git a/client/src/components/Agents/VirtualizedAgentGrid.tsx b/client/src/components/Agents/VirtualizedAgentGrid.tsx index d5f026cb132..165c6e9d0e4 100644 --- a/client/src/components/Agents/VirtualizedAgentGrid.tsx +++ b/client/src/components/Agents/VirtualizedAgentGrid.tsx @@ -225,7 +225,7 @@ const VirtualizedAgentGrid: React.FC = ({ } // Handle loading state - if (isLoading || (isFetching && !isFetchingNextPage)) { + if ((isLoading || (isFetching && !isFetchingNextPage)) && !hasData) { return loadingSpinner; } diff --git a/client/src/components/Agents/tests/AgentGrid.integration.spec.tsx b/client/src/components/Agents/tests/AgentGrid.integration.spec.tsx index a4d6282aa74..03043535a80 100644 --- a/client/src/components/Agents/tests/AgentGrid.integration.spec.tsx +++ b/client/src/components/Agents/tests/AgentGrid.integration.spec.tsx @@ -18,11 +18,6 @@ jest.mock('~/hooks/Agents', () => ({ })), })); -// Mock SmartLoader -jest.mock('../SmartLoader', () => ({ - useHasData: jest.fn(() => true), -})); - // Mock useLocalize hook jest.mock('~/hooks/useLocalize', () => () => (key: string, options?: any) => { const mockTranslations: Record = { @@ -362,6 +357,23 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => { expect(spinner).toBeInTheDocument(); }); + it('should retain cached agents while refetching', () => { + mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({ + ...defaultMockQueryResult, + isFetching: true, + }); + + const Wrapper = createWrapper(); + render( + + + , + ); + + expect(screen.getByTestId('agent-card-1')).toBeInTheDocument(); + expect(screen.getByTestId('agent-card-2')).toBeInTheDocument(); + }); + it('should show empty state when no agents are available', () => { mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({ ...defaultMockQueryResult, diff --git a/client/src/components/Agents/tests/SmartLoader.spec.tsx b/client/src/components/Agents/tests/SmartLoader.spec.tsx index 766d5a27072..3d2609c94b6 100644 --- a/client/src/components/Agents/tests/SmartLoader.spec.tsx +++ b/client/src/components/Agents/tests/SmartLoader.spec.tsx @@ -313,6 +313,35 @@ describe('useHasData', () => { expect(screen.getByTestId('result')).toHaveTextContent('no-data'); }); + it('detects empty data array (AgentListResponse) as no data', () => { + render( + , + ); + expect(screen.getByTestId('result')).toHaveTextContent('no-data'); + }); + + it('detects non-empty data array (AgentListResponse) as has data', () => { + render( + , + ); + expect(screen.getByTestId('result')).toHaveTextContent('has-data'); + }); + + it('detects invalid data property as no data', () => { + render(); + expect(screen.getByTestId('result')).toHaveTextContent('no-data'); + }); + it('detects empty agents array as no data', () => { render(); expect(screen.getByTestId('result')).toHaveTextContent('no-data'); diff --git a/client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx b/client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx index b756fb9add3..04425af54dc 100644 --- a/client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx +++ b/client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx @@ -160,10 +160,6 @@ jest.mock('~/hooks', () => ({ }, })); -jest.mock('../SmartLoader', () => ({ - useHasData: () => true, -})); - jest.mock('../AgentCard', () => { return function MockAgentCard({ agent, @@ -266,6 +262,21 @@ describe('VirtualizedAgentGrid', () => { expect(spinner).toHaveClass('h-8 w-8 text-text-primary'); }); + it('retains cached agents while refetching', () => { + const useMarketplaceAgentsInfiniteQuery = ( + jest.requireMock('~/data-provider/Agents') as MarketplaceAgentsMock + ).useMarketplaceAgentsInfiniteQuery; + useMarketplaceAgentsInfiniteQuery.mockImplementation(() => + createMockInfiniteQuery({ isFetching: true }), + ); + + renderComponent(); + + expect(screen.getByTestId('virtual-list')).toBeInTheDocument(); + expect(screen.getByTestId('agent-card-1')).toBeInTheDocument(); + expect(screen.getByTestId('agent-card-2')).toBeInTheDocument(); + }); + it('has proper accessibility attributes', () => { renderComponent({ category: 'productivity' }); From 8773b36eec7f0cbbcba9f1873de1a0707a6bfedb Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 24 Aug 2026 03:21:48 -0400 Subject: [PATCH 03/20] =?UTF-8?q?=F0=9F=8E=BD=20fix:=20Commit=20Subagent?= =?UTF-8?q?=20Roster=20Selections=20to=20Form=20State=20(#15154)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: persist subagent selections synchronously * test: verify subagent roster form state * style: sort subagent roster test imports --- .../Agents/Advanced/AgentSubagents.tsx | 26 +++-- .../__tests__/AgentSubagents.spec.tsx | 105 ++++++++++++++++++ 2 files changed, 120 insertions(+), 11 deletions(-) create mode 100644 client/src/components/SidePanel/Agents/Advanced/__tests__/AgentSubagents.spec.tsx diff --git a/client/src/components/SidePanel/Agents/Advanced/AgentSubagents.tsx b/client/src/components/SidePanel/Agents/Advanced/AgentSubagents.tsx index f85ab7d732d..98c2331eb59 100644 --- a/client/src/components/SidePanel/Agents/Advanced/AgentSubagents.tsx +++ b/client/src/components/SidePanel/Agents/Advanced/AgentSubagents.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { Switch } from '@librechat/client'; import { Network, Users } from 'lucide-react'; import type { ControllerRenderProps } from 'react-hook-form'; @@ -16,7 +16,6 @@ interface AgentSubagentsProps { const AgentSubagents: React.FC = ({ field, currentAgentId, maxSubagents }) => { const localize = useLocalize(); - const [newAgentId, setNewAgentId] = useState(''); const fieldValue = field.value; const value = useMemo(() => fieldValue ?? {}, [fieldValue]); @@ -65,14 +64,19 @@ const AgentSubagents: React.FC = ({ field, currentAgentId, [field, value], ); - useEffect(() => { - if (newAgentId && agentIds.length < maxSubagents && !agentIds.includes(newAgentId)) { - setAgentIds([...agentIds, newAgentId]); - setNewAgentId(''); - } else if (newAgentId) { - setNewAgentId(''); - } - }, [newAgentId, agentIds, maxSubagents, setAgentIds]); + const addAgent = useCallback( + (agentId: string) => { + if (!agentId || agentIds.length >= maxSubagents || agentIds.includes(agentId)) { + return; + } + + /** Commit the selection directly to react-hook-form. Deferring this + * through component state and an effect allowed an immediate form submit + * to persist the enable toggles before the selected roster. */ + setAgentIds([...agentIds, agentId]); + }, + [agentIds, maxSubagents, setAgentIds], + ); const removeAgentAt = (index: number) => { setAgentIds(agentIds.filter((_, i) => i !== index)); @@ -140,7 +144,7 @@ const AgentSubagents: React.FC = ({ field, currentAgentId, {agentIds.length < maxSubagents && ( diff --git a/client/src/components/SidePanel/Agents/Advanced/__tests__/AgentSubagents.spec.tsx b/client/src/components/SidePanel/Agents/Advanced/__tests__/AgentSubagents.spec.tsx new file mode 100644 index 00000000000..8280fc2a642 --- /dev/null +++ b/client/src/components/SidePanel/Agents/Advanced/__tests__/AgentSubagents.spec.tsx @@ -0,0 +1,105 @@ +/** + * @jest-environment jsdom + */ +import { Controller, useForm } from 'react-hook-form'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { UseFormReturn } from 'react-hook-form'; +import type { ReactNode } from 'react'; +import type { AgentForm } from '~/common'; +import AgentSubagents from '../AgentSubagents'; + +let mockSelectAgent: ((agentId: string) => void) | undefined; +let mockGetValues: UseFormReturn['getValues'] | undefined; +const mockSubmit = jest.fn(); + +jest.mock('@librechat/client', () => ({ + Switch: () => null, +})); + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, +})); + +jest.mock('../AgentList', () => ({ + AddAgentSelect: ({ onSelect }: { onSelect: (agentId: string) => void }) => { + mockSelectAgent = onSelect; + return null; + }, + ListMeta: () => null, + StaticAgentRow: () => null, + useSelectableAgents: () => ({ options: [], getAgent: () => undefined }), +})); + +jest.mock('../OrchestrationPattern', () => ({ + __esModule: true, + default: ({ children, trailing }: { children: ReactNode; trailing: ReactNode }) => ( + <> + {trailing} + {children} + + ), +})); + +jest.mock('../ui', () => ({ + ToggleSetting: () => null, +})); + +describe('AgentSubagents', () => { + beforeEach(() => { + mockSelectAgent = undefined; + mockGetValues = undefined; + mockSubmit.mockReset(); + }); + + function Harness() { + const methods = useForm({ + defaultValues: { + subagents: { + enabled: true, + allowSelf: false, + agent_ids: [], + }, + }, + }); + mockGetValues = methods.getValues; + + return ( +
+ ( + + )} + /> + + ); + } + + it('commits a selected agent before an immediate form submit', async () => { + render(); + + act(() => { + mockSelectAgent?.('child'); + fireEvent.submit(screen.getByRole('form', { name: 'agent form' })); + }); + + expect(mockGetValues?.('subagents')).toEqual({ + enabled: true, + allowSelf: false, + agent_ids: ['child'], + }); + await waitFor(() => + expect(mockSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + subagents: { + enabled: true, + allowSelf: false, + agent_ids: ['child'], + }, + }), + expect.anything(), + ), + ); + }); +}); From c52ba4efdbcd71951ce81acb56c472cbd3d1f73d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 24 Aug 2026 03:28:59 -0400 Subject: [PATCH 04/20] fix: restore provider typing against the Agents SDK declarations (#15161) @librechat/agents publishes its declaration files with its internal @/* path aliases unrewritten, across 112 files. types/llm.d.ts imports Providers that way, so a consumer cannot resolve it, ProviderOptionsMap's computed keys go unresolved, and keyof ProviderOptionsMap collapses to number. Through v3.6.15 that only degraded LLMConfig silently: provider was typed as the unresolved Providers, so everything assigned. v3.6.16 made SharedLLMConfig generic over that key union, turning provider into number | RuntimeProviderName, which nothing real is assignable to. That is the whole of the "Type check @librechat/api" failure on dev. Declaring the one alias llm.d.ts needs restores the enum and the provider key union, taking the package from 20 errors to 4. The remaining 4 were genuine: custom-endpoint specs pass provider: 'custom', which widens to string, and the SDK models a provider outside ProviderOptionsMap as RuntimeProviderName. Mapping every @/* alias instead was tried and rejected here: it unmasks a backlog of roughly 114 latent errors elsewhere in the package, which is a separate cleanup. The real fix belongs upstream, in what the SDK ships. --- packages/api/src/agents/memory.spec.ts | 9 +++++---- packages/api/src/types/agents.d.ts | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 packages/api/src/types/agents.d.ts diff --git a/packages/api/src/agents/memory.spec.ts b/packages/api/src/agents/memory.spec.ts index 79d1c94f54a..6ecd579422b 100644 --- a/packages/api/src/agents/memory.spec.ts +++ b/packages/api/src/agents/memory.spec.ts @@ -3,6 +3,7 @@ import { Run, Providers, GraphEvents } from '@librechat/agents'; import { AIMessage, HumanMessage } from '@librechat/agents/langchain/messages'; import { Tools, MemoryScope, EModelEndpoint, AgentCapabilities } from 'librechat-data-provider'; import type { FiltersConfig } from 'librechat-data-provider'; +import type { RuntimeProviderName } from '@librechat/agents'; import type { IUser } from '@librechat/data-schemas'; import type { Response } from 'express'; import type { ServerRequest } from '~/types'; @@ -223,7 +224,7 @@ describe('Memory Agent Header Resolution', () => { it('should resolve environment variables in custom endpoint headers', async () => { const llmConfig = { - provider: 'custom', + provider: 'custom' as RuntimeProviderName, model: 'gpt-4o-mini', configuration: { defaultHeaders: { @@ -258,7 +259,7 @@ describe('Memory Agent Header Resolution', () => { it('should resolve user placeholders in custom endpoint headers', async () => { const llmConfig = { - provider: 'custom', + provider: 'custom' as RuntimeProviderName, model: 'gpt-4o-mini', configuration: { defaultHeaders: { @@ -293,7 +294,7 @@ describe('Memory Agent Header Resolution', () => { it('should handle mixed environment variables and user placeholders', async () => { const llmConfig = { - provider: 'custom', + provider: 'custom' as RuntimeProviderName, model: 'gpt-4o-mini', configuration: { defaultHeaders: { @@ -330,7 +331,7 @@ describe('Memory Agent Header Resolution', () => { it('should resolve env vars when user is undefined', async () => { const llmConfig = { - provider: 'custom', + provider: 'custom' as RuntimeProviderName, model: 'gpt-4o-mini', configuration: { defaultHeaders: { diff --git a/packages/api/src/types/agents.d.ts b/packages/api/src/types/agents.d.ts new file mode 100644 index 00000000000..26c14e012c1 --- /dev/null +++ b/packages/api/src/types/agents.d.ts @@ -0,0 +1,16 @@ +/** + * `@librechat/agents` publishes its declaration files with its internal `@/*` path aliases + * unrewritten, so a consumer cannot resolve them. `types/llm.d.ts` imports `Providers` that + * way, which leaves `ProviderOptionsMap`'s computed keys unresolved and collapses + * `keyof ProviderOptionsMap` to `number`. Until v3.6.16 that only degraded `LLMConfig` + * silently; v3.6.16 made `SharedLLMConfig` generic over that key union, so `provider` became + * `number | RuntimeProviderName` and no real provider was assignable to it. + * + * Declaring the one alias `llm.d.ts` needs restores the enum, and with it the provider key + * union. Remove this once the SDK ships declarations with its aliases resolved — note that + * mapping every `@/*` alias instead unmasks a large backlog of latent errors elsewhere in + * this package, so widening it is a separate cleanup rather than a drop-in improvement. + */ +declare module '@/common' { + export { Providers } from '@librechat/agents'; +} From b6e3cf46d2a40d3efcc9477365ebbce4d9ad53d3 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 24 Aug 2026 08:36:21 -0400 Subject: [PATCH 05/20] =?UTF-8?q?=F0=9F=95=AF=EF=B8=8F=20fix:=20Decay=20Vi?= =?UTF-8?q?olation=20Scores=20With=20a=20Configurable=20TTL=20(#15153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 + .../src/cache/__tests__/cacheConfig.spec.ts | 36 ++++++++++++ .../cacheFactory/violationCache.spec.ts | 56 +++++++++++++++++++ packages/api/src/cache/cacheConfig.ts | 16 +++++- packages/api/src/cache/cacheFactory.ts | 8 ++- 5 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 packages/api/src/cache/__tests__/cacheFactory/violationCache.spec.ts diff --git a/.env.example b/.env.example index 4d47b6b064c..eb7350a93e2 100644 --- a/.env.example +++ b/.env.example @@ -677,6 +677,9 @@ BAN_VIOLATIONS=true BAN_DURATION=1000 * 60 * 60 * 2 BAN_INTERVAL=20 +# Violation scores expire after this long (in ms) without new violations; 0 = never expire +VIOLATION_SCORE_TTL=1000 * 60 * 60 + LOGIN_VIOLATION_SCORE=1 REGISTRATION_VIOLATION_SCORE=1 CONCURRENT_VIOLATION_SCORE=1 diff --git a/packages/api/src/cache/__tests__/cacheConfig.spec.ts b/packages/api/src/cache/__tests__/cacheConfig.spec.ts index 820815b5f51..0ef09a2710e 100644 --- a/packages/api/src/cache/__tests__/cacheConfig.spec.ts +++ b/packages/api/src/cache/__tests__/cacheConfig.spec.ts @@ -15,6 +15,7 @@ describe('cacheConfig', () => { delete process.env.REDIS_CLUSTER_SAFE_DELETE; delete process.env.REDIS_PING_INTERVAL; delete process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES; + delete process.env.VIOLATION_SCORE_TTL; // Clear module cache jest.resetModules(); @@ -263,4 +264,39 @@ describe('cacheConfig', () => { expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual(['CONFIG_STORE', 'APP_CONFIG']); }); }); + + describe('VIOLATION_SCORE_TTL configuration', () => { + test('should default to one hour when not set', async () => { + const { cacheConfig } = await import('../cacheConfig'); + expect(cacheConfig.VIOLATION_SCORE_TTL).toBe(3600000); + }); + + test('should evaluate math expressions from the environment', async () => { + process.env.VIOLATION_SCORE_TTL = '1000 * 60 * 60 * 24'; + + const { cacheConfig } = await import('../cacheConfig'); + expect(cacheConfig.VIOLATION_SCORE_TTL).toBe(86400000); + }); + + test('should disable expiry when set to 0', async () => { + process.env.VIOLATION_SCORE_TTL = '0'; + + const { cacheConfig } = await import('../cacheConfig'); + expect(cacheConfig.VIOLATION_SCORE_TTL).toBeUndefined(); + }); + + test('should disable expiry for negative values', async () => { + process.env.VIOLATION_SCORE_TTL = '-1000'; + + const { cacheConfig } = await import('../cacheConfig'); + expect(cacheConfig.VIOLATION_SCORE_TTL).toBeUndefined(); + }); + + test('should fall back to the default on invalid input', async () => { + process.env.VIOLATION_SCORE_TTL = 'not-a-duration'; + + const { cacheConfig } = await import('../cacheConfig'); + expect(cacheConfig.VIOLATION_SCORE_TTL).toBe(3600000); + }); + }); }); diff --git a/packages/api/src/cache/__tests__/cacheFactory/violationCache.spec.ts b/packages/api/src/cache/__tests__/cacheFactory/violationCache.spec.ts new file mode 100644 index 00000000000..cc6382e2ff5 --- /dev/null +++ b/packages/api/src/cache/__tests__/cacheFactory/violationCache.spec.ts @@ -0,0 +1,56 @@ +describe('violationCache TTL defaults', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + delete process.env.USE_REDIS; + delete process.env.REDIS_URI; + delete process.env.VIOLATION_SCORE_TTL; + jest.resetModules(); + }); + + afterEach(() => { + process.env = originalEnv; + jest.resetModules(); + }); + + test('applies the default violation score TTL when none is given', async () => { + const { violationCache } = await import('../../cacheFactory'); + const cache = violationCache('logins'); + + expect(cache.opts.ttl).toBe(3600000); + expect(cache.opts.namespace).toBe('violations:logins'); + }); + + test('an explicit TTL overrides the default', async () => { + const { violationCache } = await import('../../cacheFactory'); + const cache = violationCache('logins', 60000); + + expect(cache.opts.ttl).toBe(60000); + }); + + test('honors VIOLATION_SCORE_TTL from the environment', async () => { + process.env.VIOLATION_SCORE_TTL = '1000 * 60 * 5'; + + const { violationCache } = await import('../../cacheFactory'); + expect(violationCache('concurrent').opts.ttl).toBe(300000); + }); + + test('VIOLATION_SCORE_TTL=0 disables expiry', async () => { + process.env.VIOLATION_SCORE_TTL = '0'; + + const { violationCache } = await import('../../cacheFactory'); + expect(violationCache('concurrent').opts.ttl).toBeUndefined(); + }); + + test('expires violation entries once the TTL elapses', async () => { + const { violationCache } = await import('../../cacheFactory'); + const cache = violationCache('expiry-check', 500); + + await cache.set('user-1', 3); + await expect(cache.get('user-1')).resolves.toBe(3); + + await new Promise((resolve) => setTimeout(resolve, 800)); + await expect(cache.get('user-1')).resolves.toBeUndefined(); + }); +}); diff --git a/packages/api/src/cache/cacheConfig.ts b/packages/api/src/cache/cacheConfig.ts index 21fe1e7d034..303fb968c8f 100644 --- a/packages/api/src/cache/cacheConfig.ts +++ b/packages/api/src/cache/cacheConfig.ts @@ -1,6 +1,6 @@ import { readFileSync, existsSync } from 'fs'; import { logger } from '@librechat/data-schemas'; -import { CacheKeys } from 'librechat-data-provider'; +import { Time, CacheKeys } from 'librechat-data-provider'; import { math, isEnabled } from '~/utils'; // To ensure that different deployments do not interfere with each other's cache, we use a prefix for the Redis keys. @@ -48,6 +48,12 @@ if (FORCED_IN_MEMORY_CACHE_NAMESPACES.length > 0) { } } +// Violation scores expire after this long without new violations; every violation write +// restarts the countdown. Non-positive values disable expiry, restoring the legacy +// accumulate-forever behavior. +const VIOLATION_SCORE_TTL_MS = math(process.env.VIOLATION_SCORE_TTL, Time.ONE_HOUR); +const VIOLATION_SCORE_TTL = VIOLATION_SCORE_TTL_MS > 0 ? VIOLATION_SCORE_TTL_MS : undefined; + /** Helper function to safely read Redis CA certificate from file * @returns {string|null} The contents of the CA certificate file, or null if not set or on error */ @@ -105,6 +111,13 @@ const cacheConfig: { CI: boolean; DEBUG_MEMORY_CACHE: boolean; BAN_DURATION: number; // 2 hours + /** + * TTL in ms for violation scores: a score expires after this long without new violations + * (each violation write restarts the countdown). `undefined` — from a non-positive + * setting — disables expiry so scores accumulate forever. + * @default 3600000 (1 hour) + */ + VIOLATION_SCORE_TTL: number | undefined; /** * Number of keys to delete in each batch during Redis DEL operations. * In cluster mode, keys are deleted individually in parallel chunks to avoid CROSSSLOT errors. @@ -176,6 +189,7 @@ const cacheConfig: { DEBUG_MEMORY_CACHE: isEnabled(process.env.DEBUG_MEMORY_CACHE), BAN_DURATION: math(process.env.BAN_DURATION, 7200000), // 2 hours + VIOLATION_SCORE_TTL, /** * Number of keys to delete in each batch during Redis DEL operations. diff --git a/packages/api/src/cache/cacheFactory.ts b/packages/api/src/cache/cacheFactory.ts index d61986d62a3..4e760058e82 100644 --- a/packages/api/src/cache/cacheFactory.ts +++ b/packages/api/src/cache/cacheFactory.ts @@ -116,10 +116,14 @@ export const tokenConfigCache = (): Keyv => * Creates a cache instance for storing violation data. * Uses a file-based fallback store if Redis is not enabled. * @param namespace - The cache namespace for violations. - * @param ttl - Time to live for cache entries. + * @param ttl - Time to live for cache entries. Defaults to `cacheConfig.VIOLATION_SCORE_TTL` + * so violation scores decay instead of accumulating forever; each write restarts the countdown. * @returns Cache instance for violations. */ -export const violationCache = (namespace: string, ttl?: number): Keyv => { +export const violationCache = ( + namespace: string, + ttl: number | undefined = cacheConfig.VIOLATION_SCORE_TTL, +): Keyv => { return standardCache(`violations:${namespace}`, ttl, violationFile); }; From 6a7da61234b0ded6d43284c875ba3b221f1f6b3b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 24 Aug 2026 08:38:30 -0400 Subject: [PATCH 06/20] =?UTF-8?q?=F0=9F=A5=B8=20chore:=20Resolve=20Agents?= =?UTF-8?q?=20SDK=20Path=20Aliases=20That=20Masked=20Backend=20Types=20(#1?= =?UTF-8?q?5160)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🐛 fix: Restore Agents SDK Type Resolution in Backend Type Checks * 🐛 fix: Preserve Typed Prompt Callback Assignability * 🐛 fix: Accept Agents Function Tool Calls in isImageVisionTool * 🐛 fix: Prove the Run Step Wire Contract at Compile Time --- client/src/hooks/SSE/useStepHandler.ts | 2 +- .../__tests__/graph-subagent.e2e.test.ts | 1 - .../__tests__/summarization.e2e.test.ts | 6 +- .../src/agents/activityPhases/runtime.spec.ts | 159 +++++++++--------- .../api/src/agents/activityPhases/runtime.ts | 5 +- packages/api/src/agents/hitl/resume.ts | 4 +- .../agents/reasoningLabels/runtime.spec.ts | 6 +- packages/api/src/agents/run.ts | 6 +- .../agents/steering/__tests__/offset.spec.ts | 41 ++++- .../agents/steering/__tests__/runtime.spec.ts | 16 +- packages/api/src/agents/subagentThreads.ts | 2 +- .../implementations/InMemoryJobStore.ts | 3 +- .../stream/implementations/RedisJobStore.ts | 3 +- .../api/src/stream/interfaces/IJobStore.ts | 53 +++++- packages/api/src/types/agents.d.ts | 16 -- packages/api/tsconfig.json | 6 +- packages/data-provider/src/schemas.ts | 11 +- packages/data-provider/src/types/agents.ts | 43 ++++- 18 files changed, 241 insertions(+), 142 deletions(-) delete mode 100644 packages/api/src/types/agents.d.ts diff --git a/client/src/hooks/SSE/useStepHandler.ts b/client/src/hooks/SSE/useStepHandler.ts index 934fad74a0d..d7b91f855dd 100644 --- a/client/src/hooks/SSE/useStepHandler.ts +++ b/client/src/hooks/SSE/useStepHandler.ts @@ -954,7 +954,7 @@ export default function useStepHandler({ // Store tool call IDs if present if (runStep.stepDetails.type === StepTypes.TOOL_CALLS) { let updatedResponse = { ...response }; - (runStep.stepDetails.tool_calls as Agents.ToolCall[]).forEach((toolCall) => { + ((runStep.stepDetails.tool_calls ?? []) as Agents.ToolCall[]).forEach((toolCall) => { const toolCallId = toolCall.id ?? ''; if ('id' in toolCall && toolCallId) { toolCallIdMap.current.set(runStep.id, toolCallId); diff --git a/packages/api/src/agents/__tests__/graph-subagent.e2e.test.ts b/packages/api/src/agents/__tests__/graph-subagent.e2e.test.ts index ebe067a743c..d66f8c0a0f9 100644 --- a/packages/api/src/agents/__tests__/graph-subagent.e2e.test.ts +++ b/packages/api/src/agents/__tests__/graph-subagent.e2e.test.ts @@ -139,7 +139,6 @@ liveDescribe('Graph subagent E2E (LibreChat)', () => { { configurable: { thread_id: runId }, recursionLimit: 100, - streamMode: 'values', version: 'v2', }, ); diff --git a/packages/api/src/agents/__tests__/summarization.e2e.test.ts b/packages/api/src/agents/__tests__/summarization.e2e.test.ts index 03ef2ca6d4c..0a52354060d 100644 --- a/packages/api/src/agents/__tests__/summarization.e2e.test.ts +++ b/packages/api/src/agents/__tests__/summarization.e2e.test.ts @@ -212,7 +212,11 @@ async function runFullTurn({ initialSummary, runId: `e2e-${Date.now()}`, signal: abortController.signal, - customHandlers: buildHandlers(collectedUsage, aggregateContent, spies) as never, + customHandlers: buildHandlers( + collectedUsage, + aggregateContent as (params: { event: string; data: unknown }) => void, + spies, + ) as never, summarizationConfig, tokenCounter, }); diff --git a/packages/api/src/agents/activityPhases/runtime.spec.ts b/packages/api/src/agents/activityPhases/runtime.spec.ts index f9291240d98..44a451c6068 100644 --- a/packages/api/src/agents/activityPhases/runtime.spec.ts +++ b/packages/api/src/agents/activityPhases/runtime.spec.ts @@ -100,7 +100,7 @@ describe('createActivityPhaseWiring', () => { phase: 'final_answer', }, }, - }, + } as never, undefined, undefined, ); @@ -112,7 +112,7 @@ describe('createActivityPhaseWiring', () => { delta: { content: { type: ContentTypes.TEXT, text: 'A'.repeat(SUBSTANTIAL_TEXT_CHARS + 1) }, }, - }, + } as never, undefined, undefined, ); @@ -185,13 +185,13 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: id, content_type: 'text', ...(phase && { phase }) }, }, - }, + } as never, undefined, undefined, ); handlers?.[GraphEvents.ON_MESSAGE_DELTA]?.handle( GraphEvents.ON_MESSAGE_DELTA, - { id, delta: { content: { type: ContentTypes.TEXT, text } } }, + { id, delta: { content: { type: ContentTypes.TEXT, text } } } as never, undefined, undefined, ); @@ -274,13 +274,16 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); handlers?.[GraphEvents.ON_MESSAGE_DELTA]?.handle( GraphEvents.ON_MESSAGE_DELTA, - { id: 'interleaved-text', delta: { content: { type: ContentTypes.TEXT, text: 'prefix' } } }, + { + id: 'interleaved-text', + delta: { content: { type: ContentTypes.TEXT, text: 'prefix' } }, + } as never, undefined, undefined, ); @@ -291,7 +294,7 @@ describe('createActivityPhaseWiring', () => { { id: 'interleaved-text', delta: { content: { type: ContentTypes.TEXT, text: 'x'.repeat(SUBSTANTIAL_TEXT_CHARS) } }, - }, + } as never, undefined, undefined, ); @@ -348,7 +351,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); @@ -360,7 +363,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'think' }, }, - }, + } as never, undefined, undefined, ); @@ -370,7 +373,7 @@ describe('createActivityPhaseWiring', () => { { id: 'later-reasoning', delta: { content: { type: ContentTypes.THINK, think: 'Investigating the later tool.' } }, - }, + } as never, undefined, undefined, ); @@ -379,7 +382,7 @@ describe('createActivityPhaseWiring', () => { { id: 'boundary-text', delta: { content: { type: ContentTypes.TEXT, text: substantialText('Boundary result.') } }, - }, + } as never, undefined, undefined, ); @@ -547,14 +550,14 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); const emitTextDelta = (id: string, text: string) => handlers?.[GraphEvents.ON_MESSAGE_DELTA]?.handle( GraphEvents.ON_MESSAGE_DELTA, - { id, delta: { content: { type: ContentTypes.TEXT, text } } }, + { id, delta: { content: { type: ContentTypes.TEXT, text } } } as never, undefined, undefined, ); @@ -572,7 +575,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text', phase: 'final_answer' }, }, - }, + } as never, undefined, undefined, ); @@ -626,7 +629,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text', phase: 'final_answer' }, }, - }, + } as never, undefined, undefined, ); @@ -678,7 +681,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text', phase: 'final_answer' }, }, - }, + } as never, undefined, undefined, ); @@ -736,7 +739,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'think' }, }, - }, + } as never, undefined, undefined, ); @@ -746,7 +749,7 @@ describe('createActivityPhaseWiring', () => { { id: 'missing-tool-reasoning', delta: { content: { type: ContentTypes.THINK, think: repeatedReasoning } }, - }, + } as never, undefined, undefined, ); @@ -766,7 +769,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'think' }, }, - }, + } as never, undefined, undefined, ); @@ -776,7 +779,7 @@ describe('createActivityPhaseWiring', () => { { id: 'current-reasoning', delta: { content: { type: ContentTypes.THINK, think: repeatedReasoning } }, - }, + } as never, undefined, undefined, ); @@ -789,7 +792,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text', phase: 'final_answer' }, }, - }, + } as never, undefined, undefined, ); @@ -826,7 +829,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text', phase: 'final_answer' }, }, - }, + } as never, undefined, undefined, ); @@ -858,7 +861,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'think' }, }, - }, + } as never, undefined, undefined, ); @@ -867,7 +870,7 @@ describe('createActivityPhaseWiring', () => { { id: 'reasoning-step', delta: { content: { type: ContentTypes.THINK, think: 'Compared both auth paths.' } }, - }, + } as never, undefined, undefined, ); @@ -880,7 +883,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text', phase: 'commentary' }, }, - }, + } as never, undefined, undefined, ); @@ -907,7 +910,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text', phase: 'final_answer' }, }, - }, + } as never, undefined, undefined, ); @@ -948,7 +951,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text', phase: 'final_answer' }, }, - }, + } as never, undefined, undefined, ); @@ -985,7 +988,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'think' }, }, - }, + } as never, undefined, undefined, ); @@ -994,7 +997,7 @@ describe('createActivityPhaseWiring', () => { { id: 'lane-reasoning', delta: { content: { type: ContentTypes.THINK, think: 'Checked the lane input.' } }, - }, + } as never, undefined, undefined, ); @@ -1009,7 +1012,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); @@ -1039,7 +1042,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); @@ -1083,7 +1086,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'think' }, }, - }, + } as never, undefined, undefined, ); @@ -1093,7 +1096,7 @@ describe('createActivityPhaseWiring', () => { { id, delta: { content: { type: ContentTypes.THINK, think: text } }, - }, + } as never, undefined, undefined, ); @@ -1115,7 +1118,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text', phase: 'final_answer' }, }, - }, + } as never, undefined, undefined, ); @@ -1173,7 +1176,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text', phase: 'final_answer' }, }, - }, + } as never, undefined, undefined, ); @@ -1254,7 +1257,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'think' }, }, - }, + } as never, undefined, undefined, ); @@ -1266,7 +1269,7 @@ describe('createActivityPhaseWiring', () => { delta: { content: { type: ContentTypes.THINK, think: 'Verified one more edge case.' }, }, - }, + } as never, undefined, undefined, ); @@ -1823,7 +1826,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); @@ -1874,7 +1877,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); @@ -1924,7 +1927,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); @@ -1979,7 +1982,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); @@ -2035,7 +2038,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); @@ -2090,7 +2093,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); @@ -2147,7 +2150,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); @@ -2216,7 +2219,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); @@ -2332,7 +2335,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); @@ -2408,7 +2411,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); @@ -2448,7 +2451,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'think' }, }, - }, + } as never, undefined, undefined, ); @@ -2485,13 +2488,13 @@ describe('createActivityPhaseWiring', () => { message_creation: { message_id: 'm', content_type: 'text', phase: 'final_answer' }, }, }; - handler?.handle(GraphEvents.ON_RUN_STEP, finalStep, undefined, undefined); + handler?.handle(GraphEvents.ON_RUN_STEP, finalStep as never, undefined, undefined); await flushDetached(); expect(generatePhase).not.toHaveBeenCalled(); handler?.handle( GraphEvents.ON_RUN_STEP, - { ...finalStep, id: 'root-final', groupId: undefined }, + { ...finalStep, id: 'root-final', groupId: undefined } as never, undefined, undefined, ); @@ -2526,13 +2529,13 @@ describe('createActivityPhaseWiring', () => { message_creation: { message_id: 'm', content_type: 'text' }, }, }; - handler?.handle(GraphEvents.ON_RUN_STEP, textStep, undefined, undefined); + handler?.handle(GraphEvents.ON_RUN_STEP, textStep as never, undefined, undefined); await flushDetached(); expect(generatePhase).not.toHaveBeenCalled(); handler?.handle( GraphEvents.ON_RUN_STEP, - { ...textStep, id: 'root-text', groupId: undefined }, + { ...textStep, id: 'root-text', groupId: undefined } as never, undefined, undefined, ); @@ -2579,7 +2582,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text', phase: 'commentary' }, }, - }, + } as never, undefined, undefined, ); @@ -2624,7 +2627,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); @@ -2639,7 +2642,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text', phase: 'commentary' }, }, - }, + } as never, undefined, undefined, ); @@ -2760,7 +2763,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); @@ -2778,7 +2781,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); @@ -2826,7 +2829,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); @@ -2880,7 +2883,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); @@ -2934,7 +2937,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text', phase: 'final_answer' }, }, - }, + } as never, undefined, undefined, ); @@ -2972,7 +2975,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text', phase: 'final_answer' }, }, - }, + } as never, undefined, undefined, ); @@ -3018,7 +3021,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); @@ -3027,7 +3030,7 @@ describe('createActivityPhaseWiring', () => { { id: 'boundary', delta: { content: { type: ContentTypes.TEXT, text: substantialText('Boundary result.') } }, - }, + } as never, undefined, undefined, ); @@ -3123,7 +3126,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text', phase: 'final_answer' }, }, - }, + } as never, undefined, undefined, ); @@ -3132,7 +3135,7 @@ describe('createActivityPhaseWiring', () => { { id: 'final-step', delta: { content: { type: ContentTypes.TEXT, text: 'Done.' } }, - }, + } as never, undefined, undefined, ); @@ -3194,7 +3197,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); @@ -3203,7 +3206,7 @@ describe('createActivityPhaseWiring', () => { { id: 'early-text', delta: { content: { type: ContentTypes.TEXT, text: 'Context for the earlier work.' } }, - }, + } as never, undefined, undefined, ); @@ -3305,7 +3308,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); @@ -3319,7 +3322,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); @@ -3328,7 +3331,7 @@ describe('createActivityPhaseWiring', () => { { id: 'parallel-text', delta: { content: { type: ContentTypes.TEXT, text: 'Context for the later work.' } }, - }, + } as never, undefined, undefined, ); @@ -3337,7 +3340,7 @@ describe('createActivityPhaseWiring', () => { { id: 'boundary', delta: { content: { type: ContentTypes.TEXT, text: substantialText('Boundary result.') } }, - }, + } as never, undefined, undefined, ); @@ -3402,7 +3405,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); @@ -3412,7 +3415,7 @@ describe('createActivityPhaseWiring', () => { { id: 'parallel-text', delta: { content: { type: ContentTypes.TEXT, text: 'Context for the later work.' } }, - }, + } as never, undefined, undefined, ); @@ -3421,7 +3424,7 @@ describe('createActivityPhaseWiring', () => { { id: 'boundary', delta: { content: { type: ContentTypes.TEXT, text: substantialText('Boundary result.') } }, - }, + } as never, undefined, undefined, ); @@ -3484,7 +3487,7 @@ describe('createActivityPhaseWiring', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', content_type: 'text' }, }, - }, + } as never, undefined, undefined, ); @@ -3493,7 +3496,7 @@ describe('createActivityPhaseWiring', () => { { id: 'boundary', delta: { content: { type: ContentTypes.TEXT, text: substantialText('Boundary result.') } }, - }, + } as never, undefined, undefined, ); @@ -3528,7 +3531,7 @@ describe('createAssistantPhaseStampingHandlers', () => { type: StepTypes.MESSAGE_CREATION, message_creation: { message_id: 'm', phase: 'commentary' }, }, - }, + } as never, undefined, undefined, ); @@ -3537,7 +3540,7 @@ describe('createAssistantPhaseStampingHandlers', () => { { id: 'commentary-step', delta: { content: { type: ContentTypes.TEXT, text: 'I will compare both paths.' } }, - }, + } as never, undefined, undefined, ); diff --git a/packages/api/src/agents/activityPhases/runtime.ts b/packages/api/src/agents/activityPhases/runtime.ts index c542d7c258e..1d0228f828e 100644 --- a/packages/api/src/agents/activityPhases/runtime.ts +++ b/packages/api/src/agents/activityPhases/runtime.ts @@ -415,13 +415,12 @@ export function createAssistantPhaseStampingHandlers( (part as { type?: string } | null)?.type === ContentTypes.TEXT ? { ...(part as Record), phase } : part; - const forwarded = { - ...(data as Record), + const forwarded = Object.assign({}, data, { delta: { ...delta, content: Array.isArray(raw) ? raw.map(stamp) : stamp(raw), }, - }; + }); return messageHandler.handle(event, forwarded, metadata, graph); }, }; diff --git a/packages/api/src/agents/hitl/resume.ts b/packages/api/src/agents/hitl/resume.ts index 36575f0c34a..833ace36f56 100644 --- a/packages/api/src/agents/hitl/resume.ts +++ b/packages/api/src/agents/hitl/resume.ts @@ -324,7 +324,7 @@ type ResumableRunStep = { export function normalizeResumeRunStepIndices( runSteps: readonly T[], - seedContent: readonly { type?: string; tool_call?: { id?: string } }[] = [], + seedContent: readonly ({ type?: string; tool_call?: { id?: string } } | undefined)[] = [], ): T[] { const toolCallIndices = new Map(); seedContent.forEach((part, index) => { @@ -361,7 +361,7 @@ export function hydrateResumeRunSteps( runSteps: readonly RunStep[], stepMap: Map | undefined, graph: { toolCallStepIds?: Map } | null | undefined, - seedContent: readonly { type?: string; tool_call?: { id?: string } }[] = [], + seedContent: readonly ({ type?: string; tool_call?: { id?: string } } | undefined)[] = [], ): void { for (const runStep of normalizeResumeRunStepIndices(runSteps, seedContent)) { if (!runStep?.id) { diff --git a/packages/api/src/agents/reasoningLabels/runtime.spec.ts b/packages/api/src/agents/reasoningLabels/runtime.spec.ts index c09ddfdb1f1..20aea85c126 100644 --- a/packages/api/src/agents/reasoningLabels/runtime.spec.ts +++ b/packages/api/src/agents/reasoningLabels/runtime.spec.ts @@ -91,21 +91,21 @@ function createHarness( type: StepTypes.MESSAGE_CREATION, message_creation: { content_type: ContentTypes.THINK }, }, - }, + } as never, metadata, ); }; const append = async (text: string, id = 'reasoning-1') => { await wrapped[GraphEvents.ON_REASONING_DELTA].handle( GraphEvents.ON_REASONING_DELTA, - reasoningDelta(id, text), + reasoningDelta(id, text) as never, ); }; const close = async (id = 'reasoning-1') => { await wrapped[GraphEvents.ON_RUN_STEP_CLOSED].handle(GraphEvents.ON_RUN_STEP_CLOSED, { id, status: 'completed', - }); + } as never); }; const settle = async () => { for (let i = 0; i < 5; i += 1) { diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 341a83df6d2..13b2725986b 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'crypto'; import { logger } from '@librechat/data-schemas'; import { ensureHandler } from '@langchain/core/callbacks/manager'; import { Run, Providers, Constants, HookRegistry } from '@librechat/agents'; @@ -1883,6 +1884,7 @@ export async function createRun({ } const streamLimits = resolveStreamLimits(agentsEndpointConfig); + const resolvedRunId = runId ?? randomUUID(); /** * Built as a variable (not an inline literal) so the extra @@ -1892,7 +1894,7 @@ export async function createRun({ * the field at the call site once the dependency is bumped. */ const runConfig = { - runId, + runId: resolvedRunId, graphConfig, tokenCounter, customHandlers, @@ -1959,7 +1961,7 @@ export async function createRun({ // tracing is enabled. Requires @librechat/agents >= 3.2.21. langfuse: buildLangfuseConfig({ appConfig, - runId, + runId: resolvedRunId, tenantId: tenantId ?? user?.tenantId, centralTraceExportEnabled, }), diff --git a/packages/api/src/agents/steering/__tests__/offset.spec.ts b/packages/api/src/agents/steering/__tests__/offset.spec.ts index f572f99b0cd..321b90086c7 100644 --- a/packages/api/src/agents/steering/__tests__/offset.spec.ts +++ b/packages/api/src/agents/steering/__tests__/offset.spec.ts @@ -27,11 +27,26 @@ describe('createSteerIndexOffsetHandlers', () => { ); const handler = wrapped![GraphEvents.ON_RUN_STEP]; - handler.handle(GraphEvents.ON_RUN_STEP, { id: 'step-1', index: 0 }, undefined, undefined); + handler.handle( + GraphEvents.ON_RUN_STEP, + { id: 'step-1', index: 0 } as never, + undefined, + undefined, + ); state.offset = 1; - handler.handle(GraphEvents.ON_RUN_STEP, { id: 'step-2', index: 1 }, undefined, undefined); + handler.handle( + GraphEvents.ON_RUN_STEP, + { id: 'step-2', index: 1 } as never, + undefined, + undefined, + ); state.offset = 2; - handler.handle(GraphEvents.ON_RUN_STEP, { id: 'step-3', index: 2 }, undefined, undefined); + handler.handle( + GraphEvents.ON_RUN_STEP, + { id: 'step-3', index: 2 } as never, + undefined, + undefined, + ); expect(calls.map((c) => (c.data as { index: number }).index)).toEqual([0, 2, 4]); }); @@ -47,14 +62,14 @@ describe('createSteerIndexOffsetHandlers', () => { const handler = wrapped![GraphEvents.ON_AGENT_UPDATE]; handler.handle( GraphEvents.ON_AGENT_UPDATE, - { agent_update: { index: 3, runId: 'run-1' } }, + { agent_update: { index: 3, runId: 'run-1' } } as never, undefined, undefined, ); state.offset = 2; handler.handle( GraphEvents.ON_AGENT_UPDATE, - { agent_update: { index: 4, runId: 'run-1' } }, + { agent_update: { index: 4, runId: 'run-1' } } as never, undefined, undefined, ); @@ -79,7 +94,7 @@ describe('createSteerIndexOffsetHandlers', () => { expect(wrapped![GraphEvents.ON_MESSAGE_DELTA]).toBe(passthrough); wrapped![GraphEvents.ON_RUN_STEP].handle( GraphEvents.ON_RUN_STEP, - { id: 'step-x' }, + { id: 'step-x' } as never, undefined, undefined, ); @@ -99,9 +114,19 @@ describe('createSteerIndexOffsetHandlers', () => { ); const handler = wrapped![GraphEvents.ON_RUN_STEP]; - handler.handle(GraphEvents.ON_RUN_STEP, { id: 'step-1', index: 0 }, undefined, undefined); + handler.handle( + GraphEvents.ON_RUN_STEP, + { id: 'step-1', index: 0 } as never, + undefined, + undefined, + ); state.offset = 1; - handler.handle(GraphEvents.ON_RUN_STEP, { id: 'step-2', index: 1 }, undefined, undefined); + handler.handle( + GraphEvents.ON_RUN_STEP, + { id: 'step-2', index: 1 } as never, + undefined, + undefined, + ); // seed offset (2) applies inside; steer offset applies on top expect(calls.map((c) => (c.data as { index: number }).index)).toEqual([2, 4]); diff --git a/packages/api/src/agents/steering/__tests__/runtime.spec.ts b/packages/api/src/agents/steering/__tests__/runtime.spec.ts index f4c471cb58c..f35d2908f81 100644 --- a/packages/api/src/agents/steering/__tests__/runtime.spec.ts +++ b/packages/api/src/agents/steering/__tests__/runtime.spec.ts @@ -98,7 +98,7 @@ describe('createSteerDrainHook', () => { }, }); - const output: SteerDrainOutput = await hook(batchInput(), abortSignal); + const output = (await hook(batchInput(), abortSignal)) as SteerDrainOutput; expect(applied).toEqual(['first', 'second']); expect(output.injectedMessages).toEqual([ { role: 'user', content: 'first', source: 'steer' }, @@ -156,7 +156,7 @@ describe('createSteerDrainHook', () => { }, }); - const output: SteerDrainOutput = await hook(batchInput(), abortSignal); + const output = (await hook(batchInput(), abortSignal)) as SteerDrainOutput; expect(output).toEqual({}); expect((await GenerationJobManager.steering.peek(streamId)).map((item) => item.text)).toEqual([ 'survives', @@ -194,7 +194,7 @@ describe('createSteerDrainHook', () => { buildMedia, }); - const output: SteerDrainOutput = await hook(batchInput(), abortSignal); + const output = (await hook(batchInput(), abortSignal)) as SteerDrainOutput; // buildMedia is consulted only for items that carry files. expect(buildMedia).toHaveBeenCalledTimes(1); expect(calls).toEqual(['apply:s1', 'apply:s2', 'media:s1']); @@ -227,7 +227,7 @@ describe('createSteerDrainHook', () => { }, }); - const output: SteerDrainOutput = await hook(batchInput(), abortSignal); + const output = (await hook(batchInput(), abortSignal)) as SteerDrainOutput; expect(appliedBeforeEncode).toBe(true); expect(output.injectedMessages).toEqual([ { role: 'user', content: 'must land first', source: 'steer' }, @@ -251,7 +251,7 @@ describe('createSteerDrainHook', () => { }, }); - const output: SteerDrainOutput = await hook(batchInput(), abortSignal); + const output = (await hook(batchInput(), abortSignal)) as SteerDrainOutput; expect(output.injectedMessages).toEqual([ { role: 'user', content: 'words survive', source: 'steer' }, ]); @@ -300,7 +300,7 @@ describe('createSteerPreemptBoundaryHook', () => { }, }); - const output: SteerDrainOutput = await hook(boundaryInput(), abortSignal); + const output = (await hook(boundaryInput(), abortSignal)) as SteerDrainOutput; expect(applied).toEqual(['first', 'second']); expect(output.injectedMessages).toEqual([ { role: 'user', content: 'first', source: 'steer' }, @@ -436,7 +436,7 @@ describe('createSteerPreemptBoundaryHook', () => { jobCreatedAt: job.createdAt, applySteer: jest.fn(), }); - const output: SteerDrainOutput = await hook(boundaryInput(), abortSignal); + const output = (await hook(boundaryInput(), abortSignal)) as SteerDrainOutput; expect(output.injectedMessages).toHaveLength(1); /** Both the drained id and the stale snapshot id are spent. */ @@ -485,7 +485,7 @@ describe('createSteerPreemptBoundaryHook', () => { }, }); - const output: SteerDrainOutput = await hook(boundaryInput(), abortSignal); + const output = (await hook(boundaryInput(), abortSignal)) as SteerDrainOutput; expect(output).toEqual({}); expect((await GenerationJobManager.steering.peek(streamId)).map((item) => item.text)).toEqual([ 'still injected', diff --git a/packages/api/src/agents/subagentThreads.ts b/packages/api/src/agents/subagentThreads.ts index e32854f4590..40e5af74d06 100644 --- a/packages/api/src/agents/subagentThreads.ts +++ b/packages/api/src/agents/subagentThreads.ts @@ -1146,7 +1146,7 @@ export class SubagentThreadTaskStore extends InMemorySubagentTaskStore { const snapshot = this.get(scopeId, taskId); const lockKey = snapshot?.threadId == null ? undefined : `${scopeId}\u0000${snapshot.threadId}`; const lease = lockKey == null ? undefined : this.activeThreads.get(lockKey); - if (lease?.taskId === taskId && lease.settling) { + if (snapshot != null && lease?.taskId === taskId && lease.settling) { return { status: 'not_running', task: snapshot }; } const result = super.control(scopeId, taskId, command); diff --git a/packages/api/src/stream/implementations/InMemoryJobStore.ts b/packages/api/src/stream/implementations/InMemoryJobStore.ts index 2ae4ad2d182..7718af55729 100644 --- a/packages/api/src/stream/implementations/InMemoryJobStore.ts +++ b/packages/api/src/stream/implementations/InMemoryJobStore.ts @@ -31,6 +31,7 @@ import { PAUSE_PERSISTENCE_TIMEOUT_ERROR, PAUSE_PERSISTENCE_TIMEOUT_MS, isPendingActionStale, + toWireRunSteps, } from '~/stream/interfaces/IJobStore'; import { isRecoveredSteerPayload, @@ -1466,7 +1467,7 @@ export class InMemoryJobStore implements IJobStoreV2 { // Dereference WeakRef - may return undefined if GC'd const graph = state.graphRef.deref(); - return graph?.contentData ?? []; + return toWireRunSteps(graph?.contentData ?? []); } /** diff --git a/packages/api/src/stream/implementations/RedisJobStore.ts b/packages/api/src/stream/implementations/RedisJobStore.ts index fd794c58540..56addef3465 100644 --- a/packages/api/src/stream/implementations/RedisJobStore.ts +++ b/packages/api/src/stream/implementations/RedisJobStore.ts @@ -34,6 +34,7 @@ import { PAUSE_PERSISTENCE_TIMEOUT_ERROR, PAUSE_PERSISTENCE_TIMEOUT_MS, isPendingActionStale, + toWireRunSteps, } from '~/stream/interfaces/IJobStore'; import { MAX_COALESCED_BYTES, @@ -3661,7 +3662,7 @@ export class RedisJobStore implements IJobStoreV2 { g.getRunSteps(), ); if (localSteps && localSteps.length > 0) { - return localSteps; + return toWireRunSteps(localSteps); } } // Note: Don't delete from cache here - graph may still be valid diff --git a/packages/api/src/stream/interfaces/IJobStore.ts b/packages/api/src/stream/interfaces/IJobStore.ts index 8e469d08578..ed1d5f44ca6 100644 --- a/packages/api/src/stream/interfaces/IJobStore.ts +++ b/packages/api/src/stream/interfaces/IJobStore.ts @@ -4,12 +4,63 @@ import type { TPendingSteer, UserSubmittedMessageFieldPath, } from 'librechat-data-provider'; -import type { StandardGraph } from '@librechat/agents'; +import type { RunStep, StandardGraph } from '@librechat/agents'; import type { ActivityPhaseSnapshot } from '~/agents/activityPhases/runtime'; import type { ResolvedAskUserQuestion } from '~/agents/hitl/resume'; import type { RecoveredSteerPayload } from '../SteerRecovery'; import type { MCPRuntimeRequestBody } from '~/mcp/types'; +/** + * Rewrites string-enum members to their literal values, recursively. The SDK and + * data-provider declare nominally distinct enums (`ContentTypes`, `StepTypes`, ...) + * with identical string values; erasing that nominality is what lets the two run-step + * contracts be compared structurally. + */ +type WireShape = T extends string + ? `${T}` + : T extends readonly (infer U)[] + ? WireShape[] + : T extends object + ? { [K in keyof T]: WireShape } + : T; + +type StaticAssert = T; + +/** + * Compile-time proof that the SDK run step and the wire contract (`Agents.RunStep`) + * agree structurally once enum nominality is erased: any added, removed, retyped, or + * newly optional SDK field fails these assertions, so drift cannot silently enter + * resume state through `toWireRunSteps`. + * + * `summary.content` is the one deliberately unchecked field: the SDK reuses its full + * `MessageContentComplex` union there, while the wire contract narrows it to the plain + * text blocks summarization actually emits. That narrowing is the single semantic + * judgment this conversion vouches for. + */ +type _WireRunStepContractHolds = StaticAssert< + WireShape> extends WireShape> + ? true + : false +>; + +type _WireSummaryContractHolds = StaticAssert< + WireShape, 'content'>> extends WireShape< + Omit, 'content'> + > + ? true + : false +>; + +/** + * Run steps living on the SDK graph serialize to exactly the wire shape + * `Agents.RunStep` describes; the assertion is safe because + * `_WireRunStepContractHolds` above proves the contracts identical modulo the + * nominally-split enums, which share their string values at runtime. + */ +export function toWireRunSteps(steps: readonly RunStep[]): Agents.RunStep[] { + return steps as Agents.RunStep[]; +} + /** * A pause owner has this long to durably persist the interrupted turn before * the barrier is considered abandoned. An abandoned barrier must fail closed: diff --git a/packages/api/src/types/agents.d.ts b/packages/api/src/types/agents.d.ts deleted file mode 100644 index 26c14e012c1..00000000000 --- a/packages/api/src/types/agents.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * `@librechat/agents` publishes its declaration files with its internal `@/*` path aliases - * unrewritten, so a consumer cannot resolve them. `types/llm.d.ts` imports `Providers` that - * way, which leaves `ProviderOptionsMap`'s computed keys unresolved and collapses - * `keyof ProviderOptionsMap` to `number`. Until v3.6.16 that only degraded `LLMConfig` - * silently; v3.6.16 made `SharedLLMConfig` generic over that key union, so `provider` became - * `number | RuntimeProviderName` and no real provider was assignable to it. - * - * Declaring the one alias `llm.d.ts` needs restores the enum, and with it the provider key - * union. Remove this once the SDK ships declarations with its aliases resolved — note that - * mapping every `@/*` alias instead unmasks a large backlog of latent errors elsewhere in - * this package, so widening it is a separate cleanup rather than a drop-in improvement. - */ -declare module '@/common' { - export { Providers } from '@librechat/agents'; -} diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json index f3e4a5bb0aa..10246b7aa5c 100644 --- a/packages/api/tsconfig.json +++ b/packages/api/tsconfig.json @@ -19,7 +19,11 @@ "noEmit": true, "sourceMap": true, "paths": { - "~/*": ["./src/*"] + "~/*": ["./src/*"], + // @librechat/agents ships declaration files that import its internal "@/" alias, which + // package exports cannot map; resolve them into its published types until the SDK + // rewrites the aliases at build time. + "@/*": ["../../node_modules/@librechat/agents/dist/types/*"] } }, "ts-node": { diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index c24f1dfeec5..39d62751b4d 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -1,10 +1,5 @@ import { z } from 'zod'; -import type { - TMessageContentParts, - AgentSubagentGraph, - FunctionToolCall, - FunctionTool, -} from './types/assistants'; +import type { TMessageContentParts, AgentSubagentGraph, FunctionTool } from './types/assistants'; import type { SearchResultData } from './types/web'; import type { TFile } from './types/files'; import { userSubmittedMessageFieldPathSchema } from './filters'; @@ -391,7 +386,9 @@ export const ImageVisionTool: FunctionTool = { }, }; -export const isImageVisionTool = (tool: FunctionTool | FunctionToolCall) => +/** Structural on purpose: accepts assistants tools/tool calls and agents function tool + * calls alike — the check only ever reads `type` and `function.name`. */ +export const isImageVisionTool = (tool: { type?: string; function?: { name?: string } }) => tool.type === 'function' && tool.function?.name === ImageVisionTool.function?.name; export const openAISettings = { diff --git a/packages/data-provider/src/types/agents.ts b/packages/data-provider/src/types/agents.ts index 35d2e39c9f8..a51d2870d90 100644 --- a/packages/data-provider/src/types/agents.ts +++ b/packages/data-provider/src/types/agents.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-namespace */ import type { TTokenUsageEvent, TContextUsageEvent, TPendingSteer } from './runs'; -import type { FunctionToolCall, SummaryContentPart } from './assistants'; import type { TAttachment, TPlugin } from 'src/schemas'; +import type { SummaryContentPart } from './assistants'; import { StepTypes, ContentTypes, ToolCallTypes } from './runs'; export namespace Agents { @@ -67,8 +67,9 @@ export namespace Agents { * A call to a tool. */ export type ToolCall = { - /** Type ("tool_call") according to Assistants Tool Call Structure */ - type: ToolCallTypes.TOOL_CALL; + /** Type ("tool_call") according to Assistants Tool Call Structure; optional literal + * form included to mirror langchain's ToolCall, whose `type` is optional. */ + type?: ToolCallTypes.TOOL_CALL | 'tool_call'; /** The name of the tool to be called */ name: string; @@ -203,7 +204,8 @@ export namespace Agents { groupId?: number; // #new stepDetails: StepDetails; summary?: SummaryContentPart; - usage: null | object; + /** Optional to mirror the agents SDK, which omits usage until a step reports it. */ + usage?: null | object; /** Epoch ms the step was opened. Emitted by `@librechat/agents` >= 3.4.6. */ created_at?: number; status?: RunStepStatus; @@ -324,7 +326,7 @@ export namespace Agents { }; export type ToolCallsDetails = { type: StepTypes.TOOL_CALLS; - tool_calls: AgentToolCall[]; + tool_calls?: AgentToolCall[]; }; export type ToolCallDelta = { type: StepTypes.TOOL_CALLS | string; @@ -338,7 +340,22 @@ export namespace Agents { description?: string; }; }; - export type AgentToolCall = FunctionToolCall | ToolCall; + /** + * Mirrors the agents SDK's function tool-call variant: `arguments` may arrive as a + * parsed object, and `output` is attached by LibreChat aggregation only once available + * (the legacy assistants `FunctionToolCall` requires both as string/present). + */ + export type AgentFunctionToolCall = { + id: string; + type: 'function'; + function: { + name: string; + arguments: string | object; + output?: string | null; + }; + }; + + export type AgentToolCall = AgentFunctionToolCall | ToolCall; /** * Human-in-the-loop interrupt categories. The discriminator on @@ -734,8 +751,20 @@ export type GraphEdge = { * * For handoff edges: Description for the input parameter that the handoff tool accepts, * allowing the supervisor to pass specific instructions/context to the transferred agent. + * + * The callback receives a minimal structural view of the run's messages (data-provider + * cannot depend on langchain's BaseMessage): every langchain message satisfies + * `{ content: unknown }`, and callbacks typed against richer structural message shapes + * remain assignable. The promise branch mirrors the agents SDK signature exactly — + * widening it (e.g. to `Promise`) would break assignability of + * stored edges into the SDK's `GraphEdge`. */ - prompt?: string | ((messages: BaseMessage[], runStartIndex: number) => string | undefined); + prompt?: + | string + | (( + messages: { content: unknown }[], + runStartIndex: number, + ) => string | Promise | undefined); /** * When true, excludes messages from startIndex when adding prompt. * Automatically set to true when {results} variable is used in prompt. From 18cc47128daaf2099ff26b73c1e2907957438fff Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 24 Aug 2026 08:54:58 -0400 Subject: [PATCH 07/20] chore: bump agents sdk to v3.7.0 (#15163) --- api/package.json | 2 +- package-lock.json | 10 +++++----- packages/api/package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/api/package.json b/api/package.json index 396f6fe83ed..49ebfa9df32 100644 --- a/api/package.json +++ b/api/package.json @@ -46,7 +46,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "5.1.6", - "@librechat/agents": "^3.6.16", + "@librechat/agents": "^3.7.0", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/package-lock.json b/package-lock.json index 248b0948b1c..fa0b0d14f6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "5.1.6", - "@librechat/agents": "^3.6.16", + "@librechat/agents": "^3.7.0", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -10630,9 +10630,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.6.16", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.6.16.tgz", - "integrity": "sha512-V8WQTC4gwoUdej9Xg5slaw2QgypVhTkosNp8UL/ANh9OlS7zSKgyu5/iDz8ZXQBywKNdt7y6k7+cO9W+I6kezA==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.7.0.tgz", + "integrity": "sha512-U/OewG2fUyrzqT18pz054Aq56GlH9mLJDL7+eGgWchxhLM5T8N+CDMEvev7O3CiSvvkX7e5F1MCAHQNOlCgZBQ==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.115.0", @@ -42822,7 +42822,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "5.1.6", - "@librechat/agents": "^3.6.16", + "@librechat/agents": "^3.7.0", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.30.0", "@opentelemetry/api": "^1.9.0", diff --git a/packages/api/package.json b/packages/api/package.json index 5fe51278139..ac741c15ed4 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -113,7 +113,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.8.0", "@keyv/redis": "5.1.6", - "@librechat/agents": "^3.6.16", + "@librechat/agents": "^3.7.0", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.30.0", "@opentelemetry/api": "^1.9.0", From f10fcd7d19bad75bc671ff357c36985502e2d0f9 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 24 Aug 2026 09:04:24 -0400 Subject: [PATCH 08/20] =?UTF-8?q?=F0=9F=8E=B0=20ci:=20Vote=20on=20the=20Fu?= =?UTF-8?q?ll=20Mock=20Suite=20to=20End=20Phantom=20Spec=20Trials=20(#1516?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: votes run the full mock suite; covered list from Playwright's own discovery The vote workflow passed the merged PR's skippable tier as CLI path filters, but playwright.config.mock.ts scopes discovery to testDir specs/mock/ — tier entries outside that directory matched nothing, and the covered-list log line still claimed them. Run 32701691037 proves it: a11y/keys/messages in the covered list, zero of their tests executed, '120 passed' all from specs/mock/. The graduation ledger was minting clean trials for specs that never ran. Now every dev push runs the full mock suite (no path filters to mismatch), the covered list is derived from playwright --list --reporter=json (git-enumeration fallback over the same testDir), and each merge is one trial for every pool spec — ~4x faster accrual toward the pre-registered graduation bars, plus the post-merge Playwright safety net the jest workflows already have. Timeout 30->45 for the wider run; newest merge still cancels older votes; observe-only, continue-on-error, kill switch CODEGRAPH_E2E_VOTES unchanged. * ci: covered list from executed results, not discovery (Codex P1) Env-gated suites (mcp-tool-list-changed needs E2E_MCP_LIST_CHANGED, enforced- model-specs needs E2E_MODEL_SPECS_ENFORCE) are discovered by --list yet skip every test under the vote job's default env — counting them as covered would mint phantom trials, the exact class this PR exists to kill. The run now emits line+json reporters and the ledger step derives covered from specs with at least one non-skipped test outcome; no results json means no trials logged. Verified against a synthetic suite: gated spec excluded, nested dirs handled, crash branch logs nothing. --- .github/workflows/codegraph-e2e-votes.yml | 125 +++++++++------------- 1 file changed, 51 insertions(+), 74 deletions(-) diff --git a/.github/workflows/codegraph-e2e-votes.yml b/.github/workflows/codegraph-e2e-votes.yml index fdcbe79626c..35637a3041a 100644 --- a/.github/workflows/codegraph-e2e-votes.yml +++ b/.github/workflows/codegraph-e2e-votes.yml @@ -1,15 +1,22 @@ # Codegraph e2e VOTES — observe-only, post-merge, time-boxed. # # Playwright never runs on pushes to dev, so evidence for the e2e skip election would -# otherwise wait on rare organic PR spec failures. This workflow runs EXACTLY the skippable -# tier the merged PR's selection computed — every merge becomes a direct trial of "would -# skipping these specs have missed a failure". A green run is a confirmation vote; a failing -# spec here is a tier-miss vote counted AGAINST enabling skipping. The shadow evaluator on -# the codegraph droplet harvests these runs and attributes them back to the merged PR. +# otherwise wait on rare organic PR spec failures. This workflow runs the FULL mock suite on +# every merge: each run is one graduation trial for every spec it executes, and doubles as +# the post-merge safety net the jest workflows already have via their dev-push triggers. # -# It cannot fail the branch: the tier lookup exits 0 on every path and the test step is -# continue-on-error. The newest merge cancels older vote runs. The whole campaign switches -# off by setting repo variable CODEGRAPH_E2E_VOTES=off once the election passes. +# It previously ran only the merged PR's skippable tier, passing the tier as CLI path +# filters. playwright.config.mock.ts scopes discovery to testDir specs/mock/, so tier +# entries outside that directory matched nothing — and the covered-list log line still +# claimed them, minting graduation trials for specs that never executed (run 32701691037: +# a11y/keys/messages in the covered list, zero of their tests run). The covered list below +# is therefore derived from the run's EXECUTED results — discovery is not enough either, +# since env-gated suites self-skip under this job's default env — and the run takes no +# path filters at all. +# +# It cannot fail the branch: the test step is continue-on-error. The newest merge cancels +# older vote runs. The whole campaign switches off by setting repo variable +# CODEGRAPH_E2E_VOTES=off once the election passes. name: Codegraph E2E Votes on: @@ -20,6 +27,7 @@ on: - '**' - '!**.md' - '!.github/workflows/**' + - '.github/workflows/codegraph-e2e-votes.yml' permissions: contents: read @@ -34,10 +42,10 @@ env: jobs: vote: - name: vote (skippable tier) + name: vote (full suite) if: vars.CODEGRAPH_E2E_VOTES != 'off' runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 45 env: CI: 'true' E2E_CHROMIUM_CHANNEL: chrome @@ -45,56 +53,12 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Ask codegraph for this merge's skippable tier - id: tiers - env: - URL: ${{ secrets.CODEGRAPH_URL }} - TOKEN: ${{ secrets.CODEGRAPH_TOKEN }} - GH_TOKEN: ${{ github.token }} - run: | - set +e - N=0 - if [ -n "$URL" ] && [ -n "$TOKEN" ]; then - gh api "repos/${{ github.repository }}/commits/${{ github.sha }}" \ - --jq '[.files[] | {path: .filename, status: .status}]' > files.json 2>/dev/null - if [ -s files.json ]; then - jq -c '{files: .}' files.json > body.json - RESP=$(curl -sS -m 45 -H "Authorization: Bearer $TOKEN" \ - -H 'content-type: application/json' --data-binary @body.json "$URL/v1/select") - # fail_open reflects the JEST floors (root config, lockfile, stale graph); the - # e2e tiers come from the testid bridge and are valid whenever they computed at - # all. The old fail-open skip silently excused exactly the big backend merges - # whose trials matter most (LibreChat#14957's merge produced no vote because - # api/package.json tripped the jest floor). Tiers present => vote. - if ! echo "$RESP" | jq -e '.e2e.skippable' >/dev/null 2>&1; then - echo "codegraph unavailable or no tiers; skipping" - else - echo "$RESP" | jq -r '.e2e.skippable[]' | sed 's|^e2e/||' > skippable.txt - N=$(wc -l < skippable.txt | tr -d ' ') - fi - else - echo "could not read merge commit files; skipping" - fi - else - echo "no codegraph config; skipping" - fi - echo "codegraph-votes: running $N skippable specs" - # The exact list, one log line: the shadow's per-spec graduation ledger counts a clean - # trial for every spec a green vote run covered, and until this line existed it had to - # approximate coverage from the decision event's tier (drift: the tier is recomputed - # here at the merge commit against a possibly newer graph head). - if [ "$N" != "0" ]; then echo "codegraph-votes-specs: $(tr '\n' ' ' < skippable.txt)"; fi - echo "count=$N" >> "$GITHUB_OUTPUT" - exit 0 - - name: Use Node.js 24.16.0 - if: steps.tiers.outputs.count != '0' uses: actions/setup-node@v5 with: node-version: '24.16.0' - name: Restore node_modules cache - if: steps.tiers.outputs.count != '0' id: cache-node-modules uses: actions/cache@v5 with: @@ -109,11 +73,10 @@ jobs: key: node-modules-e2e-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }} - name: Install dependencies - if: steps.tiers.outputs.count != '0' && steps.cache-node-modules.outputs.cache-hit != 'true' + if: steps.cache-node-modules.outputs.cache-hit != 'true' run: npm ci - name: Restore data-provider build cache - if: steps.tiers.outputs.count != '0' id: cache-data-provider uses: actions/cache@v5 with: @@ -121,11 +84,10 @@ jobs: key: build-data-provider-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} - name: Build data-provider - if: steps.tiers.outputs.count != '0' && steps.cache-data-provider.outputs.cache-hit != 'true' + if: steps.cache-data-provider.outputs.cache-hit != 'true' run: npm run build:data-provider - name: Restore data-schemas build cache - if: steps.tiers.outputs.count != '0' id: cache-data-schemas uses: actions/cache@v5 with: @@ -133,11 +95,10 @@ jobs: key: build-data-schemas-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} - name: Build data-schemas - if: steps.tiers.outputs.count != '0' && steps.cache-data-schemas.outputs.cache-hit != 'true' + if: steps.cache-data-schemas.outputs.cache-hit != 'true' run: npm run build:data-schemas - name: Restore api build cache - if: steps.tiers.outputs.count != '0' id: cache-api uses: actions/cache@v5 with: @@ -145,11 +106,10 @@ jobs: key: build-api-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }} - name: Build api - if: steps.tiers.outputs.count != '0' && steps.cache-api.outputs.cache-hit != 'true' + if: steps.cache-api.outputs.cache-hit != 'true' run: npm run build:api - name: Restore client-package build cache - if: steps.tiers.outputs.count != '0' id: cache-client-package uses: actions/cache@v5 with: @@ -157,11 +117,10 @@ jobs: key: build-client-package-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} - name: Build client-package - if: steps.tiers.outputs.count != '0' && steps.cache-client-package.outputs.cache-hit != 'true' + if: steps.cache-client-package.outputs.cache-hit != 'true' run: npm run build:client-package - name: Restore client app build cache - if: steps.tiers.outputs.count != '0' id: cache-client-app uses: actions/cache@v5 with: @@ -169,24 +128,21 @@ jobs: key: build-client-app-e2e-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'client/src/**', 'client/public/**', 'client/scripts/post-build.cjs', 'client/index.html', 'client/package.json', 'client/vite.config.*', 'client/tsconfig*.json', 'client/tailwind.config.*', 'client/postcss.config.*', 'packages/client/src/**', 'packages/client/tailwind.preset.cjs', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} - name: Build client app - if: steps.tiers.outputs.count != '0' && steps.cache-client-app.outputs.cache-hit != 'true' + if: steps.cache-client-app.outputs.cache-hit != 'true' run: npm run build:client - name: Verify Chrome is present - if: steps.tiers.outputs.count != '0' run: google-chrome --version # ffmpeg for retry video — see the note in playwright-mock.yml. - name: Resolve Playwright version id: playwright-version - if: steps.tiers.outputs.count != '0' run: | version=$(node -p "require('./package-lock.json').packages['node_modules/playwright-core'].version") echo "version=${version}" >> "$GITHUB_OUTPUT" - name: Restore Playwright ffmpeg cache id: cache-ffmpeg - if: steps.tiers.outputs.count != '0' uses: actions/cache/restore@v5 with: path: ~/.cache/ms-playwright @@ -194,7 +150,7 @@ jobs: - name: Install Playwright ffmpeg (best effort) id: install-ffmpeg - if: steps.tiers.outputs.count != '0' && steps.cache-ffmpeg.outputs.cache-hit != 'true' + if: steps.cache-ffmpeg.outputs.cache-hit != 'true' timeout-minutes: 3 continue-on-error: true run: | @@ -202,7 +158,7 @@ jobs: .github/scripts/verify-playwright-ffmpeg.sh - name: Save Playwright ffmpeg cache - if: steps.tiers.outputs.count != '0' && steps.install-ffmpeg.outcome == 'success' + if: steps.install-ffmpeg.outcome == 'success' continue-on-error: true uses: actions/cache/save@v5 with: @@ -211,15 +167,36 @@ jobs: # Optional fonts only — see the note in playwright-mock.yml. - name: Install optional Playwright font dependencies (best effort) - if: steps.tiers.outputs.count != '0' timeout-minutes: 4 continue-on-error: true run: .github/scripts/install-playwright-fonts.sh - - name: Vote — run the skippable tier (cannot fail the branch) - if: steps.tiers.outputs.count != '0' + - name: Vote — run the full mock suite (cannot fail the branch) continue-on-error: true - run: npx playwright test --config=e2e/playwright.config.mock.ts $(tr '\n' ' ' < skippable.txt) + env: + PLAYWRIGHT_JSON_OUTPUT_NAME: pw-results.json + run: npx playwright test --config=e2e/playwright.config.mock.ts --reporter=line,json + + - name: Ledger — log the specs that actually executed + run: | + set +e + # The shadow's per-spec graduation ledger counts a clean trial for every spec a green + # run covered, so the covered list must come from EXECUTED tests, not from discovery: + # env-gated suites (mcp-tool-list-changed needs E2E_MCP_LIST_CHANGED, enforced-model- + # specs needs E2E_MODEL_SPECS_ENFORCE) are discovered by --list yet skip every test + # under this job's default env — counting them as covered would mint phantom trials, + # the exact bug this workflow revision exists to kill (Codex P1 on #15162). A spec is + # covered iff at least one of its tests reached a non-skipped outcome. + if jq -e '.suites' pw-results.json >/dev/null 2>&1; then + jq -r '[.suites[] | recurse(.suites[]?) | .specs[]? | select([.tests[]?.status] | any(. != "skipped")) | .file] | unique | .[]' pw-results.json \ + | sed 's|^|specs/mock/|' > covered.txt + N=$(wc -l < covered.txt | tr -d ' ') + echo "codegraph-votes: running $N specs (executed, full suite)" + if [ "$N" != "0" ]; then echo "codegraph-votes-specs: $(tr '\n' ' ' < covered.txt)"; fi + else + echo "codegraph-votes: no results json — run crashed before reporting; no trials logged" + fi + exit 0 - name: Done if: always() From bf1e13b8068b07d9d23bf25ac159ecb3b9e504a9 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:10:03 +0200 Subject: [PATCH 09/20] =?UTF-8?q?=F0=9F=A5=81=20fix:=20Compare=20TOTP=20Co?= =?UTF-8?q?des=20in=20Constant=20Time=20(#15157)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: pacocartones --- api/server/services/twoFactorService.js | 29 +++++++++++++- api/server/services/twoFactorService.spec.js | 42 ++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 api/server/services/twoFactorService.spec.js diff --git a/api/server/services/twoFactorService.js b/api/server/services/twoFactorService.js index 313c5571339..081fa611bdf 100644 --- a/api/server/services/twoFactorService.js +++ b/api/server/services/twoFactorService.js @@ -1,4 +1,4 @@ -const { webcrypto } = require('node:crypto'); +const { webcrypto, timingSafeEqual } = require('node:crypto'); const { hashBackupCode, decryptV3, decryptV2 } = require('@librechat/data-schemas'); const { updateUser } = require('~/models'); @@ -102,6 +102,31 @@ const generateTOTP = async (secret, forTime = Date.now()) => { return code; }; +/** + * Constant-time comparison of a candidate 2FA code against the expected value. + * A plain `===` comparison short-circuits at the first differing character, so + * an attacker submitting codes to the 2FA verification endpoint could, in + * principle, learn how many leading digits are correct from the response time. + * Codes are of a fixed, public length, so returning early on a length mismatch + * (or a non-string input) leaks nothing secret while keeping the match path + * timing-independent. Mirrors the `crypto.timingSafeEqual(Buffer.from(...))` + * pattern already used for CSRF token checks in `packages/api`. + * @param {string} expected + * @param {string} candidate + * @returns {boolean} + */ +const constantTimeEqual = (expected, candidate) => { + if (typeof expected !== 'string' || typeof candidate !== 'string') { + return false; + } + const expectedBuffer = Buffer.from(expected, 'utf8'); + const candidateBuffer = Buffer.from(candidate, 'utf8'); + if (expectedBuffer.length !== candidateBuffer.length) { + return false; + } + return timingSafeEqual(expectedBuffer, candidateBuffer); +}; + /** * Verifies a TOTP token by checking a ±1 time step window. * @param {string} secret @@ -113,7 +138,7 @@ const verifyTOTP = async (secret, token) => { const currentTime = Date.now(); for (let offset = -1; offset <= 1; offset++) { const expected = await generateTOTP(secret, currentTime + offset * timeStepMS); - if (expected === token) { + if (constantTimeEqual(expected, token)) { return true; } } diff --git a/api/server/services/twoFactorService.spec.js b/api/server/services/twoFactorService.spec.js new file mode 100644 index 00000000000..81cd03d152b --- /dev/null +++ b/api/server/services/twoFactorService.spec.js @@ -0,0 +1,42 @@ +const crypto = require('node:crypto'); + +jest.mock('node:crypto', () => { + const actual = jest.requireActual('node:crypto'); + return { + ...actual, + timingSafeEqual: jest.fn((a, b) => actual.timingSafeEqual(a, b)), + }; +}); + +jest.mock('@librechat/data-schemas', () => ({ + hashBackupCode: jest.fn(), + decryptV3: jest.fn(), + decryptV2: jest.fn(), +})); + +jest.mock('~/models', () => ({ updateUser: jest.fn() })); + +const { generateTOTP, verifyTOTP, generateTOTPSecret } = require('./twoFactorService'); + +describe('verifyTOTP', () => { + it('accepts a valid current TOTP code', async () => { + const secret = generateTOTPSecret(); + const code = await generateTOTP(secret); + await expect(verifyTOTP(secret, code)).resolves.toBe(true); + }); + + it('rejects an invalid code of the same length', async () => { + const secret = generateTOTPSecret(); + const code = await generateTOTP(secret); + const wrong = code === '000000' ? '111111' : '000000'; + await expect(verifyTOTP(secret, wrong)).resolves.toBe(false); + }); + + it('compares codes in constant time via crypto.timingSafeEqual', async () => { + const secret = generateTOTPSecret(); + const code = await generateTOTP(secret); + crypto.timingSafeEqual.mockClear(); + await verifyTOTP(secret, code); + expect(crypto.timingSafeEqual).toHaveBeenCalled(); + }); +}); From 6f3d303985e44a498216272ac02fd65856b451fc Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 24 Aug 2026 10:33:12 -0400 Subject: [PATCH 10/20] =?UTF-8?q?=F0=9F=93=8D=20ci:=20Pin=20the=20Votes=20?= =?UTF-8?q?Ledger=20Results=20JSON=20to=20an=20Absolute=20Path=20(#15166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First live full-suite vote run (32731087273, 198 passed) wrote e2e/pw-results.json while the ledger step looked in the repo root and took the fail-safe branch: zero trials logged. A relative PLAYWRIGHT_JSON_OUTPUT_NAME resolves against the config directory, not cwd — reproduced synthetically with the config in a subdirectory. Absolute workspace path on both the reporter env and the ledger read. --- .github/workflows/codegraph-e2e-votes.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codegraph-e2e-votes.yml b/.github/workflows/codegraph-e2e-votes.yml index 35637a3041a..b3783e26588 100644 --- a/.github/workflows/codegraph-e2e-votes.yml +++ b/.github/workflows/codegraph-e2e-votes.yml @@ -174,7 +174,11 @@ jobs: - name: Vote — run the full mock suite (cannot fail the branch) continue-on-error: true env: - PLAYWRIGHT_JSON_OUTPUT_NAME: pw-results.json + # Absolute on purpose: Playwright resolves a relative PLAYWRIGHT_JSON_OUTPUT_NAME + # against the CONFIG directory (e2e/), not the working directory — the first live run + # wrote e2e/pw-results.json while the ledger looked in the repo root and logged zero + # trials (fail-safe, but a silent no-op). + PLAYWRIGHT_JSON_OUTPUT_NAME: ${{ github.workspace }}/pw-results.json run: npx playwright test --config=e2e/playwright.config.mock.ts --reporter=line,json - name: Ledger — log the specs that actually executed @@ -187,8 +191,8 @@ jobs: # under this job's default env — counting them as covered would mint phantom trials, # the exact bug this workflow revision exists to kill (Codex P1 on #15162). A spec is # covered iff at least one of its tests reached a non-skipped outcome. - if jq -e '.suites' pw-results.json >/dev/null 2>&1; then - jq -r '[.suites[] | recurse(.suites[]?) | .specs[]? | select([.tests[]?.status] | any(. != "skipped")) | .file] | unique | .[]' pw-results.json \ + if jq -e '.suites' "$GITHUB_WORKSPACE/pw-results.json" >/dev/null 2>&1; then + jq -r '[.suites[] | recurse(.suites[]?) | .specs[]? | select([.tests[]?.status] | any(. != "skipped")) | .file] | unique | .[]' "$GITHUB_WORKSPACE/pw-results.json" \ | sed 's|^|specs/mock/|' > covered.txt N=$(wc -l < covered.txt | tr -d ' ') echo "codegraph-votes: running $N specs (executed, full suite)" From 5a8700643c415551a6f0927f7c8be730ff10f4ee Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 24 Aug 2026 10:33:47 -0400 Subject: [PATCH 11/20] =?UTF-8?q?=E2=9A=A1=20perf:=20Build=20the=20Memory?= =?UTF-8?q?=20Message=20Copy=20Only=20When=20Something=20Reads=20It=20(#15?= =?UTF-8?q?164)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildMessages formatted every history row twice per turn — a prompt copy and a memory copy — then discarded the entire memory payload unless some row carried fileContext, which is the rare case. The memory copy has exactly two consumers: that payload, and the canonical recount of a row, where it is content-identical to the prompt copy unless the row itself has fileContext. So the prompt copy is now the recount surface for context-free rows, a fileContext row builds its memory copy at recount time, and the full memory payload is assembled in a deferred pass — same formatting, same per-row merge order — only once a row has proven the payload will be kept. The common turn formats each row once instead of twice and no longer allocates a payload it throws away. Also forwards the run's useLegacyContent to formatAgentMessages as the new legacyContent option, inert on the current SDK release: once the SDK change ships, text history is emitted pre-flattened so the per-request legacy projection stops cloning every message and the context meter's identity-based count reuse holds across the projection. --- api/server/controllers/agents/client.js | 55 ++++++++++++++++---- api/server/controllers/agents/client.test.js | 38 ++++++++++++++ 2 files changed, 83 insertions(+), 10 deletions(-) diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 0332bdddc12..320abe0691d 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -1693,13 +1693,36 @@ class AgentClient extends BaseClient { let hasFileContext = false; let promptTokenTotal = 0; const encoding = this.getEncoding(); - const formattedMessages = orderedMessages.map((message, i) => { - const formattedMessage = formatMessage({ + /** + * Rebuilds the memory-side copy of one source row: the same formatting and + * per-message merges as the prompt copy, minus the fileContext prepend. + * Only materialized when something actually consumes it — the canonical + * recount of a fileContext row, or the memory payload once any row proves + * to carry fileContext — instead of unconditionally formatting every row + * twice per turn. + */ + const buildMemoryFormattedMessage = (message) => { + const memoryFormattedMessage = formatMessage({ message, userName: this.options?.name, assistantName: this.options?.modelLabel, }); - const memoryFormattedMessage = formatMessage({ + const sourceMessageId = message.messageId ?? message.id; + if (typeof sourceMessageId === 'string' && sourceMessageId.length > 0) { + memoryFormattedMessage.messageId = sourceMessageId; + } + if (Array.isArray(message.quotes) && message.quotes.length > 0) { + prependQuotes(memoryFormattedMessage, message.quotes); + } + const turnFiles = this.message_file_map?.[message.messageId] ?? message.files; + applyAttachmentOnlyText(memoryFormattedMessage, turnFiles); + return memoryFormattedMessage; + }; + /** Memory copies built for canonical recounts, reused by the memory payload pass. */ + const memoryFormattedMessages = []; + + const formattedMessages = orderedMessages.map((message, i) => { + const formattedMessage = formatMessage({ message, userName: this.options?.name, assistantName: this.options?.modelLabel, @@ -1707,7 +1730,6 @@ class AgentClient extends BaseClient { const sourceMessageId = message.messageId ?? message.id; if (typeof sourceMessageId === 'string' && sourceMessageId.length > 0) { formattedMessage.messageId = sourceMessageId; - memoryFormattedMessage.messageId = sourceMessageId; } /** @@ -1732,7 +1754,6 @@ class AgentClient extends BaseClient { */ if (Array.isArray(message.quotes) && message.quotes.length > 0) { prependQuotes(formattedMessage, message.quotes); - prependQuotes(memoryFormattedMessage, message.quotes); } /** @@ -1746,9 +1767,6 @@ class AgentClient extends BaseClient { */ const turnFiles = this.message_file_map?.[message.messageId] ?? message.files; applyAttachmentOnlyText(formattedMessage, turnFiles); - applyAttachmentOnlyText(memoryFormattedMessage, turnFiles); - - memoryPayload.push(memoryFormattedMessage); const dbTokenCount = Number(orderedMessages[i].tokenCount); const hasDbTokenCount = Number.isFinite(dbTokenCount) && dbTokenCount > 0; @@ -1766,7 +1784,15 @@ class AgentClient extends BaseClient { let canonicalTokenCount = hasDbTokenCount ? dbTokenCount : 0; if (needsCanonicalTokenCount) { - canonicalTokenCount = countFormattedMessageTokens(memoryFormattedMessage, encoding); + /** Without fileContext the memory copy is content-identical to the + * prompt copy, so the prompt copy is the counting surface; with it, + * the canonical count must exclude the prepended context. */ + let countSurface = formattedMessage; + if (message.fileContext) { + memoryFormattedMessages[i] = buildMemoryFormattedMessage(message); + countSurface = memoryFormattedMessages[i]; + } + canonicalTokenCount = countFormattedMessageTokens(countSurface, encoding); } const promptMessageTokenCount = message.fileContext @@ -1917,6 +1943,13 @@ class AgentClient extends BaseClient { } } } + if (hasFileContext) { + for (let i = 0; i < orderedMessages.length; i++) { + memoryPayload.push( + memoryFormattedMessages[i] ?? buildMemoryFormattedMessage(orderedMessages[i]), + ); + } + } this.memoryPayload = hasFileContext ? memoryPayload : null; messages = orderedMessages; promptTokens = promptTokenTotal; @@ -3288,13 +3321,15 @@ class AgentClient extends BaseClient { manualSkillPrimes, alwaysApplySkillPrimes, }); + const useLegacyContent = this.options.agent?.useLegacyContent === true; const formatOptions = - needsReasoningContentFormat || freshSkillPrimeNames.size > 0 + needsReasoningContentFormat || freshSkillPrimeNames.size > 0 || useLegacyContent ? { ...(needsReasoningContentFormat ? { preserveReasoningContent: true } : {}), ...(freshSkillPrimeNames.size > 0 ? { skipSkillBodyNames: freshSkillPrimeNames } : {}), + ...(useLegacyContent ? { legacyContent: true } : {}), } : undefined; let { diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 027feffe129..073daddbf0c 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -3783,6 +3783,44 @@ describe('AgentClient - titleConvo', () => { expect(client.memoryPayload[0].content).toBe('What is written here?'); }); + it('recounts a quote-bearing history row from quote-merged content and keeps the memory payload unbuilt without file context', async () => { + const { countFormattedMessageTokens } = require('@librechat/api'); + countFormattedMessageTokens.mockImplementation(({ content }) => { + const text = Array.isArray(content) + ? content.map((part) => part.text ?? part[ContentTypes.TEXT] ?? '').join('\n') + : String(content ?? ''); + return text.includes('quoted excerpt') ? 77 : 11; + }); + + const result = await client.buildMessages( + [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Discuss this.', + isCreatedByUser: true, + tokenCount: 5, + quotes: ['quoted excerpt'], + }, + { + messageId: 'msg-2', + parentMessageId: 'msg-1', + sender: 'Assistant', + text: 'Sure.', + isCreatedByUser: false, + tokenCount: 3, + }, + ], + 'msg-2', + {}, + ); + + expect(result.tokenCountMap['msg-1']).toBe(77); + expect(result.tokenCountMap['msg-2']).toBe(3); + expect(client.memoryPayload).toBeNull(); + }); + it('does not duplicate a file that is both request context and scoped context', async () => { const sharedFile = makeTextFile('shared-file', 'shared.txt', 'Shared duplicate context'); From e0d5e11cdf20b4414ba83135ed013d71364bb01b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 24 Aug 2026 11:36:36 -0400 Subject: [PATCH 12/20] =?UTF-8?q?=E2=8F=B1=EF=B8=8F=20feat:=20Show=20Elaps?= =?UTF-8?q?ed=20Time=20Under=20the=20Streaming=20Response=20(#15167)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ⏱️ feat: Show Elapsed Time Under the Streaming Response A minimalist elapsed-time indicator (5s, then 1m 5s) occupies the footer slot the hover actions vacate while a response generates, anchored to a per-index submission-start timestamp so remounts (new-conversation id hydration, navigation) never reset it. The once-per-second tick is component-local state, so streaming rows never re-render on its account. * 🧭 fix: Keep the Original Elapsed Baseline When Reattaching a Stream Codex round 1: resume-on-load restamped the anchor at reattach time, so navigating away from a still-streaming conversation and back restarted the reading at 0s — the exact reset the atom exists to prevent. Resume paths now leave the anchor alone: a same-session return keeps its ask baseline, and a reload (atom empty) falls back to the indicator's mount time, which is what the stamp produced anyway. * 🪗 fix: Scope the Elapsed Timer to Its Own Generation, Localized and Spoken Codex round 2, all four findings: - The anchor is cleared on every terminal path (final, error, abort fallback), and resume-on-load only fills an empty one — so a run another client started never inherits a stale baseline, while a same-session reattach still keeps its original start. - The indicator additionally requires the newest sibling position: latestMessageId follows the selected branch, so a settled older sibling paged to mid-regeneration satisfied the latest+submitting gate and got a counting timer under settled content. - Visible digits now come from the shared run-step duration formatter (Intl.NumberFormat per locale), replacing the raw-number interpolations. - The compact reading is aria-hidden with a spoken 'N seconds elapsed' equivalent beside it, per the house duration-label pattern; still no aria-live, so the tick never announces. --- .../src/components/Chat/Messages/Elapsed.tsx | 72 ++++++++++ .../components/Chat/Messages/MessageParts.tsx | 8 ++ .../Chat/Messages/__tests__/Elapsed.spec.tsx | 135 ++++++++++++++++++ .../__tests__/HoverActions.streaming.spec.tsx | 97 +++++++++++-- .../Chat/Messages/ui/MessageRender.tsx | 8 ++ .../src/components/Messages/ContentRender.tsx | 8 ++ .../useChatFunctions.regenerate.spec.tsx | 1 + client/src/hooks/Chat/useChatFunctions.ts | 2 + client/src/hooks/SSE/useEventHandlers.ts | 11 ++ client/src/hooks/SSE/useResumeOnLoad.ts | 7 + client/src/locales/en/translation.json | 4 + client/src/store/families.ts | 17 +++ .../utils/__tests__/runStepDuration.spec.ts | 38 ++++- client/src/utils/runStepDuration.ts | 37 +++++ e2e/specs/mock/hover-actions.spec.ts | 8 ++ 15 files changed, 441 insertions(+), 12 deletions(-) create mode 100644 client/src/components/Chat/Messages/Elapsed.tsx create mode 100644 client/src/components/Chat/Messages/__tests__/Elapsed.spec.tsx diff --git a/client/src/components/Chat/Messages/Elapsed.tsx b/client/src/components/Chat/Messages/Elapsed.tsx new file mode 100644 index 00000000000..913f75b81b0 --- /dev/null +++ b/client/src/components/Chat/Messages/Elapsed.tsx @@ -0,0 +1,72 @@ +import { memo, useEffect, useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import { useTranslation } from 'react-i18next'; +import { getElapsedDurationLabels } from '~/utils'; +import { useLocalize } from '~/hooks'; +import store from '~/store'; + +const elapsedSeconds = (start: number): number => + Math.max(0, Math.floor((Date.now() - start) / 1000)); + +type ElapsedVisibility = { + isSubmitting: boolean; + isLatestMessage: boolean; + isCreatedByUser?: boolean; + siblingIdx?: number; + siblingCount?: number; +}; + +/** + * Whether the elapsed indicator belongs under a row: the latest assistant row + * while its generation streams — but only at the newest sibling position. + * `latestMessageId` follows the SELECTED branch, so during a regeneration a + * settled older sibling the reader paged to mid-stream would otherwise satisfy + * the same latest+submitting gate the withheld hover actions use, and a + * counting timer under settled content misleads in a way hidden buttons don't. + */ +export const shouldShowElapsed = ({ + isSubmitting, + isLatestMessage, + isCreatedByUser, + siblingIdx, + siblingCount, +}: ElapsedVisibility): boolean => + isSubmitting && + isLatestMessage && + isCreatedByUser !== true && + (siblingIdx ?? 0) === (siblingCount ?? 1) - 1; + +/** + * Elapsed generation time under the actively streaming response, in the footer + * slot the hover actions occupy once the answer lands. The once-per-second tick + * is component-local state, so parents that re-render per streaming token never + * re-render on its account. The compact reading is hidden from assistive + * technology in favor of a spoken equivalent; neither is an `aria-live` region, + * so the tick never announces. + */ +const Elapsed = memo(function Elapsed({ index }: { index: number }) { + const localize = useLocalize(); + const { i18n } = useTranslation(); + const submissionStart = useRecoilValue(store.submissionStartFamily(index)); + const [mountTime] = useState(() => Date.now()); + const start = submissionStart ?? mountTime; + const [seconds, setSeconds] = useState(() => elapsedSeconds(start)); + + useEffect(() => { + setSeconds(elapsedSeconds(start)); + const intervalId = setInterval(() => setSeconds(elapsedSeconds(start)), 1000); + return () => clearInterval(intervalId); + }, [start]); + + const labels = getElapsedDurationLabels(seconds * 1000, i18n.language); + return ( + + + {localize(labels.announcedKey, labels.announcedValues)} + + ); +}); + +export default Elapsed; diff --git a/client/src/components/Chat/Messages/MessageParts.tsx b/client/src/components/Chat/Messages/MessageParts.tsx index 620e642ebae..1afd543e44f 100644 --- a/client/src/components/Chat/Messages/MessageParts.tsx +++ b/client/src/components/Chat/Messages/MessageParts.tsx @@ -14,6 +14,7 @@ import { getHeaderModelName } from '~/components/Chat/Messages/ui/HeaderLabel'; import { revealOnRowHoverClasses, messageFooterClasses } from './styles'; import MessageRow from '~/components/Chat/Messages/ui/MessageRow'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; +import Elapsed, { shouldShowElapsed } from './Elapsed'; import ContentParts from './Content/ContentParts'; import SiblingSwitch from './SiblingSwitch'; import HoverButtons from './HoverButtons'; @@ -134,6 +135,13 @@ function MessageParts(props: TMessageProps) { isSubmitting && messageId === latestMessageId && revealOnRowHoverClasses, )} /> + {shouldShowElapsed({ + isSubmitting, + isLatestMessage: messageId === latestMessageId, + isCreatedByUser, + siblingIdx, + siblingCount, + }) && } void) { + return render( + + + , + ); +} + +function advance(ms: number) { + act(() => { + jest.advanceTimersByTime(ms); + }); +} + +describe('Elapsed', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders seconds from the submission start anchor and rolls into minutes', () => { + const start = Date.now() - 5_000; + renderElapsed(({ set }) => set(store.submissionStartFamily(0), start)); + + expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^5s$/); + expect(screen.getByTestId('stream-elapsed')).toHaveAttribute('aria-hidden', 'true'); + expect(screen.getByText('5 seconds elapsed')).toHaveClass('sr-only'); + + advance(54_000); + expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^59s$/); + + advance(1_000); + expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^1m 0s$/); + expect(screen.getByText('1 minute elapsed')).toHaveClass('sr-only'); + + advance(59_000); + expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^1m 59s$/); + + advance(1_000); + expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^2m 0s$/); + }); + + it('counts from mount when no submission start is recorded', () => { + renderElapsed(); + + expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^0s$/); + + advance(3_000); + expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^3s$/); + }); + + it('clamps a future anchor to zero instead of going negative', () => { + const start = Date.now() + 60_000; + renderElapsed(({ set }) => set(store.submissionStartFamily(0), start)); + + expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^0s$/); + + advance(61_000); + expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^1s$/); + }); + + it('continues from the anchored start across an unmount and remount', () => { + const start = Date.now() - 30_000; + const view = render( + set(store.submissionStartFamily(0), start)}> + + , + ); + + expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^30s$/); + + view.rerender( + set(store.submissionStartFamily(0), start)}> + {null} + , + ); + expect(screen.queryByTestId('stream-elapsed')).toBeNull(); + + advance(5_000); + view.rerender( + set(store.submissionStartFamily(0), start)}> + + , + ); + expect(screen.getByTestId('stream-elapsed')).toHaveTextContent(/^35s$/); + }); + + it('clears its interval on unmount', () => { + const view = renderElapsed(); + const timersWhileMounted = jest.getTimerCount(); + expect(timersWhileMounted).toBeGreaterThanOrEqual(1); + + view.rerender({null}); + expect(jest.getTimerCount()).toBe(timersWhileMounted - 1); + }); +}); + +describe('shouldShowElapsed', () => { + const streamingRow = { + isSubmitting: true, + isLatestMessage: true, + isCreatedByUser: false, + siblingIdx: 1, + siblingCount: 2, + }; + + it('shows under the newest sibling of the streaming latest assistant row', () => { + expect(shouldShowElapsed(streamingRow)).toBe(true); + }); + + it('shows when sibling metadata is absent (a lone response)', () => { + expect( + shouldShowElapsed({ isSubmitting: true, isLatestMessage: true, isCreatedByUser: false }), + ).toBe(true); + }); + + it('hides under an older sibling the reader paged to mid-stream', () => { + expect(shouldShowElapsed({ ...streamingRow, siblingIdx: 0 })).toBe(false); + }); + + it('hides for user rows, settled rows, and non-latest rows', () => { + expect(shouldShowElapsed({ ...streamingRow, isCreatedByUser: true })).toBe(false); + expect(shouldShowElapsed({ ...streamingRow, isSubmitting: false })).toBe(false); + expect(shouldShowElapsed({ ...streamingRow, isLatestMessage: false })).toBe(false); + }); +}); diff --git a/client/src/components/Chat/Messages/__tests__/HoverActions.streaming.spec.tsx b/client/src/components/Chat/Messages/__tests__/HoverActions.streaming.spec.tsx index ed0bb121666..e0918057668 100644 --- a/client/src/components/Chat/Messages/__tests__/HoverActions.streaming.spec.tsx +++ b/client/src/components/Chat/Messages/__tests__/HoverActions.streaming.spec.tsx @@ -12,6 +12,7 @@ import Message from '~/components/Chat/Messages/Message'; import store from '~/store'; let mockHoverButtonsRenderCount = 0; +let mockContentRenderCount = 0; jest.mock('~/components/Chat/Messages/HoverButtons', () => ({ __esModule: true, @@ -23,14 +24,18 @@ jest.mock('~/components/Chat/Messages/HoverButtons', () => ({ jest.mock('~/components/Chat/Messages/Content/MessageContent', () => ({ __esModule: true, - default: ({ text }: { text: string }) =>
{text}
, + default: ({ text }: { text: string }) => { + mockContentRenderCount += 1; + return
{text}
; + }, })); jest.mock('~/components/Chat/Messages/Content/ContentParts', () => ({ __esModule: true, - default: ({ content }: { content?: TMessage['content'] }) => ( -
{JSON.stringify(content ?? [])}
- ), + default: ({ content }: { content?: TMessage['content'] }) => { + mockContentRenderCount += 1; + return
{JSON.stringify(content ?? [])}
; + }, })); jest.mock('~/components/Chat/Messages/Content/Parts/AuthorHeader', () => ({ @@ -135,7 +140,15 @@ function createQueryClient() { }); } -function DerivedStreamingRow({ structured = false }: { structured?: boolean }) { +function DerivedStreamingRow({ + structured = false, + submitting = true, + siblingIdx = 1, +}: { + structured?: boolean; + submitting?: boolean; + siblingIdx?: number; +}) { const queryClient = useQueryClient(); const latestMessage = useLatestMessage(0); const latestMessageId = useLatestMessageId(0); @@ -151,7 +164,7 @@ function DerivedStreamingRow({ structured = false }: { structured?: boolean }) { latestMessageId: latestMessageId ?? undefined, latestMessageDepth, handleContinue: jest.fn(), - isSubmitting: true, + isSubmitting: submitting, abortScroll: false, setAbortScroll: jest.fn(), getMessages: () => @@ -163,7 +176,7 @@ function DerivedStreamingRow({ structured = false }: { structured?: boolean }) { ); }, }) as unknown as ReturnType, - [latestMessageDepth, latestMessageId, queryClient], + [latestMessageDepth, latestMessageId, queryClient, submitting], ); if (!latestMessage) { @@ -178,7 +191,7 @@ function DerivedStreamingRow({ structured = false }: { structured?: boolean }) { message={latestMessage} currentEditId={null} setCurrentEditId={jest.fn()} - siblingIdx={0} + siblingIdx={siblingIdx} siblingCount={2} setSiblingIdx={jest.fn()} /> @@ -187,7 +200,7 @@ function DerivedStreamingRow({ structured = false }: { structured?: boolean }) { ); } -function renderStreamingRow(structured = false) { +function renderStreamingRow(structured = false, submitting = true, siblingIdx = 1) { const queryClient = createQueryClient(); queryClient.setQueryData( [QueryKeys.messages, conversation.conversationId], @@ -196,14 +209,18 @@ function renderStreamingRow(structured = false) { const initializeState = ({ set }: MutableSnapshot) => { set(store.conversationByIndex(0), conversation); - set(store.isSubmittingFamily(0), true); + set(store.isSubmittingFamily(0), submitting); }; render( - + , @@ -215,6 +232,7 @@ function renderStreamingRow(structured = false) { describe('streaming hover actions', () => { beforeEach(() => { mockHoverButtonsRenderCount = 0; + mockContentRenderCount = 0; }); it('keeps actions mounted while an optimistic assistant row is replaced', async () => { @@ -284,4 +302,61 @@ describe('streaming hover actions', () => { expect(screen.getByTestId('hover-buttons').parentElement).toHaveClass('min-h-[31px]'); }); + + /** + * The elapsed-time indicator fills the footer slot the withheld actions leave + * empty, but only under the response that is actively generating. + */ + it.each([ + ['a plain text', false], + ['a structured', true], + ])('shows the elapsed timer under %s streaming response', (_label, structured) => { + renderStreamingRow(structured); + + expect(screen.getByTestId('stream-elapsed')).toBeInTheDocument(); + }); + + it('renders no elapsed timer once the row is not submitting', () => { + renderStreamingRow(false, false); + + expect(screen.queryByTestId('stream-elapsed')).toBeNull(); + }); + + /** + * `latestMessageId` follows the SELECTED branch, so a settled older sibling + * the reader paged to mid-regeneration satisfies the latest+submitting gate. + * The timer additionally requires the newest sibling position — a counting + * timer under settled content misleads in a way withheld buttons don't. + */ + it('renders no elapsed timer under an older sibling selected mid-stream', () => { + renderStreamingRow(false, true, 0); + + expect(screen.queryByTestId('stream-elapsed')).toBeNull(); + expect(screen.getByTestId('hover-buttons')).toBeInTheDocument(); + }); + + /** + * The timer's once-per-second tick is component-local state: advancing the + * clock must re-render nothing beyond the timer itself, or the indicator + * would tax every streaming frame's neighbors. + */ + it('ticks the elapsed timer without re-rendering content or actions', () => { + jest.useFakeTimers(); + try { + renderStreamingRow(); + + const hoverRenders = mockHoverButtonsRenderCount; + const contentRenders = mockContentRenderCount; + + act(() => { + jest.advanceTimersByTime(5_000); + }); + + expect(screen.getByTestId('stream-elapsed')).toBeInTheDocument(); + expect(mockHoverButtonsRenderCount).toBe(hoverRenders); + expect(mockContentRenderCount).toBe(contentRenders); + } finally { + jest.useRealTimers(); + } + }); }); diff --git a/client/src/components/Chat/Messages/ui/MessageRender.tsx b/client/src/components/Chat/Messages/ui/MessageRender.tsx index 110dea397b7..355ae59c2c5 100644 --- a/client/src/components/Chat/Messages/ui/MessageRender.tsx +++ b/client/src/components/Chat/Messages/ui/MessageRender.tsx @@ -9,6 +9,7 @@ import { getMessageAriaLabel, } from '~/utils'; import { revealOnRowHoverClasses, messageFooterClasses } from '~/components/Chat/Messages/styles'; +import Elapsed, { shouldShowElapsed } from '~/components/Chat/Messages/Elapsed'; import MessageContent from '~/components/Chat/Messages/Content/MessageContent'; import { getHeaderModelName } from '~/components/Chat/Messages/ui/HeaderLabel'; import { useLocalize, useMessageActions, useContentMetadata } from '~/hooks'; @@ -180,6 +181,13 @@ const MessageRender = memo(function MessageRender({ isSubmitting && isLatestMessage && revealOnRowHoverClasses, )} /> + {shouldShowElapsed({ + isSubmitting, + isLatestMessage, + isCreatedByUser: msg.isCreatedByUser, + siblingIdx, + siblingCount, + }) && } + {shouldShowElapsed({ + isSubmitting, + isLatestMessage, + isCreatedByUser: msg.isCreatedByUser, + siblingIdx, + siblingCount, + }) && } ({ default: { isTemporary: 'isTemporary', isSubmittingFamily: () => 'isSubmitting', + submissionStartFamily: () => 'submissionStart', showStopButtonByIndex: () => 'showStopButton', pendingManualSkillsByConvoId: () => 'pendingManualSkills', pendingQuotesByConvoId: () => 'pendingQuotes', diff --git a/client/src/hooks/Chat/useChatFunctions.ts b/client/src/hooks/Chat/useChatFunctions.ts index b34058bb766..195d23a5576 100644 --- a/client/src/hooks/Chat/useChatFunctions.ts +++ b/client/src/hooks/Chat/useChatFunctions.ts @@ -219,6 +219,7 @@ export default function useChatFunctions({ const isTemporary = useRecoilValue(store.isTemporary); const { getExpiry } = useUserKey(immutableConversation?.endpoint ?? ''); const setIsSubmitting = useSetRecoilState(store.isSubmittingFamily(index)); + const setSubmissionStart = useSetRecoilState(store.submissionStartFamily(index)); const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index)); const focusRegeneratedResponse = useFocusRegeneratedResponse(); @@ -712,6 +713,7 @@ export default function useChatFunctions({ setMessages([...submissionMessages, currentMsg, initialResponse]); } + setSubmissionStart(Date.now()); setSubmission(submission); logger.dir('message_stream', submission, { depth: null }); }; diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index d1b7d4f247c..1550c24e06f 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -342,6 +342,11 @@ export default function useEventHandlers({ const { announcePolite } = useLiveAnnouncer(); const applyAgentTemplate = useApplyAgentTemplate(); const setAbortScroll = useSetRecoilState(store.abortScroll); + /** Cleared on every terminal path below: the elapsed anchor must not outlive + * its generation, or a later externally-started run attached at this index + * would inherit a stale baseline. Navigation teardown deliberately does not + * clear it — a reattach to a still-live run keeps its original start. */ + const setSubmissionStart = useSetRecoilState(store.submissionStartFamily(runIndex)); const navigate = useNavigate(); const location = useLocation(); @@ -734,6 +739,7 @@ export default function useEventHandlers({ isTemporary: _isTemporary = false, } = submission; const serverConversation = conversation as TConversation; + setSubmissionStart(null); try { // Handle early abort - aborted before any response message was saved. @@ -975,6 +981,7 @@ export default function useEventHandlers({ location.pathname, applyAgentTemplate, attachmentHandler, + setSubmissionStart, restorePendingQuotes, ], ); @@ -983,6 +990,7 @@ export default function useEventHandlers({ ({ data, submission }: { data?: TResData; submission: EventSubmission }) => { const { userMessage, initialResponse } = submission; setCompleted((prev) => new Set(prev.add(initialResponse.messageId))); + setSubmissionStart(null); const conversationId = userMessage.conversationId ?? submission.conversation?.conversationId ?? ''; @@ -1075,6 +1083,7 @@ export default function useEventHandlers({ paramId, newConversation, setIsSubmitting, + setSubmissionStart, getMessages, queryClient, ], @@ -1122,6 +1131,7 @@ export default function useEventHandlers({ console.error('Error in finalHandler during abort:', error); setShowStopButton(false); setIsSubmitting(false); + setSubmissionStart(null); } return; } else if (!isAssistantsEndpoint(endpoint)) { @@ -1198,6 +1208,7 @@ export default function useEventHandlers({ newConversation, setIsSubmitting, setShowStopButton, + setSubmissionStart, ], ); diff --git a/client/src/hooks/SSE/useResumeOnLoad.ts b/client/src/hooks/SSE/useResumeOnLoad.ts index f0b0f7653fe..f68baee9348 100644 --- a/client/src/hooks/SSE/useResumeOnLoad.ts +++ b/client/src/hooks/SSE/useResumeOnLoad.ts @@ -246,6 +246,7 @@ export default function useResumeOnLoad( ) { const queryClient = useQueryClient(); const setSubmission = useSetRecoilState(store.submissionByIndex(runIndex)); + const setSubmissionStart = useSetRecoilState(store.submissionStartFamily(runIndex)); const currentSubmission = useRecoilValue(store.submissionByIndex(runIndex)); const currentConversation = useRecoilValue(store.conversationByIndex(runIndex)); const endpoint = currentConversation?.endpoint; @@ -589,6 +590,11 @@ export default function useResumeOnLoad( }); const messages = getMessages() || []; + /** Fill the elapsed baseline only when none survives: a reattach to the run + * this session already anchored keeps its original start (the atom outlives + * the submission), while a run it never anchored — another client's, or any + * attach after the previous run's terminal clear — counts from attach. */ + setSubmissionStart((prev) => prev ?? Date.now()); // Build submission from resume state if available if (streamStatus.resumeState) { @@ -658,6 +664,7 @@ export default function useResumeOnLoad( streamStatus, getMessages, setSubmission, + setSubmissionStart, restoreResumeBranch, restoreSteerChips, settleAppliedSteerParts, diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 29743ca1a38..b66e186052b 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1223,6 +1223,10 @@ "com_ui_edited_file": "Edited {{0}}", "com_ui_editing_file": "Editing {{0}}", "com_ui_editor_instructions": "Drag the image to reposition • Use zoom slider or buttons to adjust size", + "com_ui_elapsed_announced_minutes": "{{count}} minutes elapsed", + "com_ui_elapsed_announced_minutes_one": "{{count}} minute elapsed", + "com_ui_elapsed_announced_seconds": "{{count}} seconds elapsed", + "com_ui_elapsed_announced_seconds_one": "{{count}} second elapsed", "com_ui_empty_category": "-", "com_ui_enabled": "Enabled", "com_ui_endpoint": "Endpoint", diff --git a/client/src/store/families.ts b/client/src/store/families.ts index 122db2c160d..53a9e1522ec 100644 --- a/client/src/store/families.ts +++ b/client/src/store/families.ts @@ -38,6 +38,22 @@ const submissionByIndex = atomFamily({ default: null, }); +/** + * Epoch ms baseline for the streaming elapsed indicator at this chat index. + * Stamped when this session submits a generation (every path through `ask`), + * cleared by the terminal handlers when that generation ends, and only FILLED + * — never overwritten — when resume-on-load attaches a run. The reading + * therefore survives mid-stream remounts (new-conversation id hydration, + * navigating away from a still-live run and back) without a later, + * externally-started generation inheriting a stale baseline. Known residual: + * a run whose end this pane never observed (left mid-stream, finished + * elsewhere) leaves its stamp for the next attach at this index to inherit. + */ +const submissionStartFamily = atomFamily({ + key: 'submissionStartByIndex', + default: null, +}); + const submissionKeysSelector = selector<(string | number)[]>({ key: 'submissionKeysSelector', get: ({ get }) => { @@ -683,6 +699,7 @@ export default { filesByIndex, presetByIndex, submissionByIndex, + submissionStartFamily, textByIndex, showStopButtonByIndex, abortScrollFamily, diff --git a/client/src/utils/__tests__/runStepDuration.spec.ts b/client/src/utils/__tests__/runStepDuration.spec.ts index c4af8e3c5f6..1288048ec5f 100644 --- a/client/src/utils/__tests__/runStepDuration.spec.ts +++ b/client/src/utils/__tests__/runStepDuration.spec.ts @@ -1,4 +1,4 @@ -import { getRunStepDurationLabels } from '../runStepDuration'; +import { getRunStepDurationLabels, getElapsedDurationLabels } from '../runStepDuration'; describe('getRunStepDurationLabels', () => { describe('under ten seconds', () => { @@ -82,3 +82,39 @@ describe('getRunStepDurationLabels', () => { }); }); }); + +describe('getElapsedDurationLabels', () => { + it('keeps the run-step visible form and rephrases the spoken form as elapsed', () => { + expect(getElapsedDurationLabels(5_000, 'en')).toEqual({ + key: 'com_ui_duration_seconds', + values: { 0: '5' }, + announcedKey: 'com_ui_elapsed_announced_seconds', + announcedValues: { count: '5' }, + }); + }); + + it('announces the singular form only for exactly one unit', () => { + expect(getElapsedDurationLabels(1_000).announcedKey).toBe( + 'com_ui_elapsed_announced_seconds_one', + ); + expect(getElapsedDurationLabels(60_000).announcedKey).toBe( + 'com_ui_elapsed_announced_minutes_one', + ); + }); + + it('rounds the spoken form to whole minutes past a minute', () => { + expect(getElapsedDurationLabels(90_000, 'en')).toMatchObject({ + key: 'com_ui_duration_minutes', + values: { 0: '1', 1: '30' }, + announcedKey: 'com_ui_elapsed_announced_minutes', + announcedValues: { count: '2' }, + }); + }); + + it('formats every interpolated number for the active locale', () => { + expect(getElapsedDurationLabels(65_000, 'ar-EG')).toMatchObject({ + values: { 0: '١', 1: '٥' }, + announcedValues: { count: '١' }, + }); + }); +}); diff --git a/client/src/utils/runStepDuration.ts b/client/src/utils/runStepDuration.ts index 964fff1c852..35d314fe102 100644 --- a/client/src/utils/runStepDuration.ts +++ b/client/src/utils/runStepDuration.ts @@ -96,3 +96,40 @@ export function getRunStepDurationLabels( announcedValues: { count: formatDurationValue(announcedMinutes, language) }, }; } + +/** + * The streaming elapsed indicator's variant of the duration labels: the same + * locale-formatted visible form, with the spoken form phrased for a run still + * in progress ("5 seconds elapsed") rather than a settled one ("took 5 + * seconds"). Produced here, beside `getRunStepDurationLabels`, so both forms + * keep sharing one per-locale number formatter. + */ +export function getElapsedDurationLabels( + durationMs: number, + language?: string, +): RunStepDurationLabels { + const { key, values } = getRunStepDurationLabels(durationMs, language); + const totalSeconds = durationMs / MS_PER_SECOND; + + if (Math.round(totalSeconds) < SECONDS_PER_MINUTE) { + const seconds = Math.round(totalSeconds); + return { + key, + values, + announcedKey: + seconds === 1 ? 'com_ui_elapsed_announced_seconds_one' : 'com_ui_elapsed_announced_seconds', + announcedValues: { count: formatDurationValue(seconds, language) }, + }; + } + + const announcedMinutes = Math.round(totalSeconds / SECONDS_PER_MINUTE); + return { + key, + values, + announcedKey: + announcedMinutes === 1 + ? 'com_ui_elapsed_announced_minutes_one' + : 'com_ui_elapsed_announced_minutes', + announcedValues: { count: formatDurationValue(announcedMinutes, language) }, + }; +} diff --git a/e2e/specs/mock/hover-actions.spec.ts b/e2e/specs/mock/hover-actions.spec.ts index 4a53bd51d8d..98ef71e0a82 100644 --- a/e2e/specs/mock/hover-actions.spec.ts +++ b/e2e/specs/mock/hover-actions.spec.ts @@ -64,6 +64,13 @@ test.describe('message hover actions', () => { await expect(streamingEdit).toHaveCount(0); await expect(streamingFork).toHaveCount(0); + /** What the withheld actions leave behind is the elapsed-time indicator, + * ticking once per second in the slot they reclaim when the answer lands. */ + const streamingElapsed = streaming.getByTestId('stream-elapsed'); + await expect(streamingElapsed).toHaveText(/^\d+s$/); + const firstReading = (await streamingElapsed.textContent()) ?? ''; + await expect(streamingElapsed).not.toHaveText(firstReading, { timeout: 5000 }); + /** The settled turn above carries the positive control: the toolbar system is * mounted and working, so the absences above read as "withheld" rather than * "nothing rendered yet". */ @@ -71,6 +78,7 @@ test.describe('message hover actions', () => { /** ...and the response earns them back, or "withheld" would just be "gone". */ await expect(stopButton(page)).toBeHidden({ timeout: 60000 }); + await expect(streamingElapsed).toHaveCount(0); await expect(streamingCopy).toBeEnabled(); await expect(streamingEdit).toBeEnabled(); await expect(streamingFork).toBeEnabled(); From a997275902c145f4cb104e2df0d97ef7fb08f494 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 24 Aug 2026 11:39:56 -0400 Subject: [PATCH 13/20] =?UTF-8?q?=F0=9F=A7=BE=20feat:=20Persist=20Authorit?= =?UTF-8?q?ative=20Subagent=20Control=20Receipts=20(#15168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: persist subagent control receipts * fix: require control receipt persistence * fix: preserve authoritative control history --- .../Endpoints/agents/subagentThreadStore.js | 1 + .../agents/subagentThreadStore.spec.js | 9 + packages/api/src/agents/guard.spec.ts | 1 + .../api/src/agents/subagentTaskRouting.ts | 9 +- .../api/src/agents/subagentThreads.spec.ts | 509 +++++++++++++++++- packages/api/src/agents/subagentThreads.ts | 380 ++++++++++++- packages/api/src/agents/view.spec.ts | 58 ++ packages/api/src/agents/view.ts | 47 ++ packages/data-provider/src/types/subagents.ts | 17 + .../data-schemas/src/methods/message.spec.ts | 274 +++++++++- packages/data-schemas/src/methods/message.ts | 279 ++++++++++ packages/data-schemas/src/schema/message.ts | 27 + packages/data-schemas/src/types/message.ts | 25 + 13 files changed, 1608 insertions(+), 28 deletions(-) diff --git a/api/server/services/Endpoints/agents/subagentThreadStore.js b/api/server/services/Endpoints/agents/subagentThreadStore.js index fd1f4061785..c094c2850cb 100644 --- a/api/server/services/Endpoints/agents/subagentThreadStore.js +++ b/api/server/services/Endpoints/agents/subagentThreadStore.js @@ -64,6 +64,7 @@ const subagentThreadTaskStore = createSubagentThreadTaskStore( getConvo: db.getConvo, getMessages: db.getMessages, listActiveSubagentThreadLeases: db.listActiveSubagentThreadLeases, + recordSubagentTaskControlReceipt: db.recordSubagentTaskControlReceipt, releaseSubagentThreadLease: db.releaseSubagentThreadLease, reserveSubagentThread: db.reserveSubagentThread, renewSubagentThreadLease: db.renewSubagentThreadLease, diff --git a/api/server/services/Endpoints/agents/subagentThreadStore.spec.js b/api/server/services/Endpoints/agents/subagentThreadStore.spec.js index 615661dd52c..513e765df08 100644 --- a/api/server/services/Endpoints/agents/subagentThreadStore.spec.js +++ b/api/server/services/Endpoints/agents/subagentThreadStore.spec.js @@ -30,6 +30,7 @@ jest.mock('~/models', () => ({ getConvo: jest.fn(), getMessages: jest.fn(), listActiveSubagentThreadLeases: jest.fn(), + recordSubagentTaskControlReceipt: jest.fn(), releaseSubagentThreadLease: jest.fn(), reserveSubagentThread: jest.fn(), renewSubagentThreadLease: jest.fn(), @@ -55,11 +56,19 @@ const { const subagentThreadTaskStore = require('./subagentThreadStore'); const { configureSubagentTaskRouting } = subagentThreadTaskStore; const taskStoreOptions = createSubagentThreadTaskStore.mock.calls[0][1]; +const taskStoreMethods = createSubagentThreadTaskStore.mock.calls[0][0]; +const db = require('~/models'); const activityPrepareRegistration = registerShutdownTask.mock.calls.find( ([name]) => name === 'subagent activity streams prepare', ); describe('subagent thread Redis lifecycle', () => { + it('wires durable control receipt persistence into the host store', () => { + expect(taskStoreMethods.recordSubagentTaskControlReceipt).toBe( + db.recordSubagentTaskControlReceipt, + ); + }); + it('reads completion wakeup rollout state at task preparation time', async () => { isEnabled.mockReturnValueOnce(false); diff --git a/packages/api/src/agents/guard.spec.ts b/packages/api/src/agents/guard.spec.ts index 07cf516ea35..e329acef46a 100644 --- a/packages/api/src/agents/guard.spec.ts +++ b/packages/api/src/agents/guard.spec.ts @@ -39,6 +39,7 @@ function makeStore(): SubagentThreadTaskStore { getConvo: unused as AllMethods['getConvo'], getMessages: unused as AllMethods['getMessages'], listActiveSubagentThreadLeases: unused as AllMethods['listActiveSubagentThreadLeases'], + recordSubagentTaskControlReceipt: unused as AllMethods['recordSubagentTaskControlReceipt'], releaseSubagentThreadLease: unused as AllMethods['releaseSubagentThreadLease'], reserveSubagentThread: unused as AllMethods['reserveSubagentThread'], renewSubagentThreadLease: unused as AllMethods['renewSubagentThreadLease'], diff --git a/packages/api/src/agents/subagentTaskRouting.ts b/packages/api/src/agents/subagentTaskRouting.ts index d4488776669..f7b8eb502e3 100644 --- a/packages/api/src/agents/subagentTaskRouting.ts +++ b/packages/api/src/agents/subagentTaskRouting.ts @@ -172,7 +172,7 @@ export interface SubagentTaskControlHandler { taskId: string, command: SubagentTaskControlCommand, invocationId: string, - ): SubagentTaskControlResult; + ): Promise | SubagentTaskControlResult; list(scopeId: string): SubagentTaskSnapshot[]; cancelScope(scopeId: string, threadIds: string[] | null): number; } @@ -1021,7 +1021,12 @@ export class RedisSubagentTaskControlTransport implements SubagentTaskControlTra result = claim; } else { result = boundedControlResult( - handler.control(request.scopeId, request.taskId, request.command, request.invocationId), + await handler.control( + request.scopeId, + request.taskId, + request.command, + request.invocationId, + ), ); } const serializedResult = JSON.stringify(result); diff --git a/packages/api/src/agents/subagentThreads.spec.ts b/packages/api/src/agents/subagentThreads.spec.ts index 47b62429bbc..c56c78e4fe6 100644 --- a/packages/api/src/agents/subagentThreads.spec.ts +++ b/packages/api/src/agents/subagentThreads.spec.ts @@ -53,6 +53,24 @@ class TestTaskRoutingHub { } } +class ReceiptTestSubagentThreadTaskStore extends SubagentThreadTaskStore { + emitControlReceiptForTest( + scopeId: string, + taskId: string, + receipt: { + controlId: string; + action: 'steer' | 'queue' | 'interrupt'; + status: 'accepted' | 'applied' | 'rejected' | 'failed'; + createdAt: number; + updatedAt: number; + boundary?: 'preempt' | 'tool' | 'turn'; + reason?: 'withdrawn' | 'task_completed' | 'task_cancelled' | 'task_failed'; + }, + ): void { + this.onControlReceipt(scopeId, taskId, receipt); + } +} + class TestTaskControlTransport implements SubagentTaskControlTransport { private handler?: SubagentTaskControlHandler; readonly registrations: Array<{ scopeId: string; taskId: string; ttlMs: number }> = []; @@ -187,9 +205,12 @@ async function waitForSettled( throw new Error('Timed out waiting for the subagent task.'); } -async function waitUntil(condition: () => boolean, description: string): Promise { +async function waitUntil( + condition: () => boolean | Promise, + description: string, +): Promise { for (let attempt = 0; attempt < 400; attempt += 1) { - if (condition()) { + if (await condition()) { return; } await new Promise((resolve) => setTimeout(resolve, 10)); @@ -1964,6 +1985,20 @@ describe('SubagentThreadTaskStore', () => { await expect(requesterStore.listTasks(config.scopeId)).resolves.toEqual([ expect.objectContaining({ taskId, status: 'running' }), ]); + await waitUntil( + async () => + ( + await methods.getMessages( + { + user: userId, + conversationId: requireThreadId(started), + messageId: `${taskId}:user`, + }, + '+subagentTask', + ) + ).length === 1, + 'the durable control receipt target', + ); await expect( requesterStore.controlTask(config.scopeId, taskId, { action: 'queue', @@ -1991,7 +2026,7 @@ describe('SubagentThreadTaskStore', () => { const parentConversationId = randomUUID(); await saveParent(userId, parentConversationId); const hub = new TestTaskRoutingHub(); - const ownerStore = new SubagentThreadTaskStore(methods); + const ownerStore = new ReceiptTestSubagentThreadTaskStore(methods); const requesterStore = new SubagentThreadTaskStore(methods); await ownerStore.configureTaskControlTransport(new TestTaskControlTransport(hub)); await requesterStore.configureTaskControlTransport(new TestTaskControlTransport(hub)); @@ -2006,12 +2041,35 @@ describe('SubagentThreadTaskStore', () => { }), ); const taskId = requireAccepted(started).task.taskId; - await Promise.resolve(); + const threadId = requireThreadId(started); + let durableInput: IMessage | undefined; + for (let attempt = 0; attempt < 200; attempt += 1) { + [durableInput] = await methods.getMessages( + { user: userId, conversationId: threadId, messageId: `${taskId}:user` }, + '+subagentTask', + ); + if (durableInput != null) break; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(durableInput).toBeDefined(); const steer = { action: 'queue' as const, message: 'Verify the primary source too.' }; const routed = await requesterStore.controlTask(config.scopeId, taskId, steer, 'invocation-1'); expect(routed).toMatchObject({ status: 'accepted' }); + [durableInput] = await methods.getMessages( + { user: userId, conversationId: threadId, messageId: `${taskId}:user` }, + '+subagentTask', + ); + expect(durableInput?.subagentTask?.controlReceipts).toEqual([ + expect.objectContaining({ + invocationId: 'invocation-1', + action: 'queue', + status: 'accepted', + message: steer.message, + }), + ]); + /** The same invocation reaching the owner directly replays that result rather than * queueing a second steer, so local and routed callers agree. */ await expect( @@ -2019,6 +2077,49 @@ describe('SubagentThreadTaskStore', () => { ).resolves.toEqual(routed); expect(ownerStore.get(config.scopeId, taskId)?.pendingControls).toBe(1); + const acceptedControlId = + routed.status === 'accepted' && routed.controlId != null ? routed.controlId : undefined; + expect(acceptedControlId).toBeDefined(); + const transitionTime = Date.now(); + ownerStore.emitControlReceiptForTest(config.scopeId, taskId, { + controlId: acceptedControlId as string, + action: 'queue', + status: 'applied', + createdAt: transitionTime - 1, + updatedAt: transitionTime, + boundary: 'turn', + }); + await waitUntil( + () => ownerStore.get(config.scopeId, taskId) != null, + 'the owner task to remain available', + ); + for (let attempt = 0; attempt < 200; attempt += 1) { + [durableInput] = await methods.getMessages( + { user: userId, conversationId: threadId, messageId: `${taskId}:user` }, + '+subagentTask', + ); + if (durableInput?.subagentTask?.controlReceipts?.[0]?.status === 'applied') break; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(durableInput?.subagentTask?.controlReceipts).toEqual([ + expect.objectContaining({ + invocationId: 'invocation-1', + status: 'applied', + boundary: 'turn', + }), + ]); + + /** A delayed retry can replay accepted in memory but cannot downgrade the + * already-applied durable receipt. */ + await expect( + ownerStore.controlTask(config.scopeId, taskId, steer, 'invocation-1'), + ).resolves.toEqual(routed); + [durableInput] = await methods.getMessages( + { user: userId, conversationId: threadId, messageId: `${taskId}:user` }, + '+subagentTask', + ); + expect(durableInput?.subagentTask?.controlReceipts?.[0]?.status).toBe('applied'); + /** Reusing one invocation id for different content is a caller error, not a retry. */ await expect( requesterStore.controlTask( @@ -2038,6 +2139,406 @@ describe('SubagentThreadTaskStore', () => { ]); }); + it('fails acceptance closed when the durable receipt target is not ready', async () => { + const userId = 'receipt-target-user'; + const parentConversationId = randomUUID(); + await saveParent(userId, parentConversationId); + const store = new SubagentThreadTaskStore(methods, { controlReceiptRetryMs: 10 }); + const config = buildSubagentThreadTaskConfig(store, { userId, parentConversationId }); + let finish = (_value: { content: string }): void => undefined; + const result = new Promise<{ content: string }>((resolve) => { + finish = resolve; + }); + const started = store.start(taskRequest(config.scopeId, { run: async () => result })); + const taskId = requireAccepted(started).task.taskId; + const threadId = requireThreadId(started); + await waitUntil( + async () => + ( + await methods.getMessages( + { user: userId, conversationId: threadId, messageId: `${taskId}:user` }, + '+subagentTask', + ) + ).length === 1, + 'the durable task input', + ); + + const persistReceipt = methods.recordSubagentTaskControlReceipt; + const persistence = jest + .spyOn(methods, 'recordSubagentTaskControlReceipt') + .mockResolvedValueOnce(false) + .mockImplementation(persistReceipt); + const command = { action: 'queue' as const, message: 'Check the source.' }; + try { + await expect( + store.controlTask(config.scopeId, taskId, command, 'not-ready-invocation'), + ).rejects.toBeInstanceOf(SubagentTaskOwnerUnavailableError); + expect(store.get(config.scopeId, taskId)?.pendingControls).toBe(1); + + await expect( + store.controlTask(config.scopeId, taskId, command, 'not-ready-invocation'), + ).resolves.toMatchObject({ status: 'accepted' }); + expect(store.get(config.scopeId, taskId)?.pendingControls).toBe(1); + } finally { + persistence.mockRestore(); + finish({ content: 'Done.' }); + await waitForSettled(store, config.scopeId, started); + await store.destroyTaskControlTransport(); + } + }); + + it('retains only the bounded public projection of a control payload', async () => { + const userId = 'bounded-control-payload-user'; + const parentConversationId = randomUUID(); + await saveParent(userId, parentConversationId); + const store = new SubagentThreadTaskStore(methods); + const config = buildSubagentThreadTaskConfig(store, { userId, parentConversationId }); + let finish = (_value: { content: string }): void => undefined; + const result = new Promise<{ content: string }>((resolve) => { + finish = resolve; + }); + const started = store.start(taskRequest(config.scopeId, { run: async () => result })); + const taskId = requireAccepted(started).task.taskId; + const threadId = requireThreadId(started); + await waitUntil( + async () => + ( + await methods.getMessages( + { user: userId, conversationId: threadId, messageId: `${taskId}:user` }, + '+subagentTask', + ) + ).length === 1, + 'the durable task input', + ); + + await expect( + store.controlTask( + config.scopeId, + taskId, + { action: 'queue', message: 'x'.repeat(64 * 1024) }, + 'bounded-payload-invocation', + ), + ).resolves.toMatchObject({ status: 'accepted' }); + const invocations = ( + store as unknown as { + controlInvocations: Map; + } + ).controlInvocations; + expect(invocations.values().next().value?.command).toEqual({ + action: 'queue', + message: 'x'.repeat(4 * 1024), + }); + await expect( + store.controlTask( + config.scopeId, + taskId, + { action: 'queue', message: `${'x'.repeat(64 * 1024 - 1)}y` }, + 'bounded-payload-invocation', + ), + ).resolves.toMatchObject({ status: 'invalid' }); + + finish({ content: 'Done.' }); + await waitForSettled(store, config.scopeId, started); + await store.destroyTaskControlTransport(); + }); + + it('restores tenant context for a routed control receipt write', async () => { + const userId = 'routed-receipt-tenant-user'; + const tenantId = 'routed-receipt-tenant'; + const parentConversationId = randomUUID(); + await tenantStorage.run({ tenantId, userId }, async () => + saveParent(userId, parentConversationId, { tenantId }), + ); + const hub = new TestTaskRoutingHub(); + const ownerStore = new SubagentThreadTaskStore(methods); + const requesterStore = new SubagentThreadTaskStore(methods); + await ownerStore.configureTaskControlTransport(new TestTaskControlTransport(hub)); + await requesterStore.configureTaskControlTransport(new TestTaskControlTransport(hub)); + const config = buildSubagentThreadTaskConfig(ownerStore, { + userId, + tenantId, + parentConversationId, + }); + let finish = (_value: { content: string }): void => undefined; + const result = new Promise<{ content: string }>((resolve) => { + finish = resolve; + }); + const started = ownerStore.start(taskRequest(config.scopeId, { run: async () => result })); + const taskId = requireAccepted(started).task.taskId; + const threadId = requireThreadId(started); + await waitUntil( + () => + tenantStorage.run({ tenantId, userId }, async () => + Boolean( + ( + await methods.getMessages( + { user: userId, conversationId: threadId, messageId: `${taskId}:user` }, + '+subagentTask', + ) + )[0], + ), + ), + 'the tenant-scoped task input', + ); + + const contexts: Array<{ tenantId?: string; userId?: string }> = []; + const persistReceipt = methods.recordSubagentTaskControlReceipt; + const persistence = jest + .spyOn(methods, 'recordSubagentTaskControlReceipt') + .mockImplementation((input) => { + contexts.push({ tenantId: getTenantId(), userId: getUserId() }); + return persistReceipt(input); + }); + try { + await expect( + requesterStore.controlTask( + config.scopeId, + taskId, + { action: 'queue', message: 'Check the tenant source.' }, + 'tenant-invocation', + ), + ).resolves.toMatchObject({ status: 'accepted' }); + expect(contexts).toEqual([{ tenantId, userId }]); + } finally { + persistence.mockRestore(); + finish({ content: 'Done.' }); + await waitForSettled(ownerStore, config.scopeId, started); + await Promise.all([ + ownerStore.destroyTaskControlTransport(), + requesterStore.destroyTaskControlTransport(), + ]); + } + }); + + it('persists the target control id for cancel_message receipts', async () => { + const userId = 'cancel-message-receipt-user'; + const parentConversationId = randomUUID(); + await saveParent(userId, parentConversationId); + const store = new SubagentThreadTaskStore(methods); + const config = buildSubagentThreadTaskConfig(store, { userId, parentConversationId }); + let finish = (_value: { content: string }): void => undefined; + const result = new Promise<{ content: string }>((resolve) => { + finish = resolve; + }); + const started = store.start(taskRequest(config.scopeId, { run: async () => result })); + const taskId = requireAccepted(started).task.taskId; + const threadId = requireThreadId(started); + await waitUntil( + async () => + ( + await methods.getMessages( + { user: userId, conversationId: threadId, messageId: `${taskId}:user` }, + '+subagentTask', + ) + ).length === 1, + 'the durable task input', + ); + + const queued = await store.controlTask( + config.scopeId, + taskId, + { action: 'queue', message: 'Withdraw me.' }, + 'queued-invocation', + ); + expect(queued).toMatchObject({ status: 'accepted' }); + const targetControlId = queued.status === 'accepted' ? queued.controlId : undefined; + expect(targetControlId).toBeDefined(); + await expect( + store.controlTask( + config.scopeId, + taskId, + { action: 'cancel_message', controlId: targetControlId as string }, + 'cancel-message-invocation', + ), + ).resolves.toMatchObject({ status: 'accepted' }); + await expect( + store.controlTask( + config.scopeId, + taskId, + { action: 'cancel_message', controlId: 'missing-control' }, + 'missing-cancel-message-invocation', + ), + ).resolves.toMatchObject({ status: 'control_not_found' }); + + await waitUntil(async () => { + const [input] = await methods.getMessages( + { user: userId, conversationId: threadId, messageId: `${taskId}:user` }, + '+subagentTask', + ); + return ( + input?.subagentTask?.controlReceipts?.some( + (receipt) => receipt.invocationId === 'cancel-message-invocation', + ) === true + ); + }, 'the cancel_message receipt'); + const [input] = await methods.getMessages( + { user: userId, conversationId: threadId, messageId: `${taskId}:user` }, + '+subagentTask', + ); + expect(input?.subagentTask?.controlReceipts).toContainEqual( + expect.objectContaining({ + invocationId: 'cancel-message-invocation', + controlId: targetControlId, + action: 'cancel_message', + status: 'applied', + }), + ); + expect(input?.subagentTask?.controlReceipts).toContainEqual( + expect.objectContaining({ + invocationId: 'missing-cancel-message-invocation', + controlId: 'missing-control', + action: 'cancel_message', + status: 'rejected', + reason: 'control_not_found', + }), + ); + + finish({ content: 'Done.' }); + await waitForSettled(store, config.scopeId, started); + await store.destroyTaskControlTransport(); + }); + + it('retries a terminal control receipt after settlement when storage recovers', async () => { + const userId = 'receipt-retry-user'; + const parentConversationId = randomUUID(); + await saveParent(userId, parentConversationId); + const store = new ReceiptTestSubagentThreadTaskStore(methods, { + controlReceiptRetryMs: 10, + }); + const config = buildSubagentThreadTaskConfig(store, { userId, parentConversationId }); + let finish = (_value: { content: string }): void => undefined; + const result = new Promise<{ content: string }>((resolve) => { + finish = resolve; + }); + const started = store.start( + taskRequest(config.scopeId, { + run: async () => result, + }), + ); + const taskId = requireAccepted(started).task.taskId; + const threadId = requireThreadId(started); + await waitUntil( + async () => + ( + await methods.getMessages( + { user: userId, conversationId: threadId, messageId: `${taskId}:user` }, + '+subagentTask', + ) + ).length === 1, + 'the durable task input', + ); + const command = { action: 'queue' as const, message: 'Verify the source.' }; + const accepted = await store.controlTask(config.scopeId, taskId, command, 'retry-invocation'); + expect(accepted).toMatchObject({ status: 'accepted' }); + const controlId = accepted.status === 'accepted' ? accepted.controlId : undefined; + expect(controlId).toBeDefined(); + + const persistReceipt = methods.recordSubagentTaskControlReceipt; + const persistence = jest + .spyOn(methods, 'recordSubagentTaskControlReceipt') + .mockRejectedValueOnce(new Error('database temporarily unavailable')) + .mockResolvedValueOnce(false) + .mockImplementation(persistReceipt); + try { + const appliedAt = Date.now(); + store.emitControlReceiptForTest(config.scopeId, taskId, { + controlId: controlId as string, + action: 'queue', + status: 'applied', + createdAt: appliedAt - 1, + updatedAt: appliedAt, + boundary: 'turn', + }); + await waitUntil( + () => persistence.mock.calls.length >= 1, + 'the applied transition persistence attempt', + ); + finish({ content: 'Done.' }); + await waitForSettled(store, config.scopeId, started); + + await waitUntil( + () => persistence.mock.calls.length >= 3, + 'the post-settlement receipt retry', + ); + await waitUntil(async () => { + const [input] = await methods.getMessages( + { user: userId, conversationId: threadId, messageId: `${taskId}:user` }, + '+subagentTask', + ); + return input?.subagentTask?.controlReceipts?.[0]?.status === 'applied'; + }, 'the receipt retry to converge after terminal settlement'); + expect(persistence.mock.calls.length).toBeGreaterThanOrEqual(3); + } finally { + persistence.mockRestore(); + finish({ content: 'Done.' }); + await store.destroyTaskControlTransport(); + } + }); + + it('flushes a pending control receipt once during graceful shutdown', async () => { + const userId = 'receipt-shutdown-user'; + const parentConversationId = randomUUID(); + await saveParent(userId, parentConversationId); + const store = new ReceiptTestSubagentThreadTaskStore(methods, { + controlReceiptRetryMs: 60_000, + }); + const config = buildSubagentThreadTaskConfig(store, { userId, parentConversationId }); + let runtime: SubagentTaskRuntime | undefined; + let finish = (_value: { content: string }): void => undefined; + const result = new Promise<{ content: string }>((resolve) => { + finish = resolve; + }); + const started = store.start( + taskRequest(config.scopeId, { + run: async (taskRuntime) => { + runtime = taskRuntime; + return result; + }, + }), + ); + const taskId = requireAccepted(started).task.taskId; + const threadId = requireThreadId(started); + await waitUntil(() => runtime != null, 'the child runtime to start'); + const accepted = await store.controlTask( + config.scopeId, + taskId, + { action: 'queue', message: 'Persist before shutdown.' }, + 'shutdown-invocation', + ); + expect(accepted).toMatchObject({ status: 'accepted' }); + + const persistReceipt = methods.recordSubagentTaskControlReceipt; + const persistence = jest + .spyOn(methods, 'recordSubagentTaskControlReceipt') + .mockRejectedValueOnce(new Error('database temporarily unavailable')) + .mockImplementation(persistReceipt); + const controlId = accepted.status === 'accepted' ? accepted.controlId : undefined; + expect(controlId).toBeDefined(); + store.emitControlReceiptForTest(config.scopeId, taskId, { + controlId: controlId as string, + action: 'queue', + status: 'applied', + createdAt: Date.now() - 1, + updatedAt: Date.now(), + boundary: 'turn', + }); + await waitUntil(() => persistence.mock.calls.length === 1, 'the failed receipt write'); + + await store.destroyTaskControlTransport(); + const [input] = await methods.getMessages( + { user: userId, conversationId: threadId, messageId: `${taskId}:user` }, + '+subagentTask', + ); + expect(input?.subagentTask?.controlReceipts).toContainEqual( + expect.objectContaining({ invocationId: 'shutdown-invocation', status: 'applied' }), + ); + expect(persistence).toHaveBeenCalledTimes(2); + + persistence.mockRestore(); + finish({ content: 'Done.' }); + await waitForSettled(store, config.scopeId, started); + }); + it('fails a child closed when its owner address cannot be published', async () => { const userId = 'unregistered-user'; const parentConversationId = randomUUID(); diff --git a/packages/api/src/agents/subagentThreads.ts b/packages/api/src/agents/subagentThreads.ts index 40e5af74d06..d4fdb30eae6 100644 --- a/packages/api/src/agents/subagentThreads.ts +++ b/packages/api/src/agents/subagentThreads.ts @@ -21,6 +21,7 @@ import type { AllMethods, IActiveSubagentThreadLease, IConversation, + ISubagentTaskControlReceipt, IMessage, MessageMethods, ConversationMethods, @@ -62,6 +63,8 @@ const DEFAULT_OWNER_DRAIN_POLL_MS = 100; const DELETION_CANCEL_CONCURRENCY = 32; /** Bounds retained control invocations; one entry per applied command. */ const MAX_CONTROL_INVOCATIONS = 4_096; +const MAX_DURABLE_CONTROL_MESSAGE_CHARS = 4 * 1024; +const DEFAULT_CONTROL_RECEIPT_RETRY_MS = 5_000; /** Bounds retained live-only updates while an event transport is unavailable. */ const MAX_PENDING_ACTIVITY_EVENTS = 32; /** Live activity must never delay terminal notification indefinitely. */ @@ -99,6 +102,7 @@ type SubagentThreadMethods = Pick< | 'listActiveSubagentThreadLeases' | 'reserveSubagentThread' | 'releaseSubagentThreadLease' + | 'recordSubagentTaskControlReceipt' | 'renewSubagentThreadLease' | 'saveConvo' | 'saveMessage' @@ -132,6 +136,33 @@ type ThreadMessage = Pick< 'messageId' | 'parentMessageId' | 'text' | 'createdAt' | 'subagentTranscript' | 'subagentTask' >; +type SdkControlReceipt = { + controlId: string; + action: 'steer' | 'queue' | 'interrupt'; + status: 'accepted' | 'applied' | 'rejected' | 'failed'; + createdAt: number; + updatedAt: number; + boundary?: 'preempt' | 'tool' | 'turn'; + reason?: 'withdrawn' | 'task_completed' | 'task_cancelled' | 'task_failed'; +}; + +type SnapshotWithControlReceipts = SubagentTaskSnapshot & { + controlReceipts?: SdkControlReceipt[]; +}; + +type ControlInvocationRecord = { + scopeId: string; + taskId: string; + invocationId: string; + fingerprint: string; + command: SubagentTaskControlCommand; + result: SubagentTaskControlResult; + createdAt: number; + /** Last authoritative SDK transition, retained for idempotent retries even + * after the bounded SDK snapshot evicts older receipt history. */ + receipt?: ISubagentTaskControlReceipt; +}; + interface TaskThreadLease { idempotencyKey: string; taskId: string; @@ -188,6 +219,7 @@ export interface SubagentThreadTaskStoreOptions extends InMemorySubagentTaskStor taskRoutingTtlMs?: number; isOwnerActive?: (userId: string) => Promise; maxControlInvocations?: number; + controlReceiptRetryMs?: number; ownerFenceGraceMs?: number; fenceOwnerAdmission?: (userId: string, token: string, fencedUntil: Date) => Promise; renewOwnerAdmission?: (userId: string, token: string, fencedUntil: Date) => Promise; @@ -431,6 +463,42 @@ function drainKey(parentConversationId: string, taskId: string): string { return `${parentConversationId}\u0000${taskId}`; } +function controlTaskKey(scopeId: string, taskId: string): string { + return `${scopeId}\u0000${taskId}`; +} + +function parseControlTaskKey(key: string): { scopeId: string; taskId: string } | undefined { + const separator = key.lastIndexOf('\u0000'); + if (separator < 0 || separator === key.length - 1) return undefined; + return { scopeId: key.slice(0, separator), taskId: key.slice(separator + 1) }; +} + +function controlReceiptKey(scopeId: string, taskId: string, controlId: string): string { + return `${scopeId}\u0000${taskId}\u0000${controlId}`; +} + +function boundedControlMessage(command: SubagentTaskControlCommand): { + message?: string; + messageTruncated?: boolean; +} { + if (!('message' in command)) return {}; + if (command.message.length <= MAX_DURABLE_CONTROL_MESSAGE_CHARS) { + return { message: command.message }; + } + return { + message: command.message.slice(0, MAX_DURABLE_CONTROL_MESSAGE_CHARS), + messageTruncated: true, + }; +} + +function boundedControlCommand(command: SubagentTaskControlCommand): SubagentTaskControlCommand { + if (!('message' in command)) return command; + return { + action: command.action, + message: command.message.slice(0, MAX_DURABLE_CONTROL_MESSAGE_CHARS), + }; +} + function safeErrorMessage(error: unknown): string { return `Subagent task failed: ${publicFailureDetail(error).slice(0, 2_000)}`; } @@ -453,11 +521,18 @@ async function observeSlowPreparation( export class SubagentThreadTaskStore extends InMemorySubagentTaskStore { readonly supportsThreadContinuation = true; private readonly activeThreads = new Map(); - private readonly controlInvocations = new Map< + private readonly controlInvocations = new Map(); + + private readonly controlInvocationByReceipt = new Map(); + private readonly pendingControlReceipts = new Map< string, - { scopeId: string; taskId: string; fingerprint: string; result: SubagentTaskControlResult } + Map >(); + private readonly controlPersistenceTails = new Map>(); + private readonly controlPersistenceRetryTimers = new Map>(); + private controlPersistenceStopping = false; + private readonly parentPersistence = new Map>(); private readonly maxThreadDepth: number; private readonly leaseTtlMs: number; @@ -466,6 +541,7 @@ export class SubagentThreadTaskStore extends InMemorySubagentTaskStore { private readonly ownerDrainPollMs: number; private readonly taskRoutingTtlMs: number; private readonly maxControlInvocations: number; + private readonly controlReceiptRetryMs: number; private readonly ownerFenceGraceMs: number; private readonly isOwnerActive: (userId: string) => Promise; private readonly fenceOwnerAdmission?: ( @@ -510,6 +586,10 @@ export class SubagentThreadTaskStore extends InMemorySubagentTaskStore { options.maxControlInvocations, MAX_CONTROL_INVOCATIONS, ); + this.controlReceiptRetryMs = positiveInteger( + options.controlReceiptRetryMs, + DEFAULT_CONTROL_RECEIPT_RETRY_MS, + ); this.ownerFenceGraceMs = positiveInteger(options.ownerFenceGraceMs, OWNER_FENCE_GRACE_MS); this.isOwnerActive = options.isOwnerActive ?? (async () => true); this.fenceOwnerAdmission = options.fenceOwnerAdmission; @@ -519,6 +599,191 @@ export class SubagentThreadTaskStore extends InMemorySubagentTaskStore { this.onTaskPrepared = options.onTaskPrepared; } + /** Receives payload-free authoritative transitions from the SDK task store. */ + protected onControlReceipt(scopeId: string, taskId: string, receipt: SdkControlReceipt): void { + const invocation = this.controlInvocationByReceipt.get( + controlReceiptKey(scopeId, taskId, receipt.controlId), + ); + const threadId = this.get(scopeId, taskId)?.threadId; + if (invocation == null || threadId == null) return; + const durable = this.durableReceipt(invocation, receipt); + invocation.receipt = durable; + void this.queueControlReceipt(scopeId, taskId, threadId, durable).catch((error) => { + logger.warn('[subagentThreads] Failed to persist a child control transition', error); + }); + } + + private durableReceipt( + invocation: ControlInvocationRecord, + receipt: SdkControlReceipt, + ): ISubagentTaskControlReceipt { + return { + invocationId: invocation.invocationId, + fingerprint: invocation.fingerprint, + controlId: receipt.controlId, + action: receipt.action, + status: receipt.status, + createdAt: new Date(receipt.createdAt), + updatedAt: new Date(receipt.updatedAt), + ...(receipt.boundary == null ? {} : { boundary: receipt.boundary }), + ...(receipt.reason == null ? {} : { reason: receipt.reason }), + ...boundedControlMessage(invocation.command), + }; + } + + private controlResultReceipt( + invocation: ControlInvocationRecord, + ): ISubagentTaskControlReceipt | undefined { + const { command, result } = invocation; + if (result.status === 'not_found' || result.status === 'invalid') return undefined; + if (invocation.receipt != null) return invocation.receipt; + const snapshot = result.task as SnapshotWithControlReceipts; + if ( + result.status === 'accepted' && + result.controlId != null && + (command.action === 'steer' || command.action === 'queue' || command.action === 'interrupt') + ) { + const sdkReceipt = snapshot.controlReceipts?.find( + (receipt) => receipt.controlId === result.controlId, + ); + if (sdkReceipt != null) return this.durableReceipt(invocation, sdkReceipt); + return { + invocationId: invocation.invocationId, + fingerprint: invocation.fingerprint, + controlId: result.controlId, + action: command.action, + status: 'accepted', + createdAt: new Date(invocation.createdAt), + updatedAt: new Date(invocation.createdAt), + ...boundedControlMessage(command), + }; + } + const now = new Date(); + let reason: string | undefined; + if (result.status === 'not_running') { + reason = 'task_not_running'; + } else if (result.status === 'control_not_found') { + reason = 'control_not_found'; + } + let targetControlId: string | undefined; + if (command.action === 'cancel_message') { + targetControlId = command.controlId; + } else if (result.status === 'accepted') { + targetControlId = result.controlId; + } + return { + invocationId: invocation.invocationId, + fingerprint: invocation.fingerprint, + ...(targetControlId == null ? {} : { controlId: targetControlId }), + action: command.action, + status: + result.status === 'accepted' || result.status === 'cancelled' ? 'applied' : 'rejected', + createdAt: new Date(invocation.createdAt), + updatedAt: now, + ...(reason == null ? {} : { reason }), + ...boundedControlMessage(command), + }; + } + + private queueControlReceipt( + scopeId: string, + taskId: string, + threadId: string, + receipt: ISubagentTaskControlReceipt, + ): Promise { + const key = controlTaskKey(scopeId, taskId); + const pending = this.pendingControlReceipts.get(key) ?? new Map(); + pending.set(receipt.invocationId, { threadId, receipt }); + this.pendingControlReceipts.set(key, pending); + return this.flushControlReceipts(scopeId, taskId); + } + + private flushControlReceipts(scopeId: string, taskId: string): Promise { + const key = controlTaskKey(scopeId, taskId); + const prior = this.controlPersistenceTails.get(key) ?? Promise.resolve(); + const operation = prior + .catch(() => undefined) + .then(async () => { + const pending = this.pendingControlReceipts.get(key); + if (pending == null) return; + const scope = parseScope(scopeId); + for (const [invocationId, candidate] of [...pending]) { + const current = pending.get(invocationId); + if (current !== candidate) continue; + const persisted = await this.runWithOwnerContext(scope, () => + this.methods.recordSubagentTaskControlReceipt({ + userId: scope.userId, + conversationId: candidate.threadId, + taskId, + ...(scope.tenantId == null ? {} : { tenantId: scope.tenantId }), + receipt: candidate.receipt, + }), + ); + if (!persisted) { + throw new Error('The child control receipt target is not ready.'); + } + if (pending.get(invocationId) === candidate) { + pending.delete(invocationId); + } + } + if (pending.size === 0) { + this.pendingControlReceipts.delete(key); + const retry = this.controlPersistenceRetryTimers.get(key); + if (retry != null) clearTimeout(retry); + this.controlPersistenceRetryTimers.delete(key); + } + }); + this.controlPersistenceTails.set(key, operation); + void operation.then( + () => { + if (this.controlPersistenceTails.get(key) === operation) { + this.controlPersistenceTails.delete(key); + } + this.scheduleControlReceiptRetry(scopeId, taskId); + }, + () => { + if (this.controlPersistenceTails.get(key) === operation) { + this.controlPersistenceTails.delete(key); + } + this.scheduleControlReceiptRetry(scopeId, taskId); + }, + ); + return operation; + } + + /** A terminal child may have no later caller to retrigger persistence. Keep a + * single bounded retry timer per task so transient storage failures converge + * while this process still owns the task; restart durability remains AI-1737. */ + private scheduleControlReceiptRetry(scopeId: string, taskId: string): void { + const key = controlTaskKey(scopeId, taskId); + if (this.get(scopeId, taskId) == null) { + this.pendingControlReceipts.delete(key); + return; + } + if ( + this.controlPersistenceStopping || + this.controlPersistenceRetryTimers.has(key) || + !this.pendingControlReceipts.has(key) + ) { + return; + } + const timer = setTimeout(() => { + this.controlPersistenceRetryTimers.delete(key); + void this.flushControlReceipts(scopeId, taskId).catch((error) => { + logger.warn('[subagentThreads] Failed to retry child control receipts', error); + }); + }, this.controlReceiptRetryMs); + this.controlPersistenceRetryTimers.set(key, timer); + } + + private async flushControlReceiptsForSettlement(scopeId: string, taskId: string): Promise { + try { + await this.flushControlReceipts(scopeId, taskId); + } catch (error) { + logger.warn('[subagentThreads] Failed to flush child control receipts', error); + } + } + /** Enables optional cross-replica lookup after the host's Redis service is ready. */ async configureTaskControlTransport(transport: SubagentTaskControlTransport): Promise { if (this.taskControlTransport != null) { @@ -527,7 +792,7 @@ export class SubagentThreadTaskStore extends InMemorySubagentTaskStore { await transport.bind({ claim: (scopeId, taskId) => super.claim(scopeId, taskId), control: (scopeId, taskId, command, invocationId) => - this.controlInvocation(scopeId, taskId, command, invocationId), + this.controlInvocationAndPersist(scopeId, taskId, command, invocationId), list: (scopeId) => super.list(scopeId), cancelScope: (scopeId, threadIds) => this.cancelForScope(scopeId, threadIds), }); @@ -535,6 +800,16 @@ export class SubagentThreadTaskStore extends InMemorySubagentTaskStore { } async destroyTaskControlTransport(): Promise { + this.controlPersistenceStopping = true; + for (const timer of this.controlPersistenceRetryTimers.values()) clearTimeout(timer); + this.controlPersistenceRetryTimers.clear(); + const pendingTasks = [...this.pendingControlReceipts.keys()] + .map(parseControlTaskKey) + .filter((task): task is { scopeId: string; taskId: string } => task != null); + await Promise.allSettled( + pendingTasks.map(({ scopeId, taskId }) => this.flushControlReceipts(scopeId, taskId)), + ); + await Promise.allSettled(this.controlPersistenceTails.values()); const transport = this.taskControlTransport; this.taskControlTransport = undefined; await transport?.destroy(); @@ -1039,7 +1314,7 @@ export class SubagentThreadTaskStore extends InMemorySubagentTaskStore { command: SubagentTaskControlCommand, invocationId: string = randomUUID(), ): Promise { - const local = this.controlInvocation(scopeId, taskId, command, invocationId); + const local = await this.controlInvocationAndPersist(scopeId, taskId, command, invocationId); if (local.status !== 'not_found') { return local; } @@ -1093,7 +1368,50 @@ export class SubagentThreadTaskStore extends InMemorySubagentTaskStore { if (result.status === 'not_found') { return result; } - this.controlInvocations.set(key, { scopeId, taskId, fingerprint, result }); + const invocation: ControlInvocationRecord = { + scopeId, + taskId, + invocationId, + fingerprint, + command: boundedControlCommand(command), + result, + createdAt: Date.now(), + }; + this.controlInvocations.set(key, invocation); + if ( + result.status === 'accepted' && + result.controlId != null && + (command.action === 'steer' || command.action === 'queue' || command.action === 'interrupt') + ) { + this.controlInvocationByReceipt.set( + controlReceiptKey(scopeId, taskId, result.controlId), + invocation, + ); + } + return result; + } + + private async controlInvocationAndPersist( + scopeId: string, + taskId: string, + command: SubagentTaskControlCommand, + invocationId: string, + ): Promise { + const result = this.controlInvocation(scopeId, taskId, command, invocationId); + const invocation = this.controlInvocations.get( + `${scopeId}\u0000${taskId}\u0000${invocationId}`, + ); + const threadId = 'task' in result ? result.task.threadId : undefined; + if (invocation == null || threadId == null) return result; + const receipt = this.controlResultReceipt(invocation); + if (receipt != null) { + try { + await this.queueControlReceipt(scopeId, taskId, threadId, receipt); + } catch (error) { + logger.warn('[subagentThreads] Failed to durably accept a child control', error); + throw new SubagentTaskOwnerUnavailableError(); + } + } return result; } @@ -1111,6 +1429,13 @@ export class SubagentThreadTaskStore extends InMemorySubagentTaskStore { for (const [key, invocation] of this.controlInvocations) { if (this.get(invocation.scopeId, invocation.taskId) == null) { this.controlInvocations.delete(key); + const result = invocation.result; + if (result.status === 'accepted' && result.controlId != null) { + this.controlInvocationByReceipt.delete( + controlReceiptKey(invocation.scopeId, invocation.taskId, result.controlId), + ); + } + this.pendingControlReceipts.delete(controlTaskKey(invocation.scopeId, invocation.taskId)); } } return this.controlInvocations.size < this.maxControlInvocations; @@ -1298,25 +1623,29 @@ export class SubagentThreadTaskStore extends InMemorySubagentTaskStore { (lease) => removed.has(lease.parentConversationId) || removed.has(lease.conversationId), ) .map((lease) => - cancelSlot(() => - this.controlTask( - serializeScope({ - userId, - parentConversationId: lease.parentConversationId, - ...(tenantId ? { tenantId } : {}), - }), - lease.taskId, - { action: 'cancel' }, - ), - ), + cancelSlot(async () => { + const scopeId = serializeScope({ + userId, + parentConversationId: lease.parentConversationId, + ...(tenantId ? { tenantId } : {}), + }); + const stopped = await transport.cancelScope(scopeId, [lease.conversationId]); + if (stopped > 0 || this.cancelUnroutedTask == null) return stopped; + return (await this.cancelUnroutedTask({ + userId, + parentConversationId: lease.parentConversationId, + taskId: lease.taskId, + ...(tenantId ? { tenantId } : {}), + })) + ? 1 + : 0; + }), ); for (const count of await Promise.all(scopeCancellations)) { cancelled += count; } - for (const result of await Promise.all(leaseCancellations)) { - if (result.status === 'cancelled') { - cancelled += 1; - } + for (const count of await Promise.all(leaseCancellations)) { + cancelled += count; } return cancelled; } @@ -1943,6 +2272,7 @@ export class SubagentThreadTaskStore extends InMemorySubagentTaskStore { if (savedUserMessage == null) { throw new Error('Unable to persist the child-thread input.'); } + await this.flushControlReceiptsForSettlement(scopeId, taskId); const currentParent = await this.methods.getConvo(scope.userId, scope.parentConversationId); if (currentParent == null || !matchesTenant(currentParent.tenantId, scope.tenantId)) { throw new SubagentThreadPublicError('Parent thread is unavailable.'); @@ -2013,6 +2343,7 @@ export class SubagentThreadTaskStore extends InMemorySubagentTaskStore { if (prepared.userMessageId == null) { throw new Error('The child-thread input was not prepared.'); } + await this.flushControlReceiptsForSettlement(request.scopeId, taskId); const subagentTranscript = serializeTranscript( taskId, prepared.initialStoredMessages, @@ -2063,6 +2394,7 @@ export class SubagentThreadTaskStore extends InMemorySubagentTaskStore { error: unknown, detachedUsage: UsageMetadata[], ): Promise { + await this.flushControlReceiptsForSettlement(request.scopeId, taskId); const conversation = await this.currentConversation(scope, request, threadId); if (conversation == null || !(await this.taskInputExists(scope, threadId, taskId))) { return; @@ -2128,6 +2460,7 @@ export class SubagentThreadTaskStore extends InMemorySubagentTaskStore { taskId: string, detachedUsage: UsageMetadata[], ): Promise { + await this.flushControlReceiptsForSettlement(request.scopeId, taskId); const conversation = await this.currentConversation(scope, request, threadId); if (conversation == null || !(await this.taskInputExists(scope, threadId, taskId))) { return; @@ -2308,6 +2641,7 @@ const REQUIRED_THREAD_METHODS = [ 'getConvo', 'getMessages', 'listActiveSubagentThreadLeases', + 'recordSubagentTaskControlReceipt', 'releaseSubagentThreadLease', 'renewSubagentThreadLease', 'reserveSubagentThread', @@ -2330,7 +2664,11 @@ export function createSubagentThreadTaskStore( > & Pick< MessageMethods, - 'claimSubagentTaskResult' | 'deleteMessages' | 'getMessages' | 'saveMessage' + | 'claimSubagentTaskResult' + | 'deleteMessages' + | 'getMessages' + | 'recordSubagentTaskControlReceipt' + | 'saveMessage' >, options?: SubagentThreadTaskStoreOptions, ): SubagentThreadTaskStore { diff --git a/packages/api/src/agents/view.spec.ts b/packages/api/src/agents/view.spec.ts index 8bee7350d91..a3527d6813c 100644 --- a/packages/api/src/agents/view.spec.ts +++ b/packages/api/src/agents/view.spec.ts @@ -127,6 +127,7 @@ describe('subagent thread parent-scoped view', () => { status: 'completed', activity: [], activityTruncated: false, + controlReceipts: [], messages: [ expect.objectContaining({ messageId: 'task-1:user', role: 'user' }), expect.objectContaining({ @@ -203,6 +204,63 @@ describe('subagent thread parent-scoped view', () => { expect(view.messages[0]).not.toHaveProperty('subagentTranscript'); }); + it('returns bounded authoritative control receipts without private fingerprints', async () => { + const input = message('task-1:user', 'running', true); + input.subagentTask!.controlReceipts = [ + ...Array.from({ length: 32 }, (_, index) => ({ + invocationId: `earlier-${index}`, + fingerprint: `private-${index}`, + action: 'queue' as const, + status: 'applied' as const, + createdAt: new Date(`2026-08-21T10:00:${String(index).padStart(2, '0')}.000Z`), + updatedAt: new Date(`2026-08-21T10:00:${String(index).padStart(2, '0')}.000Z`), + })), + { + invocationId: 'invocation-1', + fingerprint: 'private-fingerprint', + controlId: 'control-1', + action: 'steer', + status: 'applied', + createdAt: new Date('2026-08-21T11:00:01.000Z'), + updatedAt: new Date('2026-08-21T11:00:02.000Z'), + boundary: 'tool', + message: 'x'.repeat(1_000), + }, + ]; + const handler = createSubagentThreadViewHandler({ + getConvoOwnership: jest.fn().mockResolvedValue(parent), + getSubagentThreadForParent: jest.fn().mockResolvedValue(child), + getMessagesForSubagentThreadView: jest + .fn() + .mockResolvedValue([message('task-1:assistant', 'completed'), input]), + }); + const { response, json } = createResponse(); + + await handler(createRequest({}, { taskId: 'task-1' }), response); + + const view = json.mock.calls[0][0]; + expect(view.controlReceipts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + invocationId: 'invocation-1', + controlId: 'control-1', + action: 'steer', + status: 'applied', + boundary: 'tool', + messageTruncated: true, + }), + ]), + ); + const projected = view.controlReceipts.find( + (receipt: { invocationId: string }) => receipt.invocationId === 'invocation-1', + ); + expect(projected).toBeDefined(); + expect(Buffer.byteLength(projected?.message ?? '', 'utf8')).toBeLessThanOrEqual(512); + expect(view.controlReceipts).toHaveLength(32); + expect(view.controlReceiptsTruncated).toBe(true); + expect(JSON.stringify(view)).not.toContain('private-fingerprint'); + }); + it('fences replacement activity to the exact selected task input', async () => { const selected = { ...message('task-1:assistant', 'completed'), diff --git a/packages/api/src/agents/view.ts b/packages/api/src/agents/view.ts index ad9393c3a08..36859a51c63 100644 --- a/packages/api/src/agents/view.ts +++ b/packages/api/src/agents/view.ts @@ -3,6 +3,7 @@ import type { ParentSubagentIndex, ParentSubagentSummary, ParentSubagentTaskSummary, + SubagentControlReceipt, SubagentThreadMessage, SubagentThreadStatus, SubagentThreadView, @@ -30,6 +31,8 @@ const MAX_TITLE_BYTES = 1024; const MAX_PARENT_CHILDREN = 64; const MAX_PARENT_TASKS_PER_CHILD = 20; const MAX_PARENT_INDEX_BYTES = 96 * 1024; +const MAX_PUBLIC_CONTROL_RECEIPTS = 32; +const MAX_PUBLIC_CONTROL_MESSAGE_BYTES = 512; type SubagentThreadViewDependencies = Pick< ConversationMethods, 'getConvoOwnership' | 'getSubagentThreadForParent' @@ -116,6 +119,44 @@ const publicMessage = ( }; }; +const publicControlReceipts = ( + messages: SubagentThreadViewMessageRecord[], + taskId: string, +): { receipts: SubagentControlReceipt[]; truncated: boolean } => { + const input = messages.find((message) => message.messageId === `${taskId}:user`); + const stored = input?.subagentTask?.controlReceipts ?? []; + const accepted = stored.filter((receipt) => receipt.status === 'accepted'); + const terminal = stored.filter((receipt) => receipt.status !== 'accepted'); + const terminalLimit = Math.max(0, MAX_PUBLIC_CONTROL_RECEIPTS - accepted.length); + const retained = [...accepted, ...(terminalLimit === 0 ? [] : terminal.slice(-terminalLimit))] + .slice(0, MAX_PUBLIC_CONTROL_RECEIPTS) + .map((receipt) => { + const message = + receipt.message == null + ? undefined + : truncateUtf8(receipt.message, MAX_PUBLIC_CONTROL_MESSAGE_BYTES); + return { + invocationId: truncateUtf8(receipt.invocationId, MAX_PUBLIC_ID_BYTES).text, + ...(receipt.controlId == null + ? {} + : { controlId: truncateUtf8(receipt.controlId, MAX_PUBLIC_ID_BYTES).text }), + action: receipt.action, + status: receipt.status, + createdAt: isoDate(receipt.createdAt) ?? new Date(0).toISOString(), + updatedAt: isoDate(receipt.updatedAt) ?? new Date(0).toISOString(), + ...(receipt.boundary == null ? {} : { boundary: receipt.boundary }), + ...(receipt.reason == null + ? {} + : { reason: truncateUtf8(receipt.reason, MAX_PUBLIC_ID_BYTES).text }), + ...(message == null ? {} : { message: message.text }), + ...(receipt.messageTruncated === true || message?.truncated === true + ? { messageTruncated: true } + : {}), + }; + }); + return { receipts: retained, truncated: retained.length < stored.length }; +}; + const publicStatus = ( messages: SubagentThreadViewMessageRecord[], activeLeaseTaskId: string | undefined, @@ -444,6 +485,10 @@ export function createSubagentThreadViewHandler(deps: SubagentThreadViewDependen projectedNewestFirst.push(projected.message); remainingTextBytes -= projected.bytes; } + const projectedControls = + requestedTaskId == null + ? { receipts: [], truncated: false } + : publicControlReceipts(newestFirst, requestedTaskId); const view: SubagentThreadView = { threadId, parentConversationId, @@ -461,6 +506,8 @@ export function createSubagentThreadViewHandler(deps: SubagentThreadViewDependen status: publicStatus(newestFirst, activeLeaseTaskId, requestedTaskId), activity: projectedActivity.activity, activityTruncated: projectedActivity.truncated, + controlReceipts: projectedControls.receipts, + ...(projectedControls.truncated ? { controlReceiptsTruncated: true } : {}), messages: projectedNewestFirst.reverse(), historyTruncated: historyTruncated || projectedNewestFirst.length < newestFirst.length, ...(isoDate(child.updatedAt) == null ? {} : { updatedAt: isoDate(child.updatedAt) }), diff --git a/packages/data-provider/src/types/subagents.ts b/packages/data-provider/src/types/subagents.ts index c5b7695f636..4f6f5554695 100644 --- a/packages/data-provider/src/types/subagents.ts +++ b/packages/data-provider/src/types/subagents.ts @@ -66,6 +66,19 @@ export type SubagentActivityItem = outputTruncated?: boolean; }; +export type SubagentControlReceipt = { + invocationId: string; + controlId?: string; + action: 'steer' | 'queue' | 'interrupt' | 'cancel' | 'cancel_message'; + status: 'accepted' | 'applied' | 'rejected' | 'failed'; + createdAt: string; + updatedAt: string; + boundary?: 'preempt' | 'tool' | 'turn'; + reason?: string; + message?: string; + messageTruncated?: boolean; +}; + export type SubagentThreadMessage = { messageId: string; parentMessageId: string | null; @@ -89,6 +102,10 @@ export type SubagentThreadView = { /** Activity for the exact task requested by the parent card, when retained. */ activity: SubagentActivityItem[]; activityTruncated: boolean; + /** Bounded authoritative parent-to-child command receipts for this task. */ + controlReceipts?: SubagentControlReceipt[]; + /** True when older authoritative command receipts were omitted from this view. */ + controlReceiptsTruncated?: boolean; messages: SubagentThreadMessage[]; historyTruncated: boolean; updatedAt?: string; diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index 1444ef51255..e26f9fea162 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -1,7 +1,7 @@ import mongoose from 'mongoose'; import { v4 as uuidv4 } from 'uuid'; -import { RetentionMode } from 'librechat-data-provider'; import { MongoMemoryServer } from 'mongodb-memory-server'; +import { Constants, RetentionMode } from 'librechat-data-provider'; import type { IMessage } from '..'; import { createMessageMethods, @@ -39,6 +39,9 @@ let updateMessageText: ReturnType['updateMessageTex let deleteMessagesSince: ReturnType['deleteMessagesSince']; let recordMessage: ReturnType['recordMessage']; let claimSubagentTaskResult: ReturnType['claimSubagentTaskResult']; +let recordSubagentTaskControlReceipt: ReturnType< + typeof createMessageMethods +>['recordSubagentTaskControlReceipt']; let releaseSubagentTaskResultClaim: ReturnType< typeof createMessageMethods >['releaseSubagentTaskResultClaim']; @@ -64,6 +67,7 @@ beforeAll(async () => { deleteMessagesSince = methods.deleteMessagesSince; recordMessage = methods.recordMessage; claimSubagentTaskResult = methods.claimSubagentTaskResult; + recordSubagentTaskControlReceipt = methods.recordSubagentTaskControlReceipt; releaseSubagentTaskResultClaim = methods.releaseSubagentTaskResultClaim; await mongoose.connect(mongoUri); @@ -2298,6 +2302,274 @@ describe('Message Operations', () => { expect((doc as Record | null)?.unknownPipelineField).toBeUndefined(); }); }); + describe('recordSubagentTaskControlReceipt', () => { + const createTaskInput = async (conversationId: string, taskId = 'task-1') => { + await Message.create({ + user: 'user123', + conversationId, + messageId: `${taskId}:user`, + parentMessageId: Constants.NO_PARENT, + sender: 'User', + text: 'Do the work', + endpoint: 'agents', + isCreatedByUser: true, + subagentTask: { + attemptKey: `attempt-${taskId}`, + status: 'running', + }, + }); + }; + + it('advances one invocation monotonically and enforces ownership', async () => { + const conversationId = uuidv4(); + await createTaskInput(conversationId); + const accepted = { + invocationId: 'invocation-1', + fingerprint: 'fingerprint-1', + controlId: 'control-1', + action: 'steer' as const, + status: 'accepted' as const, + createdAt: new Date('2026-08-24T12:00:00.000Z'), + updatedAt: new Date('2026-08-24T12:00:00.000Z'), + message: 'Use the primary source.', + }; + + await expect( + recordSubagentTaskControlReceipt({ + userId: 'user123', + conversationId, + taskId: 'task-1', + receipt: accepted, + }), + ).resolves.toBe(true); + await expect( + recordSubagentTaskControlReceipt({ + userId: 'user123', + conversationId, + taskId: 'task-1', + receipt: { + ...accepted, + status: 'applied', + boundary: 'tool', + updatedAt: new Date('2026-08-24T12:00:01.000Z'), + }, + }), + ).resolves.toBe(true); + /** A delayed accepted replay cannot downgrade the durable terminal receipt. */ + await recordSubagentTaskControlReceipt({ + userId: 'user123', + conversationId, + taskId: 'task-1', + receipt: accepted, + }); + await expect( + recordSubagentTaskControlReceipt({ + userId: 'another-user', + conversationId, + taskId: 'task-1', + receipt: accepted, + }), + ).resolves.toBe(false); + + const stored = await Message.findOne({ + user: 'user123', + conversationId, + messageId: 'task-1:user', + }) + .select('+subagentTask') + .lean(); + expect(stored).not.toBeNull(); + if (stored == null) throw new Error('Expected the durable task input.'); + expect(stored.subagentTask?.status).toBe('running'); + expect(stored.subagentTask?.controlReceipts).toEqual([ + expect.objectContaining({ + invocationId: 'invocation-1', + action: 'steer', + status: 'applied', + boundary: 'tool', + }), + ]); + }); + + it('updates only the authorized tenant when message identities collide', async () => { + const conversationId = uuidv4(); + const taskId = 'tenant-task'; + await Promise.all( + ['tenant-a', 'tenant-b'].map((tenantId) => + Message.create({ + user: 'user123', + tenantId, + conversationId, + messageId: `${taskId}:user`, + parentMessageId: Constants.NO_PARENT, + sender: 'User', + text: 'Do the tenant work', + endpoint: 'agents', + isCreatedByUser: true, + subagentTask: { attemptKey: `attempt-${tenantId}`, status: 'running' }, + }), + ), + ); + const now = new Date('2026-08-24T12:00:00.000Z'); + + await expect( + recordSubagentTaskControlReceipt({ + userId: 'user123', + tenantId: 'tenant-b', + conversationId, + taskId, + receipt: { + invocationId: 'tenant-invocation', + fingerprint: 'tenant-fingerprint', + controlId: 'tenant-control', + action: 'queue', + status: 'accepted', + createdAt: now, + updatedAt: now, + }, + }), + ).resolves.toBe(true); + + const [tenantA, tenantB] = await Promise.all( + ['tenant-a', 'tenant-b'].map((tenantId) => + Message.findOne({ + user: 'user123', + tenantId, + conversationId, + messageId: `${taskId}:user`, + }) + .select('+subagentTask') + .lean(), + ), + ); + expect(tenantA?.subagentTask?.controlReceipts).toBeUndefined(); + expect(tenantB?.subagentTask?.controlReceipts).toEqual([ + expect.objectContaining({ invocationId: 'tenant-invocation' }), + ]); + }); + + it('retains accepted commands while bounding terminal receipt history', async () => { + const conversationId = uuidv4(); + await createTaskInput(conversationId); + const createdAt = new Date('2026-08-24T12:00:00.000Z'); + await recordSubagentTaskControlReceipt({ + userId: 'user123', + conversationId, + taskId: 'task-1', + receipt: { + invocationId: 'pending', + fingerprint: 'pending-fingerprint', + controlId: 'pending-control', + action: 'queue', + status: 'accepted', + createdAt, + updatedAt: createdAt, + }, + }); + for (let index = 0; index < 70; index += 1) { + await recordSubagentTaskControlReceipt({ + userId: 'user123', + conversationId, + taskId: 'task-1', + receipt: { + invocationId: `terminal-${index}`, + fingerprint: `fingerprint-${index}`, + controlId: `control-${index}`, + action: 'steer', + status: 'applied', + createdAt: new Date(createdAt.getTime() + index + 1), + updatedAt: new Date(createdAt.getTime() + index + 1), + boundary: 'tool', + }, + }); + } + + const stored = await Message.findOne({ + user: 'user123', + conversationId, + messageId: 'task-1:user', + }) + .select('+subagentTask') + .lean(); + expect(stored).not.toBeNull(); + if (stored == null) throw new Error('Expected the durable task input.'); + expect(stored.subagentTask?.controlReceipts).toHaveLength(64); + expect(stored.subagentTask?.controlReceipts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ invocationId: 'pending', status: 'accepted' }), + expect.objectContaining({ invocationId: 'terminal-69', status: 'applied' }), + ]), + ); + expect(stored.subagentTask?.controlReceipts).not.toEqual( + expect.arrayContaining([expect.objectContaining({ invocationId: 'terminal-0' })]), + ); + + /** An old idempotent retry retains its original occurrence ordering and + * cannot evict newer terminal history merely by arriving again. */ + await recordSubagentTaskControlReceipt({ + userId: 'user123', + conversationId, + taskId: 'task-1', + receipt: { + invocationId: 'terminal-0', + fingerprint: 'fingerprint-0', + controlId: 'control-0', + action: 'steer', + status: 'applied', + createdAt: new Date(createdAt.getTime() + 1), + updatedAt: new Date(createdAt.getTime() + 1), + boundary: 'tool', + }, + }); + const afterReplay = await Message.findOne({ + user: 'user123', + conversationId, + messageId: 'task-1:user', + }) + .select('+subagentTask') + .lean(); + expect(afterReplay?.subagentTask?.controlReceipts).toHaveLength(64); + expect(afterReplay?.subagentTask?.controlReceipts).toEqual( + expect.arrayContaining([expect.objectContaining({ invocationId: 'terminal-7' })]), + ); + expect(afterReplay?.subagentTask?.controlReceipts).not.toEqual( + expect.arrayContaining([expect.objectContaining({ invocationId: 'terminal-0' })]), + ); + }); + + it('defensively caps accepted receipts outside the supported task-store path', async () => { + const conversationId = uuidv4(); + await createTaskInput(conversationId); + const createdAt = new Date('2026-08-24T12:00:00.000Z'); + for (let index = 0; index < 70; index += 1) { + await recordSubagentTaskControlReceipt({ + userId: 'user123', + conversationId, + taskId: 'task-1', + receipt: { + invocationId: `accepted-${index}`, + fingerprint: `fingerprint-${index}`, + controlId: `control-${index}`, + action: 'queue', + status: 'accepted', + createdAt: new Date(createdAt.getTime() + index), + updatedAt: new Date(createdAt.getTime() + index), + }, + }); + } + + const stored = await Message.findOne({ + user: 'user123', + conversationId, + messageId: 'task-1:user', + }) + .select('+subagentTask') + .lean(); + expect(stored?.subagentTask?.controlReceipts).toHaveLength(64); + expect(stored?.subagentTask?.controlReceipts?.[0]?.invocationId).toBe('accepted-6'); + }); + }); + describe('claimSubagentTaskResult', () => { const terminalResult = async (taskId: string, conversationId: string, status: string) => saveMessage({ userId: 'user123' }, { diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index 798f9a93431..941147f53c6 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -13,6 +13,8 @@ const MAX_STORED_USER_SUBMITTED_PATHS = 256; const MAX_NORMALIZED_USER_SUBMITTED_PATHS = MAX_STORED_USER_SUBMITTED_PATHS + 1; const MAX_STORED_USER_SUBMITTED_FIELD_PATHS = MAX_NORMALIZED_USER_SUBMITTED_PATHS; const MAX_USER_SUBMITTED_PATH_LENGTH = 2048; +const MAX_SUBAGENT_CONTROL_RECEIPTS = 64; +const MAX_SUBAGENT_CONTROL_MESSAGE_LENGTH = 4 * 1024; const PROVENANCE_PATHS_UNION_FIELD = '__lcProvenancePathsUnion'; const PROVENANCE_FIELD_PATHS_UNION_FIELD = '__lcProvenanceFieldPathsUnion'; const HITL_MESSAGE_FILTER_FIELD_SET = new Set(HITL_MESSAGE_FILTER_FIELDS); @@ -283,6 +285,13 @@ export interface MessageMethods { params: Partial & { newMessageId?: string }, metadata?: { context?: string }, ): Promise; + recordSubagentTaskControlReceipt(input: { + userId: string; + conversationId: string; + taskId: string; + tenantId?: string; + receipt: NonNullable['controlReceipts']>[number]; + }): Promise; bulkSaveMessages( messages: Array>, overrideTimestamp?: boolean, @@ -866,6 +875,275 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa } } + /** + * Atomically records one bounded parent-to-child control receipt on the + * durable task input. Terminal receipt states are monotonic, and accepted + * receipts are retained ahead of older terminal history when the bound fills. + */ + async function recordSubagentTaskControlReceipt({ + userId, + conversationId, + taskId, + tenantId, + receipt, + }: { + userId: string; + conversationId: string; + taskId: string; + tenantId?: string; + receipt: NonNullable['controlReceipts']>[number]; + }): Promise { + const validActions = new Set(['steer', 'queue', 'interrupt', 'cancel', 'cancel_message']); + const validStatuses = new Set(['accepted', 'applied', 'rejected', 'failed']); + if ( + userId.length === 0 || + conversationId.length === 0 || + conversationId.length > 256 || + taskId.length === 0 || + taskId.length > 256 || + receipt.invocationId.length === 0 || + receipt.invocationId.length > 128 || + receipt.fingerprint.length === 0 || + receipt.fingerprint.length > 128 || + !validActions.has(receipt.action) || + !validStatuses.has(receipt.status) || + (receipt.controlId != null && receipt.controlId.length > 256) || + (receipt.message != null && receipt.message.length > MAX_SUBAGENT_CONTROL_MESSAGE_LENGTH) || + !Number.isFinite(receipt.createdAt.getTime()) || + !Number.isFinite(receipt.updatedAt.getTime()) + ) { + throw new TypeError('Invalid subagent task control receipt'); + } + const Message = mongoose.models.Message as Model; + const terminalStatuses = ['applied', 'rejected', 'failed']; + const updated = await Message.findOneAndUpdate( + { + user: userId, + conversationId, + ...(tenantId == null ? { tenantId: { $exists: false } } : { tenantId }), + messageId: `${taskId}:user`, + 'subagentTask.status': 'running', + }, + [ + { + $set: { + 'subagentTask.controlReceipts': { + $let: { + vars: { + current: { + $cond: [ + { $isArray: '$subagentTask.controlReceipts' }, + '$subagentTask.controlReceipts', + [], + ], + }, + }, + in: { + $let: { + vars: { + existing: { + $arrayElemAt: [ + { + $filter: { + input: '$$current', + as: 'candidate', + cond: { $eq: ['$$candidate.invocationId', receipt.invocationId] }, + }, + }, + 0, + ], + }, + }, + in: { + $let: { + vars: { + next: { + $cond: [ + { + $or: [ + { + $in: [{ $ifNull: ['$$existing.status', ''] }, terminalStatuses], + }, + { + $and: [ + { $ne: [{ $ifNull: ['$$existing', null] }, null] }, + { $ne: ['$$existing.fingerprint', receipt.fingerprint] }, + ], + }, + ], + }, + '$$existing', + { $literal: receipt }, + ], + }, + }, + in: { + $let: { + vars: { + merged: { + $concatArrays: [ + { + $filter: { + input: '$$current', + as: 'candidate', + cond: { + $ne: ['$$candidate.invocationId', receipt.invocationId], + }, + }, + }, + ['$$next'], + ], + }, + }, + in: { + $let: { + vars: { + accepted: { + /** The supported task store admits at most 32 live + * controls. Keep a defensive storage bound here so + * custom callers cannot grow the private projection. */ + $slice: [ + { + $filter: { + input: '$$merged', + as: 'candidate', + cond: { $eq: ['$$candidate.status', 'accepted'] }, + }, + }, + -MAX_SUBAGENT_CONTROL_RECEIPTS, + ], + }, + }, + in: { + $concatArrays: [ + '$$accepted', + { + $slice: [ + { + /** DocumentDB 5 does not support $sortArray. + * Insert each bounded receipt into a stable + * createdAt/invocationId order using baseline + * aggregation expressions instead. */ + $reduce: { + input: { + $filter: { + input: '$$merged', + as: 'candidate', + cond: { + $ne: ['$$candidate.status', 'accepted'], + }, + }, + }, + initialValue: [], + in: { + $concatArrays: [ + { + $filter: { + input: '$$value', + as: 'ordered', + cond: { + $or: [ + { + $lt: [ + '$$ordered.createdAt', + '$$this.createdAt', + ], + }, + { + $and: [ + { + $eq: [ + '$$ordered.createdAt', + '$$this.createdAt', + ], + }, + { + $lte: [ + '$$ordered.invocationId', + '$$this.invocationId', + ], + }, + ], + }, + ], + }, + }, + }, + ['$$this'], + { + $filter: { + input: '$$value', + as: 'ordered', + cond: { + $or: [ + { + $gt: [ + '$$ordered.createdAt', + '$$this.createdAt', + ], + }, + { + $and: [ + { + $eq: [ + '$$ordered.createdAt', + '$$this.createdAt', + ], + }, + { + $gt: [ + '$$ordered.invocationId', + '$$this.invocationId', + ], + }, + ], + }, + ], + }, + }, + }, + ], + }, + }, + }, + { + $multiply: [ + -1, + { + $max: [ + 0, + { + $subtract: [ + MAX_SUBAGENT_CONTROL_RECEIPTS, + { $size: '$$accepted' }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + { new: true, projection: { messageId: 1 } }, + ).lean<{ messageId: string } | null>(); + return updated != null; + } + /** Atomically assigns one durable terminal child result to either its * explicit poller or one idempotent automatic wakeup delivery. */ async function claimSubagentTaskResult({ @@ -1470,6 +1748,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa updateMessageText, updateToolCallResult, updateMessage, + recordSubagentTaskControlReceipt, claimSubagentTaskResult, releaseSubagentTaskResultClaim, deleteMessagesSince, diff --git a/packages/data-schemas/src/schema/message.ts b/packages/data-schemas/src/schema/message.ts index 08dfeafaea7..56f594d1d6c 100644 --- a/packages/data-schemas/src/schema/message.ts +++ b/packages/data-schemas/src/schema/message.ts @@ -175,6 +175,33 @@ const messageSchema: Schema = new Schema( _id: false, default: undefined, }, + controlReceipts: { + type: [ + { + invocationId: { type: String, required: true }, + fingerprint: { type: String, required: true }, + controlId: { type: String }, + action: { + type: String, + enum: ['steer', 'queue', 'interrupt', 'cancel', 'cancel_message'], + required: true, + }, + status: { + type: String, + enum: ['accepted', 'applied', 'rejected', 'failed'], + required: true, + }, + createdAt: { type: Date, required: true }, + updatedAt: { type: Date, required: true }, + boundary: { type: String, enum: ['preempt', 'tool', 'turn'] }, + reason: { type: String }, + message: { type: String }, + messageTruncated: { type: Boolean }, + _id: false, + }, + ], + default: undefined, + }, }, _id: false, select: false, diff --git a/packages/data-schemas/src/types/message.ts b/packages/data-schemas/src/types/message.ts index fffc14bdc31..fa36036cffd 100644 --- a/packages/data-schemas/src/types/message.ts +++ b/packages/data-schemas/src/types/message.ts @@ -5,6 +5,30 @@ import type { } from 'librechat-data-provider'; import type { Document } from 'mongoose'; +export type SubagentTaskControlAction = + | 'steer' + | 'queue' + | 'interrupt' + | 'cancel' + | 'cancel_message'; + +export type SubagentTaskControlReceiptStatus = 'accepted' | 'applied' | 'rejected' | 'failed'; + +/** Server-private durable receipt for one parent-to-child control invocation. */ +export interface ISubagentTaskControlReceipt { + invocationId: string; + fingerprint: string; + controlId?: string; + action: SubagentTaskControlAction; + status: SubagentTaskControlReceiptStatus; + createdAt: Date; + updatedAt: Date; + boundary?: 'preempt' | 'tool' | 'turn'; + reason?: string; + message?: string; + messageTruncated?: boolean; +} + // @ts-ignore export interface IMessage extends Document { messageId: string; @@ -70,6 +94,7 @@ export interface IMessage extends Document { claimId: string; claimedAt: Date; }; + controlReceipts?: ISubagentTaskControlReceipt[]; }; contextMeta?: { calibrationRatio?: number; From f9c051f8ead663546f7e2553e88a38b0f90463fc Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 24 Aug 2026 11:52:30 -0400 Subject: [PATCH 14/20] =?UTF-8?q?=F0=9F=AA=B6=20feat:=20Support=20Non-Pers?= =?UTF-8?q?istent=20Controlled=20Themes=20(#15170)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/client/src/theme/README.md | 5 + .../src/theme/context/ThemeProvider.spec.tsx | 68 +++++++++ .../src/theme/context/ThemeProvider.tsx | 137 ++++++++++-------- 3 files changed, 153 insertions(+), 57 deletions(-) diff --git a/packages/client/src/theme/README.md b/packages/client/src/theme/README.md index 64f171e0d8c..46e74e41130 100644 --- a/packages/client/src/theme/README.md +++ b/packages/client/src/theme/README.md @@ -545,6 +545,11 @@ function App() { remain synchronized when they change. Only pass theme props when the parent should control those values; otherwise use the context setters and allow stored preferences to remain authoritative. +Set `persistThemeDefinition={false}` when a parent controls a deployment or embedded theme that +must not replace the user's stored theme definition, legacy colors, name, or source. Appearance +mode changes remain independently persisted through `color-theme`; leave `initialTheme` undefined +when the stored light, dark, or system preference should remain authoritative. + ## Contributing When adding new theme colors: diff --git a/packages/client/src/theme/context/ThemeProvider.spec.tsx b/packages/client/src/theme/context/ThemeProvider.spec.tsx index 10b06603020..48991884b5c 100644 --- a/packages/client/src/theme/context/ThemeProvider.spec.tsx +++ b/packages/client/src/theme/context/ThemeProvider.spec.tsx @@ -106,6 +106,74 @@ describe('ThemeProvider', () => { expect(localStorage.getItem('theme-source')).toBe('definition'); }); + it('applies a controlled definition without replacing stored theme preferences', async () => { + const storedDefinition = { + version: 1 as const, + name: 'stored', + modes: { light: { colors: { 'rgb-accent-primary': '1 2 3' } } }, + }; + const storedColors = { 'rgb-accent-primary': '1 2 3' }; + localStorage.setItem('theme-definition', JSON.stringify(storedDefinition)); + localStorage.setItem('theme-colors', JSON.stringify(storedColors)); + localStorage.setItem('theme-name', 'stored'); + localStorage.setItem('theme-source', 'legacy'); + + const { rerender, unmount } = render( + + + , + ); + + await waitFor(() => { + expect(document.documentElement.dataset.theme).toBe('deployment'); + }); + expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('4 5 6'); + expect(JSON.parse(localStorage.getItem('theme-definition') ?? '{}')).toEqual(storedDefinition); + expect(JSON.parse(localStorage.getItem('theme-colors') ?? '{}')).toEqual(storedColors); + expect(localStorage.getItem('theme-name')).toBe('stored'); + expect(localStorage.getItem('theme-source')).toBe('legacy'); + + rerender( + + + , + ); + + await waitFor(() => { + expect(document.documentElement.dataset.theme).toBe('deployment-next'); + }); + expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('7 8 9'); + expect(JSON.parse(localStorage.getItem('theme-definition') ?? '{}')).toEqual(storedDefinition); + + unmount(); + render( + + + , + ); + + await waitFor(() => { + expect(document.documentElement.dataset.theme).toBe('stored'); + }); + expect(document.documentElement.style.getPropertyValue('--accent-primary')).toBe('1 2 3'); + }); + it('keeps valid legacy overrides when another token is malformed', async () => { render( (undefined); const themeClassSnapshot = useRef(undefined); + const writeThemeStorage = useCallback( + (key: string, value?: string) => { + if (!persistThemeDefinition) { + return; + } + writeStorage(key, value); + }, + [persistThemeDefinition], + ); + const restoreAppliedTheme = useCallback((root = window.document.documentElement) => { if (!themeDOMSnapshot.current) { return; @@ -326,19 +339,19 @@ export function ThemeProvider({ const definition = validPropDefinition ?? legacyDefinition; if (!definition) { if (propThemeName !== undefined && themeDefinition) { - writeStorage(THEME_DEFINITION_KEY, JSON.stringify(themeDefinition)); - writeStorage(THEME_NAME_KEY, themeDefinition.name); - writeStorage(THEME_SOURCE_KEY, legacyThemeRGB ? 'legacy' : 'definition'); + writeThemeStorage(THEME_DEFINITION_KEY, JSON.stringify(themeDefinition)); + writeThemeStorage(THEME_NAME_KEY, themeDefinition.name); + writeThemeStorage(THEME_SOURCE_KEY, legacyThemeRGB ? 'legacy' : 'definition'); } else if (propThemeName && !themeDefinition) { - writeStorage(THEME_NAME_KEY, propThemeName); + writeThemeStorage(THEME_NAME_KEY, propThemeName); } return; } - writeStorage(THEME_DEFINITION_KEY, JSON.stringify(definition)); - writeStorage(THEME_NAME_KEY, definition.name); - writeStorage(THEME_SOURCE_KEY, legacyDefinition ? 'legacy' : 'definition'); - writeStorage( + writeThemeStorage(THEME_DEFINITION_KEY, JSON.stringify(definition)); + writeThemeStorage(THEME_NAME_KEY, definition.name); + writeThemeStorage(THEME_SOURCE_KEY, legacyDefinition ? 'legacy' : 'definition'); + writeThemeStorage( THEME_COLORS_KEY, !propThemeDefinition && legacyDefinition ? JSON.stringify(legacyDefinition.modes.light?.colors ?? {}) @@ -351,6 +364,7 @@ export function ThemeProvider({ propThemeName, propThemeRGB, themeDefinition, + writeThemeStorage, ]); const setTheme = useCallback((newTheme: string) => { @@ -361,57 +375,66 @@ export function ThemeProvider({ writeStorage(THEME_KEY, newTheme); }, []); - const setThemeDefinition = useCallback((definition?: ThemeDefinition) => { - const errors = definition ? validateThemeDefinition(definition) : []; - if (errors.length > 0) { - throw new TypeError(errors.join('\n')); - } - themeDefinitionRef.current = definition; - setThemeDefinitionState(definition); - legacyThemeRGBRef.current = undefined; - setLegacyThemeRGB(undefined); - writeStorage(THEME_DEFINITION_KEY, definition ? JSON.stringify(definition) : undefined); - writeStorage(THEME_COLORS_KEY); - writeStorage(THEME_SOURCE_KEY, definition ? 'definition' : undefined); - setThemeNameState(definition?.name); - themeNameRef.current = definition?.name; - writeStorage(THEME_NAME_KEY, definition?.name); - }, []); + const setThemeDefinition = useCallback( + (definition?: ThemeDefinition) => { + const errors = definition ? validateThemeDefinition(definition) : []; + if (errors.length > 0) { + throw new TypeError(errors.join('\n')); + } + themeDefinitionRef.current = definition; + setThemeDefinitionState(definition); + legacyThemeRGBRef.current = undefined; + setLegacyThemeRGB(undefined); + writeThemeStorage(THEME_DEFINITION_KEY, definition ? JSON.stringify(definition) : undefined); + writeThemeStorage(THEME_COLORS_KEY); + writeThemeStorage(THEME_SOURCE_KEY, definition ? 'definition' : undefined); + setThemeNameState(definition?.name); + themeNameRef.current = definition?.name; + writeThemeStorage(THEME_NAME_KEY, definition?.name); + }, + [writeThemeStorage], + ); - const setThemeRGB = useCallback((colors?: IThemeRGB) => { - const definition = colors - ? fromLegacyTheme(colors, themeDefinitionRef.current?.name ?? themeNameRef.current) - : undefined; - const legacyColors = definition?.modes.light?.colors; - themeDefinitionRef.current = definition; - setThemeDefinitionState(definition); - legacyThemeRGBRef.current = legacyColors; - setLegacyThemeRGB(legacyColors); - setThemeNameState(definition?.name); - themeNameRef.current = definition?.name; - writeStorage(THEME_DEFINITION_KEY, definition ? JSON.stringify(definition) : undefined); - writeStorage(THEME_NAME_KEY, definition?.name); - writeStorage(THEME_COLORS_KEY, legacyColors ? JSON.stringify(legacyColors) : undefined); - writeStorage(THEME_SOURCE_KEY, definition ? 'legacy' : undefined); - }, []); + const setThemeRGB = useCallback( + (colors?: IThemeRGB) => { + const definition = colors + ? fromLegacyTheme(colors, themeDefinitionRef.current?.name ?? themeNameRef.current) + : undefined; + const legacyColors = definition?.modes.light?.colors; + themeDefinitionRef.current = definition; + setThemeDefinitionState(definition); + legacyThemeRGBRef.current = legacyColors; + setLegacyThemeRGB(legacyColors); + setThemeNameState(definition?.name); + themeNameRef.current = definition?.name; + writeThemeStorage(THEME_DEFINITION_KEY, definition ? JSON.stringify(definition) : undefined); + writeThemeStorage(THEME_NAME_KEY, definition?.name); + writeThemeStorage(THEME_COLORS_KEY, legacyColors ? JSON.stringify(legacyColors) : undefined); + writeThemeStorage(THEME_SOURCE_KEY, definition ? 'legacy' : undefined); + }, + [writeThemeStorage], + ); - const setThemeName = useCallback((name?: string) => { - const currentDefinition = themeDefinitionRef.current; - const nextName = name?.trim() || (currentDefinition ? 'custom' : undefined); - setThemeNameState(nextName); - themeNameRef.current = nextName; - writeStorage(THEME_NAME_KEY, nextName); + const setThemeName = useCallback( + (name?: string) => { + const currentDefinition = themeDefinitionRef.current; + const nextName = name?.trim() || (currentDefinition ? 'custom' : undefined); + setThemeNameState(nextName); + themeNameRef.current = nextName; + writeThemeStorage(THEME_NAME_KEY, nextName); - if (!nextName || !currentDefinition) { - return; - } + if (!nextName || !currentDefinition) { + return; + } - const renamedDefinition = { ...currentDefinition, name: nextName }; - themeDefinitionRef.current = renamedDefinition; - setThemeDefinitionState(renamedDefinition); - writeStorage(THEME_DEFINITION_KEY, JSON.stringify(renamedDefinition)); - writeStorage(THEME_SOURCE_KEY, legacyThemeRGBRef.current ? 'legacy' : 'definition'); - }, []); + const renamedDefinition = { ...currentDefinition, name: nextName }; + themeDefinitionRef.current = renamedDefinition; + setThemeDefinitionState(renamedDefinition); + writeThemeStorage(THEME_DEFINITION_KEY, JSON.stringify(renamedDefinition)); + writeThemeStorage(THEME_SOURCE_KEY, legacyThemeRGBRef.current ? 'legacy' : 'definition'); + }, + [writeThemeStorage], + ); useEffect(() => { if (!synchronizedThemeProps.current) { @@ -540,9 +563,9 @@ export function ThemeProvider({ const resetTheme = useCallback(() => { setTheme('system'); setThemeDefinition(undefined); - writeStorage(THEME_COLORS_KEY); + writeThemeStorage(THEME_COLORS_KEY); restoreAppliedTheme(); - }, [restoreAppliedTheme, setTheme, setThemeDefinition]); + }, [restoreAppliedTheme, setTheme, setThemeDefinition, writeThemeStorage]); const themeRGB = legacyThemeRGB ?? themeDefinition?.modes.light?.colors; const value = useMemo( From 3df046f29afc1e082422cb4dd58a2d8bf645f6b6 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 24 Aug 2026 11:53:50 -0400 Subject: [PATCH 15/20] =?UTF-8?q?=F0=9F=8E=93=20ci:=20Graduated=20E2E=20Sp?= =?UTF-8?q?ec=20Skipping,=20Wired=20Dark=20Until=20Armed=20(#15172)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Select emits e2e_skip from the decision's e2e.graduated (skippable-tier specs whose clean-trial streaks meet the pre-registered bar, 2x where history- coupled, >=3 distinct days — computed server-side from the shadow's per-spec ledger). Dark by default: the output is empty unless repo var CODEGRAPH_E2E_SKIP=on, and even armed it accepts only a well-typed pool-path list from a non-fail-open decision (traversal segments rejected). Shard steps subtract the skips from a git-derived run list — unknown names match nothing, skip-everything falls back to full, and skipped specs still execute post-merge in every full-suite vote run. 15 verbatim guard tests, scripts extracted from this YAML and executed against fixtures: arm/disarm, fail-open, malformed/missing/non-array lists, traversal, out-of-pool paths, unknown names, all-skips fallback, and the 2-real-skips 59->57 arg case. The traversal case caught a real regex gap pre-commit. --- .github/workflows/playwright-mock.yml | 47 ++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/.github/workflows/playwright-mock.yml b/.github/workflows/playwright-mock.yml index 9e489d320a3..25b6f1c8f7f 100644 --- a/.github/workflows/playwright-mock.yml +++ b/.github/workflows/playwright-mock.yml @@ -49,6 +49,7 @@ jobs: decided: ${{ steps.sel.outputs.decided }} e2e_include: ${{ steps.sel.outputs.e2e_include }} mcp_run: ${{ steps.sel.outputs.mcp_run }} + e2e_skip: ${{ steps.sel.outputs.e2e_skip }} steps: - name: Select matrix lanes, fail open on any doubt id: sel @@ -61,6 +62,7 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} CHANGED: ${{ github.event.pull_request.changed_files }} + E2E_SKIP_ARMED: ${{ vars.CODEGRAPH_E2E_SKIP }} FULL_INCLUDE: '{"include":[{"name":"memory, shard 1/3","stream_store":"memory","redis_image":"","suite":"full","shard":"1/3","artifact":"memory-1-of-3"},{"name":"memory, shard 2/3","stream_store":"memory","redis_image":"","suite":"full","shard":"2/3","artifact":"memory-2-of-3"},{"name":"memory, shard 3/3","stream_store":"memory","redis_image":"","suite":"full","shard":"3/3","artifact":"memory-3-of-3"},{"name":"redis transport","stream_store":"redis","redis_image":"redis:7-alpine","suite":"transport","shard":"","artifact":"redis-transport"}]}' run: | set +e @@ -115,6 +117,25 @@ jobs: exit 0 fi echo "e2e_include=$INCLUDE" >> "$GITHUB_OUTPUT" + # Graduated per-spec skips are DARK until the operator arms repo variable + # CODEGRAPH_E2E_SKIP=on (the election switch — flipped only when the pre-registered + # resume condition holds). Even then, act only on a well-typed list from a non-fail-open + # decision: every entry must be a pool spec path, or nothing is skipped. The server + # already intersects with this PR's skippable tier and applies the streak bars + # (2x where history-coupled); see codegraph-poc service/graduate.ts. + SKIP="" + if [ "$E2E_SKIP_ARMED" = "on" ]; then + if echo "$RESP" | jq -e '(.e2e.fail_open != true) and (.e2e.graduated | type == "array" and all(.[]?; type == "string" and test("^e2e/specs/mock/[A-Za-z0-9._/-]+\\.spec\\.ts$") and (contains("..") | not)))' >/dev/null 2>&1; then + SKIP=$(echo "$RESP" | jq -r '[.e2e.graduated[] | sub("^e2e/"; "")] | join(" ")') + else + note "_graduated list absent or malformed; no specs skipped_" + fi + fi + echo "e2e_skip=$SKIP" >> "$GITHUB_OUTPUT" + if [ -n "$SKIP" ]; then + note "| graduated spec skips | $(echo "$SKIP" | wc -w | tr -d ' ') (armed) |" + echo "codegraph-e2e-graduated-skips: $SKIP" + fi echo "codegraph-select: redis_transport=$REDIS mcp_tool_list_changed=$MCP matrix_entries=$(echo "$INCLUDE" | jq '.include | length')" if [ "$MCP_SKIP" = 1 ]; then echo "mcp_run=false" >> "$GITHUB_OUTPUT" @@ -305,9 +326,33 @@ jobs: - name: Run full mock-LLM Tier-1 e2e if: matrix.suite == 'full' - run: npx playwright test --config=e2e/playwright.config.mock.ts --shard=${{ matrix.shard }} env: CI: 'true' + E2E_SKIP: ${{ needs.codegraph_select.outputs.e2e_skip }} + run: | + set +e + # Graduated-spec skipping (dark until repo var CODEGRAPH_E2E_SKIP=on upstream): subtract + # the earned skips from a run list derived from the tree itself, so an unknown or stale + # name in the skip list simply matches nothing. If subtraction would drop everything — + # or drops nothing — run the full shard exactly as before. Skipped specs still execute + # post-merge in every full-suite vote run, which is the net that catches a wrong skip. + RUN_ARGS="" + if [ -n "$E2E_SKIP" ]; then + KEEP=""; DROP=0 + for spec in $(git ls-files 'e2e/specs/mock/*.spec.ts' 'e2e/specs/mock/**/*.spec.ts' | sed 's|^e2e/||' | sort -u); do + case "$spec" in *" "*) KEEP="$KEEP $spec"; continue;; esac + case " $E2E_SKIP " in + *" $spec "*) DROP=$((DROP+1));; + *) KEEP="$KEEP $spec";; + esac + done + if [ "$DROP" -gt 0 ] && [ -n "$KEEP" ]; then + RUN_ARGS="$KEEP" + echo "codegraph-e2e-skip: dropped $DROP graduated specs from this shard's pool" + fi + fi + set -e + npx playwright test --config=e2e/playwright.config.mock.ts --shard=${{ matrix.shard }} $RUN_ARGS - name: Run Redis stream transport e2e if: matrix.suite == 'transport' From 649e68170eaeea4f0e26134c74b802a3e60290b0 Mon Sep 17 00:00:00 2001 From: Marco Beretta Date: Mon, 24 Aug 2026 19:00:38 +0200 Subject: [PATCH 16/20] =?UTF-8?q?=F0=9F=96=BC=EF=B8=8F=20refactor:=20Conso?= =?UTF-8?q?lidate=20Provider=20Icons=20Into=20a=20Single=20Registry=20(#15?= =?UTF-8?q?148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: make useIsActiveItem observer assertions deterministic The two attribute-flip tests mutated inside act() and then raced a 4 second waitFor against MutationObserver delivery, so they failed once the client workspace gained enough suites for a worker to stall past that budget. Wait on actual observer delivery instead. The hook registers its observer on mount, so it is ahead of the test's in delivery order and has already reacted by the time the promise resolves. The new helper filters on data-active-item because React writes data-active onto the same element when it re-renders, and an unfiltered observer would resolve on that write instead. This removes the last wall-clock dependence in the file, so the 20 second jest timeout is no longer needed. * feat: add canonical ProviderId vocabulary and resolver * feat: resolve custom endpoint provider identity at config load * feat: add provider icon registry data * feat: add ProviderIcon and ProviderAvatar components * feat: add provider icon resolution hook * refactor: migrate direct icon lookups to the provider registry * refactor: migrate composite endpoint icons to the provider registry * refactor: render message provider icons from the registry * refactor: remove the duplicated endpoint icon maps The model selector was the last consumer of the icons map, so it now resolves art through the provider registry like every other icon call site. That leaves getIconKey with no callers, and the five icon map types it depended on with no references, so all of them go too. * fix: address Codex review findings on provider icons Move brand tile colors onto theme tokens, accept relative image paths, pass endpoint config into message icon resolution, keep Cohere padding on landing only, render configured image URLs in provider-only consumers, preserve the Gemma label, and publish provider assets with the shared client package. * fix: address remaining Codex findings on provider icons Keep monochrome art white on branded avatar tiles, inline provider assets as module data URLs so ProviderIcon works outside the SPA, and recognize api.cohere.ai when resolving custom endpoint brands. * fix: address the latest Codex review notes Stop inlining provider logos into the shared bundle, keep agents and assistants marks on group icons, reject CSS appended to brand gradients, give brand tokens hex fallbacks for package consumers, and treat data image URLs as configured artwork. * fix: honor native provider and theme-controlled avatar contrast Use an explicit custom-endpoint provider when host branding misses, keep agents and assistants marks on model specs, and drive branded avatar foreground from a theme token instead of a raw white class. * fix: tighten brand validation and inherit SVG fill color Forward the computed color class into provider SVGs, accept only a single balanced gradient for brand backgrounds, keep provider foreground hex-only, recognize relative image fragments, and preserve percentage sizing in URLIcon fallbacks. * fix: keep EndpointIcon hook-free and accept protocol-relative icon URLs useMentions.ts invokes EndpointIcon({...}) as a plain function in seven places, inside useMemo mappings and a React Query select callback, so the useProviderIcon call added to it ran a hook outside a render and threw "Invalid hook call" as soon as the mention list was built. It now uses the hook-free resolveProviderIcon, and a spec pins the imperative-call contract those call sites depend on. isImageURL explicitly rejected protocol-relative URLs, so an endpoint or model group configured with //cdn.example.com/provider.png fell through to provider resolution and rendered the generic mark, where the removed UnknownIcon rendered any nonempty custom iconURL. A leading // followed by a host is now an image; a bare // or /// still is not. The ConvoIcon spec's two cohere conversations move to one shared fixture, since ProviderId.cohere is not an EModelEndpoint and a single-step assertion to TConversation failed the client type check. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MWvn6ezgLmN8D5GDmFVnwv * fix: annotate themeBrandTokens for isolatedDeclarations packages/client compiles with isolatedDeclarations, under which `as const satisfies` is not an explicit type annotation, so the emitted declaration could not be produced from the initializer alone. This never surfaced before because the "Type check @librechat/client" step only runs after "Type check @librechat/api", which was failing on dev's Agents SDK issue and skipping it. Annotated as readonly (keyof IThemeBrands)[] and frozen, matching themeColorTokens directly above it. Both consumers only call .includes() and .map(), so no literal tuple type is lost. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MWvn6ezgLmN8D5GDmFVnwv * fix: keep nested provider SVGs at their span's size ProviderIcon sizes component art with an outer span carrying an inline width/height, then rendered the SVG with cn('h-full w-full', classes). Because cn is twMerge, a caller's own sizing class won that merge, so the fraction applied twice: Landing passes size={41} with h-2/3 w-2/3, ConvoIcon scales to a 27px span, and the SVG then took two thirds of that again, ~18px where it used to be ~27px. Only component-backed providers regressed. The asset branch has no wrapping span, so its fraction still resolves against the 40px container. Reordering the merge makes the span's size authoritative while leaving every other caller class in place, including the [color:inherit] that branded avatars forward. The img branch keeps resolving against its parent, so its size is unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MWvn6ezgLmN8D5GDmFVnwv * fix: close the image-format and provider-host tables Two allowlists that the refactor narrowed, fixed as sets rather than one entry at a time. isImageURL's extension list had grown by patch four times, each round restoring one form the old renderer accepted. It now carries every format browsers actually render, so avif joins apng, bmp, cur, jfif and the jpeg spellings in a single pass. The host table had no Azure entry, so an OpenAI-compatible endpoint on team.openai.azure.com fell through to the generic mark; the custom schema cannot express provider: azure, so host was its only signal. Both supported Azure suffixes are added, and enumerating ProviderId against the table surfaced Google as the same gap, which is added too. Bedrock, mlx and ollama are the remainder and cannot be host-resolved: bedrock's hostname is region-scoped under a shared AWS suffix, and the other two are served from the operator's own machine. That is now recorded next to the table and pinned by a test, so a provider added later without a host fails rather than silently rendering the generic mark. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MWvn6ezgLmN8D5GDmFVnwv --------- Co-authored-by: Claude --- client/src/common/types.ts | 22 +-- .../Menus/Endpoints/components/GroupIcon.tsx | 45 ++--- .../Menus/Endpoints/components/SpecIcon.tsx | 38 ++-- .../components/__tests__/GroupIcon.test.tsx | 48 ++--- .../components/__tests__/SpecIcon.test.tsx | 42 +--- .../Chat/Menus/Presets/PresetItems.tsx | 26 +-- .../Presets/__tests__/PresetItems.spec.tsx | 36 +++- .../components/Chat/Messages/MessageIcon.tsx | 2 +- client/src/components/Endpoints/ConvoIcon.tsx | 148 ++++++++++---- .../src/components/Endpoints/ConvoIconURL.tsx | 24 +-- .../src/components/Endpoints/EndpointIcon.tsx | 44 +++-- .../Endpoints/EntityEndpointMark.tsx | 29 +++ .../Endpoints/MessageEndpointIcon.spec.tsx | 117 ++++++++++- .../Endpoints/MessageEndpointIcon.tsx | 186 +++++------------- .../src/components/Endpoints/MinimalIcon.tsx | 108 +++++----- .../Endpoints/ResolvedProviderIcon.tsx | 33 ++++ client/src/components/Endpoints/URLIcon.tsx | 14 +- .../Endpoints/__tests__/ConvoIcon.test.tsx | 111 +++++++++++ .../Endpoints/__tests__/EndpointIcon.test.tsx | 18 ++ .../Endpoints/__tests__/MinimalIcon.test.tsx | 79 ++++++++ .../ProviderKeys/ProviderKeyRow.tsx | 28 ++- .../__tests__/ProviderKeyRow.spec.tsx | 22 ++- .../SidePanel/Agents/AgentConfig.tsx | 38 ++-- client/src/hooks/Endpoint/Icons.tsx | 72 ------- client/src/hooks/Endpoint/UnknownIcon.tsx | 124 ------------ .../__tests__/useProviderIcon.spec.ts | 57 ++++++ client/src/hooks/Endpoint/index.ts | 1 + client/src/hooks/Endpoint/useEndpoints.ts | 51 +++-- client/src/hooks/Endpoint/useProviderIcon.ts | 62 ++++++ client/src/style.css | 14 ++ client/src/utils/__tests__/icons.test.ts | 24 ++- client/src/utils/endpoints.ts | 22 +-- client/src/utils/icons.ts | 14 +- packages/api/src/endpoints/custom/config.ts | 7 + .../src/endpoints/custom/providers.spec.ts | 111 +++++++++++ .../api/src/endpoints/custom/providers.ts | 82 ++++++++ .../client/src/icons/provider/Avatar.spec.tsx | 48 +++++ packages/client/src/icons/provider/Avatar.tsx | 57 ++++++ .../client/src/icons/provider/Icon.spec.tsx | 55 ++++++ packages/client/src/icons/provider/Icon.tsx | 53 +++++ .../src/icons/provider/assets/anyscale.png | Bin 0 -> 70768 bytes .../src/icons/provider/assets/apipie.png | Bin 0 -> 34503 bytes .../src/icons/provider/assets/cohere.png | Bin 0 -> 26469 bytes .../src/icons/provider/assets/deepseek.svg | 1 + .../src/icons/provider/assets/fireworks.png | Bin 0 -> 223682 bytes .../client/src/icons/provider/assets/groq.png | Bin 0 -> 23360 bytes .../src/icons/provider/assets/helicone.svg | 16 ++ .../src/icons/provider/assets/huggingface.svg | 8 + .../src/icons/provider/assets/mistral.png | Bin 0 -> 4799 bytes .../client/src/icons/provider/assets/mlx.png | Bin 0 -> 83421 bytes .../src/icons/provider/assets/ollama.png | Bin 0 -> 39717 bytes .../src/icons/provider/assets/openrouter.png | Bin 0 -> 15406 bytes .../src/icons/provider/assets/perplexity.png | Bin 0 -> 14309 bytes .../client/src/icons/provider/assets/qwen.svg | 1 + .../src/icons/provider/assets/shuttleai.png | Bin 0 -> 261079 bytes .../src/icons/provider/assets/together.png | Bin 0 -> 20985 bytes .../src/icons/provider/assets/unify.webp | Bin 0 -> 6928 bytes packages/client/src/icons/provider/index.ts | 6 + .../src/icons/provider/registry.spec.ts | 71 +++++++ .../client/src/icons/provider/registry.ts | 132 +++++++++++++ packages/client/src/index.ts | 3 + packages/client/src/svgs/AnthropicIcon.tsx | 2 +- packages/client/src/svgs/BedrockIcon.tsx | 2 +- packages/client/src/theme/index.ts | 2 + packages/client/src/theme/registry.spec.ts | 64 ++++++ packages/client/src/theme/registry.ts | 69 ++++++- packages/client/src/theme/types/index.ts | 12 ++ .../client/src/theme/utils/applyTheme.spec.ts | 16 ++ packages/client/src/theme/utils/applyTheme.ts | 14 +- packages/client/tsdown.config.mjs | 1 + .../data-provider/specs/providers.spec.ts | 80 ++++++++ packages/data-provider/src/index.ts | 2 + packages/data-provider/src/providers.ts | 98 +++++++++ packages/data-provider/src/types.ts | 3 + 74 files changed, 1990 insertions(+), 725 deletions(-) create mode 100644 client/src/components/Endpoints/EntityEndpointMark.tsx create mode 100644 client/src/components/Endpoints/ResolvedProviderIcon.tsx create mode 100644 client/src/components/Endpoints/__tests__/ConvoIcon.test.tsx create mode 100644 client/src/components/Endpoints/__tests__/MinimalIcon.test.tsx delete mode 100644 client/src/hooks/Endpoint/Icons.tsx delete mode 100644 client/src/hooks/Endpoint/UnknownIcon.tsx create mode 100644 client/src/hooks/Endpoint/__tests__/useProviderIcon.spec.ts create mode 100644 client/src/hooks/Endpoint/useProviderIcon.ts create mode 100644 packages/api/src/endpoints/custom/providers.spec.ts create mode 100644 packages/api/src/endpoints/custom/providers.ts create mode 100644 packages/client/src/icons/provider/Avatar.spec.tsx create mode 100644 packages/client/src/icons/provider/Avatar.tsx create mode 100644 packages/client/src/icons/provider/Icon.spec.tsx create mode 100644 packages/client/src/icons/provider/Icon.tsx create mode 100644 packages/client/src/icons/provider/assets/anyscale.png create mode 100644 packages/client/src/icons/provider/assets/apipie.png create mode 100644 packages/client/src/icons/provider/assets/cohere.png create mode 100644 packages/client/src/icons/provider/assets/deepseek.svg create mode 100644 packages/client/src/icons/provider/assets/fireworks.png create mode 100644 packages/client/src/icons/provider/assets/groq.png create mode 100644 packages/client/src/icons/provider/assets/helicone.svg create mode 100644 packages/client/src/icons/provider/assets/huggingface.svg create mode 100644 packages/client/src/icons/provider/assets/mistral.png create mode 100644 packages/client/src/icons/provider/assets/mlx.png create mode 100644 packages/client/src/icons/provider/assets/ollama.png create mode 100644 packages/client/src/icons/provider/assets/openrouter.png create mode 100644 packages/client/src/icons/provider/assets/perplexity.png create mode 100644 packages/client/src/icons/provider/assets/qwen.svg create mode 100644 packages/client/src/icons/provider/assets/shuttleai.png create mode 100644 packages/client/src/icons/provider/assets/together.png create mode 100644 packages/client/src/icons/provider/assets/unify.webp create mode 100644 packages/client/src/icons/provider/index.ts create mode 100644 packages/client/src/icons/provider/registry.spec.ts create mode 100644 packages/client/src/icons/provider/registry.ts create mode 100644 packages/data-provider/specs/providers.spec.ts create mode 100644 packages/data-provider/src/providers.ts diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 28d2872217c..768386c50be 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -103,27 +103,6 @@ export enum IconContext { message = 'message', } -export type IconMapProps = { - className?: string; - iconURL?: string; - context?: 'landing' | 'menu-item' | 'nav' | 'message'; - endpoint?: string | null; - endpointType?: string; - assistantName?: string; - agentName?: string; - avatar?: string; - size?: number; -}; - -export type IconComponent = React.ComponentType; -export type AgentIconComponent = React.ComponentType; -export type IconComponentTypes = IconComponent | AgentIconComponent; -export type IconsRecord = { - [key in t.EModelEndpoint | 'unknown' | string]: IconComponentTypes | null | undefined; -}; - -export type AgentIconMapProps = IconMapProps & { agentName?: string }; - export type NavLink = { title: TranslationKeys; label?: string; @@ -537,6 +516,7 @@ export type IconProps = Pick & iconClassName?: string; endpoint?: t.EModelEndpoint | string | null; endpointType?: t.EModelEndpoint | null; + endpointsConfig?: t.TEndpointsConfig | null; assistantName?: string; agentName?: string; error?: boolean; diff --git a/client/src/components/Chat/Menus/Endpoints/components/GroupIcon.tsx b/client/src/components/Chat/Menus/Endpoints/components/GroupIcon.tsx index 67cc3d6bf20..41d393c3870 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/GroupIcon.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/GroupIcon.tsx @@ -1,37 +1,36 @@ import React, { memo, useState } from 'react'; import { AlertCircle } from 'lucide-react'; -import type { IconMapProps } from '~/common'; -import { getKnownEndpointAsset, hasKnownEndpointIcon } from '~/hooks/Endpoint/UnknownIcon'; -import { icons } from '~/hooks/Endpoint/Icons'; +import { ProviderIcon } from '@librechat/client'; +import { resolveProviderId } from 'librechat-data-provider'; +import { EntityEndpointMark, isEntityEndpoint } from '~/components/Endpoints/EntityEndpointMark'; +import { isImageURL } from '~/utils/icons'; interface GroupIconProps { iconURL: string; groupName: string; } -type IconType = (props: IconMapProps) => React.JSX.Element; - const GroupIcon: React.FC = ({ iconURL, groupName }) => { const [imageError, setImageError] = useState(false); + const provider = resolveProviderId(iconURL); const handleImageError = () => { setImageError(true); }; - // Check if the iconURL is a built-in icon key - if (iconURL in icons) { - const Icon: IconType = (icons[iconURL] ?? icons.unknown) as IconType; - return ; + if (isEntityEndpoint(iconURL)) { + return ( +
+ +
+ ); } - if (imageError) { - const DefaultIcon: IconType = icons.unknown as IconType; + if (provider || !isImageURL(iconURL) || imageError) { return (
-
- -
- {imageError && iconURL && ( + + {imageError && (
= ({ iconURL, groupName }) => { ); } - const resolvedIconURL = getKnownEndpointAsset(iconURL); - - if (!resolvedIconURL && hasKnownEndpointIcon(iconURL)) { - const Icon: IconType = icons.unknown as IconType; - return ( - - ); - } - return (
{groupName} React.JSX.Element; - const SpecIcon: React.FC = ({ currentSpec, endpointsConfig, agentAvatarURL }) => { const iconURL = getModelSpecIconURL(currentSpec, agentAvatarURL); const endpoint = currentSpec.preset?.endpoint; - const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL'); - const iconKey = getIconKey({ endpoint, endpointsConfig, endpointIconURL }); - const shouldRenderURLIcon = isImageURL(iconURL); - let Icon: IconType; + const { provider, imageURL } = useProviderIcon({ endpoint, endpointsConfig, iconURL }); + const { provider: fallbackProvider } = useProviderIcon({ endpoint, endpointsConfig }); - if (!shouldRenderURLIcon) { - Icon = (icons[iconURL] ?? icons[iconKey] ?? icons.unknown) as IconType; - } else { + if (imageURL) { return ( ); } + if (isEntityEndpoint(iconURL || endpoint)) { + return ; + } + return ( - ); }; diff --git a/client/src/components/Chat/Menus/Endpoints/components/__tests__/GroupIcon.test.tsx b/client/src/components/Chat/Menus/Endpoints/components/__tests__/GroupIcon.test.tsx index ee8ba0f5f06..83f3b8163b2 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/__tests__/GroupIcon.test.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/__tests__/GroupIcon.test.tsx @@ -1,56 +1,42 @@ import { render, screen } from '@testing-library/react'; +import { EModelEndpoint } from 'librechat-data-provider'; import GroupIcon from '../GroupIcon'; -jest.mock('~/hooks/Endpoint/Icons', () => { - const React = jest.requireActual('react'); - const createIcon = - (iconKey: string) => - ({ className, endpoint }: { className?: string; endpoint?: string | null }) => - React.createElement('span', { - className, - 'data-testid': 'endpoint-icon', - 'data-icon-key': iconKey, - 'data-endpoint': endpoint ?? '', - }); - - return { - icons: { - openAI: createIcon('openAI'), - unknown: createIcon('unknown'), - }, - }; -}); - describe('GroupIcon', () => { it('renders built-in endpoint icon keys', () => { render(); - expect(screen.getByTestId('endpoint-icon')).toHaveAttribute('data-icon-key', 'openAI'); + expect(screen.getByRole('img', { name: 'OpenAI' })).toBeInTheDocument(); + }); + + it('keeps the agents mark for an agents group icon', () => { + const { container } = render( + , + ); + + expect(screen.queryByRole('img', { name: 'Custom' })).not.toBeInTheDocument(); + expect(container.querySelector('svg')).toBeInTheDocument(); + expect(screen.getByTitle('My Agents')).toBeInTheDocument(); }); it('resolves known endpoint asset aliases case-insensitively', () => { render(); - expect(screen.getByRole('img', { name: 'OpenRouter' })).toHaveAttribute( - 'src', - 'assets/openrouter.png', - ); + const src = screen.getByRole('img', { name: 'OpenRouter' }).getAttribute('src'); + expect(src).toBeTruthy(); + expect(src).not.toBe(''); }); it('resolves known endpoint asset aliases to shipped file paths', () => { render(); - expect(screen.getByRole('img', { name: 'Helicone' })).toHaveAttribute( - 'src', - 'assets/helicone.svg', - ); + expect(screen.getByRole('img', { name: 'Helicone' })).toHaveAttribute('alt', 'Helicone'); }); it('renders known endpoint aliases backed by components', () => { render(); - expect(screen.getByTestId('endpoint-icon')).toHaveAttribute('data-icon-key', 'unknown'); - expect(screen.getByTestId('endpoint-icon')).toHaveAttribute('data-endpoint', 'Moonshot'); + expect(screen.getByRole('img', { name: 'Moonshot' })).toBeInTheDocument(); }); it('renders configured image URLs directly', () => { diff --git a/client/src/components/Chat/Menus/Endpoints/components/__tests__/SpecIcon.test.tsx b/client/src/components/Chat/Menus/Endpoints/components/__tests__/SpecIcon.test.tsx index 45b0f3f001e..0de9af574dd 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/__tests__/SpecIcon.test.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/__tests__/SpecIcon.test.tsx @@ -1,37 +1,16 @@ import { render, screen } from '@testing-library/react'; -import { EModelEndpoint } from 'librechat-data-provider'; +import { EModelEndpoint, ProviderId } from 'librechat-data-provider'; import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider'; import SpecIcon from '../SpecIcon'; -jest.mock('~/hooks/Endpoint/Icons', () => { - const React = jest.requireActual('react'); - const createIcon = - (iconKey: string) => - ({ endpoint, iconURL }: { endpoint?: string | null; iconURL?: string }) => - React.createElement('span', { - 'data-testid': 'endpoint-icon', - 'data-icon-key': iconKey, - 'data-endpoint': endpoint ?? '', - 'data-icon-url': iconURL ?? '', - }); - - return { - icons: { - google: createIcon('google'), - openAI: createIcon('openAI'), - unknown: createIcon('unknown'), - }, - }; -}); - jest.mock('~/components/Endpoints/URLIcon', () => { const React = jest.requireActual('react'); return { - URLIcon: ({ iconURL, endpoint }: { iconURL: string; endpoint?: string }) => + URLIcon: ({ iconURL, provider }: { iconURL: string; provider?: string | null }) => React.createElement('span', { 'data-testid': 'url-icon', 'data-icon-url': iconURL, - 'data-endpoint': endpoint ?? '', + 'data-provider': provider ?? '', }), }; }); @@ -48,11 +27,7 @@ describe('SpecIcon', () => { render(); - expect(screen.getByTestId('endpoint-icon')).toHaveAttribute( - 'data-icon-key', - EModelEndpoint.google, - ); - expect(screen.getByTestId('endpoint-icon')).toHaveAttribute('data-endpoint', ''); + expect(screen.getByRole('img', { name: 'Google' })).toBeInTheDocument(); }); it('renders same-origin absolute spec icon URLs as images', () => { @@ -71,13 +46,10 @@ describe('SpecIcon', () => { 'data-icon-url', '/assets/clickhouse-logo.svg', ); - expect(screen.getByTestId('url-icon')).toHaveAttribute( - 'data-endpoint', - EModelEndpoint.anthropic, - ); + expect(screen.getByTestId('url-icon')).toHaveAttribute('data-provider', ProviderId.anthropic); }); - it('falls back to the unknown icon when runtime spec data has no icon or preset', () => { + it('falls back to the generic icon when runtime spec data has no icon or preset', () => { const currentSpec = { name: 'gemini-test', label: 'Gemini Test', @@ -85,7 +57,7 @@ describe('SpecIcon', () => { render(); - expect(screen.getByTestId('endpoint-icon')).toHaveAttribute('data-icon-key', 'unknown'); + expect(screen.getByRole('img', { name: 'Custom' })).toBeInTheDocument(); }); it("renders the agent's avatar when the spec defines no icon of its own", () => { diff --git a/client/src/components/Chat/Menus/Presets/PresetItems.tsx b/client/src/components/Chat/Menus/Presets/PresetItems.tsx index b7f426e1a0e..31362c9d911 100644 --- a/client/src/components/Chat/Menus/Presets/PresetItems.tsx +++ b/client/src/components/Chat/Menus/Presets/PresetItems.tsx @@ -3,7 +3,6 @@ import { useRecoilValue } from 'recoil'; import * as Ariakit from '@ariakit/react'; import { Close } from '@radix-ui/react-popover'; import { Flipper, Flipped } from 'react-flip-toolkit'; -import { getEndpointField } from 'librechat-data-provider'; import { BookCopy, FileUp, FileX2, Ellipsis } from 'lucide-react'; import { Button, @@ -25,9 +24,10 @@ import { import type { MenuItemProps } from '@librechat/client'; import type { TPreset } from 'librechat-data-provider'; import type { ChangeEvent, FC } from 'react'; +import { ResolvedProviderIcon } from '~/components/Endpoints/ResolvedProviderIcon'; +import { resolveProviderIcon } from '~/hooks/Endpoint'; import { useGetEndpointsQuery } from '~/data-provider'; -import { getPresetTitle, getIconKey } from '~/utils'; -import { icons } from '~/hooks/Endpoint/Icons'; +import { getPresetTitle } from '~/utils'; import { MenuSeparator } from '../UI'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; @@ -226,8 +226,10 @@ const PresetItems: FC<{ return null; } - const iconKey = getIconKey({ endpoint: preset.endpoint, endpointsConfig }); - const Icon = icons[iconKey]; + const { provider, imageURL } = resolveProviderIcon({ + endpoint: preset.endpoint, + endpointsConfig, + }); const presetTitle = getPresetTitle(preset); return ( @@ -243,14 +245,12 @@ const PresetItems: FC<{ aria-label={presetTitle} data-testid={`preset-item-${presetId}`} > - {Icon != null && ( - - )} + {presetTitle}
diff --git a/client/src/components/Chat/Menus/Presets/__tests__/PresetItems.spec.tsx b/client/src/components/Chat/Menus/Presets/__tests__/PresetItems.spec.tsx index 4f08c342c5e..0d548d2c5a6 100644 --- a/client/src/components/Chat/Menus/Presets/__tests__/PresetItems.spec.tsx +++ b/client/src/components/Chat/Menus/Presets/__tests__/PresetItems.spec.tsx @@ -11,11 +11,15 @@ jest.mock('~/hooks', () => ({ })); jest.mock('~/data-provider', () => ({ - useGetEndpointsQuery: () => ({ data: {} }), -})); - -jest.mock('~/hooks/Endpoint/Icons', () => ({ - icons: {}, + useGetEndpointsQuery: () => ({ + data: { + Branded: { + type: 'custom', + iconURL: 'https://cdn.example.com/x.png', + order: 0, + }, + }, + }), })); const preset = { @@ -116,3 +120,25 @@ describe('PresetItems clear-all dialog', () => { }); }); }); + +describe('PresetItems icons', () => { + it('renders a configured endpoint image instead of the generic mark', () => { + render( + + + + + , + ); + + expect(screen.getByRole('img')).toHaveAttribute('src', 'https://cdn.example.com/x.png'); + }); +}); diff --git a/client/src/components/Chat/Messages/MessageIcon.tsx b/client/src/components/Chat/Messages/MessageIcon.tsx index 7eabab3ebb0..a5a395473f6 100644 --- a/client/src/components/Chat/Messages/MessageIcon.tsx +++ b/client/src/components/Chat/Messages/MessageIcon.tsx @@ -73,7 +73,6 @@ const MessageIcon = memo(({ iconData, assistant, agent }: MessageIconProps) => { context="message" assistantAvatar={assistantAvatar} agentAvatar={agentAvatar} - endpointIconURL={endpointIconURL} assistantName={assistantName} agentName={agentName} /> @@ -85,6 +84,7 @@ const MessageIcon = memo(({ iconData, assistant, agent }: MessageIconProps) => { isCreatedByUser={iconData?.isCreatedByUser ?? false} endpoint={endpoint} iconURL={avatarURL || endpointIconURL} + endpointsConfig={endpointsConfig} model={iconData?.model} assistantName={assistantName} agentName={agentName} diff --git a/client/src/components/Endpoints/ConvoIcon.tsx b/client/src/components/Endpoints/ConvoIcon.tsx index 859f3b957b9..2a4596ba3bc 100644 --- a/client/src/components/Endpoints/ConvoIcon.tsx +++ b/client/src/components/Endpoints/ConvoIcon.tsx @@ -1,11 +1,71 @@ import React, { useMemo } from 'react'; -import { getEndpointField } from 'librechat-data-provider'; +import { Feather } from 'lucide-react'; +import { ProviderId } from 'librechat-data-provider'; +import { Sparkles, AssistantIcon, ProviderIcon } from '@librechat/client'; import type * as t from 'librechat-data-provider'; -import { getIconKey, getEntity, getIconEndpoint } from '~/utils'; import ConvoIconURL from '~/components/Endpoints/ConvoIconURL'; -import { icons } from '~/hooks/Endpoint/Icons'; +import { cn, getEntity, getIconEndpoint } from '~/utils'; +import { useProviderIcon } from '~/hooks/Endpoint'; import { isImageURL } from '~/utils/icons'; +/** Callers frame the mark at two thirds of the round container around it. */ +const artScale = 2 / 3; + +const entityAvatarClassName = + 'bg-token-surface-secondary h-full w-full rounded-full object-cover dark:bg-surface-tertiary'; + +function AgentAvatar({ + avatar, + agentName, + className, + size, +}: { + avatar: string; + agentName: string; + className: string; + size?: number; +}) { + if (agentName && avatar) { + return ( + {agentName} + ); + } + + return ; +} + +function AssistantAvatar({ + avatar, + assistantName, + className, + context, + size, +}: { + avatar: string; + assistantName: string; + className: string; + context?: 'message' | 'nav' | 'landing' | 'menu-item'; + size?: number; +}) { + if (assistantName && avatar) { + return ( + {assistantName} + ); + } + + if (assistantName) { + return ; + } + + return ; +} + export default function ConvoIcon({ conversation, endpointsConfig, @@ -29,7 +89,7 @@ export default function ConvoIcon({ let endpoint = conversation?.endpoint; endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint }); - const { entity, isAgent } = useMemo( + const { entity, isAgent, isAssistant } = useMemo( () => getEntity({ endpoint, @@ -46,39 +106,55 @@ export default function ConvoIcon({ ? (entity as t.Agent | undefined)?.avatar?.filepath : ((entity as t.Assistant | undefined)?.metadata?.avatar as string); - const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL'); - const iconKey = getIconKey({ endpoint, endpointsConfig, endpointIconURL }); - const Icon = icons[iconKey] ?? null; - - return ( - <> - {isImageURL(iconURL) ? ( - + ); + } + + const renderArt = () => { + if (isAgent) { + return ( + + ); + } + + if (isAssistant) { + return ( + - ) : ( -
- {endpoint && Icon != null && ( - - )} -
- )} - - ); + ); + } + + if (imageURL != null) { + return {`${endpoint}; + } + + return ( + + ); + }; + + return
{endpoint !== '' && renderArt()}
; } diff --git a/client/src/components/Endpoints/ConvoIconURL.tsx b/client/src/components/Endpoints/ConvoIconURL.tsx index d4bdf534473..3e2f797b150 100644 --- a/client/src/components/Endpoints/ConvoIconURL.tsx +++ b/client/src/components/Endpoints/ConvoIconURL.tsx @@ -1,12 +1,13 @@ import { memo, useMemo } from 'react'; +import { ProviderIcon } from '@librechat/client'; +import type { ProviderId } from 'librechat-data-provider'; import { URLIcon } from '~/components/Endpoints/URLIcon'; -import { icons } from '~/hooks/Endpoint/Icons'; import { isImageURL } from '~/utils/icons'; interface ConvoIconURLProps { iconURL?: string; modelLabel?: string | null; - endpointIconURL?: string; + provider?: ProviderId | null; assistantName?: string; agentName?: string; context?: 'landing' | 'menu-item' | 'nav' | 'message'; @@ -32,14 +33,9 @@ const styleImageMap = { const ConvoIconURL: React.FC = ({ iconURL = '', modelLabel = '', - endpointIconURL, - assistantAvatar, - assistantName, - agentAvatar, - agentName, + provider, context, }) => { - const Icon = useMemo(() => icons[iconURL] ?? icons.unknown, [iconURL]); const isURL = useMemo(() => isImageURL(iconURL), [iconURL]); if (isURL) { return ( @@ -55,17 +51,7 @@ const ConvoIconURL: React.FC = ({ return (
- {Icon && ( - - )} +
); }; diff --git a/client/src/components/Endpoints/EndpointIcon.tsx b/client/src/components/Endpoints/EndpointIcon.tsx index c2add9ed7a4..753e72de6a1 100644 --- a/client/src/components/Endpoints/EndpointIcon.tsx +++ b/client/src/components/Endpoints/EndpointIcon.tsx @@ -1,4 +1,9 @@ -import { getEndpointField, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider'; +import { + getEndpointField, + isAssistantsEndpoint, + isAgentsEndpoint, + ProviderId, +} from 'librechat-data-provider'; import type { TPreset, TConversation, @@ -6,9 +11,10 @@ import type { TAssistantsMap, TEndpointsConfig, } from 'librechat-data-provider'; +import { getAgentAvatarUrl, getIconEndpoint, cn } from '~/utils'; import ConvoIconURL from '~/components/Endpoints/ConvoIconURL'; import MinimalIcon from '~/components/Endpoints/MinimalIcon'; -import { getAgentAvatarUrl, getIconEndpoint } from '~/utils'; +import { resolveProviderIcon } from '~/hooks/Endpoint'; import { isImageURL } from '~/utils/icons'; const emptyEndpointsConfig = {} as TEndpointsConfig; @@ -36,8 +42,8 @@ export default function EndpointIcon({ let endpoint = originalEndpoint; endpoint = getIconEndpoint({ endpointsConfig, iconURL: convoIconURL, endpoint }); - const endpointType = getEndpointField(endpointsConfig, endpoint, 'type'); const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL'); + const { provider } = resolveProviderIcon({ endpoint, endpointsConfig }); const agent = isAgentsEndpoint(endpoint) ? agentsMap?.[conversation?.agent_id ?? ''] : null; const assistant = isAssistantsEndpoint(endpoint) @@ -59,28 +65,28 @@ export default function EndpointIcon({ ); - } else { - return ( - - ); } + + return ( + + ); } diff --git a/client/src/components/Endpoints/EntityEndpointMark.tsx b/client/src/components/Endpoints/EntityEndpointMark.tsx new file mode 100644 index 00000000000..eae9d9d6744 --- /dev/null +++ b/client/src/components/Endpoints/EntityEndpointMark.tsx @@ -0,0 +1,29 @@ +import { Feather } from 'lucide-react'; +import { Sparkles } from '@librechat/client'; +import { EModelEndpoint } from 'librechat-data-provider'; + +export function isEntityEndpoint(endpoint?: string | null): boolean { + return ( + endpoint === EModelEndpoint.agents || + endpoint === EModelEndpoint.assistants || + endpoint === EModelEndpoint.azureAssistants + ); +} + +export function EntityEndpointMark({ + endpoint, + className = 'icon-md shrink-0', +}: { + endpoint?: string | null; + className?: string; +}) { + if (endpoint === EModelEndpoint.agents) { + return