mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +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:
+24
-10
@@ -13,7 +13,7 @@ and rebuilds the projection when an agent resumes.
|
||||
The invariants are:
|
||||
|
||||
> A continuously subscribed client applies every committed row in order. Opening or resuming an
|
||||
> agent establishes the daemon's current tail in one bounded request, with older history reachable
|
||||
> agent establishes the daemon's current tail in one complete-turn request, with older history reachable
|
||||
> through backward pagination.
|
||||
|
||||
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
|
||||
@@ -44,7 +44,20 @@ recovery is complete only when `hasNewer: false`.
|
||||
|
||||
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
|
||||
|
||||
Opening or resuming an agent fetches one bounded latest tail page. Older history remains
|
||||
Opening or resuming an agent fetches the latest available turns with a soft target of 40
|
||||
projected items. Backward pages and prompt-jump windows also include whole turns, including
|
||||
all currently available rows of an active run. There is no per-turn cap: a large run can
|
||||
exceed the target. Local history mounting uses the same boundaries so revealing older rows
|
||||
does not change the collapse state of a turn already displayed.
|
||||
|
||||
`wholeTurns` is optional for existing SDK callers. The app requires
|
||||
`features.wholeTurnTimeline` once at the client fetch boundary; unsupported hosts receive an
|
||||
update-host error. Forward catch-up stays incremental, but its reset snapshot honors the
|
||||
whole-turn request. Source coverage still includes interleaved tool updates.
|
||||
|
||||
Canonical IDs define runs. With missing IDs, human prompts, heartbeats and typed received
|
||||
agent messages each start a fallback turn. This deterministic fallback is shared by paging
|
||||
and presentation; it cannot recover historical batching or steering. Older history remains
|
||||
user-driven by scrolling upward.
|
||||
|
||||
A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling.
|
||||
@@ -149,12 +162,13 @@ range describes those exact items: `startSeq` drives older pagination and `endSe
|
||||
catch-up. The owner requests `after endSeq`, and requests `before startSeq` when the user loads older
|
||||
history. Code outside the owner does not distinguish cached and network timelines.
|
||||
|
||||
The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail
|
||||
The first resume request is bounded. If it reports more newer history, fetch one latest complete-turn tail
|
||||
instead of replaying every missed page. Live gap recovery still pages forward until current.
|
||||
|
||||
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
|
||||
If the canonical window exceeds the cache item target, contains a discontiguous retained range, has a
|
||||
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
|
||||
range and keeps a display-only tail. Never slice items while retaining the pre-slice range; that
|
||||
range and keeps a display-only tail of complete available turns. The item target is soft;
|
||||
the byte budget still evicts entire cache rows. Never slice items while retaining the pre-slice range; that
|
||||
falsely certifies discarded source rows. A display-only row paints without granting synchronization
|
||||
authority, so the owner uses the ordinary bounded `tail` bootstrap.
|
||||
|
||||
@@ -238,11 +252,11 @@ foreground control ownership remains a separate daemon concern. Cancellation req
|
||||
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions
|
||||
remain a separate pre-turn registry and retire on canonical acknowledgement.
|
||||
|
||||
Canonical turns and visible responses are different boundaries. Agent-originated prompts and lifecycle
|
||||
notifications project into tool rows, so they do not create a human-message boundary. Legacy system
|
||||
prompts, including schedule injections, remain hidden. One visible response can span several canonical
|
||||
turns; layout and copy group that response together while lifecycle, timing, tool sequences, and exact
|
||||
fork positions retain the canonical `turnId` boundaries.
|
||||
Every canonical run owns a visible turn, including heartbeats and agent notifications.
|
||||
Notifications delivered during a run stay inside it; batched notifications share the run
|
||||
that starts or accepts them. Received messages retain sender attribution, while lifecycle
|
||||
notifications remain visible events. Collapse, timing and locally mounted history share
|
||||
these boundaries.
|
||||
|
||||
Agent-message XML belongs to the daemon's `agent/agent-messages` boundary. Prompt delivery encodes
|
||||
provenance; acceptance, provider echoes, import, and replay share its projection. The delivery ID is
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1658,6 +1658,11 @@ test("honors explicit fetchAgentTimeline timeout below the session RPC default",
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
await expect(client.fetchAgentTimeline("agent-1", { wholeTurns: true })).rejects.toThrow(
|
||||
"Update the host to load complete conversation turns.",
|
||||
);
|
||||
expect(mock.sent).toHaveLength(0);
|
||||
|
||||
const responsePromise = client.fetchAgentTimeline("agent-1", {
|
||||
requestId: "req-timeline-1",
|
||||
direction: "tail",
|
||||
|
||||
@@ -686,6 +686,7 @@ export interface FetchAgentOptions {
|
||||
}
|
||||
type LegacyFetchAgentOptions = Omit<FetchAgentOptions, "agentId">;
|
||||
export interface FetchAgentTimelineOptions {
|
||||
wholeTurns?: boolean;
|
||||
direction?: FetchAgentTimelineDirection;
|
||||
cursor?: FetchAgentTimelineCursor;
|
||||
limit?: number;
|
||||
@@ -3548,9 +3549,13 @@ export class DaemonClient {
|
||||
agentId: string,
|
||||
options: FetchAgentTimelineOptions = {},
|
||||
): Promise<FetchAgentTimelinePayload> {
|
||||
if (options.wholeTurns && this.lastServerInfoMessage?.features?.wholeTurnTimeline !== true) {
|
||||
throw new Error("Update the host to load complete conversation turns.");
|
||||
}
|
||||
const resolvedRequestId = this.createRequestId(options.requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "fetch_agent_timeline_request",
|
||||
...(options.wholeTurns !== undefined ? { wholeTurns: options.wholeTurns } : {}),
|
||||
agentId,
|
||||
requestId: resolvedRequestId,
|
||||
...(options.direction ? { direction: options.direction } : {}),
|
||||
|
||||
@@ -1910,6 +1910,8 @@ export const AgentTimelineCursorSchema = z.object({
|
||||
});
|
||||
|
||||
export const FetchAgentTimelineRequestMessageSchema = z.object({
|
||||
// Keep available turns whole for tail/before pages; after catch-up stays incremental.
|
||||
wholeTurns: z.boolean().optional(),
|
||||
type: z.literal("fetch_agent_timeline_request"),
|
||||
agentId: z.string(),
|
||||
requestId: z.string(),
|
||||
@@ -3765,6 +3767,7 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
providerSubagents: z.boolean().optional(),
|
||||
// COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain wire field.
|
||||
projectedSubagentTimeline: z.boolean().optional(),
|
||||
wholeTurnTimeline: z.boolean().optional(),
|
||||
// COMPAT(providerSubagentNesting): added in v0.7, remove gate after 2027-03-04.
|
||||
providerSubagentNesting: z.boolean().optional(),
|
||||
// COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12.
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface PaseoToolDetailSpec {
|
||||
|
||||
export interface PaseoToolEntry {
|
||||
label: string;
|
||||
completedLabel: string;
|
||||
context?: PaseoToolContext;
|
||||
detail?: PaseoToolDetailSpec;
|
||||
}
|
||||
@@ -123,14 +124,24 @@ function baseName(value: unknown): string | undefined {
|
||||
return value.split(/[\\/]/).findLast((segment) => segment.length > 0);
|
||||
}
|
||||
|
||||
function browser(label: string, context?: PaseoToolContext): PaseoToolEntry {
|
||||
return { label, ...(context ? { context } : {}), detail: { inputOrder: BROWSER_FIELDS } };
|
||||
function browser(
|
||||
label: string,
|
||||
completedLabel: string,
|
||||
context?: PaseoToolContext,
|
||||
): PaseoToolEntry {
|
||||
return {
|
||||
label,
|
||||
completedLabel,
|
||||
...(context ? { context } : {}),
|
||||
detail: { inputOrder: BROWSER_FIELDS },
|
||||
};
|
||||
}
|
||||
|
||||
export const PASEO_TOOL_CATALOG = {
|
||||
// Agents
|
||||
create_agent: {
|
||||
label: "Create agent",
|
||||
completedLabel: "Created agent",
|
||||
context: field("title", "initialPrompt"),
|
||||
detail: {
|
||||
promptField: "initialPrompt",
|
||||
@@ -140,6 +151,7 @@ export const PASEO_TOOL_CATALOG = {
|
||||
},
|
||||
send_agent_prompt: {
|
||||
label: "Send message",
|
||||
completedLabel: "Sent message",
|
||||
context: field("prompt"),
|
||||
detail: {
|
||||
promptField: "prompt",
|
||||
@@ -149,42 +161,55 @@ export const PASEO_TOOL_CATALOG = {
|
||||
},
|
||||
get_agent_status: {
|
||||
label: "Check agent",
|
||||
completedLabel: "Checked agent",
|
||||
context: targetAgent(),
|
||||
detail: { inputOrder: ["agentId"] },
|
||||
},
|
||||
get_agent_activity: {
|
||||
label: "Read agent activity",
|
||||
completedLabel: "Read agent activity",
|
||||
context: targetAgent(),
|
||||
detail: { inputOrder: ["agentId", "limit"] },
|
||||
},
|
||||
list_agents: {
|
||||
label: "List agents",
|
||||
completedLabel: "Listed agents",
|
||||
detail: { inputOrder: ["cwd", "statuses", "sinceHours", "limit", "includeArchived"] },
|
||||
},
|
||||
update_agent: {
|
||||
label: "Update agent",
|
||||
completedLabel: "Updated agent",
|
||||
context: fieldOrTargetAgent("name"),
|
||||
detail: { inputOrder: AGENT_FIELDS },
|
||||
},
|
||||
set_agent_mode: {
|
||||
label: "Set agent mode",
|
||||
completedLabel: "Set agent mode",
|
||||
context: field("modeId"),
|
||||
detail: { inputOrder: ["agentId", "modeId"] },
|
||||
},
|
||||
cancel_agent: {
|
||||
label: "Cancel agent",
|
||||
completedLabel: "Canceled agent",
|
||||
context: targetAgent(),
|
||||
detail: { inputOrder: ["agentId"] },
|
||||
},
|
||||
archive_agent: {
|
||||
label: "Archive agent",
|
||||
completedLabel: "Archived agent",
|
||||
context: targetAgent(),
|
||||
detail: { inputOrder: ["agentId"] },
|
||||
},
|
||||
kill_agent: { label: "Kill agent", context: targetAgent(), detail: { inputOrder: ["agentId"] } },
|
||||
list_pending_permissions: { label: "List permissions" },
|
||||
kill_agent: {
|
||||
label: "Kill agent",
|
||||
completedLabel: "Killed agent",
|
||||
context: targetAgent(),
|
||||
detail: { inputOrder: ["agentId"] },
|
||||
},
|
||||
list_pending_permissions: { label: "List permissions", completedLabel: "Listed permissions" },
|
||||
respond_to_permission: {
|
||||
label: "Respond to permission",
|
||||
completedLabel: "Responded to permission",
|
||||
context: targetAgent(),
|
||||
detail: { inputOrder: ["agentId", "requestId", "response"] },
|
||||
},
|
||||
@@ -192,32 +217,41 @@ export const PASEO_TOOL_CATALOG = {
|
||||
// Workspaces and scripts
|
||||
create_workspace: {
|
||||
label: "Create workspace",
|
||||
completedLabel: "Created workspace",
|
||||
context: workspaceContext(),
|
||||
detail: { inputOrder: WORKSPACE_FIELDS, outputFields: ["workspaceId", "projectId"] },
|
||||
},
|
||||
list_hosts: { label: "List hosts" },
|
||||
list_workspaces: { label: "List workspaces" },
|
||||
list_hosts: { label: "List hosts", completedLabel: "Listed hosts" },
|
||||
list_workspaces: { label: "List workspaces", completedLabel: "Listed workspaces" },
|
||||
rename_workspace: {
|
||||
label: "Rename workspace",
|
||||
completedLabel: "Renamed workspace",
|
||||
context: field("title"),
|
||||
detail: { inputOrder: ["title", "workspaceId"] },
|
||||
},
|
||||
archive_workspace: {
|
||||
label: "Archive workspace",
|
||||
completedLabel: "Archived workspace",
|
||||
context: field("workspaceId"),
|
||||
detail: {
|
||||
inputOrder: ["workspaceId"],
|
||||
outputFields: ["workspaceId", "archivedAgentIds", "removedDirectory"],
|
||||
},
|
||||
},
|
||||
list_workspace_scripts: { label: "List scripts", detail: { inputOrder: ["workspaceId"] } },
|
||||
list_workspace_scripts: {
|
||||
label: "List scripts",
|
||||
completedLabel: "Listed scripts",
|
||||
detail: { inputOrder: ["workspaceId"] },
|
||||
},
|
||||
start_workspace_script: {
|
||||
label: "Start script",
|
||||
completedLabel: "Started script",
|
||||
context: field("scriptName"),
|
||||
detail: { inputOrder: ["workspaceId", "scriptName"] },
|
||||
},
|
||||
stop_workspace_script: {
|
||||
label: "Stop script",
|
||||
completedLabel: "Stopped script",
|
||||
context: field("scriptName"),
|
||||
detail: { inputOrder: ["workspaceId", "scriptName"] },
|
||||
},
|
||||
@@ -225,22 +259,30 @@ export const PASEO_TOOL_CATALOG = {
|
||||
// Terminals
|
||||
create_terminal: {
|
||||
label: "Create terminal",
|
||||
completedLabel: "Created terminal",
|
||||
context: field("name", "command"),
|
||||
detail: { inputOrder: ["cwd", "workspaceId", "name", "command"] },
|
||||
},
|
||||
list_terminals: { label: "List terminals", detail: { inputOrder: ["cwd", "all"] } },
|
||||
list_terminals: {
|
||||
label: "List terminals",
|
||||
completedLabel: "Listed terminals",
|
||||
detail: { inputOrder: ["cwd", "all"] },
|
||||
},
|
||||
capture_terminal: {
|
||||
label: "Read terminal",
|
||||
completedLabel: "Read terminal",
|
||||
context: field("terminalId"),
|
||||
detail: { inputOrder: ["terminalId", "lines"] },
|
||||
},
|
||||
send_terminal_keys: {
|
||||
label: "Send keys",
|
||||
completedLabel: "Sent keys",
|
||||
context: field("keys"),
|
||||
detail: { inputOrder: ["terminalId", "keys", "literal"] },
|
||||
},
|
||||
kill_terminal: {
|
||||
label: "Kill terminal",
|
||||
completedLabel: "Killed terminal",
|
||||
context: field("terminalId"),
|
||||
detail: { inputOrder: ["terminalId"] },
|
||||
},
|
||||
@@ -248,6 +290,7 @@ export const PASEO_TOOL_CATALOG = {
|
||||
// Schedules and heartbeats
|
||||
create_schedule: {
|
||||
label: "Create schedule",
|
||||
completedLabel: "Created schedule",
|
||||
context: field("name", "cron"),
|
||||
detail: {
|
||||
promptField: "prompt",
|
||||
@@ -257,6 +300,7 @@ export const PASEO_TOOL_CATALOG = {
|
||||
},
|
||||
create_heartbeat: {
|
||||
label: "Create heartbeat",
|
||||
completedLabel: "Created heartbeat",
|
||||
context: field("name", "cron"),
|
||||
detail: {
|
||||
promptField: "prompt",
|
||||
@@ -266,79 +310,93 @@ export const PASEO_TOOL_CATALOG = {
|
||||
},
|
||||
update_schedule: {
|
||||
label: "Update schedule",
|
||||
completedLabel: "Updated schedule",
|
||||
context: field("name", "id"),
|
||||
detail: { promptField: "prompt", inputOrder: AUTOMATION_FIELDS },
|
||||
},
|
||||
list_schedules: { label: "List schedules" },
|
||||
list_schedules: { label: "List schedules", completedLabel: "Listed schedules" },
|
||||
inspect_schedule: {
|
||||
label: "Inspect schedule",
|
||||
completedLabel: "Inspected schedule",
|
||||
context: field("id"),
|
||||
detail: { inputOrder: ["id"] },
|
||||
},
|
||||
schedule_logs: {
|
||||
label: "Read schedule logs",
|
||||
completedLabel: "Read schedule logs",
|
||||
context: field("id"),
|
||||
detail: { inputOrder: ["id"] },
|
||||
},
|
||||
run_schedule_once: {
|
||||
label: "Run schedule",
|
||||
completedLabel: "Ran schedule",
|
||||
context: field("id"),
|
||||
detail: { inputOrder: ["id"] },
|
||||
},
|
||||
pause_schedule: {
|
||||
label: "Pause schedule",
|
||||
completedLabel: "Paused schedule",
|
||||
context: field("id"),
|
||||
detail: { inputOrder: ["id"] },
|
||||
},
|
||||
pause_schedule: { label: "Pause schedule", context: field("id"), detail: { inputOrder: ["id"] } },
|
||||
resume_schedule: {
|
||||
label: "Resume schedule",
|
||||
completedLabel: "Resumed schedule",
|
||||
context: field("id"),
|
||||
detail: { inputOrder: ["id"] },
|
||||
},
|
||||
delete_schedule: {
|
||||
label: "Delete schedule",
|
||||
completedLabel: "Deleted schedule",
|
||||
context: field("id"),
|
||||
detail: { inputOrder: ["id"] },
|
||||
},
|
||||
delete_heartbeat: {
|
||||
label: "Delete heartbeat",
|
||||
completedLabel: "Deleted heartbeat",
|
||||
context: field("id"),
|
||||
detail: { inputOrder: ["id"] },
|
||||
},
|
||||
|
||||
// Providers
|
||||
list_providers: { label: "List providers" },
|
||||
list_providers: { label: "List providers", completedLabel: "Listed providers" },
|
||||
list_models: {
|
||||
label: "List models",
|
||||
completedLabel: "Listed models",
|
||||
context: field("provider"),
|
||||
detail: { inputOrder: ["provider"] },
|
||||
},
|
||||
list_profiles: { label: "List profiles" },
|
||||
list_profiles: { label: "List profiles", completedLabel: "Listed profiles" },
|
||||
inspect_provider: {
|
||||
label: "Inspect provider",
|
||||
completedLabel: "Inspected provider",
|
||||
context: field("provider"),
|
||||
detail: { inputOrder: ["provider", "cwd", "settings"] },
|
||||
},
|
||||
|
||||
// Browser
|
||||
browser_list_tabs: browser("List browser tabs"),
|
||||
browser_new_tab: browser("Open browser tab", field("url")),
|
||||
browser_close_tab: browser("Close browser tab", field("browserId")),
|
||||
browser_navigate: browser("Navigate", field("url")),
|
||||
browser_back: browser("Go back"),
|
||||
browser_forward: browser("Go forward"),
|
||||
browser_reload: browser("Reload page"),
|
||||
browser_snapshot: browser("Snapshot page"),
|
||||
browser_screenshot: browser("Take screenshot"),
|
||||
browser_click: browser("Click element", field("ref")),
|
||||
browser_hover: browser("Hover element", field("ref")),
|
||||
browser_drag: browser("Drag element", field("sourceRef")),
|
||||
browser_fill: browser("Fill field", field("ref")),
|
||||
browser_select: browser("Select option", field("value", "ref")),
|
||||
browser_type: browser("Type text", field("text")),
|
||||
browser_keypress: browser("Press key", field("key")),
|
||||
browser_upload: browser("Upload files", field("filePaths")),
|
||||
browser_wait: browser("Wait for page", field("text", "url")),
|
||||
browser_scroll: browser("Scroll page"),
|
||||
browser_resize: browser("Resize browser", viewportContext()),
|
||||
browser_logs: browser("Read browser logs"),
|
||||
browser_evaluate: browser("Run JavaScript", field("function")),
|
||||
browser_list_tabs: browser("List browser tabs", "Listed browser tabs"),
|
||||
browser_new_tab: browser("Open browser tab", "Opened browser tab", field("url")),
|
||||
browser_close_tab: browser("Close browser tab", "Closed browser tab", field("browserId")),
|
||||
browser_navigate: browser("Navigate", "Navigated", field("url")),
|
||||
browser_back: browser("Go back", "Went back"),
|
||||
browser_forward: browser("Go forward", "Went forward"),
|
||||
browser_reload: browser("Reload page", "Reloaded page"),
|
||||
browser_snapshot: browser("Snapshot page", "Snapshotted page"),
|
||||
browser_screenshot: browser("Take screenshot", "Took screenshot"),
|
||||
browser_click: browser("Click element", "Clicked element", field("ref")),
|
||||
browser_hover: browser("Hover element", "Hovered element", field("ref")),
|
||||
browser_drag: browser("Drag element", "Dragged element", field("sourceRef")),
|
||||
browser_fill: browser("Fill field", "Filled field", field("ref")),
|
||||
browser_select: browser("Select option", "Selected option", field("value", "ref")),
|
||||
browser_type: browser("Type text", "Typed text", field("text")),
|
||||
browser_keypress: browser("Press key", "Pressed key", field("key")),
|
||||
browser_upload: browser("Upload files", "Uploaded files", field("filePaths")),
|
||||
browser_wait: browser("Wait for page", "Waited for page", field("text", "url")),
|
||||
browser_scroll: browser("Scroll page", "Scrolled page"),
|
||||
browser_resize: browser("Resize browser", "Resized browser", viewportContext()),
|
||||
browser_logs: browser("Read browser logs", "Read browser logs"),
|
||||
browser_evaluate: browser("Run JavaScript", "Ran JavaScript", field("function")),
|
||||
} satisfies Record<string, PaseoToolEntry>;
|
||||
|
||||
export type PaseoToolName = keyof typeof PASEO_TOOL_CATALOG;
|
||||
|
||||
@@ -28,6 +28,8 @@ export const PASEO_TOOL_NAMES = Object.keys(PASEO_TOOL_CATALOG) as readonly Pase
|
||||
|
||||
/** Returns null for tools outside the Paseo namespace. */
|
||||
export function buildPaseoToolDisplay(input: PaseoToolDisplayInput): PaseoToolDisplay | null {
|
||||
// Canonical details own internal lifecycle rows such as worktree setup.
|
||||
if (input.detail.type !== "unknown") return null;
|
||||
const leafName = paseoLeafName(input.toolName);
|
||||
if (!leafName) return null;
|
||||
const entry = getPaseoToolEntry(leafName);
|
||||
@@ -39,6 +41,13 @@ export function buildPaseoToolDisplay(input: PaseoToolDisplayInput): PaseoToolDi
|
||||
return { displayName: entry.label, ...(summary ? { summary } : {}) };
|
||||
}
|
||||
|
||||
/** A semantic action label for summaries, sharing the tool presentation catalog. */
|
||||
export function getPaseoToolActionLabel(toolName: string): string | null {
|
||||
const leafName = paseoLeafName(toolName);
|
||||
if (!leafName) return null;
|
||||
return getPaseoToolEntry(leafName)?.completedLabel ?? humanizeLeafName(leafName);
|
||||
}
|
||||
|
||||
/** The agent a Paseo call acts on, so callers can resolve its title. */
|
||||
export function getPaseoToolTargetAgentId(
|
||||
toolName: string,
|
||||
@@ -49,7 +58,11 @@ export function getPaseoToolTargetAgentId(
|
||||
}
|
||||
|
||||
function paseoLeafName(toolName: string): string | null {
|
||||
return isPaseoToolName(toolName) ? getPaseoToolLeafName(toolName) : null;
|
||||
if (isPaseoToolName(toolName)) return getPaseoToolLeafName(toolName);
|
||||
const normalized = toolName.trim().toLowerCase();
|
||||
return normalized.startsWith("paseo_") && !normalized.startsWith("paseo__")
|
||||
? normalized.slice("paseo_".length)
|
||||
: null;
|
||||
}
|
||||
|
||||
function readToolInput(detail: ToolCallDetail): Readonly<Record<string, unknown>> {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Canonical run identity wins over trigger-shaped rows steered into that run. */
|
||||
export function continuesTimelineTurn(
|
||||
previousTurnId: string | undefined,
|
||||
nextTurnId: string | undefined,
|
||||
nextIsTrigger: boolean,
|
||||
): boolean {
|
||||
if (previousTurnId !== undefined && nextTurnId !== undefined) {
|
||||
return previousTurnId === nextTurnId;
|
||||
}
|
||||
// Missing IDs cannot recover historical batching or steering. Each visible
|
||||
// trigger deterministically starts a turn in both paging and presentation.
|
||||
return !nextIsTrigger;
|
||||
}
|
||||
|
||||
export function isTimelineTurnTrigger(item: {
|
||||
type: string;
|
||||
name?: string;
|
||||
agentMessage?: unknown;
|
||||
}): boolean {
|
||||
return (
|
||||
item.type === "user_message" ||
|
||||
(item.type === "tool_call" && (item.agentMessage !== undefined || item.name === "heartbeat"))
|
||||
);
|
||||
}
|
||||
@@ -880,6 +880,66 @@ test("retries provider history hydration after a stream failure", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("notification batches share the run they start or join", async () => {
|
||||
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-notification-turns-"));
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new (class extends TestAgentClient {
|
||||
override async createSession(): Promise<AgentSession> {
|
||||
return session;
|
||||
}
|
||||
})(),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
const batch = (prefix: string) =>
|
||||
["one", "two"]
|
||||
.map((id) =>
|
||||
formatAgentMessage({
|
||||
id: `${prefix}-${id}`,
|
||||
source: { kind: "agent-notification", agentId: "worker", event: "finished" },
|
||||
text: "Worker finished",
|
||||
}),
|
||||
)
|
||||
.join("\n\n");
|
||||
const consume = async (prompt: string, clientMessageId: string) => {
|
||||
for await (const _event of manager.streamAgent(agent.id, prompt, { clientMessageId })) {
|
||||
}
|
||||
};
|
||||
try {
|
||||
const first = consume("human", "human");
|
||||
await manager.waitForAgentRunStart(agent.id);
|
||||
await expect(
|
||||
manager.steerAgentRun(agent.id, batch("joined"), { clientMessageId: "joined-one" }),
|
||||
).resolves.toEqual({ status: "accepted" });
|
||||
session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-1" });
|
||||
await first;
|
||||
const second = consume(batch("idle"), "idle-one");
|
||||
await manager.waitForAgentRunStart(agent.id);
|
||||
session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-2" });
|
||||
await second;
|
||||
expect(
|
||||
manager.fetchTimeline(agent.id, { limit: 0 }).rows.map((row) => ({
|
||||
turnId: row.turnId,
|
||||
id: row.item.type === "tool_call" ? row.item.callId : row.item.type,
|
||||
})),
|
||||
).toEqual([
|
||||
{ turnId: "active-turn-1", id: "user_message" },
|
||||
{ turnId: "active-turn-1", id: "paseo-agent-message:joined-one" },
|
||||
{ turnId: "active-turn-1", id: "paseo-agent-message:joined-two" },
|
||||
{ turnId: "active-turn-2", id: "paseo-agent-message:idle-one" },
|
||||
{ turnId: "active-turn-2", id: "paseo-agent-message:idle-two" },
|
||||
]);
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("unavailable steer interrupts once and starts one replacement turn", async () => {
|
||||
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
|
||||
session.steerResult = "unavailable";
|
||||
@@ -11341,7 +11401,7 @@ test("listImportableSessions searches every provider result before global rankin
|
||||
]);
|
||||
});
|
||||
|
||||
test("user_message events wrapping a paseo-system envelope are not added to the timeline", async () => {
|
||||
test("system-injected prompts appear as trigger tools in the live timeline", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-live-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
@@ -11370,13 +11430,16 @@ test("user_message events wrapping a paseo-system envelope are not added to the
|
||||
await manager.runAgent(snapshot.id, { text: "do something" });
|
||||
|
||||
const timeline = manager.getTimeline(snapshot.id);
|
||||
expect(timeline).toContainEqual(
|
||||
expect.objectContaining({ type: "tool_call", name: "heartbeat", status: "completed" }),
|
||||
);
|
||||
const userMessages = timeline.filter((item) => item.type === "user_message");
|
||||
|
||||
expect(userMessages).toHaveLength(1);
|
||||
expect(userMessages[0].text).toBe("plain user message");
|
||||
});
|
||||
|
||||
test("user_message events wrapping a paseo-system envelope are not restored during history replay", async () => {
|
||||
test("system-injected prompts appear as trigger tools during history replay", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-history-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
@@ -11411,6 +11474,9 @@ test("user_message events wrapping a paseo-system envelope are not restored duri
|
||||
await manager.hydrateTimelineFromProvider(snapshot.id, { force: true });
|
||||
|
||||
const timeline = manager.getTimeline(snapshot.id);
|
||||
expect(timeline).toContainEqual(
|
||||
expect.objectContaining({ type: "tool_call", name: "heartbeat", status: "completed" }),
|
||||
);
|
||||
const userMessages = timeline.filter((item) => item.type === "user_message");
|
||||
|
||||
expect(userMessages).toHaveLength(1);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
AgentPromptContentBlock,
|
||||
@@ -175,9 +175,18 @@ function projectAgentMessage(item: AgentTimelineItem): AgentTimelineItem | null
|
||||
if (item.type !== "user_message") return item;
|
||||
const message = parseAgentMessage(item.text);
|
||||
if (!message) {
|
||||
// COMPAT(legacySystemEnvelope): added in v0.11.1; remove after 2027-04-08 once
|
||||
// legacy notification history is retired. Schedules still use this envelope.
|
||||
return isSystemInjectedEnvelope(item.text) ? null : item;
|
||||
// Schedule bodies include their run ID, keeping identity stable across acceptance
|
||||
// and provider replay even when the provider assigns its own message ID.
|
||||
if (!isSystemInjectedEnvelope(item.text)) return item;
|
||||
const text = item.text.slice("<paseo-system>\n".length, -"\n</paseo-system>".length);
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId: `paseo-system:${createHash("sha256").update(item.text).digest("hex")}`,
|
||||
name: "heartbeat",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "plain_text", text, icon: "sparkles" },
|
||||
};
|
||||
}
|
||||
const { source } = message;
|
||||
if (!source) {
|
||||
|
||||
@@ -357,3 +357,21 @@ test("a human pasting an agent envelope stays a human message through provider r
|
||||
clientMessageId: "human-submission",
|
||||
});
|
||||
});
|
||||
|
||||
test("schedule prompts remain visible as completed tool calls on replay", () => {
|
||||
const text = formatSystemNotificationPrompt("Heartbeat: check progress");
|
||||
expect(projectAgentMessages({ type: "user_message", messageId: "fire-1", text })).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "tool_call",
|
||||
status: "completed",
|
||||
detail: { type: "plain_text", text: "Heartbeat: check progress", icon: "sparkles" },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("schedule trigger identity survives the provider assigning a message ID", () => {
|
||||
const text = formatSystemNotificationPrompt("Schedule fired (id=heartbeat, run=run-1).");
|
||||
expect(projectAgentMessages({ type: "user_message", text })).toEqual(
|
||||
projectAgentMessages({ type: "user_message", text, messageId: "provider-message" }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface AgentTimelineCursor {
|
||||
export type AgentTimelineFetchDirection = "tail" | "before" | "after";
|
||||
|
||||
export interface AgentTimelineFetchOptions {
|
||||
wholeTurns?: boolean;
|
||||
direction?: AgentTimelineFetchDirection;
|
||||
cursor?: AgentTimelineCursor;
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,39 @@ import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
|
||||
|
||||
describe("InMemoryAgentTimelineStore", () => {
|
||||
it("resets stale forward cursors to complete available turns", () => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("agent", { epoch: "current" });
|
||||
for (let index = 0; index < 60; index++)
|
||||
store.append(
|
||||
"agent",
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: `call-${index}`,
|
||||
name: "shell",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "unknown", input: null, output: null },
|
||||
},
|
||||
{ turnId: "run" },
|
||||
);
|
||||
const page = store.fetch("agent", {
|
||||
direction: "after",
|
||||
cursor: { epoch: "old", seq: 10 },
|
||||
limit: 40,
|
||||
wholeTurns: true,
|
||||
});
|
||||
expect(page.rows).toHaveLength(60);
|
||||
expect(page).toMatchObject({
|
||||
reset: true,
|
||||
staleCursor: true,
|
||||
startSeq: 1,
|
||||
endSeq: 60,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps an overshooting before cursor into the bounded tail window", () => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("agent-1", {
|
||||
@@ -217,31 +250,35 @@ it("includes transitive tool updates in a contiguous projected tail", () => {
|
||||
expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false });
|
||||
});
|
||||
|
||||
it("serves tail 200 from 100,000 retained rows without stalling the loop", async () => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("large");
|
||||
for (let index = 0; index < 100_000; index++) {
|
||||
store.append("large", { type: "user_message", text: String(index) });
|
||||
}
|
||||
// Generation and its GC are outside the measured request window.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const started = performance.now();
|
||||
const probe = new Promise<number>((resolve) =>
|
||||
setImmediate(() => resolve(performance.now() - started)),
|
||||
);
|
||||
let page = store.fetch("large", { limit: 200 });
|
||||
for (let repeat = 1; repeat < 8; repeat++) page = store.fetch("large", { limit: 200 });
|
||||
const duration = performance.now() - started;
|
||||
const loopGap = await probe;
|
||||
console.info({ duration, loopGap });
|
||||
expect(page.rows).toHaveLength(200);
|
||||
expect(page.startSeq).toBe(99_801);
|
||||
expect(page.endSeq).toBe(100_000);
|
||||
// CI runners can pause for scheduling/GC; the local per-request 2 ms gate
|
||||
// is measured separately. Eight requests amplify the old full-history work.
|
||||
expect(duration).toBeLessThan(50);
|
||||
expect(loopGap).toBeLessThan(50);
|
||||
});
|
||||
it.each([false, true])(
|
||||
"serves tail 200 from 100,000 retained rows without stalling the loop (wholeTurns=%s)",
|
||||
async (wholeTurns) => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("large");
|
||||
for (let index = 0; index < 100_000; index++) {
|
||||
store.append("large", { type: "user_message", text: String(index) });
|
||||
}
|
||||
// Generation and its GC are outside the measured request window.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const started = performance.now();
|
||||
const probe = new Promise<number>((resolve) =>
|
||||
setImmediate(() => resolve(performance.now() - started)),
|
||||
);
|
||||
let page = store.fetch("large", { limit: 200, wholeTurns });
|
||||
for (let repeat = 1; repeat < 8; repeat++)
|
||||
page = store.fetch("large", { limit: 200, wholeTurns });
|
||||
const duration = performance.now() - started;
|
||||
const loopGap = await probe;
|
||||
console.info({ duration, loopGap });
|
||||
expect(page.rows).toHaveLength(200);
|
||||
expect(page.startSeq).toBe(99_801);
|
||||
expect(page.endSeq).toBe(100_000);
|
||||
// CI runners can pause for scheduling/GC; the local per-request 2 ms gate
|
||||
// is measured separately. Eight requests amplify the old full-history work.
|
||||
expect(duration).toBeLessThan(50);
|
||||
expect(loopGap).toBeLessThan(50);
|
||||
},
|
||||
);
|
||||
|
||||
it("matches the previous reprojecting selector on seeded randomized timelines", () => {
|
||||
let seed = 0x76aabbcc;
|
||||
|
||||
@@ -107,6 +107,7 @@ export class InMemoryAgentTimelineStore {
|
||||
direction: reset ? "tail" : direction,
|
||||
cursorSeq: cursor?.seq,
|
||||
limit: options?.limit ?? DEFAULT_TIMELINE_FETCH_LIMIT,
|
||||
wholeTurns: options?.wholeTurns,
|
||||
});
|
||||
return {
|
||||
epoch: state.epoch,
|
||||
|
||||
@@ -230,6 +230,7 @@ interface LargeAgentStreamPayloadRequest {
|
||||
}
|
||||
|
||||
interface AgentStreamStressRequest {
|
||||
tools: boolean;
|
||||
count: number;
|
||||
coalesced: boolean;
|
||||
}
|
||||
@@ -461,7 +462,7 @@ function parseLargeAgentStreamPayloadPrompt(
|
||||
|
||||
function parseAgentStreamStressPrompt(prompt: AgentPromptInput): AgentStreamStressRequest | null {
|
||||
const text = promptToText(prompt);
|
||||
const match = /emit\s+(\d+)\s+(coalesced\s+)?agent stream updates/i.exec(text);
|
||||
const match = /emit\s+(\d+)\s+(coalesced\s+|tool\s+)?agent stream updates/i.exec(text);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
@@ -471,7 +472,8 @@ function parseAgentStreamStressPrompt(prompt: AgentPromptInput): AgentStreamStre
|
||||
}
|
||||
return {
|
||||
count: Math.min(count, 5_000),
|
||||
coalesced: Boolean(match[2]),
|
||||
coalesced: match[2]?.trim() === "coalesced",
|
||||
tools: match[2]?.trim() === "tool",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1452,21 +1454,37 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
this.emitTurnStarted(turn);
|
||||
|
||||
for (let index = 0; index < stress.count; index += 1) {
|
||||
this.emitTimeline(
|
||||
turn.turnId,
|
||||
stress.coalesced
|
||||
? {
|
||||
type: "assistant_message",
|
||||
text: `stress-update-${index}`,
|
||||
messageId: turn.assistantMessageId,
|
||||
}
|
||||
: {
|
||||
type: "todo",
|
||||
items: [{ text: `stress-update-${index}`, completed: index % 2 === 0 }],
|
||||
},
|
||||
);
|
||||
let item: AgentTimelineItem;
|
||||
if (stress.tools) {
|
||||
item = {
|
||||
type: "tool_call",
|
||||
callId: `${turn.turnId}:stress:${index}`,
|
||||
name: "Bash",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "shell", command: `echo stress-${index}`, output: `stress-${index}` },
|
||||
};
|
||||
} else if (stress.coalesced) {
|
||||
item = {
|
||||
type: "assistant_message",
|
||||
text: `stress-update-${index}`,
|
||||
messageId: turn.assistantMessageId,
|
||||
};
|
||||
} else {
|
||||
item = {
|
||||
type: "todo",
|
||||
items: [{ text: `stress-update-${index}`, completed: index % 2 === 0 }],
|
||||
};
|
||||
}
|
||||
this.emitTimeline(turn.turnId, item);
|
||||
}
|
||||
|
||||
if (stress.tools)
|
||||
this.emitTimeline(turn.turnId, {
|
||||
type: "assistant_message",
|
||||
text: `Completed ${stress.count} commands`,
|
||||
messageId: turn.assistantMessageId,
|
||||
});
|
||||
this.activeTurn = null;
|
||||
const usage = {
|
||||
inputTokens: 1,
|
||||
|
||||
@@ -234,7 +234,7 @@ export function selectReferenceProjectedTimelinePage(input: {
|
||||
|
||||
// Existing selection fixtures exercise the retained owner through its public API.
|
||||
export function selectProjectedTimelinePage(
|
||||
input: Parameters<typeof selectReferenceProjectedTimelinePage>[0],
|
||||
input: Parameters<typeof selectReferenceProjectedTimelinePage>[0] & { wholeTurns?: boolean },
|
||||
) {
|
||||
const projection = new TimelineProjection(input.rows);
|
||||
const bounds = input.bounds ?? getTimelineBounds(input.rows) ?? { minSeq: 0, maxSeq: 0 };
|
||||
|
||||
@@ -534,6 +534,154 @@ describe("selectProjectedTimelinePage", () => {
|
||||
};
|
||||
}
|
||||
|
||||
test("whole-turn pages extend past the soft target and keep prompt jumps whole", () => {
|
||||
const rows: AgentTimelineRow[] = Array.from({ length: 125 }, (_, index) => ({
|
||||
seq: index + 1,
|
||||
timestamp: new Date(1000 + index).toISOString(),
|
||||
turnId: ["short", "long", "latest"][Number(index >= 3) + Number(index >= 123)],
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: `tool-${index}`,
|
||||
name: "shell",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "unknown", input: null, output: null },
|
||||
},
|
||||
}));
|
||||
const tail = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "tail",
|
||||
limit: 40,
|
||||
wholeTurns: true,
|
||||
});
|
||||
expect(tail.entries.map((entry) => entry.seqStart)).toEqual(
|
||||
rows.slice(3).map((row) => row.seq),
|
||||
);
|
||||
expect(tail).toMatchObject({ startSeq: 4, endSeq: 125, hasOlder: true, hasNewer: false });
|
||||
const before = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "before",
|
||||
cursorSeq: 124,
|
||||
limit: 40,
|
||||
wholeTurns: true,
|
||||
});
|
||||
expect(before.entries.map((entry) => entry.seqStart)).toEqual(
|
||||
rows.slice(3, 123).map((row) => row.seq),
|
||||
);
|
||||
expect(before.endSeq).toBe(123);
|
||||
const older = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "before",
|
||||
cursorSeq: 4,
|
||||
limit: 40,
|
||||
wholeTurns: true,
|
||||
});
|
||||
expect(older.entries.map((entry) => entry.seqStart)).toEqual([1, 2, 3]);
|
||||
expect(older.hasOlder).toBe(false);
|
||||
const jump = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "before",
|
||||
cursorSeq: 70,
|
||||
limit: 40,
|
||||
wholeTurns: true,
|
||||
});
|
||||
expect(jump.entries.map((entry) => entry.seqStart)).toEqual(
|
||||
rows.slice(3, 123).map((row) => row.seq),
|
||||
);
|
||||
expect(jump).toMatchObject({ startSeq: 4, endSeq: 123, hasOlder: true, hasNewer: true });
|
||||
const nearPreviousPrompt = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "before",
|
||||
cursorSeq: 10,
|
||||
limit: 40,
|
||||
wholeTurns: true,
|
||||
});
|
||||
expect(nearPreviousPrompt).toMatchObject({ startSeq: 1, endSeq: 123 });
|
||||
expect(
|
||||
selectProjectedTimelinePage({ rows, direction: "tail", limit: 40 }).entries,
|
||||
).toHaveLength(40);
|
||||
});
|
||||
|
||||
test("whole-turn pages share short runs and use the same deterministic legacy triggers", () => {
|
||||
const rows: AgentTimelineRow[] = Array.from({ length: 24 }, (_, index) => ({
|
||||
seq: index + 1,
|
||||
timestamp: new Date(1000 + index).toISOString(),
|
||||
item:
|
||||
index % 2 === 0
|
||||
? { type: "user_message", text: `prompt-${index}` }
|
||||
: { type: "assistant_message", text: `reply-${index}` },
|
||||
}));
|
||||
const page = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "tail",
|
||||
limit: 7,
|
||||
wholeTurns: true,
|
||||
});
|
||||
expect(page.entries.map((entry) => entry.seqStart)).toEqual([17, 18, 19, 20, 21, 22, 23, 24]);
|
||||
expect(
|
||||
selectProjectedTimelinePage({ rows, direction: "tail", limit: 0, wholeTurns: true }).entries,
|
||||
).toHaveLength(24);
|
||||
rows.push({
|
||||
...toolRow(25, "completed"),
|
||||
item: { ...toolRow(25, "completed").item, name: "heartbeat" } as AgentTimelineRow["item"],
|
||||
});
|
||||
rows.push({
|
||||
seq: 26,
|
||||
timestamp: new Date().toISOString(),
|
||||
item: { type: "assistant_message", text: "awake" },
|
||||
});
|
||||
expect(
|
||||
selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "tail",
|
||||
limit: 1,
|
||||
wholeTurns: true,
|
||||
}).entries.map((entry) => entry.seqStart),
|
||||
).toEqual([25, 26]);
|
||||
});
|
||||
|
||||
test("whole-turn tails close interleaved source coverage without changing forward catch-up", () => {
|
||||
const rows: AgentTimelineRow[] = [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: new Date(1).toISOString(),
|
||||
turnId: "first",
|
||||
item: { type: "user_message", text: "go" },
|
||||
},
|
||||
{ ...toolRow(2, "running"), turnId: "first" },
|
||||
{
|
||||
seq: 3,
|
||||
timestamp: new Date(3).toISOString(),
|
||||
turnId: "second",
|
||||
item: { type: "user_message", text: "next" },
|
||||
},
|
||||
{
|
||||
seq: 4,
|
||||
timestamp: new Date(4).toISOString(),
|
||||
turnId: "second",
|
||||
item: { type: "assistant_message", text: "done" },
|
||||
},
|
||||
{ ...toolRow(5, "completed"), turnId: "first" },
|
||||
];
|
||||
expect(
|
||||
selectProjectedTimelinePage({ rows, direction: "tail", limit: 1, wholeTurns: true }),
|
||||
).toMatchObject({
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
});
|
||||
expect(
|
||||
selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "after",
|
||||
cursorSeq: 2,
|
||||
limit: 1,
|
||||
wholeTurns: true,
|
||||
}),
|
||||
).toEqual(selectProjectedTimelinePage({ rows, direction: "after", cursorSeq: 2, limit: 1 }));
|
||||
});
|
||||
|
||||
test("tail page returns full projected items instead of tool lifecycle deltas", () => {
|
||||
const rows: AgentTimelineRow[] = [
|
||||
{ seq: 1, timestamp: "2026-02-13T00:00:00.000Z", item: { type: "user_message", text: "go" } },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { continuesTimelineTurn, isTimelineTurnTrigger } from "@getpaseo/protocol/timeline-turn";
|
||||
import type { AgentTimelineItem, ToolCallDetail } from "./agent-sdk-types.js";
|
||||
import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity";
|
||||
|
||||
@@ -296,6 +297,7 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[]
|
||||
export type ProjectedTimelineRow = AgentTimelineRow & TimelineProjectionEntry;
|
||||
|
||||
interface ProjectedTimelinePageOptions {
|
||||
wholeTurns?: boolean;
|
||||
bounds: { minSeq: number; maxSeq: number };
|
||||
direction: TimelineLimitDirection;
|
||||
cursorSeq?: number;
|
||||
@@ -423,7 +425,8 @@ export class TimelineProjection {
|
||||
private selectTail(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
|
||||
const limit = Math.max(0, Math.floor(input.limit ?? 0));
|
||||
const requestedStart = limit === 0 ? 0 : Math.max(0, this.rows.length - limit);
|
||||
const start = this.spans[this.spanIndex(requestedStart)].start;
|
||||
const coveredStart = this.spans[this.spanIndex(requestedStart)].start;
|
||||
const start = input.wholeTurns ? this.wholeTurnWindowStart(coveredStart) : coveredStart;
|
||||
return {
|
||||
entries: this.rows.slice(start),
|
||||
startSeq: this.rows[start].seqStart,
|
||||
@@ -433,6 +436,17 @@ export class TimelineProjection {
|
||||
};
|
||||
}
|
||||
|
||||
private wholeTurnWindowStart(requestedStart: number): number {
|
||||
let start = requestedStart;
|
||||
for (;;) {
|
||||
while (start > 0 && continuesProjectedTurn(this.rows[start - 1], this.rows[start])) start--;
|
||||
// Source coverage can pull in an earlier tool and therefore its entire run.
|
||||
const coveredStart = this.spans[this.spanIndex(start)].start;
|
||||
if (coveredStart === start) return start;
|
||||
start = coveredStart;
|
||||
}
|
||||
}
|
||||
|
||||
private selectAfter(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
|
||||
const startSeq = Math.max(
|
||||
input.bounds.minSeq,
|
||||
@@ -480,13 +494,23 @@ export class TimelineProjection {
|
||||
else high = middle;
|
||||
}
|
||||
const limit = Math.max(0, Math.floor(input.limit ?? 0));
|
||||
const start = limit === 0 ? 0 : Math.max(0, low - limit);
|
||||
const requestedStart = limit === 0 ? 0 : Math.max(0, low - limit);
|
||||
const start = input.wholeTurns ? this.wholeTurnWindowStart(requestedStart) : requestedStart;
|
||||
// Choose the window before extending the right edge, so a prompt jump
|
||||
// cannot lose its target when the next turn is arbitrarily large.
|
||||
if (input.wholeTurns) {
|
||||
while (low < this.rows.length && continuesProjectedTurn(this.rows[low - 1], this.rows[low]))
|
||||
low++;
|
||||
}
|
||||
let selectedEnd = endSeq;
|
||||
if (input.wholeTurns)
|
||||
selectedEnd = low === this.rows.length ? input.bounds.maxSeq : this.rows[low].seqStart - 1;
|
||||
return {
|
||||
entries: this.rows.slice(start, low),
|
||||
startSeq: this.rows[start]?.seqStart ?? null,
|
||||
endSeq,
|
||||
endSeq: selectedEnd,
|
||||
hasOlder: start > 0,
|
||||
hasNewer: endSeq < input.bounds.maxSeq,
|
||||
hasNewer: selectedEnd < input.bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
return { entries: [], startSeq: null, endSeq: null, hasOlder: false, hasNewer: true };
|
||||
@@ -675,6 +699,13 @@ function selectProjectedEntriesAfter(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function continuesProjectedTurn(
|
||||
previous: TimelineProjectionEntry,
|
||||
next: TimelineProjectionEntry,
|
||||
): boolean {
|
||||
return continuesTimelineTurn(previous.turnId, next.turnId, isTimelineTurnTrigger(next.item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a projected-count limit to a flat AgentTimelineItem[] without seq metadata.
|
||||
* Used by callers that only have items in hand (e.g. MCP tools reading
|
||||
|
||||
@@ -7805,6 +7805,7 @@ export class Session {
|
||||
direction,
|
||||
cursor,
|
||||
limit: msg.limit,
|
||||
wholeTurns: msg.wholeTurns,
|
||||
});
|
||||
const selectedTimeline = {
|
||||
timeline: fetchedControlTimeline,
|
||||
|
||||
@@ -2062,6 +2062,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
providerSubagents: true,
|
||||
// COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain advertisement.
|
||||
projectedSubagentTimeline: true,
|
||||
wholeTurnTimeline: true,
|
||||
// COMPAT(providerSubagentNesting): added in v0.7, remove gate after 2027-03-04.
|
||||
providerSubagentNesting: true,
|
||||
// COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12.
|
||||
|
||||
Reference in New Issue
Block a user