session-search.host.spec.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891
  1. /**
  2. * Session Controller search projection: list-equivalent visibility, fixed message
  3. * filters and result bound, cancellation mapping, and unavailable/failure
  4. * behavior.
  5. */
  6. import { describe, expect, it, vi } from 'vitest'
  7. import { Context } from '@deepseek-ai/cordis'
  8. import { stat } from 'node:fs/promises'
  9. import AgentRegistry from '@deepseek-ai/dsh-agent'
  10. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  11. import SessionStore from '@deepseek-ai/dsh-session'
  12. import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
  13. import {
  14. SessionQueryError,
  15. type SessionSearchHit,
  16. type SessionSearchRequest,
  17. } from '@deepseek-ai/dsh-session-query'
  18. import { createSessionTestRemote } from './test-remote.ts'
  19. vi.mock('node:fs/promises', async (importOriginal) => {
  20. const actual = await importOriginal<typeof import('node:fs/promises')>()
  21. return { ...actual, stat: vi.fn(actual.stat) }
  22. })
  23. const sid = (value: string): SessionId => value as SessionId
  24. const defaults = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
  25. function request(query: string): { query: string } {
  26. return { query }
  27. }
  28. function header(id: string, cwd: string | null = '/project'): SessionHeader {
  29. return {
  30. version: 0,
  31. id: sid(id),
  32. createdAt: 100,
  33. ...(cwd === null ? {} : { cwd }),
  34. }
  35. }
  36. function hit(id: string, index = 0): SessionSearchHit {
  37. const session = header(id)
  38. return {
  39. header: session,
  40. live: true,
  41. persisted: false,
  42. bestMatch: {
  43. sessionId: session.id,
  44. seq: index,
  45. type: 'user/message',
  46. time: 200 + index,
  47. surface: 'current',
  48. snippet: `match ${index}`,
  49. },
  50. }
  51. }
  52. async function baseContext(): Promise<Context> {
  53. const ctx = new Context()
  54. await ctx.plugin(SessionStore)
  55. await ctx.plugin(AgentRegistry)
  56. return ctx
  57. }
  58. describe('session.search', () => {
  59. it('searches only list-visible ids and current conversation-message events', async () => {
  60. const ctx = await baseContext()
  61. const live = ctx.sessions.create(sid('live'), { meta: header('live', '/live') })
  62. live.append('user/message', createUserMessage({
  63. content: [{ type: 'text', text: 'live text' }],
  64. source: { kind: 'user' },
  65. }), { surfaceOp: 'append' })
  66. const cold = header('cold', '/cold')
  67. const legacy = header('legacy', null)
  68. ctx.provide('sessionPersistence', {
  69. list: () => Promise.resolve([cold, legacy]),
  70. locate: () => undefined,
  71. } as never)
  72. const searchSessions = vi.fn((
  73. _request: SessionSearchRequest,
  74. _exec?: { signal?: AbortSignal },
  75. ) => Promise.resolve({
  76. items: [
  77. {
  78. header: legacy,
  79. live: false,
  80. persisted: true,
  81. bestMatch: {
  82. sessionId: legacy.id,
  83. seq: 3,
  84. type: 'user/message' as const,
  85. time: 190,
  86. surface: 'current' as const,
  87. snippet: 'must remain hidden',
  88. },
  89. },
  90. {
  91. header: cold,
  92. live: false,
  93. persisted: true,
  94. bestMatch: {
  95. sessionId: cold.id,
  96. seq: 4,
  97. type: 'assistant/message' as const,
  98. time: 200,
  99. surface: 'current' as const,
  100. snippet: 'the matching answer',
  101. },
  102. },
  103. ],
  104. }))
  105. ctx.provide('sessionQuery', { searchSessions } as never)
  106. const remote = createSessionTestRemote(ctx, defaults)
  107. const signal = new AbortController().signal
  108. const response = await remote.search(request(' matching answer '), signal)
  109. expect(response).toEqual({
  110. ok: true,
  111. value: {
  112. items: [{ sessionId: 'cold', snippet: 'the matching answer' }],
  113. hasMore: false,
  114. },
  115. })
  116. expect(searchSessions).toHaveBeenCalledOnce()
  117. const [query, exec] = searchSessions.mock.calls[0] as unknown as [
  118. SessionSearchRequest,
  119. { signal: AbortSignal },
  120. ]
  121. expect(query).toEqual({
  122. query: 'matching answer',
  123. eventFilters: [
  124. {
  125. kind: 'type',
  126. values: ['user/message', 'assistant/message'],
  127. },
  128. { kind: 'surface', values: ['current'] },
  129. ],
  130. limit: 20,
  131. })
  132. expect(exec.signal).toBe(signal)
  133. })
  134. it('rejects invalid wire queries before invoking the search provider', async () => {
  135. const ctx = await baseContext()
  136. ctx.sessions.create(sid('visible'), { meta: header('visible') })
  137. const searchSessions = vi.fn()
  138. ctx.provide('sessionQuery', { searchSessions } as never)
  139. const remote = createSessionTestRemote(ctx, defaults)
  140. for (const query of ['', ' ', 'contains\0nul', 'x'.repeat(501)]) {
  141. await expect(remote.search(request(query), new AbortController().signal))
  142. .resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } })
  143. }
  144. expect(searchSessions).not.toHaveBeenCalled()
  145. await ctx.fiber.dispose()
  146. })
  147. it('returns an empty page without invoking the index when no session is visible', async () => {
  148. const ctx = await baseContext()
  149. const searchSessions = vi.fn()
  150. ctx.provide('sessionQuery', { searchSessions } as never)
  151. const remote = createSessionTestRemote(ctx, defaults)
  152. const response = await remote.search(
  153. request('anything'),
  154. new AbortController().signal,
  155. )
  156. expect(response).toEqual({
  157. ok: true,
  158. value: { items: [], hasMore: false },
  159. })
  160. expect(searchSessions).not.toHaveBeenCalled()
  161. })
  162. it('rejects snippets whose recorded provider violates the Host filters', async () => {
  163. const ctx = await baseContext()
  164. const visible = hit('visible')
  165. ctx.sessions.create(visible.header.id, { meta: visible.header })
  166. const withBestMatch = (
  167. index: number,
  168. bestMatch: Partial<SessionSearchHit['bestMatch']>,
  169. ): SessionSearchHit => {
  170. const base = hit('visible', index)
  171. return { ...base, bestMatch: { ...base.bestMatch, ...bestMatch } }
  172. }
  173. ctx.provide('sessionQuery', {
  174. searchSessions: () => Promise.resolve({
  175. items: [
  176. withBestMatch(0, { sessionId: sid('hidden') }),
  177. withBestMatch(1, { surface: 'shadowed' }),
  178. withBestMatch(2, { type: 'tool/result' }),
  179. withBestMatch(3, { type: 'user/message', snippet: 'allowed snippet' }),
  180. ],
  181. }),
  182. } as never)
  183. const response = await createSessionTestRemote(ctx, defaults).search(
  184. request('match'),
  185. new AbortController().signal,
  186. )
  187. expect(response).toEqual({
  188. ok: true,
  189. value: {
  190. items: [{ sessionId: 'visible', snippet: 'allowed snippet' }],
  191. hasMore: false,
  192. },
  193. })
  194. })
  195. it('pages the globally ranked stream until the 20-item Host boundary is known', async () => {
  196. const ctx = await baseContext()
  197. const items = Array.from({ length: 22 }, (_, index) => hit(`visible-${index}`, index))
  198. for (const item of items) {
  199. ctx.sessions.create(item.header.id, { meta: item.header })
  200. }
  201. const searchSessions = vi.fn()
  202. .mockResolvedValueOnce({
  203. items: [hit('hidden-ranked-first'), ...items.slice(0, 19)],
  204. nextCursor: 'page-2',
  205. })
  206. .mockResolvedValueOnce({ items: items.slice(19) })
  207. ctx.provide('sessionQuery', {
  208. searchSessions,
  209. } as never)
  210. const response = await createSessionTestRemote(ctx, defaults).search(
  211. request('match'),
  212. new AbortController().signal,
  213. )
  214. expect(response).toMatchObject({
  215. ok: true,
  216. value: { hasMore: true },
  217. })
  218. if (!response.ok) throw new Error('unreachable')
  219. expect(response.value.items).toHaveLength(20)
  220. expect(response.value.items.at(-1)?.sessionId).toBe('visible-19')
  221. expect(searchSessions).toHaveBeenCalledTimes(2)
  222. expect(searchSessions.mock.calls[1]?.[0]).toMatchObject({ cursor: 'page-2' })
  223. })
  224. it('learns a provider maxLimit of 10 and collects the 20-item result plus lookahead', async () => {
  225. const ctx = await baseContext()
  226. const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
  227. for (const item of items) {
  228. ctx.sessions.create(item.header.id, { meta: item.header })
  229. }
  230. const invalidLimit = new SessionQueryError(
  231. 'provider accepts at most 10 items',
  232. 'SESSION_QUERY_INVALID_LIMIT',
  233. )
  234. const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
  235. const limit = providerRequest.limit
  236. if (limit === undefined) throw new Error('Host search must request an explicit provider limit')
  237. if (limit > 10) return Promise.reject(invalidLimit)
  238. const offset = providerRequest.cursor === undefined
  239. ? 0
  240. : Number.parseInt(providerRequest.cursor.slice('offset-'.length), 10)
  241. const end = Math.min(items.length, offset + limit)
  242. return Promise.resolve({
  243. items: items.slice(offset, end),
  244. ...end < items.length ? { nextCursor: `offset-${end}` } : {},
  245. })
  246. })
  247. ctx.provide('sessionQuery', { searchSessions } as never)
  248. const response = await createSessionTestRemote(ctx, defaults).search(
  249. request('adaptive-page-limit'),
  250. new AbortController().signal,
  251. )
  252. expect(response).toMatchObject({
  253. ok: true,
  254. value: { hasMore: true },
  255. })
  256. if (!response.ok) throw new Error('unreachable')
  257. expect(response.value.items.map(item => item.sessionId))
  258. .toEqual(items.slice(0, 20).map(item => item.header.id))
  259. expect(searchSessions.mock.calls.map(([providerRequest]) => ({
  260. limit: providerRequest.limit,
  261. cursor: providerRequest.cursor,
  262. }))).toEqual([
  263. { limit: 20, cursor: undefined },
  264. { limit: 10, cursor: undefined },
  265. { limit: 10, cursor: 'offset-10' },
  266. { limit: 10, cursor: 'offset-20' },
  267. ])
  268. })
  269. it('counts a page-limit probe inside the 100-call budget', async () => {
  270. const ctx = await baseContext()
  271. ctx.sessions.create(sid('visible'), { meta: header('visible') })
  272. const invalidLimit = new SessionQueryError(
  273. 'provider accepts at most 10 items',
  274. 'SESSION_QUERY_INVALID_LIMIT',
  275. )
  276. const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
  277. if (searchSessions.mock.calls.length === 1) {
  278. expect(providerRequest).toMatchObject({ limit: 20 })
  279. return Promise.reject(invalidLimit)
  280. }
  281. expect(providerRequest.limit).toBe(10)
  282. return Promise.resolve({
  283. items: [],
  284. nextCursor: `page-${searchSessions.mock.calls.length}`,
  285. })
  286. })
  287. ctx.provide('sessionQuery', { searchSessions } as never)
  288. const response = await createSessionTestRemote(ctx, defaults).search(
  289. request('endless-pages'),
  290. new AbortController().signal,
  291. )
  292. expect(response.ok).toBe(false)
  293. if (response.ok) throw new Error('unreachable')
  294. expect(response.error).toMatchObject({ code: 'internal' })
  295. expect(response.error.message).toContain('100-call work budget')
  296. expect(searchSessions).toHaveBeenCalledTimes(100)
  297. })
  298. it('restarts a stale continuation with its learned limit and original visibility snapshot', async () => {
  299. const ctx = await baseContext()
  300. const oldOnly = hit('old-only', 0)
  301. const shared = hit('shared', 1)
  302. const freshFirst = hit('fresh-first', 2)
  303. const freshLast = hit('fresh-last', 3)
  304. for (const item of [oldOnly, shared, freshFirst, freshLast]) {
  305. ctx.sessions.create(item.header.id, { meta: item.header })
  306. }
  307. const late = hit('late-visible', 4)
  308. const stale = new SessionQueryError(
  309. 'provider generation changed',
  310. 'SESSION_QUERY_STALE_CURSOR',
  311. )
  312. const invalidLimit = new SessionQueryError(
  313. 'provider accepts at most 10 items',
  314. 'SESSION_QUERY_INVALID_LIMIT',
  315. )
  316. const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
  317. switch (searchSessions.mock.calls.length) {
  318. case 1:
  319. expect(providerRequest).toMatchObject({ limit: 20 })
  320. expect(providerRequest).not.toHaveProperty('cursor')
  321. return Promise.reject(invalidLimit)
  322. case 2:
  323. expect(providerRequest).toMatchObject({ limit: 10 })
  324. expect(providerRequest).not.toHaveProperty('cursor')
  325. return Promise.resolve({
  326. items: [oldOnly, shared],
  327. nextCursor: 'old-cursor',
  328. })
  329. case 3:
  330. expect(providerRequest).toMatchObject({ limit: 10 })
  331. expect(providerRequest.cursor).toBe('old-cursor')
  332. ctx.sessions.create(late.header.id, { meta: late.header })
  333. return Promise.reject(stale)
  334. case 4:
  335. expect(providerRequest).toMatchObject({ limit: 10 })
  336. expect(providerRequest).not.toHaveProperty('cursor')
  337. return Promise.resolve({
  338. items: [freshFirst, shared],
  339. nextCursor: 'old-cursor',
  340. })
  341. case 5:
  342. expect(providerRequest).toMatchObject({ limit: 10 })
  343. expect(providerRequest.cursor).toBe('old-cursor')
  344. return Promise.resolve({ items: [freshLast, late] })
  345. default:
  346. return Promise.reject(new Error('unexpected provider call'))
  347. }
  348. })
  349. ctx.provide('sessionQuery', { searchSessions } as never)
  350. const response = await createSessionTestRemote(ctx, defaults).search(
  351. request('stale-restart'),
  352. new AbortController().signal,
  353. )
  354. expect(response).toEqual({
  355. ok: true,
  356. value: {
  357. items: [
  358. { sessionId: 'fresh-first', snippet: 'match 2' },
  359. { sessionId: 'shared', snippet: 'match 1' },
  360. { sessionId: 'fresh-last', snippet: 'match 3' },
  361. ],
  362. hasMore: false,
  363. },
  364. })
  365. expect(searchSessions).toHaveBeenCalledTimes(5)
  366. })
  367. it('counts continuous stale restarts against the 100-call budget', async () => {
  368. const ctx = await baseContext()
  369. const partial = hit('partial')
  370. ctx.sessions.create(partial.header.id, { meta: partial.header })
  371. const stale = new SessionQueryError(
  372. 'provider generation changed',
  373. 'SESSION_QUERY_STALE_CURSOR',
  374. )
  375. const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
  376. if (searchSessions.mock.calls.length > 100) {
  377. return Promise.reject(new Error('provider was called after the shared budget'))
  378. }
  379. if (providerRequest.cursor !== undefined) return Promise.reject(stale)
  380. return Promise.resolve({
  381. items: [partial],
  382. nextCursor: `cursor-${searchSessions.mock.calls.length}`,
  383. })
  384. })
  385. ctx.provide('sessionQuery', { searchSessions } as never)
  386. const response = await createSessionTestRemote(ctx, defaults).search(
  387. request('stale-churn'),
  388. new AbortController().signal,
  389. )
  390. expect(response.ok).toBe(false)
  391. if (response.ok) throw new Error('unreachable')
  392. expect(response.error.code).toBe('internal')
  393. expect(response.error.message).toContain('100-call work budget')
  394. expect(response).not.toHaveProperty('value')
  395. expect(searchSessions).toHaveBeenCalledTimes(100)
  396. })
  397. it('gives abort priority over a coincident stale continuation failure', async () => {
  398. const ctx = await baseContext()
  399. ctx.sessions.create(sid('visible'), { meta: header('visible') })
  400. const controller = new AbortController()
  401. const stale = new SessionQueryError(
  402. 'provider generation changed',
  403. 'SESSION_QUERY_STALE_CURSOR',
  404. )
  405. const searchSessions = vi.fn()
  406. .mockResolvedValueOnce({ items: [], nextCursor: 'stale-cursor' })
  407. .mockImplementationOnce(() => {
  408. controller.abort()
  409. return Promise.reject(stale)
  410. })
  411. ctx.provide('sessionQuery', { searchSessions } as never)
  412. const response = await createSessionTestRemote(ctx, defaults).search(
  413. request('abort-stale'),
  414. controller.signal,
  415. )
  416. expect(response).toMatchObject({
  417. ok: false,
  418. error: { code: 'cancelled' },
  419. })
  420. expect(searchSessions).toHaveBeenCalledTimes(2)
  421. })
  422. it('does not retry a stale first-page failure', async () => {
  423. const ctx = await baseContext()
  424. ctx.sessions.create(sid('visible'), { meta: header('visible') })
  425. const searchSessions = vi.fn(() => Promise.reject(new SessionQueryError(
  426. 'provider generation changed before paging',
  427. 'SESSION_QUERY_STALE_CURSOR',
  428. )))
  429. ctx.provide('sessionQuery', { searchSessions } as never)
  430. const response = await createSessionTestRemote(ctx, defaults).search(
  431. request('first-page-stale'),
  432. new AbortController().signal,
  433. )
  434. expect(response).toMatchObject({
  435. ok: false,
  436. error: { code: 'internal' },
  437. })
  438. expect(response).not.toHaveProperty('value')
  439. expect(searchSessions).toHaveBeenCalledOnce()
  440. })
  441. it('does not adapt an invalid-limit continuation failure', async () => {
  442. const ctx = await baseContext()
  443. ctx.sessions.create(sid('visible'), { meta: header('visible') })
  444. const searchSessions = vi.fn()
  445. .mockResolvedValueOnce({ items: [], nextCursor: 'page-2' })
  446. .mockRejectedValueOnce(new SessionQueryError(
  447. 'continuation limit is invalid',
  448. 'SESSION_QUERY_INVALID_LIMIT',
  449. ))
  450. ctx.provide('sessionQuery', { searchSessions } as never)
  451. const response = await createSessionTestRemote(ctx, defaults).search(
  452. request('continuation-invalid-limit'),
  453. new AbortController().signal,
  454. )
  455. expect(response).toMatchObject({
  456. ok: false,
  457. error: { code: 'internal' },
  458. })
  459. expect(searchSessions).toHaveBeenCalledTimes(2)
  460. expect(searchSessions.mock.calls.map(([providerRequest]) => (
  461. providerRequest as SessionSearchRequest
  462. ).limit))
  463. .toEqual([20, 20])
  464. })
  465. it('stops page-limit adaptation at one item', async () => {
  466. const ctx = await baseContext()
  467. ctx.sessions.create(sid('visible'), { meta: header('visible') })
  468. const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => Promise.reject(
  469. new SessionQueryError(
  470. `provider rejects ${providerRequest.limit}`,
  471. 'SESSION_QUERY_INVALID_LIMIT',
  472. ),
  473. ))
  474. ctx.provide('sessionQuery', { searchSessions } as never)
  475. const response = await createSessionTestRemote(ctx, defaults).search(
  476. request('minimum-page-limit'),
  477. new AbortController().signal,
  478. )
  479. expect(response).toMatchObject({
  480. ok: false,
  481. error: { code: 'internal' },
  482. })
  483. expect(searchSessions.mock.calls.map(([providerRequest]) => providerRequest.limit))
  484. .toEqual([20, 10, 5, 2, 1])
  485. })
  486. it('gives abort priority over a coincident invalid first-page limit', async () => {
  487. const ctx = await baseContext()
  488. ctx.sessions.create(sid('visible'), { meta: header('visible') })
  489. const controller = new AbortController()
  490. const searchSessions = vi.fn(() => {
  491. controller.abort()
  492. return Promise.reject(new SessionQueryError(
  493. 'provider rejects 20',
  494. 'SESSION_QUERY_INVALID_LIMIT',
  495. ))
  496. })
  497. ctx.provide('sessionQuery', { searchSessions } as never)
  498. const response = await createSessionTestRemote(ctx, defaults).search(
  499. request('abort-invalid-limit'),
  500. controller.signal,
  501. )
  502. expect(response).toMatchObject({
  503. ok: false,
  504. error: { code: 'cancelled' },
  505. })
  506. expect(searchSessions).toHaveBeenCalledOnce()
  507. })
  508. it('rejects an oversized provider page', async () => {
  509. const ctx = await baseContext()
  510. ctx.sessions.create(sid('visible'), { meta: header('visible') })
  511. const oversized = Array.from({ length: 21 }, (_, index) => hit(`oversized-${index}`))
  512. const searchSessions = vi.fn(() => Promise.resolve({ items: oversized }))
  513. ctx.provide('sessionQuery', { searchSessions } as never)
  514. const response = await createSessionTestRemote(ctx, defaults).search(
  515. request('oversized-page'),
  516. new AbortController().signal,
  517. )
  518. expect(response.ok).toBe(false)
  519. if (response.ok) throw new Error('unreachable')
  520. expect(response.error).toMatchObject({ code: 'internal' })
  521. expect(response.error.message).toContain('returned 21 items; maximum is 20')
  522. })
  523. it('uses the learned provider limit for the overproduction guard', async () => {
  524. const ctx = await baseContext()
  525. ctx.sessions.create(sid('visible'), { meta: header('visible') })
  526. const oversized = Array.from({ length: 11 }, (_, index) => hit(`oversized-${index}`))
  527. const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
  528. if (providerRequest.limit === 20) {
  529. return Promise.reject(new SessionQueryError(
  530. 'provider accepts at most 10 items',
  531. 'SESSION_QUERY_INVALID_LIMIT',
  532. ))
  533. }
  534. return Promise.resolve({ items: oversized })
  535. })
  536. ctx.provide('sessionQuery', { searchSessions } as never)
  537. const response = await createSessionTestRemote(ctx, defaults).search(
  538. request('adapted-oversized-page'),
  539. new AbortController().signal,
  540. )
  541. expect(response.ok).toBe(false)
  542. if (response.ok) throw new Error('unreachable')
  543. expect(response.error).toMatchObject({ code: 'internal' })
  544. expect(response.error.message).toContain('returned 11 items; maximum is 10')
  545. expect(searchSessions).toHaveBeenCalledTimes(2)
  546. })
  547. it('bounds provider snippets to 240 Unicode code points without splitting astral text', async () => {
  548. const ctx = await baseContext()
  549. const visible = hit('visible')
  550. ctx.sessions.create(visible.header.id, { meta: visible.header })
  551. const expected = `${'x'.repeat(239)}😀`
  552. const overlong = {
  553. ...visible,
  554. bestMatch: {
  555. ...visible.bestMatch,
  556. snippet: `${expected}${'y'.repeat(10_000)}`,
  557. },
  558. }
  559. ctx.provide('sessionQuery', {
  560. searchSessions: () => Promise.resolve({ items: [overlong] }),
  561. } as never)
  562. const response = await createSessionTestRemote(ctx, defaults).search(
  563. request('bounded-snippet'),
  564. new AbortController().signal,
  565. )
  566. expect(response).toEqual({
  567. ok: true,
  568. value: {
  569. items: [{ sessionId: 'visible', snippet: expected }],
  570. hasMore: false,
  571. },
  572. })
  573. })
  574. it('fails closed when the provider repeats a continuation cursor', async () => {
  575. const ctx = await baseContext()
  576. ctx.sessions.create(sid('visible'), { meta: header('visible') })
  577. const searchSessions = vi.fn()
  578. .mockResolvedValueOnce({ items: [], nextCursor: 'repeated' })
  579. .mockResolvedValueOnce({ items: [], nextCursor: 'repeated' })
  580. ctx.provide('sessionQuery', { searchSessions } as never)
  581. const response = await createSessionTestRemote(ctx, defaults).search(
  582. request('repeated-cursor'),
  583. new AbortController().signal,
  584. )
  585. expect(response.ok).toBe(false)
  586. if (response.ok) throw new Error('unreachable')
  587. expect(response.error).toMatchObject({ code: 'internal' })
  588. expect(response.error.message).toContain('repeated a continuation cursor')
  589. expect(searchSessions).toHaveBeenCalledTimes(2)
  590. })
  591. it('validates a repeated cursor before accepting the authorized lookahead', async () => {
  592. const ctx = await baseContext()
  593. const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
  594. for (const item of items) {
  595. ctx.sessions.create(item.header.id, { meta: item.header })
  596. }
  597. const searchSessions = vi.fn()
  598. .mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'repeated' })
  599. .mockResolvedValueOnce({ items: items.slice(20), nextCursor: 'repeated' })
  600. ctx.provide('sessionQuery', { searchSessions } as never)
  601. const response = await createSessionTestRemote(ctx, defaults).search(
  602. request('repeated-lookahead-cursor'),
  603. new AbortController().signal,
  604. )
  605. expect(response).toMatchObject({
  606. ok: false,
  607. error: { code: 'internal' },
  608. })
  609. expect(response).not.toHaveProperty('value')
  610. if (response.ok) throw new Error('unreachable')
  611. expect(response.error.message).toContain('repeated a continuation cursor')
  612. expect(searchSessions).toHaveBeenCalledTimes(2)
  613. })
  614. it('does not count duplicate session ids toward the result or lookahead boundary', async () => {
  615. const ctx = await baseContext()
  616. const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
  617. for (const item of items) {
  618. ctx.sessions.create(item.header.id, { meta: item.header })
  619. }
  620. const searchSessions = vi.fn()
  621. .mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'page-2' })
  622. .mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'page-3' })
  623. .mockResolvedValueOnce({ items: items.slice(20) })
  624. ctx.provide('sessionQuery', { searchSessions } as never)
  625. const response = await createSessionTestRemote(ctx, defaults).search(
  626. request('duplicate-pages'),
  627. new AbortController().signal,
  628. )
  629. expect(response).toMatchObject({
  630. ok: true,
  631. value: { hasMore: true },
  632. })
  633. if (!response.ok) throw new Error('unreachable')
  634. expect(response.value.items.map(item => item.sessionId)).toEqual(
  635. items.slice(0, 20).map(item => item.header.id),
  636. )
  637. expect(searchSessions).toHaveBeenCalledTimes(3)
  638. })
  639. it('cancels on a continuation page and passes the carrier signal to both calls', async () => {
  640. const ctx = await baseContext()
  641. ctx.sessions.create(sid('visible'), { meta: header('visible') })
  642. const controller = new AbortController()
  643. const searchSessions = vi.fn()
  644. .mockResolvedValueOnce({ items: [], nextCursor: 'page-2' })
  645. .mockImplementationOnce(() => {
  646. controller.abort()
  647. return Promise.resolve({ items: [] })
  648. })
  649. ctx.provide('sessionQuery', { searchSessions } as never)
  650. const response = await createSessionTestRemote(ctx, defaults).search(
  651. request('cancel-continuation'),
  652. controller.signal,
  653. )
  654. expect(response).toMatchObject({
  655. ok: false,
  656. error: { code: 'cancelled' },
  657. })
  658. expect(searchSessions).toHaveBeenCalledTimes(2)
  659. for (const call of searchSessions.mock.calls) {
  660. expect(call[1]).toEqual({ signal: controller.signal })
  661. }
  662. })
  663. it('keeps visibility sets above SQLite variable limits out of provider bindings', async () => {
  664. const ctx = await baseContext()
  665. const cold = Array.from(
  666. { length: 32_751 },
  667. (_, index) => header(`cold-${index}`, `/cold-${index}`),
  668. )
  669. ctx.provide('sessionPersistence', {
  670. list: () => Promise.resolve(cold),
  671. locate: () => undefined,
  672. } as never)
  673. const searchSessions = vi.fn((_request: SessionSearchRequest) => Promise.resolve({
  674. items: [hit('cold-32750')],
  675. }))
  676. ctx.provide('sessionQuery', { searchSessions } as never)
  677. const response = await createSessionTestRemote(ctx, defaults).search(
  678. request('large corpus'),
  679. new AbortController().signal,
  680. )
  681. expect(response).toEqual({
  682. ok: true,
  683. value: {
  684. items: [{ sessionId: 'cold-32750', snippet: 'match 0' }],
  685. hasMore: false,
  686. },
  687. })
  688. expect(searchSessions).toHaveBeenCalledOnce()
  689. expect(searchSessions.mock.calls[0]?.[0]).not.toHaveProperty('sessionFilters')
  690. })
  691. it('propagates cancellation through visible-session collection and stops cold-summary work', async () => {
  692. const ctx = await baseContext()
  693. const controller = new AbortController()
  694. const cold = Array.from({ length: 32 }, (_, index) => header(`cold-${index}`, `/cold-${index}`))
  695. const list = vi.fn((signal?: AbortSignal) => {
  696. expect(signal).toBe(controller.signal)
  697. return Promise.resolve(cold)
  698. })
  699. let locateCalls = 0
  700. ctx.provide('sessionPersistence', {
  701. list,
  702. locate: () => {
  703. locateCalls++
  704. controller.abort()
  705. return undefined
  706. },
  707. } as never)
  708. const searchSessions = vi.fn()
  709. ctx.provide('sessionQuery', { searchSessions } as never)
  710. const response = await createSessionTestRemote(ctx, defaults).search(
  711. request('cancel-during-visibility'),
  712. controller.signal,
  713. )
  714. expect(response).toMatchObject({
  715. ok: false,
  716. error: { code: 'cancelled' },
  717. })
  718. expect(list).toHaveBeenCalledOnce()
  719. expect(locateCalls).toBe(1)
  720. expect(searchSessions).not.toHaveBeenCalled()
  721. })
  722. it('awaits every started cold-summary stat before returning cancellation', async () => {
  723. const ctx = await baseContext()
  724. const controller = new AbortController()
  725. const cold = Array.from({ length: 16 }, (_, index) => header(`cold-${index}`, `/cold-${index}`))
  726. const statGates = cold.map(() => Promise.withResolvers<{ mtimeMs: number }>())
  727. const statMock = vi.mocked(stat)
  728. statMock.mockClear()
  729. for (const gate of statGates) {
  730. statMock.mockImplementationOnce((() => gate.promise) as never)
  731. }
  732. ctx.provide('sessionPersistence', {
  733. list: () => Promise.resolve(cold),
  734. locate: (meta: SessionHeader) => ({ kind: 'jsonl', path: `/logs/${meta.id}.jsonl` }),
  735. } as never)
  736. const searchSessions = vi.fn()
  737. ctx.provide('sessionQuery', { searchSessions } as never)
  738. let settled = false
  739. const responsePromise = createSessionTestRemote(ctx, defaults).search(
  740. request('cancel-during-cold-stats'),
  741. controller.signal,
  742. ).finally(() => {
  743. settled = true
  744. })
  745. await vi.waitFor(() => {
  746. expect(statMock).toHaveBeenCalledTimes(16)
  747. })
  748. controller.abort()
  749. statGates[0]!.resolve({ mtimeMs: 101 })
  750. await new Promise<void>(resolve => setImmediate(resolve))
  751. expect(settled).toBe(false)
  752. for (const gate of statGates.slice(1)) gate.resolve({ mtimeMs: 102 })
  753. const response = await responsePromise
  754. expect(response).toMatchObject({
  755. ok: false,
  756. error: { code: 'cancelled' },
  757. })
  758. expect(searchSessions).not.toHaveBeenCalled()
  759. })
  760. it('maps missing composition, query cancellation, and provider failure', async () => {
  761. const missingCtx = await baseContext()
  762. missingCtx.sessions.create(sid('visible'), { meta: header('visible') })
  763. const missingApi = createSessionTestRemote(missingCtx, defaults)
  764. const preAborted = new AbortController()
  765. preAborted.abort()
  766. const cancelledBeforeLookup = await missingApi.search(
  767. request('cancel-before-lookup'),
  768. preAborted.signal,
  769. )
  770. expect(cancelledBeforeLookup).toMatchObject({
  771. ok: false,
  772. error: { code: 'cancelled' },
  773. })
  774. const missing = await missingApi.search(
  775. request('needle'),
  776. new AbortController().signal,
  777. )
  778. expect(missing.ok).toBe(false)
  779. if (missing.ok) throw new Error('unreachable')
  780. expect(missing.error.code).toBe('internal')
  781. expect(missing.error.message).toContain('does not mount')
  782. const ctx = await baseContext()
  783. ctx.sessions.create(sid('visible'), { meta: header('visible') })
  784. const aborted = new SessionQueryError('provider stopped', 'SESSION_QUERY_ABORTED')
  785. const searchSessions = vi.fn()
  786. .mockRejectedValueOnce(aborted)
  787. .mockRejectedValueOnce(new Error('database unavailable'))
  788. ctx.provide('sessionQuery', { searchSessions } as never)
  789. const remote = createSessionTestRemote(ctx, defaults)
  790. const cancelled = await remote.search(
  791. request('first'),
  792. new AbortController().signal,
  793. )
  794. expect(cancelled).toMatchObject({
  795. ok: false,
  796. error: { code: 'cancelled' },
  797. })
  798. const failed = await remote.search(
  799. request('second'),
  800. new AbortController().signal,
  801. )
  802. expect(failed.ok).toBe(false)
  803. if (failed.ok) throw new Error('unreachable')
  804. expect(failed.error.code).toBe('internal')
  805. expect(failed.error.message).toContain('database unavailable')
  806. })
  807. })