From 2d7597bd3030336085ccdc89d212c40604ede7fb Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 13 Aug 2026 08:14:05 -0400 Subject: [PATCH] fix(api): anchor retained phase partitions --- .../utils/__tests__/activityLabels.spec.ts | 10 ++++- client/src/utils/activityLabels.ts | 7 +++ .../src/agents/activityPhases/runtime.spec.ts | 44 +++++++++++++++++++ .../api/src/agents/activityPhases/runtime.ts | 6 ++- 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/client/src/utils/__tests__/activityLabels.spec.ts b/client/src/utils/__tests__/activityLabels.spec.ts index 9d816090ca..fa9fadbc3f 100644 --- a/client/src/utils/__tests__/activityLabels.spec.ts +++ b/client/src/utils/__tests__/activityLabels.spec.ts @@ -253,7 +253,8 @@ describe('groupActivityPhases', () => { type: ContentTypes.TEXT, text: 'Substantial result', } as TMessageContentParts; - const content = [tool, tool, boundary, tool, first as never, tool, second as never]; + const final = { type: ContentTypes.TEXT, text: 'Final result' } as TMessageContentParts; + const content = [tool, tool, boundary, tool, first as never, tool, final, second as never]; const segments = groupActivityPhases(content); @@ -272,9 +273,14 @@ describe('groupActivityPhases', () => { expect.objectContaining({ type: 'phase', startIndex: 3, - labelIndex: 6, + labelIndex: 7, contentIndices: [3, 5], }), + expect.objectContaining({ + type: 'content', + startIndex: 6, + contentIndices: [6], + }), ]); }); diff --git a/client/src/utils/activityLabels.ts b/client/src/utils/activityLabels.ts index 871ff0f6e0..736e33b779 100644 --- a/client/src/utils/activityLabels.ts +++ b/client/src/utils/activityLabels.ts @@ -192,6 +192,7 @@ export function groupActivityPhases( * segment so a later phase marker can still claim its declared span. */ if (start < cursor) { const recoveredIndices: number[] = []; + const deferredTrailingIndices: number[] = []; for (let segmentIndex = segments.length - 1; segmentIndex >= 0; segmentIndex -= 1) { const segment = segments[segmentIndex]; if (segment.type !== 'content') { @@ -207,6 +208,8 @@ export function groupActivityPhases( const childIndex = segment.contentIndices[childPosition]; if (childIndex >= start && childIndex < end) { recoveredIndices.push(childIndex); + } else if (childIndex >= end) { + deferredTrailingIndices.push(childIndex); } else { retainedContent.push(segment.content[childPosition]); retainedIndices.push(childIndex); @@ -224,6 +227,10 @@ export function groupActivityPhases( for (const recoveredIndex of recoveredIndices) { append(phase, recoveredIndex); } + deferredTrailingIndices.sort((a, b) => a - b); + for (const trailingIndex of deferredTrailingIndices) { + append(trailing, trailingIndex); + } } while (definedPosition < definedIndices.length && definedIndices[definedPosition] < index) { const childIndex = definedIndices[definedPosition]; diff --git a/packages/api/src/agents/activityPhases/runtime.spec.ts b/packages/api/src/agents/activityPhases/runtime.spec.ts index 6d0e7b2c44..ef8b0b33b9 100644 --- a/packages/api/src/agents/activityPhases/runtime.spec.ts +++ b/packages/api/src/agents/activityPhases/runtime.spec.ts @@ -1210,6 +1210,50 @@ describe('createActivityPhaseWiring', () => { }); }); + it('anchors a phase represented entirely by retained overflow bookkeeping', async () => { + const parts: LooseContentPart[] = [ + { type: ContentTypes.THINK, think: 'Earlier retained work' }, + { type: ContentTypes.TEXT, text: substantialText('Persisted intermediate result.') }, + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'overflow-a' } }, + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'overflow-b' } }, + ]; + const wiring = createActivityPhaseWiring({ + initialSnapshot: { + version: 1, + generated: 0, + activityCount: 15, + failedActivityCount: 0, + partialActivityCount: 0, + agentIds: [], + activities: Array.from({ length: 13 }, () => ({ + startIndex: 0, + status: 'success' as const, + })), + overflowActivityStartIndex: 30, + overflowToolCallIds: ['overflow-a', 'overflow-b'], + overflowBoundaryToolCallIds: ['overflow-a', 'overflow-b'], + assistantContext: [], + pendingReasoning: [], + }, + getContentParts: () => parts, + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase: jest.fn(async () => ({ label: 'Completed the overflow phase' })), + }); + + wiring.complete(); + await flushDetached(); + + expect(parts[5]).toMatchObject({ + activity_start_index: 2, + activity_end_index: 5, + activity_count: 2, + }); + expect(Number.isFinite(parts[5]?.activity_start_index)).toBe(true); + expect(Number.isFinite(parts[5]?.activity_end_index)).toBe(true); + }); + it('rebases a resumed overflow anchor after content compaction', async () => { const parts: LooseContentPart[] = Array.from({ length: 13 }, (_, index) => ({ type: ContentTypes.TOOL_CALL, diff --git a/packages/api/src/agents/activityPhases/runtime.ts b/packages/api/src/agents/activityPhases/runtime.ts index 0c2f58e0e3..f208bf8305 100644 --- a/packages/api/src/agents/activityPhases/runtime.ts +++ b/packages/api/src/agents/activityPhases/runtime.ts @@ -815,6 +815,7 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity overflowCount > 0 && (requestedEndIndex == null || (overflowActivityStartIndex != null && overflowActivityStartIndex < requestedEndIndex)); + const closingOverflowStartIndex = overflowCloses ? overflowActivityStartIndex : undefined; const failedCount = snapshot.filter((activity) => activity.status === 'error').length + (overflowCloses ? overflowFailedCount : 0); @@ -846,7 +847,10 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity generated += 1; const phaseIndex = generated - 1; - let startIndex = Math.min(...snapshot.map((activity) => activity.startIndex)); + let startIndex = + snapshot.length > 0 + ? Math.min(...snapshot.map((activity) => activity.startIndex)) + : (closingOverflowStartIndex ?? 0); /** Pull leading commentary/reasoning into the parent card. A prior phase * marker or steer is the only hard UI boundary; plain text can be * intermediate context on providers that do not expose phase metadata. */