From 69c117bb1c4e91b8c2338b2da18c1e1c9985a295 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 13 Aug 2026 08:04:42 -0400 Subject: [PATCH] fix(api): preserve activity phase boundaries --- .../utils/__tests__/activityLabels.spec.ts | 44 +++ client/src/utils/activityLabels.ts | 43 ++- .../src/agents/activityPhases/runtime.spec.ts | 172 ++++++++- .../api/src/agents/activityPhases/runtime.ts | 332 +++++++++--------- 4 files changed, 405 insertions(+), 186 deletions(-) diff --git a/client/src/utils/__tests__/activityLabels.spec.ts b/client/src/utils/__tests__/activityLabels.spec.ts index 50fb24b20d..9d816090ca 100644 --- a/client/src/utils/__tests__/activityLabels.spec.ts +++ b/client/src/utils/__tests__/activityLabels.spec.ts @@ -234,6 +234,50 @@ describe('groupActivityPhases', () => { expect(lastVisibleContentIdx([tool, tool, final, phase as never])).toBe(2); }); + it('groups multiple logical spans when an earlier phase marker arrives late', () => { + const first = labelPart({ activity_label: 'Completed the first phase', pending: false }); + Object.assign(first, { + activity_label_type: 'phase', + activity_start_index: 0, + activity_end_index: 2, + activity_count: 2, + }); + const second = labelPart({ activity_label: 'Completed the second phase', pending: false }); + Object.assign(second, { + activity_label_type: 'phase', + activity_start_index: 3, + activity_end_index: 6, + activity_count: 2, + }); + const boundary = { + type: ContentTypes.TEXT, + text: 'Substantial result', + } as TMessageContentParts; + const content = [tool, tool, boundary, tool, first as never, tool, second as never]; + + const segments = groupActivityPhases(content); + + expect(segments).toEqual([ + expect.objectContaining({ + type: 'phase', + startIndex: 0, + labelIndex: 4, + contentIndices: [0, 1], + }), + expect.objectContaining({ + type: 'content', + startIndex: 2, + contentIndices: [2], + }), + expect.objectContaining({ + type: 'phase', + startIndex: 3, + labelIndex: 6, + contentIndices: [3, 5], + }), + ]); + }); + it('keeps a late child label in the phase while leaving final text outside', () => { const child = labelPart({ activity_label: 'Recorded the delayed child result', diff --git a/client/src/utils/activityLabels.ts b/client/src/utils/activityLabels.ts index fb99bab66b..871ff0f6e0 100644 --- a/client/src/utils/activityLabels.ts +++ b/client/src/utils/activityLabels.ts @@ -182,14 +182,49 @@ export function groupActivityPhases( * max permits many parent phases in one long response. */ for (const { part, index } of completed) { if (!part) continue; - const start = Math.max( - cursor, - Math.min(index, Math.max(0, part.activity_start_index ?? index)), - ); + const start = Math.min(index, Math.max(0, part.activity_start_index ?? index)); const end = Math.max(start, Math.min(index, Math.max(0, part.activity_end_index ?? index))); const adjacent = collect(); const phase = collect(); const trailing = collect(); + /** A boundary may resolve after a higher-index parallel activity has + * already rendered. Recover that activity from an earlier adjacent + * segment so a later phase marker can still claim its declared span. */ + if (start < cursor) { + const recoveredIndices: number[] = []; + for (let segmentIndex = segments.length - 1; segmentIndex >= 0; segmentIndex -= 1) { + const segment = segments[segmentIndex]; + if (segment.type !== 'content') { + continue; + } + const retainedContent: Array = []; + const retainedIndices: number[] = []; + for ( + let childPosition = 0; + childPosition < segment.contentIndices.length; + childPosition += 1 + ) { + const childIndex = segment.contentIndices[childPosition]; + if (childIndex >= start && childIndex < end) { + recoveredIndices.push(childIndex); + } else { + retainedContent.push(segment.content[childPosition]); + retainedIndices.push(childIndex); + } + } + if (retainedIndices.length === 0) { + segments.splice(segmentIndex, 1); + } else { + segment.content = retainedContent; + segment.contentIndices = retainedIndices; + segment.startIndex = retainedIndices[0]; + } + } + recoveredIndices.sort((a, b) => a - b); + for (const recoveredIndex of recoveredIndices) { + append(phase, recoveredIndex); + } + } while (definedPosition < definedIndices.length && definedIndices[definedPosition] < index) { const childIndex = definedIndices[definedPosition]; definedPosition += 1; diff --git a/packages/api/src/agents/activityPhases/runtime.spec.ts b/packages/api/src/agents/activityPhases/runtime.spec.ts index e809715118..6d0e7b2c44 100644 --- a/packages/api/src/agents/activityPhases/runtime.spec.ts +++ b/packages/api/src/agents/activityPhases/runtime.spec.ts @@ -224,6 +224,148 @@ describe('createActivityPhaseWiring', () => { }); }); + it('retains a later-indexed activity when an interleaved text step crosses a boundary', async () => { + const parts: LooseContentPart[] = [ + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-1' } }, + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-2' } }, + ]; + const generatedPayloads: GenerateActivityPhasePayload[] = []; + const wiring = createActivityPhaseWiring({ + getContentParts: () => parts, + getStepIndex: (stepId) => (stepId === 'interleaved-text' ? 2 : undefined), + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase: jest.fn(async (payload: GenerateActivityPhasePayload) => { + generatedPayloads.push(payload); + return { label: 'Completed one activity phase' }; + }), + }); + await wiring.hook(batch('tool-1'), new AbortController().signal); + await wiring.hook(batch('tool-2'), new AbortController().signal); + const handlers = wiring.handlers({ + [GraphEvents.ON_RUN_STEP]: { handle: jest.fn() }, + [GraphEvents.ON_MESSAGE_DELTA]: { + handle: (_event, data) => { + const delta = data as { delta?: { content?: { text?: string } } }; + parts[2] = { + type: ContentTypes.TEXT, + text: `${parts[2]?.text ?? ''}${delta.delta?.content?.text ?? ''}`, + }; + }, + }, + }); + handlers?.[GraphEvents.ON_RUN_STEP]?.handle( + GraphEvents.ON_RUN_STEP, + { + id: 'interleaved-text', + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { message_id: 'm', content_type: 'text' }, + }, + }, + undefined, + undefined, + ); + handlers?.[GraphEvents.ON_MESSAGE_DELTA]?.handle( + GraphEvents.ON_MESSAGE_DELTA, + { id: 'interleaved-text', delta: { content: { type: ContentTypes.TEXT, text: 'prefix' } } }, + undefined, + undefined, + ); + parts[3] = { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-3' } }; + await wiring.hook(batch('tool-3'), new AbortController().signal); + handlers?.[GraphEvents.ON_MESSAGE_DELTA]?.handle( + GraphEvents.ON_MESSAGE_DELTA, + { + id: 'interleaved-text', + delta: { content: { type: ContentTypes.TEXT, text: 'x'.repeat(SUBSTANTIAL_TEXT_CHARS) } }, + }, + undefined, + undefined, + ); + parts[5] = { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-4' } }; + await wiring.hook(batch('tool-4'), new AbortController().signal); + + wiring.complete(); + await flushDetached(); + + expect(generatedPayloads).toHaveLength(2); + expect(generatedPayloads.map(({ totalActivityCount }) => totalActivityCount)).toEqual([2, 2]); + expect(parts[4]).toMatchObject({ activity_end_index: 2, activity_count: 2 }); + expect(parts[6]).toMatchObject({ activity_start_index: 3, activity_count: 2 }); + }); + + it('splits resumed activities around every persisted substantial text boundary', async () => { + const parts: LooseContentPart[] = [ + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-1' } }, + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-2' } }, + { type: ContentTypes.TEXT, text: substantialText('First persisted result.') }, + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-3' } }, + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-4' } }, + { type: ContentTypes.TEXT, text: substantialText('Second persisted result.') }, + ]; + const generatePhase = jest.fn(async () => ({ label: 'Completed one persisted phase' })); + const wiring = createActivityPhaseWiring({ + initialSnapshot: { + version: 1, + generated: 0, + activityCount: 4, + failedActivityCount: 0, + partialActivityCount: 0, + agentIds: [], + activities: [ + { startIndex: 0, status: 'success', toolCallIds: ['tool-1'] }, + { startIndex: 1, status: 'success', toolCallIds: ['tool-2'] }, + { startIndex: 3, status: 'success', toolCallIds: ['tool-3'] }, + { startIndex: 4, status: 'success', toolCallIds: ['tool-4'] }, + ], + assistantContext: [], + pendingReasoning: [], + }, + getContentParts: () => parts, + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase, + }); + + wiring.complete(); + await flushDetached(); + + expect(generatePhase).toHaveBeenCalledTimes(2); + expect(parts[6]).toMatchObject({ activity_start_index: 0, activity_end_index: 2 }); + expect(parts[7]).toMatchObject({ activity_start_index: 3, activity_end_index: 5 }); + }); + + it('keeps a substantial boundary hard when too little preceding work earns a phase', async () => { + const parts: LooseContentPart[] = [ + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-1' } }, + { type: ContentTypes.TEXT, text: substantialText('Standalone result.') }, + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-2' } }, + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-3' } }, + ]; + const wiring = createActivityPhaseWiring({ + getContentParts: () => parts, + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase: jest.fn(async () => ({ label: 'Completed the later phase' })), + }); + await wiring.hook(batch('tool-1'), new AbortController().signal); + await wiring.hook(batch('tool-2'), new AbortController().signal); + await wiring.hook(batch('tool-3'), new AbortController().signal); + + wiring.complete(); + await flushDetached(); + + expect(parts[4]).toMatchObject({ + activity_start_index: 2, + activity_end_index: 4, + activity_count: 2, + }); + }); + it('keeps interleaved parallel text context keyed to its run step', async () => { const parts: LooseContentPart[] = [ { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-1' } }, @@ -934,7 +1076,7 @@ describe('createActivityPhaseWiring', () => { expect(parts[3]).toMatchObject({ activity_end_index: 2, activity_count: 3 }); }); - it('resolves index-less pending reasoning before preserving the final-text boundary', async () => { + it('keeps index-less reasoning after a preceding substantial-text boundary', async () => { const parts: LooseContentPart[] = [ { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-1' } }, { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-2' } }, @@ -981,10 +1123,10 @@ describe('createActivityPhaseWiring', () => { wiring.complete(); await flushDetached(); - expect(parts[4]).toMatchObject({ activity_end_index: 4, activity_count: 3 }); + expect(parts[4]).toMatchObject({ activity_end_index: 2, activity_count: 2 }); }); - it('keeps a partially materialized retained batch after the candidate final text', async () => { + it('does not force a partially materialized batch across a substantial boundary', async () => { const parts: LooseContentPart[] = [ { type: ContentTypes.TOOL_CALL, tool_call: { id: 'batch-a' } }, { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-2' } }, @@ -1022,7 +1164,7 @@ describe('createActivityPhaseWiring', () => { wiring.complete(); await flushDetached(); - expect(parts[3]).toMatchObject({ activity_end_index: 3, activity_count: 2 }); + expect(parts[3]).toBeUndefined(); }); it('bounds persisted evidence while preserving the full activity count', async () => { @@ -1172,10 +1314,10 @@ describe('createActivityPhaseWiring', () => { wiring.complete(); await flushDetached(); - expect(parts[1]).toMatchObject({ activity_end_index: 0, activity_count: 14 }); + expect(parts[1]).toMatchObject({ activity_end_index: 0, activity_count: 13 }); }); - it('rejects a text boundary when a duplicate reasoning anchor follows it', async () => { + it('splits duplicate reasoning anchors by their retained materialized positions', async () => { const duplicate = 'The same reasoning prefix identifies both retained activities.'; const parts: LooseContentPart[] = [ { type: ContentTypes.THINK, think: duplicate }, @@ -1222,10 +1364,10 @@ describe('createActivityPhaseWiring', () => { wiring.complete(); await flushDetached(); - expect(parts[3]).toMatchObject({ activity_end_index: 3, activity_count: 2 }); + expect(parts[3]).toBeUndefined(); }); - it('rejects a text boundary when a duplicate overflow reasoning anchor follows it', async () => { + it('keeps duplicate overflow reasoning after a substantial boundary', async () => { const duplicate = 'The overflow reasoning prefix is shared by both positions.'; const parts: LooseContentPart[] = [ { type: ContentTypes.THINK, think: duplicate }, @@ -1277,7 +1419,7 @@ describe('createActivityPhaseWiring', () => { wiring.complete(); await flushDetached(); - expect(parts[3]).toMatchObject({ activity_end_index: 3, activity_count: 14 }); + expect(parts[3]).toMatchObject({ activity_end_index: 1, activity_count: 13 }); }); it('checks every overflow reasoning anchor when delayed parts materialize out of order', async () => { @@ -1333,7 +1475,7 @@ describe('createActivityPhaseWiring', () => { wiring.complete(); await flushDetached(); - expect(parts[3]).toMatchObject({ activity_end_index: 3, activity_count: 15 }); + expect(parts[3]).toMatchObject({ activity_end_index: 1, activity_count: 13 }); }); it('retains an earlier overflow ID when delayed tools materialize in reverse order', async () => { @@ -1388,7 +1530,7 @@ describe('createActivityPhaseWiring', () => { wiring.complete(); await flushDetached(); - expect(parts[3]).toMatchObject({ activity_end_index: 3, activity_count: 15 }); + expect(parts[3]).toMatchObject({ activity_end_index: 1, activity_count: 13 }); }); it('keeps the overflow fallback while a boundary tool remains unresolved', async () => { @@ -1442,7 +1584,7 @@ describe('createActivityPhaseWiring', () => { wiring.complete(); await flushDetached(); - expect(parts[2]).toMatchObject({ activity_end_index: 2, activity_count: 15 }); + expect(parts[2]).toMatchObject({ activity_end_index: 1, activity_count: 13 }); }); it('extends a sparse phase start using only defined boundary slots', async () => { @@ -1477,7 +1619,7 @@ describe('createActivityPhaseWiring', () => { expect(parts[1_000_001]).toMatchObject({ activity_start_index: 0, activity_count: 2 }); }); - it('keeps post-cap activities grouped after the last root text', async () => { + it('keeps post-cap activities outside the phase before substantial text', async () => { const parts: LooseContentPart[] = []; const generatePhase = jest.fn(async () => ({ label: 'Completed the extended investigation' })); const wiring = createActivityPhaseWiring({ @@ -1520,8 +1662,8 @@ describe('createActivityPhaseWiring', () => { expect(generatePhase).toHaveBeenCalledTimes(1); expect(parts[15]).toMatchObject({ activity_start_index: 0, - activity_end_index: 15, - activity_count: 14, + activity_end_index: 13, + activity_count: 13, }); }); diff --git a/packages/api/src/agents/activityPhases/runtime.ts b/packages/api/src/agents/activityPhases/runtime.ts index 0176e97b01..0c2f58e0e3 100644 --- a/packages/api/src/agents/activityPhases/runtime.ts +++ b/packages/api/src/agents/activityPhases/runtime.ts @@ -149,6 +149,12 @@ function textValue(value: unknown): string { return typeof nested === 'string' ? nested : ''; } +function isSubstantialText(part: LooseContentPart | null | undefined): boolean { + return ( + part?.type === ContentTypes.TEXT && textValue(part.text).trim().length > SUBSTANTIAL_TEXT_CHARS + ); +} + function normalizeLabel(value: string | undefined): string { const firstLine = value?.split(/\r?\n/).find((line) => line.trim().length > 0) ?? ''; const normalized = firstLine.replace(/\s+/g, ' ').trim(); @@ -215,64 +221,33 @@ function findTrackedStart( parts: ReadonlyArray, activity: TrackedActivity, ): number { + const materializedStart = findMaterializedActivityStart(parts, activity); + return materializedStart ?? Math.min(activity.startIndex, Math.max(0, parts.length - 1)); +} + +function findMaterializedActivityStart( + parts: ReadonlyArray, + activity: TrackedActivity, +): number | undefined { const toolStart = findTrackedToolStart(parts, activity); if (toolStart != null) { return toolStart; } const excerpt = activity.thinkingExcerpts?.[0]?.trim(); if (excerpt) { - const reasoningStart = findReasoningExcerptStart(parts, excerpt); - if (reasoningStart != null) { + const reasoningStart = findReasoningStart(parts, excerpt, activity.startIndex); + if ( + parts[reasoningStart]?.type === ContentTypes.THINK && + textValue(parts[reasoningStart]?.think).includes( + excerpt.trim().slice(0, REASONING_ANCHOR_CHARS), + ) + ) { return reasoningStart; } } - return Math.min(activity.startIndex, Math.max(0, parts.length - 1)); -} - -function findReasoningExcerptStart( - parts: ReadonlyArray, - excerpt: string, -): number | undefined { - const needle = excerpt.trim().slice(0, REASONING_ANCHOR_CHARS); - if (!needle) { - return undefined; - } - for (const index of definedPartIndices(parts)) { - if ( - parts[index]?.type === ContentTypes.THINK && - textValue(parts[index]?.think).includes(needle) - ) { - return index; - } - } return undefined; } -function hasReasoningExcerptAtOrAfter( - parts: ReadonlyArray, - excerpt: string, - minimumIndex: number, -): boolean { - const needle = excerpt.trim().slice(0, REASONING_ANCHOR_CHARS); - if (!needle) { - return false; - } - const indices = definedPartIndices(parts); - for (let position = indices.length - 1; position >= 0; position -= 1) { - const index = indices[position]; - if (index < minimumIndex) { - break; - } - if ( - parts[index]?.type === ContentTypes.THINK && - textValue(parts[index]?.think).includes(needle) - ) { - return true; - } - } - return false; -} - type ReasoningAnchorIndex = Map>; function addReasoningAnchor( @@ -304,31 +279,6 @@ function includesReasoningAnchor(text: string, index: ReasoningAnchorIndex): boo return false; } -function hasIndexedReasoningAtOrAfter( - parts: ReadonlyArray, - index: ReasoningAnchorIndex, - minimumIndex: number, -): boolean { - if (index.size === 0) { - return false; - } - const indices = definedPartIndices(parts); - for (let position = indices.length - 1; position >= 0; position -= 1) { - const partIndex = indices[position]; - if (partIndex < minimumIndex) { - break; - } - const part = parts[partIndex]; - if ( - part?.type === ContentTypes.THINK && - includesReasoningAnchor(textValue(part.think), index) - ) { - return true; - } - } - return false; -} - function findTrackedToolStart( parts: ReadonlyArray, activity: TrackedActivity, @@ -592,6 +542,66 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity contributingAgentIds.clear(); }; + const restoreActivities = ( + retainedActivities: TrackedActivity[], + retainedContext: AssistantContextEntry[], + retainedActivityCount: number, + retainedFailedCount: number, + retainedPartialCount: number, + retainOverflow: boolean, + ) => { + const retainedStepKinds = new Map< + string, + { kind: 'text' | 'think'; phase?: AssistantTextPhase; captureContext?: boolean } + >(); + for (const entry of retainedContext) { + if (entry.stepId == null) { + continue; + } + const kind = stepKinds.get(entry.stepId); + if (kind != null) { + retainedStepKinds.set(entry.stepId, kind); + } + } + const retainedOverflowActivityStartIndex = retainOverflow + ? overflowActivityStartIndex + : undefined; + const retainedOverflowToolCallIds = retainOverflow ? [...overflowToolCallIds] : []; + const retainedOverflowBoundaryToolCallIds = retainOverflow + ? [...overflowBoundaryToolCallIds] + : []; + const retainedOverflowReasoningAnchors = retainOverflow ? [...overflowReasoningAnchors] : []; + clear(); + activities = retainedActivities; + assistantContext = retainedContext; + activityCount = retainedActivityCount; + failedActivityCount = retainedFailedCount; + partialActivityCount = retainedPartialCount; + overflowActivityStartIndex = retainedOverflowActivityStartIndex; + for (const id of retainedOverflowToolCallIds) { + overflowToolCallIds.add(id); + } + for (const id of retainedOverflowBoundaryToolCallIds) { + overflowBoundaryToolCallIds.add(id); + } + for (const anchor of retainedOverflowReasoningAnchors) { + addReasoningAnchor(overflowReasoningAnchors, overflowReasoningAnchorIndex, anchor); + } + for (const activity of retainedActivities) { + if (activity.agentId != null) { + contributingAgentIds.add(activity.agentId); + } + } + for (const [stepId, kind] of retainedStepKinds) { + stepKinds.set(stepId, kind); + } + for (const entry of retainedContext) { + if (entry.stepId != null) { + textContextByStepId.set(entry.stepId, entry); + } + } + }; + const trackActivity = (activity: TrackedActivity) => { activityCount += 1; if (activity.status === 'error') { @@ -728,17 +738,6 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity const close = (closingTextPhase?: AssistantTextPhase, requestedEndIndex?: number) => { addPendingReasoning(); - if (generated >= maxPerRun) { - clear(); - return; - } - if (activityCount < MIN_ACTIVITIES) { - clear(); - return; - } - - generated += 1; - const phaseIndex = generated - 1; const currentParts = deps.getContentParts(); /** Child-label and phase hooks run independently. A child label can claim * its slot before the corresponding tool part reaches the shared content @@ -746,11 +745,41 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity * from stable tool ids when they are available at close; the backward * scan below claims unresolved leading slots without crossing visible * answer content. */ - const snapshot = activities.map((activity) => { + const resolvedActivities = activities.map((activity) => { const toolStart = findTrackedToolStart(currentParts, activity); return toolStart != null ? { ...activity, startIndex: toolStart } : activity; }); - const contextSnapshot = assistantContext + const closesBeforeBoundary = (activity: TrackedActivity): boolean => { + if (requestedEndIndex == null) { + return true; + } + if ( + activity.unresolvedToolStartIndex != null && + activity.unresolvedToolStartIndex >= requestedEndIndex + ) { + return false; + } + const materializedStart = findMaterializedActivityStart(currentParts, activity); + return materializedStart == null || materializedStart < requestedEndIndex; + }; + const snapshot = resolvedActivities.filter(closesBeforeBoundary); + const retainedActivities = resolvedActivities.filter( + (activity) => !closesBeforeBoundary(activity), + ); + const closingContext: AssistantContextEntry[] = []; + const retainedContext: AssistantContextEntry[] = []; + for (const entry of assistantContext) { + const stepIndex = entry.stepId != null ? deps.getStepIndex?.(entry.stepId) : undefined; + const entryIndex = entry.text.trim() + ? findTextBoundary(currentParts, entry.text, stepIndex) + : stepIndex; + if (requestedEndIndex != null && entryIndex != null && entryIndex >= requestedEndIndex) { + retainedContext.push(entry); + } else { + closingContext.push(entry); + } + } + const contextSnapshot = closingContext .map(({ text }) => text) .filter((text) => text.trim().length > 0); /** Completion-finalized phases leave the final root text outside their @@ -771,9 +800,52 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity } } } - const totalActivityCount = activityCount; - const failedCount = failedActivityCount; - const partialCount = partialActivityCount; + const overflowCount = Math.max(0, activityCount - resolvedActivities.length); + const overflowFailedCount = Math.max( + 0, + failedActivityCount - + resolvedActivities.filter((activity) => activity.status === 'error').length, + ); + const overflowPartialCount = Math.max( + 0, + partialActivityCount - + resolvedActivities.filter((activity) => activity.status === 'partial').length, + ); + const overflowCloses = + overflowCount > 0 && + (requestedEndIndex == null || + (overflowActivityStartIndex != null && overflowActivityStartIndex < requestedEndIndex)); + const failedCount = + snapshot.filter((activity) => activity.status === 'error').length + + (overflowCloses ? overflowFailedCount : 0); + const partialCount = + snapshot.filter((activity) => activity.status === 'partial').length + + (overflowCloses ? overflowPartialCount : 0); + const totalActivityCount = snapshot.length + (overflowCloses ? overflowCount : 0); + const agentIds = overflowCloses + ? [...contributingAgentIds] + : [ + ...new Set( + snapshot.flatMap((activity) => (activity.agentId != null ? [activity.agentId] : [])), + ), + ]; + const retainedActivityCount = activityCount - totalActivityCount; + const retainedFailedCount = failedActivityCount - failedCount; + const retainedPartialCount = partialActivityCount - partialCount; + restoreActivities( + retainedActivities, + retainedContext, + retainedActivityCount, + retainedFailedCount, + retainedPartialCount, + overflowCount > 0 && !overflowCloses, + ); + if (generated >= maxPerRun || totalActivityCount < MIN_ACTIVITIES) { + return; + } + + generated += 1; + const phaseIndex = generated - 1; let startIndex = Math.min(...snapshot.map((activity) => activity.startIndex)); /** Pull leading commentary/reasoning into the parent card. A prior phase * marker or steer is the only hard UI boundary; plain text can be @@ -789,6 +861,7 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity if ( prior?.type === ContentTypes.STEER || (prior?.type === ContentTypes.ACTIVITY_LABEL && prior.activity_label_type === 'phase') || + isSubstantialText(prior) || (prior?.type === ContentTypes.TEXT && prior.phase === 'final_answer' && textValue(prior.text).trim().length > 0) @@ -798,7 +871,6 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity } } startIndex = extendedStartIndex; - const agentIds = [...contributingAgentIds]; let phaseStatus: 'ok' | 'partial' | 'failed' = 'ok'; if (failedCount === totalActivityCount) { phaseStatus = 'failed'; @@ -821,7 +893,6 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity deps.getContentParts().push(part); deps.bumpIndexOffset(); void Promise.resolve(deps.emitLabelEvent(index, part)).catch(() => undefined); - clear(); const task = (async () => { let generatedPhase: GeneratedActivityPhase = {}; @@ -1029,6 +1100,7 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity assistantContext = assistantContext.filter((entry) => entry !== contextEntry); textContextByStepId.delete(id ?? ''); close(tracked.phase, boundaryIndex); + stepKinds.delete(id ?? ''); } return result; } @@ -1057,91 +1129,17 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity }; const complete = () => { - let finalTextIndex: number | undefined; const parts = deps.getContentParts(); /** A live substantial-text boundary normally closes its phase while * streaming. Reconcile persisted content as a fallback for resume paths * where the original delta crossed the boundary before this wiring was * attached. */ - const definedIndices = Object.keys(parts); - for (let position = definedIndices.length - 1; position >= 0; position -= 1) { - const index = Number(definedIndices[position]); - const part = parts[index]; - /** Provider phase metadata is only a hint. Size determines whether text - * is a standalone result, including MCP responses that retain a lane - * id. Later-activity checks reject stale intermediate boundaries. */ - if ( - part?.type === ContentTypes.TEXT && - textValue(part.text).trim().length > SUBSTANTIAL_TEXT_CHARS - ) { - finalTextIndex = index; - break; + for (const index of definedPartIndices(parts)) { + if (isSubstantialText(parts[index])) { + close(undefined, index); } } - if (finalTextIndex != null) { - const candidateFinalTextIndex = finalTextIndex; - const materializedToolIds = new Set(); - const trailingToolIds = new Set(); - for (const key of Object.keys(parts)) { - const index = Number(key); - const part = parts[index]; - if (part?.type !== ContentTypes.TOOL_CALL || typeof part.tool_call?.id !== 'string') { - continue; - } - materializedToolIds.add(part.tool_call.id); - if (index >= candidateFinalTextIndex) { - trailingToolIds.add(part.tool_call.id); - } - } - const hasLaterTrackedActivity = activities.some((activity) => { - const toolCallIds = activity.toolCallIds ?? []; - if (toolCallIds.some((id) => trailingToolIds.has(id))) { - return true; - } - const reasoningExcerpt = activity.thinkingExcerpts?.[0]; - if (toolCallIds.length === 0 && reasoningExcerpt) { - return hasReasoningExcerptAtOrAfter(parts, reasoningExcerpt, candidateFinalTextIndex); - } - return ( - toolCallIds.some((id) => !materializedToolIds.has(id)) && - (activity.unresolvedToolStartIndex ?? activity.startIndex) >= candidateFinalTextIndex - ); - }); - const overflowIds = [...overflowToolCallIds]; - const overflowBoundaryIds = [...overflowBoundaryToolCallIds]; - const hasLaterOverflowActivity = - overflowIds.some((id) => trailingToolIds.has(id)) || - hasIndexedReasoningAtOrAfter( - parts, - overflowReasoningAnchorIndex, - candidateFinalTextIndex, - ) || - (!overflowBoundaryIds.every((id) => materializedToolIds.has(id)) && - overflowActivityStartIndex != null && - overflowActivityStartIndex >= candidateFinalTextIndex); - const pendingReasoningAnchors = new Set(); - const pendingReasoningAnchorIndex: ReasoningAnchorIndex = new Map(); - let hasLaterPendingReasoningIndex = false; - for (const reasoning of pendingReasoning.values()) { - /** Empty reasoning reservations are not activities: addPendingReasoning - * deliberately drops them. They can still receive a later sparse - * index from the SDK, so do not let that placeholder pull a fully - * materialized final answer into the completed parent phase. */ - if (!reasoning.text.trim()) { - continue; - } - hasLaterPendingReasoningIndex ||= - reasoning.startIndex != null && reasoning.startIndex >= candidateFinalTextIndex; - addReasoningAnchor(pendingReasoningAnchors, pendingReasoningAnchorIndex, reasoning.text); - } - const hasLaterPendingReasoning = - hasLaterPendingReasoningIndex || - hasIndexedReasoningAtOrAfter(parts, pendingReasoningAnchorIndex, candidateFinalTextIndex); - if (hasLaterTrackedActivity || hasLaterOverflowActivity || hasLaterPendingReasoning) { - finalTextIndex = undefined; - } - } - close(undefined, finalTextIndex); + close(); }; return { hook, handlers: wrapHandlers, drop: clear, complete, snapshot };