Compare commits

...
Author SHA1 Message Date
Antje Worring cbb65a4f30 fix(agents): stop post-reply error banner from nulled config.configurable
run.processStream (@librechat/agents >= 3.1.52) treats the passed config as
owned and, in its post-stream cleanup, sets config.configurable = undefined to
break the AsyncLocalStorage -> LangGraph reference chain that keeps heavy graph
state (base64 images/PDFs) alive (agents dist/esm/run.mjs:239). Reading
config.configurable.hide_sequential_outputs AFTER runAgents() therefore threw
"Cannot read properties of undefined (reading 'hide_sequential_outputs')",
caught by chatCompletion and pushed as an ERROR content part -- the red
post-reply banner. The same throw aborted the post-stream finalize, which is
why auto-titling also stopped.

Hoist the read to before runAgents() (the value is a static agent property only
needed afterward by the deprecated Agent Chain output filter), so it no longer
depends on post-run config state. Matches upstream LibreChat's fix
(PR #11942, a0f9782e6).

Adds a regression test that reproduces the mutation (processStream nulls
config.configurable) and asserts no ERROR content part is produced, plus
coverage for the hide_sequential_outputs true/false filter behavior.
2026-06-21 10:18:30 -07:00
2 changed files with 132 additions and 1 deletions
+12 -1
View File
@@ -891,9 +891,20 @@ class AgentClient extends BaseClient {
config.signal = null;
};
/**
* Capture before running the graph. `run.processStream` (via
* `@librechat/agents`) treats the passed `config` as owned and, during its
* post-stream cleanup, sets `config.configurable = undefined` to break the
* reference chain that keeps heavy graph state (base64 images/PDFs) alive.
* Reading `config.configurable.hide_sequential_outputs` AFTER `runAgents`
* therefore throws `Cannot read properties of undefined`, which surfaces as
* the post-reply error banner. Hoisting the read keeps the deprecated Agent
* Chain filter working without depending on post-run config state.
*/
const hideSequentialOutputs = config.configurable.hide_sequential_outputs;
await runAgents(initialMessages);
/** @deprecated Agent Chain */
if (config.configurable.hide_sequential_outputs) {
if (hideSequentialOutputs) {
this.contentParts = this.contentParts.filter((part, index) => {
// Include parts that are either:
// 1. At or after the finalContentStart index
@@ -15,6 +15,7 @@ jest.mock('@hanzochat/api', () => ({
checkAccess: jest.fn(),
initializeAgent: jest.fn(),
createMemoryProcessor: jest.fn(),
createRun: jest.fn(),
}));
jest.mock('~/models/Agent', () => ({
@@ -2257,3 +2258,122 @@ describe('AgentClient - titleConvo', () => {
});
});
});
describe('AgentClient - chatCompletion post-reply banner regression', () => {
/**
* Regression for the post-reply error banner:
* "Cannot read properties of undefined (reading 'hide_sequential_outputs')".
*
* `run.processStream` (from `@librechat/agents` >= 3.1.52) treats the passed
* `config` as owned and, in its post-stream cleanup, sets
* `config.configurable = undefined` to break the AsyncLocalStorage reference
* chain that keeps heavy graph state (base64 images/PDFs) alive. The
* controller must therefore NOT read `config.configurable.hide_sequential_outputs`
* AFTER the stream completes, or it throws and pushes an ERROR content part
* (the red banner) which also aborts the post-stream finalize (auto-titling).
* This test reproduces that mutation and asserts no ERROR part is produced.
*/
const { createRun } = require('@hanzochat/api');
const { ContentTypes } = require('librechat-data-provider');
let processStreamMock;
const makeClient = (agentOverrides = {}) => {
const agent = {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
model_parameters: { model: 'gpt-4' },
...agentOverrides,
};
const req = {
user: { id: 'user-123' },
body: {},
config: { endpoints: {} },
};
const client = new AgentClient({
req,
res: {},
agent,
endpoint: EModelEndpoint.openAI,
contentParts: [],
collectedUsage: [],
artifactPromises: [],
endpointTokenConfig: {},
});
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';
client.parentMessageId = '00000000-0000-0000-0000-000000000000';
client.user = 'user-123';
// Avoid DB / memory side effects unrelated to this regression.
client.recordCollectedUsage = jest.fn().mockResolvedValue();
client.runMemory = jest.fn().mockResolvedValue(undefined);
client.awaitMemoryWithTimeout = jest.fn().mockResolvedValue(undefined);
return client;
};
beforeEach(() => {
jest.clearAllMocks();
// Faithfully mimic `@librechat/agents` Run.processStream cleanup: it nulls
// the caller's `config.configurable` after the stream finishes.
processStreamMock = jest.fn(async (_inputs, config) => {
config.configurable = undefined;
return [];
});
createRun.mockResolvedValue({
Graph: {},
processStream: processStreamMock,
});
});
const errorParts = (client) =>
(client.getContentParts() || []).filter((p) => p && p.type === ContentTypes.ERROR);
it('does not push an error content part when processStream nulls config.configurable', async () => {
const client = makeClient();
await expect(
client.sendCompletion('hello', { abortController: new AbortController() }),
).resolves.toBeDefined();
expect(processStreamMock).toHaveBeenCalledTimes(1);
// The config passed to processStream had configurable nulled (reproduction sanity check).
const passedConfig = processStreamMock.mock.calls[0][1];
expect(passedConfig.configurable).toBeUndefined();
// The fix: no banner-producing ERROR part despite the nulled configurable.
expect(errorParts(client)).toHaveLength(0);
});
it('still applies the deprecated hide_sequential_outputs filter using the pre-stream value', async () => {
const client = makeClient({ hide_sequential_outputs: true });
// Seed multiple content parts; the filter keeps only the last part plus
// tool_call parts. With the value captured before the stream, the filter
// must run even though config.configurable is undefined afterwards.
client.contentParts = [
{ type: 'text', text: 'intermediate-1' },
{ type: 'text', text: 'intermediate-2' },
{ type: 'text', text: 'final' },
];
await client.sendCompletion('hello', { abortController: new AbortController() });
expect(errorParts(client)).toHaveLength(0);
// hide_sequential_outputs collapses to the final part only (no tool calls present).
expect(client.contentParts).toEqual([{ type: 'text', text: 'final' }]);
});
it('leaves content parts intact when hide_sequential_outputs is falsy', async () => {
const client = makeClient({ hide_sequential_outputs: false });
client.contentParts = [
{ type: 'text', text: 'a' },
{ type: 'text', text: 'b' },
];
await client.sendCompletion('hello', { abortController: new AbortController() });
expect(errorParts(client)).toHaveLength(0);
expect(client.contentParts).toEqual([
{ type: 'text', text: 'a' },
{ type: 'text', text: 'b' },
]);
});
});