🔖 feat: Bound Parent Activity Phases With an Exclusive End Index (#14768)

* 🧭 fix: Finalize Parent Activity Phases at Run Completion

* 🧭 fix: Preserve Activity Phase Boundaries

* 🎨 fix: Format Activity Phase Boundary Check

* 🧭 fix: Ignore Late Label Artifacts at Phase Completion

* 🧭 fix: Preserve Logical Activity Phase Membership

* 🩹 fix: Narrow Optional Activity Phase Marker

* fix activity phase tail boundaries

* fix activity phase test lint

* fix straddling activity phase batches

* preserve activity phase boundaries at scale

* fix persisted activity phase final boundary

* fix resumed activity phase edge cases

* fix sparse activity phase grouping

* fix sparse activity phase tail scan

* fix resumed activity phase text fallback

* fix sparse activity phase completion scans

* avoid sparse activity phase runtime scans

* stabilize sparse activity phase resumes

* support activity phases on current ts target

* preserve sparse phase reservations

* finalize activity phase boundary handling

* avoid sparse phase start scans

* fix activity phase final text bounds

* tighten activity phase summary boundaries

* format activity phase boundary checks

* leave final commentary outside activity phases

* recognize lane-tagged final activity text

* rebase retained activity boundaries on resume

* bound activity phase collection work

* correct resumed phase activity count

* resolve late reasoning before phase completion

* preserve lane-tagged final answers

* assert durable activity phase bounds in e2e

* preserve empty finalized activity phases

* ignore empty reasoning at phase completion

* format phase completion guard

* fix(api): retain overflow reasoning anchors

* perf(api): index overflow reasoning anchors

* perf(api): skip empty reasoning index scans

* fix(api): reconcile completion boundaries efficiently
This commit is contained in:
Danny Avila 2026-08-12 23:43:35 -04:00 committed by GitHub
parent 1a3e2aebcb
commit df6e15a0de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 2436 additions and 157 deletions

View file

@ -1306,6 +1306,9 @@ class BaseClient {
return {
...part,
activity_start_index: part.activity_start_index + phaseIndexOffset,
...(typeof part.activity_end_index === 'number' && {
activity_end_index: part.activity_end_index + phaseIndexOffset,
}),
};
});

View file

@ -1883,6 +1883,7 @@ describe('BaseClient', () => {
type: ContentTypes.ACTIVITY_LABEL,
activity_label_type: 'phase',
activity_start_index: 0,
activity_end_index: 1,
activity_label: 'Verified deployment health',
},
];
@ -1890,7 +1891,7 @@ describe('BaseClient', () => {
expect(TestClient.mergeEditedContent(existing, completion, ContentTypes.TEXT)).toEqual([
existing[0],
completion[0],
{ ...completion[1], activity_start_index: 1 },
{ ...completion[1], activity_start_index: 1, activity_end_index: 2 },
]);
});

View file

