mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 09:54:40 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
339a308365 | ||
|
|
b2b82e5cb0 | ||
|
|
180f07587a |
@@ -112,6 +112,21 @@ offline, then Updating messages until authoritative catch-up completes. Socket c
|
||||
certify that the displayed conversation is current. The timeline owner publishes freshness; the
|
||||
view renders it without a toast timer or a separate resume workflow.
|
||||
|
||||
The turn footer in a chat follows its messages, not the agent status. Turn activity comes from the
|
||||
agent directory and messages from the chat's own catch-up, and either can land first. So the timeline
|
||||
owner keeps an unsettled turn per chat: the turn its messages were captured during. It is set when a
|
||||
catch-up becomes owed while the turn is open, or when cached messages are painted that were saved
|
||||
while one was, and it is saved with those messages in the replica. It carries the turn's start time.
|
||||
While it is set and the agent reads idle, the chat keeps showing that running turn, with its loader
|
||||
and elapsed timer unchanged, and there is no separate catching-up footer. The commit that brings the
|
||||
chat current clears it, in the same store write as the rows, so the final messages and the completed
|
||||
footer land together. A failed catch-up clears it too, and the footer takes the true status. Losing
|
||||
the connection is not a failed catch-up: none can run or fail until the host returns, so the running
|
||||
footer stays beside "Reconnecting to host" and the catch-up on reconnect resolves it. Releasing it
|
||||
offline would put a completed footer under messages known to be behind. Surfaces outside the timeline (the sidebar, tabs, the composer's stop and send controls) read the true status
|
||||
and never see this state. Live delivery needs no hold because `applyStreamEvent` commits queued rows
|
||||
before it applies turn liveness.
|
||||
|
||||
The draft-create handoff has the same lifetime: the viewed-timeline owner releases it when the sync
|
||||
stops owing that chat a catch-up, not when the first authoritative page lands. Releasing it at the
|
||||
first page leaves a chat this client just created looking hydrated but still catching up, which
|
||||
|
||||
@@ -21,7 +21,10 @@ interface Box {
|
||||
interface TurnEndFrame {
|
||||
at: number;
|
||||
running: boolean;
|
||||
replyLength: number;
|
||||
footer: Box | null;
|
||||
footerText: string;
|
||||
elapsed: string | null;
|
||||
userRow: Box | null;
|
||||
replyVisible: boolean;
|
||||
}
|
||||
@@ -61,10 +64,13 @@ async function recordTurnEndFrames(
|
||||
const viewport = Array.from(
|
||||
document.querySelectorAll('[data-testid="agent-chat-scroll"]'),
|
||||
).find((candidate) => isVisible(candidate));
|
||||
const footers = Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
|
||||
.map(boxOf)
|
||||
.filter((box): box is Box => box !== null)
|
||||
.sort((left, right) => left.top - right.top);
|
||||
const footer = Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
|
||||
.map((element) => ({ element, box: boxOf(element) }))
|
||||
.filter((row): row is { element: Element; box: Box } => row.box !== null)
|
||||
.sort((left, right) => left.box.top - right.box.top)
|
||||
.at(-1);
|
||||
const elapsed =
|
||||
viewport?.querySelector('[data-testid="turn-working-elapsed"]')?.textContent ?? null;
|
||||
const userRow = Array.from(
|
||||
viewport?.querySelectorAll('[data-testid="user-message"]') ?? [],
|
||||
).find((candidate) => candidate.textContent?.includes(prompt));
|
||||
@@ -74,10 +80,16 @@ async function recordTurnEndFrames(
|
||||
: Array.from(viewport?.querySelectorAll('[data-testid="assistant-message"]') ?? []).find(
|
||||
(candidate) => candidate.textContent?.includes(reply),
|
||||
);
|
||||
const assistantRows = Array.from(
|
||||
viewport?.querySelectorAll('[data-testid="assistant-message"]') ?? [],
|
||||
);
|
||||
state.frames.push({
|
||||
at: performance.now(),
|
||||
running: Boolean(viewport?.querySelector('[data-testid="turn-working-indicator"]')),
|
||||
footer: footers.at(-1) ?? null,
|
||||
replyLength: assistantRows.reduce((sum, row) => sum + (row.textContent?.length ?? 0), 0),
|
||||
footer: footer?.box ?? null,
|
||||
footerText: (footer?.element.textContent ?? "").replace(elapsed ?? "", ""),
|
||||
elapsed,
|
||||
userRow: boxOf(userRow),
|
||||
replyVisible: boxOf(replyRow) !== null,
|
||||
});
|
||||
@@ -147,7 +159,7 @@ function describeFrames(frames: TurnEndFrame[]): string {
|
||||
(frame, index) =>
|
||||
`${index}: ${frame.running ? "running " : "complete"} footer=${
|
||||
frame.footer ? `${frame.footer.top.toFixed(1)}+${frame.footer.height.toFixed(1)}` : "none"
|
||||
} prompt=${frame.userRow?.top.toFixed(1) ?? "none"} reply=${frame.replyVisible}`,
|
||||
} timer=${frame.elapsed ?? "none"} prompt=${frame.userRow?.top.toFixed(1) ?? "none"} reply=${frame.replyVisible}`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
@@ -182,6 +194,192 @@ function expectSteadyTurnEnd(frames: TurnEndFrame[], input: { reply: string | nu
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
interface ReturnedTurn {
|
||||
frames: TurnEndFrame[];
|
||||
reconnectedAt: number;
|
||||
finalReplyLength: number;
|
||||
}
|
||||
|
||||
// The agent finishes while the socket is down, then the catch-up is held so the idle status
|
||||
// reaches the chat first. Painted frames are sampled from the running footer until everything
|
||||
// has settled.
|
||||
async function returnAfterTurnFinished(input: {
|
||||
page: Page;
|
||||
agent: MockAgentWorkspace;
|
||||
prompt: string;
|
||||
leaveWhile: (gate: DaemonWebSocketGate) => Promise<void>;
|
||||
}): Promise<ReturnedTurn> {
|
||||
const { page, agent, prompt, leaveWhile } = input;
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
|
||||
await recordTurnEndFrames(page, { prompt, reply: "" });
|
||||
await leaveWhile(gate);
|
||||
gate.holdTimelineResponses(agent.agentId);
|
||||
const reconnectedAt = await page.evaluate(() => performance.now());
|
||||
gate.restore();
|
||||
await gate.waitForHeldTimelineResponse();
|
||||
await expectAgentIdle(page);
|
||||
|
||||
await recordPaintsFor(page, 2_000);
|
||||
gate.releaseHeldTimelineResponses();
|
||||
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
|
||||
await expect(page.getByTestId("turn-footer").last()).toBeVisible();
|
||||
await recordPaintsFor(page, 500);
|
||||
const frames = await stopRecording(page);
|
||||
return { frames, reconnectedAt, finalReplyLength: frames.at(-1)?.replyLength ?? 0 };
|
||||
}
|
||||
|
||||
type DaemonWebSocketGate = Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
|
||||
|
||||
async function leaveWithOnlyThePromptOnScreen(
|
||||
page: Page,
|
||||
agent: MockAgentWorkspace,
|
||||
gate: DaemonWebSocketGate,
|
||||
prompt: string,
|
||||
): Promise<void> {
|
||||
gate.setAgentStreamItemSuppressed("assistant_message", true);
|
||||
gate.holdNextAgentStreamEvent("turn_completed");
|
||||
gate.holdNextAgentUpdate(agent.agentId, "idle");
|
||||
gate.setServerMessageSuppressed("agent_update", true);
|
||||
await submitMessage(page, prompt);
|
||||
await Promise.all([
|
||||
gate.waitForHeldAgentStreamEvent("turn_completed"),
|
||||
gate.waitForHeldAgentUpdate(agent.agentId, "idle"),
|
||||
]);
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await gate.drop();
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
gate.setAgentStreamItemSuppressed("assistant_message", false);
|
||||
gate.setServerMessageSuppressed("agent_update", false);
|
||||
}
|
||||
|
||||
async function leaveWithAPartialReplyOnScreen(
|
||||
page: Page,
|
||||
agent: MockAgentWorkspace,
|
||||
gate: DaemonWebSocketGate,
|
||||
prompt: string,
|
||||
): Promise<void> {
|
||||
await agent.client.sendAgentMessage(agent.agentId, prompt);
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
|
||||
await gate.drop();
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
}
|
||||
|
||||
function elapsedSeconds(frame: TurnEndFrame): number {
|
||||
const units: Record<string, number> = { h: 3600, m: 60, s: 1 };
|
||||
return [...(frame.elapsed ?? "").matchAll(/(\d+)([hms])/g)].reduce(
|
||||
(total, [, value, unit]) => total + Number(value) * units[unit],
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function expectFooterUnchangedUntilMessagesLand(
|
||||
{ frames, reconnectedAt }: ReturnedTurn,
|
||||
record: string,
|
||||
): void {
|
||||
const held = frames.slice(
|
||||
frames.findIndex((frame) => frame.at >= reconnectedAt),
|
||||
frames.findIndex((frame) => frame.at >= reconnectedAt && !frame.running),
|
||||
);
|
||||
expect(
|
||||
held.length,
|
||||
`the running footer must hold until the messages land\n${record}`,
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
new Set(held.map((frame) => `${frame.footer?.top}+${frame.footer?.height}`)).size,
|
||||
`the held footer must not move or resize\n${record}`,
|
||||
).toBe(1);
|
||||
expect(
|
||||
new Set(held.map((frame) => frame.footerText)).size,
|
||||
`the held footer's content must not change\n${record}`,
|
||||
).toBe(1);
|
||||
expect(
|
||||
held.filter((frame) => frame.elapsed === null).map((frame) => frame.at),
|
||||
`the timer must stay on screen\n${record}`,
|
||||
).toEqual([]);
|
||||
const seconds = held.map(elapsedSeconds);
|
||||
expect(seconds, `the timer must keep counting up\n${record}`).toEqual(
|
||||
seconds.toSorted((left, right) => left - right),
|
||||
);
|
||||
}
|
||||
|
||||
function expectReturnLandsWhole(returned: ReturnedTurn): void {
|
||||
const { frames, finalReplyLength } = returned;
|
||||
const record = `frames:\n${describeFrames(frames)}`;
|
||||
const firstRunning = frames.findIndex((frame) => frame.running && frame.footer !== null);
|
||||
expect(firstRunning, record).toBeGreaterThanOrEqual(0);
|
||||
const fromRunning = frames.slice(firstRunning);
|
||||
|
||||
expect(
|
||||
fromRunning.filter((frame) => frame.footer === null).map((frame) => frame.at),
|
||||
`the footer must never disappear\n${record}`,
|
||||
).toEqual([]);
|
||||
|
||||
const completed = fromRunning.filter((frame) => !frame.running);
|
||||
expect(completed.length, record).toBeGreaterThan(0);
|
||||
// The first Markdown on a page paints an empty row until the web parser loads. Only text that
|
||||
// is present and short of the final reply is stale.
|
||||
expect(
|
||||
completed
|
||||
.filter((frame) => frame.replyLength > 0 && frame.replyLength !== finalReplyLength)
|
||||
.map((frame) => frame.at),
|
||||
`a completed footer must never sit under stale messages\n${record}`,
|
||||
).toEqual([]);
|
||||
|
||||
expectFooterUnchangedUntilMessagesLand(returned, record);
|
||||
}
|
||||
|
||||
async function withFirstTurnAgent(
|
||||
model: string,
|
||||
run: (agent: MockAgentWorkspace) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "turn-return-",
|
||||
title: "Turn return",
|
||||
model,
|
||||
});
|
||||
try {
|
||||
await run(agent);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("Returning to a turn that finished while away", () => {
|
||||
test("keeps the running footer until the messages land when only the prompt was on screen", async ({
|
||||
page,
|
||||
}) => {
|
||||
await withFirstTurnAgent("e2e-fast-stream", async (agent) => {
|
||||
const prompt = "emit 1 coalesced agent stream updates";
|
||||
const returned = await returnAfterTurnFinished({
|
||||
page,
|
||||
agent,
|
||||
prompt,
|
||||
leaveWhile: (gate) => leaveWithOnlyThePromptOnScreen(page, agent, gate, prompt),
|
||||
});
|
||||
expectReturnLandsWhole(returned);
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the running footer until the messages land under a partial reply", async ({
|
||||
page,
|
||||
}) => {
|
||||
await withFirstTurnAgent("ten-second-stream", async (agent) => {
|
||||
const prompt = "Return partial prompt";
|
||||
const returned = await returnAfterTurnFinished({
|
||||
page,
|
||||
agent,
|
||||
prompt,
|
||||
leaveWhile: (gate) => leaveWithAPartialReplyOnScreen(page, agent, gate, prompt),
|
||||
});
|
||||
expectReturnLandsWhole(returned);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Turn end", () => {
|
||||
test("ends a plain reply without moving or dropping its footer", async ({ page, agent }) => {
|
||||
const input = { prompt: "emit 1 coalesced agent stream updates", reply: "stress-update-0" };
|
||||
|
||||
@@ -79,7 +79,9 @@ test.describe("Schedules", () => {
|
||||
cleanupTasks.length = 0;
|
||||
});
|
||||
|
||||
test("edit form hydrates the scheduled model selection", async ({ page }) => {
|
||||
test("hydrates schedule editing and opens a pristine creation form afterward", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-model-hydration-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const scheduleName = `Hydrate model ${Date.now()}`;
|
||||
@@ -93,29 +95,45 @@ test.describe("Schedules", () => {
|
||||
|
||||
await row.click();
|
||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expectStableHeight(formSheet);
|
||||
const hostTrigger = page.getByTestId("schedule-host-trigger");
|
||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
||||
const modelTrigger = page.getByTestId("schedule-model-trigger");
|
||||
const thinkingTrigger = page.getByTestId("schedule-thinking-trigger");
|
||||
const modeTrigger = page.getByTestId("schedule-mode-trigger");
|
||||
await expect(hostTrigger).toBeVisible({ timeout: 30_000 });
|
||||
await expect(hostTrigger).toBeDisabled();
|
||||
await expectSettled(hostTrigger);
|
||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(modelTrigger).toContainText("Ten second stream", { timeout: 30_000 });
|
||||
await expectSettled(modelTrigger);
|
||||
await expect(thinkingTrigger).toContainText("Low");
|
||||
await expectSettled(thinkingTrigger);
|
||||
await expect(modeTrigger).toBeVisible({ timeout: 30_000 });
|
||||
await expectSettled(modeTrigger);
|
||||
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
||||
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
||||
await expect(page.getByText(/Times are in/)).toHaveCount(0);
|
||||
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
|
||||
await test.step("edit form hydrates the saved model and controls", async () => {
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expectStableHeight(formSheet);
|
||||
await expect(hostTrigger).toBeVisible({ timeout: 30_000 });
|
||||
await expect(hostTrigger).toBeDisabled();
|
||||
await expectSettled(hostTrigger);
|
||||
await expect(projectTrigger).toContainText(workspace.projectDisplayName, { timeout: 30_000 });
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(modelTrigger).toContainText("Ten second stream", { timeout: 30_000 });
|
||||
await expectSettled(modelTrigger);
|
||||
await expect(thinkingTrigger).toContainText("Low");
|
||||
await expectSettled(thinkingTrigger);
|
||||
await expect(modeTrigger).toBeVisible({ timeout: 30_000 });
|
||||
await expectSettled(modeTrigger);
|
||||
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
||||
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
||||
await expect(page.getByText(/Times are in/)).toHaveCount(0);
|
||||
await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("closing edit leaves the new schedule form pristine", async () => {
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(formSheet).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("schedules-new").click();
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expect(projectTrigger).toContainText(/select project/i);
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(page.getByTestId("schedule-model-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-mode-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("edit form hydrates a non-default host schedule after reload", async ({ page }) => {
|
||||
@@ -165,32 +183,4 @@ test.describe("Schedules", () => {
|
||||
await expect(page.getByTestId("cadence-mode")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-cadence-preset-trigger")).toContainText("Daily 9:00");
|
||||
});
|
||||
|
||||
test("create opens pristine after closing an edit form", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "schedule-pristine-create-" });
|
||||
cleanupTasks.push(() => workspace.cleanup());
|
||||
const scheduleName = `Pristine create ${Date.now()}`;
|
||||
const scheduleId = await seedMockSchedule(workspace, scheduleName);
|
||||
cleanupTasks.push(() => deleteSeededSchedule(workspace, scheduleId));
|
||||
|
||||
await page.goto(buildSchedulesRoute());
|
||||
const row = page.getByTestId(`schedule-row-${scheduleId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
const formSheet = page.getByTestId("schedule-form-sheet");
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
await expectStableHeight(formSheet);
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(formSheet).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("schedules-new").click();
|
||||
await expect(formSheet).toBeVisible({ timeout: 10_000 });
|
||||
const projectTrigger = page.getByTestId("schedule-project-trigger");
|
||||
await expect(projectTrigger).toContainText(/select project/i);
|
||||
await expectSettled(projectTrigger);
|
||||
await expect(page.getByTestId("schedule-model-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("schedule-mode-trigger")).toHaveCount(0);
|
||||
await expect(page.getByTestId("cadence-interval-value")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,12 +74,15 @@ export function HorizontalScrollBoundaryShades({
|
||||
leftStyle,
|
||||
rightStyle,
|
||||
testIDPrefix = "horizontal-scroll-boundary",
|
||||
bottomInset = 1,
|
||||
}: {
|
||||
visible: boolean;
|
||||
backdrop: "surface" | "sidebar";
|
||||
leftStyle: AnimatedStyle<{ opacity: number }>;
|
||||
rightStyle: AnimatedStyle<{ opacity: number }>;
|
||||
testIDPrefix?: string;
|
||||
/** Set to 0 when the scrolled content's own border reaches the bottom edge and must fade too. */
|
||||
bottomInset?: number;
|
||||
}) {
|
||||
if (!visible) return null;
|
||||
const colorMapping = backdrop === "sidebar" ? sidebarColorMapping : surfaceColorMapping;
|
||||
@@ -88,14 +91,14 @@ export function HorizontalScrollBoundaryShades({
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
testID={`${testIDPrefix}-left`}
|
||||
style={[styles.shade, styles.left, leftStyle]}
|
||||
style={[styles.shade(bottomInset), styles.left, leftStyle]}
|
||||
>
|
||||
<ThemedScrollBoundaryShadeSvg side="left" uniProps={colorMapping} />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
testID={`${testIDPrefix}-right`}
|
||||
style={[styles.shade, styles.right, rightStyle]}
|
||||
style={[styles.shade(bottomInset), styles.right, rightStyle]}
|
||||
>
|
||||
<ThemedScrollBoundaryShadeSvg side="right" uniProps={colorMapping} />
|
||||
</Animated.View>
|
||||
@@ -104,12 +107,12 @@ export function HorizontalScrollBoundaryShades({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
shade: {
|
||||
shade: (bottomInset: number) => ({
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 1,
|
||||
bottom: bottomInset,
|
||||
width: SHADE_WIDTH,
|
||||
},
|
||||
}),
|
||||
left: { left: 0 },
|
||||
right: { right: 0 },
|
||||
}));
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import React, { act, useCallback, useState, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { Pressable, Text } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import { ComposerTrackPill, ComposerTrackRow, type ComposerTrackPillSegment } from "./tracks";
|
||||
import { MobilePanelsProvider } from "@/mobile-panels/provider";
|
||||
import {
|
||||
ComposerTrackBar,
|
||||
ComposerTrackPill,
|
||||
ComposerTrackRow,
|
||||
type ComposerTrackPillSegment,
|
||||
} from "./tracks";
|
||||
|
||||
const SUBAGENT_SEGMENTS: ComposerTrackPillSegment[] = [{ bucket: null, text: "3 subagents" }];
|
||||
|
||||
@@ -192,6 +198,87 @@ describe("composer track panel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const BAR_PILL_STYLE = { width: 120, height: 32 };
|
||||
const TRANSCRIPT_STYLE = { position: "absolute", top: 0, right: 0, bottom: 0, left: 0 } as const;
|
||||
const NARROW_HOST = { position: "relative", width: 300, height: 120 } as const;
|
||||
const WIDE_HOST = { position: "relative", width: 400, height: 120 } as const;
|
||||
|
||||
function mountTrackBar({
|
||||
host,
|
||||
pills,
|
||||
}: {
|
||||
host: typeof NARROW_HOST | typeof WIDE_HOST;
|
||||
pills: number;
|
||||
}): void {
|
||||
mount(
|
||||
<MobilePanelsProvider>
|
||||
<View style={host}>
|
||||
<View testID="transcript" style={TRANSCRIPT_STYLE} />
|
||||
<ComposerTrackBar>
|
||||
{Array.from({ length: pills }, (_, index) => (
|
||||
<View key={index} testID={`bar-pill-${index}`} style={BAR_PILL_STYLE} />
|
||||
))}
|
||||
</ComposerTrackBar>
|
||||
</View>
|
||||
</MobilePanelsProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function byTestID(testID: string): HTMLElement {
|
||||
const element = document.querySelector(`[data-testid="${testID}"]`);
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error(`${testID} did not render`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
/** The element that receives a touch landing at this point of the page. */
|
||||
function hitTest(x: number, y: number): Element | null {
|
||||
return document.elementFromPoint(x, y);
|
||||
}
|
||||
|
||||
describe("composer track bar", () => {
|
||||
// Android's horizontal ScrollView ignores the drag unless the scroller itself can be touched.
|
||||
// Browsers scroll either way, so this is the only part of that bug a browser can see.
|
||||
it("keeps the scroller touchable, so Android can drag the row", () => {
|
||||
mountTrackBar({ host: NARROW_HOST, pills: 4 });
|
||||
|
||||
expect(getComputedStyle(byTestID("composer-track-scroll")).pointerEvents).toBe("auto");
|
||||
});
|
||||
|
||||
it("stays inside the bar when the pills overflow it", () => {
|
||||
mountTrackBar({ host: NARROW_HOST, pills: 4 });
|
||||
|
||||
const scroller = byTestID("composer-track-scroll");
|
||||
expect(scroller.getBoundingClientRect().right).toBeLessThanOrEqual(
|
||||
byTestID("transcript").getBoundingClientRect().right,
|
||||
);
|
||||
expect(scroller.scrollWidth).toBeGreaterThan(scroller.clientWidth);
|
||||
});
|
||||
|
||||
it("leaves the empty part of the row to the transcript", () => {
|
||||
mountTrackBar({ host: WIDE_HOST, pills: 1 });
|
||||
|
||||
const pillRect = byTestID("bar-pill-0").getBoundingClientRect();
|
||||
const transcript = byTestID("transcript");
|
||||
const emptyX = transcript.getBoundingClientRect().right - 40;
|
||||
expect(emptyX).toBeGreaterThan(pillRect.right);
|
||||
|
||||
expect(hitTest(emptyX, pillRect.top + pillRect.height / 2)).toBe(transcript);
|
||||
});
|
||||
|
||||
it("fades the full height of the row without taking its touches", () => {
|
||||
mountTrackBar({ host: NARROW_HOST, pills: 4 });
|
||||
|
||||
const scroller = byTestID("composer-track-scroll").getBoundingClientRect();
|
||||
const shade = byTestID("composer-track-scroll-shade-right").getBoundingClientRect();
|
||||
expect(shade.bottom).toBe(scroller.bottom);
|
||||
|
||||
const underShade = hitTest(shade.left + shade.width / 2, shade.top + shade.height / 2);
|
||||
expect(byTestID("composer-track-scroll").contains(underShade)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("composer track pill status mark", () => {
|
||||
/** The mark is the glyph itself: the dot for a still state, the ring's carrier for running. */
|
||||
function mountMark(bucket: "running" | "failed"): { mark: HTMLElement; body: HTMLElement } {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useMemo, useState, type ReactElement, type ReactNode } from "react";
|
||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
MENU_ITEM_HEIGHT,
|
||||
@@ -15,6 +16,10 @@ import {
|
||||
} from "@/components/ui/menu";
|
||||
import { StatusRing } from "@/components/status-ring";
|
||||
import { bySizing } from "@/components/ui/control-geometry";
|
||||
import {
|
||||
HorizontalScrollBoundaryShades,
|
||||
useHorizontalScrollBoundary,
|
||||
} from "@/components/ui/horizontal-scroll-boundary";
|
||||
import { STATUS_RING_HALO_INSET } from "@/components/status-ring/geometry";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { usePanelAwareHorizontalScrollGesture } from "@/mobile-panels/gestures";
|
||||
@@ -38,22 +43,35 @@ import { COMPOSER_PILL_CLEARANCE, composerPillStyles } from "./pill-styles";
|
||||
*/
|
||||
export function ComposerTrackBar({ children }: { children: ReactNode }): ReactElement {
|
||||
const scrollGesture = usePanelAwareHorizontalScrollGesture();
|
||||
const scrollBoundary = useHorizontalScrollBoundary();
|
||||
return (
|
||||
<View style={styles.bar} pointerEvents="box-none">
|
||||
<View style={styles.trackViewport} pointerEvents="box-none">
|
||||
<GestureDetector gesture={scrollGesture}>
|
||||
<ScrollView
|
||||
<Animated.ScrollView
|
||||
testID="composer-track-scroll"
|
||||
style={styles.trackScroller}
|
||||
horizontal
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsHorizontalScrollIndicator={false}
|
||||
pointerEvents="box-none"
|
||||
onLayout={scrollBoundary.onLayout}
|
||||
onContentSizeChange={scrollBoundary.onContentSizeChange}
|
||||
onScroll={scrollBoundary.onScroll}
|
||||
scrollEventThrottle={16}
|
||||
>
|
||||
<View style={styles.track} pointerEvents="box-none">
|
||||
{children}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</Animated.ScrollView>
|
||||
</GestureDetector>
|
||||
<HorizontalScrollBoundaryShades
|
||||
visible
|
||||
backdrop="surface"
|
||||
testIDPrefix="composer-track-scroll-shade"
|
||||
bottomInset={0}
|
||||
leftStyle={scrollBoundary.leftShadeStyle}
|
||||
rightStyle={scrollBoundary.rightShadeStyle}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -393,6 +411,13 @@ const styles = StyleSheet.create((theme) => {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
// Only as wide as its pills, so the rest of the row passes touches to the transcript. The
|
||||
// scroller can't use box-none for that: Android's horizontal ScrollView won't scroll with it.
|
||||
trackScroller: {
|
||||
alignSelf: "flex-start",
|
||||
// Web sizes a flex-start scroller to its content, so without a cap it overflows the bar.
|
||||
maxWidth: "100%",
|
||||
},
|
||||
track: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -86,7 +86,7 @@ import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
selectAgentTimelineState,
|
||||
selectAgentTurnPresentation,
|
||||
selectAgentTimelineTurnPresentation,
|
||||
type Agent,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
@@ -1411,7 +1411,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
const turnPresentation = useSessionStore(
|
||||
useShallow((state) =>
|
||||
agentId
|
||||
? selectAgentTurnPresentation(state.sessions[serverId], agentId)
|
||||
? selectAgentTimelineTurnPresentation(state.sessions[serverId], agentId)
|
||||
: { isActive: false, isCancelling: false, startedAt: null, turnId: null },
|
||||
),
|
||||
);
|
||||
|
||||
@@ -206,6 +206,7 @@ export class AgentStoreProjection {
|
||||
const store = useSessionStore.getState();
|
||||
const removeKey = withoutAgent(agentId);
|
||||
store.setAgentTimelineCursor(this.serverId, removeKey);
|
||||
store.setAgentTimelineUnsettledTurn(this.serverId, agentId, null);
|
||||
store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false);
|
||||
store.setAgentStreamTail(this.serverId, removeKey);
|
||||
store.clearAgentStreamHead(this.serverId, agentId);
|
||||
|
||||
@@ -216,6 +216,19 @@ function timeline(text = "Cached") {
|
||||
};
|
||||
}
|
||||
|
||||
it("keeps the unsettled turn with the cached messages", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
const cached = {
|
||||
...timeline(),
|
||||
unsettledTurn: { startedAt: new Date("2026-08-26T09:59:00.000Z"), turnId: "turn-1" },
|
||||
};
|
||||
writer.commitTimeline(SERVER_ID, "agent-1", cached);
|
||||
await writer.flush();
|
||||
|
||||
expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toEqual(cached);
|
||||
});
|
||||
|
||||
function commitDirectory(
|
||||
cache: ReplicaCache,
|
||||
serverId: string,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type AgentToolCallData,
|
||||
type StreamItem,
|
||||
} from "@/types/stream";
|
||||
import type { UnsettledTurn } from "@/timeline/turn-liveness";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { clearLegacyReplicaCache } from "./legacy-cleanup";
|
||||
import {
|
||||
@@ -67,6 +68,8 @@ export interface CachedTimeline {
|
||||
items: StreamItem[];
|
||||
range: AgentTimelineCursorState | null;
|
||||
hasOlder: boolean;
|
||||
/** Set when these messages were captured while a turn was open and not yet caught up. */
|
||||
unsettledTurn?: UnsettledTurn;
|
||||
}
|
||||
|
||||
const PERSIST_DELAY_MS = 1_000;
|
||||
@@ -356,6 +359,9 @@ const StoredTimelineSchema = z.strictObject({
|
||||
})
|
||||
.nullable(),
|
||||
hasOlder: z.boolean(),
|
||||
unsettledTurn: z
|
||||
.strictObject({ turnId: z.string().nullable(), startedAt: IsoDateSchema.nullable() })
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type StoredAgent = z.infer<typeof StoredAgentSchema>;
|
||||
@@ -412,6 +418,16 @@ function deserializeTimeline(stored: StoredTimeline | null): CachedTimeline | nu
|
||||
items: stored.items.map(deserializeTimelineItem),
|
||||
range: stored.range,
|
||||
hasOlder: stored.hasOlder,
|
||||
...(stored.unsettledTurn
|
||||
? {
|
||||
unsettledTurn: {
|
||||
turnId: stored.unsettledTurn.turnId,
|
||||
startedAt: stored.unsettledTurn.startedAt
|
||||
? new Date(stored.unsettledTurn.startedAt)
|
||||
: null,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -812,6 +828,14 @@ function serializeTimeline(timeline: CachedTimeline): StoredTimeline | null {
|
||||
? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq }
|
||||
: null,
|
||||
hasOlder: canPersistCoverage ? timeline.hasOlder : false,
|
||||
...(timeline.unsettledTurn
|
||||
? {
|
||||
unsettledTurn: {
|
||||
turnId: timeline.unsettledTurn.turnId,
|
||||
startedAt: timeline.unsettledTurn.startedAt?.toISOString() ?? null,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -56,10 +56,12 @@ import {
|
||||
type WorkspaceAgentActivity,
|
||||
} from "@/utils/workspace-agent-activity";
|
||||
import {
|
||||
resolveTimelineTurnPresentation,
|
||||
resolveTurnPresentation,
|
||||
TURN_LIVENESS_IDLE,
|
||||
type TurnLiveness,
|
||||
type TurnPresentation,
|
||||
type UnsettledTurn,
|
||||
} from "@/timeline/turn-liveness";
|
||||
|
||||
export interface AgentRuntimeInfo {
|
||||
@@ -374,6 +376,64 @@ export function selectAgentTurnPresentation(
|
||||
);
|
||||
}
|
||||
|
||||
/** Turn state for the chat's own timeline. Every other surface reads the true status above. */
|
||||
export function selectAgentTimelineTurnPresentation(
|
||||
session: SessionState | undefined,
|
||||
agentId: string,
|
||||
): TurnPresentation {
|
||||
return resolveTimelineTurnPresentation(
|
||||
selectAgentTurnPresentation(session, agentId),
|
||||
session?.agentTimelineUnsettledTurn.get(agentId),
|
||||
);
|
||||
}
|
||||
|
||||
/** The unsettled turn a chat's messages carry: the one open right now, or the one it holds. */
|
||||
export function selectAgentUnsettledTurn(
|
||||
session: SessionState | undefined,
|
||||
agentId: string,
|
||||
): UnsettledTurn | undefined {
|
||||
const turn = session?.agents.get(agentId)?.turn ?? session?.agentDetails.get(agentId)?.turn;
|
||||
if (turn?.phase === "open") return { turnId: turn.turnId, startedAt: turn.startedAt };
|
||||
return session?.agentTimelineUnsettledTurn.get(agentId);
|
||||
}
|
||||
|
||||
function withUnsettledTurn(
|
||||
current: Map<string, UnsettledTurn>,
|
||||
agentId: string,
|
||||
turn: UnsettledTurn,
|
||||
): Map<string, UnsettledTurn> {
|
||||
const held = current.get(agentId);
|
||||
// COMPAT(agentTurnIdentity): added in v0.2.6, remove after 2027-01-31 once daemon floor >= v0.2.6.
|
||||
// Hosts without turn ids report null for every turn, so only the start time tells two turns apart.
|
||||
if (held?.turnId === turn.turnId && held?.startedAt?.getTime() === turn.startedAt?.getTime()) {
|
||||
return current;
|
||||
}
|
||||
const next = new Map(current);
|
||||
next.set(agentId, turn);
|
||||
return next;
|
||||
}
|
||||
|
||||
function withoutUnsettledTurn(
|
||||
current: Map<string, UnsettledTurn>,
|
||||
agentId: string,
|
||||
): Map<string, UnsettledTurn> {
|
||||
if (!current.has(agentId)) return current;
|
||||
const next = new Map(current);
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
}
|
||||
|
||||
// The messages that bring a chat current settle its turn in this same commit.
|
||||
function commitUnsettledTurn(
|
||||
current: Map<string, UnsettledTurn>,
|
||||
agentId: string,
|
||||
commit: { synchronized: boolean; unsettledTurn?: UnsettledTurn },
|
||||
): Map<string, UnsettledTurn> {
|
||||
if (commit.synchronized) return withoutUnsettledTurn(current, agentId);
|
||||
if (commit.unsettledTurn) return withUnsettledTurn(current, agentId, commit.unsettledTurn);
|
||||
return current;
|
||||
}
|
||||
|
||||
function latestTasksFromStream(items: readonly StreamItem[]): TodoEntry[] {
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
@@ -426,6 +486,7 @@ export interface SessionState {
|
||||
agentTasks: Map<string, TodoEntry[]>;
|
||||
messageSubmissions: Map<string, MessageSubmissionRecord[]>;
|
||||
agentTimelineCursor: Map<string, AgentTimelineCursorState>;
|
||||
agentTimelineUnsettledTurn: Map<string, UnsettledTurn>;
|
||||
agentTimelineHasOlder: Map<string, boolean>;
|
||||
agentTimelineHasNewer: Map<string, boolean>;
|
||||
agentTimelineOlderFetchInFlight: Map<string, boolean>;
|
||||
@@ -569,8 +630,14 @@ interface SessionStoreActions {
|
||||
newer: boolean;
|
||||
synchronized: boolean;
|
||||
acknowledgedClientMessageIds: string[];
|
||||
unsettledTurn?: UnsettledTurn;
|
||||
},
|
||||
) => void;
|
||||
setAgentTimelineUnsettledTurn: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
turn: UnsettledTurn | null,
|
||||
) => void;
|
||||
|
||||
// Initializing agents
|
||||
setInitializingAgents: (
|
||||
@@ -674,6 +741,7 @@ function createInitialSessionState(
|
||||
agentTasks: new Map(),
|
||||
messageSubmissions: new Map(),
|
||||
agentTimelineCursor: new Map(),
|
||||
agentTimelineUnsettledTurn: new Map(),
|
||||
agentTimelineHasOlder: new Map(),
|
||||
agentTimelineHasNewer: new Map(),
|
||||
agentTimelineOlderFetchInFlight: new Map(),
|
||||
@@ -1455,6 +1523,11 @@ export const useSessionStore = create<SessionStore>()(
|
||||
nextAuthoritative.set(agentId, true);
|
||||
nextSyncGeneration.set(agentId, session.historySyncGeneration);
|
||||
}
|
||||
const agentTimelineUnsettledTurn = commitUnsettledTurn(
|
||||
session.agentTimelineUnsettledTurn,
|
||||
agentId,
|
||||
state,
|
||||
);
|
||||
const tasks = latestTasksFromStream([...state.items, ...state.head]);
|
||||
const agentTasks = new Map(session.agentTasks);
|
||||
if (tasks.length > 0) agentTasks.set(agentId, tasks);
|
||||
@@ -1472,6 +1545,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
agentTimelineCursor: nextCursor,
|
||||
agentTimelineHasOlder: nextHasOlder,
|
||||
agentTimelineHasNewer: nextHasNewer,
|
||||
agentTimelineUnsettledTurn,
|
||||
agentAuthoritativeHistoryApplied: nextAuthoritative,
|
||||
agentHistorySyncGeneration: nextSyncGeneration,
|
||||
messageSubmissions,
|
||||
@@ -1481,6 +1555,25 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setAgentTimelineUnsettledTurn: (serverId, agentId, turn) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) return prev;
|
||||
const current = session.agentTimelineUnsettledTurn;
|
||||
const next = turn
|
||||
? withUnsettledTurn(current, agentId, turn)
|
||||
: withoutUnsettledTurn(current, agentId);
|
||||
if (next === current) return prev;
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, agentTimelineUnsettledTurn: next },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Initializing agents
|
||||
setInitializingAgents: (serverId, state) => {
|
||||
set((prev) => {
|
||||
|
||||
@@ -174,6 +174,8 @@ it("feeds restored layout to timeline sync as a release signal, not a subscripti
|
||||
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
|
||||
onCatchUpEnded: () => undefined,
|
||||
reportError: () => undefined,
|
||||
onCatchUpOwed: () => undefined,
|
||||
onCatchUpSettled: () => undefined,
|
||||
});
|
||||
// The edge session-context.tsx owns. Pointing it at subscription instead would resume every
|
||||
// agent behind a restored tab on the daemon.
|
||||
|
||||
@@ -8,15 +8,23 @@ import {
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import type { CachedTimeline } from "@/runtime/replica-cache";
|
||||
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
selectAgentTimelineState,
|
||||
selectAgentTimelineTurnPresentation,
|
||||
selectAgentTurnPresentation,
|
||||
useSessionStore,
|
||||
type Agent,
|
||||
} from "@/stores/session-store";
|
||||
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { TurnLivenessTransition } from "./turn-liveness";
|
||||
import { reduceTurnLiveness, type TurnLivenessTransition } from "./turn-liveness";
|
||||
import {
|
||||
createTimelineReplica,
|
||||
createViewedTimelineOwner,
|
||||
type TimelineReplicaStorage,
|
||||
type TimelineResponsePayload,
|
||||
type ViewedTimelineOwner,
|
||||
type ViewedTimelineOwnerPorts,
|
||||
} from "./viewed-timeline-sync";
|
||||
|
||||
const SERVER_ID = "timeline-replica-host";
|
||||
@@ -47,6 +55,14 @@ function createOwner(
|
||||
agentId: string,
|
||||
transitions: readonly TurnLivenessTransition[],
|
||||
) => void = () => undefined,
|
||||
fetchPage: ViewedTimelineOwnerPorts["fetchPage"] = async () => ({
|
||||
hasNewer: false,
|
||||
endCursor: null,
|
||||
}),
|
||||
observe: ViewedTimelineOwnerPorts["observe"] = () => ({
|
||||
ready: Promise.resolve(),
|
||||
release: async () => undefined,
|
||||
}),
|
||||
): ViewedTimelineOwner {
|
||||
const replica = createTimelineReplica({
|
||||
serverId: SERVER_ID,
|
||||
@@ -60,9 +76,9 @@ function createOwner(
|
||||
drainQueuedAgentMessage: () => undefined,
|
||||
applyTurnLiveness,
|
||||
ports: {
|
||||
observe: () => ({ ready: Promise.resolve(), release: async () => undefined }),
|
||||
observe,
|
||||
readCursor: () => undefined,
|
||||
fetchPage: async () => ({ hasNewer: false, endCursor: null }),
|
||||
fetchPage,
|
||||
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
|
||||
reportError: () => undefined,
|
||||
},
|
||||
@@ -626,6 +642,471 @@ describe("turn boundaries", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the turn footer changes together with its messages", () => {
|
||||
const OPEN_TURN = { turnId: "turn-1", startedAt: new Date("2026-08-26T09:59:00.000Z") };
|
||||
|
||||
function setTurn(transition: TurnLivenessTransition): void {
|
||||
useSessionStore.getState().setAgents(SERVER_ID, (agents) => {
|
||||
const agent =
|
||||
agents.get(AGENT_ID) ??
|
||||
({
|
||||
id: AGENT_ID,
|
||||
turn: { phase: "idle", cancellationRequestId: null },
|
||||
} as Agent);
|
||||
const next = new Map(agents);
|
||||
next.set(AGENT_ID, { ...agent, turn: reduceTurnLiveness(agent.turn, transition) });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
const agentIsRunning = () => setTurn({ type: "snapshot", activeTurn: OPEN_TURN });
|
||||
const idleSnapshotArrives = () => setTurn({ type: "snapshot", activeTurn: null });
|
||||
|
||||
type Footer = "running" | "settled";
|
||||
function footer(): Footer {
|
||||
const session = useSessionStore.getState().sessions[SERVER_ID];
|
||||
const turn = selectAgentTimelineTurnPresentation(session, AGENT_ID);
|
||||
return turn.isActive ? "running" : "settled";
|
||||
}
|
||||
function timerStart(): Date | null {
|
||||
const session = useSessionStore.getState().sessions[SERVER_ID];
|
||||
return selectAgentTimelineTurnPresentation(session, AGENT_ID).startedAt;
|
||||
}
|
||||
function replies(): string[] {
|
||||
const session = useSessionStore.getState().sessions[SERVER_ID];
|
||||
return [
|
||||
...(session?.agentStreamTail.get(AGENT_ID) ?? []),
|
||||
...(session?.agentStreamHead.get(AGENT_ID) ?? []),
|
||||
]
|
||||
.filter((entry) => entry.kind === "assistant_message")
|
||||
.map((entry) => entry.text);
|
||||
}
|
||||
|
||||
// A chat that was current when the connection dropped, holding `cachedReply` if given.
|
||||
function createChat(
|
||||
cachedReply: string | null,
|
||||
fetchPage?: ViewedTimelineOwnerPorts["fetchPage"],
|
||||
): ViewedTimelineOwner {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
useSessionStore.getState().applyAgentTimelineResponseState(SERVER_ID, AGENT_ID, {
|
||||
items: cachedReply ? [item("reply-1", cachedReply, 4)] : [],
|
||||
head: [],
|
||||
range: { epoch: "epoch-1", startSeq: 1, endSeq: 4 },
|
||||
older: "none",
|
||||
newer: false,
|
||||
synchronized: true,
|
||||
acknowledgedClientMessageIds: [],
|
||||
});
|
||||
return createOwner(
|
||||
{ readTimeline: async () => undefined, commitTimeline: () => undefined },
|
||||
undefined,
|
||||
fetchPage,
|
||||
);
|
||||
}
|
||||
|
||||
async function connectAndCatchUp(owner: ViewedTimelineOwner): Promise<void> {
|
||||
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
|
||||
owner.setConnected(true);
|
||||
await expect.poll(() => owner.getAgentTimelineStatus(AGENT_ID)).toBe("ready");
|
||||
}
|
||||
|
||||
function catchUpPage(input: {
|
||||
replyThroughSeq: number | null;
|
||||
reply?: string;
|
||||
}): TimelineResponsePayload {
|
||||
const end = input.replyThroughSeq ?? 4;
|
||||
return {
|
||||
requestId: "catch-up",
|
||||
agentId: AGENT_ID,
|
||||
agent: null,
|
||||
direction: "after",
|
||||
projection: "projected",
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
window: { minSeq: 1, maxSeq: end, nextSeq: end + 1 },
|
||||
startCursor: input.reply ? { epoch: "epoch-1", seq: 5 } : null,
|
||||
endCursor: input.reply ? { epoch: "epoch-1", seq: end } : null,
|
||||
entries: input.reply
|
||||
? [
|
||||
{
|
||||
provider: "mock",
|
||||
item: { type: "assistant_message", text: input.reply },
|
||||
timestamp: "2026-08-26T10:00:02.000Z",
|
||||
seqStart: 5,
|
||||
seqEnd: end,
|
||||
sourceSeqRanges: [{ startSeq: 5, endSeq: end }],
|
||||
collapsed: [],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
error: null,
|
||||
hasNewer: false,
|
||||
hasOlder: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
};
|
||||
}
|
||||
|
||||
function recordCommits(): { commits: { footer: Footer; replies: string[] }[]; stop(): void } {
|
||||
const commits: { footer: Footer; replies: string[] }[] = [
|
||||
{ footer: footer(), replies: replies() },
|
||||
];
|
||||
const stop = useSessionStore.subscribe(() => {
|
||||
commits.push({ footer: footer(), replies: replies() });
|
||||
});
|
||||
return { commits, stop };
|
||||
}
|
||||
|
||||
it.each([
|
||||
["only the prompt cached", null, ["Done."]],
|
||||
["a partial reply cached", "Half", ["HalfDone."]],
|
||||
])("snapshot before messages, %s: running until messages land", async (_name, cached, final) => {
|
||||
const owner = createChat(cached);
|
||||
agentIsRunning();
|
||||
await connectAndCatchUp(owner);
|
||||
expect(footer()).toBe("running");
|
||||
|
||||
owner.setConnected(false);
|
||||
expect(footer()).toBe("running");
|
||||
owner.setConnected(true);
|
||||
const recorder = recordCommits();
|
||||
|
||||
idleSnapshotArrives();
|
||||
expect(footer()).toBe("running");
|
||||
|
||||
owner.applyTimelineResponse(catchUpPage({ replyThroughSeq: 8, reply: "Done." }));
|
||||
recorder.stop();
|
||||
|
||||
expect(footer()).toBe("settled");
|
||||
expect(replies()).toEqual(final);
|
||||
// The messages and the settled footer arrive in one commit, with no state between.
|
||||
const previous = recorder.commits.at(-2);
|
||||
expect(previous?.footer).toBe("running");
|
||||
expect(previous?.replies).not.toEqual(final);
|
||||
expect(
|
||||
recorder.commits.filter(
|
||||
(commit) => commit.footer === "settled" && commit.replies.join() !== final.join(),
|
||||
),
|
||||
).toEqual([]);
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("messages before snapshot: running stays until the idle snapshot, then completes", async () => {
|
||||
const owner = createChat("Half");
|
||||
agentIsRunning();
|
||||
await connectAndCatchUp(owner);
|
||||
owner.setConnected(false);
|
||||
owner.setConnected(true);
|
||||
|
||||
owner.applyTimelineResponse(catchUpPage({ replyThroughSeq: 8, reply: "Done." }));
|
||||
expect(replies()).toEqual(["HalfDone."]);
|
||||
expect(footer()).toBe("running");
|
||||
|
||||
idleSnapshotArrives();
|
||||
expect(footer()).toBe("settled");
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("a turn that ended with no new messages settles when the empty catch-up lands", async () => {
|
||||
const owner = createChat("Done.");
|
||||
agentIsRunning();
|
||||
await connectAndCatchUp(owner);
|
||||
owner.setConnected(false);
|
||||
owner.setConnected(true);
|
||||
idleSnapshotArrives();
|
||||
expect(footer()).toBe("running");
|
||||
|
||||
owner.applyTimelineResponse(catchUpPage({ replyThroughSeq: null }));
|
||||
|
||||
expect(footer()).toBe("settled");
|
||||
expect(replies()).toEqual(["Done."]);
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("keeps the true idle status for every surface outside the timeline", async () => {
|
||||
const owner = createChat("Half");
|
||||
agentIsRunning();
|
||||
await connectAndCatchUp(owner);
|
||||
owner.setConnected(false);
|
||||
owner.setConnected(true);
|
||||
idleSnapshotArrives();
|
||||
|
||||
expect(footer()).toBe("running");
|
||||
const session = useSessionStore.getState().sessions[SERVER_ID];
|
||||
expect(selectAgentTurnPresentation(session, AGENT_ID).isActive).toBe(false);
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("keeps the running turn's identity and timer start when the idle status arrives", async () => {
|
||||
const owner = createChat("Half");
|
||||
agentIsRunning();
|
||||
await connectAndCatchUp(owner);
|
||||
owner.setConnected(false);
|
||||
owner.setConnected(true);
|
||||
idleSnapshotArrives();
|
||||
|
||||
const session = useSessionStore.getState().sessions[SERVER_ID];
|
||||
expect(selectAgentTimelineTurnPresentation(session, AGENT_ID)).toEqual({
|
||||
isActive: true,
|
||||
isCancelling: false,
|
||||
startedAt: OPEN_TURN.startedAt,
|
||||
turnId: "turn-1",
|
||||
});
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("an agent still running on return keeps its running footer", async () => {
|
||||
const owner = createChat("Half");
|
||||
agentIsRunning();
|
||||
await connectAndCatchUp(owner);
|
||||
owner.setConnected(false);
|
||||
owner.setConnected(true);
|
||||
agentIsRunning();
|
||||
|
||||
expect(footer()).toBe("running");
|
||||
owner.applyTimelineResponse(catchUpPage({ replyThroughSeq: 6, reply: " more" }));
|
||||
expect(footer()).toBe("running");
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("applies a snapshot at once when the chat is current", async () => {
|
||||
const owner = createChat("Done.");
|
||||
agentIsRunning();
|
||||
await connectAndCatchUp(owner);
|
||||
|
||||
idleSnapshotArrives();
|
||||
|
||||
expect(footer()).toBe("settled");
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("a reconnect that finds nothing changed never leaves the settled footer", async () => {
|
||||
const owner = createChat("Done.");
|
||||
await connectAndCatchUp(owner);
|
||||
const recorder = recordCommits();
|
||||
|
||||
owner.setConnected(false);
|
||||
owner.setConnected(true);
|
||||
idleSnapshotArrives();
|
||||
owner.applyTimelineResponse(catchUpPage({ replyThroughSeq: null }));
|
||||
recorder.stop();
|
||||
|
||||
expect(recorder.commits.map((commit) => commit.footer)).not.toContain("running");
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("catch-up failure gives the footer the true status", async () => {
|
||||
let failNext = false;
|
||||
const owner = createChat("Half", async () => {
|
||||
if (failNext) throw new Error("catch-up failed");
|
||||
return { hasNewer: false, endCursor: null };
|
||||
});
|
||||
agentIsRunning();
|
||||
await connectAndCatchUp(owner);
|
||||
failNext = true;
|
||||
owner.setConnected(false);
|
||||
owner.setConnected(true);
|
||||
idleSnapshotArrives();
|
||||
|
||||
await expect.poll(() => owner.getAgentTimelineStatus(AGENT_ID)).toBe("error");
|
||||
|
||||
expect(footer()).toBe("settled");
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("a catch-up that completes without a page still releases the footer", async () => {
|
||||
const owner = createChat("Half");
|
||||
agentIsRunning();
|
||||
await connectAndCatchUp(owner);
|
||||
owner.setConnected(false);
|
||||
idleSnapshotArrives();
|
||||
expect(footer()).toBe("running");
|
||||
|
||||
owner.setConnected(true);
|
||||
|
||||
await expect.poll(() => owner.getAgentTimelineStatus(AGENT_ID)).toBe("ready");
|
||||
expect(footer()).toBe("settled");
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("a chat closed while running and reopened after it finished keeps the running footer", async () => {
|
||||
const owner = createChat("Half");
|
||||
agentIsRunning();
|
||||
await connectAndCatchUp(owner);
|
||||
owner.replaceVisibleAgentIds("test", []);
|
||||
owner.replaceOpenTabAgentIds([]);
|
||||
idleSnapshotArrives();
|
||||
|
||||
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
|
||||
expect(footer()).toBe("running");
|
||||
|
||||
owner.applyTimelineResponse(catchUpPage({ replyThroughSeq: 8, reply: "Done." }));
|
||||
expect(footer()).toBe("settled");
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ host: "that reports turn ids", olderTurnId: "turn-1", newerTurnId: "turn-2" },
|
||||
{ host: "without turn ids", olderTurnId: null, newerTurnId: null },
|
||||
])("on a host $host, a newer turn replaces the held one so the timer follows it", async (ids) => {
|
||||
const newerTurn = { turnId: ids.newerTurnId, startedAt: new Date("2026-08-26T09:59:30.000Z") };
|
||||
const owner = createChat("Half");
|
||||
setTurn({
|
||||
type: "snapshot",
|
||||
activeTurn: { turnId: ids.olderTurnId, startedAt: OPEN_TURN.startedAt },
|
||||
});
|
||||
await connectAndCatchUp(owner);
|
||||
owner.replaceVisibleAgentIds("test", []);
|
||||
owner.replaceOpenTabAgentIds([]);
|
||||
idleSnapshotArrives();
|
||||
|
||||
setTurn({ type: "snapshot", activeTurn: newerTurn });
|
||||
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
|
||||
idleSnapshotArrives();
|
||||
|
||||
expect(footer()).toBe("running");
|
||||
expect(timerStart()).toEqual(newerTurn.startedAt);
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
function paintChat(unsettledTurn: CachedTimeline["unsettledTurn"]): ViewedTimelineOwner {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
idleSnapshotArrives();
|
||||
return createOwner({
|
||||
readTimeline: async () => ({
|
||||
agentId: AGENT_ID,
|
||||
items: [item("reply-1", "Half", 4)],
|
||||
range: { epoch: "epoch-1", startSeq: 1, endSeq: 4 },
|
||||
hasOlder: false,
|
||||
...(unsettledTurn ? { unsettledTurn } : {}),
|
||||
}),
|
||||
commitTimeline: () => undefined,
|
||||
});
|
||||
}
|
||||
|
||||
it("first paint from cache: shows the running footer in the first frame when the cached messages were from an open turn", async () => {
|
||||
const owner = paintChat({ turnId: "turn-1", startedAt: OPEN_TURN.startedAt });
|
||||
const recorder = recordCommits();
|
||||
|
||||
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
|
||||
await expect.poll(() => replies()).toEqual(["Half"]);
|
||||
recorder.stop();
|
||||
|
||||
expect(recorder.commits.find((commit) => commit.replies.length > 0)?.footer).toBe("running");
|
||||
expect(timerStart()).toEqual(OPEN_TURN.startedAt);
|
||||
owner.applyTimelineResponse(catchUpPage({ replyThroughSeq: 8, reply: "Done." }));
|
||||
expect(footer()).toBe("settled");
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
async function subscriptionRefusedDuringCacheRead(): Promise<{
|
||||
owner: ViewedTimelineOwner;
|
||||
finishCacheRead(): void;
|
||||
}> {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
idleSnapshotArrives();
|
||||
let resolveRead: (timeline: CachedTimeline) => void = () => undefined;
|
||||
const owner = createOwner(
|
||||
{
|
||||
readTimeline: () => new Promise((resolve) => (resolveRead = resolve)),
|
||||
commitTimeline: () => undefined,
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
() => ({
|
||||
ready: Promise.reject(new Error("subscription refused")),
|
||||
release: async () => undefined,
|
||||
}),
|
||||
);
|
||||
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
|
||||
owner.setConnected(true);
|
||||
await expect.poll(() => owner.getAgentTimelineStatus(AGENT_ID)).toBe("error");
|
||||
return {
|
||||
owner,
|
||||
finishCacheRead: () =>
|
||||
resolveRead({
|
||||
agentId: AGENT_ID,
|
||||
items: [item("reply-1", "Half", 4)],
|
||||
range: { epoch: "epoch-1", startSeq: 1, endSeq: 4 },
|
||||
hasOlder: false,
|
||||
unsettledTurn: { turnId: "turn-1", startedAt: OPEN_TURN.startedAt },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
it("first paint from cache: a catch-up that failed during the cache read keeps the true status", async () => {
|
||||
const { owner, finishCacheRead } = await subscriptionRefusedDuringCacheRead();
|
||||
|
||||
finishCacheRead();
|
||||
await expect.poll(() => replies()).toEqual(["Half"]);
|
||||
|
||||
expect(owner.getAgentTimelineStatus(AGENT_ID)).toBe("error");
|
||||
expect(footer()).toBe("settled");
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("first paint from cache: a catch-up owed again during the cache read keeps the running footer", async () => {
|
||||
const { owner, finishCacheRead } = await subscriptionRefusedDuringCacheRead();
|
||||
owner.setConnected(false);
|
||||
|
||||
finishCacheRead();
|
||||
await expect.poll(() => replies()).toEqual(["Half"]);
|
||||
|
||||
expect(footer()).toBe("running");
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("first paint from cache: shows the settled footer at once when the cached messages were from a finished turn", async () => {
|
||||
const owner = paintChat(undefined);
|
||||
|
||||
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
|
||||
await expect.poll(() => replies()).toEqual(["Half"]);
|
||||
|
||||
expect(footer()).toBe("settled");
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("first paint from cache: records the open turn with the messages and drops it once the turn ends", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
applySynced(AGENT_ID, 8);
|
||||
agentIsRunning();
|
||||
const commits: CachedTimeline[] = [];
|
||||
const owner = createOwner(
|
||||
{
|
||||
readTimeline: async () => undefined,
|
||||
commitTimeline: (_serverId, _agentId, timeline) => commits.push(timeline),
|
||||
},
|
||||
(_agentId, transitions) => {
|
||||
for (const transition of transitions) setTurn(transition);
|
||||
},
|
||||
);
|
||||
owner.applyStreamEvent(AGENT_ID, {
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: "Working" },
|
||||
} as AgentStreamEventPayload,
|
||||
seq: 9,
|
||||
epoch: "epoch-1",
|
||||
timestamp: new Date("2026-08-26T10:00:01.000Z"),
|
||||
});
|
||||
owner.flushStreamAgent(AGENT_ID);
|
||||
expect(commits.at(-1)?.unsettledTurn).toEqual({
|
||||
turnId: "turn-1",
|
||||
startedAt: expect.any(Date),
|
||||
});
|
||||
|
||||
owner.applyStreamEvent(AGENT_ID, {
|
||||
event: { type: "turn_completed", provider: "codex", turnId: "turn-1" },
|
||||
seq: undefined,
|
||||
epoch: undefined,
|
||||
timestamp: new Date("2026-08-26T10:00:02.000Z"),
|
||||
});
|
||||
|
||||
expect(commits.at(-1)?.unsettledTurn).toBeUndefined();
|
||||
owner.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("create handoff lifetime", () => {
|
||||
const DRAFT_ID = "draft-1";
|
||||
|
||||
|
||||
@@ -54,6 +54,16 @@ export interface TurnPresentation {
|
||||
turnId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn a chat's messages were captured during, kept until the chat has caught up past it.
|
||||
* While it is held and the agent reads idle, the messages on screen may be older than the turn's
|
||||
* end, so the chat cannot claim the turn is finished yet.
|
||||
*/
|
||||
export interface UnsettledTurn {
|
||||
turnId: string | null;
|
||||
startedAt: Date | null;
|
||||
}
|
||||
|
||||
export function resolveTurnPresentation(
|
||||
liveness: TurnLiveness,
|
||||
hasActiveSubmission: boolean,
|
||||
@@ -74,6 +84,24 @@ export function resolveTurnPresentation(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What a chat shows for its turn. It differs from the true status only while the agent has
|
||||
* finished and the chat's messages have not caught up: the chat keeps showing the running turn, so
|
||||
* the completed footer can land with the messages it belongs to.
|
||||
*/
|
||||
export function resolveTimelineTurnPresentation(
|
||||
turn: TurnPresentation,
|
||||
unsettledTurn: UnsettledTurn | undefined,
|
||||
): TurnPresentation {
|
||||
if (turn.isActive || !unsettledTurn) return turn;
|
||||
return {
|
||||
isActive: true,
|
||||
isCancelling: false,
|
||||
startedAt: unsettledTurn.startedAt,
|
||||
turnId: unsettledTurn.turnId,
|
||||
};
|
||||
}
|
||||
|
||||
function closeTurn(current: TurnLiveness, turnId: string | null): TurnLiveness {
|
||||
// COMPAT(agentTurnIdentity): added in v0.2.6, remove after 2027-01-31 once daemon floor >= v0.2.6.
|
||||
const targetsDifferentIdentifiedTurn =
|
||||
|
||||
@@ -142,6 +142,8 @@ class TimelineWorld {
|
||||
status: this.sync.getAgentTimelineStatus(agentId),
|
||||
});
|
||||
},
|
||||
onCatchUpOwed: () => undefined,
|
||||
onCatchUpSettled: () => undefined,
|
||||
prepare: async (agentId) => {
|
||||
this.cacheRequests.push(agentId);
|
||||
this.cacheRequestWaiters.shift()?.(agentId);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import type { CachedTimeline } from "@/runtime/replica-cache";
|
||||
import {
|
||||
selectAgentTimelineState,
|
||||
selectAgentUnsettledTurn,
|
||||
useSessionStore,
|
||||
type AgentTimelineCursorState,
|
||||
type AgentTimelineState,
|
||||
@@ -40,6 +41,7 @@ async function prepareCachedTimeline(input: {
|
||||
agentId: string;
|
||||
storage: TimelineReplicaStorage;
|
||||
prepareAgent: (agentId: string) => Promise<void>;
|
||||
isTurnSettled: () => boolean;
|
||||
}): Promise<CachedTimeline | undefined> {
|
||||
const before = useSessionStore.getState().sessions[input.serverId];
|
||||
const beforeTimeline = selectAgentTimelineState(before, input.agentId);
|
||||
@@ -87,6 +89,7 @@ async function prepareCachedTimeline(input: {
|
||||
newer: false,
|
||||
synchronized: false,
|
||||
acknowledgedClientMessageIds: replacement.acknowledgedClientMessageIds,
|
||||
unsettledTurn: input.isTurnSettled() ? undefined : stored.unsettledTurn,
|
||||
});
|
||||
return stored;
|
||||
}
|
||||
@@ -96,11 +99,16 @@ export interface TimelineReplica {
|
||||
readCursor(agentId: string): { epoch: string; endSeq: number } | undefined;
|
||||
readRange(agentId: string): AgentTimelineCursorState | undefined;
|
||||
timelineUpdated(agentId: string): void;
|
||||
catchUpOwed(agentId: string): void;
|
||||
catchUpSettled(agentId: string): void;
|
||||
}
|
||||
|
||||
class TimelineReplicaOwner implements TimelineReplica {
|
||||
private readonly cachedRanges = new Map<string, AgentTimelineCursorState>();
|
||||
private readonly preparations = new Map<string, Promise<void>>();
|
||||
// A cache read in flight still carries the turn as it was saved. Once the catch-up has
|
||||
// settled, that turn must not come back with the cached messages.
|
||||
private readonly settledDuringPreparation = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly serverId: string,
|
||||
@@ -123,6 +131,7 @@ class TimelineReplicaOwner implements TimelineReplica {
|
||||
const preparation = this.load(agentId).finally(() => {
|
||||
if (this.preparations.get(agentId) === preparation) {
|
||||
this.preparations.delete(agentId);
|
||||
this.settledDuringPreparation.delete(agentId);
|
||||
}
|
||||
});
|
||||
this.preparations.set(agentId, preparation);
|
||||
@@ -135,6 +144,7 @@ class TimelineReplicaOwner implements TimelineReplica {
|
||||
agentId,
|
||||
storage: this.storage,
|
||||
prepareAgent: this.prepareAgent,
|
||||
isTurnSettled: () => this.settledDuringPreparation.has(agentId),
|
||||
});
|
||||
if (!stored) return;
|
||||
if (stored.range) {
|
||||
@@ -161,8 +171,21 @@ class TimelineReplicaOwner implements TimelineReplica {
|
||||
items: [...timeline.items, ...(session?.agentStreamHead.get(agentId) ?? [])],
|
||||
range: timeline.status === "synced" ? timeline.range : null,
|
||||
hasOlder: timeline.status === "synced" && timeline.older === "available",
|
||||
unsettledTurn: selectAgentUnsettledTurn(session, agentId),
|
||||
});
|
||||
}
|
||||
|
||||
catchUpOwed(agentId: string): void {
|
||||
this.settledDuringPreparation.delete(agentId);
|
||||
const store = useSessionStore.getState();
|
||||
const openTurn = selectAgentUnsettledTurn(store.sessions[this.serverId], agentId);
|
||||
if (openTurn) store.setAgentTimelineUnsettledTurn(this.serverId, agentId, openTurn);
|
||||
}
|
||||
|
||||
catchUpSettled(agentId: string): void {
|
||||
if (this.preparations.has(agentId)) this.settledDuringPreparation.add(agentId);
|
||||
useSessionStore.getState().setAgentTimelineUnsettledTurn(this.serverId, agentId, null);
|
||||
}
|
||||
}
|
||||
|
||||
export function createTimelineReplica(input: {
|
||||
@@ -240,6 +263,7 @@ function commitProcessedTimeline(input: {
|
||||
});
|
||||
}
|
||||
store.markAgentHistorySynchronized(serverId, agentId);
|
||||
if (synchronized) store.setAgentTimelineUnsettledTurn(serverId, agentId, null);
|
||||
}
|
||||
|
||||
function finalizeProcessedTimeline(input: {
|
||||
@@ -330,6 +354,10 @@ export interface ViewedTimelineSyncPorts {
|
||||
* demanded set. Disconnect and backgrounding keep the obligation and do not report here.
|
||||
*/
|
||||
onCatchUpEnded(agentId: string): void;
|
||||
/** The chat stopped receiving every row, so a catch-up is owed before it is current again. */
|
||||
onCatchUpOwed(agentId: string): void;
|
||||
/** The owed catch-up reached current or failed. The chat can take the true turn status. */
|
||||
onCatchUpSettled(agentId: string): void;
|
||||
reportError(error: unknown): void;
|
||||
}
|
||||
|
||||
@@ -358,7 +386,7 @@ export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
|
||||
|
||||
export type ViewedTimelineOwnerPorts = Omit<
|
||||
ViewedTimelineSyncPorts,
|
||||
"prepare" | "replaceDemandedAgentIds" | "onCatchUpEnded"
|
||||
"prepare" | "replaceDemandedAgentIds" | "onCatchUpEnded" | "onCatchUpOwed" | "onCatchUpSettled"
|
||||
>;
|
||||
|
||||
export interface ViewedTimelineOwner extends ViewedTimelineSync {
|
||||
@@ -396,6 +424,8 @@ export function createViewedTimelineOwner(input: {
|
||||
replaceDemandedAgentIds: input.replaceDemandedAgentIds,
|
||||
onCatchUpEnded: (agentId) =>
|
||||
useCreateFlowStore.getState().clearByAgent({ serverId: input.serverId, agentId }),
|
||||
onCatchUpOwed: (agentId) => input.replica.catchUpOwed(agentId),
|
||||
onCatchUpSettled: (agentId) => input.replica.catchUpSettled(agentId),
|
||||
});
|
||||
const streamQueue = createSessionAgentStreamReducerQueue({
|
||||
serverId: input.serverId,
|
||||
@@ -423,6 +453,7 @@ export function createViewedTimelineOwner(input: {
|
||||
if (turnLiveness.length === 0) return;
|
||||
streamQueue.flushAgent(agentId);
|
||||
input.applyTurnLiveness(agentId, turnLiveness);
|
||||
input.replica.timelineUpdated(agentId);
|
||||
},
|
||||
flushStreamAgent(agentId) {
|
||||
streamQueue.flushAgent(agentId);
|
||||
@@ -537,6 +568,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
const wasPending = visibilityCatchUpPending.delete(agentId);
|
||||
const hadError = visibilityCatchUpErrors.delete(agentId);
|
||||
const wasRetrying = manualRetries.delete(agentId);
|
||||
ports.onCatchUpSettled(agentId);
|
||||
ports.onCatchUpEnded(agentId);
|
||||
if (wasPending || hadError || wasRetrying) notifyListeners();
|
||||
};
|
||||
@@ -545,6 +577,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
let changed = false;
|
||||
for (const agentId of agentIds) {
|
||||
ports.onCatchUpSettled(agentId);
|
||||
if (manualRetries.delete(agentId)) changed = true;
|
||||
if (visibilityCatchUpPending.delete(agentId)) changed = true;
|
||||
if (visibilityCatchUpErrors.get(agentId) !== message) {
|
||||
@@ -555,6 +588,11 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
if (changed) notifyListeners();
|
||||
};
|
||||
|
||||
const markCatchUpOwed = (agentId: string) => {
|
||||
visibilityCatchUpPending.add(agentId);
|
||||
ports.onCatchUpOwed(agentId);
|
||||
};
|
||||
|
||||
const cancelCatchUp = (agentId: string) => {
|
||||
catchUpGenerations.set(agentId, (catchUpGenerations.get(agentId) ?? 0) + 1);
|
||||
catchUps.delete(agentId);
|
||||
@@ -749,7 +787,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
if (options.resetCatchUpStatus) {
|
||||
for (const agentId of nextDesired) {
|
||||
if (!visibilityCatchUpPending.has(agentId)) {
|
||||
visibilityCatchUpPending.add(agentId);
|
||||
markCatchUpOwed(agentId);
|
||||
statusChanged = true;
|
||||
}
|
||||
if (visibilityCatchUpErrors.delete(agentId)) statusChanged = true;
|
||||
@@ -768,12 +806,13 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
visibilityCatchUpPending.delete(agentId);
|
||||
visibilityCatchUpErrors.delete(agentId);
|
||||
manualRetries.delete(agentId);
|
||||
ports.onCatchUpOwed(agentId);
|
||||
released.push(agentId);
|
||||
}
|
||||
}
|
||||
for (const agentId of nextDesired) {
|
||||
if (!desired.includes(agentId)) {
|
||||
visibilityCatchUpPending.add(agentId);
|
||||
markCatchUpOwed(agentId);
|
||||
visibilityCatchUpErrors.delete(agentId);
|
||||
manualRetries.delete(agentId);
|
||||
ensureCacheLoaded(agentId);
|
||||
@@ -830,8 +869,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
}
|
||||
for (const agentId of visibleAgentIds()) {
|
||||
if (visibilityCatchUpErrors.has(agentId)) continue;
|
||||
visibilityCatchUpPending.add(agentId);
|
||||
visibilityCatchUpErrors.delete(agentId);
|
||||
markCatchUpOwed(agentId);
|
||||
startCatchUp(agentId, { supersede: true });
|
||||
}
|
||||
notifyListeners();
|
||||
|
||||
Reference in New Issue
Block a user