Skip to content

Commit a311cdb

Browse files
fix(assistant): replace unbounded listSessions fallback with paginated lookup
1 parent 735af66 commit a311cdb

2 files changed

Lines changed: 65 additions & 21 deletions

File tree

frontend/src/hooks/useAssistantSessionLauncher.test.tsx

Lines changed: 55 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { initializeAssistantMode } from '@/api/repos'
66

77
const mocks = vi.hoisted(() => ({
88
listSessions: vi.fn(),
9+
listSessionsPage: vi.fn(),
910
createSession: vi.fn(),
1011
sendPromptAsync: vi.fn(),
1112
initializeAssistantMode: vi.fn(),
@@ -18,6 +19,7 @@ vi.mock('@/api/repos', () => ({
1819
vi.mock('@/api/opencode', () => ({
1920
OpenCodeClient: vi.fn(() => ({
2021
listSessions: mocks.listSessions,
22+
listSessionsPage: mocks.listSessionsPage,
2123
createSession: mocks.createSession,
2224
sendPromptAsync: mocks.sendPromptAsync,
2325
})),
@@ -35,12 +37,14 @@ describe('useAssistantSessionLauncher', () => {
3537
})
3638

3739
it('opens the latest root session in the assistant directory', async () => {
38-
mocks.listSessions.mockResolvedValue([
39-
{ id: 'older', directory: '/assistant', time: { updated: 10 } },
40-
{ id: 'newest-child', parentID: 'newest', directory: '/assistant', time: { updated: 40 } },
41-
{ id: 'different-directory', directory: '/other', time: { updated: 50 } },
42-
{ id: 'newest', directory: '/assistant', time: { updated: 30 } },
43-
])
40+
mocks.listSessionsPage.mockResolvedValue({
41+
items: [
42+
{ id: 'older', directory: '/assistant', time: { updated: 10 } },
43+
{ id: 'newest-child', parentID: 'newest', directory: '/assistant', time: { updated: 40 } },
44+
{ id: 'different-directory', directory: '/other', time: { updated: 50 } },
45+
{ id: 'newest', directory: '/assistant', time: { updated: 30 } },
46+
],
47+
})
4448
const onNavigate = vi.fn()
4549
const { result } = renderHook(() => useAssistantSessionLauncher({
4650
repoId: 123,
@@ -54,13 +58,44 @@ describe('useAssistantSessionLauncher', () => {
5458

5559
expect(initializeAssistantMode).toHaveBeenCalledWith(123)
5660
expect(OpenCodeClient).toHaveBeenCalledWith('http://localhost:5551', '/assistant')
57-
expect(mocks.listSessions).toHaveBeenCalledWith({ limit: 1, roots: true })
61+
expect(mocks.listSessionsPage).toHaveBeenCalledWith({ limit: 25, order: 'desc' })
62+
expect(mocks.listSessions).not.toHaveBeenCalled()
5863
expect(onNavigate).toHaveBeenCalledWith('newest')
5964
expect(localStorage.getItem('ocm:assistant:last-session:123:/assistant')).toBe('newest')
6065
expect(mocks.createSession).not.toHaveBeenCalled()
6166
expect(mocks.sendPromptAsync).not.toHaveBeenCalled()
6267
})
6368

69+
it('paginates assistant sessions instead of making an unbounded session list request', async () => {
70+
mocks.listSessionsPage
71+
.mockResolvedValueOnce({
72+
items: [
73+
{ id: 'newest-child', parentID: 'newest', directory: '/assistant', time: { updated: 40 } },
74+
],
75+
nextCursor: 'next-page',
76+
})
77+
.mockResolvedValueOnce({
78+
items: [
79+
{ id: 'newest', directory: '/assistant', time: { updated: 30 } },
80+
],
81+
})
82+
const onNavigate = vi.fn()
83+
const { result } = renderHook(() => useAssistantSessionLauncher({
84+
repoId: 123,
85+
opcodeUrl: 'http://localhost:5551',
86+
onNavigate,
87+
}))
88+
89+
await act(async () => {
90+
await result.current.openAssistant()
91+
})
92+
93+
expect(mocks.listSessionsPage).toHaveBeenNthCalledWith(1, { limit: 25, order: 'desc' })
94+
expect(mocks.listSessionsPage).toHaveBeenNthCalledWith(2, { cursor: 'next-page' })
95+
expect(mocks.listSessions).not.toHaveBeenCalled()
96+
expect(onNavigate).toHaveBeenCalledWith('newest')
97+
})
98+
6499
it('notifies an existing assistant session when some generated updates were preserved', async () => {
65100
mocks.initializeAssistantMode.mockResolvedValue({
66101
directory: '/assistant',
@@ -72,9 +107,11 @@ describe('useAssistantSessionLauncher', () => {
72107
},
73108
],
74109
})
75-
mocks.listSessions.mockResolvedValue([
76-
{ id: 'existing', directory: '/assistant', time: { updated: 10 } },
77-
])
110+
mocks.listSessionsPage.mockResolvedValue({
111+
items: [
112+
{ id: 'existing', directory: '/assistant', time: { updated: 10 } },
113+
],
114+
})
78115
const onNavigate = vi.fn()
79116
const { result } = renderHook(() => useAssistantSessionLauncher({
80117
repoId: 123,
@@ -87,7 +124,7 @@ describe('useAssistantSessionLauncher', () => {
87124
})
88125

89126
expect(onNavigate).toHaveBeenCalledWith('existing')
90-
expect(mocks.listSessions).toHaveBeenCalledWith({ limit: 1, roots: true })
127+
expect(mocks.listSessionsPage).toHaveBeenCalledWith({ limit: 25, order: 'desc' })
91128
expect(mocks.sendPromptAsync).toHaveBeenCalledWith('existing', {
92129
parts: [
93130
expect.objectContaining({
@@ -101,9 +138,11 @@ describe('useAssistantSessionLauncher', () => {
101138
})
102139

103140
it('creates a session when the assistant directory has no root sessions', async () => {
104-
mocks.listSessions.mockResolvedValue([
105-
{ id: 'other', directory: '/other', time: { updated: 50 } },
106-
])
141+
mocks.listSessionsPage.mockResolvedValue({
142+
items: [
143+
{ id: 'other', directory: '/other', time: { updated: 50 } },
144+
],
145+
})
107146
mocks.createSession.mockResolvedValue({ id: 'created' })
108147
const onNavigate = vi.fn()
109148
const { result } = renderHook(() => useAssistantSessionLauncher({
@@ -141,7 +180,7 @@ describe('useAssistantSessionLauncher', () => {
141180
})
142181

143182
it('navigates after creating a session without waiting for the welcome prompt to complete', async () => {
144-
mocks.listSessions.mockResolvedValue([])
183+
mocks.listSessionsPage.mockResolvedValue({ items: [] })
145184
let resolvePrompt: () => void
146185
const promptPromise = new Promise<void>((resolve) => {
147186
resolvePrompt = resolve
@@ -172,7 +211,7 @@ describe('useAssistantSessionLauncher', () => {
172211
})
173212

174213
it('navigates even when welcome prompt fails', async () => {
175-
mocks.listSessions.mockResolvedValue([])
214+
mocks.listSessionsPage.mockResolvedValue({ items: [] })
176215
mocks.createSession.mockResolvedValue({ id: 'created' })
177216
mocks.sendPromptAsync.mockRejectedValueOnce(new Error('provider unavailable'))
178217
const onNavigate = vi.fn()

frontend/src/hooks/useAssistantSessionLauncher.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ interface UseAssistantSessionLauncherOptions {
1212

1313
type OpenCodeSession = components['schemas']['Session']
1414

15+
const ASSISTANT_SESSION_LOOKUP_PAGE_SIZE = 25
16+
1517
const LAST_ASSISTANT_SESSION_KEY_PREFIX = 'ocm:assistant:last-session'
1618

1719
function getLastAssistantSessionKey(repoId: number, directory: string): string {
@@ -40,12 +42,15 @@ async function getLatestAssistantSession(
4042
client: OpenCodeClient,
4143
assistantDirectory: string,
4244
): Promise<OpenCodeSession | undefined> {
43-
const latestSessions = await client.listSessions({ limit: 1, roots: true })
44-
const latestRootSession = findNewestRootAssistantSession(latestSessions, assistantDirectory)
45-
if (latestRootSession || latestSessions.length === 0) return latestRootSession
45+
let page = await client.listSessionsPage({ limit: ASSISTANT_SESSION_LOOKUP_PAGE_SIZE, order: 'desc' })
46+
let latestRootSession = findNewestRootAssistantSession(page.items, assistantDirectory)
47+
48+
while (!latestRootSession && page.nextCursor) {
49+
page = await client.listSessionsPage({ cursor: page.nextCursor })
50+
latestRootSession = findNewestRootAssistantSession(page.items, assistantDirectory)
51+
}
4652

47-
const sessions = await client.listSessions()
48-
return findNewestRootAssistantSession(sessions, assistantDirectory)
53+
return latestRootSession
4954
}
5055

5156
const ASSISTANT_WELCOME_PROMPT = `Welcome to OpenCode Manager! I'm your assistant and I'm here to help you work with your code.

0 commit comments

Comments
 (0)