From f3202ac41960c13413aa2c307d9b34b7d97f4923 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 17 Aug 2026 14:01:20 +0200 Subject: [PATCH] fix(app): merge hidden-prompt turns visually (#3484) System-injected prompts never enter the Paseo timeline, but their provider runs keep distinct canonical turn IDs. Keep those IDs for lifecycle correctness while grouping adjacent output without a visible prompt into one response. --- docs/timeline-sync.md | 5 +++++ packages/app/src/agent-stream/layout.test.ts | 6 +++--- packages/app/src/agent-stream/layout.ts | 12 ++++++------ .../app/src/agent-stream/render-strategy.test.ts | 14 +++++++------- packages/app/src/agent-stream/strategy.ts | 12 ++++++------ packages/app/src/agent-stream/turn-footer.tsx | 4 ++-- .../app/src/agent-stream/turn-membership.test.ts | 13 +++++++++++++ packages/app/src/agent-stream/turn-membership.ts | 13 +++++++++++++ 8 files changed, 55 insertions(+), 24 deletions(-) diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 71bda619a2..c055cc7efa 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -173,6 +173,11 @@ foreground control ownership remains a separate daemon concern. Cancellation req with that record rather than in a React component, so an old request cannot clear a newer one. Submissions remain a separate pre-turn registry and retire on canonical acknowledgement. +Canonical turns and visible responses are different boundaries. System-injected prompts are absent from +the Paseo timeline, so one visible response can span several canonical turns without a user message +between them. Layout and copy group that response together; lifecycle, timing, tool sequences, and exact +fork positions retain the canonical `turnId` boundaries. + The compatibility boundary for older daemons is snapshot normalization: running/idle status becomes an anonymous active turn or idle state once, and downstream code consumes the same activity shape. The app does not combine anonymous lifecycle events, timestamps, timeline rows, and resume coverage to infer a diff --git a/packages/app/src/agent-stream/layout.test.ts b/packages/app/src/agent-stream/layout.test.ts index ec75aaec56..7d1a28c06a 100644 --- a/packages/app/src/agent-stream/layout.test.ts +++ b/packages/app/src/agent-stream/layout.test.ts @@ -150,7 +150,7 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem { } describe("layoutStream", () => { - it("does not borrow an assistant footer across adjacent tagged tool-only turns", () => { + it("places one response footer after an adjacent tagged tool-only turn", () => { const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1"); const nextTool = toolCall("next-tool", 2, "turn-2"); const layout = layoutFor({ @@ -159,14 +159,14 @@ describe("layoutStream", () => { timingIds: [priorAssistant.id], }); - expect(layout.auxiliaryTurnFooter).toBeNull(); + expect(layout.auxiliaryTurnFooter?.itemId).toBe(priorAssistant.id); expect(footerOwners(layout)).toEqual([priorAssistant.id]); }); it("recomputes cached history layout when only the live boundary turn changes", () => { const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1"); const history = [priorAssistant]; - const liveHead = [toolCall("live-tool", 2, "turn-1")]; + const liveHead: StreamItem[] = [{ ...userMessage("live-user", 2), turnId: "turn-1" }]; const strategy = strategyFor("web"); const first = layoutStream({ strategy, diff --git a/packages/app/src/agent-stream/layout.ts b/packages/app/src/agent-stream/layout.ts index 221dbda33e..ca52dfbf85 100644 --- a/packages/app/src/agent-stream/layout.ts +++ b/packages/app/src/agent-stream/layout.ts @@ -2,7 +2,7 @@ import type { TurnTiming } from "@/timeline/turn-time"; import type { StreamItem } from "@/types/stream"; import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./spacing"; import type { StreamFrameChildOrder, StreamStrategy } from "./strategy"; -import { continuesTurn, isTurnBoundary } from "./turn-membership"; +import { continuesResponse, continuesTurn, isResponseBoundary } from "./turn-membership"; export type StreamToolSequence = "single" | "first" | "middle" | "last" | "none"; @@ -78,7 +78,7 @@ function createTurnFooterHost(input: { }; } -function findLatestAssistantInTurn(input: { +function findLatestAssistantInResponse(input: { strategy: StreamStrategy; items: StreamItem[]; startIndex: number; @@ -97,7 +97,7 @@ function findLatestAssistantInTurn(input: { index = input.strategy.getNeighborIndex(index, "above") ) { const item = items[index]; - if (!item || (laterItem && !continuesTurn(item, laterItem))) { + if (!item || (laterItem && !continuesResponse(item, laterItem))) { return null; } if (item.kind === "assistant_message") { @@ -132,7 +132,7 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | return null; } - const assistant = findLatestAssistantInTurn({ + const assistant = findLatestAssistantInResponse({ strategy: input.strategy, items: footerItems, startIndex: latestIndex, @@ -160,11 +160,11 @@ function resolveCompletedFooter(input: { boundaryAboveItems: StreamItem[] | null; boundaryAboveIndex: number | null; }): TurnFooterHost | null { - if (input.item.kind === "user_message" || !isTurnBoundary(input.item, input.belowItem)) { + if (input.item.kind === "user_message" || !isResponseBoundary(input.item, input.belowItem)) { return null; } - const assistant = findLatestAssistantInTurn({ + const assistant = findLatestAssistantInResponse({ strategy: input.strategy, items: input.items, startIndex: input.index, diff --git a/packages/app/src/agent-stream/render-strategy.test.ts b/packages/app/src/agent-stream/render-strategy.test.ts index c91910dc6d..22d7c5f2f1 100644 --- a/packages/app/src/agent-stream/render-strategy.test.ts +++ b/packages/app/src/agent-stream/render-strategy.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { StreamItem } from "@/types/stream"; import { - collectAssistantTurnContentForStreamRenderStrategy, + collectAssistantResponseContentForStreamRenderStrategy, getBottomOffsetForStreamRenderStrategy, getFrameChildOrderForStreamRenderStrategy, getHistoryLiveBoundaryIndexForStreamRenderStrategy, @@ -175,7 +175,7 @@ describe("neighbor and traversal semantics", () => { expect(getStreamNeighborIndex({ strategy: inverted, index: 3, relation: "below" })).toBe(2); }); - it("collects assistant turn content with strategy traversal direction", () => { + it("collects assistant response content with strategy traversal direction", () => { const chronological: StreamItem[] = [ userMessage("u1", "user-1", 1), assistantMessage("a1", "assistant-1", 2), @@ -189,7 +189,7 @@ describe("neighbor and traversal semantics", () => { }); const forwardStartIndex = chronological.findIndex((item) => item.id === "a2"); expect( - collectAssistantTurnContentForStreamRenderStrategy({ + collectAssistantResponseContentForStreamRenderStrategy({ strategy: forward, items: chronological, startIndex: forwardStartIndex, @@ -206,7 +206,7 @@ describe("neighbor and traversal semantics", () => { }); const invertedStartIndex = invertedItems.findIndex((item) => item.id === "a2"); expect( - collectAssistantTurnContentForStreamRenderStrategy({ + collectAssistantResponseContentForStreamRenderStrategy({ strategy: inverted, items: invertedItems, startIndex: invertedStartIndex, @@ -214,7 +214,7 @@ describe("neighbor and traversal semantics", () => { ).toBe("assistant-1\n\nassistant-2"); }); - it("does not collect copy or fork content across an adjacent tagged turn", () => { + it("collects copy content across adjacent turns without a visible prompt", () => { const chronological: StreamItem[] = [ { ...assistantMessage("a1", "first turn", 1), turnId: "turn-1" }, { ...toolCall("tool", 2), turnId: "turn-2" }, @@ -223,12 +223,12 @@ describe("neighbor and traversal semantics", () => { const strategy = resolveStreamRenderStrategy({ platform: "web", isMobileBreakpoint: false }); expect( - collectAssistantTurnContentForStreamRenderStrategy({ + collectAssistantResponseContentForStreamRenderStrategy({ strategy, items: chronological, startIndex: 2, }), - ).toBe("second turn"); + ).toBe("first turn\n\nsecond turn"); }); it("returns undefined neighbor when index would be out of bounds", () => { diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index 9684500ffa..636ebe870a 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -1,7 +1,7 @@ import type { ComponentType, ReactElement, ReactNode, RefObject } from "react"; import type { StyleProp, ViewStyle } from "react-native"; import type { StreamItem } from "@/types/stream"; -import { continuesTurn } from "./turn-membership"; +import { continuesResponse } from "./turn-membership"; import type { StreamHistoryBoundary, StreamRenderSegments } from "./model"; import type { BottomAnchorLocalRequest, @@ -99,7 +99,7 @@ export interface StreamStrategy { index: number, relation: NeighborRelation, ) => StreamItem | undefined; - collectAssistantTurnContent: (items: StreamItem[], startIndex: number) => string; + collectAssistantResponseContent: (items: StreamItem[], startIndex: number) => string; isNearBottom: (input: StreamNearBottomInput) => boolean; getBottomOffset: (metrics: StreamViewportMetrics) => number; getEdgeSlotProps: ( @@ -164,7 +164,7 @@ export function createStreamStrategy(config: StreamStrategyConfig): StreamStrate } return items[neighborIndex]; }, - collectAssistantTurnContent: (items, startIndex) => { + collectAssistantResponseContent: (items, startIndex) => { const messages: string[] = []; let laterItem: StreamItem | null = null; for ( @@ -173,7 +173,7 @@ export function createStreamStrategy(config: StreamStrategyConfig): StreamStrate index += config.assistantTurnTraversalStep ) { const currentItem = items[index]; - if (!currentItem || (laterItem && !continuesTurn(currentItem, laterItem))) { + if (!currentItem || (laterItem && !continuesResponse(currentItem, laterItem))) { break; } if (currentItem.kind === "assistant_message") { @@ -276,12 +276,12 @@ export function getStreamNeighborItem(params: { return params.strategy.getNeighborItem(params.items, params.index, params.relation); } -export function collectAssistantTurnContentForStreamRenderStrategy(params: { +export function collectAssistantResponseContentForStreamRenderStrategy(params: { strategy: StreamStrategy; items: StreamItem[]; startIndex: number; }): string { - return params.strategy.collectAssistantTurnContent(params.items, params.startIndex); + return params.strategy.collectAssistantResponseContent(params.items, params.startIndex); } export function isNearBottomForStreamRenderStrategy( diff --git a/packages/app/src/agent-stream/turn-footer.tsx b/packages/app/src/agent-stream/turn-footer.tsx index acc2e858ec..22408c583a 100644 --- a/packages/app/src/agent-stream/turn-footer.tsx +++ b/packages/app/src/agent-stream/turn-footer.tsx @@ -6,7 +6,7 @@ import type { Theme } from "@/styles/theme"; import type { TurnTiming } from "@/timeline/turn-time"; import type { StreamItem } from "@/types/stream"; import { - collectAssistantTurnContentForStreamRenderStrategy, + collectAssistantResponseContentForStreamRenderStrategy, type StreamStrategy, } from "./strategy"; import { resolveAssistantTurnForkBoundary, type AssistantTurnForkBoundary } from "./turn-boundary"; @@ -173,7 +173,7 @@ function CompletedTurnFooter({ }) { const getContent = useCallback( () => - collectAssistantTurnContentForStreamRenderStrategy({ + collectAssistantResponseContentForStreamRenderStrategy({ strategy, items, startIndex, diff --git a/packages/app/src/agent-stream/turn-membership.test.ts b/packages/app/src/agent-stream/turn-membership.test.ts index cb2992a002..171b9c060d 100644 --- a/packages/app/src/agent-stream/turn-membership.test.ts +++ b/packages/app/src/agent-stream/turn-membership.test.ts @@ -76,6 +76,19 @@ describe("canonical turn membership", () => { ); }); + it("renders adjacent turns without visible prompts as one response", () => { + const items = [ + user("prompt", 1, "turn-1"), + assistant("first reply", 2, "turn-1"), + runningTool("heartbeat-1-tool", 3, "turn-2"), + assistant("heartbeat-1-reply", 4, "turn-2"), + runningTool("heartbeat-2-tool", 5, "turn-3"), + assistant("heartbeat-2-reply", 6, "turn-3"), + ]; + + expect(completedFooterIds(layoutFor(items, false).layout)).toEqual(["heartbeat-2-reply"]); + }); + it.each([ [ "Claude", diff --git a/packages/app/src/agent-stream/turn-membership.ts b/packages/app/src/agent-stream/turn-membership.ts index e2d01d6d65..414d583305 100644 --- a/packages/app/src/agent-stream/turn-membership.ts +++ b/packages/app/src/agent-stream/turn-membership.ts @@ -12,10 +12,23 @@ export function continuesTurn(previous: StreamItem | null, next: StreamItem | nu return next.kind !== "user_message"; } +/** + * A visible response can span multiple canonical turns when their prompts are + * system-injected and therefore absent from the Paseo timeline. + */ +export function continuesResponse(previous: StreamItem | null, next: StreamItem | null): boolean { + if (!previous || !next) return false; + return continuesTurn(previous, next) || next.kind !== "user_message"; +} + export function isTurnBoundary(previous: StreamItem | null, next: StreamItem | null): boolean { return previous !== null && next !== null && !continuesTurn(previous, next); } +export function isResponseBoundary(previous: StreamItem | null, next: StreamItem | null): boolean { + return previous !== null && next !== null && !continuesResponse(previous, next); +} + /** Whether `item` begins a new chronological turn after `previous`. */ export function startsNewTurn(item: StreamItem, previous: StreamItem | null): boolean { return previous === null || isTurnBoundary(previous, item);