mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>HAPI history load e2e fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./history-load-fixture.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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']>): 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<MessagesResponse> => {
|
||||
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<Map<string, ChatBlock>>(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 (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<div className="flex h-screen min-h-0 flex-col">
|
||||
<HappyThread
|
||||
api={fakeApi}
|
||||
sessionId={SESSION_ID}
|
||||
metadata={null}
|
||||
disabled={false}
|
||||
onRefresh={() => {}}
|
||||
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={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<I18nProvider>
|
||||
<FixtureThread />
|
||||
</I18nProvider>
|
||||
)
|
||||
Reference in New Issue
Block a user