fix: restore inherited Langfuse agent state

This commit is contained in:
Danny Avila 2026-05-12 22:03:09 -04:00
parent 001c3b5401
commit dc2fa2b40b
10 changed files with 170 additions and 9 deletions

View file

@ -988,6 +988,36 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(latestVersion.langfuse).toBeUndefined();
});
test('should remove Langfuse config when update clears only enabled inheritance override', async () => {
await Agent.updateOne(
{ id: existingAgentId },
{
langfuse: {
enabled: false,
},
},
);
mockReq.params.id = existingAgentId;
mockReq.body = {
langfuse: {
enabled: null,
publicKey: '',
secretKey: '',
baseUrl: '',
},
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent.langfuse).toBeUndefined();
const agentInDb = await Agent.findOne({ id: existingAgentId }).lean();
expect(agentInDb.langfuse).toBeUndefined();
});
test('uploadAgentAvatarHandler should redact Langfuse secret in response', async () => {
await Agent.updateOne(
{ id: existingAgentId },

View file

@ -29,6 +29,10 @@ export type TAgentCapabilities = {
[AgentCapabilities.hide_sequential_outputs]?: boolean;
};
type AgentLangfuseFormConfig = Omit<LangfuseConfig, 'enabled'> & {
enabled?: boolean | null;
};
export type AgentForm = {
agent?: TAgentOption;
id: string;
@ -47,7 +51,7 @@ export type AgentForm = {
agent_ids?: string[];
edges?: GraphEdge[];
subagents?: AgentSubagentsConfig;
langfuse?: LangfuseConfig;
langfuse?: AgentLangfuseFormConfig;
[AgentCapabilities.artifacts]?: ArtifactModes | string;
recursion_limit?: number;
support_contact?: SupportContact;

View file

@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from 'react';
import { Activity, Eye, EyeOff, X } from 'lucide-react';
import { Activity, Eye, EyeOff, RotateCcw, X } from 'lucide-react';
import { Input, Label, Switch } from '@librechat/client';
import { LANGFUSE_SECRET_CLEAR_VALUE } from 'librechat-data-provider';
import type { ControllerRenderProps } from 'react-hook-form';
@ -11,7 +11,7 @@ interface AgentLangfuseProps {
}
const fieldDefaults = {
enabled: undefined as boolean | undefined,
enabled: undefined as boolean | null | undefined,
publicKey: '',
secretKey: '',
baseUrl: '',
@ -22,15 +22,19 @@ export default function AgentLangfuse({ field }: AgentLangfuseProps) {
const [showSecret, setShowSecret] = useState(false);
const value = useMemo(() => ({ ...fieldDefaults, ...(field.value ?? {}) }), [field.value]);
const enabled = value.enabled === true;
const hasEnabledOverride = typeof value.enabled === 'boolean';
let statusKey = 'com_ui_agent_langfuse_inherited';
if (typeof value.enabled === 'boolean') {
if (hasEnabledOverride) {
statusKey = enabled ? 'com_ui_agent_langfuse_enabled' : 'com_ui_agent_langfuse_disabled';
}
const secretMarkedForClear = value.secretKey === LANGFUSE_SECRET_CLEAR_VALUE;
const secretInputValue = secretMarkedForClear ? '' : value.secretKey;
const hasCredentialValue =
value.publicKey !== '' || secretInputValue !== '' || value.baseUrl !== '';
const showConfigFields = enabled || hasCredentialValue || secretMarkedForClear;
const updateField = useCallback(
(key: keyof typeof fieldDefaults, next: string | boolean | undefined) => {
(key: keyof typeof fieldDefaults, next: string | boolean | null | undefined) => {
field.onChange({
...value,
[key]: next,
@ -43,6 +47,10 @@ export default function AgentLangfuse({ field }: AgentLangfuseProps) {
updateField('secretKey', secretMarkedForClear ? '' : LANGFUSE_SECRET_CLEAR_VALUE);
}, [secretMarkedForClear, updateField]);
const inheritEnabled = useCallback(() => {
updateField('enabled', null);
}, [updateField]);
const enableId = 'agent-langfuse-enable-toggle';
return (
@ -65,6 +73,16 @@ export default function AgentLangfuse({ field }: AgentLangfuseProps) {
<span className="rounded-full border border-border-light px-2 py-0.5 text-xs font-medium text-text-secondary">
{localize(statusKey)}
</span>
{hasEnabledOverride && (
<button
type="button"
onClick={inheritEnabled}
className="inline-flex items-center gap-1 text-xs font-medium text-text-secondary transition-colors hover:text-text-primary"
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
{localize('com_ui_agent_langfuse_use_inherited')}
</button>
)}
<Switch
id={enableId}
checked={enabled}
@ -74,7 +92,7 @@ export default function AgentLangfuse({ field }: AgentLangfuseProps) {
</div>
</div>
{enabled && (
{showConfigFields && (
<div className="mt-3 space-y-3">
<div className="space-y-1.5">
<Label htmlFor="agent-langfuse-public-key" className="text-xs font-medium">

View file

@ -0,0 +1,45 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import type { ControllerRenderProps } from 'react-hook-form';
import type { AgentForm } from '~/common';
import AgentLangfuse from '../AgentLangfuse';
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
function createField(
value: AgentForm['langfuse'],
onChange = jest.fn(),
): ControllerRenderProps<AgentForm, 'langfuse'> {
return {
name: 'langfuse',
value,
onChange,
onBlur: jest.fn(),
ref: jest.fn(),
};
}
describe('AgentLangfuse', () => {
it('can clear an explicit enabled override back to inherited', () => {
const onChange = jest.fn();
render(<AgentLangfuse field={createField({ enabled: false }, onChange)} />);
fireEvent.click(screen.getByText('com_ui_agent_langfuse_use_inherited'));
expect(onChange).toHaveBeenCalledWith({
enabled: null,
publicKey: '',
secretKey: '',
baseUrl: '',
});
});
it('keeps credential fields visible while enabled is inherited', () => {
render(<AgentLangfuse field={createField({ enabled: null, publicKey: 'pk-agent' })} />);
expect(screen.getByDisplayValue('pk-agent')).toBeInTheDocument();
expect(screen.getByText('com_ui_agent_langfuse_inherited')).toBeInTheDocument();
});
});

View file

@ -65,11 +65,13 @@ function composeLangfusePayload(
};
if (typeof langfuse.enabled === 'boolean') {
normalized.enabled = langfuse.enabled;
} else if (langfuse.enabled === null) {
normalized.enabled = null;
}
const hasCredentialValue =
normalized.publicKey !== '' || normalized.secretKey !== '' || normalized.baseUrl !== '';
const hasExplicitEnabled = typeof normalized.enabled === 'boolean';
const hasExplicitEnabled = typeof normalized.enabled === 'boolean' || normalized.enabled === null;
if (!hasExplicitEnabled && !hasCredentialValue) {
return undefined;

View file

@ -143,6 +143,31 @@ describe('composeAgentUpdatePayload', () => {
});
});
it('sends null Langfuse enabled to clear an explicit override', () => {
const form = createForm();
form.agent = {
id: 'agent_123',
langfuse: {
enabled: false,
},
} as Agent;
form.langfuse = {
enabled: null,
publicKey: '',
secretKey: '',
baseUrl: '',
};
const { payload } = composeAgentUpdatePayload(form, 'agent_123');
expect(payload.langfuse).toEqual({
enabled: null,
publicKey: '',
secretKey: '',
baseUrl: '',
});
});
it('sends the Langfuse secret clear sentinel when requested', () => {
const form = createForm();
form.agent = {

View file

@ -717,6 +717,7 @@
"com_ui_agent_langfuse_public_key_placeholder": "Enter your Public Key",
"com_ui_agent_langfuse_secret_key": "Secret Key",
"com_ui_agent_langfuse_secret_key_placeholder": "Leave blank to keep current key",
"com_ui_agent_langfuse_use_inherited": "Use inherited",
"com_ui_agent_subagents": "Subagents",
"com_ui_agent_subagents_add": "Add subagent",
"com_ui_agent_subagents_agents": "Additional subagents",

View file

@ -72,6 +72,37 @@ describe('normalizeLangfuseConfig', () => {
expect(result).toBeUndefined();
});
it('clears an explicit enabled override when null is sent', async () => {
const result = await normalizeLangfuseConfig(
{
enabled: null,
},
{
enabled: false,
},
);
expect(result).toBeUndefined();
});
it('preserves credentials while clearing an explicit enabled override', async () => {
const result = await normalizeLangfuseConfig(
{
enabled: null,
},
{
enabled: false,
publicKey: 'pk-agent',
secretKey: '0123456789abcdef0123456789abcdef:736b2d6167656e74',
},
);
expect(result).toEqual({
publicKey: 'pk-agent',
secretKey: '0123456789abcdef0123456789abcdef:736b2d6167656e74',
});
});
it('clears explicit blank non-secret fields while preserving absent fields', async () => {
const result = await normalizeLangfuseConfig(
{

View file

@ -61,9 +61,10 @@ export async function normalizeLangfuseConfig(
const existingLangfuse = isRecord(existingConfig) ? existingConfig : {};
const normalized: LangfuseConfig = {};
const shouldClearEnabled = hasOwn(incoming, 'enabled') && incoming.enabled === null;
if (hasOwn(incoming, 'enabled') && typeof incoming.enabled === 'boolean') {
normalized.enabled = incoming.enabled;
} else if (typeof existingLangfuse.enabled === 'boolean') {
} else if (!shouldClearEnabled && typeof existingLangfuse.enabled === 'boolean') {
normalized.enabled = existingLangfuse.enabled;
}

View file

@ -95,7 +95,11 @@ export const agentSubagentsSchema = z
})
.optional();
export const agentLangfuseSchema = langfuseConfigSchema.optional();
export const agentLangfuseSchema = langfuseConfigSchema
.extend({
enabled: z.union([z.boolean(), z.null()]).optional(),
})
.optional();
/** Base agent schema with all common fields */
export const agentBaseSchema = z.object({