Keep agent timelines stable from submission through resume (#2718)

* Reapply: Stop completed turns from appearing stuck (#2484)

* Reapply: Keep older chat history and image previews stable (#2490)

* Lock timeline layout stability with user-driven specs

Two reds reproduce the reported jank:
- new agent, first prompt: the row paints at top=100 then jumps to 144 through
  authoritative hydration, because the draft tab and the agent panel each render
  their own AgentStreamView for the same logical row.
- long loaded timeline, submit: scrollHeight collapses 10793 -> 9111 mid-turn,
  because splitOrderedTail reclassifies measured rows into the virtualized segment
  where the virtualizer falls back to estimateStreamItemHeight.

Also restores centered timeline rows: the pagination anchor wrapper introduced in
#2490 was a plain block div, which broke alignSelf: center.

* Measure the turn footer blink from when the footer appears

Bounding the window at the prompt row conflated two things: the footer arriving a
frame after the row, and the footer leaving the layout and coming back. Bounded from
the footer's first frame, the blink still reproduces, so the footer-ownership race is
real rather than a sampling artifact.

* Rename the shadowed prompt row binding in the frame recorder

* fix(desktop): allow concurrent worktree dev instances

Desktop terminals can inherit another instance's profile path and debugging flags. Scope the profile to the current dev root and select an available debugging port so instances do not share global runtime state.

* fix(desktop): expose development providers in dev builds

Desktop-managed daemons inherited no Paseo runtime mode, so dev builds constructed the production provider registry. Mark unpackaged internal launches as development while continuing to force packaged launches to production.

* fix(timeline): stabilize optimistic turn layout

Keep initial history loading dormant until user intent and preserve active-turn layout across delayed transport updates. Add painted-frame coverage for optimistic timeline transitions.

* fix(timeline): preserve optimistic prompts through hydration

Canonical replacement relied on a transport submission record that can settle before a provider echoes the user row. Keep presentation continuity owned by the unreconciled row while destructive history replacement remains governed by active transactions.

* fix(timeline): make submitted prompts canonical

Submission settlement previously depended on status updates that could arrive after the optimistic row. Persist every accepted prompt in the canonical timeline and keep the footer active until RPC and canonical acknowledgement converge.

* fix(timeline): stop older history pages repeating entries

Backward pagination counted source sequence rows, so page boundaries inside a merged projected item could return that item repeatedly. Count projected entries and advance from their display anchors instead, with server and browser regressions covering the contract.

* fix(timeline): resume agents from the latest history

Use a bounded tail probe on resume so long background runs appear atomically instead of replaying every missed page. Preserve unchanged views, reconcile local/live rows, and keep skipped history reachable through backward pagination.

* fix(timeline): reconcile turn state after missed updates

Turn completion events can be missed while an agent timeline is unsubscribed, leaving footer activity stale even after the directory reports idle. Reconcile the single-owned turn lifecycle from authoritative resume snapshots while preserving newer live evidence.

* test(timeline): cover running state across refresh

Protect the hydration boundary where directory state must restore consistent tab, composer, and footer activity. Expose the running indicator semantically so the regression checks user-visible state instead of spinner implementation details.

* fix(timeline): keep history loading geometry stable

Reserve a fixed history-start gutter regardless of pagination state so showing or hiding the loader cannot shift timeline content.

* fix(timeline): keep turn activity coherent

Derive footer, tab, and composer activity from one lifecycle and submission projection. Idle directory transitions now request an authoritative resume so missed terminal events converge without treating directory delivery order as turn authority.

* fix(tests): align checks with runtime boundaries

* fix(desktop): stop dev app surviving its terminal

Electron was detached from the desktop runner, allowing it to outlive a stopped workspace terminal with broken output pipes and retain the single-instance lock. Keep Electron owned by the runner and preserve explicit process-group cleanup for detached child trees.

* fix(timeline): preserve activity through turn handoff

Correlate lifecycle events and snapshots with daemon-owned turn identity, and keep optimistic submissions active until their canonical row arrives. This prevents stale updates and RPC timing from exposing a false idle state.

* refactor(timeline): remove submission timing state

* fix(timeline): restore prompt identity on resume

* fix(timeline): exclude attachments from canonical prompt text

Structured provider prompts can include hidden history attachments before the user's text. Persist only plain prompt blocks so authoritative user messages match what the composer displayed while provider input remains unchanged.

* refactor(agent): remove unused pending-run message identity

* fix(desktop): let Chromium allocate the debugging port

* refactor(agent): remove impossible finalized-turn tracking

* fix(timeline): load history after keyboard scrolling

Treat observed upward displacement as user intent so keyboard and scrollbar navigation can wake dormant pagination.

* test(server): preserve accepted turn stream order

The manager-owned accepted turn boundary intentionally precedes coalesced output. Assert that lifecycle event explicitly while retaining the timeline-before-terminal ordering contract.

* test(app): scope sidebar fixtures by identity

Shared E2E daemon state may contain unrelated projects. Keep stale-route coverage tied to its named missing project and drag only the two rows seeded by the reorder spec.

* fix(timeline): gate prompt revisions by client capability

* fix(timeline): finalize canonical prompt delivery

Treat provider identity as a single stored-row transition so legacy clients receive one deliverable canonical prompt. Release identity-pending rows before later foreground output or the current terminal while preserving stale-turn isolation.

* fix(server): preserve legacy prompt pagination

* fix(timeline): keep assistant block identity stable

Assign canonical block IDs during live promotion so terminal finalization cannot remount image-bearing rows.

* fix(timeline): apply covered revisions during resume append

Run the exact canonical revision pass before overlapping resume pages append newer rows, while leaving cursor advancement to the incremental path.

* fix(server): preserve queued agent transitions

Project each queued live snapshot before awaiting stored metadata so later lifecycle state cannot overwrite an earlier transition.

* fix(server): snapshot queued agent updates

Capture each live agent payload before it enters the per-agent queue so shared nested state cannot change while an earlier storage read is pending.

* docs(app): date agent turn compatibility cleanup

* refactor(timeline): mint submitted prompt identity at acceptance

Canonical prompt rows now keep one immutable wire identity while provider identity remains internal for rewind translation. This removes the unreleased same-sequence revision and legacy projection contract.

* refactor(app): remove prompt revision reconciliation

Submitted prompt rows are immutable on the wire, so resume and live reducers no longer need to apply already-covered same-sequence updates.

* test(app): cover submitted prompt rewind roundtrip

* docs(timeline): describe immutable submitted prompt identity

Provider echoes now enrich daemon-internal identity only; wire rows keep their acceptance-time identity and rewind performs the provider translation.

* test(server): validate mock rewind targets

Reject unknown provider message IDs so app rewind coverage exercises wire-to-provider identity translation.

* fix(server): validate rewind before interrupting

* fix(server): omit rewind identity for daemon prompts

* test(app): guard rewind eligibility and rejection

* fix(server): preserve prompt identity across hydration

* refactor(timeline): remove stale revision remnants

* Revert "fix(server): preserve prompt identity across hydration"

This reverts commit 6adcf7e5b0bc8601761af59b461a746cb5e395fa.

* fix(timeline): settle missed submission acknowledgements
This commit is contained in:
Mohamed Boudra
2026-08-01 14:13:47 +02:00
committed by GitHub
parent 8b87299ce1
commit 8021d2b6c9
161 changed files with 13757 additions and 2523 deletions
@@ -0,0 +1,853 @@
import type { Page } from "@playwright/test";
import { expect, test as baseTest } from "./fixtures";
import { expectAgentIdle } from "./helpers/agent-stream";
import {
attachImageFromMenu,
expectAttachmentPill,
expectComposerVisible,
submitMessage,
} from "./helpers/composer";
import { clickNewChat, gotoWorkspace } from "./helpers/launcher";
import { seedWorkspace } from "./helpers/seed-client";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "./helpers/mock-agent";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
interface FrameRect {
top: number;
right: number;
bottom: number;
left: number;
width: number;
height: number;
}
interface ElementFrame {
mounted: boolean;
visible: boolean;
painted: boolean;
rect: FrameRect | null;
opacity: number | null;
backgroundColor: string | null;
}
interface ContentChildFrame extends ElementFrame {
key: string;
text: string;
}
interface TurnFrame {
at: number;
userRow: ElementFrame;
attachment: ElementFrame;
footerRow: ElementFrame;
spinner: ElementFrame;
agentTab: ElementFrame;
tabProgress: ElementFrame;
interruptControl: ElementFrame;
primaryActionCount: number;
composer: ElementFrame & { value: string | null };
contentChildren: ContentChildFrame[];
scroll: ElementFrame & {
scrollTop: number | null;
clientHeight: number | null;
scrollHeight: number | null;
};
}
interface ActivityCheckpoint {
row: boolean;
stop: boolean;
footer: boolean;
tabProgress: boolean;
elapsed: boolean;
}
interface ActivityOracleState {
phase: "waiting" | "observing" | "complete";
checkpoints: ActivityCheckpoint[];
observer: MutationObserver;
}
type RunningReleaseOrder = "turn-before-response" | "response-before-turn" | "snapshot-before-turn";
const FIRST_PROMPT_IMAGE = {
name: "first-prompt.png",
mimeType: "image/png",
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
),
};
declare global {
interface Window {
__consecutiveTurnFrames?: { active: boolean; frames: TurnFrame[] };
__activityContinuityOracle?: ActivityOracleState;
}
}
const test = baseTest.extend<{ streamingAgent: MockAgentWorkspace }>({
streamingAgent: async ({ page: _page }, provide) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "consecutive-ten-second-turns-",
title: "Consecutive ten-second turns",
model: "ten-second-stream",
initialPrompt: "emit 100 coalesced agent stream updates",
});
await agent.client.waitForFinish(agent.agentId, 15_000);
await provide(agent);
await agent.cleanup();
},
});
async function recordTurnFrames(page: Page, prompt: string): Promise<void> {
await page.evaluate((promptText) => {
const state = { active: true, frames: [] as TurnFrame[] };
const emptyElement = (): ElementFrame => ({
mounted: false,
visible: false,
painted: false,
rect: null,
opacity: null,
backgroundColor: null,
});
const rectOf = (element: Element): FrameRect => {
const rect = element.getBoundingClientRect();
return {
top: rect.top,
right: rect.right,
bottom: rect.bottom,
left: rect.left,
width: rect.width,
height: rect.height,
};
};
const intersects = (first: FrameRect, second: FrameRect) =>
first.bottom > second.top &&
first.top < second.bottom &&
first.right > second.left &&
first.left < second.right;
const windowRect: FrameRect = {
top: 0,
right: innerWidth,
bottom: innerHeight,
left: 0,
width: innerWidth,
height: innerHeight,
};
const hasColor = (color: string) =>
color !== "" && color !== "none" && color !== "transparent" && color !== "rgba(0, 0, 0, 0)";
const isVisible = (element: Element, clip?: Element | null) => {
const rect = rectOf(element);
return (
element.isConnected &&
element.checkVisibility() &&
rect.width > 0 &&
rect.height > 0 &&
intersects(rect, windowRect) &&
(!clip || intersects(rect, rectOf(clip)))
);
};
const isPainted = (element: Element, clip?: Element | null) => {
if (!isVisible(element, clip)) return false;
return [element, ...Array.from(element.querySelectorAll("*"))].some((candidate) => {
if (!isVisible(candidate, clip)) return false;
const style = getComputedStyle(candidate);
if (Number(style.opacity) <= 0) return false;
if (hasColor(style.backgroundColor)) return true;
const hasPaintedBorder = ["Top", "Right", "Bottom", "Left"].some(
(side) =>
Number.parseFloat(style.getPropertyValue(`border-${side.toLowerCase()}-width`)) > 0 &&
hasColor(style.getPropertyValue(`border-${side.toLowerCase()}-color`)),
);
if (hasPaintedBorder) return true;
if (candidate.textContent?.trim() && hasColor(style.color)) return true;
if (candidate instanceof SVGElement) {
const fill = style.fill || candidate.getAttribute("fill") || "";
const stroke = style.stroke || candidate.getAttribute("stroke") || "";
return hasColor(fill) || hasColor(stroke);
}
return candidate instanceof HTMLImageElement || candidate instanceof HTMLCanvasElement;
});
};
const snapshot = (
element: Element | null | undefined,
clip?: Element | null,
painted?: boolean,
): ElementFrame => {
if (!element?.isConnected) return emptyElement();
const style = getComputedStyle(element);
return {
mounted: true,
visible: isVisible(element, clip),
painted: painted ?? isPainted(element, clip),
rect: rectOf(element),
opacity: Number(style.opacity),
backgroundColor: style.backgroundColor,
};
};
const unionRect = (elements: Element[]): FrameRect | null => {
if (elements.length === 0) return null;
const rects = elements.map(rectOf);
const top = Math.min(...rects.map((rect) => rect.top));
const right = Math.max(...rects.map((rect) => rect.right));
const bottom = Math.max(...rects.map((rect) => rect.bottom));
const left = Math.min(...rects.map((rect) => rect.left));
return { top, right, bottom, left, width: right - left, height: bottom - top };
};
const spinnerSnapshot = (
footer: Element | null | undefined,
clip: Element | null,
): ElementFrame => {
const dots = Array.from(footer?.querySelectorAll("*") ?? []).filter((candidate) =>
hasColor(getComputedStyle(candidate).backgroundColor),
);
if (dots.length === 0) return emptyElement();
const opacities = dots.map((dot) => Number(getComputedStyle(dot).opacity));
return {
mounted: true,
visible: dots.some((dot) => isVisible(dot, clip)),
painted: dots.some((dot, index) => opacities[index] > 0 && isVisible(dot, clip)),
rect: unionRect(dots),
opacity: Math.max(...opacities),
backgroundColor: getComputedStyle(dots[0]).backgroundColor,
};
};
const findImageAttachment = (row: Element | undefined) =>
row?.querySelector('[role="button"][aria-label="Open image attachment"]');
const findAgentTabState = () => {
const agentTab = Array.from(
document.querySelectorAll('[data-testid^="workspace-tab-agent_"]'),
).find((candidate) => isVisible(candidate));
return {
agentTab,
tabProgress: agentTab?.querySelector('[role="progressbar"][aria-label="Agent running"]'),
};
};
const countPrimaryActions = (composerRoot: Element | null | undefined) =>
Array.from(composerRoot?.querySelectorAll('[role="button"][aria-label]') ?? []).filter(
(candidate) =>
/stop agent|canceling agent|interrupt agent|send message|send and interrupt|queue message/i.test(
candidate.getAttribute("aria-label") ?? "",
) && isVisible(candidate),
).length;
const sample = () => {
const viewport = Array.from(
document.querySelectorAll('[data-testid="agent-chat-scroll"]'),
).find((candidate) => isVisible(candidate));
const promptRow = Array.from(
viewport?.querySelectorAll('[data-testid="user-message"]') ?? [],
).find((candidate) => candidate.textContent?.includes(promptText));
const attachment = findImageAttachment(promptRow);
const footer = Array.from(
viewport?.querySelectorAll('[data-testid="turn-working-indicator"]') ?? [],
)[0];
const spinner = spinnerSnapshot(footer, viewport ?? null);
const footerRow = footer?.parentElement;
const composer = Array.from(
document.querySelectorAll('[aria-label="Message agent..."]'),
).find((candidate) => isVisible(candidate));
const composerRoot = composer?.closest('[data-testid="message-input-root"]');
const interrupt = Array.from(
composerRoot?.querySelectorAll('[role="button"][aria-label]') ?? [],
).find((candidate) =>
/stop agent|canceling agent/i.test(candidate.getAttribute("aria-label") ?? ""),
);
const primaryActionCount = countPrimaryActions(composerRoot);
const { agentTab, tabProgress } = findAgentTabState();
const scrollFrame = snapshot(viewport);
const contentChildren = Array.from(viewport?.firstElementChild?.children ?? []).map(
(child, index): ContentChildFrame =>
Object.assign(snapshot(child, viewport), {
key:
(child.querySelector('[data-testid="turn-working-indicator"]')
? "turn-footer"
: (child.getAttribute("data-history-row-id") ??
child.getAttribute("data-testid"))) ?? `child-${index}`,
text: child.textContent?.trim().slice(0, 120) ?? "",
}),
);
state.frames.push({
at: performance.now(),
userRow: snapshot(promptRow, viewport),
attachment: snapshot(attachment, viewport),
footerRow: snapshot(footerRow, viewport, spinner.painted),
spinner,
agentTab: snapshot(agentTab),
tabProgress: snapshot(tabProgress),
interruptControl: snapshot(interrupt),
primaryActionCount,
composer: {
...snapshot(composerRoot ?? composer),
value:
composer instanceof HTMLInputElement || composer instanceof HTMLTextAreaElement
? composer.value
: null,
},
contentChildren,
scroll: {
...scrollFrame,
scrollTop: viewport instanceof HTMLElement ? viewport.scrollTop : null,
clientHeight: viewport instanceof HTMLElement ? viewport.clientHeight : null,
scrollHeight: viewport instanceof HTMLElement ? viewport.scrollHeight : null,
},
});
if (state.active) scheduleAfterNextPaint();
};
const scheduleAfterNextPaint = () => {
requestAnimationFrame(() => setTimeout(sample, 0));
};
window.__consecutiveTurnFrames = state;
scheduleAfterNextPaint();
}, prompt);
}
async function waitForRecordedFrames(
page: Page,
predicate: "user-visible" | "turn-running",
count: number,
): Promise<void> {
await expect
.poll(() =>
page.evaluate(
(input) => {
const frames = window.__consecutiveTurnFrames?.frames ?? [];
return frames.filter((frame) => {
if (input.predicate === "user-visible") return frame.userRow.visible;
return (
frame.interruptControl.painted &&
frame.footerRow.painted &&
frame.spinner.painted &&
frame.tabProgress.painted
);
}).length;
},
{ predicate },
),
)
.toBeGreaterThanOrEqual(count);
}
async function recordPaintsFor(page: Page, durationMs: number): Promise<void> {
const until = await page.evaluate((duration) => performance.now() + duration, durationMs);
await expect
.poll(() => page.evaluate(() => window.__consecutiveTurnFrames?.frames.at(-1)?.at ?? 0))
.toBeGreaterThanOrEqual(until);
}
async function installActivityContinuityOracle(page: Page, prompt: string): Promise<void> {
await page.evaluate((promptText) => {
const isVisible = (element: Element | null) => Boolean(element?.checkVisibility());
const snapshot = (): ActivityCheckpoint => {
const visibleAgentTab = Array.from(
document.querySelectorAll('[data-testid^="workspace-tab-agent_"]'),
).find((candidate) => isVisible(candidate));
return {
row: Array.from(document.querySelectorAll('[data-testid="user-message"]')).some(
(candidate) => candidate.textContent?.includes(promptText),
),
stop: isVisible(document.querySelector('[role="button"][aria-label="Stop agent"]')),
footer: isVisible(document.querySelector('[data-testid="turn-working-indicator"]')),
tabProgress: isVisible(
visibleAgentTab?.querySelector('[role="progressbar"][aria-label="Agent running"]') ??
null,
),
elapsed: isVisible(document.querySelector('[data-testid="turn-working-elapsed"]')),
};
};
const state = {
phase: "waiting" as ActivityOracleState["phase"],
checkpoints: [] as ActivityCheckpoint[],
observer: null as unknown as MutationObserver,
};
const observeCommit = () => {
const checkpoint = snapshot();
if (state.phase === "waiting" && checkpoint.row) {
state.phase = "observing";
}
if (state.phase !== "observing") return;
state.checkpoints.push(checkpoint);
if (checkpoint.elapsed) {
state.phase = "complete";
state.observer.disconnect();
}
};
state.observer = new MutationObserver(observeCommit);
window.__activityContinuityOracle = state;
state.observer.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
characterData: true,
});
}, prompt);
}
async function waitForActivityOracleArmed(page: Page): Promise<void> {
await expect
.poll(() => page.evaluate(() => window.__activityContinuityOracle?.phase ?? "missing"))
.not.toBe("waiting");
}
async function readActivityContinuityOracle(page: Page): Promise<ActivityCheckpoint[]> {
return page.evaluate(() => {
const state = window.__activityContinuityOracle;
if (!state) throw new Error("Activity continuity oracle was never installed");
state.observer.disconnect();
return state.checkpoints;
});
}
function expectActivityContinuity(checkpoints: ActivityCheckpoint[]): void {
const incompleteActivityCheckpoints = checkpoints.filter(
(checkpoint) => !checkpoint.stop || !checkpoint.footer || !checkpoint.tabProgress,
);
expect(checkpoints[0]?.row, "activity oracle never armed on the optimistic row").toBe(true);
expect(
incompleteActivityCheckpoints,
`activity oracle observed row without full activity cluster:\n${JSON.stringify(incompleteActivityCheckpoints, null, 2)}\n\nall checkpoints:\n${JSON.stringify(checkpoints, null, 2)}`,
).toEqual([]);
expect(
checkpoints.slice(0, -1).filter((checkpoint) => checkpoint.elapsed),
`activity oracle observed elapsed before its authoritative disarm checkpoint:\n${JSON.stringify(checkpoints, null, 2)}`,
).toEqual([]);
expect(checkpoints.at(-1)?.elapsed, "activity oracle never observed authoritative elapsed").toBe(
true,
);
}
async function stopTurnFrameRecording(page: Page): Promise<TurnFrame[]> {
return page.evaluate(() => {
const state = window.__consecutiveTurnFrames;
if (!state) throw new Error("Turn frames were never recorded");
state.active = false;
return state.frames;
});
}
function formatFrames(frames: TurnFrame[], centers: number | number[]): string {
const indexes = new Set<number>();
for (const center of Array.isArray(centers) ? centers : [centers]) {
for (let index = Math.max(0, center - 3); index <= center + 3; index += 1) {
if (index < frames.length) indexes.add(index);
}
}
return Array.from(indexes)
.sort((left, right) => left - right)
.map((index) => `frame ${index}: ${JSON.stringify(frames[index])}`)
.join("\n");
}
function expectRowContinuity(frames: TurnFrame[]): void {
const first = frames.findIndex((frame) => hasPaintedLayout(frame.userRow));
const baseline = frames[first]?.userRow.rect?.top;
const disappeared = frames.findIndex(
(frame, index) => index > first && !hasPaintedLayout(frame.userRow),
);
const reappeared = frames.findIndex(
(frame, index) => index > disappeared && disappeared >= 0 && hasPaintedLayout(frame.userRow),
);
const shifted = frames.findIndex(
(frame, index) =>
index > first &&
hasPaintedLayout(frame.userRow) &&
baseline !== undefined &&
Math.abs((frame.userRow.rect?.top ?? baseline) - baseline) > 1,
);
const attachmentMissing = frames.findIndex(
(frame, index) => index >= first && !hasPaintedLayout(frame.attachment),
);
const violations = [
...(first < 0 ? ["submitted row never became visible"] : []),
...(disappeared >= 0
? [
`submitted row disappeared for ${Math.max(0, reappeared - disappeared)} recorded frames (${Math.max(0, (frames[reappeared]?.at ?? frames.at(-1)?.at ?? 0) - frames[disappeared].at).toFixed(1)}ms) before authoritative recovery`,
]
: []),
...(shifted >= 0
? [
`submitted row moved from ${baseline?.toFixed(1)} to ${frames[shifted].userRow.rect?.top.toFixed(1)}`,
]
: []),
...(attachmentMissing >= 0 ? ["submitted image attachment left the painted layout"] : []),
];
let failure = Math.max(0, first);
if (attachmentMissing >= 0) failure = attachmentMissing;
if (shifted >= 0) failure = shifted;
if (disappeared >= 0) failure = disappeared;
expect(
violations,
`transition violations:\n${formatFrames(frames, [failure, Math.max(failure, reappeared)])}`,
).toEqual([]);
}
interface FrameViolation {
frame: number;
reason: string;
}
function geometryChanged(
value: number | null | undefined,
expected: number | null | undefined,
): boolean {
if (value == null || expected == null) return true;
return Math.abs(value - expected) > 1;
}
function hasPaintedLayout(element: ElementFrame): boolean {
return [element.mounted, element.visible, element.painted].every(Boolean);
}
function violationsForChecks(
frame: number,
checks: Array<{ passes: boolean; reason: string }>,
): FrameViolation[] {
return checks.filter(({ passes }) => !passes).map(({ reason }) => ({ frame, reason }));
}
function collectElementViolations(
frame: TurnFrame,
baseline: TurnFrame | undefined,
index: number,
): FrameViolation[] {
return violationsForChecks(index, [
{
passes: hasPaintedLayout(frame.userRow),
reason: "submitted row left the painted layout",
},
{
passes: !geometryChanged(frame.userRow.rect?.top, baseline?.userRow.rect?.top),
reason: "submitted row moved vertically",
},
{
passes: [hasPaintedLayout(frame.footerRow), (frame.footerRow.rect?.height ?? 0) > 0].every(
Boolean,
),
reason: "footer row was absent from the painted layout",
},
{
passes: [
geometryChanged(frame.footerRow.rect?.top, baseline?.footerRow.rect?.top),
geometryChanged(frame.footerRow.rect?.height, baseline?.footerRow.rect?.height),
].every((hasChanged) => !hasChanged),
reason: "footer geometry moved",
},
{
passes: hasPaintedLayout(frame.spinner),
reason: "working spinner was not painted",
},
{
passes: hasPaintedLayout(frame.tabProgress),
reason: "selected tab running indicator was not painted",
},
{
passes: hasPaintedLayout(frame.interruptControl),
reason: "interrupt control was not painted",
},
{
passes: frame.primaryActionCount === 1,
reason: `expected one primary action, found ${frame.primaryActionCount}`,
},
{
passes: [hasPaintedLayout(frame.composer), frame.composer.value === ""].every(Boolean),
reason: "composer was not painted and empty",
},
{
passes: [
geometryChanged(frame.composer.rect?.top, baseline?.composer.rect?.top),
geometryChanged(frame.composer.rect?.height, baseline?.composer.rect?.height),
].every((hasChanged) => !hasChanged),
reason: "composer geometry moved",
},
]);
}
function collectScrollViolations(
frame: TurnFrame,
baseline: TurnFrame | undefined,
index: number,
): FrameViolation[] {
return violationsForChecks(index, [
{
passes: [
frame.scroll.mounted,
frame.scroll.visible,
!geometryChanged(frame.scroll.rect?.top, baseline?.scroll.rect?.top),
!geometryChanged(frame.scroll.rect?.height, baseline?.scroll.rect?.height),
!geometryChanged(frame.scroll.clientHeight, baseline?.scroll.clientHeight),
].every(Boolean),
reason: "scroll viewport geometry moved",
},
{
passes: [
geometryChanged(frame.scroll.scrollTop, baseline?.scroll.scrollTop),
geometryChanged(frame.scroll.scrollHeight, baseline?.scroll.scrollHeight),
].every((hasChanged) => !hasChanged),
reason: "scroll content metrics moved",
},
]);
}
function collectContentViolations(
frame: TurnFrame,
stableContentChildren: Map<string, ContentChildFrame>,
index: number,
): FrameViolation[] {
return Array.from(stableContentChildren).flatMap(([key, expected]) => {
const child = frame.contentChildren.find((candidate) => candidate.key === key);
return violationsForChecks(index, [
{
passes: [
Boolean(child?.mounted),
!geometryChanged(child?.rect?.top, expected.rect?.top),
!geometryChanged(child?.rect?.bottom, expected.rect?.bottom),
!geometryChanged(child?.rect?.height, expected.rect?.height),
].every(Boolean),
reason: `content row ${key} geometry moved`,
},
]);
});
}
function expectAtomicIdleToRunningTransition(frames: TurnFrame[]): void {
const first = frames.findIndex((frame) => frame.userRow.visible);
const running = frames.findIndex(
(frame, index) =>
index >= first &&
frame.interruptControl.painted &&
frame.footerRow.painted &&
frame.spinner.painted &&
frame.tabProgress.painted,
);
const transition = frames.slice(first);
const baseline = transition[0];
const violations: FrameViolation[] = [];
const stableContentChildren = new Map(
baseline?.contentChildren
.filter(({ key }) => key !== "turn-footer")
.map((child) => [child.key, child]),
);
if (first < 0) violations.push({ frame: 0, reason: "submitted row never became visible" });
if (running < 0) violations.push({ frame: Math.max(first, 0), reason: "turn never visibly ran" });
for (const [offset, frame] of transition.entries()) {
const index = first + offset;
violations.push(...collectElementViolations(frame, baseline, index));
violations.push(...collectScrollViolations(frame, baseline, index));
violations.push(...collectContentViolations(frame, stableContentChildren, index));
}
for (let offset = 1; offset < transition.length; offset += 1) {
const before = transition[offset - 1];
const after = transition[offset];
const footerEnteredLayout = [
(before.footerRow.rect?.height ?? 0) <= 0,
(after.footerRow.rect?.height ?? 0) > 0,
].every(Boolean);
if (footerEnteredLayout) {
violations.push({
frame: first + offset,
reason: "footer height changed from zero to non-zero",
});
}
}
const failure = violations[0]?.frame ?? Math.max(first, 0);
expect(
violations,
`transition violations:\n${violations.map(({ frame, reason }) => `frame ${frame}: ${reason}`).join("\n")}\n\nframe record:\n${formatFrames(frames, [failure, running])}`,
).toEqual([]);
}
function expectAtomicFirstPromptTransition(frames: TurnFrame[]): void {
expectRowContinuity(frames);
const first = frames.findIndex((frame) => hasPaintedLayout(frame.userRow));
const transition = frames.slice(first);
const violations: FrameViolation[] = [];
for (const [offset, frame] of transition.entries()) {
const index = first + offset;
violations.push(
...violationsForChecks(index, [
{
passes: hasPaintedLayout(frame.footerRow),
reason: "footer was not painted with the first prompt",
},
{
passes: hasPaintedLayout(frame.spinner),
reason: "working spinner was not painted with the first prompt",
},
{
passes: hasPaintedLayout(frame.composer) && frame.composer.value === "",
reason: "composer was not painted and empty with the first prompt",
},
{
passes: !frame.agentTab.mounted || hasPaintedLayout(frame.tabProgress),
reason: "created agent tab appeared without running state",
},
{
passes: !frame.agentTab.mounted || hasPaintedLayout(frame.interruptControl),
reason: "created agent appeared without its interrupt control",
},
]),
);
}
const failure = violations[0]?.frame ?? Math.max(first, 0);
expect(
violations,
`transition violations:\n${violations.map(({ frame, reason }) => `frame ${frame}: ${reason}`).join("\n")}\n\nframe record:\n${formatFrames(frames, failure)}`,
).toEqual([]);
}
async function recordDelayedRunningTransition(
page: Page,
agent: MockAgentWorkspace,
releaseOrder: RunningReleaseOrder,
): Promise<{ frames: TurnFrame[]; checkpoints: ActivityCheckpoint[] }> {
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectAgentIdle(page);
await expect(page.getByRole("button", { name: "Copy turn" })).toHaveCount(1);
const prompt = "Second prompt keeps streaming.";
gate.holdNextAgentStreamEvent("turn_started");
gate.holdNextAgentUpdate(agent.agentId, "running");
gate.holdNextServerMessage("send_agent_message_response");
gate.setAgentStreamItemSuppressed("assistant_message", true);
await recordTurnFrames(page, prompt);
await installActivityContinuityOracle(page, prompt);
await submitMessage(page, prompt);
await Promise.all([
gate.waitForHeldAgentStreamEvent("turn_started"),
gate.waitForHeldAgentUpdate(agent.agentId, "running"),
gate.waitForHeldServerMessage("send_agent_message_response"),
]);
let turnReleased = false;
let snapshotReleased = false;
let responseReleased = false;
try {
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toHaveValue("");
await gate.waitForAgentStreamItem("user_message");
await waitForActivityOracleArmed(page);
const elapsedBeforeAuthoritative = await page.getByTestId("turn-working-elapsed").count();
if (releaseOrder === "turn-before-response") {
gate.releaseHeldAgentStreamEvent("turn_started");
turnReleased = true;
await expect(page.getByTestId("turn-working-elapsed")).toBeVisible();
gate.releaseHeldServerMessage("send_agent_message_response");
responseReleased = true;
gate.releaseHeldAgentUpdate(agent.agentId, "running");
snapshotReleased = true;
} else if (releaseOrder === "response-before-turn") {
gate.releaseHeldServerMessage("send_agent_message_response");
responseReleased = true;
await recordPaintsFor(page, 80);
gate.releaseHeldAgentStreamEvent("turn_started");
turnReleased = true;
await expect(page.getByTestId("turn-working-elapsed")).toBeVisible();
gate.releaseHeldAgentUpdate(agent.agentId, "running");
snapshotReleased = true;
} else {
gate.releaseHeldServerMessage("send_agent_message_response");
responseReleased = true;
await recordPaintsFor(page, 80);
gate.releaseHeldAgentUpdate(agent.agentId, "running");
snapshotReleased = true;
await expect(page.getByTestId("turn-working-elapsed")).toBeVisible();
gate.releaseHeldAgentStreamEvent("turn_started");
turnReleased = true;
}
await waitForRecordedFrames(page, "turn-running", 3);
const frames = await stopTurnFrameRecording(page);
const checkpoints = await readActivityContinuityOracle(page);
expectActivityContinuity(checkpoints);
expect(elapsedBeforeAuthoritative).toBe(0);
gate.setAgentStreamItemSuppressed("assistant_message", false);
return { frames, checkpoints };
} finally {
if (!turnReleased) gate.releaseHeldAgentStreamEvent("turn_started");
if (!snapshotReleased) gate.releaseHeldAgentUpdate(agent.agentId, "running");
if (!responseReleased) gate.releaseHeldServerMessage("send_agent_message_response");
gate.setAgentStreamItemSuppressed("assistant_message", false);
}
}
// The first prompt of a brand new agent is submitted before the agent exists, so the
// authoritative timeline arrives after the row is already on screen. That is the only
// path where hydration can move an already-visible message.
test("keeps the first prompt of a new agent in place through authoritative hydration", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "new-agent-first-prompt-" });
try {
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:create-agent-preferences",
JSON.stringify({
provider: "mock",
providerPreferences: {
mock: { mode: "load-test", model: "ten-second-stream" },
},
}),
);
});
const gate = await installDaemonWebSocketGate(page);
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
await expectComposerVisible(page);
const prompt = "Delay synthetic user message by 300ms.";
await attachImageFromMenu(page, FIRST_PROMPT_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await recordTurnFrames(page, prompt);
await submitMessage(page, prompt);
const submittedRow = page.getByTestId("user-message").filter({ hasText: prompt }).first();
await expect(submittedRow).toBeVisible();
await gate.waitForServerMessage("fetch_agent_timeline_response");
await recordPaintsFor(page, 80);
expectAtomicFirstPromptTransition(await stopTurnFrameRecording(page));
} finally {
await workspace.cleanup();
}
});
test("keeps follow-up activity continuous when turn starts before response", async ({
page,
streamingAgent,
}) => {
const { frames } = await recordDelayedRunningTransition(
page,
streamingAgent,
"turn-before-response",
);
expectAtomicIdleToRunningTransition(frames);
});
test("keeps follow-up activity continuous when response arrives before running state", async ({
page,
streamingAgent,
}) => {
const { frames } = await recordDelayedRunningTransition(
page,
streamingAgent,
"response-before-turn",
);
expectAtomicIdleToRunningTransition(frames);
});
test("keeps follow-up activity continuous when snapshot opens before turn event", async ({
page,
streamingAgent,
}) => {
const { frames } = await recordDelayedRunningTransition(
page,
streamingAgent,
"snapshot-before-turn",
);
expectAtomicIdleToRunningTransition(frames);
});
@@ -0,0 +1,133 @@
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "./fixtures";
import { expectAgentIdle, expectAgentReadyToInterrupt } from "./helpers/agent-stream";
import {
cancelAgent,
expectComposerDraft,
expectComposerVisible,
submitMessage,
} from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
async function completeSubmittedTurn(
page: Page,
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>,
prompt: string,
): Promise<Locator> {
await submitMessage(page, prompt);
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(userMessage).toBeVisible();
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
expect(finish.status).toBe("idle");
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
await expect(userMessage).toHaveAttribute("aria-busy", "false");
return userMessage;
}
async function rewindConversation(page: Page, userMessage: Locator, prompt: string): Promise<void> {
await userMessage.getByText(prompt, { exact: true }).hover();
await userMessage.getByRole("button", { name: "Rewind to this message", exact: true }).click();
await page.getByRole("button", { name: "Rewind conversation", exact: true }).click();
}
async function expectTurnCompletesNormally(
page: Page,
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>,
): Promise<void> {
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
expect(finish.status).toBe("idle");
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
await expectAgentIdle(page);
}
test.describe("Agent message rewind", () => {
test("rewinds a submitted prompt and restores it to the composer", async ({ page }) => {
test.setTimeout(90_000);
const prompt = "Restore this submitted prompt after rewind.";
const agent = await seedMockAgentWorkspace({
repoPrefix: "message-rewind-roundtrip-",
title: "Message rewind roundtrip",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const userMessage = await completeSubmittedTurn(page, agent, prompt);
await rewindConversation(page, userMessage, prompt);
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(0);
await expectComposerDraft(page, prompt);
} finally {
await agent.cleanup();
}
});
test("keeps a pre-acknowledgement turn running after rewind is rejected", async ({ page }) => {
test.setTimeout(90_000);
const prompt = "Delay synthetic user message by 2000ms.";
const rewindError = "Cannot rewind before the provider acknowledges the submitted prompt";
const agent = await seedMockAgentWorkspace({
repoPrefix: "message-rewind-pre-echo-",
title: "Message rewind pre-echo",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await submitMessage(page, prompt);
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(userMessage).toBeVisible();
await expectAgentReadyToInterrupt(page);
await rewindConversation(page, userMessage, prompt);
await expect(page.getByText(rewindError, { exact: true })).toBeVisible();
await expectAgentReadyToInterrupt(page);
await expectTurnCompletesNormally(page, agent);
} finally {
await agent.cleanup();
}
});
test("does not offer rewind for daemon-handled prompts", async ({ page }) => {
const commandPrompt = "/mock handled-command";
const providerPrompt = "Keep this provider-bound prompt rewindable.";
const agent = await seedMockAgentWorkspace({
repoPrefix: "message-rewind-daemon-command-",
title: "Message rewind daemon command",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await expectAgentIdle(page);
await submitMessage(page, commandPrompt);
const commandMessage = page.getByTestId("user-message").filter({ hasText: commandPrompt });
await expect(commandMessage).toBeVisible();
await expect(page.getByText("Mock command handled", { exact: true })).toBeVisible();
await commandMessage.getByText(commandPrompt, { exact: true }).hover();
await expect(
commandMessage.getByRole("button", { name: "Rewind to this message", exact: true }),
).toHaveCount(0);
await submitMessage(page, providerPrompt);
const providerMessage = page.getByTestId("user-message").filter({ hasText: providerPrompt });
await expect(providerMessage).toBeVisible();
await expectAgentReadyToInterrupt(page);
await providerMessage.getByText(providerPrompt, { exact: true }).hover();
await expect(
providerMessage.getByRole("button", { name: "Rewind to this message", exact: true }),
).toBeVisible();
await cancelAgent(page);
await expectAgentIdle(page);
} finally {
await agent.cleanup();
}
});
});
File diff suppressed because it is too large Load Diff
+26 -1
View File
@@ -3,6 +3,7 @@ import {
awaitAssistantMessage,
expectAgentIdle,
expectInlineWorkingIndicator,
expectRunningAgentChrome,
expectTurnCopyButton,
expectScrollFollowsNewContent,
} from "./helpers/agent-stream";
@@ -18,11 +19,35 @@ import { delayCreatedAgentInitialTailResponse } from "./helpers/agent-timeline-g
import { selectModel } from "./helpers/app";
import { clickNewChat } from "./helpers/launcher";
import { expectComposerVisible, startRunningMockAgent } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import {
openAgentRoute,
seedMockAgentWorkspace,
seedRunningMockAgentWorkspace,
} from "./helpers/mock-agent";
const SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE = 360;
test.describe("Agent stream UI", () => {
test("keeps running agent chrome after page refresh", async ({ page }) => {
const title = "Running agent refresh";
const agent = await seedRunningMockAgentWorkspace({
repoPrefix: "stream-running-refresh-",
title,
model: "five-minute-stream",
initialPrompt: "Stay running while the page refreshes.",
});
try {
await openAgentRoute(page, agent);
await expectRunningAgentChrome(page, title);
await page.reload();
await expectRunningAgentChrome(page, title);
} finally {
await agent.cleanup();
}
});
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
test.setTimeout(120_000);
const agent = await startRunningMockAgent(page, {
@@ -0,0 +1,85 @@
import { test as base } from "./fixtures";
import {
appendSettledTimelineTurns,
createNearTenMegabyteAssistantPng,
createSettledMockAgent,
createSmallAssistantPng,
emitSettledAssistantImage,
expectAssistantImageNotMounted,
expectAssistantImageRendered,
openAssistantImageTimeline,
openExistingImageAgentTabs,
remountAndRecoverAssistantImageFromHistory,
sendFollowUpAndExpectVisibleResponse,
switchAwayAndBackWithoutImageInstability,
userPagesUntilAssistantImageRenders,
} from "./helpers/assistant-images";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
imageWorkspace: async ({ page: _page }, provide) => {
const workspace = await seedWorkspace({ repoPrefix: "agent-tab-image-stability-" });
try {
await provide(workspace);
} finally {
await workspace.cleanup();
}
},
});
test("switching between settled agent tabs keeps a real assistant PNG rendered", async ({
imageWorkspace: workspace,
page,
}) => {
test.setTimeout(120_000);
const image = await createSmallAssistantPng(workspace, {
alt: "Real file image",
fileName: "assistant-preview.png",
});
const imageAgent = await createSettledMockAgent(workspace, "Image timeline");
const otherAgent = await createSettledMockAgent(workspace, "Other timeline");
await emitSettledAssistantImage(workspace.client, imageAgent, image);
await openExistingImageAgentTabs(page, { imageAgent, otherAgent });
await expectAssistantImageRendered(page, image);
await switchAwayAndBackWithoutImageInstability(page, { image, imageAgent, otherAgent });
});
test("a real assistant PNG remains reachable through pagination and remount", async ({
imageWorkspace: workspace,
page,
}) => {
test.setTimeout(120_000);
const image = await createSmallAssistantPng(workspace, {
alt: "Paginated real file image",
fileName: "paginated-assistant-preview.png",
});
const imageAgent = await createSettledMockAgent(workspace, "Paginated image timeline");
await emitSettledAssistantImage(workspace.client, imageAgent, image);
await appendSettledTimelineTurns(workspace.client, imageAgent, 40);
await openAssistantImageTimeline(page, imageAgent);
await expectAssistantImageNotMounted(page, image);
await userPagesUntilAssistantImageRenders(page, image);
await remountAndRecoverAssistantImageFromHistory(page, image);
});
test("a near-10 MiB real assistant PNG renders and the app remains responsive", async ({
imageWorkspace: workspace,
page,
}) => {
test.setTimeout(180_000);
const image = await createNearTenMegabyteAssistantPng(workspace, {
alt: "Large real file image",
fileName: "large-assistant-preview.png",
});
const imageAgent = await createSettledMockAgent(workspace, "Large image timeline");
await emitSettledAssistantImage(workspace.client, imageAgent, image);
await openAssistantImageTimeline(page, imageAgent);
await expectAssistantImageRendered(page, image);
await sendFollowUpAndExpectVisibleResponse(page, {
prompt: "confirm responsiveness: emit 1 coalesced agent stream updates",
response: "stress-update-0",
});
});
@@ -1,27 +1,229 @@
import { test } from "./fixtures";
import { expect, test } from "./fixtures";
import {
expectLoadedTimelineDoesNotScroll,
expectSameOlderHistoryLoadingOperation,
expectStableHistoryStartGutter,
expectTimelineAtHistoryStart,
expectTimelinePromptCentered,
expectTimelinePromptNotMounted,
expectTimelinePromptPositionPreserved,
expectTimelinePromptVisible,
holdNextOlderTimelinePage,
holdBootstrapTimelinePage,
holdDaemonHydration,
holdOlderHistoryPages,
makeLoadedTimelineFitViewport,
openAgentTimeline,
rememberOlderHistoryLoadingOperation,
rememberTimelineViewport,
rememberTimelinePromptPosition,
reloadAgentTimelineFromPersistedReplica,
scrollTimelineUntilOlderHistoryIsReachable,
scrollTimelineToOldestLoadedEdge,
scrollTimelineToNewestLoadedEdge,
seedLongMockAgentTimeline,
scrollThroughOlderHistoryPages,
seedRealisticOlderTimeline,
sendLiveTurnBeforeHydration,
expectTimelineViewportAnchoredAfterPrepend,
userNavigatesTimelineToHistoryStartWithKeyboard,
userScrollsTimelineToHistoryStart,
} from "./helpers/timeline-pagination";
test.describe("Agent timeline pagination", () => {
test("loads older history when the user scrolls to the top of a long agent timeline", async ({
test("keeps a fixed history-start gutter before, during, and after pagination", async ({
page,
}) => {
const agent = await seedLongMockAgentTimeline({ turns: 40 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await expectStableHistoryStartGutter(page);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
await expectStableHistoryStartGutter(page);
history.releasePage(1);
await history.expectSettledWithRequestedPages(1);
await expectStableHistoryStartGutter(page);
} finally {
await agent.cleanup();
}
});
test("loads one page each time the user returns to history start", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectTimelinePromptCentered(page, agent.newestPrompt);
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
history.releasePage(1);
await history.expectSettledWithRequestedPages(1);
await expectTimelinePromptCentered(page, agent.firstOlderPagePrompt);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(2);
} finally {
await agent.cleanup();
}
});
test("loads older history when the user navigates to history start with the keyboard", async ({
page,
}) => {
const agent = await seedLongMockAgentTimeline({ turns: 40 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userNavigatesTimelineToHistoryStartWithKeyboard(page);
await history.expectRequestedPages(1);
} finally {
await agent.cleanup();
}
});
test("does not repeat an assistant block that spans older history pages", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedRealisticOlderTimeline();
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await scrollThroughOlderHistoryPages(page, 3, history);
history.expectNoRepeatedAssistantEntries();
} finally {
await agent.cleanup();
}
});
test("keeps the visible timeline position anchored while prepending a page", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const viewport = await rememberTimelineViewport(page);
history.releasePage(1);
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
} finally {
await agent.cleanup();
}
});
test("keeps visible history anchored when live output grows during a prepend", async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const position = await rememberTimelinePromptPosition(page, agent.initialTailOldestPrompt);
await scrollTimelineUntilOlderHistoryIsReachable(page);
await agent.client.sendAgentMessage(
agent.agentId,
"timeline live during held older page: emit 20 coalesced agent stream updates",
);
await agent.client.waitForFinish(agent.agentId, 15_000);
history.releasePage(1);
await expectTimelinePromptPositionPreserved(page, position);
} finally {
await agent.cleanup();
}
});
test("finishes loading an older page while live output continues", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 40 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
await agent.client.sendAgentMessage(agent.agentId, "keep streaming while history settles");
await agent.client.waitForAgentUpsert(
agent.agentId,
(snapshot) => snapshot.status === "running",
);
history.releasePage(1);
await expect(page.getByTestId("load-older-history-spinner")).toBeHidden({ timeout: 5_000 });
const running = await agent.client.fetchAgents({ scope: "active" });
expect(running.entries.find((entry) => entry.agent.id === agent.agentId)?.agent.status).toBe(
"running",
);
} finally {
await agent.cleanup();
}
});
test("keeps the visible timeline anchored when the final page finishes", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 40 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const position = await rememberTimelinePromptPosition(page, agent.initialTailOldestPrompt);
history.releasePage(1);
await expectTimelinePromptPositionPreserved(page, position);
await history.expectSettledWithRequestedPages(1);
} finally {
await agent.cleanup();
}
});
test("continues one loading operation while older pages still leave history start exposed", async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await makeLoadedTimelineFitViewport(page);
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await scrollTimelineToOldestLoadedEdge(page);
await history.expectRequestedPages(1);
const loading = await rememberOlderHistoryLoadingOperation(page);
history.releasePage(1);
await history.expectRequestedPages(2);
await expectTimelineAtHistoryStart(page);
await expectSameOlderHistoryLoadingOperation(page, loading);
} finally {
await agent.cleanup();
}
});
test("keeps complete loaded history reachable after reload", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await openAgentTimeline(page, agent);
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
const hydration = await holdDaemonHydration(page);
await reloadAgentTimelineFromPersistedReplica(page, agent);
hydration.release();
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
} finally {
@@ -29,18 +231,27 @@ test.describe("Agent timeline pagination", () => {
}
});
test("loads older history when the initial page does not fill the viewport", async ({ page }) => {
test("preserves a live row received before replica hydration", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 30 });
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await makeLoadedTimelineFitViewport(page);
const olderPage = await holdNextOlderTimelinePage(page, agent);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectLoadedTimelineDoesNotScroll(page);
await olderPage.expectLoading();
olderPage.release();
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
const hydration = await holdBootstrapTimelinePage(page, agent);
await reloadAgentTimelineFromPersistedReplica(page, agent);
await hydration.waitForDelayedResponse();
const livePrompt = await sendLiveTurnBeforeHydration(agent);
await expectTimelinePromptVisible(page, livePrompt);
hydration.release();
await hydration.waitForDelayedCatchUp();
await expectTimelinePromptVisible(page, livePrompt);
hydration.releaseCatchUp();
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
await scrollTimelineToNewestLoadedEdge(page);
await expectTimelinePromptVisible(page, livePrompt);
} finally {
await agent.cleanup();
}
@@ -0,0 +1,65 @@
import { test } from "./fixtures";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import {
expectTimelinePresentationUnchanged,
expectTimelinePromptNotMounted,
expectTimelinePromptVisible,
openAgentTimeline,
rememberTimelinePresentation,
scrollTimelinePromptIntoView,
scrollTimelineUntilOlderHistoryIsReachable,
seedLongMockAgentTimeline,
} from "./helpers/timeline-pagination";
import {
commitTimelineTurnsWhileDisconnected,
disconnectViewedTimeline,
expectOneTailCheckWithoutCatchUpPages,
rememberTimelineRequestCounts,
restoreViewedTimelineWithHeldTail,
} from "./helpers/timeline-resume";
test.describe("Agent timeline resume", () => {
test("a long disconnected run lands as one latest-tail update", async ({ page }) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const agent = await seedLongMockAgentTimeline({ turns: 3 });
try {
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
const requests = rememberTimelineRequestCounts(gate);
await disconnectViewedTimeline(page, gate);
const background = await commitTimelineTurnsWhileDisconnected(agent, 24);
await restoreViewedTimelineWithHeldTail(page, gate);
await expectTimelinePromptVisible(page, background.newestPrompt);
await expectTimelinePromptNotMounted(page, background.firstPrompt);
expectOneTailCheckWithoutCatchUpPages(gate, requests);
await scrollTimelineUntilOlderHistoryIsReachable(page, background.firstPrompt);
} finally {
gate.restore();
await agent.cleanup();
}
});
test("an unchanged resume leaves a scrolled-up timeline untouched", async ({ page }) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const agent = await seedLongMockAgentTimeline({ turns: 30 });
try {
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await scrollTimelinePromptIntoView(page, agent.initialTailAnchorPrompt);
const presentation = await rememberTimelinePresentation(page, agent.initialTailAnchorPrompt);
const requests = rememberTimelineRequestCounts(gate);
await disconnectViewedTimeline(page, gate);
await restoreViewedTimelineWithHeldTail(page, gate);
await expectTimelinePresentationUnchanged(page, presentation);
expectOneTailCheckWithoutCatchUpPages(gate, requests);
} finally {
gate.restore();
await agent.cleanup();
}
});
});
@@ -0,0 +1,86 @@
import type { Page, WebSocketRoute } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
type WebSocketMessage = string | Buffer;
interface SendAgentMessageRequest {
type: "send_agent_message_request";
requestId: string;
agentId: string;
}
function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | null {
if (typeof message !== "string") return null;
try {
const envelope = JSON.parse(message) as {
type?: unknown;
message?: Record<string, unknown>;
};
const request = envelope.type === "session" ? envelope.message : null;
if (
request?.type !== "send_agent_message_request" ||
typeof request.requestId !== "string" ||
typeof request.agentId !== "string"
) {
return null;
}
return {
type: "send_agent_message_request",
requestId: request.requestId,
agentId: request.agentId,
};
} catch {
return null;
}
}
export async function gateNextAgentMessage(page: Page) {
let serverSocket: WebSocketRoute | null = null;
let browserSocket: WebSocketRoute | null = null;
const heldMessages: Array<WebSocketMessage | null> = [];
const requests: SendAgentMessageRequest[] = [];
const requestWaiters = new Set<() => void>();
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
browserSocket = ws;
const server = ws.connectToServer();
serverSocket = server;
ws.onMessage((message) => {
const request = readSendRequest(message);
if (request) {
heldMessages.push(message);
requests.push(request);
for (const resolve of requestWaiters) resolve();
requestWaiters.clear();
return;
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});
const waitForRequest = async (count = 1): Promise<SendAgentMessageRequest> => {
while (requests.length < count) {
await new Promise<void>((resolve) => requestWaiters.add(resolve));
}
return requests[count - 1];
};
return {
waitForRequest,
accept(index = 0) {
const heldMessage = heldMessages[index];
if (!serverSocket || !heldMessage) {
throw new Error("No held send-agent-message request to accept");
}
serverSocket.send(heldMessage);
heldMessages[index] = null;
},
async disconnect(): Promise<void> {
if (!browserSocket) throw new Error("No browser daemon socket to disconnect");
await browserSocket.close({ code: 1008, reason: "Dropped by submission test." });
},
};
}
+44
View File
@@ -22,6 +22,50 @@ export async function expectInlineWorkingIndicator(page: Page): Promise<void> {
await expect(page.getByTestId("turn-working-indicator")).toBeVisible({ timeout: 30_000 });
}
export async function expectRunningAgentChrome(page: Page, title: string): Promise<void> {
const tab = page.getByRole("button", { name: title, exact: true });
await expect(tab).toBeVisible({ timeout: 30_000 });
await expect(tab.getByRole("progressbar", { name: "Agent running" })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByRole("button", { name: /stop agent|canceling agent/i })).toBeVisible({
timeout: 30_000,
});
await expectInlineWorkingIndicator(page);
}
export async function expectAgentReadyToInterrupt(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByRole("button", { name: "Canceling agent", exact: true })).toHaveCount(0);
}
export async function expectVisibleAgentSurfacesIdle(page: Page): Promise<void> {
const visibleAgentTab = page
.getByTestId(/^workspace-tab-agent_/)
.filter({ visible: true })
.first();
await expect(visibleAgentTab).toBeVisible({ timeout: 30_000 });
await expect(visibleAgentTab.getByRole("progressbar", { name: "Agent running" })).toHaveCount(0);
await expect(page.getByRole("button", { name: /stop agent|canceling agent/i })).toHaveCount(0);
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
await expect(page.getByTestId("turn-working-elapsed")).toHaveCount(0);
}
export async function expectAgentSurfacesIdle(page: Page, title: string): Promise<void> {
const tab = page.getByRole("button", { name: title, exact: true });
await expect(tab).toBeVisible({ timeout: 30_000 });
await expect(tab.getByRole("progressbar", { name: "Agent running" })).toHaveCount(0);
await expect(page.getByRole("button", { name: /stop agent|canceling agent/i })).toHaveCount(0);
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
await expect(page.getByTestId("turn-working-elapsed")).toHaveCount(0);
await expectTurnCopyButton(page);
}
export async function expectTurnCopyButton(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Copy turn" }).first()).toBeVisible({
timeout: 30_000,
+202 -1
View File
@@ -15,6 +15,22 @@ export interface AgentTimelineResponseGate {
waitForDelayedResponse(): Promise<void>;
}
export interface OlderTimelinePagesGate {
getRequestCount(): number;
getRepeatedAssistantEntryCount(): number;
releasePage(pageNumber: number): void;
waitForRequestCount(count: number): Promise<void>;
}
export interface DaemonHydrationGate {
release(): void;
}
export interface BootstrapTimelineGate extends AgentTimelineResponseGate {
releaseCatchUp(): void;
waitForDelayedCatchUp(): Promise<void>;
}
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
@@ -45,6 +61,32 @@ function getPayload(message: Record<string, unknown>): Record<string, unknown> |
: null;
}
export async function holdDaemonHydration(page: Page): Promise<DaemonHydrationGate> {
let released = false;
const delayedForwards: Array<() => void> = [];
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (released) {
ws.send(message);
return;
}
delayedForwards.push(() => ws.send(message));
});
});
return {
release() {
released = true;
for (const forward of delayedForwards.splice(0)) {
forward();
}
},
};
}
export async function delayCreatedAgentInitialTailResponse(
page: Page,
): Promise<CreatedAgentTimelineGate> {
@@ -127,6 +169,165 @@ export async function delayCreatedAgentInitialTailResponse(
export async function delayAgentOlderTimelineResponse(
page: Page,
agentId: string,
): Promise<AgentTimelineResponseGate> {
return delayAgentTimelineResponse(page, agentId, "before");
}
export async function holdAgentOlderTimelinePages(
page: Page,
agentId: string,
): Promise<OlderTimelinePagesGate> {
let requestCount = 0;
let responseCount = 0;
let repeatedAssistantEntryCount = 0;
const assistantEntryKeys = new Set<string>();
const releasedPages = new Set<number>();
const delayedForwards = new Map<number, Array<() => void>>();
const requestWaiters = new Map<number, Array<() => void>>();
const resolveRequestWaiters = () => {
for (const [count, resolvers] of requestWaiters) {
if (requestCount < count) continue;
requestWaiters.delete(count);
for (const resolve of resolvers) resolve();
}
};
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (
sessionMessage?.type === "fetch_agent_timeline_request" &&
sessionMessage.agentId === agentId &&
sessionMessage.direction === "before"
) {
requestCount += 1;
resolveRequestWaiters();
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (
sessionMessage?.type === "fetch_agent_timeline_response" &&
payload?.agentId === agentId &&
payload.direction === "before"
) {
responseCount += 1;
const entries = Array.isArray(payload.entries) ? payload.entries : [];
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;
if (releasedPages.has(pageNumber)) {
ws.send(message);
return;
}
const forwards = delayedForwards.get(pageNumber) ?? [];
forwards.push(() => ws.send(message));
delayedForwards.set(pageNumber, forwards);
return;
}
ws.send(message);
});
});
return {
getRequestCount: () => requestCount,
getRepeatedAssistantEntryCount: () => repeatedAssistantEntryCount,
releasePage(pageNumber) {
releasedPages.add(pageNumber);
for (const forward of delayedForwards.get(pageNumber) ?? []) forward();
delayedForwards.delete(pageNumber);
},
waitForRequestCount(count) {
if (requestCount >= count) return Promise.resolve();
return new Promise<void>((resolve) => {
const resolvers = requestWaiters.get(count) ?? [];
resolvers.push(resolve);
requestWaiters.set(count, resolvers);
});
},
};
}
export async function delayAgentBootstrapTailResponse(
page: Page,
agentId: string,
): Promise<BootstrapTimelineGate> {
let tailReleased = false;
let catchUpReleased = false;
const delayedTailForwards: Array<() => void> = [];
const delayedCatchUpForwards: Array<() => void> = [];
let resolveDelayedTail: (() => void) | null = null;
let resolveDelayedCatchUp: (() => void) | null = null;
const delayedTail = new Promise<void>((resolve) => {
resolveDelayedTail = resolve;
});
const delayedCatchUp = new Promise<void>((resolve) => {
resolveDelayedCatchUp = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
const isTimelineResponse =
sessionMessage?.type === "fetch_agent_timeline_response" && payload?.agentId === agentId;
if (isTimelineResponse && payload.direction === "tail") {
resolveDelayedTail?.();
if (tailReleased) ws.send(message);
else delayedTailForwards.push(() => ws.send(message));
return;
}
if (isTimelineResponse && payload.direction === "after") {
resolveDelayedCatchUp?.();
if (catchUpReleased) ws.send(message);
else delayedCatchUpForwards.push(() => ws.send(message));
return;
}
ws.send(message);
});
});
return {
release() {
tailReleased = true;
for (const forward of delayedTailForwards.splice(0)) forward();
},
releaseCatchUp() {
catchUpReleased = true;
for (const forward of delayedCatchUpForwards.splice(0)) forward();
},
waitForDelayedResponse: () => delayedTail,
waitForDelayedCatchUp: () => delayedCatchUp,
};
}
async function delayAgentTimelineResponse(
page: Page,
agentId: string,
direction: "before" | "tail",
): Promise<AgentTimelineResponseGate> {
let releaseRequested = false;
let delayedResponseSeen = false;
@@ -148,7 +349,7 @@ export async function delayAgentOlderTimelineResponse(
!delayedResponseSeen &&
sessionMessage?.type === "fetch_agent_timeline_response" &&
payload?.agentId === agentId &&
payload.direction === "before"
payload.direction === direction
) {
delayedResponseSeen = true;
resolveDelayedResponse?.();
@@ -0,0 +1,343 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { deflateSync } from "node:zlib";
import { expect, type Page } from "@playwright/test";
import type { ArchiveTabAgent } from "./archive-tab";
import { openWorkspaceWithAgents } from "./archive-tab";
import { submitMessage } from "./composer";
import type { SeedDaemonClient, SeededWorkspace } from "./seed-client";
import { openAgentRoute } from "./mock-agent";
import { rememberTimelineViewport, userScrollsTimelineToHistoryStart } from "./timeline-pagination";
const IMAGE_PREVIEW_ERROR = "Unable to load image preview.";
const SMALL_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
"base64",
);
const TEN_MEBIBYTES = 10 * 1024 * 1024;
export interface AssistantImageFixture {
alt: string;
height: number;
relativePath: string;
size: number;
width: number;
}
export async function createSmallAssistantPng(
workspace: SeededWorkspace,
input: { alt: string; fileName: string },
): Promise<AssistantImageFixture> {
await writeFile(path.join(workspace.repoPath, input.fileName), SMALL_PNG);
return {
alt: input.alt,
height: 1,
relativePath: input.fileName,
size: SMALL_PNG.byteLength,
width: 1,
};
}
export async function createNearTenMegabyteAssistantPng(
workspace: SeededWorkspace,
input: { alt: string; fileName: string },
): Promise<AssistantImageFixture> {
const width = 2_048;
const height = 1_280;
const bytes = encodeDeterministicPng(width, height);
if (bytes.byteLength < TEN_MEBIBYTES * 0.95 || bytes.byteLength > TEN_MEBIBYTES * 1.05) {
throw new Error(`Expected a PNG near 10 MiB, encoded ${bytes.byteLength} bytes`);
}
await writeFile(path.join(workspace.repoPath, input.fileName), bytes);
return {
alt: input.alt,
height,
relativePath: input.fileName,
size: bytes.byteLength,
width,
};
}
export async function createSettledMockAgent(
workspace: SeededWorkspace,
title: string,
): Promise<ArchiveTabAgent> {
const agent = await workspace.client.createAgent({
provider: "mock",
model: "ten-second-stream",
modeId: "load-test",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
});
await workspace.client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "idle",
30_000,
);
return {
id: agent.id,
title,
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
};
}
export async function emitSettledAssistantImage(
client: SeedDaemonClient,
agent: ArchiveTabAgent,
image: AssistantImageFixture,
): Promise<void> {
await client.sendAgentMessage(
agent.id,
`Emit settled assistant image Markdown: ![${image.alt}](${image.relativePath})`,
);
const result = await client.waitForFinish(agent.id, 30_000);
if (result.status !== "idle" || result.final?.lastError) {
throw new Error(
`Assistant image agent did not settle: ${result.final?.lastError ?? result.status}`,
);
}
}
export async function appendSettledTimelineTurns(
client: SeedDaemonClient,
agent: ArchiveTabAgent,
count: number,
): Promise<void> {
for (let index = 0; index < count; index += 1) {
await client.sendAgentMessage(
agent.id,
`image-history-turn-${index}: emit 1 coalesced agent stream updates`,
);
const result = await client.waitForFinish(agent.id, 30_000);
if (result.status !== "idle" || result.final?.lastError) {
throw new Error(
`Assistant image history turn did not settle: ${result.final?.lastError ?? result.status}`,
);
}
}
}
export async function expectAssistantImageRendered(
page: Page,
image: AssistantImageFixture,
): Promise<void> {
const rendered = page.getByRole("img", { name: image.alt }).first();
await expect(rendered).toBeVisible({ timeout: 30_000 });
await expect
.poll(async () =>
rendered.evaluate((element) => {
const imageElement =
element instanceof HTMLImageElement ? element : element.querySelector("img");
return imageElement?.complete
? { height: imageElement.naturalHeight, width: imageElement.naturalWidth }
: null;
}),
)
.toEqual({ height: image.height, width: image.width });
}
export async function switchAwayAndBackWithoutImageInstability(
page: Page,
input: {
image: AssistantImageFixture;
imageAgent: ArchiveTabAgent;
otherAgent: ArchiveTabAgent;
},
): Promise<void> {
await beginVisibleImageStabilityObservation(page, input.image.alt, input.imageAgent.id);
await selectSettledAgentTab(page, input.otherAgent);
await selectSettledAgentTab(page, input.imageAgent);
await expectAssistantImageRendered(page, input.image);
await expectNoVisibleImageInstability(page);
}
export async function openExistingImageAgentTabs(
page: Page,
input: { imageAgent: ArchiveTabAgent; otherAgent: ArchiveTabAgent },
): Promise<void> {
await openWorkspaceWithAgents(page, [input.otherAgent, input.imageAgent]);
}
export async function openAssistantImageTimeline(
page: Page,
agent: ArchiveTabAgent,
): Promise<void> {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.id });
}
export async function expectAssistantImageNotMounted(
page: Page,
image: AssistantImageFixture,
): Promise<void> {
await expect(page.getByRole("img", { name: image.alt })).toHaveCount(0);
}
export async function userPagesUntilAssistantImageRenders(
page: Page,
image: AssistantImageFixture,
): Promise<void> {
const rendered = page.getByRole("img", { name: image.alt });
for (let attempt = 0; attempt < 10; attempt += 1) {
if ((await rendered.count()) > 0) {
await expectAssistantImageRendered(page, image);
return;
}
const previous = await rememberTimelineViewport(page);
await userScrollsTimelineToHistoryStart(page);
await expect
.poll(async () => (await rememberTimelineViewport(page)).scrollHeight)
.toBeGreaterThan(previous.scrollHeight);
}
await expectAssistantImageRendered(page, image);
}
export async function remountAndRecoverAssistantImageFromHistory(
page: Page,
image: AssistantImageFixture,
): Promise<void> {
await page.reload();
await userPagesUntilAssistantImageRenders(page, image);
}
export async function sendFollowUpAndExpectVisibleResponse(
page: Page,
input: { prompt: string; response: string },
): Promise<void> {
await submitMessage(page, input.prompt);
await expect(page.getByText(input.prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
await expect(page.getByText(input.response, { exact: true })).toBeVisible({ timeout: 30_000 });
}
async function selectSettledAgentTab(page: Page, agent: ArchiveTabAgent): Promise<void> {
const tab = page.getByRole("button", { name: agent.title, exact: true });
await tab.click();
await expect(page).toHaveTitle(agent.title);
await expect(tab).toHaveAttribute("aria-selected", "true");
await expect(page.locator('[data-testid="agent-chat-scroll"]:visible').first()).toBeVisible({
timeout: 30_000,
});
}
async function beginVisibleImageStabilityObservation(
page: Page,
alt: string,
imageAgentId: string,
): Promise<void> {
await page.evaluate(
({ accessibleName, errorText, tabTestId }) => {
const record = {
errorSeen: false,
missingSeen: false,
inspect: () => undefined,
observer: null as MutationObserver | null,
};
const isVisible = (element: Element) => {
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
record.inspect = () => {
record.errorSeen ||= Array.from(document.querySelectorAll("*")).some(
(element) =>
element.children.length === 0 &&
element.textContent?.trim() === errorText &&
isVisible(element),
);
const imageTab = document.querySelector(`[data-testid="${tabTestId}"]`);
if (imageTab?.getAttribute("aria-selected") !== "true") return;
const imageVisible = Array.from(document.querySelectorAll('[role="img"]')).some(
(element) => element.getAttribute("aria-label") === accessibleName && isVisible(element),
);
record.missingSeen ||= !imageVisible;
};
record.observer = new MutationObserver(record.inspect);
record.observer.observe(document.body, {
attributes: true,
childList: true,
subtree: true,
characterData: true,
});
record.inspect();
(
window as unknown as {
__paseoAssistantImageObservation?: typeof record;
}
).__paseoAssistantImageObservation = record;
},
{
accessibleName: alt,
errorText: IMAGE_PREVIEW_ERROR,
tabTestId: `workspace-tab-agent_${imageAgentId}`,
},
);
}
async function expectNoVisibleImageInstability(page: Page): Promise<void> {
const observation = await page.evaluate(() => {
const owner = window as unknown as {
__paseoAssistantImageObservation?: {
errorSeen: boolean;
missingSeen: boolean;
inspect(): void;
observer: MutationObserver | null;
};
};
const record = owner.__paseoAssistantImageObservation;
if (!record) throw new Error("Assistant image stability observation was not started");
record.inspect();
record.observer?.disconnect();
delete owner.__paseoAssistantImageObservation;
return { errorSeen: record.errorSeen, missingSeen: record.missingSeen };
});
expect(observation).toEqual({ errorSeen: false, missingSeen: false });
}
function encodeDeterministicPng(width: number, height: number): Buffer {
const stride = width * 4 + 1;
const scanlines = Buffer.allocUnsafe(stride * height);
let state = 0x9e3779b9;
for (let row = 0; row < height; row += 1) {
const rowOffset = row * stride;
scanlines[rowOffset] = 0;
for (let offset = 1; offset < stride; offset += 1) {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
scanlines[rowOffset + offset] = state & 0xff;
}
}
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8;
header[9] = 6;
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
pngChunk("IHDR", header),
pngChunk("IDAT", deflateSync(scanlines, { level: 0 })),
pngChunk("IEND", Buffer.alloc(0)),
]);
}
function pngChunk(type: string, data: Buffer): Buffer {
const typeBytes = Buffer.from(type, "ascii");
const chunk = Buffer.allocUnsafe(12 + data.byteLength);
chunk.writeUInt32BE(data.byteLength, 0);
typeBytes.copy(chunk, 4);
data.copy(chunk, 8);
chunk.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])), 8 + data.byteLength);
return chunk;
}
function crc32(bytes: Buffer): number {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
}
}
return (crc ^ 0xffffffff) >>> 0;
}
+503 -41
View File
@@ -14,8 +14,10 @@ export interface DirectoryRequestStartCounts {
interface ClientRequest {
type?: unknown;
direction?: unknown;
subscribe?: unknown;
page?: { cursor?: unknown };
payload?: unknown;
mode?: unknown;
path?: unknown;
}
@@ -33,6 +35,31 @@ interface ServerMessage {
};
}
interface HeldAgentUpdate {
agentId: string;
status: string;
}
interface HeldServerMessage {
browser: WebSocketRoute;
message: string | Buffer;
key: string;
agentStreamFollowers: Array<string | Buffer>;
blockedAgentId?: string;
}
function readSessionMessage(message: string | Buffer): ClientRequest | null {
try {
const envelope = JSON.parse(typeof message === "string" ? message : message.toString()) as {
type?: unknown;
message?: ClientRequest;
};
return envelope.message ?? envelope;
} catch {
return null;
}
}
function readClientRequest(message: string | Buffer): ClientRequest | null {
if (typeof message !== "string") return null;
try {
@@ -46,18 +73,6 @@ function readClientRequest(message: string | Buffer): ClientRequest | null {
}
}
function readServerMessage(message: string | Buffer): ServerMessage | null {
try {
const envelope = JSON.parse(typeof message === "string" ? message : message.toString()) as {
type?: unknown;
message?: ServerMessage;
};
return envelope.type === "session" ? (envelope.message ?? null) : envelope;
} catch {
return null;
}
}
function directoryForRequest(request: ClientRequest): keyof DirectoryBootstrapCounts | null {
if (request.page?.cursor) return null;
if (request.type === "fetch_agents_request") return "agents";
@@ -65,15 +80,170 @@ function directoryForRequest(request: ClientRequest): keyof DirectoryBootstrapCo
return null;
}
function stripAssistantMessageId(
message: string | Buffer,
enabled: boolean,
messageType: unknown,
): string | Buffer {
if (!enabled || messageType !== "agent_stream" || typeof message !== "string") return message;
const envelope = JSON.parse(message) as {
message?: { payload?: { event?: { type?: unknown; item?: Record<string, unknown> } } };
payload?: { event?: { type?: unknown; item?: Record<string, unknown> } };
};
const event = (envelope.message?.payload ?? envelope.payload)?.event;
if (event?.type !== "timeline" || event.item?.type !== "assistant_message") return message;
delete event.item.messageId;
return JSON.stringify(envelope);
}
function stripCanonicalSubmittedPrompts(
message: string | Buffer,
enabled: boolean,
messageType: unknown,
): string | Buffer {
if (!enabled || messageType !== "status" || typeof message !== "string") return message;
const envelope = JSON.parse(message) as {
message?: { payload?: { status?: unknown; features?: Record<string, unknown> } };
payload?: { status?: unknown; features?: Record<string, unknown> };
};
const payload = envelope.message?.payload ?? envelope.payload;
if (payload?.status !== "server_info" || !payload.features) return message;
delete payload.features.canonicalSubmittedPrompts;
return JSON.stringify(envelope);
}
function forceTimelineReset(message: string | Buffer, enabled: boolean): string | Buffer {
if (!enabled || typeof message !== "string") return message;
const envelope = JSON.parse(message) as {
message?: { payload?: Record<string, unknown> };
payload?: Record<string, unknown>;
};
const payload = envelope.message?.payload ?? envelope.payload;
if (!payload) return message;
payload.epoch = `playwright-reset-${Date.now()}`;
payload.reset = true;
return JSON.stringify(envelope);
}
function readAgentStreamEventType(message: ClientRequest | null): string | null {
if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") {
return null;
}
const event = (message.payload as { event?: { type?: unknown } }).event;
return typeof event?.type === "string" ? event.type : null;
}
function readAgentStreamAgentId(message: ClientRequest | null): string | null {
if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") {
return null;
}
const agentId = (message.payload as { agentId?: unknown }).agentId;
return typeof agentId === "string" ? agentId : null;
}
function readAgentStreamItemType(message: ClientRequest | null): string | null {
if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") {
return null;
}
const event = (message.payload as { event?: { type?: unknown; item?: { type?: unknown } } })
.event;
return event?.type === "timeline" && typeof event.item?.type === "string"
? event.item.type
: null;
}
function shouldSuppressServerMessage(input: {
message: ClientRequest | null;
messageTypes: ReadonlySet<string>;
agentStreamEventTypes: ReadonlySet<string>;
agentStreamItemTypes: ReadonlySet<string>;
suppressAgentStream: boolean;
}): boolean {
const messageType = typeof input.message?.type === "string" ? input.message.type : null;
if (messageType && input.messageTypes.has(messageType)) return true;
if (input.suppressAgentStream && messageType === "agent_stream") return true;
const itemType = readAgentStreamItemType(input.message);
if (itemType && input.agentStreamItemTypes.has(itemType)) return true;
const eventType = readAgentStreamEventType(input.message);
return Boolean(eventType && input.agentStreamEventTypes.has(eventType));
}
function serverMessageKey(type: string): string {
return `message:${type}`;
}
function agentStreamEventKey(type: string): string {
return `agent-stream-event:${type}`;
}
function agentUpdateKey(agentId: string, status: string): string {
return `agent-update:${agentId}:${status}`;
}
function matchesAgentUpdate(message: ClientRequest | null, agentUpdate: HeldAgentUpdate): boolean {
if (message?.type !== "agent_update") return false;
const payload = message.payload;
if (
!payload ||
typeof payload !== "object" ||
(payload as { kind?: unknown }).kind !== "upsert"
) {
return false;
}
const agent = (payload as { agent?: { id?: unknown; status?: unknown } }).agent;
return agent?.id === agentUpdate.agentId && agent.status === agentUpdate.status;
}
function recordClientRequest(
request: ClientRequest | null,
clientRequestCounts: Map<string, number>,
timelineRequestCounts: Map<string, number>,
directoryStarts: DirectoryRequestStartCounts,
): void {
if (typeof request?.type !== "string") return;
clientRequestCounts.set(request.type, (clientRequestCounts.get(request.type) ?? 0) + 1);
if (request.type === "fetch_agent_timeline_request" && typeof request.direction === "string") {
timelineRequestCounts.set(
request.direction,
(timelineRequestCounts.get(request.direction) ?? 0) + 1,
);
}
const directory = directoryForRequest(request);
if (!directory) return;
const subscription = request.subscribe === undefined ? "unsubscribed" : "subscribed";
directoryStarts[subscription][directory] += 1;
directoryStarts.total[directory] += 1;
}
export async function installDaemonWebSocketGate(page: Page) {
let acceptingConnections = true;
let reconnectWithFreshClient = false;
let suppressAgentStream = false;
let forceTimelineEpochReset = false;
let stripAssistantMessageIds = false;
let stripCanonicalSubmittedPromptsFeature = false;
let heldClientRequestType: string | null = null;
let heldClientRequest: { server: WebSocketRoute; message: string | Buffer } | null = null;
let resolveHeldClientRequest: (() => void) | null = null;
const pendingServerMessageHolds = new Map<string, (message: ClientRequest | null) => boolean>();
const heldServerMessages: HeldServerMessage[] = [];
const heldServerMessageWaiters = new Set<() => void>();
const suppressedServerMessageTypes = new Set<string>();
const suppressedAgentStreamEventTypes = new Set<string>();
const suppressedAgentStreamItemTypes = new Set<string>();
const activeSockets = new Set<WebSocketRoute>();
let latestServer: WebSocketRoute | null = null;
const directoryStarts: DirectoryRequestStartCounts = {
subscribed: { agents: 0, workspaces: 0 },
unsubscribed: { agents: 0, workspaces: 0 },
total: { agents: 0, workspaces: 0 },
};
const clientRequestCounts = new Map<string, number>();
const timelineRequestCounts = new Map<string, number>();
const serverMessageCounts = new Map<string, number>();
const agentStreamEventCounts = new Map<string, number>();
const agentStreamItemCounts = new Map<string, number>();
const serverMessageWaiters = new Set<() => void>();
const subscribedFilePaths = new Set<string>();
const fileSubscriptionWaiters = new Map<string, () => void>();
const observedFileUpdates = new Set<string>();
@@ -100,6 +270,93 @@ export async function installDaemonWebSocketGate(page: Page) {
fileUpdateWaiters.delete(key);
};
const recordServerMessage = (message: ClientRequest | null): void => {
const messageType = typeof message?.type === "string" ? message.type : null;
const itemType = readAgentStreamItemType(message);
const eventType = readAgentStreamEventType(message);
if (messageType)
serverMessageCounts.set(messageType, (serverMessageCounts.get(messageType) ?? 0) + 1);
if (itemType)
agentStreamItemCounts.set(itemType, (agentStreamItemCounts.get(itemType) ?? 0) + 1);
if (eventType)
agentStreamEventCounts.set(eventType, (agentStreamEventCounts.get(eventType) ?? 0) + 1);
if (!messageType && !itemType && !eventType) return;
for (const resolve of serverMessageWaiters) resolve();
serverMessageWaiters.clear();
};
const observeFileMessage = (message: ServerMessage | null): void => {
if (
message?.type === "fs.file.subscribe.response" &&
typeof message.payload?.initial?.path === "string"
) {
const path = message.payload.initial.path;
subscribedFilePaths.add(path);
fileSubscriptionWaiters.get(path)?.();
fileSubscriptionWaiters.delete(path);
}
if (message) recordFileUpdate(message);
};
const holdServerMessage = (input: {
browser: WebSocketRoute;
message: string | Buffer;
parsed: ClientRequest | null;
}): boolean => {
const agentId = readAgentStreamAgentId(input.parsed);
const blocked = agentId
? heldServerMessages.find((held) => held.blockedAgentId === agentId)
: undefined;
const suppressed = shouldSuppressServerMessage({
message: input.parsed,
messageTypes: suppressedServerMessageTypes,
agentStreamEventTypes: suppressedAgentStreamEventTypes,
agentStreamItemTypes: suppressedAgentStreamItemTypes,
suppressAgentStream,
});
if (blocked) {
if (!suppressed) blocked.agentStreamFollowers.push(input.message);
return true;
}
const matchedHold = Array.from(pendingServerMessageHolds).find(([, matches]) =>
matches(input.parsed),
);
if (!matchedHold) return suppressed;
const [key] = matchedHold;
pendingServerMessageHolds.delete(key);
heldServerMessages.push({
browser: input.browser,
message: input.message,
key,
agentStreamFollowers: [],
blockedAgentId:
readAgentStreamEventType(input.parsed) === "turn_started"
? (agentId ?? undefined)
: undefined,
});
for (const resolve of heldServerMessageWaiters) resolve();
heldServerMessageWaiters.clear();
return true;
};
const holdReadyFileUpdate = (
browser: WebSocketRoute,
message: string | Buffer,
fileMessage: ServerMessage | null,
): boolean => {
if (
fileMessage?.type !== "fs.file.update" ||
fileMessage.payload?.version?.status !== "ready" ||
fileMessage.payload.version.path !== readyFileUpdatePathToHold ||
heldReadyFileUpdate
) {
return false;
}
heldReadyFileUpdate = () => browser.send(message);
resolveHeldReadyFileUpdate?.();
return true;
};
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
if (!acceptingConnections) {
void ws.close({ code: 1008, reason: "Blocked by reconnect test." });
@@ -108,19 +365,28 @@ export async function installDaemonWebSocketGate(page: Page) {
activeSockets.add(ws);
const server = ws.connectToServer();
latestServer = server;
ws.onMessage((message) => {
if (!acceptingConnections) return;
const request = readClientRequest(message);
if (typeof request?.type === "string") {
clientRequestCounts.set(request.type, (clientRequestCounts.get(request.type) ?? 0) + 1);
const directory = directoryForRequest(request);
if (directory) {
const subscription = request.subscribe === undefined ? "unsubscribed" : "subscribed";
directoryStarts[subscription][directory] += 1;
directoryStarts.total[directory] += 1;
if (reconnectWithFreshClient && typeof message === "string") {
const hello = readClientRequest(message);
if (hello?.type === "hello") {
const parsed = JSON.parse(message) as { clientId?: string };
parsed.clientId = `${parsed.clientId ?? "playwright"}-fresh-${Date.now()}`;
reconnectWithFreshClient = false;
server.send(JSON.stringify(parsed));
return;
}
}
const request = readClientRequest(message);
recordClientRequest(request, clientRequestCounts, timelineRequestCounts, directoryStarts);
if (request?.type === heldClientRequestType) {
heldClientRequest = { server, message };
resolveHeldClientRequest?.();
resolveHeldClientRequest = null;
return;
}
if (
request?.type === "file_explorer_request" &&
request.mode === "file" &&
@@ -139,29 +405,29 @@ export async function installDaemonWebSocketGate(page: Page) {
server.onMessage((message) => {
if (!acceptingConnections) return;
const serverMessage = readServerMessage(message);
if (
serverMessage?.type === "fs.file.subscribe.response" &&
typeof serverMessage.payload?.initial?.path === "string"
) {
const path = serverMessage.payload.initial.path;
subscribedFilePaths.add(path);
fileSubscriptionWaiters.get(path)?.();
fileSubscriptionWaiters.delete(path);
}
if (serverMessage) recordFileUpdate(serverMessage);
if (
serverMessage?.type === "fs.file.update" &&
serverMessage.payload?.version?.status === "ready" &&
serverMessage.payload.version.path === readyFileUpdatePathToHold &&
!heldReadyFileUpdate
) {
heldReadyFileUpdate = () => ws.send(message);
resolveHeldReadyFileUpdate?.();
const serverMessage = readSessionMessage(message);
const fileMessage = serverMessage as ServerMessage | null;
observeFileMessage(fileMessage);
let outboundMessage = stripAssistantMessageId(
message,
stripAssistantMessageIds,
serverMessage?.type,
);
outboundMessage = stripCanonicalSubmittedPrompts(
outboundMessage,
stripCanonicalSubmittedPromptsFeature,
serverMessage?.type,
);
const shouldForceTimelineReset =
forceTimelineEpochReset && serverMessage?.type === "fetch_agent_timeline_response";
outboundMessage = forceTimelineReset(outboundMessage, shouldForceTimelineReset);
if (shouldForceTimelineReset) forceTimelineEpochReset = false;
recordServerMessage(serverMessage);
if (holdServerMessage({ browser: ws, message: outboundMessage, parsed: serverMessage }))
return;
}
if (holdReadyFileUpdate(ws, outboundMessage, fileMessage)) return;
try {
ws.send(message);
ws.send(outboundMessage);
} catch {
activeSockets.delete(ws);
}
@@ -225,6 +491,181 @@ export async function installDaemonWebSocketGate(page: Page) {
restore(): void {
acceptingConnections = true;
},
restoreFresh(): void {
reconnectWithFreshClient = true;
acceptingConnections = true;
},
holdNextClientRequest(type: string): void {
heldClientRequestType = type;
heldClientRequest = null;
},
waitForHeldClientRequest(): Promise<void> {
if (heldClientRequest) return Promise.resolve();
return new Promise<void>((resolve) => {
resolveHeldClientRequest = resolve;
});
},
releaseHeldClientRequest(): void {
if (!heldClientRequest) throw new Error("No held client request to release");
heldClientRequest.server.send(heldClientRequest.message);
heldClientRequest = null;
heldClientRequestType = null;
},
holdNextServerMessage(type: string): void {
pendingServerMessageHolds.set(serverMessageKey(type), (message) => message?.type === type);
},
holdNextAgentUpdate(agentId: string, status: string): void {
const heldAgentUpdate = { agentId, status };
pendingServerMessageHolds.set(agentUpdateKey(agentId, status), (message) =>
matchesAgentUpdate(message, heldAgentUpdate),
);
},
holdNextAgentStreamEvent(type: string): void {
pendingServerMessageHolds.set(
agentStreamEventKey(type),
(message) => readAgentStreamEventType(message) === type,
);
},
async waitForHeldServerMessage(type?: string): Promise<void> {
const key = type ? serverMessageKey(type) : null;
while (!heldServerMessages.some((message) => key === null || message.key === key)) {
await new Promise<void>((resolve) => heldServerMessageWaiters.add(resolve));
}
},
releaseHeldServerMessage(type?: string): void {
const key = type ? serverMessageKey(type) : null;
const index = heldServerMessages.findIndex((message) => key === null || message.key === key);
const [heldServerMessage] = index >= 0 ? heldServerMessages.splice(index, 1) : [];
if (!heldServerMessage) throw new Error("No held server message to release");
heldServerMessage.browser.send(heldServerMessage.message);
for (const follower of heldServerMessage.agentStreamFollowers) {
heldServerMessage.browser.send(follower);
}
},
async waitForHeldAgentUpdate(agentId: string, status: string): Promise<void> {
const key = agentUpdateKey(agentId, status);
while (!heldServerMessages.some((message) => message.key === key)) {
await new Promise<void>((resolve) => heldServerMessageWaiters.add(resolve));
}
},
releaseHeldAgentUpdate(agentId: string, status: string): void {
const key = agentUpdateKey(agentId, status);
const index = heldServerMessages.findIndex((message) => message.key === key);
const [heldServerMessage] = index >= 0 ? heldServerMessages.splice(index, 1) : [];
if (!heldServerMessage) throw new Error("No held agent update to release");
heldServerMessage.browser.send(heldServerMessage.message);
for (const follower of heldServerMessage.agentStreamFollowers) {
heldServerMessage.browser.send(follower);
}
},
async waitForHeldAgentStreamEvent(type: string): Promise<void> {
const key = agentStreamEventKey(type);
while (!heldServerMessages.some((message) => message.key === key)) {
await new Promise<void>((resolve) => heldServerMessageWaiters.add(resolve));
}
},
releaseHeldAgentStreamEvent(type: string): void {
const key = agentStreamEventKey(type);
const index = heldServerMessages.findIndex((message) => message.key === key);
const [heldServerMessage] = index >= 0 ? heldServerMessages.splice(index, 1) : [];
if (!heldServerMessage) throw new Error("No held agent stream event to release");
heldServerMessage.browser.send(heldServerMessage.message);
for (const follower of heldServerMessage.agentStreamFollowers) {
heldServerMessage.browser.send(follower);
}
},
requestTimelineTail(agentId: string): void {
if (!latestServer) throw new Error("No daemon WebSocket is connected");
latestServer.send(
JSON.stringify({
type: "session",
message: {
type: "fetch_agent_timeline_request",
agentId,
requestId: `playwright-timeline-${Date.now()}`,
direction: "tail",
limit: 0,
projection: "projected",
},
}),
);
},
getHeldTimelineLastItemType(): string | null {
const heldServerMessage = heldServerMessages[0];
if (!heldServerMessage) throw new Error("No held server message to inspect");
const response = readSessionMessage(heldServerMessage.message);
const payload = response?.payload;
if (!payload || typeof payload !== "object") return null;
const entries = (payload as { entries?: unknown }).entries;
if (!Array.isArray(entries)) return null;
const last = entries.at(-1) as { item?: { type?: unknown } } | undefined;
return typeof last?.item?.type === "string" ? last.item.type : null;
},
truncateHeldTimelineAfterLast(itemType: string): void {
const heldServerMessage = heldServerMessages[0];
if (!heldServerMessage || typeof heldServerMessage.message !== "string") {
throw new Error("No held text server message to truncate");
}
const envelope = JSON.parse(heldServerMessage.message) as {
message?: { payload?: Record<string, unknown> };
payload?: Record<string, unknown>;
};
const payload = envelope.message?.payload ?? envelope.payload;
if (!payload) throw new Error("Held message has no payload");
const entries = payload.entries;
if (!Array.isArray(entries)) throw new Error("Held message is not a timeline response");
const index = entries.findLastIndex(
(entry) =>
typeof entry === "object" &&
entry !== null &&
(entry as { item?: { type?: unknown } }).item?.type === itemType,
);
if (index < 0) throw new Error(`Timeline response has no ${itemType} item`);
const retained = entries.slice(0, index + 1) as Array<{ seqEnd?: unknown }>;
const lastSeq = retained.at(-1)?.seqEnd;
if (typeof lastSeq !== "number") throw new Error("Timeline entry has no sequence end");
payload.entries = retained;
payload.endCursor = { epoch: payload.epoch, seq: lastSeq };
payload.hasNewer = false;
if (payload.window && typeof payload.window === "object") {
(payload.window as Record<string, unknown>).maxSeq = lastSeq;
(payload.window as Record<string, unknown>).nextSeq = lastSeq + 1;
}
heldServerMessage.message = JSON.stringify(envelope);
},
setServerMessageSuppressed(type: string, suppressed: boolean): void {
if (suppressed) {
suppressedServerMessageTypes.add(type);
} else {
suppressedServerMessageTypes.delete(type);
}
},
setAgentStreamEventSuppressed(type: string, suppressed: boolean): void {
if (suppressed) {
suppressedAgentStreamEventTypes.add(type);
} else {
suppressedAgentStreamEventTypes.delete(type);
}
},
setAgentStreamItemSuppressed(type: string, suppressed: boolean): void {
if (suppressed) {
suppressedAgentStreamItemTypes.add(type);
} else {
suppressedAgentStreamItemTypes.delete(type);
}
},
setAssistantMessageIdsStripped(stripped: boolean): void {
stripAssistantMessageIds = stripped;
},
setCanonicalSubmittedPromptsStripped(stripped: boolean): void {
stripCanonicalSubmittedPromptsFeature = stripped;
},
setAgentStreamSuppressed(suppressed: boolean): void {
suppressAgentStream = suppressed;
},
forceNextTimelineEpochReset(): void {
forceTimelineEpochReset = true;
},
getDirectoryRequestStartCounts(): DirectoryRequestStartCounts {
return {
subscribed: { ...directoryStarts.subscribed },
@@ -235,5 +676,26 @@ export async function installDaemonWebSocketGate(page: Page) {
getClientRequestCount(type: string): number {
return clientRequestCounts.get(type) ?? 0;
},
getTimelineRequestCount(direction: "tail" | "before" | "after"): number {
return timelineRequestCounts.get(direction) ?? 0;
},
getAgentStreamItemCount(type: string): number {
return agentStreamItemCounts.get(type) ?? 0;
},
async waitForServerMessage(type: string, count = 1): Promise<void> {
while ((serverMessageCounts.get(type) ?? 0) < count) {
await new Promise<void>((resolve) => serverMessageWaiters.add(resolve));
}
},
async waitForAgentStreamItem(type: string, count = 1): Promise<void> {
while ((agentStreamItemCounts.get(type) ?? 0) < count) {
await new Promise<void>((resolve) => serverMessageWaiters.add(resolve));
}
},
async waitForAgentStreamEvent(type: string, count = 1): Promise<void> {
while ((agentStreamEventCounts.get(type) ?? 0) < count) {
await new Promise<void>((resolve) => serverMessageWaiters.add(resolve));
}
},
};
}
+17
View File
@@ -53,6 +53,23 @@ export async function seedMockAgentWorkspace(
}
}
export async function seedRunningMockAgentWorkspace(
options: MockAgentOptions,
): Promise<MockAgentWorkspace> {
const agent = await seedMockAgentWorkspace(options);
try {
await agent.client.waitForAgentUpsert(
agent.agentId,
(snapshot) => snapshot.status === "running",
15_000,
);
return agent;
} catch (error) {
await agent.cleanup();
throw error;
}
}
export function buildAgentRoute(workspaceId: string, agentId: string): string {
return buildHostAgentDetailRoute(getServerId(), agentId, workspaceId);
}
+414 -24
View File
@@ -1,20 +1,64 @@
import { expect, type Page } from "@playwright/test";
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import {
delayAgentBootstrapTailResponse,
delayAgentOlderTimelineResponse,
holdDaemonHydration,
holdAgentOlderTimelinePages,
type AgentTimelineResponseGate,
type BootstrapTimelineGate,
type OlderTimelinePagesGate,
} from "./agent-timeline-gate";
export { holdDaemonHydration };
interface LongTimelineAgentOptions {
turns: number;
}
interface LongTimelineAgent extends MockAgentWorkspace {
export interface LongTimelineAgent extends MockAgentWorkspace {
firstOlderPagePrompt: string;
initialTailAnchorPrompt: string;
initialTailOldestPrompt: string;
oldestPrompt: string;
newestPrompt: string;
}
interface RealisticOlderTimelineAgent extends MockAgentWorkspace {
newestPrompt: string;
}
const PROMPT_PREFIX = "timeline-pagination-turn";
const LIVE_BEFORE_HYDRATION_PROMPT = "timeline live before authoritative hydration";
const HISTORY_START_THRESHOLD_PX = 96;
interface TimelineViewportSnapshot {
scrollHeight: number;
scrollTop: number;
}
export interface TimelinePresentationSnapshot {
marker: string;
position: TimelinePromptPositionSnapshot;
viewport: TimelineViewportSnapshot;
}
interface TimelinePromptPositionSnapshot {
prompt: string;
top: number;
}
interface OlderHistoryLoadingOperation {
animationStartTime: number | null;
marker: string;
}
interface OlderHistoryPages {
expectRequestedPages(count: number): Promise<void>;
expectSettledWithRequestedPages(count: number): Promise<void>;
expectNoRepeatedAssistantEntries(): void;
releasePage(pageNumber: number): void;
}
function promptForTurn(index: number): string {
return `${PROMPT_PREFIX}-${index}: emit 1 coalesced agent stream updates`;
@@ -36,12 +80,88 @@ export async function seedLongMockAgentTimeline(
return {
...agent,
firstOlderPagePrompt: promptForTurn(Math.max(0, options.turns - 40)),
initialTailAnchorPrompt: promptForTurn(Math.max(0, options.turns - 5)),
initialTailOldestPrompt: promptForTurn(Math.max(0, options.turns - 20)),
oldestPrompt: promptForTurn(0),
newestPrompt: promptForTurn(options.turns - 1),
};
}
export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): Promise<void> {
export async function seedRealisticOlderTimeline(): Promise<RealisticOlderTimelineAgent> {
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-realistic-older-history-",
title: "Realistic older timeline pagination regression",
model: "ten-second-stream",
});
const olderTurnCount = 40;
for (let index = 0; index < olderTurnCount; 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);
const newerTurnCount = 20;
for (let index = 0; index < newerTurnCount; index += 1) {
await agent.client.sendAgentMessage(agent.agentId, promptForTurn(index));
await agent.client.waitForFinish(agent.agentId, 15_000);
}
return {
...agent,
newestPrompt: promptForTurn(newerTurnCount - 1),
};
}
export async function scrollThroughOlderHistoryPages(
page: Page,
pageCount: number,
history: OlderHistoryPages,
): Promise<void> {
for (let pageNumber = 0; pageNumber < pageCount; pageNumber += 1) {
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(pageNumber + 1);
history.releasePage(pageNumber + 1);
await history.expectSettledWithRequestedPages(pageNumber + 1);
}
}
export async function rememberTimelinePromptPosition(
page: Page,
prompt: string,
): Promise<TimelinePromptPositionSnapshot> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const item = timeline.getByText(prompt, { exact: true });
await expect(item).toBeVisible();
const box = await item.boundingBox();
if (!box) {
throw new Error(`Expected a rendered timeline item for ${prompt}`);
}
return { prompt, top: box.y };
}
export async function expectTimelinePromptPositionPreserved(
page: Page,
before: TimelinePromptPositionSnapshot,
): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const item = timeline.getByText(before.prompt, { exact: true });
await expect(item).toBeVisible();
await expect
.poll(async () => {
const box = await item.boundingBox();
return box ? Math.abs(box.y - before.top) : Number.POSITIVE_INFINITY;
})
.toBeLessThanOrEqual(2);
}
export async function openAgentTimeline(page: Page, agent: MockAgentWorkspace): Promise<void> {
await page.goto(buildAgentRoute(agent.workspaceId, agent.agentId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
@@ -50,15 +170,37 @@ export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): P
}
export async function expectTimelinePromptVisible(page: Page, prompt: string): Promise<void> {
await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await expect(timeline.getByText(prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
}
export async function expectTimelinePromptCentered(page: Page, prompt: string): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const message = timeline.getByTestId("user-message").filter({ hasText: prompt });
await expect(message).toBeVisible();
await expect
.poll(async () => {
const [timelineBox, messageBox] = await Promise.all([
timeline.boundingBox(),
message.boundingBox(),
]);
if (!timelineBox || !messageBox) {
return Number.POSITIVE_INFINITY;
}
const timelineCenter = timelineBox.x + timelineBox.width / 2;
const messageCenter = messageBox.x + messageBox.width / 2;
return Math.abs(timelineCenter - messageCenter);
})
.toBeLessThanOrEqual(2);
}
export async function expectTimelinePromptNotMounted(page: Page, prompt: string): Promise<void> {
await expect(page.getByText(prompt, { exact: true })).toHaveCount(0);
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await expect(timeline.getByText(prompt, { exact: true })).toHaveCount(0);
}
export async function makeLoadedTimelineFitViewport(page: Page): Promise<void> {
await page.setViewportSize({ width: 1280, height: 8_000 });
await page.setViewportSize({ width: 1280, height: 20_000 });
}
export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise<void> {
@@ -75,6 +217,33 @@ export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise<voi
.toBe(true);
}
export async function reloadAgentTimelineFromPersistedReplica(
page: Page,
agent: LongTimelineAgent,
): Promise<void> {
await expect
.poll(() =>
page.evaluate((agentId) => {
const raw = localStorage.getItem("@paseo:replica-cache");
if (!raw) return false;
const cache = JSON.parse(raw) as {
hosts?: Array<{
timeline?: {
agentId?: string;
items?: unknown[];
} | null;
}>;
};
const timeline = cache.hosts?.find((host) => host.timeline?.agentId === agentId)?.timeline;
return timeline?.items?.length === 50;
}, agent.agentId),
)
.toBe(true);
await page.reload();
await expectTimelinePromptVisible(page, agent.newestPrompt);
}
export async function holdNextOlderTimelinePage(
page: Page,
agent: LongTimelineAgent,
@@ -90,6 +259,154 @@ export async function holdNextOlderTimelinePage(
};
}
export async function holdOlderHistoryPages(
page: Page,
agent: MockAgentWorkspace,
): Promise<OlderHistoryPages> {
const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(page, agent.agentId);
return {
async expectRequestedPages(count) {
await gate.waitForRequestCount(count);
expect(gate.getRequestCount()).toBe(count);
},
async expectSettledWithRequestedPages(count) {
await waitForTimelineGeometryToSettle(page);
expect(gate.getRequestCount()).toBe(count);
},
expectNoRepeatedAssistantEntries() {
expect(gate.getRepeatedAssistantEntryCount()).toBe(0);
},
releasePage(pageNumber) {
gate.releasePage(pageNumber);
},
};
}
export async function rememberTimelineViewport(page: Page): Promise<TimelineViewportSnapshot> {
return readTimelineViewport(page);
}
export async function expectStableHistoryStartGutter(page: Page): Promise<void> {
const gutter = page.getByTestId("older-history-slot");
await expect(gutter).toBeAttached();
await expect
.poll(() => gutter.evaluate((element) => element.getBoundingClientRect().height))
.toBe(32);
}
export async function scrollTimelinePromptIntoView(page: Page, prompt: string): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const row = timeline.getByTestId("user-message").filter({ hasText: prompt });
await row.scrollIntoViewIfNeeded();
await expect(row).toBeVisible();
await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop)
.toBeGreaterThan(HISTORY_START_THRESHOLD_PX);
}
export async function rememberTimelinePresentation(
page: Page,
prompt: string,
): Promise<TimelinePresentationSnapshot> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const row = timeline.getByTestId("user-message").filter({ hasText: prompt });
await expect(row).toBeVisible();
const marker = `timeline-presentation-${Date.now()}`;
await row.evaluate((element, value) => {
element.dataset.timelinePresentation = value;
}, marker);
return {
marker,
position: await rememberTimelinePromptPosition(page, prompt),
viewport: await rememberTimelineViewport(page),
};
}
export async function expectTimelinePresentationUnchanged(
page: Page,
before: TimelinePresentationSnapshot,
): Promise<void> {
await expect(page.locator(`[data-timeline-presentation="${before.marker}"]`)).toBeVisible();
await expectTimelinePromptPositionPreserved(page, before.position);
await expect.poll(async () => rememberTimelineViewport(page)).toEqual(before.viewport);
}
export async function expectTimelineViewportAnchoredAfterPrepend(
page: Page,
before: TimelineViewportSnapshot,
): Promise<void> {
await expect
.poll(async () => (await readTimelineViewport(page)).scrollHeight)
.toBeGreaterThan(before.scrollHeight);
await waitForTimelineGeometryToSettle(page);
const after = await readTimelineViewport(page);
const contentGrowth = after.scrollHeight - before.scrollHeight;
const scrollAdjustment = after.scrollTop - before.scrollTop;
expect(Math.abs(contentGrowth - scrollAdjustment)).toBeLessThanOrEqual(2);
}
export async function rememberOlderHistoryLoadingOperation(
page: Page,
): Promise<OlderHistoryLoadingOperation> {
const slot = page.getByTestId("load-older-history-spinner");
await expect(slot).toBeVisible();
const marker = `older-history-loading-${Date.now()}`;
await slot.evaluate((element, operationMarker) => {
const candidates = [element, ...Array.from(element.querySelectorAll("*"))];
const animated = candidates.find(
(candidate) => getComputedStyle(candidate).animationName !== "none",
);
if (!(animated instanceof HTMLElement)) {
throw new Error("Expected the older-history loader to contain an animated element");
}
animated.dataset.olderHistoryLoadingOperation = operationMarker;
}, marker);
const animated = page.locator(`[data-older-history-loading-operation="${marker}"]`);
await expect
.poll(async () => {
const startTime = await animated.evaluate((element) => element.getAnimations()[0]?.startTime);
return typeof startTime === "number" ? startTime : null;
})
.not.toBeNull();
const animationStartTime = await animated.evaluate((element) => {
const startTime = element.getAnimations()[0]?.startTime;
return typeof startTime === "number" ? startTime : null;
});
return { animationStartTime, marker };
}
export async function expectSameOlderHistoryLoadingOperation(
page: Page,
operation: OlderHistoryLoadingOperation,
): Promise<void> {
const animated = page.locator(`[data-older-history-loading-operation="${operation.marker}"]`);
await expect(animated).toBeVisible();
const animationStartTime = await animated.evaluate((element) => {
const startTime = element.getAnimations()[0]?.startTime;
return typeof startTime === "number" ? startTime : null;
});
expect(animationStartTime).toBe(operation.animationStartTime);
}
export async function expectTimelineAtHistoryStart(page: Page): Promise<void> {
await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop)
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
}
export async function holdBootstrapTimelinePage(
page: Page,
agent: LongTimelineAgent,
): Promise<BootstrapTimelineGate> {
return delayAgentBootstrapTailResponse(page, agent.agentId);
}
export async function sendLiveTurnBeforeHydration(agent: LongTimelineAgent): Promise<string> {
await agent.client.sendAgentMessage(agent.agentId, LIVE_BEFORE_HYDRATION_PROMPT);
await agent.client.waitForFinish(agent.agentId, 15_000);
return LIVE_BEFORE_HYDRATION_PROMPT;
}
export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.hover();
@@ -109,31 +426,104 @@ export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void
});
}
export async function scrollTimelineUntilOlderHistoryIsReachable(page: Page): Promise<void> {
export async function userScrollsTimelineToHistoryStart(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const previousHeight = await scroll.evaluate((element) => {
await scroll.hover();
for (let step = 0; step < 60; step += 1) {
if ((await readTimelineViewport(page)).scrollTop <= HISTORY_START_THRESHOLD_PX) {
break;
}
await page.mouse.wheel(0, -1_000);
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
}),
);
}
await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop)
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
}
export async function userNavigatesTimelineToHistoryStartWithKeyboard(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.focus();
await page.keyboard.press("Home");
await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop)
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
}
export async function scrollTimelineToNewestLoadedEdge(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Agent chat scroll element is not an HTMLElement");
}
return element.scrollHeight;
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
}
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
export async function scrollTimelineUntilOlderHistoryIsReachable(
page: Page,
oldestPrompt: string,
): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const prompt = scroll.getByText(oldestPrompt, { exact: true });
for (let attempt = 0; attempt < 10; attempt += 1) {
if ((await prompt.count()) > 0) {
await expect(prompt).toBeVisible();
return;
}
const previousHeight = await readTimelineViewport(page);
await userScrollsTimelineToHistoryStart(page);
await expect
.poll(async () => (await readTimelineViewport(page)).scrollHeight)
.toBeGreaterThan(previousHeight.scrollHeight);
await waitForTimelineGeometryToSettle(page);
}
await expect(prompt).toBeVisible();
}
async function readTimelineViewport(page: Page): Promise<TimelineViewportSnapshot> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
return scroll.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Agent chat scroll element is not an HTMLElement");
}
return { scrollHeight: element.scrollHeight, scrollTop: element.scrollTop };
});
}
async function waitForTimelineGeometryToSettle(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.evaluate(
(element) =>
new Promise<void>((resolve, reject) => {
if (!(element instanceof HTMLElement)) {
reject(new Error("Agent chat scroll element is not an HTMLElement"));
return;
}
const startedAt = performance.now();
let stableFrames = 0;
let previous = `${element.scrollTop}:${element.scrollHeight}`;
const sample = () => {
const current = `${element.scrollTop}:${element.scrollHeight}`;
stableFrames = current === previous ? stableFrames + 1 : 0;
previous = current;
if (stableFrames >= 4) {
resolve();
return;
}
if (performance.now() - startedAt > 5_000) {
reject(new Error("Timeline geometry did not settle"));
return;
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
}),
);
await scrollTimelineToOldestLoadedEdge(page);
await expect
.poll(async () =>
scroll.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Agent chat scroll element is not an HTMLElement");
}
return element.scrollHeight;
}),
)
.toBeGreaterThan(previousHeight);
await scrollTimelineToOldestLoadedEdge(page);
}
@@ -0,0 +1,68 @@
import { expect, type Page } from "@playwright/test";
import type { installDaemonWebSocketGate } from "./daemon-websocket-gate";
import type { LongTimelineAgent } from "./timeline-pagination";
import { expectReconnectingToastGone, expectReconnectingToastVisible } from "./workspace-ui";
type DaemonWebSocketGate = Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
export interface TimelineRequestCounts {
after: number;
tail: number;
}
export interface BackgroundTimelineTurns {
firstPrompt: string;
newestPrompt: string;
}
function backgroundPrompt(index: number): string {
return `timeline-resume-background-${index}: emit 1 coalesced agent stream updates`;
}
export async function commitTimelineTurnsWhileDisconnected(
agent: LongTimelineAgent,
count: number,
): Promise<BackgroundTimelineTurns> {
for (let index = 0; index < count; index += 1) {
await agent.client.sendAgentMessage(agent.agentId, backgroundPrompt(index));
await agent.client.waitForFinish(agent.agentId, 15_000);
}
return {
firstPrompt: backgroundPrompt(0),
newestPrompt: backgroundPrompt(count - 1),
};
}
export function rememberTimelineRequestCounts(gate: DaemonWebSocketGate): TimelineRequestCounts {
return {
after: gate.getTimelineRequestCount("after"),
tail: gate.getTimelineRequestCount("tail"),
};
}
export function expectOneTailCheckWithoutCatchUpPages(
gate: DaemonWebSocketGate,
before: TimelineRequestCounts,
): void {
expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(1);
expect(gate.getTimelineRequestCount("after") - before.after).toBe(0);
}
export async function disconnectViewedTimeline(
page: Page,
gate: DaemonWebSocketGate,
): Promise<void> {
await gate.drop();
await expectReconnectingToastVisible(page);
}
export async function restoreViewedTimelineWithHeldTail(
page: Page,
gate: DaemonWebSocketGate,
): Promise<void> {
gate.holdNextServerMessage("fetch_agent_timeline_response");
gate.restore();
await gate.waitForHeldServerMessage();
gate.releaseHeldServerMessage();
await expectReconnectingToastGone(page);
}
+1 -2
View File
@@ -15,7 +15,7 @@ import { seedSavedSettingsHosts } from "./helpers/settings";
import { getServerId } from "./helpers/server-id";
import { projectEquivalenceViewKey } from "./helpers/project-view-key";
import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
import { waitForNoProjectsInSidebar, waitForSidebarHydration } from "./helpers/workspace-ui";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
// Model B entry points into the New Workspace screen. The surviving entries are
// the global button (universal) and each project's per-row New workspace icon
@@ -112,7 +112,6 @@ test.describe("New workspace entry points", () => {
test("a stale project route keeps the New Workspace controls enabled", async ({ page }) => {
await gotoAppShell(page);
await waitForNoProjectsInSidebar(page);
await openMissingProjectNewWorkspaceComposer(page, {
serverId: getServerId(),
projectId: "missing-project",
@@ -0,0 +1,29 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "./fixtures";
import { submitMessage } from "./helpers/composer";
import { cleanupRewindFlow, launchAgent, type AgentHandle } from "./helpers/rewind-flow";
test.describe("Codex out-of-band commands", () => {
test.setTimeout(300_000);
test("settles the submitted row when a goal command completes without a turn", async ({
page,
}) => {
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-codex-command-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider: "codex", cwd, mode: "full-access" });
await submitMessage(page, "/goal clear");
const command = page.getByTestId("user-message").filter({ hasText: "/goal clear" });
await expect(command).toBeVisible();
await expect(command).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
});
@@ -1,6 +1,8 @@
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "./fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import { scrollChatAwayFromBottom } from "./helpers/agent-bottom-anchor";
import {
composerLocator,
expectComposerDraft,
@@ -23,7 +25,152 @@ async function expectUserMessageVisible(page: Page, text: string): Promise<void>
await expect(userMessage(page, text)).toBeVisible();
}
async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Promise<void> {
await expect
.poll(() =>
page.evaluate((messageText) => {
const raw = localStorage.getItem("@paseo:replica-cache");
if (!raw) return false;
const cache = JSON.parse(raw) as {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
};
for (const host of cache.hosts ?? []) {
for (const item of host.timeline?.items ?? []) {
if (item.kind === "user_message" && item.text === messageText && item.messageId) {
return true;
}
}
}
return false;
}, prompt),
)
.toBe(true);
await page.evaluate((messageText) => {
const key = "@paseo:replica-cache";
const raw = localStorage.getItem(key);
if (!raw) throw new Error("Replica cache was not persisted");
const cache = JSON.parse(raw) as {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
};
const cachedMessage = cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.find((item) => item.kind === "user_message" && item.text === messageText);
if (!cachedMessage) throw new Error("Cached user message was not found");
delete cachedMessage.messageId;
localStorage.setItem(key, JSON.stringify(cache));
}, prompt);
}
async function waitForCurrentSubmissionExcludedFromCache(
page: Page,
prompt: string,
): Promise<void> {
await expect
.poll(() =>
page.evaluate((messageText) => {
const raw = localStorage.getItem("@paseo:replica-cache");
if (!raw) return false;
const cache = JSON.parse(raw) as {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
};
return !cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.some(
(item) =>
item.kind === "user_message" &&
item.text === messageText &&
typeof item.clientMessageId === "string" &&
item.messageId === undefined,
);
}, prompt),
)
.toBe(true);
}
async function waitForCachedMessageWithoutProviderId(page: Page, prompt: string): Promise<void> {
await expect
.poll(() =>
page.evaluate((messageText) => {
const raw = localStorage.getItem("@paseo:replica-cache");
if (!raw) return false;
const cache = JSON.parse(raw) as {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
};
return cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.some(
(item) =>
item.kind === "user_message" &&
item.text === messageText &&
item.messageId === undefined,
);
}, prompt),
)
.toBe(true);
}
async function expectPendingSubmissionNotRestoredAfterReload(page: Page): Promise<void> {
const prompt = "Keep this cached submission pending.";
const gate = await installDaemonWebSocketGate(page);
const session = await seedMockAgentWorkspace({
repoPrefix: "rewind-current-cache-e2e-",
title: "Current cache submission e2e",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
gate.holdNextClientRequest("send_agent_message_request");
await submitMessage(page, prompt);
await gate.waitForHeldClientRequest();
await waitForCurrentSubmissionExcludedFromCache(page, prompt);
await gate.drop();
await page.reload();
await expect(userMessage(page, prompt)).toHaveCount(0);
} finally {
gate.restore();
await session.cleanup();
}
}
test.describe("Rewind sheet", () => {
test("does not restore a local-only submission from the display cache", async ({ page }) => {
await expectPendingSubmissionNotRestoredAfterReload(page);
});
test("does not invent rewind identity for an ID-less cached message", async ({ page }) => {
const prompt = "Restore this rewind identity from the legacy cache.";
const gate = await installDaemonWebSocketGate(page);
const session = await seedMockAgentWorkspace({
repoPrefix: "rewind-cache-upgrade-e2e-",
title: "Rewind cache upgrade e2e",
initialPrompt: prompt,
});
let heldTimelineRequest = false;
try {
await openAgentRoute(page, session);
await expectUserMessageVisible(page, prompt);
await rewriteCachedMessageAsLegacyRow(page, prompt);
gate.holdNextClientRequest("fetch_agent_timeline_request");
await page.reload();
await gate.waitForHeldClientRequest();
heldTimelineRequest = true;
const restoredMessage = userMessage(page, prompt);
await expect(restoredMessage).toBeVisible();
await restoredMessage.hover();
await expect(restoredMessage.getByTestId("rewind-menu-trigger")).toHaveCount(0);
await waitForCachedMessageWithoutProviderId(page, prompt);
} finally {
if (heldTimelineRequest) gate.releaseHeldClientRequest();
gate.restore();
await session.cleanup();
}
});
test("rewinds from a user message sheet option", async ({ page }) => {
const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn.";
const secondPrompt = "Prepare deleted rewind turn assistant content.";
@@ -45,6 +192,10 @@ test.describe("Rewind sheet", () => {
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
await expectUserMessageCount(page, 2);
await scrollChatAwayFromBottom(page, {
deltaY: -900,
minDistanceFromBottom: 300,
});
await userMessage(page, firstPrompt).hover();
await page.getByTestId("rewind-menu-trigger").first().click();
const rewindSheet = page.getByTestId("rewind-menu-content");
+6 -1
View File
@@ -1,6 +1,7 @@
import type { Locator } from "@playwright/test";
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { projectEquivalenceViewKey } from "./helpers/project-view-key";
import { getServerId } from "./helpers/server-id";
import { seedWorkspace } from "./helpers/seed-client";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
@@ -56,7 +57,11 @@ test("projects and workspaces reorder with an immediate mouse drag", async ({ pa
await gotoAppShell(page);
await waitForSidebarHydration(page);
await quickDragFirstRowAfterSecond(page.locator('[data-testid^="sidebar-project-row-"]'));
const firstProjectTestId = `sidebar-project-row-${projectEquivalenceViewKey(firstProject.projectKey)}`;
const secondProjectTestId = `sidebar-project-row-${projectEquivalenceViewKey(secondProject.projectKey)}`;
await quickDragFirstRowAfterSecond(
page.locator(`[data-testid="${firstProjectTestId}"], [data-testid="${secondProjectTestId}"]`),
);
const firstWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${firstProject.workspaceId}`;
const secondWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondWorkspace.workspace.id}`;
await quickDragFirstRowAfterSecond(
@@ -6,6 +6,11 @@ import { getServerId } from "./helpers/server-id";
import { observeTimelineSubscriptions } from "./helpers/timeline-delivery";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import {
expectAgentIdle,
expectInlineWorkingIndicator,
expectTurnCopyButton,
} from "./helpers/agent-stream";
import {
expectReconnectingToastGone,
expectReconnectingToastVisible,
@@ -19,19 +24,21 @@ interface ViewedTimelineScenario {
cleanup(): Promise<void>;
}
async function seedViewedTimelineScenario(): Promise<ViewedTimelineScenario> {
async function seedViewedTimelineScenario(
options: { firstAgentModel?: string } = {},
): Promise<ViewedTimelineScenario> {
const workspace = await seedWorkspace({ repoPrefix: "viewed-timelines-" });
const createAgent = (title: string) =>
const createAgent = (title: string, model = "ten-second-stream") =>
workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
modeId: "load-test",
model: "ten-second-stream",
model,
});
const [firstAgent, secondAgent] = await Promise.all([
createAgent("First viewed chat"),
createAgent("First viewed chat", options.firstAgentModel),
createAgent("Second viewed chat"),
]);
return {
@@ -71,7 +78,46 @@ async function commitMessage(scenario: ViewedTimelineScenario, agentId: string,
expect(finish.status).toBe("idle");
}
async function startVisibleTurn(
page: Page,
scenario: ViewedTimelineScenario,
prompt: string,
): Promise<void> {
await scenario.client.sendAgentMessage(scenario.firstAgentId, prompt);
await expect(page.getByText(prompt, { exact: true })).toBeVisible();
await expectInlineWorkingIndicator(page);
}
async function expectAgentConsistentlyIdle(page: Page, title: string): Promise<void> {
const tab = page.getByRole("button", { name: title, exact: true });
await expect(tab.locator('[data-status-bucket="running"]')).toHaveCount(0);
await expectAgentIdle(page);
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
await expectTurnCopyButton(page);
}
test.describe("Viewed agent timelines", () => {
test("a turn that finishes while unsubscribed reopens with consistently idle chrome", async ({
page,
}) => {
test.setTimeout(150_000);
const subscriptions = observeTimelineSubscriptions(page);
const scenario = await seedViewedTimelineScenario({ firstAgentModel: "one-minute-stream" });
try {
await openAgent(page, scenario, scenario.firstAgentId);
await startVisibleTurn(page, scenario, "Finish after this chat becomes unsubscribed.");
await selectAgent(page, "Second viewed chat");
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId], { timeout: 45_000 });
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 90_000);
expect(finish.status).toBe("idle");
await selectAgent(page, "First viewed chat");
await expectAgentConsistentlyIdle(page, "First viewed chat");
} finally {
await scenario.cleanup();
}
});
test("an unsubscribed hidden chat catches up when shown", async ({ page }) => {
test.setTimeout(90_000);
const subscriptions = observeTimelineSubscriptions(page);