diff --git a/docs/release.md b/docs/release.md index 8b53f9547..b5ffa6296 100644 --- a/docs/release.md +++ b/docs/release.md @@ -38,6 +38,14 @@ There are two supported ways to ship from `main`: 1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately. 2. **Beta flow**: release candidates on the `beta` channel. Betas carry an in-place changelog entry (beta users check it), publish npm only on the explicit `beta` dist-tag, and never move the website download target off the latest stable. +Paseo has one linear release track even though npm dist-tags are independent +pointers. The npm invariant is: + +- A beta release moves only `beta`; `latest` remains on the newest stable. +- A stable release moves both `latest` and `beta` to that stable version. This + keeps users who install `@getpaseo/cli@beta` on the newest Paseo release after + a beta is promoted or superseded by a direct stable release. + ## Release version decision Every fresh release starts by classifying the full previous-stable-to-`HEAD` @@ -76,6 +84,20 @@ npm run release:minor This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo. +After the stable release succeeds, move npm's `beta` pointer to the new stable +version for every published package. This changes dist-tags only; do not +republish the packages: + +```bash +PASEO_VERSION=$(node -p "require('./package.json').version") +for package in highlight relay protocol client server cli; do + npm dist-tag add "@getpaseo/$package@$PASEO_VERSION" beta +done +``` + +Verify both npm tags now resolve to `PASEO_VERSION` before considering the +stable release complete. + The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`. The production relay is the Elixir service in [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay), with its own deployment process. Paseo releases and pushes to this repository do not deploy it. The Cloudflare relay code and workflow in this repository are legacy and are not used in production. @@ -92,6 +114,7 @@ npm run version:all:patch npm run version:all:minor npm run release:publish # Publish to npm npm run release:push # Push HEAD + tag (triggers CI workflows) +# Then move npm's beta dist-tag to this stable version using the command above. ``` ## Beta flow @@ -506,6 +529,7 @@ Betas are checkpoints along the way; the entry is the single record for the jump - [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). When promoting from beta, overwrite the existing `## X.Y.Z-beta.N` heading in place (heading → `X.Y.Z`, date → promotion day) — do not add a new entry on top of the beta one - [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format - [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully +- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it - [ ] GitHub `Desktop Release` workflow for the `v*` tag is green - [ ] GitHub `Android APK Release` workflow for the same tag is green - [ ] EAS `Release Mobile` workflow for the same tag is green diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index ed22654c2..b881744f5 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -37,12 +37,6 @@ 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. @@ -67,22 +61,6 @@ 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`: @@ -109,42 +87,14 @@ 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. -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. +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. Canonical submitted user rows carry the provider's `messageId` and Paseo's optional -`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. +`clientMessageId`. Clients reconcile optimistic prompts by `clientMessageId`. Content matching is +limited to the dated compatibility path for daemon timelines created before that field existed. ## Relevant code diff --git a/packages/app/e2e/agent-message-submission.spec.ts b/packages/app/e2e/agent-message-submission.spec.ts deleted file mode 100644 index 092159b9b..000000000 --- a/packages/app/e2e/agent-message-submission.spec.ts +++ /dev/null @@ -1,685 +0,0 @@ -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 { readScrollMetrics } from "./helpers/agent-bottom-anchor"; -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"; - -const IMAGE = { - name: "message-submission.png", - mimeType: "image/png", - buffer: Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", - "base64", - ), -}; - -interface MessageGeometry { - x: number; - y: number; - width: number; - height: number; -} - -interface SubmissionScenario { - gate: Awaited>; -} - -interface DraftCreateScenario { - workspaceId: string; - agentCreatedDelay: Awaited>; -} - -interface RejectionScenario { - errorMessage: string; -} - -interface UnrelatedRunningScenario { - gate: Awaited>; - agent: Awaited>; -} - -const test = baseTest.extend<{ - submissionScenario: SubmissionScenario; - draftCreateScenario: DraftCreateScenario; - rejectionScenario: RejectionScenario; - unrelatedRunningScenario: UnrelatedRunningScenario; -}>({ - submissionScenario: async ({ page }, provide, testInfo) => { - const gate = await gateNextAgentMessage(page); - const agent = await seedMockAgentWorkspace({ - repoPrefix: `message-submission-${testInfo.workerIndex}-`, - title: "Message submission regression", - model: "ten-second-stream", - }); - await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); - await expectComposerVisible(page); - await expectAgentIdle(page); - await provide({ gate }); - await agent.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 { - 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 { - 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 { - 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 readMessageGeometry(page: Page, userMessage: Locator): Promise { - const box = await userMessage.boundingBox(); - if (!box) throw new Error("Submitted user message has no browser geometry"); - const { offsetY } = await readScrollMetrics(page); - return { x: box.x, y: box.y + offsetY, width: box.width, height: box.height }; -} - -async function beginWorkingFooterContinuityCheck(page: Page): Promise<() => Promise> { - await expect(page.getByTestId("turn-working-indicator")).toBeVisible(); - await page.evaluate(() => { - const state = { active: true, sawMissing: false }; - const windowState = window as unknown as Record; - windowState.__messageSubmissionFooterContinuity = state; - const checkFrame = () => { - if (!state.active) return; - if (!document.querySelector('[data-testid="turn-working-indicator"]')) { - state.sawMissing = true; - } - requestAnimationFrame(checkFrame); - }; - requestAnimationFrame(checkFrame); - }); - - return async () => { - const sawMissing = await page.evaluate(() => { - const windowState = window as unknown as Record; - const state = windowState.__messageSubmissionFooterContinuity as - | { active: boolean; sawMissing: boolean } - | undefined; - if (!state) throw new Error("Working-footer continuity check was not started"); - state.active = false; - delete windowState.__messageSubmissionFooterContinuity; - return state.sawMissing; - }); - expect(sawMissing).toBe(false); - }; -} - -async function expectAcceptedSubmission( - page: Page, - userMessage: Locator, - submittedGeometry: MessageGeometry, -): Promise { - await expect(page.getByTestId("turn-working-indicator")).toBeVisible(); - await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); - await expect(page.getByTestId("turn-working-indicator")).toBeVisible(); - expect(await readMessageGeometry(page, userMessage)).toEqual(submittedGeometry); -} - -async function submitMessageThatWillBeRejected(page: Page, prompt: string): Promise { - 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 { - 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 { - 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 { - await fillComposerDraft(page, prompt); - await sendDraftToQueue(page); -} - -async function expectQueuedSendFailuresRestored(page: Page, prompts: string[]): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - scenario.agentCreatedDelay.release(); - await expectCreatedAgentHandoff(page, pending.prompt, pending.userMessage); -} - -test.describe("Agent message submission", () => { - test("keeps the submitted row stable when the host accepts", async ({ - page, - submissionScenario, - }) => { - const userMessage = await submitMessageWithImage(page, "Hold this submission."); - await expectPendingSubmission(page, userMessage); - await submissionScenario.gate.waitForRequest(); - const submittedGeometry = await readMessageGeometry(page, userMessage); - const finishFooterContinuityCheck = await beginWorkingFooterContinuityCheck(page); - submissionScenario.gate.accept(); - await expectAcceptedSubmission(page, userMessage, submittedGeometry); - await finishFooterContinuityCheck(); - }); - - 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); - }); -}); diff --git a/packages/app/e2e/agent-tab-image-stability.spec.ts b/packages/app/e2e/agent-tab-image-stability.spec.ts deleted file mode 100644 index 6ad5d0550..000000000 --- a/packages/app/e2e/agent-tab-image-stability.spec.ts +++ /dev/null @@ -1,85 +0,0 @@ -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", - }); -}); diff --git a/packages/app/e2e/agent-timeline-pagination.spec.ts b/packages/app/e2e/agent-timeline-pagination.spec.ts index 0f562987f..30c0bc379 100644 --- a/packages/app/e2e/agent-timeline-pagination.spec.ts +++ b/packages/app/e2e/agent-timeline-pagination.spec.ts @@ -1,197 +1,46 @@ -import { expect, test } from "./fixtures"; +import { test } from "./fixtures"; import { - expectSameOlderHistoryLoadingOperation, - expectTimelineAtHistoryStart, + expectLoadedTimelineDoesNotScroll, expectTimelinePromptNotMounted, - expectTimelinePromptPositionPreserved, expectTimelinePromptVisible, - holdBootstrapTimelinePage, - holdDaemonHydration, - holdOlderHistoryPages, + holdNextOlderTimelinePage, makeLoadedTimelineFitViewport, openAgentTimeline, - rememberOlderHistoryLoadingOperation, - rememberTimelineViewport, - rememberTimelinePromptPosition, - reloadAgentTimelineFromPersistedReplica, scrollTimelineUntilOlderHistoryIsReachable, - scrollTimelineToNewestLoadedEdge, seedLongMockAgentTimeline, - sendLiveTurnBeforeHydration, - expectTimelineViewportAnchoredAfterPrepend, - userScrollsTimelineToHistoryStart, } from "./helpers/timeline-pagination"; test.describe("Agent timeline pagination", () => { - test("loads one page each time the user returns to history start", async ({ page }) => { + test("loads older history when the user scrolls to the top of a long agent timeline", 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); - history.releasePage(1); - await history.expectSettledWithRequestedPages(1); + await scrollTimelineUntilOlderHistoryIsReachable(page); - await userScrollsTimelineToHistoryStart(page); - await history.expectRequestedPages(2); + await expectTimelinePromptVisible(page, agent.oldestPrompt); } finally { await agent.cleanup(); } }); - test("keeps the visible timeline position anchored while prepending a page", async ({ page }) => { + test("loads older history when the initial page does not fill the viewport", 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 userScrollsTimelineToHistoryStart(page); - await history.expectRequestedPages(1); - const position = await rememberTimelinePromptPosition(page, agent.initialTailOldestPrompt); - - 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 }); + const agent = await seedLongMockAgentTimeline({ turns: 30 }); try { await makeLoadedTimelineFitViewport(page); - const history = await holdOlderHistoryPages(page, agent); + const olderPage = await holdNextOlderTimelinePage(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.newestPrompt); + await expectLoadedTimelineDoesNotScroll(page); + await olderPage.expectLoading(); + olderPage.release(); 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 { - await agent.cleanup(); - } - }); - - test("preserves a live row received before replica hydration", async ({ page }) => { - test.setTimeout(120_000); - const agent = await seedLongMockAgentTimeline({ turns: 80 }); - try { - await openAgentTimeline(page, agent); - 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(); } diff --git a/packages/app/e2e/helpers/agent-message-gate.ts b/packages/app/e2e/helpers/agent-message-gate.ts deleted file mode 100644 index 866d25770..000000000 --- a/packages/app/e2e/helpers/agent-message-gate.ts +++ /dev/null @@ -1,86 +0,0 @@ -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; - }; - 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 = []; - 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 => { - while (requests.length < count) { - await new Promise((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 { - if (!browserSocket) throw new Error("No browser daemon socket to disconnect"); - await browserSocket.close({ code: 1008, reason: "Dropped by submission test." }); - }, - }; -} diff --git a/packages/app/e2e/helpers/agent-timeline-gate.ts b/packages/app/e2e/helpers/agent-timeline-gate.ts index 104f0a159..511d303db 100644 --- a/packages/app/e2e/helpers/agent-timeline-gate.ts +++ b/packages/app/e2e/helpers/agent-timeline-gate.ts @@ -15,21 +15,6 @@ export interface AgentTimelineResponseGate { waitForDelayedResponse(): Promise; } -export interface OlderTimelinePagesGate { - getRequestCount(): number; - releasePage(pageNumber: number): void; - waitForRequestCount(count: number): Promise; -} - -export interface DaemonHydrationGate { - release(): void; -} - -export interface BootstrapTimelineGate extends AgentTimelineResponseGate { - releaseCatchUp(): void; - waitForDelayedCatchUp(): Promise; -} - function parseWebSocketJson(message: WebSocketMessage): unknown { const rawMessage = typeof message === "string" ? message : message.toString("utf8"); try { @@ -60,32 +45,6 @@ function getPayload(message: Record): Record | : null; } -export async function holdDaemonHydration(page: Page): Promise { - 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 { @@ -168,142 +127,6 @@ export async function delayCreatedAgentInitialTailResponse( export async function delayAgentOlderTimelineResponse( page: Page, agentId: string, -): Promise { - return delayAgentTimelineResponse(page, agentId, "before"); -} - -export async function holdAgentOlderTimelinePages( - page: Page, - agentId: string, -): Promise { - let requestCount = 0; - let responseCount = 0; - const releasedPages = new Set(); - const delayedForwards = new Map void>>(); - const requestWaiters = new Map 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((resolve) => { - const resolvers = requestWaiters.get(count) ?? []; - resolvers.push(resolve); - requestWaiters.set(count, resolvers); - }); - }, - }; -} - -export async function delayAgentBootstrapTailResponse( - page: Page, - agentId: string, -): Promise { - 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((resolve) => { - resolveDelayedTail = resolve; - }); - const delayedCatchUp = new Promise((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 { let releaseRequested = false; let delayedResponseSeen = false; @@ -325,7 +148,7 @@ async function delayAgentTimelineResponse( !delayedResponseSeen && sessionMessage?.type === "fetch_agent_timeline_response" && payload?.agentId === agentId && - payload.direction === direction + payload.direction === "before" ) { delayedResponseSeen = true; resolveDelayedResponse?.(); diff --git a/packages/app/e2e/helpers/assistant-images.ts b/packages/app/e2e/helpers/assistant-images.ts deleted file mode 100644 index 00016f0c0..000000000 --- a/packages/app/e2e/helpers/assistant-images.ts +++ /dev/null @@ -1,343 +0,0 @@ -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 { - 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 { - 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 { - 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 { - await client.sendAgentMessage( - agent.id, - `Emit settled assistant image Markdown: ![${image.alt}](${image.relativePath})`, - ); - const result = await client.waitForFinish(agent.id, 30_000); - if (result.status !== "idle" || result.final?.lastError) { - throw new Error( - `Assistant image agent did not settle: ${result.final?.lastError ?? result.status}`, - ); - } -} - -export async function appendSettledTimelineTurns( - client: SeedDaemonClient, - agent: ArchiveTabAgent, - count: number, -): Promise { - 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 { - 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 { - 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 { - await openWorkspaceWithAgents(page, [input.otherAgent, input.imageAgent]); -} - -export async function openAssistantImageTimeline( - page: Page, - agent: ArchiveTabAgent, -): Promise { - await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.id }); -} - -export async function expectAssistantImageNotMounted( - page: Page, - image: AssistantImageFixture, -): Promise { - await expect(page.getByRole("img", { name: image.alt })).toHaveCount(0); -} - -export async function userPagesUntilAssistantImageRenders( - page: Page, - image: AssistantImageFixture, -): Promise { - 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 { - await page.reload(); - await userPagesUntilAssistantImageRenders(page, image); -} - -export async function sendFollowUpAndExpectVisibleResponse( - page: Page, - input: { prompt: string; response: string }, -): Promise { - 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 { - 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 { - 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 { - 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; -} diff --git a/packages/app/e2e/helpers/daemon-websocket-gate.ts b/packages/app/e2e/helpers/daemon-websocket-gate.ts index c1e57f7ad..a6c7f3867 100644 --- a/packages/app/e2e/helpers/daemon-websocket-gate.ts +++ b/packages/app/e2e/helpers/daemon-websocket-gate.ts @@ -16,20 +16,6 @@ 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 { @@ -52,111 +38,15 @@ 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 } } }; - payload?: { event?: { type?: unknown; item?: Record } }; - }; - 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 }; - payload?: Record; - }; - 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 }; - payload?: Record; - }; - 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; - agentStreamEventTypes: ReadonlySet; - 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(); - const suppressedAgentStreamEventTypes = new Set(); const activeSockets = new Set(); - 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(); - const serverMessageCounts = new Map(); - const agentStreamItemCounts = new Map(); - const serverMessageWaiters = new Set<() => void>(); await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { if (!acceptingConnections) { @@ -166,20 +56,9 @@ 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); @@ -190,12 +69,6 @@ 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 { @@ -205,55 +78,8 @@ 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(outboundMessage); + ws.send(message); } catch { activeSockets.delete(ws); } @@ -274,125 +100,6 @@ 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 { - if (heldClientRequest) return Promise.resolve(); - return new Promise((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 { - if (heldServerMessage) return Promise.resolve(); - return new Promise((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 }; - payload?: Record; - }; - 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).maxSeq = lastSeq; - (payload.window as Record).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 }, @@ -403,18 +110,5 @@ 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 { - while ((serverMessageCounts.get(type) ?? 0) < count) { - await new Promise((resolve) => serverMessageWaiters.add(resolve)); - } - }, - async waitForAgentStreamItem(type: string, count = 1): Promise { - while ((agentStreamItemCounts.get(type) ?? 0) < count) { - await new Promise((resolve) => serverMessageWaiters.add(resolve)); - } - }, }; } diff --git a/packages/app/e2e/helpers/timeline-pagination.ts b/packages/app/e2e/helpers/timeline-pagination.ts index cc11dc82e..bf3023309 100644 --- a/packages/app/e2e/helpers/timeline-pagination.ts +++ b/packages/app/e2e/helpers/timeline-pagination.ts @@ -1,51 +1,20 @@ 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 { - 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; - expectSettledWithRequestedPages(count: number): Promise; - releasePage(pageNumber: number): void; -} function promptForTurn(index: number): string { return `${PROMPT_PREFIX}-${index}: emit 1 coalesced agent stream updates`; @@ -67,41 +36,11 @@ export async function seedLongMockAgentTimeline( return { ...agent, - 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 { - 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 { - 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 { await page.goto(buildAgentRoute(agent.workspaceId, agent.agentId)); await page.waitForURL( @@ -111,17 +50,15 @@ export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): P } export async function expectTimelinePromptVisible(page: Page, prompt: string): Promise { - const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); - await expect(timeline.getByText(prompt, { exact: true })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 30_000 }); } export async function expectTimelinePromptNotMounted(page: Page, prompt: string): Promise { - const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); - await expect(timeline.getByText(prompt, { exact: true })).toHaveCount(0); + await expect(page.getByText(prompt, { exact: true })).toHaveCount(0); } export async function makeLoadedTimelineFitViewport(page: Page): Promise { - await page.setViewportSize({ width: 1280, height: 20_000 }); + await page.setViewportSize({ width: 1280, height: 8_000 }); } export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise { @@ -138,33 +75,6 @@ export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise { - 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, @@ -180,106 +90,6 @@ export async function holdNextOlderTimelinePage( }; } -export async function holdOlderHistoryPages( - page: Page, - agent: LongTimelineAgent, -): Promise { - 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 { - return readTimelineViewport(page); -} - -export async function expectTimelineViewportAnchoredAfterPrepend( - page: Page, - before: TimelineViewportSnapshot, -): Promise { - 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 { - 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 { - 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 { - await expect - .poll(async () => (await readTimelineViewport(page)).scrollTop) - .toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX); -} - -export async function holdBootstrapTimelinePage( - page: Page, - agent: LongTimelineAgent, -): Promise { - return delayAgentBootstrapTailResponse(page, agent.agentId); -} - -export async function sendLiveTurnBeforeHydration(agent: LongTimelineAgent): Promise { - 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 { const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); await scroll.hover(); @@ -299,95 +109,31 @@ export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise { +export async function scrollTimelineUntilOlderHistoryIsReachable(page: Page): Promise { const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); - 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((resolve) => { - requestAnimationFrame(() => resolve()); - }), - ); - } - await expect - .poll(async () => (await readTimelineViewport(page)).scrollTop) - .toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX); -} - -export async function scrollTimelineToNewestLoadedEdge(page: Page): Promise { - const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); - await scroll.evaluate((element) => { + const previousHeight = await scroll.evaluate((element) => { if (!(element instanceof HTMLElement)) { throw new Error("Agent chat scroll element is not an HTMLElement"); } - element.scrollTop = element.scrollHeight; - element.dispatchEvent(new Event("scroll", { bubbles: true })); + return element.scrollHeight; }); -} -export async function scrollTimelineUntilOlderHistoryIsReachable( - page: Page, - oldestPrompt: string, -): Promise { - 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 { - 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 { - const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); - await scroll.evaluate( - (element) => - new Promise((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 page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); }), ); + 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); } diff --git a/packages/app/e2e/out-of-band-command.codex.real.spec.ts b/packages/app/e2e/out-of-band-command.codex.real.spec.ts deleted file mode 100644 index 2df34398d..000000000 --- a/packages/app/e2e/out-of-band-command.codex.real.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -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 }); - } - }); -}); diff --git a/packages/app/e2e/rewind-menu.ui-contract.spec.ts b/packages/app/e2e/rewind-menu.ui-contract.spec.ts index af5d75a0f..158f40ae1 100644 --- a/packages/app/e2e/rewind-menu.ui-contract.spec.ts +++ b/packages/app/e2e/rewind-menu.ui-contract.spec.ts @@ -1,8 +1,6 @@ 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, @@ -25,152 +23,7 @@ async function expectUserMessageVisible(page: Page, text: string): Promise await expect(userMessage(page, text)).toBeVisible(); } -async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Promise { - 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> } | 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> } | 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 { - 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> } | 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 { - 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> } | 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 { - 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."; @@ -192,10 +45,6 @@ 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"); diff --git a/packages/app/src/agent-stream/history-start-pagination.test.ts b/packages/app/src/agent-stream/history-start-pagination.test.ts index af45b48f7..2801f479f 100644 --- a/packages/app/src/agent-stream/history-start-pagination.test.ts +++ b/packages/app/src/agent-stream/history-start-pagination.test.ts @@ -1,15 +1,11 @@ import { describe, expect, it } from "vitest"; import { - abandonHistoryStartPaginationRequest, createHistoryStartPaginationState, evaluateHistoryStartPagination, - isHistoryStartLoadingOperation, rearmHistoryStartPagination, - settleHistoryStartPagination, - type HistoryStartPaginationInput, } from "./history-start-pagination"; -const visibleHistoryStart: HistoryStartPaginationInput = { +const visibleHistoryStart = { distanceFromHistoryStart: 0, hasOlderHistory: true, isLoadingOlderHistory: false, @@ -18,140 +14,47 @@ const visibleHistoryStart: HistoryStartPaginationInput = { }; describe("history start pagination", () => { - it("waits for anchored page geometry before authorizing another page", () => { - const first = evaluateHistoryStartPagination( - createHistoryStartPaginationState(), - visibleHistoryStart, - ); - const inFlight = evaluateHistoryStartPagination(first.state, { + 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, { ...visibleHistoryStart, - isLoadingOlderHistory: true, - }); - const pageApplied = evaluateHistoryStartPagination(inFlight.state, { - ...visibleHistoryStart, - isLoadingOlderHistory: true, progressKey: "epoch-1:10", }); - expect([first.shouldLoad, inFlight.shouldLoad, pageApplied.shouldLoad]).toEqual([ + expect([first.shouldLoad, duplicate.shouldLoad, nextPage.shouldLoad]).toEqual([ true, false, - false, + true, ]); - expect(pageApplied.state).toEqual({ status: "settling", loadedProgressKey: "epoch-1:10" }); }); - it("loads one page each time anchored geometry leaves and returns to history start", () => { + it("allows the same revision again after the user leaves the history edge", () => { const first = evaluateHistoryStartPagination( createHistoryStartPaginationState(), visibleHistoryStart, ); - const pageApplied = evaluateHistoryStartPagination(first.state, { + const away = 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", + distanceFromHistoryStart: 200, }); + const returned = evaluateHistoryStartPagination(away.state, visibleHistoryStart); - expect([ - first.shouldLoad, - pageApplied.shouldLoad, - settledAway.shouldLoad, - returned.shouldLoad, - ]).toEqual([true, false, false, true]); + expect([first.shouldLoad, away.shouldLoad, returned.shouldLoad]).toEqual([true, false, true]); }); - it("continues the same loading operation when anchored geometry remains at history start", () => { + it("re-arms the same cursor when the user makes another upward edge gesture", () => { const first = evaluateHistoryStartPagination( createHistoryStartPaginationState(), visibleHistoryStart, ); - const pageApplied = evaluateHistoryStartPagination(first.state, { - ...visibleHistoryStart, - progressKey: "epoch-1:10", - }); - const continued = settleHistoryStartPagination(pageApplied.state, { - ...visibleHistoryStart, - progressKey: "epoch-1:10", - }); - - expect(continued.shouldLoad).toBe(true); - expect(isHistoryStartLoadingOperation(first.state)).toBe(true); - expect(isHistoryStartLoadingOperation(pageApplied.state)).toBe(true); - expect(isHistoryStartLoadingOperation(continued.state)).toBe(true); - }); - - 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(failed.state), + rearmHistoryStartPagination(first.state), visibleHistoryStart, ); - 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); + expect([first.shouldLoad, retried.shouldLoad]).toEqual([true, true]); }); it("waits while history loading is unavailable or already active", () => { diff --git a/packages/app/src/agent-stream/history-start-pagination.ts b/packages/app/src/agent-stream/history-start-pagination.ts index 317753b1a..180ea5cf0 100644 --- a/packages/app/src/agent-stream/history-start-pagination.ts +++ b/packages/app/src/agent-stream/history-start-pagination.ts @@ -1,91 +1,33 @@ export const HISTORY_START_THRESHOLD_PX = 96; -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 interface HistoryStartPaginationState { + requestedProgressKey: string | null; } export function createHistoryStartPaginationState(): HistoryStartPaginationState { - return { status: "ready" }; -} - -export function isHistoryStartLoadingOperation(state: HistoryStartPaginationState): boolean { - return state.status === "loading" || state.status === "settling"; + return { requestedProgressKey: null }; } export function rearmHistoryStartPagination( - state: HistoryStartPaginationState, + _state: HistoryStartPaginationState, ): HistoryStartPaginationState { - 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" }; + return createHistoryStartPaginationState(); } export function evaluateHistoryStartPagination( state: HistoryStartPaginationState, - 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 }; + 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 }; } if ( - state.status === "latched" || !input.isReady || !input.hasOlderHistory || input.isLoadingOlderHistory || @@ -93,42 +35,11 @@ export function evaluateHistoryStartPagination( ) { return { state, shouldLoad: false }; } - return { - state: { - status: "loading", - requestedProgressKey: input.progressKey, - requestObserved: false, - }, - shouldLoad: true, - }; -} - -export function settleHistoryStartPagination( - state: HistoryStartPaginationState, - input: HistoryStartPaginationInput, -): HistoryStartPaginationTransition { - if (state.status !== "settling") { + if (state.requestedProgressKey === input.progressKey) { 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, - }, + state: { requestedProgressKey: input.progressKey }, shouldLoad: true, }; } diff --git a/packages/app/src/agent-stream/history-start-settle-scheduler.test.ts b/packages/app/src/agent-stream/history-start-settle-scheduler.test.ts deleted file mode 100644 index 2a8894299..000000000 --- a/packages/app/src/agent-stream/history-start-settle-scheduler.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -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 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); - }); -}); diff --git a/packages/app/src/agent-stream/history-start-settle-scheduler.ts b/packages/app/src/agent-stream/history-start-settle-scheduler.ts deleted file mode 100644 index 3049ab8ed..000000000 --- a/packages/app/src/agent-stream/history-start-settle-scheduler.ts +++ /dev/null @@ -1,52 +0,0 @@ -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; - }, - }; -} diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index cb802918c..2a38fa26d 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -32,19 +32,10 @@ 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, @@ -62,8 +53,6 @@ const historyStartSlotStyle: ViewStyle = { paddingTop: 4, paddingBottom: 8, }; -const HISTORY_START_SETTLE_FRAMES = 2; - interface HistoryRowDisplayVariants { regular?: StreamItem; compact?: StreamItem; @@ -127,11 +116,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false); const nativeViewportSettlingFrameIdRef = useRef(null); const historyStartReadyRef = useRef(false); - const [historyStartPaginationState, setHistoryStartPaginationState] = useState( - createHistoryStartPaginationState, - ); - const historyStartPaginationStateRef = useRef(historyStartPaginationState); - const historyStartSettleSchedulerRef = useRef(null); + const historyStartPaginationStateRef = useRef(createHistoryStartPaginationState()); const historyItems = useMemo(() => { if (segments.historyVirtualized.length === 0) { @@ -160,74 +145,22 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat ), [displayStateHistoryRows, historyRowRevision?.contentById], ); - const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput => { + const evaluateHistoryStart = useStableEvent(() => { const metrics = streamViewportMetricsRef.current; const hasMeasuredViewport = metrics.viewportMeasuredForKey === metrics.containerKey && metrics.contentMeasuredForKey === metrics.containerKey; - return { + const result = evaluateHistoryStartPagination(historyStartPaginationStateRef.current, { distanceFromHistoryStart: metrics.contentHeight - metrics.viewportHeight - metrics.offsetY, hasOlderHistory, isLoadingOlderHistory, isReady: historyStartReadyRef.current && hasMeasuredViewport, progressKey: olderHistoryProgressKey, - }; - }); - 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; + }); + historyStartPaginationStateRef.current = result.state; + if (result.shouldLoad) { + onNearHistoryStart(); } - scheduler.schedule(); }); const clearNativeViewportSettling = useCallback(() => { @@ -328,9 +261,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat clearNativeViewportSettling(); setIsNativeViewportSettling(false); historyStartReadyRef.current = false; - const initialHistoryStartState = createHistoryStartPaginationState(); - historyStartPaginationStateRef.current = initialHistoryStartState; - setHistoryStartPaginationState(initialHistoryStartState); + historyStartPaginationStateRef.current = createHistoryStartPaginationState(); const frame = requestAnimationFrame(() => { historyStartReadyRef.current = true; evaluateHistoryStart(); @@ -338,8 +269,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat return () => { cancelAnimationFrame(frame); clearPendingUserScrollEnd(); - historyStartSettleSchedulerRef.current?.cancel(); - historyStartSettleSchedulerRef.current = null; }; }, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]); @@ -440,16 +369,16 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }); const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent) => { + if (!isLoadingOlderHistory) { + historyStartPaginationStateRef.current = rearmHistoryStartPagination( + historyStartPaginationStateRef.current, + ); + } clearPendingUserScrollEnd(); isUserScrollActiveRef.current = true; scrollKeyboardDismiss.onScrollBeginDrag(event); bottomAnchorController.beginUserScroll(); - const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current); - if (rearmed !== historyStartPaginationStateRef.current) { - historyStartPaginationStateRef.current = rearmed; - setHistoryStartPaginationState(rearmed); - evaluateHistoryStart(); - } + evaluateHistoryStart(); }); // Defer drag end so momentum can take ownership, but capture the terminal @@ -525,23 +454,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat contentHeight: nextContentHeight, }); evaluateHistoryStart(); - if (historyStartPaginationStateRef.current.status === "settling") { - scheduleHistoryStartSettle(); - } }); useEffect(() => { evaluateHistoryStart(); - if (historyStartPaginationStateRef.current.status === "settling") { - scheduleHistoryStartSettle(); - } - }, [ - evaluateHistoryStart, - hasOlderHistory, - isLoadingOlderHistory, - olderHistoryProgressKey, - scheduleHistoryStartSettle, - ]); + }, [evaluateHistoryStart, hasOlderHistory, isLoadingOlderHistory, olderHistoryProgressKey]); const renderItem = useStableEvent( ({ item, index }: ListRenderItemInfo): ReactElement | null => { @@ -582,21 +499,20 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat ]); const historyFooterContent = useMemo(() => { - const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState); - if (!hasOlderHistory && !isLoadingOperation) { + if (!hasOlderHistory && !isLoadingOlderHistory) { return null; } return ( - {isLoadingOperation ? ( + {isLoadingOlderHistory ? ( ) : null} ); - }, [hasOlderHistory, historyStartPaginationState]); + }, [hasOlderHistory, isLoadingOlderHistory]); // RN's FlatList strictMode keeps its internal renderItem wrapper stable when // data or the live header changes, preserving the row identities above. diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index c9035fc68..c125293cf 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -130,7 +130,7 @@ describe("createWebStreamStrategy", () => { routeBottomAnchorRequest: null, isAuthoritativeHistoryReady: true, onNearBottomChange: vi.fn(), - onNearHistoryStart: vi.fn().mockReturnValue(true), + onNearHistoryStart: vi.fn(), isLoadingOlderHistory: false, hasOlderHistory: false, olderHistoryProgressKey: null, @@ -174,7 +174,7 @@ describe("createWebStreamStrategy", () => { routeBottomAnchorRequest: null, isAuthoritativeHistoryReady: true, onNearBottomChange: vi.fn(), - onNearHistoryStart: vi.fn().mockReturnValue(true), + onNearHistoryStart: vi.fn(), isLoadingOlderHistory: false, hasOlderHistory: false, olderHistoryProgressKey: null, @@ -230,7 +230,7 @@ describe("createWebStreamStrategy", () => { routeBottomAnchorRequest: null, isAuthoritativeHistoryReady: true, onNearBottomChange: vi.fn(), - onNearHistoryStart: vi.fn().mockReturnValue(true), + onNearHistoryStart: vi.fn(), isLoadingOlderHistory: false, hasOlderHistory: false, olderHistoryProgressKey: null, @@ -285,6 +285,76 @@ 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(); + 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, @@ -296,7 +366,7 @@ describe("createWebStreamStrategy", () => { }); const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); const viewportRef = React.createRef(); - const onNearHistoryStart = vi.fn().mockReturnValue(true); + const onNearHistoryStart = vi.fn(); const renderInput = { agentId: "agent", boundary: { @@ -403,7 +473,7 @@ describe("createWebStreamStrategy", () => { viewportRef, routeBottomAnchorRequest, onNearBottomChange: vi.fn(), - onNearHistoryStart: vi.fn().mockReturnValue(true), + onNearHistoryStart: vi.fn(), isLoadingOlderHistory: false, hasOlderHistory: false, olderHistoryProgressKey: null, @@ -511,7 +581,7 @@ describe("createWebStreamStrategy", () => { viewportRef, routeBottomAnchorRequest, onNearBottomChange: vi.fn(), - onNearHistoryStart: vi.fn().mockReturnValue(true), + onNearHistoryStart: vi.fn(), isLoadingOlderHistory: false, hasOlderHistory: false, olderHistoryProgressKey: null, @@ -608,7 +678,7 @@ describe("createWebStreamStrategy", () => { viewportRef, routeBottomAnchorRequest, onNearBottomChange: vi.fn(), - onNearHistoryStart: vi.fn().mockReturnValue(true), + onNearHistoryStart: vi.fn(), isLoadingOlderHistory: false, hasOlderHistory: false, olderHistoryProgressKey: null, @@ -707,7 +777,7 @@ describe("createWebStreamStrategy", () => { viewportRef, routeBottomAnchorRequest: null, onNearBottomChange: vi.fn(), - onNearHistoryStart: vi.fn().mockReturnValue(true), + onNearHistoryStart: vi.fn(), isLoadingOlderHistory: false, hasOlderHistory: false, olderHistoryProgressKey: null, diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 7f31539e1..7df586cef 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -17,30 +17,15 @@ 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; @@ -48,22 +33,12 @@ 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("[data-history-row-id]")) { - if (element.dataset.historyRowId === rowId) { - return element; - } - } - return null; -} - const historyStartSlotStyle: CSSProperties = { display: "flex", alignItems: "center", @@ -171,14 +146,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const pendingAutoScrollTimeoutRef = useRef(null); const pendingVirtualRowMeasureFramesRef = useRef(new Map()); const historyStartReadyRef = useRef(false); - const [historyStartPaginationState, setHistoryStartPaginationState] = useState( - createHistoryStartPaginationState, - ); - const [isHistoryStartSlotReserved, setIsHistoryStartSlotReserved] = useState(hasOlderHistory); - const historyStartPaginationStateRef = useRef(historyStartPaginationState); - const historyStartPrependAnchorRef = useRef(null); - const historyStartPrependAnchorActiveRef = useRef(false); - const historyStartSettleSchedulerRef = useRef(null); + const historyStartPaginationStateRef = useRef(createHistoryStartPaginationState()); const shouldUseVirtualizer = segments.historyVirtualized.length > 0; const { renderHistoryVirtualizedRow, @@ -208,9 +176,6 @@ 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); @@ -222,162 +187,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool }, [rowVirtualizer]); const virtualRows = rowVirtualizer.getVirtualItems(); const virtualTotalSize = rowVirtualizer.getTotalSize(); - const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput | null => { + const evaluateHistoryStart = useStableEvent(() => { const scrollContainer = scrollContainerRef.current; if (!scrollContainer) { - return null; + return; } const bottomAnchorSettled = !followOutputRef.current || isScrollContainerNearBottom(scrollContainer); - return { + const result = evaluateHistoryStartPagination(historyStartPaginationStateRef.current, { 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) => { @@ -507,11 +335,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool }, [cancelPendingStickToBottom, evaluateHistoryStart, updateScrollMetrics]); useEffect(() => { - const initialHistoryStartState = createHistoryStartPaginationState(); - historyStartPaginationStateRef.current = initialHistoryStartState; - setHistoryStartPaginationState(initialHistoryStartState); - historyStartPrependAnchorRef.current = null; - historyStartPrependAnchorActiveRef.current = false; + historyStartPaginationStateRef.current = createHistoryStartPaginationState(); const frame = window.requestAnimationFrame(() => { historyStartReadyRef.current = true; evaluateHistoryStart(); @@ -519,8 +343,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool return () => { window.cancelAnimationFrame(frame); historyStartReadyRef.current = false; - historyStartSettleSchedulerRef.current?.cancel(); - historyStartSettleSchedulerRef.current = null; }; }, [evaluateHistoryStart, props.agentId]); @@ -579,15 +401,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool useEffect(() => { updateScrollMetrics(); evaluateHistoryStart(); - if (historyStartPaginationStateRef.current.status === "settling") { - scheduleHistoryStartPrependSettle(); - } }, [ evaluateHistoryStart, hasOlderHistory, isLoadingOlderHistory, olderHistoryProgressKey, - scheduleHistoryStartPrependSettle, segments.historyMounted.length, segments.historyVirtualized.length, segments.liveHead.length, @@ -605,12 +423,6 @@ 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) { @@ -625,13 +437,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool return () => { observer.disconnect(); }; - }, [ - applyHistoryStartPrependAnchor, - evaluateHistoryStart, - scheduleHistoryStartPrependSettle, - scheduleStickToBottom, - updateScrollMetrics, - ]); + }, [evaluateHistoryStart, scheduleStickToBottom, updateScrollMetrics]); useEffect(() => { const scrollContainer = scrollContainerRef.current; @@ -641,9 +447,14 @@ 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(); - rearmHistoryStartFromUserIntent(); + evaluateHistoryStart(); } }; const handlePointerDown = () => { @@ -666,9 +477,14 @@ 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(); - rearmHistoryStartFromUserIntent(); + evaluateHistoryStart(); } lastTouchClientYRef.current = touch.clientY; }; @@ -697,7 +513,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool scrollContainer.removeEventListener("touchend", handleTouchEnd); scrollContainer.removeEventListener("touchcancel", handleTouchEnd); }; - }, [cancelPendingStickToBottom, handleDomScroll, rearmHistoryStartFromUserIntent]); + }, [cancelPendingStickToBottom, evaluateHistoryStart, handleDomScroll, isLoadingOlderHistory]); useEffect(() => { const handle: StreamViewportHandle = { @@ -764,9 +580,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ); const mountedHistoryRows = useMemo(() => { return segments.historyMounted.map((item, index) => ( -
+ {renderHistoryMountedRow(item, index, segments.historyMounted)} -
+ )); }, [renderHistoryMountedRow, segments.historyMounted]); const liveHeadRows = useMemo(() => { @@ -778,27 +594,21 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const liveAuxiliary = useMemo(() => { return renderLiveAuxiliary(); }, [renderLiveAuxiliary]); - useEffect(() => { - if (hasOlderHistory || isHistoryStartLoadingOperation(historyStartPaginationState)) { - setIsHistoryStartSlotReserved(true); - } - }, [hasOlderHistory, historyStartPaginationState]); const historyStartSlot = useMemo(() => { - const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState); - if (!isHistoryStartSlotReserved && !hasOlderHistory && !isLoadingOperation) { + if (!hasOlderHistory && !isLoadingOlderHistory) { return null; } return (
- {isLoadingOperation ? ( + {isLoadingOlderHistory ? ( ) : null}
); - }, [hasOlderHistory, historyStartPaginationState, isHistoryStartSlotReserved]); + }, [hasOlderHistory, isLoadingOlderHistory]); const shouldRenderEmpty = !boundary.hasMountedHistory && !boundary.hasVirtualizedHistory && @@ -825,7 +635,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index 15836f623..9a467c578 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -69,7 +69,7 @@ export interface StreamRenderInput { routeBottomAnchorRequest: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady: boolean; onNearBottomChange: (value: boolean) => void; - onNearHistoryStart: () => boolean | Promise; + onNearHistoryStart: () => void; isLoadingOlderHistory: boolean; hasOlderHistory: boolean; olderHistoryProgressKey: string | null; diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 63308e56b..5af665d40 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -41,7 +41,6 @@ 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, @@ -77,7 +76,6 @@ import { type BottomAnchorLocalRequest, type BottomAnchorRouteRequest, } from "./bottom-anchor-controller"; -import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache"; import { AssistantFileLinkResolverProvider, normalizeInlinePathTarget, @@ -241,7 +239,6 @@ export interface AgentStreamViewProps { streamItems: StreamItem[]; streamHead?: StreamItem[]; pendingPermissions: Map; - pendingMessageSubmissions?: readonly PendingMessageSubmission[]; routeBottomAnchorRequest?: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady?: boolean; toast?: ToastApi | null; @@ -251,7 +248,7 @@ export interface AgentStreamViewProps { hasOlder: boolean; isLoadingOlder: boolean; progressKey: string | null; - onLoadOlder: () => boolean | Promise; + onLoadOlder: () => void; }; } @@ -268,7 +265,6 @@ 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: { @@ -331,7 +327,6 @@ const AgentStreamViewComponent = forwardRef settings.autoExpandReasoning); const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel); const viewportRef = useRef(null); - const pendingClientMessageIds = useMemo( - () => new Set(pendingMessageSubmissions.map((submission) => submission.clientMessageId)), - [pendingMessageSubmissions], - ); const isMobile = useIsCompactFormFactor(); const streamRenderStrategy = useMemo( () => @@ -665,7 +656,7 @@ const AgentStreamViewComponent = forwardRef ); }, - [context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId], + [context.capabilities, agentId, client, resolvedServerId], ); const renderAssistantMessageItem = useCallback( @@ -695,7 +682,6 @@ const AgentStreamViewComponent = forwardRef ); }, - [agentId, client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot], + [client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot], ); const renderThoughtItem = useCallback( @@ -892,8 +878,7 @@ const AgentStreamViewComponent = forwardRef 0; + const showRunningTurnFooter = baseRenderModel.turnTiming.isActive; const pendingPermissionsNode = useMemo( () => renderPendingPermissionsNode({ @@ -1172,9 +1157,6 @@ 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) ) { diff --git a/packages/app/src/assistant-image/acquisition-cache.test.ts b/packages/app/src/assistant-image/acquisition-cache.test.ts deleted file mode 100644 index ce1961b63..000000000 --- a/packages/app/src/assistant-image/acquisition-cache.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -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({ 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({ 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({ 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({ - 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({ - 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()); - }); -}); diff --git a/packages/app/src/assistant-image/acquisition-cache.ts b/packages/app/src/assistant-image/acquisition-cache.ts deleted file mode 100644 index f76f594b8..000000000 --- a/packages/app/src/assistant-image/acquisition-cache.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { createPreviewAttachmentId } from "@/attachments/utils"; - -export interface AssistantImageAcquisitionCache { - acquire(key: string, locate: () => Promise): Promise; - acquireRetained( - key: string, - locate: () => Promise, - ): { promise: Promise; 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(input: { - capacity: number; - onRetain?: (value: T) => () => void; -}): AssistantImageAcquisitionCache { - 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; - resolved: boolean; - value?: T; - release: (() => void) | null; - activeConsumers: number; - } - const entries = new Map(); - - 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, 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; - }, - }; -} diff --git a/packages/app/src/assistant-image/file-acquisition.test.ts b/packages/app/src/assistant-image/file-acquisition.test.ts deleted file mode 100644 index 552609e75..000000000 --- a/packages/app/src/assistant-image/file-acquisition.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -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" }]); - }); -}); diff --git a/packages/app/src/assistant-image/file-acquisition.ts b/packages/app/src/assistant-image/file-acquisition.ts deleted file mode 100644 index f7a80e853..000000000 --- a/packages/app/src/assistant-image/file-acquisition.ts +++ /dev/null @@ -1,67 +0,0 @@ -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; - persist(input: { - id: string; - bytes: Uint8Array; - mimeType: string; - fileName: string | null; - }): Promise; -} - -export interface AssistantImageAcquisition { - key: string; - locate: () => Promise; -} - -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), - }); - }, - }; -} diff --git a/packages/app/src/assistant-image/lifecycle.test.ts b/packages/app/src/assistant-image/lifecycle.test.ts deleted file mode 100644 index ed7729dd3..000000000 --- a/packages/app/src/assistant-image/lifecycle.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -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); - }); -}); diff --git a/packages/app/src/assistant-image/lifecycle.ts b/packages/app/src/assistant-image/lifecycle.ts deleted file mode 100644 index bf0db18c9..000000000 --- a/packages/app/src/assistant-image/lifecycle.ts +++ /dev/null @@ -1,46 +0,0 @@ -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; -} diff --git a/packages/app/src/assistant-image/load-dimensions.test.ts b/packages/app/src/assistant-image/load-dimensions.test.ts deleted file mode 100644 index a60f9b1b6..000000000 --- a/packages/app/src/assistant-image/load-dimensions.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -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 }); - }); -}); diff --git a/packages/app/src/assistant-image/load-dimensions.ts b/packages/app/src/assistant-image/load-dimensions.ts deleted file mode 100644 index 069814ced..000000000 --- a/packages/app/src/assistant-image/load-dimensions.ts +++ /dev/null @@ -1,45 +0,0 @@ -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); -} diff --git a/packages/app/src/assistant-image/retry.test.ts b/packages/app/src/assistant-image/retry.test.ts deleted file mode 100644 index 9777663be..000000000 --- a/packages/app/src/assistant-image/retry.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -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); - }); -}); diff --git a/packages/app/src/assistant-image/retry.ts b/packages/app/src/assistant-image/retry.ts deleted file mode 100644 index 4a2cf5c58..000000000 --- a/packages/app/src/assistant-image/retry.ts +++ /dev/null @@ -1,31 +0,0 @@ -export const ASSISTANT_IMAGE_RETRY_DELAYS_MS = [100, 400, 1_200] as const; - -export async function runAssistantImageOperationWithRetry(input: { - operation: () => Promise; - delaysMs?: readonly number[]; - shouldStop?: () => boolean; - wait?: (delayMs: number) => Promise; -}): Promise { - const delays = input.delaysMs ?? ASSISTANT_IMAGE_RETRY_DELAYS_MS; - const shouldStop = input.shouldStop ?? (() => false); - const wait = - input.wait ?? - (async (delayMs: number) => { - await new Promise((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; - } - } - } -} diff --git a/packages/app/src/assistant-image/use-assistant-image.ts b/packages/app/src/assistant-image/use-assistant-image.ts deleted file mode 100644 index 0c0818e8f..000000000 --- a/packages/app/src/assistant-image/use-assistant-image.ts +++ /dev/null @@ -1,544 +0,0 @@ -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({ - capacity: 500, - onRetain: (attachment) => retainAttachmentForGarbageCollection(attachment.id), -}); - -interface CachedPreviewUrl { - attachment: AttachmentMetadata; - uri: string; -} - -const previewUrlCache = createAssistantImageAcquisitionCache({ - capacity: 500, - onRetain: - ({ attachment, uri }) => - () => { - void releaseAttachmentPreviewUrl({ attachment, url: uri }); - }, -}); - -const LOADED_IMAGE_CACHE_CAPACITY = 500; -const loadedImageCache = new Map(); - -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 | 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 | 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(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, - }; -} diff --git a/packages/app/src/attachments/gc-retention.test.ts b/packages/app/src/attachments/gc-retention.test.ts deleted file mode 100644 index 4b3992681..000000000 --- a/packages/app/src/attachments/gc-retention.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -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"); - }); -}); diff --git a/packages/app/src/attachments/gc-retention.ts b/packages/app/src/attachments/gc-retention.ts deleted file mode 100644 index 4473350d9..000000000 --- a/packages/app/src/attachments/gc-retention.ts +++ /dev/null @@ -1,22 +0,0 @@ -const retentionCounts = new Map(); - -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 { - return new Set(retentionCounts.keys()); -} diff --git a/packages/app/src/attachments/service.test.ts b/packages/app/src/attachments/service.test.ts index 05e13034f..080204ed0 100644 --- a/packages/app/src/attachments/service.test.ts +++ b/packages/app/src/attachments/service.test.ts @@ -1,11 +1,7 @@ import { afterEach, describe, expect, it } from "vitest"; import type { AttachmentMetadata, AttachmentStore, SaveAttachmentInput } from "@/attachments/types"; import { __setAttachmentStoreForTests } from "./store"; -import { - encodeAttachmentsForSend, - garbageCollectAttachments, - persistAttachmentFromBytes, -} from "./service"; +import { encodeAttachmentsForSend, persistAttachmentFromBytes } from "./service"; function createAttachment(input: Partial = {}): AttachmentMetadata { return { @@ -98,46 +94,4 @@ 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((resolve) => { - reportSaveStarted = resolve; - }); - const saveGate = new Promise((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"]]); - }); }); diff --git a/packages/app/src/attachments/service.ts b/packages/app/src/attachments/service.ts index d878b1986..15cff1ee2 100644 --- a/packages/app/src/attachments/service.ts +++ b/packages/app/src/attachments/service.ts @@ -1,40 +1,5 @@ -import { collectRetainedAttachmentIds } from "@/attachments/gc-retention"; +import type { AttachmentMetadata } from "@/attachments/types"; import { getAttachmentStore } from "@/attachments/store"; -import type { AttachmentMetadata, SaveAttachmentInput } from "@/attachments/types"; - -const activePersistence = new Set>(); -const persistedDuringGarbageCollection = new Set(); -let pendingGarbageCollections = 0; -let garbageCollectionTail: Promise = Promise.resolve(); -let persistenceBarrier: Promise | null = null; -let releasePersistenceBarrier: (() => void) | null = null; - -async function waitForPersistenceBarrier(): Promise { - const barrier = persistenceBarrier; - if (!barrier) { - return; - } - await barrier; - await waitForPersistenceBarrier(); -} - -async function persistAttachment(input: SaveAttachmentInput): Promise { - 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; @@ -42,7 +7,8 @@ export async function persistAttachmentFromBlob(input: { fileName?: string | null; id?: string; }): Promise { - return await persistAttachment({ + const store = await getAttachmentStore(); + return await store.save({ id: input.id, mimeType: input.mimeType, fileName: input.fileName, @@ -56,7 +22,8 @@ export async function persistAttachmentFromDataUrl(input: { fileName?: string | null; id?: string; }): Promise { - return await persistAttachment({ + const store = await getAttachmentStore(); + return await store.save({ id: input.id, mimeType: input.mimeType, fileName: input.fileName, @@ -70,7 +37,8 @@ export async function persistAttachmentFromBytes(input: { fileName?: string | null; id?: string; }): Promise { - return await persistAttachment({ + const store = await getAttachmentStore(); + return await store.save({ id: input.id, mimeType: input.mimeType, fileName: input.fileName, @@ -84,7 +52,8 @@ export async function persistAttachmentFromFileUri(input: { fileName?: string | null; id?: string; }): Promise { - return await persistAttachment({ + const store = await getAttachmentStore(); + return await store.save({ id: input.id, mimeType: input.mimeType, fileName: input.fileName, @@ -164,41 +133,6 @@ export async function deleteAttachments( export async function garbageCollectAttachments(input: { referencedIds: ReadonlySet; }): Promise { - pendingGarbageCollections += 1; - if (!persistenceBarrier) { - persistenceBarrier = new Promise((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?.(); - } - } + const store = await getAttachmentStore(); + await store.garbageCollect({ referencedIds: input.referencedIds }); } diff --git a/packages/app/src/attachments/utils.test.ts b/packages/app/src/attachments/utils.test.ts index d6fce078f..319f247d2 100644 --- a/packages/app/src/attachments/utils.test.ts +++ b/packages/app/src/attachments/utils.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { createImageSourceCacheKey, - createPreviewAttachmentId, fileUriToPath, localFileSourceToPath, parseDataUrl, @@ -87,29 +86,4 @@ 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", - }), - ); - }); }); diff --git a/packages/app/src/attachments/utils.ts b/packages/app/src/attachments/utils.ts index 5fa033435..bb1903f9f 100644 --- a/packages/app/src/attachments/utils.ts +++ b/packages/app/src/attachments/utils.ts @@ -56,7 +56,7 @@ export function parseImageDataUrl( if (!isRasterImageMimeType(parsed.mimeType)) { return null; } - const fingerprint = `${parsed.mimeType}\0${parsed.base64}`; + const fingerprint = `${parsed.mimeType}\0${parsed.base64.length}\0${parsed.base64.slice(0, 64)}\0${parsed.base64.slice(-64)}`; return { ...parsed, cacheKey: `data-image:${parsed.mimeType}:${parsed.base64.length}:${hashString(fingerprint)}`, @@ -87,15 +87,12 @@ 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 contentKey = input.contentKey?.trim() ?? ""; - const identity = `${input.mimeType}\0${path}\0${size}\0${modifiedAt}\0${contentLength}`; - const hash = hashString(contentKey ? `${identity}\0${contentKey}` : identity); + const hash = hashString(`${input.mimeType}\0${path}\0${size}\0${modifiedAt}\0${contentLength}`); return `preview_${size || contentLength || "unknown"}_${hash}`; } diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 8a85a8645..8abe67928 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -29,6 +29,7 @@ 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, @@ -74,7 +75,19 @@ 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"; @@ -89,7 +102,8 @@ import { useAssistantLinkPress, } from "@/assistant-file-links"; import { getCompactionMarkerLabel } from "./message-compaction-label"; -import { useAssistantImage } from "@/assistant-image/use-assistant-image"; +import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; +import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service"; import { AttachmentFrame, AttachmentLabel, @@ -118,7 +132,6 @@ interface UserMessageProps { client?: DaemonClient | null; isFirstInGroup?: boolean; isLastInGroup?: boolean; - isPending?: boolean; disableOuterSpacing?: boolean; } @@ -417,7 +430,6 @@ export const UserMessage = memo(function UserMessage({ client, isFirstInGroup = true, isLastInGroup = true, - isPending = false, disableOuterSpacing, }: UserMessageProps) { const isCompact = useIsCompactFormFactor(); @@ -429,7 +441,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 = !isPending && hasText && (isCompact || isNative || isHovered); + const showTrailingRow = hasText && (isCompact || isNative || isHovered); const formattedTimestamp = useMemo( () => formatMessageTimestamp(new Date(timestamp)), [timestamp], @@ -482,7 +494,7 @@ export const UserMessage = memo(function UserMessage({ ); return ( - + {hasText ? ( - - - {formattedTimestamp} - - {capabilities && messageId ? ( + + {formattedTimestamp} + {capabilities ? ( ({ 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", @@ -782,9 +777,125 @@ 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; + source: string; + workspaceRoot?: string; + serverId?: string; +}) { + const cachedMetadata = useMemo( + () => getAssistantImageMetadata({ source, workspaceRoot, serverId }), + [serverId, source, workspaceRoot], + ); + const [loadState, setLoadState] = useState(() => + 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>( + () => [ + assistantMessageStylesheet.imageSurface, + loadState.status === "ready" + ? { aspectRatio: loadState.aspectRatio } + : { height: ASSISTANT_IMAGE_MIN_HEIGHT }, + ], + [loadState], + ); + const frameStyle = useMemo>( + () => [assistantMessageStylesheet.imageFrame, containerStyle], + [containerStyle], + ); + const stateSurfaceStyle = useMemo>( + () => [surfaceStyle, assistantMessageStylesheet.imageState], + [surfaceStyle], + ); + const imageSource = useMemo(() => ({ uri }), [uri]); + + if (loadState.status !== "ready") { + return ( + + + {loadState.status === "loading" ? ( + + ) : null} + {loadState.status === "error" ? ( + + {t("message.attachments.imageUnavailable")} + + ) : null} + + + ); + } + + return ( + + + + + + ); +}); + function AssistantMarkdownImage({ source, - occurrenceKey, alt, hasLeadingContent, client, @@ -792,13 +903,18 @@ 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>( () => ({ marginTop: hasLeadingContent ? 16 : 0, @@ -806,31 +922,64 @@ function AssistantMarkdownImage({ }), [hasLeadingContent], ); - const image = useAssistantImage({ - source, - occurrenceKey, - client, - workspaceRoot, - serverId, + + 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 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>( - () => [assistantMessageStylesheet.imageFrame, containerStyle], - [containerStyle], - ); - const imageSizeStyle = useMemo(() => { - if (aspectRatio) { - return { aspectRatio }; - } - return { height: ASSISTANT_IMAGE_MIN_HEIGHT }; - }, [aspectRatio]); - const surfaceStyle = useMemo>( - () => [assistantMessageStylesheet.imageSurface, imageSizeStyle], - [imageSizeStyle], - ); + 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 stateFrameStyle = useMemo>( () => [ @@ -842,15 +991,20 @@ function AssistantMarkdownImage({ [containerStyle], ); - if (image.status === "failed") { + if (resolvedUri) { return ( - - {image.message} - + ); } - if (!binding) { + if (query.isLoading || dataImageQuery.isLoading) { return ( @@ -858,27 +1012,29 @@ function AssistantMarkdownImage({ ); } + const errorText = resolveAssistantImageErrorText( + query.error, + dataImageQuery.error, + t("message.attachments.imagePreviewLoadFailed"), + ); + return ( - - - - {image.status === "loading" ? ( - - - - ) : null} - + + {errorText} ); } +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, content: string, @@ -1419,7 +1575,6 @@ function MarkdownListView({ baseStyle, spacing, children }: MarkdownListViewProp } export const AssistantMessage = memo(function AssistantMessage({ - occurrenceKey, message, timestamp: _timestamp, workspaceRoot, @@ -1754,7 +1909,6 @@ export const AssistantMessage = memo(function AssistantMessage({ splitMarkdownBlocks(message), [message]); const keyedBlocks = useMemo( diff --git a/packages/app/src/components/rewind/use-rewind-agent-mutation.ts b/packages/app/src/components/rewind/use-rewind-agent-mutation.ts index f1483808c..037f7c733 100644 --- a/packages/app/src/components/rewind/use-rewind-agent-mutation.ts +++ b/packages/app/src/components/rewind/use-rewind-agent-mutation.ts @@ -7,6 +7,7 @@ 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 { @@ -35,6 +36,13 @@ 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() diff --git a/packages/app/src/components/ui/loading-spinner.tsx b/packages/app/src/components/ui/loading-spinner.tsx index abcbc64e8..f73fd037b 100644 --- a/packages/app/src/components/ui/loading-spinner.tsx +++ b/packages/app/src/components/ui/loading-spinner.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { ActivityIndicator, type ActivityIndicatorProps } from "react-native"; interface LoadingSpinnerProps { diff --git a/packages/app/src/composer/actions.test.ts b/packages/app/src/composer/actions.test.ts index 8896650f0..178fe9eb6 100644 --- a/packages/app/src/composer/actions.test.ts +++ b/packages/app/src/composer/actions.test.ts @@ -6,17 +6,7 @@ import type { UserComposerAttachment, WorkspaceComposerAttachment, } from "@/attachments/types"; -import { - appendSubmittedUserMessage, - removeSubmittedUserMessage, - type StreamItem, -} from "@/types/stream"; -import { - acceptMessageSubmission, - beginMessageSubmission, - rejectMessageSubmission, - type MessageSubmissionRecord, -} from "@/composer/submission/model"; +import type { StreamItem } from "@/types/stream"; import { cancelComposerAgent, dispatchComposerAgentMessage, @@ -30,7 +20,7 @@ import { sendQueuedComposerMessageNow, toggleGithubAttachment, toggleGithubAttachmentFromPicker, - type MessageSubmissionWriter, + type AgentStreamWriter, type AttachmentPersister, type ComposerCancelClient, type ComposerSendClient, @@ -178,16 +168,14 @@ interface FakeSendCall { } function createFakeSendClient( - options: { rejection?: Error; beforeRejection?: (call: FakeSendCall) => void } = {}, + options: { rejection?: Error } = {}, ): ComposerSendClient & { calls: FakeSendCall[] } { const calls: FakeSendCall[] = []; return { calls, sendAgentMessage: async (agentId, text, opts) => { - const call = { agentId, text, options: opts }; - calls.push(call); + calls.push({ agentId, text, options: opts }); if (options.rejection) { - options.beforeRejection?.(call); throw options.rejection; } }, @@ -195,7 +183,7 @@ function createFakeSendClient( }; } -interface FakeStream extends MessageSubmissionWriter { +interface FakeStream extends AgentStreamWriter { head: Map; tail: Map; } @@ -204,70 +192,18 @@ function createFakeStream(initialHead: Map = new Map()): F const fake: FakeStream = { head: new Map(initialHead), tail: new Map(), - 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, - }), - }); + getTail: (agentId) => fake.tail.get(agentId), + getHead: (agentId) => fake.head.get(agentId), + setHead: (updater) => { + fake.head = updater(fake.head); }, - 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; + setTail: (updater) => { + fake.tail = updater(fake.tail); }, }; return fake; } -const submissionsByFakeStream = new WeakMap>(); - -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 = new Map(), ): QueueWriter & { state: Map } { @@ -401,7 +337,7 @@ describe("pickAndPersistImages", () => { }); describe("dispatchComposerAgentMessage", () => { - it("removes the submitted prompt when the host rejects it", async () => { + it("removes the optimistic prompt when the host rejects it", async () => { const rejection = new Error("Host rejected prompt"); const client = createFakeSendClient({ rejection }); const stream = createFakeStream(); @@ -413,54 +349,14 @@ describe("dispatchComposerAgentMessage", () => { text: "rejected prompt", attachments: [], encodeImages: passthroughEncodeImages, - submission: stream, + stream, }), ).rejects.toBe(rejection); - expect(stream.head.get("agent")).toEqual([]); + expect(stream.head.get("agent")).toBeUndefined(); 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(); @@ -475,7 +371,7 @@ describe("dispatchComposerAgentMessage", () => { { kind: "github_pr", item: prItem }, ], encodeImages: passthroughEncodeImages, - submission: stream, + stream, }); expect(client.calls).toHaveLength(1); @@ -497,7 +393,7 @@ describe("dispatchComposerAgentMessage", () => { }, ]); - expect(stream.head.get("agent")).toEqual([]); + expect(stream.head.get("agent")).toBeUndefined(); const tail = stream.tail.get("agent"); expect(tail).toHaveLength(1); const userMessage = tail?.[0] as Extract; @@ -506,8 +402,7 @@ describe("dispatchComposerAgentMessage", () => { expect(userMessage.images).toEqual([image]); expect(userMessage.attachments).toEqual(call.options.attachments); expect(userMessage.id).toBe(call.options.messageId); - expect(userMessage.clientMessageId).toBe(call.options.messageId); - expect(userMessage.messageId).toBeUndefined(); + expect(userMessage.optimistic).toBe(true); }); it("can send legacy GitHub attachment payloads for old daemons", async () => { @@ -521,7 +416,7 @@ describe("dispatchComposerAgentMessage", () => { attachments: [{ kind: "forge_change_request", item: prItem }], attachmentSubmitFormat: "legacy-github", encodeImages: passthroughEncodeImages, - submission: stream, + stream, }); expect(client.calls[0].options.attachments).toEqual([ @@ -554,11 +449,11 @@ describe("dispatchComposerAgentMessage", () => { text: "next message", attachments: [], encodeImages: passthroughEncodeImages, - submission: stream, + stream, }); expect(stream.head.get("agent")).toHaveLength(2); - expect(stream.tail.get("agent")).toEqual([]); + expect(stream.tail.get("agent")).toBeUndefined(); }); it("submits empty wire arrays when no attachments are provided", async () => { @@ -571,7 +466,7 @@ describe("dispatchComposerAgentMessage", () => { text: "plain message", attachments: [], encodeImages: passthroughEncodeImages, - submission: stream, + stream, }); expect(client.calls[0]?.options).toMatchObject({ @@ -591,7 +486,7 @@ describe("dispatchComposerAgentMessage", () => { text: "review this", attachments: [review], encodeImages: passthroughEncodeImages, - submission: stream, + stream, }); expect(client.calls[0]?.options.attachments).toEqual([review.attachment]); @@ -609,7 +504,7 @@ describe("dispatchComposerAgentMessage", () => { text: "inspect element", attachments: [browserElement], encodeImages: passthroughEncodeImages, - submission: stream, + stream, }); expect(client.calls[0]?.options.attachments).toEqual([ diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index 08cd0d03e..44fc31747 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -12,8 +12,13 @@ import { splitComposerAttachmentsForSubmit, type ComposerAttachmentSubmitFormat, } from "@/composer/attachments/submit"; -import { createUserMessage, generateMessageId, type UserMessageItem } from "@/types/stream"; -import type { MessageSubmissionRejectionOutcome } from "@/composer/submission/model"; +import { + appendOptimisticUserMessageToStream, + buildOptimisticUserMessage, + generateMessageId, + type StreamItem, + type UserMessageItem, +} from "@/types/stream"; import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker"; import { i18n } from "@/i18n/i18next"; @@ -46,7 +51,7 @@ export interface ComposerSendClient { images: Array<{ data: string; mimeType: string }>; attachments: ReturnType["attachments"]; }, - ) => Promise; + ) => Promise; uploadFile: (input: { fileName: string; mimeType: string; bytes: Uint8Array }) => Promise<{ requestId: string; file: { @@ -65,10 +70,11 @@ export interface ComposerCancelClient { cancelAgent: (agentId: string) => Promise | 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 AgentStreamWriter { + getTail: (agentId: string) => StreamItem[] | undefined; + getHead: (agentId: string) => StreamItem[] | undefined; + setHead: (updater: (prev: Map) => Map) => void; + setTail: (updater: (prev: Map) => Map) => void; } export interface QueueWriter { @@ -163,7 +169,7 @@ export interface DispatchComposerAgentMessageInput { encodeImages: ( images: AttachmentMetadata[], ) => Promise | undefined>; - submission: MessageSubmissionWriter; + stream: AgentStreamWriter; } export async function dispatchComposerAgentMessage( @@ -172,30 +178,60 @@ export async function dispatchComposerAgentMessage( const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, { format: input.attachmentSubmitFormat, }); - const clientMessageId = generateMessageId(); - const userMessage = createUserMessage({ - clientMessageId, + const messageId = generateMessageId(); + const userMessage = buildOptimisticUserMessage({ + id: messageId, text: input.text, timestamp: new Date(), images: wirePayload.images, attachments: wirePayload.attachments, }); - input.submission.begin(input.agentId, userMessage); + const rollbackOptimisticMessage = appendUserMessageToStream( + input.agentId, + userMessage, + input.stream, + ); try { const imagesData = await input.encodeImages(wirePayload.images); - const result = await input.client.sendAgentMessage(input.agentId, input.text, { - messageId: clientMessageId, + await input.client.sendAgentMessage(input.agentId, input.text, { + messageId, images: imagesData ?? [], attachments: wirePayload.attachments, }); - input.submission.accept(input.agentId, clientMessageId, result?.outOfBand); } catch (error) { - const outcome = input.submission.reject(input.agentId, clientMessageId); - if (outcome === "accepted") return; + rollbackOptimisticMessage(); 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; diff --git a/packages/app/src/composer/draft/create-flow.test.ts b/packages/app/src/composer/draft/create-flow.test.ts index 471d1fc8b..41ea2a4d9 100644 --- a/packages/app/src/composer/draft/create-flow.test.ts +++ b/packages/app/src/composer/draft/create-flow.test.ts @@ -13,7 +13,7 @@ describe("useDraftAgentCreateFlow", () => { useCreateFlowStore.setState({ pendingByDraftId: {} }); }); - it("renders a prepared new-workspace submission before continuing it", async () => { + it("renders a prepared new-workspace create attempt as optimistic chat 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.submittedStreamItems).toEqual([ + expect(result.current.optimisticStreamItems).toEqual([ { kind: "user_message", id: "msg-prepared", - clientMessageId: "msg-prepared", text: "build this", timestamp: attempt.timestamp, + optimistic: true, images: [image], attachments: [attachment], }, diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index ea3638da6..d4fddc412 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -8,13 +8,12 @@ import { import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useSessionStore } from "@/stores/session-store"; import { - createUserMessage, + buildOptimisticUserMessage, 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[] = []; @@ -134,7 +133,7 @@ export function useDraftAgentCreateFlow({ const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : ""; const isSubmitting = machine.tag === "creating"; - const submittedStreamItems = useMemo(() => { + const optimisticStreamItems = useMemo(() => { if (machine.tag !== "creating") { return EMPTY_STREAM_ITEMS; } @@ -148,8 +147,8 @@ export function useDraftAgentCreateFlow({ } return [ - createUserMessage({ - clientMessageId: machine.attempt.clientMessageId, + buildOptimisticUserMessage({ + id: machine.attempt.clientMessageId, text: machine.attempt.text, timestamp: machine.attempt.timestamp, images: machine.attempt.images, @@ -157,15 +156,6 @@ export function useDraftAgentCreateFlow({ }), ]; }, [machine]); - const pendingMessageSubmissions = useMemo(() => { - if (machine.tag !== "creating") return []; - return [ - { - clientMessageId: machine.attempt.clientMessageId, - submittedAt: machine.attempt.timestamp, - }, - ]; - }, [machine]); const draftAgent = useMemo(() => { if (machine.tag !== "creating") { @@ -205,8 +195,8 @@ export function useDraftAgentCreateFlow({ handoffCreatedAgentUserMessage( pendingServerId, createResult.agentId, - createUserMessage({ - clientMessageId: attempt.clientMessageId, + buildOptimisticUserMessage({ + id: attempt.clientMessageId, text: attempt.text, timestamp: attempt.timestamp, images: attempt.images, @@ -336,8 +326,7 @@ export function useDraftAgentCreateFlow({ machine, formErrorMessage, isSubmitting, - submittedStreamItems, - pendingMessageSubmissions, + optimisticStreamItems, draftAgent, handleCreateFromInput, continueCreateFromAttempt, diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index a4aa272df..490b0a495 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -479,8 +479,7 @@ export function WorkspaceDraftAgentTab({ const { formErrorMessage, isSubmitting, - submittedStreamItems, - pendingMessageSubmissions, + optimisticStreamItems, draftAgent, handleCreateFromInput, continueCreateFromAttempt, @@ -643,8 +642,7 @@ export function WorkspaceDraftAgentTab({ agentId={tabId} serverId={serverId} context={draftAgent} - streamItems={submittedStreamItems} - pendingMessageSubmissions={pendingMessageSubmissions} + streamItems={optimisticStreamItems} pendingPermissions={EMPTY_PENDING_PERMISSIONS} onOpenWorkspaceFile={onOpenWorkspaceFile} /> diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 12be75422..35232cd02 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -64,6 +64,7 @@ import { sendQueuedComposerMessageNow, toggleGithubAttachmentFromPicker, uploadFileAttachments, + type AgentStreamWriter, type QueueWriter, type QueuedComposerMessage, } from "@/composer/actions"; @@ -90,7 +91,6 @@ 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,6 +1079,8 @@ 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); @@ -1281,6 +1283,12 @@ 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, @@ -1290,11 +1298,19 @@ export function Composer({ supportsForgeAttachments: supportsForgeSearch, }), encodeImages, - submission: createMessageSubmissionWriter(serverId), + stream, }); onAttentionPromptSend?.(); }; - }, [client, onAttentionPromptSend, serverId, supportsForgeSearch, t]); + }, [ + client, + onAttentionPromptSend, + serverId, + setAgentStreamTail, + setAgentStreamHead, + supportsForgeSearch, + t, + ]); useEffect(() => { onSubmitMessageRef.current = onSubmitMessage; diff --git a/packages/app/src/composer/submission/model.test.ts b/packages/app/src/composer/submission/model.test.ts deleted file mode 100644 index cab44f8a3..000000000 --- a/packages/app/src/composer/submission/model.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -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"); - }); -}); diff --git a/packages/app/src/composer/submission/model.ts b/packages/app/src/composer/submission/model.ts deleted file mode 100644 index 9efc5e66a..000000000 --- a/packages/app/src/composer/submission/model.ts +++ /dev/null @@ -1,108 +0,0 @@ -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), - }; -} diff --git a/packages/app/src/composer/submission/writer.ts b/packages/app/src/composer/submission/writer.ts deleted file mode 100644 index 9b41c84e7..000000000 --- a/packages/app/src/composer/submission/writer.ts +++ /dev/null @@ -1,20 +0,0 @@ -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), - }; -} diff --git a/packages/app/src/composer/submit.ts b/packages/app/src/composer/submit.ts index c0c39171c..fb892d985 100644 --- a/packages/app/src/composer/submit.ts +++ b/packages/app/src/composer/submit.ts @@ -51,7 +51,7 @@ export async function submitAgentInput( return "queued"; } - // Clear immediately so the submitted timeline row and composer state stay in sync. + // Clear immediately so optimistic stream updates and composer state stay in sync. if (shouldClearOnSubmit) { input.setUserInput(""); input.setAttachments([]); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index de18f551f..14d36f183 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -40,12 +40,7 @@ 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 { - selectAgentTimelineState, - useSessionStore, - type MessageEntry, - type SessionState, -} from "@/stores/session-store"; +import { 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"; @@ -58,7 +53,6 @@ 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"; @@ -197,6 +191,13 @@ type WorkspaceSetupProgressPayload = Extract< type SessionStoreActions = ReturnType; 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, @@ -229,6 +230,75 @@ 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; @@ -249,6 +319,8 @@ function finalizeTimelineApplication(input: { serverId: string; shouldMarkAuthoritativeHistoryApplied: boolean; setInitializingAgents: SetInitializingAgents; + setAgentAuthoritativeHistoryApplied: SetAgentAuthoritativeHistoryApplied; + markAgentHistorySynchronized: MarkAgentHistorySynchronized; }): void { const { result, @@ -257,13 +329,17 @@ 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") { @@ -349,10 +425,14 @@ 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 applyAgentTimelineResponseState = useSessionStore( - (state) => state.applyAgentTimelineResponseState, + const markAgentHistorySynchronized = useSessionStore( + (state) => state.markAgentHistorySynchronized, + ); + const setAgentAuthoritativeHistoryApplied = useSessionStore( + (state) => state.setAgentAuthoritativeHistoryApplied, ); const setAgents = useSessionStore((state) => state.setAgents); const setWorkspaces = useSessionStore((state) => state.setWorkspaces); @@ -575,14 +655,18 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const isInitializing = session?.initializingAgents.get(agentId) === true; const activeInitDeferred = getInitDeferred(initKey); const hasActiveInitDeferred = Boolean(activeInitDeferred); - const timeline = selectAgentTimelineState(session, agentId); - const currentCursor = - timeline.status === "synced" ? (timeline.range ?? undefined) : undefined; - const currentTail = timeline.status === "cold" ? [] : timeline.items; + const currentCursor = session?.agentTimelineCursor.get(agentId); + const currentTail = session?.agentStreamTail.get(agentId) ?? []; const currentHead = session?.agentStreamHead.get(agentId) ?? []; - const sendingClientMessageIds = getSendingClientMessageIds( - session?.messageSubmissions.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; + }); // Call pure reducer const result = processTimelineResponse({ @@ -593,8 +677,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider isInitializing, hasActiveInitDeferred, initRequestDirection: activeInitDeferred?.requestDirection ?? "tail", - sendingClientMessageIds, - hasAuthoritativeBaseline: timeline.status === "synced", }); if (result.error) { @@ -608,13 +690,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider return; } - 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, + applyTimelineStreamPatches({ + result, + agentId, + serverId, + currentTail, + currentHead, + setAgentStreamTail, + setAgentStreamHead, + clearAgentStreamHead, + setAgentTimelineCursor, }); executeTimelineSideEffects({ @@ -630,9 +715,22 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider serverId, shouldMarkAuthoritativeHistoryApplied, setInitializingAgents, + setAgentAuthoritativeHistoryApplied, + markAgentHistorySynchronized, }); }, - [applyAgentTimelineResponseState, recoverTimelineGap, serverId, setInitializingAgents], + [ + clearAgentStreamHead, + markAgentHistorySynchronized, + recoverTimelineGap, + serverId, + setAgentAuthoritativeHistoryApplied, + setAgentStreamHead, + setAgentStreamTail, + setAgentTimelineCursor, + setAgentTimelineHasOlder, + setInitializingAgents, + ], ); useEffect(() => { @@ -643,20 +741,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const sync = createViewedTimelineSync({ initialDeliveryMode, setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds), - readCursor: (agentId) => { - const timeline = selectAgentTimelineState( - useSessionStore.getState().sessions[serverId], - agentId, - ); - return timeline.status === "synced" ? (timeline.range ?? undefined) : undefined; - }, + readCursor: (agentId) => + useSessionStore.getState().sessions[serverId]?.agentTimelineCursor.get(agentId), hasAuthoritativeHistory: (agentId) => - selectAgentTimelineState(useSessionStore.getState().sessions[serverId], agentId).status === - "synced", + useSessionStore + .getState() + .sessions[serverId]?.agentAuthoritativeHistoryApplied.get(agentId) === true, fetchPage: async (agentId, request) => { const session = useSessionStore.getState().sessions[serverId]; const initKey = getInitKey(serverId, agentId); - const shouldInitialize = selectAgentTimelineState(session, agentId).status !== "synced"; + const shouldInitialize = session?.agentAuthoritativeHistoryApplied.get(agentId) !== true; if (shouldInitialize) { if (!getInitDeferred(initKey)) { const deferred = createInitDeferred(initKey, request.direction ?? "tail"); @@ -714,6 +808,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider serverId, setAgentStreamState, setAgentTimelineCursor, + setAgents, recoverTimelineGap, }); @@ -730,6 +825,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider ) { voiceRuntime?.onTurnEvent(serverId, agentId, event.type); } + agentStreamReducerQueue.enqueue(agentId, { event: streamEvent, seq, diff --git a/packages/app/src/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts index e78388a90..52fb58e1e 100644 --- a/packages/app/src/hooks/use-agent-initialization.test.ts +++ b/packages/app/src/hooks/use-agent-initialization.test.ts @@ -111,47 +111,6 @@ 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(); diff --git a/packages/app/src/hooks/use-agent-initialization.ts b/packages/app/src/hooks/use-agent-initialization.ts index ade27abac..ae80d352e 100644 --- a/packages/app/src/hooks/use-agent-initialization.ts +++ b/packages/app/src/hooks/use-agent-initialization.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; +import { useSessionStore } from "@/stores/session-store"; import { createInitDeferred, getInitDeferred, @@ -52,9 +52,8 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput): } const session = useSessionStore.getState().sessions[serverId]; - const timeline = selectAgentTimelineState(session, agentId); - const cursor = timeline.status === "synced" ? (timeline.range ?? undefined) : undefined; - const hasAuthoritativeHistory = timeline.status === "synced"; + const cursor = session?.agentTimelineCursor.get(agentId); + const hasAuthoritativeHistory = session?.agentAuthoritativeHistoryApplied.get(agentId) === true; const timelineRequest = planInitialAgentTimelineSync({ cursor, hasAuthoritativeHistory }); const deferred = createInitDeferred(key, timelineRequest.direction); diff --git a/packages/app/src/hooks/use-load-older-agent-history.test.ts b/packages/app/src/hooks/use-load-older-agent-history.test.ts index 292b54ab8..28efe17fc 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.test.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.test.ts @@ -84,7 +84,7 @@ describe("loadOlderAgentHistory", () => { const client = createClient(); const inFlight = createInFlight(); - const started = await loadOlderAgentHistory(agentId, { + await loadOlderAgentHistory(agentId, { client, cursor: undefined, hasOlder: true, @@ -94,14 +94,13 @@ 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(); - const started = await loadOlderAgentHistory(agentId, { + await loadOlderAgentHistory(agentId, { client, cursor: someCursor, hasOlder: false, @@ -111,14 +110,13 @@ 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); - const started = await loadOlderAgentHistory(agentId, { + await loadOlderAgentHistory(agentId, { client, cursor: someCursor, hasOlder: true, @@ -128,14 +126,13 @@ 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(); - const started = await loadOlderAgentHistory(agentId, { + await loadOlderAgentHistory(agentId, { client, cursor: someCursor, hasOlder: true, @@ -155,7 +152,6 @@ 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 () => { diff --git a/packages/app/src/hooks/use-load-older-agent-history.ts b/packages/app/src/hooks/use-load-older-agent-history.ts index 2bc3f5c9d..1bf8b9557 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.ts @@ -2,11 +2,7 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import type { ToastApi } from "@/components/toast-host"; import { i18n } from "@/i18n/i18next"; -import { - selectAgentTimelineState, - useSessionStore, - type AgentTimelineCursorState, -} from "@/stores/session-store"; +import { useSessionStore, type AgentTimelineCursorState } from "@/stores/session-store"; import { planTimelineOlderFetch } from "@/timeline/timeline-sync-plan"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; @@ -40,14 +36,11 @@ export interface LoadOlderAgentHistoryDeps { export async function loadOlderAgentHistory( agentId: string, deps: LoadOlderAgentHistoryDeps, -): Promise { +): Promise { const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger, failedMessage } = deps; - if (isLoadingOlder) { - return true; - } - if (!client || !cursor || !hasOlder) { - return false; + if (!client || !cursor || !hasOlder || isLoadingOlder) { + return; } setInFlight(true); @@ -65,7 +58,6 @@ export async function loadOlderAgentHistory( } finally { setInFlight(false); } - return true; } export function useLoadOlderAgentHistory({ @@ -78,17 +70,15 @@ export function useLoadOlderAgentHistory({ toast?: ToastApi | null; }) { const { t } = useTranslation(); - const hasOlder = useSessionStore((state) => { - const timeline = selectAgentTimelineState(state.sessions[serverId], agentId); - return timeline.status === "synced" && timeline.older === "available"; - }); + const hasOlder = + useSessionStore((state) => state.sessions[serverId]?.agentTimelineHasOlder.get(agentId)) === + true; const isLoadingOlder = useSessionStore((state) => state.sessions[serverId]?.agentTimelineOlderFetchInFlight.get(agentId), ) === true; const progressKey = useSessionStore((state) => { - const timeline = selectAgentTimelineState(state.sessions[serverId], agentId); - const cursor = timeline.status === "synced" ? timeline.range : null; + const cursor = state.sessions[serverId]?.agentTimelineCursor.get(agentId); return cursor ? `${cursor.epoch}:${cursor.startSeq}` : null; }); const setOlderFetchInFlight = useSessionStore( @@ -109,18 +99,17 @@ export function useLoadOlderAgentHistory({ [agentId, serverId, setOlderFetchInFlight], ); - const loadOlder = useCallback(async (): Promise => { + const loadOlder = useCallback(() => { const session = useSessionStore.getState().sessions[serverId]; - const timeline = selectAgentTimelineState(session, agentId); - return await loadOlderAgentHistory(agentId, { + void loadOlderAgentHistory(agentId, { client: session?.client ? { fetchAgentTimeline: (timelineAgentId, request) => getHostRuntimeStore().fetchAgentTimeline(serverId, timelineAgentId, request), } : null, - cursor: timeline.status === "synced" ? (timeline.range ?? undefined) : undefined, - hasOlder: timeline.status === "synced" && timeline.older === "available", + cursor: session?.agentTimelineCursor.get(agentId), + hasOlder: session?.agentTimelineHasOlder.get(agentId) === true, isLoadingOlder: session?.agentTimelineOlderFetchInFlight.get(agentId) === true, setInFlight, toast, diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index dbc2ab46f..240f300db 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -25,7 +25,6 @@ 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 { @@ -72,7 +71,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 { selectAgentTimelineState, type Agent, useSessionStore } from "@/stores/session-store"; +import { 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"; @@ -443,7 +442,6 @@ export function useDraftPanelDescriptor( } const EMPTY_STREAM_ITEMS: StreamItem[] = []; -const EMPTY_MESSAGE_SUBMISSIONS = [] as const; const EMPTY_PENDING_PERMISSIONS = new Map(); const EMPTY_PENDING_PERMISSION_LIST: PendingPermission[] = []; @@ -792,12 +790,11 @@ function ChatAgentContent({ const historySyncGeneration = useSessionStore( (state) => state.sessions[serverId]?.historySyncGeneration ?? 0, ); - const replicaTimelineStatus = useSessionStore((state) => + const hasAppliedAuthoritativeHistory = useSessionStore((state) => agentId - ? selectAgentTimelineState(state.sessions[serverId], agentId).status - : ("cold" as const), + ? state.sessions[serverId]?.agentAuthoritativeHistoryApplied?.get(agentId) === true + : false, ); - const hasAppliedAuthoritativeHistory = replicaTimelineStatus === "synced"; const agentHistorySyncGeneration = useSessionStore((state) => agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1, ); @@ -833,8 +830,7 @@ function ChatAgentContent({ kind: "idle", }); - const hasHydratedHistoryBefore = - hasAppliedAuthoritativeHistory || replicaTimelineStatus === "painted"; + const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory; const attentionController = useAgentAttentionClear({ agentId, @@ -1292,11 +1288,6 @@ 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, @@ -1336,7 +1327,6 @@ const AgentStreamSection = memo(function AgentStreamSection({ routeBottomAnchorRequest={routeBottomAnchorRequest} isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory} toast={toast} - pendingMessageSubmissions={pendingMessageSubmissions} onOpenWorkspaceFile={onOpenWorkspaceFile} /> ); diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index 12558a2ed..a5bac3b7b 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -104,12 +104,10 @@ function ProviderSubagentPanel() { .catch(() => undefined); }, [client, serverId, supported, target.parentAgentId, target.subagentId]); - const loadOlder = useCallback((): boolean => { - if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) { - return false; - } + const loadOlder = useCallback(() => { + if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) return; const firstSeq = timeline.rows.size ? Math.min(...timeline.rows.keys()) : null; - if (firstSeq === null) return false; + if (firstSeq === null) return; setIsLoadingOlder(true); void client .fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, { @@ -123,7 +121,6 @@ function ProviderSubagentPanel() { }) .catch(() => undefined) .finally(() => setIsLoadingOlder(false)); - return true; }, [ client, isLoadingOlder, diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 6f73dd640..0f4946a4b 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -80,16 +80,13 @@ class FakeDaemonClient { this.setConnectionState({ status: "disconnected", reason: "client_closed" }); } - async sendAgentMessage( - ...args: Parameters - ): ReturnType { + async sendAgentMessage(...args: Parameters): Promise { 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 { @@ -2262,66 +2259,6 @@ 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(); - 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(); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index d77682a2f..144632817 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -49,9 +49,11 @@ import { } from "@/data/push-router"; import { mountBrowserAutomationDaemonClientHandler } from "@/browser-automation/handler"; import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules"; -import { dispatchComposerAgentMessage, sendQueuedComposerMessageNow } from "@/composer/actions"; -import { createMessageSubmissionWriter } from "@/composer/submission/writer"; -import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit"; +import { sendQueuedComposerMessageNow } from "@/composer/actions"; +import { + resolveComposerAttachmentSubmitFormat, + splitComposerAttachmentsForSubmit, +} from "@/composer/attachments/submit"; import { encodeImages } from "@/utils/encode-images"; import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync"; import { ReplicaCache } from "@/runtime/replica-cache"; @@ -2069,16 +2071,14 @@ export class HostRuntimeStore { submitMessage: async ({ text, attachments }) => { const supportsForgeAttachments = useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === true; - await dispatchComposerAgentMessage({ - client, - agentId, - text, - attachments, - attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({ - supportsForgeAttachments, - }), - encodeImages, - submission: createMessageSubmissionWriter(serverId), + 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, }); }, }) diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index 3ae4114d2..69800f03a 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -4,7 +4,6 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { normalizeEmptyProjectDescriptor, normalizeWorkspaceDescriptor, - selectAgentTimelineState, useSessionStore, } from "@/stores/session-store"; import type { StreamItem } from "@/types/stream"; @@ -167,14 +166,13 @@ 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).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?.agentAuthoritativeHistoryApplied.get("agent-1")).toBe(true); + expect(session?.agentTimelineCursor.get("agent-1")).toEqual({ + epoch: "epoch-1", + startSeq: 1, + endSeq: 12, }); + expect(session?.agentTimelineHasOlder.get("agent-1")).toBe(true); }); it("persists only the focused agent view with a short timeline tail", async () => { @@ -218,13 +216,6 @@ 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 | 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 () => { @@ -252,38 +243,14 @@ describe("ReplicaCache", () => { expect(Object.keys(useSessionStore.getState().sessions).sort()).toEqual(["host-a", "host-c"]); }); - it("rejects version 1 cache data and overwrites it on flush", async () => { + it("drops malformed or unknown cache versions", async () => { const storage = new MemoryStorage(); - 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, - }, - }, - ], - }), - ); + storage.values.set("@paseo:replica-cache", JSON.stringify({ version: 999, hosts: [] })); 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: [], - }); }); }); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 2cad88aec..7f8582884 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -11,7 +11,6 @@ import { import { normalizeEmptyProjectDescriptor, normalizeWorkspaceDescriptor, - selectAgentTimelineState, useSessionStore, type Agent, type SessionReplica, @@ -20,10 +19,9 @@ 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 = 2; +const CACHE_VERSION = 1; const PERSIST_DELAY_MS = 750; const MAX_TIMELINE_ITEMS = 50; const MAX_CACHE_BYTES = 1024 * 1024; @@ -38,6 +36,14 @@ 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({ @@ -134,6 +140,8 @@ function deserializeTimeline(stored: StoredHost["timeline"]): SessionReplica["ti return { agentId: stored.agentId, items: decoded, + cursor: stored.cursor, + hasOlder: stored.hasOlder, }; } @@ -362,29 +370,14 @@ export class ReplicaCache { (workspace) => workspace.workspaceDirectory === focusedAgent.cwd, )) : 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 items = focusedAgentId ? session.agentStreamTail.get(focusedAgentId) : undefined; 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 = { diff --git a/packages/app/src/stores/create-flow-store.ts b/packages/app/src/stores/create-flow-store.ts index 2056e42c0..5ca6b9532 100644 --- a/packages/app/src/stores/create-flow-store.ts +++ b/packages/app/src/stores/create-flow-store.ts @@ -109,10 +109,7 @@ export const useCreateFlowStore = create((set) => ({ set((state) => { const next = Object.fromEntries( Object.entries(state.pendingByDraftId).filter( - ([, pending]) => - pending.lifecycle !== "sent" || - pending.serverId !== serverId || - pending.agentId !== agentId, + ([, pending]) => pending.serverId !== serverId || pending.agentId !== agentId, ), ); if (Object.keys(next).length === Object.keys(state.pendingByDraftId).length) { diff --git a/packages/app/src/stores/draft-store/index.ts b/packages/app/src/stores/draft-store/index.ts index 528517041..c558d2fa0 100644 --- a/packages/app/src/stores/draft-store/index.ts +++ b/packages/app/src/stores/draft-store/index.ts @@ -8,7 +8,6 @@ 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"; @@ -139,9 +138,6 @@ async function runAttachmentGc(): Promise { 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)) { diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 369097d26..1588fd114 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -5,11 +5,9 @@ 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( @@ -55,54 +53,6 @@ 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 = [ diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 1d59abdfe..1d61867f2 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -5,21 +5,10 @@ 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"; @@ -336,6 +325,8 @@ export interface AgentTimelineCursorState { export interface SessionReplicaTimeline { agentId: string; items: StreamItem[]; + cursor: AgentTimelineCursorState | null; + hasOlder: boolean; } export interface SessionReplica { @@ -345,33 +336,6 @@ 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 @@ -404,7 +368,6 @@ export interface SessionState { // Stream state (head/tail model) agentStreamTail: Map; agentStreamHead: Map; - messageSubmissions: Map; agentTimelineCursor: Map; agentTimelineHasOlder: Map; agentTimelineOlderFetchInFlight: Map; @@ -496,28 +459,8 @@ interface SessionStoreActions { setAgentStreamState: ( serverId: string, agentId: string, - state: { - tail?: StreamItem[]; - head?: StreamItem[]; - acknowledgedClientMessageIds?: readonly string[]; - }, + state: { tail?: StreamItem[]; head?: StreamItem[] }, ) => 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, @@ -545,18 +488,6 @@ 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: ( @@ -636,27 +567,6 @@ type SessionStore = SessionStoreState & SessionStoreActions; const agentLastActivityCoalescer = createAgentLastActivityCoalescer(); -function applyRunningAgentsToAcceptedSubmissions(input: { - previousAgents: Map; - nextAgents: Map; - submissions: Map; -}): Map { - 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, @@ -678,7 +588,6 @@ function createInitialSessionState( currentAssistantMessage: "", agentStreamTail: new Map(), agentStreamHead: new Map(), - messageSubmissions: new Map(), agentTimelineCursor: new Map(), agentTimelineHasOlder: new Map(), agentTimelineOlderFetchInFlight: new Map(), @@ -795,8 +704,16 @@ export const useSessionStore = create()( const session = createInitialSessionState(serverId, null); const timeline = replica.timeline; const agentStreamTail = new Map(); + const agentTimelineCursor = new Map(); + const agentTimelineHasOlder = new Map(); + const agentAuthoritativeHistoryApplied = new Map(); + const agentHistorySyncGeneration = new Map(); 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()) { @@ -813,6 +730,10 @@ export const useSessionStore = create()( workspaces: replica.workspaces, emptyProjects: replica.emptyProjects, agentStreamTail, + agentTimelineCursor, + agentTimelineHasOlder, + agentAuthoritativeHistoryApplied, + agentHistorySyncGeneration, }, }, agentLastActivity, @@ -1126,27 +1047,10 @@ export const useSessionStore = create()( } } - const currentSubmissions = session.messageSubmissions.get(agentId) ?? []; - const observedSubmissions = observeMessageSubmissionCanonical( - currentSubmissions, - state.acknowledgedClientMessageIds ?? [], - ); - const changedSubmissions = observedSubmissions !== currentSubmissions; - - if (!changedTail && !changedHead && !changedSubmissions) { + if (!changedTail && !changedHead) { 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: { @@ -1155,129 +1059,12 @@ export const useSessionStore = create()( ...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) => { @@ -1482,61 +1269,6 @@ export const useSessionStore = create()( }); }, - 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) => { @@ -1566,12 +1298,7 @@ export const useSessionStore = create()( return prev; } const nextAgents = typeof agents === "function" ? agents(session.agents) : agents; - const messageSubmissions = applyRunningAgentsToAcceptedSubmissions({ - previousAgents: session.agents, - nextAgents, - submissions: session.messageSubmissions, - }); - if (session.agents === nextAgents && session.messageSubmissions === messageSubmissions) { + if (session.agents === nextAgents) { return prev; } return { @@ -1581,11 +1308,10 @@ export const useSessionStore = create()( [serverId]: { ...session, agents: nextAgents, - messageSubmissions, - workspaceAgentActivity: - nextAgents === session.agents - ? session.workspaceAgentActivity - : buildWorkspaceAgentActivityIndex(nextAgents, session.workspaceAgentActivity), + workspaceAgentActivity: buildWorkspaceAgentActivityIndex( + nextAgents, + session.workspaceAgentActivity, + ), }, }, }; diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index 79c392a24..491da6763 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; import { - createUserMessage, + buildOptimisticUserMessage, hydrateStreamState, type AgentToolCallItem, type StreamItem, @@ -119,12 +119,12 @@ function makeAssistantItem( }; } -function makeSubmittedUserMessage( +function makeOptimisticUserMessage( text: string, - id = `submitted-${text.length}`, + id = `optimistic-${text.length}`, ): Extract { - return createUserMessage({ - clientMessageId: id, + return buildOptimisticUserMessage({ + id, text, timestamp: new Date(1000), }); @@ -172,8 +172,6 @@ const baseTimelineInput: ProcessTimelineResponseInput = { isInitializing: false, hasActiveInitDeferred: false, initRequestDirection: "tail", - sendingClientMessageIds: [], - hasAuthoritativeBaseline: true, }; const baseStreamInput: ProcessAgentStreamEventInput = { @@ -183,6 +181,7 @@ const baseStreamInput: ProcessAgentStreamEventInput = { currentTail: [], currentHead: [], currentCursor: undefined, + currentAgent: null, timestamp: new Date(2000), }; @@ -294,511 +293,6 @@ describe("processTimelineResponse", () => { expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true); }); - it("keeps a live assistant and submitted head prompt in one lane during replacement", () => { - const submitted = makeSubmittedUserMessage("New prompt", "client-new-prompt"); - const liveAssistant = { - ...makeAssistantItem("Live answer", "answer-1"), - messageId: "answer-1", - }; - - const result = processTimelineResponse({ - ...baseTimelineInput, - currentTail: [], - currentHead: [liveAssistant, submitted], - currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, - sendingClientMessageIds: ["client-new-prompt"], - payload: { - ...baseTimelineInput.payload, - reset: true, - epoch: "epoch-1", - startCursor: { seq: 1 }, - endCursor: { seq: 1 }, - entries: [ - { - ...makeTimelineEntry(1, "Live", "assistant_message"), - item: { - type: "assistant_message", - text: "Live", - messageId: "answer-1", - }, - }, - ], - }, - }); - - expect(result.tail).toEqual([]); - expect(result.head).toEqual([{ ...liveAssistant, text: "Live" }, submitted]); - }); - - it("preserves newer live head items when canonical replacement ends in a tool call", () => { - const liveThought: StreamItem = { - kind: "thought", - id: "live-thought", - text: "newer reasoning", - timestamp: new Date(3000), - status: "loading", - }; - const liveAssistant = makeAssistantItem("newer answer", "live-answer"); - - const result = processTimelineResponse({ - ...baseTimelineInput, - currentHead: [liveThought, liveAssistant], - currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, - payload: { - ...baseTimelineInput.payload, - reset: true, - epoch: "epoch-1", - startCursor: { seq: 1 }, - endCursor: { seq: 1 }, - entries: [ - makeToolCallTimelineEntry(1, "canonical-call", "completed", { - type: "read", - filePath: "/tmp/older.ts", - }), - ], - }, - }); - - expect(result.tail.map((item) => item.kind)).toEqual(["tool_call"]); - expect(result.head).toEqual([liveThought, liveAssistant]); - }); - - it("keeps a newer live tool completion when bootstrap contains the running call", () => { - const callId = "toolu_live_completion"; - const liveCompletion = hydrateStreamState( - [ - { - event: { - type: "timeline", - provider: "claude", - item: makeToolCallTimelineEntry(2, callId, "completed", { - type: "read", - filePath: "/tmp/example.ts", - }).item, - } as AgentStreamEventPayload, - timestamp: new Date(2000), - timelineCursor: { epoch: "epoch-1", seq: 2 }, - }, - ], - { source: "canonical" }, - ); - - const result = processTimelineResponse({ - ...baseTimelineInput, - currentHead: liveCompletion, - hasAuthoritativeBaseline: false, - isInitializing: true, - hasActiveInitDeferred: true, - payload: { - ...baseTimelineInput.payload, - direction: "tail", - reset: false, - epoch: "epoch-1", - startCursor: { seq: 1 }, - endCursor: { seq: 1 }, - entries: [ - makeToolCallTimelineEntry(1, callId, "running", { - type: "unknown", - input: { file_path: "/tmp/example.ts" }, - output: null, - }), - ], - }, - }); - - expect(getAgentToolCalls([...result.tail, ...result.head])).toEqual([ - expect.objectContaining({ - timelineCursor: { epoch: "epoch-1", seq: 2 }, - payload: expect.objectContaining({ - data: expect.objectContaining({ callId, status: "completed" }), - }), - }), - ]); - }); - - it("keeps one newer todo state when bootstrap contains its older state", () => { - const liveTodo = hydrateStreamState( - [ - { - event: { - type: "timeline", - provider: "codex", - item: { - type: "todo", - items: [{ text: "Verify hydration", completed: true }], - }, - } as AgentStreamEventPayload, - timestamp: new Date(2000), - timelineCursor: { epoch: "epoch-1", seq: 2 }, - }, - ], - { source: "canonical" }, - ); - - const result = processTimelineResponse({ - ...baseTimelineInput, - currentHead: liveTodo, - hasAuthoritativeBaseline: false, - isInitializing: true, - hasActiveInitDeferred: true, - payload: { - ...baseTimelineInput.payload, - direction: "tail", - reset: false, - epoch: "epoch-1", - startCursor: { seq: 1 }, - endCursor: { seq: 1 }, - entries: [ - { - seqStart: 1, - seqEnd: 1, - provider: "codex", - item: { - type: "todo", - items: [{ text: "Verify hydration", completed: false }], - }, - timestamp: new Date(1000).toISOString(), - }, - ], - }, - }); - - expect([...result.tail, ...result.head].filter((item) => item.kind === "todo_list")).toEqual([ - expect.objectContaining({ - timelineCursor: { epoch: "epoch-1", seq: 2 }, - items: [{ text: "Verify hydration", completed: true }], - }), - ]); - }); - - it("keeps one completed compaction when bootstrap contains its loading state", () => { - const liveCompaction = hydrateStreamState( - [ - { - event: { - type: "timeline", - provider: "codex", - item: { type: "compaction", status: "completed", trigger: "auto", preTokens: 1200 }, - } as AgentStreamEventPayload, - timestamp: new Date(2000), - timelineCursor: { epoch: "epoch-1", seq: 2 }, - }, - ], - { source: "canonical" }, - ); - - const result = processTimelineResponse({ - ...baseTimelineInput, - currentHead: liveCompaction, - hasAuthoritativeBaseline: false, - isInitializing: true, - hasActiveInitDeferred: true, - payload: { - ...baseTimelineInput.payload, - direction: "tail", - reset: false, - epoch: "epoch-1", - startCursor: { seq: 1 }, - endCursor: { seq: 1 }, - entries: [ - { - seqStart: 1, - seqEnd: 1, - provider: "codex", - item: { type: "compaction", status: "loading", trigger: "auto" }, - timestamp: new Date(1000).toISOString(), - }, - ], - }, - }); - - expect([...result.tail, ...result.head].filter((item) => item.kind === "compaction")).toEqual([ - expect.objectContaining({ - timelineCursor: { epoch: "epoch-1", seq: 2 }, - status: "completed", - trigger: "auto", - preTokens: 1200, - }), - ]); - }); - - it("replaces a painted replica with an empty authoritative timeline", () => { - const result = processTimelineResponse({ - ...baseTimelineInput, - currentTail: [makeAssistantItem("painted replica")], - isInitializing: true, - hasActiveInitDeferred: true, - hasAuthoritativeBaseline: false, - payload: { - ...baseTimelineInput.payload, - direction: "tail", - startCursor: null, - endCursor: null, - entries: [], - hasOlder: false, - }, - }); - - expect(result.tail).toEqual([]); - expect(result.cursor).toBeNull(); - }); - - it("keeps a newer live assistant continuation when bootstrap ends on the same message", () => { - const result = processTimelineResponse({ - ...baseTimelineInput, - currentTail: [makeAssistantItem("painted replica")], - currentHead: [ - { - kind: "assistant_message", - id: "assistant-live", - messageId: "assistant-live", - text: " continuation", - timestamp: new Date(2001), - timelineCursor: { epoch: "epoch-1", seq: 101 }, - }, - ], - isInitializing: true, - hasActiveInitDeferred: true, - hasAuthoritativeBaseline: false, - payload: { - ...baseTimelineInput.payload, - direction: "tail", - startCursor: { seq: 61 }, - endCursor: { seq: 100 }, - entries: [ - { - ...makeTimelineEntry(100, "canonical prefix"), - item: { - type: "assistant_message", - text: "canonical prefix", - messageId: "assistant-live", - }, - }, - ], - }, - }); - - const assistants = [...result.tail, ...result.head].filter( - (item) => item.kind === "assistant_message", - ); - expect(assistants).toHaveLength(1); - expect(assistants[0]?.text).toBe("canonical prefix continuation"); - expect(assistants[0]?.timelineCursor).toEqual({ epoch: "epoch-1", seq: 101 }); - }); - - it("keeps assistant segments separate when a live tool sits between them", () => { - const messageId = "assistant-with-tool"; - const liveTool = hydrateStreamState( - [ - { - event: { - type: "timeline", - provider: "claude", - item: makeToolCallTimelineEntry(101, "tool-between-segments", "completed", { - type: "read", - filePath: "/tmp/example.ts", - }).item, - } as AgentStreamEventPayload, - timestamp: new Date(2101), - timelineCursor: { epoch: "epoch-1", seq: 101 }, - }, - { - event: makeAssistantTimelineEvent("after tool", messageId), - timestamp: new Date(2102), - timelineCursor: { epoch: "epoch-1", seq: 102 }, - }, - ], - { source: "canonical" }, - ); - - const result = processTimelineResponse({ - ...baseTimelineInput, - currentTail: [makeAssistantItem("painted replica")], - currentHead: liveTool, - isInitializing: true, - hasActiveInitDeferred: true, - hasAuthoritativeBaseline: false, - payload: { - ...baseTimelineInput.payload, - direction: "tail", - startCursor: { seq: 61 }, - endCursor: { seq: 100 }, - entries: [ - { - ...makeTimelineEntry(100, "before tool"), - item: { - type: "assistant_message", - text: "before tool", - messageId, - }, - }, - ], - }, - }); - - expect([...result.tail, ...result.head].map((item) => item.kind)).toEqual([ - "assistant_message", - "tool_call", - "assistant_message", - ]); - expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([ - "before tool", - "after tool", - ]); - }); - - it("keeps a newer live assistant continuation without a provider message ID", () => { - const result = processTimelineResponse({ - ...baseTimelineInput, - currentTail: [makeAssistantItem("painted replica")], - currentHead: [ - { - kind: "assistant_message", - id: "assistant-live", - text: "canonical prefix continuation", - timestamp: new Date(2001), - timelineCursor: { epoch: "epoch-1", seq: 101 }, - }, - ], - isInitializing: true, - hasActiveInitDeferred: true, - hasAuthoritativeBaseline: false, - payload: { - ...baseTimelineInput.payload, - direction: "tail", - startCursor: { seq: 61 }, - endCursor: { seq: 100 }, - entries: [makeTimelineEntry(100, "canonical prefix")], - }, - }); - - const assistants = [...result.tail, ...result.head].filter( - (item) => item.kind === "assistant_message", - ); - expect(assistants).toHaveLength(1); - expect(assistants[0]?.text).toBe("canonical prefix continuation"); - expect(assistants[0]?.timelineCursor).toEqual({ epoch: "epoch-1", seq: 101 }); - }); - - it("keeps a canonical live user row newer than the bootstrap page", () => { - const live = processAgentStreamEvent({ - ...baseStreamInput, - event: { - type: "timeline", - provider: "claude", - item: { - type: "user_message", - text: "remote live prompt", - clientMessageId: "remote-client-message", - }, - }, - seq: 101, - epoch: "epoch-1", - currentTail: [makeAssistantItem("painted replica")], - hasAuthoritativeBaseline: false, - }); - expect(live.head[0]).toMatchObject({ - kind: "user_message", - timelineCursor: { epoch: "epoch-1", seq: 101 }, - }); - - const result = processTimelineResponse({ - ...baseTimelineInput, - currentTail: live.tail, - currentHead: live.head, - isInitializing: true, - hasActiveInitDeferred: true, - hasAuthoritativeBaseline: false, - payload: { - ...baseTimelineInput.payload, - direction: "tail", - startCursor: { seq: 61 }, - endCursor: { seq: 100 }, - entries: [makeTimelineEntry(100, "canonical answer")], - }, - }); - - expect(getUserTexts(result.head)).toEqual(["remote live prompt"]); - }); - - it("keeps a provider-acknowledged painted-tail prompt newer than the bootstrap page", () => { - const submitted = makeSubmittedUserMessage("submitted before bootstrap", "client-message-1"); - const live = processAgentStreamEvent({ - ...baseStreamInput, - event: { - type: "timeline", - provider: "claude", - item: { - type: "user_message", - text: "canonical presentation", - clientMessageId: "client-message-1", - messageId: "provider-message-1", - }, - }, - seq: 51, - epoch: "epoch-1", - currentTail: [makeAssistantItem("painted replica"), submitted], - currentCursor: undefined, - hasAuthoritativeBaseline: false, - }); - - const result = processTimelineResponse({ - ...baseTimelineInput, - currentTail: live.tail, - currentHead: live.head, - sendingClientMessageIds: [], - isInitializing: true, - hasActiveInitDeferred: true, - hasAuthoritativeBaseline: false, - payload: { - ...baseTimelineInput.payload, - direction: "tail", - startCursor: { seq: 11 }, - endCursor: { seq: 50 }, - entries: [makeTimelineEntry(50, "canonical answer")], - }, - }); - - expect(getUserTexts([...result.tail, ...result.head])).toEqual(["submitted before bootstrap"]); - expect(result.head[0]).toMatchObject({ - kind: "user_message", - clientMessageId: "client-message-1", - messageId: "provider-message-1", - timelineCursor: { epoch: "epoch-1", seq: 51 }, - }); - }); - - it("does not duplicate a live row already covered by the bootstrap page", () => { - const live = processAgentStreamEvent({ - ...baseStreamInput, - event: makeTimelineEvent("thinking", "reasoning"), - seq: 100, - epoch: "epoch-1", - currentTail: [makeAssistantItem("painted replica")], - hasAuthoritativeBaseline: false, - }); - - const result = processTimelineResponse({ - ...baseTimelineInput, - currentTail: live.tail, - currentHead: live.head, - isInitializing: true, - hasActiveInitDeferred: true, - hasAuthoritativeBaseline: false, - payload: { - ...baseTimelineInput.payload, - direction: "tail", - startCursor: { seq: 61 }, - endCursor: { seq: 100 }, - entries: [makeTimelineEntry(100, "thinking", "reasoning")], - }, - }); - - expect([...result.tail, ...result.head].filter((item) => item.kind === "thought")).toHaveLength( - 1, - ); - }); - it("uses the timeline entry timestamp as canonical", () => { const result = processTimelineResponse({ ...baseTimelineInput, @@ -827,12 +321,12 @@ describe("processTimelineResponse", () => { expect(assistant?.timestamp.toISOString()).toBe("2025-01-01T12:00:04.000Z"); }); - it("reconciles a submitted user message during tail replacement", () => { + it("reconciles an optimistic user message during tail replacement", () => { const image = { - id: "submitted-image", + id: "optimistic-image", mimeType: "image/png", storageType: "web-indexeddb" as const, - storageKey: "submitted-image", + storageKey: "optimistic-image", createdAt: 1000, }; const attachment = { @@ -841,8 +335,8 @@ describe("processTimelineResponse", () => { text: "attached context", title: "context.txt", }; - const submitted = createUserMessage({ - clientMessageId: "submitted-create-user", + const optimistic = buildOptimisticUserMessage({ + id: "optimistic-create-user", text: "Analyze this", timestamp: new Date(1000), images: [image], @@ -851,7 +345,7 @@ describe("processTimelineResponse", () => { const result = processTimelineResponse({ ...baseTimelineInput, - currentTail: [submitted], + currentTail: [optimistic], payload: { ...baseTimelineInput.payload, reset: true, @@ -864,7 +358,6 @@ describe("processTimelineResponse", () => { type: "user_message", text: "server-rendered attachment text", messageId: "canonical-create-user", - clientMessageId: "submitted-create-user", }, }, ], @@ -874,14 +367,13 @@ describe("processTimelineResponse", () => { const userMessages = result.tail.filter((item) => item.kind === "user_message"); expect(userMessages).toHaveLength(1); expect(userMessages[0]).toMatchObject({ - id: "submitted-create-user", - clientMessageId: "submitted-create-user", - messageId: "canonical-create-user", + id: "canonical-create-user", text: "Analyze this", timestamp: new Date(1000), images: [image], attachments: [attachment], }); + expect(userMessages[0]?.optimistic).toBeUndefined(); const repeated = processTimelineResponse({ ...baseTimelineInput, @@ -898,7 +390,6 @@ describe("processTimelineResponse", () => { type: "user_message", text: "server-rendered attachment text", messageId: "canonical-create-user", - clientMessageId: "submitted-create-user", }, }, ], @@ -908,93 +399,24 @@ describe("processTimelineResponse", () => { expect(repeated.tail.filter((item) => item.kind === "user_message")).toEqual(userMessages); }); - it("keeps an unmatched submitted user message during tail replacement", () => { - const submitted = makeSubmittedUserMessage("still sending", "submitted-unmatched"); + it("keeps an unmatched optimistic user message during tail replacement", () => { + const optimistic = makeOptimisticUserMessage("still sending", "optimistic-unmatched"); const result = processTimelineResponse({ ...baseTimelineInput, - currentTail: [submitted], - currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, - sendingClientMessageIds: ["submitted-unmatched"], + currentTail: [optimistic], payload: { ...baseTimelineInput.payload, reset: true, - epoch: "epoch-2", entries: [], }, }); - expect(result.tail).toEqual([submitted]); + expect(result.tail).toEqual([optimistic]); }); - it("keeps every unresolved submission during replacement", () => { - const first = makeSubmittedUserMessage("first pending", "client-first"); - const second = makeSubmittedUserMessage("second pending", "client-second"); - - const result = processTimelineResponse({ - ...baseTimelineInput, - currentTail: [first, second], - currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, - sendingClientMessageIds: ["client-first", "client-second"], - payload: { - ...baseTimelineInput.payload, - reset: true, - epoch: "epoch-2", - entries: [], - }, - }); - - expect(result.tail).toEqual([first, second]); - }); - - it("drops an acknowledged local row omitted by a same-epoch replacement", () => { - const acknowledged = createUserMessage({ - clientMessageId: "client-local-only", - text: "provider may not echo this", - timestamp: new Date(1000), - }); - - const result = processTimelineResponse({ - ...baseTimelineInput, - currentTail: [acknowledged], - currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, - sendingClientMessageIds: [], - payload: { - ...baseTimelineInput.payload, - reset: true, - epoch: "epoch-1", - entries: [], - }, - }); - - expect(result.tail).toEqual([]); - }); - - it("drops an acknowledged local row omitted by a known epoch change", () => { - const acknowledged = createUserMessage({ - clientMessageId: "client-prior-epoch", - text: "prior prompt", - timestamp: new Date(1000), - }); - - const result = processTimelineResponse({ - ...baseTimelineInput, - currentTail: [acknowledged], - currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, - sendingClientMessageIds: [], - payload: { - ...baseTimelineInput.payload, - reset: true, - epoch: "epoch-2", - entries: [], - }, - }); - - expect(result.tail).toEqual([]); - }); - - it("keeps an unmatched submission after the canonical replacement range", () => { - const unmatched = makeSubmittedUserMessage("first submission", "client-first"); + it("does not move an unmatched submission during timeline replacement", () => { + const unmatched = makeOptimisticUserMessage("first submission", "client-first"); const acknowledged: StreamItem[] = [ { kind: "user_message", @@ -1015,7 +437,6 @@ describe("processTimelineResponse", () => { const result = processTimelineResponse({ ...baseTimelineInput, currentTail: [unmatched, ...acknowledged], - sendingClientMessageIds: ["client-first"], payload: { ...baseTimelineInput.payload, reset: true, @@ -1041,10 +462,10 @@ describe("processTimelineResponse", () => { }, }, { - ...makeTimelineEntry(4, "response to canonical submissions"), + ...makeTimelineEntry(4, "response to all three submissions"), item: { type: "assistant_message", - text: "response to canonical submissions", + text: "response to all three submissions", messageId: "assistant-response", }, }, @@ -1059,14 +480,14 @@ describe("processTimelineResponse", () => { text: "text" in item ? item.text : undefined, })), ).toEqual([ + { kind: "user_message", id: "client-first", text: "first submission" }, { kind: "user_message", id: "provider-second", text: "second submission" }, { kind: "user_message", id: "provider-third", text: "third submission" }, { kind: "assistant_message", id: "assistant-response", - text: "response to canonical submissions", + text: "response to all three submissions", }, - { kind: "user_message", id: "client-first", text: "first submission" }, ]); }); @@ -1199,11 +620,11 @@ describe("processTimelineResponse", () => { startSeq: 1, endSeq: 1, }; - const submitted = makeSubmittedUserMessage("sent while catching up", "submitted-after"); + const optimistic = makeOptimisticUserMessage("sent while catching up", "optimistic-after"); const result = processTimelineResponse({ ...baseTimelineInput, - currentTail: [submitted], + currentTail: [optimistic], currentCursor: existingCursor, payload: { ...baseTimelineInput.payload, @@ -1223,19 +644,16 @@ describe("processTimelineResponse", () => { const userMessages = result.tail.filter((item) => item.kind === "user_message"); expect(userMessages).toHaveLength(1); - expect(userMessages[0]).toMatchObject({ - id: "submitted-after", - clientMessageId: "submitted-after", - messageId: "canonical-after", - }); + expect(userMessages[0]?.id).toBe("canonical-after"); + expect(userMessages[0]?.optimistic).toBeUndefined(); }); - it("reconciles a submitted user message by client message id", () => { - const submitted = makeSubmittedUserMessage("local presentation", "client-message"); + it("reconciles an optimistic user message by client message id", () => { + const optimistic = makeOptimisticUserMessage("local presentation", "client-message"); const result = processTimelineResponse({ ...baseTimelineInput, - currentTail: [submitted], + currentTail: [optimistic], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, payload: { ...baseTimelineInput.payload, @@ -1257,21 +675,20 @@ describe("processTimelineResponse", () => { const userMessages = result.tail.filter((item) => item.kind === "user_message"); expect(userMessages).toEqual([ expect.objectContaining({ - id: "client-message", + id: "provider-message", clientMessageId: "client-message", - messageId: "provider-message", text: "local presentation", }), ]); - expect(result.acknowledgedClientMessageIds).toEqual(["client-message"]); + expect(userMessages[0]?.optimistic).toBeUndefined(); }); - it("reconciles multiple submitted user messages in canonical order", () => { + it("reconciles multiple optimistic user messages in canonical order", () => { const result = processTimelineResponse({ ...baseTimelineInput, currentTail: [ - makeSubmittedUserMessage("first prompt", "submitted-first"), - makeSubmittedUserMessage("second prompt", "submitted-second"), + makeOptimisticUserMessage("first prompt", "optimistic-first"), + makeOptimisticUserMessage("second prompt", "optimistic-second"), ], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, payload: { @@ -1282,14 +699,14 @@ describe("processTimelineResponse", () => { entries: [ { ...makeTimelineEntry(2, "first prompt", "user_message"), - item: { type: "user_message", text: "first prompt", messageId: "submitted-first" }, + item: { type: "user_message", text: "first prompt", messageId: "optimistic-first" }, }, { ...makeTimelineEntry(3, "second prompt", "user_message"), item: { type: "user_message", text: "second prompt", - messageId: "submitted-second", + messageId: "optimistic-second", }, }, ], @@ -1299,15 +716,15 @@ describe("processTimelineResponse", () => { expect( result.tail .filter((item) => item.kind === "user_message") - .map((item) => ({ id: item.id, text: item.text, messageId: item.messageId })), + .map((item) => ({ id: item.id, text: item.text, optimistic: item.optimistic })), ).toEqual([ - { id: "submitted-first", text: "first prompt", messageId: "submitted-first" }, - { id: "submitted-second", text: "second prompt", messageId: "submitted-second" }, + { id: "optimistic-first", text: "first prompt", optimistic: undefined }, + { id: "optimistic-second", text: "second prompt", optimistic: undefined }, ]); }); - it("keeps a tail submitted prompt before a reconciled live assistant head", () => { - const prompt = makeSubmittedUserMessage("new prompt", "submitted-new-prompt"); + it("keeps a tail optimistic prompt before a reconciled live assistant head", () => { + const prompt = makeOptimisticUserMessage("new prompt", "optimistic-new-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1346,8 +763,8 @@ describe("processTimelineResponse", () => { ).toEqual(["new prompt", "Hello"]); }); - it("keeps a tail submitted prompt before a live head flushed by catch-up", () => { - const prompt = makeSubmittedUserMessage("new prompt", "submitted-new-prompt"); + it("keeps a tail optimistic prompt before a live head flushed by catch-up", () => { + const prompt = makeOptimisticUserMessage("new prompt", "optimistic-new-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1418,6 +835,7 @@ describe("processTimelineResponse", () => { currentTail: [], currentHead: [], currentCursor: undefined, + currentAgent: null, }); const result = processTimelineResponse({ @@ -1495,6 +913,7 @@ describe("processTimelineResponse", () => { currentTail: [], currentHead: [], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + currentAgent: null, }); expect(getAssistantTexts(live.tail)).toHaveLength(1); expect(getAssistantTexts(live.head)).toHaveLength(1); @@ -1542,6 +961,7 @@ describe("processTimelineResponse", () => { currentTail: [], currentHead: [], currentCursor: undefined, + currentAgent: null, }); const result = processTimelineResponse({ @@ -1580,7 +1000,7 @@ describe("processTimelineResponse", () => { }); it("does not move a submitted prompt when catch-up history arrives", () => { - const prompt = makeSubmittedUserMessage("New prompt", "new-prompt"); + const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1604,7 +1024,7 @@ describe("processTimelineResponse", () => { }); it("does not move an unmatched head prompt when catch-up history arrives", () => { - const prompt = makeSubmittedUserMessage("New prompt", "new-prompt"); + const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1632,8 +1052,8 @@ describe("processTimelineResponse", () => { ]); }); - it("moves an acknowledged head prompt to its catch-up sequence position", () => { - const prompt = makeSubmittedUserMessage("New prompt", "new-prompt"); + it("acknowledges a head prompt in place while catch-up history arrives", () => { + const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1664,18 +1084,18 @@ describe("processTimelineResponse", () => { expect([...result.tail, ...result.head].map((item) => item.kind)).toEqual([ "assistant_message", - "tool_call", "user_message", + "tool_call", ]); expect( [...result.tail, ...result.head] .filter((item) => item.kind === "user_message") - .map((item) => item.clientMessageId), - ).toEqual(["new-prompt"]); + .map((item) => item.optimistic), + ).toEqual([undefined]); }); it("does not move a prompt around unrelated catch-up history", () => { - const prompt = makeSubmittedUserMessage("New prompt", "new-prompt"); + const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1721,7 +1141,7 @@ describe("processTimelineResponse", () => { }); it("does not move a prompt or its live answer around catch-up history", () => { - const prompt = makeSubmittedUserMessage("New prompt", "new-prompt"); + const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); const live = processAgentStreamEvents({ events: [ makeStreamReducerEvent( @@ -1732,6 +1152,7 @@ describe("processTimelineResponse", () => { currentTail: [prompt], currentHead: [], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + currentAgent: null, }); const result = processTimelineResponse({ @@ -1765,7 +1186,7 @@ describe("processTimelineResponse", () => { }); it("does not move a prompt or its live answer around catch-up tool history", () => { - const prompt = makeSubmittedUserMessage("New prompt", "new-prompt"); + const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); const live = processAgentStreamEvents({ events: [ makeStreamReducerEvent( @@ -1776,6 +1197,7 @@ describe("processTimelineResponse", () => { currentTail: [prompt], currentHead: [], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + currentAgent: null, }); const result = processTimelineResponse({ @@ -1806,7 +1228,7 @@ describe("processTimelineResponse", () => { }); it("never moves submitted messages behind a later assistant response", () => { - const unmatched = makeSubmittedUserMessage("first submission", "client-first"); + const unmatched = makeOptimisticUserMessage("first submission", "client-first"); const acknowledged: StreamItem[] = [ { kind: "user_message", @@ -1864,8 +1286,8 @@ describe("processTimelineResponse", () => { ]); }); - it("places a local prompt after an earlier remote canonical row", () => { - const prompt = makeSubmittedUserMessage("Local prompt", "local-prompt"); + it("acknowledges a local prompt in place when a remote user row also arrives", () => { + const prompt = makeOptimisticUserMessage("Local prompt", "local-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1898,12 +1320,17 @@ describe("processTimelineResponse", () => { }); expect( - result.tail.filter((item) => item.kind === "user_message").map((item) => item.text), - ).toEqual(["Remote prompt", "Local prompt"]); + result.tail + .filter((item) => item.kind === "user_message") + .map((item) => ({ text: item.text, optimistic: item.optimistic })), + ).toEqual([ + { text: "Local prompt", optimistic: undefined }, + { text: "Remote prompt", optimistic: undefined }, + ]); }); - it("keeps an unmatched submitted prompt when catch-up contains only a remote user row", () => { - const prompt = makeSubmittedUserMessage("Local prompt", "local-prompt"); + it("keeps an unmatched optimistic prompt when catch-up contains only a remote user row", () => { + const prompt = makeOptimisticUserMessage("Local prompt", "local-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1928,12 +1355,17 @@ describe("processTimelineResponse", () => { }); expect( - result.tail.filter((item) => item.kind === "user_message").map((item) => item.text), - ).toEqual(["Local prompt", "Remote prompt"]); + result.tail + .filter((item) => item.kind === "user_message") + .map((item) => ({ text: item.text, optimistic: item.optimistic })), + ).toEqual([ + { text: "Local prompt", optimistic: true }, + { text: "Remote prompt", optimistic: undefined }, + ]); }); it("does not match equal prompt text when canonical client message ids differ", () => { - const prompt = makeSubmittedUserMessage("continue", "local-prompt"); + const prompt = makeOptimisticUserMessage("continue", "local-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1961,10 +1393,10 @@ describe("processTimelineResponse", () => { expect( result.tail .filter((item) => item.kind === "user_message") - .map((item) => ({ id: item.id, messageId: item.messageId })), + .map((item) => ({ id: item.id, optimistic: item.optimistic })), ).toEqual([ - { id: "local-prompt", messageId: undefined }, - { id: "remote-prompt", messageId: "remote-prompt" }, + { id: "local-prompt", optimistic: true }, + { id: "remote-prompt", optimistic: undefined }, ]); }); @@ -2207,8 +1639,8 @@ describe("processTimelineResponse", () => { }); }); - it("does not reconcile an active submitted user message from a before-page response", () => { - const submitted = makeSubmittedUserMessage("active prompt", "submitted-active"); + it("does not reconcile an active optimistic user message from a before-page response", () => { + const optimistic = makeOptimisticUserMessage("active prompt", "optimistic-active"); const existingCursor: TimelineCursor = { epoch: "epoch-1", startSeq: 3, @@ -2217,7 +1649,7 @@ describe("processTimelineResponse", () => { const result = processTimelineResponse({ ...baseTimelineInput, - currentTail: [submitted], + currentTail: [optimistic], currentCursor: existingCursor, payload: { ...baseTimelineInput.payload, @@ -2240,8 +1672,8 @@ describe("processTimelineResponse", () => { const userMessages = result.tail.filter((item) => item.kind === "user_message"); expect(userMessages).toHaveLength(2); - expect(userMessages.map((item) => item.id)).toEqual(["canonical-before", "submitted-active"]); - expect(userMessages[1]?.clientMessageId).toBe("submitted-active"); + expect(userMessages.map((item) => item.id)).toEqual(["canonical-before", "optimistic-active"]); + expect(userMessages[1]?.optimistic).toBe(true); }); it("leaves the cursor alone when a before page makes no progress", () => { @@ -2310,10 +1742,7 @@ describe("processTimelineResponse", () => { expect(getAssistantTexts(result.tail)).toEqual(["older chunk newer chunk"]); expect(result.tail[0]).toEqual( - expect.objectContaining({ - id: "assistant-newer", - timelineCursor: { epoch: "epoch-1", seq: 3 }, - }), + expect.objectContaining({ timelineCursor: { epoch: "epoch-1", seq: 3 } }), ); expect(result.cursor).toEqual({ epoch: "epoch-1", @@ -2384,68 +1813,6 @@ describe("processTimelineResponse", () => { ]); }); - it("removes a reconciled submitted prompt before coalescing a tool call at the pagination seam", () => { - const clientMessageId = "client-boundary-prompt"; - const callId = "toolu_submitted_boundary"; - const currentTail = [ - makeSubmittedUserMessage("Inspect the file", clientMessageId), - ...hydrateStreamState( - [ - { - event: { - type: "timeline", - provider: "claude", - item: makeToolCallTimelineEntry(3, callId, "completed", { - type: "read", - filePath: "/tmp/example.ts", - }).item, - } as AgentStreamEventPayload, - timestamp: new Date(3000), - }, - ], - { source: "canonical" }, - ), - ]; - - const result = processTimelineResponse({ - ...baseTimelineInput, - currentTail, - currentCursor: { epoch: "epoch-1", startSeq: 3, endSeq: 5 }, - payload: { - ...baseTimelineInput.payload, - direction: "before", - epoch: "epoch-1", - startCursor: { seq: 1 }, - endCursor: { seq: 2 }, - entries: [ - { - ...makeTimelineEntry(1, "Inspect the file", "user_message"), - item: { - type: "user_message", - text: "Inspect the file", - messageId: "provider-boundary-prompt", - clientMessageId, - }, - }, - makeToolCallTimelineEntry(2, callId, "running", { - type: "unknown", - input: { file_path: "/tmp/example.ts" }, - output: null, - }), - ], - }, - }); - - expect(result.tail.filter((item) => item.kind === "user_message")).toHaveLength(1); - expect(getAgentToolCalls(result.tail)).toEqual([ - expect.objectContaining({ - payload: expect.objectContaining({ - data: expect.objectContaining({ callId, status: "completed" }), - }), - }), - ]); - }); - it("does not coalesce tool call lifecycle rows away from the prepend boundary", () => { const callId = "toolu_not_boundary"; const currentTail = hydrateStreamState( @@ -2858,59 +2225,150 @@ describe("processAgentStreamEvent", () => { }); }); - it("renders a live row over painted items without creating an authoritative cursor", () => { + it("derives optimistic idle status on turn_completed for running agent", () => { + const turnCompletedEvent: AgentStreamEventPayload = { + type: "turn_completed", + provider: "claude", + }; + const result = processAgentStreamEvent({ ...baseStreamInput, - event: makeTimelineEvent("live before bootstrap", "user_message"), - seq: 51, - epoch: "epoch-1", - currentTail: [makeAssistantItem("painted replica")], - currentCursor: undefined, - hasAuthoritativeBaseline: false, + event: turnCompletedEvent, + currentAgent: { + status: "running", + updatedAt: new Date(1000), + lastActivityAt: new Date(1000), + }, + timestamp: new Date(2000), }); - expect(getAssistantTexts(result.tail)).toEqual(["painted replica"]); - expect(getUserTexts(result.head)).toEqual(["live before bootstrap"]); - expect(result.head[0]).toMatchObject({ - kind: "user_message", - timelineCursor: { epoch: "epoch-1", seq: 51 }, - }); - expect(result.cursorChanged).toBe(false); - expect(result.cursor).toBeNull(); - expect(result.sideEffects).toEqual([]); + expect(result.agentChanged).toBe(true); + expect(result.agent).not.toBe(null); + expect(result.agent!.status).toBe("idle"); + expect(result.agent!.updatedAt.getTime()).toBe(2000); + expect(result.agent!.lastActivityAt.getTime()).toBe(2000); }); - it("reconciles a pre-bootstrap echo with its submitted row", () => { - const submitted = makeSubmittedUserMessage("submitted before bootstrap", "client-message-1"); + it("derives optimistic error status on turn_failed for running agent", () => { + const turnFailedEvent: AgentStreamEventPayload = { + type: "turn_failed", + provider: "claude", + error: "something broke", + }; + const result = processAgentStreamEvent({ ...baseStreamInput, - event: { - type: "timeline", - provider: "claude", - item: { - type: "user_message", - text: "canonical presentation", - clientMessageId: "client-message-1", - messageId: "provider-message-1", - }, + event: turnFailedEvent, + currentAgent: { + status: "running", + updatedAt: new Date(1000), + lastActivityAt: new Date(1000), }, - seq: 51, - epoch: "epoch-1", - currentTail: [makeAssistantItem("painted replica"), submitted], - currentCursor: undefined, - hasAuthoritativeBaseline: false, + timestamp: new Date(2000), }); - const users = [...result.tail, ...result.head].filter((item) => item.kind === "user_message"); - expect(users).toEqual([ - expect.objectContaining({ - id: "client-message-1", - clientMessageId: "client-message-1", - messageId: "provider-message-1", - text: "submitted before bootstrap", - timelineCursor: { epoch: "epoch-1", seq: 51 }, - }), - ]); + expect(result.agentChanged).toBe(true); + expect(result.agent!.status).toBe("error"); + }); + + it("does not derive optimistic idle status on turn_canceled for running agent", () => { + const turnCanceledEvent: AgentStreamEventPayload = { + type: "turn_canceled", + provider: "codex", + reason: "interrupted", + }; + + const result = processAgentStreamEvent({ + ...baseStreamInput, + event: turnCanceledEvent, + currentAgent: { + status: "running", + updatedAt: new Date(1000), + lastActivityAt: new Date(1000), + }, + timestamp: new Date(2000), + }); + + expect(result.agentChanged).toBe(false); + expect(result.agent).toBe(null); + }); + + it("does not change agent when status is not running", () => { + const turnCompletedEvent: AgentStreamEventPayload = { + type: "turn_completed", + provider: "claude", + }; + + const result = processAgentStreamEvent({ + ...baseStreamInput, + event: turnCompletedEvent, + currentAgent: { + status: "idle", + updatedAt: new Date(1000), + lastActivityAt: new Date(1000), + }, + timestamp: new Date(2000), + }); + + expect(result.agentChanged).toBe(false); + expect(result.agent).toBe(null); + }); + + it("does not change agent when no agent is provided", () => { + const turnCompletedEvent: AgentStreamEventPayload = { + type: "turn_completed", + provider: "claude", + }; + + const result = processAgentStreamEvent({ + ...baseStreamInput, + event: turnCompletedEvent, + currentAgent: null, + timestamp: new Date(2000), + }); + + expect(result.agentChanged).toBe(false); + expect(result.agent).toBe(null); + }); + + it("preserves updatedAt when agent timestamp is newer than event", () => { + const turnCompletedEvent: AgentStreamEventPayload = { + type: "turn_completed", + provider: "claude", + }; + + const result = processAgentStreamEvent({ + ...baseStreamInput, + event: turnCompletedEvent, + currentAgent: { + status: "running", + updatedAt: new Date(5000), + lastActivityAt: new Date(5000), + }, + timestamp: new Date(2000), + }); + + expect(result.agentChanged).toBe(true); + expect(result.agent!.updatedAt.getTime()).toBe(5000); + expect(result.agent!.lastActivityAt.getTime()).toBe(5000); + }); + + it("does not produce agent patch for non-terminal events", () => { + const result = processAgentStreamEvent({ + ...baseStreamInput, + event: makeTimelineEvent("just text"), + currentAgent: { + status: "running", + updatedAt: new Date(1000), + lastActivityAt: new Date(1000), + }, + seq: 1, + epoch: "epoch-1", + timestamp: new Date(2000), + }); + + expect(result.agentChanged).toBe(false); + expect(result.agent).toBe(null); }); }); @@ -2924,6 +2382,7 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, + currentAgent: null, }); expect(result.changedTail).toBe(false); @@ -2951,6 +2410,7 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, + currentAgent: null, }); expect(result.changedTail).toBe(false); @@ -2973,6 +2433,7 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, + currentAgent: null, }); expect(result.changedTail).toBe(true); @@ -2990,6 +2451,7 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, + currentAgent: null, }); expect(result.changedTail).toBe(true); @@ -3007,6 +2469,7 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, + currentAgent: null, }); expect(result.changedTail).toBe(true); @@ -3038,6 +2501,7 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, + currentAgent: null, }); expect(result.changedTail).toBe(true); @@ -3063,6 +2527,7 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, + currentAgent: null, }); expect(result.changedTail).toBe(true); @@ -3087,6 +2552,7 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, + currentAgent: null, }); expect(result.changedTail).toBe(true); @@ -3178,6 +2644,7 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, + currentAgent: null, }); expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([ @@ -3188,7 +2655,7 @@ describe("processAgentStreamEvents", () => { ]); }); - it("does not derive lifecycle state from a terminal event in a batch", () => { + it("returns the final optimistic lifecycle patch across a batch", () => { const result = processAgentStreamEvents({ events: [ makeStreamReducerEvent(makeTimelineEvent("Done"), 1), @@ -3202,10 +2669,21 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, + currentAgent: { + status: "running", + updatedAt: new Date(1000), + lastActivityAt: new Date(1000), + }, }); expect(result.head).toEqual([]); expect(result.tail).toHaveLength(1); + expect(result.agentChanged).toBe(true); + expect(result.agent).toMatchObject({ + status: "idle", + updatedAt: new Date(3000), + lastActivityAt: new Date(3000), + }); }); it("keeps a live Claude assistant paragraph contiguous when init tail hydration lands mid-stream", () => { @@ -3354,6 +2832,7 @@ describe("createAgentStreamReducerQueue", () => { currentTail, currentHead, currentCursor: undefined, + currentAgent: null, }), commit: (agentId, result) => { currentTail = result.tail; @@ -3395,6 +2874,7 @@ describe("createAgentStreamReducerQueue", () => { currentTail: [], currentHead: [], currentCursor: undefined, + currentAgent: null, }), commit: (agentId, result) => { commits.push( @@ -3424,6 +2904,7 @@ describe("createAgentStreamReducerQueue", () => { currentTail, currentHead, currentCursor, + currentAgent: null, }), commit: (_agentId, result) => { currentTail = result.tail; @@ -3473,6 +2954,7 @@ describe("createAgentStreamReducerQueue", () => { currentTail: [], currentHead: [], currentCursor: undefined, + currentAgent: null, }), commit: (agentId, result) => { commits.push( diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index 1cd26517f..7458e855d 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -1,15 +1,15 @@ import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; -import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; -import type { AssistantMessageItem, StreamItem } from "@/types/stream"; +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 { applyStreamEvent, flushHeadToTail, hydrateStreamState, isAgentToolCallItem, mergeAgentToolCallItem, - replaceWithCanonicalStream, reduceStreamUpdate, - upsertUserMessageAcrossStream, } from "@/types/stream"; const AGENT_STREAM_REDUCER_FLUSH_DELAY_MS = 16 * 3; @@ -88,8 +88,6 @@ export interface ProcessTimelineResponseInput { isInitializing: boolean; hasActiveInitDeferred: boolean; initRequestDirection: InitRequestDirection; - sendingClientMessageIds: readonly string[]; - hasAuthoritativeBaseline: boolean; } export interface ProcessTimelineResponseOutput { @@ -101,7 +99,6 @@ export interface ProcessTimelineResponseOutput { clearInitializing: boolean; error: string | null; sideEffects: TimelineReducerSideEffect[]; - acknowledgedClientMessageIds: string[]; } interface TimelineUnit { @@ -118,7 +115,6 @@ interface TimelinePathResult { cursor: TimelineCursor | null | undefined; cursorChanged: boolean; sideEffects: TimelineReducerSideEffect[]; - acknowledgedClientMessageIds: string[]; } function classifySessionTimelineSeq({ @@ -205,39 +201,76 @@ 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 => + 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; currentTail: StreamItem[]; currentHead: StreamItem[]; - sendingClientMessageIds: readonly string[]; - preserveLiveHead: boolean; toHydratedEvents: ( units: TimelineUnit[], ) => Array<{ event: AgentStreamEventPayload; timestamp: Date }>; }): TimelinePathResult { - const { - timelineUnits, - payload, - bootstrapPolicy, - currentTail, - currentHead, - sendingClientMessageIds, - preserveLiveHead, - toHydratedEvents, - } = args; + const { timelineUnits, payload, bootstrapPolicy, currentTail, currentHead, toHydratedEvents } = + args; const hydratedTail = hydrateStreamState(toHydratedEvents(timelineUnits), { source: "canonical" }); - const { tail, head, acknowledgedClientMessageIds } = replaceWithCanonicalStream({ - canonical: hydratedTail, + const reconciledTail = reconcileLocalUserPresentationAfterReplace({ + canonicalTail: hydratedTail, previousTail: currentTail, previousHead: currentHead, - sendingClientMessageIds, - preserveLiveHead, - canonicalCoverage: { - epoch: payload.epoch, - endSeq: payload.endCursor?.seq ?? null, - }, + }); + const { tail, head } = preserveReplacePathAssistantHead({ + tail: reconciledTail, + currentHead, }); const cursor: TimelineCursor | null = payload.startCursor && payload.endCursor @@ -251,15 +284,136 @@ 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 { - tail, - head, - cursor, - cursorChanged: true, - sideEffects, - acknowledgedClientMessageIds, + 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 } + : {}), }; } + +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(); + 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; @@ -361,34 +515,6 @@ 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]; @@ -410,15 +536,16 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea return [...olderTail, ...currentTail]; } - 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)]; + return [ + ...olderTail.slice(0, -1), + { + ...olderLast, + text: `${olderLast.text}${currentFirst.text}`, + timestamp: currentFirst.timestamp, + ...(currentFirst.timelineCursor ? { timelineCursor: currentFirst.timelineCursor } : {}), + }, + ...currentTail.slice(1), + ]; } function replaceLiveAssistantWithProjectedText(params: { @@ -618,24 +745,9 @@ function applyCanonicalForwardUnit(params: { head: StreamItem[]; unit: TimelineUnit; epoch: string; -}): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } { +}): { tail: StreamItem[]; head: StreamItem[] } { 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, { @@ -643,7 +755,6 @@ function applyCanonicalForwardUnit(params: { timelineCursor, }), head: params.head, - acknowledgedClientMessageIds: [], }; } const replacedHead = replaceLiveAssistantWithProjectedText({ @@ -652,9 +763,7 @@ function applyCanonicalForwardUnit(params: { timestamp, timelineCursor, }); - if (replacedHead) { - return { tail: params.tail, head: replacedHead, acknowledgedClientMessageIds: [] }; - } + if (replacedHead) return { tail: params.tail, head: replacedHead }; const activeAssistant = params.head.findLast( (item): item is Extract => @@ -672,7 +781,6 @@ function applyCanonicalForwardUnit(params: { source: "canonical", timelineCursor, }), - acknowledgedClientMessageIds: [], }; } @@ -684,11 +792,7 @@ function applyCanonicalForwardUnit(params: { source: "canonical", timelineCursor, }); - return { - tail: applied.tail, - head: applied.head, - acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [], - }; + return { tail: applied.tail, head: applied.head }; } function applyAcceptedForwardTimelineUnits(params: { @@ -697,7 +801,7 @@ function applyAcceptedForwardTimelineUnits(params: { currentTail: StreamItem[]; currentHead: StreamItem[]; currentEndSeq: number | undefined; -}): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } { +}): { tail: StreamItem[]; head: StreamItem[] } { const reconciled = reconcileOverlappingProjectedStreamItems({ tail: params.currentTail, head: params.currentHead, @@ -707,19 +811,15 @@ function applyAcceptedForwardTimelineUnits(params: { }); let tail = reconciled.tail; let head = reconciled.head; - const acknowledgedClientMessageIds = new Set(); 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, acknowledgedClientMessageIds: [...acknowledgedClientMessageIds] }; + return { tail, head }; } function applyTimelineIncrementalPath(args: { @@ -735,17 +835,9 @@ 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, - acknowledgedClientMessageIds, - }; + return { tail: nextTail, head: nextHead, cursor: nextCursor, cursorChanged, sideEffects }; } const { acceptedUnits, cursor, gapCursor } = @@ -782,7 +874,6 @@ function applyTimelineIncrementalPath(args: { }); nextTail = applied.tail; nextHead = applied.head; - acknowledgedClientMessageIds = applied.acknowledgedClientMessageIds; } } @@ -801,14 +892,7 @@ function applyTimelineIncrementalPath(args: { sideEffects.push({ type: "catch_up", cursor: gapCursor }); } - return { - tail: nextTail, - head: nextHead, - cursor: nextCursor, - cursorChanged, - sideEffects, - acknowledgedClientMessageIds, - }; + return { tail: nextTail, head: nextHead, cursor: nextCursor, cursorChanged, sideEffects }; } export function processTimelineResponse( @@ -822,8 +906,6 @@ export function processTimelineResponse( isInitializing, hasActiveInitDeferred, initRequestDirection, - sendingClientMessageIds, - hasAuthoritativeBaseline, } = input; // ------------------------------------------------------------------ @@ -839,7 +921,6 @@ export function processTimelineResponse( clearInitializing: isInitializing, error: payload.error, sideEffects: [], - acknowledgedClientMessageIds: [], }; } @@ -886,6 +967,7 @@ export function processTimelineResponse( hasActiveInitDeferred, }); const replace = bootstrapPolicy.replace; + const sideEffects: TimelineReducerSideEffect[] = []; const timelineResult = replace ? applyTimelineReplacePath({ @@ -894,9 +976,6 @@ export function processTimelineResponse( bootstrapPolicy, currentTail, currentHead, - sendingClientMessageIds, - preserveLiveHead: - currentCursor?.epoch === payload.epoch || (!payload.reset && !hasAuthoritativeBaseline), toHydratedEvents, }) : applyTimelineIncrementalPath({ @@ -945,7 +1024,6 @@ export function processTimelineResponse( clearInitializing, error: null, sideEffects, - acknowledgedClientMessageIds: timelineResult.acknowledgedClientMessageIds, }; } @@ -960,10 +1038,20 @@ export interface ProcessAgentStreamEventInput { currentTail: StreamItem[]; currentHead: StreamItem[]; currentCursor: TimelineCursor | undefined; - hasAuthoritativeBaseline?: boolean; + currentAgent: { + status: AgentLifecycleStatus; + updatedAt: Date; + lastActivityAt: Date; + } | null; timestamp: Date; } +export interface AgentPatch { + status: AgentLifecycleStatus; + updatedAt: Date; + lastActivityAt: Date; +} + export interface ProcessAgentStreamEventOutput { tail: StreamItem[]; head: StreamItem[]; @@ -971,7 +1059,8 @@ export interface ProcessAgentStreamEventOutput { changedHead: boolean; cursor: TimelineCursor | null; cursorChanged: boolean; - acknowledgedClientMessageIds: string[]; + agent: AgentPatch | null; + agentChanged: boolean; sideEffects: AgentStreamReducerSideEffect[]; } @@ -990,12 +1079,18 @@ 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; - hasAuthoritativeBaseline?: boolean; + currentAgent: AgentStreamReducerAgentSnapshot | null; } export type AgentStreamReducerSnapshot = Omit; @@ -1019,14 +1114,27 @@ 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, hasAuthoritativeBaseline } = input; + const { event, seq, epoch, currentCursor } = input; const base: TimelineSequencingGateResult = { shouldApplyStreamEvent: true, nextTimelineCursor: null, @@ -1037,9 +1145,6 @@ 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, @@ -1096,24 +1201,10 @@ function processTimelineSequencingGate(input: { export function processAgentStreamEvent( input: ProcessAgentStreamEventInput, ): ProcessAgentStreamEventOutput { - const { - event, - seq, - epoch, - currentTail, - currentHead, - currentCursor, - timestamp, - hasAuthoritativeBaseline = true, - } = input; + const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } = + input; - const sequencing = processTimelineSequencingGate({ - event, - seq, - epoch, - currentCursor, - hasAuthoritativeBaseline, - }); + const sequencing = processTimelineSequencingGate({ event, seq, epoch, currentCursor }); const timelineCursor = event.type === "timeline" && seq !== undefined && epoch !== undefined ? { epoch, seq } @@ -1122,52 +1213,49 @@ export function processAgentStreamEvent( // ------------------------------------------------------------------ // Apply stream event to tail/head // ------------------------------------------------------------------ - let streamResult: ReturnType; - 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({ + const { tail, head, changedTail, changedHead } = sequencing.shouldApplyStreamEvent + ? applyStreamEvent({ + tail: sequencing.resetLiveTimeline ? [] : currentTail, + head: sequencing.resetLiveTimeline ? [] : currentHead, + event, + timestamp, + source: "live", + timelineCursor, + }) + : { 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: [...overlay.tail, ...overlay.head], changedTail: false, - changedHead: overlay.changedTail || overlay.changedHead, + changedHead: false, }; + + // ------------------------------------------------------------------ + // 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, @@ -1176,7 +1264,8 @@ export function processAgentStreamEvent( changedHead, cursor: sequencing.nextTimelineCursor, cursorChanged: sequencing.cursorChanged, - acknowledgedClientMessageIds: streamResult.acknowledgedClientMessageIds ?? [], + agent: agentPatch, + agentChanged, sideEffects: sequencing.sideEffects, }; } @@ -1187,10 +1276,12 @@ 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; - const acknowledgedClientMessageIds = new Set(); + let agentPatch: AgentPatch | null = null; + let agentChanged = false; const sideEffects: AgentStreamReducerSideEffect[] = []; for (const reducerEvent of input.events) { @@ -1201,7 +1292,7 @@ export function processAgentStreamEvents( currentTail: tail, currentHead: head, currentCursor: cursor, - hasAuthoritativeBaseline: input.hasAuthoritativeBaseline, + currentAgent: agent, timestamp: reducerEvent.timestamp, }); @@ -1210,14 +1301,17 @@ 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 { @@ -1227,7 +1321,8 @@ export function processAgentStreamEvents( changedHead, cursor: cursor ?? null, cursorChanged, - acknowledgedClientMessageIds: [...acknowledgedClientMessageIds], + agent: agentPatch, + agentChanged, sideEffects, }; } @@ -1310,7 +1405,6 @@ export function createAgentStreamReducerQueue( interface StreamStatePatch { tail?: StreamItem[]; head?: StreamItem[]; - acknowledgedClientMessageIds?: readonly string[]; } export interface CreateSessionAgentStreamReducerQueueInput { @@ -1320,6 +1414,7 @@ export interface CreateSessionAgentStreamReducerQueueInput { serverId: string, state: (prev: Map) => Map, ) => void; + setAgents: (serverId: string, state: (prev: Map) => Map) => void; recoverTimelineGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void; } @@ -1334,31 +1429,31 @@ function cancelAgentStreamReducerFlush(id: number) { export function createSessionAgentStreamReducerQueue( input: CreateSessionAgentStreamReducerQueueInput, ): AgentStreamReducerQueue { - const { serverId, setAgentStreamState, setAgentTimelineCursor, recoverTimelineGap } = input; + const { serverId, setAgentStreamState, setAgentTimelineCursor, setAgents, recoverTimelineGap } = + input; return createAgentStreamReducerQueue({ getSnapshot: (agentId) => { const session = useSessionStore.getState().sessions[serverId]; - const timeline = selectAgentTimelineState(session, agentId); + const currentAgentEntry = session?.agents.get(agentId); return { - currentTail: timeline.status === "cold" ? [] : timeline.items, + currentTail: session?.agentStreamTail.get(agentId) ?? [], currentHead: session?.agentStreamHead.get(agentId) ?? [], - currentCursor: timeline.status === "synced" ? (timeline.range ?? undefined) : undefined, - hasAuthoritativeBaseline: timeline.status === "synced", + currentCursor: session?.agentTimelineCursor.get(agentId), + currentAgent: currentAgentEntry + ? { + status: currentAgentEntry.status, + updatedAt: currentAgentEntry.updatedAt, + lastActivityAt: currentAgentEntry.lastActivityAt, + } + : null, }; }, commit: (agentId, result, events) => { - if ( - result.changedTail || - result.changedHead || - result.acknowledgedClientMessageIds.length > 0 - ) { + if (result.changedTail || result.changedHead) { setAgentStreamState(serverId, agentId, { ...(result.changedTail ? { tail: result.tail } : {}), ...(result.changedHead ? { head: result.head } : {}), - ...(result.acknowledgedClientMessageIds.length > 0 - ? { acknowledgedClientMessageIds: result.acknowledgedClientMessageIds } - : {}), }); } @@ -1391,6 +1486,24 @@ 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) { diff --git a/packages/app/src/timeline/turn-time.test.ts b/packages/app/src/timeline/turn-time.test.ts index 3945edbcb..d63d2de7d 100644 --- a/packages/app/src/timeline/turn-time.test.ts +++ b/packages/app/src/timeline/turn-time.test.ts @@ -22,16 +22,34 @@ function assistant(id: string, timestamp: Date): StreamItem { } describe("deriveStreamTurnTiming", () => { - it("starts elapsed time from the submitted prompt", () => { - const submittedAt = new Date("2026-05-15T00:00:00.000Z"); + 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, + }; const timing = deriveStreamTurnTiming({ agentStatus: "running", tail: [], - head: [user("submitted", submittedAt)], + head: [optimisticPrompt], }); - assert.equal(timing.runningStartedAt, submittedAt); + assert.equal(timing.runningStartedAt, null); }); it("uses the last user message as the running turn start", () => { diff --git a/packages/app/src/timeline/turn-time.ts b/packages/app/src/timeline/turn-time.ts index 7ebd77c21..435694524 100644 --- a/packages/app/src/timeline/turn-time.ts +++ b/packages/app/src/timeline/turn-time.ts @@ -9,6 +9,7 @@ export interface TurnTiming { export interface StreamTurnTiming { byAssistantId: Map; runningStartedAt: Date | null; + isActive: boolean; } export function deriveStreamTurnTiming(params: { @@ -18,6 +19,8 @@ export function deriveStreamTurnTiming(params: { }): StreamTurnTiming { const byAssistantId = new Map(); let currentUserAt: Date | null = null; + let currentAuthoritativeUserAt: Date | null = null; + let currentUserIsOptimistic = false; let currentLastItemAt: Date | null = null; let currentAssistantIds: string[] = []; @@ -39,6 +42,8 @@ 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; @@ -60,7 +65,7 @@ export function deriveStreamTurnTiming(params: { } const isRunning = params.agentStatus === "running"; - const runningStartedAt = isRunning ? currentUserAt : null; + const runningStartedAt = isRunning ? currentAuthoritativeUserAt : null; if (params.agentStatus !== "running") { flushCompletedTurn(); } @@ -68,5 +73,6 @@ export function deriveStreamTurnTiming(params: { return { byAssistantId, runningStartedAt, + isActive: isRunning || currentUserIsOptimistic, }; } diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index e3de58f42..fb68d7a02 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -4,7 +4,9 @@ import { describe, expect, it } from "vitest"; import { applyStreamEvent, - createUserMessage, + appendOptimisticUserMessageToStream, + buildOptimisticUserMessage, + clearOptimisticUserMessages, handoffCreatedAgentUserMessageToStream, hydrateStreamState, mergeToolCallDetail, @@ -12,8 +14,6 @@ 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,109 +21,6 @@ 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", @@ -998,15 +895,15 @@ describe("stream reducer canonical tool calls", () => { assert.strictEqual(todos.items[0]?.text, "Task 1"); }); - it("preserves submitted user message images when authoritative user message arrives", () => { + it("preserves optimistic user message images when authoritative user message arrives", () => { const messageId = "msg-user-images"; - const submittedTimestamp = new Date("2025-01-01T11:10:00Z"); - const submittedImages = [ + const optimisticTimestamp = new Date("2025-01-01T11:10:00Z"); + const optimisticImages = [ { - id: "att-submitted", + id: "att-optimistic", mimeType: "image/jpeg", storageType: "native-file" as const, - storageKey: "/tmp/submitted.jpg", + storageKey: "/tmp/optimistic.jpg", createdAt: Date.now(), }, ]; @@ -1014,10 +911,10 @@ describe("stream reducer canonical tool calls", () => { { kind: "user_message", id: messageId, - clientMessageId: messageId, text: "Analyze this image", - timestamp: submittedTimestamp, - images: submittedImages, + timestamp: optimisticTimestamp, + optimistic: true, + images: optimisticImages, }, ]; const event: AgentStreamEventPayload = { @@ -1036,9 +933,9 @@ describe("stream reducer canonical tool calls", () => { assert.ok(message); assert.strictEqual(message.id, messageId); - assert.deepStrictEqual(message.images, submittedImages); + assert.deepStrictEqual(message.images, optimisticImages); assert.strictEqual(message.text, "Analyze this image"); - assert.strictEqual(message.timestamp.getTime(), submittedTimestamp.getTime()); + assert.strictEqual(message.timestamp.getTime(), optimisticTimestamp.getTime()); }); it("keeps canonical assistant/user/assistant order during replay", () => { @@ -1084,7 +981,7 @@ describe("stream reducer canonical tool calls", () => { ); }); - it("keeps live submitted assistant merge behavior", () => { + it("keeps live optimistic assistant merge behavior", () => { const state: StreamItem[] = [ { kind: "assistant_message", @@ -1220,23 +1117,23 @@ describe("turn lifecycle events", () => { }); it.each(["codex", "opencode", "pi"] satisfies AgentProvider[])( - "replaces a submitted user message when a live %s provider-owned id echo arrives without text matching", + "replaces an optimistic user message when a live %s provider-owned id echo arrives without text matching", (provider) => { - const submittedTimestamp = new Date("2025-01-01T15:02:00Z"); + const optimisticTimestamp = new Date("2025-01-01T15:02:00Z"); const serverTimestamp = new Date("2025-01-01T15:02:01Z"); - const submitted: StreamItem = { + const optimistic: StreamItem = { kind: "user_message", - id: "msg_submitted", - clientMessageId: "msg_submitted", + id: "msg_optimistic", text: "same user text", - timestamp: submittedTimestamp, + timestamp: optimisticTimestamp, + optimistic: true, images: [ { id: "image-1", mimeType: "image/png", storageType: "web-indexeddb", storageKey: "image-1", - createdAt: submittedTimestamp.getTime(), + createdAt: optimisticTimestamp.getTime(), }, ], attachments: [ @@ -1250,7 +1147,7 @@ describe("turn lifecycle events", () => { }; const state = reduceStreamUpdate( - [submitted], + [optimistic], { type: "timeline", provider, @@ -1258,7 +1155,6 @@ describe("turn lifecycle events", () => { type: "user_message", text: "server-owned rendered text", messageId: "provider-owned-id", - clientMessageId: "msg_submitted", }, }, serverTimestamp, @@ -1269,28 +1165,28 @@ describe("turn lifecycle events", () => { assert.strictEqual(userMessages.length, 1); const userMessage = userMessages[0]; invariant(userMessage?.kind === "user_message"); - 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); + 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); }, ); - it("replaces one submitted plain-text user message with the next live server user message", () => { - const submittedTimestamp = new Date("2025-01-01T15:03:00Z"); + it("replaces one optimistic plain-text user message with the next live server user message", () => { + const optimisticTimestamp = new Date("2025-01-01T15:03:00Z"); const serverTimestamp = new Date("2025-01-01T15:03:01Z"); - const submitted: StreamItem = { + const optimistic: StreamItem = { kind: "user_message", - id: "msg_submitted", - clientMessageId: "msg_submitted", + id: "msg_optimistic", text: "typed plain text", - timestamp: submittedTimestamp, + timestamp: optimisticTimestamp, + optimistic: true, }; const state = reduceStreamUpdate( - [submitted], + [optimistic], { type: "timeline", provider: "opencode", @@ -1308,20 +1204,20 @@ describe("turn lifecycle events", () => { assert.strictEqual(userMessages.length, 1); const userMessage = userMessages[0]; invariant(userMessage?.kind === "user_message"); - assert.strictEqual(userMessage.id, "msg_submitted"); - assert.strictEqual(userMessage.messageId, "msg_opencode_provider_owned"); + assert.strictEqual(userMessage.id, "msg_opencode_provider_owned"); assert.strictEqual(userMessage.text, "typed plain text"); - assert.strictEqual(userMessage.timestamp.getTime(), submittedTimestamp.getTime()); + assert.strictEqual(userMessage.timestamp.getTime(), optimisticTimestamp.getTime()); + assert.strictEqual(userMessage.optimistic, undefined); }); - it("replaces a submitted image user message with the next canonical server user message", () => { - const submittedTimestamp = new Date("2025-01-01T15:03:10Z"); + it("replaces an optimistic image user message with the next canonical server user message", () => { + const optimisticTimestamp = new Date("2025-01-01T15:03:10Z"); const image = { id: "image-canonical", mimeType: "image/png", storageType: "web-indexeddb" as const, storageKey: "image-canonical", - createdAt: submittedTimestamp.getTime(), + createdAt: optimisticTimestamp.getTime(), }; const attachment = { type: "text" as const, @@ -1329,16 +1225,16 @@ describe("turn lifecycle events", () => { text: "context", title: "context.txt", }; - const submitted = createUserMessage({ - clientMessageId: "msg_submitted_canonical", + const optimistic = buildOptimisticUserMessage({ + id: "msg_optimistic_canonical", text: "Analyze this", - timestamp: submittedTimestamp, + timestamp: optimisticTimestamp, images: [image], attachments: [attachment], }); const state = reduceStreamUpdate( - [submitted], + [optimistic], { type: "timeline", provider: "claude", @@ -1346,32 +1242,28 @@ describe("turn lifecycle events", () => { type: "user_message", text: "server-rendered attachment text", messageId: "provider-owned-canonical", - clientMessageId: submitted.id, + clientMessageId: optimistic.id, }, }, new Date("2025-01-01T15:03:11Z"), - { - source: "canonical", - timelineCursor: { epoch: "epoch-1", seq: 42 }, - }, + { source: "canonical" }, ); 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, "msg_submitted_canonical"); - assert.strictEqual(userMessage.messageId, "provider-owned-canonical"); + assert.strictEqual(userMessage.id, "provider-owned-canonical"); assert.strictEqual(userMessage.text, "Analyze this"); - assert.strictEqual(userMessage.timestamp.getTime(), submittedTimestamp.getTime()); - assert.deepStrictEqual(userMessage.timelineCursor, { epoch: "epoch-1", seq: 42 }); + assert.strictEqual(userMessage.timestamp.getTime(), optimisticTimestamp.getTime()); + assert.strictEqual(userMessage.optimistic, undefined); assert.deepStrictEqual(userMessage.images, [image]); assert.deepStrictEqual(userMessage.attachments, [attachment]); }); - it("places submitted user messages through the identity producer", () => { - const submitted = createUserMessage({ - clientMessageId: "msg_append_once", + it("places optimistic user messages through one append helper", () => { + const optimistic = buildOptimisticUserMessage({ + id: "msg_append_once", text: "append once", timestamp: new Date("2025-01-01T15:03:20Z"), }); @@ -1382,30 +1274,28 @@ describe("turn lifecycle events", () => { timestamp: new Date("2025-01-01T15:03:19Z"), }; - const first = upsertUserMessageAcrossStream({ + const first = appendOptimisticUserMessageToStream({ tail: [], head: [headItem], - message: submitted, - insert: "head", - presentation: "existing", + message: optimistic, + placement: "active-head", }); - const second = upsertUserMessageAcrossStream({ + const second = appendOptimisticUserMessageToStream({ tail: first.tail, head: first.head, - message: submitted, - insert: "head", - presentation: "existing", + message: optimistic, + placement: "active-head", }); assert.deepStrictEqual(first.tail, []); - assert.deepStrictEqual(first.head, [headItem, submitted]); + assert.deepStrictEqual(first.head, [headItem, optimistic]); assert.strictEqual(second.changedHead, false); assert.strictEqual(second.head, first.head); }); - it("hands rich submitted content to its create message without overwriting an earlier user row", () => { + it("hands rich optimistic content to an authoritative create message without duplicating it", () => { const timestamp = new Date("2025-01-01T15:03:20Z"); - const submitted = createUserMessage({ - clientMessageId: "client-user", + const optimistic = buildOptimisticUserMessage({ + id: "client-user", text: "", timestamp, images: [ @@ -1427,44 +1317,32 @@ 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: [precedingProviderRow, canonical], + tail: [canonical], head: [], - message: submitted, + message: optimistic, }); const repeated = handoffCreatedAgentUserMessageToStream({ tail: handedOff.tail, head: handedOff.head, - message: submitted, + message: optimistic, }); assert.deepStrictEqual(handedOff.tail, [ - precedingProviderRow, { kind: "user_message", - id: "client-user", - clientMessageId: "client-user", - messageId: "provider-user", - text: submitted.text, - timestamp: submitted.timestamp, - images: submitted.images, - attachments: submitted.attachments, + id: "provider-user", + text: optimistic.text, + timestamp: optimistic.timestamp, + images: optimistic.images, + attachments: optimistic.attachments, }, ]); assert.deepStrictEqual(handedOff.head, []); @@ -1486,22 +1364,22 @@ describe("turn lifecycle events", () => { ); assert.deepStrictEqual( afterNextUser.filter((item) => item.kind === "user_message").map((item) => item.id), - ["provider-system-user", "client-user", "provider-next-user"], + ["provider-user", "provider-next-user"], ); }); - it("flushes an interrupted head when its submitted prompt becomes canonical", () => { - const submitted: StreamItem = { + it("reconciles an optimistic user message that was pending in the streaming head", () => { + const optimistic: StreamItem = { kind: "user_message", - id: "msg_head_submitted", - clientMessageId: "msg_head_submitted", + id: "msg_head_optimistic", text: "plain text in head", timestamp: new Date("2025-01-01T15:03:02Z"), + optimistic: true, }; const result = applyStreamEvent({ tail: [], - head: [submitted], + head: [optimistic], event: { type: "timeline", provider: "opencode", @@ -1515,80 +1393,33 @@ describe("turn lifecycle events", () => { source: "live", }); - assert.deepStrictEqual(result.head, []); + assert.strictEqual(result.head.length, 0); const userMessages = result.tail.filter((item) => item.kind === "user_message"); assert.strictEqual(userMessages.length, 1); - assert.strictEqual(userMessages[0]?.id, "msg_head_submitted"); - assert.strictEqual(userMessages[0]?.messageId, "provider-owned-head"); + assert.strictEqual(userMessages[0]?.id, "provider-owned-head"); + assert.strictEqual(userMessages[0]?.optimistic, undefined); }); - 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 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"); + 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 firstSubmitted: StreamItem = { + const firstOptimistic: StreamItem = { kind: "user_message", - id: "msg_submitted_1", - clientMessageId: "msg_submitted_1", + id: "msg_optimistic_1", text: "first typed text", - timestamp: submittedTimestamp, + timestamp: optimisticTimestamp, + optimistic: true, }; - const secondSubmitted: StreamItem = { + const secondOptimistic: StreamItem = { kind: "user_message", - id: "msg_submitted_2", - clientMessageId: "msg_submitted_2", + id: "msg_optimistic_2", text: "second typed text", timestamp: new Date("2025-01-01T15:04:00.500Z"), + optimistic: true, }; const afterFirstEcho = reduceStreamUpdate( - [firstSubmitted, secondSubmitted], + [firstOptimistic, secondOptimistic], { type: "timeline", provider: "opencode", @@ -1596,7 +1427,6 @@ describe("turn lifecycle events", () => { type: "user_message", text: "first server text", messageId: "provider-owned-first", - clientMessageId: "msg_submitted_1", }, }, serverTimestamp, @@ -1611,7 +1441,6 @@ 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"), @@ -1621,30 +1450,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.messageId]), + userMessages.map((item) => [item.id, item.text, item.optimistic]), [ - ["msg_submitted_1", "first typed text", "provider-owned-first"], - ["msg_submitted_2", "second typed text", "provider-owned-second"], + ["provider-owned-first", "first typed text", undefined], + ["provider-owned-second", "second typed text", undefined], ], ); }); - it("does not shift later prompts when an earlier submitted prompt has no canonical echo", () => { + it("does not shift later prompts when an earlier optimistic 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( @@ -1667,16 +1496,15 @@ describe("turn lifecycle events", () => { stalePrompt, { kind: "user_message", - id: "msg_submitted", + id: "provider-owned-submitted", clientMessageId: submittedPrompt.id, - messageId: "provider-owned-submitted", text: submittedPrompt.text, timestamp: submittedPrompt.timestamp, }, ]); }); - it("appends a live server user message when no submitted user message is pending", () => { + it("appends a live server user message when no optimistic user message is pending", () => { const state = reduceStreamUpdate( [], { @@ -1695,11 +1523,21 @@ 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("appends a server user message after a rewound local row was removed", () => { + 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]); + const state = reduceStreamUpdate( - [], + cleared, { type: "timeline", provider: "opencode", @@ -1717,6 +1555,7 @@ 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", () => { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 45a712884..76407d84c 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -87,589 +87,22 @@ export interface UserMessageItem { kind: "user_message"; id: string; clientMessageId?: string; - messageId?: string; - timelineCursor?: TimelinePosition; + text: string; + timestamp: Date; + optimistic?: true; + images?: UserMessageImageAttachment[]; + attachments?: AgentAttachment[]; +} + +export interface OptimisticUserMessageInput { + id: string; text: string; timestamp: Date; images?: UserMessageImageAttachment[]; attachments?: AgentAttachment[]; } -export interface UserMessageInput { - id?: string; - clientMessageId?: string; - messageId?: string; - timelineCursor?: TimelinePosition; - text: string; - timestamp: Date; - images?: UserMessageImageAttachment[]; - attachments?: AgentAttachment[]; -} - -export function createUserMessage(input: UserMessageInput): UserMessageItem { - const id = input.id ?? input.clientMessageId ?? input.messageId; - if (!id) { - throw new Error("User message identity is required"); - } - return { - kind: "user_message", - id, - ...(input.clientMessageId ? { clientMessageId: input.clientMessageId } : {}), - ...(input.messageId ? { messageId: input.messageId } : {}), - ...(input.timelineCursor ? { timelineCursor: input.timelineCursor } : {}), - text: input.text, - timestamp: input.timestamp, - ...(input.images && input.images.length > 0 ? { images: input.images } : {}), - ...(input.attachments && input.attachments.length > 0 - ? { attachments: input.attachments } - : {}), - }; -} - -export function appendSubmittedUserMessage(input: { - tail: StreamItem[]; - head: StreamItem[]; - message: UserMessageItem; -}): { tail: StreamItem[]; head: StreamItem[] } { - const clientMessageId = input.message.clientMessageId; - if (!clientMessageId) { - throw new Error("Submitted user message requires client identity"); - } - const alreadyExists = [...input.tail, ...input.head].some( - (item) => item.kind === "user_message" && item.clientMessageId === clientMessageId, - ); - if (alreadyExists) { - throw new Error(`Submitted user message already exists: ${clientMessageId}`); - } - return input.head.length > 0 - ? { tail: input.tail, head: [...input.head, input.message] } - : { tail: [...input.tail, input.message], head: input.head }; -} - -export function removeSubmittedUserMessage(input: { - tail: StreamItem[]; - head: StreamItem[]; - clientMessageId: string; -}): { tail: StreamItem[]; head: StreamItem[] } { - const remove = (items: StreamItem[]) => { - const next = items.filter( - (item) => item.kind !== "user_message" || item.clientMessageId !== input.clientMessageId, - ); - return next.length === items.length ? items : next; - }; - return { tail: remove(input.tail), head: remove(input.head) }; -} - -// COMPAT(userMessageClientId): added in v0.2.0, remove after 2027-01-20 once the -// supported daemon floor emits clientMessageId on submitted user messages. Until then a -// locally submitted row (clientMessageId, no messageId) and its canonical twin from an -// old daemon (messageId, no clientMessageId) share no identifier, so canonical ingestion -// may match an explicit local candidate by the id supplied over the wire or by text. -function matchesLegacyCanonicalUserMessage( - submitted: UserMessageItem, - canonical: UserMessageItem, -): boolean { - if (submitted.clientMessageId === undefined || submitted.messageId !== undefined) return false; - if (canonical.messageId === undefined) return false; - return canonical.messageId === submitted.clientMessageId || canonical.text === submitted.text; -} - -type UserMessageMatchPolicy = "canonical-incoming" | "handoff"; - -function matchesUserMessage( - existing: UserMessageItem, - incoming: UserMessageItem, - policy: UserMessageMatchPolicy, -): boolean { - if (existing.clientMessageId && incoming.clientMessageId) { - return existing.clientMessageId === incoming.clientMessageId; - } - if (existing.messageId && incoming.messageId) { - return existing.messageId === incoming.messageId; - } - if (matchesLegacyCanonicalUserMessage(existing, incoming)) return true; - return policy === "handoff" && matchesLegacyCanonicalUserMessage(incoming, existing); -} - -export function upsertUserMessage( - items: StreamItem[], - incoming: UserMessageItem, - insertAt = items.length, -): StreamItem[] { - return produceUserMessage(items, incoming, insertAt, "existing").items; -} - -type UserMessagePresentationPolicy = "existing" | "incoming"; - -interface UserMessageProductionResult { - items: StreamItem[]; - index: number; - message: UserMessageItem; - matched: boolean; -} - -function produceUserMessage( - items: StreamItem[], - incoming: UserMessageItem, - insertAt: number | null, - presentationPolicy: UserMessagePresentationPolicy, - matchPolicy: UserMessageMatchPolicy = "canonical-incoming", -): UserMessageProductionResult { - const index = items.findIndex( - (item) => item.kind === "user_message" && matchesUserMessage(item, incoming, matchPolicy), - ); - if (index < 0) { - if (insertAt === null) { - return { items, index: -1, message: incoming, matched: false }; - } - return { - items: [...items.slice(0, insertAt), incoming, ...items.slice(insertAt)], - index: insertAt, - message: incoming, - matched: false, - }; - } - - const existing = items[index]; - if (!existing || existing.kind !== "user_message") { - throw new Error("User message upsert matched a non-user row"); - } - const presentation = presentationPolicy === "incoming" ? incoming : existing; - const merged = createUserMessage({ - ...presentation, - clientMessageId: incoming.clientMessageId ?? existing.clientMessageId, - messageId: incoming.messageId ?? existing.messageId, - timelineCursor: incoming.timelineCursor ?? existing.timelineCursor, - }); - if ( - existing.id === merged.id && - existing.clientMessageId === merged.clientMessageId && - existing.messageId === merged.messageId && - existing.timelineCursor === merged.timelineCursor && - existing.text === merged.text && - existing.timestamp === merged.timestamp && - existing.images === merged.images && - existing.attachments === merged.attachments - ) { - return { items, index, message: existing, matched: true }; - } - const next = [...items]; - next[index] = merged; - return { items: next, index, message: merged, matched: true }; -} - -export interface UserMessageStreamUpsertInput { - tail: StreamItem[]; - head: StreamItem[]; - message: UserMessageItem; - insert: "tail" | "head" | "prepend-tail" | "none"; - presentation: UserMessagePresentationPolicy; - matchPolicy?: UserMessageMatchPolicy; -} - -export interface UserMessageStreamUpsertResult extends ApplyStreamEventResult { - location: { - lane: "tail" | "head"; - index: number; - message: UserMessageItem; - matched: boolean; - } | null; -} - -export function upsertUserMessageAcrossStream( - input: UserMessageStreamUpsertInput, -): UserMessageStreamUpsertResult { - const tailResult = produceUserMessage( - input.tail, - input.message, - null, - input.presentation, - input.matchPolicy, - ); - if (tailResult.matched) { - return { - tail: tailResult.items, - head: input.head, - changedTail: tailResult.items !== input.tail, - changedHead: false, - location: { - lane: "tail", - index: tailResult.index, - message: tailResult.message, - matched: true, - }, - }; - } - const headResult = produceUserMessage( - input.head, - input.message, - null, - input.presentation, - input.matchPolicy, - ); - if (headResult.matched) { - return { - tail: input.tail, - head: headResult.items, - changedTail: false, - changedHead: headResult.items !== input.head, - location: { - lane: "head", - index: headResult.index, - message: headResult.message, - matched: true, - }, - }; - } - if (input.insert === "none") { - return { - tail: input.tail, - head: input.head, - changedTail: false, - changedHead: false, - location: null, - }; - } - if (input.insert === "head") { - const inserted = produceUserMessage( - input.head, - input.message, - input.head.length, - input.presentation, - input.matchPolicy, - ); - return { - tail: input.tail, - head: inserted.items, - changedTail: false, - changedHead: true, - location: { - lane: "head", - index: inserted.index, - message: inserted.message, - matched: false, - }, - }; - } - const inserted = produceUserMessage( - input.tail, - input.message, - input.insert === "prepend-tail" ? 0 : input.tail.length, - input.presentation, - input.matchPolicy, - ); - return { - tail: inserted.items, - head: input.head, - changedTail: true, - changedHead: false, - location: { - lane: "tail", - index: inserted.index, - message: inserted.message, - matched: false, - }, - }; -} - -function placeCanonicalUserMessageAtTail( - tail: StreamItem[], - message: UserMessageItem, - insertWhenUnmatched: boolean, -): Pick { - const produced = produceUserMessage(tail, message, null, "existing"); - if (!produced.matched && !insertWhenUnmatched) { - return produced; - } - const preceding = produced.matched - ? [...produced.items.slice(0, produced.index), ...produced.items.slice(produced.index + 1)] - : produced.items; - return { - items: [...preceding, produced.message], - message: produced.message, - matched: produced.matched, - }; -} - -export interface CanonicalStreamReplacementInput { - canonical: StreamItem[]; - previousTail: StreamItem[]; - previousHead: StreamItem[]; - sendingClientMessageIds: readonly string[]; - preserveLiveHead: boolean; - canonicalCoverage: { epoch: string; endSeq: number | null }; -} - -export interface CanonicalStreamReplacementResult { - tail: StreamItem[]; - head: StreamItem[]; - acknowledgedClientMessageIds: string[]; -} - -function isAfterCanonicalCoverage( - position: TimelinePosition, - coverage: CanonicalStreamReplacementInput["canonicalCoverage"], -): boolean { - return ( - position.epoch === coverage.epoch && - (coverage.endSeq === null || position.seq > coverage.endSeq) - ); -} - -function removeUserMessageAt(items: UserMessageItem[], index: number): UserMessageItem[] { - return [...items.slice(0, index), ...items.slice(index + 1)]; -} - -function mergeRetainedLifecycleItem(tail: StreamItem[], retained: StreamItem): StreamItem[] | null { - if (!retained.timelineCursor) { - return null; - } - if (isAgentToolCallItem(retained)) { - const tailIndex = findExistingAgentToolCallIndex(tail, retained.payload.data.callId); - const existing = tail[tailIndex]; - if (tailIndex < 0 || !existing || !isAgentToolCallItem(existing)) { - return null; - } - const next = [...tail]; - next[tailIndex] = mergeAgentToolCallItem( - existing, - retained.payload.data, - retained.timestamp, - retained.timelineCursor, - ); - return next; - } - if (retained.kind === "todo_list") { - const tailIndex = tail.length - 1; - const existing = tail[tailIndex]; - if (!existing || existing.kind !== "todo_list" || existing.provider !== retained.provider) { - return null; - } - const next = [...tail]; - next[tailIndex] = { - ...existing, - timelineCursor: retained.timelineCursor, - timestamp: retained.timestamp, - items: retained.items, - }; - return next; - } - if (retained.kind === "compaction" && retained.status === "completed") { - const tailIndex = tail.findIndex( - (item) => item.kind === "compaction" && item.status === "loading", - ); - const existing = tail[tailIndex]; - if (tailIndex < 0 || !existing || existing.kind !== "compaction") { - return null; - } - const next = [...tail]; - next[tailIndex] = { - ...existing, - timelineCursor: retained.timelineCursor, - status: "completed", - trigger: retained.trigger ?? existing.trigger, - preTokens: retained.preTokens ?? existing.preTokens, - }; - return next; - } - return null; -} - -function reconcileReplacementHeadAgainstTail( - tail: StreamItem[], - retainedHead: StreamItem[], -): { tail: StreamItem[]; head: StreamItem[] } { - let reconciledTail = tail; - const reconciledHeadIndexes = new Set(); - for (const [headIndex, item] of retainedHead.entries()) { - const nextTail = mergeRetainedLifecycleItem(reconciledTail, item); - if (!nextTail) { - continue; - } - reconciledTail = nextTail; - reconciledHeadIndexes.add(headIndex); - } - - const tailIds = new Set(reconciledTail.map((item) => item.id)); - return { - tail: reconciledTail, - head: retainedHead.filter( - (item, index) => - !reconciledHeadIndexes.has(index) && - (item.kind === "assistant_message" || !tailIds.has(item.id)), - ), - }; -} - -function preserveReplacementHead( - tail: StreamItem[], - currentHead: StreamItem[], - preserveLiveHead: boolean, - sendingClientMessageIds: ReadonlySet, - canonicalCoverage: CanonicalStreamReplacementInput["canonicalCoverage"], -): CanonicalStreamReplacementResult { - const canonicalTailAssistant = tail.at(-1); - const retainedHead = preserveLiveHead - ? currentHead.filter( - (item) => - !item.timelineCursor || - isAfterCanonicalCoverage(item.timelineCursor, canonicalCoverage) || - (item.kind === "assistant_message" && - canonicalTailAssistant?.kind === "assistant_message" && - item.text.startsWith(canonicalTailAssistant.text)), - ) - : currentHead.filter( - (item) => - item.kind === "user_message" && - item.clientMessageId !== undefined && - sendingClientMessageIds.has(item.clientMessageId), - ); - const { tail: reconciledTail, head: unreconciledHead } = reconcileReplacementHeadAgainstTail( - tail, - retainedHead, - ); - const liveAssistantIndex = unreconciledHead[0]?.kind === "assistant_message" ? 0 : -1; - if (liveAssistantIndex < 0) { - return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] }; - } - - const liveAssistant = unreconciledHead[liveAssistantIndex]; - const tailAssistant = reconciledTail.at(-1); - if ( - liveAssistant.kind !== "assistant_message" || - !tailAssistant || - tailAssistant.kind !== "assistant_message" - ) { - return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] }; - } - - const hasNewerCursor = - liveAssistant.timelineCursor !== undefined && - tailAssistant.timelineCursor !== undefined && - liveAssistant.timelineCursor.epoch === tailAssistant.timelineCursor.epoch && - liveAssistant.timelineCursor.seq > tailAssistant.timelineCursor.seq; - const hasMatchingProviderMessageId = - liveAssistant.messageId !== undefined && liveAssistant.messageId === tailAssistant.messageId; - const hasIdlessTextContinuation = - liveAssistant.messageId === undefined && - tailAssistant.messageId === undefined && - liveAssistant.text.startsWith(tailAssistant.text); - const isNewerContinuation = - hasNewerCursor && (hasMatchingProviderMessageId || hasIdlessTextContinuation); - if (isNewerContinuation) { - const text = liveAssistant.text.startsWith(tailAssistant.text) - ? liveAssistant.text - : `${tailAssistant.text}${liveAssistant.text}`; - const head = [ - ...unreconciledHead.slice(0, liveAssistantIndex), - { ...liveAssistant, text }, - ...unreconciledHead.slice(liveAssistantIndex + 1), - ]; - return { - tail: reconciledTail.slice(0, -1), - head, - acknowledgedClientMessageIds: [], - }; - } - if (!liveAssistant.text.startsWith(tailAssistant.text)) { - return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] }; - } - - const head = [ - ...unreconciledHead.slice(0, liveAssistantIndex), - { ...liveAssistant, text: tailAssistant.text }, - ...unreconciledHead.slice(liveAssistantIndex + 1), - ]; - return { - tail: reconciledTail.slice(0, -1), - head, - acknowledgedClientMessageIds: [], - }; -} - -export function replaceWithCanonicalStream( - input: CanonicalStreamReplacementInput, -): CanonicalStreamReplacementResult { - const sendingClientMessageIds = new Set(input.sendingClientMessageIds); - let unmatchedTailMessages = input.previousTail.filter( - (item): item is UserMessageItem => - item.kind === "user_message" && item.clientMessageId !== undefined, - ); - let nextHead = input.previousHead; - const nextTail: StreamItem[] = []; - const acknowledgedClientMessageIds = new Set(); - - for (const item of input.canonical) { - if (item.kind !== "user_message") { - nextTail.push(item); - continue; - } - - const tailResult = produceUserMessage(unmatchedTailMessages, item, null, "existing"); - if (tailResult.matched) { - unmatchedTailMessages = removeUserMessageAt(unmatchedTailMessages, tailResult.index); - nextTail.push(tailResult.message); - if ( - tailResult.message.clientMessageId && - sendingClientMessageIds.has(tailResult.message.clientMessageId) - ) { - acknowledgedClientMessageIds.add(tailResult.message.clientMessageId); - } - continue; - } - - const headResult = produceUserMessage(nextHead, item, null, "existing"); - if (headResult.matched) { - nextHead = [ - ...headResult.items.slice(0, headResult.index), - ...headResult.items.slice(headResult.index + 1), - ]; - nextTail.push(headResult.message); - if ( - headResult.message.clientMessageId && - sendingClientMessageIds.has(headResult.message.clientMessageId) - ) { - acknowledgedClientMessageIds.add(headResult.message.clientMessageId); - } - continue; - } - - nextTail.push(item); - } - - const retainedTailMessages: UserMessageItem[] = []; - for (const local of unmatchedTailMessages) { - if (local.clientMessageId && sendingClientMessageIds.has(local.clientMessageId)) { - nextTail.push(local); - } else if ( - input.preserveLiveHead && - local.timelineCursor && - isAfterCanonicalCoverage(local.timelineCursor, input.canonicalCoverage) - ) { - retainedTailMessages.push(local); - } - } - nextHead = [...retainedTailMessages, ...nextHead]; - - nextHead = nextHead.filter((item) => { - if (item.kind !== "user_message" || !item.clientMessageId) return true; - if (sendingClientMessageIds.has(item.clientMessageId)) return true; - if (!input.preserveLiveHead || !item.timelineCursor) return false; - return isAfterCanonicalCoverage(item.timelineCursor, input.canonicalCoverage); - }); - - const replacement = preserveReplacementHead( - nextTail, - nextHead, - input.preserveLiveHead, - sendingClientMessageIds, - input.canonicalCoverage, - ); - return { - ...replacement, - acknowledgedClientMessageIds: [...acknowledgedClientMessageIds], - }; -} +export type OptimisticUserMessagePlacement = "tail" | "active-head"; export interface AssistantMessageItem { kind: "assistant_message"; @@ -692,7 +125,6 @@ export type ThoughtStatus = "loading" | "ready"; export interface ThoughtItem { kind: "thought"; id: string; - timelineCursor?: TimelinePosition; text: string; timestamp: Date; status: ThoughtStatus; @@ -727,7 +159,6 @@ export type ToolCallPayload = export interface ToolCallItem { kind: "tool_call"; id: string; - timelineCursor?: TimelinePosition; timestamp: Date; payload: ToolCallPayload; } @@ -745,7 +176,6 @@ type ActivityLogType = "system" | "info" | "success" | "error"; export interface ActivityLogItem { kind: "activity_log"; id: string; - timelineCursor?: TimelinePosition; timestamp: Date; activityType: ActivityLogType; message: string; @@ -755,7 +185,6 @@ export interface ActivityLogItem { export interface CompactionItem { kind: "compaction"; id: string; - timelineCursor?: TimelinePosition; timestamp: Date; status: "loading" | "completed"; trigger?: "auto" | "manual"; @@ -770,7 +199,6 @@ export interface TodoEntry { export interface TodoListItem { kind: "todo_list"; id: string; - timelineCursor?: TimelinePosition; timestamp: Date; provider: AgentProvider; items: TodoEntry[]; @@ -809,27 +237,126 @@ function markThoughtReady(item: ThoughtItem): ThoughtItem { }; } +function buildUserMessageItem(input: { + id: string; + clientMessageId?: string; + text: string; + timestamp: Date; + optimistic?: UserMessageItem | null; +}): UserMessageItem { + if (input.optimistic) { + return { + kind: "user_message", + id: input.id, + ...(input.clientMessageId ? { clientMessageId: input.clientMessageId } : {}), + text: input.optimistic.text, + timestamp: input.optimistic.timestamp, + ...(input.optimistic.images && input.optimistic.images.length > 0 + ? { images: input.optimistic.images } + : {}), + ...(input.optimistic.attachments && input.optimistic.attachments.length > 0 + ? { attachments: input.optimistic.attachments } + : {}), + }; + } + + return { + kind: "user_message", + id: input.id, + ...(input.clientMessageId ? { clientMessageId: input.clientMessageId } : {}), + text: input.text, + timestamp: input.timestamp, + }; +} + +export function buildOptimisticUserMessage(input: OptimisticUserMessageInput): UserMessageItem { + return { + kind: "user_message", + id: input.id, + text: input.text, + timestamp: input.timestamp, + optimistic: true, + ...(input.images && input.images.length > 0 ? { images: input.images } : {}), + ...(input.attachments && input.attachments.length > 0 + ? { attachments: input.attachments } + : {}), + }; +} + +export function appendOptimisticUserMessageToStream(params: { + tail: StreamItem[]; + head: StreamItem[]; + message: UserMessageItem; + placement: OptimisticUserMessagePlacement; +}): ApplyStreamEventResult { + const { tail, head, message, placement } = params; + if (tail.some((item) => item.id === message.id) || head.some((item) => item.id === message.id)) { + return { tail, head, changedTail: false, changedHead: false }; + } + + if (placement === "active-head" && head.length > 0) { + return { + tail, + head: [...head, message], + changedTail: false, + changedHead: true, + }; + } + + return { + tail: [...tail, message], + head, + changedTail: true, + changedHead: false, + }; +} + export function handoffCreatedAgentUserMessageToStream(params: { tail: StreamItem[]; head: StreamItem[]; message: UserMessageItem; }): ApplyStreamEventResult { - return upsertUserMessageAcrossStream({ - ...params, - insert: "tail", - presentation: "incoming", - matchPolicy: "handoff", + const { tail, head, message } = params; + const items = [...tail, ...head]; + const userIndex = items.findIndex((item) => item.kind === "user_message"); + if (userIndex < 0) { + return appendOptimisticUserMessageToStream({ + tail, + head, + message, + placement: "tail", + }); + } + + const userMessage = items[userIndex]; + if (!userMessage || userMessage.kind !== "user_message" || userMessage.optimistic) { + return { tail, head, changedTail: false, changedHead: false }; + } + + const handedOffMessage = buildUserMessageItem({ + id: userMessage.id, + text: message.text, + timestamp: message.timestamp, + optimistic: message, }); + if (userIndex < tail.length) { + const nextTail = [...tail]; + nextTail[userIndex] = handedOffMessage; + return { tail: nextTail, head, changedTail: true, changedHead: false }; + } + + const nextHead = [...head]; + nextHead[userIndex - tail.length] = handedOffMessage; + return { tail, head: nextHead, changedTail: false, changedHead: true }; } function appendUserMessage( state: StreamItem[], text: string, timestamp: Date, - _source: StreamUpdateSource, + source: StreamUpdateSource, messageId?: string, clientMessageId?: string, - timelineCursor?: TimelinePosition, ): StreamItem[] { const { chunk, hasContent } = normalizeChunk(text); if (!hasContent) { @@ -837,15 +364,37 @@ function appendUserMessage( } const chunkSeed = chunk.trim() || chunk; - const nextItem = createUserMessage({ - id: messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp), + const entryId = messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp); + const optimisticIndex = state.findIndex( + (entry) => + entry.kind === "user_message" && + entry.optimistic && + (clientMessageId !== undefined + ? entry.id === clientMessageId + : source === "live" || entry.id === messageId || entry.text === chunk), + ); + const optimistic = optimisticIndex >= 0 ? (state[optimisticIndex] as UserMessageItem) : null; + + const nextItem = buildUserMessageItem({ + id: entryId, clientMessageId, - messageId, - timelineCursor, text: chunk, timestamp, + optimistic, }); - return upsertUserMessage(state, nextItem); + + if (optimisticIndex >= 0) { + const next = [...state]; + next[optimisticIndex] = nextItem; + return next; + } + + return [...state, nextItem]; +} + +export function clearOptimisticUserMessages(state: StreamItem[]): StreamItem[] { + const next = state.filter((item) => item.kind !== "user_message" || !item.optimistic); + return next.length === state.length ? state : next; } function appendAssistantMessage( @@ -877,8 +426,8 @@ function appendAssistantMessage( return [...state.slice(0, -1), updated]; } - // A submitted user row can follow the streaming assistant during interrupt. - // In that case, look one row further back for the assistant to extend. + // If the last item is a user_message (optimistic append to head during + // interrupt), look one further back for the streaming assistant_message. const secondLast = state[state.length - 2]; if ( source === "live" && @@ -912,12 +461,7 @@ function appendAssistantMessage( return [...state, item]; } -function appendThought( - state: StreamItem[], - text: string, - timestamp: Date, - timelineCursor?: TimelinePosition, -): StreamItem[] { +function appendThought(state: StreamItem[], text: string, timestamp: Date): StreamItem[] { const { chunk, hasContent } = normalizeChunk(text); if (!chunk) { return state; @@ -927,7 +471,6 @@ function appendThought( if (last && last.kind === "thought") { const updated: ThoughtItem = { ...last, - ...(timelineCursor ? { timelineCursor } : {}), text: `${last.text}${chunk}`, timestamp, status: "loading", @@ -943,7 +486,6 @@ function appendThought( const item: ThoughtItem = { kind: "thought", id: createUniqueTimelineId(state, "thought", idSeed, timestamp), - ...(timelineCursor ? { timelineCursor } : {}), text: chunk, timestamp, status: "loading", @@ -1079,7 +621,6 @@ export function mergeAgentToolCallItem( existing: AgentToolCallItem, data: AgentToolCallData, timestamp: Date, - timelineCursor?: TimelinePosition, ): AgentToolCallItem { const mergedStatus = mergeAgentToolCallStatus(existing.payload.data.status, data.status); const mergedError = @@ -1091,7 +632,6 @@ export function mergeAgentToolCallItem( return { ...existing, - ...(timelineCursor ? { timelineCursor } : {}), timestamp, payload: { source: "agent", @@ -1111,7 +651,6 @@ function appendAgentToolCall( state: StreamItem[], data: AgentToolCallData, timestamp: Date, - timelineCursor?: TimelinePosition, ): StreamItem[] { const existingIndex = findExistingAgentToolCallIndex(state, data.callId); @@ -1120,7 +659,7 @@ function appendAgentToolCall( if (!existing || !isAgentToolCallItem(existing)) { return state; } - const merged = mergeAgentToolCallItem(existing, data, timestamp, timelineCursor); + const merged = mergeAgentToolCallItem(existing, data, timestamp); if ( merged.payload.data.provider === existing.payload.data.provider && @@ -1129,8 +668,7 @@ function appendAgentToolCall( merged.payload.data.status === existing.payload.data.status && merged.payload.data.error === existing.payload.data.error && merged.payload.data.detail === existing.payload.data.detail && - merged.payload.data.metadata === existing.payload.data.metadata && - merged.timelineCursor === existing.timelineCursor + merged.payload.data.metadata === existing.payload.data.metadata ) { return state; } @@ -1143,7 +681,6 @@ function appendAgentToolCall( const item: ToolCallItem = { kind: "tool_call", id: `agent_tool_${data.callId}`, - ...(timelineCursor ? { timelineCursor } : {}), timestamp, payload: { source: "agent", @@ -1172,7 +709,6 @@ function appendTodoList( provider: AgentProvider, items: TodoEntry[], timestamp: Date, - timelineCursor?: TimelinePosition, ): StreamItem[] { const normalizedItems = items.map((item) => ({ text: item.text, @@ -1184,7 +720,6 @@ function appendTodoList( const next = [...state]; const updated: TodoListItem = { ...lastItem, - ...(timelineCursor ? { timelineCursor } : {}), items: normalizedItems, timestamp, }; @@ -1198,7 +733,6 @@ function appendTodoList( const entry: TodoListItem = { kind: "todo_list", id: entryId, - ...(timelineCursor ? { timelineCursor } : {}), timestamp, provider, items: normalizedItems, @@ -1215,7 +749,6 @@ function reduceTimelineToolCall( { type: "tool_call" } >, timestamp: Date, - timelineCursor?: TimelinePosition, ): StreamItem[] { const normalizedToolName = item.name .trim() @@ -1238,7 +771,6 @@ function reduceTimelineToolCall( event.provider, tasks.map((entry) => ({ text: entry.text, completed: entry.completed })), timestamp, - timelineCursor, ); } @@ -1249,7 +781,6 @@ function reduceTimelineToolCall( event.provider, tasks.map((entry) => ({ text: entry.text, completed: entry.completed })), timestamp, - timelineCursor, ); } @@ -1265,7 +796,6 @@ function reduceTimelineToolCall( metadata: item.metadata, }, timestamp, - timelineCursor, ); } @@ -1276,7 +806,6 @@ function reduceTimelineCompaction( { type: "compaction" } >, timestamp: Date, - timelineCursor?: TimelinePosition, ): StreamItem[] { if (item.status === "completed") { const loadingIdx = state.findIndex((s) => s.kind === "compaction" && s.status === "loading"); @@ -1284,7 +813,6 @@ function reduceTimelineCompaction( if (loadingIdx >= 0 && existing && existing.kind === "compaction") { const updated: CompactionItem = { ...existing, - ...(timelineCursor ? { timelineCursor } : {}), status: "completed", trigger: item.trigger ?? existing.trigger, preTokens: item.preTokens ?? existing.preTokens, @@ -1298,7 +826,6 @@ function reduceTimelineCompaction( const compaction: CompactionItem = { kind: "compaction", id: createTimelineId("compaction", item.status, timestamp), - ...(timelineCursor ? { timelineCursor } : {}), timestamp, status: item.status, trigger: item.trigger, @@ -1326,7 +853,6 @@ function reduceTimelineEvent( source, item.messageId, item.clientMessageId, - timelineCursor, ), ); case "assistant_message": @@ -1342,11 +868,9 @@ function reduceTimelineEvent( ), ); case "reasoning": - return appendThought(state, item.text, timestamp, timelineCursor); + return appendThought(state, item.text, timestamp); case "tool_call": - return finalizeActiveThoughts( - reduceTimelineToolCall(state, event, item, timestamp, timelineCursor), - ); + return finalizeActiveThoughts(reduceTimelineToolCall(state, event, item, timestamp)); case "todo": { if (event.provider === "claude") { return finalizeActiveThoughts(state); @@ -1355,15 +879,12 @@ function reduceTimelineEvent( text: todo.text, completed: todo.completed, })); - return finalizeActiveThoughts( - appendTodoList(state, event.provider, items, timestamp, timelineCursor), - ); + return finalizeActiveThoughts(appendTodoList(state, event.provider, items, timestamp)); } case "error": { const activity: ActivityLogItem = { kind: "activity_log", id: createTimelineId("error", item.message ?? "", timestamp), - ...(timelineCursor ? { timelineCursor } : {}), timestamp, activityType: "error", message: item.message ?? "Unknown error", @@ -1371,9 +892,7 @@ function reduceTimelineEvent( return finalizeActiveThoughts(appendActivityLog(state, activity)); } case "compaction": - return finalizeActiveThoughts( - reduceTimelineCompaction(state, item, timestamp, timelineCursor), - ); + return finalizeActiveThoughts(reduceTimelineCompaction(state, item, timestamp)); default: return state; } @@ -1630,6 +1149,7 @@ export function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamI if (newItems.length === 0) { return tail; } + return [...tail, ...newItems]; } @@ -1657,7 +1177,8 @@ function shouldFlushHead(input: { return true; } - // Find the last streamable item in head (skip trailing non-streamable items). + // Find the last streamable item in head (skip trailing non-streamable + // items like an optimistic user_message appended during interrupt). let lastStreamable: StreamItem | undefined; for (let i = head.length - 1; i >= 0; i--) { if (isStreamableKind(head[i].kind)) { @@ -1688,63 +1209,6 @@ export interface ApplyStreamEventResult { head: StreamItem[]; changedTail: boolean; changedHead: boolean; - acknowledgedClientMessageIds?: string[]; -} - -function applyCanonicalUserMessageEvent(params: { - tail: StreamItem[]; - head: StreamItem[]; - event: AgentStreamEventPayload; - timestamp: Date; - timelineCursor?: TimelinePosition; - unmatchedInsert?: "tail" | "head"; -}): ApplyStreamEventResult | null { - const { tail, head, event, timestamp, timelineCursor, unmatchedInsert = "tail" } = params; - if (event.type !== "timeline" || event.item.type !== "user_message") return null; - const normalized = normalizeChunk(event.item.text); - - const flushedTail = head.length > 0 ? flushHeadToTail(tail, head) : tail; - const flushedHead = head.length > 0 ? [] : head; - const canonical = createUserMessage({ - id: - event.item.messageId ?? - createUniqueTimelineId([...tail, ...head], "user", normalized.chunk.trim(), timestamp), - messageId: event.item.messageId, - clientMessageId: event.item.clientMessageId, - timelineCursor, - text: normalized.chunk, - timestamp, - }); - if (unmatchedInsert === "head") { - const reconciled = upsertUserMessageAcrossStream({ - tail, - head, - message: canonical, - insert: normalized.hasContent ? "head" : "none", - presentation: "existing", - }); - return { - tail: reconciled.tail, - head: reconciled.head, - changedTail: reconciled.changedTail, - changedHead: reconciled.changedHead, - acknowledgedClientMessageIds: - reconciled.location?.matched && reconciled.location.message.clientMessageId - ? [reconciled.location.message.clientMessageId] - : [], - }; - } - const reconciled = placeCanonicalUserMessageAtTail(flushedTail, canonical, normalized.hasContent); - return { - tail: reconciled.items, - head: flushedHead, - changedTail: flushedTail !== tail || reconciled.items !== flushedTail, - changedHead: flushedHead !== head, - acknowledgedClientMessageIds: - reconciled.matched && reconciled.message.clientMessageId - ? [reconciled.message.clientMessageId] - : [], - }; } /** @@ -1765,23 +1229,14 @@ export function applyStreamEvent(params: { timestamp: Date; source?: StreamUpdateSource; timelineCursor?: TimelinePosition; - unmatchedUserMessageInsert?: "tail" | "head"; }): ApplyStreamEventResult { const { tail, head, event, timestamp } = params; - const canonicalUserResult = applyCanonicalUserMessageEvent({ - tail, - head, - event, - timestamp, - timelineCursor: params.timelineCursor, - unmatchedInsert: params.unmatchedUserMessageInsert, - }); - if (canonicalUserResult) return canonicalUserResult; const source = params.source ?? "live"; let nextTail = tail; let nextHead = head; let changedTail = false; let changedHead = false; + const flushHead = () => { if (nextHead.length === 0) { return; diff --git a/packages/app/src/utils/agent-directory-sync.test.ts b/packages/app/src/utils/agent-directory-sync.test.ts index 5330f8aa2..1ec16bde2 100644 --- a/packages/app/src/utils/agent-directory-sync.test.ts +++ b/packages/app/src/utils/agent-directory-sync.test.ts @@ -7,7 +7,6 @@ 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( @@ -65,111 +64,6 @@ 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"; diff --git a/packages/app/src/utils/assistant-image-metadata.test.ts b/packages/app/src/utils/assistant-image-metadata.test.ts index d4b99655f..e6138dc71 100644 --- a/packages/app/src/utils/assistant-image-metadata.test.ts +++ b/packages/app/src/utils/assistant-image-metadata.test.ts @@ -3,6 +3,7 @@ import { clearAssistantImageMetadataCache, estimateAssistantMessageHeightFromCache, extractAssistantImageSources, + getAssistantImageLoadStateFromMetadata, getAssistantImageMetadata, setAssistantImageMetadata, } from "./assistant-image-metadata"; @@ -41,6 +42,23 @@ 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( { diff --git a/packages/app/src/utils/assistant-image-metadata.ts b/packages/app/src/utils/assistant-image-metadata.ts index 9f6e159b0..b11d26baf 100644 --- a/packages/app/src/utils/assistant-image-metadata.ts +++ b/packages/app/src/utils/assistant-image-metadata.ts @@ -8,6 +8,11 @@ export interface AssistantImageMetadata { aspectRatio: number; } +export type AssistantImageLoadState = + | { status: "loading" } + | { status: "ready"; aspectRatio: number } + | { status: "error" }; + const assistantImageMetadataCache = new Map(); const assistantImageParseCache = new Map(); const ASSISTANT_IMAGE_METADATA_CACHE_LIMIT = 500; @@ -126,6 +131,15 @@ 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; diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index bed840c0e..8e67ea4b5 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -324,11 +324,6 @@ 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"; @@ -2864,7 +2859,7 @@ export class DaemonClient { agentId: string, text: string, options?: SendMessageOptions, - ): Promise { + ): Promise { const requestId = this.createRequestId(); const messageId = options?.messageId ?? crypto.randomUUID(); const message = SessionInboundMessageSchema.parse({ @@ -2893,7 +2888,6 @@ 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 { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index a0fdbd5e7..d7f21f124 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -478,9 +478,7 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor latest = result?.agent ?? null; return result; }, - send: async (text, options) => { - await daemonClient.sendAgentMessage(id, text, options); - }, + send: (text, options) => daemonClient.sendAgentMessage(id, text, options), archive: async () => { const result = await daemonClient.archiveAgent(id); if (latest) { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index dd9772e5c..c269321f6 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3710,8 +3710,6 @@ 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(), }), }); diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts index c32d9a25d..98d904076 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts @@ -49,25 +49,6 @@ 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(); @@ -284,39 +265,6 @@ 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 = "![Fixture image](screenshots/fixture.png)"; - - 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-")); diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index 76f9848d8..33b9ddfac 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -36,8 +36,6 @@ 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, @@ -158,13 +156,6 @@ 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)) { @@ -592,7 +583,6 @@ 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; @@ -603,13 +593,6 @@ 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 { @@ -628,10 +611,6 @@ 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(); @@ -678,13 +657,10 @@ 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.scheduleSettledAssistantTurn(turn, JSON.stringify(structuredBranchName)); - } else if (settledAssistantImageMarkdown) { - this.scheduleSettledAssistantTurn(turn, settledAssistantImageMarkdown); + this.scheduleStructuredJsonTurn(turn, structuredBranchName); } else if (shouldEmitPlanApprovalPrompt(prompt)) { this.schedulePlanApprovalTurn(turn); } else if (questionPrompt) { @@ -899,14 +875,14 @@ export class MockLoadTestAgentSession implements AgentSession { turn.timer.unref?.(); } - private scheduleSettledAssistantTurn(turn: ActiveTurn, finalText: string): void { + private scheduleStructuredJsonTurn(turn: ActiveTurn, result: Record): void { turn.timer = setTimeout(() => { - this.emitSettledAssistantTurn(turn, finalText); + this.emitStructuredJsonTurn(turn, result); }, 0); turn.timer.unref?.(); } - private emitSettledAssistantTurn(turn: ActiveTurn, finalText: string): void { + private emitStructuredJsonTurn(turn: ActiveTurn, result: Record): void { if (this.activeTurn !== turn) { return; } @@ -918,6 +894,7 @@ export class MockLoadTestAgentSession implements AgentSession { turnId: turn.turnId, }); + const finalText = JSON.stringify(result); this.emitTimeline(turn.turnId, { type: "assistant_message", text: finalText, @@ -1128,14 +1105,9 @@ 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: `![Synthetic image](data:image/png;base64,${imagePayload})`, + text: `data:image/png;base64,${payload}`, messageId: turn.assistantMessageId, }); } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 51059637a..2682979fe 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -6345,7 +6345,6 @@ export class Session { agentId, accepted: true, error: null, - outOfBand: true, }, }); return; @@ -6373,7 +6372,6 @@ export class Session { agentId, accepted: true, error: null, - outOfBand: false, }, }); } catch (error) {