mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
fix: sync subagent and answer handoffs
This commit is contained in:
parent
73b5162f39
commit
589862fac5
4 changed files with 109 additions and 12 deletions
|
|
@ -82,7 +82,10 @@ export default function SubagentCall({
|
|||
isSubmitting,
|
||||
typeof args === 'string' ? args : JSON.stringify(args ?? null),
|
||||
output ?? '',
|
||||
persistedContent?.length ?? -1,
|
||||
/** Approval interrupts replace nested parts without changing the array
|
||||
* length. Fingerprint the content so the detached panel receives those
|
||||
* same-length state transitions. */
|
||||
JSON.stringify(persistedContent ?? null),
|
||||
/** Fingerprint attachments by content, not just count — deferred
|
||||
* previews resolve in place (same array length, new filepath), and the
|
||||
* detached panel reads attachments only from this registry entry, so a
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot, useRecoilCallback, useRecoilValue } from 'recoil';
|
||||
import { render, screen, act, fireEvent, waitFor, within } from '@testing-library/react';
|
||||
import type { SubagentUpdateEvent } from 'librechat-data-provider';
|
||||
import type { SubagentUpdateEvent, TMessageContentParts } from 'librechat-data-provider';
|
||||
import type {
|
||||
SubagentContentPart,
|
||||
SubagentTickerState,
|
||||
|
|
@ -121,6 +121,7 @@ function PanelProbe() {
|
|||
<>
|
||||
<div data-testid="current-run-id">{runId ?? ''}</div>
|
||||
<div data-testid="registered-run-ids">{Object.keys(runs ?? {}).join(',')}</div>
|
||||
<div data-testid="registered-runs">{JSON.stringify(runs ?? {})}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -132,6 +133,7 @@ function renderWithState(args: {
|
|||
output?: string | null;
|
||||
progress?: SubagentProgress | null;
|
||||
initializeStreaming?: boolean;
|
||||
persistedContent?: TMessageContentParts[];
|
||||
}) {
|
||||
const setter = { current: null as null | ((next: SubagentProgress | null) => void) };
|
||||
const SeedHelper = () => {
|
||||
|
|
@ -144,7 +146,7 @@ function renderWithState(args: {
|
|||
);
|
||||
return null;
|
||||
};
|
||||
const rendered = render(
|
||||
const renderTree = (persistedContent = args.persistedContent) => (
|
||||
<RecoilRoot
|
||||
initializeState={({ set }) => {
|
||||
if (args.initializeStreaming) {
|
||||
|
|
@ -160,16 +162,21 @@ function renderWithState(args: {
|
|||
isSubmitting={args.isSubmitting ?? false}
|
||||
output={args.output}
|
||||
args={{ subagent_type: 'self', description: 'compute' }}
|
||||
persistedContent={persistedContent}
|
||||
/>
|
||||
</RecoilRoot>,
|
||||
</RecoilRoot>
|
||||
);
|
||||
const rendered = render(renderTree());
|
||||
const setProgress = (next: SubagentProgress | null) => {
|
||||
act(() => {
|
||||
setter.current?.(next);
|
||||
});
|
||||
};
|
||||
setProgress(args.progress ?? null);
|
||||
return { ...rendered, setProgress };
|
||||
const rerenderPersistedContent = (persistedContent: TMessageContentParts[]) => {
|
||||
rendered.rerender(renderTree(persistedContent));
|
||||
};
|
||||
return { ...rendered, setProgress, rerenderPersistedContent };
|
||||
}
|
||||
|
||||
describe('SubagentCall — status resolution', () => {
|
||||
|
|
@ -398,6 +405,42 @@ describe('SubagentCall — panel open contract', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('updates the registry when persisted content changes without changing length', async () => {
|
||||
const initialContent = [
|
||||
{
|
||||
type: 'tool_call',
|
||||
tool_call: { id: 'nested-approval', name: 'approval_probe', progress: 0.5 },
|
||||
},
|
||||
] as unknown as TMessageContentParts[];
|
||||
const contentWithApproval = [
|
||||
{
|
||||
type: 'tool_call',
|
||||
tool_call: {
|
||||
id: 'nested-approval',
|
||||
name: 'approval_probe',
|
||||
progress: 0.5,
|
||||
approval: { state: 'pending' },
|
||||
},
|
||||
},
|
||||
] as unknown as TMessageContentParts[];
|
||||
const { rerenderPersistedContent } = renderWithState({
|
||||
toolCallId: 'call_content_replacement',
|
||||
initialProgress: 0.5,
|
||||
isSubmitting: true,
|
||||
persistedContent: initialContent,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('registered-runs')).not.toHaveTextContent('"state":"pending"');
|
||||
});
|
||||
|
||||
rerenderPersistedContent(contentWithApproval);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('registered-runs')).toHaveTextContent('"state":"pending"');
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the shared panel (sets currentSubagentRunId) on click — no dialog', () => {
|
||||
renderWithState({
|
||||
toolCallId: 'call_open',
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
const mockSubmitAskAnswer = jest.fn();
|
||||
const mockResetComposer = jest.fn();
|
||||
const mockGetComposerText = jest.fn(() => 'answer from A');
|
||||
const mockSetComposerText = jest.fn();
|
||||
const mockSetCollapsedIds = jest.fn();
|
||||
const mockSetSelected = jest.fn();
|
||||
const mockSetChecked = jest.fn();
|
||||
const mockSetAnswerDraft = jest.fn();
|
||||
const mockSetDraft = jest.fn();
|
||||
let mockSaveDrafts = false;
|
||||
let mockCollapsedIds: string[] = [];
|
||||
let mockAnswerDraft = { actionId: null as string | null, text: '' };
|
||||
|
||||
jest.mock('~/data-provider', () => ({ useGetMessagesByConvoId: jest.fn() }));
|
||||
jest.mock('~/components/Chat/Messages/Content/ApprovalContext', () => ({
|
||||
|
|
@ -15,15 +20,20 @@ jest.mock('~/Providers', () => ({
|
|||
useOptionalChatFormContext: () => ({
|
||||
reset: mockResetComposer,
|
||||
getValues: mockGetComposerText,
|
||||
setValue: mockSetComposerText,
|
||||
}),
|
||||
}));
|
||||
jest.mock('~/utils', () => ({
|
||||
getAskAnswerDraftId: (id: string) => `draft-${id}`,
|
||||
morphTransition: (update: () => void) => update(),
|
||||
setDraft: (...args: unknown[]) => mockSetDraft(...args),
|
||||
}));
|
||||
jest.mock('recoil', () => ({
|
||||
atom: (cfg: unknown) => cfg,
|
||||
useRecoilState: (state: { key?: string }) => {
|
||||
if (state.key === 'askAnswerModeCollapsedActions') {
|
||||
return [mockCollapsedIds, mockSetCollapsedIds];
|
||||
}
|
||||
if (state.key === 'askAnswerModeSelection') {
|
||||
return [null, mockSetSelected];
|
||||
}
|
||||
|
|
@ -31,7 +41,7 @@ jest.mock('recoil', () => ({
|
|||
return [[], mockSetChecked];
|
||||
}
|
||||
if (state.key === 'askAnswerModeText') {
|
||||
return [{ actionId: null, text: '' }, mockSetAnswerDraft];
|
||||
return [mockAnswerDraft, mockSetAnswerDraft];
|
||||
}
|
||||
return [[], jest.fn()];
|
||||
},
|
||||
|
|
@ -55,6 +65,8 @@ describe('useAskAnswerMode', () => {
|
|||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSaveDrafts = false;
|
||||
mockCollapsedIds = [];
|
||||
mockAnswerDraft = { actionId: null, text: '' };
|
||||
mockGetComposerText.mockReturnValue('answer from A');
|
||||
});
|
||||
|
||||
|
|
@ -115,6 +127,34 @@ describe('useAskAnswerMode', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('restores the card answer into the composer when drafts are disabled', () => {
|
||||
mockCollapsedIds = ['a1'];
|
||||
mockAnswerDraft = { actionId: 'a1', text: 'answer edited in the card' };
|
||||
mockUseGetMessages.mockReturnValue({ data: liveAsk });
|
||||
const { result } = renderHook(() => useAskAnswerMode('conversation-1'));
|
||||
|
||||
act(() => result.current.expand());
|
||||
|
||||
expect(mockSetComposerText).toHaveBeenCalledWith('text', 'answer edited in the card');
|
||||
});
|
||||
|
||||
it('keeps the ask-specific draft current while editing the card', () => {
|
||||
mockSaveDrafts = true;
|
||||
mockUseGetMessages.mockReturnValue({ data: liveAsk });
|
||||
const { result } = renderHook(() => useAskAnswerMode('conversation-1'));
|
||||
|
||||
act(() => result.current.setAnswerText('answer edited in the card'));
|
||||
|
||||
expect(mockSetAnswerDraft).toHaveBeenCalledWith({
|
||||
actionId: 'a1',
|
||||
text: 'answer edited in the card',
|
||||
});
|
||||
expect(mockSetDraft).toHaveBeenCalledWith({
|
||||
id: 'draft-a1',
|
||||
value: 'answer edited in the card',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let a delayed answer success clear the composer or selection after navigation', () => {
|
||||
let finishAnswer: (() => void) | undefined;
|
||||
mockUseGetMessages.mockReturnValue({ data: liveAsk });
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
findLiveAskUserQuestion,
|
||||
splitOtherOption,
|
||||
} from '~/utils/approval';
|
||||
import { getAskAnswerDraftId, morphTransition } from '~/utils';
|
||||
import { getAskAnswerDraftId, morphTransition, setDraft } from '~/utils';
|
||||
import { useGetMessagesByConvoId } from '~/data-provider';
|
||||
import { useOptionalChatFormContext } from '~/Providers';
|
||||
import store from '~/store';
|
||||
|
|
@ -155,9 +155,15 @@ export default function useAskAnswerMode(conversationId?: string | null) {
|
|||
(text: string) => {
|
||||
if (liveAsk) {
|
||||
setAnswerDraft({ actionId: liveAsk.actionId, text });
|
||||
/** While the card owns the answer, `useAutoSave` is tracking the
|
||||
* conversation draft instead. Keep the dormant ask draft current so
|
||||
* expanding can restore this edit without clobbering that message. */
|
||||
if (saveDrafts) {
|
||||
setDraft({ id: getAskAnswerDraftId(liveAsk.actionId), value: text });
|
||||
}
|
||||
}
|
||||
},
|
||||
[liveAsk, setAnswerDraft],
|
||||
[liveAsk, saveDrafts, setAnswerDraft],
|
||||
);
|
||||
|
||||
/** Selection state is per-question: a new pause must never inherit a stale
|
||||
|
|
@ -186,11 +192,16 @@ export default function useAskAnswerMode(conversationId?: string | null) {
|
|||
|
||||
const expand = useCallback(() => {
|
||||
if (liveAsk) {
|
||||
morphTransition(() =>
|
||||
setCollapsedIds((prev) => prev.filter((id) => id !== liveAsk.actionId)),
|
||||
);
|
||||
morphTransition(() => {
|
||||
/** Autosave restores the ask-specific draft after the key switch. If
|
||||
* drafts are disabled, perform that handoff directly. */
|
||||
if (!saveDrafts) {
|
||||
formContext?.setValue('text', answerText);
|
||||
}
|
||||
setCollapsedIds((prev) => prev.filter((id) => id !== liveAsk.actionId));
|
||||
});
|
||||
}
|
||||
}, [liveAsk, setCollapsedIds]);
|
||||
}, [liveAsk, saveDrafts, formContext, answerText, setCollapsedIds]);
|
||||
|
||||
/** Pure check toggle: the keyboard highlight is steered only by the
|
||||
* composer's digit/arrow shortcuts, so a mouse toggle never leaves a
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue