From 09cbd54f4881e4c1a8d690820f66df0fb9771516 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 10 Aug 2026 15:05:10 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=86=20fix:=20Rebase=20Activity=20Phase?= =?UTF-8?q?=20Bounds=20over=20Sparse=20Content=20(#14729)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- api/server/controllers/agents/client.js | 13 ++++++++++- api/server/controllers/agents/client.test.js | 24 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 9e8b0604d6..0673f6a31a 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -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} 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 ( diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 872d05369a..5bfa1f6093 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -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', () => {