session-search.host.spec.ts 30 KB

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