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( + + + +)