Fix terminal answer handoff and encoded edit previews

This commit is contained in:
Marco Beretta 2026-09-06 04:37:23 +02:00
parent 318da18d28
commit a23e8828ae
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
13 changed files with 235 additions and 34 deletions

View file

@ -206,6 +206,7 @@ type ContentPartsProps = {
attachments?: TAttachment[];
searchResults?: { [key: string]: SearchResultData };
isCreatedByUser: boolean;
showThinking: boolean;
isLast: boolean;
isSubmitting: boolean;
isLatestMessage?: boolean;
@ -269,6 +270,7 @@ const ContentPartsBody = memo(function ContentPartsBody({
authorHeader,
conversationId,
isCreatedByUser,
showThinking,
isLatestMessage,
createdAt,
nestedActivityPhase = false,
@ -811,6 +813,7 @@ const ContentPartsBody = memo(function ContentPartsBody({
isSubmitting={isSubmitting}
isLatestMessage={isLatestMessage}
nestedActivityPhase
showThinking={showThinking}
withinActivityPhase={withinPhase}
cursorOwnedElsewhere={cursorOwnedByCard}
hideAttachments={hoisted}
@ -1057,6 +1060,7 @@ const ContentPartsBody = memo(function ContentPartsBody({
lastContentIdx={lastContentIdx}
groupAttachments={group.groupAttachments}
initialExpansionState={expansionState.get(groupId)}
showThinking={showThinking}
onExpansionChange={(state) => handleGroupExpansionChange(groupId, state)}
labelPart={group.labelPart}
withinActivityPhase={withinActivityPhase}

View file

@ -51,18 +51,31 @@ function textValue(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function coerceJsonValue(value: unknown): unknown {
if (typeof value !== 'string') {
return value;
}
try {
return JSON.parse(value);
} catch {
return value;
}
}
/** Structured edits for the args preview. Never formatted into diff text:
* `buildEditPreviewDiff` renders them directly, so no separator has to be
* invented and later told apart from real content. */
function buildEditArgsPreview(args: ToolCallArgs): TextEditPreview[] {
const parsed = parseArgsObject(args);
if (Array.isArray(parsed?.edits) && parsed.edits.length > 0) {
return parsed.edits
const edits = coerceJsonValue(parsed?.edits);
if (Array.isArray(edits) && edits.length > 0) {
return edits
.map((edit): TextEditPreview | undefined => {
if (typeof edit !== 'object' || edit === null || Array.isArray(edit)) {
const coercedEdit = coerceJsonValue(edit);
if (typeof coercedEdit !== 'object' || coercedEdit === null || Array.isArray(coercedEdit)) {
return undefined;
}
const entry = edit as Record<string, unknown>;
const entry = coercedEdit as Record<string, unknown>;
const oldText = textValue(entry.old_text);
const newText = textValue(entry.new_text);
return oldText || newText ? { oldText, newText } : undefined;

View file

@ -158,8 +158,8 @@ describe('FileAuthoringCall', () => {
/>,
);
expect(screen.getByText('old line').closest('div')).toHaveClass('bg-status-error-subtle');
expect(screen.getByText('new line').closest('div')).toHaveClass('bg-status-success-subtle');
expect(screen.getByText('old line')).toBeInTheDocument();
expect(screen.getByText('new line')).toBeInTheDocument();
expect(screen.queryByText(/--- old_text/)).not.toBeInTheDocument();
});
@ -178,8 +178,8 @@ describe('FileAuthoringCall', () => {
/>,
);
expect(screen.getByText('missing text').closest('div')).toHaveClass('bg-status-error-subtle');
expect(screen.getByText('replacement').closest('div')).toHaveClass('bg-status-success-subtle');
expect(screen.getByText('missing text')).toBeInTheDocument();
expect(screen.getByText('replacement')).toBeInTheDocument();
expect(screen.getByText(/matched 0 locations/)).toBeInTheDocument();
});
@ -201,12 +201,8 @@ describe('FileAuthoringCall', () => {
expect(screen.getByTestId('progress-text')).toHaveTextContent('Editing SKILL.md');
expect(screen.getByTestId('code-window-header')).toHaveAttribute('data-language', 'SKILL.md');
expect(screen.queryByText(/--- old_text/)).not.toBeInTheDocument();
expect(screen.getByText('description: Old behavior').closest('div')).toHaveClass(
'bg-status-error-subtle',
);
expect(screen.getByText('description: New behavior').closest('div')).toHaveClass(
'bg-status-success-subtle',
);
expect(screen.getByText('description: Old behavior')).toBeInTheDocument();
expect(screen.getByText('description: New behavior')).toBeInTheDocument();
});
it('streams create_file content from partial JSON string args during run_step_delta', () => {
@ -238,12 +234,8 @@ describe('FileAuthoringCall', () => {
/>,
);
expect(screen.getByText('description: Old behavior').closest('div')).toHaveClass(
'bg-status-error-subtle',
);
expect(screen.getByText('description: New beh').closest('div')).toHaveClass(
'bg-status-success-subtle',
);
expect(screen.getByText('description: Old behavior')).toBeInTheDocument();
expect(screen.getByText('description: New beh')).toBeInTheDocument();
});
it('streams batched edit_file previews from a partial edits array', () => {
@ -262,10 +254,10 @@ describe('FileAuthoringCall', () => {
);
const diff = screen.getByTestId('diff-view');
expect(screen.getByText('first old').closest('div')).toHaveClass('bg-status-error-subtle');
expect(screen.getByText('first new').closest('div')).toHaveClass('bg-status-success-subtle');
expect(screen.getByText('second old').closest('div')).toHaveClass('bg-status-error-subtle');
expect(screen.getByText('second n').closest('div')).toHaveClass('bg-status-success-subtle');
expect(screen.getByText('first old')).toBeInTheDocument();
expect(screen.getByText('first new')).toBeInTheDocument();
expect(screen.getByText('second old')).toBeInTheDocument();
expect(screen.getByText('second n')).toBeInTheDocument();
expect(diff).toBeInTheDocument();
});
@ -286,8 +278,56 @@ describe('FileAuthoringCall', () => {
/>,
);
expect(screen.getByText('first old').closest('div')).toHaveClass('bg-status-error-subtle');
expect(screen.getByText('second new').closest('div')).toHaveClass('bg-status-success-subtle');
expect(screen.getByText('first old')).toBeInTheDocument();
expect(screen.getByText('second new')).toBeInTheDocument();
});
it('renders replacements from a JSON-stringified edits batch after workspace update', () => {
render(
<FileAuthoringCall
toolName="edit_file"
initialProgress={1}
isSubmitting={false}
args={{
file_path: 'workspace/demo/SKILL.md',
edits: JSON.stringify([
{ old_text: 'description: Old behavior', new_text: 'description: New behavior' },
{ old_text: 'status: draft', new_text: 'status: published' },
]),
}}
output="Updated workspace/demo/SKILL.md with 2 exact replacements."
/>,
);
expect(screen.getByText('description: Old behavior')).toBeInTheDocument();
expect(screen.getByText('description: New behavior')).toBeInTheDocument();
expect(screen.getByText('status: draft')).toBeInTheDocument();
expect(screen.getByText('status: published')).toBeInTheDocument();
expect(
screen.getByText('Updated workspace/demo/SKILL.md with 2 exact replacements.'),
).toBeInTheDocument();
});
it('renders replacements from JSON-stringified edits entries after workspace update', () => {
render(
<FileAuthoringCall
toolName="edit_file"
initialProgress={1}
isSubmitting={false}
args={{
file_path: 'workspace/demo/SKILL.md',
edits: [
JSON.stringify({ old_text: 'enabled: false', new_text: 'enabled: true' }),
JSON.stringify({ old_text: 'tier: free', new_text: 'tier: pro' }),
],
}}
output="Updated workspace/demo/SKILL.md with 2 exact replacements."
/>,
);
expect(screen.getByText('enabled: false')).toBeInTheDocument();
expect(screen.getByText('enabled: true')).toBeInTheDocument();
expect(screen.getByText('tier: free')).toBeInTheDocument();
expect(screen.getByText('tier: pro')).toBeInTheDocument();
});
});

View file

@ -1,5 +1,4 @@
import { useState, useRef, useMemo, useEffect, useCallback } from 'react';
import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil';
import { Button } from '@librechat/client';
import { ChevronDown, MessageCircleQuestion, Users } from 'lucide-react';
@ -28,7 +27,6 @@ import { resolveToolCallPhase } from '~/utils/toolCallPhase';
import { AttachmentGroup, ReasoningCompact } from './Parts';
import { isMemoryFailureOutput } from './Parts/MemoryCall';
import { isError, StackedToolIcons } from './ToolOutput';
import { showThinkingAtom } from '~/store/showThinking';
import { isBashProgrammaticToolCall } from './routing';
import { ASK_USER_QUESTION } from '~/utils/approval';
import SearchVerticals from './verticals';
@ -201,6 +199,7 @@ interface ToolCallGroupProps {
parts: PartWithIndex[];
isSubmitting: boolean;
isLast: boolean;
showThinking: boolean;
renderPart: (
part: TMessageContentParts,
idx: number,
@ -233,6 +232,7 @@ export default function ToolCallGroup({
parts,
isSubmitting,
isLast,
showThinking,
renderPart,
lastContentIdx,
groupAttachments,
@ -244,7 +244,6 @@ export default function ToolCallGroup({
const localize = useLocalize();
const mcpIconMap = useMCPIconMap();
const mcpServerNames = useMCPServerNames();
const showThinking = useAtomValue(showThinkingAtom);
const rootRef = useRef<HTMLDivElement | null>(null);
const cancelLayoutReconcileRef = useRef<(() => void) | null>(null);
const retainedForPendingApprovalRef = useRef(false);

View file

@ -234,6 +234,7 @@ describe('ContentParts integration: MCP image hoist and grouping', () => {
isCreatedByUser: false,
isLast: true,
isSubmitting: false,
showThinking: false,
isLatestMessage: true,
};
@ -597,6 +598,7 @@ describe('ContentParts — synthesized activity folds', () => {
messageId: 'msg1',
isCreatedByUser: false,
isLast: true,
showThinking: false,
isSubmitting: false,
isLatestMessage: true,
};
@ -952,6 +954,7 @@ describe('ContentParts integration: phase media row', () => {
isCreatedByUser: false,
isLast: true,
isSubmitting: false,
showThinking: false,
isLatestMessage: true,
};
@ -1030,6 +1033,7 @@ describe('ContentParts integration: lane groups backed by one agent', () => {
isCreatedByUser: false,
isLast: true,
isSubmitting: false,
showThinking: false,
isLatestMessage: true,
};

View file

@ -174,6 +174,7 @@ const baseProps = {
isSubmitting: false,
isLatestMessage: false,
isCreatedByUser: false,
showThinking: false,
content: [],
};

View file

@ -259,6 +259,7 @@ describe('ToolCallGroup image hoisting', () => {
parts,
isSubmitting: false,
isLast: false,
showThinking: false,
lastContentIdx: 1,
renderPart: (_p: TMessageContentParts, idx: number) => (
<div data-testid={`inner-${idx}`} key={idx}>

View file

@ -1,4 +1,5 @@
import React, { useMemo } from 'react';
import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil';
import type { TMessageContentParts } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common';
@ -14,6 +15,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 { showThinkingAtom } from '~/store/showThinking';
import Elapsed, { shouldShowElapsed } from './Elapsed';
import ContentParts from './Content/ContentParts';
import SiblingSwitch from './SiblingSwitch';
@ -46,6 +48,7 @@ function MessageParts(props: TMessageProps) {
} = useMessageHelpers(props, searchResults);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const showThinking = useAtomValue(showThinkingAtom);
const { messageId = null, isCreatedByUser } = message ?? {};
const name = useMemo(() => {
@ -173,6 +176,7 @@ function MessageParts(props: TMessageProps) {
setSiblingIdx={setSiblingIdx}
isCreatedByUser={message.isCreatedByUser}
conversationId={conversation?.conversationId}
showThinking={showThinking}
isLatestMessage={messageId === latestMessageId}
content={message.content as Array<TMessageContentParts | undefined>}
/>

View file

@ -1,4 +1,5 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { useAtomValue } from 'jotai';
import { Button } from '@librechat/client';
import { ContentTypes } from 'librechat-data-provider';
import { CSSTransition } from 'react-transition-group';
@ -17,6 +18,7 @@ import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
import Container from '~/components/Chat/Messages/Content/Container';
import { EmptyText } from '~/components/Chat/Messages/Content/Parts';
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
import { showThinkingAtom } from '~/store/showThinking';
import { useChatSurface } from './surface';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -322,6 +324,7 @@ export function SubagentActivityContent({
onCancelControl?: (controlId: string) => void;
}) {
const localize = useLocalize();
const showThinking = useAtomValue(showThinkingAtom);
const isSubmitting = isLiveSubagentStatus(activity.status);
const parts = useMemo(() => activity.items.map(toContentPart), [activity.items]);
@ -353,6 +356,7 @@ export function SubagentActivityContent({
isCreatedByUser={false}
isLast
isSubmitting={isSubmitting}
showThinking={showThinking}
isLatestMessage={isSubmitting}
/>
);

View file

@ -1,4 +1,5 @@
import { useMemo, useState } from 'react';
import { useAtomValue } from 'jotai';
import { ChevronDown, CornerDownRight, Radio } from 'lucide-react';
import { ContentTypes, EModelEndpoint } from 'librechat-data-provider';
import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } from '@librechat/client';
@ -13,6 +14,7 @@ import { messageFooterClasses } from '~/components/Chat/Messages/styles';
import MessageRow from '~/components/Chat/Messages/ui/MessageRow';
import { ElapsedTimer } from '~/components/Chat/Messages/Elapsed';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import { showThinkingAtom } from '~/store/showThinking';
import { useAgentsMapContext } from '~/Providers';
import { useChatSurface } from './surface';
import { useLocalize } from '~/hooks';
@ -113,6 +115,7 @@ function ExternalEventTrigger({
}
function TriggerMessage({ turn, fullWidth }: { turn: ChildConversationTurn; fullWidth: boolean }) {
const showThinking = useAtomValue(showThinkingAtom);
const localize = useLocalize();
const label = localize(TRIGGER_LABELS[turn.trigger.kind]);
const content = useMemo<TMessageContentParts[]>(
@ -152,6 +155,7 @@ function TriggerMessage({ turn, fullWidth }: { turn: ChildConversationTurn; full
messageId={`${turn.taskId}:trigger`}
conversationId={null}
isCreatedByUser={true}
showThinking={showThinking}
isLast={false}
isSubmitting={false}
isLatestMessage={false}

View file

@ -1,4 +1,5 @@
import { useCallback, useMemo, memo } from 'react';
import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil';
import { Constants } from 'librechat-data-provider';
import type { TMessage, TMessageContentParts } from 'librechat-data-provider';
@ -20,6 +21,7 @@ import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import MessageRow from '~/components/Chat/Messages/ui/MessageRow';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import { showThinkingAtom } from '~/store/showThinking';
import SubRow from '~/components/Chat/Messages/SubRow';
import store from '~/store';
@ -106,6 +108,7 @@ const ContentRender = memo(function ContentRender({
chatContext,
});
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const showThinking = useAtomValue(showThinkingAtom);
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
const isLast = useMemo(
@ -219,6 +222,7 @@ const ContentRender = memo(function ContentRender({
isSubmitting={isSubmitting}
isCreatedByUser={msg.isCreatedByUser}
createdAt={msg.createdAt ?? msg.clientTimestamp}
showThinking={showThinking}
conversationId={conversation?.conversationId}
content={msg.content as Array<TMessageContentParts | undefined>}
/>

View file

@ -16,6 +16,7 @@ const mockGetComposerText = jest.fn(() => 'answer from A');
const mockSetComposerText = jest.fn();
const mockSetDraft = jest.fn();
let mockSaveDrafts = false;
let mockAskStatus = 'idle';
let jotaiStore = createStore();
const JotaiWrapper = ({ children }: { children: React.ReactNode }) =>
@ -27,7 +28,7 @@ const JotaiWrapper = ({ children }: { children: React.ReactNode }) =>
jest.mock('~/data-provider', () => ({ useGetMessagesByConvoId: jest.fn() }));
jest.mock('~/components/Chat/Messages/Content/ApprovalContext', () => ({
useAskSubmitStatus: () => ({ getAskStatus: () => 'idle' }),
useAskSubmitStatus: () => ({ getAskStatus: () => mockAskStatus }),
useResumeSubmit: () => ({ submitAskAnswer: mockSubmitAskAnswer }),
}));
jest.mock('~/Providers', () => ({
@ -66,8 +67,11 @@ describe('useAskAnswerMode', () => {
beforeEach(() => {
jest.clearAllMocks();
mockSaveDrafts = false;
mockAskStatus = 'idle';
jotaiStore = createStore();
mockGetComposerText.mockReturnValue('answer from A');
mockSetComposerText.mockReset();
mockSubmitAskAnswer.mockReset();
});
it('is active when a live ask is available', () => {
@ -233,6 +237,68 @@ describe('useAskAnswerMode', () => {
});
});
it.each(['expired', 'submitted', 'removed'])(
'restores the released message when the question is %s',
(terminal) => {
let composerText = 'answer from A';
mockGetComposerText.mockImplementation(() => composerText);
mockSetComposerText.mockImplementation((_name: string, text: string) => {
composerText = text;
});
mockUseGetMessages.mockReturnValue({ data: liveAsk });
const { result, rerender } = renderHook(() => useAskAnswerMode('conversation-1'), {
wrapper: JotaiWrapper,
});
act(() => result.current.collapse());
composerText = 'an ordinary unsent message';
act(() => result.current.expand());
expect(composerText).toBe('answer from A');
if (terminal === 'removed') {
mockUseGetMessages.mockReturnValue({ data: null });
} else {
mockAskStatus = terminal;
}
rerender();
expect(result.current.active).toBe(false);
expect(composerText).toBe('an ordinary unsent message');
expect(jotaiStore.get(releasedComposerTextAtom)).toEqual({});
},
);
it('preserves newer composer edits when an in-flight question expires', () => {
jotaiStore.set(releasedComposerTextAtom, { a1: 'an older message' });
mockUseGetMessages.mockReturnValue({ data: liveAsk });
const { rerender } = renderHook(() => useAskAnswerMode('conversation-1'), {
wrapper: JotaiWrapper,
});
mockAskStatus = 'submitting';
rerender();
mockGetComposerText.mockReturnValue('newer message typed during submission');
mockAskStatus = 'expired';
rerender();
expect(mockSetComposerText).not.toHaveBeenCalled();
expect(jotaiStore.get(releasedComposerTextAtom)).toEqual({});
});
it('does not restore a departed conversation stash into a different composer', () => {
jotaiStore.set(releasedComposerTextAtom, { a1: 'message for conversation A' });
mockUseGetMessages.mockReturnValue({ data: liveAsk });
const { rerender } = renderHook(({ id }) => useAskAnswerMode(id), {
initialProps: { id: 'conversation-1' },
wrapper: JotaiWrapper,
});
mockUseGetMessages.mockReturnValue({ data: null });
rerender({ id: 'conversation-2' });
expect(mockSetComposerText).not.toHaveBeenCalled();
expect(jotaiStore.get(releasedComposerTextAtom)).toEqual({
a1: 'message for conversation A',
});
});
it('hands the released composer text back when the answer is submitted', () => {
/** Stashed by a prior expand: the question was collapsed, an ordinary
* message was typed in the released composer, then the question was moved

View file

@ -132,6 +132,63 @@ export default function useAskAnswerMode(conversationId?: string | null) {
() => splitOtherOption(batchMode ? undefined : liveAsk?.question.options),
[batchMode, liveAsk],
);
const lastAnswerableRef = useRef<{
conversationId: typeof conversationId;
actionId: string;
submitting: boolean;
composerText: string;
} | null>(null);
/** Expiration and server-side removal bypass the success callback. Recover
* the in-memory message on those exits too, without crossing conversations
* or replacing text edited while a resume was in flight. */
useEffect(() => {
const previous = lastAnswerableRef.current;
if (answerable && liveAsk) {
const sameAction =
previous != null &&
previous.conversationId === conversationId &&
previous.actionId === liveAsk.actionId;
const submitting = status === 'submitting';
lastAnswerableRef.current = {
conversationId,
actionId: liveAsk.actionId,
submitting,
composerText:
sameAction && submitting && previous.submitting
? previous.composerText
: (formContext?.getValues('text') ?? ''),
};
return;
}
lastAnswerableRef.current = null;
if (!previous || previous.conversationId !== conversationId) {
return;
}
const released = releasedComposerText[previous.actionId];
if (released == null) {
return;
}
if (!previous.submitting || formContext?.getValues('text') === previous.composerText) {
formContext?.setValue('text', released);
}
setReleasedComposerText((current) => {
if (current[previous.actionId] == null) {
return current;
}
const next = { ...current };
delete next[previous.actionId];
return next;
});
}, [
answerable,
conversationId,
liveAsk,
status,
formContext,
releasedComposerText,
setReleasedComposerText,
]);
const answerText = liveAsk != null ? (answerDrafts[liveAsk.actionId] ?? '') : '';
const setAnswerText = useCallback(
(text: string) => {
@ -284,10 +341,10 @@ export default function useAskAnswerMode(conversationId?: string | null) {
* flight).
*
* The selection/composer cleanup runs ONLY after the resume is accepted (in
* `submitAskAnswer`'s success path): a failed resume — the 16k answer-cap
* 400, an expired action, a network error — leaves `status` re-answerable,
* so wiping the composer (the user's only copy of a free-form answer) up
* front would lose it. The composer only resets when its text was consumed
* `submitAskAnswer`'s success path): a retryable failed resume — the 16k
* answer-cap 400 or a network error — must preserve the user's free-form
* answer. Terminal exits separately restore any released normal message.
* The composer only resets when its text was consumed
* by the answer or when the draft machinery will restore the stashed
* conversation draft; with drafts disabled and an option-click answer the
* typed text is left alone.