mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 13:13:25 +00:00
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
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<number> {
|
||||
});
|
||||
}
|
||||
|
||||
async function groupedToolsInsideLatestTurn(page: Page): Promise<string[]> {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ export async function addCustomModel(page: Page, id: string): Promise<void> {
|
||||
.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<void> {
|
||||
|
||||
@@ -149,7 +149,11 @@ export interface SeedDaemonClient {
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
cancelAgent(agentId: string): Promise<void>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
sendAgentMessage(
|
||||
agentId: string,
|
||||
text: string,
|
||||
options?: { sourceAgentId?: string },
|
||||
): Promise<void>;
|
||||
waitForFinish(
|
||||
agentId: string,
|
||||
timeout?: number,
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -39,13 +39,13 @@ export interface StreamLayoutInput {
|
||||
isTurnActive: boolean;
|
||||
history: StreamItem[];
|
||||
liveHead: StreamItem[];
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
timingByItemId: Map<string, TurnTiming>;
|
||||
}
|
||||
|
||||
interface LayoutSegmentInput {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
timingByItemId: Map<string, TurnTiming>;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
hasAuxiliaryFooter: boolean;
|
||||
frameOrder: StreamFrameChildOrder;
|
||||
@@ -57,8 +57,8 @@ interface LayoutSegmentInput {
|
||||
phase: "streaming" | "complete";
|
||||
}
|
||||
|
||||
interface AssistantFooterSource {
|
||||
item: Extract<StreamItem, { kind: "assistant_message" }>;
|
||||
interface TurnFooterSource {
|
||||
item: StreamItem;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
}
|
||||
@@ -67,23 +67,26 @@ function createTurnFooterHost(input: {
|
||||
item: StreamItem;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
timingByItemId: Map<string, TurnTiming>;
|
||||
}): 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<string, TurnTiming>;
|
||||
timingByItemId: Map<string, TurnTiming>;
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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<string>;
|
||||
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({
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,41 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
|
||||
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")], {
|
||||
|
||||
@@ -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<string>;
|
||||
/** 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<string, CollapsedTurn> = 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<StreamItem>((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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -197,7 +197,7 @@ function CompletedTurnFooter({
|
||||
return (
|
||||
<View style={stylesheet.turnFooterSlot}>
|
||||
<AssistantTurnFooter
|
||||
getContent={getContent}
|
||||
getContent={items[startIndex]?.kind === "assistant_message" ? getContent : undefined}
|
||||
completedAt={timing?.completedAt}
|
||||
durationMs={timing?.durationMs}
|
||||
onFork={boundary && onForkAssistantTurn ? handleFork : undefined}
|
||||
|
||||
@@ -53,7 +53,7 @@ function layoutFor(items: StreamItem[], isTurnActive: boolean) {
|
||||
isTurnActive,
|
||||
history: model.segments.historyMounted,
|
||||
liveHead: model.segments.liveHead,
|
||||
timingByAssistantId: model.turnTiming.byAssistantId,
|
||||
timingByItemId: model.turnTiming.byItemId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -67,6 +67,24 @@ function completedFooterIds(layout: ReturnType<typeof layoutFor>["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,
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -524,8 +524,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
|
||||
transform: transformTimelineItem,
|
||||
display: chatDisplay,
|
||||
autoExpandReasoning,
|
||||
expandedTurnHostIds,
|
||||
isTurnActive,
|
||||
activeTurnId: effectiveTurnPresentation.turnId,
|
||||
}),
|
||||
[
|
||||
presentStream,
|
||||
@@ -533,8 +535,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
effectiveStreamHead,
|
||||
transformTimelineItem,
|
||||
chatDisplay,
|
||||
autoExpandReasoning,
|
||||
expandedTurnHostIds,
|
||||
isTurnActive,
|
||||
effectiveTurnPresentation.turnId,
|
||||
],
|
||||
);
|
||||
const {
|
||||
@@ -576,12 +580,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
isTurnActive,
|
||||
history: baseRenderModel.history,
|
||||
liveHead: baseRenderModel.segments.liveHead,
|
||||
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
|
||||
timingByItemId: baseRenderModel.turnTiming.byItemId,
|
||||
}),
|
||||
[
|
||||
baseRenderModel.history,
|
||||
baseRenderModel.segments.liveHead,
|
||||
baseRenderModel.turnTiming.byAssistantId,
|
||||
baseRenderModel.turnTiming.byItemId,
|
||||
isTurnActive,
|
||||
streamRenderStrategy,
|
||||
],
|
||||
@@ -722,6 +726,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
agentId={agentId}
|
||||
messageId={item.messageId}
|
||||
message={item.text}
|
||||
sender={item.sender}
|
||||
images={item.images}
|
||||
attachments={item.attachments}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
@@ -787,6 +792,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[agentId, client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
|
||||
);
|
||||
|
||||
const isExpandedTurnItem = useStableEvent((id: string) =>
|
||||
presentation.turns.nestedItemIds.has(id),
|
||||
);
|
||||
const renderThoughtItem = useCallback(
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "thought" }>) => {
|
||||
return (
|
||||
@@ -796,11 +804,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
text={item.text}
|
||||
status={item.status}
|
||||
isLastInSequence={layoutItem.isLastInToolSequence}
|
||||
defaultExpanded={autoExpandReasoning}
|
||||
defaultExpanded={autoExpandReasoning || isExpandedTurnItem(item.id)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[autoExpandReasoning, setInlineDetailsExpanded],
|
||||
[autoExpandReasoning, setInlineDetailsExpanded, isExpandedTurnItem],
|
||||
);
|
||||
|
||||
const renderSingleToolCallItem = useCallback(
|
||||
@@ -830,6 +838,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
itemId={item.id}
|
||||
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
|
||||
toolName={data.name}
|
||||
defaultExpanded={isExpandedTurnItem(item.id)}
|
||||
forceInline={isExpandedTurnItem(item.id)}
|
||||
error={data.error}
|
||||
status={data.status}
|
||||
detail={data.detail}
|
||||
@@ -859,7 +869,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
/>
|
||||
);
|
||||
},
|
||||
[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<AgentStreamViewHandle, AgentStreamVi
|
||||
presentation.groupsByHostId.get(hostId),
|
||||
);
|
||||
const renderToolCallItem = useCallback(
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
|
||||
(
|
||||
layoutItem: StreamLayoutItem,
|
||||
item: Extract<StreamItem, { kind: "tool_call" | "thought" }>,
|
||||
) => {
|
||||
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<AgentStreamViewHandle, AgentStreamVi
|
||||
{expanded
|
||||
? group.run.calls.map((call, index) => (
|
||||
<React.Fragment key={call.id}>
|
||||
{renderSingleToolCallItem(
|
||||
call,
|
||||
index === group.run.calls.length - 1,
|
||||
GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
|
||||
{call.kind === "thought" ? (
|
||||
<ThoughtSlot
|
||||
itemId={call.id}
|
||||
text={call.text}
|
||||
status={call.status}
|
||||
isLastInSequence={index === group.run.calls.length - 1}
|
||||
defaultExpanded
|
||||
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
|
||||
/>
|
||||
) : (
|
||||
renderSingleToolCallItem(
|
||||
call,
|
||||
index === group.run.calls.length - 1,
|
||||
GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
|
||||
)
|
||||
)}
|
||||
</React.Fragment>
|
||||
))
|
||||
@@ -897,6 +929,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
},
|
||||
[
|
||||
expandedToolCallGroupIds,
|
||||
renderThoughtItem,
|
||||
setInlineDetailsExpanded,
|
||||
getToolCallGroup,
|
||||
renderSingleToolCallItem,
|
||||
setToolCallGroupExpanded,
|
||||
@@ -914,10 +948,20 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return renderAssistantMessageItem(layoutItem, item);
|
||||
|
||||
case "thought":
|
||||
return renderThoughtItem(layoutItem, item);
|
||||
return renderToolCallItem(layoutItem, item);
|
||||
|
||||
case "tool_call":
|
||||
return renderToolCallItem(layoutItem, item);
|
||||
return (
|
||||
<>
|
||||
{renderToolCallItem(layoutItem, item)}
|
||||
{getCollapsedTurn(item.id) ? (
|
||||
<TurnSummaryLine
|
||||
turn={getCollapsedTurn(item.id)!}
|
||||
onExpandedChange={setTurnExpanded}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
case "notification":
|
||||
return <Notification level={item.level} message={item.message} />;
|
||||
@@ -947,8 +991,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
agentId,
|
||||
renderUserMessageItem,
|
||||
renderAssistantMessageItem,
|
||||
renderThoughtItem,
|
||||
renderToolCallItem,
|
||||
getCollapsedTurn,
|
||||
setTurnExpanded,
|
||||
resolvedServerId,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -70,6 +70,17 @@ function indexEntries(items: StreamItem[]): IndexedStreamItem[] {
|
||||
}
|
||||
|
||||
describe("findMountedWindowStart", () => {
|
||||
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)];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 ? <MessageSender sender={sender} serverId={serverId} /> : null}
|
||||
<View style={userMessageStylesheet.bubble}>
|
||||
{hasImages ? (
|
||||
<View style={imagePreviewContainerStyle}>
|
||||
{images.map((image) => (
|
||||
<UserMessageImagePill
|
||||
key={image.id}
|
||||
image={image}
|
||||
onOpen={setLightboxMetadata}
|
||||
accessibilityLabel={t("composer.attachments.openImage")}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{hasAttachments ? (
|
||||
<View style={attachmentPreviewContainerStyle}>
|
||||
{attachments.map((attachment, index) => {
|
||||
const content = getAgentAttachmentPillContent(attachment, t);
|
||||
return (
|
||||
<AttachmentFrame
|
||||
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
|
||||
>
|
||||
<AttachmentLabel
|
||||
icon={content.icon}
|
||||
title={content.title}
|
||||
subtitle={content.subtitle}
|
||||
/>
|
||||
</AttachmentFrame>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
{hasText ? (
|
||||
<Text selectable style={userMessageStylesheet.text} dataSet={MESSAGE_TEXT_DATASET}>
|
||||
{message}
|
||||
</Text>
|
||||
) : null}
|
||||
<MessagePreview>
|
||||
{hasImages ? (
|
||||
<View style={imagePreviewContainerStyle}>
|
||||
{images.map((image) => (
|
||||
<UserMessageImagePill
|
||||
key={image.id}
|
||||
image={image}
|
||||
onOpen={setLightboxMetadata}
|
||||
accessibilityLabel={t("composer.attachments.openImage")}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{hasAttachments ? (
|
||||
<View style={attachmentPreviewContainerStyle}>
|
||||
{attachments.map((attachment, index) => {
|
||||
const content = getAgentAttachmentPillContent(attachment, t);
|
||||
return (
|
||||
<AttachmentFrame
|
||||
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
|
||||
>
|
||||
<AttachmentLabel
|
||||
icon={content.icon}
|
||||
title={content.title}
|
||||
subtitle={content.subtitle}
|
||||
/>
|
||||
</AttachmentFrame>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
{hasText ? (
|
||||
<Text selectable style={userMessageStylesheet.text} dataSet={MESSAGE_TEXT_DATASET}>
|
||||
{message}
|
||||
</Text>
|
||||
) : null}
|
||||
</MessagePreview>
|
||||
</View>
|
||||
{hasText ? (
|
||||
<View
|
||||
@@ -541,7 +547,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
});
|
||||
|
||||
interface AssistantTurnFooterProps {
|
||||
getContent: () => string;
|
||||
getContent?: () => string;
|
||||
completedAt?: Date;
|
||||
durationMs?: number | null;
|
||||
onFork?: (target: AssistantForkTarget) => Promise<void> | void;
|
||||
@@ -641,10 +647,12 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
|
||||
return (
|
||||
<View style={assistantTurnFooterStylesheet.container}>
|
||||
<TurnCopyButton
|
||||
getContent={getContent}
|
||||
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
||||
/>
|
||||
{getContent ? (
|
||||
<TurnCopyButton
|
||||
getContent={getContent}
|
||||
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
||||
/>
|
||||
) : null}
|
||||
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
|
||||
{primaryLabel ? (
|
||||
<Pressable
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
@@ -42,8 +43,7 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
|
||||
: undefined;
|
||||
if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
await getHostRuntimeStore().fetchAgentTimeline(input.serverId, input.agentId, {
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
...planTimelineTailFetch(),
|
||||
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "reac
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
type EdgeFadeBackdrop = "surface0" | "surfaceWorkspace";
|
||||
type EdgeFadeBackdrop = "surface0" | "surfaceWorkspace" | "surface3";
|
||||
|
||||
const backdropColorMappings: Record<EdgeFadeBackdrop, (theme: Theme) => { color: string }> = {
|
||||
surface3: (theme) => ({ color: theme.colors.surface3 }),
|
||||
surface0: (theme) => ({ color: theme.colors.surface0 }),
|
||||
surfaceWorkspace: (theme) => ({ color: theme.colors.surfaceWorkspace }),
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<View style={!expanded && styles.preview}>
|
||||
<View style={styles.body} onLayout={measure}>
|
||||
{children}
|
||||
</View>
|
||||
{overflowing && !expanded ? (
|
||||
<EdgeFade edge="bottom" solidFraction={0} backdrop="surface3" style={styles.fade} />
|
||||
) : null}
|
||||
</View>
|
||||
{overflowing ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
style={styles.toggle}
|
||||
accessibilityState={accessibilityState}
|
||||
onPress={toggle}
|
||||
>
|
||||
{t(expanded ? "turnSummary.showLess" : "turnSummary.showMore")}
|
||||
</Button>
|
||||
) : 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 (
|
||||
<Button variant="ghost" size="sm" leftIcon={ProviderIcon} disabled={!host} onPress={openSender}>
|
||||
{t("turnSummary.sentBy", { name: agent?.title ?? sender.title ?? sender.id })}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
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] },
|
||||
}));
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -152,6 +152,7 @@ describe("loadOlderAgentHistory", () => {
|
||||
cursor: { epoch: "epoch-1", seq: 10 },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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}} ملفات",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}}個のファイルを編集",
|
||||
|
||||
@@ -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}} 파일을 편집했습니다.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}})",
|
||||
|
||||
@@ -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}} 个文件",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } }
|
||||
: {}),
|
||||
|
||||
@@ -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 }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<string, TurnTiming>;
|
||||
byItemId: Map<string, TurnTiming>;
|
||||
runningStartedAt: Date | null;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ interface OpenTurn {
|
||||
|
||||
interface HeadTurnTiming {
|
||||
additions: Map<string, TurnTiming>;
|
||||
byAssistantId: Map<string, TurnTiming>;
|
||||
byItemId: Map<string, TurnTiming>;
|
||||
}
|
||||
|
||||
interface TailTurnTiming {
|
||||
@@ -37,14 +37,18 @@ const EMPTY_OPEN_TURN: OpenTurn = {
|
||||
};
|
||||
|
||||
function closeTurn(turn: OpenTurn, into: Map<string, TurnTiming>): 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<string, TurnTiming>,
|
||||
): Map<string, TurnTiming> {
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<TGroup> {
|
||||
tail: StreamItem[];
|
||||
groupsByHostId: Map<string, TGroup>;
|
||||
pendingCalls: readonly ToolCallItem[];
|
||||
pendingCalls: readonly GroupableItem[];
|
||||
}
|
||||
|
||||
export interface GroupedToolCalls<TGroup> {
|
||||
@@ -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<TGroup>(input: {
|
||||
calls: readonly ToolCallItem[];
|
||||
calls: readonly GroupableItem[];
|
||||
isSealed: boolean;
|
||||
output: StreamItem[];
|
||||
groups: Map<string, TGroup>;
|
||||
@@ -110,13 +118,17 @@ function appendRun<TGroup>(input: {
|
||||
export function prepareGroupedHistory<TGroup>(input: {
|
||||
tail: StreamItem[];
|
||||
buildGroup: (run: ToolCallRun) => TGroup;
|
||||
autoExpandReasoning?: boolean;
|
||||
}): GroupedHistory<TGroup> {
|
||||
const output: StreamItem[] = [];
|
||||
const groups = new Map<string, TGroup>();
|
||||
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<TGroup>(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<TGroup>(input: {
|
||||
head: StreamItem[];
|
||||
isTurnActive: boolean;
|
||||
buildGroup: (run: ToolCallRun) => TGroup;
|
||||
autoExpandReasoning?: boolean;
|
||||
}): GroupedToolCalls<TGroup> {
|
||||
const head: StreamItem[] = [];
|
||||
const liveGroups = new Map<string, TGroup>();
|
||||
@@ -175,7 +189,8 @@ export function groupLiveToolCalls<TGroup>(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";
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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<string, number>;
|
||||
}
|
||||
|
||||
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<string>();
|
||||
const readFiles = new Set<string>();
|
||||
let thoughtCount = 0;
|
||||
let commandCount = 0;
|
||||
let searchCount = 0;
|
||||
let otherToolCount = 0;
|
||||
let paseoCallCount = 0;
|
||||
const paseoActions: Record<string, number> = {};
|
||||
|
||||
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";
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ export type ToolCallDetailGroup = OverviewToolCallGroup;
|
||||
|
||||
export interface PreparedToolCallHistory {
|
||||
mode: "overview";
|
||||
autoExpandReasoning: boolean;
|
||||
grouped: GroupedHistory<ToolCallDetailGroup>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user