Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 669ec700d8 test(speech): reproduce model installation without bzip2 2026-10-26 21:43:09 +00:00
38 changed files with 275 additions and 662 deletions
+2 -94
View File
@@ -1,4 +1,3 @@
import { getActiveTabTestId } from "../support/helpers/launcher";
import type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
@@ -20,10 +19,7 @@ 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.\n" + "Keep the previous response visible.\n".repeat(40),
);
await agent.client.sendAgentMessage(agent.agentId, "Make the fix and run the burst script.");
await agent.client.waitForFinish(agent.agentId, 30_000);
return agent;
}
@@ -40,67 +36,17 @@ 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(/^Edited 1 file/);
await expect(summary).toHaveText(/^Wrote \d+ messages?, 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(
@@ -111,9 +57,6 @@ 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);
@@ -122,38 +65,3 @@ 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();
}
});
@@ -149,11 +149,7 @@ export interface SeedDaemonClient {
timeout?: number,
): Promise<{ status: string }>;
cancelAgent(agentId: string): Promise<void>;
sendAgentMessage(
agentId: string,
text: string,
options?: { sourceAgentId?: string },
): Promise<void>;
sendAgentMessage(agentId: string, text: 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: [],
timingByItemId: timingFor(assistant.id),
timingByAssistantId: timingFor(assistant.id),
}),
};
}
+6 -6
View File
@@ -104,7 +104,7 @@ function layoutFor(input: {
strategy,
streamHead: input.head ?? [],
}),
timingByItemId: timingFor(...(input.timingIds ?? [])),
timingByAssistantId: timingFor(...(input.timingIds ?? [])),
});
}
@@ -148,7 +148,7 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
}
describe("layoutStream", () => {
it("keeps separate footers for an assistant turn and an adjacent tool-only turn", () => {
it("places one response footer after an adjacent tagged tool-only turn", () => {
const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1");
const nextTool = toolCall("next-tool", 2, "turn-2");
const layout = layoutFor({
@@ -157,8 +157,8 @@ describe("layoutStream", () => {
timingIds: [priorAssistant.id],
});
expect(layout.auxiliaryTurnFooter?.itemId).toBe(nextTool.id);
expect(footerOwners(layout)).toEqual([priorAssistant.id, nextTool.id]);
expect(layout.auxiliaryTurnFooter?.itemId).toBe(priorAssistant.id);
expect(footerOwners(layout)).toEqual([priorAssistant.id]);
});
it("recomputes cached history layout when only the live boundary turn changes", () => {
@@ -171,7 +171,7 @@ describe("layoutStream", () => {
isTurnActive: true,
history,
liveHead,
timingByItemId: timingFor(priorAssistant.id),
timingByAssistantId: timingFor(priorAssistant.id),
});
liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" };
@@ -180,7 +180,7 @@ describe("layoutStream", () => {
isTurnActive: true,
history,
liveHead,
timingByItemId: timingFor(priorAssistant.id),
timingByAssistantId: timingFor(priorAssistant.id),
});
expect(footerOwners(first)).toEqual([]);
+26 -29
View File
@@ -39,13 +39,13 @@ export interface StreamLayoutInput {
isTurnActive: boolean;
history: StreamItem[];
liveHead: StreamItem[];
timingByItemId: Map<string, TurnTiming>;
timingByAssistantId: Map<string, TurnTiming>;
}
interface LayoutSegmentInput {
strategy: StreamStrategy;
items: StreamItem[];
timingByItemId: Map<string, TurnTiming>;
timingByAssistantId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
hasAuxiliaryFooter: boolean;
frameOrder: StreamFrameChildOrder;
@@ -57,8 +57,8 @@ interface LayoutSegmentInput {
phase: "streaming" | "complete";
}
interface TurnFooterSource {
item: StreamItem;
interface AssistantFooterSource {
item: Extract<StreamItem, { kind: "assistant_message" }>;
items: StreamItem[];
index: number;
}
@@ -67,26 +67,23 @@ function createTurnFooterHost(input: {
item: StreamItem;
items: StreamItem[];
index: number;
timingByItemId: Map<string, TurnTiming>;
timingByAssistantId: Map<string, TurnTiming>;
}): TurnFooterHost {
return {
itemId: input.item.id,
items: input.items,
timing: input.timingByItemId.get(input.item.id),
timing: input.timingByAssistantId.get(input.item.id),
startIndex: input.index,
};
}
function findTurnFooterSource(input: {
function findLatestAssistantInResponse(input: {
strategy: StreamStrategy;
items: StreamItem[];
startIndex: number;
boundaryAboveItems?: StreamItem[] | null;
boundaryAboveIndex?: number | 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;
}): AssistantFooterSource | null {
let items = input.items;
let index = input.startIndex;
let canCrossBoundary = true;
@@ -100,7 +97,7 @@ function findTurnFooterSource(input: {
) {
const item = items[index];
if (!item || (laterItem && !continuesResponse(item, laterItem))) {
return toolFallback;
return null;
}
if (item.kind === "assistant_message") {
return { item, items, index };
@@ -114,7 +111,7 @@ function findTurnFooterSource(input: {
input.boundaryAboveIndex === null ||
input.boundaryAboveIndex === undefined
) {
return toolFallback;
return null;
}
items = input.boundaryAboveItems;
@@ -134,20 +131,20 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
return null;
}
const source = findTurnFooterSource({
const assistant = findLatestAssistantInResponse({
strategy: input.strategy,
items: footerItems,
startIndex: latestIndex,
});
if (!source) {
if (!assistant) {
return null;
}
return createTurnFooterHost({
item: source.item,
items: source.items,
index: source.index,
timingByItemId: input.timingByItemId,
item: assistant.item,
items: assistant.items,
index: assistant.index,
timingByAssistantId: input.timingByAssistantId,
});
}
@@ -157,7 +154,7 @@ function resolveCompletedFooter(input: {
index: number;
item: StreamItem;
belowItem: StreamItem | null;
timingByItemId: Map<string, TurnTiming>;
timingByAssistantId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
boundaryAboveItems: StreamItem[] | null;
boundaryAboveIndex: number | null;
@@ -166,21 +163,21 @@ function resolveCompletedFooter(input: {
return null;
}
const source = findTurnFooterSource({
const assistant = findLatestAssistantInResponse({
strategy: input.strategy,
items: input.items,
startIndex: input.index,
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
});
if (!source || input.auxiliaryTurnFooter?.itemId === source.item.id) {
if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) {
return null;
}
return createTurnFooterHost({
item: source.item,
items: source.items,
index: source.index,
timingByItemId: input.timingByItemId,
item: assistant.item,
items: assistant.items,
index: assistant.index,
timingByAssistantId: input.timingByAssistantId,
});
}
@@ -309,7 +306,7 @@ function layoutSegmentItem(
index,
item,
belowItem,
timingByItemId: input.timingByItemId,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
@@ -378,7 +375,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
history = layoutSegment({
strategy: input.strategy,
items: input.history,
timingByItemId: input.timingByItemId,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
hasAuxiliaryFooter,
frameOrder,
@@ -398,7 +395,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
const liveHead = layoutSegment({
strategy: input.strategy,
items: input.liveHead,
timingByItemId: input.timingByItemId,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
hasAuxiliaryFooter,
frameOrder,
+9 -9
View File
@@ -69,8 +69,8 @@ describe("buildAgentStreamRenderModel", () => {
historyStart: 2,
});
expect(model.turnTiming.byItemId.has("hidden-a")).toBe(false);
expect(model.turnTiming.byItemId.get("visible-a")).toEqual({
expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false);
expect(model.turnTiming.byAssistantId.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.byItemId.has("live-a")).toBe(false);
expect(model.turnTiming.byAssistantId.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.byItemId.get("live-a")).toEqual({
expect(model.turnTiming.byAssistantId.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.byItemId.get("a1")).toEqual({
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({
completedAt: tail[1]?.timestamp,
durationMs: 3000,
});
@@ -238,7 +238,7 @@ describe("buildAgentStreamRenderModel", () => {
isMobileBreakpoint: false,
});
expect(model.turnTiming.byItemId.size).toBe(0);
expect(model.turnTiming.byAssistantId.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.byItemId).toBe(first.turnTiming.byItemId);
expect(second.turnTiming.byAssistantId).toBe(first.turnTiming.byAssistantId);
expect(first.history).toHaveLength(20);
expect(first.turnTiming.byItemId.has("a29")).toBe(false);
expect(first.turnTiming.byItemId.get("a38")).toEqual({
expect(first.turnTiming.byAssistantId.has("a29")).toBe(false);
expect(first.turnTiming.byAssistantId.get("a38")).toEqual({
completedAt: tail[77]?.timestamp,
durationMs: 1000,
});
@@ -588,69 +588,3 @@ 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"]);
});
+2 -40
View File
@@ -1,4 +1,3 @@
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";
@@ -20,7 +19,6 @@ interface PresentationInput {
/** User messages whose collapsed turn the reader opened. */
expandedTurnHostIds: ReadonlySet<string>;
isTurnActive: boolean;
activeTurnId?: string | null;
}
export interface StreamPresentation extends ToolCallDetailProjection {
@@ -65,22 +63,6 @@ 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];
}
@@ -93,14 +75,7 @@ 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) &&
!isTurnBoundary(projection.tail.at(-1) ?? null, projection.head[0] ?? null) &&
!(
input.activeTurnId &&
projection.tail.at(-1)?.turnId &&
input.activeTurnId !== projection.tail.at(-1)?.turnId
);
const isLatestTurnOpen = input.isTurnActive || projection.head.length > 0;
if (
!collapsed ||
collapsedSource !== projection.tail ||
@@ -118,20 +93,7 @@ export function createStreamPresentation() {
collapsedExpandedHostIds = input.expandedTurnHostIds;
}
const { tail, ...turns } = collapsed;
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 { ...projection, tail, turns };
}
return (input: PresentationInput): StreamPresentation => {
@@ -110,7 +110,7 @@ function runTicks(input: { tail: StreamItem[]; historyStart: number; heads: Stre
isTurnActive: true,
history: model.history,
liveHead: model.segments.liveHead,
timingByItemId: model.turnTiming.byItemId,
timingByAssistantId: model.turnTiming.byAssistantId,
});
}
}
@@ -63,6 +63,7 @@ describe("collapseTurns", () => {
hostId: "u1",
expanded: false,
summary: {
messageCount: 1,
tools: expect.objectContaining({ commandCount: 2 }),
},
});
@@ -120,34 +121,6 @@ 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,13 @@
import { isTurnBoundary, isTurnTrigger } from "../turn-membership";
import { getStreamItemMessageId, type StreamItem, type ToolCallItem } from "@/types/stream";
import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model";
export interface TurnSummary {
messageCount: number;
tools: OverviewSummary;
}
export interface CollapsedTurn {
/** The input or first loaded row that carries the turn's summary line. */
/** The user message the turn answers. Its row carries the turn's summary line. */
hostId: string;
expanded: boolean;
summary: TurnSummary;
@@ -40,15 +40,16 @@ function summarizeTurn(
getGroupedCalls: CollapseTurnsInput["getGroupedCalls"],
): TurnSummary {
const calls: ToolCallItem[] = [];
let messageCount = 0;
for (const item of items) {
if (item.kind === "tool_call" && !isTurnTrigger(item))
calls.push(...(getGroupedCalls(item.id) ?? [item]));
if (item.kind === "assistant_message") messageCount += 1;
else if (item.kind === "tool_call") calls.push(...(getGroupedCalls(item.id) ?? [item]));
}
return { tools: summarizeToolCalls(calls) };
return { messageCount, tools: summarizeToolCalls(calls) };
}
function hasWork(summary: TurnSummary): boolean {
return Object.values(summary.tools).some((count) => count > 0);
return summary.messageCount > 0 || Object.values(summary.tools).some((count) => count > 0);
}
function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number {
@@ -76,43 +77,28 @@ 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 ||
(host.kind !== "user_message" && host.kind !== "tool_call") ||
!isClosed ||
replyIndex <= runStart
) {
if (host?.kind !== "user_message" || !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) && !folded.some((item) => item.kind === "assistant_message")) {
if (!hasWork(summary)) {
output.push(...tail.slice(runStart, runEnd));
return;
}
const expanded = input.expandedHostIds.has(host.id);
turnsByHostId.set(host.id, { hostId: host.id, expanded, summary });
const revealed = expanded
? folded.flatMap<StreamItem>((item) =>
item.kind === "tool_call" ? [...(input.getGroupedCalls(item.id) ?? [item])] : [item],
)
: folded;
for (const item of revealed) {
if (isTurnTrigger(item)) continue;
for (const item of folded) {
hostIdByFoldedMessageId.set(getStreamItemMessageId(item), host.id);
if (expanded) nestedItemIds.add(item.id);
}
output.push(...(expanded ? revealed : folded.filter(isTurnTrigger)));
if (expanded) output.push(...folded);
output.push(...tail.slice(replyIndex, runEnd));
};
for (let index = 0; index < tail.length; index += 1) {
if (
!(index === 0 && isTurnTrigger(tail[index]!)) &&
!isTurnBoundary(tail[index - 1] ?? null, tail[index] ?? null)
)
continue;
if (tail[index]?.kind !== "user_message") continue;
flushRun(index);
output.push(tail[index]!);
runStart = index + 1;
@@ -128,10 +114,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
};
}
return {
tail:
output.length === tail.length && output.every((item, index) => item === tail[index])
? tail
: output,
tail: output.length === tail.length ? tail : output,
turnsByHostId,
nestedItemIds,
hostIdByFoldedMessageId,
@@ -1,4 +1,4 @@
import { memo, useCallback } from "react";
import { memo, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Summary } from "lucide-react-native";
import { ExpandableBadge } from "@/components/message";
@@ -17,7 +17,19 @@ export const TurnSummaryLine = memo(function TurnSummaryLine({
onExpandedChange: (hostId: string, expanded: boolean) => void;
}) {
const { t } = useTranslation();
const label = useOverviewSummaryLabel(turn.summary.tools) || t("turnSummary.details");
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 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={items[startIndex]?.kind === "assistant_message" ? getContent : undefined}
getContent={getContent}
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,
timingByItemId: model.turnTiming.byItemId,
timingByAssistantId: model.turnTiming.byAssistantId,
}),
};
}
@@ -76,7 +76,7 @@ describe("canonical turn membership", () => {
);
});
it("renders a separate footer for each canonical turn without visible prompts", () => {
it("renders adjacent turns without visible prompts as one response", () => {
const items = [
user("prompt", 1, "turn-1"),
assistant("first reply", 2, "turn-1"),
@@ -88,11 +88,7 @@ describe("canonical turn membership", () => {
const completed = layoutFor(items, false);
expect(completedFooterIds(completed.layout)).toEqual([
"first reply",
"heartbeat-1-reply",
"heartbeat-2-reply",
]);
expect(completedFooterIds(completed.layout)).toEqual(["heartbeat-2-reply"]);
expect(completed.layout.auxiliaryTurnFooter?.timing).toEqual({
completedAt: at(6),
durationMs: null,
@@ -127,7 +123,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.byItemId.get("done")).toMatchObject({
expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({
completedAt: at(9),
durationMs: 8000,
});
@@ -9,15 +9,16 @@ export function continuesTurn(previous: StreamItem | null, next: StreamItem | nu
if (previous.turnId !== undefined && next.turnId !== undefined) {
return previous.turnId === next.turnId;
}
return !isTurnTrigger(next);
return next.kind !== "user_message";
}
/**
* Every canonical turn owns its response, including system-triggered runs.
* A visible response can span multiple canonical turns when their prompts are
* system-injected and therefore absent from the Paseo timeline.
*/
export function continuesResponse(previous: StreamItem | null, next: StreamItem | null): boolean {
if (!previous || !next) return false;
return continuesTurn(previous, next);
return continuesTurn(previous, next) || next.kind !== "user_message";
}
export function isTurnBoundary(previous: StreamItem | null, next: StreamItem | null): boolean {
@@ -36,13 +37,3 @@ export function startsNewTurn(item: StreamItem, previous: StreamItem | null): bo
export function belongsToTurn(item: StreamItem, turnId: string | null): boolean {
return turnId !== null && item.turnId === turnId;
}
/** Inputs that start a turn when older history has no canonical turn IDs. */
export function isTurnTrigger(item: StreamItem): boolean {
if (item.kind === "user_message") return true;
return (
item.kind === "tool_call" &&
item.payload.source === "agent" &&
(item.payload.data.agentMessage !== undefined || item.payload.data.name === "heartbeat")
);
}
+6 -32
View File
@@ -526,7 +526,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
display: chatDisplay,
expandedTurnHostIds,
isTurnActive,
activeTurnId: effectiveTurnPresentation.turnId,
}),
[
presentStream,
@@ -536,7 +535,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
chatDisplay,
expandedTurnHostIds,
isTurnActive,
effectiveTurnPresentation.turnId,
],
);
const {
@@ -578,12 +576,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
isTurnActive,
history: baseRenderModel.history,
liveHead: baseRenderModel.segments.liveHead,
timingByItemId: baseRenderModel.turnTiming.byItemId,
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
}),
[
baseRenderModel.history,
baseRenderModel.segments.liveHead,
baseRenderModel.turnTiming.byItemId,
baseRenderModel.turnTiming.byAssistantId,
isTurnActive,
streamRenderStrategy,
],
@@ -724,7 +722,6 @@ 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()}
@@ -790,9 +787,6 @@ 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 (
@@ -802,11 +796,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
text={item.text}
status={item.status}
isLastInSequence={layoutItem.isLastInToolSequence}
defaultExpanded={autoExpandReasoning || isExpandedTurnItem(item.id)}
defaultExpanded={autoExpandReasoning}
/>
);
},
[autoExpandReasoning, setInlineDetailsExpanded, isExpandedTurnItem],
[autoExpandReasoning, setInlineDetailsExpanded],
);
const renderSingleToolCallItem = useCallback(
@@ -836,8 +830,6 @@ 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}
@@ -867,13 +859,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
/>
);
},
[
context.cwd,
resolvedServerId,
setInlineDetailsExpanded,
handleToolCallOpenFile,
isExpandedTurnItem,
],
[context.cwd, resolvedServerId, setInlineDetailsExpanded, handleToolCallOpenFile],
);
// Read through a stable event so live group updates do not change the renderer identity
@@ -931,17 +917,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return renderThoughtItem(layoutItem, item);
case "tool_call":
return (
<>
{renderToolCallItem(layoutItem, item)}
{getCollapsedTurn(item.id) ? (
<TurnSummaryLine
turn={getCollapsedTurn(item.id)!}
onExpandedChange={setTurnExpanded}
/>
) : null}
</>
);
return renderToolCallItem(layoutItem, item);
case "notification":
return <Notification level={item.level} message={item.message} />;
@@ -973,8 +949,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
renderAssistantMessageItem,
renderThoughtItem,
renderToolCallItem,
getCollapsedTurn,
setTurnExpanded,
resolvedServerId,
],
);
+4 -3
View File
@@ -3,10 +3,11 @@ import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
/**
* 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.
* 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.
*/
const RAIL_LEFT = 1;
const RAIL_LEFT = 7;
const RAIL_WIDTH = 1;
export const INDENT_RAIL_CONTENT_OFFSET = 21;
+40 -48
View File
@@ -1,4 +1,3 @@
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";
@@ -90,7 +89,6 @@ export type { InlinePathTarget } from "@/assistant-file-links";
export type { AssistantForkTarget };
interface UserMessageProps {
sender?: AgentMessage["sender"];
serverId?: string;
agentId?: string;
messageId?: string;
@@ -389,7 +387,6 @@ function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessage
const MESSAGE_TEXT_DATASET = { messageText: "true" };
export const UserMessage = memo(function UserMessage({
sender,
serverId,
agentId,
messageId,
@@ -476,45 +473,42 @@ export const UserMessage = memo(function UserMessage({
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
{sender ? <MessageSender sender={sender} serverId={serverId} /> : null}
<View style={userMessageStylesheet.bubble}>
<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>
{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}
</View>
{hasText ? (
<View
@@ -547,7 +541,7 @@ export const UserMessage = memo(function UserMessage({
});
interface AssistantTurnFooterProps {
getContent?: () => string;
getContent: () => string;
completedAt?: Date;
durationMs?: number | null;
onFork?: (target: AssistantForkTarget) => Promise<void> | void;
@@ -647,12 +641,10 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
return (
<View style={assistantTurnFooterStylesheet.container}>
{getContent ? (
<TurnCopyButton
getContent={getContent}
containerStyle={assistantTurnFooterStylesheet.copyButton}
/>
) : null}
<TurnCopyButton
getContent={getContent}
containerStyle={assistantTurnFooterStylesheet.copyButton}
/>
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
{primaryLabel ? (
<Pressable
+1 -2
View File
@@ -4,10 +4,9 @@ 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" | "surface3";
type EdgeFadeBackdrop = "surface0" | "surfaceWorkspace";
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 }),
};
@@ -1,79 +0,0 @@
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] },
}));
+4 -4
View File
@@ -2018,10 +2018,10 @@ export const ar: TranslationResources = {
output: "الإخراج",
},
turnSummary: {
details: "عرض تفاصيل الجولة",
sentBy: "أرسلها {{name}}",
showMore: "عرض المزيد",
showLess: "عرض أقل",
messages: {
one: "كتب {{count}} رسالة",
other: "كتب {{count}} رسائل",
},
},
toolCallGroup: {
editedFiles: {
+4 -4
View File
@@ -2043,10 +2043,10 @@ export const en = {
output: "Output",
},
turnSummary: {
details: "View turn details",
sentBy: "Sent by {{name}}",
showMore: "Show more",
showLess: "Show less",
messages: {
one: "wrote {{count}} message",
other: "wrote {{count}} messages",
},
},
toolCallGroup: {
editedFiles: {
+4 -4
View File
@@ -2066,10 +2066,10 @@ export const es: TranslationResources = {
output: "Producción",
},
turnSummary: {
details: "Ver detalles del turno",
sentBy: "Enviado por {{name}}",
showMore: "Mostrar más",
showLess: "Mostrar menos",
messages: {
one: "escribió {{count}} mensaje",
other: "escribió {{count}} mensajes",
},
},
toolCallGroup: {
editedFiles: {
+4 -4
View File
@@ -2065,10 +2065,10 @@ export const fr: TranslationResources = {
output: "Sortie",
},
turnSummary: {
details: "Voir les détails du tour",
sentBy: "Envoyé par {{name}}",
showMore: "Afficher plus",
showLess: "Afficher moins",
messages: {
one: "{{count}} message écrit",
other: "{{count}} messages écrits",
},
},
toolCallGroup: {
editedFiles: {
+4 -4
View File
@@ -2038,10 +2038,10 @@ export const ja: TranslationResources = {
output: "出力",
},
turnSummary: {
details: "ターンの詳細を表示",
sentBy: "{{name}} から送信",
showMore: "もっと見る",
showLess: "折りたたむ",
messages: {
one: "{{count}}件のメッセージを送信",
other: "{{count}}件のメッセージを送信",
},
},
toolCallGroup: {
editedFiles: {
+4 -4
View File
@@ -2028,10 +2028,10 @@ export const ko: TranslationResources = {
output: "출력",
},
turnSummary: {
details: "턴 세부 정보 보기",
sentBy: "{{name}}이(가) 보냄",
showMore: "더 보기",
showLess: "접기",
messages: {
one: "{{count}} 메시지를 작성했습니다.",
other: "{{count}} 메시지를 작성했습니다.",
},
},
toolCallGroup: {
editedFiles: {
+4 -4
View File
@@ -2051,10 +2051,10 @@ export const ptBR: TranslationResources = {
output: "Saída",
},
turnSummary: {
details: "Ver detalhes do turno",
sentBy: "Enviado por {{name}}",
showMore: "Mostrar mais",
showLess: "Mostrar menos",
messages: {
one: "escreveu {{count}} mensagem",
other: "escreveu {{count}} mensagens",
},
},
toolCallGroup: {
editedFiles: {
+4 -4
View File
@@ -2051,10 +2051,10 @@ export const ru: TranslationResources = {
output: "Выходные данные",
},
turnSummary: {
details: "Показать подробности хода",
sentBy: "Отправлено агентом {{name}}",
showMore: "Показать больше",
showLess: "Свернуть",
messages: {
one: "написано {{count}} сообщение",
other: "написаны сообщения ({{count}})",
},
},
toolCallGroup: {
editedFiles: {
+4 -4
View File
@@ -1996,10 +1996,10 @@ export const zhCN: TranslationResources = {
output: "输出",
},
turnSummary: {
details: "查看轮次详情",
sentBy: "由 {{name}} 发送",
showMore: "展开",
showLess: "收起",
messages: {
one: "写了 {{count}} 条消息",
other: "写了 {{count}} 条消息",
},
},
toolCallGroup: {
editedFiles: {
+16 -44
View File
@@ -51,7 +51,7 @@ describe("deriveStreamTurnTiming", () => {
});
assert.equal(timing.runningStartedAt, secondUserAt);
assert.equal(timing.byItemId.has("a2"), false);
assert.equal(timing.byAssistantId.has("a2"), false);
});
it("derives completed turn timing from user and assistant item timestamps", () => {
@@ -69,7 +69,7 @@ describe("deriveStreamTurnTiming", () => {
head: [],
});
assert.deepEqual(timing.byItemId.get("a1"), {
assert.deepEqual(timing.byAssistantId.get("a1"), {
completedAt: assistantAt,
durationMs: 7000,
});
@@ -95,8 +95,8 @@ describe("deriveStreamTurnTiming", () => {
completedAt: lastAssistantAt,
durationMs: 7000,
};
assert.deepEqual(timing.byItemId.get("a1"), expected);
assert.deepEqual(timing.byItemId.get("a2"), expected);
assert.deepEqual(timing.byAssistantId.get("a1"), expected);
assert.deepEqual(timing.byAssistantId.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.byItemId.get("hidden-prompt-a2"), {
assert.deepEqual(timing.byAssistantId.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.byItemId, first.byItemId);
assert.deepEqual(first.byItemId.get("a1"), {
assert.equal(second.byAssistantId, first.byAssistantId);
assert.deepEqual(first.byAssistantId.get("a1"), {
completedAt: tail[1]?.timestamp,
durationMs: 7000,
});
assert.equal(first.byItemId.has("a2"), false);
assert.equal(first.byAssistantId.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.byItemId, first.byItemId);
assert.deepEqual(first.byItemId.get("a1"), {
assert.equal(second.byAssistantId, first.byAssistantId);
assert.deepEqual(first.byAssistantId.get("a1"), {
completedAt: lastTailAt,
durationMs: 7000,
});
@@ -193,9 +193,9 @@ describe("deriveStreamTurnTiming", () => {
head,
});
assert.notEqual(completed.byItemId, active.byItemId);
assert.equal(active.byItemId.has("a1"), false);
assert.deepEqual(completed.byItemId.get("a1"), { completedAt, durationMs: 9000 });
assert.notEqual(completed.byAssistantId, active.byAssistantId);
assert.equal(active.byAssistantId.has("a1"), false);
assert.deepEqual(completed.byAssistantId.get("a1"), { completedAt, durationMs: 9000 });
});
it("derives fresh timing when the tail itself changes", () => {
@@ -211,39 +211,11 @@ describe("deriveStreamTurnTiming", () => {
const first = derive(firstTail);
const second = derive(secondTail);
assert.notEqual(second.byItemId, first.byItemId);
assert.equal(first.byItemId.has("a2"), false);
assert.deepEqual(second.byItemId.get("a2"), {
assert.notEqual(second.byAssistantId, first.byAssistantId);
assert.equal(first.byAssistantId.has("a2"), false);
assert.deepEqual(second.byAssistantId.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);
});
+13 -17
View File
@@ -1,5 +1,5 @@
import type { StreamItem } from "@/types/stream";
import { startsNewTurn, isTurnTrigger } from "@/agent-stream/turn-membership";
import { startsNewTurn } from "@/agent-stream/turn-membership";
export interface TurnTiming {
completedAt: Date;
@@ -7,7 +7,7 @@ export interface TurnTiming {
}
export interface StreamTurnTiming {
byItemId: Map<string, TurnTiming>;
byAssistantId: Map<string, TurnTiming>;
runningStartedAt: Date | null;
}
@@ -20,7 +20,7 @@ interface OpenTurn {
interface HeadTurnTiming {
additions: Map<string, TurnTiming>;
byItemId: Map<string, TurnTiming>;
byAssistantId: Map<string, TurnTiming>;
}
interface TailTurnTiming {
@@ -37,18 +37,14 @@ const EMPTY_OPEN_TURN: OpenTurn = {
};
function closeTurn(turn: OpenTurn, into: Map<string, TurnTiming>): void {
if (!turn.lastItemAt) {
if (!turn.lastItemAt || turn.assistantIds.length === 0) {
return;
}
const timing: TurnTiming = {
completedAt: turn.lastItemAt,
durationMs: turn.userAt ? Math.max(0, turn.lastItemAt.getTime() - turn.userAt.getTime()) : null,
};
let footerIds = turn.assistantIds;
if (footerIds.length === 0 && turn.previousItem?.kind === "tool_call") {
footerIds = [turn.previousItem.id];
}
for (const id of footerIds) {
for (const id of turn.assistantIds) {
into.set(id, timing);
}
}
@@ -65,7 +61,7 @@ function walkTurns(
for (const item of items) {
if (startsNewTurn(item, previousItem)) {
closeTurn({ userAt, lastItemAt, assistantIds, previousItem }, into);
userAt = isTurnTrigger(item) ? item.timestamp : null;
userAt = item.kind === "user_message" ? item.timestamp : null;
lastItemAt = null;
assistantIds = [];
}
@@ -112,7 +108,7 @@ function getTailTurnTiming(tail: StreamItem[]): TailTurnTiming {
return timing;
}
function resolveByItemId(
function resolveByAssistantId(
tail: TailTurnTiming,
additions: Map<string, TurnTiming>,
): Map<string, TurnTiming> {
@@ -120,14 +116,14 @@ function resolveByItemId(
return tail.completed;
}
if (tail.lastHead && areTimingsEqual(tail.lastHead.additions, additions)) {
return tail.lastHead.byItemId;
return tail.lastHead.byAssistantId;
}
const byItemId = new Map(tail.completed);
const byAssistantId = new Map(tail.completed);
for (const [id, timing] of additions) {
byItemId.set(id, timing);
byAssistantId.set(id, timing);
}
tail.lastHead = { additions, byItemId };
return byItemId;
tail.lastHead = { additions, byAssistantId };
return byAssistantId;
}
export function deriveStreamTurnTiming(params: {
@@ -143,7 +139,7 @@ export function deriveStreamTurnTiming(params: {
closeTurn(openTurn, additions);
}
return {
byItemId: resolveByItemId(tail, additions),
byAssistantId: resolveByAssistantId(tail, additions),
runningStartedAt: params.isTurnActive ? params.activeTurnStartedAt : null,
};
}
@@ -1,4 +1,3 @@
import { isTurnTrigger, isTurnBoundary } from "@/agent-stream/turn-membership";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import type { StreamItem, ToolCallItem } from "@/types/stream";
@@ -64,7 +63,7 @@ export function describeToolCall(item: ToolCallItem): ToolCallDescriptor {
}
export function isGroupableToolCall(item: StreamItem): item is ToolCallItem {
if (item.kind !== "tool_call" || isTurnTrigger(item)) {
if (item.kind !== "tool_call") {
return false;
}
const descriptor = describeToolCall(item);
@@ -117,7 +116,7 @@ export function prepareGroupedHistory<TGroup>(input: {
let pending: ToolCallItem[] = [];
for (const item of input.tail) {
if (isGroupableToolCall(item) && !isTurnBoundary(pending.at(-1) ?? null, item)) {
if (isGroupableToolCall(item)) {
pending.push(item);
continue;
}
@@ -129,8 +128,7 @@ export function prepareGroupedHistory<TGroup>(input: {
buildGroup: input.buildGroup,
});
pending = [];
if (isGroupableToolCall(item)) pending.push(item);
else output.push(item);
output.push(item);
}
appendRun({
@@ -177,7 +175,6 @@ export function groupLiveToolCalls<TGroup>(input: {
};
for (const item of input.head) {
if (isTurnBoundary(pending.at(-1) ?? null, item)) flush(true);
if (isGroupableToolCall(item)) {
if (pending.length === 0) {
hostPlacement = "head";
@@ -480,24 +480,3 @@ 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"]);
});
-2
View File
@@ -91,8 +91,6 @@ 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;
@@ -11341,7 +11341,7 @@ test("listImportableSessions searches every provider result before global rankin
]);
});
test("system-injected prompts appear as trigger tools in the live timeline", async () => {
test("user_message events wrapping a paseo-system envelope are not added to the timeline", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-live-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -11370,16 +11370,13 @@ test("system-injected prompts appear as trigger tools in the live timeline", asy
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("system-injected prompts appear as trigger tools during history replay", async () => {
test("user_message events wrapping a paseo-system envelope are not restored during history replay", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-history-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -11414,9 +11411,6 @@ test("system-injected prompts appear as trigger tools during history replay", as
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 { createHash, randomUUID } from "node:crypto";
import { randomUUID } from "node:crypto";
import { z } from "zod";
import type {
AgentPromptContentBlock,
@@ -175,18 +175,9 @@ function projectAgentMessage(item: AgentTimelineItem): AgentTimelineItem | null
if (item.type !== "user_message") return item;
const message = parseAgentMessage(item.text);
if (!message) {
// 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" },
};
// 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;
}
const { source } = message;
if (!source) {
@@ -357,21 +357,3 @@ 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" }),
);
});
@@ -8,6 +8,7 @@ import {
existsSync,
truncateSync,
readdirSync,
symlinkSync,
} from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
@@ -23,7 +24,71 @@ function makeTmpDir(): string {
const logger = pino({ level: "silent" });
// A tar.bz2 containing the three Kokoro files ("complete model data") and espeak-ng-data/.
// Generated with Python tarfile + bz2, so testing extraction needs no system compressor.
const compressedModelArchive = Buffer.from(
"QlpoOTFBWSZTWYGlJLIAAOjfgcqBQAP/ICEAAAD+r99AABAICDAA1mQkopo0P1JkeoMTBDE9RgyiADQZAAAACSVPTU9TT1B6g0aGJ6hkyafQaedKK3SkotKCAUvkgkMWhnICQFABoykPAQBIYAZHteVO8PnEPulpWBWogKIMAi5XOBFWQPCYTsU1U7snVwYKXg/axQ0QgDBKDgsClwZB7z0TLGwJV+3hIFscrlfwje/SLh4QDkqFxkXTaE+x7Zh/DDrAILu8MdygDAzOzD88IBQpa8wkDqd3cEH8XckU4UJCBpSSyA==",
"base64",
);
describe("sherpa model downloader", () => {
test.skipIf(process.platform !== "linux")(
"installs a cached bzip2 model without a system bzip2 executable",
async () => {
const root = makeTmpDir();
const modelsDir = path.join(root, "models");
const spec = getSherpaOnnxModelSpec("kokoro-en-v0_19");
const archive = path.join(
modelsDir,
".downloads",
path.basename(new URL(spec.archiveUrl).pathname),
);
const originalPath = process.env.PATH;
try {
const binDir = path.join(root, "bin");
mkdirSync(binDir);
symlinkSync(
execFileSync("which", ["tar"], { encoding: "utf8" }).trim(),
path.join(binDir, "tar"),
);
mkdirSync(path.dirname(archive), { recursive: true });
writeFileSync(archive, compressedModelArchive);
// GNU tar is available, but the bzip2 executable it invokes is not.
process.env.PATH = binDir;
const modelDir = await ensureSherpaOnnxModel({ modelsDir, modelId: spec.id, logger });
expect(readFileSync(path.join(modelDir, "model.onnx"), "utf8")).toBe("complete model data");
expect(existsSync(path.join(modelDir, "espeak-ng-data"))).toBe(true);
expect(existsSync(archive)).toBe(false);
} finally {
if (originalPath === undefined) delete process.env.PATH;
else process.env.PATH = originalPath;
rmSync(root, { recursive: true, force: true });
}
},
);
test.skipIf(process.platform !== "linux")(
"installs the same cached bzip2 archive when system tools are available",
async () => {
const root = makeTmpDir();
const modelsDir = path.join(root, "models");
const spec = getSherpaOnnxModelSpec("kokoro-en-v0_19");
const archive = path.join(
modelsDir,
".downloads",
path.basename(new URL(spec.archiveUrl).pathname),
);
try {
mkdirSync(path.dirname(archive), { recursive: true });
writeFileSync(archive, compressedModelArchive);
const modelDir = await ensureSherpaOnnxModel({ modelsDir, modelId: spec.id, logger });
expect(readFileSync(path.join(modelDir, "model.onnx"), "utf8")).toBe("complete model data");
expect(existsSync(archive)).toBe(false);
} finally {
rmSync(root, { recursive: true, force: true });
}
},
);
test("does not expose a partial model when tar extraction fails", async () => {
const root = makeTmpDir();
const modelsDir = path.join(root, "models");