Keep timeline history duplicate-free across daemon upgrades (#2789)
* fix(app): keep timeline pagination duplicate-free Older daemons page projected history by canonical sequence ranges, so one projected block can appear in adjacent responses. Accept each block on the page containing its projection anchor while still advancing across empty projected pages.\n\nProjected siblings can share a provider message ID, so render identity also includes the timeline cursor instead of collapsing distinct blocks onto one layout row. * fix(e2e): preserve isolated daemon launch contract Keep the source-daemon spawn path recognizable to the repository launch-contract check while selecting the built supervisor only for published package fixtures. * fix(app): preserve stable timeline item identity
This commit is contained in:
@@ -0,0 +1,86 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { metroTest as test } 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 {
|
||||||
|
expectTimelinePromptVisible,
|
||||||
|
holdOlderHistoryPages,
|
||||||
|
openAgentTimeline,
|
||||||
|
scrollThroughOlderHistoryPages,
|
||||||
|
} from "../support/helpers/timeline-pagination";
|
||||||
|
|
||||||
|
test("does not repeat an assistant block when the current app paginates a published 0.2.5 daemon", 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 workspace = await seedWorkspace({
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let index = 0; index < 40; index += 1) {
|
||||||
|
await agent.client.sendAgentMessage(
|
||||||
|
agent.agentId,
|
||||||
|
`timeline-pagination-older-turn-${index}: emit 1 coalesced agent stream updates`,
|
||||||
|
);
|
||||||
|
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||||
|
}
|
||||||
|
await agent.client.sendAgentMessage(agent.agentId, "build a realistic long mock timeline");
|
||||||
|
await agent.client.waitForFinish(agent.agentId, 20_000);
|
||||||
|
for (let index = 0; index < 20; index += 1) {
|
||||||
|
await agent.client.sendAgentMessage(
|
||||||
|
agent.agentId,
|
||||||
|
`timeline-pagination-turn-${index}: emit 1 coalesced agent stream updates`,
|
||||||
|
);
|
||||||
|
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(serverId) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const history = await holdOlderHistoryPages(page, agent, daemon.port);
|
||||||
|
await openAgentTimeline(page, agent, serverId);
|
||||||
|
await expectTimelinePromptVisible(
|
||||||
|
page,
|
||||||
|
"timeline-pagination-turn-19: emit 1 coalesced agent stream updates",
|
||||||
|
);
|
||||||
|
await scrollThroughOlderHistoryPages(page, 3, history);
|
||||||
|
|
||||||
|
history.expectRepeatedEntries();
|
||||||
|
await history.expectOwnedTextRendered("Now I have a clearer picture.");
|
||||||
|
} finally {
|
||||||
|
await agent.cleanup();
|
||||||
|
await daemon.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -97,7 +97,7 @@ test.describe("Agent timeline pagination", () => {
|
|||||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||||
|
|
||||||
await scrollThroughOlderHistoryPages(page, 3, history);
|
await scrollThroughOlderHistoryPages(page, 3, history);
|
||||||
history.expectNoRepeatedAssistantEntries();
|
history.expectNoRepeatedEntries();
|
||||||
} finally {
|
} finally {
|
||||||
await agent.cleanup();
|
await agent.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Page } from "@playwright/test";
|
import type { Page } from "@playwright/test";
|
||||||
import { daemonWsRoutePattern } from "./daemon-port";
|
import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port";
|
||||||
|
|
||||||
type WebSocketMessage = string | Buffer;
|
type WebSocketMessage = string | Buffer;
|
||||||
|
|
||||||
@@ -17,7 +17,8 @@ export interface AgentTimelineResponseGate {
|
|||||||
|
|
||||||
export interface OlderTimelinePagesGate {
|
export interface OlderTimelinePagesGate {
|
||||||
getRequestCount(): number;
|
getRequestCount(): number;
|
||||||
getRepeatedAssistantEntryCount(): number;
|
getRepeatedEntryCount(): number;
|
||||||
|
getOwnedEntryCountContaining(text: string): number;
|
||||||
releasePage(pageNumber: number): void;
|
releasePage(pageNumber: number): void;
|
||||||
waitForRequestCount(count: number): Promise<void>;
|
waitForRequestCount(count: number): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -61,6 +62,62 @@ function getPayload(message: Record<string, unknown>): Record<string, unknown> |
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ObservedTimelineEntry {
|
||||||
|
seqStart: number;
|
||||||
|
seqEnd: number;
|
||||||
|
item: { type: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTimelineEntries(payload: Record<string, unknown>): ObservedTimelineEntry[] {
|
||||||
|
if (!Array.isArray(payload.entries)) return [];
|
||||||
|
return payload.entries.filter((entry): entry is ObservedTimelineEntry => {
|
||||||
|
if (!entry || typeof entry !== "object") return false;
|
||||||
|
const candidate = entry as Partial<ObservedTimelineEntry>;
|
||||||
|
return (
|
||||||
|
typeof candidate.seqStart === "number" &&
|
||||||
|
typeof candidate.seqEnd === "number" &&
|
||||||
|
typeof candidate.item?.type === "string"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCursorSeq(cursor: unknown): number | undefined {
|
||||||
|
if (!cursor || typeof cursor !== "object") return undefined;
|
||||||
|
const seq = (cursor as { seq?: unknown }).seq;
|
||||||
|
return typeof seq === "number" ? seq : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordOwnedTimelineEntries(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
ownedEntries: Map<string, string>,
|
||||||
|
): void {
|
||||||
|
const pageStartSeq = readCursorSeq(payload.startCursor);
|
||||||
|
const pageEndSeq = readCursorSeq(payload.endCursor);
|
||||||
|
for (const entry of readTimelineEntries(payload)) {
|
||||||
|
const belongsToPage =
|
||||||
|
payload.direction === "tail" ||
|
||||||
|
(pageStartSeq !== undefined &&
|
||||||
|
pageEndSeq !== undefined &&
|
||||||
|
entry.seqStart >= pageStartSeq &&
|
||||||
|
entry.seqStart <= pageEndSeq);
|
||||||
|
if (!belongsToPage) continue;
|
||||||
|
ownedEntries.set(`${entry.seqStart}:${entry.seqEnd}`, JSON.stringify(entry.item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordRepeatedTimelineEntries(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
entryKeys: Set<string>,
|
||||||
|
): number {
|
||||||
|
let repeats = 0;
|
||||||
|
for (const entry of readTimelineEntries(payload)) {
|
||||||
|
const key = `${entry.seqStart}:${entry.seqEnd}:${entry.item.type}`;
|
||||||
|
if (entryKeys.has(key)) repeats += 1;
|
||||||
|
else entryKeys.add(key);
|
||||||
|
}
|
||||||
|
return repeats;
|
||||||
|
}
|
||||||
|
|
||||||
export async function holdDaemonHydration(page: Page): Promise<DaemonHydrationGate> {
|
export async function holdDaemonHydration(page: Page): Promise<DaemonHydrationGate> {
|
||||||
let released = false;
|
let released = false;
|
||||||
const delayedForwards: Array<() => void> = [];
|
const delayedForwards: Array<() => void> = [];
|
||||||
@@ -176,11 +233,13 @@ export async function delayAgentOlderTimelineResponse(
|
|||||||
export async function holdAgentOlderTimelinePages(
|
export async function holdAgentOlderTimelinePages(
|
||||||
page: Page,
|
page: Page,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
|
daemonPort?: number,
|
||||||
): Promise<OlderTimelinePagesGate> {
|
): Promise<OlderTimelinePagesGate> {
|
||||||
let requestCount = 0;
|
let requestCount = 0;
|
||||||
let responseCount = 0;
|
let responseCount = 0;
|
||||||
let repeatedAssistantEntryCount = 0;
|
let repeatedEntryCount = 0;
|
||||||
const assistantEntryKeys = new Set<string>();
|
const entryKeys = new Set<string>();
|
||||||
|
const ownedEntries = new Map<string, string>();
|
||||||
const releasedPages = new Set<number>();
|
const releasedPages = new Set<number>();
|
||||||
const delayedForwards = new Map<number, Array<() => void>>();
|
const delayedForwards = new Map<number, Array<() => void>>();
|
||||||
const requestWaiters = new Map<number, Array<() => void>>();
|
const requestWaiters = new Map<number, Array<() => void>>();
|
||||||
@@ -193,7 +252,9 @@ export async function holdAgentOlderTimelinePages(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
const routePattern =
|
||||||
|
daemonPort === undefined ? daemonWsRoutePattern() : wsRoutePatternForPort(String(daemonPort));
|
||||||
|
await page.routeWebSocket(routePattern, (ws) => {
|
||||||
const server = ws.connectToServer();
|
const server = ws.connectToServer();
|
||||||
ws.onMessage((message) => {
|
ws.onMessage((message) => {
|
||||||
const sessionMessage = getSessionMessage(message);
|
const sessionMessage = getSessionMessage(message);
|
||||||
@@ -210,32 +271,20 @@ export async function holdAgentOlderTimelinePages(
|
|||||||
server.onMessage((message) => {
|
server.onMessage((message) => {
|
||||||
const sessionMessage = getSessionMessage(message);
|
const sessionMessage = getSessionMessage(message);
|
||||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||||
|
if (
|
||||||
|
sessionMessage?.type === "fetch_agent_timeline_response" &&
|
||||||
|
payload?.agentId === agentId &&
|
||||||
|
(payload.direction === "tail" || payload.direction === "before")
|
||||||
|
) {
|
||||||
|
recordOwnedTimelineEntries(payload, ownedEntries);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
sessionMessage?.type === "fetch_agent_timeline_response" &&
|
sessionMessage?.type === "fetch_agent_timeline_response" &&
|
||||||
payload?.agentId === agentId &&
|
payload?.agentId === agentId &&
|
||||||
payload.direction === "before"
|
payload.direction === "before"
|
||||||
) {
|
) {
|
||||||
responseCount += 1;
|
responseCount += 1;
|
||||||
const entries = Array.isArray(payload.entries) ? payload.entries : [];
|
repeatedEntryCount += recordRepeatedTimelineEntries(payload, entryKeys);
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry || typeof entry !== "object") continue;
|
|
||||||
const projectedEntry = entry as {
|
|
||||||
seqStart?: unknown;
|
|
||||||
seqEnd?: unknown;
|
|
||||||
item?: { type?: unknown };
|
|
||||||
};
|
|
||||||
if (
|
|
||||||
typeof projectedEntry.seqStart !== "number" ||
|
|
||||||
typeof projectedEntry.seqEnd !== "number" ||
|
|
||||||
typeof projectedEntry.item?.type !== "string"
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (projectedEntry.item.type !== "assistant_message") continue;
|
|
||||||
const key = `${projectedEntry.seqStart}:${projectedEntry.seqEnd}:${projectedEntry.item.type}`;
|
|
||||||
if (assistantEntryKeys.has(key)) repeatedAssistantEntryCount += 1;
|
|
||||||
else assistantEntryKeys.add(key);
|
|
||||||
}
|
|
||||||
const pageNumber = responseCount;
|
const pageNumber = responseCount;
|
||||||
if (releasedPages.has(pageNumber)) {
|
if (releasedPages.has(pageNumber)) {
|
||||||
ws.send(message);
|
ws.send(message);
|
||||||
@@ -252,7 +301,9 @@ export async function holdAgentOlderTimelinePages(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
getRequestCount: () => requestCount,
|
getRequestCount: () => requestCount,
|
||||||
getRepeatedAssistantEntryCount: () => repeatedAssistantEntryCount,
|
getRepeatedEntryCount: () => repeatedEntryCount,
|
||||||
|
getOwnedEntryCountContaining: (value) =>
|
||||||
|
[...ownedEntries.values()].filter((text) => text.includes(value)).length,
|
||||||
releasePage(pageNumber) {
|
releasePage(pageNumber) {
|
||||||
releasedPages.add(pageNumber);
|
releasedPages.add(pageNumber);
|
||||||
for (const forward of delayedForwards.get(pageNumber) ?? []) forward();
|
for (const forward of delayedForwards.get(pageNumber) ?? []) forward();
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { once } from "node:events";
|
import { once } from "node:events";
|
||||||
import { spawn, execSync, type ChildProcess } from "node:child_process";
|
import {
|
||||||
|
spawn,
|
||||||
|
execFileSync,
|
||||||
|
execSync,
|
||||||
|
type ChildProcess,
|
||||||
|
type SpawnOptions,
|
||||||
|
} from "node:child_process";
|
||||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
@@ -23,6 +29,7 @@ export interface IsolatedHostDaemonOptions {
|
|||||||
};
|
};
|
||||||
paseoHome?: string;
|
paseoHome?: string;
|
||||||
preserveHome?: boolean;
|
preserveHome?: boolean;
|
||||||
|
publishedVersion?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAvailablePort(): Promise<number> {
|
async function getAvailablePort(): Promise<number> {
|
||||||
@@ -100,6 +107,31 @@ export async function startIsolatedHostDaemon(
|
|||||||
|
|
||||||
const paseoHome =
|
const paseoHome =
|
||||||
options.paseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-secondary-host-")));
|
options.paseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-secondary-host-")));
|
||||||
|
let publishedPackageRoot: string | null = null;
|
||||||
|
if (options.publishedVersion) {
|
||||||
|
publishedPackageRoot = await mkdtemp(path.join(tmpdir(), "paseo-e2e-published-server-"));
|
||||||
|
await writeFile(
|
||||||
|
path.join(publishedPackageRoot, "package.json"),
|
||||||
|
`${JSON.stringify({ private: true })}\n`,
|
||||||
|
);
|
||||||
|
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||||
|
try {
|
||||||
|
execFileSync(
|
||||||
|
npmCommand,
|
||||||
|
[
|
||||||
|
"install",
|
||||||
|
"--no-audit",
|
||||||
|
"--no-fund",
|
||||||
|
"--no-package-lock",
|
||||||
|
`@getpaseo/server@${options.publishedVersion}`,
|
||||||
|
],
|
||||||
|
{ cwd: publishedPackageRoot, stdio: "ignore" },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
await rm(publishedPackageRoot, { recursive: true, force: true });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (options.mutableRelay) {
|
if (options.mutableRelay) {
|
||||||
const endpoint =
|
const endpoint =
|
||||||
options.mutableRelay.endpoint ??
|
options.mutableRelay.endpoint ??
|
||||||
@@ -120,10 +152,12 @@ export async function startIsolatedHostDaemon(
|
|||||||
})}\n`,
|
})}\n`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const serverDir = path.resolve(__dirname, "../../../../server");
|
const serverDir = publishedPackageRoot
|
||||||
|
? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server")
|
||||||
|
: path.resolve(__dirname, "../../../../server");
|
||||||
const tsxBin = execSync("which tsx").toString().trim();
|
const tsxBin = execSync("which tsx").toString().trim();
|
||||||
const spawnDaemon = async (): Promise<ChildProcess> => {
|
const spawnDaemon = async (): Promise<ChildProcess> => {
|
||||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
const spawnOptions: SpawnOptions = {
|
||||||
cwd: serverDir,
|
cwd: serverDir,
|
||||||
env: withDisabledE2ESpeechEnv({
|
env: withDisabledE2ESpeechEnv({
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -138,7 +172,10 @@ export async function startIsolatedHostDaemon(
|
|||||||
}),
|
}),
|
||||||
stdio: ["ignore", "ignore", "pipe"],
|
stdio: ["ignore", "ignore", "pipe"],
|
||||||
detached: false,
|
detached: false,
|
||||||
});
|
};
|
||||||
|
const child = publishedPackageRoot
|
||||||
|
? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions)
|
||||||
|
: spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], spawnOptions);
|
||||||
|
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
child.stderr?.on("data", (chunk: Buffer) => {
|
child.stderr?.on("data", (chunk: Buffer) => {
|
||||||
@@ -165,6 +202,9 @@ export async function startIsolatedHostDaemon(
|
|||||||
if (!options.preserveHome) {
|
if (!options.preserveHome) {
|
||||||
await rm(paseoHome, { recursive: true, force: true });
|
await rm(paseoHome, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
if (publishedPackageRoot) {
|
||||||
|
await rm(publishedPackageRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
let closed = false;
|
let closed = false;
|
||||||
@@ -186,6 +226,9 @@ export async function startIsolatedHostDaemon(
|
|||||||
if (!options.preserveHome) {
|
if (!options.preserveHome) {
|
||||||
await rm(paseoHome, { recursive: true, force: true });
|
await rm(paseoHome, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
if (publishedPackageRoot) {
|
||||||
|
await rm(publishedPackageRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,8 +70,12 @@ export async function seedRunningMockAgentWorkspace(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildAgentRoute(workspaceId: string, agentId: string): string {
|
export function buildAgentRoute(
|
||||||
return buildHostAgentDetailRoute(getServerId(), agentId, workspaceId);
|
workspaceId: string,
|
||||||
|
agentId: string,
|
||||||
|
serverId = getServerId(),
|
||||||
|
): string {
|
||||||
|
return buildHostAgentDetailRoute(serverId, agentId, workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */
|
/** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ interface OlderHistoryLoadingOperation {
|
|||||||
interface OlderHistoryPages {
|
interface OlderHistoryPages {
|
||||||
expectRequestedPages(count: number): Promise<void>;
|
expectRequestedPages(count: number): Promise<void>;
|
||||||
expectSettledWithRequestedPages(count: number): Promise<void>;
|
expectSettledWithRequestedPages(count: number): Promise<void>;
|
||||||
expectNoRepeatedAssistantEntries(): void;
|
expectNoRepeatedEntries(): void;
|
||||||
|
expectRepeatedEntries(): void;
|
||||||
|
expectOwnedTextRendered(text: string): Promise<void>;
|
||||||
releasePage(pageNumber: number): void;
|
releasePage(pageNumber: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,8 +163,12 @@ export async function expectTimelinePromptPositionPreserved(
|
|||||||
.toBeLessThanOrEqual(2);
|
.toBeLessThanOrEqual(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openAgentTimeline(page: Page, agent: MockAgentWorkspace): Promise<void> {
|
export async function openAgentTimeline(
|
||||||
await page.goto(buildAgentRoute(agent.workspaceId, agent.agentId));
|
page: Page,
|
||||||
|
agent: MockAgentWorkspace,
|
||||||
|
serverId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await page.goto(buildAgentRoute(agent.workspaceId, agent.agentId, serverId));
|
||||||
await page.waitForURL(
|
await page.waitForURL(
|
||||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||||
{ timeout: 60_000 },
|
{ timeout: 60_000 },
|
||||||
@@ -262,8 +268,13 @@ export async function holdNextOlderTimelinePage(
|
|||||||
export async function holdOlderHistoryPages(
|
export async function holdOlderHistoryPages(
|
||||||
page: Page,
|
page: Page,
|
||||||
agent: MockAgentWorkspace,
|
agent: MockAgentWorkspace,
|
||||||
|
daemonPort?: number,
|
||||||
): Promise<OlderHistoryPages> {
|
): Promise<OlderHistoryPages> {
|
||||||
const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(page, agent.agentId);
|
const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(
|
||||||
|
page,
|
||||||
|
agent.agentId,
|
||||||
|
daemonPort,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
async expectRequestedPages(count) {
|
async expectRequestedPages(count) {
|
||||||
await gate.waitForRequestCount(count);
|
await gate.waitForRequestCount(count);
|
||||||
@@ -273,8 +284,17 @@ export async function holdOlderHistoryPages(
|
|||||||
await waitForTimelineGeometryToSettle(page);
|
await waitForTimelineGeometryToSettle(page);
|
||||||
expect(gate.getRequestCount()).toBe(count);
|
expect(gate.getRequestCount()).toBe(count);
|
||||||
},
|
},
|
||||||
expectNoRepeatedAssistantEntries() {
|
expectNoRepeatedEntries() {
|
||||||
expect(gate.getRepeatedAssistantEntryCount()).toBe(0);
|
expect(gate.getRepeatedEntryCount()).toBe(0);
|
||||||
|
},
|
||||||
|
expectRepeatedEntries() {
|
||||||
|
expect(gate.getRepeatedEntryCount()).toBeGreaterThan(0);
|
||||||
|
},
|
||||||
|
async expectOwnedTextRendered(text) {
|
||||||
|
const expectedCount = gate.getOwnedEntryCountContaining(text);
|
||||||
|
expect(expectedCount).toBeGreaterThan(0);
|
||||||
|
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||||
|
await expect(timeline.getByText(text, { exact: false })).toHaveCount(expectedCount);
|
||||||
},
|
},
|
||||||
releasePage(pageNumber) {
|
releasePage(pageNumber) {
|
||||||
gate.releasePage(pageNumber);
|
gate.releasePage(pageNumber);
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ const baseTimelineInput: ProcessTimelineResponseInput = {
|
|||||||
payload: {
|
payload: {
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
direction: "after",
|
direction: "after",
|
||||||
|
projection: "projected",
|
||||||
reset: false,
|
reset: false,
|
||||||
epoch: "epoch-1",
|
epoch: "epoch-1",
|
||||||
window: { minSeq: 1, maxSeq: 0, nextSeq: 1 },
|
window: { minSeq: 1, maxSeq: 0, nextSeq: 1 },
|
||||||
@@ -2540,6 +2541,123 @@ describe("processTimelineResponse", () => {
|
|||||||
expect(result.older).toBe("none");
|
expect(result.older).toBe("none");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps only projected entries anchored in an older response page", () => {
|
||||||
|
const currentTail: StreamItem[] = [
|
||||||
|
{
|
||||||
|
kind: "user_message",
|
||||||
|
id: "current-81",
|
||||||
|
text: "current-81",
|
||||||
|
timestamp: new Date(81000),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = processTimelineResponse({
|
||||||
|
...baseTimelineInput,
|
||||||
|
currentTail,
|
||||||
|
currentCursor: { epoch: "epoch-1", startSeq: 81, endSeq: 100 },
|
||||||
|
payload: {
|
||||||
|
...baseTimelineInput.payload,
|
||||||
|
direction: "before",
|
||||||
|
projection: "projected",
|
||||||
|
epoch: "epoch-1",
|
||||||
|
startCursor: { seq: 41 },
|
||||||
|
endCursor: { seq: 80 },
|
||||||
|
hasOlder: true,
|
||||||
|
entries: [
|
||||||
|
makeTimelineEntry(1, "wide assistant", "assistant_message", 80),
|
||||||
|
makeTimelineEntry(50, "owned by this page", "user_message"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getAssistantTexts(result.tail)).toEqual([]);
|
||||||
|
expect(getUserTexts(result.tail)).toEqual(["owned by this page", "current-81"]);
|
||||||
|
expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 41, endSeq: 100 });
|
||||||
|
expect(result.older).toBe("available");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advances through an older projected page with no anchored entries", () => {
|
||||||
|
const currentTail: StreamItem[] = [
|
||||||
|
{
|
||||||
|
kind: "user_message",
|
||||||
|
id: "current-81",
|
||||||
|
text: "current-81",
|
||||||
|
timestamp: new Date(81000),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = processTimelineResponse({
|
||||||
|
...baseTimelineInput,
|
||||||
|
currentTail,
|
||||||
|
currentCursor: { epoch: "epoch-1", startSeq: 81, endSeq: 100 },
|
||||||
|
payload: {
|
||||||
|
...baseTimelineInput.payload,
|
||||||
|
direction: "before",
|
||||||
|
projection: "projected",
|
||||||
|
epoch: "epoch-1",
|
||||||
|
startCursor: { seq: 41 },
|
||||||
|
endCursor: { seq: 80 },
|
||||||
|
hasOlder: true,
|
||||||
|
entries: [makeTimelineEntry(1, "wide assistant", "assistant_message", 80)],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.tail).toBe(currentTail);
|
||||||
|
expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 41, endSeq: 100 });
|
||||||
|
expect(result.cursorChanged).toBe(true);
|
||||||
|
expect(result.older).toBe("available");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives prepended assistant blocks unique ids across independently hydrated pages", () => {
|
||||||
|
const currentTail: StreamItem[] = [
|
||||||
|
{
|
||||||
|
kind: "user_message",
|
||||||
|
id: "current-81",
|
||||||
|
text: "current-81",
|
||||||
|
timestamp: new Date(81000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "assistant_message",
|
||||||
|
id: "shared-provider-message",
|
||||||
|
messageId: "shared-provider-message",
|
||||||
|
text: "newer assistant block",
|
||||||
|
timestamp: new Date(82000),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = processTimelineResponse({
|
||||||
|
...baseTimelineInput,
|
||||||
|
currentTail,
|
||||||
|
currentCursor: { epoch: "epoch-1", startSeq: 81, endSeq: 100 },
|
||||||
|
payload: {
|
||||||
|
...baseTimelineInput.payload,
|
||||||
|
direction: "before",
|
||||||
|
projection: "projected",
|
||||||
|
epoch: "epoch-1",
|
||||||
|
startCursor: { seq: 41 },
|
||||||
|
endCursor: { seq: 80 },
|
||||||
|
hasOlder: true,
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
...makeTimelineEntry(50, "older assistant block"),
|
||||||
|
item: {
|
||||||
|
type: "assistant_message",
|
||||||
|
text: "older assistant block",
|
||||||
|
messageId: "shared-provider-message",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistants = result.tail.filter((item) => item.kind === "assistant_message");
|
||||||
|
expect(assistants.map((item) => item.messageId)).toEqual([
|
||||||
|
"shared-provider-message",
|
||||||
|
"shared-provider-message",
|
||||||
|
]);
|
||||||
|
expect(new Set(assistants.map((item) => item.id)).size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("drops a stale before page anchored before a resume-tail replacement", () => {
|
it("drops a stale before page anchored before a resume-tail replacement", () => {
|
||||||
const currentTail: StreamItem[] = [
|
const currentTail: StreamItem[] = [
|
||||||
{
|
{
|
||||||
@@ -3616,6 +3734,7 @@ describe("processAgentStreamEvents", () => {
|
|||||||
payload: {
|
payload: {
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
direction: "tail",
|
direction: "tail",
|
||||||
|
projection: "projected",
|
||||||
reset: false,
|
reset: false,
|
||||||
epoch: "epoch-1",
|
epoch: "epoch-1",
|
||||||
window: { minSeq: 186, maxSeq: 186, nextSeq: 187 },
|
window: { minSeq: 186, maxSeq: 186, nextSeq: 187 },
|
||||||
@@ -3660,6 +3779,7 @@ describe("processAgentStreamEvents", () => {
|
|||||||
payload: {
|
payload: {
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
direction: "tail",
|
direction: "tail",
|
||||||
|
projection: "projected",
|
||||||
reset: false,
|
reset: false,
|
||||||
epoch: "epoch-1",
|
epoch: "epoch-1",
|
||||||
window: { minSeq: 10, maxSeq: 10, nextSeq: 11 },
|
window: { minSeq: 10, maxSeq: 10, nextSeq: 11 },
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export interface ProcessTimelineResponseInput {
|
|||||||
payload: {
|
payload: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
direction: TimelineDirection;
|
direction: TimelineDirection;
|
||||||
|
projection: "projected" | "canonical";
|
||||||
reset: boolean;
|
reset: boolean;
|
||||||
epoch: string;
|
epoch: string;
|
||||||
window: { minSeq: number; maxSeq: number; nextSeq: number };
|
window: { minSeq: number; maxSeq: number; nextSeq: number };
|
||||||
@@ -786,6 +787,27 @@ function deriveCanonicalAcknowledgements(params: {
|
|||||||
return [...acknowledged];
|
return [...acknowledged];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectEntriesOwnedByTimelinePage(
|
||||||
|
payload: ProcessTimelineResponseInput["payload"],
|
||||||
|
): TimelineResponseEntry[] {
|
||||||
|
// COMPAT(projectedBeforePageOwnership): added in v0.2.6, remove after 2027-02-02
|
||||||
|
// once the supported daemon floor paginates projected before pages.
|
||||||
|
if (
|
||||||
|
payload.direction !== "before" ||
|
||||||
|
payload.projection !== "projected" ||
|
||||||
|
!payload.startCursor ||
|
||||||
|
!payload.endCursor
|
||||||
|
) {
|
||||||
|
return payload.entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageStartSeq = payload.startCursor.seq;
|
||||||
|
const pageEndSeq = payload.endCursor.seq;
|
||||||
|
return payload.entries.filter(
|
||||||
|
(entry) => entry.seqStart >= pageStartSeq && entry.seqStart <= pageEndSeq,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function applyTimelineIncrementalPath(args: {
|
function applyTimelineIncrementalPath(args: {
|
||||||
timelineUnits: TimelineUnit[];
|
timelineUnits: TimelineUnit[];
|
||||||
payload: ProcessTimelineResponseInput["payload"];
|
payload: ProcessTimelineResponseInput["payload"];
|
||||||
@@ -802,7 +824,7 @@ function applyTimelineIncrementalPath(args: {
|
|||||||
const sideEffects: TimelineReducerSideEffect[] = [];
|
const sideEffects: TimelineReducerSideEffect[] = [];
|
||||||
let acknowledgedClientMessageIds: string[] = [];
|
let acknowledgedClientMessageIds: string[] = [];
|
||||||
|
|
||||||
if (timelineUnits.length === 0) {
|
if (timelineUnits.length === 0 && payload.direction !== "before") {
|
||||||
return {
|
return {
|
||||||
tail: nextTail,
|
tail: nextTail,
|
||||||
head: nextHead,
|
head: nextHead,
|
||||||
@@ -827,16 +849,33 @@ function applyTimelineIncrementalPath(args: {
|
|||||||
currentCursor,
|
currentCursor,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
payload.direction === "before" &&
|
||||||
|
cursor &&
|
||||||
|
currentCursor &&
|
||||||
|
cursor.startSeq !== currentCursor.startSeq
|
||||||
|
) {
|
||||||
|
older = payload.hasOlder ? "available" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
if (acceptedUnits.length > 0) {
|
if (acceptedUnits.length > 0) {
|
||||||
if (payload.direction === "before") {
|
if (payload.direction === "before") {
|
||||||
older = payload.hasOlder ? "available" : "none";
|
|
||||||
const olderTail = hydrateStreamState(
|
const olderTail = hydrateStreamState(
|
||||||
acceptedUnits.map(({ event, timestamp, seqEnd }) => ({
|
acceptedUnits.map(({ event, timestamp, seqEnd }) => ({
|
||||||
event,
|
event,
|
||||||
timestamp,
|
timestamp,
|
||||||
timelineCursor: { epoch: payload.epoch, seq: seqEnd },
|
timelineCursor: { epoch: payload.epoch, seq: seqEnd },
|
||||||
})),
|
})),
|
||||||
{ source: "canonical" },
|
{
|
||||||
|
source: "canonical",
|
||||||
|
reservedItemIds: new Set(
|
||||||
|
currentTail.flatMap((item) =>
|
||||||
|
item.kind === "assistant_message" && item.blockGroupId
|
||||||
|
? [item.id, item.blockGroupId]
|
||||||
|
: [item.id],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
nextTail = mergePrependedCanonicalTail(olderTail, currentTail);
|
nextTail = mergePrependedCanonicalTail(olderTail, currentTail);
|
||||||
} else {
|
} else {
|
||||||
@@ -915,7 +954,7 @@ export function processTimelineResponse(
|
|||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Convert entries to timeline units
|
// Convert entries to timeline units
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
const timelineUnits = payload.entries.map((entry) => ({
|
const timelineUnits = selectEntriesOwnedByTimelinePage(payload).map((entry) => ({
|
||||||
seq: entry.seqStart,
|
seq: entry.seqStart,
|
||||||
seqEnd: entry.seqEnd,
|
seqEnd: entry.seqEnd,
|
||||||
sourceSeqRanges:
|
sourceSeqRanges:
|
||||||
|
|||||||
@@ -1423,7 +1423,7 @@ export function hydrateStreamState(
|
|||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
timelineCursor?: TimelinePosition;
|
timelineCursor?: TimelinePosition;
|
||||||
}>,
|
}>,
|
||||||
options?: { source?: StreamUpdateSource },
|
options?: { source?: StreamUpdateSource; reservedItemIds?: ReadonlySet<string> },
|
||||||
): StreamItem[] {
|
): StreamItem[] {
|
||||||
const hydrated = events.reduce<StreamItem[]>((state, { event, timestamp, timelineCursor }) => {
|
const hydrated = events.reduce<StreamItem[]>((state, { event, timestamp, timelineCursor }) => {
|
||||||
return reduceStreamUpdate(state, event, timestamp, { ...options, timelineCursor });
|
return reduceStreamUpdate(state, event, timestamp, { ...options, timelineCursor });
|
||||||
|
|||||||
Reference in New Issue
Block a user