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 7e15aa750c
commit 6d4072f853
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
3 changed files with 33 additions and 14 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

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

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