mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-14 12:23:16 +00:00
fix(app): keep hidden chats current through brief switches
This commit is contained in:
@@ -67,11 +67,11 @@ The app chooses one delivery policy from `server_info.features.selectiveAgentTim
|
|||||||
|
|
||||||
- Selective daemons receive the union of agents visible in every pane. Additions subscribe and
|
- Selective daemons receive the union of agents visible in every pane. Additions subscribe and
|
||||||
catch up immediately. Every visibility-driven removal, including app backgrounding, stays
|
catch up immediately. Every visibility-driven removal, including app backgrounding, stays
|
||||||
subscribed for a short grace period so brief tab, pane, route, and app switches do not repeatedly
|
subscribed for a 30-second grace period so brief tab, pane, route, and app switches do not repeatedly
|
||||||
unsubscribe and catch up. Losing window keyboard focus does not make a selected pane invisible.
|
unsubscribe and catch up. Losing window keyboard focus does not make a selected pane invisible.
|
||||||
Disconnecting and disposal clear pending grace because the subscription itself no longer exists.
|
Disconnecting and disposal clear pending grace because the subscription itself no longer exists.
|
||||||
After grace has expired, a retained timeline stays covered when revisited until authoritative
|
After grace has expired, revisiting a retained timeline displays its cached state immediately and
|
||||||
catch-up completes; cached partial output is never presented as current history.
|
authoritative catch-up advances it to the current tail.
|
||||||
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
|
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
|
||||||
authoritative catch-up, but the app does not issue selective-subscription RPCs.
|
authoritative catch-up, but the app does not issue selective-subscription RPCs.
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ interface SessionMessage {
|
|||||||
payload?: unknown;
|
payload?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TimelineSubscriptionWaitOptions {
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
function readSessionMessage(message: WebSocketMessage): SessionMessage | null {
|
function readSessionMessage(message: WebSocketMessage): SessionMessage | null {
|
||||||
if (typeof message !== "string") return null;
|
if (typeof message !== "string") return null;
|
||||||
try {
|
try {
|
||||||
@@ -36,72 +40,16 @@ export function observeTimelineSubscriptions(page: Page) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async waitForSubscribedAgents(agentIds: string[]): Promise<void> {
|
async waitForSubscribedAgents(
|
||||||
|
agentIds: string[],
|
||||||
|
options: TimelineSubscriptionWaitOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
const expected = [...new Set(agentIds)].sort();
|
const expected = [...new Set(agentIds)].sort();
|
||||||
await expect
|
await expect
|
||||||
.poll(() => acknowledgedAgentIds?.slice().sort() ?? null, { timeout: 15_000 })
|
.poll(() => acknowledgedAgentIds?.slice().sort() ?? null, {
|
||||||
|
timeout: options.timeout ?? 15_000,
|
||||||
|
})
|
||||||
.toEqual(expected);
|
.toEqual(expected);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AssistantFrameState {
|
|
||||||
active: boolean;
|
|
||||||
lastText: string | null;
|
|
||||||
snapshots: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function observeLastAssistantFrames(page: Page) {
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const state: AssistantFrameState = {
|
|
||||||
active: true,
|
|
||||||
lastText: null,
|
|
||||||
snapshots: [],
|
|
||||||
};
|
|
||||||
const sample = () => {
|
|
||||||
const hasPaintedHistoryOverlay = Array.from(
|
|
||||||
document.querySelectorAll('[data-testid="agent-history-overlay"]'),
|
|
||||||
).some((element) => {
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
return rect.width > 0 && rect.height > 0;
|
|
||||||
});
|
|
||||||
if (!hasPaintedHistoryOverlay) {
|
|
||||||
const messages = Array.from(
|
|
||||||
document.querySelectorAll('[data-testid="assistant-message"]'),
|
|
||||||
).filter((element) => {
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
return rect.width > 0 && rect.height > 0;
|
|
||||||
});
|
|
||||||
const text = messages.at(-1)?.textContent?.trim() ?? "";
|
|
||||||
if (text && text !== state.lastText) {
|
|
||||||
state.lastText = text;
|
|
||||||
state.snapshots.push(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (state.active) requestAnimationFrame(sample);
|
|
||||||
};
|
|
||||||
Object.assign(window, { __assistantFrameState: state });
|
|
||||||
requestAnimationFrame(sample);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
async stop(): Promise<string[]> {
|
|
||||||
return page.evaluate(
|
|
||||||
() =>
|
|
||||||
new Promise<string[]>((resolve) => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const state = (
|
|
||||||
window as typeof window & { __assistantFrameState?: AssistantFrameState }
|
|
||||||
).__assistantFrameState;
|
|
||||||
if (!state) {
|
|
||||||
resolve([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.active = false;
|
|
||||||
resolve([...state.snapshots]);
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
|||||||
import { test } from "./fixtures";
|
import { test } from "./fixtures";
|
||||||
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
|
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
import {
|
import { observeTimelineSubscriptions } from "./helpers/timeline-delivery";
|
||||||
observeLastAssistantFrames,
|
|
||||||
observeTimelineSubscriptions,
|
|
||||||
} from "./helpers/timeline-delivery";
|
|
||||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||||
import {
|
import {
|
||||||
@@ -75,47 +72,41 @@ async function commitMessage(scenario: ViewedTimelineScenario, agentId: string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Viewed agent timelines", () => {
|
test.describe("Viewed agent timelines", () => {
|
||||||
test("a focused turn streams live and resumes its hidden remainder atomically", async ({
|
test("an unsubscribed hidden chat catches up when shown", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
const subscriptions = observeTimelineSubscriptions(page);
|
const subscriptions = observeTimelineSubscriptions(page);
|
||||||
const scenario = await seedViewedTimelineScenario();
|
const scenario = await seedViewedTimelineScenario();
|
||||||
try {
|
try {
|
||||||
await openAgent(page, scenario, scenario.firstAgentId);
|
await openAgent(page, scenario, scenario.firstAgentId);
|
||||||
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId]);
|
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId]);
|
||||||
|
|
||||||
await scenario.client.sendAgentMessage(
|
|
||||||
scenario.firstAgentId,
|
|
||||||
"Stream while focused, then finish while hidden.",
|
|
||||||
);
|
|
||||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.getByTestId("assistant-message").filter({ hasText: "Cycle 1" }).first(),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(0);
|
|
||||||
|
|
||||||
await selectAgent(page, "Second viewed chat");
|
await selectAgent(page, "Second viewed chat");
|
||||||
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId]);
|
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId], { timeout: 45_000 });
|
||||||
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 30_000);
|
await commitMessage(
|
||||||
expect(finish.status).toBe("idle");
|
scenario,
|
||||||
|
scenario.firstAgentId,
|
||||||
const assistantFrames = await observeLastAssistantFrames(page);
|
"Committed after the first chat unsubscribed.",
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
page.getByText("Committed after the first chat unsubscribed.", { exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
await selectAgent(page, "First viewed chat");
|
await selectAgent(page, "First viewed chat");
|
||||||
|
await expect(
|
||||||
|
page.getByText("Committed after the first chat unsubscribed.", { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
||||||
const snapshots = await assistantFrames.stop();
|
|
||||||
|
|
||||||
expect(snapshots[0]).toContain("(end of synthetic stream)");
|
|
||||||
} finally {
|
} finally {
|
||||||
await scenario.cleanup();
|
await scenario.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a hidden retained chat catches up when shown", async ({ page }) => {
|
test("a hidden retained chat stays current during unsubscribe grace", async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
const subscriptions = observeTimelineSubscriptions(page);
|
||||||
const scenario = await seedViewedTimelineScenario();
|
const scenario = await seedViewedTimelineScenario();
|
||||||
try {
|
try {
|
||||||
await openAgent(page, scenario, scenario.firstAgentId);
|
await openAgent(page, scenario, scenario.firstAgentId);
|
||||||
await selectAgent(page, "Second viewed chat");
|
await selectAgent(page, "Second viewed chat");
|
||||||
|
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]);
|
||||||
await commitMessage(
|
await commitMessage(
|
||||||
scenario,
|
scenario,
|
||||||
scenario.firstAgentId,
|
scenario.firstAgentId,
|
||||||
@@ -128,6 +119,7 @@ test.describe("Viewed agent timelines", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByText("Committed while the first chat is hidden.", { exact: true }),
|
page.getByText("Committed while the first chat is hidden.", { exact: true }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
||||||
} finally {
|
} finally {
|
||||||
await scenario.cleanup();
|
await scenario.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,6 +174,10 @@ class TimelineWorld {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("keeps hidden agents subscribed for thirty seconds", () => {
|
||||||
|
expect(VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS).toBe(30_000);
|
||||||
|
});
|
||||||
|
|
||||||
test("uses a tail fetch when a live cursor is not authoritative", async () => {
|
test("uses a tail fetch when a live cursor is not authoritative", async () => {
|
||||||
const world = new TimelineWorld();
|
const world = new TimelineWorld();
|
||||||
world.setLiveCursor("agent-a", 9);
|
world.setLiveCursor("agent-a", 9);
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RETRY_DELAY_MS = 1_000;
|
const RETRY_DELAY_MS = 1_000;
|
||||||
export const VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS = 5_000;
|
export const VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS = 30_000;
|
||||||
|
|
||||||
type CatchUpStatus = "running" | "complete" | "error";
|
type CatchUpStatus = "running" | "complete" | "error";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user