session-search.host.spec.ts 31 KB

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