From 6d4072f8539353b292b4694b14e450fe03a2c793 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:39:32 +0200 Subject: [PATCH] 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. --- .../Chat/Messages/Content/AgentHandoff.tsx | 25 ++++++++++++++++--- client/src/hooks/Input/useAskAnswerMode.ts | 20 +++++++-------- client/src/locales/en/translation.json | 2 ++ 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/AgentHandoff.tsx b/client/src/components/Chat/Messages/Content/AgentHandoff.tsx index b3ac9f3efb..b4c0004273 100644 --- a/client/src/components/Chat/Messages/Content/AgentHandoff.tsx +++ b/client/src/components/Chat/Messages/Content/AgentHandoff.tsx @@ -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): 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 = { + 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 = ({ 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 = ({ name, args: _args = '' }) =
{key && (
- {fieldLabel(key)} + {fieldLabel(key, localize)}
)}
diff --git a/client/src/hooks/Input/useAskAnswerMode.ts b/client/src/hooks/Input/useAskAnswerMode.ts index e9c0fce4ad..c862d3d72e 100644 --- a/client/src/hooks/Input/useAskAnswerMode.ts +++ b/client/src/hooks/Input/useAskAnswerMode.ts @@ -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) => diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 4b6b673d3f..4a28729e9f 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1406,6 +1406,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",