diff --git a/packages/app/e2e/browser/turn-collapse.spec.ts b/packages/app/e2e/browser/turn-collapse.spec.ts index 1deb050fa8..6dd8b7c0b1 100644 --- a/packages/app/e2e/browser/turn-collapse.spec.ts +++ b/packages/app/e2e/browser/turn-collapse.spec.ts @@ -1,3 +1,4 @@ +import { getActiveTabTestId } from "../support/helpers/launcher"; import type { Page } from "@playwright/test"; import { test, expect } from "../support/fixtures"; import { expectComposerVisible } from "../support/helpers/composer"; @@ -19,7 +20,10 @@ async function seedAnsweredAgent(title: string) { initialPrompt: "Why does the chat stop following new output?", }); await agent.client.waitForFinish(agent.agentId, 30_000); - await agent.client.sendAgentMessage(agent.agentId, "Make the fix and run the burst script."); + await agent.client.sendAgentMessage( + agent.agentId, + "Make the fix and run the burst script.\n" + "Keep the previous response visible.\n".repeat(40), + ); await agent.client.waitForFinish(agent.agentId, 30_000); return agent; } @@ -36,17 +40,67 @@ async function rowsBetweenLatestSummaryAndReply(page: Page): Promise { }); } +async function groupedToolsInsideLatestTurn(page: Page): Promise { + return page.evaluate(() => { + const rows = [...document.querySelectorAll("[data-message-id]")]; + const start = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]")); + const end = rows.findLastIndex((row) => row.querySelector("[data-testid=assistant-message]")); + return rows + .slice(start + 1, end) + .flatMap((row) => + [...row.querySelectorAll("[data-testid=tool-call-group]")].map( + (group) => group.textContent ?? "", + ), + ); + }); +} + +async function expandAndCollapseLongPrompt(page: Page) { + const prompt = page.getByTestId("user-message").last(); + const before = await prompt.boundingBox(); + const text = prompt.locator('[data-message-text="true"]'); + const messageLeft = (await text.boundingBox())!.x; + await expect + .poll(async () => (await prompt.getByText("Show more", { exact: true }).boundingBox())!.x) + .toBe(messageLeft); + await prompt.getByRole("button", { name: "Show more", exact: true }).click(); + await expect(prompt.getByRole("button", { name: "Show less", exact: true })).toBeVisible(); + await expect + .poll(async () => (await prompt.getByText("Show less", { exact: true }).boundingBox())!.x) + .toBe(messageLeft); + await expect + .poll(async () => (await prompt.boundingBox())?.height ?? 0) + .toBeGreaterThan(before!.height); + await prompt.getByRole("button", { name: "Show less", exact: true }).click(); + await expect.poll(async () => (await prompt.boundingBox())?.height ?? 0).toBe(before!.height); + await prompt.scrollIntoViewIfNeeded(); + const bounds = (await prompt.boundingBox())!; + const body = (await text.boundingBox())!; + await page.screenshot({ + path: "/tmp/message-toggle-aligned.png", + clip: { + x: body.x - 16, + y: bounds.y, + width: bounds.x + bounds.width - body.x + 16, + height: bounds.height, + }, + }); +} + test.describe("Turn collapse", () => { test("folds each answered turn behind a summary line that opens it", async ({ page }) => { test.setTimeout(120_000); + await page.setViewportSize({ width: 1280, height: 1000 }); await useChatDisplay(page, "collapseTurns"); const agent = await seedAnsweredAgent("Turn collapse"); try { await openAgentRoute(page, agent); await expectComposerVisible(page); + await expandAndCollapseLongPrompt(page); + const summary = page.getByTestId("turn-summary").last().getByRole("button"); - await expect(summary).toHaveText(/^Wrote \d+ messages?, edited 1 file/); + await expect(summary).toHaveText(/^Edited 1 file/); // Text chunks that stream back to back merge into one message, so the reply holds // at least the stream's last chunk. await expect(page.getByTestId("assistant-message").last()).toHaveText( @@ -57,6 +111,9 @@ test.describe("Turn collapse", () => { await summary.click(); await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0); await expect(summary).toBeInViewport(); + await summary.evaluate((element) => element.scrollIntoView({ block: "start" })); + await page.screenshot({ path: "/tmp/turn-rail-aligned.png" }); + await expect.poll(() => groupedToolsInsideLatestTurn(page)).toEqual([]); await summary.click(); await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); @@ -65,3 +122,38 @@ test.describe("Turn collapse", () => { } }); }); + +test("received messages show their sender and open that agent", async ({ page }) => { + const recipient = await seedMockAgentWorkspace({ + repoPrefix: "sender-attribution-", + title: "Implementation", + }); + try { + const sender = await recipient.client.createAgent({ + provider: "mock", + cwd: recipient.cwd, + workspaceId: recipient.workspaceId, + title: "Reviewer", + model: "e2e-fast-stream", + modeId: "load-test", + }); + await recipient.client.sendAgentMessage( + recipient.agentId, + "I reviewed the turn-grouping changes. Heartbeats now keep their own timing footer, and the previous response stays visible.", + { sourceAgentId: sender.id }, + ); + await openAgentRoute(page, recipient); + await expectComposerVisible(page); + const attribution = page.getByRole("button", { name: "Sent by Reviewer", exact: true }); + await expect(attribution).toBeVisible(); + await expect(page.getByTestId("user-message").filter({ has: attribution })).toContainText( + "I reviewed the turn-grouping changes.", + ); + await attribution.scrollIntoViewIfNeeded(); + await page.screenshot({ path: "/tmp/sender-attribution.png" }); + await attribution.click(); + await expect.poll(() => getActiveTabTestId(page)).toContain(sender.id); + } finally { + await recipient.cleanup(); + } +}); diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index 4419e5193f..f57beb5783 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -149,7 +149,11 @@ export interface SeedDaemonClient { timeout?: number, ): Promise<{ status: string }>; cancelAgent(agentId: string): Promise; - sendAgentMessage(agentId: string, text: string): Promise; + sendAgentMessage( + agentId: string, + text: string, + options?: { sourceAgentId?: string }, + ): Promise; waitForFinish( agentId: string, timeout?: number, diff --git a/packages/app/src/agent-stream/footer-spacing.test.ts b/packages/app/src/agent-stream/footer-spacing.test.ts index 5fa3535bce..341ce75583 100644 --- a/packages/app/src/agent-stream/footer-spacing.test.ts +++ b/packages/app/src/agent-stream/footer-spacing.test.ts @@ -71,7 +71,7 @@ function layoutFor(input: { platform: "web" | "native"; isTurnActive: boolean }) isTurnActive: input.isTurnActive, history: strategy.orderTail([userMessage("user"), assistant]), liveHead: [], - timingByAssistantId: timingFor(assistant.id), + timingByItemId: timingFor(assistant.id), }), }; } diff --git a/packages/app/src/agent-stream/layout.test.ts b/packages/app/src/agent-stream/layout.test.ts index f10fde2cb1..a86817b6a7 100644 --- a/packages/app/src/agent-stream/layout.test.ts +++ b/packages/app/src/agent-stream/layout.test.ts @@ -104,7 +104,7 @@ function layoutFor(input: { strategy, streamHead: input.head ?? [], }), - timingByAssistantId: timingFor(...(input.timingIds ?? [])), + timingByItemId: timingFor(...(input.timingIds ?? [])), }); } @@ -148,7 +148,7 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem { } describe("layoutStream", () => { - it("places one response footer after an adjacent tagged tool-only turn", () => { + it("keeps separate footers for an assistant turn and an adjacent tool-only turn", () => { const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1"); const nextTool = toolCall("next-tool", 2, "turn-2"); const layout = layoutFor({ @@ -157,8 +157,8 @@ describe("layoutStream", () => { timingIds: [priorAssistant.id], }); - expect(layout.auxiliaryTurnFooter?.itemId).toBe(priorAssistant.id); - expect(footerOwners(layout)).toEqual([priorAssistant.id]); + expect(layout.auxiliaryTurnFooter?.itemId).toBe(nextTool.id); + expect(footerOwners(layout)).toEqual([priorAssistant.id, nextTool.id]); }); it("recomputes cached history layout when only the live boundary turn changes", () => { @@ -171,7 +171,7 @@ describe("layoutStream", () => { isTurnActive: true, history, liveHead, - timingByAssistantId: timingFor(priorAssistant.id), + timingByItemId: timingFor(priorAssistant.id), }); liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" }; @@ -180,7 +180,7 @@ describe("layoutStream", () => { isTurnActive: true, history, liveHead, - timingByAssistantId: timingFor(priorAssistant.id), + timingByItemId: timingFor(priorAssistant.id), }); expect(footerOwners(first)).toEqual([]); diff --git a/packages/app/src/agent-stream/layout.ts b/packages/app/src/agent-stream/layout.ts index 82ef98db54..924d9d75ec 100644 --- a/packages/app/src/agent-stream/layout.ts +++ b/packages/app/src/agent-stream/layout.ts @@ -39,13 +39,13 @@ export interface StreamLayoutInput { isTurnActive: boolean; history: StreamItem[]; liveHead: StreamItem[]; - timingByAssistantId: Map; + timingByItemId: Map; } interface LayoutSegmentInput { strategy: StreamStrategy; items: StreamItem[]; - timingByAssistantId: Map; + timingByItemId: Map; auxiliaryTurnFooter: TurnFooterHost | null; hasAuxiliaryFooter: boolean; frameOrder: StreamFrameChildOrder; @@ -57,8 +57,8 @@ interface LayoutSegmentInput { phase: "streaming" | "complete"; } -interface AssistantFooterSource { - item: Extract; +interface TurnFooterSource { + item: StreamItem; items: StreamItem[]; index: number; } @@ -67,23 +67,26 @@ function createTurnFooterHost(input: { item: StreamItem; items: StreamItem[]; index: number; - timingByAssistantId: Map; + timingByItemId: Map; }): TurnFooterHost { return { itemId: input.item.id, items: input.items, - timing: input.timingByAssistantId.get(input.item.id), + timing: input.timingByItemId.get(input.item.id), startIndex: input.index, }; } -function findLatestAssistantInResponse(input: { +function findTurnFooterSource(input: { strategy: StreamStrategy; items: StreamItem[]; startIndex: number; boundaryAboveItems?: StreamItem[] | null; boundaryAboveIndex?: number | null; -}): AssistantFooterSource | null { +}): TurnFooterSource | null { + const last = input.items[input.startIndex]; + const toolFallback: TurnFooterSource | null = + last?.kind === "tool_call" ? { item: last, items: input.items, index: input.startIndex } : null; let items = input.items; let index = input.startIndex; let canCrossBoundary = true; @@ -97,7 +100,7 @@ function findLatestAssistantInResponse(input: { ) { const item = items[index]; if (!item || (laterItem && !continuesResponse(item, laterItem))) { - return null; + return toolFallback; } if (item.kind === "assistant_message") { return { item, items, index }; @@ -111,7 +114,7 @@ function findLatestAssistantInResponse(input: { input.boundaryAboveIndex === null || input.boundaryAboveIndex === undefined ) { - return null; + return toolFallback; } items = input.boundaryAboveItems; @@ -131,20 +134,20 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | return null; } - const assistant = findLatestAssistantInResponse({ + const source = findTurnFooterSource({ strategy: input.strategy, items: footerItems, startIndex: latestIndex, }); - if (!assistant) { + if (!source) { return null; } return createTurnFooterHost({ - item: assistant.item, - items: assistant.items, - index: assistant.index, - timingByAssistantId: input.timingByAssistantId, + item: source.item, + items: source.items, + index: source.index, + timingByItemId: input.timingByItemId, }); } @@ -154,7 +157,7 @@ function resolveCompletedFooter(input: { index: number; item: StreamItem; belowItem: StreamItem | null; - timingByAssistantId: Map; + timingByItemId: Map; auxiliaryTurnFooter: TurnFooterHost | null; boundaryAboveItems: StreamItem[] | null; boundaryAboveIndex: number | null; @@ -163,21 +166,21 @@ function resolveCompletedFooter(input: { return null; } - const assistant = findLatestAssistantInResponse({ + const source = findTurnFooterSource({ strategy: input.strategy, items: input.items, startIndex: input.index, boundaryAboveItems: input.boundaryAboveItems, boundaryAboveIndex: input.boundaryAboveIndex, }); - if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) { + if (!source || input.auxiliaryTurnFooter?.itemId === source.item.id) { return null; } return createTurnFooterHost({ - item: assistant.item, - items: assistant.items, - index: assistant.index, - timingByAssistantId: input.timingByAssistantId, + item: source.item, + items: source.items, + index: source.index, + timingByItemId: input.timingByItemId, }); } @@ -306,7 +309,7 @@ function layoutSegmentItem( index, item, belowItem, - timingByAssistantId: input.timingByAssistantId, + timingByItemId: input.timingByItemId, auxiliaryTurnFooter: input.auxiliaryTurnFooter, boundaryAboveItems: input.boundaryAboveItems, boundaryAboveIndex: input.boundaryAboveIndex, @@ -375,7 +378,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout { history = layoutSegment({ strategy: input.strategy, items: input.history, - timingByAssistantId: input.timingByAssistantId, + timingByItemId: input.timingByItemId, auxiliaryTurnFooter, hasAuxiliaryFooter, frameOrder, @@ -395,7 +398,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout { const liveHead = layoutSegment({ strategy: input.strategy, items: input.liveHead, - timingByAssistantId: input.timingByAssistantId, + timingByItemId: input.timingByItemId, auxiliaryTurnFooter, hasAuxiliaryFooter, frameOrder, diff --git a/packages/app/src/agent-stream/model.test.ts b/packages/app/src/agent-stream/model.test.ts index dec4c489b9..9c6ef3384d 100644 --- a/packages/app/src/agent-stream/model.test.ts +++ b/packages/app/src/agent-stream/model.test.ts @@ -69,8 +69,8 @@ describe("buildAgentStreamRenderModel", () => { historyStart: 2, }); - expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false); - expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({ + expect(model.turnTiming.byItemId.has("hidden-a")).toBe(false); + expect(model.turnTiming.byItemId.get("visible-a")).toEqual({ completedAt: tail[3]?.timestamp, durationMs: 1000, }); @@ -184,7 +184,7 @@ describe("buildAgentStreamRenderModel", () => { }); expect(model.turnTiming.runningStartedAt).toBe(tail[0]?.timestamp); - expect(model.turnTiming.byAssistantId.has("live-a")).toBe(false); + expect(model.turnTiming.byItemId.has("live-a")).toBe(false); }); it("maps completed turn timing to assistant ids across committed history and live head", () => { @@ -201,7 +201,7 @@ describe("buildAgentStreamRenderModel", () => { }); expect(model.turnTiming.runningStartedAt).toBe(null); - expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({ + expect(model.turnTiming.byItemId.get("live-a")).toEqual({ completedAt: head[0]?.timestamp, durationMs: 3000, }); @@ -220,7 +220,7 @@ describe("buildAgentStreamRenderModel", () => { }); expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]); - expect(model.turnTiming.byAssistantId.get("a1")).toEqual({ + expect(model.turnTiming.byItemId.get("a1")).toEqual({ completedAt: tail[1]?.timestamp, durationMs: 3000, }); @@ -238,7 +238,7 @@ describe("buildAgentStreamRenderModel", () => { isMobileBreakpoint: false, }); - expect(model.turnTiming.byAssistantId.size).toBe(0); + expect(model.turnTiming.byItemId.size).toBe(0); }); it.each(["web", "native"] as const)( @@ -274,10 +274,10 @@ describe("buildAgentStreamRenderModel", () => { expect(second.history).toBe(first.history); expect(second.segments.historyMounted).toBe(first.segments.historyMounted); - expect(second.turnTiming.byAssistantId).toBe(first.turnTiming.byAssistantId); + expect(second.turnTiming.byItemId).toBe(first.turnTiming.byItemId); expect(first.history).toHaveLength(20); - expect(first.turnTiming.byAssistantId.has("a29")).toBe(false); - expect(first.turnTiming.byAssistantId.get("a38")).toEqual({ + expect(first.turnTiming.byItemId.has("a29")).toBe(false); + expect(first.turnTiming.byItemId.get("a38")).toEqual({ completedAt: tail[77]?.timestamp, durationMs: 1000, }); diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts index 357b6c079b..2dcb250923 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -588,3 +588,69 @@ describe("timeline presentation", () => { expect(projectTimelineItems([source], () => [])).toEqual([]); }); }); + +it("opening a turn reveals individual calls rather than another tool group", () => { + const present = createStreamPresentation(); + const timestamp = new Date(); + const tail: StreamItem[] = [ + { kind: "user_message", id: "prompt", text: "Work", timestamp }, + toolCall("one", "Bash"), + toolCall("two", "Bash"), + { kind: "assistant_message", id: "reply", text: "Done", timestamp }, + ]; + const result = present({ + ...presentationOptions, + transform: undefined, + tail, + head: [], + display: "collapseTurns", + isTurnActive: false, + expandedTurnHostIds: new Set(["prompt"]), + }); + expect(result.tail.map((item) => item.id)).toEqual(tail.map((item) => item.id)); + expect(result.groupsByHostId.size).toBe(0); +}); + +it("presents received agent messages as attributed bubbles with the original turn identity", () => { + const call = toolCall("received", "agent_message"); + if (call.payload.source !== "agent") throw new Error("Expected agent tool"); + call.turnId = "turn"; + call.payload.data.agentMessage = { + event: "message", + sender: { id: "sender", title: "Reviewer" }, + text: "Ready", + }; + const result = createStreamPresentation()({ + ...presentationOptions, + transform: undefined, + tail: [call], + head: [], + }); + expect(result.tail[0]).toMatchObject({ + kind: "user_message", + id: call.id, + turnId: "turn", + text: "Ready", + sender: { id: "sender", title: "Reviewer" }, + }); +}); + +it("does not reopen the previous turn while a new run waits for its first row", () => { + const timestamp = new Date(); + const tail: StreamItem[] = [ + { kind: "user_message", id: "prompt", text: "Work", timestamp }, + toolCall("one", "Bash"), + { kind: "assistant_message", id: "reply", text: "Done", timestamp }, + ]; + for (const item of tail) item.turnId = "completed"; + const result = createStreamPresentation()({ + ...presentationOptions, + transform: undefined, + tail, + head: [], + display: "collapseTurns", + isTurnActive: true, + activeTurnId: "new", + }); + expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]); +}); diff --git a/packages/app/src/agent-stream/presentation.ts b/packages/app/src/agent-stream/presentation.ts index 9e8e5b430e..650816bfd5 100644 --- a/packages/app/src/agent-stream/presentation.ts +++ b/packages/app/src/agent-stream/presentation.ts @@ -1,3 +1,4 @@ +import { isTurnBoundary } from "./turn-membership"; import type { ChatDisplay } from "@/hooks/use-settings"; import type { TimelineItemTransform } from "@/plugins/timeline/model"; import { projectPluginTimelineItems } from "@/plugins/timeline/projection"; @@ -19,6 +20,7 @@ interface PresentationInput { /** User messages whose collapsed turn the reader opened. */ expandedTurnHostIds: ReadonlySet; isTurnActive: boolean; + activeTurnId?: string | null; } export interface StreamPresentation extends ToolCallDetailProjection { @@ -63,6 +65,22 @@ export function createStreamPresentation() { let collapsed: TurnCollapse | undefined; function presentRows(item: StreamItem): StreamItem[] { + if (item.kind === "tool_call" && item.payload.source === "agent") { + const message = item.payload.data.agentMessage; + if (message?.event === "message") { + return [ + { + kind: "user_message", + id: item.id, + turnId: item.turnId, + timelineCursor: item.timelineCursor, + timestamp: item.timestamp, + text: message.text, + sender: message.sender, + }, + ]; + } + } return [item.kind === "user_message" ? presentUserMessage(item) : item]; } @@ -75,7 +93,14 @@ export function createStreamPresentation() { } // Live text updates reach the head only, so the folded history is reused until the // retained rows, the latest turn's liveness, or the reader's expanded turns change. - const isLatestTurnOpen = input.isTurnActive || projection.head.length > 0; + const isLatestTurnOpen = + (input.isTurnActive || projection.head.length > 0) && + !isTurnBoundary(projection.tail.at(-1) ?? null, projection.head[0] ?? null) && + !( + input.activeTurnId && + projection.tail.at(-1)?.turnId && + input.activeTurnId !== projection.tail.at(-1)?.turnId + ); if ( !collapsed || collapsedSource !== projection.tail || @@ -93,7 +118,20 @@ export function createStreamPresentation() { collapsedExpandedHostIds = input.expandedTurnHostIds; } const { tail, ...turns } = collapsed; - return { ...projection, tail, turns }; + if (turns.nestedItemIds.size === 0) return { ...projection, tail, turns }; + // Opening a turn reveals its raw calls; those rows must not become group hosts again. + const withoutExpandedRows = (groups: StreamPresentation["groupsByHostId"]) => ({ + size: groups.size - [...turns.nestedItemIds].filter((id) => groups.has(id)).length, + get: (id: string) => (turns.nestedItemIds.has(id) ? undefined : groups.get(id)), + has: (id: string) => !turns.nestedItemIds.has(id) && groups.has(id), + }); + return { + ...projection, + tail, + turns, + groupsByHostId: withoutExpandedRows(projection.groupsByHostId), + historyGroupUpdatesByHostId: withoutExpandedRows(projection.historyGroupUpdatesByHostId), + }; } return (input: PresentationInput): StreamPresentation => { diff --git a/packages/app/src/agent-stream/stream-tick.bench.ts b/packages/app/src/agent-stream/stream-tick.bench.ts index dc1fecb881..b765c441e8 100644 --- a/packages/app/src/agent-stream/stream-tick.bench.ts +++ b/packages/app/src/agent-stream/stream-tick.bench.ts @@ -110,7 +110,7 @@ function runTicks(input: { tail: StreamItem[]; historyStart: number; heads: Stre isTurnActive: true, history: model.history, liveHead: model.segments.liveHead, - timingByAssistantId: model.turnTiming.byAssistantId, + timingByItemId: model.turnTiming.byItemId, }); } } diff --git a/packages/app/src/agent-stream/turn-collapse/model.test.ts b/packages/app/src/agent-stream/turn-collapse/model.test.ts index 4535b14d9e..71e9471ad9 100644 --- a/packages/app/src/agent-stream/turn-collapse/model.test.ts +++ b/packages/app/src/agent-stream/turn-collapse/model.test.ts @@ -63,7 +63,6 @@ describe("collapseTurns", () => { hostId: "u1", expanded: false, summary: { - messageCount: 1, tools: expect.objectContaining({ commandCount: 2 }), }, }); @@ -121,6 +120,34 @@ describe("collapseTurns", () => { expect(result.turnsByHostId.size).toBe(0); }); + it("keeps a completed response folded when a promptless canonical turn starts", () => { + const first = [user("u1"), shell("c1"), reply("r1")].map((item) => + Object.assign(item, { turnId: "first" }), + ); + const next = { ...shell("heartbeat"), turnId: "second" }; + const result = collapse([...first, next], { isLatestTurnOpen: true }); + expect(ids(result.tail)).toEqual(["u1", "r1", "heartbeat"]); + expect(result.turnsByHostId.has("u1")).toBe(true); + }); + + it("keeps mid-turn messages visible when work is folded", () => { + const tail = [user("prompt"), shell("work"), user("steer"), shell("more"), reply("done")]; + for (const item of tail) item.turnId = "same-turn"; + const result = collapse(tail); + expect(ids(result.tail)).toEqual(["prompt", "steer", "done"]); + expect(result.hostIdByFoldedMessageId.has("steer")).toBe(false); + }); + + it("reveals every raw call inside an expanded turn", () => { + const calls = [shell("one"), shell("two")]; + const result = collapse([user("prompt"), calls[0]!, reply("done")], { + expandedHostIds: new Set(["prompt"]), + getGroupedCalls: (id) => (id === "one" ? calls : undefined), + }); + expect(ids(result.tail)).toEqual(["prompt", "one", "two", "done"]); + expect([...result.nestedItemIds]).toEqual(["one", "two"]); + }); + it("counts every call a tool-call group row stands for", () => { const group = [shell("c1"), shell("c2"), shell("c3")]; const result = collapse([user("u1"), group[0]!, reply("final")], { diff --git a/packages/app/src/agent-stream/turn-collapse/model.ts b/packages/app/src/agent-stream/turn-collapse/model.ts index d9f532ad73..badd1ed664 100644 --- a/packages/app/src/agent-stream/turn-collapse/model.ts +++ b/packages/app/src/agent-stream/turn-collapse/model.ts @@ -1,13 +1,13 @@ +import { isTurnBoundary, isTurnTrigger } from "../turn-membership"; import { getStreamItemMessageId, type StreamItem, type ToolCallItem } from "@/types/stream"; import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model"; export interface TurnSummary { - messageCount: number; tools: OverviewSummary; } export interface CollapsedTurn { - /** The user message the turn answers. Its row carries the turn's summary line. */ + /** The input or first loaded row that carries the turn's summary line. */ hostId: string; expanded: boolean; summary: TurnSummary; @@ -40,16 +40,15 @@ function summarizeTurn( getGroupedCalls: CollapseTurnsInput["getGroupedCalls"], ): TurnSummary { const calls: ToolCallItem[] = []; - let messageCount = 0; for (const item of items) { - if (item.kind === "assistant_message") messageCount += 1; - else if (item.kind === "tool_call") calls.push(...(getGroupedCalls(item.id) ?? [item])); + if (item.kind === "tool_call" && !isTurnTrigger(item)) + calls.push(...(getGroupedCalls(item.id) ?? [item])); } - return { messageCount, tools: summarizeToolCalls(calls) }; + return { tools: summarizeToolCalls(calls) }; } function hasWork(summary: TurnSummary): boolean { - return summary.messageCount > 0 || Object.values(summary.tools).some((count) => count > 0); + return Object.values(summary.tools).some((count) => count > 0); } function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number { @@ -77,28 +76,43 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { const host = tail[runStart - 1]; const isClosed = runEnd < tail.length || !input.isLatestTurnOpen; const replyIndex = findLastAssistantIndex(tail, runStart, runEnd); - if (host?.kind !== "user_message" || !isClosed || replyIndex <= runStart) { + if ( + !host || + (host.kind !== "user_message" && host.kind !== "tool_call") || + !isClosed || + replyIndex <= runStart + ) { output.push(...tail.slice(runStart, runEnd)); return; } const folded = tail.slice(runStart, replyIndex); const summary = summarizeTurn(folded, input.getGroupedCalls); - if (!hasWork(summary)) { + if (!hasWork(summary) && !folded.some((item) => item.kind === "assistant_message")) { output.push(...tail.slice(runStart, runEnd)); return; } const expanded = input.expandedHostIds.has(host.id); turnsByHostId.set(host.id, { hostId: host.id, expanded, summary }); - for (const item of folded) { + const revealed = expanded + ? folded.flatMap((item) => + item.kind === "tool_call" ? [...(input.getGroupedCalls(item.id) ?? [item])] : [item], + ) + : folded; + for (const item of revealed) { + if (isTurnTrigger(item)) continue; hostIdByFoldedMessageId.set(getStreamItemMessageId(item), host.id); if (expanded) nestedItemIds.add(item.id); } - if (expanded) output.push(...folded); + output.push(...(expanded ? revealed : folded.filter(isTurnTrigger))); output.push(...tail.slice(replyIndex, runEnd)); }; for (let index = 0; index < tail.length; index += 1) { - if (tail[index]?.kind !== "user_message") continue; + if ( + !(index === 0 && isTurnTrigger(tail[index]!)) && + !isTurnBoundary(tail[index - 1] ?? null, tail[index] ?? null) + ) + continue; flushRun(index); output.push(tail[index]!); runStart = index + 1; @@ -114,7 +128,10 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { }; } return { - tail: output.length === tail.length ? tail : output, + tail: + output.length === tail.length && output.every((item, index) => item === tail[index]) + ? tail + : output, turnsByHostId, nestedItemIds, hostIdByFoldedMessageId, diff --git a/packages/app/src/agent-stream/turn-collapse/summary-line.tsx b/packages/app/src/agent-stream/turn-collapse/summary-line.tsx index 9f73f1ac22..191ac706d5 100644 --- a/packages/app/src/agent-stream/turn-collapse/summary-line.tsx +++ b/packages/app/src/agent-stream/turn-collapse/summary-line.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo } from "react"; +import { memo, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Summary } from "lucide-react-native"; import { ExpandableBadge } from "@/components/message"; @@ -17,19 +17,7 @@ export const TurnSummaryLine = memo(function TurnSummaryLine({ onExpandedChange: (hostId: string, expanded: boolean) => void; }) { const { t } = useTranslation(); - const { messageCount, tools } = turn.summary; - const messageParts = useMemo( - () => - messageCount > 0 - ? [ - t(`turnSummary.messages.${messageCount === 1 ? "one" : "other"}`, { - count: messageCount, - }), - ] - : [], - [messageCount, t], - ); - const label = useOverviewSummaryLabel(tools, messageParts); + const label = useOverviewSummaryLabel(turn.summary.tools) || t("turnSummary.details"); const toggle = useCallback( () => onExpandedChange(turn.hostId, !turn.expanded), [onExpandedChange, turn.expanded, turn.hostId], diff --git a/packages/app/src/agent-stream/turn-footer.tsx b/packages/app/src/agent-stream/turn-footer.tsx index acbd920b81..5d04f9eca9 100644 --- a/packages/app/src/agent-stream/turn-footer.tsx +++ b/packages/app/src/agent-stream/turn-footer.tsx @@ -197,7 +197,7 @@ function CompletedTurnFooter({ return ( { ); }); - it("renders adjacent turns without visible prompts as one response", () => { + it("renders a separate footer for each canonical turn without visible prompts", () => { const items = [ user("prompt", 1, "turn-1"), assistant("first reply", 2, "turn-1"), @@ -88,7 +88,11 @@ describe("canonical turn membership", () => { const completed = layoutFor(items, false); - expect(completedFooterIds(completed.layout)).toEqual(["heartbeat-2-reply"]); + expect(completedFooterIds(completed.layout)).toEqual([ + "first reply", + "heartbeat-1-reply", + "heartbeat-2-reply", + ]); expect(completed.layout.auxiliaryTurnFooter?.timing).toEqual({ completedAt: at(6), durationMs: null, @@ -123,7 +127,7 @@ describe("canonical turn membership", () => { const completedItems = [...build(turnId), assistant("done", 9, turnId)]; const completed = layoutFor(completedItems, false); expect(completedFooterIds(completed.layout)).toEqual(["done"]); - expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({ + expect(completed.model.turnTiming.byItemId.get("done")).toMatchObject({ completedAt: at(9), durationMs: 8000, }); diff --git a/packages/app/src/agent-stream/turn-membership.ts b/packages/app/src/agent-stream/turn-membership.ts index 414d583305..822efe2404 100644 --- a/packages/app/src/agent-stream/turn-membership.ts +++ b/packages/app/src/agent-stream/turn-membership.ts @@ -9,16 +9,15 @@ export function continuesTurn(previous: StreamItem | null, next: StreamItem | nu if (previous.turnId !== undefined && next.turnId !== undefined) { return previous.turnId === next.turnId; } - return next.kind !== "user_message"; + return !isTurnTrigger(next); } /** - * A visible response can span multiple canonical turns when their prompts are - * system-injected and therefore absent from the Paseo timeline. + * Every canonical turn owns its response, including system-triggered runs. */ export function continuesResponse(previous: StreamItem | null, next: StreamItem | null): boolean { if (!previous || !next) return false; - return continuesTurn(previous, next) || next.kind !== "user_message"; + return continuesTurn(previous, next); } export function isTurnBoundary(previous: StreamItem | null, next: StreamItem | null): boolean { @@ -37,3 +36,13 @@ export function startsNewTurn(item: StreamItem, previous: StreamItem | null): bo export function belongsToTurn(item: StreamItem, turnId: string | null): boolean { return turnId !== null && item.turnId === turnId; } + +/** Inputs that start a turn when older history has no canonical turn IDs. */ +export function isTurnTrigger(item: StreamItem): boolean { + if (item.kind === "user_message") return true; + return ( + item.kind === "tool_call" && + item.payload.source === "agent" && + (item.payload.data.agentMessage !== undefined || item.payload.data.name === "heartbeat") + ); +} diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 67e08a6f82..df5d5448e6 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -526,6 +526,7 @@ const AgentStreamViewComponent = forwardRef + presentation.turns.nestedItemIds.has(id), + ); const renderThoughtItem = useCallback( (layoutItem: StreamLayoutItem, item: Extract) => { return ( @@ -796,11 +802,11 @@ const AgentStreamViewComponent = forwardRef ); }, - [autoExpandReasoning, setInlineDetailsExpanded], + [autoExpandReasoning, setInlineDetailsExpanded, isExpandedTurnItem], ); const renderSingleToolCallItem = useCallback( @@ -830,6 +836,8 @@ const AgentStreamViewComponent = forwardRef ); }, - [context.cwd, resolvedServerId, setInlineDetailsExpanded, handleToolCallOpenFile], + [ + context.cwd, + resolvedServerId, + setInlineDetailsExpanded, + handleToolCallOpenFile, + isExpandedTurnItem, + ], ); // Read through a stable event so live group updates do not change the renderer identity @@ -917,7 +931,17 @@ const AgentStreamViewComponent = forwardRef + {renderToolCallItem(layoutItem, item)} + {getCollapsedTurn(item.id) ? ( + + ) : null} + + ); case "notification": return ; @@ -949,6 +973,8 @@ const AgentStreamViewComponent = forwardRef + {sender ? : null} - {hasImages ? ( - - {images.map((image) => ( - - ))} - - ) : null} - {hasAttachments ? ( - - {attachments.map((attachment, index) => { - const content = getAgentAttachmentPillContent(attachment, t); - return ( - - - - ); - })} - - ) : null} - {hasText ? ( - - {message} - - ) : null} + + {hasImages ? ( + + {images.map((image) => ( + + ))} + + ) : null} + {hasAttachments ? ( + + {attachments.map((attachment, index) => { + const content = getAgentAttachmentPillContent(attachment, t); + return ( + + + + ); + })} + + ) : null} + {hasText ? ( + + {message} + + ) : null} + {hasText ? ( string; + getContent?: () => string; completedAt?: Date; durationMs?: number | null; onFork?: (target: AssistantForkTarget) => Promise | void; @@ -641,10 +647,12 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ return ( - + {getContent ? ( + + ) : null} {canFork ? : null} {primaryLabel ? ( { color: string }> = { + surface3: (theme) => ({ color: theme.colors.surface3 }), surface0: (theme) => ({ color: theme.colors.surface0 }), surfaceWorkspace: (theme) => ({ color: theme.colors.surfaceWorkspace }), }; diff --git a/packages/app/src/components/user-message/index.tsx b/packages/app/src/components/user-message/index.tsx new file mode 100644 index 0000000000..18a48798fb --- /dev/null +++ b/packages/app/src/components/user-message/index.tsx @@ -0,0 +1,79 @@ +import { useState, useCallback, useMemo, type ReactNode } from "react"; +import { View, type LayoutChangeEvent } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import type { AgentMessage } from "@getpaseo/protocol/agent-message"; +import { Button } from "@/components/ui/button"; +import { EdgeFade } from "@/components/ui/edge-fade"; +import { useProviderIcon } from "@/components/provider-icons"; +import { useSessionStore } from "@/stores/session-store"; +import { navigateToAgent } from "@/utils/navigate-to-agent"; + +const PREVIEW_HEIGHT = 240; + +export function MessagePreview({ children }: { children: ReactNode }) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + const [height, setHeight] = useState(0); + const overflowing = height > PREVIEW_HEIGHT; + const measure = useCallback( + (event: LayoutChangeEvent) => setHeight(event.nativeEvent.layout.height), + [], + ); + const toggle = useCallback(() => setExpanded((value) => !value), []); + const accessibilityState = useMemo(() => ({ expanded }), [expanded]); + return ( + <> + + + {children} + + {overflowing && !expanded ? ( + + ) : null} + + {overflowing ? ( + + ) : null} + + ); +} + +export function MessageSender({ + sender, + serverId, +}: { + sender: AgentMessage["sender"]; + serverId?: string; +}) { + const { t } = useTranslation(); + const host = sender.serverId ?? serverId; + const agent = useSessionStore((state) => { + const session = host ? state.sessions[host] : undefined; + return session?.agents.get(sender.id) ?? session?.agentDetails.get(sender.id); + }); + const ProviderIcon = useProviderIcon(agent?.provider ?? "", host); + const openSender = useCallback(() => { + if (host) navigateToAgent({ serverId: host, agentId: sender.id }); + }, [host, sender.id]); + return ( + + ); +} + +const styles = StyleSheet.create((theme) => ({ + preview: { maxHeight: PREVIEW_HEIGHT, overflow: "hidden" }, + body: { flexShrink: 0 }, + toggle: { alignSelf: "flex-start", paddingHorizontal: 0, borderWidth: 0 }, + fade: { position: "absolute", bottom: 0, left: 0, right: 0, height: theme.spacing[12] }, +})); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 108fad41d4..04b38a2bd3 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2018,10 +2018,10 @@ export const ar: TranslationResources = { output: "الإخراج", }, turnSummary: { - messages: { - one: "كتب {{count}} رسالة", - other: "كتب {{count}} رسائل", - }, + details: "عرض تفاصيل الجولة", + sentBy: "أرسلها {{name}}", + showMore: "عرض المزيد", + showLess: "عرض أقل", }, toolCallGroup: { editedFiles: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 16bddd9cf9..710db52eb7 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2043,10 +2043,10 @@ export const en = { output: "Output", }, turnSummary: { - messages: { - one: "wrote {{count}} message", - other: "wrote {{count}} messages", - }, + details: "View turn details", + sentBy: "Sent by {{name}}", + showMore: "Show more", + showLess: "Show less", }, toolCallGroup: { editedFiles: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 2226583781..262662a9cc 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2066,10 +2066,10 @@ export const es: TranslationResources = { output: "Producción", }, turnSummary: { - messages: { - one: "escribió {{count}} mensaje", - other: "escribió {{count}} mensajes", - }, + details: "Ver detalles del turno", + sentBy: "Enviado por {{name}}", + showMore: "Mostrar más", + showLess: "Mostrar menos", }, toolCallGroup: { editedFiles: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 65d72d405b..f1ea8930d9 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2065,10 +2065,10 @@ export const fr: TranslationResources = { output: "Sortie", }, turnSummary: { - messages: { - one: "{{count}} message écrit", - other: "{{count}} messages écrits", - }, + details: "Voir les détails du tour", + sentBy: "Envoyé par {{name}}", + showMore: "Afficher plus", + showLess: "Afficher moins", }, toolCallGroup: { editedFiles: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 5829bf4fa3..b56b5f7836 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2038,10 +2038,10 @@ export const ja: TranslationResources = { output: "出力", }, turnSummary: { - messages: { - one: "{{count}}件のメッセージを送信", - other: "{{count}}件のメッセージを送信", - }, + details: "ターンの詳細を表示", + sentBy: "{{name}} から送信", + showMore: "もっと見る", + showLess: "折りたたむ", }, toolCallGroup: { editedFiles: { diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 36a27fc84e..6d4305eec1 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2028,10 +2028,10 @@ export const ko: TranslationResources = { output: "출력", }, turnSummary: { - messages: { - one: "{{count}} 메시지를 작성했습니다.", - other: "{{count}} 메시지를 작성했습니다.", - }, + details: "턴 세부 정보 보기", + sentBy: "{{name}}이(가) 보냄", + showMore: "더 보기", + showLess: "접기", }, toolCallGroup: { editedFiles: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 5a83b4345c..761be9f434 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2051,10 +2051,10 @@ export const ptBR: TranslationResources = { output: "Saída", }, turnSummary: { - messages: { - one: "escreveu {{count}} mensagem", - other: "escreveu {{count}} mensagens", - }, + details: "Ver detalhes do turno", + sentBy: "Enviado por {{name}}", + showMore: "Mostrar mais", + showLess: "Mostrar menos", }, toolCallGroup: { editedFiles: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 135d5fe2f9..89f4f9037f 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2051,10 +2051,10 @@ export const ru: TranslationResources = { output: "Выходные данные", }, turnSummary: { - messages: { - one: "написано {{count}} сообщение", - other: "написаны сообщения ({{count}})", - }, + details: "Показать подробности хода", + sentBy: "Отправлено агентом {{name}}", + showMore: "Показать больше", + showLess: "Свернуть", }, toolCallGroup: { editedFiles: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 78b0f06442..1e7e37678c 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1996,10 +1996,10 @@ export const zhCN: TranslationResources = { output: "输出", }, turnSummary: { - messages: { - one: "写了 {{count}} 条消息", - other: "写了 {{count}} 条消息", - }, + details: "查看轮次详情", + sentBy: "由 {{name}} 发送", + showMore: "展开", + showLess: "收起", }, toolCallGroup: { editedFiles: { diff --git a/packages/app/src/timeline/turn-time.test.ts b/packages/app/src/timeline/turn-time.test.ts index 3ce6942301..f3e97b2e49 100644 --- a/packages/app/src/timeline/turn-time.test.ts +++ b/packages/app/src/timeline/turn-time.test.ts @@ -51,7 +51,7 @@ describe("deriveStreamTurnTiming", () => { }); assert.equal(timing.runningStartedAt, secondUserAt); - assert.equal(timing.byAssistantId.has("a2"), false); + assert.equal(timing.byItemId.has("a2"), false); }); it("derives completed turn timing from user and assistant item timestamps", () => { @@ -69,7 +69,7 @@ describe("deriveStreamTurnTiming", () => { head: [], }); - assert.deepEqual(timing.byAssistantId.get("a1"), { + assert.deepEqual(timing.byItemId.get("a1"), { completedAt: assistantAt, durationMs: 7000, }); @@ -95,8 +95,8 @@ describe("deriveStreamTurnTiming", () => { completedAt: lastAssistantAt, durationMs: 7000, }; - assert.deepEqual(timing.byAssistantId.get("a1"), expected); - assert.deepEqual(timing.byAssistantId.get("a2"), expected); + assert.deepEqual(timing.byItemId.get("a1"), expected); + assert.deepEqual(timing.byItemId.get("a2"), expected); }); it("preserves the completion timestamp when a canonical turn has no visible prompt", () => { @@ -120,7 +120,7 @@ describe("deriveStreamTurnTiming", () => { head: [], }); - assert.deepEqual(timing.byAssistantId.get("hidden-prompt-a2"), { + assert.deepEqual(timing.byItemId.get("hidden-prompt-a2"), { completedAt: hiddenPromptTurnAt, durationMs: null, }); @@ -143,12 +143,12 @@ describe("deriveStreamTurnTiming", () => { const first = derive("one", "2026-05-15T00:01:04.000Z"); const second = derive("one two", "2026-05-15T00:01:05.000Z"); - assert.equal(second.byAssistantId, first.byAssistantId); - assert.deepEqual(first.byAssistantId.get("a1"), { + assert.equal(second.byItemId, first.byItemId); + assert.deepEqual(first.byItemId.get("a1"), { completedAt: tail[1]?.timestamp, durationMs: 7000, }); - assert.equal(first.byAssistantId.has("a2"), false); + assert.equal(first.byItemId.has("a2"), false); }); it("keeps the timing map identity when the head closed the tail's open turn", () => { @@ -168,8 +168,8 @@ describe("deriveStreamTurnTiming", () => { const first = derive("one", "2026-05-15T00:01:04.000Z"); const second = derive("one two", "2026-05-15T00:01:05.000Z"); - assert.equal(second.byAssistantId, first.byAssistantId); - assert.deepEqual(first.byAssistantId.get("a1"), { + assert.equal(second.byItemId, first.byItemId); + assert.deepEqual(first.byItemId.get("a1"), { completedAt: lastTailAt, durationMs: 7000, }); @@ -193,9 +193,9 @@ describe("deriveStreamTurnTiming", () => { head, }); - assert.notEqual(completed.byAssistantId, active.byAssistantId); - assert.equal(active.byAssistantId.has("a1"), false); - assert.deepEqual(completed.byAssistantId.get("a1"), { completedAt, durationMs: 9000 }); + assert.notEqual(completed.byItemId, active.byItemId); + assert.equal(active.byItemId.has("a1"), false); + assert.deepEqual(completed.byItemId.get("a1"), { completedAt, durationMs: 9000 }); }); it("derives fresh timing when the tail itself changes", () => { @@ -211,11 +211,39 @@ describe("deriveStreamTurnTiming", () => { const first = derive(firstTail); const second = derive(secondTail); - assert.notEqual(second.byAssistantId, first.byAssistantId); - assert.equal(first.byAssistantId.has("a2"), false); - assert.deepEqual(second.byAssistantId.get("a2"), { + assert.notEqual(second.byItemId, first.byItemId); + assert.equal(first.byItemId.has("a2"), false); + assert.deepEqual(second.byItemId.get("a2"), { completedAt: secondTail[3]?.timestamp, durationMs: 3000, }); }); }); + +it("times a heartbeat that ends with tools and no assistant text", () => { + const trigger: StreamItem = { + kind: "tool_call", + id: "trigger", + timestamp: new Date("2026-05-15T00:00:00Z"), + turnId: "heartbeat", + payload: { + source: "agent", + data: { + provider: "claude", + callId: "trigger", + name: "heartbeat", + status: "completed", + error: null, + detail: { type: "plain_text", text: "Check progress" }, + }, + }, + }; + const tool: StreamItem = { ...trigger, id: "last", timestamp: new Date("2026-05-15T00:00:05Z") }; + const timing = deriveStreamTurnTiming({ + tail: [trigger, tool], + head: [], + isTurnActive: false, + activeTurnStartedAt: null, + }); + assert.equal(timing.byItemId.get("last")?.durationMs, 5000); +}); diff --git a/packages/app/src/timeline/turn-time.ts b/packages/app/src/timeline/turn-time.ts index c47a6983cd..8d1aa00648 100644 --- a/packages/app/src/timeline/turn-time.ts +++ b/packages/app/src/timeline/turn-time.ts @@ -1,5 +1,5 @@ import type { StreamItem } from "@/types/stream"; -import { startsNewTurn } from "@/agent-stream/turn-membership"; +import { startsNewTurn, isTurnTrigger } from "@/agent-stream/turn-membership"; export interface TurnTiming { completedAt: Date; @@ -7,7 +7,7 @@ export interface TurnTiming { } export interface StreamTurnTiming { - byAssistantId: Map; + byItemId: Map; runningStartedAt: Date | null; } @@ -20,7 +20,7 @@ interface OpenTurn { interface HeadTurnTiming { additions: Map; - byAssistantId: Map; + byItemId: Map; } interface TailTurnTiming { @@ -37,14 +37,18 @@ const EMPTY_OPEN_TURN: OpenTurn = { }; function closeTurn(turn: OpenTurn, into: Map): void { - if (!turn.lastItemAt || turn.assistantIds.length === 0) { + if (!turn.lastItemAt) { return; } const timing: TurnTiming = { completedAt: turn.lastItemAt, durationMs: turn.userAt ? Math.max(0, turn.lastItemAt.getTime() - turn.userAt.getTime()) : null, }; - for (const id of turn.assistantIds) { + let footerIds = turn.assistantIds; + if (footerIds.length === 0 && turn.previousItem?.kind === "tool_call") { + footerIds = [turn.previousItem.id]; + } + for (const id of footerIds) { into.set(id, timing); } } @@ -61,7 +65,7 @@ function walkTurns( for (const item of items) { if (startsNewTurn(item, previousItem)) { closeTurn({ userAt, lastItemAt, assistantIds, previousItem }, into); - userAt = item.kind === "user_message" ? item.timestamp : null; + userAt = isTurnTrigger(item) ? item.timestamp : null; lastItemAt = null; assistantIds = []; } @@ -108,7 +112,7 @@ function getTailTurnTiming(tail: StreamItem[]): TailTurnTiming { return timing; } -function resolveByAssistantId( +function resolveByItemId( tail: TailTurnTiming, additions: Map, ): Map { @@ -116,14 +120,14 @@ function resolveByAssistantId( return tail.completed; } if (tail.lastHead && areTimingsEqual(tail.lastHead.additions, additions)) { - return tail.lastHead.byAssistantId; + return tail.lastHead.byItemId; } - const byAssistantId = new Map(tail.completed); + const byItemId = new Map(tail.completed); for (const [id, timing] of additions) { - byAssistantId.set(id, timing); + byItemId.set(id, timing); } - tail.lastHead = { additions, byAssistantId }; - return byAssistantId; + tail.lastHead = { additions, byItemId }; + return byItemId; } export function deriveStreamTurnTiming(params: { @@ -139,7 +143,7 @@ export function deriveStreamTurnTiming(params: { closeTurn(openTurn, additions); } return { - byAssistantId: resolveByAssistantId(tail, additions), + byItemId: resolveByItemId(tail, additions), runningStartedAt: params.isTurnActive ? params.activeTurnStartedAt : null, }; } diff --git a/packages/app/src/tool-calls/detail-level/grouping.ts b/packages/app/src/tool-calls/detail-level/grouping.ts index 079158b2d9..ba809d9e44 100644 --- a/packages/app/src/tool-calls/detail-level/grouping.ts +++ b/packages/app/src/tool-calls/detail-level/grouping.ts @@ -1,3 +1,4 @@ +import { isTurnTrigger, isTurnBoundary } from "@/agent-stream/turn-membership"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import type { StreamItem, ToolCallItem } from "@/types/stream"; @@ -63,7 +64,7 @@ export function describeToolCall(item: ToolCallItem): ToolCallDescriptor { } export function isGroupableToolCall(item: StreamItem): item is ToolCallItem { - if (item.kind !== "tool_call") { + if (item.kind !== "tool_call" || isTurnTrigger(item)) { return false; } const descriptor = describeToolCall(item); @@ -116,7 +117,7 @@ export function prepareGroupedHistory(input: { let pending: ToolCallItem[] = []; for (const item of input.tail) { - if (isGroupableToolCall(item)) { + if (isGroupableToolCall(item) && !isTurnBoundary(pending.at(-1) ?? null, item)) { pending.push(item); continue; } @@ -128,7 +129,8 @@ export function prepareGroupedHistory(input: { buildGroup: input.buildGroup, }); pending = []; - output.push(item); + if (isGroupableToolCall(item)) pending.push(item); + else output.push(item); } appendRun({ @@ -175,6 +177,7 @@ export function groupLiveToolCalls(input: { }; for (const item of input.head) { + if (isTurnBoundary(pending.at(-1) ?? null, item)) flush(true); if (isGroupableToolCall(item)) { if (pending.length === 0) { hostPlacement = "head"; diff --git a/packages/app/src/tool-calls/detail-level/projection.test.ts b/packages/app/src/tool-calls/detail-level/projection.test.ts index dabf87b73d..4d044e22b4 100644 --- a/packages/app/src/tool-calls/detail-level/projection.test.ts +++ b/packages/app/src/tool-calls/detail-level/projection.test.ts @@ -480,3 +480,24 @@ describe("tool call detail-level projection", () => { expect(result.groupsByHostId.size).toBe(1); }); }); + +it("keeps a heartbeat trigger visible outside grouped work", () => { + const trigger = toolCall( + "1", + { type: "plain_text", text: "Check progress" }, + { name: "heartbeat" }, + ); + const work = toolCall("2", { type: "shell", command: "pwd" }); + const result = project({ level: "overview", tail: [trigger, work] }); + expect(result.tail.map((item) => item.id)).toEqual(["1", "2"]); + expect(result.groupsByHostId.has("1")).toBe(false); +}); + +it("does not merge tools from different canonical turns", () => { + const first = toolCall("1", { type: "shell", command: "pwd" }); + first.turnId = "first"; + const second = toolCall("2", { type: "shell", command: "ls" }); + second.turnId = "second"; + const result = project({ level: "overview", tail: [first, second] }); + expect(result.tail.map((item) => item.id)).toEqual(["1", "2"]); +}); diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 657d218b35..b1d0903a92 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -91,6 +91,8 @@ export type StreamItem = export type UserMessageImageAttachment = AttachmentMetadata; export interface UserMessageItem { + /** Attribution added by presentation for received agent messages. */ + sender?: AgentMessage["sender"]; kind: "user_message"; id: string; clientMessageId?: string; diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index ae8989c003..d383a9deec 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -11341,7 +11341,7 @@ test("listImportableSessions searches every provider result before global rankin ]); }); -test("user_message events wrapping a paseo-system envelope are not added to the timeline", async () => { +test("system-injected prompts appear as trigger tools in the live timeline", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-live-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); @@ -11370,13 +11370,16 @@ test("user_message events wrapping a paseo-system envelope are not added to the await manager.runAgent(snapshot.id, { text: "do something" }); const timeline = manager.getTimeline(snapshot.id); + expect(timeline).toContainEqual( + expect.objectContaining({ type: "tool_call", name: "heartbeat", status: "completed" }), + ); const userMessages = timeline.filter((item) => item.type === "user_message"); expect(userMessages).toHaveLength(1); expect(userMessages[0].text).toBe("plain user message"); }); -test("user_message events wrapping a paseo-system envelope are not restored during history replay", async () => { +test("system-injected prompts appear as trigger tools during history replay", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-history-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); @@ -11411,6 +11414,9 @@ test("user_message events wrapping a paseo-system envelope are not restored duri await manager.hydrateTimelineFromProvider(snapshot.id, { force: true }); const timeline = manager.getTimeline(snapshot.id); + expect(timeline).toContainEqual( + expect.objectContaining({ type: "tool_call", name: "heartbeat", status: "completed" }), + ); const userMessages = timeline.filter((item) => item.type === "user_message"); expect(userMessages).toHaveLength(1); diff --git a/packages/server/src/server/agent/agent-messages/envelope.ts b/packages/server/src/server/agent/agent-messages/envelope.ts index 2144d6ae02..a0aa880fce 100644 --- a/packages/server/src/server/agent/agent-messages/envelope.ts +++ b/packages/server/src/server/agent/agent-messages/envelope.ts @@ -1,5 +1,5 @@ import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { z } from "zod"; import type { AgentPromptContentBlock, @@ -175,9 +175,18 @@ function projectAgentMessage(item: AgentTimelineItem): AgentTimelineItem | null if (item.type !== "user_message") return item; const message = parseAgentMessage(item.text); if (!message) { - // COMPAT(legacySystemEnvelope): added in v0.11.1; remove after 2027-04-08 once - // legacy notification history is retired. Schedules still use this envelope. - return isSystemInjectedEnvelope(item.text) ? null : item; + // Schedule bodies include their run ID, keeping identity stable across acceptance + // and provider replay even when the provider assigns its own message ID. + if (!isSystemInjectedEnvelope(item.text)) return item; + const text = item.text.slice("\n".length, -"\n".length); + return { + type: "tool_call", + callId: `paseo-system:${createHash("sha256").update(item.text).digest("hex")}`, + name: "heartbeat", + status: "completed", + error: null, + detail: { type: "plain_text", text, icon: "sparkles" }, + }; } const { source } = message; if (!source) { diff --git a/packages/server/src/server/agent/agent-prompt.test.ts b/packages/server/src/server/agent/agent-prompt.test.ts index 00e70756de..2c150bd2e1 100644 --- a/packages/server/src/server/agent/agent-prompt.test.ts +++ b/packages/server/src/server/agent/agent-prompt.test.ts @@ -357,3 +357,21 @@ test("a human pasting an agent envelope stays a human message through provider r clientMessageId: "human-submission", }); }); + +test("schedule prompts remain visible as completed tool calls on replay", () => { + const text = formatSystemNotificationPrompt("Heartbeat: check progress"); + expect(projectAgentMessages({ type: "user_message", messageId: "fire-1", text })).toEqual([ + expect.objectContaining({ + type: "tool_call", + status: "completed", + detail: { type: "plain_text", text: "Heartbeat: check progress", icon: "sparkles" }, + }), + ]); +}); + +test("schedule trigger identity survives the provider assigning a message ID", () => { + const text = formatSystemNotificationPrompt("Schedule fired (id=heartbeat, run=run-1)."); + expect(projectAgentMessages({ type: "user_message", text })).toEqual( + projectAgentMessages({ type: "user_message", text, messageId: "provider-message" }), + ); +});