fix(client): make group identities collision-proof and localize handoff labels

Disambiguate duplicate tool-group identities by how many earlier groups already
claimed the same provider tool-call id. Those ids repeat across agents in a
handoff transcript and restart per turn within one agent, and a lone tool with
reasoning now groups too, so sibling groups could share a React key and one
expansion override. Occurrence order is stable because groups are append-only,
and the first occurrence keeps the bare id, so an id that never repeats is
unchanged and still survives a content-index shift.

Restore a stashed ask message regardless of the current Save drafts setting. The
stash is only ever created while saving is off, so it never reached the
conversation draft; gating the restore on the setting dropped it whenever the
user enabled saving between expanding and collapsing.

Localize the handoff field labels the SDK defines (instruction, instructions,
context). Any other key is an admin-authored promptKey from the agent graph, so
it stays as written, like an agent's own name.
This commit is contained in:
Marco Beretta 2026-08-29 00:39:32 +02:00
parent e0fd022d68
commit 5992cd78ef
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
4 changed files with 58 additions and 36 deletions

View file

@ -4,6 +4,7 @@ import { ChevronDown } from 'lucide-react';
import { Button } from '@librechat/client';
import { EModelEndpoint, Constants } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import type { TranslationKeys } from '~/hooks';
import CopyButton from '~/components/Messages/Content/CopyButton';
import { unescapeJsonString } from './Parts/parseJsonField';
import MessageIcon from '~/components/Share/MessageIcon';
@ -78,7 +79,23 @@ function parseHandoffFields(args: string | Record<string, unknown>): HandoffFiel
}
}
function fieldLabel(key: string): string {
/**
* The SDK's own prompt keys, which are the ones that carry product meaning.
* Any other key is an admin-authored `promptKey` from the agent graph (see
* `handoffPromptKeyCompatibility`), so it is author content with no
* localization key to map to and is left as written, like an agent's own name.
*/
const HANDOFF_FIELD_LABELS: Record<string, TranslationKeys> = {
instruction: 'com_ui_handoff_field_instructions',
instructions: 'com_ui_handoff_field_instructions',
context: 'com_ui_handoff_field_context',
};
function fieldLabel(key: string, localize: (translationKey: TranslationKeys) => string): string {
const known = HANDOFF_FIELD_LABELS[key.toLowerCase()];
if (known) {
return localize(known);
}
const words = key
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
@ -118,9 +135,9 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
fields.length === 1
? fields[0].value
: fields
.map(({ key, value }) => `${key ? `${fieldLabel(key)}\n` : ''}${value}`)
.map(({ key, value }) => `${key ? `${fieldLabel(key, localize)}\n` : ''}${value}`)
.join('\n\n'),
[fields],
[fields, localize],
);
const hasInfo = fields.length > 0;
const agentName = targetAgent?.name || localize('com_ui_agent');
@ -215,7 +232,7 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
<div key={`${key ?? 'field'}-${index}`}>
{key && (
<dt className="mb-0.5 text-xs font-medium text-text-secondary">
{fieldLabel(key)}
{fieldLabel(key, localize)}
</dt>
)}
<dd className="whitespace-pre-wrap break-words text-sm leading-6 text-text-primary">

View file

@ -55,12 +55,7 @@ const getToolGroupId = (parts: PartWithIndex[], fallbackScope: number): string =
for (const { part, idx } of parts) {
const toolCallId = getToolCallId(part);
if (toolCallId) {
/** Provider tool-call ids repeat across agents in a handoff transcript
* (`call_0`), and a lone tool with reasoning now groups too, so two
* sibling groups would otherwise share one React key and one expansion
* override: toggling either would move both. */
const agentId = getPartAgentId(part);
return agentId ? `tool:${agentId}:${toolCallId}` : `tool:${toolCallId}`;
return `tool:${toolCallId}`;
}
if (firstToolIdx === undefined && part?.type === ContentTypes.TOOL_CALL) {
firstToolIdx = idx;
@ -441,22 +436,30 @@ const ContentParts = memo(function ContentParts({
}, [absoluteIndexAt, content]);
const postSteerAuthors = resumeAuthors ?? detectedResumeAuthors;
const groupedParts = useMemo(
() =>
groupSequentialToolCalls(sequentialParts).map((group) => {
if (group.type === 'single') {
return group;
}
const groupId = getToolGroupId(group.parts, fallbackScope);
const groupAttachments = group.parts.flatMap(
({ part }) =>
filterAttachmentsForPart(attachmentMap[getToolCallId(part)], getPartAgentId(part)) ??
[],
);
return { ...group, groupId, groupAttachments };
}),
[sequentialParts, attachmentMap, fallbackScope],
);
const groupedParts = useMemo(() => {
/** Provider tool-call ids are NOT unique: they repeat across agents in a
* handoff transcript and restart per turn within one agent, and a lone
* tool with reasoning now groups too, so sibling groups could share a
* React key and one expansion override. Disambiguated by how many earlier
* groups already claimed the same id, which is stable because group order
* is append-only. The FIRST occurrence keeps the bare id, so an id that
* never repeats is unchanged and survives a content-index shift. */
const claimedGroupIds = new Map<string, number>();
return groupSequentialToolCalls(sequentialParts).map((group) => {
if (group.type === 'single') {
return group;
}
const baseGroupId = getToolGroupId(group.parts, fallbackScope);
const claimed = claimedGroupIds.get(baseGroupId) ?? 0;
claimedGroupIds.set(baseGroupId, claimed + 1);
const groupId = claimed === 0 ? baseGroupId : `${baseGroupId}#${claimed}`;
const groupAttachments = group.parts.flatMap(
({ part }) =>
filterAttachmentsForPart(attachmentMap[getToolCallId(part)], getPartAgentId(part)) ?? [],
);
return { ...group, groupId, groupAttachments };
});
}, [sequentialParts, attachmentMap, fallbackScope]);
/** The re-attribution node for a part resuming after a steer block, shared
* by the sequential path and the parallel renderer's sequential stretches. */

View file

@ -227,16 +227,14 @@ export default function useAskAnswerMode(conversationId?: string | null) {
runHandoff(() => {
if (!batchMode) {
setAnswerDrafts((current) => ({ ...current, [liveAsk.actionId]: composerAnswer }));
if (!saveDrafts) {
/** Hand back whatever ordinary message was typed while this
* question owned the card, rather than clearing the composer and
* losing it. */
const released = releasedComposerText[liveAsk.actionId];
if (released) {
formContext?.setValue('text', released);
} else {
formContext?.reset();
}
/** An existing stash is restored regardless of the CURRENT
* preference: it was captured while saving was off, so it never
* reached the conversation draft and this is its only recovery path.
* Gating on `!saveDrafts` dropped it whenever the user enabled
* saving between expanding and collapsing. */
const released = releasedComposerText[liveAsk.actionId];
if (released) {
formContext?.setValue('text', released);
setReleasedComposerText((current) => {
if (current[liveAsk.actionId] == null) {
return current;
@ -245,6 +243,8 @@ export default function useAskAnswerMode(conversationId?: string | null) {
delete next[liveAsk.actionId];
return next;
});
} else if (!saveDrafts) {
formContext?.reset();
}
}
setCollapsedIds((prev) =>

View file

@ -1386,6 +1386,8 @@
"com_ui_greeting_working_on": "What are we working on?",
"com_ui_greeting_working_on_named": "What are we working on, {{name}}?",
"com_ui_group": "Group",
"com_ui_handoff_field_context": "Context",
"com_ui_handoff_field_instructions": "Instructions",
"com_ui_handoff_instructions": "Handoff instructions",
"com_ui_happy_birthday": "It's my 1st birthday!",
"com_ui_header_format": "Header Format",