@ -2248,17 +2248,25 @@ class AgentClient extends BaseClient {
*/
/**
* @deprecated Agent Chain strip hidden intermediate sequential-agent content
* before persistence, keeping only the last part + tool_call parts. Mirrors the
* chat path so a HITL resume doesn't persist/emit intermediate outputs the
* agent's `hide_sequential_outputs` setting is meant to hide.
* before persistence, keeping only the last non-label part + tool_call parts.
* Parent activity markers can be appended after the final answer, so physical
* array order alone cannot identify the response output that must survive.
*/
applyHideSequentialOutputsFilter() {
if (!this.options.agent?.hide_sequential_outputs || !Array.isArray(this.contentParts)) {
return;
}
let lastOutputIndex = -1;
for (let index = this.contentParts.length - 1; index >= 0; index -= 1) {
const part = this.contentParts[index];
if (part != null && part.type !== ContentTypes.ACTIVITY_LABEL) {
lastOutputIndex = index;
break;
}
}
this.contentParts = this.contentParts.filter(
(part, index) =>
index >= this.contentParts.length - 1 ||
index === lastOutputIndex ||
part.type === ContentTypes.TOOL_CALL ||
// Steer parts are user speech, not intermediate agent output — dropping
// one would erase the user's words from the persisted turn.
@ -2292,26 +2300,39 @@ class AgentClient extends BaseClient {
* SDK event lands after the phase closes; scanning retained identities
* in an unchanged array would skip that hole and move the bound past the
* delayed tool before it arrives. */
const previousDefinedIndexes = Object.keys(previousParts)
.map(Number)
.filter((index) => previousParts[index] != null);
const currentDefinedIndexes = Object.keys(this.contentParts)
.map(Number)
.filter((index) => this.contentParts[index] != null);
if (previousParts.length === this.contentParts.length) {
let unchanged = true;
for (let index = 0; index < previousParts.length; index += 1) {
if (previousParts[index] !== this.contentParts[index]) {
unchanged = false;
break;
}
}
const unchanged =
previousDefinedIndexes.length === currentDefinedIndexes.length &&
previousDefinedIndexes.every(
(index, position) =>
index === currentDefinedIndexes[position] &&
previousParts[index] === this.contentParts[index],
);
if (unchanged) {
return;
}
}
const retainedIndexes = new Map();
for (let index = 0; index < this.contentParts.length; index += 1) {
for (const index of currentDefinedIndexes) {
const part = this.contentParts[index];
if (part != null) {
retainedIndexes.set(part, index);
}
}
for (let markerIndex = 0; markerIndex < this.contentParts.length; markerIndex += 1) {
const previousIndexesByPart = new Map();
for (const index of previousDefinedIndexes) {
const part = previousParts[index];
if (part != null) {
previousIndexesByPart.set(part, index);
}
}
for (const markerIndex of currentDefinedIndexes) {
const marker = this.contentParts[markerIndex];
if (
marker?.type !== ContentTypes.ACTIVITY_LABEL ||
@ -2320,26 +2341,65 @@ class AgentClient extends BaseClient {
) {
continue;
}
const previousMarkerIndex = previousParts.indexOf(marker);
if (previousMarkerIndex < 0) {
const previousMarkerIndex = previousIndexesByPart.get(marker);
if (previousMarkerIndex == null) {
continue;
}
const previousStartIndex = Math.min(
previousMarkerIndex,
Math.max(0, marker.activity_start_index),
);
const hasExplicitEnd = typeof marker.activity_end_index === 'number';
const previousEndIndex = hasExplicitEnd
? Math.max(previousStartIndex, Math.min(previousMarkerIndex, marker.activity_end_index))
: previousMarkerIndex;
let nextStartIndex = markerIndex;
for (let index = previousStartIndex; index < previousMarkerIndex; index += 1) {
let nextEndIndex = markerIndex;
let foundRetainedPart = false;
for (const index of previousDefinedIndexes) {
if (index < previousStartIndex || index >= previousEndIndex) {
continue;
}
const retainedIndex = retainedIndexes.get(previousParts[index]);
if (retainedIndex != null && retainedIndex < markerIndex) {
nextStartIndex = retainedIndex;
break;
if (!foundRetainedPart) {
nextStartIndex = retainedIndex;
nextEndIndex = retainedIndex + 1;
foundRetainedPart = true;
} else {
nextStartIndex = Math.min(nextStartIndex, retainedIndex);
nextEndIndex = Math.min(markerIndex, Math.max(nextEndIndex, retainedIndex + 1));
}
}
}
if (!foundRetainedPart && hasExplicitEnd) {
for (const index of previousDefinedIndexes) {
if (index < previousEndIndex || index >= previousMarkerIndex) {
continue;
}
const retainedIndex = retainedIndexes.get(previousParts[index]);
if (retainedIndex != null && retainedIndex < markerIndex) {
nextStartIndex = retainedIndex;
nextEndIndex = retainedIndex;
break;
}
}
}
marker.activity_start_index = nextStartIndex;
if (hasExplicitEnd) {
marker.activity_end_index = Math.max(nextStartIndex, nextEndIndex);
}
}
}
/** Finalize only a completed root run; HITL interruptions retain their snapshot for resume. */
completeActivityPhase(run, activityPhase) {
if (typeof run?.getInterrupt === 'function' && run.getInterrupt()?.payload) {
return;
}
activityPhase?.complete?.();
}
/**
* Surface any human-in-the-loop interrupt the SDK captured during the most
* recent `processStream` / `resume`. When the run paused for tool approval (or
@ -2904,6 +2964,7 @@ class AgentClient extends BaseClient {
[Callback.TOOL_ERROR]: logToolError,
},
});
this.completeActivityPhase(run, activityPhase);
// HITL: if the run paused for tool approval, mark the job
// `requires_action` + emit the prompt and leave the turn unfinalized
@ -3278,6 +3339,7 @@ class AgentClient extends BaseClient {
{ callbacks: { [Callback.TOOL_ERROR]: logToolError } },
commandOptions,
);
this.completeActivityPhase(run, activityPhase);
config.signal = null;

View file

@ -142,7 +142,7 @@ describe('AgentClient - applyHideSequentialOutputsFilter', () => {
const textPart = (text) => ({ type: ContentTypes.TEXT, text });
const toolCallPart = (id) => ({ type: ContentTypes.TOOL_CALL, tool_call: { id } });
it('keeps only the last part + tool_call parts when hide_sequential_outputs is on', () => {
it('keeps only the last non-label part + tool_call parts when filtering is on', () => {
const ctx = {
options: { agent: { hide_sequential_outputs: true } },
contentParts: [
@ -156,6 +156,58 @@ describe('AgentClient - applyHideSequentialOutputsFilter', () => {
expect(ctx.contentParts).toEqual([toolCallPart('tc1'), textPart('final')]);
});
it('keeps the final text when a parent phase marker is appended after it', () => {
const tool = toolCallPart('tc1');
const final = textPart('final');
const phase = {
type: ContentTypes.ACTIVITY_LABEL,
activity_label: 'Completed the investigation',
activity_label_type: 'phase',
activity_start_index: 0,
activity_end_index: 2,
};
const ctx = {
options: { agent: { hide_sequential_outputs: true } },
contentParts: [textPart('intermediate'), tool, final, phase],
};
const previousParts = [...ctx.contentParts];
AgentClient.prototype.applyHideSequentialOutputsFilter.call(ctx);
AgentClient.prototype.rebaseActivityPhaseBounds.call(ctx, previousParts);
expect(ctx.contentParts).toEqual([tool, final, phase]);
expect(phase.activity_start_index).toBe(0);
expect(phase.activity_end_index).toBe(1);
});
it('keeps an appended phase before the final text when all phase children are filtered', () => {
const final = textPart('final');
const phase = {
type: ContentTypes.ACTIVITY_LABEL,
activity_label: 'Completed both reasoning activities',
activity_label_type: 'phase',
activity_start_index: 0,
activity_end_index: 2,
};
const ctx = {
options: { agent: { hide_sequential_outputs: true } },
contentParts: [
{ type: ContentTypes.THINK, think: 'first' },
{ type: ContentTypes.THINK, think: 'second' },
final,
phase,
],
};
const previousParts = [...ctx.contentParts];
AgentClient.prototype.applyHideSequentialOutputsFilter.call(ctx);
AgentClient.prototype.rebaseActivityPhaseBounds.call(ctx, previousParts);
expect(ctx.contentParts).toEqual([final, phase]);
expect(phase.activity_start_index).toBe(0);
expect(phase.activity_end_index).toBe(0);
});
it('is a no-op when hide_sequential_outputs is off', () => {
const parts = [textPart('a'), textPart('b')];
const ctx = { options: { agent: { hide_sequential_outputs: false } }, contentParts: parts };
@ -171,6 +223,7 @@ describe('AgentClient - applyHideSequentialOutputsFilter', () => {
activity_label: 'Resolved the session issue',
activity_label_type: 'phase',
activity_start_index: 0,
activity_end_index: 2,
};
const final = textPart('final');
const previousParts = [reasoning, activityTool, phase, final];
@ -185,6 +238,7 @@ describe('AgentClient - applyHideSequentialOutputsFilter', () => {
expect(ctx.contentParts).toEqual([skillCard, activityTool, phase, final]);
expect(phase.activity_start_index).toBe(1);
expect(phase.activity_end_index).toBe(2);
});
it('rebases phase bounds over reshaped sparse content without retaining holes', () => {
@ -211,6 +265,26 @@ describe('AgentClient - applyHideSequentialOutputsFilter', () => {
expect(phase.activity_start_index).toBe(0);
});
it('rebases explicit bounds using only defined sparse slots', () => {
const toolCall = toolCallPart('tc-large-sparse');
const phase = {
type: ContentTypes.ACTIVITY_LABEL,
activity_label: 'Searched the sparse transcript',
activity_label_type: 'phase',
activity_start_index: 5,
activity_end_index: 999_999,
};
const previousParts = [];
previousParts[5] = toolCall;
previousParts[999_999] = phase;
const ctx = { options: { agent: {} }, contentParts: [toolCall, phase] };
AgentClient.prototype.rebaseActivityPhaseBounds.call(ctx, previousParts);
expect(phase.activity_start_index).toBe(0);
expect(phase.activity_end_index).toBe(1);
});
it('preserves a sparse phase reservation when completion does not reshape content', () => {
const firstTool = toolCallPart('tool-1');
const secondTool = toolCallPart('tool-2');
@ -256,6 +330,28 @@ describe('AgentClient - applyHideSequentialOutputsFilter', () => {
});
});
describe('AgentClient - activity phase completion', () => {
it('completes an uninterrupted root run', () => {
const complete = jest.fn();
AgentClient.prototype.completeActivityPhase.call(
{},
{ getInterrupt: () => undefined },
{ complete },
);
expect(complete).toHaveBeenCalledTimes(1);
});
it('retains phase state when the root run pauses for HITL', () => {
const complete = jest.fn();
AgentClient.prototype.completeActivityPhase.call(
{},
{ getInterrupt: () => ({ payload: { type: 'tool_approval' } }) },
{ complete },
);
expect(complete).not.toHaveBeenCalled();
});
});
describe('AgentClient - startup telemetry', () => {
afterEach(() => {
jest.restoreAllMocks();

View file

@ -10,6 +10,7 @@ import { cn } from '~/utils';
type ActivityPhasePart = Extract<TMessageContentParts, { type: ContentTypes.ACTIVITY_LABEL }> & {
activity_label_type?: 'phase';
activity_start_index?: number;
activity_end_index?: number;
};
export default function ActivityPhaseGroup({

View file

@ -153,6 +153,8 @@ type ContentPartsProps = {
nestedActivityPhase?: boolean;
/** Absolute transcript index represented by `content[0]` in a phase slice. */
contentIndexOffset?: number;
/** Absolute transcript index for each compacted sparse segment entry. */
contentIndices?: ReadonlyArray<number>;
/** Message-wide steer attribution retained across nested phase segments. */
resumeAuthors?: ReadonlyMap<number, string | undefined>;
/** Message-wide tool-group expansion overrides retained across phase slices. */
@ -184,6 +186,7 @@ const ContentParts = memo(function ContentParts({
createdAt,
nestedActivityPhase = false,
contentIndexOffset = 0,
contentIndices,
resumeAuthors,
toolGroupExpansionState,
}: ContentPartsProps) {
@ -200,6 +203,17 @@ const ContentParts = memo(function ContentParts({
fallbackScopeRef.current.messageId = messageId;
}
const fallbackScope = fallbackScopeRef.current.scope;
const localIndexByAbsolute = useMemo(
() =>
contentIndices == null
? undefined
: new Map(contentIndices.map((absoluteIndex, localIndex) => [absoluteIndex, localIndex])),
[contentIndices],
);
const absoluteIndexAt = useCallback(
(localIndex: number) => contentIndices?.[localIndex] ?? localIndex + contentIndexOffset,
[contentIndexOffset, contentIndices],
);
const handleGroupExpansionChange = useCallback(
(groupId: string, state: ToolCallGroupExpansionState) => {
@ -271,7 +285,7 @@ const ContentParts = memo(function ContentParts({
const renderPart = useCallback(
(part: TMessageContentParts, idx: number, isLastPart: boolean) => {
const localIdx = idx - contentIndexOffset;
const localIdx = localIndexByAbsolute?.get(idx) ?? idx - contentIndexOffset;
return (
<PartWithContext
key={`provider-${messageId}-${idx}`}
@ -296,6 +310,7 @@ const ContentParts = memo(function ContentParts({
attachmentMap,
content,
contentIndexOffset,
localIndexByAbsolute,
conversationId,
effectiveIsSubmitting,
isCreatedByUser,
@ -307,7 +322,7 @@ const ContentParts = memo(function ContentParts({
const renderGroupedPart = useCallback(
(part: TMessageContentParts, idx: number, isLastPart: boolean, onToolExpand?: () => void) => {
const localIdx = idx - contentIndexOffset;
const localIdx = localIndexByAbsolute?.get(idx) ?? idx - contentIndexOffset;
return (
<PartWithContext
key={`provider-${messageId}-${idx}`}
@ -334,6 +349,7 @@ const ContentParts = memo(function ContentParts({
attachmentMap,
content,
contentIndexOffset,
localIndexByAbsolute,
conversationId,
effectiveIsSubmitting,
isCreatedByUser,
@ -362,7 +378,7 @@ const ContentParts = memo(function ContentParts({
if (!part) {
return;
}
const idx = localIdx + contentIndexOffset;
const idx = absoluteIndexAt(localIdx);
if (prevType === ContentTypes.STEER && part.type !== ContentTypes.STEER) {
authors.set(idx, activeAgentId);
}
@ -373,7 +389,7 @@ const ContentParts = memo(function ContentParts({
parts.push({ part, idx });
});
return { sequentialParts: parts, detectedResumeAuthors: authors };
}, [content, contentIndexOffset]);
}, [absoluteIndexAt, content]);
const postSteerAuthors = resumeAuthors ?? detectedResumeAuthors;
const groupedParts = useMemo(
@ -423,7 +439,7 @@ const ContentParts = memo(function ContentParts({
if (!part) {
return null;
}
const idx = localIdx + contentIndexOffset;
const idx = absoluteIndexAt(localIdx);
const isTextPart =
part?.type === ContentTypes.TEXT ||
typeof (part as unknown as Agents.MessageContentText)?.text === 'string';
@ -460,13 +476,13 @@ const ContentParts = memo(function ContentParts({
if (phaseSegments != null) {
const relativeGlobalLastContentIdx = lastVisibleContentIdx(content ?? []);
const globalLastContentIdx =
relativeGlobalLastContentIdx < 0 ? -1 : relativeGlobalLastContentIdx + contentIndexOffset;
relativeGlobalLastContentIdx < 0 ? -1 : absoluteIndexAt(relativeGlobalLastContentIdx);
const renderSegment = (
segmentContent: Array<TMessageContentParts | undefined>,
segmentStartIndex: number,
segmentIndices: ReadonlyArray<number>,
key: string,
) => {
const localLastContentIdx = globalLastContentIdx - segmentStartIndex;
return (
<ContentParts
key={key}
@ -478,11 +494,12 @@ const ContentParts = memo(function ContentParts({
attachments={attachments}
searchResults={searchResults}
isCreatedByUser={isCreatedByUser}
isLast={isLast && segmentContent[localLastContentIdx] != null}
isLast={isLast && segmentIndices.includes(globalLastContentIdx)}
isSubmitting={isSubmitting}
isLatestMessage={isLatestMessage}
nestedActivityPhase
contentIndexOffset={segmentStartIndex}
contentIndices={segmentIndices}
resumeAuthors={postSteerAuthors}
toolGroupExpansionState={expansionState}
/>
@ -506,19 +523,21 @@ const ContentParts = memo(function ContentParts({
showCursor={
isLast &&
effectiveIsSubmitting &&
segment.labelIndex + contentIndexOffset === globalLastContentIdx
absoluteIndexAt(segment.labelIndex) === globalLastContentIdx
}
>
{renderSegment(
segment.content,
segment.startIndex + contentIndexOffset,
absoluteIndexAt(segment.startIndex),
segment.contentIndices.map(absoluteIndexAt),
`phase-content-${index}`,
)}
</ActivityPhaseGroup>
) : (
renderSegment(
segment.content,
segment.startIndex + contentIndexOffset,
absoluteIndexAt(segment.startIndex),
segment.contentIndices.map(absoluteIndexAt),
`phase-adjacent-${index}`,
)
),
@ -534,8 +553,7 @@ const ContentParts = memo(function ContentParts({
* counting one as last would strip the streaming cursor from the last
* VISIBLE part until the next delta. */
const relativeLastContentIdx = lastVisibleContentIdx(safeContent);
const lastContentIdx =
relativeLastContentIdx < 0 ? -1 : relativeLastContentIdx + contentIndexOffset;
const lastContentIdx = relativeLastContentIdx < 0 ? -1 : absoluteIndexAt(relativeLastContentIdx);
// Parallel content: use dedicated renderer with columns (TMessageContentParts includes ContentMetadata)
const hasParallelContent = safeContent.some((part) => part?.groupId != null);
@ -555,6 +573,7 @@ const ContentParts = memo(function ContentParts({
renderResumeAttribution={renderResumeAttribution}
showDecorations={!nestedActivityPhase}
contentIndexOffset={contentIndexOffset}
contentIndices={contentIndices}
/>
</>
);

View file

@ -36,6 +36,7 @@ export type ParallelSection = {
export function groupParallelContent(
content: Array<TMessageContentParts | undefined> | undefined,
contentIndexOffset = 0,
contentIndices?: ReadonlyArray<number>,
): { parallelSections: ParallelSection[]; sequentialParts: PartWithIndex[] } {
if (!content) {
return { parallelSections: [], sequentialParts: [] };
@ -50,7 +51,7 @@ export function groupParallelContent(
if (!part) {
return;
}
const idx = localIdx + contentIndexOffset;
const idx = contentIndices?.[localIdx] ?? localIdx + contentIndexOffset;
// Read metadata directly from content part (TMessageContentParts includes ContentMetadata)
const { groupId } = part;
@ -230,6 +231,8 @@ type ParallelContentRendererProps = {
showDecorations?: boolean;
/** Absolute transcript index represented by `content[0]` in a phase slice. */
contentIndexOffset?: number;
/** Absolute transcript index for each compacted sparse segment entry. */
contentIndices?: ReadonlyArray<number>;
};
/**
@ -248,10 +251,11 @@ export const ParallelContentRenderer = memo(function ParallelContentRenderer({
renderResumeAttribution,
showDecorations = true,
contentIndexOffset = 0,
contentIndices,
}: ParallelContentRendererProps) {
const { parallelSections, sequentialParts } = useMemo(
() => groupParallelContent(content, contentIndexOffset),
[content, contentIndexOffset],
() => groupParallelContent(content, contentIndexOffset, contentIndices),
[content, contentIndexOffset, contentIndices],
);
/** Same walk-back as `ContentParts`: a trailing BLANK label reservation is
@ -259,7 +263,9 @@ export const ParallelContentRenderer = memo(function ParallelContentRenderer({
* rendered part with the last-part cursor until the label fills. */
const relativeLastContentIdx = lastVisibleContentIdx(content);
const lastContentIdx =
relativeLastContentIdx < 0 ? -1 : relativeLastContentIdx + contentIndexOffset;
relativeLastContentIdx < 0
? -1
: (contentIndices?.[relativeLastContentIdx] ?? relativeLastContentIdx + contentIndexOffset);
// Split sequential parts into before/after parallel sections
const { before, after } = useMemo(() => {

View file

@ -327,6 +327,33 @@ describe('ContentParts — post-steer author re-attribution', () => {
});
describe('ContentParts — activity phase state', () => {
it('renders a completion-appended parent before the final root text', () => {
const tool = {
type: ContentTypes.TOOL_CALL,
[ContentTypes.TOOL_CALL]: { id: 'tool-1', name: 'search', args: {}, output: 'done' },
} as unknown as TMessageContentParts;
const final = {
type: ContentTypes.TEXT,
text: 'Final answer',
} as unknown as TMessageContentParts;
const phase = {
type: ContentTypes.ACTIVITY_LABEL,
[ContentTypes.ACTIVITY_LABEL]: 'Completed the full investigation',
activity_label_type: 'phase',
activity_start_index: 0,
activity_end_index: 2,
activity_count: 2,
pending: false,
} as unknown as TMessageContentParts;
render(<ContentParts {...baseProps} content={[tool, tool, final, phase]} />);
const parent = screen.getByTestId('activity-phase-group');
const finalPart = screen.getByTestId(`real-part-${ContentTypes.TEXT}`);
expect(parent.compareDocumentPosition(finalPart)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(finalPart).toHaveAttribute('data-index', '2');
});
it('keeps a streaming cursor when a completed phase marker is the visible tail', () => {
const phase = {
type: ContentTypes.ACTIVITY_LABEL,

View file

@ -20,4 +20,24 @@ describe('groupParallelContent', () => {
expect(grouped.sequentialParts).toEqual([{ part: sequential, idx: 4 }]);
expect(grouped.parallelSections[0]?.columns[0]?.parts).toEqual([{ part: parallel, idx: 5 }]);
});
test('preserves absolute indices for a compacted sparse phase segment', () => {
const sequential = {
type: ContentTypes.TEXT,
text: 'before lanes',
} as unknown as TMessageContentParts;
const parallel = {
type: ContentTypes.TEXT,
text: 'lane result',
groupId: 1,
agentId: 'agent-1',
} as unknown as TMessageContentParts;
const grouped = groupParallelContent([sequential, parallel], 0, [2, 10_000]);
expect(grouped.sequentialParts).toEqual([{ part: sequential, idx: 2 }]);
expect(grouped.parallelSections[0]?.columns[0]?.parts).toEqual([
{ part: parallel, idx: 10_000 },
]);
});
});

View file

@ -3413,6 +3413,42 @@ describe('useStepHandler', () => {
const response = currentMessages.find((m) => !m.isCreatedByUser);
expect(response?.content?.[2]).toMatchObject({ [ContentTypes.TEXT]: 'streamed' });
expect(response?.content?.[0]).toMatchObject({ [ContentTypes.TEXT]: 'kept a' });
expect(
(submission as { editPrefixFirstPartFolded?: boolean }).editPrefixFirstPartFolded,
).toBeUndefined();
});
it('records when the first completion part actually folds into the retained tail', () => {
const submission = createSubmission({
editedContent: { index: 0, type: ContentTypes.TEXT },
initialResponse: createResponseMessage({ content: [textPart('kept'), textPart('tail')] }),
} as never);
(submission as { editPrefixLength?: number }).editPrefixLength = 2;
const responseMessage = submission.initialResponse as TMessage;
let currentMessages: TMessage[] = [responseMessage];
mockGetMessages.mockImplementation(() => currentMessages);
mockSetMessages.mockImplementation((messages: TMessage[]) => {
currentMessages = messages;
});
const { result } = renderHook(() => useStepHandler(createHookParams()));
act(() => {
result.current.stepHandler(
{
event: StepEvents.ON_RUN_STEP,
data: createRunStep({ index: 0, runId: responseMessage.messageId }),
},
submission,
);
result.current.stepHandler(
{ event: StepEvents.ON_MESSAGE_DELTA, data: createMessageDelta('step-1', ' continued') },
submission,
);
});
expect(
(submission as { editPrefixFirstPartFolded?: boolean }).editPrefixFirstPartFolded,
).toBe(true);
});
it('does not merge final-answer text into a retained commentary phase', () => {

View file

@ -45,6 +45,7 @@ import {
resolveRunEndTarget,
findSteerMessageIndex,
applyActivityLabelPart,
offsetActivityPhaseBoundary,
findActivityLabelMessageIndex,
appendAppliedSteerIds,
collectAppliedSteerIds,
@ -724,6 +725,7 @@ export default function useResumableSSE(
* `editPrefixLength` must no longer be applied by run steps or labels.
*/
const editPrefixClearedRef = useRef(false);
const editPrefixFirstPartFoldedRef = useRef(false);
/** Generation the cleared-prefix state above belongs to, so it is dropped
* when a new generation starts rather than when a subscribe happens to be
* live. Keyed by response message id the stream id is the conversation
@ -1056,13 +1058,15 @@ export default function useResumableSSE(
* the non-resumable transport, the submission passes through untouched.
*/
const stepHandler = useCallback(
(...[event, submission]: Parameters<typeof rawStepHandler>) =>
rawStepHandler(
event,
editPrefixClearedRef.current
? ({ ...submission, editPrefixCleared: true } as EventSubmission)
: submission,
),
(...[event, submission]: Parameters<typeof rawStepHandler>) => {
const eventSubmission = editPrefixClearedRef.current
? ({ ...submission, editPrefixCleared: true } as EventSubmission)
: submission;
rawStepHandler(event, eventSubmission);
if (eventSubmission.editPrefixFirstPartFolded === true) {
editPrefixFirstPartFoldedRef.current = true;
}
},
[rawStepHandler],
);
@ -1165,6 +1169,7 @@ export default function useResumableSSE(
if (prefixStateGenerationIdRef.current !== generationId) {
prefixStateGenerationIdRef.current = generationId;
editPrefixClearedRef.current = false;
editPrefixFirstPartFoldedRef.current = false;
}
let { userMessage } = currentSubmission;
let textIndex: number | null = null;
@ -1356,27 +1361,29 @@ export default function useResumableSSE(
const phasePart = event.part as TActivityLabelEvent['part'] & {
activity_label_type?: 'phase';
activity_start_index?: number;
activity_end_index?: number;
};
let offsetEvent = event;
if (prefixLength > 0) {
let offsetPart: TActivityLabelEvent['part'] & {
activity_label_type?: 'phase';
activity_start_index?: number;
activity_end_index?: number;
} = phasePart;
if (
phasePart.activity_label_type === 'phase' &&
typeof phasePart.activity_start_index === 'number'
) {
let activityStartIndex = phasePart.activity_start_index + prefixLength;
const foldedFirstPart = editPrefixFirstPartFoldedRef.current;
const targetContent = messages[index]?.content;
/** The first completion text/think part can merge into the
* retained edit tail at prefixLength - 1. Tool/nonmatching starts
* occupy the ordinary +prefix slot, so only fold back across the
* recognizable empty merge slot. */
/** The step handler records an actual server-index-zero text/think
* merge. An empty +prefix slot is insufficient evidence because
* a delayed tool may not have materialized there yet. */
if (
phasePart.activity_start_index === 0 &&
activityStartIndex > 0 &&
targetContent?.[activityStartIndex] == null &&
foldedFirstPart &&
targetContent?.[activityStartIndex - 1] != null
) {
activityStartIndex -= 1;
@ -1384,6 +1391,13 @@ export default function useResumableSSE(
offsetPart = {
...phasePart,
activity_start_index: activityStartIndex,
...(typeof phasePart.activity_end_index === 'number' && {
activity_end_index: offsetActivityPhaseBoundary(
phasePart.activity_end_index,
prefixLength,
foldedFirstPart,
),
}),
};
}
offsetEvent = {

View file

@ -984,6 +984,14 @@ export default function useStepHandler({
updatedResponse.content,
phase,
);
if (
submission != null &&
runStep.index === 0 &&
editPrefixOffset > 0 &&
currentIndex === editPrefixOffset - 1
) {
submission.editPrefixFirstPartFolded = true;
}
updatedResponse = updateContent(
updatedResponse,
currentIndex,
@ -1034,6 +1042,14 @@ export default function useStepHandler({
contentPart.type || '',
updatedResponse.content,
);
if (
submission != null &&
runStep.index === 0 &&
editPrefixOffset > 0 &&
currentIndex === editPrefixOffset - 1
) {
submission.editPrefixFirstPartFolded = true;
}
updatedResponse = updateContent(
updatedResponse,
currentIndex,

View file

@ -4,6 +4,7 @@ import {
applyActivityLabelPart,
groupActivityPhases,
lastVisibleContentIdx,
offsetActivityPhaseBoundary,
} from '../activityLabels';
const buildMessage = (content: TMessage['content']): TMessage =>
@ -71,6 +72,29 @@ describe('applyActivityLabelPart', () => {
pending: false,
});
});
it('never lets a stale pending placeholder overwrite an empty finalized phase', () => {
const finalized = labelPart({ pending: false });
Object.assign(finalized, {
activity_label_type: 'phase',
activity_start_index: 0,
activity_end_index: 2,
activity_count: 2,
});
const message = buildMessage([finalized as never]);
const updated = applyActivityLabelPart(message, {
index: 0,
part: labelPart({ pending: true }),
});
expect(updated).toBe(message);
expect((updated.content as unknown[])[0]).toMatchObject({
activity_label: '',
activity_label_type: 'phase',
pending: false,
});
});
});
describe('lastVisibleContentIdx', () => {
@ -105,6 +129,61 @@ describe('lastVisibleContentIdx', () => {
sparse[1] = tool;
expect(lastVisibleContentIdx(sparse)).toBe(1);
});
it('keeps sparse late-label adoption bounded to defined slots', () => {
const sparse = new Array<TMessageContentParts | undefined>(10_000);
sparse[0] = tool;
sparse[9_998] = labelPart({
activity_label: 'Recorded the delayed result',
pending: false,
}) as never;
const phase = labelPart({ activity_label: 'Completed the investigation', pending: false });
Object.assign(phase, {
activity_label_type: 'phase',
activity_start_index: 0,
activity_end_index: 1,
activity_count: 2,
});
sparse[9_999] = phase as never;
expect(lastVisibleContentIdx(sparse)).toBe(9_999);
});
it('keeps an appended phase marker as the visible tail when its final slot is empty', () => {
const phase = labelPart({ activity_label: 'Completed the investigation', pending: false });
Object.assign(phase, {
activity_label_type: 'phase',
activity_start_index: 0,
activity_end_index: 1,
activity_count: 2,
});
const emptyText = { type: ContentTypes.TEXT, text: '' } as TMessageContentParts;
expect(lastVisibleContentIdx([tool, emptyText, phase as never])).toBe(2);
});
it('keeps the phase marker visible when an adopted late label follows an empty final slot', () => {
const child = labelPart({ activity_label: 'Recorded the delayed result', pending: false });
const phase = labelPart({ activity_label: 'Completed the investigation', pending: false });
Object.assign(phase, {
activity_label_type: 'phase',
activity_start_index: 0,
activity_end_index: 1,
activity_count: 2,
});
const emptyText = { type: ContentTypes.TEXT, text: '' } as TMessageContentParts;
expect(lastVisibleContentIdx([tool, emptyText, child as never, phase as never])).toBe(3);
});
});
describe('offsetActivityPhaseBoundary', () => {
it('folds only boundaries covered by the merged first completion part', () => {
expect(offsetActivityPhaseBoundary(0, 5, true)).toBe(4);
expect(offsetActivityPhaseBoundary(1, 5, true)).toBe(5);
expect(offsetActivityPhaseBoundary(3, 5, true)).toBe(8);
expect(offsetActivityPhaseBoundary(3, 5, false)).toBe(8);
});
});
describe('groupActivityPhases', () => {
@ -129,6 +208,133 @@ describe('groupActivityPhases', () => {
}
});
it('renders an appended parent marker before the final text using its explicit end', () => {
const phase = labelPart({ activity_label: 'Completed the full investigation', pending: false });
Object.assign(phase, {
activity_label_type: 'phase',
activity_start_index: 0,
activity_end_index: 2,
activity_count: 2,
});
const final = { type: ContentTypes.TEXT, text: 'Final answer' } as TMessageContentParts;
const segments = groupActivityPhases([tool, tool, final, phase as never]);
expect(segments).toHaveLength(2);
expect(segments?.[0]).toMatchObject({
type: 'phase',
labelIndex: 3,
startIndex: 0,
content: [tool, tool],
});
expect(segments?.[1]).toMatchObject({
type: 'content',
startIndex: 2,
content: [final],
});
expect(lastVisibleContentIdx([tool, tool, final, phase as never])).toBe(2);
});
it('keeps a late child label in the phase while leaving final text outside', () => {
const child = labelPart({
activity_label: 'Recorded the delayed child result',
pending: false,
tool_call_ids: ['t1'],
});
const phase = labelPart({ activity_label: 'Completed the investigation', pending: false });
Object.assign(phase, {
activity_label_type: 'phase',
activity_start_index: 0,
activity_end_index: 1,
activity_count: 2,
});
const final = { type: ContentTypes.TEXT, text: 'Final answer' } as TMessageContentParts;
const content = [tool, final, child as never, phase as never];
const segments = groupActivityPhases(content);
expect(segments).toHaveLength(2);
expect(segments?.[0]).toMatchObject({
type: 'phase',
startIndex: 0,
content: [tool, child],
contentIndices: [0, 2],
});
expect(segments?.[1]).toMatchObject({
type: 'content',
startIndex: 1,
content: [final],
contentIndices: [1],
});
expect(lastVisibleContentIdx(content)).toBe(1);
});
it('groups a sparse late child label without walking the empty range', () => {
const content = new Array<TMessageContentParts | undefined>(10_000);
const child = labelPart({ activity_label: 'Recorded the delayed result', pending: false });
const phase = labelPart({
activity_label: 'Completed the sparse investigation',
pending: false,
});
Object.assign(phase, {
activity_label_type: 'phase',
activity_start_index: 0,
activity_end_index: 1,
activity_count: 2,
});
content[0] = tool;
content[9_998] = child as never;
content[9_999] = phase as never;
const segments = groupActivityPhases(content);
expect(segments?.[0]).toMatchObject({
type: 'phase',
startIndex: 0,
labelIndex: 9_999,
});
if (segments?.[0]?.type === 'phase') {
expect(segments[0].content[0]).toBe(tool);
expect(segments[0].content[1]).toBe(child);
expect(segments[0].contentIndices).toEqual([0, 9_998]);
expect(segments[0].content).toHaveLength(2);
}
});
it('restores a late child label when the parent label resolves empty', () => {
const child = labelPart({
activity_label: 'Recorded the delayed child result',
pending: false,
tool_call_ids: ['t1'],
});
const phase = labelPart({ pending: false });
Object.assign(phase, {
activity_label_type: 'phase',
activity_start_index: 0,
activity_end_index: 1,
activity_count: 2,
});
const final = { type: ContentTypes.TEXT, text: 'Final answer' } as TMessageContentParts;
const content = [tool, final, child as never, phase as never];
const segments = groupActivityPhases(content);
expect(segments).toEqual([
expect.objectContaining({
type: 'content',
startIndex: 0,
content: [tool, child],
contentIndices: [0, 2],
}),
expect.objectContaining({
type: 'content',
startIndex: 1,
content: [final],
contentIndices: [1],
}),
]);
expect(lastVisibleContentIdx(content)).toBe(1);
});
it('leaves pending or empty parent markers on the feature-off path', () => {
const pending = labelPart();
Object.assign(pending, { activity_label_type: 'phase', activity_start_index: 0 });

View file

@ -4,6 +4,7 @@ import type { TMessage, TActivityLabelEvent, TMessageContentParts } from 'librec
type ActivityLabelPart = Extract<TMessageContentParts, { type: ContentTypes.ACTIVITY_LABEL }> & {
activity_label_type?: 'phase';
activity_start_index?: number;
activity_end_index?: number;
activity_count?: number;
agent_ids?: string[];
};
@ -12,11 +13,13 @@ export type ActivityPhaseSegment =
| {
type: 'content';
content: Array<TMessageContentParts | undefined>;
contentIndices: number[];
startIndex: number;
}
| {
type: 'phase';
content: Array<TMessageContentParts | undefined>;
contentIndices: number[];
startIndex: number;
labelPart: ActivityLabelPart;
labelIndex: number;
@ -33,6 +36,66 @@ function isVisibleContentPart(part: TMessageContentParts | undefined): boolean {
);
}
function isLogicallyEarlierPhaseMarker(
parts: ReadonlyArray<TMessageContentParts | undefined>,
index: number,
): boolean {
const part = parts[index];
const label = getActivityLabelPart(part);
if (!isPhaseActivityLabel(label) || typeof label?.activity_end_index !== 'number') {
return false;
}
const endIndex = Math.max(0, Math.min(index, label.activity_end_index));
if (endIndex >= index) {
return false;
}
return Object.keys(parts).some((key) => {
const trailingIndex = Number(key);
if (trailingIndex < endIndex || trailingIndex >= index) {
return false;
}
const trailingPart = parts[trailingIndex];
if (!isVisibleContentPart(trailingPart)) {
return false;
}
if (getBatchActivityLabelPart(trailingPart) != null) {
return false;
}
if (trailingPart?.type !== ContentTypes.TEXT) {
return true;
}
const text =
typeof trailingPart.text === 'string' ? trailingPart.text : trailingPart.text?.value;
return typeof text === 'string' && text.length > 0;
});
}
function findLateActivityLabelsConsumedByPhase(
parts: ReadonlyArray<TMessageContentParts | undefined>,
): Set<number> {
const consumed = new Set<number>();
let earliestPhaseEnd: number | undefined;
const definedIndices = Object.keys(parts);
for (let position = definedIndices.length - 1; position >= 0; position -= 1) {
const index = Number(definedIndices[position]);
const marker = getActivityLabelPart(parts[index]);
if (
isPhaseActivityLabel(marker) &&
marker?.pending !== true &&
typeof marker?.activity_end_index === 'number'
) {
earliestPhaseEnd = Math.min(earliestPhaseEnd ?? index, marker.activity_end_index);
} else if (
earliestPhaseEnd != null &&
earliestPhaseEnd <= index &&
getBatchActivityLabelPart(parts[index]) != null
) {
consumed.add(index);
}
}
return consumed;
}
export function isPhaseActivityLabel(part: ActivityLabelPart | undefined): boolean {
return part?.activity_label_type === 'phase';
}
@ -67,10 +130,19 @@ export function getActivityLabelText(part: ActivityLabelPart | undefined): strin
return typeof label === 'string' ? label.trim() : '';
}
/** Maps a completion-local half-open boundary into edited-response coordinates. */
export function offsetActivityPhaseBoundary(
boundary: number,
prefixLength: number,
foldedFirstPart: boolean,
): number {
return boundary + prefixLength - (foldedFirstPart && boundary <= 1 ? 1 : 0);
}
/**
* Partitions completed phase markers into collapsed parent groups while
* carrying absolute start offsets alongside dense content slices. Empty/pending
* markers deliberately return no phase segment, preserving feature-off UI.
* carrying absolute indexes alongside compact content slices. Pending markers
* preserve feature-off UI; finalized empty markers only restore child order.
*/
export function groupActivityPhases(
content: Array<TMessageContentParts | undefined> | undefined,
@ -78,13 +150,13 @@ export function groupActivityPhases(
if (!content) {
return undefined;
}
const completed = content
.map((part, index) => ({ part: getActivityLabelPart(part), index }))
const definedIndices = Object.keys(content).map(Number);
const completed = definedIndices
.map((index) => ({ part: getActivityLabelPart(content[index]), index }))
.filter(
({ part }) =>
isPhaseActivityLabel(part) &&
part?.pending !== true &&
getActivityLabelText(part).length > 0 &&
typeof part?.activity_start_index === 'number',
);
if (completed.length === 0) {
@ -93,47 +165,102 @@ export function groupActivityPhases(
const segments: ActivityPhaseSegment[] = [];
let cursor = 0;
/** Dense, disjoint slices copy every part at most once. `startIndex` carries
* the absolute transcript position into the recursive renderer. */
const slice = (start: number, end: number) => {
const segmentContent = content.slice(start, end);
return {
content: segmentContent,
startIndex: start,
hasContent: segmentContent.some(isVisibleContentPart),
};
let definedPosition = 0;
const collect = () => ({
content: [] as Array<TMessageContentParts | undefined>,
contentIndices: [] as number[],
hasContent: false,
});
const append = (segment: ReturnType<typeof collect>, partIndex: number) => {
const child = content[partIndex];
segment.content.push(child);
segment.contentIndices.push(partIndex);
segment.hasContent ||= isVisibleContentPart(child);
};
/** Phase markers and defined content indexes are both sorted. Walk them in
* lockstep so every ordinary part is classified once, even when a custom
* 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 end = Math.max(start, Math.min(index, Math.max(0, part.activity_end_index ?? index)));
const adjacent = collect();
const phase = collect();
const trailing = collect();
while (definedPosition < definedIndices.length && definedIndices[definedPosition] < index) {
const childIndex = definedIndices[definedPosition];
definedPosition += 1;
if (childIndex < cursor) {
continue;
}
if (childIndex < start) {
append(adjacent, childIndex);
} else if (childIndex < end || getBatchActivityLabelPart(content[childIndex]) != null) {
append(phase, childIndex);
} else {
append(trailing, childIndex);
}
}
if (definedIndices[definedPosition] === index) {
definedPosition += 1;
}
if (start > cursor) {
const adjacent = slice(cursor, start);
segments.push({
type: 'content',
content: adjacent.content,
startIndex: adjacent.startIndex,
contentIndices: adjacent.contentIndices,
startIndex: cursor,
});
}
const labelText = getActivityLabelText(part);
if (labelText) {
segments.push({
type: 'phase',
content: phase.content,
contentIndices: phase.contentIndices,
startIndex: start,
labelPart: part,
labelIndex: index,
hasContent: phase.hasContent,
});
} else {
/** A failed/empty parent stays visually feature-off, but its bounds are
* still authoritative: delayed child labels must move back beside the
* tools they describe instead of rendering after the final answer. */
segments.push({
type: 'content',
content: phase.content,
contentIndices: phase.contentIndices,
startIndex: start,
});
}
if (end < index) {
segments.push({
type: 'content',
content: trailing.content,
contentIndices: trailing.contentIndices,
startIndex: end,
});
}
const phase = slice(start, index);
segments.push({
type: 'phase',
content: phase.content,
startIndex: phase.startIndex,
labelPart: part,
labelIndex: index,
hasContent: phase.hasContent,
});
cursor = index + 1;
}
if (cursor < content.length) {
const adjacent = slice(cursor, content.length);
const adjacent = collect();
while (definedPosition < definedIndices.length) {
const childIndex = definedIndices[definedPosition];
definedPosition += 1;
if (childIndex >= cursor) {
append(adjacent, childIndex);
}
}
segments.push({
type: 'content',
content: adjacent.content,
startIndex: adjacent.startIndex,
contentIndices: adjacent.contentIndices,
startIndex: cursor,
});
}
return segments;
@ -151,9 +278,14 @@ export function lastVisibleContentIdx(
content: ReadonlyArray<TMessageContentParts | undefined> | undefined,
): number {
const parts = content ?? [];
const consumedLateActivityLabels = findLateActivityLabelsConsumedByPhase(parts);
let last = parts.length - 1;
while (last >= 0 && last in parts) {
if (isVisibleContentPart(parts[last])) {
if (
isVisibleContentPart(parts[last]) &&
!isLogicallyEarlierPhaseMarker(parts, last) &&
!consumedLateActivityLabels.has(last)
) {
return last;
}
last -= 1;
@ -166,7 +298,12 @@ export function lastVisibleContentIdx(
const definedIndices = Object.keys(parts);
for (let i = definedIndices.length - 1; i >= 0; i -= 1) {
const index = Number(definedIndices[i]);
if (index <= last && isVisibleContentPart(parts[index])) {
if (
index <= last &&
isVisibleContentPart(parts[index]) &&
!isLogicallyEarlierPhaseMarker(parts, index) &&
!consumedLateActivityLabels.has(index)
) {
return index;
}
}
@ -221,18 +358,12 @@ export function applyActivityLabelPart(message: TMessage, event: TActivityLabelE
existing.pending === part.pending &&
existing.activity_label_type === incoming.activity_label_type &&
existing.activity_start_index === incoming.activity_start_index &&
existing.activity_end_index === incoming.activity_end_index &&
existing.activity_count === incoming.activity_count
) {
return message;
}
const existingText = existing?.[ContentTypes.ACTIVITY_LABEL];
if (
existing != null &&
existing.pending !== true &&
typeof existingText === 'string' &&
existingText.length > 0 &&
part.pending === true
) {
if (existing != null && existing.pending !== true && part.pending === true) {
return message;
}
const nextContent = [...content] as TMessageContentParts[];

View file

@ -27,6 +27,7 @@ type PersistedContentPart = {
activity_label?: string;
activity_label_type?: string;
activity_start_index?: number;
activity_end_index?: number;
activity_count?: number;
pending?: boolean;
tool_call?: { id?: string };
@ -148,6 +149,38 @@ test.describe('parent activity phases', () => {
const parent = messagesView(page).locator(`summary[aria-label="${PARENT_LABEL}"]`);
await expect(parent).toBeVisible({ timeout: 60000 });
/** Inspect the durable projection before the live DOM assertion so a
* failure identifies whether the server bound or client grouping is
* wrong. This remains a useful contract assertion after the bug is fixed. */
const liveConversationId = await getConversationId(page);
const liveToken = await getAccessToken(page);
let liveAssistant: PersistedMessage | undefined;
await expect
.poll(
async () => {
const messages = await fetchJson<PersistedMessage[]>(
page,
`/api/messages/${encodeURIComponent(liveConversationId)}`,
liveToken,
);
liveAssistant = messages.find(
(message) =>
message.isCreatedByUser === false && messageText(message).includes(finalText),
);
return liveAssistant?.unfinished;
},
{ timeout: 30000 },
)
.toBe(false);
const liveContent = liveAssistant?.content ?? [];
const livePhase = liveContent.find(
(part) => part?.type === 'activity_label' && part.activity_label_type === 'phase',
);
const liveFinalTextIndex = liveContent.findIndex((part) =>
contentPartText(part).includes(finalText),
);
expect(liveFinalTextIndex).toBe(livePhase?.activity_end_index);
await expect(messagesView(page).getByText(finalText)).toBeVisible({ timeout: 60000 });
await parent.click();
await expect(messagesView(page).getByRole('button', { name: childLabels.first })).toBeVisible();
@ -210,8 +243,12 @@ test.describe('parent activity phases', () => {
pending: false,
});
expect(phasePart?.activity_start_index).toBeGreaterThanOrEqual(0);
expect(phasePart?.activity_start_index).toBeLessThan(phaseIndex);
const phaseChildren = content.slice(phasePart?.activity_start_index ?? phaseIndex, phaseIndex);
expect(phasePart?.activity_end_index).toBeGreaterThan(phasePart?.activity_start_index ?? -1);
expect(phasePart?.activity_end_index).toBeLessThanOrEqual(phaseIndex);
const phaseChildren = content.slice(
phasePart?.activity_start_index ?? phaseIndex,
phasePart?.activity_end_index ?? phaseIndex,
);
expect(phaseChildren.map((part) => part?.tool_call?.id).filter(Boolean)).toEqual(
expect.arrayContaining([firstToolCallId, secondToolCallId]),
);
@ -219,7 +256,7 @@ test.describe('parent activity phases', () => {
expect.arrayContaining([childLabels.first, childLabels.second]),
);
const finalTextIndex = content.findIndex((part) => contentPartText(part).includes(finalText));
expect(finalTextIndex).toBeGreaterThan(phaseIndex);
expect(finalTextIndex).toBe(phasePart?.activity_end_index);
await page.reload();
const reloadedParent = messagesView(page).locator(`summary[aria-label="${PARENT_LABEL}"]`);

View file

@ -187,11 +187,12 @@ describe('synthesizeActivityLabelGapEvents', () => {
activity_label: 'Inspected and fixed the session',
activity_label_type: 'phase',
activity_start_index: 0,
activity_end_index: 1,
activity_count: 2,
pending: false,
},
];
const fresh: LooseContentPart[] = [{ ...snapshot[0], activity_start_index: 1 }];
const fresh: LooseContentPart[] = [{ ...snapshot[0], activity_end_index: 2 }];
expect(synthesizeActivityLabelGapEvents(snapshot, fresh, meta)).toHaveLength(1);
});

View file

@ -154,6 +154,7 @@ export function synthesizeActivityLabelGapEvents(
snapshot[ContentTypes.ACTIVITY_LABEL] === part[ContentTypes.ACTIVITY_LABEL] &&
snapshot.activity_label_type === part.activity_label_type &&
snapshot.activity_start_index === part.activity_start_index &&
snapshot.activity_end_index === part.activity_end_index &&
snapshot.activity_count === part.activity_count &&
snapshot.pending === part.pending;
if (isSameLabel) {

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@ import { stringifyActivityEvidence } from '~/agents/activityLabels/runtime';
type PostToolBatchInput = HookInputByEvent['PostToolBatch'];
type BatchEntry = PostToolBatchInput['entries'][number];
type AssistantContextEntry = { stepId?: string; text: string };
export type AssistantTextPhase = 'commentary' | 'final_answer';
@ -28,6 +29,8 @@ type TrackedActivity = ActivityPhaseEntry & {
childLabelIndex?: number;
/** Stable anchors survive content filtering and prepends across HITL resume. */
toolCallIds?: string[];
/** Original boundary retained while only part of a saved tool batch is materialized. */
unresolvedToolStartIndex?: number;
};
export interface ActivityPhaseSnapshot {
@ -38,6 +41,14 @@ export interface ActivityPhaseSnapshot {
partialActivityCount: number;
agentIds: string[];
activities: TrackedActivity[];
overflowActivityStartIndex?: number;
overflowToolCallIds?: string[];
/** IDs tied to the saved numeric overflow boundary, including equal-index batches. */
overflowBoundaryToolCallIds?: string[];
/** @deprecated Stable anchor retained for snapshots created before multi-anchor support. */
overflowReasoningExcerpt?: string;
/** Bounded stable anchors for reasoning-only overflow after HITL content compaction. */
overflowReasoningAnchors?: string[];
assistantContext: string[];
pendingReasoning: Array<{
key: string;
@ -87,6 +98,8 @@ export interface ActivityPhaseWiring {
) => Record<string, EventHandler> | undefined;
/** A steer is a hard semantic boundary; incomplete evidence is discarded. */
drop: () => void;
/** Finalizes unphased evidence once the root AgentRun has actually completed. */
complete: () => void;
/** Bounded state needed to continue the same phase after a HITL pause. */
snapshot: () => ActivityPhaseSnapshot;
}
@ -96,6 +109,7 @@ const DEFAULT_CHAR_LIMIT = 600;
const MIN_ACTIVITIES = 2;
const MAX_CONTEXT_ITEMS = 6;
const MAX_EXCERPT_CHARS = 600;
const REASONING_ANCHOR_CHARS = 80;
const OUTPUT_CHAR_LIMIT = 160;
const PHASE_TIMEOUT_MS = 12_000;
/** Twelve enter the SDK prompt; one extra preserves its omitted-activity row. */
@ -160,13 +174,19 @@ function buildSignal(signal?: AbortSignal): AbortSignal {
: timeout;
}
function definedPartIndices(parts: ReadonlyArray<LooseContentPart | null | undefined>): number[] {
return Object.keys(parts).map(Number);
}
function findLastPartIndex(
parts: ReadonlyArray<LooseContentPart | null | undefined>,
type: string,
): number {
for (let i = parts.length - 1; i >= 0; i--) {
if (parts[i]?.type === type) {
return i;
const indices = definedPartIndices(parts);
for (let position = indices.length - 1; position >= 0; position -= 1) {
const index = indices[position];
if (parts[index]?.type === type) {
return index;
}
}
return Math.max(0, parts.length - 1);
@ -177,14 +197,14 @@ function findBatchStart(
toolCallIds: Set<string>,
): number {
let first = -1;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
for (const index of definedPartIndices(parts)) {
const part = parts[index];
if (
part?.type === ContentTypes.TOOL_CALL &&
typeof part.tool_call?.id === 'string' &&
toolCallIds.has(part.tool_call.id)
) {
first = first < 0 ? i : Math.min(first, i);
first = first < 0 ? index : Math.min(first, index);
}
}
return first >= 0 ? first : Math.max(0, parts.length - 1);
@ -200,16 +220,114 @@ function findTrackedStart(
}
const excerpt = activity.thinkingExcerpts?.[0]?.trim();
if (excerpt) {
const needle = excerpt.slice(0, 80);
for (let i = 0; i < parts.length; i++) {
if (parts[i]?.type === ContentTypes.THINK && textValue(parts[i]?.think).includes(needle)) {
return i;
}
const reasoningStart = findReasoningExcerptStart(parts, excerpt);
if (reasoningStart != null) {
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(
anchors: Set<string>,
index: ReasoningAnchorIndex,
excerpt: string,
): void {
const anchor = excerpt.trim().slice(0, REASONING_ANCHOR_CHARS);
if (!anchor || anchors.has(anchor)) {
return;
}
anchors.add(anchor);
const matchingLength = index.get(anchor.length);
if (matchingLength != null) {
matchingLength.add(anchor);
} else {
index.set(anchor.length, new Set([anchor]));
}
}
function includesReasoningAnchor(text: string, index: ReasoningAnchorIndex): boolean {
for (const [length, anchors] of index) {
for (let offset = 0; offset <= text.length - length; offset += 1) {
if (anchors.has(text.slice(offset, offset + length))) {
return true;
}
}
}
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,
@ -237,7 +355,7 @@ function findReasoningStart(
if (startIndex != null && matches(parts[startIndex])) {
return startIndex;
}
for (let index = 0; index < parts.length; index += 1) {
for (const index of definedPartIndices(parts)) {
if (matches(parts[index])) {
return index;
}
@ -301,9 +419,9 @@ export function createAssistantPhaseStampingHandlers(
}
/**
* Collects run-wide logical activities and emits one parent summary at a text
* boundary. The summary call is detached; the boundary only pays the cheap
* synchronous slot claim needed to keep streamed content indices stable.
* Collects run-wide logical activities and emits one parent summary at an
* explicit final-answer boundary or root-run completion. The summary call is
* detached; final-answer streams only pay the synchronous slot reservation.
*/
export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): ActivityPhaseWiring {
const maxPerRun = deps.maxPerRun ?? DEFAULT_MAX_PER_RUN;
@ -312,15 +430,35 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
const initialSnapshot = deps.initialSnapshot?.version === 1 ? deps.initialSnapshot : undefined;
let generated = Math.max(
initialSnapshot?.generated ?? 0,
content.filter(
(part) => part?.type === ContentTypes.ACTIVITY_LABEL && part.activity_label_type === 'phase',
).length,
definedPartIndices(content).filter((index) => {
const part = content[index];
return part?.type === ContentTypes.ACTIVITY_LABEL && part.activity_label_type === 'phase';
}).length,
);
const initiallyMaterializedToolIds = new Set(
definedPartIndices(content).flatMap((index) => {
const part = content[index];
const id = part?.type === ContentTypes.TOOL_CALL ? part.tool_call?.id : undefined;
return typeof id === 'string' ? [id] : [];
}),
);
let activities: TrackedActivity[] =
initialSnapshot?.activities.map((activity) => ({
...activity,
startIndex: findTrackedStart(content, activity),
})) ?? [];
initialSnapshot?.activities.map((activity) => {
const { unresolvedToolStartIndex, ...retainedActivity } = activity;
const startIndex = findTrackedStart(content, activity);
const toolCallIds = activity.toolCallIds ?? [];
const hasUnresolvedTool = toolCallIds.some((id) => !initiallyMaterializedToolIds.has(id));
return {
...retainedActivity,
startIndex,
...(hasUnresolvedTool && {
unresolvedToolStartIndex: Math.max(
unresolvedToolStartIndex ?? activity.startIndex,
startIndex,
),
}),
};
}) ?? [];
let activityCount = initialSnapshot?.activityCount ?? activities.length;
let failedActivityCount =
initialSnapshot?.failedActivityCount ??
@ -328,23 +466,88 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
let partialActivityCount =
initialSnapshot?.partialActivityCount ??
activities.filter((activity) => activity.status === 'partial').length;
const overflowToolCallIds = new Set(initialSnapshot?.overflowToolCallIds ?? []);
const overflowBoundaryToolCallIds = new Set(
initialSnapshot?.overflowBoundaryToolCallIds ?? initialSnapshot?.overflowToolCallIds ?? [],
);
const materializedOverflowToolIds = new Set<string>();
const rebasedOverflowToolIndexes = definedPartIndices(content).filter((index) => {
const part = content[index];
const toolCallId = part?.type === ContentTypes.TOOL_CALL ? part.tool_call?.id : undefined;
const matches = typeof toolCallId === 'string' && overflowToolCallIds.has(toolCallId);
if (matches) {
materializedOverflowToolIds.add(toolCallId);
}
return matches;
});
const overflowReasoningAnchors = new Set<string>();
const overflowReasoningAnchorIndex: ReasoningAnchorIndex = new Map();
const initialOverflowReasoningAnchors =
initialSnapshot?.overflowReasoningAnchors ??
(initialSnapshot?.overflowReasoningExcerpt != null
? [initialSnapshot.overflowReasoningExcerpt]
: []);
for (const anchor of initialOverflowReasoningAnchors) {
addReasoningAnchor(overflowReasoningAnchors, overflowReasoningAnchorIndex, anchor);
}
let rebasedOverflowReasoningIndex: number | undefined;
if (overflowReasoningAnchors.size > 0) {
for (const index of definedPartIndices(content)) {
const part = content[index];
if (
part?.type === ContentTypes.THINK &&
includesReasoningAnchor(textValue(part.think), overflowReasoningAnchorIndex)
) {
rebasedOverflowReasoningIndex = index;
}
}
}
const rebasedOverflowStartIndex =
rebasedOverflowToolIndexes.length > 0
? Math.max(...rebasedOverflowToolIndexes, rebasedOverflowReasoningIndex ?? -1)
: rebasedOverflowReasoningIndex;
const hasUnresolvedBoundaryTool = [...overflowBoundaryToolCallIds].some(
(id) => !materializedOverflowToolIds.has(id),
);
let overflowActivityStartIndex = hasUnresolvedBoundaryTool
? Math.max(rebasedOverflowStartIndex ?? -1, initialSnapshot?.overflowActivityStartIndex ?? -1)
: (rebasedOverflowStartIndex ??
(overflowReasoningAnchors.size === 0
? initialSnapshot?.overflowActivityStartIndex
: undefined));
if (overflowActivityStartIndex != null && overflowActivityStartIndex < 0) {
overflowActivityStartIndex = undefined;
}
const contributingAgentIds = new Set(initialSnapshot?.agentIds ?? []);
let assistantContext = (initialSnapshot?.assistantContext ?? []).slice(-MAX_CONTEXT_ITEMS);
let assistantContext: AssistantContextEntry[] = (initialSnapshot?.assistantContext ?? [])
.slice(-MAX_CONTEXT_ITEMS)
.map((text) => ({ text }));
const pendingReasoning = new Map<string, { text: string; agentId?: string; startIndex?: number }>(
(initialSnapshot?.pendingReasoning ?? []).map(({ key, text, agentId, startIndex }) => [
key,
{
text: text.slice(-MAX_EXCERPT_CHARS),
...(agentId != null && { agentId }),
...(startIndex != null && { startIndex }),
},
]),
(initialSnapshot?.pendingReasoning ?? []).map(({ key, text, agentId, startIndex }) => {
const boundedText = text.slice(-MAX_EXCERPT_CHARS);
const needle = boundedText.trim().slice(0, REASONING_ANCHOR_CHARS);
const rebasedStartIndex = needle ? findReasoningStart(content, boundedText, startIndex) : -1;
const hasMaterializedReasoning =
needle.length > 0 &&
content[rebasedStartIndex]?.type === ContentTypes.THINK &&
textValue(content[rebasedStartIndex]?.think).includes(needle);
return [
key,
{
text: boundedText,
...(agentId != null && { agentId }),
...(hasMaterializedReasoning && { startIndex: rebasedStartIndex }),
},
] as const;
}),
);
const reasoningStepKeys = new Map<string, string>();
const stepKinds = new Map<
string,
{ kind: 'text' | 'think'; phase?: AssistantTextPhase; captureContext?: boolean }
>();
const textContextByStepId = new Map<string, AssistantContextEntry>();
let lastRootTextStepId: string | undefined;
const clear = () => {
activities = [];
@ -352,10 +555,17 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
pendingReasoning.clear();
reasoningStepKeys.clear();
stepKinds.clear();
textContextByStepId.clear();
activityCount = 0;
failedActivityCount = 0;
partialActivityCount = 0;
overflowActivityStartIndex = undefined;
overflowToolCallIds.clear();
overflowBoundaryToolCallIds.clear();
overflowReasoningAnchors.clear();
overflowReasoningAnchorIndex.clear();
contributingAgentIds.clear();
lastRootTextStepId = undefined;
};
const trackActivity = (activity: TrackedActivity) => {
@ -370,6 +580,26 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
}
if (activities.length < MAX_RETAINED_ACTIVITIES) {
activities.push(activity);
} else {
if (overflowActivityStartIndex == null || activity.startIndex > overflowActivityStartIndex) {
overflowActivityStartIndex = activity.startIndex;
overflowBoundaryToolCallIds.clear();
}
const reasoningExcerpts = activity.thinkingExcerpts;
const reasoningExcerpt = reasoningExcerpts?.[reasoningExcerpts.length - 1]?.trim();
if (reasoningExcerpt) {
addReasoningAnchor(
overflowReasoningAnchors,
overflowReasoningAnchorIndex,
reasoningExcerpt,
);
}
for (const id of activity.toolCallIds ?? []) {
overflowToolCallIds.add(id);
if (activity.startIndex === overflowActivityStartIndex) {
overflowBoundaryToolCallIds.add(id);
}
}
}
};
@ -380,6 +610,14 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
failedActivityCount,
partialActivityCount,
agentIds: [...contributingAgentIds],
...(overflowActivityStartIndex != null && { overflowActivityStartIndex }),
...(overflowToolCallIds.size > 0 && { overflowToolCallIds: [...overflowToolCallIds] }),
...(overflowActivityStartIndex != null && {
overflowBoundaryToolCallIds: [...overflowBoundaryToolCallIds],
}),
...(overflowReasoningAnchors.size > 0 && {
overflowReasoningAnchors: [...overflowReasoningAnchors],
}),
activities: activities.map((activity) => ({
...activity,
...(activity.entries != null && {
@ -396,7 +634,7 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
thinkingExcerpts: activity.thinkingExcerpts.map((text) => text.slice(-MAX_EXCERPT_CHARS)),
}),
})),
assistantContext: assistantContext.slice(-MAX_CONTEXT_ITEMS),
assistantContext: assistantContext.slice(-MAX_CONTEXT_ITEMS).map(({ text }) => text),
pendingReasoning: [...pendingReasoning].map(([key, reasoning]) => ({
key,
text: reasoning.text.slice(-MAX_EXCERPT_CHARS),
@ -431,7 +669,13 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
const resolveActivities = (snapshot: TrackedActivity[]): ActivityPhaseEntry[] => {
const parts = deps.getContentParts();
return snapshot.map(
({ childLabelIndex, toolCallIds, startIndex: _startIndex, ...activity }) => {
({
childLabelIndex,
toolCallIds,
startIndex: _startIndex,
unresolvedToolStartIndex: _unresolvedToolStartIndex,
...activity
}) => {
const matchesToolIds = (part: LooseContentPart | null | undefined): boolean => {
if (part?.type !== ContentTypes.ACTIVITY_LABEL || part.activity_label_type === 'phase') {
return false;
@ -444,7 +688,9 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
};
let child = childLabelIndex == null ? undefined : parts[childLabelIndex];
if (!matchesToolIds(child) && toolCallIds != null && toolCallIds.length > 0) {
child = parts.find(matchesToolIds);
child = definedPartIndices(parts)
.map((index) => parts[index])
.find(matchesToolIds);
}
if (!matchesToolIds(child)) {
return activity;
@ -456,14 +702,14 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
);
};
const close = (closingTextPhase?: AssistantTextPhase, hardBoundary = false) => {
const close = (closingTextPhase?: AssistantTextPhase, requestedEndIndex?: number) => {
addPendingReasoning();
if (generated >= maxPerRun) {
clear();
return;
}
if (activityCount < MIN_ACTIVITIES) {
if (hardBoundary) clear();
clear();
return;
}
@ -480,7 +726,25 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
const toolStart = findTrackedToolStart(currentParts, activity);
return toolStart != null ? { ...activity, startIndex: toolStart } : activity;
});
const contextSnapshot = [...assistantContext];
const contextSnapshot = assistantContext.map(({ text }) => text);
/** Completion-finalized phases leave the final root text outside their
* UI bounds. Remove its matching retained excerpt from the label prompt
* as well, or the parent can paraphrase the answer it does not contain.
* Search from the tail because identical intermediate/final text should
* discard only the most recent capture. */
if (requestedEndIndex != null) {
const excludedText = textValue(currentParts[requestedEndIndex]?.text)
.trim()
.slice(-MAX_EXCERPT_CHARS);
if (excludedText) {
for (let position = contextSnapshot.length - 1; position >= 0; position -= 1) {
if (contextSnapshot[position].trim() === excludedText) {
contextSnapshot.splice(position, 1);
break;
}
}
}
}
const totalActivityCount = activityCount;
const failedCount = failedActivityCount;
const partialCount = partialActivityCount;
@ -488,8 +752,14 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
/** 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. */
for (let i = startIndex - 1; i >= 0; i--) {
const prior = currentParts[i];
const definedIndices = definedPartIndices(currentParts);
let extendedStartIndex = 0;
for (let position = definedIndices.length - 1; position >= 0; position -= 1) {
const priorIndex = definedIndices[position];
if (priorIndex >= startIndex) {
continue;
}
const prior = currentParts[priorIndex];
if (
prior?.type === ContentTypes.STEER ||
(prior?.type === ContentTypes.ACTIVITY_LABEL && prior.activity_label_type === 'phase') ||
@ -497,10 +767,11 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
prior.phase === 'final_answer' &&
textValue(prior.text).trim().length > 0)
) {
extendedStartIndex = priorIndex + 1;
break;
}
startIndex = i;
}
startIndex = extendedStartIndex;
const agentIds = [...contributingAgentIds];
let phaseStatus: 'ok' | 'partial' | 'failed' = 'ok';
if (failedCount === totalActivityCount) {
@ -509,11 +780,13 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
phaseStatus = 'partial';
}
const index = deps.getContentParts().length;
const endIndex = Math.max(startIndex, Math.min(index, requestedEndIndex ?? index));
const part: LooseContentPart = {
type: ContentTypes.ACTIVITY_LABEL,
[ContentTypes.ACTIVITY_LABEL]: '',
activity_label_type: 'phase',
activity_start_index: startIndex,
activity_end_index: endIndex,
activity_count: totalActivityCount,
...(agentIds.length > 0 && { agent_ids: agentIds }),
status: phaseStatus,
@ -583,15 +856,27 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
const ids = new Set(input.entries.map((entry) => entry.toolUseId));
const parts = deps.getContentParts();
let childLabelIndex: number | undefined;
for (let i = parts.length - 1; i >= 0; i--) {
const part = parts[i];
if (part?.type !== ContentTypes.ACTIVITY_LABEL || part.activity_label_type === 'phase') {
continue;
let batchStartIndex: number | undefined;
const indices = definedPartIndices(parts);
for (let position = indices.length - 1; position >= 0; position -= 1) {
const index = indices[position];
const part = parts[index];
if (
part?.type === ContentTypes.TOOL_CALL &&
typeof part.tool_call?.id === 'string' &&
ids.has(part.tool_call.id)
) {
batchStartIndex = index;
}
const childIds = Array.isArray(part.tool_call_ids) ? part.tool_call_ids : [];
if (childIds.some((id) => typeof id === 'string' && ids.has(id))) {
childLabelIndex = i;
break;
if (
childLabelIndex == null &&
part?.type === ContentTypes.ACTIVITY_LABEL &&
part.activity_label_type !== 'phase'
) {
const childIds = Array.isArray(part.tool_call_ids) ? part.tool_call_ids : [];
if (childIds.some((id) => typeof id === 'string' && ids.has(id))) {
childLabelIndex = index;
}
}
}
const entries = input.entries.map((entry: BatchEntry) => ({
@ -613,7 +898,7 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
...(reasoning ? { thinkingExcerpts: [reasoning.slice(0, MAX_EXCERPT_CHARS)] } : {}),
...(input.executingAgentId != null && { agentId: input.executingAgentId }),
status: activityStatus,
startIndex: findBatchStart(parts, ids),
startIndex: batchStartIndex ?? Math.max(0, parts.length - 1),
toolCallIds: [...ids],
...(childLabelIndex != null && { childLabelIndex }),
});
@ -664,26 +949,35 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
}
return result;
} else {
if (step.groupId == null) {
/** `final_answer` closes immediately before its text streams.
* Commentary does not: if it is the root run's last text,
* completion must leave it outside the parent like any
* unphased answer. Later activities still invalidate this
* candidate in `complete`. */
lastRootTextStepId = phase === 'final_answer' ? undefined : step.id;
}
if (phase === 'final_answer' && step.groupId == null) {
addPendingReasoning(step.agentId ?? 'root');
stepKinds.set(step.id, { kind, phase, captureContext: false });
close(phase, true);
close(phase);
} else {
if (phase == null && step.groupId == null) {
addPendingReasoning(step.agentId ?? 'root');
}
const closesPhase =
phase == null && step.groupId == null && activityCount >= MIN_ACTIVITIES;
stepKinds.set(step.id, {
kind,
...(phase != null && { phase }),
captureContext: !closesPhase,
captureContext: true,
});
if (closesPhase) {
close(undefined, false);
} else {
assistantContext.push('');
if (assistantContext.length > MAX_CONTEXT_ITEMS) assistantContext.shift();
const contextEntry: AssistantContextEntry = { stepId: step.id, text: '' };
assistantContext.push(contextEntry);
textContextByStepId.set(step.id, contextEntry);
if (assistantContext.length > MAX_CONTEXT_ITEMS) {
const removed = assistantContext.shift();
if (removed?.stepId != null) {
textContextByStepId.delete(removed.stepId);
}
}
}
}
@ -701,14 +995,9 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
const tracked = id ? stepKinds.get(id) : undefined;
if (tracked?.kind === 'text' && tracked.captureContext === true) {
const text = deltaText(data, 'text');
if (text) {
const last = assistantContext.length - 1;
const next = `${last >= 0 ? assistantContext[last] : ''}${text}`.slice(
-MAX_EXCERPT_CHARS,
);
if (last >= 0) assistantContext[last] = next;
else assistantContext.push(next);
if (assistantContext.length > MAX_CONTEXT_ITEMS) assistantContext.shift();
const contextEntry = id ? textContextByStepId.get(id) : undefined;
if (text && contextEntry != null) {
contextEntry.text = `${contextEntry.text}${text}`.slice(-MAX_EXCERPT_CHARS);
}
}
return messageHandler.handle(event, data, metadata, graph);
@ -735,5 +1024,112 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity
return wrapped;
};
return { hook, handlers: wrapHandlers, drop: clear, snapshot };
const complete = () => {
let finalTextIndex =
lastRootTextStepId == null ? undefined : deps.getStepIndex?.(lastRootTextStepId);
const parts = deps.getContentParts();
if (
finalTextIndex != null &&
(parts[finalTextIndex]?.type !== ContentTypes.TEXT ||
!textValue(parts[finalTextIndex]?.text).trim())
) {
finalTextIndex = undefined;
}
/** The host step map is an event-coordinate hint, not the authoritative
* rendered position. Activity-label reservations advance the shared
* content offset after earlier steps were indexed, and a provider can
* materialize the final text at a later slot. Always reconcile against
* the live parts so a stale-but-defined step index cannot pull the final
* answer into the parent phase. */
const definedIndices = Object.keys(parts);
for (let position = definedIndices.length - 1; position >= 0; position -= 1) {
const index = Number(definedIndices[position]);
const part = parts[index];
/** The UI contract is the last materialized TEXT part, not the last
* part whose provider lane metadata happens to look root-scoped.
* Some MCP runs retain a groupId on their final response, so even a
* `final_answer` part may not have taken the immediate-close branch.
* An already-closed phase has no remaining activities and completion
* is a no-op. The later-activity checks below still reject an
* intermediate lane text when tools or reasoning follow it. */
if (part?.type === ContentTypes.TEXT && textValue(part.text).trim()) {
finalTextIndex = index;
break;
}
}
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);
};
return { hook, handlers: wrapHandlers, drop: clear, complete, snapshot };
}

View file

@ -201,6 +201,8 @@ export type TSubmission = {
* resumes for run steps and activity labels alike.
*/
editPrefixLength?: number;
/** True once server index 0 text/reasoning actually merged into the retained tail. */
editPrefixFirstPartFolded?: boolean;
/**
* Set once a resume SYNC has replaced the response's retained prefix with
* the server's completion-local snapshot. From that point the prefix is

View file

@ -662,6 +662,8 @@ export type TMessageContentParts =
tool_call_ids?: string[];
/** Parent phase bounds and telemetry. */
activity_start_index?: number;
/** Exclusive end of the grouped content; may precede the marker itself. */
activity_end_index?: number;
activity_count?: number;
agent_ids?: string[];
/** ok = all tools succeeded, failed = all failed, partial = mixed. */

View file

@ -102,6 +102,7 @@ export type TActivityLabelEvent = {
activity_label_type?: 'phase';
tool_call_ids?: string[];
activity_start_index?: number;
activity_end_index?: number;
activity_count?: number;
agent_ids?: string[];
counts?: {