mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da3472fe51 | ||
|
|
e34aa277a2 |
@@ -27,6 +27,10 @@ import {
|
||||
seedRealisticOlderTimeline,
|
||||
sendLiveTurnBeforeHydration,
|
||||
expectTimelineViewportAnchoredAfterPrepend,
|
||||
expectToolCallGroupPositionPreserved,
|
||||
rememberToolCallGroupPosition,
|
||||
seedItemPagedToolGroupTimeline,
|
||||
useChatDisplay,
|
||||
userNavigatesTimelineToHistoryStartWithKeyboard,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
@@ -100,6 +104,32 @@ test.describe("Agent timeline pagination", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Hosts paged by items can end a page inside a tool run. The older page extends the group
|
||||
// the reader is on, which changes the row hosting it; the reader must stay with that group
|
||||
// rather than land at history start and drain every older page.
|
||||
test("keeps the reading position when an older item page extends the tool-call group being read", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await useChatDisplay(page, "collapseToolCalls");
|
||||
const agent = await seedItemPagedToolGroupTimeline();
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent, { itemPagedHost: true });
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
const reading = await rememberToolCallGroupPosition(page, agent.loadedCommands);
|
||||
|
||||
history.releasePage(1);
|
||||
await expectToolCallGroupPositionPreserved(page, agent.totalCommands, reading);
|
||||
await history.expectSettledWithRequestedPages(1);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("loads older history when the user navigates to history start with the keyboard", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -5,22 +5,86 @@ import {
|
||||
getTerminalBufferText,
|
||||
} from "../support/helpers/terminal-perf";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { metroTest as test, expect } from "../support/fixtures";
|
||||
import { metroTest as test, expect, type Page } from "../support/fixtures";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import type { MockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectFoldedTurnAtReadingPosition,
|
||||
expectOldestLoadedTurnPartial,
|
||||
holdOlderHistoryPages,
|
||||
openAgentTimeline,
|
||||
expectTimelinePromptVisible,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
|
||||
interface PublishedDaemonAgent {
|
||||
serverId: string;
|
||||
daemon: Awaited<ReturnType<typeof startIsolatedHostDaemon>>;
|
||||
agent: MockAgentWorkspace;
|
||||
/** Register the host in the browser and apply app settings before the first load. */
|
||||
seedBrowser(page: Page, appSettings?: Record<string, unknown>): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
async function startPublishedDaemonAgent(options: {
|
||||
publishedVersion: string;
|
||||
serverIdPrefix: string;
|
||||
repoPrefix: string;
|
||||
title: string;
|
||||
}): Promise<PublishedDaemonAgent> {
|
||||
const serverId = `${options.serverIdPrefix}${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
||||
const daemon = await startIsolatedHostDaemon(serverId, {
|
||||
publishedVersion: options.publishedVersion,
|
||||
});
|
||||
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: daemon.port });
|
||||
const createdAgent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: options.title,
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const agent: MockAgentWorkspace = {
|
||||
agentId: createdAgent.id,
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: workspace.repoPath,
|
||||
client: workspace.client,
|
||||
cleanup: workspace.cleanup,
|
||||
};
|
||||
return {
|
||||
serverId,
|
||||
daemon,
|
||||
agent,
|
||||
async seedBrowser(page, appSettings) {
|
||||
const host = buildSeededHost({
|
||||
serverId,
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
await page.addInitScript(
|
||||
({ seededHost, preferences, settings }) => {
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
if (settings) localStorage.setItem("@paseo:app-settings", JSON.stringify(settings));
|
||||
},
|
||||
{ seededHost: host, preferences: buildCreateAgentPreferences(), settings: appSettings },
|
||||
);
|
||||
},
|
||||
async close() {
|
||||
await agent.cleanup();
|
||||
await daemon.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("loads history across browser reconnection while retaining old-daemon connections and terminals", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
||||
const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" });
|
||||
const observationSockets = new Set<unknown>();
|
||||
page.on("websocket", (socket) => {
|
||||
socket.on("framesent", ({ payload }) => {
|
||||
@@ -34,43 +98,18 @@ test("loads history across browser reconnection while retaining old-daemon conne
|
||||
observationSockets.add(socket);
|
||||
});
|
||||
});
|
||||
const workspace = await seedWorkspace({
|
||||
const published = await startPublishedDaemonAgent({
|
||||
publishedVersion: "0.2.5",
|
||||
serverIdPrefix: "srv_old_pagination_",
|
||||
repoPrefix: "timeline-old-daemon-pagination-",
|
||||
port: daemon.port,
|
||||
});
|
||||
const createdAgent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Published daemon pagination regression",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const agent: MockAgentWorkspace = {
|
||||
agentId: createdAgent.id,
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: workspace.repoPath,
|
||||
client: workspace.client,
|
||||
cleanup: workspace.cleanup,
|
||||
};
|
||||
const { agent, serverId } = published;
|
||||
|
||||
try {
|
||||
await agent.client.sendAgentMessage(agent.agentId, "emit 1 coalesced agent stream updates");
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
|
||||
const host = buildSeededHost({
|
||||
serverId,
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
await page.addInitScript(
|
||||
({ seededHost, preferences }) => {
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ seededHost: host, preferences: buildCreateAgentPreferences() },
|
||||
);
|
||||
await published.seedBrowser(page);
|
||||
|
||||
await openAgentTimeline(page, agent, serverId);
|
||||
await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates");
|
||||
@@ -87,7 +126,7 @@ test("loads history across browser reconnection while retaining old-daemon conne
|
||||
const terminal = await agent.client.createTerminal(agent.cwd, "Compatibility terminal");
|
||||
expect(terminal.error).toBeNull();
|
||||
const terminalId = terminal.terminal!.id;
|
||||
const route = buildHostWorkspaceRoute(serverId, workspace.workspaceId);
|
||||
const route = buildHostWorkspaceRoute(serverId, agent.workspaceId);
|
||||
await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`);
|
||||
await focusTerminalSurface(page);
|
||||
await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n");
|
||||
@@ -97,7 +136,46 @@ test("loads history across browser reconnection while retaining old-daemon conne
|
||||
await agent.client.killTerminal(terminalId);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
await daemon.close();
|
||||
await published.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Hosts without whole-turn pages split turns across item pages. The oldest loaded turn
|
||||
// starts mid-turn and only folds once the next page completes it; the reader must stay
|
||||
// with that turn instead of landing at history start and draining every older page.
|
||||
test("keeps the reader on the turn that folds when an older item page completes it", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
const published = await startPublishedDaemonAgent({
|
||||
publishedVersion: "0.13.0-beta.2",
|
||||
serverIdPrefix: "srv_old_fold_",
|
||||
repoPrefix: "timeline-old-daemon-fold-",
|
||||
title: "Published daemon folded turn pagination",
|
||||
});
|
||||
const { agent, serverId } = published;
|
||||
const prompts = Array.from({ length: 5 }, (_, index) => `Realistic turn ${index}: inspect it`);
|
||||
|
||||
try {
|
||||
for (const prompt of prompts) {
|
||||
await agent.client.sendAgentMessage(agent.agentId, prompt);
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
}
|
||||
await published.seedBrowser(page, { chatDisplay: "collapseTurns" });
|
||||
const history = await holdOlderHistoryPages(page, agent, {
|
||||
daemonPort: published.daemon.port,
|
||||
});
|
||||
await openAgentTimeline(page, agent, serverId);
|
||||
await expectTimelinePromptVisible(page, prompts.at(-1)!);
|
||||
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
const partialTurnPrompt = await expectOldestLoadedTurnPartial(page, prompts);
|
||||
|
||||
history.releasePage(1);
|
||||
await expectFoldedTurnAtReadingPosition(page, partialTurnPrompt);
|
||||
await history.expectSettledWithRequestedPages(1);
|
||||
} finally {
|
||||
await published.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -193,6 +193,7 @@ test("received messages show their sender and open that agent", async ({ page })
|
||||
.getByText("Sent by Reviewer", { exact: true })
|
||||
.evaluate((element) => getComputedStyle(element).color);
|
||||
await expect(attribution.locator("svg")).toHaveCSS("fill", labelColor);
|
||||
await expectSenderAlignedWithBubble(attribution);
|
||||
await attribution.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: "/tmp/sender-attribution.png" });
|
||||
await attribution.click();
|
||||
@@ -202,6 +203,22 @@ test("received messages show their sender and open that agent", async ({ page })
|
||||
}
|
||||
});
|
||||
|
||||
async function expectSenderAlignedWithBubble(attribution: Locator) {
|
||||
const edges = await attribution.evaluate((button) => {
|
||||
const bubble = button.nextElementSibling;
|
||||
const label = button.querySelector('[dir="auto"]');
|
||||
if (!bubble || !label) throw new Error("Sender label or message bubble missing");
|
||||
return {
|
||||
labelRight: label.getBoundingClientRect().right,
|
||||
bubbleRight: bubble.getBoundingClientRect().right,
|
||||
};
|
||||
});
|
||||
expect(
|
||||
Math.abs(edges.labelRight - edges.bubbleRight),
|
||||
"sender and bubble share the right edge",
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
async function seedWholeTurnHistory() {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "whole-turn-history-",
|
||||
@@ -301,6 +318,7 @@ for (const autoExpandReasoning of [false, true]) {
|
||||
await expectComposerVisible(page);
|
||||
const summary = page.getByTestId("turn-summary").last().getByRole("button");
|
||||
await expect(summary).toBeVisible();
|
||||
await expect(summary).not.toContainText(/thought/i);
|
||||
await expect(reasoning).toHaveCount(0);
|
||||
await summary.click();
|
||||
await expect(reasoning).toHaveCount(autoExpandReasoning ? 1 : 0);
|
||||
@@ -339,9 +357,10 @@ async function expectThinkingPresentation(page: Page, autoExpandReasoning: boole
|
||||
await expect(reasoning).toHaveCount(0);
|
||||
const group = page
|
||||
.getByTestId("tool-call-group")
|
||||
.filter({ hasText: /thought/, visible: true })
|
||||
.filter({ hasText: /read 1 file/i, visible: true })
|
||||
.last();
|
||||
await expect(group).toContainText(/read 1 file/i);
|
||||
await expect(group.getByRole("button").first()).not.toContainText(/thought/i);
|
||||
await group.getByRole("button").first().click();
|
||||
await expect(reasoning).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -338,11 +338,32 @@ export async function delayAgentOlderTimelineResponse(
|
||||
return delayAgentTimelineResponse(page, agentId, "before");
|
||||
}
|
||||
|
||||
export interface OlderTimelinePagesOptions {
|
||||
daemonPort?: number;
|
||||
/**
|
||||
* Hide whole-turn paging from the app so it reads this host with item pages, as it reads
|
||||
* daemons released before whole-turn pages. An item page can end inside a turn or a tool run.
|
||||
*/
|
||||
itemPagedHost?: boolean;
|
||||
}
|
||||
|
||||
function withoutWholeTurnPages(message: WebSocketMessage): WebSocketMessage {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
if (sessionMessage?.type !== "status" || payload?.status !== "server_info") return message;
|
||||
const envelope = parseWebSocketJson(message) as {
|
||||
message: { payload: { features?: Record<string, unknown> } };
|
||||
};
|
||||
delete envelope.message.payload.features?.wholeTurnTimeline;
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
export async function holdAgentOlderTimelinePages(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
daemonPort?: number,
|
||||
options: OlderTimelinePagesOptions = {},
|
||||
): Promise<OlderTimelinePagesGate> {
|
||||
const { daemonPort } = options;
|
||||
let requestCount = 0;
|
||||
let responseCount = 0;
|
||||
let repeatedEntryCount = 0;
|
||||
@@ -376,7 +397,8 @@ export async function holdAgentOlderTimelinePages(
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => {
|
||||
server.onMessage((received) => {
|
||||
const message = options.itemPagedHost ? withoutWholeTurnPages(received) : received;
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
if (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type TestInfo, type Page } from "@playwright/test";
|
||||
import { expect, type Locator, type TestInfo, type Page } from "@playwright/test";
|
||||
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import { readReplicaCache } from "./replica-cache-storage";
|
||||
import {
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type AgentTimelineResponseGate,
|
||||
type BootstrapTimelineGate,
|
||||
type OlderTimelinePagesGate,
|
||||
type OlderTimelinePagesOptions,
|
||||
} from "./agent-timeline-gate";
|
||||
|
||||
export { holdDaemonHydration };
|
||||
@@ -35,6 +36,19 @@ interface RealisticOlderTimelineAgent extends MockAgentWorkspace {
|
||||
newestPrompt: string;
|
||||
}
|
||||
|
||||
export interface ItemPagedToolGroupAgent extends MockAgentWorkspace {
|
||||
newestPrompt: string;
|
||||
/** Commands in one run, more than an item page holds. */
|
||||
totalCommands: number;
|
||||
/** Commands of that run the initial tail loads, so the size of the group as first shown. */
|
||||
loadedCommands: number;
|
||||
}
|
||||
|
||||
interface ToolCallGroupPositionSnapshot {
|
||||
top: number;
|
||||
}
|
||||
|
||||
const ITEM_PAGE_SIZE = 40;
|
||||
const PROMPT_PREFIX = "timeline-pagination-turn";
|
||||
const LIVE_BEFORE_HYDRATION_PROMPT = "timeline live before authoritative hydration";
|
||||
const HISTORY_START_THRESHOLD_PX = 96;
|
||||
@@ -126,6 +140,93 @@ export async function seedRealisticOlderTimeline(): Promise<RealisticOlderTimeli
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A run of commands longer than an item page, followed by enough short turns that the
|
||||
* initial tail ends inside the run. Read with item pages, the tail loads the newest
|
||||
* commands as one group; the older page carries the rest of that run and the turns before
|
||||
* it, with a further page behind it.
|
||||
*/
|
||||
export async function seedItemPagedToolGroupTimeline(): Promise<ItemPagedToolGroupAgent> {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "timeline-item-page-tool-group-",
|
||||
title: "Item page tool group regression",
|
||||
});
|
||||
const olderTurns = 10;
|
||||
const newerTurns = 12;
|
||||
const totalCommands = 50;
|
||||
// A short turn is its prompt and one reply; the run's turn ends with one reply.
|
||||
const loadedCommands = ITEM_PAGE_SIZE - newerTurns * 2 - 1;
|
||||
const shortTurnPrompt = (label: string, index: number) =>
|
||||
`item-page-${label}-turn-${index}: emit 1 coalesced agent stream updates`;
|
||||
const sendTurn = async (prompt: string) => {
|
||||
await agent.client.sendAgentMessage(agent.agentId, prompt);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
};
|
||||
|
||||
for (let index = 0; index < olderTurns; index += 1) {
|
||||
await sendTurn(shortTurnPrompt("older", index));
|
||||
}
|
||||
await sendTurn(`emit ${totalCommands} tool agent stream updates`);
|
||||
for (let index = 0; index < newerTurns; index += 1) {
|
||||
await sendTurn(shortTurnPrompt("newer", index));
|
||||
}
|
||||
|
||||
return {
|
||||
...agent,
|
||||
newestPrompt: shortTurnPrompt("newer", newerTurns - 1),
|
||||
totalCommands,
|
||||
loadedCommands,
|
||||
};
|
||||
}
|
||||
|
||||
export async function useChatDisplay(
|
||||
page: Page,
|
||||
chatDisplay: "full" | "collapseToolCalls" | "collapseTurns",
|
||||
): Promise<void> {
|
||||
await page.addInitScript((value) => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: value }));
|
||||
}, chatDisplay);
|
||||
}
|
||||
|
||||
function toolCallGroup(page: Page, commandCount: number): Locator {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
return timeline
|
||||
.getByTestId("tool-call-group")
|
||||
.getByRole("button", { name: `Ran ${commandCount} commands`, exact: true });
|
||||
}
|
||||
|
||||
export async function rememberToolCallGroupPosition(
|
||||
page: Page,
|
||||
commandCount: number,
|
||||
): Promise<ToolCallGroupPositionSnapshot> {
|
||||
const group = toolCallGroup(page, commandCount);
|
||||
await expect(group).toBeVisible();
|
||||
const box = await group.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error(`Expected a rendered tool-call group of ${commandCount} commands`);
|
||||
}
|
||||
return { top: box.y };
|
||||
}
|
||||
|
||||
/** The group the reader was on keeps its place on screen, now counting the older page's calls. */
|
||||
export async function expectToolCallGroupPositionPreserved(
|
||||
page: Page,
|
||||
commandCount: number,
|
||||
before: ToolCallGroupPositionSnapshot,
|
||||
): Promise<void> {
|
||||
const group = toolCallGroup(page, commandCount);
|
||||
await expect(group).toBeVisible();
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const box = await group.boundingBox();
|
||||
return box ? Math.abs(box.y - before.top) : Number.POSITIVE_INFINITY;
|
||||
},
|
||||
{ message: `the group of ${commandCount} commands keeps its viewport position` },
|
||||
)
|
||||
.toBeLessThanOrEqual(2);
|
||||
}
|
||||
|
||||
export async function scrollThroughOlderHistoryPages(
|
||||
page: Page,
|
||||
pageCount: number,
|
||||
@@ -316,13 +417,14 @@ export async function holdNextOlderTimelinePage(
|
||||
export async function holdOlderHistoryPages(
|
||||
page: Page,
|
||||
agent: MockAgentWorkspace,
|
||||
daemonPort?: number,
|
||||
options?: OlderTimelinePagesOptions,
|
||||
): Promise<OlderHistoryPages> {
|
||||
const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(
|
||||
page,
|
||||
agent.agentId,
|
||||
daemonPort,
|
||||
options,
|
||||
);
|
||||
const loader = page.getByTestId("load-older-history-spinner");
|
||||
return {
|
||||
async expectRequestedPages(count) {
|
||||
await gate.waitForRequestCount(count);
|
||||
@@ -330,7 +432,17 @@ export async function holdOlderHistoryPages(
|
||||
},
|
||||
async expectSettledWithRequestedPages(count) {
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
expect(gate.getRequestCount()).toBe(count);
|
||||
// The loader stays until the prepend settles and the viewport decides whether to
|
||||
// keep loading. Once it hides, no further page loads without new user intent.
|
||||
await expect
|
||||
.poll(
|
||||
async () => ({
|
||||
requestedPages: gate.getRequestCount(),
|
||||
loading: await loader.isVisible(),
|
||||
}),
|
||||
{ message: `older history settles after ${count} requested page(s)` },
|
||||
)
|
||||
.toEqual({ requestedPages: count, loading: false });
|
||||
},
|
||||
expectNoRepeatedEntries() {
|
||||
expect(gate.getRepeatedEntryCount()).toBe(0);
|
||||
@@ -423,6 +535,53 @@ export async function expectTimelineViewportAnchoredAfterPrepend(
|
||||
expect(Math.abs(contentGrowth - scrollAdjustment)).toBeLessThanOrEqual(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item-based pages from older hosts split turns, so the oldest loaded turn starts
|
||||
* mid-turn: its prompt is above loaded history. Returns that prompt.
|
||||
*/
|
||||
export async function expectOldestLoadedTurnPartial(
|
||||
page: Page,
|
||||
prompts: readonly string[],
|
||||
): Promise<string> {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
const oldestLoadedPrompt = await timeline
|
||||
.getByTestId("user-message")
|
||||
.first()
|
||||
.evaluate((element) => element.textContent ?? "");
|
||||
const loadedIndex = prompts.findIndex((prompt) => oldestLoadedPrompt.includes(prompt));
|
||||
expect(loadedIndex, "oldest loaded prompt is a seeded turn").toBeGreaterThan(0);
|
||||
const oldestRow = timeline.locator("[data-history-row-id]").first();
|
||||
await expect(oldestRow.getByTestId("user-message"), "rows above the oldest prompt").toHaveCount(
|
||||
0,
|
||||
);
|
||||
const partialPrompt = prompts[loadedIndex - 1]!;
|
||||
await expectTimelinePromptNotMounted(page, partialPrompt);
|
||||
return partialPrompt;
|
||||
}
|
||||
|
||||
/** The reader stays with the turn they were reading once it folds behind its summary. */
|
||||
export async function expectFoldedTurnAtReadingPosition(page: Page, prompt: string): Promise<void> {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
const promptRow = timeline.locator("[data-history-row-id]").filter({ hasText: prompt });
|
||||
await expect(promptRow).toBeVisible({ timeout: 15_000 });
|
||||
// The host row carries the folded turn's summary line.
|
||||
await expect(promptRow.getByTestId("turn-summary")).toBeVisible();
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const [timelineBox, promptBox] = await Promise.all([
|
||||
timeline.boundingBox(),
|
||||
promptRow.boundingBox(),
|
||||
]);
|
||||
if (!timelineBox || !promptBox) return false;
|
||||
const offset = promptBox.y - timelineBox.y;
|
||||
return offset >= 0 && offset <= timelineBox.height / 2;
|
||||
},
|
||||
{ message: "folded turn sits in the upper half of the viewport" },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function expectTimelineAtHistoryStart(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => (await readTimelineViewport(page)).scrollTop)
|
||||
|
||||
@@ -15,6 +15,7 @@ import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAgentStreamRenderModel } from "./model";
|
||||
import { createStreamPresentation } from "./presentation";
|
||||
import { createReadingAnchor } from "./reading-anchor";
|
||||
|
||||
const runtime = {
|
||||
paseo: {},
|
||||
@@ -609,8 +610,54 @@ it("opening a turn reveals individual calls rather than another tool group", ()
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual(tail.map((item) => item.id));
|
||||
expect(result.groupsByHostId.size).toBe(0);
|
||||
expect(result.readingRowReplacements.get("tool-two")).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["collapseToolCalls", "collapseTurns"] as const)(
|
||||
"preserves the reader when a prepend extends a group in %s",
|
||||
(display) => {
|
||||
const present = createStreamPresentation();
|
||||
const anchor = createReadingAnchor();
|
||||
const calls = [toolCall("one", "Bash"), toolCall("two", "Bash")];
|
||||
const input = {
|
||||
...presentationOptions,
|
||||
transform: undefined,
|
||||
display,
|
||||
head: [],
|
||||
isTurnActive: false,
|
||||
};
|
||||
const partial = present({ ...input, tail: calls.slice(1) });
|
||||
anchor.reconcile(0, [{ id: partial.tail[0]!.id, top: 48, height: 200 }]);
|
||||
const completed = present({ ...input, tail: calls });
|
||||
const groupId = completed.tail[0]!.id;
|
||||
expect(anchor.getRowId(completed.readingRowReplacements)).toBe(groupId);
|
||||
expect(
|
||||
anchor.reconcile(
|
||||
0,
|
||||
[{ id: groupId, top: 448, height: 200 }],
|
||||
false,
|
||||
completed.readingRowReplacements,
|
||||
),
|
||||
).toBe(400);
|
||||
expect(anchor.getRowId()).toBe(groupId);
|
||||
const timestamp = new Date();
|
||||
const folded = present({
|
||||
...input,
|
||||
display: "collapseTurns",
|
||||
tail: [
|
||||
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
|
||||
...calls,
|
||||
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
|
||||
],
|
||||
});
|
||||
// Both the original partial group and its replacement resolve directly to the turn.
|
||||
expect(folded.readingRowReplacements.get(calls[1]!.id)).toBe("prompt");
|
||||
expect(anchor.getRowId(folded.readingRowReplacements)).toBe("prompt");
|
||||
const opened = present({ ...input, tail: calls, display: "full" });
|
||||
expect(opened.readingRowReplacements.get(calls[1]!.id)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
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");
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/tool-calls/detail-level/projection";
|
||||
import type { StreamItem, UserMessageItem } from "@/types/stream";
|
||||
import { collapseTurns, type TurnCollapse } from "./turn-collapse/model";
|
||||
import type { ReadingRowReplacements } from "./reading-anchor";
|
||||
|
||||
interface PresentationInput {
|
||||
tail: StreamItem[];
|
||||
@@ -26,6 +27,22 @@ interface PresentationInput {
|
||||
|
||||
export interface StreamPresentation extends ToolCallDetailProjection {
|
||||
turns: Omit<TurnCollapse, "tail">;
|
||||
readingRowReplacements: ReadingRowReplacements;
|
||||
}
|
||||
|
||||
function groupedRowHosts(
|
||||
rows: readonly StreamItem[],
|
||||
groups: ToolCallDetailProjection["groupsByHostId"],
|
||||
): ReadonlyMap<string, string> {
|
||||
const hosts = new Map<string, string>();
|
||||
for (const row of rows) {
|
||||
const group = groups.get(row.id);
|
||||
if (!group) continue;
|
||||
for (const call of group.run.calls) {
|
||||
if (call.id !== row.id) hosts.set(call.id, row.id);
|
||||
}
|
||||
}
|
||||
return hosts;
|
||||
}
|
||||
|
||||
const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({
|
||||
@@ -61,6 +78,7 @@ export function createStreamPresentation() {
|
||||
let preparedAutoExpandReasoning: boolean | undefined;
|
||||
let preparedLevel: ToolCallDetailLevel | undefined;
|
||||
let preparedHistory: PreparedToolCallHistory | null = null;
|
||||
let historyGroupHosts: ReadonlyMap<string, string> = new Map();
|
||||
let collapsedSource: StreamItem[] | undefined;
|
||||
let collapsedIsLatestTurnOpen = false;
|
||||
let collapsedExpandedHostIds: ReadonlySet<string> | undefined;
|
||||
@@ -89,7 +107,7 @@ export function createStreamPresentation() {
|
||||
function collapse(
|
||||
projection: ToolCallDetailProjection,
|
||||
input: PresentationInput,
|
||||
): StreamPresentation {
|
||||
): Omit<StreamPresentation, "readingRowReplacements"> {
|
||||
if (input.display !== "collapseTurns") {
|
||||
return { ...projection, turns: NO_COLLAPSED_TURNS };
|
||||
}
|
||||
@@ -157,19 +175,42 @@ export function createStreamPresentation() {
|
||||
preparedAutoExpandReasoning !== input.autoExpandReasoning
|
||||
) {
|
||||
preparedHistory = prepareToolCallHistory(level, historyRows, input.autoExpandReasoning);
|
||||
historyGroupHosts = preparedHistory
|
||||
? groupedRowHosts(preparedHistory.grouped.tail, preparedHistory.grouped.groupsByHostId)
|
||||
: new Map();
|
||||
preparedTail = historyRows;
|
||||
preparedLevel = level;
|
||||
preparedAutoExpandReasoning = input.autoExpandReasoning;
|
||||
}
|
||||
return collapse(
|
||||
projectToolCallDetailLevel({
|
||||
level,
|
||||
tail: historyRows,
|
||||
head,
|
||||
preparedHistory,
|
||||
isTurnActive: input.isTurnActive,
|
||||
}),
|
||||
input,
|
||||
const projection = projectToolCallDetailLevel({
|
||||
level,
|
||||
tail: historyRows,
|
||||
head,
|
||||
preparedHistory,
|
||||
isTurnActive: input.isTurnActive,
|
||||
});
|
||||
const result = collapse(projection, input);
|
||||
// Only the trailing retained group can absorb head rows. Reuse the retained
|
||||
// index across streaming updates rather than walking the full history again.
|
||||
const last = projection.tail.at(-1);
|
||||
const liveGroupHosts = groupedRowHosts(
|
||||
last ? [last, ...projection.head] : projection.head,
|
||||
projection.groupsByHostId,
|
||||
);
|
||||
const retainedGroupHosts = historyGroupHosts;
|
||||
return {
|
||||
...result,
|
||||
readingRowReplacements: {
|
||||
get(id) {
|
||||
if (result.turns.nestedItemIds.has(id)) return undefined;
|
||||
const groupHost = liveGroupHosts.get(id) ?? retainedGroupHosts.get(id) ?? id;
|
||||
return (
|
||||
result.turns.hostIdByHiddenRowId.get(id) ??
|
||||
result.turns.hostIdByHiddenRowId.get(groupHost) ??
|
||||
(groupHost === id ? undefined : groupHost)
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,24 @@ import { describe, expect, it } from "vitest";
|
||||
import { createReadingAnchor } from "./reading-anchor";
|
||||
|
||||
describe("reading anchor", () => {
|
||||
it("pins the folded turn host when a prepend replaces the row being read", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(0, [{ id: "tool", top: 48, height: 200 }]);
|
||||
const replacements = new Map([["tool", "prompt"]]);
|
||||
expect(anchor.getRowId(replacements)).toBe("prompt");
|
||||
expect(anchor.project(0, { id: "prompt", top: 1048 }, replacements)).toBe(1000);
|
||||
// Projection does not consume the correction; an unmounted host keeps the pin.
|
||||
expect(anchor.reconcile(0, [], false, replacements)).toBe(0);
|
||||
expect(anchor.getRowId()).toBe("tool");
|
||||
expect(
|
||||
anchor.reconcile(0, [{ id: "prompt", top: 1048, height: 100 }], false, replacements),
|
||||
).toBe(1000);
|
||||
expect(anchor.getRowId()).toBe("prompt");
|
||||
expect(
|
||||
anchor.reconcile(1000, [{ id: "prompt", top: 1048, height: 100 }], false, replacements),
|
||||
).toBe(1000);
|
||||
});
|
||||
|
||||
const rows = [
|
||||
{ id: "above", top: 0, height: 500 },
|
||||
{ id: "reading", top: 500, height: 500 },
|
||||
|
||||
@@ -6,6 +6,10 @@ interface RowGeometry {
|
||||
|
||||
// A row must clear the reading line before the next row takes ownership.
|
||||
const READING_POSITION_OFFSET_PX = 8;
|
||||
export interface ReadingRowReplacements {
|
||||
get(id: string): string | undefined;
|
||||
}
|
||||
const NO_REPLACEMENTS: ReadingRowReplacements = new Map();
|
||||
|
||||
// Content coordinates survive user scrolling. Layout commits replace the geometry;
|
||||
// scroll events reuse it, without another DOM measurement pass.
|
||||
@@ -14,10 +18,15 @@ export function createReadingAnchor() {
|
||||
let geometry: readonly RowGeometry[] = [];
|
||||
const readingRow = (scrollTop: number) =>
|
||||
geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX);
|
||||
const project = (scrollTop: number, row: Pick<RowGeometry, "id" | "top"> | undefined) =>
|
||||
scrollTop + (anchor && row?.id === anchor.id ? row.top - anchor.top : 0);
|
||||
const getRowId = (replacements = NO_REPLACEMENTS) =>
|
||||
anchor ? (replacements.get(anchor.id) ?? anchor.id) : null;
|
||||
const project = (
|
||||
scrollTop: number,
|
||||
row: Pick<RowGeometry, "id" | "top"> | undefined,
|
||||
replacements = NO_REPLACEMENTS,
|
||||
) => scrollTop + (anchor && row && row.id === getRowId(replacements) ? row.top - anchor.top : 0);
|
||||
return {
|
||||
getRowId: () => anchor?.id ?? null,
|
||||
getRowId,
|
||||
getReadingRowId: (scrollTop: number) =>
|
||||
readingRow(scrollTop)?.id ?? geometry.at(-1)?.id ?? null,
|
||||
project,
|
||||
@@ -28,13 +37,18 @@ export function createReadingAnchor() {
|
||||
const next = readingRow(scrollTop);
|
||||
anchor = next ? { id: next.id, top: next.top } : null;
|
||||
},
|
||||
reconcile(scrollTop: number, rows: readonly RowGeometry[], userScrolled = false): number {
|
||||
reconcile(
|
||||
scrollTop: number,
|
||||
rows: readonly RowGeometry[],
|
||||
userScrolled = false,
|
||||
replacements = NO_REPLACEMENTS,
|
||||
): number {
|
||||
geometry = rows;
|
||||
const previous = anchor && rows.find((row) => row.id === anchor?.id);
|
||||
const previous = anchor && rows.find((row) => row.id === getRowId(replacements));
|
||||
// The first virtualized commit can precede its mounted range. Keep the
|
||||
// pinned reader until it mounts, rather than adopting an unrelated row.
|
||||
if (anchor && !previous && !userScrolled) return scrollTop;
|
||||
const correctedTop = project(scrollTop, previous ?? undefined);
|
||||
const correctedTop = project(scrollTop, previous ?? undefined, replacements);
|
||||
// A prepend can expose the bottom of an estimated row above the reader.
|
||||
// Do not transfer ownership to it until the user moves the reading position.
|
||||
const next = previous && !userScrolled ? previous : readingRow(correctedTop);
|
||||
|
||||
@@ -394,7 +394,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const rangeExtractor = useCallback(
|
||||
(range: VirtualRange): number[] => {
|
||||
const anchorIndex = segments.historyVirtualized.findIndex(
|
||||
(row) => row.id === readingAnchor.getRowId(),
|
||||
(row) => row.id === readingAnchor.getRowId(props.readingRowReplacements),
|
||||
);
|
||||
const container = scrollContainerRef.current;
|
||||
// TanStack has computed the new measurements before calling the extractor.
|
||||
@@ -404,10 +404,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const measurement = virtualizer.measurementsCache[anchorIndex];
|
||||
const correctedOffset =
|
||||
container && measurement
|
||||
? readingAnchor.project(container.scrollTop, {
|
||||
id: String(measurement.key),
|
||||
top: measurement.start,
|
||||
})
|
||||
? readingAnchor.project(
|
||||
container.scrollTop,
|
||||
{
|
||||
id: String(measurement.key),
|
||||
top: measurement.start,
|
||||
},
|
||||
props.readingRowReplacements,
|
||||
)
|
||||
: container?.scrollTop;
|
||||
let visibleRange = range;
|
||||
if (
|
||||
@@ -428,7 +432,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
]),
|
||||
].sort((left, right) => left - right);
|
||||
},
|
||||
[chatFindRowIndexes, readingAnchor, segments.historyVirtualized],
|
||||
[chatFindRowIndexes, readingAnchor, segments.historyVirtualized, props.readingRowReplacements],
|
||||
);
|
||||
const virtualOffsetListener = useRef<((offset: number, scrolling: boolean) => void) | null>(null);
|
||||
const observeVirtualOffset = useCallback<typeof observeElementOffset>((instance, onOffset) => {
|
||||
@@ -742,7 +746,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
);
|
||||
const shouldAnchor = !followOutputRef.current && !isJumpSettling();
|
||||
if (!shouldAnchor) readingAnchor.reset();
|
||||
const correctedTop = readingAnchor.reconcile(container.scrollTop, rows);
|
||||
const correctedTop = readingAnchor.reconcile(
|
||||
container.scrollTop,
|
||||
rows,
|
||||
false,
|
||||
props.readingRowReplacements,
|
||||
);
|
||||
if (!shouldAnchor) {
|
||||
readingAnchor.reset();
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { ReadingRowReplacements } from "./reading-anchor";
|
||||
import { continuesResponse } from "./turn-membership";
|
||||
import type { StreamHistoryBoundary, StreamRenderSegments } from "./model";
|
||||
import type {
|
||||
@@ -78,6 +79,8 @@ export interface StreamRenderInput {
|
||||
segments: StreamRenderSegments;
|
||||
historyRowRevision?: StreamHistoryRowRevision;
|
||||
liveHeadRowRevision?: unknown;
|
||||
/** Presentation rows replaced by another row after history is folded. */
|
||||
readingRowReplacements?: ReadingRowReplacements;
|
||||
boundary: StreamHistoryBoundary;
|
||||
renderers: StreamSegmentRenderers;
|
||||
listEmptyComponent: ReactNode;
|
||||
|
||||
@@ -48,6 +48,14 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
|
||||
const ids = (items: StreamItem[]) => items.map((item) => item.id);
|
||||
|
||||
describe("collapseTurns", () => {
|
||||
it("maps hidden rows only while their turn is closed", () => {
|
||||
const result = collapse([user("u1"), shell("c1"), reply("final")]);
|
||||
expect(result.hostIdByHiddenRowId.get("c1")).toBe("u1");
|
||||
const expanded = collapse([user("u1"), shell("c1"), reply("final")], {
|
||||
expandedHostIds: new Set(["u1"]),
|
||||
});
|
||||
expect(expanded.hostIdByHiddenRowId.size).toBe(0);
|
||||
});
|
||||
it("keeps lifecycle events and compaction recognizable beside a semantic action summary", () => {
|
||||
const create = shell("create");
|
||||
if (create.payload.source !== "agent") throw new Error("Expected agent tool");
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface TurnCollapse {
|
||||
nestedItemIds: ReadonlySet<string>;
|
||||
/** The turn a folded message belongs to, for surfaces that must reveal it. */
|
||||
hostIdByFoldedMessageId: ReadonlyMap<string, string>;
|
||||
/** Mounted rows replaced by a closed turn's host, for preserving the reading position. */
|
||||
hostIdByHiddenRowId: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
export interface CollapseTurnsInput {
|
||||
@@ -80,6 +82,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
const turnsByHostId = new Map<string, CollapsedTurn>();
|
||||
const nestedItemIds = new Set<string>();
|
||||
const hostIdByFoldedMessageId = new Map<string, string>();
|
||||
const hostIdByHiddenRowId = new Map<string, string>();
|
||||
|
||||
let runStart = 0;
|
||||
const flushRun = (runEnd: number) => {
|
||||
@@ -114,6 +117,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
if (staysVisibleWhenCollapsed(item)) continue;
|
||||
hostIdByFoldedMessageId.set(getStreamItemMessageId(item), host.id);
|
||||
if (expanded) nestedItemIds.add(item.id);
|
||||
else hostIdByHiddenRowId.set(item.id, host.id);
|
||||
}
|
||||
output.push(
|
||||
...(expanded ? revealed : folded.filter((item) => staysVisibleWhenCollapsed(item))),
|
||||
@@ -139,6 +143,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
turnsByHostId: EMPTY_TURNS,
|
||||
nestedItemIds: EMPTY_IDS,
|
||||
hostIdByFoldedMessageId: EMPTY_HOSTS,
|
||||
hostIdByHiddenRowId: EMPTY_HOSTS,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -149,5 +154,6 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
turnsByHostId,
|
||||
nestedItemIds,
|
||||
hostIdByFoldedMessageId,
|
||||
hostIdByHiddenRowId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1203,6 +1203,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
<AssistantSelectionCopySurface style={stylesheet.container}>
|
||||
<MessageOuterSpacingProvider disableOuterSpacing>
|
||||
{streamRenderStrategy.render({
|
||||
readingRowReplacements: presentation.readingRowReplacements,
|
||||
agentId,
|
||||
segments: renderModel.segments,
|
||||
historyRowRevision,
|
||||
|
||||
@@ -73,6 +73,7 @@ export function MessageSender({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={renderProviderIcon}
|
||||
style={styles.sender}
|
||||
disabled={!host}
|
||||
onPress={openSender}
|
||||
>
|
||||
@@ -84,6 +85,7 @@ export function MessageSender({
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
preview: { maxHeight: PREVIEW_HEIGHT, overflow: "hidden" },
|
||||
body: { flexShrink: 0 },
|
||||
sender: { paddingRight: 0, borderRightWidth: 0 },
|
||||
toggle: { alignSelf: "flex-start", paddingHorizontal: 0, minHeight: 0, borderWidth: 0 },
|
||||
fade: { position: "absolute", bottom: 0, left: 0, right: 0, height: theme.spacing[12] },
|
||||
}));
|
||||
|
||||
@@ -44,7 +44,9 @@ export function useOverviewSummaryLabel(
|
||||
count === 1 ? action.toLocaleLowerCase() : `${action.toLocaleLowerCase()} ×${count}`,
|
||||
);
|
||||
}
|
||||
if (summary.thoughtCount > 0) parts.push(t("toolCallGroup.thought").toLocaleLowerCase());
|
||||
// Reasoning stays in the details; it is not an action in a work summary.
|
||||
// Keep a label for a group containing only reasoning.
|
||||
if (parts.length === 0 && summary.thoughtCount > 0) return t("toolCallGroup.thought");
|
||||
return joinSummaryParts(parts, t("toolCallGroup.and"));
|
||||
}, [leadingParts, summary, t]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user