fix(api): preserve activity phase boundaries

This commit is contained in:
Danny Avila 2026-08-13 08:04:42 -04:00
parent 3da5f8d35a
commit 69c117bb1c
4 changed files with 405 additions and 186 deletions

View file

@ -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',

View file

@ -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<TMessageContentParts | undefined> = [];
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;

View file

@ -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,
});
});

View file

@ -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<LooseContentPart | null | undefined>,
activity: TrackedActivity,
): number {
const materializedStart = findMaterializedActivityStart(parts, activity);
return materializedStart ?? Math.min(activity.startIndex, Math.max(0, parts.length - 1));
}
function findMaterializedActivityStart(
parts: ReadonlyArray<LooseContentPart | null | undefined>,
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<LooseContentPart | null | undefined>,
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<LooseContentPart | null | undefined>,
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<number, Set<string>>;
function addReasoningAnchor(
@ -304,31 +279,6 @@ function includesReasoningAnchor(text: string, index: ReasoningAnchorIndex): boo
return false;
}
function hasIndexedReasoningAtOrAfter(
parts: ReadonlyArray<LooseContentPart | null | undefined>,
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<LooseContentPart | null | undefined>,
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<string>();
const trailingToolIds = new Set<string>();
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<string>();
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 };