api-proxy-search.spec.ts 31 KB

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