diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 401834dada..19d4619bad 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -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, + }), }; }); diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index fd5b9aeb8a..77848851a2 100644 --- a/api/app/clients/specs/BaseClient.test.js +++ b/api/app/clients/specs/BaseClient.test.js @@ -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 }, ]); }); diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index fc0747419c..ec94924120 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -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; diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index b68bc29f0c..8c1a99ab0f 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -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(); diff --git a/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx b/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx index cf73a7e9f3..7bf86bab8f 100644 --- a/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx +++ b/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx @@ -10,6 +10,7 @@ import { cn } from '~/utils'; type ActivityPhasePart = Extract & { activity_label_type?: 'phase'; activity_start_index?: number; + activity_end_index?: number; }; export default function ActivityPhaseGroup({ diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index e22b940ff8..870e6d6cd2 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -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; /** Message-wide steer attribution retained across nested phase segments. */ resumeAuthors?: ReadonlyMap; /** 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 ( void) => { - const localIdx = idx - contentIndexOffset; + const localIdx = localIndexByAbsolute?.get(idx) ?? idx - contentIndexOffset; return ( , segmentStartIndex: number, + segmentIndices: ReadonlyArray, key: string, ) => { - const localLastContentIdx = globalLastContentIdx - segmentStartIndex; return ( @@ -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}`, )} ) : ( 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} /> ); diff --git a/client/src/components/Chat/Messages/Content/ParallelContent.tsx b/client/src/components/Chat/Messages/Content/ParallelContent.tsx index 23ef8ec695..6b3dbbbbf5 100644 --- a/client/src/components/Chat/Messages/Content/ParallelContent.tsx +++ b/client/src/components/Chat/Messages/Content/ParallelContent.tsx @@ -36,6 +36,7 @@ export type ParallelSection = { export function groupParallelContent( content: Array | undefined, contentIndexOffset = 0, + contentIndices?: ReadonlyArray, ): { 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; }; /** @@ -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(() => { diff --git a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx index 61f5b2cdb8..e0a5951a71 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx @@ -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(); + + 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, diff --git a/client/src/components/Chat/Messages/Content/__tests__/ParallelContent.test.ts b/client/src/components/Chat/Messages/Content/__tests__/ParallelContent.test.ts index 2bcff82a2f..1633d7a55d 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ParallelContent.test.ts +++ b/client/src/components/Chat/Messages/Content/__tests__/ParallelContent.test.ts @@ -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 }, + ]); + }); }); diff --git a/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts b/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts index f74700172c..6b32b81aea 100644 --- a/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts +++ b/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts @@ -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', () => { diff --git a/client/src/hooks/SSE/useResumableSSE.ts b/client/src/hooks/SSE/useResumableSSE.ts index 13634ea004..249da43cdf 100644 --- a/client/src/hooks/SSE/useResumableSSE.ts +++ b/client/src/hooks/SSE/useResumableSSE.ts @@ -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) => - rawStepHandler( - event, - editPrefixClearedRef.current - ? ({ ...submission, editPrefixCleared: true } as EventSubmission) - : submission, - ), + (...[event, submission]: Parameters) => { + 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 = { diff --git a/client/src/hooks/SSE/useStepHandler.ts b/client/src/hooks/SSE/useStepHandler.ts index 57ec442cf2..e2487ca00a 100644 --- a/client/src/hooks/SSE/useStepHandler.ts +++ b/client/src/hooks/SSE/useStepHandler.ts @@ -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, diff --git a/client/src/utils/__tests__/activityLabels.spec.ts b/client/src/utils/__tests__/activityLabels.spec.ts index 37545d7af1..50fb24b20d 100644 --- a/client/src/utils/__tests__/activityLabels.spec.ts +++ b/client/src/utils/__tests__/activityLabels.spec.ts @@ -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(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(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 }); diff --git a/client/src/utils/activityLabels.ts b/client/src/utils/activityLabels.ts index acb2e0cba8..fb99bab66b 100644 --- a/client/src/utils/activityLabels.ts +++ b/client/src/utils/activityLabels.ts @@ -4,6 +4,7 @@ import type { TMessage, TActivityLabelEvent, TMessageContentParts } from 'librec type ActivityLabelPart = Extract & { 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; + contentIndices: number[]; startIndex: number; } | { type: 'phase'; content: Array; + contentIndices: number[]; startIndex: number; labelPart: ActivityLabelPart; labelIndex: number; @@ -33,6 +36,66 @@ function isVisibleContentPart(part: TMessageContentParts | undefined): boolean { ); } +function isLogicallyEarlierPhaseMarker( + parts: ReadonlyArray, + 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, +): Set { + const consumed = new Set(); + 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 | 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, + contentIndices: [] as number[], + hasContent: false, + }); + const append = (segment: ReturnType, 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 | 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[]; diff --git a/e2e/specs/mock/activity-phases.spec.ts b/e2e/specs/mock/activity-phases.spec.ts index d11a0da28b..3fa674b762 100644 --- a/e2e/specs/mock/activity-phases.spec.ts +++ b/e2e/specs/mock/activity-phases.spec.ts @@ -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( + 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}"]`); diff --git a/packages/api/src/agents/activityLabels/__tests__/wiring.spec.ts b/packages/api/src/agents/activityLabels/__tests__/wiring.spec.ts index d1abaf5406..0e2aaba489 100644 --- a/packages/api/src/agents/activityLabels/__tests__/wiring.spec.ts +++ b/packages/api/src/agents/activityLabels/__tests__/wiring.spec.ts @@ -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); }); diff --git a/packages/api/src/agents/activityLabels/wiring.ts b/packages/api/src/agents/activityLabels/wiring.ts index 2e3583bc4b..35c0905900 100644 --- a/packages/api/src/agents/activityLabels/wiring.ts +++ b/packages/api/src/agents/activityLabels/wiring.ts @@ -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) { diff --git a/packages/api/src/agents/activityPhases/runtime.spec.ts b/packages/api/src/agents/activityPhases/runtime.spec.ts index 3c20c65d28..5f217a6bb2 100644 --- a/packages/api/src/agents/activityPhases/runtime.spec.ts +++ b/packages/api/src/agents/activityPhases/runtime.spec.ts @@ -85,6 +85,7 @@ describe('createActivityPhaseWiring', () => { type: ContentTypes.ACTIVITY_LABEL, activity_label_type: 'phase', activity_start_index: 0, + activity_end_index: 2, activity_count: 2, pending: true, }); @@ -105,6 +106,72 @@ describe('createActivityPhaseWiring', () => { expect(emitLabelEvent).toHaveBeenCalledTimes(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' } }, + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-2' } }, + ]; + const generatePhase = jest.fn(async () => ({ label: 'Compared both parallel findings' })); + const wiring = createActivityPhaseWiring({ + getContentParts: () => parts, + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase, + }); + 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: jest.fn() }, + }); + const emitTextStep = (id: string, agentId: string) => + handlers?.[GraphEvents.ON_RUN_STEP]?.handle( + GraphEvents.ON_RUN_STEP, + { + id, + agentId, + groupId: agentId, + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { message_id: 'm', content_type: 'text' }, + }, + }, + undefined, + undefined, + ); + const emitTextDelta = (id: string, text: string) => + handlers?.[GraphEvents.ON_MESSAGE_DELTA]?.handle( + GraphEvents.ON_MESSAGE_DELTA, + { id, delta: { content: { type: ContentTypes.TEXT, text } } }, + undefined, + undefined, + ); + + emitTextStep('lane-a', 'agent-a'); + emitTextStep('lane-b', 'agent-b'); + emitTextDelta('lane-a', 'First lane '); + emitTextDelta('lane-b', 'Second lane'); + emitTextDelta('lane-a', 'completed'); + handlers?.[GraphEvents.ON_RUN_STEP]?.handle( + GraphEvents.ON_RUN_STEP, + { + id: 'root-final', + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { message_id: 'm', content_type: 'text', phase: 'final_answer' }, + }, + }, + undefined, + undefined, + ); + + await flushDetached(); + expect(generatePhase).toHaveBeenCalledWith( + expect.objectContaining({ assistantContext: ['First lane completed', 'Second lane'] }), + ); + }); + it('reanchors a tool that lands after the phase hook observes its child label', async () => { const parts: LooseContentPart[] = []; const wiring = createActivityPhaseWiring({ @@ -702,6 +769,134 @@ describe('createActivityPhaseWiring', () => { expect(parts[parts.length - 1]).toMatchObject({ activity_start_index: 0 }); }); + it('drops a stale pending-reasoning index after HITL content compaction', 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: 'The resumed answer is complete.' }, + ]; + const wiring = createActivityPhaseWiring({ + initialSnapshot: { + version: 1, + generated: 0, + activityCount: 2, + failedActivityCount: 0, + partialActivityCount: 0, + agentIds: [], + activities: [ + { startIndex: 0, status: 'success', toolCallIds: ['tool-1'] }, + { startIndex: 1, status: 'success', toolCallIds: ['tool-2'] }, + ], + assistantContext: [], + pendingReasoning: [ + { + key: 'root', + text: 'Reasoning removed by hide_sequential_outputs.', + startIndex: 20, + }, + ], + }, + getContentParts: () => parts, + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase: jest.fn(async () => ({ label: 'Completed the resumed workflow' })), + }); + + wiring.complete(); + await flushDetached(); + + expect(parts[3]).toMatchObject({ activity_end_index: 2, activity_count: 3 }); + }); + + it('resolves index-less pending reasoning before preserving the final-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: 'This answer preceded more reasoning.' }, + ]; + const wiring = createActivityPhaseWiring({ + getContentParts: () => parts, + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase: jest.fn(async () => ({ label: 'Completed the extended investigation' })), + }); + 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_REASONING_DELTA]: { handle: jest.fn() }, + }); + handlers?.[GraphEvents.ON_RUN_STEP]?.handle( + GraphEvents.ON_RUN_STEP, + { + id: 'late-reasoning', + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { message_id: 'm', content_type: 'think' }, + }, + }, + undefined, + undefined, + ); + parts[3] = { type: ContentTypes.THINK, think: 'Verified one more edge case.' }; + handlers?.[GraphEvents.ON_REASONING_DELTA]?.handle( + GraphEvents.ON_REASONING_DELTA, + { + id: 'late-reasoning', + delta: { + content: { type: ContentTypes.THINK, think: 'Verified one more edge case.' }, + }, + }, + undefined, + undefined, + ); + + wiring.complete(); + await flushDetached(); + + expect(parts[4]).toMatchObject({ activity_end_index: 4, activity_count: 3 }); + }); + + it('keeps a partially materialized retained batch after the candidate final text', async () => { + const parts: LooseContentPart[] = [ + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'batch-a' } }, + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-2' } }, + { type: ContentTypes.TEXT, text: 'This answer preceded the delayed batch tool.' }, + ]; + const wiring = createActivityPhaseWiring({ + initialSnapshot: { + version: 1, + generated: 0, + activityCount: 2, + failedActivityCount: 0, + partialActivityCount: 0, + agentIds: [], + activities: [ + { + startIndex: 20, + status: 'success', + toolCallIds: ['batch-a', 'batch-b'], + }, + { startIndex: 1, status: 'success', toolCallIds: ['tool-2'] }, + ], + assistantContext: [], + pendingReasoning: [], + }, + getContentParts: () => parts, + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase: jest.fn(async () => ({ label: 'Completed the delayed batch workflow' })), + }); + + wiring.complete(); + await flushDetached(); + + expect(parts[3]).toMatchObject({ activity_end_index: 3, activity_count: 2 }); + }); + it('bounds persisted evidence while preserving the full activity count', async () => { const parts: LooseContentPart[] = []; const wiring = createActivityPhaseWiring({ @@ -720,6 +915,686 @@ describe('createActivityPhaseWiring', () => { const snapshot = wiring.snapshot(); expect(snapshot.activityCount).toBe(20); expect(snapshot.activities).toHaveLength(13); + expect(snapshot.overflowActivityStartIndex).toBe(19); + expect(snapshot.overflowToolCallIds).toHaveLength(7); + }); + + it('retains every overflow tool ID tied at the latest boundary', async () => { + const parts: LooseContentPart[] = []; + const wiring = createActivityPhaseWiring({ + getContentParts: () => parts, + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase: jest.fn(async () => ({})), + }); + for (let index = 0; index < 13; index += 1) { + await wiring.hook(batch(`retained-${index}`), new AbortController().signal); + } + await wiring.hook(batch('overflow-a'), new AbortController().signal); + await wiring.hook(batch('overflow-b'), new AbortController().signal); + + expect(wiring.snapshot()).toMatchObject({ + overflowActivityStartIndex: 0, + overflowToolCallIds: ['overflow-a', 'overflow-b'], + }); + }); + + it('rebases a resumed overflow anchor after content compaction', async () => { + const parts: LooseContentPart[] = Array.from({ length: 13 }, (_, index) => ({ + type: ContentTypes.TOOL_CALL, + tool_call: { id: `retained-${index}` }, + })); + parts.push( + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'overflow-tool' } }, + { type: ContentTypes.TEXT, text: 'The compacted answer is complete.' }, + ); + const generatePhase = jest.fn(async () => ({ label: 'Completed the resumed workflow' })); + const wiring = createActivityPhaseWiring({ + initialSnapshot: { + version: 1, + generated: 0, + activityCount: 14, + failedActivityCount: 0, + partialActivityCount: 0, + agentIds: [], + activities: Array.from({ length: 13 }, (_, index) => ({ + startIndex: index, + status: 'success' as const, + toolCallIds: [`retained-${index}`], + })), + overflowActivityStartIndex: 30, + overflowToolCallIds: ['overflow-tool'], + assistantContext: [], + pendingReasoning: [], + }, + getContentParts: () => parts, + getStepIndex: (stepId) => (stepId === 'root-text' ? 14 : undefined), + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase, + }); + wiring + .handlers({ [GraphEvents.ON_RUN_STEP]: { handle: jest.fn() } }) + ?.[GraphEvents.ON_RUN_STEP]?.handle( + GraphEvents.ON_RUN_STEP, + { + id: 'root-text', + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { message_id: 'm', content_type: 'text' }, + }, + }, + undefined, + undefined, + ); + + wiring.complete(); + await flushDetached(); + + expect(parts[15]).toMatchObject({ activity_end_index: 14, activity_count: 14 }); + }); + + it('drops a stale reasoning-only overflow anchor after HITL compaction', async () => { + const parts: LooseContentPart[] = [ + { type: ContentTypes.TEXT, text: 'The compacted answer is complete.' }, + ]; + const generatePhase = jest.fn(async () => ({ label: 'Completed the resumed workflow' })); + const wiring = createActivityPhaseWiring({ + initialSnapshot: { + version: 1, + generated: 0, + activityCount: 14, + failedActivityCount: 0, + partialActivityCount: 0, + agentIds: [], + activities: Array.from({ length: 13 }, (_, index) => ({ + startIndex: 0, + status: 'success' as const, + thinkingExcerpts: [`Retained reasoning ${index} that was filtered on pause.`], + })), + overflowActivityStartIndex: 30, + overflowReasoningExcerpt: 'Overflow reasoning that was filtered on pause.', + assistantContext: [], + pendingReasoning: [], + }, + getContentParts: () => parts, + getStepIndex: (stepId) => (stepId === 'root-text' ? 0 : undefined), + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase, + }); + wiring + .handlers({ [GraphEvents.ON_RUN_STEP]: { handle: jest.fn() } }) + ?.[GraphEvents.ON_RUN_STEP]?.handle( + GraphEvents.ON_RUN_STEP, + { + id: 'root-text', + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { message_id: 'm', content_type: 'text' }, + }, + }, + undefined, + undefined, + ); + + wiring.complete(); + await flushDetached(); + + expect(parts[1]).toMatchObject({ activity_end_index: 0, activity_count: 14 }); + }); + + it('rejects a text boundary when a duplicate reasoning anchor follows it', async () => { + const duplicate = 'The same reasoning prefix identifies both retained activities.'; + const parts: LooseContentPart[] = [ + { type: ContentTypes.THINK, think: duplicate }, + { type: ContentTypes.TEXT, text: 'This looked like the final answer.' }, + { type: ContentTypes.THINK, think: `${duplicate} Later activity details.` }, + ]; + const wiring = createActivityPhaseWiring({ + initialSnapshot: { + version: 1, + generated: 0, + activityCount: 2, + failedActivityCount: 0, + partialActivityCount: 0, + agentIds: [], + activities: [ + { startIndex: 0, status: 'success', thinkingExcerpts: [duplicate] }, + { startIndex: 2, status: 'success', thinkingExcerpts: [duplicate] }, + ], + assistantContext: [], + pendingReasoning: [], + }, + getContentParts: () => parts, + getStepIndex: (stepId) => (stepId === 'root-text' ? 1 : undefined), + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase: jest.fn(async () => ({ label: 'Completed both reasoning passes' })), + }); + wiring + .handlers({ [GraphEvents.ON_RUN_STEP]: { handle: jest.fn() } }) + ?.[GraphEvents.ON_RUN_STEP]?.handle( + GraphEvents.ON_RUN_STEP, + { + id: 'root-text', + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { message_id: 'm', content_type: 'text' }, + }, + }, + undefined, + undefined, + ); + + wiring.complete(); + await flushDetached(); + + expect(parts[3]).toMatchObject({ activity_end_index: 3, activity_count: 2 }); + }); + + it('rejects a text boundary when a duplicate overflow reasoning anchor follows it', async () => { + const duplicate = 'The overflow reasoning prefix is shared by both positions.'; + const parts: LooseContentPart[] = [ + { type: ContentTypes.THINK, think: duplicate }, + { type: ContentTypes.TEXT, text: 'This looked like the final overflow answer.' }, + { type: ContentTypes.THINK, think: `${duplicate} Later overflow details.` }, + ]; + const wiring = createActivityPhaseWiring({ + initialSnapshot: { + version: 1, + generated: 0, + activityCount: 14, + failedActivityCount: 0, + partialActivityCount: 0, + agentIds: [], + activities: Array.from({ length: 13 }, () => ({ + startIndex: 0, + status: 'success' as const, + })), + overflowActivityStartIndex: 2, + overflowReasoningExcerpt: duplicate, + assistantContext: [], + pendingReasoning: [], + }, + getContentParts: () => parts, + getStepIndex: (stepId) => (stepId === 'root-text' ? 1 : undefined), + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase: jest.fn(async () => ({ label: 'Completed overflow reasoning' })), + }); + wiring + .handlers({ [GraphEvents.ON_RUN_STEP]: { handle: jest.fn() } }) + ?.[GraphEvents.ON_RUN_STEP]?.handle( + GraphEvents.ON_RUN_STEP, + { + id: 'root-text', + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { message_id: 'm', content_type: 'text' }, + }, + }, + undefined, + undefined, + ); + + wiring.complete(); + await flushDetached(); + + expect(parts[3]).toMatchObject({ activity_end_index: 3, activity_count: 14 }); + }); + + it('checks every overflow reasoning anchor when delayed parts materialize out of order', async () => { + const earlier = 'The earlier overflow activity finished after the apparent answer.'; + const later = 'The later overflow activity materialized before the apparent answer.'; + const parts: LooseContentPart[] = [ + { type: ContentTypes.THINK, think: later }, + { type: ContentTypes.TEXT, text: 'This looked like the final overflow answer.' }, + { type: ContentTypes.THINK, think: earlier }, + ]; + const wiring = createActivityPhaseWiring({ + initialSnapshot: { + version: 1, + generated: 0, + activityCount: 15, + failedActivityCount: 0, + partialActivityCount: 0, + agentIds: [], + activities: Array.from({ length: 13 }, () => ({ + startIndex: 0, + status: 'success' as const, + })), + overflowActivityStartIndex: 2, + overflowReasoningAnchors: [earlier, later], + assistantContext: [], + pendingReasoning: [], + }, + getContentParts: () => parts, + getStepIndex: (stepId) => (stepId === 'root-text' ? 1 : undefined), + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase: jest.fn(async () => ({ label: 'Completed overflow reasoning' })), + }); + wiring + .handlers({ [GraphEvents.ON_RUN_STEP]: { handle: jest.fn() } }) + ?.[GraphEvents.ON_RUN_STEP]?.handle( + GraphEvents.ON_RUN_STEP, + { + id: 'root-text', + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { message_id: 'm', content_type: 'text' }, + }, + }, + undefined, + undefined, + ); + + wiring.complete(); + await flushDetached(); + + expect(parts[3]).toMatchObject({ activity_end_index: 3, activity_count: 15 }); + }); + + it('retains an earlier overflow ID when delayed tools materialize in reverse order', async () => { + const parts: LooseContentPart[] = [ + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'overflow-b' } }, + { type: ContentTypes.TEXT, text: 'This answer arrived between delayed tools.' }, + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'overflow-a' } }, + ]; + const generatePhase = jest.fn(async () => ({ label: 'Completed the delayed workflow' })); + const wiring = createActivityPhaseWiring({ + initialSnapshot: { + version: 1, + generated: 0, + activityCount: 15, + failedActivityCount: 0, + partialActivityCount: 0, + agentIds: [], + activities: Array.from({ length: 13 }, () => ({ + startIndex: 0, + status: 'success' as const, + })), + overflowActivityStartIndex: 20, + overflowToolCallIds: ['overflow-a', 'overflow-b'], + assistantContext: [], + pendingReasoning: [], + }, + getContentParts: () => parts, + getStepIndex: (stepId) => (stepId === 'root-text' ? 1 : undefined), + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase, + }); + wiring + .handlers({ [GraphEvents.ON_RUN_STEP]: { handle: jest.fn() } }) + ?.[GraphEvents.ON_RUN_STEP]?.handle( + GraphEvents.ON_RUN_STEP, + { + id: 'root-text', + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { message_id: 'm', content_type: 'text' }, + }, + }, + undefined, + undefined, + ); + + wiring.complete(); + await flushDetached(); + + expect(parts[3]).toMatchObject({ activity_end_index: 3, activity_count: 15 }); + }); + + it('keeps the overflow fallback while a boundary tool remains unresolved', async () => { + const parts: LooseContentPart[] = [ + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'overflow-a' } }, + { type: ContentTypes.TEXT, text: 'This answer preceded a delayed overflow tool.' }, + ]; + const wiring = createActivityPhaseWiring({ + initialSnapshot: { + version: 1, + generated: 0, + activityCount: 15, + failedActivityCount: 0, + partialActivityCount: 0, + agentIds: [], + activities: Array.from({ length: 13 }, () => ({ + startIndex: 0, + status: 'success' as const, + })), + overflowActivityStartIndex: 20, + overflowToolCallIds: ['overflow-a', 'overflow-b'], + overflowBoundaryToolCallIds: ['overflow-a', 'overflow-b'], + assistantContext: [], + pendingReasoning: [], + }, + getContentParts: () => parts, + getStepIndex: (stepId) => (stepId === 'root-text' ? 1 : undefined), + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase: jest.fn(async () => ({ label: 'Completed the delayed workflow' })), + }); + wiring + .handlers({ [GraphEvents.ON_RUN_STEP]: { handle: jest.fn() } }) + ?.[GraphEvents.ON_RUN_STEP]?.handle( + GraphEvents.ON_RUN_STEP, + { + id: 'root-text', + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { message_id: 'm', content_type: 'text' }, + }, + }, + undefined, + undefined, + ); + + wiring.complete(); + await flushDetached(); + + expect(parts[2]).toMatchObject({ activity_end_index: 2, activity_count: 15 }); + }); + + it('extends a sparse phase start using only defined boundary slots', async () => { + const parts: LooseContentPart[] = []; + parts[999_998] = { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-a' } }; + parts[1_000_000] = { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-b' } }; + const wiring = createActivityPhaseWiring({ + initialSnapshot: { + version: 1, + generated: 0, + activityCount: 2, + failedActivityCount: 0, + partialActivityCount: 0, + agentIds: [], + activities: [ + { startIndex: 999_998, status: 'success', toolCallIds: ['tool-a'] }, + { startIndex: 1_000_000, status: 'success', toolCallIds: ['tool-b'] }, + ], + assistantContext: [], + pendingReasoning: [], + }, + getContentParts: () => parts, + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase: jest.fn(async () => ({ label: 'Completed the sparse workflow' })), + }); + + wiring.complete(); + await flushDetached(); + + 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 () => { + const parts: LooseContentPart[] = []; + const generatePhase = jest.fn(async () => ({ label: 'Completed the extended investigation' })); + const wiring = createActivityPhaseWiring({ + getContentParts: () => parts, + getStepIndex: (stepId) => (stepId === 'root-text' ? 13 : undefined), + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase, + }); + for (let index = 0; index < 13; index += 1) { + const id = `tool-${index}`; + parts.push({ type: ContentTypes.TOOL_CALL, tool_call: { id } }); + await wiring.hook(batch(id), new AbortController().signal); + } + wiring + .handlers({ [GraphEvents.ON_RUN_STEP]: { handle: jest.fn() } }) + ?.[GraphEvents.ON_RUN_STEP]?.handle( + GraphEvents.ON_RUN_STEP, + { + id: 'root-text', + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { message_id: 'm', content_type: 'text' }, + }, + }, + undefined, + undefined, + ); + parts[13] = { type: ContentTypes.TEXT, text: 'This may be the final answer.' }; + parts[14] = { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-overflow' } }; + await wiring.hook(batch('tool-overflow'), new AbortController().signal); + + wiring.complete(); + await flushDetached(); + + expect(generatePhase).toHaveBeenCalledTimes(1); + expect(parts[15]).toMatchObject({ + activity_start_index: 0, + activity_end_index: 15, + activity_count: 14, + }); + }); + + it('finds an unphased final content part without a retained run-step 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: 'The persisted answer is complete.' }, + ]; + const generatePhase = jest.fn(async () => ({ label: 'Completed the persisted workflow' })); + const wiring = createActivityPhaseWiring({ + getContentParts: () => parts, + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase, + }); + await wiring.hook(batch('tool-1'), new AbortController().signal); + await wiring.hook(batch('tool-2'), new AbortController().signal); + + wiring.complete(); + await flushDetached(); + + expect(parts[3]).toMatchObject({ + activity_start_index: 0, + activity_end_index: 2, + activity_count: 2, + }); + }); + + it('excludes the persisted final text from the phase summary context', async () => { + const finalText = 'The persisted answer is complete.'; + 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: finalText }, + ]; + const generatePhase = jest.fn(async () => ({ label: 'Completed the persisted workflow' })); + const wiring = createActivityPhaseWiring({ + initialSnapshot: { + version: 1, + generated: 0, + activityCount: 2, + failedActivityCount: 0, + partialActivityCount: 0, + agentIds: [], + activities: [ + { startIndex: 0, status: 'success', toolCallIds: ['tool-1'] }, + { startIndex: 1, status: 'success', toolCallIds: ['tool-2'] }, + ], + assistantContext: ['I will inspect both sources.', finalText], + pendingReasoning: [], + }, + getContentParts: () => parts, + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase, + }); + + wiring.complete(); + await flushDetached(); + + expect(generatePhase).toHaveBeenCalledWith( + expect.objectContaining({ assistantContext: ['I will inspect both sources.'] }), + ); + expect(parts[3]).toMatchObject({ activity_end_index: 2, activity_count: 2 }); + }); + + it('prefers the materialized final text over a stale retained step index', async () => { + const parts: LooseContentPart[] = [ + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-1' } }, + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-2' } }, + ]; + const generatePhase = jest.fn(async () => ({ label: 'Completed the indexed workflow' })); + const wiring = createActivityPhaseWiring({ + getContentParts: () => parts, + getStepIndex: (stepId) => (stepId === 'root-text' ? 3 : undefined), + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase, + }); + await wiring.hook(batch('tool-1'), new AbortController().signal); + await wiring.hook(batch('tool-2'), new AbortController().signal); + wiring + .handlers({ + [GraphEvents.ON_RUN_STEP]: { handle: jest.fn() }, + }) + ?.[GraphEvents.ON_RUN_STEP]?.handle( + GraphEvents.ON_RUN_STEP, + { + id: 'root-text', + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { message_id: 'm', content_type: 'text' }, + }, + }, + undefined, + undefined, + ); + parts[2] = { type: ContentTypes.TEXT, text: 'The indexed answer is complete.' }; + + wiring.complete(); + await flushDetached(); + + expect(parts[3]).toMatchObject({ + activity_start_index: 0, + activity_end_index: 2, + activity_count: 2, + }); + }); + + it('leaves the last materialized text outside even when it retains a lane id', 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: 'The lane answer is complete.', + phase: 'final_answer', + groupId: 'lane-a', + }, + ]; + const wiring = createActivityPhaseWiring({ + getContentParts: () => parts, + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase: jest.fn(async () => ({ label: 'Completed the lane workflow' })), + }); + await wiring.hook(batch('tool-1'), new AbortController().signal); + await wiring.hook(batch('tool-2'), new AbortController().signal); + + wiring.complete(); + await flushDetached(); + + expect(parts[3]).toMatchObject({ activity_end_index: 2, activity_count: 2 }); + }); + + it('skips a trailing empty text reservation when choosing the final 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: 'The materialized answer is complete.' }, + { type: ContentTypes.TEXT, text: '', phase: 'final_answer' }, + ]; + const wiring = createActivityPhaseWiring({ + getContentParts: () => parts, + getStepIndex: (stepId) => (stepId === 'empty-final' ? 3 : undefined), + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase: jest.fn(async () => ({ label: 'Completed the workflow' })), + }); + await wiring.hook(batch('tool-1'), new AbortController().signal); + await wiring.hook(batch('tool-2'), new AbortController().signal); + wiring + .handlers({ [GraphEvents.ON_RUN_STEP]: { handle: jest.fn() } }) + ?.[GraphEvents.ON_RUN_STEP]?.handle( + GraphEvents.ON_RUN_STEP, + { + id: 'empty-final', + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { message_id: 'm', content_type: 'text' }, + }, + }, + undefined, + undefined, + ); + + wiring.complete(); + await flushDetached(); + + expect(parts[4]).toMatchObject({ activity_end_index: 2, activity_count: 2 }); + }); + + it('ignores an empty reasoning reservation after the materialized final text', 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: 'The answer is complete.' }, + { type: ContentTypes.THINK, think: '' }, + ]; + const wiring = createActivityPhaseWiring({ + getContentParts: () => parts, + getStepIndex: (stepId) => (stepId === 'empty-reasoning' ? 3 : undefined), + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase: jest.fn(async () => ({ label: 'Completed the workflow' })), + }); + await wiring.hook(batch('tool-1'), new AbortController().signal); + await wiring.hook(batch('tool-2'), new AbortController().signal); + wiring + .handlers({ + [GraphEvents.ON_RUN_STEP]: { handle: jest.fn() }, + }) + ?.[GraphEvents.ON_RUN_STEP]?.handle( + GraphEvents.ON_RUN_STEP, + { + id: 'empty-reasoning', + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { message_id: 'm', content_type: 'think' }, + }, + }, + undefined, + undefined, + ); + + wiring.complete(); + await flushDetached(); + + expect(parts[4]).toMatchObject({ activity_end_index: 2, activity_count: 2 }); }); it('keeps a parallel lane final inside the run-wide phase', async () => { @@ -799,7 +1674,333 @@ describe('createActivityPhaseWiring', () => { undefined, ); await flushDetached(); + expect(generatePhase).not.toHaveBeenCalled(); + + parts[2] = { type: ContentTypes.TEXT, text: 'Finished the run' }; + wiring.complete(); + await flushDetached(); expect(generatePhase).toHaveBeenCalledTimes(1); + expect(parts[3]).toMatchObject({ + activity_start_index: 0, + activity_end_index: 2, + activity_count: 2, + }); + }); + + it('leaves final semantic commentary outside a completion-finalized phase', async () => { + const parts: LooseContentPart[] = [ + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-1' } }, + ]; + const generatePhase = jest.fn(async () => ({ label: 'Completed the commentary phase' })); + const wiring = createActivityPhaseWiring({ + getContentParts: () => parts, + getStepIndex: (stepId) => (stepId === 'root-commentary' ? 2 : undefined), + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase, + }); + await wiring.hook(batch('tool-1'), new AbortController().signal); + parts[1] = { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-2' } }; + await wiring.hook(batch('tool-2'), new AbortController().signal); + const handler = wiring.handlers({ + [GraphEvents.ON_RUN_STEP]: { handle: jest.fn() }, + })?.[GraphEvents.ON_RUN_STEP]; + handler?.handle( + GraphEvents.ON_RUN_STEP, + { + id: 'root-commentary', + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { message_id: 'm', content_type: 'text', phase: 'commentary' }, + }, + }, + undefined, + undefined, + ); + parts[2] = { type: ContentTypes.TEXT, text: 'Intermediate commentary', phase: 'commentary' }; + + wiring.complete(); + await flushDetached(); + + expect(parts[3]).toMatchObject({ + activity_start_index: 0, + activity_end_index: 2, + activity_count: 2, + }); + }); + + it('leaves the last semantic commentary outside after earlier unphased text', async () => { + const parts: LooseContentPart[] = [ + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-1' } }, + ]; + const stepIndexes = new Map([ + ['root-text', 1], + ['commentary', 3], + ]); + const generatePhase = jest.fn(async () => ({ label: 'Completed the commentary phase' })); + const wiring = createActivityPhaseWiring({ + getContentParts: () => parts, + getStepIndex: (stepId) => stepIndexes.get(stepId), + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase, + }); + await wiring.hook(batch('tool-1'), new AbortController().signal); + const handler = wiring.handlers({ + [GraphEvents.ON_RUN_STEP]: { handle: jest.fn() }, + })?.[GraphEvents.ON_RUN_STEP]; + handler?.handle( + GraphEvents.ON_RUN_STEP, + { + id: 'root-text', + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { message_id: 'm', content_type: 'text' }, + }, + }, + undefined, + undefined, + ); + parts[1] = { type: ContentTypes.TEXT, text: 'I will keep investigating.' }; + parts[2] = { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-2' } }; + await wiring.hook(batch('tool-2'), new AbortController().signal); + handler?.handle( + GraphEvents.ON_RUN_STEP, + { + id: 'commentary', + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { message_id: 'm', content_type: 'text', phase: 'commentary' }, + }, + }, + undefined, + undefined, + ); + parts[3] = { + type: ContentTypes.TEXT, + text: 'The second search confirmed it.', + phase: 'commentary', + }; + + wiring.complete(); + await flushDetached(); + + expect(parts[4]).toMatchObject({ + activity_start_index: 0, + activity_end_index: 3, + activity_count: 2, + }); + }); + + it('leaves persisted final commentary outside the phase after HITL resume', async () => { + const parts: LooseContentPart[] = [ + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-1' } }, + { type: ContentTypes.TEXT, text: 'I will keep investigating.' }, + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-2' } }, + { type: ContentTypes.TEXT, text: 'The second search confirmed it.', phase: 'commentary' }, + ]; + const generatePhase = jest.fn(async () => ({ label: 'Completed the resumed commentary' })); + const wiring = createActivityPhaseWiring({ + initialSnapshot: { + version: 1, + generated: 0, + activityCount: 2, + failedActivityCount: 0, + partialActivityCount: 0, + agentIds: [], + activities: [ + { startIndex: 0, status: 'success', toolCallIds: ['tool-1'] }, + { startIndex: 2, status: 'success', toolCallIds: ['tool-2'] }, + ], + assistantContext: [], + pendingReasoning: [], + }, + getContentParts: () => parts, + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase, + }); + + wiring.complete(); + await flushDetached(); + + expect(parts[4]).toMatchObject({ + activity_start_index: 0, + activity_end_index: 3, + activity_count: 2, + }); + }); + + it('summarizes all unphased activities once at root-run completion', async () => { + const parts: LooseContentPart[] = []; + const stepIndexes = new Map([ + ['intermediate-text', 2], + ['final-text', 5], + ]); + const generatePhase = jest.fn(async () => ({ label: 'Completed the full investigation' })); + const wiring = createActivityPhaseWiring({ + getContentParts: () => parts, + getStepIndex: (stepId) => stepIndexes.get(stepId), + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase, + }); + const handler = wiring.handlers({ + [GraphEvents.ON_RUN_STEP]: { handle: jest.fn() }, + })?.[GraphEvents.ON_RUN_STEP]; + + parts[0] = { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-1' } }; + await wiring.hook(batch('tool-1'), new AbortController().signal); + parts[1] = { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-2' } }; + await wiring.hook(batch('tool-2'), new AbortController().signal); + handler?.handle( + GraphEvents.ON_RUN_STEP, + { + id: 'intermediate-text', + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { message_id: 'm', content_type: 'text' }, + }, + }, + undefined, + undefined, + ); + parts[2] = { type: ContentTypes.TEXT, text: 'I will try another approach.' }; + + parts[3] = { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-3' } }; + await wiring.hook(batch('tool-3'), new AbortController().signal); + parts[4] = { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-4' } }; + await wiring.hook(batch('tool-4'), new AbortController().signal); + handler?.handle( + GraphEvents.ON_RUN_STEP, + { + id: 'final-text', + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { message_id: 'm', content_type: 'text' }, + }, + }, + undefined, + undefined, + ); + parts[5] = { type: ContentTypes.TEXT, text: 'The investigation is complete.' }; + parts[6] = { + type: ContentTypes.ACTIVITY_LABEL, + [ContentTypes.ACTIVITY_LABEL]: 'Recorded the delayed child result', + tool_call_ids: ['tool-4'], + pending: false, + }; + + expect(generatePhase).not.toHaveBeenCalled(); + wiring.complete(); + await flushDetached(); + + expect(generatePhase).toHaveBeenCalledTimes(1); + expect(generatePhase).toHaveBeenCalledWith(expect.objectContaining({ totalActivityCount: 4 })); + expect(parts[7]).toMatchObject({ + activity_label_type: 'phase', + activity_start_index: 0, + activity_end_index: 5, + activity_count: 4, + }); + }); + + it('keeps later activities grouped when the last root text preceded them', async () => { + const parts: LooseContentPart[] = [{ type: ContentTypes.TEXT, text: 'I will investigate.' }]; + const generatePhase = jest.fn(async () => ({ label: 'Completed the direct-return workflow' })); + const wiring = createActivityPhaseWiring({ + getContentParts: () => parts, + getStepIndex: (stepId) => (stepId === 'root-text' ? 0 : undefined), + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase, + }); + const handler = wiring.handlers({ + [GraphEvents.ON_RUN_STEP]: { handle: jest.fn() }, + })?.[GraphEvents.ON_RUN_STEP]; + handler?.handle( + GraphEvents.ON_RUN_STEP, + { + id: 'root-text', + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { message_id: 'm', content_type: 'text' }, + }, + }, + undefined, + undefined, + ); + parts[1] = { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-1' } }; + await wiring.hook(batch('tool-1'), new AbortController().signal); + parts[2] = { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-2' } }; + await wiring.hook(batch('tool-2'), new AbortController().signal); + + wiring.complete(); + await flushDetached(); + + expect(generatePhase).toHaveBeenCalledTimes(1); + expect(parts[3]).toMatchObject({ + activity_start_index: 0, + activity_end_index: 3, + activity_count: 2, + }); + }); + + it('keeps a parallel tool batch grouped when it straddles the last root text', async () => { + const parts: LooseContentPart[] = [ + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-1' } }, + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'parallel-1' } }, + ]; + const generatePhase = jest.fn(async () => ({ label: 'Completed the parallel workflow' })); + const wiring = createActivityPhaseWiring({ + getContentParts: () => parts, + getStepIndex: (stepId) => (stepId === 'root-text' ? 2 : undefined), + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase, + }); + await wiring.hook(batch('tool-1'), new AbortController().signal); + const parallelBatch = batch('parallel-1'); + parallelBatch.entries.push({ + ...parallelBatch.entries[0], + toolUseId: 'parallel-2', + toolInput: { query: 'parallel-2' }, + toolOutput: 'parallel-2-result', + }); + await wiring.hook(parallelBatch, new AbortController().signal); + const handler = wiring.handlers({ + [GraphEvents.ON_RUN_STEP]: { handle: jest.fn() }, + })?.[GraphEvents.ON_RUN_STEP]; + handler?.handle( + GraphEvents.ON_RUN_STEP, + { + id: 'root-text', + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { message_id: 'm', content_type: 'text' }, + }, + }, + undefined, + undefined, + ); + parts[2] = { type: ContentTypes.TEXT, text: 'The parallel work may be complete.' }; + parts[3] = { type: ContentTypes.TOOL_CALL, tool_call: { id: 'parallel-2' } }; + + wiring.complete(); + await flushDetached(); + + expect(generatePhase).toHaveBeenCalledTimes(1); + expect(parts[4]).toMatchObject({ + activity_start_index: 0, + activity_end_index: 4, + activity_count: 2, + }); }); it('preserves mixed batch failures as a partial phase outcome', async () => { diff --git a/packages/api/src/agents/activityPhases/runtime.ts b/packages/api/src/agents/activityPhases/runtime.ts index 613b9d5bd3..89037f5379 100644 --- a/packages/api/src/agents/activityPhases/runtime.ts +++ b/packages/api/src/agents/activityPhases/runtime.ts @@ -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 | 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): number[] { + return Object.keys(parts).map(Number); +} + function findLastPartIndex( parts: ReadonlyArray, 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, ): 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, + excerpt: string, +): number | undefined { + const needle = excerpt.trim().slice(0, REASONING_ANCHOR_CHARS); + if (!needle) { + return undefined; + } + for (const index of definedPartIndices(parts)) { + if ( + parts[index]?.type === ContentTypes.THINK && + textValue(parts[index]?.think).includes(needle) + ) { + return index; + } + } + return undefined; +} + +function hasReasoningExcerptAtOrAfter( + parts: ReadonlyArray, + excerpt: string, + minimumIndex: number, +): boolean { + const needle = excerpt.trim().slice(0, REASONING_ANCHOR_CHARS); + if (!needle) { + return false; + } + const indices = definedPartIndices(parts); + for (let position = indices.length - 1; position >= 0; position -= 1) { + const index = indices[position]; + if (index < minimumIndex) { + break; + } + if ( + parts[index]?.type === ContentTypes.THINK && + textValue(parts[index]?.think).includes(needle) + ) { + return true; + } + } + return false; +} + +type ReasoningAnchorIndex = Map>; + +function addReasoningAnchor( + anchors: Set, + 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, + index: ReasoningAnchorIndex, + minimumIndex: number, +): boolean { + if (index.size === 0) { + return false; + } + const indices = definedPartIndices(parts); + for (let position = indices.length - 1; position >= 0; position -= 1) { + const partIndex = indices[position]; + if (partIndex < minimumIndex) { + break; + } + const part = parts[partIndex]; + if ( + part?.type === ContentTypes.THINK && + includesReasoningAnchor(textValue(part.think), index) + ) { + return true; + } + } + return false; +} + function findTrackedToolStart( parts: ReadonlyArray, activity: TrackedActivity, @@ -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(); + 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(); + 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( - (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(); const stepKinds = new Map< string, { kind: 'text' | 'think'; phase?: AssistantTextPhase; captureContext?: boolean } >(); + const textContextByStepId = new Map(); + 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(); + const trailingToolIds = new Set(); + for (const key of Object.keys(parts)) { + const index = Number(key); + const part = parts[index]; + if (part?.type !== ContentTypes.TOOL_CALL || typeof part.tool_call?.id !== 'string') { + continue; + } + materializedToolIds.add(part.tool_call.id); + if (index >= candidateFinalTextIndex) { + trailingToolIds.add(part.tool_call.id); + } + } + const hasLaterTrackedActivity = activities.some((activity) => { + const toolCallIds = activity.toolCallIds ?? []; + if (toolCallIds.some((id) => trailingToolIds.has(id))) { + return true; + } + const reasoningExcerpt = activity.thinkingExcerpts?.[0]; + if (toolCallIds.length === 0 && reasoningExcerpt) { + return hasReasoningExcerptAtOrAfter(parts, reasoningExcerpt, candidateFinalTextIndex); + } + return ( + toolCallIds.some((id) => !materializedToolIds.has(id)) && + (activity.unresolvedToolStartIndex ?? activity.startIndex) >= candidateFinalTextIndex + ); + }); + const overflowIds = [...overflowToolCallIds]; + const overflowBoundaryIds = [...overflowBoundaryToolCallIds]; + const hasLaterOverflowActivity = + overflowIds.some((id) => trailingToolIds.has(id)) || + hasIndexedReasoningAtOrAfter( + parts, + overflowReasoningAnchorIndex, + candidateFinalTextIndex, + ) || + (!overflowBoundaryIds.every((id) => materializedToolIds.has(id)) && + overflowActivityStartIndex != null && + overflowActivityStartIndex >= candidateFinalTextIndex); + const pendingReasoningAnchors = new Set(); + const pendingReasoningAnchorIndex: ReasoningAnchorIndex = new Map(); + let hasLaterPendingReasoningIndex = false; + for (const reasoning of pendingReasoning.values()) { + /** Empty reasoning reservations are not activities: addPendingReasoning + * deliberately drops them. They can still receive a later sparse + * index from the SDK, so do not let that placeholder pull a fully + * materialized final answer into the completed parent phase. */ + if (!reasoning.text.trim()) { + continue; + } + hasLaterPendingReasoningIndex ||= + reasoning.startIndex != null && reasoning.startIndex >= candidateFinalTextIndex; + addReasoningAnchor( + pendingReasoningAnchors, + pendingReasoningAnchorIndex, + reasoning.text, + ); + } + const hasLaterPendingReasoning = + hasLaterPendingReasoningIndex || + hasIndexedReasoningAtOrAfter( + parts, + pendingReasoningAnchorIndex, + candidateFinalTextIndex, + ); + if (hasLaterTrackedActivity || hasLaterOverflowActivity || hasLaterPendingReasoning) { + finalTextIndex = undefined; + } + } + close(undefined, finalTextIndex); + }; + + return { hook, handlers: wrapHandlers, drop: clear, complete, snapshot }; } diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 3c54252012..ca838a0e63 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -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 diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index c616d9dc11..9cf1ed5272 100644 --- a/packages/data-provider/src/types/assistants.ts +++ b/packages/data-provider/src/types/assistants.ts @@ -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. */ diff --git a/packages/data-provider/src/types/runs.ts b/packages/data-provider/src/types/runs.ts index 8ef436201c..dd967e8da6 100644 --- a/packages/data-provider/src/types/runs.ts +++ b/packages/data-provider/src/types/runs.ts @@ -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?: {