From a469d66bc4d6b86f244d91afb1e25089dd20efc0 Mon Sep 17 00:00:00 2001 From: weishu Date: Tue, 28 Jul 2026 19:34:52 +0800 Subject: [PATCH] fix(web): patch assistant-ui tap scheduler for bulk history prepends Loading an older page prepends hundreds of messages in one flush. tap's scheduler aborts after 50 dirty resources and drops the overflow, so the thread never applied the merged page: the scroll-restore gate never passed and the top sentinel kept re-triggering (loads everything at once). Raise MAX_FLUSH_LIMIT 50->2000 via bun patchedDependencies. Adds a Playwright regression spec driving the real message-window store and HappyThread against a fake paginated API: one page per top approach, scroll restored, no idle reloads. --- AGENTS.md | 4 + bun.lock | 3 + e2e/history-load.spec.ts | 56 +++++ package.json | 3 + patches/@assistant-ui%2Ftap@0.3.5.patch | 10 + web/e2e-fixtures/history-load-fixture.html | 12 ++ web/e2e-fixtures/history-load-fixture.tsx | 228 +++++++++++++++++++++ 7 files changed, 316 insertions(+) create mode 100644 e2e/history-load.spec.ts create mode 100644 patches/@assistant-ui%2Ftap@0.3.5.patch create mode 100644 web/e2e-fixtures/history-load-fixture.html create mode 100644 web/e2e-fixtures/history-load-fixture.tsx diff --git a/AGENTS.md b/AGENTS.md index dd7eb586..e8b86be3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,10 @@ Bun workspaces; `shared` consumed by cli, hub, web. - Prefer 4-space indentation - Zod for runtime validation (schemas in `shared/src/schemas.ts`) +## Patched dependencies + +- `@assistant-ui/tap` (`patches/`): raises tap scheduler `MAX_FLUSH_LIMIT` 50→2000. Bulk message prepends (older-history pages) create hundreds of tap resources in one flush; the default limit throws and drops the overflow, leaving the thread stale. Re-check the patch when upgrading `@assistant-ui/react`. + ## Common commands (repo root) ```bash diff --git a/bun.lock b/bun.lock index 48a9b488..ac408885 100644 --- a/bun.lock +++ b/bun.lock @@ -242,6 +242,9 @@ }, }, }, + "patchedDependencies": { + "@assistant-ui/tap@0.3.5": "patches/@assistant-ui%2Ftap@0.3.5.patch", + }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], diff --git a/e2e/history-load.spec.ts b/e2e/history-load.spec.ts new file mode 100644 index 00000000..4c5fd7c9 --- /dev/null +++ b/e2e/history-load.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from '@playwright/test' + +// Regression: loading an older page prepends hundreds of messages at once. +// assistant-ui's tap scheduler aborts a flush with more than 50 dirty +// resources and drops the rest, so the thread never reflected the merged +// page (see patches/@assistant-ui%2Ftap@0.3.5.patch). This spec drives the +// real message-window store + HappyThread against a fake paginated API and +// pins the contract: one page per top approach, scroll position restored. +test('scroll-to-top loads one page per approach with correct scroll restore', async ({ page }) => { + await page.goto('/e2e-fixtures/history-load-fixture.html') + const viewport = page.locator('.app-scroll-y') + await expect(viewport).toBeVisible() + // Wait for the initial tail sync plus the initial scroll-settling window. + await page.waitForTimeout(3500) + + await page.evaluate(() => { + (document.querySelector('.app-scroll-y') as HTMLElement).scrollTop = 0 + }) + await page.waitForTimeout(2000) + + const afterFirst = await page.evaluate(() => { + const el = document.querySelector('.app-scroll-y') as HTMLElement + return { + scrollTop: Math.round(el.scrollTop), + childCount: document.querySelector('.happy-thread-messages')?.childElementCount ?? 0, + beforeReqs: window.__probe.requests.filter((r) => r.direction === 'before').length + } + }) + + // Exactly one page loaded, the DOM shows it, scroll restored away from top. + expect(afterFirst.beforeReqs).toBe(1) + expect(afterFirst.childCount).toBe(400) + expect(afterFirst.scrollTop).toBeGreaterThan(1000) + + // Idle watch: no further loads may happen without another scroll. + await page.waitForTimeout(2500) + const idleReqs = await page.evaluate(() => window.__probe.requests.filter((r) => r.direction === 'before').length) + expect(idleReqs).toBe(1) + + // Second approach to the top loads exactly one more page. + await page.evaluate(() => { + (document.querySelector('.app-scroll-y') as HTMLElement).scrollTop = 0 + }) + await page.waitForTimeout(2000) + const afterSecond = await page.evaluate(() => { + const el = document.querySelector('.app-scroll-y') as HTMLElement + return { + scrollTop: Math.round(el.scrollTop), + childCount: document.querySelector('.happy-thread-messages')?.childElementCount ?? 0, + beforeReqs: window.__probe.requests.filter((r) => r.direction === 'before').length + } + }) + expect(afterSecond.beforeReqs).toBe(2) + expect(afterSecond.childCount).toBe(600) + expect(afterSecond.scrollTop).toBeGreaterThan(1000) +}) diff --git a/package.json b/package.json index 1f518449..02b9dd93 100644 --- a/package.json +++ b/package.json @@ -37,5 +37,8 @@ "playwright": "1.60.0", "react-devtools-core": "^7.0.1", "vite-plugin-pwa": "^1.2.0" + }, + "patchedDependencies": { + "@assistant-ui/tap@0.3.5": "patches/@assistant-ui%2Ftap@0.3.5.patch" } } diff --git a/patches/@assistant-ui%2Ftap@0.3.5.patch b/patches/@assistant-ui%2Ftap@0.3.5.patch new file mode 100644 index 00000000..80f95a9c --- /dev/null +++ b/patches/@assistant-ui%2Ftap@0.3.5.patch @@ -0,0 +1,10 @@ +diff --git a/dist/core/scheduler.js b/dist/core/scheduler.js +index a61a05417fca038339e8e80c2dee5ee2ea4ef211..05e10177ce4d69bac63d02fee06cd151ea0f62ba 100644 +--- a/dist/core/scheduler.js ++++ b/dist/core/scheduler.js +@@ -1,4 +1,4 @@ +-const MAX_FLUSH_LIMIT = 50; ++const MAX_FLUSH_LIMIT = 2000; + let flushState = { + schedulers: new Set([]), + isScheduled: false, diff --git a/web/e2e-fixtures/history-load-fixture.html b/web/e2e-fixtures/history-load-fixture.html new file mode 100644 index 00000000..cf0841b1 --- /dev/null +++ b/web/e2e-fixtures/history-load-fixture.html @@ -0,0 +1,12 @@ + + + + + + HAPI history load e2e fixture + + +
+ + + diff --git a/web/e2e-fixtures/history-load-fixture.tsx b/web/e2e-fixtures/history-load-fixture.tsx new file mode 100644 index 00000000..ec60931c --- /dev/null +++ b/web/e2e-fixtures/history-load-fixture.tsx @@ -0,0 +1,228 @@ +import { useMemo, useRef } from 'react' +import ReactDOM from 'react-dom/client' +import { AssistantRuntimeProvider } from '@assistant-ui/react' +import '../src/index.css' +import type { ApiClient } from '../src/api/client' +import type { DecryptedMessage, MessagesResponse, Session } from '../src/types/api' +import { I18nProvider } from '../src/lib/i18n-context' +import { useMessages } from '../src/hooks/queries/useMessages' +import { normalizeDecryptedMessage } from '../src/chat/normalize' +import { reduceChatBlocks } from '../src/chat/reducer' +import { reconcileChatBlocks } from '../src/chat/reconcile' +import { buildVisibleChatBlocks } from '../src/chat/toolGroups' +import { isQueuedForInvocation } from '../src/lib/messages' +import { useHappyRuntime } from '../src/lib/assistant-runtime' +import { HappyThread } from '../src/components/AssistantChat/HappyThread' +import type { ChatBlock } from '../src/chat/types' + +// Drives the real message-window store + chat pipeline + HappyThread against a +// fake paginated message API, so e2e tests can exercise older-history loading +// without a hub. `window.__probe.requests` records every API call for +// assertions (e.g. "exactly one older page per top-approach"). + +const SESSION_ID = 'history-load-fixture' +const TOTAL_MESSAGES = 1200 +const BASE_AT = 1_700_000_000_000 + +type Probe = { + requests: { direction: string; beforeSeq: number | null; limit: number | undefined; at: number }[] +} + +declare global { + interface Window { + __probe: Probe + } +} + +window.__probe = { requests: [] } + +const allMessages: DecryptedMessage[] = Array.from({ length: TOTAL_MESSAGES }, (_, index) => { + const seq = index + 1 + return { + id: `m-${seq}`, + seq, + localId: null, + content: { + role: 'user', + content: { type: 'text', text: `Fixture message ${seq}` } + }, + createdAt: BASE_AT + seq, + invokedAt: BASE_AT + seq + } as DecryptedMessage +}) + +function positionOf(message: DecryptedMessage): { at: number; seq: number } { + return { at: message.invokedAt ?? message.createdAt, seq: message.seq ?? 0 } +} + +function pageFrom(messages: DecryptedMessage[], overrides: Partial): MessagesResponse['page'] { + const oldest = messages[0] ?? null + const newest = messages[messages.length - 1] ?? null + return { + direction: 'latest', + limit: 200, + epoch: 1, + reset: false, + nextBeforeSeq: oldest?.seq ?? null, + nextBeforeAt: oldest ? positionOf(oldest).at : null, + nextAfterSeq: newest?.seq ?? null, + nextAfterAt: newest ? positionOf(newest).at : null, + snapshotHeadSeq: newest?.seq ?? null, + snapshotHeadAt: newest ? positionOf(newest).at : null, + hasMore: false, + ...overrides + } +} + +const fakeApi = { + getMessages: async (_sessionId: string, query: { + limit?: number + beforeAt?: number | null + beforeSeq?: number | null + afterAt?: number | null + afterSeq?: number | null + }): Promise => { + const limit = query.limit ?? 200 + let direction = 'latest' + if (query.beforeSeq != null || query.beforeAt != null) direction = 'before' + else if (query.afterSeq != null || query.afterAt != null) direction = 'after' + window.__probe.requests.push({ + direction, + beforeSeq: query.beforeSeq ?? null, + limit: query.limit, + at: Date.now() + }) + // Small async delay to mimic latency. + await new Promise((resolve) => setTimeout(resolve, 50)) + + if (direction === 'before') { + const cursorAt = query.beforeAt ?? Number.POSITIVE_INFINITY + const cursorSeq = query.beforeSeq ?? Number.POSITIVE_INFINITY + const older = allMessages.filter((message) => { + const position = positionOf(message) + return position.at < cursorAt || (position.at === cursorAt && position.seq < cursorSeq) + }) + const pageMessages = older.slice(-limit) + const oldest = pageMessages[0] ?? null + return { + messages: pageMessages, + page: pageFrom(pageMessages, { + direction: 'before', + limit, + hasMore: older.length > pageMessages.length, + nextBeforeSeq: oldest?.seq ?? null, + nextBeforeAt: oldest ? positionOf(oldest).at : null, + nextAfterSeq: null, + nextAfterAt: null, + snapshotHeadSeq: null, + snapshotHeadAt: null + }) + } + } + + const pageMessages = allMessages.slice(-limit) + return { + messages: pageMessages, + page: pageFrom(pageMessages, { + direction: 'latest', + limit, + reset: true, + hasMore: allMessages.length > pageMessages.length + }) + } + } +} as unknown as ApiClient + +const fakeSession = { + id: SESSION_ID, + active: true, + thinking: false, + agentState: null, + metadata: { path: '/tmp/fixture', host: 'fixture' } +} as unknown as Session + +const noopSend = () => {} +const noopAbort = async () => {} + +function FixtureThread() { + const { + messages, + warning, + isSyncingTail, + isLoadingMore, + hasMore, + unseenCount, + messagesVersion, + historyVersion, + loadMore, + setViewMode + } = useMessages(fakeApi, SESSION_ID) + + const blocksByIdRef = useRef>(new Map()) + + const normalizedMessages = useMemo(() => { + const normalized = [] + for (const message of messages) { + if (isQueuedForInvocation(message)) continue + const next = normalizeDecryptedMessage(message) + if (next) normalized.push(next) + } + return normalized + }, [messages]) + + const reduced = useMemo(() => reduceChatBlocks(normalizedMessages, null, {}), [normalizedMessages]) + const reconciled = useMemo( + () => reconcileChatBlocks(reduced.blocks, blocksByIdRef.current), + [reduced.blocks] + ) + blocksByIdRef.current = reconciled.byId + const visibleBlocks = useMemo( + () => buildVisibleChatBlocks(reconciled.blocks, { hasMoreMessages: hasMore }), + [reconciled.blocks, hasMore] + ) + + const runtime = useHappyRuntime({ + session: fakeSession, + blocks: visibleBlocks, + messagesVersion, + historyVersion, + isSending: false, + onSendMessage: noopSend, + onAbort: noopAbort + }) + + return ( + +
+ {}} + onViewModeChange={setViewMode} + isSyncingTail={isSyncingTail} + messagesWarning={warning} + hasMoreMessages={hasMore} + isLoadingMoreMessages={isLoadingMore} + onLoadMore={loadMore} + unseenCount={unseenCount} + rawMessagesCount={messages.length} + normalizedMessagesCount={normalizedMessages.length} + messagesVersion={messagesVersion} + historyVersion={historyVersion} + forceScrollToken={0} + outlineOpen={false} + outlineItems={[]} + onOutlineOpenChange={() => {}} + /> +
+
+ ) +} + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +)