diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 71bda619a..c055cc7ef 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 ec75aaec5..7d1a28c06 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 221dbda33..ca52dfbf8 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 c91910dc6..22d7c5f2f 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 9684500ff..636ebe870 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 acc2e858e..22408c583 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 cb2992a00..171b9c060 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 e2d01d6d6..414d58330 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);