diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 51d9bcf6eb..a75683a4b8 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -2,6 +2,7 @@ const { logger } = require('@librechat/data-schemas'); const { createContentAggregator, GraphNodeKeys } = require('@librechat/agents'); const { checkAccess, + resolveSender, loadSkillStates, initializeAgent, isMemoryEnabled, @@ -28,7 +29,6 @@ const { PermissionTypes, MAX_SUBAGENT_DEPTH, isAgentsEndpoint, - getResponseSender, AgentCapabilities, Tools, MAX_SUBAGENT_GRAPH_NODES, @@ -1070,20 +1070,22 @@ const initializeClient = async ({ }); } catch (err) { logger.error( - '[api/server/controllers/agents/client.js #titleConvo] Error getting custom endpoint config', + '[/api/server/services/Endpoints/agents/initialize.js] Error getting custom endpoint config', err, ); } } - const sender = - primaryAgent.name ?? - getResponseSender({ + const sender = resolveSender({ + agent: primaryConfig, + specLabel: selectedModelSpec?.label, + endpointOption: { ...endpointOption, model: endpointOption.model_parameters.model, modelDisplayLabel: endpointConfig?.modelDisplayLabel, modelLabel: endpointOption.model_parameters.modelLabel, - }); + }, + }); /** History priming uses the user's full ACL-accessible skill set (not * per-agent scoped) because prior turns may reference skills no longer diff --git a/client/src/hooks/Conversations/__tests__/useGetSender.spec.tsx b/client/src/hooks/Conversations/__tests__/useGetSender.spec.tsx new file mode 100644 index 0000000000..efba85a76b --- /dev/null +++ b/client/src/hooks/Conversations/__tests__/useGetSender.spec.tsx @@ -0,0 +1,94 @@ +import { RecoilRoot } from 'recoil'; +import { renderHook } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryKeys, EModelEndpoint, getResponseSender } from 'librechat-data-provider'; +import type { TStartupConfig, TEndpointsConfig, TEndpointOption } from 'librechat-data-provider'; +import type { ReactNode } from 'react'; +import { startupConfigKey } from '~/data-provider'; +import useGetSender from '../useGetSender'; + +const startupConfig = { + modelSpecs: { + list: [{ name: 'fast-qwen', label: 'Fast Qwen' }], + }, +} as unknown as TStartupConfig; + +const endpointsConfig = { + 'Together AI': { modelDisplayLabel: 'Together' }, + openAI: {}, +} as unknown as TEndpointsConfig; + +const renderGetSender = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, enabled: false } }, + }); + queryClient.setQueryData([QueryKeys.endpoints], endpointsConfig); + queryClient.setQueryData(startupConfigKey(false), startupConfig); + const { result } = renderHook(() => useGetSender(), { + wrapper: function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }, + }); + return result.current; +}; + +const asOption = (option: Record) => option as TEndpointOption; + +describe('useGetSender', () => { + it('prefers modelLabel over the spec and endpoint labels', () => { + const getSender = renderGetSender(); + expect( + getSender( + asOption({ + endpoint: 'Together AI', + endpointType: EModelEndpoint.custom, + model: 'Qwen/Qwen2.5-72B-Instruct', + modelLabel: 'My Qwen', + spec: 'fast-qwen', + }), + ), + ).toBe('My Qwen'); + }); + + it('surfaces the model spec label when no modelLabel is set', () => { + const getSender = renderGetSender(); + expect( + getSender( + asOption({ + endpoint: 'Together AI', + endpointType: EModelEndpoint.custom, + model: 'Qwen/Qwen2.5-72B-Instruct', + spec: 'fast-qwen', + }), + ), + ).toBe('Fast Qwen'); + }); + + it('lets the explicit modelDisplayLabel beat the family heuristic', () => { + const getSender = renderGetSender(); + expect( + getSender( + asOption({ + endpoint: 'Together AI', + endpointType: EModelEndpoint.custom, + model: 'mistralai/Mixtral-8x7B-Instruct-v0.1', + }), + ), + ).toBe('Together'); + }); + + it('falls through to getResponseSender when no label resolves', () => { + const getSender = renderGetSender(); + expect(getSender(asOption({ endpoint: EModelEndpoint.anthropic }))).toBe('Claude'); + const unknownCustom = { + endpoint: 'LocalAI', + endpointType: EModelEndpoint.custom, + model: 'some-unknown-model', + }; + expect(getSender(asOption(unknownCustom))).toBe(getResponseSender(asOption(unknownCustom))); + }); +}); diff --git a/client/src/hooks/Conversations/useGetSender.ts b/client/src/hooks/Conversations/useGetSender.ts index cced4ca4fe..1f978049fa 100644 --- a/client/src/hooks/Conversations/useGetSender.ts +++ b/client/src/hooks/Conversations/useGetSender.ts @@ -1,15 +1,28 @@ import { useCallback } from 'react'; -import { getResponseSender } from 'librechat-data-provider'; +import { getResponseSender, getEphemeralSender } from 'librechat-data-provider'; import type { TEndpointOption, TEndpointsConfig } from 'librechat-data-provider'; -import { useGetEndpointsQuery } from '~/data-provider'; +import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider'; +import { getModelSpec } from '~/utils'; +/** + * Mirrors the server's sender resolution (`modelLabel` → spec label → endpoint + * `modelDisplayLabel` → `getResponseSender`) so the optimistic streaming label + * and composer placeholder match the persisted `message.sender`. + */ export default function useGetSender() { + const { data: startupConfig } = useGetStartupConfig(); const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery(); return useCallback( (endpointOption: TEndpointOption) => { const { modelDisplayLabel } = endpointsConfig?.[endpointOption.endpoint ?? ''] ?? {}; - return getResponseSender({ ...endpointOption, modelDisplayLabel }); + const modelSpec = getModelSpec({ specName: endpointOption.spec, startupConfig }); + const sender = getEphemeralSender({ + modelLabel: endpointOption.modelLabel, + specLabel: modelSpec?.label, + modelDisplayLabel, + }); + return sender || getResponseSender({ ...endpointOption, modelDisplayLabel }); }, - [endpointsConfig], + [endpointsConfig, startupConfig], ); } diff --git a/client/src/utils/__tests__/messages.test.ts b/client/src/utils/__tests__/messages.test.ts index 0a403c32ed..22e00ff678 100644 --- a/client/src/utils/__tests__/messages.test.ts +++ b/client/src/utils/__tests__/messages.test.ts @@ -1,6 +1,7 @@ import { QueryClient } from '@tanstack/react-query'; import { Constants, QueryKeys } from 'librechat-data-provider'; import type { TMessage, TConversation } from 'librechat-data-provider'; +import type { TEndpointsConfig } from 'librechat-data-provider'; import type { LocalizeFunction, TMessageProps } from '~/common'; import { clearMessagesCache, @@ -12,6 +13,7 @@ import { areMessageFieldsEqual, areMessageRowPropsEqual, isSubmittableMessage, + createDualMessageContent, } from '../messages'; const translations: Record = { @@ -369,3 +371,59 @@ describe('isSubmittableMessage', () => { expect(isSubmittableMessage('Translate this', 1)).toBe(true); }); }); + +describe('createDualMessageContent', () => { + /** Custom endpoints carry their configured name (e.g. "Together AI") in + * `endpoint` at runtime, which `TConversation` types as `EModelEndpoint`. */ + const asConvo = (convo: Partial> & { endpoint: string }) => + convo as unknown as TConversation; + const agentIds = (parts: ReturnType) => + parts.map((part) => (part as unknown as { agentId: string }).agentId); + + it('encodes the model spec label into ephemeral agent ids', () => { + const parts = createDualMessageContent( + asConvo({ endpoint: 'Together AI', model: 'Qwen/Qwen2.5-72B-Instruct', spec: 'fast-qwen' }), + asConvo({ endpoint: 'openAI', model: 'gpt-4o', modelLabel: 'My GPT' }), + undefined, + [{ name: 'fast-qwen', label: 'Fast Qwen' }], + ); + expect(agentIds(parts)).toEqual([ + 'Together AI__Qwen/Qwen2.5-72B-Instruct___Fast Qwen', + 'openAI__gpt-4o___My GPT____1', + ]); + }); + + it('falls back to the endpoint modelDisplayLabel when no labels are set', () => { + const endpointsConfig = { + 'Together AI': { modelDisplayLabel: 'Together' }, + } as unknown as TEndpointsConfig; + const parts = createDualMessageContent( + asConvo({ endpoint: 'Together AI', model: 'mixtral-8x7b' }), + asConvo({ endpoint: 'Together AI', model: 'mixtral-8x7b' }), + endpointsConfig, + ); + expect(agentIds(parts)).toEqual([ + 'Together AI__mixtral-8x7b___Together', + 'Together AI__mixtral-8x7b___Together____1', + ]); + }); + + it('omits the sender segment entirely when no label resolves', () => { + const parts = createDualMessageContent( + asConvo({ endpoint: 'Together AI', model: 'mixtral-8x7b' }), + asConvo({ endpoint: 'Together AI', model: 'mixtral-8x7b' }), + ); + expect(agentIds(parts)).toEqual([ + 'Together AI__mixtral-8x7b', + 'Together AI__mixtral-8x7b____1', + ]); + }); + + it('passes real agent ids through, suffixing only the added agent', () => { + const parts = createDualMessageContent( + asConvo({ endpoint: 'agents', agent_id: 'agent_abc123' }), + asConvo({ endpoint: 'agents', agent_id: 'agent_abc123' }), + ); + expect(agentIds(parts)).toEqual(['agent_abc123', 'agent_abc123____1']); + }); +}); diff --git a/client/src/utils/messages.ts b/client/src/utils/messages.ts index 7faa49d6be..8625021af9 100644 --- a/client/src/utils/messages.ts +++ b/client/src/utils/messages.ts @@ -4,6 +4,7 @@ import { buildTree, ContentTypes, isEphemeralAgentId, + getEphemeralSender, appendAgentIdSuffix, encodeEphemeralAgentId, } from 'librechat-data-provider'; @@ -494,13 +495,13 @@ export const createDualMessageContent = ( primaryConvo.spec != null && primaryConvo.spec !== '' ? modelSpecs?.find((s) => s.name === primaryConvo.spec) : undefined; - // For ephemeral agents, use modelLabel if provided, then model spec's label, - // then modelDisplayLabel from endpoint config, otherwise empty string to show model name - const primarySender = - primaryConvo.modelLabel ?? - primarySpec?.label ?? - (primaryEndpoint ? endpointsConfig?.[primaryEndpoint]?.modelDisplayLabel : undefined) ?? - ''; + const primarySender = getEphemeralSender({ + modelLabel: primaryConvo.modelLabel, + specLabel: primarySpec?.label, + modelDisplayLabel: primaryEndpoint + ? endpointsConfig?.[primaryEndpoint]?.modelDisplayLabel + : undefined, + }); primaryAgentId = encodeEphemeralAgentId({ endpoint: primaryEndpoint ?? '', model: primaryModel, @@ -534,13 +535,13 @@ export const createDualMessageContent = ( addedConvo.spec != null && addedConvo.spec !== '' ? modelSpecs?.find((s) => s.name === addedConvo.spec) : undefined; - // For ephemeral agents, use modelLabel if provided, then model spec's label, - // then modelDisplayLabel from endpoint config, otherwise empty string to show model name - const addedSender = - addedConvo.modelLabel ?? - addedSpec?.label ?? - (addedEndpoint ? endpointsConfig?.[addedEndpoint]?.modelDisplayLabel : undefined) ?? - ''; + const addedSender = getEphemeralSender({ + modelLabel: addedConvo.modelLabel, + specLabel: addedSpec?.label, + modelDisplayLabel: addedEndpoint + ? endpointsConfig?.[addedEndpoint]?.modelDisplayLabel + : undefined, + }); addedAgentId = encodeEphemeralAgentId({ endpoint: addedEndpoint ?? '', model: addedModel, diff --git a/e2e/specs/mock/soft-default.spec.ts b/e2e/specs/mock/soft-default.spec.ts index 3bac47e1c6..20b550abf7 100644 --- a/e2e/specs/mock/soft-default.spec.ts +++ b/e2e/specs/mock/soft-default.spec.ts @@ -262,7 +262,11 @@ test.describe('soft default model spec', () => { const main = page.getByRole('main'); const composer = page.getByRole('textbox', { name: 'Message input' }); const user = getPrimaryE2EUser(); - await expect(composer).toHaveAttribute('placeholder', /Mock Provider A/, { timeout: 15000 }); + // The placeholder mirrors the sender chain, so a spec-launched chat shows the + // spec label (matching the model selector), not the endpoint's display label. + await expect(composer).toHaveAttribute('placeholder', new RegExp(SOFT_DEFAULT_LABEL), { + timeout: 15000, + }); await expect(main).toContainText(user.name, { timeout: 15000 }); await expect(main).not.toContainText(agentName); await expect(main).not.toContainText(agentDescription); diff --git a/packages/api/src/agents/added.ts b/packages/api/src/agents/added.ts index 0d23204500..192b48d18c 100644 --- a/packages/api/src/agents/added.ts +++ b/packages/api/src/agents/added.ts @@ -4,6 +4,7 @@ import { Constants, isAgentsEndpoint, isEphemeralAgentId, + getEphemeralSender, appendAgentIdSuffix, encodeEphemeralAgentId, } from 'librechat-data-provider'; @@ -146,11 +147,11 @@ export async function loadAddedAgent( const modelSpecs = (appConfig?.modelSpecs as { list?: TModelSpec[] })?.list; const modelSpec = spec != null && spec !== '' ? modelSpecs?.find((s) => s.name === spec) : null; - const sender = - rest.modelLabel ?? - modelSpec?.label ?? - (endpointConfig?.modelDisplayLabel as string | undefined) ?? - ''; + const sender = getEphemeralSender({ + modelLabel: rest.modelLabel, + specLabel: modelSpec?.label, + modelDisplayLabel: endpointConfig?.modelDisplayLabel as string | undefined, + }); const ephemeralId = encodeEphemeralAgentId({ endpoint, model, sender, index: 1 }); const result: Record = { @@ -268,11 +269,11 @@ export async function loadAddedAgent( } } - const sender = - rest.modelLabel ?? - modelSpec?.label ?? - (endpointConfig?.modelDisplayLabel as string | undefined) ?? - ''; + const sender = getEphemeralSender({ + modelLabel: rest.modelLabel, + specLabel: modelSpec?.label, + modelDisplayLabel: endpointConfig?.modelDisplayLabel as string | undefined, + }); const ephemeralId = encodeEphemeralAgentId({ endpoint, model, sender, index: 1 }); const result: Record = { diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 8e01ad4f58..fb78376e8a 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -39,6 +39,7 @@ export * from './tools'; export * from './validation'; export * from './added'; export * from './load'; +export * from './sender'; export * from './hitl'; export * from './hooks'; export * from './steering'; diff --git a/packages/api/src/agents/load.spec.ts b/packages/api/src/agents/load.spec.ts index 75abb6a3fd..48620851e2 100644 --- a/packages/api/src/agents/load.spec.ts +++ b/packages/api/src/agents/load.spec.ts @@ -1,5 +1,7 @@ +import { EModelEndpoint, parseEphemeralAgentId } from 'librechat-data-provider'; import type { LoadAgentDeps } from './load'; import { loadEphemeralAgent } from './load'; +import { resolveSender } from './sender'; const deps: LoadAgentDeps = { getAgent: async () => null, @@ -50,3 +52,51 @@ describe('loadEphemeralAgent ephemeral id stability (#14253 Bug 2)', () => { expect(a).toEqual(b); }); }); + +/** Custom endpoints carry their configured name in `endpoint` at runtime, + * which `TEndpointOption` types as `EModelEndpoint`. */ +const customEndpointOption = { + endpoint: 'my-custom-endpoint' as EModelEndpoint, + model: 'claude-opus-4', +}; + +describe('loadEphemeralAgent → resolveSender parity', () => { + test('the persisted sender matches the spec label encoded into the agent id', async () => { + const agent = await loadEphemeralAgent( + { + req: baseReq, + spec: 'my-opus-spec', + endpoint: 'my-custom-endpoint', + model_parameters: { model: 'claude-opus-4' } as never, + }, + deps, + ); + expect(agent?.id).toBeTruthy(); + const sender = resolveSender({ + agent: { id: agent?.id }, + specLabel: 'Spec Label', + endpointOption: customEndpointOption, + }); + expect(sender).toBe('Spec Label'); + expect(sender).toBe(parseEphemeralAgentId(agent?.id ?? '')?.sender); + }); + + test('a user modelLabel wins over the spec label in the persisted sender', async () => { + const agent = await loadEphemeralAgent( + { + req: baseReq, + spec: 'my-opus-spec', + endpoint: 'my-custom-endpoint', + model_parameters: { model: 'claude-opus-4', modelLabel: 'My Opus' } as never, + }, + deps, + ); + const sender = resolveSender({ + agent: { id: agent?.id }, + specLabel: 'Spec Label', + endpointOption: { ...customEndpointOption, modelLabel: 'My Opus' }, + }); + expect(sender).toBe('My Opus'); + expect(sender).toBe(parseEphemeralAgentId(agent?.id ?? '')?.sender); + }); +}); diff --git a/packages/api/src/agents/load.ts b/packages/api/src/agents/load.ts index a160319f62..af981b8187 100644 --- a/packages/api/src/agents/load.ts +++ b/packages/api/src/agents/load.ts @@ -4,6 +4,7 @@ import { Constants, isAgentsEndpoint, isEphemeralAgentId, + getEphemeralSender, encodeEphemeralAgentId, } from 'librechat-data-provider'; import type { @@ -134,19 +135,18 @@ export async function loadEphemeralAgent( } } - // For ephemeral agents, use modelLabel if provided, then model spec's label, - // then modelDisplayLabel from endpoint config, otherwise empty string to show model name - const sender = - (model_parameters as AgentModelParameters & { modelLabel?: string })?.modelLabel ?? - modelSpec?.label ?? - (endpointConfig as { modelDisplayLabel?: string } | undefined)?.modelDisplayLabel ?? - ''; + const sender = getEphemeralSender({ + modelLabel: (model_parameters as AgentModelParameters & { modelLabel?: string })?.modelLabel, + specLabel: modelSpec?.label, + modelDisplayLabel: (endpointConfig as { modelDisplayLabel?: string } | undefined) + ?.modelDisplayLabel, + }); // Encode ephemeral agent ID with endpoint, model, and computed sender for display const ephemeralId = encodeEphemeralAgentId({ endpoint, model: model as string, - sender: sender as string, + sender, }); const result: Partial = { diff --git a/packages/api/src/agents/sender.spec.ts b/packages/api/src/agents/sender.spec.ts new file mode 100644 index 0000000000..454f4334c0 --- /dev/null +++ b/packages/api/src/agents/sender.spec.ts @@ -0,0 +1,119 @@ +import { EModelEndpoint, getResponseSender, encodeEphemeralAgentId } from 'librechat-data-provider'; +import type { TEndpointOption } from 'librechat-data-provider'; +import { resolveSender } from './sender'; + +/** Custom endpoints carry their configured name (e.g. "Together AI") in + * `endpoint` at runtime, which `TEndpointOption` types as `EModelEndpoint`. */ +const customOption = ( + model: string, + labels: Partial> = {}, +): Partial => ({ + endpoint: 'Together AI' as EModelEndpoint, + endpointType: EModelEndpoint.custom, + model, + ...labels, +}); + +const ephemeralAgent = (model: string, sender?: string) => ({ + id: encodeEphemeralAgentId({ endpoint: 'Together AI', model, sender }), +}); + +describe('resolveSender', () => { + test('prefers a real agent name over everything else', () => { + expect( + resolveSender({ + agent: { id: 'agent_abc123', name: 'My Agent' }, + endpointOption: { endpoint: EModelEndpoint.anthropic }, + }), + ).toBe('My Agent'); + }); + + test('returns an empty-string name as-is, preserving legacy `??` semantics', () => { + expect( + resolveSender({ + agent: { id: 'agent_abc123', name: '' }, + endpointOption: { endpoint: EModelEndpoint.anthropic }, + }), + ).toBe(''); + }); + + test('skips the label chain for a nameless real agent', () => { + expect( + resolveSender({ + agent: { id: 'agent_abc123', name: null }, + specLabel: 'Spec Label', + endpointOption: { endpoint: EModelEndpoint.agents, modelLabel: 'A Label' }, + }), + ).toBe(''); + }); + + test('resolves the modelLabel for an ephemeral agent', () => { + expect( + resolveSender({ + agent: ephemeralAgent('Qwen/Qwen2.5-72B-Instruct', 'My Qwen'), + endpointOption: customOption('Qwen/Qwen2.5-72B-Instruct', { modelLabel: 'My Qwen' }), + }), + ).toBe('My Qwen'); + }); + + test('persists delimiter-heavy labels verbatim instead of decoding the lossy id', () => { + for (const modelLabel of ['A___B', 'My__Bot', 'v2____3']) { + expect( + resolveSender({ + agent: ephemeralAgent('Qwen/Qwen2.5-72B-Instruct', modelLabel), + endpointOption: customOption('Qwen/Qwen2.5-72B-Instruct', { modelLabel }), + }), + ).toBe(modelLabel); + } + }); + + test('falls back to the spec label, then the endpoint display label', () => { + expect( + resolveSender({ + agent: ephemeralAgent('Qwen/Qwen2.5-72B-Instruct', 'Spec Label'), + specLabel: 'Spec Label', + endpointOption: customOption('Qwen/Qwen2.5-72B-Instruct', { + modelDisplayLabel: 'Together', + }), + }), + ).toBe('Spec Label'); + expect( + resolveSender({ + agent: ephemeralAgent('mistralai/Mixtral-8x7B-Instruct-v0.1', 'Together'), + endpointOption: customOption('mistralai/Mixtral-8x7B-Instruct-v0.1', { + modelDisplayLabel: 'Together', + }), + }), + ).toBe('Together'); + }); + + test('keeps the family heuristic for a label-less custom endpoint', () => { + expect( + resolveSender({ + agent: ephemeralAgent('mistralai/Mixtral-8x7B-Instruct-v0.1'), + endpointOption: customOption('mistralai/Mixtral-8x7B-Instruct-v0.1'), + }), + ).toBe('Mistral'); + }); + + test('keeps the model-derived name for a label-less anthropic agent', () => { + expect( + resolveSender({ + agent: { + id: encodeEphemeralAgentId({ + endpoint: EModelEndpoint.anthropic, + model: 'claude-sonnet-5', + }), + }, + endpointOption: { endpoint: EModelEndpoint.anthropic, model: 'claude-sonnet-5' }, + }), + ).toBe('Claude'); + }); + + test('matches getResponseSender exactly for an unknown label-less custom model', () => { + const endpointOption = customOption('some-unknown-model'); + expect(resolveSender({ agent: ephemeralAgent('some-unknown-model'), endpointOption })).toBe( + getResponseSender(endpointOption), + ); + }); +}); diff --git a/packages/api/src/agents/sender.ts b/packages/api/src/agents/sender.ts new file mode 100644 index 0000000000..49067b66b5 --- /dev/null +++ b/packages/api/src/agents/sender.ts @@ -0,0 +1,39 @@ +import { getResponseSender, getEphemeralSender, isEphemeralAgentId } from 'librechat-data-provider'; +import type { Agent, TEndpointOption } from 'librechat-data-provider'; + +/** + * Resolves the display name persisted as `message.sender` for a response. + * + * Real agents use their own name. Ephemeral agents run the same label chain + * `loadEphemeralAgent` encodes into their id (`modelLabel` → spec label → + * endpoint `modelDisplayLabel`), recomputed here from the exact source values + * rather than decoded from the id — the id encoding is lossy for labels + * containing `__`/`___` sequences, and the persisted sender must match the + * configured label verbatim. `getResponseSender` remains the fallback for + * label-less agents (model-derived names such as `Claude` or `GPT-5`, family + * heuristics, `'AI'`). + */ +export function resolveSender({ + agent, + specLabel, + endpointOption, +}: { + agent: Partial>; + specLabel?: string | null; + endpointOption: Partial; +}): string { + if (agent.name != null) { + return agent.name; + } + if (agent.id != null && isEphemeralAgentId(agent.id)) { + const sender = getEphemeralSender({ + modelLabel: endpointOption.modelLabel, + specLabel, + modelDisplayLabel: endpointOption.modelDisplayLabel, + }); + if (sender) { + return sender; + } + } + return getResponseSender(endpointOption); +} diff --git a/packages/data-provider/specs/parsers.spec.ts b/packages/data-provider/specs/parsers.spec.ts index e88eab64ab..8d86dedb0c 100644 --- a/packages/data-provider/specs/parsers.spec.ts +++ b/packages/data-provider/specs/parsers.spec.ts @@ -1,4 +1,12 @@ -import { replaceSpecialVars, parseConvo, parseCompactConvo, parseTextParts } from '../src/parsers'; +import { + parseConvo, + parseTextParts, + parseCompactConvo, + replaceSpecialVars, + getEphemeralSender, + encodeEphemeralAgentId, + parseEphemeralAgentId, +} from '../src/parsers'; import { specialVariables } from '../src/config'; import { EModelEndpoint, Providers } from '../src/schemas'; import { ContentTypes } from '../src/types/runs'; @@ -701,3 +709,113 @@ describe('parseTextParts', () => { ); }); }); + +describe('encodeEphemeralAgentId / parseEphemeralAgentId', () => { + test('round-trips endpoint and model without a sender', () => { + const id = encodeEphemeralAgentId({ endpoint: 'openAI', model: 'gpt-4o' }); + expect(id).toBe('openAI__gpt-4o'); + expect(parseEphemeralAgentId(id)).toEqual({ + endpoint: 'openAI', + model: 'gpt-4o', + sender: undefined, + index: undefined, + }); + }); + + test('round-trips a sender', () => { + const id = encodeEphemeralAgentId({ + endpoint: 'Together AI', + model: 'Qwen/Qwen2.5-72B-Instruct', + sender: 'Fast Qwen', + }); + expect(id).toBe('Together AI__Qwen/Qwen2.5-72B-Instruct___Fast Qwen'); + expect(parseEphemeralAgentId(id)?.sender).toBe('Fast Qwen'); + expect(parseEphemeralAgentId(id)?.model).toBe('Qwen/Qwen2.5-72B-Instruct'); + }); + + test('round-trips a sender alongside an index suffix', () => { + const id = encodeEphemeralAgentId({ + endpoint: 'openAI', + model: 'gpt-4o', + sender: 'GPT-4o', + index: 1, + }); + expect(id).toBe('openAI__gpt-4o___GPT-4o____1'); + expect(parseEphemeralAgentId(id)).toEqual({ + endpoint: 'openAI', + model: 'gpt-4o', + sender: 'GPT-4o', + index: 1, + }); + }); + + test('omits the sender segment for an empty sender, parsing back to undefined', () => { + const id = encodeEphemeralAgentId({ endpoint: 'openAI', model: 'gpt-4o', sender: '' }); + expect(id).toBe('openAI__gpt-4o'); + expect(parseEphemeralAgentId(id)?.sender).toBeUndefined(); + }); + + test('restores colons in the endpoint, model, and sender', () => { + const id = encodeEphemeralAgentId({ + endpoint: 'custom', + model: 'claude-3:opus', + sender: 'Label:With:Colons', + }); + expect(parseEphemeralAgentId(id)).toEqual({ + endpoint: 'custom', + model: 'claude-3:opus', + sender: 'Label:With:Colons', + index: undefined, + }); + }); + + test('returns undefined for ids without the ephemeral format', () => { + expect(parseEphemeralAgentId('agent_abc123')).toBeUndefined(); + }); + + /** Characterization of known format quirks (SiblingHeader and the persisted + * sender both decode this format, so lock the behavior rather than change it): + * the parser splits on the first `___` and keeps only the next segment, and + * restores every `__` in the sender to `:`. */ + test('truncates a sender containing a triple underscore (known quirk)', () => { + const id = encodeEphemeralAgentId({ endpoint: 'openAI', model: 'gpt-4o', sender: 'A___B' }); + expect(parseEphemeralAgentId(id)?.sender).toBe('A'); + }); + + test('decodes a literal double underscore in a sender to a colon (known quirk)', () => { + const id = encodeEphemeralAgentId({ endpoint: 'openAI', model: 'gpt-4o', sender: 'My__Bot' }); + expect(parseEphemeralAgentId(id)?.sender).toBe('My:Bot'); + }); +}); + +describe('getEphemeralSender', () => { + test('prefers modelLabel over the spec and endpoint labels', () => { + expect( + getEphemeralSender({ + modelLabel: 'My Label', + specLabel: 'Spec Label', + modelDisplayLabel: 'Endpoint Label', + }), + ).toBe('My Label'); + }); + + test('falls back to the spec label, then the endpoint display label', () => { + expect( + getEphemeralSender({ specLabel: 'Spec Label', modelDisplayLabel: 'Endpoint Label' }), + ).toBe('Spec Label'); + expect(getEphemeralSender({ modelDisplayLabel: 'Endpoint Label' })).toBe('Endpoint Label'); + }); + + test('returns an empty string when no label is set', () => { + expect(getEphemeralSender({})).toBe(''); + expect(getEphemeralSender({ modelLabel: null, specLabel: null, modelDisplayLabel: null })).toBe( + '', + ); + }); + + /** `??` chain: an empty-string label short-circuits, preserving the exact + * pre-consolidation behavior of every call site. */ + test('an empty-string modelLabel short-circuits the chain', () => { + expect(getEphemeralSender({ modelLabel: '', specLabel: 'Spec Label' })).toBe(''); + }); +}); diff --git a/packages/data-provider/src/parsers.ts b/packages/data-provider/src/parsers.ts index 261ad9031d..26f478c63c 100644 --- a/packages/data-provider/src/parsers.ts +++ b/packages/data-provider/src/parsers.ts @@ -489,6 +489,23 @@ export type ParsedEphemeralAgentId = { index?: number; }; +/** + * Resolves the display label ("sender") for an ephemeral agent: + * `modelLabel` (user/preset) → model spec's `label` → endpoint config's + * `modelDisplayLabel` → `''` (lets consumers fall back to the model name). + */ +export function getEphemeralSender({ + modelLabel, + specLabel, + modelDisplayLabel, +}: { + modelLabel?: string | null; + specLabel?: string | null; + modelDisplayLabel?: string | null; +}): string { + return modelLabel ?? specLabel ?? modelDisplayLabel ?? ''; +} + /** * Encodes an ephemeral agent ID from endpoint, model, optional sender, and optional index. * Uses __ to replace : (reserved in graph node names) and ___ to separate sender.