🪆 fix: Rebase Activity Phase Bounds over Sparse Content (#14729)

The aggregator writes content parts at provider-source indexes, which can
skip slots and leave holes in contentParts. Array.prototype.map preserves
those holes and the Map constructor iterates them as undefined, so
rebaseActivityPhaseBounds threw "Iterator value undefined is not an entry
object" at the end of every run with sparse content — deterministic with
parent phase summaries enabled, on both the completion and resume paths.

Build the identity map with an index loop that skips nullish slots. Holes
must stay out of the map: one undefined key would falsely match every hole
in previousParts as a retained part and corrupt the rebased bound.
This commit is contained in:
Danny Avila 2026-08-10 15:05:10 -04:00 committed by GitHub
parent a3cec67e08
commit 09cbd54f48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 36 additions and 1 deletions

View file

@ -2276,13 +2276,24 @@ class AgentClient extends BaseClient {
* so prepended skill cards cannot enter a phase and a filtered-away leading
* reasoning part advances the bound to the first retained child.
*
* Both arrays may be sparse: the aggregator writes parts at provider-source
* indexes, which can skip slots. Holes must not enter the identity map a
* hole reads as `undefined`, and one `undefined` key would falsely match
* every hole in `previousParts` as a retained part.
*
* @param {Array<import('librechat-data-provider').ContentPart | null | undefined>} previousParts
*/
rebaseActivityPhaseBounds(previousParts) {
if (!Array.isArray(previousParts) || !Array.isArray(this.contentParts)) {
return;
}
const retainedIndexes = new Map(this.contentParts.map((part, index) => [part, index]));
const retainedIndexes = new Map();
for (let index = 0; index < this.contentParts.length; index += 1) {
const part = this.contentParts[index];
if (part != null) {
retainedIndexes.set(part, index);
}
}
for (let markerIndex = 0; markerIndex < this.contentParts.length; markerIndex += 1) {
const marker = this.contentParts[markerIndex];
if (

View file

@ -186,6 +186,30 @@ describe('AgentClient - applyHideSequentialOutputsFilter', () => {
expect(ctx.contentParts).toEqual([skillCard, activityTool, phase, final]);
expect(phase.activity_start_index).toBe(1);
});
it('rebases phase bounds over sparse content without treating holes as retained parts', () => {
const reasoning = { type: ContentTypes.THINK, think: 'planning' };
const toolCall = toolCallPart('tc-sparse');
const phase = {
type: ContentTypes.ACTIVITY_LABEL,
activity_label: 'Searched for tools',
activity_label_type: 'phase',
activity_start_index: 1,
};
const final = textPart('answer');
const contentParts = [];
contentParts[0] = reasoning;
contentParts[2] = toolCall;
contentParts[3] = phase;
contentParts[4] = final;
const previousParts = [...contentParts];
const ctx = { options: { agent: {} }, contentParts };
expect(() =>
AgentClient.prototype.rebaseActivityPhaseBounds.call(ctx, previousParts),
).not.toThrow();
expect(phase.activity_start_index).toBe(2);
});
});
describe('AgentClient - startup telemetry', () => {