diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index bc6966b18..7f31539e1 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -331,9 +331,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool settleFrames: HISTORY_START_SETTLE_FRAMES, requestFrame: (callback) => window.requestAnimationFrame(callback), cancelFrame: (frame) => window.cancelAnimationFrame(frame), - isSettling: () => - historyStartPaginationStateRef.current.status === "settling" && - historyStartPrependAnchorActiveRef.current, + isSettling: () => historyStartPaginationStateRef.current.status === "settling", isLoading: () => { const input = getHistoryStartPaginationInput(); return ( @@ -581,11 +579,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool useEffect(() => { updateScrollMetrics(); evaluateHistoryStart(); + if (historyStartPaginationStateRef.current.status === "settling") { + scheduleHistoryStartPrependSettle(); + } }, [ evaluateHistoryStart, hasOlderHistory, isLoadingOlderHistory, olderHistoryProgressKey, + scheduleHistoryStartPrependSettle, segments.historyMounted.length, segments.historyVirtualized.length, segments.liveHead.length, @@ -605,6 +607,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const observer = new ResizeObserver(() => { if (historyStartPrependAnchorActiveRef.current) { applyHistoryStartPrependAnchor(); + } + if (historyStartPaginationStateRef.current.status === "settling") { scheduleHistoryStartPrependSettle(); } updateScrollMetrics(); diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index 1fad0845a..a549571f4 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -363,6 +363,59 @@ describe("processTimelineResponse", () => { expect(result.head).toEqual([liveThought, liveAssistant]); }); + it("keeps a newer live tool completion when bootstrap contains the running call", () => { + const callId = "toolu_live_completion"; + const liveCompletion = hydrateStreamState( + [ + { + event: { + type: "timeline", + provider: "claude", + item: makeToolCallTimelineEntry(2, callId, "completed", { + type: "read", + filePath: "/tmp/example.ts", + }).item, + } as AgentStreamEventPayload, + timestamp: new Date(2000), + timelineCursor: { epoch: "epoch-1", seq: 2 }, + }, + ], + { source: "canonical" }, + ); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentHead: liveCompletion, + hasAuthoritativeBaseline: false, + isInitializing: true, + hasActiveInitDeferred: true, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + reset: false, + epoch: "epoch-1", + startCursor: { seq: 1 }, + endCursor: { seq: 1 }, + entries: [ + makeToolCallTimelineEntry(1, callId, "running", { + type: "unknown", + input: { file_path: "/tmp/example.ts" }, + output: null, + }), + ], + }, + }); + + expect(getAgentToolCalls([...result.tail, ...result.head])).toEqual([ + expect.objectContaining({ + timelineCursor: { epoch: "epoch-1", seq: 2 }, + payload: expect.objectContaining({ + data: expect.objectContaining({ callId, status: "completed" }), + }), + }), + ]); + }); + it("replaces a painted replica with an empty authoritative timeline", () => { const result = processTimelineResponse({ ...baseTimelineInput, diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 5f47bdd70..05f42c6fb 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -410,6 +410,44 @@ function removeUserMessageAt(items: UserMessageItem[], index: number): UserMessa return [...items.slice(0, index), ...items.slice(index + 1)]; } +function reconcileReplacementHeadAgainstTail( + tail: StreamItem[], + retainedHead: StreamItem[], +): { tail: StreamItem[]; head: StreamItem[] } { + let reconciledTail = tail; + const reconciledHeadIndexes = new Set(); + for (const [headIndex, item] of retainedHead.entries()) { + if (!isAgentToolCallItem(item) || !item.timelineCursor) { + continue; + } + const tailIndex = findExistingAgentToolCallIndex(reconciledTail, item.payload.data.callId); + const existing = reconciledTail[tailIndex]; + if (tailIndex < 0 || !existing || !isAgentToolCallItem(existing)) { + continue; + } + if (reconciledTail === tail) { + reconciledTail = [...tail]; + } + reconciledTail[tailIndex] = mergeAgentToolCallItem( + existing, + item.payload.data, + item.timestamp, + item.timelineCursor, + ); + reconciledHeadIndexes.add(headIndex); + } + + const tailIds = new Set(reconciledTail.map((item) => item.id)); + return { + tail: reconciledTail, + head: retainedHead.filter( + (item, index) => + !reconciledHeadIndexes.has(index) && + (item.kind === "assistant_message" || !tailIds.has(item.id)), + ), + }; +} + function preserveReplacementHead( tail: StreamItem[], currentHead: StreamItem[], @@ -435,25 +473,25 @@ function preserveReplacementHead( item.clientMessageId !== undefined && sendingClientMessageIds.has(item.clientMessageId), ); - const tailIds = new Set(tail.map((item) => item.id)); - const unreconciledHead = retainedHead.filter( - (item) => item.kind === "assistant_message" || !tailIds.has(item.id), + const { tail: reconciledTail, head: unreconciledHead } = reconcileReplacementHeadAgainstTail( + tail, + retainedHead, ); const liveAssistantIndex = unreconciledHead.findLastIndex( (item) => item.kind === "assistant_message", ); if (liveAssistantIndex < 0) { - return { tail, head: unreconciledHead, acknowledgedClientMessageIds: [] }; + return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] }; } const liveAssistant = unreconciledHead[liveAssistantIndex]; - const tailAssistant = tail.at(-1); + const tailAssistant = reconciledTail.at(-1); if ( liveAssistant.kind !== "assistant_message" || !tailAssistant || tailAssistant.kind !== "assistant_message" ) { - return { tail, head: unreconciledHead, acknowledgedClientMessageIds: [] }; + return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] }; } const isNewerContinuation = @@ -472,10 +510,14 @@ function preserveReplacementHead( { ...liveAssistant, text }, ...unreconciledHead.slice(liveAssistantIndex + 1), ]; - return { tail: tail.slice(0, -1), head, acknowledgedClientMessageIds: [] }; + return { + tail: reconciledTail.slice(0, -1), + head, + acknowledgedClientMessageIds: [], + }; } if (!liveAssistant.text.startsWith(tailAssistant.text)) { - return { tail, head: unreconciledHead, acknowledgedClientMessageIds: [] }; + return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] }; } const head = [ @@ -483,7 +525,11 @@ function preserveReplacementHead( { ...liveAssistant, text: tailAssistant.text }, ...unreconciledHead.slice(liveAssistantIndex + 1), ]; - return { tail: tail.slice(0, -1), head, acknowledgedClientMessageIds: [] }; + return { + tail: reconciledTail.slice(0, -1), + head, + acknowledgedClientMessageIds: [], + }; } export function replaceWithCanonicalStream(