Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 108dd8c77c 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.
2026-10-27 04:14:31 +01:00
38 changed files with 662 additions and 275 deletions
+94 -2
View File
@@ -1,3 +1,4 @@
import { getActiveTabTestId } from "../support/helpers/launcher";
import type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
@@ -19,7 +20,10 @@ async function seedAnsweredAgent(title: string) {
initialPrompt: "Why does the chat stop following new output?",
});
await agent.client.waitForFinish(agent.agentId, 30_000);
await agent.client.sendAgentMessage(agent.agentId, "Make the fix and run the burst script.");
await agent.client.sendAgentMessage(
agent.agentId,
"Make the fix and run the burst script.\n" + "Keep the previous response visible.\n".repeat(40),
);
await agent.client.waitForFinish(agent.agentId, 30_000);
return agent;
}
@@ -36,17 +40,67 @@ async function rowsBetweenLatestSummaryAndReply(page: Page): Promise<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 +111,9 @@ test.describe("Turn collapse", () => {
await summary.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0);
await expect(summary).toBeInViewport();
await summary.evaluate((element) => element.scrollIntoView({ block: "start" }));
await page.screenshot({ path: "/tmp/turn-rail-aligned.png" });
await expect.poll(() => groupedToolsInsideLatestTurn(page)).toEqual([]);
await summary.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
@@ -65,3 +122,38 @@ test.describe("Turn collapse", () => {
}
});
});
test("received messages show their sender and open that agent", async ({ page }) => {
const recipient = await seedMockAgentWorkspace({
repoPrefix: "sender-attribution-",
title: "Implementation",
});
try {
const sender = await recipient.client.createAgent({
provider: "mock",
cwd: recipient.cwd,
workspaceId: recipient.workspaceId,
title: "Reviewer",
model: "e2e-fast-stream",
modeId: "load-test",
});
await recipient.client.sendAgentMessage(
recipient.agentId,
"I reviewed the turn-grouping changes. Heartbeats now keep their own timing footer, and the previous response stays visible.",
{ sourceAgentId: sender.id },
);
await openAgentRoute(page, recipient);
await expectComposerVisible(page);
const attribution = page.getByRole("button", { name: "Sent by Reviewer", exact: true });
await expect(attribution).toBeVisible();
await expect(page.getByTestId("user-message").filter({ has: attribution })).toContainText(
"I reviewed the turn-grouping changes.",
);
await attribution.scrollIntoViewIfNeeded();
await page.screenshot({ path: "/tmp/sender-attribution.png" });
await attribution.click();
await expect.poll(() => getActiveTabTestId(page)).toContain(sender.id);
} finally {
await recipient.cleanup();
}
});
@@ -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),
}),
};
}
+6 -6
View File
@@ -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([]);
+29 -26
View File
@@ -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,
+9 -9
View File
@@ -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,69 @@ describe("timeline presentation", () => {
expect(projectTimelineItems([source], () => [])).toEqual([]);
});
});
it("opening a turn reveals individual calls rather than another tool group", () => {
const present = createStreamPresentation();
const timestamp = new Date();
const tail: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
toolCall("one", "Bash"),
toolCall("two", "Bash"),
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
];
const result = present({
...presentationOptions,
transform: undefined,
tail,
head: [],
display: "collapseTurns",
isTurnActive: false,
expandedTurnHostIds: new Set(["prompt"]),
});
expect(result.tail.map((item) => item.id)).toEqual(tail.map((item) => item.id));
expect(result.groupsByHostId.size).toBe(0);
});
it("presents received agent messages as attributed bubbles with the original turn identity", () => {
const call = toolCall("received", "agent_message");
if (call.payload.source !== "agent") throw new Error("Expected agent tool");
call.turnId = "turn";
call.payload.data.agentMessage = {
event: "message",
sender: { id: "sender", title: "Reviewer" },
text: "Ready",
};
const result = createStreamPresentation()({
...presentationOptions,
transform: undefined,
tail: [call],
head: [],
});
expect(result.tail[0]).toMatchObject({
kind: "user_message",
id: call.id,
turnId: "turn",
text: "Ready",
sender: { id: "sender", title: "Reviewer" },
});
});
it("does not reopen the previous turn while a new run waits for its first row", () => {
const timestamp = new Date();
const tail: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
toolCall("one", "Bash"),
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
];
for (const item of tail) item.turnId = "completed";
const result = createStreamPresentation()({
...presentationOptions,
transform: undefined,
tail,
head: [],
display: "collapseTurns",
isTurnActive: true,
activeTurnId: "new",
});
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
});
+40 -2
View File
@@ -1,3 +1,4 @@
import { isTurnBoundary } from "./turn-membership";
import type { ChatDisplay } from "@/hooks/use-settings";
import type { TimelineItemTransform } from "@/plugins/timeline/model";
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
@@ -19,6 +20,7 @@ interface PresentationInput {
/** User messages whose collapsed turn the reader opened. */
expandedTurnHostIds: ReadonlySet<string>;
isTurnActive: boolean;
activeTurnId?: string | null;
}
export interface StreamPresentation extends ToolCallDetailProjection {
@@ -63,6 +65,22 @@ export function createStreamPresentation() {
let collapsed: TurnCollapse | undefined;
function presentRows(item: StreamItem): StreamItem[] {
if (item.kind === "tool_call" && item.payload.source === "agent") {
const message = item.payload.data.agentMessage;
if (message?.event === "message") {
return [
{
kind: "user_message",
id: item.id,
turnId: item.turnId,
timelineCursor: item.timelineCursor,
timestamp: item.timestamp,
text: message.text,
sender: message.sender,
},
];
}
}
return [item.kind === "user_message" ? presentUserMessage(item) : item];
}
@@ -75,7 +93,14 @@ export function createStreamPresentation() {
}
// Live text updates reach the head only, so the folded history is reused until the
// retained rows, the latest turn's liveness, or the reader's expanded turns change.
const isLatestTurnOpen = input.isTurnActive || projection.head.length > 0;
const isLatestTurnOpen =
(input.isTurnActive || projection.head.length > 0) &&
!isTurnBoundary(projection.tail.at(-1) ?? null, projection.head[0] ?? null) &&
!(
input.activeTurnId &&
projection.tail.at(-1)?.turnId &&
input.activeTurnId !== projection.tail.at(-1)?.turnId
);
if (
!collapsed ||
collapsedSource !== projection.tail ||
@@ -93,7 +118,20 @@ export function createStreamPresentation() {
collapsedExpandedHostIds = input.expandedTurnHostIds;
}
const { tail, ...turns } = collapsed;
return { ...projection, tail, turns };
if (turns.nestedItemIds.size === 0) return { ...projection, tail, turns };
// Opening a turn reveals its raw calls; those rows must not become group hosts again.
const withoutExpandedRows = (groups: StreamPresentation["groupsByHostId"]) => ({
size: groups.size - [...turns.nestedItemIds].filter((id) => groups.has(id)).length,
get: (id: string) => (turns.nestedItemIds.has(id) ? undefined : groups.get(id)),
has: (id: string) => !turns.nestedItemIds.has(id) && groups.has(id),
});
return {
...projection,
tail,
turns,
groupsByHostId: withoutExpandedRows(projection.groupsByHostId),
historyGroupUpdatesByHostId: withoutExpandedRows(projection.historyGroupUpdatesByHostId),
};
}
return (input: PresentationInput): StreamPresentation => {
@@ -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,
});
}
}
@@ -63,7 +63,6 @@ describe("collapseTurns", () => {
hostId: "u1",
expanded: false,
summary: {
messageCount: 1,
tools: expect.objectContaining({ commandCount: 2 }),
},
});
@@ -121,6 +120,34 @@ describe("collapseTurns", () => {
expect(result.turnsByHostId.size).toBe(0);
});
it("keeps a completed response folded when a promptless canonical turn starts", () => {
const first = [user("u1"), shell("c1"), reply("r1")].map((item) =>
Object.assign(item, { turnId: "first" }),
);
const next = { ...shell("heartbeat"), turnId: "second" };
const result = collapse([...first, next], { isLatestTurnOpen: true });
expect(ids(result.tail)).toEqual(["u1", "r1", "heartbeat"]);
expect(result.turnsByHostId.has("u1")).toBe(true);
});
it("keeps mid-turn messages visible when work is folded", () => {
const tail = [user("prompt"), shell("work"), user("steer"), shell("more"), reply("done")];
for (const item of tail) item.turnId = "same-turn";
const result = collapse(tail);
expect(ids(result.tail)).toEqual(["prompt", "steer", "done"]);
expect(result.hostIdByFoldedMessageId.has("steer")).toBe(false);
});
it("reveals every raw call inside an expanded turn", () => {
const calls = [shell("one"), shell("two")];
const result = collapse([user("prompt"), calls[0]!, reply("done")], {
expandedHostIds: new Set(["prompt"]),
getGroupedCalls: (id) => (id === "one" ? calls : undefined),
});
expect(ids(result.tail)).toEqual(["prompt", "one", "two", "done"]);
expect([...result.nestedItemIds]).toEqual(["one", "two"]);
});
it("counts every call a tool-call group row stands for", () => {
const group = [shell("c1"), shell("c2"), shell("c3")];
const result = collapse([user("u1"), group[0]!, reply("final")], {
@@ -1,13 +1,13 @@
import { isTurnBoundary, isTurnTrigger } from "../turn-membership";
import { getStreamItemMessageId, type StreamItem, type ToolCallItem } from "@/types/stream";
import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model";
export interface TurnSummary {
messageCount: number;
tools: OverviewSummary;
}
export interface CollapsedTurn {
/** The user message the turn answers. Its row carries the turn's summary line. */
/** The input or first loaded row that carries the turn's summary line. */
hostId: string;
expanded: boolean;
summary: TurnSummary;
@@ -40,16 +40,15 @@ function summarizeTurn(
getGroupedCalls: CollapseTurnsInput["getGroupedCalls"],
): TurnSummary {
const calls: ToolCallItem[] = [];
let messageCount = 0;
for (const item of items) {
if (item.kind === "assistant_message") messageCount += 1;
else if (item.kind === "tool_call") calls.push(...(getGroupedCalls(item.id) ?? [item]));
if (item.kind === "tool_call" && !isTurnTrigger(item))
calls.push(...(getGroupedCalls(item.id) ?? [item]));
}
return { messageCount, tools: summarizeToolCalls(calls) };
return { tools: summarizeToolCalls(calls) };
}
function hasWork(summary: TurnSummary): boolean {
return summary.messageCount > 0 || Object.values(summary.tools).some((count) => count > 0);
return Object.values(summary.tools).some((count) => count > 0);
}
function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number {
@@ -77,28 +76,43 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
const host = tail[runStart - 1];
const isClosed = runEnd < tail.length || !input.isLatestTurnOpen;
const replyIndex = findLastAssistantIndex(tail, runStart, runEnd);
if (host?.kind !== "user_message" || !isClosed || replyIndex <= runStart) {
if (
!host ||
(host.kind !== "user_message" && host.kind !== "tool_call") ||
!isClosed ||
replyIndex <= runStart
) {
output.push(...tail.slice(runStart, runEnd));
return;
}
const folded = tail.slice(runStart, replyIndex);
const summary = summarizeTurn(folded, input.getGroupedCalls);
if (!hasWork(summary)) {
if (!hasWork(summary) && !folded.some((item) => item.kind === "assistant_message")) {
output.push(...tail.slice(runStart, runEnd));
return;
}
const expanded = input.expandedHostIds.has(host.id);
turnsByHostId.set(host.id, { hostId: host.id, expanded, summary });
for (const item of folded) {
const revealed = expanded
? folded.flatMap<StreamItem>((item) =>
item.kind === "tool_call" ? [...(input.getGroupedCalls(item.id) ?? [item])] : [item],
)
: folded;
for (const item of revealed) {
if (isTurnTrigger(item)) continue;
hostIdByFoldedMessageId.set(getStreamItemMessageId(item), host.id);
if (expanded) nestedItemIds.add(item.id);
}
if (expanded) output.push(...folded);
output.push(...(expanded ? revealed : folded.filter(isTurnTrigger)));
output.push(...tail.slice(replyIndex, runEnd));
};
for (let index = 0; index < tail.length; index += 1) {
if (tail[index]?.kind !== "user_message") continue;
if (
!(index === 0 && isTurnTrigger(tail[index]!)) &&
!isTurnBoundary(tail[index - 1] ?? null, tail[index] ?? null)
)
continue;
flushRun(index);
output.push(tail[index]!);
runStart = index + 1;
@@ -114,7 +128,10 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
};
}
return {
tail: output.length === tail.length ? tail : output,
tail:
output.length === tail.length && output.every((item, index) => item === tail[index])
? tail
: output,
turnsByHostId,
nestedItemIds,
hostIdByFoldedMessageId,
@@ -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,
}),
};
}
@@ -76,7 +76,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 +88,11 @@ describe("canonical turn membership", () => {
const completed = layoutFor(items, false);
expect(completedFooterIds(completed.layout)).toEqual(["heartbeat-2-reply"]);
expect(completedFooterIds(completed.layout)).toEqual([
"first reply",
"heartbeat-1-reply",
"heartbeat-2-reply",
]);
expect(completed.layout.auxiliaryTurnFooter?.timing).toEqual({
completedAt: at(6),
durationMs: null,
@@ -123,7 +127,7 @@ describe("canonical turn membership", () => {
const completedItems = [...build(turnId), assistant("done", 9, turnId)];
const completed = layoutFor(completedItems, false);
expect(completedFooterIds(completed.layout)).toEqual(["done"]);
expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({
expect(completed.model.turnTiming.byItemId.get("done")).toMatchObject({
completedAt: at(9),
durationMs: 8000,
});
@@ -9,16 +9,15 @@ export function continuesTurn(previous: StreamItem | null, next: StreamItem | nu
if (previous.turnId !== undefined && next.turnId !== undefined) {
return previous.turnId === next.turnId;
}
return next.kind !== "user_message";
return !isTurnTrigger(next);
}
/**
* A visible response can span multiple canonical turns when their prompts are
* system-injected and therefore absent from the Paseo timeline.
* Every canonical turn owns its response, including system-triggered runs.
*/
export function continuesResponse(previous: StreamItem | null, next: StreamItem | null): boolean {
if (!previous || !next) return false;
return continuesTurn(previous, next) || next.kind !== "user_message";
return continuesTurn(previous, next);
}
export function isTurnBoundary(previous: StreamItem | null, next: StreamItem | null): boolean {
@@ -37,3 +36,13 @@ export function startsNewTurn(item: StreamItem, previous: StreamItem | null): bo
export function belongsToTurn(item: StreamItem, turnId: string | null): boolean {
return turnId !== null && item.turnId === turnId;
}
/** Inputs that start a turn when older history has no canonical turn IDs. */
export function isTurnTrigger(item: StreamItem): boolean {
if (item.kind === "user_message") return true;
return (
item.kind === "tool_call" &&
item.payload.source === "agent" &&
(item.payload.data.agentMessage !== undefined || item.payload.data.name === "heartbeat")
);
}
+32 -6
View File
@@ -526,6 +526,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
display: chatDisplay,
expandedTurnHostIds,
isTurnActive,
activeTurnId: effectiveTurnPresentation.turnId,
}),
[
presentStream,
@@ -535,6 +536,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
chatDisplay,
expandedTurnHostIds,
isTurnActive,
effectiveTurnPresentation.turnId,
],
);
const {
@@ -576,12 +578,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 +724,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 +790,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 +802,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 +836,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 +867,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
@@ -917,7 +931,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return renderThoughtItem(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} />;
@@ -949,6 +973,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
renderAssistantMessageItem,
renderThoughtItem,
renderToolCallItem,
getCollapsedTurn,
setTurnExpanded,
resolvedServerId,
],
);
+3 -4
View File
@@ -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;
+48 -40
View File
@@ -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
+2 -1
View File
@@ -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] },
}));
+4 -4
View File
@@ -2018,10 +2018,10 @@ export const ar: TranslationResources = {
output: "الإخراج",
},
turnSummary: {
messages: {
one: "كتب {{count}} رسالة",
other: "كتب {{count}} رسائل",
},
details: "عرض تفاصيل الجولة",
sentBy: "أرسلها {{name}}",
showMore: "عرض المزيد",
showLess: "عرض أقل",
},
toolCallGroup: {
editedFiles: {
+4 -4
View File
@@ -2043,10 +2043,10 @@ export const en = {
output: "Output",
},
turnSummary: {
messages: {
one: "wrote {{count}} message",
other: "wrote {{count}} messages",
},
details: "View turn details",
sentBy: "Sent by {{name}}",
showMore: "Show more",
showLess: "Show less",
},
toolCallGroup: {
editedFiles: {
+4 -4
View File
@@ -2066,10 +2066,10 @@ export const es: TranslationResources = {
output: "Producción",
},
turnSummary: {
messages: {
one: "escribió {{count}} mensaje",
other: "escribió {{count}} mensajes",
},
details: "Ver detalles del turno",
sentBy: "Enviado por {{name}}",
showMore: "Mostrar más",
showLess: "Mostrar menos",
},
toolCallGroup: {
editedFiles: {
+4 -4
View File
@@ -2065,10 +2065,10 @@ export const fr: TranslationResources = {
output: "Sortie",
},
turnSummary: {
messages: {
one: "{{count}} message écrit",
other: "{{count}} messages écrits",
},
details: "Voir les détails du tour",
sentBy: "Envoyé par {{name}}",
showMore: "Afficher plus",
showLess: "Afficher moins",
},
toolCallGroup: {
editedFiles: {
+4 -4
View File
@@ -2038,10 +2038,10 @@ export const ja: TranslationResources = {
output: "出力",
},
turnSummary: {
messages: {
one: "{{count}}件のメッセージを送信",
other: "{{count}}件のメッセージを送信",
},
details: "ターンの詳細を表示",
sentBy: "{{name}} から送信",
showMore: "もっと見る",
showLess: "折りたたむ",
},
toolCallGroup: {
editedFiles: {
+4 -4
View File
@@ -2028,10 +2028,10 @@ export const ko: TranslationResources = {
output: "출력",
},
turnSummary: {
messages: {
one: "{{count}} 메시지를 작성했습니다.",
other: "{{count}} 메시지를 작성했습니다.",
},
details: "턴 세부 정보 보기",
sentBy: "{{name}}이(가) 보냄",
showMore: "더 보기",
showLess: "접기",
},
toolCallGroup: {
editedFiles: {
+4 -4
View File
@@ -2051,10 +2051,10 @@ export const ptBR: TranslationResources = {
output: "Saída",
},
turnSummary: {
messages: {
one: "escreveu {{count}} mensagem",
other: "escreveu {{count}} mensagens",
},
details: "Ver detalhes do turno",
sentBy: "Enviado por {{name}}",
showMore: "Mostrar mais",
showLess: "Mostrar menos",
},
toolCallGroup: {
editedFiles: {
+4 -4
View File
@@ -2051,10 +2051,10 @@ export const ru: TranslationResources = {
output: "Выходные данные",
},
turnSummary: {
messages: {
one: "написано {{count}} сообщение",
other: "написаны сообщения ({{count}})",
},
details: "Показать подробности хода",
sentBy: "Отправлено агентом {{name}}",
showMore: "Показать больше",
showLess: "Свернуть",
},
toolCallGroup: {
editedFiles: {
+4 -4
View File
@@ -1996,10 +1996,10 @@ export const zhCN: TranslationResources = {
output: "输出",
},
turnSummary: {
messages: {
one: "写了 {{count}} 条消息",
other: "写了 {{count}} 条消息",
},
details: "查看轮次详情",
sentBy: "由 {{name}} 发送",
showMore: "展开",
showLess: "收起",
},
toolCallGroup: {
editedFiles: {
+44 -16
View File
@@ -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);
});
+17 -13
View File
@@ -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,
};
}
@@ -1,3 +1,4 @@
import { isTurnTrigger, isTurnBoundary } from "@/agent-stream/turn-membership";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import type { StreamItem, ToolCallItem } from "@/types/stream";
@@ -63,7 +64,7 @@ export function describeToolCall(item: ToolCallItem): ToolCallDescriptor {
}
export function isGroupableToolCall(item: StreamItem): item is ToolCallItem {
if (item.kind !== "tool_call") {
if (item.kind !== "tool_call" || isTurnTrigger(item)) {
return false;
}
const descriptor = describeToolCall(item);
@@ -116,7 +117,7 @@ export function prepareGroupedHistory<TGroup>(input: {
let pending: ToolCallItem[] = [];
for (const item of input.tail) {
if (isGroupableToolCall(item)) {
if (isGroupableToolCall(item) && !isTurnBoundary(pending.at(-1) ?? null, item)) {
pending.push(item);
continue;
}
@@ -128,7 +129,8 @@ export function prepareGroupedHistory<TGroup>(input: {
buildGroup: input.buildGroup,
});
pending = [];
output.push(item);
if (isGroupableToolCall(item)) pending.push(item);
else output.push(item);
}
appendRun({
@@ -175,6 +177,7 @@ export function groupLiveToolCalls<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,3 +480,24 @@ describe("tool call detail-level projection", () => {
expect(result.groupsByHostId.size).toBe(1);
});
});
it("keeps a heartbeat trigger visible outside grouped work", () => {
const trigger = toolCall(
"1",
{ type: "plain_text", text: "Check progress" },
{ name: "heartbeat" },
);
const work = toolCall("2", { type: "shell", command: "pwd" });
const result = project({ level: "overview", tail: [trigger, work] });
expect(result.tail.map((item) => item.id)).toEqual(["1", "2"]);
expect(result.groupsByHostId.has("1")).toBe(false);
});
it("does not merge tools from different canonical turns", () => {
const first = toolCall("1", { type: "shell", command: "pwd" });
first.turnId = "first";
const second = toolCall("2", { type: "shell", command: "ls" });
second.turnId = "second";
const result = project({ level: "overview", tail: [first, second] });
expect(result.tail.map((item) => item.id)).toEqual(["1", "2"]);
});
+2
View File
@@ -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;
@@ -11341,7 +11341,7 @@ test("listImportableSessions searches every provider result before global rankin
]);
});
test("user_message events wrapping a paseo-system envelope are not added to the timeline", async () => {
test("system-injected prompts appear as trigger tools in the live timeline", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-live-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -11370,13 +11370,16 @@ test("user_message events wrapping a paseo-system envelope are not added to the
await manager.runAgent(snapshot.id, { text: "do something" });
const timeline = manager.getTimeline(snapshot.id);
expect(timeline).toContainEqual(
expect.objectContaining({ type: "tool_call", name: "heartbeat", status: "completed" }),
);
const userMessages = timeline.filter((item) => item.type === "user_message");
expect(userMessages).toHaveLength(1);
expect(userMessages[0].text).toBe("plain user message");
});
test("user_message events wrapping a paseo-system envelope are not restored during history replay", async () => {
test("system-injected prompts appear as trigger tools during history replay", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-history-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -11411,6 +11414,9 @@ test("user_message events wrapping a paseo-system envelope are not restored duri
await manager.hydrateTimelineFromProvider(snapshot.id, { force: true });
const timeline = manager.getTimeline(snapshot.id);
expect(timeline).toContainEqual(
expect.objectContaining({ type: "tool_call", name: "heartbeat", status: "completed" }),
);
const userMessages = timeline.filter((item) => item.type === "user_message");
expect(userMessages).toHaveLength(1);
@@ -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" }),
);
});
@@ -8,7 +8,6 @@ import {
existsSync,
truncateSync,
readdirSync,
symlinkSync,
} from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
@@ -24,71 +23,7 @@ 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");