From b12eefb28ff99fd8a26eb826fced87f5d62dd808 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 27 Oct 2026 13:57:17 +0700 Subject: [PATCH] Keep conversation turns distinct and history stable (#7990) * fix(app): keep autonomous turns distinct and readable Hidden system prompts merged autonomous work into the previous response and reopened completed turns. Preserve canonical turn boundaries and expose their triggers, while making received messages attributable and expanded turn details readable in one step. * fix(timeline): mount history at actual run boundaries * fix(timeline): load and retain complete available turns * fix(app): summarize actions and preserve conversation events * test(timeline): verify whole-turn paging stays responsive * fix(app): group reasoning according to thinking preference * test(app): align history journeys with whole-turn loading --- docs/timeline-sync.md | 34 ++- .../browser/agent-message-submission.spec.ts | 9 +- .../owned-subscriptions-old-daemon.spec.ts | 57 +---- .../app/e2e/browser/turn-collapse.spec.ts | 235 +++++++++++++++++- .../app/e2e/support/helpers/custom-models.ts | 3 + .../app/e2e/support/helpers/seed-client.ts | 6 +- .../src/agent-stream/footer-spacing.test.ts | 2 +- .../app/src/agent-stream/history-window.ts | 3 +- packages/app/src/agent-stream/layout.test.ts | 12 +- packages/app/src/agent-stream/layout.ts | 55 ++-- packages/app/src/agent-stream/model.test.ts | 18 +- .../app/src/agent-stream/presentation.test.ts | 126 ++++++++++ packages/app/src/agent-stream/presentation.ts | 54 +++- .../src/agent-stream/render-strategy.test.ts | 4 +- .../app/src/agent-stream/stream-tick.bench.ts | 2 +- .../agent-stream/turn-collapse/model.test.ts | 64 ++++- .../src/agent-stream/turn-collapse/model.ts | 72 ++++-- .../turn-collapse/summary-line.tsx | 16 +- packages/app/src/agent-stream/turn-footer.tsx | 2 +- .../src/agent-stream/turn-membership.test.ts | 30 ++- .../app/src/agent-stream/turn-membership.ts | 22 +- packages/app/src/agent-stream/view.tsx | 73 ++++-- .../agent-stream/web-virtualization.test.ts | 11 + packages/app/src/components/indent-rail.tsx | 7 +- packages/app/src/components/message.tsx | 88 ++++--- .../rewind/use-rewind-agent-mutation.ts | 4 +- packages/app/src/components/ui/edge-fade.tsx | 3 +- .../app/src/components/user-message/index.tsx | 79 ++++++ .../hooks/use-agent-initialization.test.ts | 5 + .../use-load-older-agent-history.test.ts | 1 + packages/app/src/i18n/resources/ar.ts | 9 +- packages/app/src/i18n/resources/en.ts | 9 +- packages/app/src/i18n/resources/es.ts | 9 +- packages/app/src/i18n/resources/fr.ts | 9 +- packages/app/src/i18n/resources/ja.ts | 9 +- packages/app/src/i18n/resources/ko.ts | 9 +- packages/app/src/i18n/resources/pt-BR.ts | 9 +- packages/app/src/i18n/resources/ru.ts | 9 +- packages/app/src/i18n/resources/zh-CN.ts | 9 +- .../src/runtime/replica-cache/index.test.ts | 15 ++ .../app/src/runtime/replica-cache/index.ts | 12 +- .../screens/workspace/workspace-screen.tsx | 4 +- .../src/timeline/timeline-replacement.test.ts | 2 +- .../src/timeline/timeline-sync-plan.test.ts | 4 + .../app/src/timeline/timeline-sync-plan.ts | 7 + packages/app/src/timeline/turn-time.test.ts | 60 +++-- packages/app/src/timeline/turn-time.ts | 30 ++- .../src/timeline/viewed-timeline-sync.test.ts | 21 +- .../src/tool-calls/detail-level/grouping.ts | 43 ++-- .../tool-calls/detail-level/overview/label.ts | 7 +- .../tool-calls/detail-level/overview/model.ts | 34 +-- .../detail-level/projection.test.ts | 64 ++++- .../src/tool-calls/detail-level/projection.ts | 5 + packages/app/src/types/stream.ts | 2 + packages/client/src/daemon-client.test.ts | 5 + packages/client/src/daemon-client.ts | 5 + packages/protocol/src/messages.ts | 3 + .../src/paseo-tool-presentation/catalog.ts | 126 +++++++--- .../src/paseo-tool-presentation/index.ts | 15 +- packages/protocol/src/timeline-turn.ts | 24 ++ .../src/server/agent/agent-manager.test.ts | 70 +++++- .../server/agent/agent-messages/envelope.ts | 17 +- .../src/server/agent/agent-prompt.test.ts | 18 ++ .../agent/agent-timeline-store-types.ts | 1 + .../server/agent/agent-timeline-store.test.ts | 87 +++++-- .../src/server/agent/agent-timeline-store.ts | 1 + .../agent/providers/mock-load-test-agent.ts | 48 ++-- .../test-utils/timeline-page-reference.ts | 2 +- .../server/agent/timeline-projection.test.ts | 148 +++++++++++ .../src/server/agent/timeline-projection.ts | 39 ++- packages/server/src/server/session.ts | 1 + .../server/src/server/websocket-server.ts | 1 + 72 files changed, 1690 insertions(+), 409 deletions(-) create mode 100644 packages/app/src/components/user-message/index.tsx create mode 100644 packages/protocol/src/timeline-turn.ts diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 118ad628ce..0dd2c2093f 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -13,7 +13,7 @@ and rebuilds the projection when an agent resumes. The invariants are: > A continuously subscribed client applies every committed row in order. Opening or resuming an -> agent establishes the daemon's current tail in one bounded request, with older history reachable +> agent establishes the daemon's current tail in one complete-turn request, with older history reachable > through backward pagination. Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced @@ -44,7 +44,20 @@ recovery is complete only when `hasNewer: false`. Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog. -Opening or resuming an agent fetches one bounded latest tail page. Older history remains +Opening or resuming an agent fetches the latest available turns with a soft target of 40 +projected items. Backward pages and prompt-jump windows also include whole turns, including +all currently available rows of an active run. There is no per-turn cap: a large run can +exceed the target. Local history mounting uses the same boundaries so revealing older rows +does not change the collapse state of a turn already displayed. + +`wholeTurns` is optional for existing SDK callers. The app requires +`features.wholeTurnTimeline` once at the client fetch boundary; unsupported hosts receive an +update-host error. Forward catch-up stays incremental, but its reset snapshot honors the +whole-turn request. Source coverage still includes interleaved tool updates. + +Canonical IDs define runs. With missing IDs, human prompts, heartbeats and typed received +agent messages each start a fallback turn. This deterministic fallback is shared by paging +and presentation; it cannot recover historical batching or steering. Older history remains user-driven by scrolling upward. A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling. @@ -149,12 +162,13 @@ range describes those exact items: `startSeq` drives older pagination and `endSe catch-up. The owner requests `after endSeq`, and requests `before startSeq` when the user loads older history. Code outside the owner does not distinguish cached and network timelines. -The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail +The first resume request is bounded. If it reports more newer history, fetch one latest complete-turn tail instead of replaying every missed page. Live gap recovery still pages forward until current. -If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a +If the canonical window exceeds the cache item target, contains a discontiguous retained range, has a live head, or includes presentation data the cache cannot encode losslessly, persistence drops the -range and keeps a display-only tail. Never slice items while retaining the pre-slice range; that +range and keeps a display-only tail of complete available turns. The item target is soft; +the byte budget still evicts entire cache rows. Never slice items while retaining the pre-slice range; that falsely certifies discarded source rows. A display-only row paints without granting synchronization authority, so the owner uses the ordinary bounded `tail` bootstrap. @@ -238,11 +252,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. Agent-originated prompts and lifecycle -notifications project into tool rows, so they do not create a human-message boundary. Legacy system -prompts, including schedule injections, remain hidden. One visible response can span several canonical -turns; layout and copy group that response together while lifecycle, timing, tool sequences, and exact -fork positions retain the canonical `turnId` boundaries. +Every canonical run owns a visible turn, including heartbeats and agent notifications. +Notifications delivered during a run stay inside it; batched notifications share the run +that starts or accepts them. Received messages retain sender attribution, while lifecycle +notifications remain visible events. Collapse, timing and locally mounted history share +these boundaries. Agent-message XML belongs to the daemon's `agent/agent-messages` boundary. Prompt delivery encodes provenance; acceptance, provider echoes, import, and replay share its projection. The delivery ID is diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts index 0009188b34..dcf872f8be 100644 --- a/packages/app/e2e/browser/agent-message-submission.spec.ts +++ b/packages/app/e2e/browser/agent-message-submission.spec.ts @@ -53,6 +53,7 @@ import { import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork"; import { scrollTimelineToNewestLoadedEdge, + userScrollsTimelineToHistoryStart, scrollTimelineUntilOlderHistoryIsReachable, } from "../support/helpers/timeline-pagination"; import { @@ -986,7 +987,7 @@ test.describe("Agent message submission", () => { } }); - test("keeps one prompt when its provider echo crosses the bounded tail", async ({ + test("keeps one prompt when a delayed provider echo belongs to a large whole turn", async ({ page, }, testInfo) => { const workspace = await seedWorkspace({ @@ -1003,8 +1004,10 @@ test.describe("Agent message submission", () => { await expectVisibleAgentSurfacesIdle(page); await page.reload(); - await expect(submittedPrompt).toHaveCount(0); - await scrollTimelineUntilOlderHistoryIsReachable(page, prompt); + // The complete turn arrives on reload; scrolling mounts its oldest rows + // without requiring another page or an increase in total content height. + await userScrollsTimelineToHistoryStart(page); + await expect(submittedPrompt).toBeVisible(); await expect(submittedPrompt).toHaveCount(1); await expect(submittedPrompt).toHaveAttribute("aria-busy", "false"); } finally { diff --git a/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts index 17f4db2e85..4b49d8105e 100644 --- a/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts +++ b/packages/app/e2e/browser/owned-subscriptions-old-daemon.spec.ts @@ -1,5 +1,4 @@ import { randomUUID } from "node:crypto"; -import { submitMessage, expectComposerEditable } from "../support/helpers/composer"; import { focusTerminalSurface, typeInTerminal, @@ -11,14 +10,9 @@ import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; import type { MockAgentWorkspace } from "../support/helpers/mock-agent"; import { seedWorkspace } from "../support/helpers/seed-client"; -import { - expectTimelinePromptVisible, - holdOlderHistoryPages, - openAgentTimeline, - scrollThroughOlderHistoryPages, -} from "../support/helpers/timeline-pagination"; +import { openAgentTimeline } from "../support/helpers/timeline-pagination"; -test("does not repeat an assistant block when the current app paginates a published 0.2.5 daemon", async ({ +test("requires a host update for whole-turn history while retaining old-daemon connections and terminals", async ({ page, }) => { test.setTimeout(120_000); @@ -58,22 +52,8 @@ test("does not repeat an assistant block when the current app paginates a publis }; try { - for (let index = 0; index < 40; index += 1) { - await agent.client.sendAgentMessage( - agent.agentId, - `timeline-pagination-older-turn-${index}: emit 1 coalesced agent stream updates`, - ); - await agent.client.waitForFinish(agent.agentId, 15_000); - } - await agent.client.sendAgentMessage(agent.agentId, "build a realistic long mock timeline"); - await agent.client.waitForFinish(agent.agentId, 20_000); - for (let index = 0; index < 20; index += 1) { - await agent.client.sendAgentMessage( - agent.agentId, - `timeline-pagination-turn-${index}: emit 1 coalesced agent stream updates`, - ); - await agent.client.waitForFinish(agent.agentId, 15_000); - } + await agent.client.sendAgentMessage(agent.agentId, "emit 1 coalesced agent stream updates"); + await agent.client.waitForFinish(agent.agentId, 15_000); const host = buildSeededHost({ serverId, @@ -89,34 +69,18 @@ test("does not repeat an assistant block when the current app paginates a publis { seededHost: host, preferences: buildCreateAgentPreferences() }, ); - const history = await holdOlderHistoryPages(page, agent, daemon.port); await openAgentTimeline(page, agent, serverId); - await expectTimelinePromptVisible( - page, - "timeline-pagination-turn-19: emit 1 coalesced agent stream updates", - ); - await scrollThroughOlderHistoryPages(page, 3, history); - - history.expectRepeatedEntries(); - await history.expectOwnedTextRendered("Now I have a clearer picture."); + const updateMessage = page.getByText("Update the host to load complete conversation turns.", { + exact: true, + }); + await expect(updateMessage).toBeVisible(); expect(observationSockets.size).toBe(1); - await test.step("send a live turn after reconnecting the old daemon", async () => { + await test.step("retain one directory observation after reconnecting the old daemon", async () => { await daemon.restart(); await expect.poll(() => observationSockets.size, { timeout: 30_000 }).toBe(2); - await expectComposerEditable(page); - await submitMessage( - page, - "compatibility-after-reconnect: emit 1 coalesced agent stream updates", - ); - await expectTimelinePromptVisible( - page, - "compatibility-after-reconnect: emit 1 coalesced agent stream updates", - ); - // The browser and the seed client reconnect independently after the restart. + await expect(updateMessage).toBeVisible(); await agent.client.connect(); - await agent.client.waitForFinish(agent.agentId, 20_000); - await page.screenshot({ path: test.info().outputPath("old-daemon-live-reconnect.png") }); }); await test.step("open a terminal and receive its output", async () => { @@ -130,7 +94,6 @@ test("does not repeat an assistant block when the current app paginates a publis await expect .poll(() => getTerminalBufferText(page), { timeout: 15_000 }) .toContain("compatibility-terminal-output"); - await page.screenshot({ path: test.info().outputPath("old-daemon-terminal.png") }); await agent.client.killTerminal(terminalId); }); } finally { diff --git a/packages/app/e2e/browser/turn-collapse.spec.ts b/packages/app/e2e/browser/turn-collapse.spec.ts index 1deb050fa8..bef85080cf 100644 --- a/packages/app/e2e/browser/turn-collapse.spec.ts +++ b/packages/app/e2e/browser/turn-collapse.spec.ts @@ -1,3 +1,8 @@ +import { + holdOlderHistoryPages, + userScrollsTimelineToHistoryStart, +} from "../support/helpers/timeline-pagination"; +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 +24,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 +44,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 +115,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 +126,173 @@ 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(); + } +}); + +async function seedWholeTurnHistory() { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "whole-turn-history-", + title: "Whole turns", + model: "e2e-fast-stream", + }); + for (const prompt of [ + ...Array.from( + { length: 25 }, + (_, index) => `Short ${index}: emit 1 coalesced agent stream updates`, + ), + "Older long turn: emit 60 tool agent stream updates", + "Latest long turn: emit 120 tool agent stream updates", + ]) { + await agent.client.sendAgentMessage(agent.agentId, prompt); + await agent.client.waitForFinish(agent.agentId, 15_000); + } + return agent; +} + +test("loads complete long turns and preserves expansion while revealing older history", async ({ + page, +}) => { + test.setTimeout(120_000); + await useChatDisplay(page, "collapseTurns"); + const agent = await seedWholeTurnHistory(); + try { + const history = await holdOlderHistoryPages(page, agent); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + const latest = page + .getByTestId("turn-summary") + .getByRole("button", { name: "Ran 120 commands", exact: true }); + await expect(latest).toBeVisible(); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); + await latest.click(); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120); + await userScrollsTimelineToHistoryStart(page); + await history.expectRequestedPages(1); + history.releasePage(1); + await expect( + page + .getByTestId("turn-summary") + .getByRole("button", { name: "Ran 60 commands", exact: true }), + ).toBeVisible(); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120); + history.expectNoRepeatedEntries(); + await latest.scrollIntoViewIfNeeded(); + await latest.click(); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); + } finally { + await agent.cleanup(); + } +}); + +async function useThinkingDisplay( + page: Page, + autoExpandReasoning: boolean, + chatDisplay: ChatDisplay, +) { + await page.addInitScript( + (settings) => { + localStorage.setItem( + "@paseo:app-settings", + JSON.stringify({ + ...settings, + chatDisplay: sessionStorage.getItem("e2e-chat-display") ?? settings.chatDisplay, + }), + ); + }, + { autoExpandReasoning, chatDisplay }, + ); +} + +for (const autoExpandReasoning of [false, true]) { + test(`reasoning follows Expand thinking (${autoExpandReasoning}) and opens fully with its turn`, async ({ + page, + }) => { + await page.setViewportSize({ width: 1280, height: 1000 }); + await useThinkingDisplay(page, autoExpandReasoning, "collapseToolCalls"); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "thinking-group-", + title: "Thinking with tools", + model: "ten-second-stream", + initialPrompt: "Inspect the scroll container", + }); + try { + await agent.client.waitForFinish(agent.agentId, 30_000); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + const reasoning = page + .getByText(/Need to find the scroll container, the layout effect/) + .filter({ visible: true }) + .first(); + await expectThinkingPresentation(page, autoExpandReasoning); + await switchToCollapsedTurns(page); + await expectComposerVisible(page); + const summary = page.getByTestId("turn-summary").last().getByRole("button"); + await expect(summary).toBeVisible(); + await summary.click(); + await expect(reasoning).toBeVisible(); + await expect.poll(() => groupedToolsInsideLatestTurn(page)).toEqual([]); + } finally { + await agent.cleanup(); + } + }); +} + +async function expectThinkingPresentation(page: Page, autoExpandReasoning: boolean) { + const reasoning = page + .getByText(/Need to find the scroll container, the layout effect/) + .filter({ visible: true }) + .first(); + if (autoExpandReasoning) { + await expect(reasoning).toBeVisible(); + await expect(page.getByTestId("tool-call-group").filter({ hasText: /thought/ })).toHaveCount(0); + } else { + await expect(reasoning).toHaveCount(0); + const group = page + .getByTestId("tool-call-group") + .filter({ hasText: /thought/, visible: true }) + .last(); + await expect(group).toContainText(/read 1 file/i); + await group.getByRole("button").first().click(); + await expect(reasoning).toBeVisible(); + } +} + +async function switchToCollapsedTurns(page: Page) { + await page.evaluate(() => { + sessionStorage.setItem("e2e-chat-display", "collapseTurns"); + }); + await page.reload(); +} diff --git a/packages/app/e2e/support/helpers/custom-models.ts b/packages/app/e2e/support/helpers/custom-models.ts index 11933a0dfa..aff79965ea 100644 --- a/packages/app/e2e/support/helpers/custom-models.ts +++ b/packages/app/e2e/support/helpers/custom-models.ts @@ -19,6 +19,9 @@ export async function addCustomModel(page: Page, id: string): Promise { .getByRole("button", { name: "Add", exact: true }) .click(); await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1); + // Config updates before provider refresh finishes and dismisses the add sheet. + // Resizing or interacting with the model list must wait for that save to finish. + await expect(page.getByTestId("add-custom-model-sheet")).toBeHidden(); } export async function removeCustomModel(page: Page, id: string): Promise { 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/history-window.ts b/packages/app/src/agent-stream/history-window.ts index 347f780023..43cca2bbbf 100644 --- a/packages/app/src/agent-stream/history-window.ts +++ b/packages/app/src/agent-stream/history-window.ts @@ -1,4 +1,5 @@ import type { StreamItem } from "@/types/stream"; +import { continuesTurn } from "./turn-membership"; export const DEFAULT_MOUNTED_RECENT_STREAM_ITEMS = 20; @@ -32,7 +33,7 @@ export function findMountedWindowStart(input: { } let startIndex = Math.max(items.length - minMountedCount, 0); - while (startIndex > 0 && items[startIndex]?.kind !== "user_message") { + while (startIndex > 0 && continuesTurn(items[startIndex - 1], items[startIndex])) { startIndex -= 1; } return startIndex; 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..bba7267a5d 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -588,3 +588,129 @@ 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"]); +}); + +it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => { + const present = createStreamPresentation(); + const timestamp = new Date(); + const thought: StreamItem = { + kind: "thought", + id: "thought", + text: "Consider this", + status: "ready", + timestamp, + }; + const tail: StreamItem[] = [toolCall("one", "Bash"), thought, toolCall("two", "Bash")]; + for (const item of tail) item.turnId = "run"; + const input = { + ...presentationOptions, + transform: undefined, + tail, + head: [], + display: "collapseToolCalls" as const, + isTurnActive: false, + }; + const grouped = present({ ...input, autoExpandReasoning: false }); + expect(grouped.tail).toHaveLength(1); + expect(grouped.groupsByHostId.get("tool-one")?.run.calls).toEqual(tail); + const separate = present({ ...input, autoExpandReasoning: true }); + expect(separate.tail.map((item) => item.id)).toEqual(["tool-one", "thought", "tool-two"]); + expect(separate.groupsByHostId.has("thought")).toBe(false); + const next = { ...thought, id: "next", turnId: "next-run" }; + const bounded = present({ ...input, tail: [...tail, next], autoExpandReasoning: false }); + expect(bounded.tail.map((item) => item.id)).toEqual(["tool-one", "next"]); +}); + +it.each([false, true])( + "reveals raw reasoning when opening a turn (Expand thinking %s)", + (autoExpandReasoning) => { + const timestamp = new Date(); + const tail: StreamItem[] = [ + { kind: "user_message", id: "prompt", text: "Work", timestamp }, + { kind: "thought", id: "thought", text: "Consider this", status: "ready", timestamp }, + toolCall("one", "Bash"), + { kind: "assistant_message", id: "reply", text: "Done", timestamp }, + ]; + const input = { + ...presentationOptions, + transform: undefined, + tail, + head: [], + display: "collapseTurns" as const, + isTurnActive: false, + autoExpandReasoning, + }; + const present = createStreamPresentation(); + const closed = present(input); + expect(closed.tail.some((item) => item.id === "thought")).toBe(autoExpandReasoning); + const opened = present({ ...input, expandedTurnHostIds: new Set(["prompt"]) }); + expect(opened.tail).toEqual(tail); + expect(opened.turns.nestedItemIds.has("thought")).toBe(true); + expect(opened.groupsByHostId.size).toBe(0); + }, +); diff --git a/packages/app/src/agent-stream/presentation.ts b/packages/app/src/agent-stream/presentation.ts index 9e8e5b430e..ad5d362d38 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"; @@ -16,9 +17,11 @@ interface PresentationInput { head: StreamItem[]; transform: TimelineItemTransform | undefined; display: ChatDisplay; + autoExpandReasoning?: boolean; /** User messages whose collapsed turn the reader opened. */ expandedTurnHostIds: ReadonlySet; isTurnActive: boolean; + activeTurnId?: string | null; } export interface StreamPresentation extends ToolCallDetailProjection { @@ -55,6 +58,7 @@ export function createStreamPresentation() { let historyTransform: TimelineItemTransform | undefined; let historyRows: StreamItem[] = []; let preparedTail: StreamItem[] | undefined; + let preparedAutoExpandReasoning: boolean | undefined; let preparedLevel: ToolCallDetailLevel | undefined; let preparedHistory: PreparedToolCallHistory | null = null; let collapsedSource: StreamItem[] | undefined; @@ -63,6 +67,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 +95,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 || @@ -84,6 +111,7 @@ export function createStreamPresentation() { ) { collapsed = collapseTurns({ tail: projection.tail, + autoExpandReasoning: input.autoExpandReasoning, isLatestTurnOpen, expandedHostIds: input.expandedTurnHostIds, getGroupedCalls: (itemId) => projection.groupsByHostId.get(itemId)?.run.calls, @@ -93,7 +121,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 => { @@ -111,10 +152,15 @@ export function createStreamPresentation() { const blocks = presentRows(item); head.push(...blocks); } - if (preparedTail !== historyRows || preparedLevel !== level) { - preparedHistory = prepareToolCallHistory(level, historyRows); + if ( + preparedTail !== historyRows || + preparedLevel !== level || + preparedAutoExpandReasoning !== input.autoExpandReasoning + ) { + preparedHistory = prepareToolCallHistory(level, historyRows, input.autoExpandReasoning); preparedTail = historyRows; preparedLevel = level; + preparedAutoExpandReasoning = input.autoExpandReasoning; } return collapse( projectToolCallDetailLevel({ diff --git a/packages/app/src/agent-stream/render-strategy.test.ts b/packages/app/src/agent-stream/render-strategy.test.ts index 22d7c5f2f1..59a2f7aed3 100644 --- a/packages/app/src/agent-stream/render-strategy.test.ts +++ b/packages/app/src/agent-stream/render-strategy.test.ts @@ -214,7 +214,7 @@ describe("neighbor and traversal semantics", () => { ).toBe("assistant-1\n\nassistant-2"); }); - it("collects copy content across adjacent turns without a visible prompt", () => { + it("keeps copied content within the canonical turn without a visible prompt", () => { const chronological: StreamItem[] = [ { ...assistantMessage("a1", "first turn", 1), turnId: "turn-1" }, { ...toolCall("tool", 2), turnId: "turn-2" }, @@ -228,7 +228,7 @@ describe("neighbor and traversal semantics", () => { items: chronological, startIndex: 2, }), - ).toBe("first turn\n\nsecond turn"); + ).toBe("second turn"); }); it("returns undefined neighbor when index would be out of bounds", () => { 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..6cefaf3651 100644 --- a/packages/app/src/agent-stream/turn-collapse/model.test.ts +++ b/packages/app/src/agent-stream/turn-collapse/model.test.ts @@ -48,6 +48,41 @@ function collapse(tail: StreamItem[], options: Partial = {}) const ids = (items: StreamItem[]) => items.map((item) => item.id); describe("collapseTurns", () => { + it("keeps lifecycle events and compaction recognizable beside a semantic action summary", () => { + const create = shell("create"); + if (create.payload.source !== "agent") throw new Error("Expected agent tool"); + create.payload.data.name = "mcp__paseo__create_agent"; + create.payload.data.detail = { type: "unknown", input: { title: "Side fix" }, output: null }; + const finished = shell("finished"); + if (finished.payload.source !== "agent") throw new Error("Expected agent tool"); + finished.payload.data.name = "agent_notification"; + finished.payload.data.agentMessage = { + event: "finished", + sender: { id: "worker", title: "PR helper" }, + text: "Finished", + }; + const items: StreamItem[] = [ + user("u1"), + create, + finished, + { + kind: "compaction", + id: "compaction", + timestamp: at, + status: "completed", + } satisfies StreamItem, + shell("bash"), + reply("done"), + ].map((item) => Object.assign(item, { turnId: "same-run" })); + const result = collapse(items); + expect(ids(result.tail)).toEqual(["u1", "finished", "compaction", "done"]); + expect(result.turnsByHostId.get("u1")?.summary.tools).toMatchObject({ + commandCount: 1, + otherToolCount: 0, + paseoActions: { "Created agent": 1 }, + }); + }); + it("folds a finished turn's work into its user message and keeps the reply", () => { const result = collapse([ user("u1"), @@ -63,7 +98,6 @@ describe("collapseTurns", () => { hostId: "u1", expanded: false, summary: { - messageCount: 1, tools: expect.objectContaining({ commandCount: 2 }), }, }); @@ -121,6 +155,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..c8c1617e24 100644 --- a/packages/app/src/agent-stream/turn-collapse/model.ts +++ b/packages/app/src/agent-stream/turn-collapse/model.ts @@ -1,13 +1,18 @@ -import { getStreamItemMessageId, type StreamItem, type ToolCallItem } from "@/types/stream"; +import { isTurnBoundary, isTurnTrigger } from "../turn-membership"; +import { + getStreamItemMessageId, + type StreamItem, + type ThoughtItem, + 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; @@ -24,11 +29,12 @@ export interface TurnCollapse { export interface CollapseTurnsInput { tail: StreamItem[]; + autoExpandReasoning?: boolean; /** The latest turn is still producing output, so it stays unfolded. */ isLatestTurnOpen: boolean; expandedHostIds: ReadonlySet; /** The calls a tool-call group row stands for; undefined for a single call. */ - getGroupedCalls: (itemId: string) => readonly ToolCallItem[] | undefined; + getGroupedCalls: (itemId: string) => readonly (ToolCallItem | ThoughtItem)[] | undefined; } const EMPTY_TURNS: ReadonlyMap = new Map(); @@ -39,17 +45,25 @@ function summarizeTurn( items: readonly StreamItem[], getGroupedCalls: CollapseTurnsInput["getGroupedCalls"], ): TurnSummary { - const calls: ToolCallItem[] = []; - let messageCount = 0; + const calls: (ToolCallItem | ThoughtItem)[] = []; 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" || item.kind === "thought") && !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); + const { paseoActions, thoughtCount: _thoughtCount, ...counts } = summary.tools; + return Object.keys(paseoActions).length > 0 || Object.values(counts).some((count) => count > 0); +} + +function staysVisibleWhenCollapsed(item: StreamItem, autoExpandReasoning = false): boolean { + return ( + isTurnTrigger(item) || + item.kind === "compaction" || + (autoExpandReasoning && item.kind === "thought") + ); } function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number { @@ -63,7 +77,7 @@ function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): * Folds each answered turn into its user message: everything the agent did before its last * message collapses into one summary line, and that message stays in the chat. A turn folds * only once it is complete, its user message is loaded, and it sent messages or called tools - * before its reply. Thoughts fold with the turn but are not counted. + * before its reply. Reasoning stays visible when Expand thinking is enabled. */ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { const { tail } = input; @@ -77,28 +91,49 @@ 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" || item.kind === "thought" + ? [...(input.getGroupedCalls(item.id) ?? [item])] + : [item], + ) + : folded; + for (const item of revealed) { + if (staysVisibleWhenCollapsed(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((item) => staysVisibleWhenCollapsed(item, input.autoExpandReasoning))), + ); 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 +149,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 ( ["layout"]): str } describe("canonical turn membership", () => { + it("uses trigger fallback for missing IDs without pretending to recover batching", () => { + const previous = assistant("reply", 1, "first"); + const trigger = runningTool("heartbeat", 2, "first"); + if (trigger.kind !== "tool_call" || trigger.payload.source !== "agent") + throw new Error("Expected agent tool"); + trigger.payload.data.name = "heartbeat"; + expect(continuesTurn(previous, trigger)).toBe(true); + delete trigger.turnId; + expect(continuesTurn(previous, trigger)).toBe(false); + trigger.payload.data.name = "agent_message"; + trigger.payload.data.agentMessage = { + event: "finished", + sender: { id: "worker" }, + text: "Done", + }; + expect(continuesTurn(previous, trigger)).toBe(false); + }); + it("stops tagged traversal at every mismatching turn ID", () => { expect(continuesTurn(assistant("first", 1, "turn-1"), runningTool("next", 2, "turn-2"))).toBe( false, @@ -76,7 +94,7 @@ describe("canonical turn membership", () => { ); }); - 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 +106,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 +145,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..9650c12ff1 100644 --- a/packages/app/src/agent-stream/turn-membership.ts +++ b/packages/app/src/agent-stream/turn-membership.ts @@ -1,24 +1,21 @@ +import { continuesTimelineTurn, isTimelineTurnTrigger } from "@getpaseo/protocol/timeline-turn"; import type { StreamItem } from "@/types/stream"; /** * Canonical turn IDs take precedence. Timelines without them retain the legacy - * user-message boundary rule so old daemons and persisted rows need no rewrite. + * trigger boundary rule; missing IDs cannot reconstruct historical batching. */ export function continuesTurn(previous: StreamItem | null, next: StreamItem | null): boolean { if (!previous || !next) return false; - if (previous.turnId !== undefined && next.turnId !== undefined) { - return previous.turnId === next.turnId; - } - return next.kind !== "user_message"; + return continuesTimelineTurn(previous.turnId, next.turnId, 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 +34,12 @@ 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 { + return isTimelineTurnTrigger( + item.kind === "tool_call" && item.payload.source === "agent" + ? { type: item.kind, ...item.payload.data } + : { type: item.kind }, + ); +} diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 67e08a6f82..1cfddc8ecc 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -524,8 +524,10 @@ const AgentStreamViewComponent = forwardRef + presentation.turns.nestedItemIds.has(id), + ); const renderThoughtItem = useCallback( (layoutItem: StreamLayoutItem, item: Extract) => { return ( @@ -796,11 +804,11 @@ const AgentStreamViewComponent = forwardRef ); }, - [autoExpandReasoning, setInlineDetailsExpanded], + [autoExpandReasoning, setInlineDetailsExpanded, isExpandedTurnItem], ); const renderSingleToolCallItem = useCallback( @@ -830,6 +838,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 @@ -868,10 +884,15 @@ const AgentStreamViewComponent = forwardRef) => { + ( + layoutItem: StreamLayoutItem, + item: Extract, + ) => { const group = getToolCallGroup(item.id); if (!group) { - return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence); + return item.kind === "thought" + ? renderThoughtItem(layoutItem, item) + : renderSingleToolCallItem(item, layoutItem.isLastInToolSequence); } const expanded = expandedToolCallGroupIds.has(group.run.id); return ( @@ -884,10 +905,21 @@ const AgentStreamViewComponent = forwardRef ( - {renderSingleToolCallItem( - call, - index === group.run.calls.length - 1, - GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT, + {call.kind === "thought" ? ( + + ) : ( + renderSingleToolCallItem( + call, + index === group.run.calls.length - 1, + GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT, + ) )} )) @@ -897,6 +929,8 @@ const AgentStreamViewComponent = forwardRef + {renderToolCallItem(layoutItem, item)} + {getCollapsedTurn(item.id) ? ( + + ) : null} + + ); case "notification": return ; @@ -947,8 +991,9 @@ const AgentStreamViewComponent = forwardRef { + it("mounts complete autonomous runs and keeps same-run steers together", () => { + const items = [ + { ...userMessage("human", 1), turnId: "first" }, + { ...assistantMessage("reply", 2), turnId: "first" }, + { ...toolCall("wake", 3), turnId: "autonomous" }, + { ...userMessage("steer", 4), turnId: "autonomous" }, + { ...assistantMessage("done", 5), turnId: "autonomous" }, + ]; + expect(findMountedWindowStart({ items, minMountedCount: 1 })).toBe(2); + }); + it("keeps all items mounted when the chat is below the threshold", () => { const items = [userMessage("u1", 1), assistantMessage("a1", 2)]; diff --git a/packages/app/src/components/indent-rail.tsx b/packages/app/src/components/indent-rail.tsx index b54ac1ca9b..36fd84a0a9 100644 --- a/packages/app/src/components/indent-rail.tsx +++ b/packages/app/src/components/indent-rail.tsx @@ -3,11 +3,10 @@ import { View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; /** - * Collapsed chat rows badge their summary with an icon whose center sits 7px into the content - * column (`ExpandableBadge` bleeds 13px left, then 1px border, 8px padding, 22px icon). The rail - * hangs from that center so opened rows read as children of the line that opened them. + * Align the rail with the expanded caret's left edge. Keep the content offset fixed so + * expanded tool cards stay in place, with a gap between their border and the rail. */ -const RAIL_LEFT = 7; +const RAIL_LEFT = 1; const RAIL_WIDTH = 1; export const INDENT_RAIL_CONTENT_OFFSET = 21; diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 9430aa3a12..a075b97c92 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1,3 +1,4 @@ +import { MessagePreview, MessageSender } from "@/components/user-message"; import { useStableEvent } from "@/hooks/use-stable-event"; import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content"; import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu"; @@ -89,6 +90,7 @@ export type { InlinePathTarget } from "@/assistant-file-links"; export type { AssistantForkTarget }; interface UserMessageProps { + sender?: AgentMessage["sender"]; serverId?: string; agentId?: string; messageId?: string; @@ -387,6 +389,7 @@ function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessage const MESSAGE_TEXT_DATASET = { messageText: "true" }; export const UserMessage = memo(function UserMessage({ + sender, serverId, agentId, messageId, @@ -473,42 +476,45 @@ export const UserMessage = memo(function UserMessage({ onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave} > + {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/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts index f2726fba02..e517021b6b 100644 --- a/packages/app/src/hooks/use-agent-initialization.test.ts +++ b/packages/app/src/hooks/use-agent-initialization.test.ts @@ -78,6 +78,7 @@ describe("ensureAgentIsInitialized", () => { cursor: { epoch: "epoch-1", seq: 42 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }, }, ]); @@ -105,6 +106,7 @@ describe("ensureAgentIsInitialized", () => { direction: "tail", limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }, }, ]); @@ -148,6 +150,7 @@ describe("ensureAgentIsInitialized", () => { direction: "tail", limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }, }, ]); @@ -192,6 +195,7 @@ describe("ensureAgentIsInitialized", () => { cursor: { epoch: "epoch-1", seq: 12 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }, }, ]); @@ -283,6 +287,7 @@ describe("refreshAgent", () => { direction: "tail", limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }, }, ]); diff --git a/packages/app/src/hooks/use-load-older-agent-history.test.ts b/packages/app/src/hooks/use-load-older-agent-history.test.ts index 1da68b4edf..3259631992 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.test.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.test.ts @@ -152,6 +152,7 @@ describe("loadOlderAgentHistory", () => { cursor: { epoch: "epoch-1", seq: 10 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }, }, ]); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 6bfc30095c..49e8a1669a 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2018,12 +2018,13 @@ export const ar: TranslationResources = { output: "الإخراج", }, turnSummary: { - messages: { - one: "كتب {{count}} رسالة", - other: "كتب {{count}} رسائل", - }, + details: "عرض تفاصيل الجولة", + sentBy: "أرسلها {{name}}", + showMore: "عرض المزيد", + showLess: "عرض أقل", }, toolCallGroup: { + thought: "فكّر", editedFiles: { one: "حرّر {{count}} ملفًا", other: "حرّر {{count}} ملفات", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index ff1e80d88b..1ae0e3a363 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2043,12 +2043,13 @@ 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: { + thought: "Thought", editedFiles: { one: "edited {{count}} file", other: "edited {{count}} files", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 8784adefb5..6b99c229fe 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2066,12 +2066,13 @@ 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: { + thought: "Razonó", editedFiles: { one: "editó {{count}} archivo", other: "editó {{count}} archivos", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index bbafc03dda..927237900c 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2065,12 +2065,13 @@ 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: { + thought: "Réfléchi", editedFiles: { one: "{{count}} fichier modifié", other: "{{count}} fichiers modifiés", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index e7dcc5b5e7..494714ebca 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2038,12 +2038,13 @@ export const ja: TranslationResources = { output: "出力", }, turnSummary: { - messages: { - one: "{{count}}件のメッセージを送信", - other: "{{count}}件のメッセージを送信", - }, + details: "ターンの詳細を表示", + sentBy: "{{name}} から送信", + showMore: "もっと見る", + showLess: "折りたたむ", }, toolCallGroup: { + thought: "思考", editedFiles: { one: "{{count}}個のファイルを編集", other: "{{count}}個のファイルを編集", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index d8d1fbc787..d6777add4a 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2028,12 +2028,13 @@ export const ko: TranslationResources = { output: "출력", }, turnSummary: { - messages: { - one: "{{count}} 메시지를 작성했습니다.", - other: "{{count}} 메시지를 작성했습니다.", - }, + details: "턴 세부 정보 보기", + sentBy: "{{name}}이(가) 보냄", + showMore: "더 보기", + showLess: "접기", }, toolCallGroup: { + thought: "생각함", editedFiles: { one: "{{count}} 파일을 편집했습니다.", other: "{{count}} 파일을 편집했습니다.", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 1c024dc64a..ce1a3b01c8 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2051,12 +2051,13 @@ 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: { + thought: "Pensou", editedFiles: { one: "editou {{count}} arquivo", other: "editou {{count}} arquivos", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 093ac68c36..cfc7954abf 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2051,12 +2051,13 @@ export const ru: TranslationResources = { output: "Выходные данные", }, turnSummary: { - messages: { - one: "написано {{count}} сообщение", - other: "написаны сообщения ({{count}})", - }, + details: "Показать подробности хода", + sentBy: "Отправлено агентом {{name}}", + showMore: "Показать больше", + showLess: "Свернуть", }, toolCallGroup: { + thought: "Обдумал", editedFiles: { one: "изменён {{count}} файл", other: "изменены файлы ({{count}})", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 8ca178f206..5a08f1ea40 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1996,12 +1996,13 @@ export const zhCN: TranslationResources = { output: "输出", }, turnSummary: { - messages: { - one: "写了 {{count}} 条消息", - other: "写了 {{count}} 条消息", - }, + details: "查看轮次详情", + sentBy: "由 {{name}} 发送", + showMore: "展开", + showLess: "收起", }, toolCallGroup: { + thought: "已思考", editedFiles: { one: "编辑了 {{count}} 个文件", other: "编辑了 {{count}} 个文件", diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index eaad715a52..d91d8e7ba4 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -306,6 +306,21 @@ async function expectLegacyWorkspaceRefresh({ } describe("ReplicaCache", () => { + it("persists the complete newest turn beyond the cache item target", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const items = Array.from({ length: 60 }, (_, index) => ({ + ...timelineItem(`item-${index}`), + id: `item-${index}`, + turnId: "long-run", + })); + writer.commitTimeline(SERVER_ID, "agent-1", { ...timeline(), items }); + await writer.flush(); + const restored = await createCache(storage).readTimeline(SERVER_ID, "agent-1"); + expect(restored?.items.map((item) => item.id)).toEqual(items.map((item) => item.id)); + expect(restored?.range).toBeNull(); + }); + it("preserves workspace sorting timestamps across cache reopen", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 59578e65d7..9ad604c8eb 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -1,3 +1,4 @@ +import { findMountedWindowStart } from "@/agent-stream/history-window"; import { z } from "zod"; import { AgentStatusSchema, @@ -766,7 +767,14 @@ function serializeTimeline(timeline: CachedTimeline): StoredTimeline | null { const canonicalItems = timeline.items.filter( (item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item), ); - const items = canonicalItems.map(serializeTimelineItem).filter((item) => item !== null); + const start = findMountedWindowStart({ + items: canonicalItems, + minMountedCount: MAX_TIMELINE_ITEMS, + }); + const items = canonicalItems + .slice(start) + .map(serializeTimelineItem) + .filter((item) => item !== null); const range = timeline.range; const canPersistCoverage = range !== null && @@ -783,7 +791,7 @@ function serializeTimeline(timeline: CachedTimeline): StoredTimeline | null { canonicalItems.some((item) => item.timelineCursor?.seq === range.endSeq); return { agentId: timeline.agentId, - items: items.slice(-MAX_TIMELINE_ITEMS), + items, range: canPersistCoverage ? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq } : null, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 3617c074e2..1746c50653 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,3 +1,4 @@ +import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { useHostFeature } from "@/runtime/host-features"; import { AssistantFileLinkResolverProvider, @@ -2179,8 +2180,7 @@ function WorkspaceScreenContent({ const sessionState = useSessionStore.getState().sessions[normalizedServerId]; const currentCursor = sessionState?.agentTimelineCursor.get(agentId); await getHostRuntimeStore().fetchAgentTimeline(normalizedServerId, agentId, { - direction: "tail", - projection: "projected", + ...planTimelineTailFetch(), ...(currentCursor ? { cursor: { epoch: currentCursor.epoch, seq: currentCursor.endSeq } } : {}), diff --git a/packages/app/src/timeline/timeline-replacement.test.ts b/packages/app/src/timeline/timeline-replacement.test.ts index f457ea7daa..cea2dc909d 100644 --- a/packages/app/src/timeline/timeline-replacement.test.ts +++ b/packages/app/src/timeline/timeline-replacement.test.ts @@ -15,7 +15,7 @@ describe("requestTimelineReplacement", () => { await expect(requestTimelineReplacement(client, "agent-1")).rejects.toThrow("fetch complete"); expect(requests).toEqual([ - ["agent-1", { direction: "tail", projection: "projected", limit: 40 }], + ["agent-1", { direction: "tail", projection: "projected", wholeTurns: true, limit: 40 }], ]); }); }); diff --git a/packages/app/src/timeline/timeline-sync-plan.test.ts b/packages/app/src/timeline/timeline-sync-plan.test.ts index 9135b5fb85..093941fb77 100644 --- a/packages/app/src/timeline/timeline-sync-plan.test.ts +++ b/packages/app/src/timeline/timeline-sync-plan.test.ts @@ -17,6 +17,7 @@ describe("timeline sync planning", () => { direction: "tail", limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }); }); @@ -26,6 +27,7 @@ describe("timeline sync planning", () => { cursor: { epoch: "epoch-1", seq: 49 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }); }); @@ -37,6 +39,7 @@ describe("timeline sync planning", () => { cursor: { epoch: "epoch-1", seq: 25 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }); }); @@ -49,6 +52,7 @@ describe("timeline sync planning", () => { }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, mergeWindow: true, }); }); diff --git a/packages/app/src/timeline/timeline-sync-plan.ts b/packages/app/src/timeline/timeline-sync-plan.ts index e5e8b2fb95..b66de16941 100644 --- a/packages/app/src/timeline/timeline-sync-plan.ts +++ b/packages/app/src/timeline/timeline-sync-plan.ts @@ -9,6 +9,7 @@ export interface ProjectedTimelineTailFetchPlan { direction: "tail"; limit: number; projection: "projected"; + wholeTurns: true; } export interface ProjectedTimelineAfterFetchPlan { @@ -16,6 +17,7 @@ export interface ProjectedTimelineAfterFetchPlan { cursor: TimelineSyncCursor; limit: number; projection: "projected"; + wholeTurns: true; } export interface ProjectedTimelineBeforeFetchPlan { @@ -23,6 +25,7 @@ export interface ProjectedTimelineBeforeFetchPlan { cursor: TimelineSyncCursor; limit: number; projection: "projected"; + wholeTurns: true; } export type ProjectedTimelineFetchPlan = @@ -40,6 +43,7 @@ export function planTimelineCatchUpAfter(cursor: TimelineSyncCursor) { cursor, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, } as const; } @@ -48,6 +52,7 @@ export function planTimelineTailFetch() { direction: "tail", limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, } as const; } @@ -65,6 +70,7 @@ export function planTimelineOlderFetch(cursor: TimelineSyncCursor) { cursor, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, } as const; } @@ -75,6 +81,7 @@ export function planTimelinePromptJump(target: TimelineSyncCursor) { cursor: { epoch: target.epoch, seq: target.seq + newerRows + 1 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, mergeWindow: true, } as const; } 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/timeline/viewed-timeline-sync.test.ts b/packages/app/src/timeline/viewed-timeline-sync.test.ts index 9bf6897bd9..a6e7cbeb33 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.test.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -189,6 +189,7 @@ class TimelineWorld { direction: "tail", limit: 40, projection: "projected", + wholeTurns: true, }); } finally { this.forcedTimelineTailReplacements.delete(agentId); @@ -341,7 +342,12 @@ test("uses a tail fetch when an agent becomes visible", async () => { membership.succeed(); const fetch = await world.nextFetch("agent-a"); - expect(fetch.request).toEqual({ direction: "tail", limit: 40, projection: "projected" }); + expect(fetch.request).toEqual({ + direction: "tail", + limit: 40, + projection: "projected", + wholeTurns: true, + }); fetch.respond({ hasNewer: false }); }); @@ -366,6 +372,7 @@ test("loads the cache before choosing the authoritative network request", async cursor: { epoch: "cached-epoch", seq: 17 }, limit: 40, projection: "projected", + wholeTurns: true, }); fetch.respond({ hasNewer: false }); }); @@ -384,6 +391,7 @@ test("catches up after the restored cursor when an agent becomes visible", async cursor: { epoch: "epoch-agent-a", seq: 42 }, limit: 40, projection: "projected", + wholeTurns: true, }); fetch.respond({ hasNewer: false }); }); @@ -401,7 +409,12 @@ test("falls back to the latest tail when a restored cursor has more than one cat probe.respond({ hasNewer: true, seq: 82 }); const fallback = await world.nextFetch("agent-a"); - expect(fallback.request).toEqual({ direction: "tail", limit: 40, projection: "projected" }); + expect(fallback.request).toEqual({ + direction: "tail", + limit: 40, + projection: "projected", + wholeTurns: true, + }); expect( world.applyTimelineResponse({ requestId: "fallback-tail", @@ -444,6 +457,7 @@ test("a gap absorbed by a running tail is recovered after the tail completes", a cursor: { epoch: "epoch-agent-a", seq: 9 }, limit: 40, projection: "projected", + wholeTurns: true, }); recovery.respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); @@ -527,6 +541,7 @@ test("unchanged visible-set publication does not cancel paged catch-up", async ( cursor: { epoch: "epoch-agent-a", seq: 5 }, limit: 40, projection: "projected", + wholeTurns: true, }); world.expectNoPendingMembership(); }); @@ -897,12 +912,14 @@ test("gap recovery supersedes completed catch-up and pages through the current t cursor: { epoch: "epoch-agent-a", seq: 10 }, limit: 40, projection: "projected", + wholeTurns: true, }, { direction: "after", cursor: { epoch: "epoch-agent-a", seq: 15 }, limit: 40, projection: "projected", + wholeTurns: true, }, ]); }); diff --git a/packages/app/src/tool-calls/detail-level/grouping.ts b/packages/app/src/tool-calls/detail-level/grouping.ts index 079158b2d9..5713a06de3 100644 --- a/packages/app/src/tool-calls/detail-level/grouping.ts +++ b/packages/app/src/tool-calls/detail-level/grouping.ts @@ -1,5 +1,6 @@ +import { isTurnTrigger, isTurnBoundary } from "@/agent-stream/turn-membership"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; -import type { StreamItem, ToolCallItem } from "@/types/stream"; +import type { StreamItem, ToolCallItem, ThoughtItem } from "@/types/stream"; export interface ToolCallDescriptor { detail: ToolCallDetail; @@ -9,17 +10,19 @@ export interface ToolCallDescriptor { metadata?: Record; } +export type GroupableItem = ToolCallItem | ThoughtItem; + export interface ToolCallRun { id: string; - calls: readonly ToolCallItem[]; - latest: ToolCallItem; + calls: readonly GroupableItem[]; + latest: GroupableItem; isSealed: boolean; } export interface GroupedHistory { tail: StreamItem[]; groupsByHostId: Map; - pendingCalls: readonly ToolCallItem[]; + pendingCalls: readonly GroupableItem[]; } export interface GroupedToolCalls { @@ -62,15 +65,19 @@ export function describeToolCall(item: ToolCallItem): ToolCallDescriptor { }; } -export function isGroupableToolCall(item: StreamItem): item is ToolCallItem { - if (item.kind !== "tool_call") { +export function isGroupableToolCall( + item: StreamItem, + autoExpandReasoning = false, +): item is GroupableItem { + if (item.kind === "thought") return !autoExpandReasoning; + if (item.kind !== "tool_call" || isTurnTrigger(item)) { return false; } const descriptor = describeToolCall(item); return descriptor.detail.type !== "plan" && descriptor.name.trim().toLowerCase() !== "speak"; } -function createRun(calls: readonly ToolCallItem[], isSealed: boolean): ToolCallRun { +function createRun(calls: readonly GroupableItem[], isSealed: boolean): ToolCallRun { const first = calls[0]; const latest = calls.at(-1); if (!first || !latest) { @@ -79,20 +86,21 @@ function createRun(calls: readonly ToolCallItem[], isSealed: boolean): ToolCallR return { id: first.id, calls, latest, isSealed }; } -function createHost(run: ToolCallRun): ToolCallItem { +function createHost(run: ToolCallRun): GroupableItem { if (run.calls.length === 1) { return run.latest; } return { ...run.latest, id: run.id }; } -function isRunning(call: ToolCallItem): boolean { +function isRunning(call: GroupableItem): boolean { + if (call.kind === "thought") return call.status !== "ready"; const status = describeToolCall(call).status; return status === "running" || status === "executing"; } function appendRun(input: { - calls: readonly ToolCallItem[]; + calls: readonly GroupableItem[]; isSealed: boolean; output: StreamItem[]; groups: Map; @@ -110,13 +118,17 @@ function appendRun(input: { export function prepareGroupedHistory(input: { tail: StreamItem[]; buildGroup: (run: ToolCallRun) => TGroup; + autoExpandReasoning?: boolean; }): GroupedHistory { const output: StreamItem[] = []; const groups = new Map(); - let pending: ToolCallItem[] = []; + let pending: GroupableItem[] = []; for (const item of input.tail) { - if (isGroupableToolCall(item)) { + if ( + isGroupableToolCall(item, input.autoExpandReasoning) && + !isTurnBoundary(pending.at(-1) ?? null, item) + ) { pending.push(item); continue; } @@ -128,7 +140,8 @@ export function prepareGroupedHistory(input: { buildGroup: input.buildGroup, }); pending = []; - output.push(item); + if (isGroupableToolCall(item, input.autoExpandReasoning)) pending.push(item); + else output.push(item); } appendRun({ @@ -151,6 +164,7 @@ export function groupLiveToolCalls(input: { head: StreamItem[]; isTurnActive: boolean; buildGroup: (run: ToolCallRun) => TGroup; + autoExpandReasoning?: boolean; }): GroupedToolCalls { const head: StreamItem[] = []; const liveGroups = new Map(); @@ -175,7 +189,8 @@ export function groupLiveToolCalls(input: { }; for (const item of input.head) { - if (isGroupableToolCall(item)) { + if (isTurnBoundary(pending.at(-1) ?? null, item)) flush(true); + if (isGroupableToolCall(item, input.autoExpandReasoning)) { if (pending.length === 0) { hostPlacement = "head"; } diff --git a/packages/app/src/tool-calls/detail-level/overview/label.ts b/packages/app/src/tool-calls/detail-level/overview/label.ts index 762e530172..7a34685d62 100644 --- a/packages/app/src/tool-calls/detail-level/overview/label.ts +++ b/packages/app/src/tool-calls/detail-level/overview/label.ts @@ -33,13 +33,18 @@ export function useOverviewSummaryLabel( [summary.readFileCount, "toolCallGroup.readFiles"], [summary.searchCount, "toolCallGroup.searches"], [summary.otherToolCount, "toolCallGroup.otherTools"], - [summary.paseoCallCount, "toolCallGroup.paseoCalls"], ] as const; for (const [count, key] of entries) { if (count > 0) { parts.push(t(`${key}.${count === 1 ? "one" : "other"}`, { count })); } } + for (const [action, count] of Object.entries(summary.paseoActions)) { + parts.push( + count === 1 ? action.toLocaleLowerCase() : `${action.toLocaleLowerCase()} ×${count}`, + ); + } + if (summary.thoughtCount > 0) parts.push(t("toolCallGroup.thought").toLocaleLowerCase()); return joinSummaryParts(parts, t("toolCallGroup.and")); }, [leadingParts, summary, t]); } diff --git a/packages/app/src/tool-calls/detail-level/overview/model.ts b/packages/app/src/tool-calls/detail-level/overview/model.ts index 0fb638c012..e4717c43bf 100644 --- a/packages/app/src/tool-calls/detail-level/overview/model.ts +++ b/packages/app/src/tool-calls/detail-level/overview/model.ts @@ -1,17 +1,18 @@ -import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization"; -import type { ToolCallItem } from "@/types/stream"; -import { describeToolCall, type ToolCallRun } from "../grouping"; +import { getPaseoToolActionLabel } from "@getpaseo/protocol/paseo-tool-presentation/index"; +import { isTurnTrigger } from "@/agent-stream/turn-membership"; + +import { describeToolCall, type GroupableItem, type ToolCallRun } from "../grouping"; -const DIRECT_PASEO_TOOL_PREFIX = "paseo_"; const DIRECT_SEARCH_TOOL_SUFFIX_PATTERN = /(?:^|[_.:/])(?:web_search|llm_context)$/; export interface OverviewSummary { + thoughtCount: number; editedFileCount: number; commandCount: number; readFileCount: number; searchCount: number; otherToolCount: number; - paseoCallCount: number; + paseoActions: Record; } export interface OverviewToolCallGroup { @@ -21,28 +22,31 @@ export interface OverviewToolCallGroup { isLoading: boolean; } -function isPaseoCall(name: string, normalizedName: string): boolean { - return isPaseoToolName(name) || normalizedName.startsWith(DIRECT_PASEO_TOOL_PREFIX); -} - function isSearchCall(name: string): boolean { return DIRECT_SEARCH_TOOL_SUFFIX_PATTERN.test(name); } /** Counts tool calls by what they did, for any summary of a stretch of agent work. */ -export function summarizeToolCalls(calls: readonly ToolCallItem[]): OverviewSummary { +export function summarizeToolCalls(calls: readonly GroupableItem[]): OverviewSummary { const editedFiles = new Set(); const readFiles = new Set(); + let thoughtCount = 0; let commandCount = 0; let searchCount = 0; let otherToolCount = 0; - let paseoCallCount = 0; + const paseoActions: Record = {}; for (const call of calls) { + if (call.kind === "thought") { + thoughtCount += 1; + continue; + } + if (isTurnTrigger(call)) continue; const descriptor = describeToolCall(call); const normalizedName = descriptor.name.trim().toLowerCase(); - if (isPaseoCall(descriptor.name, normalizedName)) { - paseoCallCount += 1; + const action = getPaseoToolActionLabel(descriptor.name); + if (action) { + paseoActions[action] = (paseoActions[action] ?? 0) + 1; } else if (descriptor.detail.type === "edit" || descriptor.detail.type === "write") { editedFiles.add(descriptor.detail.filePath); } else if (descriptor.detail.type === "shell") { @@ -57,17 +61,19 @@ export function summarizeToolCalls(calls: readonly ToolCallItem[]): OverviewSumm } return { + thoughtCount, editedFileCount: editedFiles.size, commandCount, readFileCount: readFiles.size, searchCount, otherToolCount, - paseoCallCount, + paseoActions, }; } export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup { const isLoading = run.calls.some((call) => { + if (call.kind === "thought") return call.status !== "ready"; const status = describeToolCall(call).status; return status === "running" || status === "executing"; }); 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..d6233854a2 100644 --- a/packages/app/src/tool-calls/detail-level/projection.test.ts +++ b/packages/app/src/tool-calls/detail-level/projection.test.ts @@ -255,12 +255,13 @@ describe("tool call detail-level projection", () => { run: expect.any(Object), isLoading: false, summary: { + thoughtCount: 0, editedFileCount: 1, commandCount: 1, readFileCount: 2, searchCount: 0, otherToolCount: 0, - paseoCallCount: 0, + paseoActions: {}, }, }); }); @@ -328,7 +329,7 @@ describe("tool call detail-level projection", () => { const result = project({ level: "overview", head: calls }); expect(result.groupsByHostId.get("1")).toMatchObject({ - summary: { otherToolCount: 2, paseoCallCount: 2 }, + summary: { otherToolCount: 2, paseoActions: { "Listed agents": 1, "List worktrees": 1 } }, }); }); @@ -346,7 +347,11 @@ describe("tool call detail-level projection", () => { const result = project({ level: "overview", head: calls }); expect(result.groupsByHostId.get("1")).toMatchObject({ - summary: { searchCount: 3, otherToolCount: 0, paseoCallCount: 3 }, + summary: { + searchCount: 3, + otherToolCount: 0, + paseoActions: { "Listed providers": 1, "List worktrees": 2 }, + }, }); }); @@ -480,3 +485,56 @@ 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"]); +}); + +it.each([false, true])( + "handles streaming reasoning across retained history (Expand thinking %s)", + (autoExpandReasoning) => { + const call = { ...toolCall("1", { type: "shell", command: "echo ok" }), turnId: "run" }; + const thought: StreamItem = { + kind: "thought", + id: "reasoning", + text: "Checking", + status: "loading", + timestamp: new Date(), + turnId: "run", + }; + const tail = [call]; + const result = project({ + level: "overview", + tail, + head: [thought], + isTurnActive: true, + preparedHistory: prepareToolCallHistory("overview", tail, autoExpandReasoning), + }); + expect(result.head).toEqual(autoExpandReasoning ? [thought] : []); + expect(result.groupsByHostId.get(call.id)?.run.calls).toEqual( + autoExpandReasoning ? [call] : [call, thought], + ); + expect(result.groupsByHostId.get(call.id)?.summary).toMatchObject({ + commandCount: 1, + otherToolCount: 0, + thoughtCount: autoExpandReasoning ? 0 : 1, + }); + }, +); diff --git a/packages/app/src/tool-calls/detail-level/projection.ts b/packages/app/src/tool-calls/detail-level/projection.ts index 9d57391b2e..3f10855ce5 100644 --- a/packages/app/src/tool-calls/detail-level/projection.ts +++ b/packages/app/src/tool-calls/detail-level/projection.ts @@ -13,6 +13,7 @@ export type ToolCallDetailGroup = OverviewToolCallGroup; export interface PreparedToolCallHistory { mode: "overview"; + autoExpandReasoning: boolean; grouped: GroupedHistory; } @@ -41,15 +42,18 @@ function visibleToolCallItems(items: StreamItem[]): StreamItem[] { export function prepareToolCallHistory( level: ToolCallDetailLevel, tail: StreamItem[], + autoExpandReasoning = false, ): PreparedToolCallHistory | null { if (level === "detailed") { return null; } return { mode: "overview", + autoExpandReasoning, grouped: prepareGroupedHistory({ tail: visibleToolCallItems(tail), buildGroup: buildOverviewGroup, + autoExpandReasoning, }), }; } @@ -74,6 +78,7 @@ export function projectToolCallDetailLevel(input: { } return groupLiveToolCalls({ history: input.preparedHistory.grouped, + autoExpandReasoning: input.preparedHistory.autoExpandReasoning, head: visibleToolCallItems(input.head), isTurnActive: input.isTurnActive, buildGroup: buildOverviewGroup, 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/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index f663aeb8be..f3b483193c 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1658,6 +1658,11 @@ test("honors explicit fetchAgentTimeline timeout below the session RPC default", mock.triggerOpen(); await connectPromise; + await expect(client.fetchAgentTimeline("agent-1", { wholeTurns: true })).rejects.toThrow( + "Update the host to load complete conversation turns.", + ); + expect(mock.sent).toHaveLength(0); + const responsePromise = client.fetchAgentTimeline("agent-1", { requestId: "req-timeline-1", direction: "tail", diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 59d928283f..8840aa1eb4 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -686,6 +686,7 @@ export interface FetchAgentOptions { } type LegacyFetchAgentOptions = Omit; export interface FetchAgentTimelineOptions { + wholeTurns?: boolean; direction?: FetchAgentTimelineDirection; cursor?: FetchAgentTimelineCursor; limit?: number; @@ -3548,9 +3549,13 @@ export class DaemonClient { agentId: string, options: FetchAgentTimelineOptions = {}, ): Promise { + if (options.wholeTurns && this.lastServerInfoMessage?.features?.wholeTurnTimeline !== true) { + throw new Error("Update the host to load complete conversation turns."); + } const resolvedRequestId = this.createRequestId(options.requestId); const message = SessionInboundMessageSchema.parse({ type: "fetch_agent_timeline_request", + ...(options.wholeTurns !== undefined ? { wholeTurns: options.wholeTurns } : {}), agentId, requestId: resolvedRequestId, ...(options.direction ? { direction: options.direction } : {}), diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 3034598f1e..704c7ad277 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1910,6 +1910,8 @@ export const AgentTimelineCursorSchema = z.object({ }); export const FetchAgentTimelineRequestMessageSchema = z.object({ + // Keep available turns whole for tail/before pages; after catch-up stays incremental. + wholeTurns: z.boolean().optional(), type: z.literal("fetch_agent_timeline_request"), agentId: z.string(), requestId: z.string(), @@ -3765,6 +3767,7 @@ export const ServerInfoStatusPayloadSchema = z providerSubagents: z.boolean().optional(), // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain wire field. projectedSubagentTimeline: z.boolean().optional(), + wholeTurnTimeline: z.boolean().optional(), // COMPAT(providerSubagentNesting): added in v0.7, remove gate after 2027-03-04. providerSubagentNesting: z.boolean().optional(), // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. diff --git a/packages/protocol/src/paseo-tool-presentation/catalog.ts b/packages/protocol/src/paseo-tool-presentation/catalog.ts index 0c35b128b7..4844b7ceb7 100644 --- a/packages/protocol/src/paseo-tool-presentation/catalog.ts +++ b/packages/protocol/src/paseo-tool-presentation/catalog.ts @@ -19,6 +19,7 @@ export interface PaseoToolDetailSpec { export interface PaseoToolEntry { label: string; + completedLabel: string; context?: PaseoToolContext; detail?: PaseoToolDetailSpec; } @@ -123,14 +124,24 @@ function baseName(value: unknown): string | undefined { return value.split(/[\\/]/).findLast((segment) => segment.length > 0); } -function browser(label: string, context?: PaseoToolContext): PaseoToolEntry { - return { label, ...(context ? { context } : {}), detail: { inputOrder: BROWSER_FIELDS } }; +function browser( + label: string, + completedLabel: string, + context?: PaseoToolContext, +): PaseoToolEntry { + return { + label, + completedLabel, + ...(context ? { context } : {}), + detail: { inputOrder: BROWSER_FIELDS }, + }; } export const PASEO_TOOL_CATALOG = { // Agents create_agent: { label: "Create agent", + completedLabel: "Created agent", context: field("title", "initialPrompt"), detail: { promptField: "initialPrompt", @@ -140,6 +151,7 @@ export const PASEO_TOOL_CATALOG = { }, send_agent_prompt: { label: "Send message", + completedLabel: "Sent message", context: field("prompt"), detail: { promptField: "prompt", @@ -149,42 +161,55 @@ export const PASEO_TOOL_CATALOG = { }, get_agent_status: { label: "Check agent", + completedLabel: "Checked agent", context: targetAgent(), detail: { inputOrder: ["agentId"] }, }, get_agent_activity: { label: "Read agent activity", + completedLabel: "Read agent activity", context: targetAgent(), detail: { inputOrder: ["agentId", "limit"] }, }, list_agents: { label: "List agents", + completedLabel: "Listed agents", detail: { inputOrder: ["cwd", "statuses", "sinceHours", "limit", "includeArchived"] }, }, update_agent: { label: "Update agent", + completedLabel: "Updated agent", context: fieldOrTargetAgent("name"), detail: { inputOrder: AGENT_FIELDS }, }, set_agent_mode: { label: "Set agent mode", + completedLabel: "Set agent mode", context: field("modeId"), detail: { inputOrder: ["agentId", "modeId"] }, }, cancel_agent: { label: "Cancel agent", + completedLabel: "Canceled agent", context: targetAgent(), detail: { inputOrder: ["agentId"] }, }, archive_agent: { label: "Archive agent", + completedLabel: "Archived agent", context: targetAgent(), detail: { inputOrder: ["agentId"] }, }, - kill_agent: { label: "Kill agent", context: targetAgent(), detail: { inputOrder: ["agentId"] } }, - list_pending_permissions: { label: "List permissions" }, + kill_agent: { + label: "Kill agent", + completedLabel: "Killed agent", + context: targetAgent(), + detail: { inputOrder: ["agentId"] }, + }, + list_pending_permissions: { label: "List permissions", completedLabel: "Listed permissions" }, respond_to_permission: { label: "Respond to permission", + completedLabel: "Responded to permission", context: targetAgent(), detail: { inputOrder: ["agentId", "requestId", "response"] }, }, @@ -192,32 +217,41 @@ export const PASEO_TOOL_CATALOG = { // Workspaces and scripts create_workspace: { label: "Create workspace", + completedLabel: "Created workspace", context: workspaceContext(), detail: { inputOrder: WORKSPACE_FIELDS, outputFields: ["workspaceId", "projectId"] }, }, - list_hosts: { label: "List hosts" }, - list_workspaces: { label: "List workspaces" }, + list_hosts: { label: "List hosts", completedLabel: "Listed hosts" }, + list_workspaces: { label: "List workspaces", completedLabel: "Listed workspaces" }, rename_workspace: { label: "Rename workspace", + completedLabel: "Renamed workspace", context: field("title"), detail: { inputOrder: ["title", "workspaceId"] }, }, archive_workspace: { label: "Archive workspace", + completedLabel: "Archived workspace", context: field("workspaceId"), detail: { inputOrder: ["workspaceId"], outputFields: ["workspaceId", "archivedAgentIds", "removedDirectory"], }, }, - list_workspace_scripts: { label: "List scripts", detail: { inputOrder: ["workspaceId"] } }, + list_workspace_scripts: { + label: "List scripts", + completedLabel: "Listed scripts", + detail: { inputOrder: ["workspaceId"] }, + }, start_workspace_script: { label: "Start script", + completedLabel: "Started script", context: field("scriptName"), detail: { inputOrder: ["workspaceId", "scriptName"] }, }, stop_workspace_script: { label: "Stop script", + completedLabel: "Stopped script", context: field("scriptName"), detail: { inputOrder: ["workspaceId", "scriptName"] }, }, @@ -225,22 +259,30 @@ export const PASEO_TOOL_CATALOG = { // Terminals create_terminal: { label: "Create terminal", + completedLabel: "Created terminal", context: field("name", "command"), detail: { inputOrder: ["cwd", "workspaceId", "name", "command"] }, }, - list_terminals: { label: "List terminals", detail: { inputOrder: ["cwd", "all"] } }, + list_terminals: { + label: "List terminals", + completedLabel: "Listed terminals", + detail: { inputOrder: ["cwd", "all"] }, + }, capture_terminal: { label: "Read terminal", + completedLabel: "Read terminal", context: field("terminalId"), detail: { inputOrder: ["terminalId", "lines"] }, }, send_terminal_keys: { label: "Send keys", + completedLabel: "Sent keys", context: field("keys"), detail: { inputOrder: ["terminalId", "keys", "literal"] }, }, kill_terminal: { label: "Kill terminal", + completedLabel: "Killed terminal", context: field("terminalId"), detail: { inputOrder: ["terminalId"] }, }, @@ -248,6 +290,7 @@ export const PASEO_TOOL_CATALOG = { // Schedules and heartbeats create_schedule: { label: "Create schedule", + completedLabel: "Created schedule", context: field("name", "cron"), detail: { promptField: "prompt", @@ -257,6 +300,7 @@ export const PASEO_TOOL_CATALOG = { }, create_heartbeat: { label: "Create heartbeat", + completedLabel: "Created heartbeat", context: field("name", "cron"), detail: { promptField: "prompt", @@ -266,79 +310,93 @@ export const PASEO_TOOL_CATALOG = { }, update_schedule: { label: "Update schedule", + completedLabel: "Updated schedule", context: field("name", "id"), detail: { promptField: "prompt", inputOrder: AUTOMATION_FIELDS }, }, - list_schedules: { label: "List schedules" }, + list_schedules: { label: "List schedules", completedLabel: "Listed schedules" }, inspect_schedule: { label: "Inspect schedule", + completedLabel: "Inspected schedule", context: field("id"), detail: { inputOrder: ["id"] }, }, schedule_logs: { label: "Read schedule logs", + completedLabel: "Read schedule logs", context: field("id"), detail: { inputOrder: ["id"] }, }, run_schedule_once: { label: "Run schedule", + completedLabel: "Ran schedule", + context: field("id"), + detail: { inputOrder: ["id"] }, + }, + pause_schedule: { + label: "Pause schedule", + completedLabel: "Paused schedule", context: field("id"), detail: { inputOrder: ["id"] }, }, - pause_schedule: { label: "Pause schedule", context: field("id"), detail: { inputOrder: ["id"] } }, resume_schedule: { label: "Resume schedule", + completedLabel: "Resumed schedule", context: field("id"), detail: { inputOrder: ["id"] }, }, delete_schedule: { label: "Delete schedule", + completedLabel: "Deleted schedule", context: field("id"), detail: { inputOrder: ["id"] }, }, delete_heartbeat: { label: "Delete heartbeat", + completedLabel: "Deleted heartbeat", context: field("id"), detail: { inputOrder: ["id"] }, }, // Providers - list_providers: { label: "List providers" }, + list_providers: { label: "List providers", completedLabel: "Listed providers" }, list_models: { label: "List models", + completedLabel: "Listed models", context: field("provider"), detail: { inputOrder: ["provider"] }, }, - list_profiles: { label: "List profiles" }, + list_profiles: { label: "List profiles", completedLabel: "Listed profiles" }, inspect_provider: { label: "Inspect provider", + completedLabel: "Inspected provider", context: field("provider"), detail: { inputOrder: ["provider", "cwd", "settings"] }, }, // Browser - browser_list_tabs: browser("List browser tabs"), - browser_new_tab: browser("Open browser tab", field("url")), - browser_close_tab: browser("Close browser tab", field("browserId")), - browser_navigate: browser("Navigate", field("url")), - browser_back: browser("Go back"), - browser_forward: browser("Go forward"), - browser_reload: browser("Reload page"), - browser_snapshot: browser("Snapshot page"), - browser_screenshot: browser("Take screenshot"), - browser_click: browser("Click element", field("ref")), - browser_hover: browser("Hover element", field("ref")), - browser_drag: browser("Drag element", field("sourceRef")), - browser_fill: browser("Fill field", field("ref")), - browser_select: browser("Select option", field("value", "ref")), - browser_type: browser("Type text", field("text")), - browser_keypress: browser("Press key", field("key")), - browser_upload: browser("Upload files", field("filePaths")), - browser_wait: browser("Wait for page", field("text", "url")), - browser_scroll: browser("Scroll page"), - browser_resize: browser("Resize browser", viewportContext()), - browser_logs: browser("Read browser logs"), - browser_evaluate: browser("Run JavaScript", field("function")), + browser_list_tabs: browser("List browser tabs", "Listed browser tabs"), + browser_new_tab: browser("Open browser tab", "Opened browser tab", field("url")), + browser_close_tab: browser("Close browser tab", "Closed browser tab", field("browserId")), + browser_navigate: browser("Navigate", "Navigated", field("url")), + browser_back: browser("Go back", "Went back"), + browser_forward: browser("Go forward", "Went forward"), + browser_reload: browser("Reload page", "Reloaded page"), + browser_snapshot: browser("Snapshot page", "Snapshotted page"), + browser_screenshot: browser("Take screenshot", "Took screenshot"), + browser_click: browser("Click element", "Clicked element", field("ref")), + browser_hover: browser("Hover element", "Hovered element", field("ref")), + browser_drag: browser("Drag element", "Dragged element", field("sourceRef")), + browser_fill: browser("Fill field", "Filled field", field("ref")), + browser_select: browser("Select option", "Selected option", field("value", "ref")), + browser_type: browser("Type text", "Typed text", field("text")), + browser_keypress: browser("Press key", "Pressed key", field("key")), + browser_upload: browser("Upload files", "Uploaded files", field("filePaths")), + browser_wait: browser("Wait for page", "Waited for page", field("text", "url")), + browser_scroll: browser("Scroll page", "Scrolled page"), + browser_resize: browser("Resize browser", "Resized browser", viewportContext()), + browser_logs: browser("Read browser logs", "Read browser logs"), + browser_evaluate: browser("Run JavaScript", "Ran JavaScript", field("function")), } satisfies Record; export type PaseoToolName = keyof typeof PASEO_TOOL_CATALOG; diff --git a/packages/protocol/src/paseo-tool-presentation/index.ts b/packages/protocol/src/paseo-tool-presentation/index.ts index c04606a231..e5395a9e62 100644 --- a/packages/protocol/src/paseo-tool-presentation/index.ts +++ b/packages/protocol/src/paseo-tool-presentation/index.ts @@ -28,6 +28,8 @@ export const PASEO_TOOL_NAMES = Object.keys(PASEO_TOOL_CATALOG) as readonly Pase /** Returns null for tools outside the Paseo namespace. */ export function buildPaseoToolDisplay(input: PaseoToolDisplayInput): PaseoToolDisplay | null { + // Canonical details own internal lifecycle rows such as worktree setup. + if (input.detail.type !== "unknown") return null; const leafName = paseoLeafName(input.toolName); if (!leafName) return null; const entry = getPaseoToolEntry(leafName); @@ -39,6 +41,13 @@ export function buildPaseoToolDisplay(input: PaseoToolDisplayInput): PaseoToolDi return { displayName: entry.label, ...(summary ? { summary } : {}) }; } +/** A semantic action label for summaries, sharing the tool presentation catalog. */ +export function getPaseoToolActionLabel(toolName: string): string | null { + const leafName = paseoLeafName(toolName); + if (!leafName) return null; + return getPaseoToolEntry(leafName)?.completedLabel ?? humanizeLeafName(leafName); +} + /** The agent a Paseo call acts on, so callers can resolve its title. */ export function getPaseoToolTargetAgentId( toolName: string, @@ -49,7 +58,11 @@ export function getPaseoToolTargetAgentId( } function paseoLeafName(toolName: string): string | null { - return isPaseoToolName(toolName) ? getPaseoToolLeafName(toolName) : null; + if (isPaseoToolName(toolName)) return getPaseoToolLeafName(toolName); + const normalized = toolName.trim().toLowerCase(); + return normalized.startsWith("paseo_") && !normalized.startsWith("paseo__") + ? normalized.slice("paseo_".length) + : null; } function readToolInput(detail: ToolCallDetail): Readonly> { diff --git a/packages/protocol/src/timeline-turn.ts b/packages/protocol/src/timeline-turn.ts new file mode 100644 index 0000000000..f509787317 --- /dev/null +++ b/packages/protocol/src/timeline-turn.ts @@ -0,0 +1,24 @@ +/** Canonical run identity wins over trigger-shaped rows steered into that run. */ +export function continuesTimelineTurn( + previousTurnId: string | undefined, + nextTurnId: string | undefined, + nextIsTrigger: boolean, +): boolean { + if (previousTurnId !== undefined && nextTurnId !== undefined) { + return previousTurnId === nextTurnId; + } + // Missing IDs cannot recover historical batching or steering. Each visible + // trigger deterministically starts a turn in both paging and presentation. + return !nextIsTrigger; +} + +export function isTimelineTurnTrigger(item: { + type: string; + name?: string; + agentMessage?: unknown; +}): boolean { + return ( + item.type === "user_message" || + (item.type === "tool_call" && (item.agentMessage !== undefined || item.name === "heartbeat")) + ); +} diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index ae8989c003..2d21bdc63c 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -880,6 +880,66 @@ test("retries provider history hydration after a stream failure", async () => { } }); +test("notification batches share the run they start or join", async () => { + const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() }); + const workdir = mkdtempSync(join(tmpdir(), "agent-notification-turns-")); + const manager = new AgentManager({ + clients: { + codex: new (class extends TestAgentClient { + override async createSession(): Promise { + return session; + } + })(), + }, + logger, + }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const batch = (prefix: string) => + ["one", "two"] + .map((id) => + formatAgentMessage({ + id: `${prefix}-${id}`, + source: { kind: "agent-notification", agentId: "worker", event: "finished" }, + text: "Worker finished", + }), + ) + .join("\n\n"); + const consume = async (prompt: string, clientMessageId: string) => { + for await (const _event of manager.streamAgent(agent.id, prompt, { clientMessageId })) { + } + }; + try { + const first = consume("human", "human"); + await manager.waitForAgentRunStart(agent.id); + await expect( + manager.steerAgentRun(agent.id, batch("joined"), { clientMessageId: "joined-one" }), + ).resolves.toEqual({ status: "accepted" }); + session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-1" }); + await first; + const second = consume(batch("idle"), "idle-one"); + await manager.waitForAgentRunStart(agent.id); + session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-2" }); + await second; + expect( + manager.fetchTimeline(agent.id, { limit: 0 }).rows.map((row) => ({ + turnId: row.turnId, + id: row.item.type === "tool_call" ? row.item.callId : row.item.type, + })), + ).toEqual([ + { turnId: "active-turn-1", id: "user_message" }, + { turnId: "active-turn-1", id: "paseo-agent-message:joined-one" }, + { turnId: "active-turn-1", id: "paseo-agent-message:joined-two" }, + { turnId: "active-turn-2", id: "paseo-agent-message:idle-one" }, + { turnId: "active-turn-2", id: "paseo-agent-message:idle-two" }, + ]); + } finally { + await manager.closeAgent(agent.id); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("unavailable steer interrupts once and starts one replacement turn", async () => { const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() }); session.steerResult = "unavailable"; @@ -11341,7 +11401,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 +11430,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 +11474,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" }), + ); +}); diff --git a/packages/server/src/server/agent/agent-timeline-store-types.ts b/packages/server/src/server/agent/agent-timeline-store-types.ts index 639b24a904..9ca1ab37b9 100644 --- a/packages/server/src/server/agent/agent-timeline-store-types.ts +++ b/packages/server/src/server/agent/agent-timeline-store-types.ts @@ -17,6 +17,7 @@ export interface AgentTimelineCursor { export type AgentTimelineFetchDirection = "tail" | "before" | "after"; export interface AgentTimelineFetchOptions { + wholeTurns?: boolean; direction?: AgentTimelineFetchDirection; cursor?: AgentTimelineCursor; /** diff --git a/packages/server/src/server/agent/agent-timeline-store.test.ts b/packages/server/src/server/agent/agent-timeline-store.test.ts index eb46916fe3..99e392f188 100644 --- a/packages/server/src/server/agent/agent-timeline-store.test.ts +++ b/packages/server/src/server/agent/agent-timeline-store.test.ts @@ -4,6 +4,39 @@ import type { AgentTimelineItem } from "./agent-sdk-types.js"; import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js"; describe("InMemoryAgentTimelineStore", () => { + it("resets stale forward cursors to complete available turns", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("agent", { epoch: "current" }); + for (let index = 0; index < 60; index++) + store.append( + "agent", + { + type: "tool_call", + callId: `call-${index}`, + name: "shell", + status: "completed", + error: null, + detail: { type: "unknown", input: null, output: null }, + }, + { turnId: "run" }, + ); + const page = store.fetch("agent", { + direction: "after", + cursor: { epoch: "old", seq: 10 }, + limit: 40, + wholeTurns: true, + }); + expect(page.rows).toHaveLength(60); + expect(page).toMatchObject({ + reset: true, + staleCursor: true, + startSeq: 1, + endSeq: 60, + hasOlder: false, + hasNewer: false, + }); + }); + it("clamps an overshooting before cursor into the bounded tail window", () => { const store = new InMemoryAgentTimelineStore(); store.initialize("agent-1", { @@ -217,31 +250,35 @@ it("includes transitive tool updates in a contiguous projected tail", () => { expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false }); }); -it("serves tail 200 from 100,000 retained rows without stalling the loop", async () => { - const store = new InMemoryAgentTimelineStore(); - store.initialize("large"); - for (let index = 0; index < 100_000; index++) { - store.append("large", { type: "user_message", text: String(index) }); - } - // Generation and its GC are outside the measured request window. - await new Promise((resolve) => setImmediate(resolve)); - const started = performance.now(); - const probe = new Promise((resolve) => - setImmediate(() => resolve(performance.now() - started)), - ); - let page = store.fetch("large", { limit: 200 }); - for (let repeat = 1; repeat < 8; repeat++) page = store.fetch("large", { limit: 200 }); - const duration = performance.now() - started; - const loopGap = await probe; - console.info({ duration, loopGap }); - expect(page.rows).toHaveLength(200); - expect(page.startSeq).toBe(99_801); - expect(page.endSeq).toBe(100_000); - // CI runners can pause for scheduling/GC; the local per-request 2 ms gate - // is measured separately. Eight requests amplify the old full-history work. - expect(duration).toBeLessThan(50); - expect(loopGap).toBeLessThan(50); -}); +it.each([false, true])( + "serves tail 200 from 100,000 retained rows without stalling the loop (wholeTurns=%s)", + async (wholeTurns) => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("large"); + for (let index = 0; index < 100_000; index++) { + store.append("large", { type: "user_message", text: String(index) }); + } + // Generation and its GC are outside the measured request window. + await new Promise((resolve) => setImmediate(resolve)); + const started = performance.now(); + const probe = new Promise((resolve) => + setImmediate(() => resolve(performance.now() - started)), + ); + let page = store.fetch("large", { limit: 200, wholeTurns }); + for (let repeat = 1; repeat < 8; repeat++) + page = store.fetch("large", { limit: 200, wholeTurns }); + const duration = performance.now() - started; + const loopGap = await probe; + console.info({ duration, loopGap }); + expect(page.rows).toHaveLength(200); + expect(page.startSeq).toBe(99_801); + expect(page.endSeq).toBe(100_000); + // CI runners can pause for scheduling/GC; the local per-request 2 ms gate + // is measured separately. Eight requests amplify the old full-history work. + expect(duration).toBeLessThan(50); + expect(loopGap).toBeLessThan(50); + }, +); it("matches the previous reprojecting selector on seeded randomized timelines", () => { let seed = 0x76aabbcc; diff --git a/packages/server/src/server/agent/agent-timeline-store.ts b/packages/server/src/server/agent/agent-timeline-store.ts index b9727e33e9..49b5013fd4 100644 --- a/packages/server/src/server/agent/agent-timeline-store.ts +++ b/packages/server/src/server/agent/agent-timeline-store.ts @@ -107,6 +107,7 @@ export class InMemoryAgentTimelineStore { direction: reset ? "tail" : direction, cursorSeq: cursor?.seq, limit: options?.limit ?? DEFAULT_TIMELINE_FETCH_LIMIT, + wholeTurns: options?.wholeTurns, }); return { epoch: state.epoch, diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index 7055030dc0..14322c6ccf 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -230,6 +230,7 @@ interface LargeAgentStreamPayloadRequest { } interface AgentStreamStressRequest { + tools: boolean; count: number; coalesced: boolean; } @@ -461,7 +462,7 @@ function parseLargeAgentStreamPayloadPrompt( function parseAgentStreamStressPrompt(prompt: AgentPromptInput): AgentStreamStressRequest | null { const text = promptToText(prompt); - const match = /emit\s+(\d+)\s+(coalesced\s+)?agent stream updates/i.exec(text); + const match = /emit\s+(\d+)\s+(coalesced\s+|tool\s+)?agent stream updates/i.exec(text); if (!match) { return null; } @@ -471,7 +472,8 @@ function parseAgentStreamStressPrompt(prompt: AgentPromptInput): AgentStreamStre } return { count: Math.min(count, 5_000), - coalesced: Boolean(match[2]), + coalesced: match[2]?.trim() === "coalesced", + tools: match[2]?.trim() === "tool", }; } @@ -1452,21 +1454,37 @@ export class MockLoadTestAgentSession implements AgentSession { this.emitTurnStarted(turn); for (let index = 0; index < stress.count; index += 1) { - this.emitTimeline( - turn.turnId, - stress.coalesced - ? { - type: "assistant_message", - text: `stress-update-${index}`, - messageId: turn.assistantMessageId, - } - : { - type: "todo", - items: [{ text: `stress-update-${index}`, completed: index % 2 === 0 }], - }, - ); + let item: AgentTimelineItem; + if (stress.tools) { + item = { + type: "tool_call", + callId: `${turn.turnId}:stress:${index}`, + name: "Bash", + status: "completed", + error: null, + detail: { type: "shell", command: `echo stress-${index}`, output: `stress-${index}` }, + }; + } else if (stress.coalesced) { + item = { + type: "assistant_message", + text: `stress-update-${index}`, + messageId: turn.assistantMessageId, + }; + } else { + item = { + type: "todo", + items: [{ text: `stress-update-${index}`, completed: index % 2 === 0 }], + }; + } + this.emitTimeline(turn.turnId, item); } + if (stress.tools) + this.emitTimeline(turn.turnId, { + type: "assistant_message", + text: `Completed ${stress.count} commands`, + messageId: turn.assistantMessageId, + }); this.activeTurn = null; const usage = { inputTokens: 1, diff --git a/packages/server/src/server/agent/test-utils/timeline-page-reference.ts b/packages/server/src/server/agent/test-utils/timeline-page-reference.ts index 8e9ab228a6..b3a51f22e5 100644 --- a/packages/server/src/server/agent/test-utils/timeline-page-reference.ts +++ b/packages/server/src/server/agent/test-utils/timeline-page-reference.ts @@ -234,7 +234,7 @@ export function selectReferenceProjectedTimelinePage(input: { // Existing selection fixtures exercise the retained owner through its public API. export function selectProjectedTimelinePage( - input: Parameters[0], + input: Parameters[0] & { wholeTurns?: boolean }, ) { const projection = new TimelineProjection(input.rows); const bounds = input.bounds ?? getTimelineBounds(input.rows) ?? { minSeq: 0, maxSeq: 0 }; diff --git a/packages/server/src/server/agent/timeline-projection.test.ts b/packages/server/src/server/agent/timeline-projection.test.ts index f2f832ffcf..d5e4c5b637 100644 --- a/packages/server/src/server/agent/timeline-projection.test.ts +++ b/packages/server/src/server/agent/timeline-projection.test.ts @@ -534,6 +534,154 @@ describe("selectProjectedTimelinePage", () => { }; } + test("whole-turn pages extend past the soft target and keep prompt jumps whole", () => { + const rows: AgentTimelineRow[] = Array.from({ length: 125 }, (_, index) => ({ + seq: index + 1, + timestamp: new Date(1000 + index).toISOString(), + turnId: ["short", "long", "latest"][Number(index >= 3) + Number(index >= 123)], + item: { + type: "tool_call", + callId: `tool-${index}`, + name: "shell", + status: "completed", + error: null, + detail: { type: "unknown", input: null, output: null }, + }, + })); + const tail = selectProjectedTimelinePage({ + rows, + direction: "tail", + limit: 40, + wholeTurns: true, + }); + expect(tail.entries.map((entry) => entry.seqStart)).toEqual( + rows.slice(3).map((row) => row.seq), + ); + expect(tail).toMatchObject({ startSeq: 4, endSeq: 125, hasOlder: true, hasNewer: false }); + const before = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: 124, + limit: 40, + wholeTurns: true, + }); + expect(before.entries.map((entry) => entry.seqStart)).toEqual( + rows.slice(3, 123).map((row) => row.seq), + ); + expect(before.endSeq).toBe(123); + const older = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: 4, + limit: 40, + wholeTurns: true, + }); + expect(older.entries.map((entry) => entry.seqStart)).toEqual([1, 2, 3]); + expect(older.hasOlder).toBe(false); + const jump = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: 70, + limit: 40, + wholeTurns: true, + }); + expect(jump.entries.map((entry) => entry.seqStart)).toEqual( + rows.slice(3, 123).map((row) => row.seq), + ); + expect(jump).toMatchObject({ startSeq: 4, endSeq: 123, hasOlder: true, hasNewer: true }); + const nearPreviousPrompt = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: 10, + limit: 40, + wholeTurns: true, + }); + expect(nearPreviousPrompt).toMatchObject({ startSeq: 1, endSeq: 123 }); + expect( + selectProjectedTimelinePage({ rows, direction: "tail", limit: 40 }).entries, + ).toHaveLength(40); + }); + + test("whole-turn pages share short runs and use the same deterministic legacy triggers", () => { + const rows: AgentTimelineRow[] = Array.from({ length: 24 }, (_, index) => ({ + seq: index + 1, + timestamp: new Date(1000 + index).toISOString(), + item: + index % 2 === 0 + ? { type: "user_message", text: `prompt-${index}` } + : { type: "assistant_message", text: `reply-${index}` }, + })); + const page = selectProjectedTimelinePage({ + rows, + direction: "tail", + limit: 7, + wholeTurns: true, + }); + expect(page.entries.map((entry) => entry.seqStart)).toEqual([17, 18, 19, 20, 21, 22, 23, 24]); + expect( + selectProjectedTimelinePage({ rows, direction: "tail", limit: 0, wholeTurns: true }).entries, + ).toHaveLength(24); + rows.push({ + ...toolRow(25, "completed"), + item: { ...toolRow(25, "completed").item, name: "heartbeat" } as AgentTimelineRow["item"], + }); + rows.push({ + seq: 26, + timestamp: new Date().toISOString(), + item: { type: "assistant_message", text: "awake" }, + }); + expect( + selectProjectedTimelinePage({ + rows, + direction: "tail", + limit: 1, + wholeTurns: true, + }).entries.map((entry) => entry.seqStart), + ).toEqual([25, 26]); + }); + + test("whole-turn tails close interleaved source coverage without changing forward catch-up", () => { + const rows: AgentTimelineRow[] = [ + { + seq: 1, + timestamp: new Date(1).toISOString(), + turnId: "first", + item: { type: "user_message", text: "go" }, + }, + { ...toolRow(2, "running"), turnId: "first" }, + { + seq: 3, + timestamp: new Date(3).toISOString(), + turnId: "second", + item: { type: "user_message", text: "next" }, + }, + { + seq: 4, + timestamp: new Date(4).toISOString(), + turnId: "second", + item: { type: "assistant_message", text: "done" }, + }, + { ...toolRow(5, "completed"), turnId: "first" }, + ]; + expect( + selectProjectedTimelinePage({ rows, direction: "tail", limit: 1, wholeTurns: true }), + ).toMatchObject({ + startSeq: 1, + endSeq: 5, + hasOlder: false, + hasNewer: false, + }); + expect( + selectProjectedTimelinePage({ + rows, + direction: "after", + cursorSeq: 2, + limit: 1, + wholeTurns: true, + }), + ).toEqual(selectProjectedTimelinePage({ rows, direction: "after", cursorSeq: 2, limit: 1 })); + }); + test("tail page returns full projected items instead of tool lifecycle deltas", () => { const rows: AgentTimelineRow[] = [ { seq: 1, timestamp: "2026-02-13T00:00:00.000Z", item: { type: "user_message", text: "go" } }, diff --git a/packages/server/src/server/agent/timeline-projection.ts b/packages/server/src/server/agent/timeline-projection.ts index 3a65f28531..8b05432545 100644 --- a/packages/server/src/server/agent/timeline-projection.ts +++ b/packages/server/src/server/agent/timeline-projection.ts @@ -1,3 +1,4 @@ +import { continuesTimelineTurn, isTimelineTurnTrigger } from "@getpaseo/protocol/timeline-turn"; import type { AgentTimelineItem, ToolCallDetail } from "./agent-sdk-types.js"; import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity"; @@ -296,6 +297,7 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[] export type ProjectedTimelineRow = AgentTimelineRow & TimelineProjectionEntry; interface ProjectedTimelinePageOptions { + wholeTurns?: boolean; bounds: { minSeq: number; maxSeq: number }; direction: TimelineLimitDirection; cursorSeq?: number; @@ -423,7 +425,8 @@ export class TimelineProjection { private selectTail(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection { const limit = Math.max(0, Math.floor(input.limit ?? 0)); const requestedStart = limit === 0 ? 0 : Math.max(0, this.rows.length - limit); - const start = this.spans[this.spanIndex(requestedStart)].start; + const coveredStart = this.spans[this.spanIndex(requestedStart)].start; + const start = input.wholeTurns ? this.wholeTurnWindowStart(coveredStart) : coveredStart; return { entries: this.rows.slice(start), startSeq: this.rows[start].seqStart, @@ -433,6 +436,17 @@ export class TimelineProjection { }; } + private wholeTurnWindowStart(requestedStart: number): number { + let start = requestedStart; + for (;;) { + while (start > 0 && continuesProjectedTurn(this.rows[start - 1], this.rows[start])) start--; + // Source coverage can pull in an earlier tool and therefore its entire run. + const coveredStart = this.spans[this.spanIndex(start)].start; + if (coveredStart === start) return start; + start = coveredStart; + } + } + private selectAfter(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection { const startSeq = Math.max( input.bounds.minSeq, @@ -480,13 +494,23 @@ export class TimelineProjection { else high = middle; } const limit = Math.max(0, Math.floor(input.limit ?? 0)); - const start = limit === 0 ? 0 : Math.max(0, low - limit); + const requestedStart = limit === 0 ? 0 : Math.max(0, low - limit); + const start = input.wholeTurns ? this.wholeTurnWindowStart(requestedStart) : requestedStart; + // Choose the window before extending the right edge, so a prompt jump + // cannot lose its target when the next turn is arbitrarily large. + if (input.wholeTurns) { + while (low < this.rows.length && continuesProjectedTurn(this.rows[low - 1], this.rows[low])) + low++; + } + let selectedEnd = endSeq; + if (input.wholeTurns) + selectedEnd = low === this.rows.length ? input.bounds.maxSeq : this.rows[low].seqStart - 1; return { entries: this.rows.slice(start, low), startSeq: this.rows[start]?.seqStart ?? null, - endSeq, + endSeq: selectedEnd, hasOlder: start > 0, - hasNewer: endSeq < input.bounds.maxSeq, + hasNewer: selectedEnd < input.bounds.maxSeq, }; } return { entries: [], startSeq: null, endSeq: null, hasOlder: false, hasNewer: true }; @@ -675,6 +699,13 @@ function selectProjectedEntriesAfter(input: { }; } +function continuesProjectedTurn( + previous: TimelineProjectionEntry, + next: TimelineProjectionEntry, +): boolean { + return continuesTimelineTurn(previous.turnId, next.turnId, isTimelineTurnTrigger(next.item)); +} + /** * Apply a projected-count limit to a flat AgentTimelineItem[] without seq metadata. * Used by callers that only have items in hand (e.g. MCP tools reading diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 9d25328079..3e78982793 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -7805,6 +7805,7 @@ export class Session { direction, cursor, limit: msg.limit, + wholeTurns: msg.wholeTurns, }); const selectedTimeline = { timeline: fetchedControlTimeline, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 4473a4004d..dfa324315c 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -2062,6 +2062,7 @@ export class VoiceAssistantWebSocketServer { providerSubagents: true, // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain advertisement. projectedSubagentTimeline: true, + wholeTurnTimeline: true, // COMPAT(providerSubagentNesting): added in v0.7, remove gate after 2027-03-04. providerSubagentNesting: true, // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12.