session-search.host.spec.ts 31 KB

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