mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
5 Commits
main
...
integratio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aed3eb490e | ||
|
|
58f8737a01 | ||
|
|
ceb06116f2 | ||
|
|
44236ea474 | ||
|
|
24934b4bbe |
@@ -37,6 +37,12 @@ Initialization timeouts guard lack of catch-up progress, not the full multi-page
|
||||
|
||||
The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward.
|
||||
|
||||
Reaching the history-start threshold loads one older page and preserves the visible content anchor.
|
||||
Cursor progress does not trigger another page. The user must leave and return to the threshold unless
|
||||
the anchored page still leaves the viewport at history start, as with short or compacted content; in
|
||||
that case pagination continues as one loading operation until the page fills the viewport or history
|
||||
is exhausted.
|
||||
|
||||
## Durable item anchors
|
||||
|
||||
Provider message IDs are not guaranteed for every displayed item. Paseo-generated system errors are one example. Rendered item indices are not durable either because pagination and projection can merge source rows.
|
||||
@@ -61,6 +67,22 @@ recomposition while the runtime still owns the same directory snapshot and timel
|
||||
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
|
||||
session and host-scoped setup state together.
|
||||
|
||||
The durable replica cache is a display cache, not a synchronization checkpoint. Its timeline record
|
||||
contains only the focused `agentId` and a truncated item tail. It never persists a cursor, epoch,
|
||||
older-history availability, authority status, or sync generation because those facts would describe
|
||||
the complete source dataset rather than the truncated display dataset.
|
||||
|
||||
Restoring that cache produces a painted timeline: the items may render immediately, but the first
|
||||
daemon timeline request is still `tail`. A successful tail response atomically establishes canonical
|
||||
items, range, and older-history availability. Live rows received between cache paint and that tail
|
||||
response stay in the separate live head, do not advance a cursor or trigger gap recovery, and are
|
||||
reconciled with the authoritative tail and subsequent catch-up.
|
||||
|
||||
Every daemon-derived live item carries its timeline epoch and sequence position. Bootstrap
|
||||
replacement keeps only positioned rows newer than the page it installs, while unresolved local
|
||||
submissions remain governed by the submission registry. This prevents a page from duplicating rows
|
||||
it already covers without making the display replica authoritative.
|
||||
|
||||
## Selective and legacy delivery
|
||||
|
||||
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
|
||||
@@ -87,14 +109,42 @@ its completion advances `seqEnd`, followed by a merged assistant message. The ap
|
||||
remaining page through the existing stream reducer. It must not append full projected text to a
|
||||
live prefix.
|
||||
|
||||
Optimistic user prompts occupy stable timeline slots. Catch-up never extracts, delays, or reinserts
|
||||
them. A canonical user row replaces its matching slot in place; an unmatched prompt stays exactly
|
||||
where the user submitted it. Other canonical rows are applied after the already-present timeline
|
||||
instead of relocating visible user messages around newly fetched history.
|
||||
Every path that sends a message to an agent — composer send, dictation accept-and-send, queued
|
||||
send-now, and the automatic queue drain in `HostRuntime` — goes through
|
||||
`dispatchComposerAgentMessage` with a submission writer. There is no second transport for the same
|
||||
product action: calling `client.sendAgentMessage` directly skips the submitted row and the pending
|
||||
footer, and permanently drops attachments because the daemon does not echo them back.
|
||||
|
||||
A submitted prompt is one `UserMessageItem` row. That row is the authoritative local presentation:
|
||||
its stable identity, text, timestamp, images, and attachments do not change when the provider
|
||||
acknowledges it. Submission lifecycle is a separate record keyed by agent, not another row shape or
|
||||
a property inferred from message identity. The transaction registry holds every unresolved send and
|
||||
records RPC acceptance and provider acknowledgement independently. Provider acknowledgement exists
|
||||
solely so a later transport error cannot roll back a prompt already observed canonically.
|
||||
|
||||
The daemon's accepted response already waits for the correlated run start, but its response and the
|
||||
directory update reach client state separately. An accepted transaction remains active until the
|
||||
directory observes that run or canonical ingestion acknowledges the prompt, bridging those ordered
|
||||
authorities without inspecting timeline snapshots. Either signal clears only an RPC-accepted
|
||||
transaction, regardless of which arrived first; it cannot settle a fresh send.
|
||||
Overlapping sends settle independently rather than collapsing to one newest pending message.
|
||||
|
||||
Canonical submitted user rows carry the provider's `messageId` and Paseo's optional
|
||||
`clientMessageId`. Clients reconcile optimistic prompts by `clientMessageId`. Content matching is
|
||||
limited to the dated compatibility path for daemon timelines created before that field existed.
|
||||
`clientMessageId`. The user-message producer reconciles them by `clientMessageId`, adds provider
|
||||
identity to the existing row, and keeps the local presentation in its original timeline slot.
|
||||
Content matching is limited to the dated compatibility path for daemon timelines created before
|
||||
that field existed. Canonical ingestion may match only an explicit unreconciled local candidate;
|
||||
the draft-create handoff is the one boundary that also permits the legacy canonical twin to have
|
||||
arrived first. Generic reducers and consumers do not reimplement message identity matching.
|
||||
|
||||
Ordinary bootstrap, same-epoch reset, and catch-up replacement preserve unmatched locally submitted
|
||||
rows because a provider may never echo them. A known epoch change or rewind replaces history and
|
||||
drops acknowledged local rows omitted by the new canonical epoch; every transaction not yet
|
||||
acknowledged by the provider, and no other local row, crosses that destructive boundary.
|
||||
|
||||
Canonical replacement owns both timeline lanes. A matching local row keeps its presentation ID and
|
||||
payload while taking the canonical row's ordered position. If a live assistant head is the
|
||||
canonical assistant prefix, it stays in the head lane. No row may be returned in both lanes.
|
||||
|
||||
## Relevant code
|
||||
|
||||
|
||||
274
packages/app/e2e/agent-consecutive-turns.spec.ts
Normal file
274
packages/app/e2e/agent-consecutive-turns.spec.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test as baseTest } from "./fixtures";
|
||||
import { expectAgentIdle } from "./helpers/agent-stream";
|
||||
import { expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||
import { clickNewChat, gotoWorkspace } from "./helpers/launcher";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import {
|
||||
openAgentTimeline,
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
scrollTimelineUntilOlderHistoryIsReachable,
|
||||
seedLongMockAgentTimeline,
|
||||
} from "./helpers/timeline-pagination";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "./helpers/mock-agent";
|
||||
|
||||
interface TurnFrame {
|
||||
scrollHeight: number;
|
||||
workingVisible: boolean;
|
||||
promptVisible: boolean;
|
||||
promptTop: number | null;
|
||||
assistantVisible: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__consecutiveTurnFrames?: { active: boolean; frames: TurnFrame[] };
|
||||
}
|
||||
}
|
||||
|
||||
const test = baseTest.extend<{ tenSecondAgent: MockAgentWorkspace }>({
|
||||
tenSecondAgent: async ({ page: _page }, provide) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "consecutive-ten-second-turns-",
|
||||
title: "Consecutive ten-second turns",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
await provide(agent);
|
||||
await agent.cleanup();
|
||||
},
|
||||
});
|
||||
|
||||
async function waitForTurnToComplete(page: Page, completedTurnCount: number): Promise<void> {
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(
|
||||
completedTurnCount + 1,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expectAgentIdle(page);
|
||||
}
|
||||
|
||||
async function recordTurnFrames(page: Page, prompt: string): Promise<void> {
|
||||
await page.evaluate((promptText) => {
|
||||
const isVisible = (candidate: Element) => candidate.getBoundingClientRect().height > 0;
|
||||
// The timeline does not exist yet when a new agent is being created, so it is
|
||||
// resolved per frame rather than captured up front.
|
||||
const findScroll = () =>
|
||||
Array.from(document.querySelectorAll('[data-testid="agent-chat-scroll"]')).find(isVisible);
|
||||
const state = { active: true, frames: [] as TurnFrame[] };
|
||||
const sample = () => {
|
||||
const scroll = findScroll();
|
||||
// Re-find the row every frame: reconciliation replaces the node, and a stale
|
||||
// reference would keep reporting the position of something already detached.
|
||||
const promptRow = Array.from(document.querySelectorAll('[data-testid="user-message"]')).find(
|
||||
(candidate) => isVisible(candidate) && candidate.textContent?.includes(promptText),
|
||||
);
|
||||
state.frames.push({
|
||||
scrollHeight: scroll ? scroll.scrollHeight : 0,
|
||||
workingVisible: Array.from(
|
||||
document.querySelectorAll('[data-testid="turn-working-indicator"]'),
|
||||
).some(isVisible),
|
||||
promptVisible: Boolean(promptRow),
|
||||
promptTop: promptRow ? promptRow.getBoundingClientRect().top : null,
|
||||
assistantVisible: Array.from(
|
||||
document.querySelectorAll('[data-testid="assistant-message"]'),
|
||||
).some(isVisible),
|
||||
});
|
||||
if (state.active) requestAnimationFrame(sample);
|
||||
};
|
||||
window.__consecutiveTurnFrames = state;
|
||||
sample();
|
||||
}, prompt);
|
||||
}
|
||||
|
||||
async function expectUninterruptedTurn(page: Page): Promise<void> {
|
||||
const result = await page.evaluate(() => {
|
||||
const state = window.__consecutiveTurnFrames;
|
||||
if (!state) throw new Error("Turn frames were never recorded");
|
||||
state.active = false;
|
||||
const start = state.frames.findIndex((frame) => frame.promptVisible);
|
||||
// The turn runs from the footer first appearing to it going away for good. Bounding
|
||||
// it that way keeps this measuring one thing: the footer leaving the layout and
|
||||
// coming back. How long the footer takes to appear after the row is a separate
|
||||
// question and does not belong in this assertion.
|
||||
const firstWorking = state.frames.findIndex((frame) => frame.workingVisible);
|
||||
const lastWorking = state.frames.reduce(
|
||||
(latest, frame, index) => (frame.workingVisible ? index : latest),
|
||||
-1,
|
||||
);
|
||||
const turn = state.frames.slice(Math.max(start, firstWorking), lastWorking + 1);
|
||||
const blinkAt = turn.findIndex((frame) => !frame.workingVisible);
|
||||
let largestHeightDrop = 0;
|
||||
let largestHeightDropAt = -1;
|
||||
for (let index = 1; index < turn.length; index += 1) {
|
||||
const drop = turn[index - 1].scrollHeight - turn[index].scrollHeight;
|
||||
if (drop > largestHeightDrop) {
|
||||
largestHeightDrop = drop;
|
||||
largestHeightDropAt = index;
|
||||
}
|
||||
}
|
||||
return {
|
||||
start,
|
||||
blinkAt,
|
||||
largestHeightDrop,
|
||||
turnFrameCount: turn.length,
|
||||
largestHeightDropAt,
|
||||
heightsAroundDrop: turn
|
||||
.slice(Math.max(0, largestHeightDropAt - 6), largestHeightDropAt + 6)
|
||||
.map((frame) => frame.scrollHeight)
|
||||
.join(","),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.start, "the submitted prompt never appeared").toBeGreaterThan(-1);
|
||||
expect(
|
||||
result.blinkAt,
|
||||
`the working indicator blinked off at frame ${result.blinkAt} of ${result.turnFrameCount} and came back`,
|
||||
).toBe(-1);
|
||||
expect(
|
||||
result.largestHeightDrop,
|
||||
`the timeline lost ${result.largestHeightDrop}px of content mid-turn (first heights: ${result.heightsAroundDrop})`,
|
||||
).toBe(0);
|
||||
}
|
||||
|
||||
async function expectSubmittedPromptNeverMovedDown(page: Page): Promise<void> {
|
||||
const result = await page.evaluate(() => {
|
||||
const state = window.__consecutiveTurnFrames;
|
||||
if (!state) throw new Error("Turn frames were never recorded");
|
||||
state.active = false;
|
||||
const start = state.frames.findIndex((frame) => frame.promptVisible);
|
||||
const tops = state.frames
|
||||
.slice(start)
|
||||
.map((frame) => frame.promptTop)
|
||||
.filter((top): top is number => top !== null);
|
||||
let largestDownwardShift = 0;
|
||||
let largestDownwardShiftAt = -1;
|
||||
for (let index = 1; index < tops.length; index += 1) {
|
||||
const shift = tops[index] - tops[index - 1];
|
||||
if (shift > largestDownwardShift) {
|
||||
largestDownwardShift = shift;
|
||||
largestDownwardShiftAt = index;
|
||||
}
|
||||
}
|
||||
return {
|
||||
start,
|
||||
largestDownwardShift,
|
||||
topsAroundShift: tops
|
||||
.slice(Math.max(0, largestDownwardShiftAt - 4), largestDownwardShiftAt + 4)
|
||||
.map((top) => Math.round(top))
|
||||
.join(","),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.start, "the submitted prompt never appeared").toBeGreaterThan(-1);
|
||||
expect(
|
||||
Math.round(result.largestDownwardShift),
|
||||
`the submitted prompt was pushed down ${Math.round(result.largestDownwardShift)}px (tops: ${result.topsAroundShift})`,
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
test("keeps the first submitted prompt in place once the assistant starts streaming", async ({
|
||||
page,
|
||||
tenSecondAgent,
|
||||
}) => {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: tenSecondAgent.workspaceId,
|
||||
agentId: tenSecondAgent.agentId,
|
||||
});
|
||||
|
||||
const prompt = "First prompt.";
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(prompt);
|
||||
await recordTurnFrames(page, prompt);
|
||||
await composer.press("Enter");
|
||||
|
||||
await expect(page.getByText(prompt, { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
|
||||
await waitForTurnToComplete(page, 0);
|
||||
await expectSubmittedPromptNeverMovedDown(page);
|
||||
});
|
||||
|
||||
// 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" } } }),
|
||||
);
|
||||
});
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const prompt = "First prompt of a new agent.";
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(prompt);
|
||||
await recordTurnFrames(page, prompt);
|
||||
await composer.press("Enter");
|
||||
|
||||
await expect(
|
||||
page.getByTestId("user-message").filter({ hasText: prompt }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("assistant-message").first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("turn-working-elapsed")).toHaveCount(1);
|
||||
await expectSubmittedPromptNeverMovedDown(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the turn footer and timeline height stable while a second turn runs", async ({
|
||||
page,
|
||||
tenSecondAgent,
|
||||
}) => {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: tenSecondAgent.workspaceId,
|
||||
agentId: tenSecondAgent.agentId,
|
||||
});
|
||||
|
||||
await submitMessage(page, "First prompt.");
|
||||
await waitForTurnToComplete(page, 0);
|
||||
|
||||
const secondPrompt = "Second prompt.";
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(secondPrompt);
|
||||
await recordTurnFrames(page, secondPrompt);
|
||||
await composer.press("Enter");
|
||||
|
||||
await expect(page.getByText(secondPrompt, { exact: true })).toBeVisible();
|
||||
await waitForTurnToComplete(page, 1);
|
||||
await expectUninterruptedTurn(page);
|
||||
});
|
||||
|
||||
test("keeps the timeline height stable when submitting into a long timeline", async ({ page }) => {
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 40 });
|
||||
try {
|
||||
await openAgentTimeline(page, agent);
|
||||
await expect(page.getByText(agent.newestPrompt, { exact: true })).toBeVisible();
|
||||
// A real long chat is fully loaded and virtualized before the user types again.
|
||||
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
|
||||
await scrollTimelineToNewestLoadedEdge(page);
|
||||
|
||||
const prompt = "Stability probe into a long timeline.";
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(prompt);
|
||||
await recordTurnFrames(page, prompt);
|
||||
await composer.press("Enter");
|
||||
|
||||
await expect(page.getByText(prompt, { exact: true }).last()).toBeVisible();
|
||||
// Observe the whole turn: wait for it to start before waiting for it to end.
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible();
|
||||
await expectAgentIdle(page, 60_000);
|
||||
await expectUninterruptedTurn(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
714
packages/app/e2e/agent-message-submission.spec.ts
Normal file
714
packages/app/e2e/agent-message-submission.spec.ts
Normal file
@@ -0,0 +1,714 @@
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test as baseTest } from "./fixtures";
|
||||
import { awaitToolCall, expectAgentIdle } from "./helpers/agent-stream";
|
||||
import { gateNextAgentMessage } from "./helpers/agent-message-gate";
|
||||
import {
|
||||
attachImageFromMenu,
|
||||
expectComposerDraft,
|
||||
expectComposerEditable,
|
||||
expectAttachmentPill,
|
||||
expectComposerVisible,
|
||||
fillComposerDraft,
|
||||
sendDraftToQueue,
|
||||
startRunningMockAgent,
|
||||
} from "./helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
import { selectModel } from "./helpers/app";
|
||||
import {
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
scrollTimelineUntilOlderHistoryIsReachable,
|
||||
} from "./helpers/timeline-pagination";
|
||||
import {
|
||||
appendSettledTimelineTurns,
|
||||
createSettledMockAgent,
|
||||
createSmallAssistantPng,
|
||||
emitSettledAssistantImage,
|
||||
expectAssistantImageRendered,
|
||||
} from "./helpers/assistant-images";
|
||||
|
||||
const IMAGE = {
|
||||
name: "message-submission.png",
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
),
|
||||
};
|
||||
|
||||
interface SubmissionScenario {
|
||||
existingPrompt: string;
|
||||
}
|
||||
|
||||
interface DraftCreateScenario {
|
||||
workspaceId: string;
|
||||
agentCreatedDelay: Awaited<ReturnType<typeof delayBrowserAgentCreatedStatus>>;
|
||||
}
|
||||
|
||||
interface RejectionScenario {
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
interface UnrelatedRunningScenario {
|
||||
gate: Awaited<ReturnType<typeof gateNextAgentMessage>>;
|
||||
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>;
|
||||
}
|
||||
|
||||
const test = baseTest.extend<{
|
||||
submissionScenario: SubmissionScenario;
|
||||
draftCreateScenario: DraftCreateScenario;
|
||||
rejectionScenario: RejectionScenario;
|
||||
unrelatedRunningScenario: UnrelatedRunningScenario;
|
||||
}>({
|
||||
submissionScenario: async ({ page }, provide, testInfo) => {
|
||||
await page.setViewportSize({ width: 1280, height: 960 });
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: `message-submission-layout-${testInfo.workerIndex}-`,
|
||||
});
|
||||
try {
|
||||
const agent = await createSettledMockAgent(workspace, "Message submission layout regression");
|
||||
await appendSettledTimelineTurns(workspace.client, agent, 80);
|
||||
const image = await createSmallAssistantPng(workspace, {
|
||||
alt: "Existing timeline image",
|
||||
fileName: "existing-timeline.png",
|
||||
});
|
||||
await emitSettledAssistantImage(workspace.client, agent, image);
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.id });
|
||||
await expectComposerVisible(page);
|
||||
await expectAgentIdle(page);
|
||||
const oldestPrompt = "image-history-turn-0: emit 1 coalesced agent stream updates";
|
||||
await scrollTimelineUntilOlderHistoryIsReachable(page, oldestPrompt);
|
||||
await scrollTimelineToNewestLoadedEdge(page);
|
||||
await expectAssistantImageRendered(page, image);
|
||||
await provide({
|
||||
existingPrompt:
|
||||
"Emit settled assistant image Markdown: ",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
},
|
||||
draftCreateScenario: async ({ page }, provide, testInfo) => {
|
||||
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: `message-create-handoff-${testInfo.workerIndex}-`,
|
||||
});
|
||||
await provide({ workspaceId: workspace.workspaceId, agentCreatedDelay });
|
||||
agentCreatedDelay.release();
|
||||
await workspace.cleanup();
|
||||
},
|
||||
rejectionScenario: async ({ page }, provide, testInfo) => {
|
||||
const errorMessage = "Requested mock prompt rejection";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: `message-rejection-${testInfo.workerIndex}-`,
|
||||
title: "Message rejection regression",
|
||||
model: "ten-second-stream",
|
||||
featureValues: { mockPromptRejections: 1 },
|
||||
});
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
await expectComposerVisible(page);
|
||||
await expectAgentIdle(page);
|
||||
await provide({ errorMessage });
|
||||
await agent.cleanup();
|
||||
},
|
||||
unrelatedRunningScenario: async ({ page }, provide, testInfo) => {
|
||||
const gate = await gateNextAgentMessage(page);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: `unrelated-running-${testInfo.workerIndex}-`,
|
||||
title: "Unrelated running transition",
|
||||
model: "one-minute-stream",
|
||||
});
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
await expectComposerVisible(page);
|
||||
await expectAgentIdle(page);
|
||||
await provide({ gate, agent });
|
||||
await agent.cleanup();
|
||||
},
|
||||
});
|
||||
|
||||
async function submitMessageWithImage(page: Page, prompt: string): Promise<Locator> {
|
||||
await attachImageFromMenu(page, IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(prompt);
|
||||
await composer.press("Enter");
|
||||
const nextFrame = await composer.evaluate(
|
||||
(composerElement, submittedPrompt) =>
|
||||
new Promise<{
|
||||
rowPresent: boolean;
|
||||
workingPresent: boolean;
|
||||
composerValue: string | null;
|
||||
attachmentPresent: boolean;
|
||||
}>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
const rows = Array.from(document.querySelectorAll('[data-testid="user-message"]'));
|
||||
const composerInput = composerElement as HTMLInputElement | HTMLTextAreaElement;
|
||||
resolve({
|
||||
rowPresent: rows.some((row) => row.textContent?.includes(submittedPrompt)),
|
||||
workingPresent: Boolean(
|
||||
document.querySelector('[data-testid="turn-working-indicator"]'),
|
||||
),
|
||||
composerValue: composerInput.value,
|
||||
attachmentPresent: Boolean(
|
||||
document.querySelector('[data-testid="composer-image-attachment-pill"]'),
|
||||
),
|
||||
});
|
||||
});
|
||||
}),
|
||||
prompt,
|
||||
);
|
||||
expect(nextFrame).toEqual({
|
||||
rowPresent: true,
|
||||
workingPresent: true,
|
||||
composerValue: "",
|
||||
attachmentPresent: false,
|
||||
});
|
||||
return page.getByTestId("user-message").filter({ hasText: prompt }).last();
|
||||
}
|
||||
|
||||
async function submitImageOnlyMessage(page: Page): Promise<Locator> {
|
||||
await attachImageFromMenu(page, IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await page.getByRole("textbox", { name: "Message agent..." }).first().press("Enter");
|
||||
const userMessage = page.getByTestId("user-message").last();
|
||||
await expect(userMessage).toBeVisible();
|
||||
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
|
||||
return userMessage;
|
||||
}
|
||||
|
||||
async function expectPendingSubmission(page: Page, userMessage: Locator): Promise<void> {
|
||||
await expect(userMessage).toBeVisible();
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toHaveValue("");
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
|
||||
await expect(userMessage.getByTestId("user-message-timestamp")).toBeAttached();
|
||||
await expect(userMessage.getByTestId("user-message-trailing-row")).toHaveCSS("opacity", "0");
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "true");
|
||||
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
|
||||
}
|
||||
|
||||
async function beginTimelineRowStabilityCheck(
|
||||
page: Page,
|
||||
timelineRow: Locator,
|
||||
): Promise<() => Promise<void>> {
|
||||
await expect(timelineRow).toBeVisible();
|
||||
await timelineRow.evaluate((initialRow) => {
|
||||
const state = {
|
||||
active: true,
|
||||
lastTop: initialRow.getBoundingClientRect().top,
|
||||
largestDownwardShift: 0,
|
||||
sawMissing: false,
|
||||
};
|
||||
const windowState = window as unknown as Record<string, unknown>;
|
||||
windowState.__messageSubmissionTimelineRowStability = state;
|
||||
const checkFrame = () => {
|
||||
if (!state.active) return;
|
||||
if (!initialRow.isConnected) {
|
||||
state.sawMissing = true;
|
||||
requestAnimationFrame(checkFrame);
|
||||
return;
|
||||
}
|
||||
const top = initialRow.getBoundingClientRect().top;
|
||||
state.largestDownwardShift = Math.max(state.largestDownwardShift, top - state.lastTop);
|
||||
state.lastTop = top;
|
||||
requestAnimationFrame(checkFrame);
|
||||
};
|
||||
requestAnimationFrame(checkFrame);
|
||||
});
|
||||
|
||||
return async () => {
|
||||
const result = await page.evaluate(() => {
|
||||
const windowState = window as unknown as Record<string, unknown>;
|
||||
const state = windowState.__messageSubmissionTimelineRowStability as
|
||||
| { active: boolean; largestDownwardShift: number; sawMissing: boolean }
|
||||
| undefined;
|
||||
if (!state) throw new Error("Timeline-row stability check was not started");
|
||||
state.active = false;
|
||||
delete windowState.__messageSubmissionTimelineRowStability;
|
||||
return { largestDownwardShift: state.largestDownwardShift, sawMissing: state.sawMissing };
|
||||
});
|
||||
expect(result.sawMissing).toBe(false);
|
||||
expect(result.largestDownwardShift).toBeLessThanOrEqual(2);
|
||||
};
|
||||
}
|
||||
|
||||
async function submitMessageThatWillBeRejected(page: Page, prompt: string): Promise<void> {
|
||||
await attachImageFromMenu(page, IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(prompt);
|
||||
await composer.press("Enter");
|
||||
}
|
||||
|
||||
async function expectRejectedSubmissionRestored(
|
||||
page: Page,
|
||||
input: { prompt: string; errorMessage: string },
|
||||
): Promise<void> {
|
||||
await expect(page.getByText(input.errorMessage)).toBeVisible({ timeout: 30_000 });
|
||||
await expectComposerDraft(page, input.prompt);
|
||||
await expectComposerEditable(page);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await expect(page.getByRole("button", { name: "Send message" })).toBeEnabled();
|
||||
await expect(page.getByTestId("user-message").filter({ hasText: input.prompt })).toHaveCount(0);
|
||||
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function retryRestoredSubmission(page: Page, prompt: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Message agent..." }).first().press("Enter");
|
||||
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
|
||||
await expect(userMessage).toHaveCount(1);
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
|
||||
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function queueMessage(page: Page, prompt: string): Promise<void> {
|
||||
await fillComposerDraft(page, prompt);
|
||||
await sendDraftToQueue(page);
|
||||
}
|
||||
|
||||
async function expectQueuedSendFailuresRestored(page: Page, prompts: string[]): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Send queued message now" })).toHaveCount(
|
||||
prompts.length,
|
||||
);
|
||||
for (const prompt of prompts) {
|
||||
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectFailedSubmissionRestored(page: Page, prompt: string): Promise<void> {
|
||||
await expectComposerDraft(page, prompt);
|
||||
await expectComposerEditable(page);
|
||||
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function expectInterruptedTurnOrderAfterReconnect(
|
||||
page: Page,
|
||||
testInfo: { workerIndex: number },
|
||||
): Promise<void> {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-reconnect-${testInfo.workerIndex}-`,
|
||||
title: "Submission reconnect ordering",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const prompt = "Keep this prompt before its response.";
|
||||
try {
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
await expectComposerVisible(page);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Start the turn that will be interrupted.");
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible();
|
||||
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
|
||||
await queueMessage(page, prompt);
|
||||
gate.setAgentStreamSuppressed(true);
|
||||
await page.getByRole("button", { name: "Send queued message now" }).click();
|
||||
const promptRow = page.getByTestId("user-message").filter({ hasText: prompt });
|
||||
await expect(promptRow).toBeVisible();
|
||||
await gate.waitForServerMessage("send_agent_message_response");
|
||||
await gate.drop();
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
gate.setAgentStreamSuppressed(false);
|
||||
gate.forceNextTimelineEpochReset();
|
||||
gate.restoreFresh();
|
||||
await gate.waitForServerMessage("fetch_agent_timeline_response", 2);
|
||||
const response = page.getByText("(end of synthetic stream)", { exact: true }).last();
|
||||
await expect(promptRow).toBeVisible();
|
||||
await expect(response).toBeVisible();
|
||||
await expectRenderedBefore(promptRow, response);
|
||||
} finally {
|
||||
gate.restore();
|
||||
await agent.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function expectCompletedSubmissionClearsAfterMissedRunningTransition(
|
||||
page: Page,
|
||||
testInfo: { workerIndex: number },
|
||||
): Promise<void> {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-missed-running-${testInfo.workerIndex}-`,
|
||||
title: "Submission missed running transition",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
await expectComposerVisible(page);
|
||||
await expectAgentIdle(page);
|
||||
gate.holdNextClientRequest("send_agent_message_request");
|
||||
const userMessage = await submitImageOnlyMessage(page);
|
||||
await gate.waitForHeldClientRequest();
|
||||
gate.setServerMessageSuppressed("agent_status", true);
|
||||
gate.setServerMessageSuppressed("agent_update", true);
|
||||
gate.releaseHeldClientRequest();
|
||||
await gate.waitForServerMessage("send_agent_message_response");
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false");
|
||||
await gate.drop();
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
gate.setServerMessageSuppressed("agent_status", false);
|
||||
gate.setServerMessageSuppressed("agent_update", false);
|
||||
gate.restoreFresh();
|
||||
await gate.waitForServerMessage("fetch_agent_timeline_response", 2);
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible();
|
||||
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false");
|
||||
} finally {
|
||||
gate.restore();
|
||||
await agent.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function expectProviderAcknowledgementBeforeRpcAcceptanceSettlesSubmission(
|
||||
page: Page,
|
||||
testInfo: { workerIndex: number },
|
||||
): Promise<void> {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-ack-before-rpc-${testInfo.workerIndex}-`,
|
||||
title: "Submission acknowledgement before RPC",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const prompt = "Settle this provider-acknowledged submission.";
|
||||
try {
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
await expectComposerVisible(page);
|
||||
await expectAgentIdle(page);
|
||||
gate.setServerMessageSuppressed("agent_status", true);
|
||||
gate.setServerMessageSuppressed("agent_update", true);
|
||||
gate.holdNextServerMessage("send_agent_message_response");
|
||||
const userMessage = await submitMessageWithImage(page, prompt);
|
||||
await gate.waitForHeldServerMessage();
|
||||
await gate.waitForAgentStreamItem("user_message");
|
||||
gate.releaseHeldServerMessage();
|
||||
await gate.drop();
|
||||
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false");
|
||||
} finally {
|
||||
gate.restore();
|
||||
await agent.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function expectLegacyAssistantStartsAfterInterruptedPrompt(
|
||||
page: Page,
|
||||
testInfo: { workerIndex: number },
|
||||
): Promise<void> {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-legacy-assistant-${testInfo.workerIndex}-`,
|
||||
title: "Legacy assistant interrupt boundary",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const prompt = "Start the replacement answer after this prompt.";
|
||||
try {
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
await expectComposerVisible(page);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Start the interrupted answer.");
|
||||
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
|
||||
await queueMessage(page, prompt);
|
||||
gate.setAssistantMessageIdsStripped(true);
|
||||
gate.setAgentStreamEventSuppressed("turn_canceled", true);
|
||||
await page.getByRole("button", { name: "Send queued message now" }).click();
|
||||
const promptRow = page.getByTestId("user-message").filter({ hasText: prompt });
|
||||
const replacementAnswer = page.getByText("(end of synthetic stream)", { exact: true }).last();
|
||||
await expect(promptRow).toBeVisible();
|
||||
await expect(replacementAnswer).toBeVisible({ timeout: 30_000 });
|
||||
await expectRenderedBefore(promptRow, replacementAnswer);
|
||||
} finally {
|
||||
gate.setAssistantMessageIdsStripped(false);
|
||||
gate.setAgentStreamEventSuppressed("turn_canceled", false);
|
||||
await agent.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function expectStaleCanonicalPagePreservesNewerLiveOutput(
|
||||
page: Page,
|
||||
testInfo: { workerIndex: number },
|
||||
): Promise<void> {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-stale-canonical-${testInfo.workerIndex}-`,
|
||||
title: "Stale canonical page race",
|
||||
model: "one-minute-stream",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
await expectComposerVisible(page);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "End the snapshot at a tool call.");
|
||||
await awaitToolCall(page, "read");
|
||||
await page
|
||||
.getByRole("button", { name: /stop|cancel/i })
|
||||
.first()
|
||||
.click();
|
||||
await expectAgentIdle(page);
|
||||
|
||||
gate.holdNextServerMessage("fetch_agent_timeline_response");
|
||||
gate.requestTimelineTail(agent.agentId);
|
||||
await gate.waitForHeldServerMessage();
|
||||
gate.truncateHeldTimelineAfterLast("tool_call");
|
||||
expect(gate.getHeldTimelineLastItemType()).toBe("tool_call");
|
||||
|
||||
const nextPrompt = "Stream after the stale snapshot.";
|
||||
await agent.client.sendAgentMessage(agent.agentId, nextPrompt);
|
||||
const nextPromptRow = page.getByTestId("user-message").filter({ hasText: nextPrompt });
|
||||
const liveAssistant = nextPromptRow.locator(
|
||||
'xpath=following::*[@data-testid="assistant-message"][1]',
|
||||
);
|
||||
await expect(nextPromptRow).toBeVisible();
|
||||
await expect(liveAssistant).toContainText("Cycle 1");
|
||||
gate.releaseHeldServerMessage();
|
||||
await expect(liveAssistant).toContainText("Cycle 1");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function expectCanonicalOrderWinsAcrossOverlappingClients(
|
||||
page: Page,
|
||||
testInfo: { workerIndex: number },
|
||||
): Promise<void> {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-cross-client-order-${testInfo.workerIndex}-`,
|
||||
title: "Cross-client submission order",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const localPrompt = "Send this after the other client turn.";
|
||||
const remotePrompt = "Commit this other client turn first.";
|
||||
try {
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
await expectComposerVisible(page);
|
||||
await expectAgentIdle(page);
|
||||
gate.holdNextClientRequest("send_agent_message_request");
|
||||
const localRow = await submitMessageWithImage(page, localPrompt);
|
||||
await gate.waitForHeldClientRequest();
|
||||
|
||||
await agent.client.sendAgentMessage(agent.agentId, remotePrompt);
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
const remoteRow = page.getByTestId("user-message").filter({ hasText: remotePrompt });
|
||||
await expect(remoteRow).toBeVisible();
|
||||
|
||||
const userMessageCount = gate.getAgentStreamItemCount("user_message");
|
||||
gate.releaseHeldClientRequest();
|
||||
await gate.waitForAgentStreamItem("user_message", userMessageCount + 1);
|
||||
await expect(localRow).toHaveAttribute("aria-busy", "false");
|
||||
await expect(localRow.getByRole("button", { name: "Open image attachment" })).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const localElement = await localRow.elementHandle();
|
||||
if (!localElement) return false;
|
||||
return remoteRow.evaluate(
|
||||
(remoteElement, localNode) =>
|
||||
Boolean(
|
||||
remoteElement.compareDocumentPosition(localNode) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
),
|
||||
localElement,
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
} finally {
|
||||
gate.restore();
|
||||
await agent.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function expectRenderedBefore(first: Locator, second: Locator): Promise<void> {
|
||||
const secondElement = await second.elementHandle();
|
||||
if (!secondElement) throw new Error("Expected the second timeline item to be rendered");
|
||||
expect(
|
||||
await first.evaluate(
|
||||
(firstElement, secondNode) =>
|
||||
Boolean(
|
||||
firstElement.compareDocumentPosition(secondNode) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
),
|
||||
secondElement,
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
async function openWorkspaceDraft(page: Page, workspaceId: string): Promise<void> {
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-new-agent-tab-inline").click();
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
async function expectCreatedAgentHandoff(
|
||||
page: Page,
|
||||
prompt: string,
|
||||
userMessage: Locator,
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await expect(page.getByTestId(/^workspace-tab-agent_/).first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(1);
|
||||
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
|
||||
}
|
||||
|
||||
interface DraftCreatePendingSubmission {
|
||||
prompt: string;
|
||||
userMessage: Locator;
|
||||
}
|
||||
|
||||
async function beginDraftCreateSubmission(
|
||||
page: Page,
|
||||
scenario: DraftCreateScenario,
|
||||
): Promise<DraftCreatePendingSubmission> {
|
||||
await openWorkspaceDraft(page, scenario.workspaceId);
|
||||
await selectModel(page, "one-minute-stream");
|
||||
const prompt = "Keep this row through create handoff.";
|
||||
const userMessage = await submitMessageWithImage(page, prompt);
|
||||
await scenario.agentCreatedDelay.waitForCreateRequest();
|
||||
await scenario.agentCreatedDelay.waitForDelayedCreatedStatus();
|
||||
await expectPendingSubmission(page, userMessage);
|
||||
return { prompt, userMessage };
|
||||
}
|
||||
|
||||
async function completeDraftCreateSubmission(
|
||||
page: Page,
|
||||
scenario: DraftCreateScenario,
|
||||
pending: DraftCreatePendingSubmission,
|
||||
): Promise<void> {
|
||||
scenario.agentCreatedDelay.release();
|
||||
await expectCreatedAgentHandoff(page, pending.prompt, pending.userMessage);
|
||||
}
|
||||
|
||||
test.describe("Agent message submission", () => {
|
||||
test("keeps layout stable when submitting to an agent with existing history", async ({
|
||||
page,
|
||||
submissionScenario,
|
||||
}) => {
|
||||
const prompt = "Hold this submission.";
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(prompt);
|
||||
await expect(composer).toHaveValue(prompt);
|
||||
await expect(
|
||||
page.getByTestId("user-message").filter({ hasText: submissionScenario.existingPrompt }),
|
||||
).toBeVisible();
|
||||
const finishTimelineRowStabilityCheck = await beginTimelineRowStabilityCheck(
|
||||
page,
|
||||
page.getByTestId("assistant-message").last(),
|
||||
);
|
||||
const assistantMessageCount = await page.getByTestId("assistant-message").count();
|
||||
const toolCallCount = await page.getByTestId("tool-call-badge").count();
|
||||
await composer.press("Enter");
|
||||
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt }).last();
|
||||
await expect(userMessage).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => page.getByTestId("assistant-message").count())
|
||||
.toBeGreaterThan(assistantMessageCount);
|
||||
await expect
|
||||
.poll(async () => page.getByTestId("tool-call-badge").count())
|
||||
.toBeGreaterThan(toolCallCount);
|
||||
await finishTimelineRowStabilityCheck();
|
||||
});
|
||||
|
||||
test("keeps the submitted row stable through draft create handoff", async ({
|
||||
page,
|
||||
draftCreateScenario,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
|
||||
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
|
||||
});
|
||||
|
||||
test("restores a rejected submission and accepts its retry", async ({
|
||||
page,
|
||||
rejectionScenario,
|
||||
}) => {
|
||||
const prompt = "Restore this rejected submission.";
|
||||
await submitMessageThatWillBeRejected(page, prompt);
|
||||
await expectRejectedSubmissionRestored(page, { prompt, ...rejectionScenario });
|
||||
await retryRestoredSubmission(page, prompt);
|
||||
});
|
||||
|
||||
test("restores overlapping queued sends when their connection fails", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const gate = await gateNextAgentMessage(page);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: `overlapping-queued-send-${testInfo.workerIndex}-`,
|
||||
model: "one-minute-stream",
|
||||
prompt: "Keep the agent running while messages queue.",
|
||||
});
|
||||
const prompts = ["Restore the first queued send.", "Restore the second queued send."];
|
||||
try {
|
||||
await queueMessage(page, prompts[0]);
|
||||
await queueMessage(page, prompts[1]);
|
||||
await page.getByRole("button", { name: "Send queued message now" }).first().click();
|
||||
await gate.waitForRequest(1);
|
||||
await page.getByRole("button", { name: "Send queued message now" }).first().click();
|
||||
await gate.waitForRequest(2);
|
||||
await gate.disconnect();
|
||||
await expectQueuedSendFailuresRestored(page, prompts);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not accept a failed submission from an unrelated running turn", async ({
|
||||
page,
|
||||
unrelatedRunningScenario,
|
||||
}) => {
|
||||
const prompt = "Restore this unsent prompt.";
|
||||
await submitMessageThatWillBeRejected(page, prompt);
|
||||
await unrelatedRunningScenario.gate.waitForRequest();
|
||||
await unrelatedRunningScenario.agent.client.sendAgentMessage(
|
||||
unrelatedRunningScenario.agent.agentId,
|
||||
"Start an unrelated turn.",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("user-message").filter({ hasText: "Start an unrelated turn." }),
|
||||
).toBeVisible();
|
||||
await unrelatedRunningScenario.gate.disconnect();
|
||||
await expectFailedSubmissionRestored(page, prompt);
|
||||
});
|
||||
|
||||
test("keeps a submitted prompt before its response when canonical history arrives", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(90_000);
|
||||
await expectInterruptedTurnOrderAfterReconnect(page, testInfo);
|
||||
});
|
||||
|
||||
test("clears an attachment-only submission when canonical history arrives after a missed running transition", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(90_000);
|
||||
await expectCompletedSubmissionClearsAfterMissedRunningTransition(page, testInfo);
|
||||
});
|
||||
|
||||
test("clears a provider acknowledgement that arrives before RPC acceptance", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(90_000);
|
||||
await expectProviderAcknowledgementBeforeRpcAcceptanceSettlesSubmission(page, testInfo);
|
||||
});
|
||||
|
||||
test("keeps an old-daemon replacement answer after its interrupted prompt", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(90_000);
|
||||
await expectLegacyAssistantStartsAfterInterruptedPrompt(page, testInfo);
|
||||
});
|
||||
|
||||
test("preserves newer live output when a stale canonical page arrives", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(90_000);
|
||||
await expectStaleCanonicalPagePreservesNewerLiveOutput(page, testInfo);
|
||||
});
|
||||
|
||||
test("uses canonical order when another client turn overtakes a held submission", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await expectCanonicalOrderWinsAcrossOverlappingClients(page, testInfo);
|
||||
});
|
||||
});
|
||||
85
packages/app/e2e/agent-tab-image-stability.spec.ts
Normal file
85
packages/app/e2e/agent-tab-image-stability.spec.ts
Normal file
@@ -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,172 @@
|
||||
import { test } from "./fixtures";
|
||||
import { expect, test } from "./fixtures";
|
||||
import {
|
||||
expectLoadedTimelineDoesNotScroll,
|
||||
expectSameOlderHistoryLoadingOperation,
|
||||
expectTimelineAtHistoryStart,
|
||||
expectTimelinePromptCentered,
|
||||
expectTimelinePromptNotMounted,
|
||||
expectTimelinePromptPositionPreserved,
|
||||
expectTimelinePromptVisible,
|
||||
holdNextOlderTimelinePage,
|
||||
holdBootstrapTimelinePage,
|
||||
holdDaemonHydration,
|
||||
holdOlderHistoryPages,
|
||||
makeLoadedTimelineFitViewport,
|
||||
openAgentTimeline,
|
||||
rememberOlderHistoryLoadingOperation,
|
||||
rememberTimelineViewport,
|
||||
rememberTimelinePromptPosition,
|
||||
reloadAgentTimelineFromPersistedReplica,
|
||||
scrollTimelineUntilOlderHistoryIsReachable,
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
seedLongMockAgentTimeline,
|
||||
sendLiveTurnBeforeHydration,
|
||||
expectTimelineViewportAnchoredAfterPrepend,
|
||||
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("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("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 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 +174,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();
|
||||
}
|
||||
|
||||
86
packages/app/e2e/helpers/agent-message-gate.ts
Normal file
86
packages/app/e2e/helpers/agent-message-gate.ts
Normal file
@@ -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." });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,21 @@ export interface AgentTimelineResponseGate {
|
||||
waitForDelayedResponse(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface OlderTimelinePagesGate {
|
||||
getRequestCount(): 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 +60,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 +168,142 @@ 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;
|
||||
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 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,
|
||||
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 +325,7 @@ export async function delayAgentOlderTimelineResponse(
|
||||
!delayedResponseSeen &&
|
||||
sessionMessage?.type === "fetch_agent_timeline_response" &&
|
||||
payload?.agentId === agentId &&
|
||||
payload.direction === "before"
|
||||
payload.direction === direction
|
||||
) {
|
||||
delayedResponseSeen = true;
|
||||
resolveDelayedResponse?.();
|
||||
|
||||
343
packages/app/e2e/helpers/assistant-images.ts
Normal file
343
packages/app/e2e/helpers/assistant-images.ts
Normal file
@@ -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: `,
|
||||
);
|
||||
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;
|
||||
}
|
||||
@@ -16,6 +16,20 @@ interface ClientRequest {
|
||||
type?: unknown;
|
||||
subscribe?: unknown;
|
||||
page?: { cursor?: unknown };
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
function readSessionMessage(message: string | Buffer): ClientRequest | null {
|
||||
if (typeof message !== "string") return null;
|
||||
try {
|
||||
const envelope = JSON.parse(message) as {
|
||||
type?: unknown;
|
||||
message?: ClientRequest;
|
||||
};
|
||||
return envelope.message ?? envelope;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readClientRequest(message: string | Buffer): ClientRequest | null {
|
||||
@@ -38,15 +52,111 @@ 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 stripMessageSubmissionDisposition(
|
||||
message: string | Buffer,
|
||||
enabled: boolean,
|
||||
messageType: unknown,
|
||||
): string | Buffer {
|
||||
if (!enabled || messageType !== "send_agent_message_response" || 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;
|
||||
delete payload.outOfBand;
|
||||
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 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>;
|
||||
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 eventType = readAgentStreamEventType(input.message);
|
||||
return Boolean(eventType && input.agentStreamEventTypes.has(eventType));
|
||||
}
|
||||
|
||||
export async function installDaemonWebSocketGate(page: Page) {
|
||||
let acceptingConnections = true;
|
||||
let reconnectWithFreshClient = false;
|
||||
let suppressAgentStream = false;
|
||||
let forceTimelineEpochReset = false;
|
||||
let stripAssistantMessageIds = false;
|
||||
let stripSubmissionDisposition = false;
|
||||
let heldClientRequestType: string | null = null;
|
||||
let heldClientRequest: { server: WebSocketRoute; message: string | Buffer } | null = null;
|
||||
let resolveHeldClientRequest: (() => void) | null = null;
|
||||
let heldServerMessageType: string | null = null;
|
||||
let heldServerMessage: { browser: WebSocketRoute; message: string | Buffer } | null = null;
|
||||
let resolveHeldServerMessage: (() => void) | null = null;
|
||||
const suppressedServerMessageTypes = new Set<string>();
|
||||
const suppressedAgentStreamEventTypes = 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 serverMessageCounts = new Map<string, number>();
|
||||
const agentStreamItemCounts = new Map<string, number>();
|
||||
const serverMessageWaiters = new Set<() => void>();
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
if (!acceptingConnections) {
|
||||
@@ -56,9 +166,20 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
|
||||
activeSockets.add(ws);
|
||||
const server = ws.connectToServer();
|
||||
latestServer = server;
|
||||
|
||||
ws.onMessage((message) => {
|
||||
if (!acceptingConnections) return;
|
||||
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);
|
||||
if (typeof request?.type === "string") {
|
||||
clientRequestCounts.set(request.type, (clientRequestCounts.get(request.type) ?? 0) + 1);
|
||||
@@ -69,6 +190,12 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
directoryStarts.total[directory] += 1;
|
||||
}
|
||||
}
|
||||
if (request?.type === heldClientRequestType) {
|
||||
heldClientRequest = { server, message };
|
||||
resolveHeldClientRequest?.();
|
||||
resolveHeldClientRequest = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
server.send(message);
|
||||
} catch {
|
||||
@@ -78,8 +205,55 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
|
||||
server.onMessage((message) => {
|
||||
if (!acceptingConnections) return;
|
||||
const serverMessage = readSessionMessage(message);
|
||||
let outboundMessage = stripAssistantMessageId(
|
||||
message,
|
||||
stripAssistantMessageIds,
|
||||
serverMessage?.type,
|
||||
);
|
||||
outboundMessage = stripMessageSubmissionDisposition(
|
||||
outboundMessage,
|
||||
stripSubmissionDisposition,
|
||||
serverMessage?.type,
|
||||
);
|
||||
const shouldForceTimelineReset =
|
||||
forceTimelineEpochReset && serverMessage?.type === "fetch_agent_timeline_response";
|
||||
outboundMessage = forceTimelineReset(outboundMessage, shouldForceTimelineReset);
|
||||
if (shouldForceTimelineReset) forceTimelineEpochReset = false;
|
||||
if (typeof serverMessage?.type === "string") {
|
||||
serverMessageCounts.set(
|
||||
serverMessage.type,
|
||||
(serverMessageCounts.get(serverMessage.type) ?? 0) + 1,
|
||||
);
|
||||
for (const resolve of serverMessageWaiters) resolve();
|
||||
serverMessageWaiters.clear();
|
||||
}
|
||||
const agentStreamItemType = readAgentStreamItemType(serverMessage);
|
||||
if (agentStreamItemType) {
|
||||
agentStreamItemCounts.set(
|
||||
agentStreamItemType,
|
||||
(agentStreamItemCounts.get(agentStreamItemType) ?? 0) + 1,
|
||||
);
|
||||
for (const resolve of serverMessageWaiters) resolve();
|
||||
serverMessageWaiters.clear();
|
||||
}
|
||||
if (serverMessage?.type === heldServerMessageType) {
|
||||
heldServerMessage = { browser: ws, message: outboundMessage };
|
||||
resolveHeldServerMessage?.();
|
||||
resolveHeldServerMessage = null;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
shouldSuppressServerMessage({
|
||||
message: serverMessage,
|
||||
messageTypes: suppressedServerMessageTypes,
|
||||
agentStreamEventTypes: suppressedAgentStreamEventTypes,
|
||||
suppressAgentStream,
|
||||
})
|
||||
)
|
||||
return;
|
||||
try {
|
||||
ws.send(message);
|
||||
ws.send(outboundMessage);
|
||||
} catch {
|
||||
activeSockets.delete(ws);
|
||||
}
|
||||
@@ -100,6 +274,125 @@ 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 {
|
||||
heldServerMessageType = type;
|
||||
heldServerMessage = null;
|
||||
},
|
||||
waitForHeldServerMessage(): Promise<void> {
|
||||
if (heldServerMessage) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => {
|
||||
resolveHeldServerMessage = resolve;
|
||||
});
|
||||
},
|
||||
releaseHeldServerMessage(): void {
|
||||
if (!heldServerMessage) throw new Error("No held server message to release");
|
||||
heldServerMessage.browser.send(heldServerMessage.message);
|
||||
heldServerMessage = null;
|
||||
heldServerMessageType = null;
|
||||
},
|
||||
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 {
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
},
|
||||
setAssistantMessageIdsStripped(stripped: boolean): void {
|
||||
stripAssistantMessageIds = stripped;
|
||||
},
|
||||
setMessageSubmissionDispositionStripped(stripped: boolean): void {
|
||||
stripSubmissionDisposition = stripped;
|
||||
},
|
||||
setAgentStreamSuppressed(suppressed: boolean): void {
|
||||
suppressAgentStream = suppressed;
|
||||
},
|
||||
forceNextTimelineEpochReset(): void {
|
||||
forceTimelineEpochReset = true;
|
||||
},
|
||||
getDirectoryRequestStartCounts(): DirectoryRequestStartCounts {
|
||||
return {
|
||||
subscribed: { ...directoryStarts.subscribed },
|
||||
@@ -110,5 +403,18 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
getClientRequestCount(type: string): number {
|
||||
return clientRequestCounts.get(type) ?? 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));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,20 +1,52 @@
|
||||
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 {
|
||||
firstOlderPagePrompt: string;
|
||||
initialTailOldestPrompt: string;
|
||||
oldestPrompt: string;
|
||||
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;
|
||||
}
|
||||
|
||||
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>;
|
||||
releasePage(pageNumber: number): void;
|
||||
}
|
||||
|
||||
function promptForTurn(index: number): string {
|
||||
return `${PROMPT_PREFIX}-${index}: emit 1 coalesced agent stream updates`;
|
||||
@@ -36,11 +68,42 @@ export async function seedLongMockAgentTimeline(
|
||||
|
||||
return {
|
||||
...agent,
|
||||
firstOlderPagePrompt: promptForTurn(Math.max(0, options.turns - 40)),
|
||||
initialTailOldestPrompt: promptForTurn(Math.max(0, options.turns - 20)),
|
||||
oldestPrompt: promptForTurn(0),
|
||||
newestPrompt: promptForTurn(options.turns - 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: LongTimelineAgent): Promise<void> {
|
||||
await page.goto(buildAgentRoute(agent.workspaceId, agent.agentId));
|
||||
await page.waitForURL(
|
||||
@@ -50,15 +113,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 +160,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 +202,106 @@ export async function holdNextOlderTimelinePage(
|
||||
};
|
||||
}
|
||||
|
||||
export async function holdOlderHistoryPages(
|
||||
page: Page,
|
||||
agent: LongTimelineAgent,
|
||||
): 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);
|
||||
},
|
||||
releasePage(pageNumber) {
|
||||
gate.releasePage(pageNumber);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function rememberTimelineViewport(page: Page): Promise<TimelineViewportSnapshot> {
|
||||
return readTimelineViewport(page);
|
||||
}
|
||||
|
||||
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 +321,95 @@ 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 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);
|
||||
}
|
||||
|
||||
52
packages/app/e2e/out-of-band-command.codex.real.spec.ts
Normal file
52
packages/app/e2e/out-of-band-command.codex.real.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
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";
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
test("settles the submitted row when an older daemon omits submission disposition", async ({
|
||||
page,
|
||||
}) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
gate.setMessageSubmissionDispositionStripped(true);
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-codex-command-compat-")));
|
||||
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 {
|
||||
gate.restore();
|
||||
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");
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
abandonHistoryStartPaginationRequest,
|
||||
createHistoryStartPaginationState,
|
||||
evaluateHistoryStartPagination,
|
||||
isHistoryStartLoadingOperation,
|
||||
rearmHistoryStartPagination,
|
||||
settleHistoryStartPagination,
|
||||
type HistoryStartPaginationInput,
|
||||
} from "./history-start-pagination";
|
||||
|
||||
const visibleHistoryStart = {
|
||||
const visibleHistoryStart: HistoryStartPaginationInput = {
|
||||
distanceFromHistoryStart: 0,
|
||||
hasOlderHistory: true,
|
||||
isLoadingOlderHistory: false,
|
||||
@@ -14,47 +18,140 @@ const visibleHistoryStart = {
|
||||
};
|
||||
|
||||
describe("history start pagination", () => {
|
||||
it("loads once for each authoritative history cursor", () => {
|
||||
const initial = createHistoryStartPaginationState();
|
||||
const first = evaluateHistoryStartPagination(initial, visibleHistoryStart);
|
||||
const duplicate = evaluateHistoryStartPagination(first.state, visibleHistoryStart);
|
||||
const nextPage = evaluateHistoryStartPagination(first.state, {
|
||||
it("waits for anchored page geometry before authorizing another page", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const inFlight = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
isLoadingOlderHistory: true,
|
||||
});
|
||||
const pageApplied = evaluateHistoryStartPagination(inFlight.state, {
|
||||
...visibleHistoryStart,
|
||||
isLoadingOlderHistory: true,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
|
||||
expect([first.shouldLoad, inFlight.shouldLoad, pageApplied.shouldLoad]).toEqual([
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
expect(pageApplied.state).toEqual({ status: "settling", loadedProgressKey: "epoch-1:10" });
|
||||
});
|
||||
|
||||
it("loads one page each time anchored geometry leaves and returns to history start", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const pageApplied = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
const settledAway = settleHistoryStartPagination(pageApplied.state, {
|
||||
...visibleHistoryStart,
|
||||
distanceFromHistoryStart: 300,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
const returned = evaluateHistoryStartPagination(settledAway.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
|
||||
expect([first.shouldLoad, duplicate.shouldLoad, nextPage.shouldLoad]).toEqual([
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
]);
|
||||
expect([
|
||||
first.shouldLoad,
|
||||
pageApplied.shouldLoad,
|
||||
settledAway.shouldLoad,
|
||||
returned.shouldLoad,
|
||||
]).toEqual([true, false, false, true]);
|
||||
});
|
||||
|
||||
it("allows the same revision again after the user leaves the history edge", () => {
|
||||
it("continues the same loading operation when anchored geometry remains at history start", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const away = evaluateHistoryStartPagination(first.state, {
|
||||
const pageApplied = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
distanceFromHistoryStart: 200,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
const continued = settleHistoryStartPagination(pageApplied.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
const returned = evaluateHistoryStartPagination(away.state, visibleHistoryStart);
|
||||
|
||||
expect([first.shouldLoad, away.shouldLoad, returned.shouldLoad]).toEqual([true, false, true]);
|
||||
expect(continued.shouldLoad).toBe(true);
|
||||
expect(isHistoryStartLoadingOperation(first.state)).toBe(true);
|
||||
expect(isHistoryStartLoadingOperation(pageApplied.state)).toBe(true);
|
||||
expect(isHistoryStartLoadingOperation(continued.state)).toBe(true);
|
||||
});
|
||||
|
||||
it("re-arms the same cursor when the user makes another upward edge gesture", () => {
|
||||
it("latches a request that finishes without cursor progress", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const inFlight = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
isLoadingOlderHistory: true,
|
||||
});
|
||||
const finished = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart);
|
||||
const duplicate = evaluateHistoryStartPagination(finished.state, visibleHistoryStart);
|
||||
const away = evaluateHistoryStartPagination(duplicate.state, {
|
||||
...visibleHistoryStart,
|
||||
distanceFromHistoryStart: 300,
|
||||
});
|
||||
|
||||
expect(finished.state).toEqual({ status: "latched" });
|
||||
expect([finished.shouldLoad, duplicate.shouldLoad, away.shouldLoad]).toEqual([
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
expect(away.state).toEqual({ status: "ready" });
|
||||
});
|
||||
|
||||
it("allows another user attempt after a request finishes without progress", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const inFlight = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
isLoadingOlderHistory: true,
|
||||
});
|
||||
const failed = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart);
|
||||
const retried = evaluateHistoryStartPagination(
|
||||
rearmHistoryStartPagination(first.state),
|
||||
rearmHistoryStartPagination(failed.state),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
|
||||
expect([first.shouldLoad, retried.shouldLoad]).toEqual([true, true]);
|
||||
expect(failed.state).toEqual({ status: "latched" });
|
||||
expect(retried.shouldLoad).toBe(true);
|
||||
});
|
||||
|
||||
it("latches an attempt that becomes invalid before entering flight", () => {
|
||||
const requested = evaluateHistoryStartPagination(
|
||||
createHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
|
||||
expect(abandonHistoryStartPaginationRequest(requested.state, "epoch-1:20")).toEqual({
|
||||
status: "latched",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mistake repeated edge observations for a finished request", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const repeated = evaluateHistoryStartPagination(first.state, visibleHistoryStart);
|
||||
|
||||
expect(repeated.state).toBe(first.state);
|
||||
expect(repeated.shouldLoad).toBe(false);
|
||||
});
|
||||
|
||||
it("waits while history loading is unavailable or already active", () => {
|
||||
|
||||
@@ -1,33 +1,91 @@
|
||||
export const HISTORY_START_THRESHOLD_PX = 96;
|
||||
|
||||
export interface HistoryStartPaginationState {
|
||||
requestedProgressKey: string | null;
|
||||
export type HistoryStartPaginationState =
|
||||
| { status: "ready" }
|
||||
| { status: "loading"; requestedProgressKey: string; requestObserved: boolean }
|
||||
| { status: "settling"; loadedProgressKey: string }
|
||||
| { status: "latched" };
|
||||
|
||||
export interface HistoryStartPaginationInput {
|
||||
distanceFromHistoryStart: number;
|
||||
hasOlderHistory: boolean;
|
||||
isLoadingOlderHistory: boolean;
|
||||
isReady: boolean;
|
||||
progressKey: string | null;
|
||||
}
|
||||
|
||||
export interface HistoryStartPaginationTransition {
|
||||
state: HistoryStartPaginationState;
|
||||
shouldLoad: boolean;
|
||||
}
|
||||
|
||||
export function createHistoryStartPaginationState(): HistoryStartPaginationState {
|
||||
return { requestedProgressKey: null };
|
||||
return { status: "ready" };
|
||||
}
|
||||
|
||||
export function isHistoryStartLoadingOperation(state: HistoryStartPaginationState): boolean {
|
||||
return state.status === "loading" || state.status === "settling";
|
||||
}
|
||||
|
||||
export function rearmHistoryStartPagination(
|
||||
_state: HistoryStartPaginationState,
|
||||
state: HistoryStartPaginationState,
|
||||
): HistoryStartPaginationState {
|
||||
return createHistoryStartPaginationState();
|
||||
return state.status === "latched" ? { status: "ready" } : state;
|
||||
}
|
||||
|
||||
export function abandonHistoryStartPaginationRequest(
|
||||
state: HistoryStartPaginationState,
|
||||
requestedProgressKey: string,
|
||||
): HistoryStartPaginationState {
|
||||
if (
|
||||
state.status !== "loading" ||
|
||||
state.requestObserved ||
|
||||
state.requestedProgressKey !== requestedProgressKey
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
return { status: "latched" };
|
||||
}
|
||||
|
||||
export function evaluateHistoryStartPagination(
|
||||
state: HistoryStartPaginationState,
|
||||
input: {
|
||||
distanceFromHistoryStart: number;
|
||||
hasOlderHistory: boolean;
|
||||
isLoadingOlderHistory: boolean;
|
||||
isReady: boolean;
|
||||
progressKey: string | null;
|
||||
},
|
||||
): { state: HistoryStartPaginationState; shouldLoad: boolean } {
|
||||
if (input.distanceFromHistoryStart > HISTORY_START_THRESHOLD_PX) {
|
||||
return { state: createHistoryStartPaginationState(), shouldLoad: false };
|
||||
input: HistoryStartPaginationInput,
|
||||
): HistoryStartPaginationTransition {
|
||||
if (state.status === "loading") {
|
||||
if (input.progressKey !== null && input.progressKey !== state.requestedProgressKey) {
|
||||
return {
|
||||
state: { status: "settling", loadedProgressKey: input.progressKey },
|
||||
shouldLoad: false,
|
||||
};
|
||||
}
|
||||
if (input.isLoadingOlderHistory && !state.requestObserved) {
|
||||
return { state: { ...state, requestObserved: true }, shouldLoad: false };
|
||||
}
|
||||
if (!input.isLoadingOlderHistory && !input.hasOlderHistory) {
|
||||
return { state: { status: "latched" }, shouldLoad: false };
|
||||
}
|
||||
if (
|
||||
state.requestObserved &&
|
||||
!input.isLoadingOlderHistory &&
|
||||
input.progressKey === state.requestedProgressKey
|
||||
) {
|
||||
return { state: { status: "latched" }, shouldLoad: false };
|
||||
}
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
|
||||
if (state.status === "settling") {
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
|
||||
const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
|
||||
if (!isAtHistoryStart) {
|
||||
return state.status === "ready"
|
||||
? { state, shouldLoad: false }
|
||||
: { state: { status: "ready" }, shouldLoad: false };
|
||||
}
|
||||
if (
|
||||
state.status === "latched" ||
|
||||
!input.isReady ||
|
||||
!input.hasOlderHistory ||
|
||||
input.isLoadingOlderHistory ||
|
||||
@@ -35,11 +93,42 @@ export function evaluateHistoryStartPagination(
|
||||
) {
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
if (state.requestedProgressKey === input.progressKey) {
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
return {
|
||||
state: { requestedProgressKey: input.progressKey },
|
||||
state: {
|
||||
status: "loading",
|
||||
requestedProgressKey: input.progressKey,
|
||||
requestObserved: false,
|
||||
},
|
||||
shouldLoad: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function settleHistoryStartPagination(
|
||||
state: HistoryStartPaginationState,
|
||||
input: HistoryStartPaginationInput,
|
||||
): HistoryStartPaginationTransition {
|
||||
if (state.status !== "settling") {
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
|
||||
if (
|
||||
!isAtHistoryStart ||
|
||||
!input.isReady ||
|
||||
!input.hasOlderHistory ||
|
||||
input.isLoadingOlderHistory ||
|
||||
input.progressKey === null
|
||||
) {
|
||||
return {
|
||||
state: isAtHistoryStart ? { status: "latched" } : { status: "ready" },
|
||||
shouldLoad: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: {
|
||||
status: "loading",
|
||||
requestedProgressKey: input.progressKey,
|
||||
requestObserved: false,
|
||||
},
|
||||
shouldLoad: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createHistoryStartSettleScheduler } from "./history-start-settle-scheduler";
|
||||
|
||||
describe("history start settle scheduler", () => {
|
||||
it("does not restart its countdown when geometry keeps changing", () => {
|
||||
const frames = new Map<number, () => void>();
|
||||
let nextFrameId = 1;
|
||||
const onFrame = vi.fn();
|
||||
const onSettle = vi.fn();
|
||||
const scheduler = createHistoryStartSettleScheduler({
|
||||
settleFrames: 2,
|
||||
requestFrame(callback) {
|
||||
const id = nextFrameId;
|
||||
nextFrameId += 1;
|
||||
frames.set(id, callback);
|
||||
return id;
|
||||
},
|
||||
cancelFrame: (id) => frames.delete(id),
|
||||
isSettling: () => true,
|
||||
isLoading: () => false,
|
||||
onFrame,
|
||||
onSettle,
|
||||
});
|
||||
const runNextFrame = () => {
|
||||
const next = frames.entries().next().value as [number, () => void] | undefined;
|
||||
if (!next) {
|
||||
throw new Error("Expected a scheduled settlement frame");
|
||||
}
|
||||
frames.delete(next[0]);
|
||||
next[1]();
|
||||
};
|
||||
|
||||
scheduler.schedule();
|
||||
scheduler.schedule();
|
||||
runNextFrame();
|
||||
scheduler.schedule();
|
||||
runNextFrame();
|
||||
scheduler.schedule();
|
||||
runNextFrame();
|
||||
|
||||
expect(onSettle).toHaveBeenCalledTimes(1);
|
||||
expect(onFrame).toHaveBeenCalledTimes(3);
|
||||
expect(frames.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
export interface HistoryStartSettleScheduler {
|
||||
schedule(): void;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
export function createHistoryStartSettleScheduler(input: {
|
||||
settleFrames: number;
|
||||
requestFrame: (callback: () => void) => number;
|
||||
cancelFrame: (id: number) => void;
|
||||
isSettling: () => boolean;
|
||||
isLoading: () => boolean;
|
||||
onFrame?: () => void;
|
||||
onSettle: () => void;
|
||||
}): HistoryStartSettleScheduler {
|
||||
let frameId: number | null = null;
|
||||
let remainingFrames = 0;
|
||||
|
||||
const tick = () => {
|
||||
input.onFrame?.();
|
||||
if (!input.isSettling()) {
|
||||
frameId = null;
|
||||
remainingFrames = 0;
|
||||
return;
|
||||
}
|
||||
if (input.isLoading() || remainingFrames > 0) {
|
||||
if (!input.isLoading()) {
|
||||
remainingFrames -= 1;
|
||||
}
|
||||
frameId = input.requestFrame(tick);
|
||||
return;
|
||||
}
|
||||
frameId = null;
|
||||
input.onSettle();
|
||||
};
|
||||
|
||||
return {
|
||||
schedule() {
|
||||
if (frameId !== null) {
|
||||
return;
|
||||
}
|
||||
remainingFrames = input.settleFrames;
|
||||
frameId = input.requestFrame(tick);
|
||||
},
|
||||
cancel() {
|
||||
if (frameId !== null) {
|
||||
input.cancelFrame(frameId);
|
||||
}
|
||||
frameId = null;
|
||||
remainingFrames = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -32,10 +32,19 @@ import {
|
||||
resolveBottomAnchorTransportBehavior,
|
||||
} from "./strategy";
|
||||
import {
|
||||
abandonHistoryStartPaginationRequest,
|
||||
createHistoryStartPaginationState,
|
||||
evaluateHistoryStartPagination,
|
||||
isHistoryStartLoadingOperation,
|
||||
rearmHistoryStartPagination,
|
||||
settleHistoryStartPagination,
|
||||
type HistoryStartPaginationInput,
|
||||
type HistoryStartPaginationTransition,
|
||||
} from "./history-start-pagination";
|
||||
import {
|
||||
createHistoryStartSettleScheduler,
|
||||
type HistoryStartSettleScheduler,
|
||||
} from "./history-start-settle-scheduler";
|
||||
|
||||
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
|
||||
minIndexForVisible: 0,
|
||||
@@ -53,6 +62,8 @@ const historyStartSlotStyle: ViewStyle = {
|
||||
paddingTop: 4,
|
||||
paddingBottom: 8,
|
||||
};
|
||||
const HISTORY_START_SETTLE_FRAMES = 2;
|
||||
|
||||
interface HistoryRowDisplayVariants {
|
||||
regular?: StreamItem;
|
||||
compact?: StreamItem;
|
||||
@@ -116,7 +127,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
|
||||
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
|
||||
const historyStartReadyRef = useRef(false);
|
||||
const historyStartPaginationStateRef = useRef(createHistoryStartPaginationState());
|
||||
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
|
||||
createHistoryStartPaginationState,
|
||||
);
|
||||
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
|
||||
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
|
||||
|
||||
const historyItems = useMemo(() => {
|
||||
if (segments.historyVirtualized.length === 0) {
|
||||
@@ -145,22 +160,74 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
),
|
||||
[displayStateHistoryRows, historyRowRevision?.contentById],
|
||||
);
|
||||
const evaluateHistoryStart = useStableEvent(() => {
|
||||
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput => {
|
||||
const metrics = streamViewportMetricsRef.current;
|
||||
const hasMeasuredViewport =
|
||||
metrics.viewportMeasuredForKey === metrics.containerKey &&
|
||||
metrics.contentMeasuredForKey === metrics.containerKey;
|
||||
const result = evaluateHistoryStartPagination(historyStartPaginationStateRef.current, {
|
||||
return {
|
||||
distanceFromHistoryStart: metrics.contentHeight - metrics.viewportHeight - metrics.offsetY,
|
||||
hasOlderHistory,
|
||||
isLoadingOlderHistory,
|
||||
isReady: historyStartReadyRef.current && hasMeasuredViewport,
|
||||
progressKey: olderHistoryProgressKey,
|
||||
});
|
||||
historyStartPaginationStateRef.current = result.state;
|
||||
if (result.shouldLoad) {
|
||||
onNearHistoryStart();
|
||||
};
|
||||
});
|
||||
const applyHistoryStartPaginationTransition = useStableEvent(
|
||||
(transition: HistoryStartPaginationTransition) => {
|
||||
const previousState = historyStartPaginationStateRef.current;
|
||||
historyStartPaginationStateRef.current = transition.state;
|
||||
if (transition.state !== previousState) {
|
||||
setHistoryStartPaginationState(transition.state);
|
||||
}
|
||||
if (transition.shouldLoad) {
|
||||
const requestedProgressKey = olderHistoryProgressKey;
|
||||
if (requestedProgressKey === null) {
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
const started = await onNearHistoryStart();
|
||||
if (started === true) {
|
||||
return;
|
||||
}
|
||||
applyHistoryStartPaginationTransition({
|
||||
state: abandonHistoryStartPaginationRequest(
|
||||
historyStartPaginationStateRef.current,
|
||||
requestedProgressKey,
|
||||
),
|
||||
shouldLoad: false,
|
||||
});
|
||||
})();
|
||||
}
|
||||
},
|
||||
);
|
||||
const evaluateHistoryStart = useStableEvent(() => {
|
||||
const transition = evaluateHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
getHistoryStartPaginationInput(),
|
||||
);
|
||||
applyHistoryStartPaginationTransition(transition);
|
||||
});
|
||||
const scheduleHistoryStartSettle = useStableEvent(() => {
|
||||
let scheduler = historyStartSettleSchedulerRef.current;
|
||||
if (!scheduler) {
|
||||
scheduler = createHistoryStartSettleScheduler({
|
||||
settleFrames: HISTORY_START_SETTLE_FRAMES,
|
||||
requestFrame: requestAnimationFrame,
|
||||
cancelFrame: cancelAnimationFrame,
|
||||
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
|
||||
isLoading: () => getHistoryStartPaginationInput().isLoadingOlderHistory,
|
||||
onSettle: () => {
|
||||
const transition = settleHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
getHistoryStartPaginationInput(),
|
||||
);
|
||||
applyHistoryStartPaginationTransition(transition);
|
||||
},
|
||||
});
|
||||
historyStartSettleSchedulerRef.current = scheduler;
|
||||
}
|
||||
scheduler.schedule();
|
||||
});
|
||||
|
||||
const clearNativeViewportSettling = useCallback(() => {
|
||||
@@ -261,7 +328,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
clearNativeViewportSettling();
|
||||
setIsNativeViewportSettling(false);
|
||||
historyStartReadyRef.current = false;
|
||||
historyStartPaginationStateRef.current = createHistoryStartPaginationState();
|
||||
const initialHistoryStartState = createHistoryStartPaginationState();
|
||||
historyStartPaginationStateRef.current = initialHistoryStartState;
|
||||
setHistoryStartPaginationState(initialHistoryStartState);
|
||||
const frame = requestAnimationFrame(() => {
|
||||
historyStartReadyRef.current = true;
|
||||
evaluateHistoryStart();
|
||||
@@ -269,6 +338,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
clearPendingUserScrollEnd();
|
||||
historyStartSettleSchedulerRef.current?.cancel();
|
||||
historyStartSettleSchedulerRef.current = null;
|
||||
};
|
||||
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]);
|
||||
|
||||
@@ -369,16 +440,16 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
});
|
||||
|
||||
const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (!isLoadingOlderHistory) {
|
||||
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
);
|
||||
}
|
||||
clearPendingUserScrollEnd();
|
||||
isUserScrollActiveRef.current = true;
|
||||
scrollKeyboardDismiss.onScrollBeginDrag(event);
|
||||
bottomAnchorController.beginUserScroll();
|
||||
evaluateHistoryStart();
|
||||
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
|
||||
if (rearmed !== historyStartPaginationStateRef.current) {
|
||||
historyStartPaginationStateRef.current = rearmed;
|
||||
setHistoryStartPaginationState(rearmed);
|
||||
evaluateHistoryStart();
|
||||
}
|
||||
});
|
||||
|
||||
// Defer drag end so momentum can take ownership, but capture the terminal
|
||||
@@ -454,11 +525,23 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
contentHeight: nextContentHeight,
|
||||
});
|
||||
evaluateHistoryStart();
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
scheduleHistoryStartSettle();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
evaluateHistoryStart();
|
||||
}, [evaluateHistoryStart, hasOlderHistory, isLoadingOlderHistory, olderHistoryProgressKey]);
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
scheduleHistoryStartSettle();
|
||||
}
|
||||
}, [
|
||||
evaluateHistoryStart,
|
||||
hasOlderHistory,
|
||||
isLoadingOlderHistory,
|
||||
olderHistoryProgressKey,
|
||||
scheduleHistoryStartSettle,
|
||||
]);
|
||||
|
||||
const renderItem = useStableEvent(
|
||||
({ item, index }: ListRenderItemInfo<StreamItem>): ReactElement | null => {
|
||||
@@ -499,20 +582,21 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
]);
|
||||
|
||||
const historyFooterContent = useMemo(() => {
|
||||
if (!hasOlderHistory && !isLoadingOlderHistory) {
|
||||
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
|
||||
if (!hasOlderHistory && !isLoadingOperation) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={historyStartSlotStyle}
|
||||
testID={isLoadingOlderHistory ? "load-older-history-spinner" : undefined}
|
||||
testID={isLoadingOperation ? "load-older-history-spinner" : undefined}
|
||||
>
|
||||
{isLoadingOlderHistory ? (
|
||||
{isLoadingOperation ? (
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}, [hasOlderHistory, isLoadingOlderHistory]);
|
||||
}, [hasOlderHistory, historyStartPaginationState]);
|
||||
|
||||
// RN's FlatList strictMode keeps its internal renderItem wrapper stable when
|
||||
// data or the live header changes, preserving the row identities above.
|
||||
|
||||
@@ -130,7 +130,7 @@ describe("createWebStreamStrategy", () => {
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn(),
|
||||
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
@@ -174,7 +174,7 @@ describe("createWebStreamStrategy", () => {
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn(),
|
||||
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
@@ -230,7 +230,7 @@ describe("createWebStreamStrategy", () => {
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn(),
|
||||
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
@@ -285,76 +285,6 @@ describe("createWebStreamStrategy", () => {
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires near-history-start when the user scrolls near the top", async () => {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const onNearHistoryStart = vi.fn();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<>
|
||||
{strategy.render({
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted: [userMessage(1), userMessage(2)],
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: true,
|
||||
olderHistoryProgressKey: "epoch-1:20",
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
})}
|
||||
</>,
|
||||
);
|
||||
});
|
||||
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 64 });
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
expect(onNearHistoryStart).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
|
||||
scrollContainer?.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
expect(onNearHistoryStart).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
|
||||
});
|
||||
|
||||
expect(onNearHistoryStart).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("waits for bottom anchoring before evaluating a delayed initial tail", async () => {
|
||||
HTMLElement.prototype.scrollTo = vi.fn(function (
|
||||
this: HTMLElement,
|
||||
@@ -366,7 +296,7 @@ describe("createWebStreamStrategy", () => {
|
||||
});
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const onNearHistoryStart = vi.fn();
|
||||
const onNearHistoryStart = vi.fn().mockReturnValue(true);
|
||||
const renderInput = {
|
||||
agentId: "agent",
|
||||
boundary: {
|
||||
@@ -473,7 +403,7 @@ describe("createWebStreamStrategy", () => {
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn(),
|
||||
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
@@ -581,7 +511,7 @@ describe("createWebStreamStrategy", () => {
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn(),
|
||||
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
@@ -678,7 +608,7 @@ describe("createWebStreamStrategy", () => {
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn(),
|
||||
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
@@ -777,7 +707,7 @@ describe("createWebStreamStrategy", () => {
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest: null,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn(),
|
||||
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
|
||||
@@ -17,15 +17,30 @@ import { estimateStreamItemHeight } from "./web-virtualization";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
import { createStreamStrategy } from "./strategy";
|
||||
import {
|
||||
abandonHistoryStartPaginationRequest,
|
||||
createHistoryStartPaginationState,
|
||||
evaluateHistoryStartPagination,
|
||||
isHistoryStartLoadingOperation,
|
||||
rearmHistoryStartPagination,
|
||||
settleHistoryStartPagination,
|
||||
type HistoryStartPaginationInput,
|
||||
type HistoryStartPaginationTransition,
|
||||
} from "./history-start-pagination";
|
||||
import {
|
||||
createHistoryStartSettleScheduler,
|
||||
type HistoryStartSettleScheduler,
|
||||
} from "./history-start-settle-scheduler";
|
||||
|
||||
interface CreateWebStreamStrategyInput {
|
||||
isMobileBreakpoint: boolean;
|
||||
}
|
||||
|
||||
interface HistoryStartPrependAnchor {
|
||||
progressKey: string;
|
||||
rowId: string;
|
||||
viewportOffset: number;
|
||||
}
|
||||
|
||||
type ScrollBehaviorLike = "auto" | "smooth";
|
||||
|
||||
const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200;
|
||||
@@ -33,12 +48,22 @@ const USER_SCROLL_DELTA_EPSILON = 1;
|
||||
const BOTTOM_OVERSCROLL_TOLERANCE_PX = 2;
|
||||
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
|
||||
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
|
||||
const HISTORY_START_SETTLE_FRAMES = 2;
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
function findHistoryRowElement(contentNode: HTMLElement, rowId: string): HTMLElement | null {
|
||||
for (const element of contentNode.querySelectorAll<HTMLElement>("[data-history-row-id]")) {
|
||||
if (element.dataset.historyRowId === rowId) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const historyStartSlotStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -48,6 +73,12 @@ const historyStartSlotStyle: CSSProperties = {
|
||||
paddingBottom: 8,
|
||||
};
|
||||
|
||||
const mountedHistoryRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
function isScrollContainerNearBottom(
|
||||
scrollContainer: Pick<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">,
|
||||
thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX,
|
||||
@@ -146,7 +177,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
|
||||
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
|
||||
const historyStartReadyRef = useRef(false);
|
||||
const historyStartPaginationStateRef = useRef(createHistoryStartPaginationState());
|
||||
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
|
||||
createHistoryStartPaginationState,
|
||||
);
|
||||
const [isHistoryStartSlotReserved, setIsHistoryStartSlotReserved] = useState(hasOlderHistory);
|
||||
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
|
||||
const historyStartPrependAnchorRef = useRef<HistoryStartPrependAnchor | null>(null);
|
||||
const historyStartPrependAnchorActiveRef = useRef(false);
|
||||
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
|
||||
const shouldUseVirtualizer = segments.historyVirtualized.length > 0;
|
||||
const {
|
||||
renderHistoryVirtualizedRow,
|
||||
@@ -176,6 +214,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
});
|
||||
useEffect(() => {
|
||||
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => {
|
||||
if (historyStartPrependAnchorActiveRef.current) {
|
||||
return false;
|
||||
}
|
||||
const viewportHeight = instance.scrollRect?.height ?? 0;
|
||||
const scrollOffset = instance.scrollOffset ?? 0;
|
||||
const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight);
|
||||
@@ -187,25 +228,162 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}, [rowVirtualizer]);
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
const virtualTotalSize = rowVirtualizer.getTotalSize();
|
||||
const evaluateHistoryStart = useStableEvent(() => {
|
||||
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput | null => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
const bottomAnchorSettled =
|
||||
!followOutputRef.current || isScrollContainerNearBottom(scrollContainer);
|
||||
const result = evaluateHistoryStartPagination(historyStartPaginationStateRef.current, {
|
||||
return {
|
||||
distanceFromHistoryStart: scrollContainer.scrollTop,
|
||||
hasOlderHistory,
|
||||
isLoadingOlderHistory,
|
||||
isReady: historyStartReadyRef.current && bottomAnchorSettled,
|
||||
progressKey: olderHistoryProgressKey,
|
||||
});
|
||||
historyStartPaginationStateRef.current = result.state;
|
||||
if (result.shouldLoad) {
|
||||
onNearHistoryStart();
|
||||
}
|
||||
};
|
||||
});
|
||||
const applyHistoryStartPaginationTransition = useStableEvent(
|
||||
(transition: HistoryStartPaginationTransition) => {
|
||||
const previousState = historyStartPaginationStateRef.current;
|
||||
historyStartPaginationStateRef.current = transition.state;
|
||||
if (transition.state !== previousState) {
|
||||
setHistoryStartPaginationState(transition.state);
|
||||
}
|
||||
if (!isHistoryStartLoadingOperation(transition.state)) {
|
||||
historyStartPrependAnchorRef.current = null;
|
||||
historyStartPrependAnchorActiveRef.current = false;
|
||||
}
|
||||
if (!transition.shouldLoad || olderHistoryProgressKey === null) {
|
||||
return;
|
||||
}
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
const contentNode = contentRef.current;
|
||||
const anchorRow = segments.historyMounted.at(-1) ?? segments.historyVirtualized.at(-1);
|
||||
const anchorElement =
|
||||
contentNode && anchorRow ? findHistoryRowElement(contentNode, anchorRow.id) : null;
|
||||
if (scrollContainer && anchorRow && anchorElement) {
|
||||
historyStartPrependAnchorRef.current = {
|
||||
progressKey: olderHistoryProgressKey,
|
||||
rowId: anchorRow.id,
|
||||
viewportOffset:
|
||||
anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
|
||||
};
|
||||
} else {
|
||||
historyStartPrependAnchorRef.current = null;
|
||||
}
|
||||
historyStartPrependAnchorActiveRef.current = false;
|
||||
const requestedProgressKey = olderHistoryProgressKey;
|
||||
void (async () => {
|
||||
const started = await onNearHistoryStart();
|
||||
if (started === true) {
|
||||
return;
|
||||
}
|
||||
applyHistoryStartPaginationTransition({
|
||||
state: abandonHistoryStartPaginationRequest(
|
||||
historyStartPaginationStateRef.current,
|
||||
requestedProgressKey,
|
||||
),
|
||||
shouldLoad: false,
|
||||
});
|
||||
})();
|
||||
},
|
||||
);
|
||||
const evaluateHistoryStart = useStableEvent(() => {
|
||||
const input = getHistoryStartPaginationInput();
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
const transition = evaluateHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
input,
|
||||
);
|
||||
applyHistoryStartPaginationTransition(transition);
|
||||
});
|
||||
const rearmHistoryStartFromUserIntent = useStableEvent(() => {
|
||||
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
|
||||
if (rearmed === historyStartPaginationStateRef.current) {
|
||||
return;
|
||||
}
|
||||
historyStartPaginationStateRef.current = rearmed;
|
||||
setHistoryStartPaginationState(rearmed);
|
||||
evaluateHistoryStart();
|
||||
});
|
||||
const applyHistoryStartPrependAnchor = useStableEvent(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
const contentNode = contentRef.current;
|
||||
const anchor = historyStartPrependAnchorRef.current;
|
||||
if (
|
||||
!scrollContainer ||
|
||||
!contentNode ||
|
||||
!anchor ||
|
||||
!historyStartPrependAnchorActiveRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const anchorElement = findHistoryRowElement(contentNode, anchor.rowId);
|
||||
if (!anchorElement) {
|
||||
return;
|
||||
}
|
||||
const viewportOffset =
|
||||
anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top;
|
||||
scrollContainer.scrollTop += viewportOffset - anchor.viewportOffset;
|
||||
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
|
||||
});
|
||||
const scheduleHistoryStartPrependSettle = useStableEvent(() => {
|
||||
let scheduler = historyStartSettleSchedulerRef.current;
|
||||
if (!scheduler) {
|
||||
scheduler = createHistoryStartSettleScheduler({
|
||||
settleFrames: HISTORY_START_SETTLE_FRAMES,
|
||||
requestFrame: (callback) => window.requestAnimationFrame(callback),
|
||||
cancelFrame: (frame) => window.cancelAnimationFrame(frame),
|
||||
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
|
||||
isLoading: () => {
|
||||
const input = getHistoryStartPaginationInput();
|
||||
return (
|
||||
!input ||
|
||||
input.isLoadingOlderHistory ||
|
||||
pendingVirtualRowMeasureFramesRef.current.size > 0
|
||||
);
|
||||
},
|
||||
onFrame: applyHistoryStartPrependAnchor,
|
||||
onSettle: () => {
|
||||
const input = getHistoryStartPaginationInput();
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
historyStartPrependAnchorActiveRef.current = false;
|
||||
const transition = settleHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
input,
|
||||
);
|
||||
historyStartPrependAnchorRef.current = null;
|
||||
applyHistoryStartPaginationTransition(transition);
|
||||
},
|
||||
});
|
||||
historyStartSettleSchedulerRef.current = scheduler;
|
||||
}
|
||||
scheduler.schedule();
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const anchor = historyStartPrependAnchorRef.current;
|
||||
if (!anchor || anchor.progressKey === olderHistoryProgressKey) {
|
||||
return;
|
||||
}
|
||||
historyStartPrependAnchorActiveRef.current = true;
|
||||
evaluateHistoryStart();
|
||||
applyHistoryStartPrependAnchor();
|
||||
scheduleHistoryStartPrependSettle();
|
||||
}, [
|
||||
applyHistoryStartPrependAnchor,
|
||||
evaluateHistoryStart,
|
||||
olderHistoryProgressKey,
|
||||
scheduleHistoryStartPrependSettle,
|
||||
segments.historyMounted,
|
||||
segments.historyVirtualized,
|
||||
virtualTotalSize,
|
||||
]);
|
||||
|
||||
const measureVirtualizedRowElement = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
@@ -335,7 +513,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}, [cancelPendingStickToBottom, evaluateHistoryStart, updateScrollMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
historyStartPaginationStateRef.current = createHistoryStartPaginationState();
|
||||
const initialHistoryStartState = createHistoryStartPaginationState();
|
||||
historyStartPaginationStateRef.current = initialHistoryStartState;
|
||||
setHistoryStartPaginationState(initialHistoryStartState);
|
||||
historyStartPrependAnchorRef.current = null;
|
||||
historyStartPrependAnchorActiveRef.current = false;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
historyStartReadyRef.current = true;
|
||||
evaluateHistoryStart();
|
||||
@@ -343,6 +525,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
historyStartReadyRef.current = false;
|
||||
historyStartSettleSchedulerRef.current?.cancel();
|
||||
historyStartSettleSchedulerRef.current = null;
|
||||
};
|
||||
}, [evaluateHistoryStart, props.agentId]);
|
||||
|
||||
@@ -379,33 +563,36 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
scheduleStickToBottom,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// Following output is a layout invariant: rows, footer, and bottom offset must
|
||||
// reach the browser in the same paint.
|
||||
useLayoutEffect(() => {
|
||||
if (!followOutputRef.current) {
|
||||
return;
|
||||
}
|
||||
scheduleStickToBottom();
|
||||
cancelPendingStickToBottom();
|
||||
scrollMessagesToBottom("auto");
|
||||
}, [
|
||||
scheduleStickToBottom,
|
||||
cancelPendingStickToBottom,
|
||||
renderLiveAuxiliary,
|
||||
scrollMessagesToBottom,
|
||||
segments.historyMounted,
|
||||
segments.historyVirtualized,
|
||||
segments.liveHead,
|
||||
virtualTotalSize,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!followOutputRef.current || !shouldUseVirtualizer) {
|
||||
return;
|
||||
}
|
||||
scheduleStickToBottom();
|
||||
}, [scheduleStickToBottom, shouldUseVirtualizer, virtualTotalSize]);
|
||||
|
||||
useEffect(() => {
|
||||
updateScrollMetrics();
|
||||
evaluateHistoryStart();
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
scheduleHistoryStartPrependSettle();
|
||||
}
|
||||
}, [
|
||||
evaluateHistoryStart,
|
||||
hasOlderHistory,
|
||||
isLoadingOlderHistory,
|
||||
olderHistoryProgressKey,
|
||||
scheduleHistoryStartPrependSettle,
|
||||
segments.historyMounted.length,
|
||||
segments.historyVirtualized.length,
|
||||
segments.liveHead.length,
|
||||
@@ -423,6 +610,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
updateScrollMetrics();
|
||||
evaluateHistoryStart();
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (historyStartPrependAnchorActiveRef.current) {
|
||||
applyHistoryStartPrependAnchor();
|
||||
}
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
scheduleHistoryStartPrependSettle();
|
||||
}
|
||||
updateScrollMetrics();
|
||||
evaluateHistoryStart();
|
||||
if (!followOutputRef.current) {
|
||||
@@ -437,7 +630,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [evaluateHistoryStart, scheduleStickToBottom, updateScrollMetrics]);
|
||||
}, [
|
||||
applyHistoryStartPrependAnchor,
|
||||
evaluateHistoryStart,
|
||||
scheduleHistoryStartPrependSettle,
|
||||
scheduleStickToBottom,
|
||||
updateScrollMetrics,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
@@ -447,14 +646,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (event.deltaY < 0) {
|
||||
if (!isLoadingOlderHistory) {
|
||||
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
);
|
||||
}
|
||||
pendingUserScrollUpIntentRef.current = true;
|
||||
cancelPendingStickToBottom();
|
||||
evaluateHistoryStart();
|
||||
rearmHistoryStartFromUserIntent();
|
||||
}
|
||||
};
|
||||
const handlePointerDown = () => {
|
||||
@@ -477,14 +671,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
const previousTouchY = lastTouchClientYRef.current;
|
||||
if (previousTouchY !== null && touch.clientY > previousTouchY + 1) {
|
||||
if (!isLoadingOlderHistory) {
|
||||
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
);
|
||||
}
|
||||
pendingUserScrollUpIntentRef.current = true;
|
||||
cancelPendingStickToBottom();
|
||||
evaluateHistoryStart();
|
||||
rearmHistoryStartFromUserIntent();
|
||||
}
|
||||
lastTouchClientYRef.current = touch.clientY;
|
||||
};
|
||||
@@ -513,7 +702,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
scrollContainer.removeEventListener("touchend", handleTouchEnd);
|
||||
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
|
||||
};
|
||||
}, [cancelPendingStickToBottom, evaluateHistoryStart, handleDomScroll, isLoadingOlderHistory]);
|
||||
}, [cancelPendingStickToBottom, handleDomScroll, rearmHistoryStartFromUserIntent]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle: StreamViewportHandle = {
|
||||
@@ -580,9 +769,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
);
|
||||
const mountedHistoryRows = useMemo(() => {
|
||||
return segments.historyMounted.map((item, index) => (
|
||||
<Fragment key={item.id}>
|
||||
<div key={item.id} data-history-row-id={item.id} style={mountedHistoryRowStyle}>
|
||||
{renderHistoryMountedRow(item, index, segments.historyMounted)}
|
||||
</Fragment>
|
||||
</div>
|
||||
));
|
||||
}, [renderHistoryMountedRow, segments.historyMounted]);
|
||||
const liveHeadRows = useMemo(() => {
|
||||
@@ -594,21 +783,27 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const liveAuxiliary = useMemo(() => {
|
||||
return renderLiveAuxiliary();
|
||||
}, [renderLiveAuxiliary]);
|
||||
useEffect(() => {
|
||||
if (hasOlderHistory || isHistoryStartLoadingOperation(historyStartPaginationState)) {
|
||||
setIsHistoryStartSlotReserved(true);
|
||||
}
|
||||
}, [hasOlderHistory, historyStartPaginationState]);
|
||||
const historyStartSlot = useMemo(() => {
|
||||
if (!hasOlderHistory && !isLoadingOlderHistory) {
|
||||
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
|
||||
if (!isHistoryStartSlotReserved && !hasOlderHistory && !isLoadingOperation) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={historyStartSlotStyle}
|
||||
data-testid={isLoadingOlderHistory ? "load-older-history-spinner" : undefined}
|
||||
data-testid={isLoadingOperation ? "load-older-history-spinner" : undefined}
|
||||
>
|
||||
{isLoadingOlderHistory ? (
|
||||
{isLoadingOperation ? (
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}, [hasOlderHistory, isLoadingOlderHistory]);
|
||||
}, [hasOlderHistory, historyStartPaginationState, isHistoryStartSlotReserved]);
|
||||
const shouldRenderEmpty =
|
||||
!boundary.hasMountedHistory &&
|
||||
!boundary.hasVirtualizedHistory &&
|
||||
@@ -635,6 +830,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
data-history-row-id={item.id}
|
||||
ref={measureVirtualizedRowElement}
|
||||
style={renderVirtualRowStyle(virtualRow.start)}
|
||||
>
|
||||
|
||||
@@ -69,7 +69,7 @@ export interface StreamRenderInput {
|
||||
routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady: boolean;
|
||||
onNearBottomChange: (value: boolean) => void;
|
||||
onNearHistoryStart: () => void;
|
||||
onNearHistoryStart: () => boolean | Promise<boolean>;
|
||||
isLoadingOlderHistory: boolean;
|
||||
hasOlderHistory: boolean;
|
||||
olderHistoryProgressKey: string | null;
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
} from "@/components/message";
|
||||
import { PlanCard } from "@/components/plan-card";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { PendingMessageSubmission } from "@/composer/submission/model";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
@@ -76,6 +77,7 @@ import {
|
||||
type BottomAnchorLocalRequest,
|
||||
type BottomAnchorRouteRequest,
|
||||
} from "./bottom-anchor-controller";
|
||||
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
|
||||
import {
|
||||
AssistantFileLinkResolverProvider,
|
||||
normalizeInlinePathTarget,
|
||||
@@ -239,6 +241,7 @@ export interface AgentStreamViewProps {
|
||||
streamItems: StreamItem[];
|
||||
streamHead?: StreamItem[];
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
pendingMessageSubmissions?: readonly PendingMessageSubmission[];
|
||||
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady?: boolean;
|
||||
toast?: ToastApi | null;
|
||||
@@ -248,7 +251,7 @@ export interface AgentStreamViewProps {
|
||||
hasOlder: boolean;
|
||||
isLoadingOlder: boolean;
|
||||
progressKey: string | null;
|
||||
onLoadOlder: () => void;
|
||||
onLoadOlder: () => boolean | Promise<boolean>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -265,6 +268,7 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
|
||||
];
|
||||
|
||||
const EMPTY_STREAM_HEAD: StreamItem[] = [];
|
||||
const EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = [];
|
||||
const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200;
|
||||
|
||||
function buildChatHistoryAttachment(input: {
|
||||
@@ -327,6 +331,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
streamItems,
|
||||
streamHead: providedStreamHead,
|
||||
pendingPermissions,
|
||||
pendingMessageSubmissions = EMPTY_PENDING_MESSAGE_SUBMISSIONS,
|
||||
routeBottomAnchorRequest = null,
|
||||
isAuthoritativeHistoryReady = true,
|
||||
toast,
|
||||
@@ -341,6 +346,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
|
||||
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const pendingClientMessageIds = useMemo(
|
||||
() => new Set(pendingMessageSubmissions.map((submission) => submission.clientMessageId)),
|
||||
[pendingMessageSubmissions],
|
||||
);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const streamRenderStrategy = useMemo(
|
||||
() =>
|
||||
@@ -656,7 +665,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
<UserMessage
|
||||
serverId={resolvedServerId}
|
||||
agentId={agentId}
|
||||
messageId={item.id}
|
||||
messageId={item.messageId}
|
||||
message={item.text}
|
||||
images={item.images}
|
||||
attachments={item.attachments}
|
||||
@@ -665,10 +674,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
client={client}
|
||||
isFirstInGroup={layoutItem.isFirstInUserGroup}
|
||||
isLastInGroup={layoutItem.isLastInUserGroup}
|
||||
isPending={
|
||||
item.clientMessageId !== undefined &&
|
||||
pendingClientMessageIds.has(item.clientMessageId)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[context.capabilities, agentId, client, resolvedServerId],
|
||||
[context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId],
|
||||
);
|
||||
|
||||
const renderAssistantMessageItem = useCallback(
|
||||
@@ -682,6 +695,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
toast={toast}
|
||||
>
|
||||
<AssistantMessage
|
||||
occurrenceKey={createAssistantImageOccurrenceKey({ agentId, itemId: item.id })}
|
||||
message={item.text}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
workspaceRoot={workspaceRoot}
|
||||
@@ -692,7 +706,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
</AssistantFileLinkResolverProvider>
|
||||
);
|
||||
},
|
||||
[client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
|
||||
[agentId, client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
|
||||
);
|
||||
|
||||
const renderThoughtItem = useCallback(
|
||||
@@ -878,7 +892,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[pendingPermissions, agentId],
|
||||
);
|
||||
|
||||
const showRunningTurnFooter = baseRenderModel.turnTiming.isActive;
|
||||
const showRunningTurnFooter =
|
||||
context.status === "running" || pendingMessageSubmissions.length > 0;
|
||||
const pendingPermissionsNode = useMemo(
|
||||
() =>
|
||||
renderPendingPermissionsNode({
|
||||
@@ -1157,6 +1172,9 @@ function agentStreamViewPropsEqual(
|
||||
if (left.streamItems !== right.streamItems) reasons.push("streamItems");
|
||||
if (left.streamHead !== right.streamHead) reasons.push("streamHead");
|
||||
if (left.pendingPermissions !== right.pendingPermissions) reasons.push("pendingPermissions");
|
||||
if (left.pendingMessageSubmissions !== right.pendingMessageSubmissions) {
|
||||
reasons.push("pendingMessageSubmissions");
|
||||
}
|
||||
if (
|
||||
!bottomAnchorRouteRequestsEqual(left.routeBottomAnchorRequest, right.routeBottomAnchorRequest)
|
||||
) {
|
||||
|
||||
193
packages/app/src/assistant-image/acquisition-cache.test.ts
Normal file
193
packages/app/src/assistant-image/acquisition-cache.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectRetainedAttachmentIds,
|
||||
retainAttachmentForGarbageCollection,
|
||||
} from "@/attachments/gc-retention";
|
||||
import {
|
||||
createAssistantImageAcquisitionCache,
|
||||
createAssistantImageFileAcquisitionKey,
|
||||
createAssistantImageFilePreviewAttachmentId,
|
||||
createAssistantImageOccurrenceKey,
|
||||
} from "./acquisition-cache";
|
||||
|
||||
describe("assistant image acquisition cache", () => {
|
||||
it("evicts a rejected acquisition so the next request can retry", async () => {
|
||||
const cache = createAssistantImageAcquisitionCache<string>({ capacity: 2 });
|
||||
let attempts = 0;
|
||||
|
||||
await expect(
|
||||
cache.acquire("image", async () => {
|
||||
attempts += 1;
|
||||
throw new Error("first attempt failed");
|
||||
}),
|
||||
).rejects.toThrow("first attempt failed");
|
||||
const recovered = await cache.acquire("image", async () => {
|
||||
attempts += 1;
|
||||
return "recovered";
|
||||
});
|
||||
|
||||
expect({ attempts, recovered, size: cache.size() }).toEqual({
|
||||
attempts: 2,
|
||||
recovered: "recovered",
|
||||
size: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds successful acquisitions and evicts the least recently used entry", async () => {
|
||||
const cache = createAssistantImageAcquisitionCache<string>({ capacity: 2 });
|
||||
const located: string[] = [];
|
||||
const locate = async (key: string) => {
|
||||
located.push(key);
|
||||
return key;
|
||||
};
|
||||
|
||||
await cache.acquire("a", async () => await locate("a"));
|
||||
await cache.acquire("b", async () => await locate("b"));
|
||||
await cache.acquire("a", async () => await locate("a-again"));
|
||||
await cache.acquire("c", async () => await locate("c"));
|
||||
await cache.acquire("b", async () => await locate("b-again"));
|
||||
|
||||
expect({ located, size: cache.size() }).toEqual({
|
||||
located: ["a", "b", "c", "b-again"],
|
||||
size: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses an acquired image when the current locator is unavailable", async () => {
|
||||
const cache = createAssistantImageAcquisitionCache<string>({ capacity: 2 });
|
||||
let unavailableCalls = 0;
|
||||
|
||||
await cache.acquire("message:image", async () => "persisted attachment");
|
||||
const cached = await cache.acquire("message:image", async () => {
|
||||
unavailableCalls += 1;
|
||||
throw new Error("daemon disconnected");
|
||||
});
|
||||
|
||||
expect({ cached, unavailableCalls }).toEqual({
|
||||
cached: "persisted attachment",
|
||||
unavailableCalls: 0,
|
||||
});
|
||||
expect(cache.peek("message:image")).toBe("persisted attachment");
|
||||
});
|
||||
|
||||
it("scopes file acquisitions to the rendered message occurrence", () => {
|
||||
const first = createAssistantImageFileAcquisitionKey({
|
||||
serverId: "server",
|
||||
occurrenceKey: "message-1:image-1",
|
||||
cwd: "/workspace",
|
||||
path: "screenshot.png",
|
||||
});
|
||||
const remount = createAssistantImageFileAcquisitionKey({
|
||||
serverId: "server",
|
||||
occurrenceKey: "message-1:image-1",
|
||||
cwd: "/workspace",
|
||||
path: "screenshot.png",
|
||||
});
|
||||
const laterMessage = createAssistantImageFileAcquisitionKey({
|
||||
serverId: "server",
|
||||
occurrenceKey: "message-2:image-1",
|
||||
cwd: "/workspace",
|
||||
path: "screenshot.png",
|
||||
});
|
||||
|
||||
expect(remount).toBe(first);
|
||||
expect(laterMessage).not.toBe(first);
|
||||
});
|
||||
|
||||
it("scopes persisted file previews to the rendered message occurrence", () => {
|
||||
const first = createAssistantImageFilePreviewAttachmentId({
|
||||
serverId: "server-1",
|
||||
occurrenceKey: "message-1:image-1",
|
||||
mimeType: "image/png",
|
||||
path: "/workspace/screenshot.png",
|
||||
size: 512,
|
||||
modifiedAt: "2026-07-27T12:00:00.000Z",
|
||||
contentLength: 512,
|
||||
});
|
||||
const second = createAssistantImageFilePreviewAttachmentId({
|
||||
serverId: "server-1",
|
||||
occurrenceKey: "message-2:image-1",
|
||||
mimeType: "image/png",
|
||||
path: "/workspace/screenshot.png",
|
||||
size: 512,
|
||||
modifiedAt: "2026-07-27T12:00:00.000Z",
|
||||
contentLength: 512,
|
||||
});
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it("scopes message occurrences to their agent", () => {
|
||||
const first = createAssistantImageOccurrenceKey({ agentId: "agent-1", itemId: "message-1" });
|
||||
const second = createAssistantImageOccurrenceKey({ agentId: "agent-2", itemId: "message-1" });
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it("retains successful values until their cache entry is evicted", async () => {
|
||||
const retained: string[] = [];
|
||||
const released: string[] = [];
|
||||
const cache = createAssistantImageAcquisitionCache<string>({
|
||||
capacity: 1,
|
||||
onRetain(value) {
|
||||
retained.push(value);
|
||||
return () => released.push(value);
|
||||
},
|
||||
});
|
||||
|
||||
await cache.acquire("first", async () => "attachment-1");
|
||||
expect({ retained, released }).toEqual({ retained: ["attachment-1"], released: [] });
|
||||
|
||||
await cache.acquire("second", async () => "attachment-2");
|
||||
expect({ retained, released }).toEqual({
|
||||
retained: ["attachment-1", "attachment-2"],
|
||||
released: ["attachment-1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not evict a value while an active consumer retains it", async () => {
|
||||
const released: string[] = [];
|
||||
const cache = createAssistantImageAcquisitionCache<string>({
|
||||
capacity: 1,
|
||||
onRetain: (value) => () => released.push(value),
|
||||
});
|
||||
|
||||
const first = cache.acquireRetained("first", async () => "attachment-1");
|
||||
await first.promise;
|
||||
const second = cache.acquireRetained("second", async () => "attachment-2");
|
||||
await second.promise;
|
||||
|
||||
expect({ released, size: cache.size() }).toEqual({ released: [], size: 2 });
|
||||
|
||||
first.release();
|
||||
expect({ released, size: cache.size() }).toEqual({
|
||||
released: ["attachment-1"],
|
||||
size: 1,
|
||||
});
|
||||
second.release();
|
||||
});
|
||||
|
||||
it("protects every actively consumed attachment from garbage collection past capacity", async () => {
|
||||
const cache = createAssistantImageAcquisitionCache<{ id: string }>({
|
||||
capacity: 1,
|
||||
onRetain: (attachment) => retainAttachmentForGarbageCollection(attachment.id),
|
||||
});
|
||||
|
||||
const first = cache.acquireRetained("first", async () => ({ id: "mounted-image-1" }));
|
||||
await first.promise;
|
||||
const second = cache.acquireRetained("second", async () => ({ id: "mounted-image-2" }));
|
||||
await second.promise;
|
||||
|
||||
expect(collectRetainedAttachmentIds()).toEqual(new Set(["mounted-image-1", "mounted-image-2"]));
|
||||
|
||||
first.release();
|
||||
expect(collectRetainedAttachmentIds()).toEqual(new Set(["mounted-image-2"]));
|
||||
second.release();
|
||||
await expect(
|
||||
cache.acquire("cleanup", async () => {
|
||||
throw new Error("cleanup");
|
||||
}),
|
||||
).rejects.toThrow("cleanup");
|
||||
expect(collectRetainedAttachmentIds()).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
159
packages/app/src/assistant-image/acquisition-cache.ts
Normal file
159
packages/app/src/assistant-image/acquisition-cache.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { createPreviewAttachmentId } from "@/attachments/utils";
|
||||
|
||||
export interface AssistantImageAcquisitionCache<T> {
|
||||
acquire(key: string, locate: () => Promise<T>): Promise<T>;
|
||||
acquireRetained(
|
||||
key: string,
|
||||
locate: () => Promise<T>,
|
||||
): { promise: Promise<T>; value?: T; release: () => void };
|
||||
peek(key: string): T | undefined;
|
||||
size(): number;
|
||||
}
|
||||
|
||||
export function createAssistantImageOccurrenceKey(input: {
|
||||
agentId: string;
|
||||
itemId: string;
|
||||
}): string {
|
||||
return `${input.agentId}:${input.itemId}`;
|
||||
}
|
||||
|
||||
export function createAssistantImageFilePreviewAttachmentId(input: {
|
||||
serverId?: string;
|
||||
occurrenceKey: string;
|
||||
mimeType: string;
|
||||
path: string;
|
||||
size: number;
|
||||
modifiedAt?: string | null;
|
||||
contentLength: number;
|
||||
}): string {
|
||||
return createPreviewAttachmentId({
|
||||
mimeType: input.mimeType,
|
||||
path: input.path,
|
||||
size: input.size,
|
||||
modifiedAt: input.modifiedAt,
|
||||
contentLength: input.contentLength,
|
||||
contentKey: `${input.serverId ?? "unknown-server"}:${input.occurrenceKey}`,
|
||||
});
|
||||
}
|
||||
|
||||
export function createAssistantImageFileAcquisitionKey(input: {
|
||||
serverId?: string;
|
||||
occurrenceKey: string;
|
||||
cwd: string;
|
||||
path: string;
|
||||
}): string {
|
||||
return `file:${input.serverId ?? "unknown-server"}:${input.occurrenceKey}:${input.cwd}:${input.path}`;
|
||||
}
|
||||
|
||||
export function createAssistantImageAcquisitionCache<T>(input: {
|
||||
capacity: number;
|
||||
onRetain?: (value: T) => () => void;
|
||||
}): AssistantImageAcquisitionCache<T> {
|
||||
if (!Number.isInteger(input.capacity) || input.capacity < 1) {
|
||||
throw new Error("Assistant image acquisition cache capacity must be a positive integer.");
|
||||
}
|
||||
interface CacheEntry {
|
||||
pending: Promise<T>;
|
||||
resolved: boolean;
|
||||
value?: T;
|
||||
release: (() => void) | null;
|
||||
activeConsumers: number;
|
||||
}
|
||||
const entries = new Map<string, CacheEntry>();
|
||||
|
||||
const evict = (key: string, entry: CacheEntry) => {
|
||||
if (entries.get(key) === entry) {
|
||||
entries.delete(key);
|
||||
}
|
||||
entry.release?.();
|
||||
entry.release = null;
|
||||
};
|
||||
|
||||
const enforceCapacity = () => {
|
||||
while (entries.size > input.capacity) {
|
||||
let evicted = false;
|
||||
for (const [key, entry] of entries) {
|
||||
if (entry.activeConsumers > 0) {
|
||||
continue;
|
||||
}
|
||||
evict(key, entry);
|
||||
evicted = true;
|
||||
break;
|
||||
}
|
||||
if (!evicted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const acquireEntry = (key: string, locate: () => Promise<T>, retain: boolean): CacheEntry => {
|
||||
const cached = entries.get(key);
|
||||
if (cached) {
|
||||
entries.delete(key);
|
||||
entries.set(key, cached);
|
||||
if (retain) {
|
||||
cached.activeConsumers += 1;
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
const pending = locate();
|
||||
const entry: CacheEntry = {
|
||||
pending,
|
||||
resolved: false,
|
||||
release: null,
|
||||
activeConsumers: retain ? 1 : 0,
|
||||
};
|
||||
entries.set(key, entry);
|
||||
enforceCapacity();
|
||||
void (async () => {
|
||||
try {
|
||||
const value = await pending;
|
||||
const release = input.onRetain?.(value) ?? null;
|
||||
if (entries.get(key) === entry) {
|
||||
entry.value = value;
|
||||
entry.resolved = true;
|
||||
entry.release = release;
|
||||
} else {
|
||||
release?.();
|
||||
}
|
||||
} catch {
|
||||
evict(key, entry);
|
||||
}
|
||||
})();
|
||||
return entry;
|
||||
};
|
||||
|
||||
return {
|
||||
acquire(key, locate) {
|
||||
return acquireEntry(key, locate, false).pending;
|
||||
},
|
||||
acquireRetained(key, locate) {
|
||||
const entry = acquireEntry(key, locate, true);
|
||||
let released = false;
|
||||
return {
|
||||
promise: entry.pending,
|
||||
...(entry.resolved ? { value: entry.value } : {}),
|
||||
release() {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
entry.activeConsumers = Math.max(0, entry.activeConsumers - 1);
|
||||
enforceCapacity();
|
||||
},
|
||||
};
|
||||
},
|
||||
peek(key) {
|
||||
const entry = entries.get(key);
|
||||
if (!entry?.resolved) {
|
||||
return undefined;
|
||||
}
|
||||
entries.delete(key);
|
||||
entries.set(key, entry);
|
||||
return entry.value;
|
||||
},
|
||||
size() {
|
||||
return entries.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
53
packages/app/src/assistant-image/file-acquisition.test.ts
Normal file
53
packages/app/src/assistant-image/file-acquisition.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import {
|
||||
createAssistantImageFileAcquisition,
|
||||
type AssistantImageFileAcquisitionPort,
|
||||
} from "./file-acquisition";
|
||||
|
||||
class MemoryFileAcquisitionPort implements AssistantImageFileAcquisitionPort {
|
||||
readonly reads: Array<{ cwd: string; path: string }> = [];
|
||||
|
||||
async readFile(cwd: string, path: string) {
|
||||
this.reads.push({ cwd, path });
|
||||
return {
|
||||
kind: "image" as const,
|
||||
path,
|
||||
mime: "image/png",
|
||||
size: 4,
|
||||
modifiedAt: "1",
|
||||
bytes: new Uint8Array([1, 2, 3, 4]),
|
||||
};
|
||||
}
|
||||
|
||||
async persist(input: { id: string; mimeType: string; fileName: string | null }) {
|
||||
return {
|
||||
id: input.id,
|
||||
mimeType: input.mimeType,
|
||||
storageType: "web-indexeddb" as const,
|
||||
storageKey: input.id,
|
||||
fileName: input.fileName,
|
||||
byteSize: 4,
|
||||
createdAt: 1,
|
||||
} satisfies AttachmentMetadata;
|
||||
}
|
||||
}
|
||||
|
||||
describe("assistant image file acquisition", () => {
|
||||
it("recreates the same acquisition with a live port after reconnect", async () => {
|
||||
const common = {
|
||||
resolution: { kind: "file_rpc" as const, cwd: "/workspace", path: "reconnect.png" },
|
||||
serverId: "server",
|
||||
occurrenceKey: "agent:message:reconnect-image",
|
||||
unavailableMessage: "Image unavailable",
|
||||
};
|
||||
const disconnected = createAssistantImageFileAcquisition({ ...common, port: null });
|
||||
const connectedPort = new MemoryFileAcquisitionPort();
|
||||
const connected = createAssistantImageFileAcquisition({ ...common, port: connectedPort });
|
||||
|
||||
expect(disconnected?.key).toBe(connected?.key);
|
||||
await expect(disconnected?.locate()).rejects.toThrow("Image unavailable");
|
||||
await expect(connected?.locate()).resolves.toMatchObject({ mimeType: "image/png" });
|
||||
expect(connectedPort.reads).toEqual([{ cwd: "/workspace", path: "reconnect.png" }]);
|
||||
});
|
||||
});
|
||||
67
packages/app/src/assistant-image/file-acquisition.ts
Normal file
67
packages/app/src/assistant-image/file-acquisition.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { getFileNameFromPath } from "@/attachments/utils";
|
||||
import type { AssistantImageSourceResolution } from "@/utils/assistant-image-source";
|
||||
import {
|
||||
createAssistantImageFileAcquisitionKey,
|
||||
createAssistantImageFilePreviewAttachmentId,
|
||||
} from "./acquisition-cache";
|
||||
|
||||
export interface AssistantImageFileAcquisitionPort {
|
||||
readFile(cwd: string, path: string): Promise<FileReadResult>;
|
||||
persist(input: {
|
||||
id: string;
|
||||
bytes: Uint8Array;
|
||||
mimeType: string;
|
||||
fileName: string | null;
|
||||
}): Promise<AttachmentMetadata>;
|
||||
}
|
||||
|
||||
export interface AssistantImageAcquisition {
|
||||
key: string;
|
||||
locate: () => Promise<AttachmentMetadata>;
|
||||
}
|
||||
|
||||
export function createAssistantImageFileAcquisition(input: {
|
||||
port: AssistantImageFileAcquisitionPort | null;
|
||||
resolution: AssistantImageSourceResolution | null;
|
||||
serverId?: string;
|
||||
occurrenceKey: string;
|
||||
unavailableMessage: string;
|
||||
}): AssistantImageAcquisition | null {
|
||||
if (input.resolution?.kind !== "file_rpc") {
|
||||
return null;
|
||||
}
|
||||
const { port, resolution } = input;
|
||||
return {
|
||||
key: createAssistantImageFileAcquisitionKey({
|
||||
serverId: input.serverId,
|
||||
occurrenceKey: input.occurrenceKey,
|
||||
cwd: resolution.cwd,
|
||||
path: resolution.path,
|
||||
}),
|
||||
locate: async () => {
|
||||
if (!port) {
|
||||
throw new Error(input.unavailableMessage);
|
||||
}
|
||||
const file = await port.readFile(resolution.cwd, resolution.path);
|
||||
if (file.kind !== "image") {
|
||||
throw new Error(input.unavailableMessage);
|
||||
}
|
||||
return await port.persist({
|
||||
id: createAssistantImageFilePreviewAttachmentId({
|
||||
serverId: input.serverId,
|
||||
occurrenceKey: input.occurrenceKey,
|
||||
mimeType: file.mime,
|
||||
path: file.path || resolution.path,
|
||||
size: file.size,
|
||||
modifiedAt: file.modifiedAt,
|
||||
contentLength: file.bytes.byteLength,
|
||||
}),
|
||||
bytes: file.bytes,
|
||||
mimeType: file.mime,
|
||||
fileName: getFileNameFromPath(file.path || resolution.path),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
120
packages/app/src/assistant-image/lifecycle.test.ts
Normal file
120
packages/app/src/assistant-image/lifecycle.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createAssistantImageLifecycle, transitionAssistantImageLifecycle } from "./lifecycle";
|
||||
|
||||
describe("assistant image lifecycle", () => {
|
||||
it("keeps preview URL recreation in the public loading state", () => {
|
||||
const loading = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
|
||||
type: "preview_created",
|
||||
uri: "blob:first",
|
||||
aspectRatio: null,
|
||||
});
|
||||
const loaded = transitionAssistantImageLifecycle(loading, {
|
||||
type: "image_loaded",
|
||||
uri: "blob:first",
|
||||
aspectRatio: 1.5,
|
||||
});
|
||||
|
||||
const recreating = transitionAssistantImageLifecycle(loaded, {
|
||||
type: "preview_released",
|
||||
});
|
||||
|
||||
expect(recreating).toEqual({ status: "loading", uri: null, aspectRatio: null });
|
||||
});
|
||||
|
||||
it("publishes loaded only after the recreated URL loads", () => {
|
||||
const recreating = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
|
||||
type: "preview_created",
|
||||
uri: "blob:recreated",
|
||||
aspectRatio: 1.5,
|
||||
});
|
||||
|
||||
expect(recreating).toEqual({
|
||||
status: "loading",
|
||||
uri: "blob:recreated",
|
||||
aspectRatio: 1.5,
|
||||
});
|
||||
|
||||
const recreated = transitionAssistantImageLifecycle(recreating, {
|
||||
type: "image_loaded",
|
||||
uri: "blob:recreated",
|
||||
aspectRatio: 0.75,
|
||||
});
|
||||
|
||||
expect(recreated).toEqual({
|
||||
status: "loaded",
|
||||
uri: "blob:recreated",
|
||||
aspectRatio: 0.75,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reset a loaded image when the same preview is reported again", () => {
|
||||
const loading = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
|
||||
type: "preview_created",
|
||||
uri: "blob:current",
|
||||
aspectRatio: null,
|
||||
});
|
||||
const loaded = transitionAssistantImageLifecycle(loading, {
|
||||
type: "image_loaded",
|
||||
uri: "blob:current",
|
||||
aspectRatio: 1.5,
|
||||
});
|
||||
|
||||
const repeated = transitionAssistantImageLifecycle(loaded, {
|
||||
type: "preview_created",
|
||||
uri: "blob:current",
|
||||
aspectRatio: 1.5,
|
||||
});
|
||||
|
||||
expect(repeated).toBe(loaded);
|
||||
});
|
||||
|
||||
it("publishes failed for a terminal image failure on the current URI", () => {
|
||||
const loading = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
|
||||
type: "preview_created",
|
||||
uri: "blob:current",
|
||||
aspectRatio: null,
|
||||
});
|
||||
const failed = transitionAssistantImageLifecycle(loading, {
|
||||
type: "failed",
|
||||
uri: "blob:current",
|
||||
message: "Unable to load image preview.",
|
||||
});
|
||||
|
||||
expect(failed).toEqual({
|
||||
status: "failed",
|
||||
message: "Unable to load image preview.",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a stale load callback from a replaced URI", () => {
|
||||
const current = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
|
||||
type: "preview_created",
|
||||
uri: "blob:current",
|
||||
aspectRatio: 1.25,
|
||||
});
|
||||
|
||||
const afterStaleLoad = transitionAssistantImageLifecycle(current, {
|
||||
type: "image_loaded",
|
||||
uri: "blob:released",
|
||||
aspectRatio: 2,
|
||||
});
|
||||
|
||||
expect(afterStaleLoad).toEqual(current);
|
||||
});
|
||||
|
||||
it("ignores a stale error callback from a replaced URI", () => {
|
||||
const current = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
|
||||
type: "preview_created",
|
||||
uri: "blob:current",
|
||||
aspectRatio: null,
|
||||
});
|
||||
|
||||
const afterStaleError = transitionAssistantImageLifecycle(current, {
|
||||
type: "failed",
|
||||
uri: "blob:released",
|
||||
message: "Image unavailable",
|
||||
});
|
||||
|
||||
expect(afterStaleError).toEqual(current);
|
||||
});
|
||||
});
|
||||
46
packages/app/src/assistant-image/lifecycle.ts
Normal file
46
packages/app/src/assistant-image/lifecycle.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export type AssistantImageLifecycle =
|
||||
| { status: "loading"; uri: string | null; aspectRatio: number | null }
|
||||
| { status: "loaded"; uri: string; aspectRatio: number }
|
||||
| { status: "failed"; message: string };
|
||||
|
||||
export type AssistantImageLifecycleEvent =
|
||||
| { type: "preview_created"; uri: string; aspectRatio: number | null }
|
||||
| { type: "preview_released" }
|
||||
| { type: "image_loaded"; uri: string; aspectRatio: number }
|
||||
| { type: "failed"; uri: string; message: string };
|
||||
|
||||
export function createAssistantImageLifecycle(): AssistantImageLifecycle {
|
||||
return { status: "loading", uri: null, aspectRatio: null };
|
||||
}
|
||||
|
||||
export function transitionAssistantImageLifecycle(
|
||||
state: AssistantImageLifecycle,
|
||||
event: AssistantImageLifecycleEvent,
|
||||
): AssistantImageLifecycle {
|
||||
if (event.type === "image_loaded") {
|
||||
if (state.status !== "loading" || state.uri !== event.uri) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
status: "loaded",
|
||||
uri: event.uri,
|
||||
aspectRatio: event.aspectRatio,
|
||||
};
|
||||
}
|
||||
if (event.type === "failed") {
|
||||
if (state.status === "failed" || state.uri !== event.uri) {
|
||||
return state;
|
||||
}
|
||||
return { status: "failed", message: event.message };
|
||||
}
|
||||
if (event.type === "preview_created") {
|
||||
if (state.status === "loaded" && state.uri === event.uri) {
|
||||
return state;
|
||||
}
|
||||
return { status: "loading", uri: event.uri, aspectRatio: event.aspectRatio };
|
||||
}
|
||||
if (event.type === "preview_released") {
|
||||
return { status: "loading", uri: null, aspectRatio: null };
|
||||
}
|
||||
return state;
|
||||
}
|
||||
48
packages/app/src/assistant-image/load-dimensions.test.ts
Normal file
48
packages/app/src/assistant-image/load-dimensions.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type AssistantImageRenderedDimensionsReader,
|
||||
resolveAssistantImageLoadDimensions,
|
||||
} from "./load-dimensions";
|
||||
|
||||
class MemoryRenderedDimensionsReader implements AssistantImageRenderedDimensionsReader {
|
||||
constructor(private readonly dimensions: { width: number; height: number } | null) {}
|
||||
|
||||
read(): { width: number; height: number } | null {
|
||||
return this.dimensions;
|
||||
}
|
||||
}
|
||||
|
||||
describe("assistant image load dimensions", () => {
|
||||
it("prefers native source dimensions", () => {
|
||||
const dimensions = resolveAssistantImageLoadDimensions({
|
||||
source: { width: 640, height: 320 },
|
||||
target: { naturalWidth: 800, naturalHeight: 400 },
|
||||
renderedImage: null,
|
||||
renderedDimensions: new MemoryRenderedDimensionsReader({ width: 900, height: 600 }),
|
||||
});
|
||||
|
||||
expect(dimensions).toEqual({ width: 640, height: 320 });
|
||||
});
|
||||
|
||||
it("uses browser event dimensions when native dimensions are absent", () => {
|
||||
const dimensions = resolveAssistantImageLoadDimensions({
|
||||
source: { width: 0, height: 0 },
|
||||
target: { naturalWidth: 800, naturalHeight: 400 },
|
||||
renderedImage: null,
|
||||
renderedDimensions: new MemoryRenderedDimensionsReader({ width: 900, height: 600 }),
|
||||
});
|
||||
|
||||
expect(dimensions).toEqual({ width: 800, height: 400 });
|
||||
});
|
||||
|
||||
it("falls back to the rendered image adapter", () => {
|
||||
const renderedImage = { id: "rendered-image" };
|
||||
const dimensions = resolveAssistantImageLoadDimensions({
|
||||
target: null,
|
||||
renderedImage,
|
||||
renderedDimensions: new MemoryRenderedDimensionsReader({ width: 900, height: 600 }),
|
||||
});
|
||||
|
||||
expect(dimensions).toEqual({ width: 900, height: 600 });
|
||||
});
|
||||
});
|
||||
45
packages/app/src/assistant-image/load-dimensions.ts
Normal file
45
packages/app/src/assistant-image/load-dimensions.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export interface AssistantImageDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface AssistantImageDimensionCandidate {
|
||||
width?: unknown;
|
||||
height?: unknown;
|
||||
}
|
||||
|
||||
export interface AssistantImageRenderedDimensionsReader {
|
||||
read(renderedImage: unknown): AssistantImageDimensions | null;
|
||||
}
|
||||
|
||||
function readPositiveDimensions(
|
||||
candidate: AssistantImageDimensionCandidate | null | undefined,
|
||||
): AssistantImageDimensions | null {
|
||||
if (
|
||||
typeof candidate?.width !== "number" ||
|
||||
typeof candidate.height !== "number" ||
|
||||
candidate.width <= 0 ||
|
||||
candidate.height <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { width: candidate.width, height: candidate.height };
|
||||
}
|
||||
|
||||
export function resolveAssistantImageLoadDimensions(input: {
|
||||
source?: AssistantImageDimensionCandidate | null;
|
||||
target?: { naturalWidth?: unknown; naturalHeight?: unknown } | null;
|
||||
renderedImage: unknown;
|
||||
renderedDimensions: AssistantImageRenderedDimensionsReader;
|
||||
}): AssistantImageDimensions | null {
|
||||
const source = readPositiveDimensions(input.source);
|
||||
if (source) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const target = readPositiveDimensions({
|
||||
width: input.target?.naturalWidth,
|
||||
height: input.target?.naturalHeight,
|
||||
});
|
||||
return target ?? input.renderedDimensions.read(input.renderedImage);
|
||||
}
|
||||
49
packages/app/src/assistant-image/retry.test.ts
Normal file
49
packages/app/src/assistant-image/retry.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runAssistantImageOperationWithRetry } from "./retry";
|
||||
|
||||
describe("assistant image retry", () => {
|
||||
it("recovers from transient failures while the image remains mounted", async () => {
|
||||
const waits: number[] = [];
|
||||
let attempts = 0;
|
||||
|
||||
const result = await runAssistantImageOperationWithRetry({
|
||||
operation: async () => {
|
||||
attempts += 1;
|
||||
if (attempts < 3) {
|
||||
throw new Error("transient");
|
||||
}
|
||||
return "loaded";
|
||||
},
|
||||
delaysMs: [10, 20, 30],
|
||||
wait: async (delayMs) => {
|
||||
waits.push(delayMs);
|
||||
},
|
||||
});
|
||||
|
||||
expect({ attempts, waits, result }).toEqual({
|
||||
attempts: 3,
|
||||
waits: [10, 20],
|
||||
result: "loaded",
|
||||
});
|
||||
});
|
||||
|
||||
it("stops retrying after the image is released", async () => {
|
||||
let stopped = false;
|
||||
let attempts = 0;
|
||||
|
||||
await expect(
|
||||
runAssistantImageOperationWithRetry({
|
||||
operation: async () => {
|
||||
attempts += 1;
|
||||
throw new Error("transient");
|
||||
},
|
||||
delaysMs: [10, 20],
|
||||
shouldStop: () => stopped,
|
||||
wait: async () => {
|
||||
stopped = true;
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("transient");
|
||||
expect(attempts).toBe(1);
|
||||
});
|
||||
});
|
||||
31
packages/app/src/assistant-image/retry.ts
Normal file
31
packages/app/src/assistant-image/retry.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export const ASSISTANT_IMAGE_RETRY_DELAYS_MS = [100, 400, 1_200] as const;
|
||||
|
||||
export async function runAssistantImageOperationWithRetry<T>(input: {
|
||||
operation: () => Promise<T>;
|
||||
delaysMs?: readonly number[];
|
||||
shouldStop?: () => boolean;
|
||||
wait?: (delayMs: number) => Promise<void>;
|
||||
}): Promise<T> {
|
||||
const delays = input.delaysMs ?? ASSISTANT_IMAGE_RETRY_DELAYS_MS;
|
||||
const shouldStop = input.shouldStop ?? (() => false);
|
||||
const wait =
|
||||
input.wait ??
|
||||
(async (delayMs: number) => {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
|
||||
});
|
||||
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
return await input.operation();
|
||||
} catch (error) {
|
||||
const delay = delays[attempt];
|
||||
if (delay === undefined || shouldStop()) {
|
||||
throw error;
|
||||
}
|
||||
await wait(delay);
|
||||
if (shouldStop()) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
544
packages/app/src/assistant-image/use-assistant-image.ts
Normal file
544
packages/app/src/assistant-image/use-assistant-image.ts
Normal file
@@ -0,0 +1,544 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { ImageLoadEvent } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { retainAttachmentForGarbageCollection } from "@/attachments/gc-retention";
|
||||
import {
|
||||
persistAttachmentFromBytes,
|
||||
persistAttachmentFromDataUrl,
|
||||
releaseAttachmentPreviewUrl,
|
||||
resolveAttachmentPreviewUrl,
|
||||
} from "@/attachments/service";
|
||||
import { createPreviewAttachmentId, parseImageDataUrl } from "@/attachments/utils";
|
||||
import {
|
||||
getAssistantImageMetadata,
|
||||
setAssistantImageMetadata,
|
||||
} from "@/utils/assistant-image-metadata";
|
||||
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
|
||||
import { createAssistantImageAcquisitionCache } from "./acquisition-cache";
|
||||
import {
|
||||
createAssistantImageFileAcquisition,
|
||||
type AssistantImageAcquisition,
|
||||
type AssistantImageFileAcquisitionPort,
|
||||
} from "./file-acquisition";
|
||||
import {
|
||||
createAssistantImageLifecycle,
|
||||
transitionAssistantImageLifecycle,
|
||||
type AssistantImageLifecycle,
|
||||
type AssistantImageLifecycleEvent,
|
||||
} from "./lifecycle";
|
||||
import {
|
||||
type AssistantImageRenderedDimensionsReader,
|
||||
resolveAssistantImageLoadDimensions,
|
||||
} from "./load-dimensions";
|
||||
import { runAssistantImageOperationWithRetry } from "./retry";
|
||||
|
||||
interface AssistantImageRenderBinding {
|
||||
uri: string;
|
||||
onRef: (instance: unknown) => void;
|
||||
onLoad: (event: ImageLoadEvent) => void;
|
||||
onError: () => void;
|
||||
}
|
||||
|
||||
const renderedDimensions: AssistantImageRenderedDimensionsReader = {
|
||||
read(renderedImage) {
|
||||
if (!isWeb || !(renderedImage instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
const image = renderedImage.querySelector("img");
|
||||
return image && image.naturalWidth > 0 && image.naturalHeight > 0
|
||||
? { width: image.naturalWidth, height: image.naturalHeight }
|
||||
: null;
|
||||
},
|
||||
};
|
||||
|
||||
export type AssistantImageResult =
|
||||
| {
|
||||
status: "loading";
|
||||
binding: AssistantImageRenderBinding | null;
|
||||
aspectRatio: number | null;
|
||||
}
|
||||
| {
|
||||
status: "loaded";
|
||||
binding: AssistantImageRenderBinding;
|
||||
aspectRatio: number;
|
||||
}
|
||||
| { status: "failed"; message: string };
|
||||
|
||||
interface UseAssistantImageInput {
|
||||
source: string;
|
||||
occurrenceKey: string;
|
||||
client?: DaemonClient | null;
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
type PreviewUrlState =
|
||||
| { status: "waiting" }
|
||||
| { status: "loading" }
|
||||
| { status: "loaded"; uri: string }
|
||||
| { status: "failed"; error: unknown };
|
||||
|
||||
type AttachmentAcquisitionState =
|
||||
| { status: "waiting" }
|
||||
| { status: "loading" }
|
||||
| { status: "loaded"; attachment: AttachmentMetadata }
|
||||
| { status: "failed"; error: unknown };
|
||||
|
||||
interface DataImage {
|
||||
mimeType: string;
|
||||
base64: string;
|
||||
cacheKey: string;
|
||||
}
|
||||
|
||||
const attachmentAcquisitionCache = createAssistantImageAcquisitionCache<AttachmentMetadata>({
|
||||
capacity: 500,
|
||||
onRetain: (attachment) => retainAttachmentForGarbageCollection(attachment.id),
|
||||
});
|
||||
|
||||
interface CachedPreviewUrl {
|
||||
attachment: AttachmentMetadata;
|
||||
uri: string;
|
||||
}
|
||||
|
||||
const previewUrlCache = createAssistantImageAcquisitionCache<CachedPreviewUrl>({
|
||||
capacity: 500,
|
||||
onRetain:
|
||||
({ attachment, uri }) =>
|
||||
() => {
|
||||
void releaseAttachmentPreviewUrl({ attachment, url: uri });
|
||||
},
|
||||
});
|
||||
|
||||
const LOADED_IMAGE_CACHE_CAPACITY = 500;
|
||||
const loadedImageCache = new Map<string, number>();
|
||||
|
||||
function getLoadedImageAspectRatio(uri: string): number | null {
|
||||
const aspectRatio = loadedImageCache.get(uri);
|
||||
if (aspectRatio === undefined) {
|
||||
return null;
|
||||
}
|
||||
loadedImageCache.delete(uri);
|
||||
loadedImageCache.set(uri, aspectRatio);
|
||||
return aspectRatio;
|
||||
}
|
||||
|
||||
function rememberLoadedImage(uri: string, aspectRatio: number): void {
|
||||
loadedImageCache.delete(uri);
|
||||
loadedImageCache.set(uri, aspectRatio);
|
||||
if (loadedImageCache.size <= LOADED_IMAGE_CACHE_CAPACITY) {
|
||||
return;
|
||||
}
|
||||
const leastRecentlyUsedUri = loadedImageCache.keys().next().value;
|
||||
if (leastRecentlyUsedUri !== undefined) {
|
||||
loadedImageCache.delete(leastRecentlyUsedUri);
|
||||
}
|
||||
}
|
||||
|
||||
function useAttachmentAcquisition(
|
||||
acquisition: AssistantImageAcquisition | null,
|
||||
): AttachmentAcquisitionState {
|
||||
const acquisitionKey = acquisition?.key ?? null;
|
||||
const [entry, setEntry] = useState<{
|
||||
key: string | null;
|
||||
state: AttachmentAcquisitionState;
|
||||
}>(() => {
|
||||
const cached = acquisitionKey ? attachmentAcquisitionCache.peek(acquisitionKey) : undefined;
|
||||
return {
|
||||
key: acquisitionKey,
|
||||
state: cached ? { status: "loaded", attachment: cached } : { status: "waiting" },
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let releaseCurrent: (() => void) | null = null;
|
||||
if (!acquisition || !acquisitionKey) {
|
||||
setEntry({ key: null, state: { status: "waiting" } });
|
||||
return;
|
||||
}
|
||||
|
||||
const acquireCurrent = () => {
|
||||
releaseCurrent?.();
|
||||
const retained = attachmentAcquisitionCache.acquireRetained(
|
||||
acquisitionKey,
|
||||
acquisition.locate,
|
||||
);
|
||||
releaseCurrent = retained.release;
|
||||
return retained;
|
||||
};
|
||||
const initial = acquireCurrent();
|
||||
if (initial.value) {
|
||||
setEntry({ key: acquisitionKey, state: { status: "loaded", attachment: initial.value } });
|
||||
return () => {
|
||||
disposed = true;
|
||||
releaseCurrent?.();
|
||||
};
|
||||
}
|
||||
|
||||
setEntry({ key: acquisitionKey, state: { status: "loading" } });
|
||||
void (async () => {
|
||||
let firstAttempt: ReturnType<typeof acquireCurrent> | null = initial;
|
||||
try {
|
||||
const attachment = await runAssistantImageOperationWithRetry({
|
||||
operation: async () => {
|
||||
const retained = firstAttempt ?? acquireCurrent();
|
||||
firstAttempt = null;
|
||||
return await retained.promise;
|
||||
},
|
||||
shouldStop: () => disposed,
|
||||
});
|
||||
if (!disposed) {
|
||||
setEntry({ key: acquisitionKey, state: { status: "loaded", attachment } });
|
||||
}
|
||||
} catch (error) {
|
||||
if (!disposed) {
|
||||
setEntry({ key: acquisitionKey, state: { status: "failed", error } });
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
disposed = true;
|
||||
releaseCurrent?.();
|
||||
};
|
||||
}, [acquisition, acquisitionKey]);
|
||||
|
||||
if (!acquisitionKey) {
|
||||
return { status: "waiting" };
|
||||
}
|
||||
const cached = attachmentAcquisitionCache.peek(acquisitionKey);
|
||||
if (cached) {
|
||||
return { status: "loaded", attachment: cached };
|
||||
}
|
||||
return entry.key === acquisitionKey ? entry.state : { status: "waiting" };
|
||||
}
|
||||
|
||||
function createDataImageAcquisition(input: {
|
||||
source: string;
|
||||
dataImage: DataImage | null;
|
||||
}): AssistantImageAcquisition | null {
|
||||
if (!input.dataImage) {
|
||||
return null;
|
||||
}
|
||||
const { dataImage, source } = input;
|
||||
return {
|
||||
key: dataImage.cacheKey,
|
||||
locate: async () =>
|
||||
await persistAttachmentFromDataUrl({
|
||||
id: createPreviewAttachmentId({
|
||||
mimeType: dataImage.mimeType,
|
||||
contentLength: dataImage.base64.length,
|
||||
contentKey: dataImage.cacheKey,
|
||||
}),
|
||||
dataUrl: source,
|
||||
mimeType: dataImage.mimeType,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function usePreviewUrl(attachment: AttachmentMetadata | null | undefined): PreviewUrlState {
|
||||
const id = attachment?.id;
|
||||
const storageType = attachment?.storageType;
|
||||
const storageKey = attachment?.storageKey;
|
||||
const mimeType = attachment?.mimeType;
|
||||
const previewKey =
|
||||
id && storageType && storageKey && mimeType
|
||||
? `${id}:${storageType}:${storageKey}:${mimeType}`
|
||||
: null;
|
||||
const [entry, setEntry] = useState<{ key: string | null; state: PreviewUrlState }>(() => {
|
||||
return {
|
||||
key: previewKey,
|
||||
state: { status: "waiting" },
|
||||
};
|
||||
});
|
||||
const getCurrentAttachment = useStableEvent(() => attachment ?? null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
let disposed = false;
|
||||
let releaseCurrent: (() => void) | null = null;
|
||||
const current = getCurrentAttachment();
|
||||
|
||||
if (!current || !previewKey) {
|
||||
setEntry({ key: null, state: { status: "waiting" } });
|
||||
return;
|
||||
}
|
||||
|
||||
const acquireCurrent = () => {
|
||||
releaseCurrent?.();
|
||||
const retained = previewUrlCache.acquireRetained(previewKey, async () => ({
|
||||
attachment: current,
|
||||
uri: await resolveAttachmentPreviewUrl(current),
|
||||
}));
|
||||
releaseCurrent = retained.release;
|
||||
return retained;
|
||||
};
|
||||
const initial = acquireCurrent();
|
||||
if (initial.value) {
|
||||
setEntry({ key: previewKey, state: { status: "loaded", uri: initial.value.uri } });
|
||||
return () => {
|
||||
disposed = true;
|
||||
releaseCurrent?.();
|
||||
};
|
||||
}
|
||||
|
||||
setEntry({ key: previewKey, state: { status: "loading" } });
|
||||
void (async () => {
|
||||
let firstAttempt: ReturnType<typeof acquireCurrent> | null = initial;
|
||||
try {
|
||||
const preview = await runAssistantImageOperationWithRetry({
|
||||
operation: async () => {
|
||||
const retained = firstAttempt ?? acquireCurrent();
|
||||
firstAttempt = null;
|
||||
return await retained.promise;
|
||||
},
|
||||
shouldStop: () => disposed,
|
||||
});
|
||||
if (!disposed) {
|
||||
setEntry({ key: previewKey, state: { status: "loaded", uri: preview.uri } });
|
||||
}
|
||||
} catch (error) {
|
||||
if (!disposed) {
|
||||
setEntry({ key: previewKey, state: { status: "failed", error } });
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
releaseCurrent?.();
|
||||
};
|
||||
}, [getCurrentAttachment, previewKey]);
|
||||
|
||||
if (!previewKey) {
|
||||
return { status: "waiting" };
|
||||
}
|
||||
return entry.key === previewKey ? entry.state : { status: "waiting" };
|
||||
}
|
||||
|
||||
function lifecycleReducer(
|
||||
state: AssistantImageLifecycle,
|
||||
event: AssistantImageLifecycleEvent,
|
||||
): AssistantImageLifecycle {
|
||||
return transitionAssistantImageLifecycle(state, event);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
function getAcquisitionFailure(input: {
|
||||
hasResolution: boolean;
|
||||
isFileSource: boolean;
|
||||
isDataImage: boolean;
|
||||
fileAttachment: AttachmentAcquisitionState;
|
||||
dataImageAttachment: AttachmentAcquisitionState;
|
||||
preview: PreviewUrlState;
|
||||
hasDirectUri: boolean;
|
||||
fallbackMessage: string;
|
||||
}): AssistantImageResult | null {
|
||||
if (!input.hasResolution) {
|
||||
return { status: "failed", message: input.fallbackMessage };
|
||||
}
|
||||
if (input.isFileSource && input.fileAttachment.status === "failed") {
|
||||
return {
|
||||
status: "failed",
|
||||
message: errorMessage(input.fileAttachment.error, input.fallbackMessage),
|
||||
};
|
||||
}
|
||||
if (input.isDataImage && input.dataImageAttachment.status === "failed") {
|
||||
return {
|
||||
status: "failed",
|
||||
message: errorMessage(input.dataImageAttachment.error, input.fallbackMessage),
|
||||
};
|
||||
}
|
||||
if (!input.hasDirectUri && input.preview.status === "failed") {
|
||||
return {
|
||||
status: "failed",
|
||||
message: errorMessage(input.preview.error, input.fallbackMessage),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useAssistantImage({
|
||||
source,
|
||||
occurrenceKey,
|
||||
client,
|
||||
workspaceRoot,
|
||||
serverId,
|
||||
}: UseAssistantImageInput): AssistantImageResult {
|
||||
const { t } = useTranslation();
|
||||
const resolution = useMemo(
|
||||
() => resolveAssistantImageSource({ source, workspaceRoot }),
|
||||
[source, workspaceRoot],
|
||||
);
|
||||
const dataImage = useMemo(() => parseImageDataUrl(source), [source]);
|
||||
const fileAcquisition = useMemo(() => {
|
||||
const port: AssistantImageFileAcquisitionPort | null = client
|
||||
? {
|
||||
readFile: async (cwd, path) => await client.readFile(cwd, path),
|
||||
persist: persistAttachmentFromBytes,
|
||||
}
|
||||
: null;
|
||||
return createAssistantImageFileAcquisition({
|
||||
port,
|
||||
resolution,
|
||||
serverId,
|
||||
occurrenceKey,
|
||||
unavailableMessage: t("message.attachments.imagePreviewUnavailable"),
|
||||
});
|
||||
}, [client, occurrenceKey, resolution, serverId, t]);
|
||||
const dataImageAcquisition = useMemo(
|
||||
() => createDataImageAcquisition({ source, dataImage }),
|
||||
[dataImage, source],
|
||||
);
|
||||
const fileAttachment = useAttachmentAcquisition(fileAcquisition);
|
||||
const dataImageAttachment = useAttachmentAcquisition(dataImageAcquisition);
|
||||
const filePreview = usePreviewUrl(
|
||||
fileAttachment.status === "loaded" ? fileAttachment.attachment : null,
|
||||
);
|
||||
const dataImagePreview = usePreviewUrl(
|
||||
dataImageAttachment.status === "loaded" ? dataImageAttachment.attachment : null,
|
||||
);
|
||||
const directUri = resolution?.kind === "direct" && !dataImage ? resolution.uri : null;
|
||||
const preview = dataImage ? dataImagePreview : filePreview;
|
||||
const previewUri = preview.status === "loaded" ? preview.uri : null;
|
||||
const uri = directUri ?? previewUri;
|
||||
const cachedMetadata = useMemo(
|
||||
() => getAssistantImageMetadata({ source, workspaceRoot, serverId }),
|
||||
[serverId, source, workspaceRoot],
|
||||
);
|
||||
const [lifecycle, dispatchLifecycle] = useReducer(
|
||||
lifecycleReducer,
|
||||
uri,
|
||||
(initialUri): AssistantImageLifecycle => {
|
||||
if (initialUri) {
|
||||
const aspectRatio = getLoadedImageAspectRatio(initialUri);
|
||||
if (aspectRatio !== null) {
|
||||
return { status: "loaded", uri: initialUri, aspectRatio };
|
||||
}
|
||||
}
|
||||
return createAssistantImageLifecycle();
|
||||
},
|
||||
);
|
||||
const dispatch = useCallback((event: AssistantImageLifecycleEvent) => {
|
||||
if (event.type === "image_loaded") {
|
||||
rememberLoadedImage(event.uri, event.aspectRatio);
|
||||
} else if (event.type === "failed" && event.uri) {
|
||||
loadedImageCache.delete(event.uri);
|
||||
}
|
||||
dispatchLifecycle(event);
|
||||
}, []);
|
||||
const renderedImageRef = useRef<unknown>(null);
|
||||
const handleImageRef = useCallback((instance: unknown) => {
|
||||
renderedImageRef.current = instance;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!uri) {
|
||||
dispatch({ type: "preview_released" });
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "preview_created",
|
||||
uri,
|
||||
aspectRatio: cachedMetadata?.aspectRatio ?? null,
|
||||
});
|
||||
}, [cachedMetadata, dispatch, uri]);
|
||||
|
||||
const handleImageError = useCallback(() => {
|
||||
if (uri) {
|
||||
dispatch({
|
||||
type: "failed",
|
||||
uri,
|
||||
message: t("message.attachments.imageUnavailable"),
|
||||
});
|
||||
}
|
||||
}, [dispatch, t, uri]);
|
||||
const handleImageLoad = useCallback(
|
||||
(event: ImageLoadEvent) => {
|
||||
if (!uri) {
|
||||
return;
|
||||
}
|
||||
const nativeEvent = event.nativeEvent as ImageLoadEvent["nativeEvent"] & {
|
||||
target?: { naturalWidth?: unknown; naturalHeight?: unknown };
|
||||
};
|
||||
const dimensions = resolveAssistantImageLoadDimensions({
|
||||
source: nativeEvent.source,
|
||||
target: nativeEvent.target,
|
||||
renderedImage: renderedImageRef.current,
|
||||
renderedDimensions,
|
||||
});
|
||||
const metadata = dimensions
|
||||
? setAssistantImageMetadata({ source, workspaceRoot, serverId }, dimensions)
|
||||
: null;
|
||||
const aspectRatio = metadata?.aspectRatio ?? cachedMetadata?.aspectRatio ?? null;
|
||||
if (!aspectRatio) {
|
||||
dispatch({
|
||||
type: "failed",
|
||||
uri,
|
||||
message: t("message.attachments.imageUnavailable"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
dispatch({ type: "image_loaded", uri, aspectRatio });
|
||||
},
|
||||
[cachedMetadata, dispatch, serverId, source, t, uri, workspaceRoot],
|
||||
);
|
||||
|
||||
const acquisitionFailure = getAcquisitionFailure({
|
||||
hasResolution: resolution !== null,
|
||||
isFileSource: resolution?.kind === "file_rpc",
|
||||
isDataImage: dataImage !== null,
|
||||
fileAttachment,
|
||||
dataImageAttachment,
|
||||
preview,
|
||||
hasDirectUri: directUri !== null,
|
||||
fallbackMessage: t("message.attachments.imagePreviewLoadFailed"),
|
||||
});
|
||||
if (acquisitionFailure) {
|
||||
return acquisitionFailure;
|
||||
}
|
||||
const hasCurrentLifecycleUri = lifecycle.status !== "failed" && lifecycle.uri === uri;
|
||||
let binding: AssistantImageRenderBinding | null = null;
|
||||
if (hasCurrentLifecycleUri && lifecycle.uri) {
|
||||
binding = {
|
||||
uri: lifecycle.uri,
|
||||
onRef: handleImageRef,
|
||||
onLoad: handleImageLoad,
|
||||
onError: handleImageError,
|
||||
};
|
||||
}
|
||||
if (lifecycle.status === "loaded" && lifecycle.uri === uri) {
|
||||
return {
|
||||
status: "loaded",
|
||||
binding: {
|
||||
uri: lifecycle.uri,
|
||||
onRef: handleImageRef,
|
||||
onLoad: handleImageLoad,
|
||||
onError: handleImageError,
|
||||
},
|
||||
aspectRatio: lifecycle.aspectRatio,
|
||||
};
|
||||
}
|
||||
if (lifecycle.status === "failed") {
|
||||
return lifecycle;
|
||||
}
|
||||
return {
|
||||
status: "loading",
|
||||
binding,
|
||||
aspectRatio: hasCurrentLifecycleUri ? lifecycle.aspectRatio : null,
|
||||
};
|
||||
}
|
||||
15
packages/app/src/attachments/gc-retention.test.ts
Normal file
15
packages/app/src/attachments/gc-retention.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectRetainedAttachmentIds, retainAttachmentForGarbageCollection } from "./gc-retention";
|
||||
|
||||
describe("attachment garbage-collection retention", () => {
|
||||
it("keeps an attachment referenced until its final owner releases it", () => {
|
||||
const releaseFirst = retainAttachmentForGarbageCollection("preview-1");
|
||||
const releaseSecond = retainAttachmentForGarbageCollection("preview-1");
|
||||
|
||||
expect(collectRetainedAttachmentIds()).toContain("preview-1");
|
||||
releaseFirst();
|
||||
expect(collectRetainedAttachmentIds()).toContain("preview-1");
|
||||
releaseSecond();
|
||||
expect(collectRetainedAttachmentIds()).not.toContain("preview-1");
|
||||
});
|
||||
});
|
||||
22
packages/app/src/attachments/gc-retention.ts
Normal file
22
packages/app/src/attachments/gc-retention.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
const retentionCounts = new Map<string, number>();
|
||||
|
||||
export function retainAttachmentForGarbageCollection(attachmentId: string): () => void {
|
||||
retentionCounts.set(attachmentId, (retentionCounts.get(attachmentId) ?? 0) + 1);
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
const nextCount = (retentionCounts.get(attachmentId) ?? 1) - 1;
|
||||
if (nextCount <= 0) {
|
||||
retentionCounts.delete(attachmentId);
|
||||
return;
|
||||
}
|
||||
retentionCounts.set(attachmentId, nextCount);
|
||||
};
|
||||
}
|
||||
|
||||
export function collectRetainedAttachmentIds(): ReadonlySet<string> {
|
||||
return new Set(retentionCounts.keys());
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { AttachmentMetadata, AttachmentStore, SaveAttachmentInput } from "@/attachments/types";
|
||||
import { __setAttachmentStoreForTests } from "./store";
|
||||
import { encodeAttachmentsForSend, persistAttachmentFromBytes } from "./service";
|
||||
import {
|
||||
encodeAttachmentsForSend,
|
||||
garbageCollectAttachments,
|
||||
persistAttachmentFromBytes,
|
||||
} from "./service";
|
||||
|
||||
function createAttachment(input: Partial<AttachmentMetadata> = {}): AttachmentMetadata {
|
||||
return {
|
||||
@@ -94,4 +98,46 @@ describe("attachment service", () => {
|
||||
{ data: "att_send:base64", mimeType: "image/jpeg" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not collect an attachment persisted while garbage collection is starting", async () => {
|
||||
let releaseSave: () => void = () => undefined;
|
||||
let reportSaveStarted: () => void = () => undefined;
|
||||
const saveStarted = new Promise<void>((resolve) => {
|
||||
reportSaveStarted = resolve;
|
||||
});
|
||||
const saveGate = new Promise<void>((resolve) => {
|
||||
releaseSave = resolve;
|
||||
});
|
||||
const garbageCollections: string[][] = [];
|
||||
const store: AttachmentStore = {
|
||||
...createRecordingStore(),
|
||||
async save(input) {
|
||||
reportSaveStarted();
|
||||
await saveGate;
|
||||
return createAttachment({ id: input.id });
|
||||
},
|
||||
async garbageCollect({ referencedIds }) {
|
||||
garbageCollections.push([...referencedIds]);
|
||||
},
|
||||
};
|
||||
__setAttachmentStoreForTests(store);
|
||||
|
||||
const persist = persistAttachmentFromBytes({
|
||||
id: "assistant-preview",
|
||||
bytes: new Uint8Array([1, 2, 3]),
|
||||
mimeType: "image/png",
|
||||
});
|
||||
await saveStarted;
|
||||
const collect = garbageCollectAttachments({ referencedIds: new Set() });
|
||||
|
||||
try {
|
||||
await Promise.resolve();
|
||||
expect(garbageCollections).toEqual([]);
|
||||
} finally {
|
||||
releaseSave();
|
||||
await Promise.all([persist, collect]);
|
||||
}
|
||||
|
||||
expect(garbageCollections).toEqual([["assistant-preview"]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { collectRetainedAttachmentIds } from "@/attachments/gc-retention";
|
||||
import { getAttachmentStore } from "@/attachments/store";
|
||||
import type { AttachmentMetadata, SaveAttachmentInput } from "@/attachments/types";
|
||||
|
||||
const activePersistence = new Set<Promise<AttachmentMetadata>>();
|
||||
const persistedDuringGarbageCollection = new Set<string>();
|
||||
let pendingGarbageCollections = 0;
|
||||
let garbageCollectionTail: Promise<void> = Promise.resolve();
|
||||
let persistenceBarrier: Promise<void> | null = null;
|
||||
let releasePersistenceBarrier: (() => void) | null = null;
|
||||
|
||||
async function waitForPersistenceBarrier(): Promise<void> {
|
||||
const barrier = persistenceBarrier;
|
||||
if (!barrier) {
|
||||
return;
|
||||
}
|
||||
await barrier;
|
||||
await waitForPersistenceBarrier();
|
||||
}
|
||||
|
||||
async function persistAttachment(input: SaveAttachmentInput): Promise<AttachmentMetadata> {
|
||||
await waitForPersistenceBarrier();
|
||||
const pending = (async () => {
|
||||
const store = await getAttachmentStore();
|
||||
const attachment = await store.save(input);
|
||||
if (pendingGarbageCollections > 0) {
|
||||
persistedDuringGarbageCollection.add(attachment.id);
|
||||
}
|
||||
return attachment;
|
||||
})();
|
||||
activePersistence.add(pending);
|
||||
try {
|
||||
return await pending;
|
||||
} finally {
|
||||
activePersistence.delete(pending);
|
||||
}
|
||||
}
|
||||
|
||||
export async function persistAttachmentFromBlob(input: {
|
||||
blob: Blob;
|
||||
@@ -7,8 +42,7 @@ export async function persistAttachmentFromBlob(input: {
|
||||
fileName?: string | null;
|
||||
id?: string;
|
||||
}): Promise<AttachmentMetadata> {
|
||||
const store = await getAttachmentStore();
|
||||
return await store.save({
|
||||
return await persistAttachment({
|
||||
id: input.id,
|
||||
mimeType: input.mimeType,
|
||||
fileName: input.fileName,
|
||||
@@ -22,8 +56,7 @@ export async function persistAttachmentFromDataUrl(input: {
|
||||
fileName?: string | null;
|
||||
id?: string;
|
||||
}): Promise<AttachmentMetadata> {
|
||||
const store = await getAttachmentStore();
|
||||
return await store.save({
|
||||
return await persistAttachment({
|
||||
id: input.id,
|
||||
mimeType: input.mimeType,
|
||||
fileName: input.fileName,
|
||||
@@ -37,8 +70,7 @@ export async function persistAttachmentFromBytes(input: {
|
||||
fileName?: string | null;
|
||||
id?: string;
|
||||
}): Promise<AttachmentMetadata> {
|
||||
const store = await getAttachmentStore();
|
||||
return await store.save({
|
||||
return await persistAttachment({
|
||||
id: input.id,
|
||||
mimeType: input.mimeType,
|
||||
fileName: input.fileName,
|
||||
@@ -52,8 +84,7 @@ export async function persistAttachmentFromFileUri(input: {
|
||||
fileName?: string | null;
|
||||
id?: string;
|
||||
}): Promise<AttachmentMetadata> {
|
||||
const store = await getAttachmentStore();
|
||||
return await store.save({
|
||||
return await persistAttachment({
|
||||
id: input.id,
|
||||
mimeType: input.mimeType,
|
||||
fileName: input.fileName,
|
||||
@@ -133,6 +164,41 @@ export async function deleteAttachments(
|
||||
export async function garbageCollectAttachments(input: {
|
||||
referencedIds: ReadonlySet<string>;
|
||||
}): Promise<void> {
|
||||
const store = await getAttachmentStore();
|
||||
await store.garbageCollect({ referencedIds: input.referencedIds });
|
||||
pendingGarbageCollections += 1;
|
||||
if (!persistenceBarrier) {
|
||||
persistenceBarrier = new Promise<void>((resolve) => {
|
||||
releasePersistenceBarrier = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
const previousGarbageCollection = garbageCollectionTail;
|
||||
const currentGarbageCollection = (async () => {
|
||||
await previousGarbageCollection;
|
||||
while (activePersistence.size > 0) {
|
||||
await Promise.allSettled(activePersistence);
|
||||
}
|
||||
const referencedIds = new Set(input.referencedIds);
|
||||
for (const id of collectRetainedAttachmentIds()) {
|
||||
referencedIds.add(id);
|
||||
}
|
||||
for (const id of persistedDuringGarbageCollection) {
|
||||
referencedIds.add(id);
|
||||
}
|
||||
const store = await getAttachmentStore();
|
||||
await store.garbageCollect({ referencedIds });
|
||||
})();
|
||||
garbageCollectionTail = currentGarbageCollection.catch(() => undefined);
|
||||
|
||||
try {
|
||||
await currentGarbageCollection;
|
||||
} finally {
|
||||
pendingGarbageCollections -= 1;
|
||||
if (pendingGarbageCollections === 0) {
|
||||
persistedDuringGarbageCollection.clear();
|
||||
const release = releasePersistenceBarrier;
|
||||
releasePersistenceBarrier = null;
|
||||
persistenceBarrier = null;
|
||||
release?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createImageSourceCacheKey,
|
||||
createPreviewAttachmentId,
|
||||
fileUriToPath,
|
||||
localFileSourceToPath,
|
||||
parseDataUrl,
|
||||
@@ -86,4 +87,29 @@ describe("parseImageDataUrl", () => {
|
||||
it("ignores SVG data URLs", () => {
|
||||
expect(parseImageDataUrl("data:image/svg+xml;base64,PHN2ZyAvPg==")).toBeNull();
|
||||
});
|
||||
|
||||
it("distinguishes image data that differs only in the middle", () => {
|
||||
const prefix = "a".repeat(64);
|
||||
const suffix = "z".repeat(64);
|
||||
const first = `data:image/png;base64,${prefix}${"b".repeat(256)}${suffix}`;
|
||||
const second = `data:image/png;base64,${prefix}${"c".repeat(256)}${suffix}`;
|
||||
|
||||
expect(createImageSourceCacheKey(first)).not.toBe(createImageSourceCacheKey(second));
|
||||
});
|
||||
|
||||
it("gives equal-length preview content distinct attachment identities", () => {
|
||||
expect(
|
||||
createPreviewAttachmentId({
|
||||
mimeType: "image/png",
|
||||
contentLength: 512,
|
||||
contentKey: "first-content",
|
||||
}),
|
||||
).not.toBe(
|
||||
createPreviewAttachmentId({
|
||||
mimeType: "image/png",
|
||||
contentLength: 512,
|
||||
contentKey: "second-content",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ export function parseImageDataUrl(
|
||||
if (!isRasterImageMimeType(parsed.mimeType)) {
|
||||
return null;
|
||||
}
|
||||
const fingerprint = `${parsed.mimeType}\0${parsed.base64.length}\0${parsed.base64.slice(0, 64)}\0${parsed.base64.slice(-64)}`;
|
||||
const fingerprint = `${parsed.mimeType}\0${parsed.base64}`;
|
||||
return {
|
||||
...parsed,
|
||||
cacheKey: `data-image:${parsed.mimeType}:${parsed.base64.length}:${hashString(fingerprint)}`,
|
||||
@@ -87,12 +87,15 @@ export function createPreviewAttachmentId(input: {
|
||||
size?: number | null;
|
||||
modifiedAt?: string | null;
|
||||
contentLength?: number | null;
|
||||
contentKey?: string | null;
|
||||
}): string {
|
||||
const path = input.path?.trim() ?? "";
|
||||
const size = Number.isFinite(input.size) ? String(input.size) : "";
|
||||
const modifiedAt = input.modifiedAt?.trim() ?? "";
|
||||
const contentLength = Number.isFinite(input.contentLength) ? String(input.contentLength) : "";
|
||||
const hash = hashString(`${input.mimeType}\0${path}\0${size}\0${modifiedAt}\0${contentLength}`);
|
||||
const contentKey = input.contentKey?.trim() ?? "";
|
||||
const identity = `${input.mimeType}\0${path}\0${size}\0${modifiedAt}\0${contentLength}`;
|
||||
const hash = hashString(contentKey ? `${identity}\0${contentKey}` : identity);
|
||||
return `preview_${size || contentLength || "unknown"}_${hash}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
} from "react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { MarkdownIt, type ASTNode, type RenderRules } from "react-native-markdown-display";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import MaskedView from "@react-native-masked-view/masked-view";
|
||||
import {
|
||||
Circle,
|
||||
@@ -75,19 +74,7 @@ import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
||||
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
|
||||
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
|
||||
import {
|
||||
getAssistantImageLoadStateFromMetadata,
|
||||
getAssistantImageMetadata,
|
||||
setAssistantImageMetadata,
|
||||
type AssistantImageLoadState,
|
||||
} from "@/utils/assistant-image-metadata";
|
||||
import { setAssistantMarkdownBlockHeight } from "@/utils/assistant-message-height-estimate";
|
||||
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
|
||||
import {
|
||||
createPreviewAttachmentId,
|
||||
getFileNameFromPath,
|
||||
parseImageDataUrl,
|
||||
} from "@/attachments/utils";
|
||||
import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content";
|
||||
import { PlanCard } from "./plan-card";
|
||||
import { useToolCallSheet } from "./tool-call-sheet";
|
||||
@@ -102,8 +89,7 @@ import {
|
||||
useAssistantLinkPress,
|
||||
} from "@/assistant-file-links";
|
||||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
|
||||
import { useAssistantImage } from "@/assistant-image/use-assistant-image";
|
||||
import {
|
||||
AttachmentFrame,
|
||||
AttachmentLabel,
|
||||
@@ -132,6 +118,7 @@ interface UserMessageProps {
|
||||
client?: DaemonClient | null;
|
||||
isFirstInGroup?: boolean;
|
||||
isLastInGroup?: boolean;
|
||||
isPending?: boolean;
|
||||
disableOuterSpacing?: boolean;
|
||||
}
|
||||
|
||||
@@ -430,6 +417,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
client,
|
||||
isFirstInGroup = true,
|
||||
isLastInGroup = true,
|
||||
isPending = false,
|
||||
disableOuterSpacing,
|
||||
}: UserMessageProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
@@ -441,7 +429,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
const hasText = message.trim().length > 0;
|
||||
const hasImages = images.length > 0;
|
||||
const hasAttachments = attachments.length > 0;
|
||||
const showTrailingRow = hasText && (isCompact || isNative || isHovered);
|
||||
const showTrailingRow = !isPending && hasText && (isCompact || isNative || isHovered);
|
||||
const formattedTimestamp = useMemo(
|
||||
() => formatMessageTimestamp(new Date(timestamp)),
|
||||
[timestamp],
|
||||
@@ -494,7 +482,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={containerStyle} testID="user-message">
|
||||
<View style={containerStyle} testID="user-message" aria-busy={isPending}>
|
||||
<View
|
||||
style={userMessageStylesheet.content}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
@@ -538,9 +526,15 @@ export const UserMessage = memo(function UserMessage({
|
||||
) : null}
|
||||
</View>
|
||||
{hasText ? (
|
||||
<View style={trailingRowStyle} pointerEvents={showTrailingRow ? "auto" : "none"}>
|
||||
<Text style={userMessageStylesheet.timestampText}>{formattedTimestamp}</Text>
|
||||
{capabilities ? (
|
||||
<View
|
||||
style={trailingRowStyle}
|
||||
pointerEvents={showTrailingRow ? "auto" : "none"}
|
||||
testID="user-message-trailing-row"
|
||||
>
|
||||
<Text style={userMessageStylesheet.timestampText} testID="user-message-timestamp">
|
||||
{formattedTimestamp}
|
||||
</Text>
|
||||
{capabilities && messageId ? (
|
||||
<RewindMenu
|
||||
capabilities={capabilities}
|
||||
isPending={rewindMutation.isPending}
|
||||
@@ -729,6 +723,7 @@ export const LiveElapsed = memo(function LiveElapsed({
|
||||
});
|
||||
|
||||
interface AssistantMessageProps {
|
||||
occurrenceKey: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
workspaceRoot?: string;
|
||||
@@ -756,11 +751,21 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
imageSurface: {
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
},
|
||||
image: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
imageLoadingOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
imageState: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -777,125 +782,9 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
|
||||
const ASSISTANT_IMAGE_MIN_HEIGHT = 160;
|
||||
|
||||
const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedImage({
|
||||
uri,
|
||||
alt,
|
||||
containerStyle,
|
||||
source,
|
||||
workspaceRoot,
|
||||
serverId,
|
||||
}: {
|
||||
uri: string;
|
||||
alt?: string;
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
source: string;
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
}) {
|
||||
const cachedMetadata = useMemo(
|
||||
() => getAssistantImageMetadata({ source, workspaceRoot, serverId }),
|
||||
[serverId, source, workspaceRoot],
|
||||
);
|
||||
const [loadState, setLoadState] = useState<AssistantImageLoadState>(() =>
|
||||
getAssistantImageLoadStateFromMetadata(cachedMetadata),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (cachedMetadata) {
|
||||
setLoadState(getAssistantImageLoadStateFromMetadata(cachedMetadata));
|
||||
return () => {};
|
||||
}
|
||||
|
||||
setLoadState({ status: "loading" });
|
||||
let cancelled = false;
|
||||
|
||||
Image.getSize(
|
||||
uri,
|
||||
(width, height) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (width > 0 && height > 0) {
|
||||
const metadata = setAssistantImageMetadata(
|
||||
{ source, workspaceRoot, serverId },
|
||||
{ width, height },
|
||||
);
|
||||
setLoadState({
|
||||
status: "ready",
|
||||
aspectRatio: metadata?.aspectRatio ?? width / height,
|
||||
});
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setLoadState({ status: "error" });
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cachedMetadata, serverId, source, uri, workspaceRoot]);
|
||||
|
||||
const handleImageError = useCallback(() => {
|
||||
setLoadState({ status: "error" });
|
||||
}, []);
|
||||
const { t } = useTranslation();
|
||||
const surfaceStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [
|
||||
assistantMessageStylesheet.imageSurface,
|
||||
loadState.status === "ready"
|
||||
? { aspectRatio: loadState.aspectRatio }
|
||||
: { height: ASSISTANT_IMAGE_MIN_HEIGHT },
|
||||
],
|
||||
[loadState],
|
||||
);
|
||||
const frameStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [assistantMessageStylesheet.imageFrame, containerStyle],
|
||||
[containerStyle],
|
||||
);
|
||||
const stateSurfaceStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [surfaceStyle, assistantMessageStylesheet.imageState],
|
||||
[surfaceStyle],
|
||||
);
|
||||
const imageSource = useMemo(() => ({ uri }), [uri]);
|
||||
|
||||
if (loadState.status !== "ready") {
|
||||
return (
|
||||
<View style={frameStyle}>
|
||||
<View style={stateSurfaceStyle}>
|
||||
{loadState.status === "loading" ? (
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
) : null}
|
||||
{loadState.status === "error" ? (
|
||||
<Text style={assistantMessageStylesheet.imageErrorText}>
|
||||
{t("message.attachments.imageUnavailable")}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={frameStyle}>
|
||||
<View style={surfaceStyle}>
|
||||
<Image
|
||||
source={imageSource}
|
||||
style={assistantMessageStylesheet.image}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel={alt}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
function AssistantMarkdownImage({
|
||||
source,
|
||||
occurrenceKey,
|
||||
alt,
|
||||
hasLeadingContent,
|
||||
client,
|
||||
@@ -903,18 +792,13 @@ function AssistantMarkdownImage({
|
||||
serverId,
|
||||
}: {
|
||||
source: string;
|
||||
occurrenceKey: string;
|
||||
alt?: string;
|
||||
hasLeadingContent: boolean;
|
||||
client?: DaemonClient | null;
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const resolution = useMemo(
|
||||
() => resolveAssistantImageSource({ source, workspaceRoot }),
|
||||
[source, workspaceRoot],
|
||||
);
|
||||
const dataImage = useMemo(() => parseImageDataUrl(source), [source]);
|
||||
const containerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => ({
|
||||
marginTop: hasLeadingContent ? 16 : 0,
|
||||
@@ -922,64 +806,31 @@ function AssistantMarkdownImage({
|
||||
}),
|
||||
[hasLeadingContent],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [
|
||||
"assistantMarkdownImage",
|
||||
serverId ?? "unknown-server",
|
||||
resolution?.kind === "file_rpc" ? resolution.cwd : null,
|
||||
resolution?.kind === "file_rpc" ? resolution.path : null,
|
||||
],
|
||||
enabled: Boolean(client && resolution?.kind === "file_rpc"),
|
||||
staleTime: 30_000,
|
||||
queryFn: async () => {
|
||||
if (!client || !resolution || resolution.kind !== "file_rpc") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const file = await client.readFile(resolution.cwd, resolution.path);
|
||||
if (file.kind !== "image") {
|
||||
throw new Error(t("message.attachments.imagePreviewUnavailable"));
|
||||
}
|
||||
|
||||
return await persistAttachmentFromBytes({
|
||||
id: createPreviewAttachmentId({
|
||||
mimeType: file.mime,
|
||||
path: file.path || resolution.path,
|
||||
size: file.size,
|
||||
modifiedAt: file.modifiedAt,
|
||||
contentLength: file.bytes.byteLength,
|
||||
}),
|
||||
bytes: file.bytes,
|
||||
mimeType: file.mime,
|
||||
fileName: getFileNameFromPath(file.path || resolution.path),
|
||||
});
|
||||
},
|
||||
const image = useAssistantImage({
|
||||
source,
|
||||
occurrenceKey,
|
||||
client,
|
||||
workspaceRoot,
|
||||
serverId,
|
||||
});
|
||||
const dataImageQuery = useQuery({
|
||||
queryKey: ["assistantMarkdownDataImage", dataImage?.cacheKey ?? null],
|
||||
enabled: dataImage !== null,
|
||||
staleTime: 30_000,
|
||||
queryFn: async () => {
|
||||
if (!dataImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await persistAttachmentFromDataUrl({
|
||||
id: createPreviewAttachmentId({
|
||||
mimeType: dataImage.mimeType,
|
||||
contentLength: dataImage.base64.length,
|
||||
}),
|
||||
dataUrl: source,
|
||||
mimeType: dataImage.mimeType,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const fileAssetUri = useAttachmentPreviewUrl(query.data);
|
||||
const dataImageAssetUri = useAttachmentPreviewUrl(dataImageQuery.data);
|
||||
const directUri = resolution?.kind === "direct" && !dataImage ? resolution.uri : null;
|
||||
const resolvedUri = directUri ?? dataImageAssetUri ?? fileAssetUri ?? null;
|
||||
const binding = image.status === "failed" ? null : image.binding;
|
||||
const aspectRatio = image.status === "failed" ? null : image.aspectRatio;
|
||||
const imageUri = binding?.uri ?? "";
|
||||
const imageSource = useMemo(() => ({ uri: imageUri }), [imageUri]);
|
||||
const frameStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [assistantMessageStylesheet.imageFrame, containerStyle],
|
||||
[containerStyle],
|
||||
);
|
||||
const imageSizeStyle = useMemo<ViewStyle>(() => {
|
||||
if (aspectRatio) {
|
||||
return { aspectRatio };
|
||||
}
|
||||
return { height: ASSISTANT_IMAGE_MIN_HEIGHT };
|
||||
}, [aspectRatio]);
|
||||
const surfaceStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [assistantMessageStylesheet.imageSurface, imageSizeStyle],
|
||||
[imageSizeStyle],
|
||||
);
|
||||
|
||||
const stateFrameStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [
|
||||
@@ -991,20 +842,15 @@ function AssistantMarkdownImage({
|
||||
[containerStyle],
|
||||
);
|
||||
|
||||
if (resolvedUri) {
|
||||
if (image.status === "failed") {
|
||||
return (
|
||||
<AssistantMarkdownResolvedImage
|
||||
uri={resolvedUri}
|
||||
alt={alt}
|
||||
containerStyle={containerStyle}
|
||||
source={source}
|
||||
workspaceRoot={workspaceRoot}
|
||||
serverId={serverId}
|
||||
/>
|
||||
<View style={stateFrameStyle}>
|
||||
<Text style={assistantMessageStylesheet.imageErrorText}>{image.message}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isLoading || dataImageQuery.isLoading) {
|
||||
if (!binding) {
|
||||
return (
|
||||
<View style={stateFrameStyle}>
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
@@ -1012,29 +858,27 @@ function AssistantMarkdownImage({
|
||||
);
|
||||
}
|
||||
|
||||
const errorText = resolveAssistantImageErrorText(
|
||||
query.error,
|
||||
dataImageQuery.error,
|
||||
t("message.attachments.imagePreviewLoadFailed"),
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={stateFrameStyle}>
|
||||
<Text style={assistantMessageStylesheet.imageErrorText}>{errorText}</Text>
|
||||
<View style={frameStyle}>
|
||||
<View style={surfaceStyle} accessibilityRole="image" accessibilityLabel={alt}>
|
||||
<Image
|
||||
ref={binding.onRef}
|
||||
source={imageSource}
|
||||
style={assistantMessageStylesheet.image}
|
||||
resizeMode="contain"
|
||||
onLoad={binding.onLoad}
|
||||
onError={binding.onError}
|
||||
/>
|
||||
{image.status === "loading" ? (
|
||||
<View pointerEvents="none" style={assistantMessageStylesheet.imageLoadingOverlay}>
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAssistantImageErrorText(
|
||||
fileError: unknown,
|
||||
dataError: unknown,
|
||||
fallbackText: string,
|
||||
): string {
|
||||
if (fileError instanceof Error) return fileError.message;
|
||||
if (dataError instanceof Error) return dataError.message;
|
||||
return fallbackText;
|
||||
}
|
||||
|
||||
function getInlineCodeAutoLinkUrl(
|
||||
markdownParser: ReturnType<typeof MarkdownIt>,
|
||||
content: string,
|
||||
@@ -1575,6 +1419,7 @@ function MarkdownListView({ baseStyle, spacing, children }: MarkdownListViewProp
|
||||
}
|
||||
|
||||
export const AssistantMessage = memo(function AssistantMessage({
|
||||
occurrenceKey,
|
||||
message,
|
||||
timestamp: _timestamp,
|
||||
workspaceRoot,
|
||||
@@ -1909,6 +1754,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
<AssistantMarkdownImage
|
||||
key={node.key}
|
||||
source={String(node.attributes?.src ?? "")}
|
||||
occurrenceKey={`${occurrenceKey}:${node.key}`}
|
||||
alt={typeof node.attributes?.alt === "string" ? node.attributes.alt : undefined}
|
||||
hasLeadingContent={hasLeadingContent}
|
||||
client={client}
|
||||
@@ -1918,7 +1764,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [client, fileLinkActions, markdownParser, serverId, workspaceRoot]);
|
||||
}, [client, fileLinkActions, markdownParser, occurrenceKey, serverId, workspaceRoot]);
|
||||
|
||||
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
|
||||
const keyedBlocks = useMemo(
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { RewindMode } from "./use-rewind-capabilities";
|
||||
import { useRewindComposerRestore } from "./composer-restore";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
|
||||
import { clearOptimisticUserMessages } from "@/types/stream";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
|
||||
interface UseRewindAgentMutationInput {
|
||||
@@ -36,13 +35,6 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
|
||||
}
|
||||
await input.client.rewindAgent(input.agentId, input.messageId, mode);
|
||||
if (mode !== "files") {
|
||||
if (input.serverId) {
|
||||
const session = useSessionStore.getState().sessions[input.serverId];
|
||||
useSessionStore.getState().setAgentStreamState(input.serverId, input.agentId, {
|
||||
tail: clearOptimisticUserMessages(session?.agentStreamTail.get(input.agentId) ?? []),
|
||||
head: clearOptimisticUserMessages(session?.agentStreamHead.get(input.agentId) ?? []),
|
||||
});
|
||||
}
|
||||
const cursor = input.serverId
|
||||
? useSessionStore
|
||||
.getState()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import { ActivityIndicator, type ActivityIndicatorProps } from "react-native";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
|
||||
@@ -6,7 +6,17 @@ import type {
|
||||
UserComposerAttachment,
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
appendSubmittedUserMessage,
|
||||
removeSubmittedUserMessage,
|
||||
type StreamItem,
|
||||
} from "@/types/stream";
|
||||
import {
|
||||
acceptMessageSubmission,
|
||||
beginMessageSubmission,
|
||||
rejectMessageSubmission,
|
||||
type MessageSubmissionRecord,
|
||||
} from "@/composer/submission/model";
|
||||
import {
|
||||
cancelComposerAgent,
|
||||
dispatchComposerAgentMessage,
|
||||
@@ -20,7 +30,7 @@ import {
|
||||
sendQueuedComposerMessageNow,
|
||||
toggleGithubAttachment,
|
||||
toggleGithubAttachmentFromPicker,
|
||||
type AgentStreamWriter,
|
||||
type MessageSubmissionWriter,
|
||||
type AttachmentPersister,
|
||||
type ComposerCancelClient,
|
||||
type ComposerSendClient,
|
||||
@@ -168,14 +178,16 @@ interface FakeSendCall {
|
||||
}
|
||||
|
||||
function createFakeSendClient(
|
||||
options: { rejection?: Error } = {},
|
||||
options: { rejection?: Error; beforeRejection?: (call: FakeSendCall) => void } = {},
|
||||
): ComposerSendClient & { calls: FakeSendCall[] } {
|
||||
const calls: FakeSendCall[] = [];
|
||||
return {
|
||||
calls,
|
||||
sendAgentMessage: async (agentId, text, opts) => {
|
||||
calls.push({ agentId, text, options: opts });
|
||||
const call = { agentId, text, options: opts };
|
||||
calls.push(call);
|
||||
if (options.rejection) {
|
||||
options.beforeRejection?.(call);
|
||||
throw options.rejection;
|
||||
}
|
||||
},
|
||||
@@ -183,7 +195,7 @@ function createFakeSendClient(
|
||||
};
|
||||
}
|
||||
|
||||
interface FakeStream extends AgentStreamWriter {
|
||||
interface FakeStream extends MessageSubmissionWriter {
|
||||
head: Map<string, StreamItem[]>;
|
||||
tail: Map<string, StreamItem[]>;
|
||||
}
|
||||
@@ -192,18 +204,70 @@ function createFakeStream(initialHead: Map<string, StreamItem[]> = new Map()): F
|
||||
const fake: FakeStream = {
|
||||
head: new Map(initialHead),
|
||||
tail: new Map(),
|
||||
getTail: (agentId) => fake.tail.get(agentId),
|
||||
getHead: (agentId) => fake.head.get(agentId),
|
||||
setHead: (updater) => {
|
||||
fake.head = updater(fake.head);
|
||||
begin: (agentId, message) => {
|
||||
const current = readSubmission(fake, agentId);
|
||||
const stream = appendSubmittedUserMessage({
|
||||
tail: current.tail,
|
||||
head: current.head,
|
||||
message,
|
||||
});
|
||||
writeSubmission(fake, agentId, {
|
||||
...stream,
|
||||
submissions: beginMessageSubmission(current.submissions, {
|
||||
clientMessageId: message.clientMessageId!,
|
||||
submittedAt: message.timestamp,
|
||||
}),
|
||||
});
|
||||
},
|
||||
setTail: (updater) => {
|
||||
fake.tail = updater(fake.tail);
|
||||
accept: (agentId, clientMessageId) => {
|
||||
const current = readSubmission(fake, agentId);
|
||||
writeSubmission(fake, agentId, {
|
||||
...current,
|
||||
submissions: acceptMessageSubmission(current.submissions, clientMessageId, true, false),
|
||||
});
|
||||
},
|
||||
reject: (agentId, clientMessageId) => {
|
||||
const current = readSubmission(fake, agentId);
|
||||
const result = rejectMessageSubmission(current.submissions, clientMessageId);
|
||||
const stream =
|
||||
result.outcome === "rejected"
|
||||
? removeSubmittedUserMessage({
|
||||
tail: current.tail,
|
||||
head: current.head,
|
||||
clientMessageId,
|
||||
})
|
||||
: current;
|
||||
writeSubmission(fake, agentId, { ...stream, submissions: result.submissions });
|
||||
return result.outcome;
|
||||
},
|
||||
};
|
||||
return fake;
|
||||
}
|
||||
|
||||
const submissionsByFakeStream = new WeakMap<FakeStream, Map<string, MessageSubmissionRecord[]>>();
|
||||
|
||||
interface FakeSubmissionState {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
submissions: MessageSubmissionRecord[];
|
||||
}
|
||||
|
||||
function readSubmission(fake: FakeStream, agentId: string): FakeSubmissionState {
|
||||
return {
|
||||
tail: fake.tail.get(agentId) ?? [],
|
||||
head: fake.head.get(agentId) ?? [],
|
||||
submissions: submissionsByFakeStream.get(fake)?.get(agentId) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function writeSubmission(fake: FakeStream, agentId: string, state: FakeSubmissionState): void {
|
||||
fake.tail = new Map(fake.tail).set(agentId, state.tail);
|
||||
fake.head = new Map(fake.head).set(agentId, state.head);
|
||||
const submissions = submissionsByFakeStream.get(fake) ?? new Map();
|
||||
submissions.set(agentId, state.submissions);
|
||||
submissionsByFakeStream.set(fake, submissions);
|
||||
}
|
||||
|
||||
function createFakeQueue(
|
||||
initial: Map<string, QueuedComposerMessage[]> = new Map(),
|
||||
): QueueWriter & { state: Map<string, QueuedComposerMessage[]> } {
|
||||
@@ -337,7 +401,7 @@ describe("pickAndPersistImages", () => {
|
||||
});
|
||||
|
||||
describe("dispatchComposerAgentMessage", () => {
|
||||
it("removes the optimistic prompt when the host rejects it", async () => {
|
||||
it("removes the submitted prompt when the host rejects it", async () => {
|
||||
const rejection = new Error("Host rejected prompt");
|
||||
const client = createFakeSendClient({ rejection });
|
||||
const stream = createFakeStream();
|
||||
@@ -349,14 +413,54 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
text: "rejected prompt",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
submission: stream,
|
||||
}),
|
||||
).rejects.toBe(rejection);
|
||||
|
||||
expect(stream.head.get("agent")).toBeUndefined();
|
||||
expect(stream.head.get("agent")).toEqual([]);
|
||||
expect(stream.tail.get("agent") ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it("rolls back an already-running force send when its RPC fails", async () => {
|
||||
const stream = createFakeStream();
|
||||
const transportError = new Error("Force send failed while the prior turn was running");
|
||||
const client = createFakeSendClient({ rejection: transportError });
|
||||
|
||||
await expect(
|
||||
dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "force send",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
submission: stream,
|
||||
}),
|
||||
).rejects.toBe(transportError);
|
||||
|
||||
expect(stream.tail.get("agent") ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not swallow a transport error when submission state is missing", async () => {
|
||||
const transportError = new Error("Connection lost with unknown submission state");
|
||||
const client = createFakeSendClient({ rejection: transportError });
|
||||
const submission: MessageSubmissionWriter = {
|
||||
begin: () => {},
|
||||
accept: () => {},
|
||||
reject: () => "unknown",
|
||||
};
|
||||
|
||||
await expect(
|
||||
dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "unknown state",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
submission,
|
||||
}),
|
||||
).rejects.toBe(transportError);
|
||||
});
|
||||
|
||||
it("sends text + image data + structured attachments and appends user_message to the tail when head is empty", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
@@ -371,7 +475,7 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
{ kind: "github_pr", item: prItem },
|
||||
],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
submission: stream,
|
||||
});
|
||||
|
||||
expect(client.calls).toHaveLength(1);
|
||||
@@ -393,7 +497,7 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(stream.head.get("agent")).toBeUndefined();
|
||||
expect(stream.head.get("agent")).toEqual([]);
|
||||
const tail = stream.tail.get("agent");
|
||||
expect(tail).toHaveLength(1);
|
||||
const userMessage = tail?.[0] as Extract<StreamItem, { kind: "user_message" }>;
|
||||
@@ -402,7 +506,8 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
expect(userMessage.images).toEqual([image]);
|
||||
expect(userMessage.attachments).toEqual(call.options.attachments);
|
||||
expect(userMessage.id).toBe(call.options.messageId);
|
||||
expect(userMessage.optimistic).toBe(true);
|
||||
expect(userMessage.clientMessageId).toBe(call.options.messageId);
|
||||
expect(userMessage.messageId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("can send legacy GitHub attachment payloads for old daemons", async () => {
|
||||
@@ -416,7 +521,7 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
attachments: [{ kind: "forge_change_request", item: prItem }],
|
||||
attachmentSubmitFormat: "legacy-github",
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
submission: stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0].options.attachments).toEqual([
|
||||
@@ -449,11 +554,11 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
text: "next message",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
submission: stream,
|
||||
});
|
||||
|
||||
expect(stream.head.get("agent")).toHaveLength(2);
|
||||
expect(stream.tail.get("agent")).toBeUndefined();
|
||||
expect(stream.tail.get("agent")).toEqual([]);
|
||||
});
|
||||
|
||||
it("submits empty wire arrays when no attachments are provided", async () => {
|
||||
@@ -466,7 +571,7 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
text: "plain message",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
submission: stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options).toMatchObject({
|
||||
@@ -486,7 +591,7 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
text: "review this",
|
||||
attachments: [review],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
submission: stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options.attachments).toEqual([review.attachment]);
|
||||
@@ -504,7 +609,7 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
text: "inspect element",
|
||||
attachments: [browserElement],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
submission: stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options.attachments).toEqual([
|
||||
|
||||
@@ -12,13 +12,8 @@ import {
|
||||
splitComposerAttachmentsForSubmit,
|
||||
type ComposerAttachmentSubmitFormat,
|
||||
} from "@/composer/attachments/submit";
|
||||
import {
|
||||
appendOptimisticUserMessageToStream,
|
||||
buildOptimisticUserMessage,
|
||||
generateMessageId,
|
||||
type StreamItem,
|
||||
type UserMessageItem,
|
||||
} from "@/types/stream";
|
||||
import { createUserMessage, generateMessageId, type UserMessageItem } from "@/types/stream";
|
||||
import type { MessageSubmissionRejectionOutcome } from "@/composer/submission/model";
|
||||
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
@@ -51,7 +46,7 @@ export interface ComposerSendClient {
|
||||
images: Array<{ data: string; mimeType: string }>;
|
||||
attachments: ReturnType<typeof splitComposerAttachmentsForSubmit>["attachments"];
|
||||
},
|
||||
) => Promise<void>;
|
||||
) => Promise<void | { outOfBand?: boolean }>;
|
||||
uploadFile: (input: { fileName: string; mimeType: string; bytes: Uint8Array }) => Promise<{
|
||||
requestId: string;
|
||||
file: {
|
||||
@@ -70,11 +65,10 @@ export interface ComposerCancelClient {
|
||||
cancelAgent: (agentId: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface AgentStreamWriter {
|
||||
getTail: (agentId: string) => StreamItem[] | undefined;
|
||||
getHead: (agentId: string) => StreamItem[] | undefined;
|
||||
setHead: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
|
||||
setTail: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
|
||||
export interface MessageSubmissionWriter {
|
||||
begin: (agentId: string, message: UserMessageItem) => void;
|
||||
accept: (agentId: string, clientMessageId: string, outOfBand: boolean | undefined) => void;
|
||||
reject: (agentId: string, clientMessageId: string) => MessageSubmissionRejectionOutcome;
|
||||
}
|
||||
|
||||
export interface QueueWriter {
|
||||
@@ -169,7 +163,7 @@ export interface DispatchComposerAgentMessageInput {
|
||||
encodeImages: (
|
||||
images: AttachmentMetadata[],
|
||||
) => Promise<Array<{ data: string; mimeType: string }> | undefined>;
|
||||
stream: AgentStreamWriter;
|
||||
submission: MessageSubmissionWriter;
|
||||
}
|
||||
|
||||
export async function dispatchComposerAgentMessage(
|
||||
@@ -178,60 +172,30 @@ export async function dispatchComposerAgentMessage(
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, {
|
||||
format: input.attachmentSubmitFormat,
|
||||
});
|
||||
const messageId = generateMessageId();
|
||||
const userMessage = buildOptimisticUserMessage({
|
||||
id: messageId,
|
||||
const clientMessageId = generateMessageId();
|
||||
const userMessage = createUserMessage({
|
||||
clientMessageId,
|
||||
text: input.text,
|
||||
timestamp: new Date(),
|
||||
images: wirePayload.images,
|
||||
attachments: wirePayload.attachments,
|
||||
});
|
||||
const rollbackOptimisticMessage = appendUserMessageToStream(
|
||||
input.agentId,
|
||||
userMessage,
|
||||
input.stream,
|
||||
);
|
||||
input.submission.begin(input.agentId, userMessage);
|
||||
try {
|
||||
const imagesData = await input.encodeImages(wirePayload.images);
|
||||
await input.client.sendAgentMessage(input.agentId, input.text, {
|
||||
messageId,
|
||||
const result = await input.client.sendAgentMessage(input.agentId, input.text, {
|
||||
messageId: clientMessageId,
|
||||
images: imagesData ?? [],
|
||||
attachments: wirePayload.attachments,
|
||||
});
|
||||
input.submission.accept(input.agentId, clientMessageId, result?.outOfBand);
|
||||
} catch (error) {
|
||||
rollbackOptimisticMessage();
|
||||
const outcome = input.submission.reject(input.agentId, clientMessageId);
|
||||
if (outcome === "accepted") return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function appendUserMessageToStream(
|
||||
agentId: string,
|
||||
userMessage: UserMessageItem,
|
||||
stream: AgentStreamWriter,
|
||||
): () => void {
|
||||
const result = appendOptimisticUserMessageToStream({
|
||||
tail: stream.getTail(agentId) ?? [],
|
||||
head: stream.getHead(agentId) ?? [],
|
||||
message: userMessage,
|
||||
placement: "active-head",
|
||||
});
|
||||
const write = result.changedHead ? stream.setHead : stream.setTail;
|
||||
const items = result.changedHead ? result.head : result.tail;
|
||||
write((prev) => new Map(prev).set(agentId, items));
|
||||
|
||||
return () => {
|
||||
write((prev) => {
|
||||
const current = prev.get(agentId);
|
||||
if (!current) return prev;
|
||||
const nextItems = current.filter(
|
||||
(item) => item.id !== userMessage.id || item.kind !== "user_message" || !item.optimistic,
|
||||
);
|
||||
if (nextItems.length === current.length) return prev;
|
||||
return new Map(prev).set(agentId, nextItems);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export interface QueueComposerMessageInput {
|
||||
agentId: string;
|
||||
text: string;
|
||||
|
||||
@@ -13,7 +13,7 @@ describe("useDraftAgentCreateFlow", () => {
|
||||
useCreateFlowStore.setState({ pendingByDraftId: {} });
|
||||
});
|
||||
|
||||
it("renders a prepared new-workspace create attempt as optimistic chat before continuing it", async () => {
|
||||
it("renders a prepared new-workspace submission before continuing it", async () => {
|
||||
const image: UserMessageImageAttachment = {
|
||||
id: "image-1",
|
||||
mimeType: "image/png",
|
||||
@@ -60,13 +60,13 @@ describe("useDraftAgentCreateFlow", () => {
|
||||
|
||||
expect(result.current.isSubmitting).toBe(true);
|
||||
expect(result.current.draftAgent).toEqual({ currentAttempt: attempt });
|
||||
expect(result.current.optimisticStreamItems).toEqual([
|
||||
expect(result.current.submittedStreamItems).toEqual([
|
||||
{
|
||||
kind: "user_message",
|
||||
id: "msg-prepared",
|
||||
clientMessageId: "msg-prepared",
|
||||
text: "build this",
|
||||
timestamp: attempt.timestamp,
|
||||
optimistic: true,
|
||||
images: [image],
|
||||
attachments: [attachment],
|
||||
},
|
||||
|
||||
@@ -8,12 +8,13 @@ import {
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
buildOptimisticUserMessage,
|
||||
createUserMessage,
|
||||
generateMessageId,
|
||||
type StreamItem,
|
||||
type UserMessageImageAttachment,
|
||||
} from "@/types/stream";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { PendingMessageSubmission } from "@/composer/submission/model";
|
||||
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
|
||||
@@ -133,7 +134,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
|
||||
const isSubmitting = machine.tag === "creating";
|
||||
|
||||
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
|
||||
const submittedStreamItems = useMemo<StreamItem[]>(() => {
|
||||
if (machine.tag !== "creating") {
|
||||
return EMPTY_STREAM_ITEMS;
|
||||
}
|
||||
@@ -147,8 +148,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
}
|
||||
|
||||
return [
|
||||
buildOptimisticUserMessage({
|
||||
id: machine.attempt.clientMessageId,
|
||||
createUserMessage({
|
||||
clientMessageId: machine.attempt.clientMessageId,
|
||||
text: machine.attempt.text,
|
||||
timestamp: machine.attempt.timestamp,
|
||||
images: machine.attempt.images,
|
||||
@@ -156,6 +157,15 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
}),
|
||||
];
|
||||
}, [machine]);
|
||||
const pendingMessageSubmissions = useMemo<readonly PendingMessageSubmission[]>(() => {
|
||||
if (machine.tag !== "creating") return [];
|
||||
return [
|
||||
{
|
||||
clientMessageId: machine.attempt.clientMessageId,
|
||||
submittedAt: machine.attempt.timestamp,
|
||||
},
|
||||
];
|
||||
}, [machine]);
|
||||
|
||||
const draftAgent = useMemo<TDraftAgent | null>(() => {
|
||||
if (machine.tag !== "creating") {
|
||||
@@ -195,8 +205,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
handoffCreatedAgentUserMessage(
|
||||
pendingServerId,
|
||||
createResult.agentId,
|
||||
buildOptimisticUserMessage({
|
||||
id: attempt.clientMessageId,
|
||||
createUserMessage({
|
||||
clientMessageId: attempt.clientMessageId,
|
||||
text: attempt.text,
|
||||
timestamp: attempt.timestamp,
|
||||
images: attempt.images,
|
||||
@@ -326,7 +336,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
machine,
|
||||
formErrorMessage,
|
||||
isSubmitting,
|
||||
optimisticStreamItems,
|
||||
submittedStreamItems,
|
||||
pendingMessageSubmissions,
|
||||
draftAgent,
|
||||
handleCreateFromInput,
|
||||
continueCreateFromAttempt,
|
||||
|
||||
@@ -479,7 +479,8 @@ export function WorkspaceDraftAgentTab({
|
||||
const {
|
||||
formErrorMessage,
|
||||
isSubmitting,
|
||||
optimisticStreamItems,
|
||||
submittedStreamItems,
|
||||
pendingMessageSubmissions,
|
||||
draftAgent,
|
||||
handleCreateFromInput,
|
||||
continueCreateFromAttempt,
|
||||
@@ -642,7 +643,8 @@ export function WorkspaceDraftAgentTab({
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
context={draftAgent}
|
||||
streamItems={optimisticStreamItems}
|
||||
streamItems={submittedStreamItems}
|
||||
pendingMessageSubmissions={pendingMessageSubmissions}
|
||||
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
|
||||
@@ -64,7 +64,6 @@ import {
|
||||
sendQueuedComposerMessageNow,
|
||||
toggleGithubAttachmentFromPicker,
|
||||
uploadFileAttachments,
|
||||
type AgentStreamWriter,
|
||||
type QueueWriter,
|
||||
type QueuedComposerMessage,
|
||||
} from "@/composer/actions";
|
||||
@@ -91,6 +90,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
import { submitAgentInput } from "@/composer/submit";
|
||||
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
|
||||
import { ComposerKeyboardScopeProvider } from "@/composer/keyboard-scope";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
@@ -1079,8 +1079,6 @@ export function Composer({
|
||||
const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY;
|
||||
|
||||
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
|
||||
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
||||
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
|
||||
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
|
||||
@@ -1283,12 +1281,6 @@ export function Composer({
|
||||
if (!client) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
const stream: AgentStreamWriter = {
|
||||
getTail: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamTail?.get(id),
|
||||
getHead: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamHead?.get(id),
|
||||
setHead: (updater) => setAgentStreamHead(serverId, updater),
|
||||
setTail: (updater) => setAgentStreamTail(serverId, updater),
|
||||
};
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: targetAgentId,
|
||||
@@ -1298,19 +1290,11 @@ export function Composer({
|
||||
supportsForgeAttachments: supportsForgeSearch,
|
||||
}),
|
||||
encodeImages,
|
||||
stream,
|
||||
submission: createMessageSubmissionWriter(serverId),
|
||||
});
|
||||
onAttentionPromptSend?.();
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
onAttentionPromptSend,
|
||||
serverId,
|
||||
setAgentStreamTail,
|
||||
setAgentStreamHead,
|
||||
supportsForgeSearch,
|
||||
t,
|
||||
]);
|
||||
}, [client, onAttentionPromptSend, serverId, supportsForgeSearch, t]);
|
||||
|
||||
useEffect(() => {
|
||||
onSubmitMessageRef.current = onSubmitMessage;
|
||||
|
||||
123
packages/app/src/composer/submission/model.test.ts
Normal file
123
packages/app/src/composer/submission/model.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
acceptMessageSubmission,
|
||||
beginMessageSubmission,
|
||||
getActiveMessageSubmissions,
|
||||
getSendingClientMessageIds,
|
||||
observeAcceptedMessageSubmissionsRunning,
|
||||
observeMessageSubmissionCanonical,
|
||||
rejectMessageSubmission,
|
||||
} from "./model";
|
||||
|
||||
const submittedAt = new Date("2026-07-26T10:00:00.000Z");
|
||||
|
||||
describe("message submission transactions", () => {
|
||||
it("tracks every in-flight submission independently", () => {
|
||||
const first = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
|
||||
const both = beginMessageSubmission(first, {
|
||||
clientMessageId: "client-2",
|
||||
submittedAt: new Date(submittedAt.getTime() + 1),
|
||||
});
|
||||
|
||||
expect(getActiveMessageSubmissions(both).map((item) => item.clientMessageId)).toEqual([
|
||||
"client-1",
|
||||
"client-2",
|
||||
]);
|
||||
expect(getSendingClientMessageIds(both)).toEqual(["client-1", "client-2"]);
|
||||
});
|
||||
|
||||
it("removes only the RPC-accepted transaction", () => {
|
||||
const both = beginMessageSubmission(
|
||||
beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }),
|
||||
{ clientMessageId: "client-2", submittedAt },
|
||||
);
|
||||
|
||||
expect(acceptMessageSubmission(both, "client-1", true, false)).toEqual([
|
||||
{
|
||||
clientMessageId: "client-2",
|
||||
submittedAt,
|
||||
rpcAccepted: false,
|
||||
providerAcknowledged: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("bridges an accepted RPC until the correlated running state is observed", () => {
|
||||
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
|
||||
const accepted = acceptMessageSubmission(sending, "client-1", false, false);
|
||||
|
||||
expect(getActiveMessageSubmissions(accepted)).toHaveLength(1);
|
||||
expect(accepted[0].rpcAccepted).toBe(true);
|
||||
expect(observeAcceptedMessageSubmissionsRunning(accepted)).toEqual([]);
|
||||
});
|
||||
|
||||
it("settles an accepted RPC when provider acknowledgement arrives after running was missed", () => {
|
||||
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
|
||||
const accepted = acceptMessageSubmission(sending, "client-1", false, false);
|
||||
|
||||
expect(observeMessageSubmissionCanonical(accepted, ["client-1"])).toEqual([]);
|
||||
});
|
||||
|
||||
it("settles an explicitly out-of-band acceptance without lifecycle inference", () => {
|
||||
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
|
||||
|
||||
expect(acceptMessageSubmission(sending, "client-1", false, true)).toEqual([]);
|
||||
});
|
||||
|
||||
it("settles an idle acceptance from a daemon without submission disposition", () => {
|
||||
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
|
||||
|
||||
expect(acceptMessageSubmission(sending, "client-1", false, undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("records provider acknowledgement without settling another transaction", () => {
|
||||
const both = beginMessageSubmission(
|
||||
beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }),
|
||||
{ clientMessageId: "client-2", submittedAt },
|
||||
);
|
||||
const observed = observeMessageSubmissionCanonical(both, ["client-1"]);
|
||||
|
||||
expect(observed).toEqual([
|
||||
{
|
||||
clientMessageId: "client-1",
|
||||
submittedAt,
|
||||
rpcAccepted: false,
|
||||
providerAcknowledged: true,
|
||||
},
|
||||
{
|
||||
clientMessageId: "client-2",
|
||||
submittedAt,
|
||||
rpcAccepted: false,
|
||||
providerAcknowledged: false,
|
||||
},
|
||||
]);
|
||||
expect(getSendingClientMessageIds(observed)).toEqual(["client-2"]);
|
||||
});
|
||||
|
||||
it("does not roll back a provider-acknowledged prompt on a later transport error", () => {
|
||||
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
|
||||
const observed = observeMessageSubmissionCanonical(sending, ["client-1"]);
|
||||
|
||||
expect(rejectMessageSubmission(observed, "client-1")).toEqual({
|
||||
outcome: "accepted",
|
||||
submissions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an unacknowledged transaction", () => {
|
||||
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
|
||||
|
||||
expect(rejectMessageSubmission(sending, "client-1")).toEqual({
|
||||
outcome: "rejected",
|
||||
submissions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create duplicate transaction identity", () => {
|
||||
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
|
||||
|
||||
expect(() =>
|
||||
beginMessageSubmission(sending, { clientMessageId: "client-1", submittedAt }),
|
||||
).toThrow("Message submission already exists");
|
||||
});
|
||||
});
|
||||
108
packages/app/src/composer/submission/model.ts
Normal file
108
packages/app/src/composer/submission/model.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
export interface PendingMessageSubmission {
|
||||
clientMessageId: string;
|
||||
submittedAt: Date;
|
||||
}
|
||||
|
||||
export type MessageSubmissionRecord = PendingMessageSubmission & {
|
||||
rpcAccepted: boolean;
|
||||
providerAcknowledged: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_MESSAGE_SUBMISSIONS: readonly MessageSubmissionRecord[] = [];
|
||||
|
||||
export function getActiveMessageSubmissions(
|
||||
submissions: readonly MessageSubmissionRecord[] | null | undefined,
|
||||
): readonly PendingMessageSubmission[] {
|
||||
return submissions ?? EMPTY_MESSAGE_SUBMISSIONS;
|
||||
}
|
||||
|
||||
export function getSendingClientMessageIds(
|
||||
submissions: readonly MessageSubmissionRecord[] | null | undefined,
|
||||
): string[] {
|
||||
return (submissions ?? [])
|
||||
.filter((submission) => !submission.providerAcknowledged)
|
||||
.map((submission) => submission.clientMessageId);
|
||||
}
|
||||
|
||||
export type MessageSubmissionRejectionOutcome = "rejected" | "accepted" | "unknown";
|
||||
|
||||
export interface MessageSubmissionRejectionResult {
|
||||
submissions: MessageSubmissionRecord[];
|
||||
outcome: MessageSubmissionRejectionOutcome;
|
||||
}
|
||||
|
||||
export function beginMessageSubmission(
|
||||
submissions: readonly MessageSubmissionRecord[],
|
||||
input: PendingMessageSubmission,
|
||||
): MessageSubmissionRecord[] {
|
||||
if (submissions.some((submission) => submission.clientMessageId === input.clientMessageId)) {
|
||||
throw new Error(`Message submission already exists: ${input.clientMessageId}`);
|
||||
}
|
||||
return [...submissions, { ...input, rpcAccepted: false, providerAcknowledged: false }];
|
||||
}
|
||||
|
||||
export function acceptMessageSubmission(
|
||||
submissions: readonly MessageSubmissionRecord[],
|
||||
clientMessageId: string,
|
||||
isAgentRunning: boolean,
|
||||
outOfBand: boolean | undefined,
|
||||
): MessageSubmissionRecord[] {
|
||||
const index = submissions.findIndex(
|
||||
(submission) => submission.clientMessageId === clientMessageId,
|
||||
);
|
||||
if (index < 0) return submissions as MessageSubmissionRecord[];
|
||||
// COMPAT(messageSubmissionDisposition): daemons before v0.2.3 omitted outOfBand.
|
||||
// Their normal-send response follows the ordered running/canonical events, while an
|
||||
// out-of-band response arrives with the agent still idle. Remove after 2027-01-27.
|
||||
const legacyOutOfBand = outOfBand === undefined && !isAgentRunning;
|
||||
if (
|
||||
outOfBand === true ||
|
||||
legacyOutOfBand ||
|
||||
isAgentRunning ||
|
||||
submissions[index].providerAcknowledged
|
||||
) {
|
||||
return submissions.filter((_, submissionIndex) => submissionIndex !== index);
|
||||
}
|
||||
if (submissions[index].rpcAccepted) return submissions as MessageSubmissionRecord[];
|
||||
const next = submissions.slice();
|
||||
next[index] = { ...next[index], rpcAccepted: true };
|
||||
return next;
|
||||
}
|
||||
|
||||
export function observeAcceptedMessageSubmissionsRunning(
|
||||
submissions: readonly MessageSubmissionRecord[],
|
||||
): MessageSubmissionRecord[] {
|
||||
const next = submissions.filter((submission) => !submission.rpcAccepted);
|
||||
return next.length === submissions.length ? (submissions as MessageSubmissionRecord[]) : next;
|
||||
}
|
||||
|
||||
export function observeMessageSubmissionCanonical(
|
||||
submissions: readonly MessageSubmissionRecord[],
|
||||
clientMessageIds: readonly string[],
|
||||
): MessageSubmissionRecord[] {
|
||||
if (clientMessageIds.length === 0) return submissions as MessageSubmissionRecord[];
|
||||
const observed = new Set(clientMessageIds);
|
||||
let changed = false;
|
||||
const next = submissions.flatMap((submission): MessageSubmissionRecord[] => {
|
||||
if (submission.providerAcknowledged || !observed.has(submission.clientMessageId)) {
|
||||
return [submission];
|
||||
}
|
||||
changed = true;
|
||||
return submission.rpcAccepted ? [] : [{ ...submission, providerAcknowledged: true }];
|
||||
});
|
||||
return changed ? next : (submissions as MessageSubmissionRecord[]);
|
||||
}
|
||||
|
||||
export function rejectMessageSubmission(
|
||||
submissions: readonly MessageSubmissionRecord[],
|
||||
clientMessageId: string,
|
||||
): MessageSubmissionRejectionResult {
|
||||
const submission = submissions.find((item) => item.clientMessageId === clientMessageId);
|
||||
if (!submission) {
|
||||
return { outcome: "unknown", submissions: submissions as MessageSubmissionRecord[] };
|
||||
}
|
||||
return {
|
||||
outcome: submission.providerAcknowledged || submission.rpcAccepted ? "accepted" : "rejected",
|
||||
submissions: submissions.filter((item) => item.clientMessageId !== clientMessageId),
|
||||
};
|
||||
}
|
||||
20
packages/app/src/composer/submission/writer.ts
Normal file
20
packages/app/src/composer/submission/writer.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { MessageSubmissionWriter } from "@/composer/actions";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
/**
|
||||
* Binds the submission lifecycle to a host session. Every path that sends a message to an
|
||||
* agent — composer send, queued send-now, automatic queue drain — goes through this so a
|
||||
* submitted row and its pending state are always created together.
|
||||
*/
|
||||
export function createMessageSubmissionWriter(serverId: string): MessageSubmissionWriter {
|
||||
return {
|
||||
begin: (agentId, message) =>
|
||||
useSessionStore.getState().beginAgentMessageSubmission(serverId, agentId, message),
|
||||
accept: (agentId, clientMessageId, outOfBand) =>
|
||||
useSessionStore
|
||||
.getState()
|
||||
.acceptAgentMessageSubmission(serverId, agentId, clientMessageId, outOfBand),
|
||||
reject: (agentId, clientMessageId) =>
|
||||
useSessionStore.getState().rejectAgentMessageSubmission(serverId, agentId, clientMessageId),
|
||||
};
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export async function submitAgentInput<TAttachment>(
|
||||
return "queued";
|
||||
}
|
||||
|
||||
// Clear immediately so optimistic stream updates and composer state stay in sync.
|
||||
// Clear immediately so the submitted timeline row and composer state stay in sync.
|
||||
if (shouldClearOnSubmit) {
|
||||
input.setUserInput("");
|
||||
input.setAttachments([]);
|
||||
|
||||
@@ -40,7 +40,12 @@ import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context";
|
||||
import type { AudioPlaybackSource } from "@/voice/audio-engine-types";
|
||||
import { useSessionStore, type MessageEntry, type SessionState } from "@/stores/session-store";
|
||||
import {
|
||||
selectAgentTimelineState,
|
||||
useSessionStore,
|
||||
type MessageEntry,
|
||||
type SessionState,
|
||||
} from "@/stores/session-store";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { sendOsNotification } from "@/utils/os-notifications";
|
||||
import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility";
|
||||
@@ -53,6 +58,7 @@ import {
|
||||
} from "@/utils/agent-initialization";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { derivePendingPermissionKey } from "@/utils/agent-snapshots";
|
||||
import { getSendingClientMessageIds } from "@/composer/submission/model";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
@@ -191,13 +197,6 @@ type WorkspaceSetupProgressPayload = Extract<
|
||||
|
||||
type SessionStoreActions = ReturnType<typeof useSessionStore.getState>;
|
||||
type SetInitializingAgents = SessionStoreActions["setInitializingAgents"];
|
||||
type SetAgentStreamTail = SessionStoreActions["setAgentStreamTail"];
|
||||
type SetAgentStreamHead = SessionStoreActions["setAgentStreamHead"];
|
||||
type ClearAgentStreamHead = SessionStoreActions["clearAgentStreamHead"];
|
||||
type SetAgentTimelineCursor = SessionStoreActions["setAgentTimelineCursor"];
|
||||
type MarkAgentHistorySynchronized = SessionStoreActions["markAgentHistorySynchronized"];
|
||||
type SetAgentAuthoritativeHistoryApplied =
|
||||
SessionStoreActions["setAgentAuthoritativeHistoryApplied"];
|
||||
|
||||
function clearAgentInitializingFlag(
|
||||
setInitializingAgents: SetInitializingAgents,
|
||||
@@ -230,75 +229,6 @@ function handleTimelineError(input: {
|
||||
}
|
||||
}
|
||||
|
||||
function applyTimelineStreamPatches(input: {
|
||||
result: ProcessTimelineResponseOutput;
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
currentTail: StreamItem[];
|
||||
currentHead: StreamItem[];
|
||||
setAgentStreamTail: SetAgentStreamTail;
|
||||
setAgentStreamHead: SetAgentStreamHead;
|
||||
clearAgentStreamHead: ClearAgentStreamHead;
|
||||
setAgentTimelineCursor: SetAgentTimelineCursor;
|
||||
}): void {
|
||||
const {
|
||||
result,
|
||||
agentId,
|
||||
serverId,
|
||||
currentTail,
|
||||
currentHead,
|
||||
setAgentStreamTail,
|
||||
setAgentStreamHead,
|
||||
clearAgentStreamHead,
|
||||
setAgentTimelineCursor,
|
||||
} = input;
|
||||
|
||||
if (result.tail !== currentTail) {
|
||||
setAgentStreamTail(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, result.tail);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (result.head !== currentHead) {
|
||||
if (result.head.length === 0) {
|
||||
clearAgentStreamHead(serverId, agentId);
|
||||
} else {
|
||||
setAgentStreamHead(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, result.head);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (result.cursorChanged) {
|
||||
setAgentTimelineCursor(serverId, (prev) => {
|
||||
const current = prev.get(agentId);
|
||||
if (!result.cursor) {
|
||||
if (!current) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
}
|
||||
if (
|
||||
current &&
|
||||
current.epoch === result.cursor.epoch &&
|
||||
current.startSeq === result.cursor.startSeq &&
|
||||
current.endSeq === result.cursor.endSeq
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, result.cursor);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function executeTimelineSideEffects(input: {
|
||||
sideEffects: TimelineReducerSideEffect[];
|
||||
agentId: string;
|
||||
@@ -319,8 +249,6 @@ function finalizeTimelineApplication(input: {
|
||||
serverId: string;
|
||||
shouldMarkAuthoritativeHistoryApplied: boolean;
|
||||
setInitializingAgents: SetInitializingAgents;
|
||||
setAgentAuthoritativeHistoryApplied: SetAgentAuthoritativeHistoryApplied;
|
||||
markAgentHistorySynchronized: MarkAgentHistorySynchronized;
|
||||
}): void {
|
||||
const {
|
||||
result,
|
||||
@@ -329,17 +257,13 @@ function finalizeTimelineApplication(input: {
|
||||
serverId,
|
||||
shouldMarkAuthoritativeHistoryApplied,
|
||||
setInitializingAgents,
|
||||
setAgentAuthoritativeHistoryApplied,
|
||||
markAgentHistorySynchronized,
|
||||
} = input;
|
||||
|
||||
if (result.clearInitializing) {
|
||||
clearAgentInitializingFlag(setInitializingAgents, serverId, agentId);
|
||||
}
|
||||
if (shouldMarkAuthoritativeHistoryApplied) {
|
||||
setAgentAuthoritativeHistoryApplied(serverId, agentId, true);
|
||||
useCreateFlowStore.getState().clearByAgent({ serverId, agentId });
|
||||
markAgentHistorySynchronized(serverId, agentId);
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
|
||||
if (agent && agent.status !== "running") {
|
||||
@@ -425,14 +349,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const setAgentStreamState = useSessionStore((state) => state.setAgentStreamState);
|
||||
const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead);
|
||||
const setAgentTimelineCursor = useSessionStore((state) => state.setAgentTimelineCursor);
|
||||
const setAgentTimelineHasOlder = useSessionStore((state) => state.setAgentTimelineHasOlder);
|
||||
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
|
||||
const bumpHistorySyncGeneration = useSessionStore((state) => state.bumpHistorySyncGeneration);
|
||||
const markAgentHistorySynchronized = useSessionStore(
|
||||
(state) => state.markAgentHistorySynchronized,
|
||||
);
|
||||
const setAgentAuthoritativeHistoryApplied = useSessionStore(
|
||||
(state) => state.setAgentAuthoritativeHistoryApplied,
|
||||
const applyAgentTimelineResponseState = useSessionStore(
|
||||
(state) => state.applyAgentTimelineResponseState,
|
||||
);
|
||||
const setAgents = useSessionStore((state) => state.setAgents);
|
||||
const setWorkspaces = useSessionStore((state) => state.setWorkspaces);
|
||||
@@ -655,18 +575,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const isInitializing = session?.initializingAgents.get(agentId) === true;
|
||||
const activeInitDeferred = getInitDeferred(initKey);
|
||||
const hasActiveInitDeferred = Boolean(activeInitDeferred);
|
||||
const currentCursor = session?.agentTimelineCursor.get(agentId);
|
||||
const currentTail = session?.agentStreamTail.get(agentId) ?? [];
|
||||
const timeline = selectAgentTimelineState(session, agentId);
|
||||
const currentCursor =
|
||||
timeline.status === "synced" ? (timeline.range ?? undefined) : undefined;
|
||||
const currentTail = timeline.status === "cold" ? [] : timeline.items;
|
||||
const currentHead = session?.agentStreamHead.get(agentId) ?? [];
|
||||
|
||||
setAgentTimelineHasOlder(serverId, (prev) => {
|
||||
if (prev.get(agentId) === payload.hasOlder) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, payload.hasOlder);
|
||||
return next;
|
||||
});
|
||||
const sendingClientMessageIds = getSendingClientMessageIds(
|
||||
session?.messageSubmissions.get(agentId),
|
||||
);
|
||||
|
||||
// Call pure reducer
|
||||
const result = processTimelineResponse({
|
||||
@@ -677,6 +593,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
initRequestDirection: activeInitDeferred?.requestDirection ?? "tail",
|
||||
sendingClientMessageIds,
|
||||
hasAuthoritativeBaseline: timeline.status === "synced",
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
@@ -690,16 +608,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
return;
|
||||
}
|
||||
|
||||
applyTimelineStreamPatches({
|
||||
result,
|
||||
agentId,
|
||||
serverId,
|
||||
currentTail,
|
||||
currentHead,
|
||||
setAgentStreamTail,
|
||||
setAgentStreamHead,
|
||||
clearAgentStreamHead,
|
||||
setAgentTimelineCursor,
|
||||
applyAgentTimelineResponseState(serverId, agentId, {
|
||||
items: result.tail,
|
||||
head: result.head,
|
||||
range: result.cursorChanged ? (result.cursor ?? null) : (currentCursor ?? null),
|
||||
older: payload.hasOlder ? "available" : "none",
|
||||
synchronized: shouldMarkAuthoritativeHistoryApplied,
|
||||
acknowledgedClientMessageIds: result.acknowledgedClientMessageIds,
|
||||
});
|
||||
|
||||
executeTimelineSideEffects({
|
||||
@@ -715,22 +630,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
serverId,
|
||||
shouldMarkAuthoritativeHistoryApplied,
|
||||
setInitializingAgents,
|
||||
setAgentAuthoritativeHistoryApplied,
|
||||
markAgentHistorySynchronized,
|
||||
});
|
||||
},
|
||||
[
|
||||
clearAgentStreamHead,
|
||||
markAgentHistorySynchronized,
|
||||
recoverTimelineGap,
|
||||
serverId,
|
||||
setAgentAuthoritativeHistoryApplied,
|
||||
setAgentStreamHead,
|
||||
setAgentStreamTail,
|
||||
setAgentTimelineCursor,
|
||||
setAgentTimelineHasOlder,
|
||||
setInitializingAgents,
|
||||
],
|
||||
[applyAgentTimelineResponseState, recoverTimelineGap, serverId, setInitializingAgents],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -741,16 +643,20 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const sync = createViewedTimelineSync({
|
||||
initialDeliveryMode,
|
||||
setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds),
|
||||
readCursor: (agentId) =>
|
||||
useSessionStore.getState().sessions[serverId]?.agentTimelineCursor.get(agentId),
|
||||
readCursor: (agentId) => {
|
||||
const timeline = selectAgentTimelineState(
|
||||
useSessionStore.getState().sessions[serverId],
|
||||
agentId,
|
||||
);
|
||||
return timeline.status === "synced" ? (timeline.range ?? undefined) : undefined;
|
||||
},
|
||||
hasAuthoritativeHistory: (agentId) =>
|
||||
useSessionStore
|
||||
.getState()
|
||||
.sessions[serverId]?.agentAuthoritativeHistoryApplied.get(agentId) === true,
|
||||
selectAgentTimelineState(useSessionStore.getState().sessions[serverId], agentId).status ===
|
||||
"synced",
|
||||
fetchPage: async (agentId, request) => {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const initKey = getInitKey(serverId, agentId);
|
||||
const shouldInitialize = session?.agentAuthoritativeHistoryApplied.get(agentId) !== true;
|
||||
const shouldInitialize = selectAgentTimelineState(session, agentId).status !== "synced";
|
||||
if (shouldInitialize) {
|
||||
if (!getInitDeferred(initKey)) {
|
||||
const deferred = createInitDeferred(initKey, request.direction ?? "tail");
|
||||
@@ -808,7 +714,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
serverId,
|
||||
setAgentStreamState,
|
||||
setAgentTimelineCursor,
|
||||
setAgents,
|
||||
recoverTimelineGap,
|
||||
});
|
||||
|
||||
@@ -825,7 +730,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
) {
|
||||
voiceRuntime?.onTurnEvent(serverId, agentId, event.type);
|
||||
}
|
||||
|
||||
agentStreamReducerQueue.enqueue(agentId, {
|
||||
event: streamEvent,
|
||||
seq,
|
||||
|
||||
@@ -111,6 +111,47 @@ describe("ensureAgentIsInitialized", () => {
|
||||
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail");
|
||||
});
|
||||
|
||||
it("requests a bounded projected tail after restoring painted replica items", () => {
|
||||
const client = new FakeDaemonClient();
|
||||
const runtime = new FakeTimelineRuntime();
|
||||
useSessionStore.getState().restoreSessionReplica(serverId, {
|
||||
agents: new Map(),
|
||||
workspaces: new Map(),
|
||||
emptyProjects: new Map(),
|
||||
timeline: {
|
||||
agentId,
|
||||
items: [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "painted-item",
|
||||
text: "Painted before hydration",
|
||||
timestamp: new Date("2026-07-27T10:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
void ensureAgentIsInitialized({
|
||||
serverId,
|
||||
agentId,
|
||||
client: client as never,
|
||||
runtime,
|
||||
setAgentInitializing: bindSetAgentInitializing(),
|
||||
});
|
||||
|
||||
expect(runtime.requests).toEqual([
|
||||
{
|
||||
serverId,
|
||||
agentId,
|
||||
request: {
|
||||
direction: "tail",
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("times out initialization after 65 seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new FakeDaemonClient();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
createInitDeferred,
|
||||
getInitDeferred,
|
||||
@@ -52,8 +52,9 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput):
|
||||
}
|
||||
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const cursor = session?.agentTimelineCursor.get(agentId);
|
||||
const hasAuthoritativeHistory = session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
|
||||
const timeline = selectAgentTimelineState(session, agentId);
|
||||
const cursor = timeline.status === "synced" ? (timeline.range ?? undefined) : undefined;
|
||||
const hasAuthoritativeHistory = timeline.status === "synced";
|
||||
const timelineRequest = planInitialAgentTimelineSync({ cursor, hasAuthoritativeHistory });
|
||||
|
||||
const deferred = createInitDeferred(key, timelineRequest.direction);
|
||||
|
||||
@@ -84,7 +84,7 @@ describe("loadOlderAgentHistory", () => {
|
||||
const client = createClient();
|
||||
const inFlight = createInFlight();
|
||||
|
||||
await loadOlderAgentHistory(agentId, {
|
||||
const started = await loadOlderAgentHistory(agentId, {
|
||||
client,
|
||||
cursor: undefined,
|
||||
hasOlder: true,
|
||||
@@ -94,13 +94,14 @@ describe("loadOlderAgentHistory", () => {
|
||||
|
||||
expect(client.calls).toEqual([]);
|
||||
expect(inFlight.values).toEqual([false]);
|
||||
expect(started).toBe(false);
|
||||
});
|
||||
|
||||
it("no-ops when the daemon says no older history exists", async () => {
|
||||
const client = createClient();
|
||||
const inFlight = createInFlight();
|
||||
|
||||
await loadOlderAgentHistory(agentId, {
|
||||
const started = await loadOlderAgentHistory(agentId, {
|
||||
client,
|
||||
cursor: someCursor,
|
||||
hasOlder: false,
|
||||
@@ -110,13 +111,14 @@ describe("loadOlderAgentHistory", () => {
|
||||
|
||||
expect(client.calls).toEqual([]);
|
||||
expect(inFlight.values).toEqual([false]);
|
||||
expect(started).toBe(false);
|
||||
});
|
||||
|
||||
it("no-ops when a request is already in flight", async () => {
|
||||
const client = createClient();
|
||||
const inFlight = createInFlight(true);
|
||||
|
||||
await loadOlderAgentHistory(agentId, {
|
||||
const started = await loadOlderAgentHistory(agentId, {
|
||||
client,
|
||||
cursor: someCursor,
|
||||
hasOlder: true,
|
||||
@@ -126,13 +128,14 @@ describe("loadOlderAgentHistory", () => {
|
||||
|
||||
expect(client.calls).toEqual([]);
|
||||
expect(inFlight.values).toEqual([true]);
|
||||
expect(started).toBe(true);
|
||||
});
|
||||
|
||||
it("requests the page before the current start cursor and clears in-flight on success", async () => {
|
||||
const client = createClient();
|
||||
const inFlight = createInFlight();
|
||||
|
||||
await loadOlderAgentHistory(agentId, {
|
||||
const started = await loadOlderAgentHistory(agentId, {
|
||||
client,
|
||||
cursor: someCursor,
|
||||
hasOlder: true,
|
||||
@@ -152,6 +155,7 @@ describe("loadOlderAgentHistory", () => {
|
||||
},
|
||||
]);
|
||||
expect(inFlight.values).toEqual([false, true, false]);
|
||||
expect(started).toBe(true);
|
||||
});
|
||||
|
||||
it("shows a panel toast, warns, and clears in-flight on failure", async () => {
|
||||
|
||||
@@ -2,7 +2,11 @@ import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { useSessionStore, type AgentTimelineCursorState } from "@/stores/session-store";
|
||||
import {
|
||||
selectAgentTimelineState,
|
||||
useSessionStore,
|
||||
type AgentTimelineCursorState,
|
||||
} from "@/stores/session-store";
|
||||
import { planTimelineOlderFetch } from "@/timeline/timeline-sync-plan";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
|
||||
@@ -36,11 +40,14 @@ export interface LoadOlderAgentHistoryDeps {
|
||||
export async function loadOlderAgentHistory(
|
||||
agentId: string,
|
||||
deps: LoadOlderAgentHistoryDeps,
|
||||
): Promise<void> {
|
||||
): Promise<boolean> {
|
||||
const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger, failedMessage } =
|
||||
deps;
|
||||
if (!client || !cursor || !hasOlder || isLoadingOlder) {
|
||||
return;
|
||||
if (isLoadingOlder) {
|
||||
return true;
|
||||
}
|
||||
if (!client || !cursor || !hasOlder) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setInFlight(true);
|
||||
@@ -58,6 +65,7 @@ export async function loadOlderAgentHistory(
|
||||
} finally {
|
||||
setInFlight(false);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function useLoadOlderAgentHistory({
|
||||
@@ -70,15 +78,17 @@ export function useLoadOlderAgentHistory({
|
||||
toast?: ToastApi | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const hasOlder =
|
||||
useSessionStore((state) => state.sessions[serverId]?.agentTimelineHasOlder.get(agentId)) ===
|
||||
true;
|
||||
const hasOlder = useSessionStore((state) => {
|
||||
const timeline = selectAgentTimelineState(state.sessions[serverId], agentId);
|
||||
return timeline.status === "synced" && timeline.older === "available";
|
||||
});
|
||||
const isLoadingOlder =
|
||||
useSessionStore((state) =>
|
||||
state.sessions[serverId]?.agentTimelineOlderFetchInFlight.get(agentId),
|
||||
) === true;
|
||||
const progressKey = useSessionStore((state) => {
|
||||
const cursor = state.sessions[serverId]?.agentTimelineCursor.get(agentId);
|
||||
const timeline = selectAgentTimelineState(state.sessions[serverId], agentId);
|
||||
const cursor = timeline.status === "synced" ? timeline.range : null;
|
||||
return cursor ? `${cursor.epoch}:${cursor.startSeq}` : null;
|
||||
});
|
||||
const setOlderFetchInFlight = useSessionStore(
|
||||
@@ -99,17 +109,18 @@ export function useLoadOlderAgentHistory({
|
||||
[agentId, serverId, setOlderFetchInFlight],
|
||||
);
|
||||
|
||||
const loadOlder = useCallback(() => {
|
||||
const loadOlder = useCallback(async (): Promise<boolean> => {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
void loadOlderAgentHistory(agentId, {
|
||||
const timeline = selectAgentTimelineState(session, agentId);
|
||||
return await loadOlderAgentHistory(agentId, {
|
||||
client: session?.client
|
||||
? {
|
||||
fetchAgentTimeline: (timelineAgentId, request) =>
|
||||
getHostRuntimeStore().fetchAgentTimeline(serverId, timelineAgentId, request),
|
||||
}
|
||||
: null,
|
||||
cursor: session?.agentTimelineCursor.get(agentId),
|
||||
hasOlder: session?.agentTimelineHasOlder.get(agentId) === true,
|
||||
cursor: timeline.status === "synced" ? (timeline.range ?? undefined) : undefined,
|
||||
hasOlder: timeline.status === "synced" && timeline.older === "available",
|
||||
isLoadingOlder: session?.agentTimelineOlderFetchInFlight.get(agentId) === true,
|
||||
setInFlight,
|
||||
toast,
|
||||
|
||||
@@ -25,6 +25,7 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { SidebarCallout } from "@/components/sidebar-callout";
|
||||
import { Composer } from "@/composer";
|
||||
import { getActiveMessageSubmissions } from "@/composer/submission/model";
|
||||
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import {
|
||||
@@ -71,7 +72,7 @@ import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { type Agent, useSessionStore } from "@/stores/session-store";
|
||||
import { selectAgentTimelineState, type Agent, useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -442,6 +443,7 @@ export function useDraftPanelDescriptor(
|
||||
}
|
||||
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
const EMPTY_MESSAGE_SUBMISSIONS = [] as const;
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map<string, PendingPermission>();
|
||||
const EMPTY_PENDING_PERMISSION_LIST: PendingPermission[] = [];
|
||||
|
||||
@@ -790,11 +792,12 @@ function ChatAgentContent({
|
||||
const historySyncGeneration = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.historySyncGeneration ?? 0,
|
||||
);
|
||||
const hasAppliedAuthoritativeHistory = useSessionStore((state) =>
|
||||
const replicaTimelineStatus = useSessionStore((state) =>
|
||||
agentId
|
||||
? state.sessions[serverId]?.agentAuthoritativeHistoryApplied?.get(agentId) === true
|
||||
: false,
|
||||
? selectAgentTimelineState(state.sessions[serverId], agentId).status
|
||||
: ("cold" as const),
|
||||
);
|
||||
const hasAppliedAuthoritativeHistory = replicaTimelineStatus === "synced";
|
||||
const agentHistorySyncGeneration = useSessionStore((state) =>
|
||||
agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1,
|
||||
);
|
||||
@@ -830,7 +833,8 @@ function ChatAgentContent({
|
||||
kind: "idle",
|
||||
});
|
||||
|
||||
const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory;
|
||||
const hasHydratedHistoryBefore =
|
||||
hasAppliedAuthoritativeHistory || replicaTimelineStatus === "painted";
|
||||
|
||||
const attentionController = useAgentAttentionClear({
|
||||
agentId,
|
||||
@@ -1288,6 +1292,11 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
const streamItemsRaw = useSessionStore((state) =>
|
||||
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
|
||||
);
|
||||
const pendingMessageSubmissions = useSessionStore((state) =>
|
||||
agentId
|
||||
? getActiveMessageSubmissions(state.sessions[serverId]?.messageSubmissions.get(agentId))
|
||||
: EMPTY_MESSAGE_SUBMISSIONS,
|
||||
);
|
||||
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
|
||||
const pendingPermissionList = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
@@ -1327,6 +1336,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
|
||||
toast={toast}
|
||||
pendingMessageSubmissions={pendingMessageSubmissions}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -104,10 +104,12 @@ function ProviderSubagentPanel() {
|
||||
.catch(() => undefined);
|
||||
}, [client, serverId, supported, target.parentAgentId, target.subagentId]);
|
||||
|
||||
const loadOlder = useCallback(() => {
|
||||
if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) return;
|
||||
const loadOlder = useCallback((): boolean => {
|
||||
if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) {
|
||||
return false;
|
||||
}
|
||||
const firstSeq = timeline.rows.size ? Math.min(...timeline.rows.keys()) : null;
|
||||
if (firstSeq === null) return;
|
||||
if (firstSeq === null) return false;
|
||||
setIsLoadingOlder(true);
|
||||
void client
|
||||
.fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, {
|
||||
@@ -121,6 +123,7 @@ function ProviderSubagentPanel() {
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => setIsLoadingOlder(false));
|
||||
return true;
|
||||
}, [
|
||||
client,
|
||||
isLoadingOlder,
|
||||
|
||||
@@ -80,13 +80,16 @@ class FakeDaemonClient {
|
||||
this.setConnectionState({ status: "disconnected", reason: "client_closed" });
|
||||
}
|
||||
|
||||
async sendAgentMessage(...args: Parameters<DaemonClient["sendAgentMessage"]>): Promise<void> {
|
||||
async sendAgentMessage(
|
||||
...args: Parameters<DaemonClient["sendAgentMessage"]>
|
||||
): ReturnType<DaemonClient["sendAgentMessage"]> {
|
||||
this.sentAgentMessages.push(args);
|
||||
for (const waiter of this.sentMessageWaiters) waiter();
|
||||
const response = this.sendAgentMessageResponses.shift();
|
||||
if (response) await response;
|
||||
const failure = this.sendAgentMessageFailures.shift();
|
||||
if (failure) throw failure;
|
||||
return {};
|
||||
}
|
||||
|
||||
async waitForSentMessages(count: number): Promise<void> {
|
||||
@@ -2259,6 +2262,66 @@ describe("HostRuntimeStore", () => {
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("submits an automatically drained message through the submission producer", async () => {
|
||||
const host = makeHost({ serverId: "srv_drain_submission" });
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
const send = new Deferred<void>();
|
||||
fakeClient.sendAgentMessageResponses.push(send.promise);
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
connectToDaemon: async () => ({
|
||||
client: fakeClient as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: null,
|
||||
}),
|
||||
getClientId: async () => "cid_drain_submission",
|
||||
},
|
||||
});
|
||||
const sessionStore = useSessionStore.getState();
|
||||
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
sessionStore.setQueuedMessages(
|
||||
host.serverId,
|
||||
new Map([
|
||||
[
|
||||
"agent",
|
||||
[
|
||||
{
|
||||
id: "queued-with-attachment",
|
||||
text: "read this file",
|
||||
attachments: [
|
||||
{
|
||||
kind: "workspace_file" as const,
|
||||
path: "src/main.ts",
|
||||
selection: { kind: "whole_file" as const },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
store.drainQueuedAgentMessage(host.serverId, "agent");
|
||||
await fakeClient.waitForSentMessages(1);
|
||||
|
||||
// The row and the pending submission must exist while the RPC is still in flight —
|
||||
// the user sees their message and the working footer immediately, exactly as when
|
||||
// they press send.
|
||||
const session = useSessionStore.getState().sessions[host.serverId];
|
||||
const tail = session?.agentStreamTail.get("agent") ?? [];
|
||||
expect(tail).toHaveLength(1);
|
||||
expect(tail[0]).toMatchObject({
|
||||
kind: "user_message",
|
||||
text: "read this file",
|
||||
attachments: [{ type: "text", title: "main.ts", text: "Workspace file: src/main.ts" }],
|
||||
});
|
||||
expect(session?.messageSubmissions.get("agent")).toBeDefined();
|
||||
|
||||
send.resolve();
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("restores an automatically drained message when sending fails", async () => {
|
||||
const host = makeHost({ serverId: "srv_failed_queue_drain" });
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
|
||||
@@ -49,11 +49,9 @@ import {
|
||||
} from "@/data/push-router";
|
||||
import { mountBrowserAutomationDaemonClientHandler } from "@/browser-automation/handler";
|
||||
import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules";
|
||||
import { sendQueuedComposerMessageNow } from "@/composer/actions";
|
||||
import {
|
||||
resolveComposerAttachmentSubmitFormat,
|
||||
splitComposerAttachmentsForSubmit,
|
||||
} from "@/composer/attachments/submit";
|
||||
import { dispatchComposerAgentMessage, sendQueuedComposerMessageNow } from "@/composer/actions";
|
||||
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
|
||||
import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync";
|
||||
import { ReplicaCache } from "@/runtime/replica-cache";
|
||||
@@ -2071,14 +2069,16 @@ export class HostRuntimeStore {
|
||||
submitMessage: async ({ text, attachments }) => {
|
||||
const supportsForgeAttachments =
|
||||
useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === true;
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
|
||||
format: resolveComposerAttachmentSubmitFormat({ supportsForgeAttachments }),
|
||||
});
|
||||
const images = await encodeImages(wirePayload.images);
|
||||
await client.sendAgentMessage(agentId, text, {
|
||||
messageId: next.id,
|
||||
...(images && images.length > 0 ? { images } : {}),
|
||||
attachments: wirePayload.attachments,
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId,
|
||||
text,
|
||||
attachments,
|
||||
attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({
|
||||
supportsForgeAttachments,
|
||||
}),
|
||||
encodeImages,
|
||||
submission: createMessageSubmissionWriter(serverId),
|
||||
});
|
||||
},
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import {
|
||||
normalizeEmptyProjectDescriptor,
|
||||
normalizeWorkspaceDescriptor,
|
||||
selectAgentTimelineState,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
@@ -166,13 +167,14 @@ describe("ReplicaCache", () => {
|
||||
expect(session?.agents.get("agent-1")?.updatedAt).toBeInstanceOf(Date);
|
||||
expect(session?.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date);
|
||||
expect(session?.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]);
|
||||
expect(session?.agentAuthoritativeHistoryApplied.get("agent-1")).toBe(true);
|
||||
expect(session?.agentTimelineCursor.get("agent-1")).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 12,
|
||||
expect(session?.agentAuthoritativeHistoryApplied).toEqual(new Map());
|
||||
expect(session?.agentTimelineCursor).toEqual(new Map());
|
||||
expect(session?.agentTimelineHasOlder).toEqual(new Map());
|
||||
expect(session?.agentHistorySyncGeneration).toEqual(new Map());
|
||||
expect(selectAgentTimelineState(session, "agent-1")).toEqual({
|
||||
status: "painted",
|
||||
items: [message("message-1", "Cached")],
|
||||
});
|
||||
expect(session?.agentTimelineHasOlder.get("agent-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("persists only the focused agent view with a short timeline tail", async () => {
|
||||
@@ -216,6 +218,13 @@ describe("ReplicaCache", () => {
|
||||
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual([]);
|
||||
expect(Array.from(timelines?.keys() ?? [])).toEqual(["agent-2"]);
|
||||
expect(timelines?.get("agent-2")).toEqual(secondTimeline.slice(-50));
|
||||
|
||||
const persisted = JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null") as {
|
||||
version: number;
|
||||
hosts: Array<{ timeline: Record<string, unknown> | null }>;
|
||||
};
|
||||
expect(persisted.version).toBe(2);
|
||||
expect(Object.keys(persisted.hosts[0]?.timeline ?? {}).sort()).toEqual(["agentId", "items"]);
|
||||
});
|
||||
|
||||
it("evicts the least recently written host when the cache exceeds its byte budget", async () => {
|
||||
@@ -243,14 +252,38 @@ describe("ReplicaCache", () => {
|
||||
expect(Object.keys(useSessionStore.getState().sessions).sort()).toEqual(["host-a", "host-c"]);
|
||||
});
|
||||
|
||||
it("drops malformed or unknown cache versions", async () => {
|
||||
it("rejects version 1 cache data and overwrites it on flush", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.values.set("@paseo:replica-cache", JSON.stringify({ version: 999, hosts: [] }));
|
||||
storage.values.set(
|
||||
"@paseo:replica-cache",
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
hosts: [
|
||||
{
|
||||
serverId: SERVER_ID,
|
||||
agents: [],
|
||||
workspaces: [],
|
||||
emptyProjects: [],
|
||||
timeline: {
|
||||
agentId: "agent-1",
|
||||
items: [],
|
||||
cursor: { epoch: "poisoned", startSeq: 1, endSeq: 100 },
|
||||
hasOlder: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const cache = new ReplicaCache(storage);
|
||||
cache.setHosts([SERVER_ID]);
|
||||
|
||||
await cache.restore();
|
||||
await cache.flush();
|
||||
|
||||
expect(useSessionStore.getState().sessions[SERVER_ID]).toBeUndefined();
|
||||
expect(JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null")).toEqual({
|
||||
version: 2,
|
||||
hosts: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
normalizeEmptyProjectDescriptor,
|
||||
normalizeWorkspaceDescriptor,
|
||||
selectAgentTimelineState,
|
||||
useSessionStore,
|
||||
type Agent,
|
||||
type SessionReplica,
|
||||
@@ -19,9 +20,10 @@ import {
|
||||
} from "@/stores/session-store";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { getSendingClientMessageIds } from "@/composer/submission/model";
|
||||
|
||||
const STORAGE_KEY = "@paseo:replica-cache";
|
||||
const CACHE_VERSION = 1;
|
||||
const CACHE_VERSION = 2;
|
||||
const PERSIST_DELAY_MS = 750;
|
||||
const MAX_TIMELINE_ITEMS = 50;
|
||||
const MAX_CACHE_BYTES = 1024 * 1024;
|
||||
@@ -36,14 +38,6 @@ const StoredAgentSchema = z.object({
|
||||
const StoredTimelineSchema = z.object({
|
||||
agentId: z.string(),
|
||||
items: z.unknown(),
|
||||
cursor: z
|
||||
.object({
|
||||
epoch: z.string(),
|
||||
startSeq: z.number().int().nonnegative(),
|
||||
endSeq: z.number().int().nonnegative(),
|
||||
})
|
||||
.nullable(),
|
||||
hasOlder: z.boolean(),
|
||||
});
|
||||
|
||||
const StoredHostSchema = z.object({
|
||||
@@ -140,8 +134,6 @@ function deserializeTimeline(stored: StoredHost["timeline"]): SessionReplica["ti
|
||||
return {
|
||||
agentId: stored.agentId,
|
||||
items: decoded,
|
||||
cursor: stored.cursor,
|
||||
hasOlder: stored.hasOlder,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -370,14 +362,29 @@ export class ReplicaCache {
|
||||
(workspace) => workspace.workspaceDirectory === focusedAgent.cwd,
|
||||
))
|
||||
: undefined;
|
||||
const items = focusedAgentId ? session.agentStreamTail.get(focusedAgentId) : undefined;
|
||||
const localSubmissionIds = new Set(
|
||||
getSendingClientMessageIds(
|
||||
focusedAgentId ? session.messageSubmissions.get(focusedAgentId) : undefined,
|
||||
),
|
||||
);
|
||||
const timelineState = focusedAgentId
|
||||
? selectAgentTimelineState(session, focusedAgentId)
|
||||
: { status: "cold" as const };
|
||||
const items =
|
||||
timelineState.status === "cold"
|
||||
? undefined
|
||||
: timelineState.items.filter(
|
||||
(item) =>
|
||||
item.kind !== "user_message" ||
|
||||
item.messageId !== undefined ||
|
||||
!item.clientMessageId ||
|
||||
!localSubmissionIds.has(item.clientMessageId),
|
||||
);
|
||||
const timeline =
|
||||
focusedAgent && items
|
||||
? {
|
||||
agentId: focusedAgent.id,
|
||||
items: encodeDates(items.slice(-MAX_TIMELINE_ITEMS)),
|
||||
cursor: session.agentTimelineCursor.get(focusedAgent.id) ?? null,
|
||||
hasOlder: session.agentTimelineHasOlder.get(focusedAgent.id) ?? false,
|
||||
}
|
||||
: null;
|
||||
const stored: StoredHost = {
|
||||
|
||||
@@ -109,7 +109,10 @@ export const useCreateFlowStore = create<CreateFlowState>((set) => ({
|
||||
set((state) => {
|
||||
const next = Object.fromEntries(
|
||||
Object.entries(state.pendingByDraftId).filter(
|
||||
([, pending]) => pending.serverId !== serverId || pending.agentId !== agentId,
|
||||
([, pending]) =>
|
||||
pending.lifecycle !== "sent" ||
|
||||
pending.serverId !== serverId ||
|
||||
pending.agentId !== agentId,
|
||||
),
|
||||
);
|
||||
if (Object.keys(next).length === Object.keys(state.pendingByDraftId).length) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
persistAttachmentFromDataUrl,
|
||||
persistAttachmentFromFileUri,
|
||||
} from "@/attachments/service";
|
||||
import { collectRetainedAttachmentIds } from "@/attachments/gc-retention";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useSessionStore, type SessionState } from "@/stores/session-store";
|
||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||
@@ -138,6 +139,9 @@ async function runAttachmentGc(): Promise<void> {
|
||||
for (const id of useDraftStore.getState().collectActiveAttachmentIds()) {
|
||||
referencedIds.add(id);
|
||||
}
|
||||
for (const id of collectRetainedAttachmentIds()) {
|
||||
referencedIds.add(id);
|
||||
}
|
||||
|
||||
const pendingByDraftId = useCreateFlowStore.getState().pendingByDraftId;
|
||||
for (const pendingCreate of Object.values(pendingByDraftId)) {
|
||||
|
||||
@@ -5,9 +5,11 @@ import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
|
||||
import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
selectAgentTimelineState,
|
||||
useSessionStore,
|
||||
type WorkspaceDescriptor,
|
||||
} from "./session-store";
|
||||
import type { StreamItem } from "../types/stream";
|
||||
import { patchWorkspaceScripts } from "../contexts/session-workspace-scripts";
|
||||
|
||||
function createWorkspace(
|
||||
@@ -53,6 +55,54 @@ function getTestSessionReferences() {
|
||||
};
|
||||
}
|
||||
|
||||
describe("agent timeline state", () => {
|
||||
it("commits canonical items, range, and older availability as one synced state", () => {
|
||||
initializeTestSession();
|
||||
const items: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "canonical-row",
|
||||
text: "canonical",
|
||||
timestamp: new Date("2026-07-27T10:00:00.000Z"),
|
||||
},
|
||||
];
|
||||
|
||||
useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", {
|
||||
items,
|
||||
head: [],
|
||||
range: { epoch: "epoch-1", startSeq: 51, endSeq: 100 },
|
||||
older: "available",
|
||||
synchronized: true,
|
||||
acknowledgedClientMessageIds: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
selectAgentTimelineState(useSessionStore.getState().sessions["test-server"], "agent-1"),
|
||||
).toEqual({
|
||||
status: "synced",
|
||||
items,
|
||||
range: { epoch: "epoch-1", startSeq: 51, endSeq: 100 },
|
||||
older: "available",
|
||||
});
|
||||
});
|
||||
|
||||
it("represents an empty authoritative timeline without inventing a range", () => {
|
||||
initializeTestSession();
|
||||
useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", {
|
||||
items: [],
|
||||
head: [],
|
||||
range: null,
|
||||
older: "none",
|
||||
synchronized: true,
|
||||
acknowledgedClientMessageIds: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
selectAgentTimelineState(useSessionStore.getState().sessions["test-server"], "agent-1"),
|
||||
).toEqual({ status: "synced", items: [], range: null, older: "none" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeWorkspaceDescriptor", () => {
|
||||
it("normalizes workspace scripts and invalid activity timestamps", () => {
|
||||
const scripts = [
|
||||
|
||||
@@ -5,10 +5,21 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync";
|
||||
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||
import {
|
||||
appendSubmittedUserMessage,
|
||||
handoffCreatedAgentUserMessageToStream,
|
||||
removeSubmittedUserMessage,
|
||||
type StreamItem,
|
||||
type UserMessageItem,
|
||||
} from "@/types/stream";
|
||||
import {
|
||||
acceptMessageSubmission,
|
||||
beginMessageSubmission,
|
||||
observeAcceptedMessageSubmissionsRunning,
|
||||
observeMessageSubmissionCanonical,
|
||||
rejectMessageSubmission,
|
||||
type MessageSubmissionRecord,
|
||||
type MessageSubmissionRejectionOutcome,
|
||||
} from "@/composer/submission/model";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
|
||||
@@ -325,8 +336,6 @@ export interface AgentTimelineCursorState {
|
||||
export interface SessionReplicaTimeline {
|
||||
agentId: string;
|
||||
items: StreamItem[];
|
||||
cursor: AgentTimelineCursorState | null;
|
||||
hasOlder: boolean;
|
||||
}
|
||||
|
||||
export interface SessionReplica {
|
||||
@@ -336,6 +345,33 @@ export interface SessionReplica {
|
||||
timeline: SessionReplicaTimeline | null;
|
||||
}
|
||||
|
||||
export type AgentTimelineState =
|
||||
| { status: "cold" }
|
||||
| { status: "painted"; items: StreamItem[] }
|
||||
| {
|
||||
status: "synced";
|
||||
items: StreamItem[];
|
||||
range: AgentTimelineCursorState | null;
|
||||
older: "available" | "none";
|
||||
};
|
||||
|
||||
export function selectAgentTimelineState(
|
||||
session: SessionState | undefined,
|
||||
agentId: string,
|
||||
): AgentTimelineState {
|
||||
if (!session) return { status: "cold" };
|
||||
const items = session.agentStreamTail.get(agentId) ?? [];
|
||||
if (session.agentAuthoritativeHistoryApplied.get(agentId) === true) {
|
||||
return {
|
||||
status: "synced",
|
||||
items,
|
||||
range: session.agentTimelineCursor.get(agentId) ?? null,
|
||||
older: session.agentTimelineHasOlder.get(agentId) === true ? "available" : "none",
|
||||
};
|
||||
}
|
||||
return items.length > 0 ? { status: "painted", items } : { status: "cold" };
|
||||
}
|
||||
|
||||
export type WorkspaceRestoreStatus = "restoring" | "failed" | "needs-host-upgrade";
|
||||
|
||||
// Per-session state
|
||||
@@ -368,6 +404,7 @@ export interface SessionState {
|
||||
// Stream state (head/tail model)
|
||||
agentStreamTail: Map<string, StreamItem[]>;
|
||||
agentStreamHead: Map<string, StreamItem[]>;
|
||||
messageSubmissions: Map<string, MessageSubmissionRecord[]>;
|
||||
agentTimelineCursor: Map<string, AgentTimelineCursorState>;
|
||||
agentTimelineHasOlder: Map<string, boolean>;
|
||||
agentTimelineOlderFetchInFlight: Map<string, boolean>;
|
||||
@@ -459,8 +496,28 @@ interface SessionStoreActions {
|
||||
setAgentStreamState: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
state: { tail?: StreamItem[]; head?: StreamItem[] },
|
||||
state: {
|
||||
tail?: StreamItem[];
|
||||
head?: StreamItem[];
|
||||
acknowledgedClientMessageIds?: readonly string[];
|
||||
},
|
||||
) => void;
|
||||
beginAgentMessageSubmission: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
message: UserMessageItem,
|
||||
) => void;
|
||||
acceptAgentMessageSubmission: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
clientMessageId: string,
|
||||
outOfBand: boolean | undefined,
|
||||
) => void;
|
||||
rejectAgentMessageSubmission: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
clientMessageId: string,
|
||||
) => MessageSubmissionRejectionOutcome;
|
||||
handoffCreatedAgentUserMessage: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
@@ -488,6 +545,18 @@ interface SessionStoreActions {
|
||||
agentId: string,
|
||||
applied: boolean,
|
||||
) => void;
|
||||
applyAgentTimelineResponseState: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
state: {
|
||||
items: StreamItem[];
|
||||
head: StreamItem[];
|
||||
range: AgentTimelineCursorState | null;
|
||||
older: "available" | "none";
|
||||
synchronized: boolean;
|
||||
acknowledgedClientMessageIds: string[];
|
||||
},
|
||||
) => void;
|
||||
|
||||
// Initializing agents
|
||||
setInitializingAgents: (
|
||||
@@ -567,6 +636,27 @@ type SessionStore = SessionStoreState & SessionStoreActions;
|
||||
|
||||
const agentLastActivityCoalescer = createAgentLastActivityCoalescer();
|
||||
|
||||
function applyRunningAgentsToAcceptedSubmissions(input: {
|
||||
previousAgents: Map<string, Agent>;
|
||||
nextAgents: Map<string, Agent>;
|
||||
submissions: Map<string, MessageSubmissionRecord[]>;
|
||||
}): Map<string, MessageSubmissionRecord[]> {
|
||||
let nextSubmissions = input.submissions;
|
||||
for (const [agentId, submissions] of input.submissions) {
|
||||
const previousAgent = input.previousAgents.get(agentId);
|
||||
const nextAgent = input.nextAgents.get(agentId);
|
||||
if (!nextAgent || previousAgent?.status === "running" || nextAgent.status !== "running") {
|
||||
continue;
|
||||
}
|
||||
const remaining = observeAcceptedMessageSubmissionsRunning(submissions);
|
||||
if (remaining === submissions) continue;
|
||||
if (nextSubmissions === input.submissions) nextSubmissions = new Map(input.submissions);
|
||||
if (remaining.length > 0) nextSubmissions.set(agentId, remaining);
|
||||
else nextSubmissions.delete(agentId);
|
||||
}
|
||||
return nextSubmissions;
|
||||
}
|
||||
|
||||
// Helper to create initial session state
|
||||
function createInitialSessionState(
|
||||
serverId: string,
|
||||
@@ -588,6 +678,7 @@ function createInitialSessionState(
|
||||
currentAssistantMessage: "",
|
||||
agentStreamTail: new Map(),
|
||||
agentStreamHead: new Map(),
|
||||
messageSubmissions: new Map(),
|
||||
agentTimelineCursor: new Map(),
|
||||
agentTimelineHasOlder: new Map(),
|
||||
agentTimelineOlderFetchInFlight: new Map(),
|
||||
@@ -704,16 +795,8 @@ export const useSessionStore = create<SessionStore>()(
|
||||
const session = createInitialSessionState(serverId, null);
|
||||
const timeline = replica.timeline;
|
||||
const agentStreamTail = new Map<string, StreamItem[]>();
|
||||
const agentTimelineCursor = new Map<string, AgentTimelineCursorState>();
|
||||
const agentTimelineHasOlder = new Map<string, boolean>();
|
||||
const agentAuthoritativeHistoryApplied = new Map<string, boolean>();
|
||||
const agentHistorySyncGeneration = new Map<string, number>();
|
||||
if (timeline) {
|
||||
agentStreamTail.set(timeline.agentId, timeline.items);
|
||||
agentTimelineHasOlder.set(timeline.agentId, timeline.hasOlder);
|
||||
agentAuthoritativeHistoryApplied.set(timeline.agentId, true);
|
||||
agentHistorySyncGeneration.set(timeline.agentId, session.historySyncGeneration);
|
||||
if (timeline.cursor) agentTimelineCursor.set(timeline.agentId, timeline.cursor);
|
||||
}
|
||||
const agentLastActivity = new Map(prev.agentLastActivity);
|
||||
for (const agent of replica.agents.values()) {
|
||||
@@ -730,10 +813,6 @@ export const useSessionStore = create<SessionStore>()(
|
||||
workspaces: replica.workspaces,
|
||||
emptyProjects: replica.emptyProjects,
|
||||
agentStreamTail,
|
||||
agentTimelineCursor,
|
||||
agentTimelineHasOlder,
|
||||
agentAuthoritativeHistoryApplied,
|
||||
agentHistorySyncGeneration,
|
||||
},
|
||||
},
|
||||
agentLastActivity,
|
||||
@@ -1047,10 +1126,27 @@ export const useSessionStore = create<SessionStore>()(
|
||||
}
|
||||
}
|
||||
|
||||
if (!changedTail && !changedHead) {
|
||||
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
|
||||
const observedSubmissions = observeMessageSubmissionCanonical(
|
||||
currentSubmissions,
|
||||
state.acknowledgedClientMessageIds ?? [],
|
||||
);
|
||||
const changedSubmissions = observedSubmissions !== currentSubmissions;
|
||||
|
||||
if (!changedTail && !changedHead && !changedSubmissions) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
let messageSubmissions = session.messageSubmissions;
|
||||
if (changedSubmissions) {
|
||||
messageSubmissions = new Map(session.messageSubmissions);
|
||||
if (observedSubmissions.length > 0) {
|
||||
messageSubmissions.set(agentId, observedSubmissions);
|
||||
} else {
|
||||
messageSubmissions.delete(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
@@ -1059,12 +1155,129 @@ export const useSessionStore = create<SessionStore>()(
|
||||
...session,
|
||||
agentStreamTail: nextTail,
|
||||
agentStreamHead: nextHead,
|
||||
messageSubmissions,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
beginAgentMessageSubmission: (serverId, agentId, message) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) return prev;
|
||||
if (!message.clientMessageId) {
|
||||
throw new Error("Beginning a message submission requires client identity");
|
||||
}
|
||||
const currentTail = session.agentStreamTail.get(agentId) ?? [];
|
||||
const currentHead = session.agentStreamHead.get(agentId) ?? [];
|
||||
const stream = appendSubmittedUserMessage({
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
message,
|
||||
});
|
||||
const submissions = beginMessageSubmission(
|
||||
session.messageSubmissions.get(agentId) ?? [],
|
||||
{ clientMessageId: message.clientMessageId, submittedAt: message.timestamp },
|
||||
);
|
||||
const messageSubmissions = new Map(session.messageSubmissions);
|
||||
messageSubmissions.set(agentId, submissions);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
agentStreamTail:
|
||||
stream.tail === currentTail
|
||||
? session.agentStreamTail
|
||||
: new Map(session.agentStreamTail).set(agentId, stream.tail),
|
||||
agentStreamHead:
|
||||
stream.head === currentHead
|
||||
? session.agentStreamHead
|
||||
: new Map(session.agentStreamHead).set(agentId, stream.head),
|
||||
messageSubmissions,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
acceptAgentMessageSubmission: (serverId, agentId, clientMessageId, outOfBand) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) return prev;
|
||||
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
|
||||
const submissions = acceptMessageSubmission(
|
||||
currentSubmissions,
|
||||
clientMessageId,
|
||||
session.agents.get(agentId)?.status === "running",
|
||||
outOfBand,
|
||||
);
|
||||
if (submissions === currentSubmissions) return prev;
|
||||
const messageSubmissions = new Map(session.messageSubmissions);
|
||||
if (submissions.length > 0) {
|
||||
messageSubmissions.set(agentId, submissions);
|
||||
} else {
|
||||
messageSubmissions.delete(agentId);
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, messageSubmissions },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
rejectAgentMessageSubmission: (serverId, agentId, clientMessageId) => {
|
||||
let outcome: MessageSubmissionRejectionOutcome = "unknown";
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) return prev;
|
||||
const currentTail = session.agentStreamTail.get(agentId) ?? [];
|
||||
const currentHead = session.agentStreamHead.get(agentId) ?? [];
|
||||
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
|
||||
const result = rejectMessageSubmission(currentSubmissions, clientMessageId);
|
||||
outcome = result.outcome;
|
||||
if (outcome === "unknown") return prev;
|
||||
const stream =
|
||||
outcome === "rejected"
|
||||
? removeSubmittedUserMessage({
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
clientMessageId,
|
||||
})
|
||||
: { tail: currentTail, head: currentHead };
|
||||
const messageSubmissions = new Map(session.messageSubmissions);
|
||||
if (result.submissions.length > 0) {
|
||||
messageSubmissions.set(agentId, result.submissions);
|
||||
} else {
|
||||
messageSubmissions.delete(agentId);
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
agentStreamTail:
|
||||
stream.tail === currentTail
|
||||
? session.agentStreamTail
|
||||
: new Map(session.agentStreamTail).set(agentId, stream.tail),
|
||||
agentStreamHead:
|
||||
stream.head === currentHead
|
||||
? session.agentStreamHead
|
||||
: new Map(session.agentStreamHead).set(agentId, stream.head),
|
||||
messageSubmissions,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
return outcome;
|
||||
},
|
||||
|
||||
handoffCreatedAgentUserMessage: (serverId, agentId, message) => {
|
||||
let didHandoff = false;
|
||||
set((prev) => {
|
||||
@@ -1269,6 +1482,61 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
applyAgentTimelineResponseState: (serverId, agentId, state) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) return prev;
|
||||
|
||||
const nextTail = new Map(session.agentStreamTail);
|
||||
nextTail.set(agentId, state.items);
|
||||
const nextHead = new Map(session.agentStreamHead);
|
||||
if (state.head.length > 0) nextHead.set(agentId, state.head);
|
||||
else nextHead.delete(agentId);
|
||||
const nextCursor = new Map(session.agentTimelineCursor);
|
||||
if (state.range) nextCursor.set(agentId, state.range);
|
||||
else nextCursor.delete(agentId);
|
||||
const nextHasOlder = new Map(session.agentTimelineHasOlder);
|
||||
nextHasOlder.set(agentId, state.older === "available");
|
||||
const nextAuthoritative = new Map(session.agentAuthoritativeHistoryApplied);
|
||||
const nextSyncGeneration = new Map(session.agentHistorySyncGeneration);
|
||||
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
|
||||
const observedSubmissions = observeMessageSubmissionCanonical(
|
||||
currentSubmissions,
|
||||
state.acknowledgedClientMessageIds,
|
||||
);
|
||||
let messageSubmissions = session.messageSubmissions;
|
||||
if (observedSubmissions !== currentSubmissions) {
|
||||
messageSubmissions = new Map(session.messageSubmissions);
|
||||
if (observedSubmissions.length > 0) {
|
||||
messageSubmissions.set(agentId, observedSubmissions);
|
||||
} else {
|
||||
messageSubmissions.delete(agentId);
|
||||
}
|
||||
}
|
||||
if (state.synchronized) {
|
||||
nextAuthoritative.set(agentId, true);
|
||||
nextSyncGeneration.set(agentId, session.historySyncGeneration);
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
agentStreamTail: nextTail,
|
||||
agentStreamHead: nextHead,
|
||||
agentTimelineCursor: nextCursor,
|
||||
agentTimelineHasOlder: nextHasOlder,
|
||||
agentAuthoritativeHistoryApplied: nextAuthoritative,
|
||||
agentHistorySyncGeneration: nextSyncGeneration,
|
||||
messageSubmissions,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Initializing agents
|
||||
setInitializingAgents: (serverId, state) => {
|
||||
set((prev) => {
|
||||
@@ -1298,7 +1566,12 @@ export const useSessionStore = create<SessionStore>()(
|
||||
return prev;
|
||||
}
|
||||
const nextAgents = typeof agents === "function" ? agents(session.agents) : agents;
|
||||
if (session.agents === nextAgents) {
|
||||
const messageSubmissions = applyRunningAgentsToAcceptedSubmissions({
|
||||
previousAgents: session.agents,
|
||||
nextAgents,
|
||||
submissions: session.messageSubmissions,
|
||||
});
|
||||
if (session.agents === nextAgents && session.messageSubmissions === messageSubmissions) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
@@ -1308,10 +1581,11 @@ export const useSessionStore = create<SessionStore>()(
|
||||
[serverId]: {
|
||||
...session,
|
||||
agents: nextAgents,
|
||||
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(
|
||||
nextAgents,
|
||||
session.workspaceAgentActivity,
|
||||
),
|
||||
messageSubmissions,
|
||||
workspaceAgentActivity:
|
||||
nextAgents === session.agents
|
||||
? session.workspaceAgentActivity
|
||||
: buildWorkspaceAgentActivityIndex(nextAgents, session.workspaceAgentActivity),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,15 @@
|
||||
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { AssistantMessageItem, StreamItem, UserMessageItem } from "@/types/stream";
|
||||
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
|
||||
import type { AssistantMessageItem, StreamItem } from "@/types/stream";
|
||||
import {
|
||||
applyStreamEvent,
|
||||
flushHeadToTail,
|
||||
hydrateStreamState,
|
||||
isAgentToolCallItem,
|
||||
mergeAgentToolCallItem,
|
||||
replaceWithCanonicalStream,
|
||||
reduceStreamUpdate,
|
||||
upsertUserMessageAcrossStream,
|
||||
} from "@/types/stream";
|
||||
|
||||
const AGENT_STREAM_REDUCER_FLUSH_DELAY_MS = 16 * 3;
|
||||
@@ -88,6 +88,8 @@ export interface ProcessTimelineResponseInput {
|
||||
isInitializing: boolean;
|
||||
hasActiveInitDeferred: boolean;
|
||||
initRequestDirection: InitRequestDirection;
|
||||
sendingClientMessageIds: readonly string[];
|
||||
hasAuthoritativeBaseline: boolean;
|
||||
}
|
||||
|
||||
export interface ProcessTimelineResponseOutput {
|
||||
@@ -99,6 +101,7 @@ export interface ProcessTimelineResponseOutput {
|
||||
clearInitializing: boolean;
|
||||
error: string | null;
|
||||
sideEffects: TimelineReducerSideEffect[];
|
||||
acknowledgedClientMessageIds: string[];
|
||||
}
|
||||
|
||||
interface TimelineUnit {
|
||||
@@ -115,6 +118,7 @@ interface TimelinePathResult {
|
||||
cursor: TimelineCursor | null | undefined;
|
||||
cursorChanged: boolean;
|
||||
sideEffects: TimelineReducerSideEffect[];
|
||||
acknowledgedClientMessageIds: string[];
|
||||
}
|
||||
|
||||
function classifySessionTimelineSeq({
|
||||
@@ -201,76 +205,39 @@ function shouldResolveTimelineInit({
|
||||
return responseDirection === initRequestDirection;
|
||||
}
|
||||
|
||||
function deriveOptimisticLifecycleStatus(
|
||||
currentStatus: AgentLifecycleStatus,
|
||||
event: AgentStreamEventPayload,
|
||||
): AgentLifecycleStatus | null {
|
||||
if (currentStatus !== "running") {
|
||||
return null;
|
||||
}
|
||||
switch (event.type) {
|
||||
case "turn_completed":
|
||||
return "idle";
|
||||
case "turn_failed":
|
||||
return "error";
|
||||
case "turn_canceled":
|
||||
// A canceled turn can be either a final user cancel or an interrupt before
|
||||
// a replacement turn starts. The daemon snapshot is authoritative here.
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function preserveReplacePathAssistantHead(params: {
|
||||
tail: StreamItem[];
|
||||
currentHead: StreamItem[];
|
||||
}): {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
} {
|
||||
const { tail, currentHead } = params;
|
||||
const liveAssistant = currentHead.findLast(
|
||||
(item): item is Extract<StreamItem, { kind: "assistant_message" }> =>
|
||||
item.kind === "assistant_message",
|
||||
);
|
||||
if (!liveAssistant) {
|
||||
return { tail, head: [] };
|
||||
}
|
||||
const tailAssistant = tail.at(-1);
|
||||
if (!tailAssistant || tailAssistant.kind !== "assistant_message") {
|
||||
return { tail, head: currentHead };
|
||||
}
|
||||
if (!liveAssistant.text.startsWith(tailAssistant.text)) {
|
||||
return { tail, head: [] };
|
||||
}
|
||||
return {
|
||||
tail: tail.slice(0, -1),
|
||||
head: [{ ...liveAssistant, text: tailAssistant.text }],
|
||||
};
|
||||
}
|
||||
|
||||
function applyTimelineReplacePath(args: {
|
||||
timelineUnits: TimelineUnit[];
|
||||
payload: ProcessTimelineResponseInput["payload"];
|
||||
bootstrapPolicy: ReturnType<typeof deriveBootstrapTailTimelinePolicy>;
|
||||
currentTail: StreamItem[];
|
||||
currentHead: StreamItem[];
|
||||
sendingClientMessageIds: readonly string[];
|
||||
preserveLiveHead: boolean;
|
||||
toHydratedEvents: (
|
||||
units: TimelineUnit[],
|
||||
) => Array<{ event: AgentStreamEventPayload; timestamp: Date }>;
|
||||
}): TimelinePathResult {
|
||||
const { timelineUnits, payload, bootstrapPolicy, currentTail, currentHead, toHydratedEvents } =
|
||||
args;
|
||||
const {
|
||||
timelineUnits,
|
||||
payload,
|
||||
bootstrapPolicy,
|
||||
currentTail,
|
||||
currentHead,
|
||||
sendingClientMessageIds,
|
||||
preserveLiveHead,
|
||||
toHydratedEvents,
|
||||
} = args;
|
||||
const hydratedTail = hydrateStreamState(toHydratedEvents(timelineUnits), { source: "canonical" });
|
||||
const reconciledTail = reconcileLocalUserPresentationAfterReplace({
|
||||
canonicalTail: hydratedTail,
|
||||
const { tail, head, acknowledgedClientMessageIds } = replaceWithCanonicalStream({
|
||||
canonical: hydratedTail,
|
||||
previousTail: currentTail,
|
||||
previousHead: currentHead,
|
||||
});
|
||||
const { tail, head } = preserveReplacePathAssistantHead({
|
||||
tail: reconciledTail,
|
||||
currentHead,
|
||||
sendingClientMessageIds,
|
||||
preserveLiveHead,
|
||||
canonicalCoverage: {
|
||||
epoch: payload.epoch,
|
||||
endSeq: payload.endCursor?.seq ?? null,
|
||||
},
|
||||
});
|
||||
const cursor: TimelineCursor | null =
|
||||
payload.startCursor && payload.endCursor
|
||||
@@ -284,136 +251,15 @@ function applyTimelineReplacePath(args: {
|
||||
if (bootstrapPolicy.catchUpCursor) {
|
||||
sideEffects.push({ type: "catch_up", cursor: bootstrapPolicy.catchUpCursor });
|
||||
}
|
||||
return { tail, head, cursor, cursorChanged: true, sideEffects };
|
||||
}
|
||||
|
||||
function collectLocallyPresentedUserMessages(items: StreamItem[]): Array<{
|
||||
ordinal: number;
|
||||
item: UserMessageItem;
|
||||
}> {
|
||||
const localUsers: Array<{ ordinal: number; item: UserMessageItem }> = [];
|
||||
let ordinal = 0;
|
||||
for (const item of items) {
|
||||
if (item.kind !== "user_message") {
|
||||
continue;
|
||||
}
|
||||
if (item.optimistic || item.images?.length || item.attachments?.length) {
|
||||
localUsers.push({ ordinal, item });
|
||||
}
|
||||
ordinal += 1;
|
||||
}
|
||||
return localUsers;
|
||||
}
|
||||
|
||||
function mergeCanonicalUserWithLocalPresentation(
|
||||
canonical: UserMessageItem,
|
||||
local: UserMessageItem,
|
||||
): UserMessageItem {
|
||||
return {
|
||||
kind: "user_message",
|
||||
id: canonical.id,
|
||||
...(canonical.clientMessageId ? { clientMessageId: canonical.clientMessageId } : {}),
|
||||
text: local.text,
|
||||
timestamp: local.timestamp,
|
||||
...(local.images && local.images.length > 0 ? { images: local.images } : {}),
|
||||
...(local.attachments && local.attachments.length > 0
|
||||
? { attachments: local.attachments }
|
||||
: {}),
|
||||
tail,
|
||||
head,
|
||||
cursor,
|
||||
cursorChanged: true,
|
||||
sideEffects,
|
||||
acknowledgedClientMessageIds,
|
||||
};
|
||||
}
|
||||
|
||||
interface CanonicalUserMessageIdentity {
|
||||
messageId?: string;
|
||||
clientMessageId?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function matchesLocalUserMessageIdentity(
|
||||
canonical: CanonicalUserMessageIdentity,
|
||||
optimistic: UserMessageItem,
|
||||
): boolean {
|
||||
if (canonical.clientMessageId !== undefined) {
|
||||
return canonical.clientMessageId === optimistic.id;
|
||||
}
|
||||
if (canonical.messageId === optimistic.id) {
|
||||
return true;
|
||||
}
|
||||
// COMPAT(userMessageClientId): added in v0.2.0, remove after 2027-01-20 once
|
||||
// the supported daemon floor emits clientMessageId on submitted user messages.
|
||||
return canonical.text.length > 0 && canonical.text === optimistic.text;
|
||||
}
|
||||
|
||||
function reconcileLocalUserPresentationAfterReplace(params: {
|
||||
canonicalTail: StreamItem[];
|
||||
previousTail: StreamItem[];
|
||||
previousHead: StreamItem[];
|
||||
}): StreamItem[] {
|
||||
const localUsers = collectLocallyPresentedUserMessages([
|
||||
...params.previousTail,
|
||||
...params.previousHead,
|
||||
]);
|
||||
if (localUsers.length === 0) {
|
||||
return params.canonicalTail;
|
||||
}
|
||||
|
||||
const canonicalUserIndexes: number[] = [];
|
||||
params.canonicalTail.forEach((item, index) => {
|
||||
if (item.kind === "user_message") {
|
||||
canonicalUserIndexes.push(index);
|
||||
}
|
||||
});
|
||||
|
||||
const nextTail = [...params.canonicalTail];
|
||||
const claimedCanonicalIndexes = new Set<number>();
|
||||
const unmatched: UserMessageItem[] = [];
|
||||
|
||||
for (const local of localUsers) {
|
||||
const exactIndex = canonicalUserIndexes.find((index) => {
|
||||
if (claimedCanonicalIndexes.has(index)) return false;
|
||||
const canonical = params.canonicalTail[index];
|
||||
return (
|
||||
canonical?.kind === "user_message" &&
|
||||
matchesLocalUserMessageIdentity(
|
||||
{
|
||||
messageId: canonical.id,
|
||||
clientMessageId: canonical.clientMessageId,
|
||||
text: canonical.text,
|
||||
},
|
||||
local.item,
|
||||
)
|
||||
);
|
||||
});
|
||||
const ordinalIndex = canonicalUserIndexes[local.ordinal];
|
||||
const ordinalItem = ordinalIndex === undefined ? undefined : params.canonicalTail[ordinalIndex];
|
||||
const canonicalIndex =
|
||||
exactIndex ??
|
||||
(ordinalIndex !== undefined &&
|
||||
!claimedCanonicalIndexes.has(ordinalIndex) &&
|
||||
ordinalItem?.kind === "user_message" &&
|
||||
ordinalItem.clientMessageId === undefined
|
||||
? ordinalIndex
|
||||
: undefined);
|
||||
const canonicalItem = canonicalIndex === undefined ? undefined : nextTail[canonicalIndex];
|
||||
if (canonicalIndex === undefined || !canonicalItem || canonicalItem.kind !== "user_message") {
|
||||
if (local.item.optimistic) {
|
||||
unmatched.push(local.item);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
nextTail[canonicalIndex] = mergeCanonicalUserWithLocalPresentation(canonicalItem, local.item);
|
||||
claimedCanonicalIndexes.add(canonicalIndex);
|
||||
}
|
||||
|
||||
for (const item of unmatched) {
|
||||
const insertionIndex = nextTail.findIndex(
|
||||
(canonical) => canonical.timestamp.getTime() > item.timestamp.getTime(),
|
||||
);
|
||||
nextTail.splice(insertionIndex < 0 ? nextTail.length : insertionIndex, 0, item);
|
||||
}
|
||||
|
||||
return nextTail;
|
||||
}
|
||||
|
||||
interface IncrementalAcceptResult {
|
||||
acceptedUnits: TimelineUnit[];
|
||||
cursor: TimelineCursor | undefined;
|
||||
@@ -515,6 +361,34 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea
|
||||
return olderTail;
|
||||
}
|
||||
|
||||
const remainingOlder: StreamItem[] = [];
|
||||
let reconciledCurrent = currentTail;
|
||||
for (const item of olderTail) {
|
||||
if (item.kind !== "user_message") {
|
||||
remainingOlder.push(item);
|
||||
continue;
|
||||
}
|
||||
const result = upsertUserMessageAcrossStream({
|
||||
tail: reconciledCurrent,
|
||||
head: [],
|
||||
message: item,
|
||||
insert: "prepend-tail",
|
||||
presentation: "existing",
|
||||
});
|
||||
if (result.location?.matched) {
|
||||
remainingOlder.push(result.location.message);
|
||||
reconciledCurrent = [
|
||||
...result.tail.slice(0, result.location.index),
|
||||
...result.tail.slice(result.location.index + 1),
|
||||
];
|
||||
} else {
|
||||
remainingOlder.push(item);
|
||||
}
|
||||
}
|
||||
olderTail = remainingOlder;
|
||||
currentTail = reconciledCurrent;
|
||||
if (olderTail.length === 0) return currentTail;
|
||||
|
||||
const olderLast = olderTail.at(-1);
|
||||
const currentFirst = currentTail[0];
|
||||
|
||||
@@ -536,16 +410,15 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea
|
||||
return [...olderTail, ...currentTail];
|
||||
}
|
||||
|
||||
return [
|
||||
...olderTail.slice(0, -1),
|
||||
{
|
||||
...olderLast,
|
||||
text: `${olderLast.text}${currentFirst.text}`,
|
||||
timestamp: currentFirst.timestamp,
|
||||
...(currentFirst.timelineCursor ? { timelineCursor: currentFirst.timelineCursor } : {}),
|
||||
},
|
||||
...currentTail.slice(1),
|
||||
];
|
||||
const mergedAssistant: AssistantMessageItem = {
|
||||
...currentFirst,
|
||||
text: `${olderLast.text}${currentFirst.text}`,
|
||||
};
|
||||
if (mergedAssistant.messageId === undefined && olderLast.messageId !== undefined) {
|
||||
mergedAssistant.messageId = olderLast.messageId;
|
||||
}
|
||||
|
||||
return [...olderTail.slice(0, -1), mergedAssistant, ...currentTail.slice(1)];
|
||||
}
|
||||
|
||||
function replaceLiveAssistantWithProjectedText(params: {
|
||||
@@ -745,9 +618,24 @@ function applyCanonicalForwardUnit(params: {
|
||||
head: StreamItem[];
|
||||
unit: TimelineUnit;
|
||||
epoch: string;
|
||||
}): { tail: StreamItem[]; head: StreamItem[] } {
|
||||
}): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } {
|
||||
const { event, timestamp, seqEnd } = params.unit;
|
||||
const timelineCursor = { epoch: params.epoch, seq: seqEnd };
|
||||
if (event.type === "timeline" && event.item.type === "user_message") {
|
||||
const applied = applyStreamEvent({
|
||||
tail: params.tail,
|
||||
head: params.head,
|
||||
event,
|
||||
timestamp,
|
||||
source: "canonical",
|
||||
timelineCursor,
|
||||
});
|
||||
return {
|
||||
tail: applied.tail,
|
||||
head: applied.head,
|
||||
acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [],
|
||||
};
|
||||
}
|
||||
if (params.head.length === 0) {
|
||||
return {
|
||||
tail: reduceStreamUpdate(params.tail, event, timestamp, {
|
||||
@@ -755,6 +643,7 @@ function applyCanonicalForwardUnit(params: {
|
||||
timelineCursor,
|
||||
}),
|
||||
head: params.head,
|
||||
acknowledgedClientMessageIds: [],
|
||||
};
|
||||
}
|
||||
const replacedHead = replaceLiveAssistantWithProjectedText({
|
||||
@@ -763,7 +652,9 @@ function applyCanonicalForwardUnit(params: {
|
||||
timestamp,
|
||||
timelineCursor,
|
||||
});
|
||||
if (replacedHead) return { tail: params.tail, head: replacedHead };
|
||||
if (replacedHead) {
|
||||
return { tail: params.tail, head: replacedHead, acknowledgedClientMessageIds: [] };
|
||||
}
|
||||
|
||||
const activeAssistant = params.head.findLast(
|
||||
(item): item is Extract<StreamItem, { kind: "assistant_message" }> =>
|
||||
@@ -781,6 +672,7 @@ function applyCanonicalForwardUnit(params: {
|
||||
source: "canonical",
|
||||
timelineCursor,
|
||||
}),
|
||||
acknowledgedClientMessageIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -792,7 +684,11 @@ function applyCanonicalForwardUnit(params: {
|
||||
source: "canonical",
|
||||
timelineCursor,
|
||||
});
|
||||
return { tail: applied.tail, head: applied.head };
|
||||
return {
|
||||
tail: applied.tail,
|
||||
head: applied.head,
|
||||
acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function applyAcceptedForwardTimelineUnits(params: {
|
||||
@@ -801,7 +697,7 @@ function applyAcceptedForwardTimelineUnits(params: {
|
||||
currentTail: StreamItem[];
|
||||
currentHead: StreamItem[];
|
||||
currentEndSeq: number | undefined;
|
||||
}): { tail: StreamItem[]; head: StreamItem[] } {
|
||||
}): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } {
|
||||
const reconciled = reconcileOverlappingProjectedStreamItems({
|
||||
tail: params.currentTail,
|
||||
head: params.currentHead,
|
||||
@@ -811,15 +707,19 @@ function applyAcceptedForwardTimelineUnits(params: {
|
||||
});
|
||||
let tail = reconciled.tail;
|
||||
let head = reconciled.head;
|
||||
const acknowledgedClientMessageIds = new Set<string>();
|
||||
|
||||
for (const unit of params.units) {
|
||||
if (reconciled.reconciledUnits.has(unit)) continue;
|
||||
const applied = applyCanonicalForwardUnit({ tail, head, unit, epoch: params.epoch });
|
||||
tail = applied.tail;
|
||||
head = applied.head;
|
||||
for (const clientMessageId of applied.acknowledgedClientMessageIds) {
|
||||
acknowledgedClientMessageIds.add(clientMessageId);
|
||||
}
|
||||
}
|
||||
|
||||
return { tail, head };
|
||||
return { tail, head, acknowledgedClientMessageIds: [...acknowledgedClientMessageIds] };
|
||||
}
|
||||
|
||||
function applyTimelineIncrementalPath(args: {
|
||||
@@ -835,9 +735,17 @@ function applyTimelineIncrementalPath(args: {
|
||||
let nextCursor: TimelineCursor | null | undefined = currentCursor;
|
||||
let cursorChanged = false;
|
||||
const sideEffects: TimelineReducerSideEffect[] = [];
|
||||
let acknowledgedClientMessageIds: string[] = [];
|
||||
|
||||
if (timelineUnits.length === 0) {
|
||||
return { tail: nextTail, head: nextHead, cursor: nextCursor, cursorChanged, sideEffects };
|
||||
return {
|
||||
tail: nextTail,
|
||||
head: nextHead,
|
||||
cursor: nextCursor,
|
||||
cursorChanged,
|
||||
sideEffects,
|
||||
acknowledgedClientMessageIds,
|
||||
};
|
||||
}
|
||||
|
||||
const { acceptedUnits, cursor, gapCursor } =
|
||||
@@ -874,6 +782,7 @@ function applyTimelineIncrementalPath(args: {
|
||||
});
|
||||
nextTail = applied.tail;
|
||||
nextHead = applied.head;
|
||||
acknowledgedClientMessageIds = applied.acknowledgedClientMessageIds;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -892,7 +801,14 @@ function applyTimelineIncrementalPath(args: {
|
||||
sideEffects.push({ type: "catch_up", cursor: gapCursor });
|
||||
}
|
||||
|
||||
return { tail: nextTail, head: nextHead, cursor: nextCursor, cursorChanged, sideEffects };
|
||||
return {
|
||||
tail: nextTail,
|
||||
head: nextHead,
|
||||
cursor: nextCursor,
|
||||
cursorChanged,
|
||||
sideEffects,
|
||||
acknowledgedClientMessageIds,
|
||||
};
|
||||
}
|
||||
|
||||
export function processTimelineResponse(
|
||||
@@ -906,6 +822,8 @@ export function processTimelineResponse(
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
initRequestDirection,
|
||||
sendingClientMessageIds,
|
||||
hasAuthoritativeBaseline,
|
||||
} = input;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -921,6 +839,7 @@ export function processTimelineResponse(
|
||||
clearInitializing: isInitializing,
|
||||
error: payload.error,
|
||||
sideEffects: [],
|
||||
acknowledgedClientMessageIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -967,7 +886,6 @@ export function processTimelineResponse(
|
||||
hasActiveInitDeferred,
|
||||
});
|
||||
const replace = bootstrapPolicy.replace;
|
||||
|
||||
const sideEffects: TimelineReducerSideEffect[] = [];
|
||||
const timelineResult = replace
|
||||
? applyTimelineReplacePath({
|
||||
@@ -976,6 +894,9 @@ export function processTimelineResponse(
|
||||
bootstrapPolicy,
|
||||
currentTail,
|
||||
currentHead,
|
||||
sendingClientMessageIds,
|
||||
preserveLiveHead:
|
||||
currentCursor?.epoch === payload.epoch || (!payload.reset && !hasAuthoritativeBaseline),
|
||||
toHydratedEvents,
|
||||
})
|
||||
: applyTimelineIncrementalPath({
|
||||
@@ -1024,6 +945,7 @@ export function processTimelineResponse(
|
||||
clearInitializing,
|
||||
error: null,
|
||||
sideEffects,
|
||||
acknowledgedClientMessageIds: timelineResult.acknowledgedClientMessageIds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1038,20 +960,10 @@ export interface ProcessAgentStreamEventInput {
|
||||
currentTail: StreamItem[];
|
||||
currentHead: StreamItem[];
|
||||
currentCursor: TimelineCursor | undefined;
|
||||
currentAgent: {
|
||||
status: AgentLifecycleStatus;
|
||||
updatedAt: Date;
|
||||
lastActivityAt: Date;
|
||||
} | null;
|
||||
hasAuthoritativeBaseline?: boolean;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface AgentPatch {
|
||||
status: AgentLifecycleStatus;
|
||||
updatedAt: Date;
|
||||
lastActivityAt: Date;
|
||||
}
|
||||
|
||||
export interface ProcessAgentStreamEventOutput {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
@@ -1059,8 +971,7 @@ export interface ProcessAgentStreamEventOutput {
|
||||
changedHead: boolean;
|
||||
cursor: TimelineCursor | null;
|
||||
cursorChanged: boolean;
|
||||
agent: AgentPatch | null;
|
||||
agentChanged: boolean;
|
||||
acknowledgedClientMessageIds: string[];
|
||||
sideEffects: AgentStreamReducerSideEffect[];
|
||||
}
|
||||
|
||||
@@ -1079,18 +990,12 @@ interface TimelineSequencingGateResult {
|
||||
sideEffects: AgentStreamReducerSideEffect[];
|
||||
}
|
||||
|
||||
export interface AgentStreamReducerAgentSnapshot {
|
||||
status: AgentLifecycleStatus;
|
||||
updatedAt: Date;
|
||||
lastActivityAt: Date;
|
||||
}
|
||||
|
||||
export interface ProcessAgentStreamEventsInput {
|
||||
events: AgentStreamReducerEvent[];
|
||||
currentTail: StreamItem[];
|
||||
currentHead: StreamItem[];
|
||||
currentCursor: TimelineCursor | undefined;
|
||||
currentAgent: AgentStreamReducerAgentSnapshot | null;
|
||||
hasAuthoritativeBaseline?: boolean;
|
||||
}
|
||||
|
||||
export type AgentStreamReducerSnapshot = Omit<ProcessAgentStreamEventsInput, "events">;
|
||||
@@ -1114,27 +1019,14 @@ export interface CreateAgentStreamReducerQueueInput {
|
||||
cancelFlush: (id: number) => void;
|
||||
}
|
||||
|
||||
function applyAgentPatch(
|
||||
currentAgent: AgentStreamReducerAgentSnapshot | null,
|
||||
patch: AgentPatch | null,
|
||||
): AgentStreamReducerAgentSnapshot | null {
|
||||
if (!currentAgent || !patch) {
|
||||
return currentAgent;
|
||||
}
|
||||
return {
|
||||
status: patch.status,
|
||||
updatedAt: patch.updatedAt,
|
||||
lastActivityAt: patch.lastActivityAt,
|
||||
};
|
||||
}
|
||||
|
||||
function processTimelineSequencingGate(input: {
|
||||
event: AgentStreamEventPayload;
|
||||
seq: number | undefined;
|
||||
epoch: string | undefined;
|
||||
currentCursor: TimelineCursor | undefined;
|
||||
hasAuthoritativeBaseline: boolean;
|
||||
}): TimelineSequencingGateResult {
|
||||
const { event, seq, epoch, currentCursor } = input;
|
||||
const { event, seq, epoch, currentCursor, hasAuthoritativeBaseline } = input;
|
||||
const base: TimelineSequencingGateResult = {
|
||||
shouldApplyStreamEvent: true,
|
||||
nextTimelineCursor: null,
|
||||
@@ -1145,6 +1037,9 @@ function processTimelineSequencingGate(input: {
|
||||
if (event.type !== "timeline" || typeof seq !== "number" || typeof epoch !== "string") {
|
||||
return base;
|
||||
}
|
||||
if (!hasAuthoritativeBaseline) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const decision = classifySessionTimelineSeq({
|
||||
cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null,
|
||||
@@ -1201,10 +1096,24 @@ function processTimelineSequencingGate(input: {
|
||||
export function processAgentStreamEvent(
|
||||
input: ProcessAgentStreamEventInput,
|
||||
): ProcessAgentStreamEventOutput {
|
||||
const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } =
|
||||
input;
|
||||
const {
|
||||
event,
|
||||
seq,
|
||||
epoch,
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
timestamp,
|
||||
hasAuthoritativeBaseline = true,
|
||||
} = input;
|
||||
|
||||
const sequencing = processTimelineSequencingGate({ event, seq, epoch, currentCursor });
|
||||
const sequencing = processTimelineSequencingGate({
|
||||
event,
|
||||
seq,
|
||||
epoch,
|
||||
currentCursor,
|
||||
hasAuthoritativeBaseline,
|
||||
});
|
||||
const timelineCursor =
|
||||
event.type === "timeline" && seq !== undefined && epoch !== undefined
|
||||
? { epoch, seq }
|
||||
@@ -1213,49 +1122,52 @@ export function processAgentStreamEvent(
|
||||
// ------------------------------------------------------------------
|
||||
// Apply stream event to tail/head
|
||||
// ------------------------------------------------------------------
|
||||
const { tail, head, changedTail, changedHead } = sequencing.shouldApplyStreamEvent
|
||||
? applyStreamEvent({
|
||||
tail: sequencing.resetLiveTimeline ? [] : currentTail,
|
||||
head: sequencing.resetLiveTimeline ? [] : currentHead,
|
||||
let streamResult: ReturnType<typeof applyStreamEvent>;
|
||||
if (!sequencing.shouldApplyStreamEvent) {
|
||||
streamResult = {
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
changedTail: false,
|
||||
changedHead: false,
|
||||
};
|
||||
} else if (!hasAuthoritativeBaseline) {
|
||||
if (event.type === "timeline" && event.item.type === "user_message") {
|
||||
streamResult = applyStreamEvent({
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
event,
|
||||
timestamp,
|
||||
source: "live",
|
||||
timelineCursor,
|
||||
})
|
||||
: {
|
||||
unmatchedUserMessageInsert: "head",
|
||||
});
|
||||
} else {
|
||||
const overlay = applyStreamEvent({
|
||||
tail: currentHead,
|
||||
head: [],
|
||||
event,
|
||||
timestamp,
|
||||
source: "live",
|
||||
timelineCursor,
|
||||
});
|
||||
streamResult = {
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
head: [...overlay.tail, ...overlay.head],
|
||||
changedTail: false,
|
||||
changedHead: false,
|
||||
changedHead: overlay.changedTail || overlay.changedHead,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Optimistic lifecycle status
|
||||
// ------------------------------------------------------------------
|
||||
let agentPatch: AgentPatch | null = null;
|
||||
let agentChanged = false;
|
||||
|
||||
if (
|
||||
currentAgent &&
|
||||
(event.type === "turn_completed" ||
|
||||
event.type === "turn_canceled" ||
|
||||
event.type === "turn_failed")
|
||||
) {
|
||||
const optimisticStatus = deriveOptimisticLifecycleStatus(currentAgent.status, event);
|
||||
if (optimisticStatus) {
|
||||
const nextUpdatedAtMs = Math.max(currentAgent.updatedAt.getTime(), timestamp.getTime());
|
||||
const nextLastActivityAtMs = Math.max(
|
||||
currentAgent.lastActivityAt.getTime(),
|
||||
timestamp.getTime(),
|
||||
);
|
||||
agentPatch = {
|
||||
status: optimisticStatus,
|
||||
updatedAt: new Date(nextUpdatedAtMs),
|
||||
lastActivityAt: new Date(nextLastActivityAtMs),
|
||||
};
|
||||
agentChanged = true;
|
||||
}
|
||||
} else {
|
||||
streamResult = applyStreamEvent({
|
||||
tail: sequencing.resetLiveTimeline ? [] : currentTail,
|
||||
head: sequencing.resetLiveTimeline ? [] : currentHead,
|
||||
event,
|
||||
timestamp,
|
||||
source: "live",
|
||||
timelineCursor,
|
||||
});
|
||||
}
|
||||
const { tail, head, changedTail, changedHead } = streamResult;
|
||||
|
||||
return {
|
||||
tail,
|
||||
@@ -1264,8 +1176,7 @@ export function processAgentStreamEvent(
|
||||
changedHead,
|
||||
cursor: sequencing.nextTimelineCursor,
|
||||
cursorChanged: sequencing.cursorChanged,
|
||||
agent: agentPatch,
|
||||
agentChanged,
|
||||
acknowledgedClientMessageIds: streamResult.acknowledgedClientMessageIds ?? [],
|
||||
sideEffects: sequencing.sideEffects,
|
||||
};
|
||||
}
|
||||
@@ -1276,12 +1187,10 @@ export function processAgentStreamEvents(
|
||||
let tail = input.currentTail;
|
||||
let head = input.currentHead;
|
||||
let cursor = input.currentCursor;
|
||||
let agent = input.currentAgent;
|
||||
let changedTail = false;
|
||||
let changedHead = false;
|
||||
let cursorChanged = false;
|
||||
let agentPatch: AgentPatch | null = null;
|
||||
let agentChanged = false;
|
||||
const acknowledgedClientMessageIds = new Set<string>();
|
||||
const sideEffects: AgentStreamReducerSideEffect[] = [];
|
||||
|
||||
for (const reducerEvent of input.events) {
|
||||
@@ -1292,7 +1201,7 @@ export function processAgentStreamEvents(
|
||||
currentTail: tail,
|
||||
currentHead: head,
|
||||
currentCursor: cursor,
|
||||
currentAgent: agent,
|
||||
hasAuthoritativeBaseline: input.hasAuthoritativeBaseline,
|
||||
timestamp: reducerEvent.timestamp,
|
||||
});
|
||||
|
||||
@@ -1301,17 +1210,14 @@ export function processAgentStreamEvents(
|
||||
changedTail = changedTail || result.changedTail;
|
||||
changedHead = changedHead || result.changedHead;
|
||||
sideEffects.push(...result.sideEffects);
|
||||
for (const clientMessageId of result.acknowledgedClientMessageIds) {
|
||||
acknowledgedClientMessageIds.add(clientMessageId);
|
||||
}
|
||||
|
||||
if (result.cursorChanged) {
|
||||
cursor = result.cursor ?? undefined;
|
||||
cursorChanged = true;
|
||||
}
|
||||
|
||||
if (result.agentChanged) {
|
||||
agentPatch = result.agent;
|
||||
agentChanged = true;
|
||||
agent = applyAgentPatch(agent, result.agent);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1321,8 +1227,7 @@ export function processAgentStreamEvents(
|
||||
changedHead,
|
||||
cursor: cursor ?? null,
|
||||
cursorChanged,
|
||||
agent: agentPatch,
|
||||
agentChanged,
|
||||
acknowledgedClientMessageIds: [...acknowledgedClientMessageIds],
|
||||
sideEffects,
|
||||
};
|
||||
}
|
||||
@@ -1405,6 +1310,7 @@ export function createAgentStreamReducerQueue(
|
||||
interface StreamStatePatch {
|
||||
tail?: StreamItem[];
|
||||
head?: StreamItem[];
|
||||
acknowledgedClientMessageIds?: readonly string[];
|
||||
}
|
||||
|
||||
export interface CreateSessionAgentStreamReducerQueueInput {
|
||||
@@ -1414,7 +1320,6 @@ export interface CreateSessionAgentStreamReducerQueueInput {
|
||||
serverId: string,
|
||||
state: (prev: Map<string, TimelineCursor>) => Map<string, TimelineCursor>,
|
||||
) => void;
|
||||
setAgents: (serverId: string, state: (prev: Map<string, Agent>) => Map<string, Agent>) => void;
|
||||
recoverTimelineGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
|
||||
}
|
||||
|
||||
@@ -1429,31 +1334,31 @@ function cancelAgentStreamReducerFlush(id: number) {
|
||||
export function createSessionAgentStreamReducerQueue(
|
||||
input: CreateSessionAgentStreamReducerQueueInput,
|
||||
): AgentStreamReducerQueue {
|
||||
const { serverId, setAgentStreamState, setAgentTimelineCursor, setAgents, recoverTimelineGap } =
|
||||
input;
|
||||
const { serverId, setAgentStreamState, setAgentTimelineCursor, recoverTimelineGap } = input;
|
||||
|
||||
return createAgentStreamReducerQueue({
|
||||
getSnapshot: (agentId) => {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const currentAgentEntry = session?.agents.get(agentId);
|
||||
const timeline = selectAgentTimelineState(session, agentId);
|
||||
return {
|
||||
currentTail: session?.agentStreamTail.get(agentId) ?? [],
|
||||
currentTail: timeline.status === "cold" ? [] : timeline.items,
|
||||
currentHead: session?.agentStreamHead.get(agentId) ?? [],
|
||||
currentCursor: session?.agentTimelineCursor.get(agentId),
|
||||
currentAgent: currentAgentEntry
|
||||
? {
|
||||
status: currentAgentEntry.status,
|
||||
updatedAt: currentAgentEntry.updatedAt,
|
||||
lastActivityAt: currentAgentEntry.lastActivityAt,
|
||||
}
|
||||
: null,
|
||||
currentCursor: timeline.status === "synced" ? (timeline.range ?? undefined) : undefined,
|
||||
hasAuthoritativeBaseline: timeline.status === "synced",
|
||||
};
|
||||
},
|
||||
commit: (agentId, result, events) => {
|
||||
if (result.changedTail || result.changedHead) {
|
||||
if (
|
||||
result.changedTail ||
|
||||
result.changedHead ||
|
||||
result.acknowledgedClientMessageIds.length > 0
|
||||
) {
|
||||
setAgentStreamState(serverId, agentId, {
|
||||
...(result.changedTail ? { tail: result.tail } : {}),
|
||||
...(result.changedHead ? { head: result.head } : {}),
|
||||
...(result.acknowledgedClientMessageIds.length > 0
|
||||
? { acknowledgedClientMessageIds: result.acknowledgedClientMessageIds }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1486,24 +1391,6 @@ export function createSessionAgentStreamReducerQueue(
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (result.agentChanged && result.agent) {
|
||||
const nextAgent = result.agent;
|
||||
setAgents(serverId, (prev) => {
|
||||
const current = prev.get(agentId);
|
||||
if (!current) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, {
|
||||
...current,
|
||||
status: nextAgent.status,
|
||||
updatedAt: nextAgent.updatedAt,
|
||||
lastActivityAt: nextAgent.lastActivityAt,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
handleSideEffects: (agentId, sideEffects) => {
|
||||
for (const effect of sideEffects) {
|
||||
|
||||
@@ -22,34 +22,16 @@ function assistant(id: string, timestamp: Date): StreamItem {
|
||||
}
|
||||
|
||||
describe("deriveStreamTurnTiming", () => {
|
||||
it("reserves a running footer for an optimistic prompt before the host starts the turn", () => {
|
||||
const optimisticPrompt = {
|
||||
...user("optimistic", new Date("2026-05-15T00:00:00.000Z")),
|
||||
optimistic: true as const,
|
||||
};
|
||||
|
||||
const timing = deriveStreamTurnTiming({
|
||||
agentStatus: "idle",
|
||||
tail: [],
|
||||
head: [optimisticPrompt],
|
||||
});
|
||||
|
||||
assert.equal(timing.isActive, true);
|
||||
});
|
||||
|
||||
it("does not start elapsed time from an optimistic prompt", () => {
|
||||
const optimisticPrompt = {
|
||||
...user("optimistic", new Date("2026-05-15T00:00:00.000Z")),
|
||||
optimistic: true as const,
|
||||
};
|
||||
it("starts elapsed time from the submitted prompt", () => {
|
||||
const submittedAt = new Date("2026-05-15T00:00:00.000Z");
|
||||
|
||||
const timing = deriveStreamTurnTiming({
|
||||
agentStatus: "running",
|
||||
tail: [],
|
||||
head: [optimisticPrompt],
|
||||
head: [user("submitted", submittedAt)],
|
||||
});
|
||||
|
||||
assert.equal(timing.runningStartedAt, null);
|
||||
assert.equal(timing.runningStartedAt, submittedAt);
|
||||
});
|
||||
|
||||
it("uses the last user message as the running turn start", () => {
|
||||
|
||||
@@ -9,7 +9,6 @@ export interface TurnTiming {
|
||||
export interface StreamTurnTiming {
|
||||
byAssistantId: Map<string, TurnTiming>;
|
||||
runningStartedAt: Date | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function deriveStreamTurnTiming(params: {
|
||||
@@ -19,8 +18,6 @@ export function deriveStreamTurnTiming(params: {
|
||||
}): StreamTurnTiming {
|
||||
const byAssistantId = new Map<string, TurnTiming>();
|
||||
let currentUserAt: Date | null = null;
|
||||
let currentAuthoritativeUserAt: Date | null = null;
|
||||
let currentUserIsOptimistic = false;
|
||||
let currentLastItemAt: Date | null = null;
|
||||
let currentAssistantIds: string[] = [];
|
||||
|
||||
@@ -42,8 +39,6 @@ export function deriveStreamTurnTiming(params: {
|
||||
if (item.kind === "user_message") {
|
||||
flushCompletedTurn();
|
||||
currentUserAt = item.timestamp;
|
||||
currentAuthoritativeUserAt = item.optimistic ? null : item.timestamp;
|
||||
currentUserIsOptimistic = item.optimistic === true;
|
||||
currentLastItemAt = null;
|
||||
currentAssistantIds = [];
|
||||
return;
|
||||
@@ -65,7 +60,7 @@ export function deriveStreamTurnTiming(params: {
|
||||
}
|
||||
|
||||
const isRunning = params.agentStatus === "running";
|
||||
const runningStartedAt = isRunning ? currentAuthoritativeUserAt : null;
|
||||
const runningStartedAt = isRunning ? currentUserAt : null;
|
||||
if (params.agentStatus !== "running") {
|
||||
flushCompletedTurn();
|
||||
}
|
||||
@@ -73,6 +68,5 @@ export function deriveStreamTurnTiming(params: {
|
||||
return {
|
||||
byAssistantId,
|
||||
runningStartedAt,
|
||||
isActive: isRunning || currentUserIsOptimistic,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
applyStreamEvent,
|
||||
appendOptimisticUserMessageToStream,
|
||||
buildOptimisticUserMessage,
|
||||
clearOptimisticUserMessages,
|
||||
createUserMessage,
|
||||
handoffCreatedAgentUserMessageToStream,
|
||||
hydrateStreamState,
|
||||
mergeToolCallDetail,
|
||||
@@ -14,6 +12,8 @@ import {
|
||||
type AgentToolCallItem,
|
||||
type StreamItem,
|
||||
isAgentToolCallItem,
|
||||
upsertUserMessage,
|
||||
upsertUserMessageAcrossStream,
|
||||
} from "./stream";
|
||||
import type { AgentProvider, ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
@@ -21,6 +21,109 @@ import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"
|
||||
|
||||
type CanonicalToolStatus = "running" | "completed" | "failed" | "canceled";
|
||||
|
||||
describe("user message identity", () => {
|
||||
it("adds provider identity without replacing local presentation", () => {
|
||||
const timestamp = new Date("2026-07-26T10:00:00.000Z");
|
||||
const local = createUserMessage({
|
||||
clientMessageId: "client-1",
|
||||
text: "local text",
|
||||
timestamp,
|
||||
images: [
|
||||
{
|
||||
id: "image-1",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "image-1.png",
|
||||
createdAt: timestamp.getTime(),
|
||||
},
|
||||
],
|
||||
attachments: [{ type: "text", mimeType: "text/plain", text: "attachment" }],
|
||||
});
|
||||
const canonical = createUserMessage({
|
||||
id: "provider-1",
|
||||
messageId: "provider-1",
|
||||
clientMessageId: "client-1",
|
||||
text: "provider text",
|
||||
timestamp: new Date("2026-07-26T10:00:01.000Z"),
|
||||
});
|
||||
|
||||
const first = upsertUserMessage([local], canonical);
|
||||
const second = upsertUserMessage(first, canonical);
|
||||
|
||||
expect(first).toEqual([
|
||||
{
|
||||
...local,
|
||||
messageId: "provider-1",
|
||||
clientMessageId: "client-1",
|
||||
},
|
||||
]);
|
||||
expect(first[0]).toBe(second[0]);
|
||||
});
|
||||
|
||||
it("keeps local presentation when a later canonical row omits provider identity", () => {
|
||||
const timestamp = new Date("2026-07-27T10:00:00.000Z");
|
||||
const local = createUserMessage({
|
||||
clientMessageId: "client-1",
|
||||
messageId: "provider-1",
|
||||
text: "local text",
|
||||
timestamp,
|
||||
images: [
|
||||
{
|
||||
id: "image-1",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "image-1.png",
|
||||
createdAt: timestamp.getTime(),
|
||||
},
|
||||
],
|
||||
attachments: [{ type: "text", mimeType: "text/plain", text: "local attachment" }],
|
||||
});
|
||||
const canonicalWithoutProviderIdentity = createUserMessage({
|
||||
id: "canonical-page-row",
|
||||
clientMessageId: "client-1",
|
||||
text: "provider-shaped text",
|
||||
timestamp: new Date("2026-07-27T10:00:01.000Z"),
|
||||
});
|
||||
|
||||
const result = upsertUserMessage([local], canonicalWithoutProviderIdentity);
|
||||
|
||||
expect(result).toEqual([local]);
|
||||
});
|
||||
|
||||
it("matches a submitted message against a legacy canonical row that has no client identity", () => {
|
||||
// Daemons before v0.2.0 do not echo clientMessageId. During agent creation the
|
||||
// legacy canonical row can land before the local submission is handed off, so the
|
||||
// submitted row arrives as `incoming` and must still match by text.
|
||||
const timestamp = new Date("2026-07-27T11:00:00.000Z");
|
||||
const legacyCanonical = createUserMessage({
|
||||
id: "provider-1",
|
||||
messageId: "provider-1",
|
||||
text: "review this",
|
||||
timestamp,
|
||||
});
|
||||
const submitted = createUserMessage({
|
||||
clientMessageId: "client-1",
|
||||
text: "review this",
|
||||
timestamp: new Date("2026-07-27T11:00:01.000Z"),
|
||||
attachments: [{ type: "text", mimeType: "text/plain", text: "attachment" }],
|
||||
});
|
||||
|
||||
const result = handoffCreatedAgentUserMessageToStream({
|
||||
tail: [legacyCanonical],
|
||||
head: [],
|
||||
message: submitted,
|
||||
});
|
||||
|
||||
expect(result.tail).toEqual([
|
||||
{
|
||||
...submitted,
|
||||
id: "client-1",
|
||||
messageId: "provider-1",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function assistantTimeline(
|
||||
text: string,
|
||||
provider: AgentProvider = "claude",
|
||||
@@ -895,15 +998,15 @@ describe("stream reducer canonical tool calls", () => {
|
||||
assert.strictEqual(todos.items[0]?.text, "Task 1");
|
||||
});
|
||||
|
||||
it("preserves optimistic user message images when authoritative user message arrives", () => {
|
||||
it("preserves submitted user message images when authoritative user message arrives", () => {
|
||||
const messageId = "msg-user-images";
|
||||
const optimisticTimestamp = new Date("2025-01-01T11:10:00Z");
|
||||
const optimisticImages = [
|
||||
const submittedTimestamp = new Date("2025-01-01T11:10:00Z");
|
||||
const submittedImages = [
|
||||
{
|
||||
id: "att-optimistic",
|
||||
id: "att-submitted",
|
||||
mimeType: "image/jpeg",
|
||||
storageType: "native-file" as const,
|
||||
storageKey: "/tmp/optimistic.jpg",
|
||||
storageKey: "/tmp/submitted.jpg",
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
@@ -911,10 +1014,10 @@ describe("stream reducer canonical tool calls", () => {
|
||||
{
|
||||
kind: "user_message",
|
||||
id: messageId,
|
||||
clientMessageId: messageId,
|
||||
text: "Analyze this image",
|
||||
timestamp: optimisticTimestamp,
|
||||
optimistic: true,
|
||||
images: optimisticImages,
|
||||
timestamp: submittedTimestamp,
|
||||
images: submittedImages,
|
||||
},
|
||||
];
|
||||
const event: AgentStreamEventPayload = {
|
||||
@@ -933,9 +1036,9 @@ describe("stream reducer canonical tool calls", () => {
|
||||
|
||||
assert.ok(message);
|
||||
assert.strictEqual(message.id, messageId);
|
||||
assert.deepStrictEqual(message.images, optimisticImages);
|
||||
assert.deepStrictEqual(message.images, submittedImages);
|
||||
assert.strictEqual(message.text, "Analyze this image");
|
||||
assert.strictEqual(message.timestamp.getTime(), optimisticTimestamp.getTime());
|
||||
assert.strictEqual(message.timestamp.getTime(), submittedTimestamp.getTime());
|
||||
});
|
||||
|
||||
it("keeps canonical assistant/user/assistant order during replay", () => {
|
||||
@@ -981,7 +1084,7 @@ describe("stream reducer canonical tool calls", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps live optimistic assistant merge behavior", () => {
|
||||
it("keeps live submitted assistant merge behavior", () => {
|
||||
const state: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
@@ -1117,23 +1220,23 @@ describe("turn lifecycle events", () => {
|
||||
});
|
||||
|
||||
it.each(["codex", "opencode", "pi"] satisfies AgentProvider[])(
|
||||
"replaces an optimistic user message when a live %s provider-owned id echo arrives without text matching",
|
||||
"replaces a submitted user message when a live %s provider-owned id echo arrives without text matching",
|
||||
(provider) => {
|
||||
const optimisticTimestamp = new Date("2025-01-01T15:02:00Z");
|
||||
const submittedTimestamp = new Date("2025-01-01T15:02:00Z");
|
||||
const serverTimestamp = new Date("2025-01-01T15:02:01Z");
|
||||
const optimistic: StreamItem = {
|
||||
const submitted: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "msg_optimistic",
|
||||
id: "msg_submitted",
|
||||
clientMessageId: "msg_submitted",
|
||||
text: "same user text",
|
||||
timestamp: optimisticTimestamp,
|
||||
optimistic: true,
|
||||
timestamp: submittedTimestamp,
|
||||
images: [
|
||||
{
|
||||
id: "image-1",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "image-1",
|
||||
createdAt: optimisticTimestamp.getTime(),
|
||||
createdAt: submittedTimestamp.getTime(),
|
||||
},
|
||||
],
|
||||
attachments: [
|
||||
@@ -1147,7 +1250,7 @@ describe("turn lifecycle events", () => {
|
||||
};
|
||||
|
||||
const state = reduceStreamUpdate(
|
||||
[optimistic],
|
||||
[submitted],
|
||||
{
|
||||
type: "timeline",
|
||||
provider,
|
||||
@@ -1155,6 +1258,7 @@ describe("turn lifecycle events", () => {
|
||||
type: "user_message",
|
||||
text: "server-owned rendered text",
|
||||
messageId: "provider-owned-id",
|
||||
clientMessageId: "msg_submitted",
|
||||
},
|
||||
},
|
||||
serverTimestamp,
|
||||
@@ -1165,28 +1269,28 @@ describe("turn lifecycle events", () => {
|
||||
assert.strictEqual(userMessages.length, 1);
|
||||
const userMessage = userMessages[0];
|
||||
invariant(userMessage?.kind === "user_message");
|
||||
assert.strictEqual(userMessage.id, "provider-owned-id");
|
||||
assert.strictEqual(userMessage.text, optimistic.text);
|
||||
assert.strictEqual(userMessage.timestamp.getTime(), optimistic.timestamp.getTime());
|
||||
assert.strictEqual(userMessage.optimistic, undefined);
|
||||
assert.deepStrictEqual(userMessage.images, optimistic.images);
|
||||
assert.deepStrictEqual(userMessage.attachments, optimistic.attachments);
|
||||
assert.strictEqual(userMessage.id, "msg_submitted");
|
||||
assert.strictEqual(userMessage.messageId, "provider-owned-id");
|
||||
assert.strictEqual(userMessage.text, submitted.text);
|
||||
assert.strictEqual(userMessage.timestamp.getTime(), submitted.timestamp.getTime());
|
||||
assert.deepStrictEqual(userMessage.images, submitted.images);
|
||||
assert.deepStrictEqual(userMessage.attachments, submitted.attachments);
|
||||
},
|
||||
);
|
||||
|
||||
it("replaces one optimistic plain-text user message with the next live server user message", () => {
|
||||
const optimisticTimestamp = new Date("2025-01-01T15:03:00Z");
|
||||
it("replaces one submitted plain-text user message with the next live server user message", () => {
|
||||
const submittedTimestamp = new Date("2025-01-01T15:03:00Z");
|
||||
const serverTimestamp = new Date("2025-01-01T15:03:01Z");
|
||||
const optimistic: StreamItem = {
|
||||
const submitted: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "msg_optimistic",
|
||||
id: "msg_submitted",
|
||||
clientMessageId: "msg_submitted",
|
||||
text: "typed plain text",
|
||||
timestamp: optimisticTimestamp,
|
||||
optimistic: true,
|
||||
timestamp: submittedTimestamp,
|
||||
};
|
||||
|
||||
const state = reduceStreamUpdate(
|
||||
[optimistic],
|
||||
[submitted],
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
@@ -1204,20 +1308,20 @@ describe("turn lifecycle events", () => {
|
||||
assert.strictEqual(userMessages.length, 1);
|
||||
const userMessage = userMessages[0];
|
||||
invariant(userMessage?.kind === "user_message");
|
||||
assert.strictEqual(userMessage.id, "msg_opencode_provider_owned");
|
||||
assert.strictEqual(userMessage.id, "msg_submitted");
|
||||
assert.strictEqual(userMessage.messageId, "msg_opencode_provider_owned");
|
||||
assert.strictEqual(userMessage.text, "typed plain text");
|
||||
assert.strictEqual(userMessage.timestamp.getTime(), optimisticTimestamp.getTime());
|
||||
assert.strictEqual(userMessage.optimistic, undefined);
|
||||
assert.strictEqual(userMessage.timestamp.getTime(), submittedTimestamp.getTime());
|
||||
});
|
||||
|
||||
it("replaces an optimistic image user message with the next canonical server user message", () => {
|
||||
const optimisticTimestamp = new Date("2025-01-01T15:03:10Z");
|
||||
it("replaces a submitted image user message with the next canonical server user message", () => {
|
||||
const submittedTimestamp = new Date("2025-01-01T15:03:10Z");
|
||||
const image = {
|
||||
id: "image-canonical",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb" as const,
|
||||
storageKey: "image-canonical",
|
||||
createdAt: optimisticTimestamp.getTime(),
|
||||
createdAt: submittedTimestamp.getTime(),
|
||||
};
|
||||
const attachment = {
|
||||
type: "text" as const,
|
||||
@@ -1225,16 +1329,16 @@ describe("turn lifecycle events", () => {
|
||||
text: "context",
|
||||
title: "context.txt",
|
||||
};
|
||||
const optimistic = buildOptimisticUserMessage({
|
||||
id: "msg_optimistic_canonical",
|
||||
const submitted = createUserMessage({
|
||||
clientMessageId: "msg_submitted_canonical",
|
||||
text: "Analyze this",
|
||||
timestamp: optimisticTimestamp,
|
||||
timestamp: submittedTimestamp,
|
||||
images: [image],
|
||||
attachments: [attachment],
|
||||
});
|
||||
|
||||
const state = reduceStreamUpdate(
|
||||
[optimistic],
|
||||
[submitted],
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
@@ -1242,28 +1346,32 @@ describe("turn lifecycle events", () => {
|
||||
type: "user_message",
|
||||
text: "server-rendered attachment text",
|
||||
messageId: "provider-owned-canonical",
|
||||
clientMessageId: optimistic.id,
|
||||
clientMessageId: submitted.id,
|
||||
},
|
||||
},
|
||||
new Date("2025-01-01T15:03:11Z"),
|
||||
{ source: "canonical" },
|
||||
{
|
||||
source: "canonical",
|
||||
timelineCursor: { epoch: "epoch-1", seq: 42 },
|
||||
},
|
||||
);
|
||||
|
||||
const userMessages = state.filter((item) => item.kind === "user_message");
|
||||
assert.strictEqual(userMessages.length, 1);
|
||||
const userMessage = userMessages[0];
|
||||
invariant(userMessage?.kind === "user_message");
|
||||
assert.strictEqual(userMessage.id, "provider-owned-canonical");
|
||||
assert.strictEqual(userMessage.id, "msg_submitted_canonical");
|
||||
assert.strictEqual(userMessage.messageId, "provider-owned-canonical");
|
||||
assert.strictEqual(userMessage.text, "Analyze this");
|
||||
assert.strictEqual(userMessage.timestamp.getTime(), optimisticTimestamp.getTime());
|
||||
assert.strictEqual(userMessage.optimistic, undefined);
|
||||
assert.strictEqual(userMessage.timestamp.getTime(), submittedTimestamp.getTime());
|
||||
assert.deepStrictEqual(userMessage.timelineCursor, { epoch: "epoch-1", seq: 42 });
|
||||
assert.deepStrictEqual(userMessage.images, [image]);
|
||||
assert.deepStrictEqual(userMessage.attachments, [attachment]);
|
||||
});
|
||||
|
||||
it("places optimistic user messages through one append helper", () => {
|
||||
const optimistic = buildOptimisticUserMessage({
|
||||
id: "msg_append_once",
|
||||
it("places submitted user messages through the identity producer", () => {
|
||||
const submitted = createUserMessage({
|
||||
clientMessageId: "msg_append_once",
|
||||
text: "append once",
|
||||
timestamp: new Date("2025-01-01T15:03:20Z"),
|
||||
});
|
||||
@@ -1274,28 +1382,30 @@ describe("turn lifecycle events", () => {
|
||||
timestamp: new Date("2025-01-01T15:03:19Z"),
|
||||
};
|
||||
|
||||
const first = appendOptimisticUserMessageToStream({
|
||||
const first = upsertUserMessageAcrossStream({
|
||||
tail: [],
|
||||
head: [headItem],
|
||||
message: optimistic,
|
||||
placement: "active-head",
|
||||
message: submitted,
|
||||
insert: "head",
|
||||
presentation: "existing",
|
||||
});
|
||||
const second = appendOptimisticUserMessageToStream({
|
||||
const second = upsertUserMessageAcrossStream({
|
||||
tail: first.tail,
|
||||
head: first.head,
|
||||
message: optimistic,
|
||||
placement: "active-head",
|
||||
message: submitted,
|
||||
insert: "head",
|
||||
presentation: "existing",
|
||||
});
|
||||
assert.deepStrictEqual(first.tail, []);
|
||||
assert.deepStrictEqual(first.head, [headItem, optimistic]);
|
||||
assert.deepStrictEqual(first.head, [headItem, submitted]);
|
||||
assert.strictEqual(second.changedHead, false);
|
||||
assert.strictEqual(second.head, first.head);
|
||||
});
|
||||
|
||||
it("hands rich optimistic content to an authoritative create message without duplicating it", () => {
|
||||
it("hands rich submitted content to its create message without overwriting an earlier user row", () => {
|
||||
const timestamp = new Date("2025-01-01T15:03:20Z");
|
||||
const optimistic = buildOptimisticUserMessage({
|
||||
id: "client-user",
|
||||
const submitted = createUserMessage({
|
||||
clientMessageId: "client-user",
|
||||
text: "",
|
||||
timestamp,
|
||||
images: [
|
||||
@@ -1317,32 +1427,44 @@ describe("turn lifecycle events", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
const precedingProviderRow: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "provider-system-user",
|
||||
messageId: "provider-system-user",
|
||||
text: "provider setup prompt",
|
||||
timestamp: new Date("2025-01-01T15:03:20.500Z"),
|
||||
};
|
||||
const canonical: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "provider-user",
|
||||
messageId: "provider-user",
|
||||
clientMessageId: "client-user",
|
||||
text: "server-rendered attachment text",
|
||||
timestamp: new Date("2025-01-01T15:03:21Z"),
|
||||
};
|
||||
|
||||
const handedOff = handoffCreatedAgentUserMessageToStream({
|
||||
tail: [canonical],
|
||||
tail: [precedingProviderRow, canonical],
|
||||
head: [],
|
||||
message: optimistic,
|
||||
message: submitted,
|
||||
});
|
||||
const repeated = handoffCreatedAgentUserMessageToStream({
|
||||
tail: handedOff.tail,
|
||||
head: handedOff.head,
|
||||
message: optimistic,
|
||||
message: submitted,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(handedOff.tail, [
|
||||
precedingProviderRow,
|
||||
{
|
||||
kind: "user_message",
|
||||
id: "provider-user",
|
||||
text: optimistic.text,
|
||||
timestamp: optimistic.timestamp,
|
||||
images: optimistic.images,
|
||||
attachments: optimistic.attachments,
|
||||
id: "client-user",
|
||||
clientMessageId: "client-user",
|
||||
messageId: "provider-user",
|
||||
text: submitted.text,
|
||||
timestamp: submitted.timestamp,
|
||||
images: submitted.images,
|
||||
attachments: submitted.attachments,
|
||||
},
|
||||
]);
|
||||
assert.deepStrictEqual(handedOff.head, []);
|
||||
@@ -1364,22 +1486,22 @@ describe("turn lifecycle events", () => {
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
afterNextUser.filter((item) => item.kind === "user_message").map((item) => item.id),
|
||||
["provider-user", "provider-next-user"],
|
||||
["provider-system-user", "client-user", "provider-next-user"],
|
||||
);
|
||||
});
|
||||
|
||||
it("reconciles an optimistic user message that was pending in the streaming head", () => {
|
||||
const optimistic: StreamItem = {
|
||||
it("flushes an interrupted head when its submitted prompt becomes canonical", () => {
|
||||
const submitted: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "msg_head_optimistic",
|
||||
id: "msg_head_submitted",
|
||||
clientMessageId: "msg_head_submitted",
|
||||
text: "plain text in head",
|
||||
timestamp: new Date("2025-01-01T15:03:02Z"),
|
||||
optimistic: true,
|
||||
};
|
||||
|
||||
const result = applyStreamEvent({
|
||||
tail: [],
|
||||
head: [optimistic],
|
||||
head: [submitted],
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
@@ -1393,33 +1515,80 @@ describe("turn lifecycle events", () => {
|
||||
source: "live",
|
||||
});
|
||||
|
||||
assert.strictEqual(result.head.length, 0);
|
||||
assert.deepStrictEqual(result.head, []);
|
||||
const userMessages = result.tail.filter((item) => item.kind === "user_message");
|
||||
assert.strictEqual(userMessages.length, 1);
|
||||
assert.strictEqual(userMessages[0]?.id, "provider-owned-head");
|
||||
assert.strictEqual(userMessages[0]?.optimistic, undefined);
|
||||
assert.strictEqual(userMessages[0]?.id, "msg_head_submitted");
|
||||
assert.strictEqual(userMessages[0]?.messageId, "provider-owned-head");
|
||||
});
|
||||
|
||||
it("replaces multiple optimistic user messages in FIFO order", () => {
|
||||
const optimisticTimestamp = new Date("2025-01-01T15:04:00Z");
|
||||
const serverTimestamp = new Date("2025-01-01T15:04:01Z");
|
||||
const firstOptimistic: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "msg_optimistic_1",
|
||||
text: "first typed text",
|
||||
timestamp: optimisticTimestamp,
|
||||
optimistic: true,
|
||||
it("keeps a replacement assistant separate after an interrupted prompt is reconciled", () => {
|
||||
const interruptedAssistant: StreamItem = {
|
||||
kind: "assistant_message",
|
||||
id: "interrupted",
|
||||
text: "old answer",
|
||||
timestamp: new Date("2025-01-01T15:03:01Z"),
|
||||
};
|
||||
const secondOptimistic: StreamItem = {
|
||||
const submitted = createUserMessage({
|
||||
clientMessageId: "msg_interrupt",
|
||||
text: "replacement prompt",
|
||||
timestamp: new Date("2025-01-01T15:03:02Z"),
|
||||
});
|
||||
const reconciled = applyStreamEvent({
|
||||
tail: [],
|
||||
head: [interruptedAssistant, submitted],
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: submitted.text,
|
||||
messageId: "provider-prompt",
|
||||
clientMessageId: submitted.clientMessageId,
|
||||
},
|
||||
},
|
||||
timestamp: new Date("2025-01-01T15:03:03Z"),
|
||||
});
|
||||
const replacement = applyStreamEvent({
|
||||
tail: reconciled.tail,
|
||||
head: reconciled.head,
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "assistant_message", text: "new answer" },
|
||||
},
|
||||
timestamp: new Date("2025-01-01T15:03:04Z"),
|
||||
});
|
||||
|
||||
expect(replacement.tail.map((item) => item.kind)).toEqual([
|
||||
"assistant_message",
|
||||
"user_message",
|
||||
]);
|
||||
expect(replacement.head).toEqual([
|
||||
expect.objectContaining({ kind: "assistant_message", text: "new answer" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces multiple submitted user messages in FIFO order", () => {
|
||||
const submittedTimestamp = new Date("2025-01-01T15:04:00Z");
|
||||
const serverTimestamp = new Date("2025-01-01T15:04:01Z");
|
||||
const firstSubmitted: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "msg_optimistic_2",
|
||||
id: "msg_submitted_1",
|
||||
clientMessageId: "msg_submitted_1",
|
||||
text: "first typed text",
|
||||
timestamp: submittedTimestamp,
|
||||
};
|
||||
const secondSubmitted: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "msg_submitted_2",
|
||||
clientMessageId: "msg_submitted_2",
|
||||
text: "second typed text",
|
||||
timestamp: new Date("2025-01-01T15:04:00.500Z"),
|
||||
optimistic: true,
|
||||
};
|
||||
|
||||
const afterFirstEcho = reduceStreamUpdate(
|
||||
[firstOptimistic, secondOptimistic],
|
||||
[firstSubmitted, secondSubmitted],
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
@@ -1427,6 +1596,7 @@ describe("turn lifecycle events", () => {
|
||||
type: "user_message",
|
||||
text: "first server text",
|
||||
messageId: "provider-owned-first",
|
||||
clientMessageId: "msg_submitted_1",
|
||||
},
|
||||
},
|
||||
serverTimestamp,
|
||||
@@ -1441,6 +1611,7 @@ describe("turn lifecycle events", () => {
|
||||
type: "user_message",
|
||||
text: "second server text",
|
||||
messageId: "provider-owned-second",
|
||||
clientMessageId: "msg_submitted_2",
|
||||
},
|
||||
},
|
||||
new Date("2025-01-01T15:04:02Z"),
|
||||
@@ -1450,30 +1621,30 @@ describe("turn lifecycle events", () => {
|
||||
const userMessages = state.filter((item) => item.kind === "user_message");
|
||||
assert.strictEqual(userMessages.length, 2);
|
||||
assert.deepStrictEqual(
|
||||
userMessages.map((item) => [item.id, item.text, item.optimistic]),
|
||||
userMessages.map((item) => [item.id, item.text, item.messageId]),
|
||||
[
|
||||
["provider-owned-first", "first typed text", undefined],
|
||||
["provider-owned-second", "second typed text", undefined],
|
||||
["msg_submitted_1", "first typed text", "provider-owned-first"],
|
||||
["msg_submitted_2", "second typed text", "provider-owned-second"],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("does not shift later prompts when an earlier optimistic prompt has no canonical echo", () => {
|
||||
it("does not shift later prompts when an earlier submitted prompt has no canonical echo", () => {
|
||||
const staleTimestamp = new Date("2025-01-01T15:04:00Z");
|
||||
const submittedTimestamp = new Date("2025-01-01T15:04:01Z");
|
||||
const stalePrompt: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "msg_stale",
|
||||
clientMessageId: "msg_stale",
|
||||
text: "first prompt without an echo",
|
||||
timestamp: staleTimestamp,
|
||||
optimistic: true,
|
||||
};
|
||||
const submittedPrompt: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "msg_submitted",
|
||||
clientMessageId: "msg_submitted",
|
||||
text: "later submitted prompt",
|
||||
timestamp: submittedTimestamp,
|
||||
optimistic: true,
|
||||
};
|
||||
|
||||
const state = reduceStreamUpdate(
|
||||
@@ -1496,15 +1667,16 @@ describe("turn lifecycle events", () => {
|
||||
stalePrompt,
|
||||
{
|
||||
kind: "user_message",
|
||||
id: "provider-owned-submitted",
|
||||
id: "msg_submitted",
|
||||
clientMessageId: submittedPrompt.id,
|
||||
messageId: "provider-owned-submitted",
|
||||
text: submittedPrompt.text,
|
||||
timestamp: submittedPrompt.timestamp,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("appends a live server user message when no optimistic user message is pending", () => {
|
||||
it("appends a live server user message when no submitted user message is pending", () => {
|
||||
const state = reduceStreamUpdate(
|
||||
[],
|
||||
{
|
||||
@@ -1523,21 +1695,11 @@ describe("turn lifecycle events", () => {
|
||||
const userMessages = state.filter((item) => item.kind === "user_message");
|
||||
assert.strictEqual(userMessages.length, 1);
|
||||
assert.strictEqual(userMessages[0]?.id, "provider-owned-resume");
|
||||
assert.strictEqual(userMessages[0]?.optimistic, undefined);
|
||||
});
|
||||
|
||||
it("does not match a server user message to an optimistic from a rewound turn after pending optimistics are cleared", () => {
|
||||
const optimistic: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "msg_rewound_optimistic",
|
||||
text: "rewound text",
|
||||
timestamp: new Date("2025-01-01T15:04:04Z"),
|
||||
optimistic: true,
|
||||
};
|
||||
const cleared = clearOptimisticUserMessages([optimistic]);
|
||||
|
||||
it("appends a server user message after a rewound local row was removed", () => {
|
||||
const state = reduceStreamUpdate(
|
||||
cleared,
|
||||
[],
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
@@ -1555,7 +1717,6 @@ describe("turn lifecycle events", () => {
|
||||
assert.strictEqual(userMessages.length, 1);
|
||||
assert.strictEqual(userMessages[0]?.id, "provider-owned-after-rewind");
|
||||
assert.strictEqual(userMessages[0]?.text, "future server echo");
|
||||
assert.strictEqual(userMessages[0]?.optimistic, undefined);
|
||||
});
|
||||
|
||||
it("keeps canonical repeated user messages distinct during hydration", () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import { useSessionStore } from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
|
||||
import { queryClient } from "@/data/query-client";
|
||||
import { createUserMessage } from "@/types/stream";
|
||||
import { applyAgentDirectoryDelta, replaceFetchedAgentDirectory } from "./agent-directory-sync";
|
||||
|
||||
function createAgentPayload(
|
||||
@@ -64,6 +65,111 @@ function permission(id: string): AgentPermissionRequest {
|
||||
return { id, provider: "codex", name: id, kind: "tool", title: id };
|
||||
}
|
||||
|
||||
function beginPendingSubmission(serverId: string, agentId: string): string {
|
||||
const clientMessageId = `client-${agentId}`;
|
||||
useSessionStore.getState().beginAgentMessageSubmission(
|
||||
serverId,
|
||||
agentId,
|
||||
createUserMessage({
|
||||
clientMessageId,
|
||||
text: "Run this",
|
||||
timestamp: new Date("2026-07-27T10:00:00.000Z"),
|
||||
}),
|
||||
);
|
||||
return clientMessageId;
|
||||
}
|
||||
|
||||
function applyAgentStatus(input: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
status: AgentSnapshotPayload["status"];
|
||||
updatedAt: string;
|
||||
}): void {
|
||||
const agent = createAgentPayload({
|
||||
id: input.agentId,
|
||||
status: input.status,
|
||||
updatedAt: input.updatedAt,
|
||||
});
|
||||
applyAgentDirectoryDelta({
|
||||
serverId: input.serverId,
|
||||
delta: { kind: "upsert", agent, project: createEntry(agent).project },
|
||||
});
|
||||
}
|
||||
|
||||
describe("message submission authority", () => {
|
||||
it("does not settle a submission from an unrelated running transition", () => {
|
||||
const serverId = "server-running-is-not-submission-ack";
|
||||
const agentId = "agent-1";
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
applyAgentStatus({
|
||||
serverId,
|
||||
agentId,
|
||||
status: "idle",
|
||||
updatedAt: "2026-07-27T10:00:00.000Z",
|
||||
});
|
||||
const clientMessageId = beginPendingSubmission(serverId, agentId);
|
||||
|
||||
applyAgentStatus({
|
||||
serverId,
|
||||
agentId,
|
||||
status: "running",
|
||||
updatedAt: "2026-07-27T10:00:01.000Z",
|
||||
});
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)).toEqual([
|
||||
{
|
||||
clientMessageId,
|
||||
submittedAt: new Date("2026-07-27T10:00:00.000Z"),
|
||||
rpcAccepted: false,
|
||||
providerAcknowledged: false,
|
||||
},
|
||||
]);
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
|
||||
it("settles provider acknowledgement only when timeline ingestion reports it", () => {
|
||||
const serverId = "server-explicit-provider-ack";
|
||||
const agentId = "agent-1";
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
applyAgentStatus({
|
||||
serverId,
|
||||
agentId,
|
||||
status: "idle",
|
||||
updatedAt: "2026-07-27T10:00:00.000Z",
|
||||
});
|
||||
const clientMessageId = beginPendingSubmission(serverId, agentId);
|
||||
store.setAgentStreamState(serverId, agentId, {
|
||||
tail: [
|
||||
createUserMessage({
|
||||
id: "provider-message",
|
||||
messageId: "provider-message",
|
||||
clientMessageId,
|
||||
text: "Run this",
|
||||
timestamp: new Date("2026-07-27T10:00:01.000Z"),
|
||||
}),
|
||||
],
|
||||
head: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)?.[0]
|
||||
?.providerAcknowledged,
|
||||
).toBe(false);
|
||||
|
||||
store.setAgentStreamState(serverId, agentId, {
|
||||
acknowledgedClientMessageIds: [clientMessageId],
|
||||
});
|
||||
|
||||
expect(
|
||||
useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)?.[0]
|
||||
?.providerAcknowledged,
|
||||
).toBe(true);
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaceFetchedAgentDirectory", () => {
|
||||
it("preserves timeline initialization while replacing directory state", () => {
|
||||
const serverId = "server-initializing";
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
clearAssistantImageMetadataCache,
|
||||
estimateAssistantMessageHeightFromCache,
|
||||
extractAssistantImageSources,
|
||||
getAssistantImageLoadStateFromMetadata,
|
||||
getAssistantImageMetadata,
|
||||
setAssistantImageMetadata,
|
||||
} from "./assistant-image-metadata";
|
||||
@@ -42,23 +41,6 @@ describe("assistant image metadata", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("maps missing metadata to the image loading state", () => {
|
||||
expect(getAssistantImageLoadStateFromMetadata(null)).toEqual({ status: "loading" });
|
||||
});
|
||||
|
||||
it("maps cached metadata to the image ready state", () => {
|
||||
expect(
|
||||
getAssistantImageLoadStateFromMetadata({
|
||||
width: 900,
|
||||
height: 1600,
|
||||
aspectRatio: 9 / 16,
|
||||
}),
|
||||
).toEqual({
|
||||
status: "ready",
|
||||
aspectRatio: 9 / 16,
|
||||
});
|
||||
});
|
||||
|
||||
it("estimates assistant message height from cached image metadata", () => {
|
||||
setAssistantImageMetadata(
|
||||
{
|
||||
|
||||
@@ -8,11 +8,6 @@ export interface AssistantImageMetadata {
|
||||
aspectRatio: number;
|
||||
}
|
||||
|
||||
export type AssistantImageLoadState =
|
||||
| { status: "loading" }
|
||||
| { status: "ready"; aspectRatio: number }
|
||||
| { status: "error" };
|
||||
|
||||
const assistantImageMetadataCache = new Map<string, AssistantImageMetadata>();
|
||||
const assistantImageParseCache = new Map<string, { sources: string[]; hasNonImageText: boolean }>();
|
||||
const ASSISTANT_IMAGE_METADATA_CACHE_LIMIT = 500;
|
||||
@@ -131,15 +126,6 @@ export function getAssistantImageMetadata(input: {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getAssistantImageLoadStateFromMetadata(
|
||||
metadata: AssistantImageMetadata | null,
|
||||
): AssistantImageLoadState {
|
||||
if (!metadata) {
|
||||
return { status: "loading" };
|
||||
}
|
||||
return { status: "ready", aspectRatio: metadata.aspectRatio };
|
||||
}
|
||||
|
||||
export function setAssistantImageMetadata(
|
||||
input: {
|
||||
source: string;
|
||||
|
||||
@@ -324,6 +324,11 @@ export interface SendMessageOptions {
|
||||
attachments?: SendAgentMessageRequest["attachments"];
|
||||
}
|
||||
|
||||
export interface SendMessageResult {
|
||||
/** Undefined when connected to a daemon predating message submission disposition. */
|
||||
outOfBand?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentAttentionRequiredNotification {
|
||||
agentId: string;
|
||||
reason: "finished" | "error" | "permission";
|
||||
@@ -2859,7 +2864,7 @@ export class DaemonClient {
|
||||
agentId: string,
|
||||
text: string,
|
||||
options?: SendMessageOptions,
|
||||
): Promise<void> {
|
||||
): Promise<SendMessageResult> {
|
||||
const requestId = this.createRequestId();
|
||||
const messageId = options?.messageId ?? crypto.randomUUID();
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
@@ -2888,6 +2893,7 @@ export class DaemonClient {
|
||||
if (!payload.accepted) {
|
||||
throw new Error(payload.error ?? "sendAgentMessage rejected");
|
||||
}
|
||||
return payload.outOfBand === undefined ? {} : { outOfBand: payload.outOfBand };
|
||||
}
|
||||
|
||||
async sendMessage(agentId: string, text: string, options?: SendMessageOptions): Promise<void> {
|
||||
|
||||
@@ -478,7 +478,9 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor
|
||||
latest = result?.agent ?? null;
|
||||
return result;
|
||||
},
|
||||
send: (text, options) => daemonClient.sendAgentMessage(id, text, options),
|
||||
send: async (text, options) => {
|
||||
await daemonClient.sendAgentMessage(id, text, options);
|
||||
},
|
||||
archive: async () => {
|
||||
const result = await daemonClient.archiveAgent(id);
|
||||
if (latest) {
|
||||
|
||||
@@ -3710,6 +3710,8 @@ export const SendAgentMessageResponseMessageSchema = z.object({
|
||||
agentId: z.string(),
|
||||
accepted: z.boolean(),
|
||||
error: z.string().nullable(),
|
||||
// COMPAT(messageSubmissionDisposition): added in v0.2.3, remove optional parsing after 2027-01-27.
|
||||
outOfBand: z.boolean().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -49,6 +49,25 @@ describe("MockLoadTestAgentClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects the configured number of prompts before starting a retry", async () => {
|
||||
const client = new MockLoadTestAgentClient();
|
||||
const session = await client.createSession({
|
||||
provider: "mock",
|
||||
cwd: process.cwd(),
|
||||
model: "ten-second-stream",
|
||||
featureValues: { mockPromptRejections: 1 },
|
||||
});
|
||||
|
||||
await expect(session.startTurn("Reject this prompt.")).rejects.toThrow(
|
||||
"Requested mock prompt rejection",
|
||||
);
|
||||
|
||||
await expect(session.startTurn("Accept this retry.")).resolves.toEqual({
|
||||
turnId: expect.any(String),
|
||||
});
|
||||
await session.interrupt();
|
||||
});
|
||||
|
||||
test("returns schema-shaped JSON for structured branch-name generation", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new MockLoadTestAgentClient();
|
||||
@@ -265,6 +284,39 @@ describe("MockLoadTestAgentClient", () => {
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("emits a settled assistant Markdown image path selected by prompt", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new MockLoadTestAgentClient();
|
||||
const session = await client.createSession({
|
||||
provider: "mock",
|
||||
cwd: process.cwd(),
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
const unsubscribe = session.subscribe((event) => events.push(event));
|
||||
const markdown = "";
|
||||
|
||||
const resultPromise = session.run(`Emit settled assistant image Markdown: ${markdown}`);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(
|
||||
events.flatMap((event): AgentTimelineItem[] =>
|
||||
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item] : [],
|
||||
),
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "assistant_message",
|
||||
text: markdown,
|
||||
}),
|
||||
]);
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
sessionId: session.id,
|
||||
finalText: markdown,
|
||||
canceled: false,
|
||||
});
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("agent manager coalesces adjacent assistant tokens into fewer messages", async () => {
|
||||
vi.useFakeTimers();
|
||||
const workdir = mkdtempSync(join(tmpdir(), "paseo-mock-load-test-"));
|
||||
|
||||
@@ -36,6 +36,8 @@ export const MOCK_LOAD_TEST_DEFAULT_MODEL_ID = "five-minute-stream";
|
||||
const MOCK_LOAD_TEST_MODE_ID = "load-test";
|
||||
const MOCK_LOAD_TEST_DURATION_MS = 5 * 60 * 1000;
|
||||
const MOCK_LOAD_TEST_INTERVAL_MS = 40;
|
||||
const ONE_PIXEL_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl4Kj8AAAAASUVORK5CYII=";
|
||||
|
||||
const CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
@@ -156,6 +158,13 @@ function shouldEmitTurnFailure(prompt: AgentPromptInput): boolean {
|
||||
return /emit\s+(?:a\s+)?synthetic\s+turn\s+failure/i.test(promptToText(prompt));
|
||||
}
|
||||
|
||||
function parseSettledAssistantImageMarkdown(prompt: AgentPromptInput): string | null {
|
||||
const match = /^emit settled assistant image markdown:\s*(!\[[^\]\r\n]*\]\(.+\))\s*$/i.exec(
|
||||
promptToText(prompt),
|
||||
);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRequest | null {
|
||||
const text = promptToText(prompt);
|
||||
if (!/emit\s+(?:a\s+)?synthetic\s+questions?/i.test(text)) {
|
||||
@@ -583,6 +592,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
private modeId: string | null;
|
||||
private modelId: string | null;
|
||||
private readonly rewindError: string | null;
|
||||
private remainingPromptRejections: number;
|
||||
|
||||
constructor(options: { config: AgentSessionConfig; sessionId: string; logger?: Logger }) {
|
||||
this.id = options.sessionId;
|
||||
@@ -593,6 +603,13 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
typeof options.config.featureValues?.mockRewindError === "string"
|
||||
? options.config.featureValues.mockRewindError
|
||||
: null;
|
||||
const requestedPromptRejections = options.config.featureValues?.mockPromptRejections;
|
||||
this.remainingPromptRejections =
|
||||
typeof requestedPromptRejections === "number" &&
|
||||
Number.isSafeInteger(requestedPromptRejections) &&
|
||||
requestedPromptRejections > 0
|
||||
? requestedPromptRejections
|
||||
: 0;
|
||||
}
|
||||
|
||||
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
||||
@@ -611,6 +628,10 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
if (this.activeTurn) {
|
||||
throw new Error("Mock load-test provider already has an active turn");
|
||||
}
|
||||
if (this.remainingPromptRejections > 0) {
|
||||
this.remainingPromptRejections -= 1;
|
||||
throw new Error("Requested mock prompt rejection");
|
||||
}
|
||||
|
||||
const profile = resolveModelProfile(this.modelId);
|
||||
const turnId = randomUUID();
|
||||
@@ -657,10 +678,13 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
const stress = parseAgentStreamStressPrompt(prompt);
|
||||
const questionPrompt = parseMockQuestionPrompt(prompt);
|
||||
const structuredBranchName = parseStructuredBranchNamePrompt(prompt);
|
||||
const settledAssistantImageMarkdown = parseSettledAssistantImageMarkdown(prompt);
|
||||
if (shouldEmitTurnFailure(prompt)) {
|
||||
this.scheduleFailedTurn(turn);
|
||||
} else if (structuredBranchName) {
|
||||
this.scheduleStructuredJsonTurn(turn, structuredBranchName);
|
||||
this.scheduleSettledAssistantTurn(turn, JSON.stringify(structuredBranchName));
|
||||
} else if (settledAssistantImageMarkdown) {
|
||||
this.scheduleSettledAssistantTurn(turn, settledAssistantImageMarkdown);
|
||||
} else if (shouldEmitPlanApprovalPrompt(prompt)) {
|
||||
this.schedulePlanApprovalTurn(turn);
|
||||
} else if (questionPrompt) {
|
||||
@@ -875,14 +899,14 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
turn.timer.unref?.();
|
||||
}
|
||||
|
||||
private scheduleStructuredJsonTurn(turn: ActiveTurn, result: Record<string, string>): void {
|
||||
private scheduleSettledAssistantTurn(turn: ActiveTurn, finalText: string): void {
|
||||
turn.timer = setTimeout(() => {
|
||||
this.emitStructuredJsonTurn(turn, result);
|
||||
this.emitSettledAssistantTurn(turn, finalText);
|
||||
}, 0);
|
||||
turn.timer.unref?.();
|
||||
}
|
||||
|
||||
private emitStructuredJsonTurn(turn: ActiveTurn, result: Record<string, string>): void {
|
||||
private emitSettledAssistantTurn(turn: ActiveTurn, finalText: string): void {
|
||||
if (this.activeTurn !== turn) {
|
||||
return;
|
||||
}
|
||||
@@ -894,7 +918,6 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
turnId: turn.turnId,
|
||||
});
|
||||
|
||||
const finalText = JSON.stringify(result);
|
||||
this.emitTimeline(turn.turnId, {
|
||||
type: "assistant_message",
|
||||
text: finalText,
|
||||
@@ -1105,9 +1128,14 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const imageBytes = Buffer.from(ONE_PIXEL_PNG_BASE64, "base64");
|
||||
const imagePayload = Buffer.concat([
|
||||
imageBytes,
|
||||
Buffer.alloc(Math.max(0, largePayload.bytes - imageBytes.length)),
|
||||
]).toString("base64");
|
||||
this.emitTimeline(turn.turnId, {
|
||||
type: "assistant_message",
|
||||
text: `data:image/png;base64,${payload}`,
|
||||
text: ``,
|
||||
messageId: turn.assistantMessageId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6345,6 +6345,7 @@ export class Session {
|
||||
agentId,
|
||||
accepted: true,
|
||||
error: null,
|
||||
outOfBand: true,
|
||||
},
|
||||
});
|
||||
return;
|
||||
@@ -6372,6 +6373,7 @@ export class Session {
|
||||
agentId,
|
||||
accepted: true,
|
||||
error: null,
|
||||
outOfBand: false,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user