fix: sync subagent and answer handoffs

This commit is contained in:
Marco Beretta 2026-08-03 17:08:14 +02:00
parent 73b5162f39
commit 589862fac5
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
4 changed files with 109 additions and 12 deletions

View file

@ -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

View file

@ -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',

View file

@ -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 });

View file

@ -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