session-search.host.spec.ts 31 KB

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