tool-session-query.spec.ts 81 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import { Context, type Fiber } from '@deepseek-ai/cordis'
  3. import type { Agent } from '@deepseek-ai/dsh-agent'
  4. import { createUserMessage, CallId, HarnessError , createMessage } from '@deepseek-ai/dsh-llm'
  5. import { MAX_TIMER_DELAY_MS, TimeoutReason } from '@deepseek-ai/dsh-timeout'
  6. import * as TimeoutPolicy from '@deepseek-ai/dsh-tool-call-timeout-policy'
  7. import SessionStore, {
  8. SESSION_FORMAT_VERSION,
  9. SessionId,
  10. type Session,
  11. type SessionHeader,
  12. type SessionId as SessionIdValue,
  13. } from '@deepseek-ai/dsh-session'
  14. import SessionQueryEngine, {
  15. SessionQueryError,
  16. SessionSearchCursor,
  17. type SessionEventSearchHit,
  18. type SessionEventSearchPage,
  19. type SessionEventSearchRequest,
  20. type SessionLineageNode,
  21. type SessionSearchExecContext,
  22. type SessionSearchHit,
  23. type SessionSearchPage,
  24. type SessionSearchRequest,
  25. type SessionTitleObservationResult,
  26. } from '@deepseek-ai/dsh-session-query'
  27. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  28. import ToolRuntime, { type ToolExecutionResult } from '@deepseek-ai/dsh-tools'
  29. import * as ToolSessionQuery from '@deepseek-ai/dsh-tool-session-query'
  30. const activeContexts: Context[] = []
  31. afterEach(async () => {
  32. vi.useRealTimers()
  33. vi.restoreAllMocks()
  34. for (const ctx of activeContexts.splice(0)) await ctx.fiber.dispose()
  35. FakeQuery.reset()
  36. })
  37. function header(id: string, cwd: string | undefined, createdAt = 1, parentSession?: SessionIdValue): SessionHeader {
  38. return {
  39. version: SESSION_FORMAT_VERSION,
  40. id: SessionId(id),
  41. createdAt,
  42. ...cwd === undefined ? {} : { cwd },
  43. ...parentSession === undefined ? {} : { parentSession },
  44. }
  45. }
  46. function createSession(
  47. ctx: Context,
  48. id: string,
  49. cwd: string | undefined,
  50. createdAt = 1,
  51. parentSession?: SessionIdValue,
  52. ): Session {
  53. return ctx.sessions.create(SessionId(id), {
  54. meta: {
  55. createdAt,
  56. ...cwd === undefined ? {} : { cwd },
  57. ...parentSession === undefined ? {} : { parentSession },
  58. },
  59. })
  60. }
  61. function openStep(session: Session, text = 'prior needle'): void {
  62. session.append('turn/start', { turn: 1 })
  63. session.append(
  64. 'user/message',
  65. createUserMessage({
  66. content: [{ type: 'text', text }], source: { kind: 'user' },
  67. }),
  68. { surfaceOp: 'append' },
  69. )
  70. session.append('step/start', { turn: 1, step: 1 })
  71. }
  72. function fakeAgent(session: Session): Agent {
  73. return { id: session.id, session } as unknown as Agent
  74. }
  75. function sessionHit(
  76. id: string,
  77. cwd: string | undefined,
  78. text = 'needle excerpt',
  79. parentSession?: SessionIdValue,
  80. ): SessionSearchHit {
  81. return {
  82. header: header(id, cwd, 100, parentSession),
  83. live: true,
  84. persisted: false,
  85. bestMatch: {
  86. sessionId: SessionId(id),
  87. seq: 4,
  88. type: 'assistant/message',
  89. time: 200,
  90. surface: 'current',
  91. snippet: text,
  92. },
  93. }
  94. }
  95. function eventHit(sessionId: SessionIdValue, seq: number, text = 'needle excerpt'): SessionEventSearchHit {
  96. return {
  97. sessionId,
  98. seq,
  99. type: 'user/message',
  100. time: 200 + seq,
  101. surface: 'current',
  102. snippet: text,
  103. }
  104. }
  105. class FakeQuery extends SessionQueryEngine {
  106. static sessionSearch: (
  107. request: SessionSearchRequest,
  108. exec?: SessionSearchExecContext,
  109. ) => Promise<SessionSearchPage<SessionSearchHit>> = () => Promise.resolve({ items: [] })
  110. static eventSearch: (
  111. request: SessionEventSearchRequest,
  112. exec?: SessionSearchExecContext,
  113. ) => Promise<SessionEventSearchPage> = request => Promise.resolve({
  114. session: header(request.sessionId, '/work'),
  115. items: [],
  116. })
  117. static sessionRequests: SessionSearchRequest[] = []
  118. static eventRequests: SessionEventSearchRequest[] = []
  119. static searchSignals: Array<AbortSignal | undefined> = []
  120. static titles = new Map<SessionIdValue, string | Error>()
  121. static reset(): void {
  122. this.sessionSearch = () => Promise.resolve({ items: [] })
  123. this.eventSearch = request => Promise.resolve({
  124. session: header(request.sessionId, '/work'),
  125. items: [],
  126. })
  127. this.sessionRequests = []
  128. this.eventRequests = []
  129. this.searchSignals = []
  130. this.titles = new Map()
  131. }
  132. override searchSessions(
  133. request: SessionSearchRequest,
  134. exec?: SessionSearchExecContext,
  135. ): Promise<SessionSearchPage<SessionSearchHit>> {
  136. FakeQuery.sessionRequests.push(request)
  137. FakeQuery.searchSignals.push(exec?.signal)
  138. return FakeQuery.sessionSearch(request, exec)
  139. }
  140. override searchEvents(
  141. request: SessionEventSearchRequest,
  142. exec?: SessionSearchExecContext,
  143. ): Promise<SessionEventSearchPage> {
  144. FakeQuery.eventRequests.push(request)
  145. FakeQuery.searchSignals.push(exec?.signal)
  146. return FakeQuery.eventSearch(request, exec)
  147. }
  148. override async readTitleSnapshots(
  149. sessionIds: readonly SessionIdValue[],
  150. signal?: AbortSignal,
  151. ): Promise<SessionTitleObservationResult[]> {
  152. const observations = await super.readTitleSnapshots(sessionIds, signal)
  153. return observations.map((observation): SessionTitleObservationResult => {
  154. const value = FakeQuery.titles.get(observation.sessionId)
  155. if (value instanceof Error) {
  156. return { sessionId: observation.sessionId, status: 'rejected', reason: value }
  157. }
  158. if (value === undefined || observation.status === 'rejected') return observation
  159. return {
  160. ...observation,
  161. value: {
  162. ...observation.value,
  163. title: {
  164. title: value,
  165. messageSeqs: [],
  166. source: { kind: 'fallback' },
  167. eventSeq: 0,
  168. updatedAt: 1,
  169. },
  170. },
  171. }
  172. })
  173. }
  174. }
  175. interface Mounted {
  176. readonly ctx: Context
  177. readonly fiber: Fiber
  178. readonly caller: Session
  179. call(name: string, args: unknown, options?: { agent?: Agent; signal?: AbortSignal }): Promise<ToolExecutionResult>
  180. }
  181. async function mount(
  182. config: ToolSessionQuery.Config = {},
  183. callerCwd: string | null = '/work',
  184. enforceTimeout = false,
  185. ): Promise<Mounted> {
  186. const ctx = new Context()
  187. activeContexts.push(ctx)
  188. await ctx.plugin(SessionStore)
  189. await ctx.plugin(SystemPrompt)
  190. await ctx.plugin(ToolRuntime)
  191. if (enforceTimeout) await ctx.plugin(TimeoutPolicy)
  192. await ctx.plugin(FakeQuery)
  193. const fiber = await ctx.plugin(ToolSessionQuery, config)
  194. const caller = createSession(ctx, 'caller', callerCwd ?? undefined, 10)
  195. openStep(caller)
  196. let calls = 0
  197. return {
  198. ctx,
  199. fiber,
  200. caller,
  201. call: (toolName, args, options = {}) => ctx.tools.execute({
  202. name: toolName,
  203. arguments: args,
  204. callId: CallId(`call-${++calls}`),
  205. signal: options.signal ?? new AbortController().signal,
  206. ...options.agent === undefined ? { agent: fakeAgent(caller) } : { agent: options.agent },
  207. }),
  208. }
  209. }
  210. function text(result: ToolExecutionResult): string {
  211. return result.content.map(block => block.type === 'text' ? block.text : '').join('\n')
  212. }
  213. function errorCode(result: ToolExecutionResult): string | undefined {
  214. return result.isError ? result.error.info?.code : undefined
  215. }
  216. describe('registration and schemas', () => {
  217. it('registers the five cursor-free tools, prompt, timeouts, and pure generic presenters, then disposes them', async () => {
  218. const mounted = await mount({ maxSearchResults: 7, searchTimeoutMs: 1234 })
  219. const names = mounted.ctx.tools.schemas().map(schema => schema.name)
  220. expect(names).toEqual([
  221. 'session_search',
  222. 'session_event_search',
  223. 'session_trace',
  224. 'session_event_trace',
  225. 'session_event_read',
  226. ])
  227. const sessionSchema = mounted.ctx.tools.schemas().find(schema => schema.name === 'session_search')
  228. expect(sessionSchema?.parameters).not.toHaveProperty('properties.cursor')
  229. expect(sessionSchema?.parameters).not.toHaveProperty('properties.limit')
  230. expect(sessionSchema?.parameters).not.toHaveProperty('properties.cwd')
  231. expect(mounted.ctx.tools.get('session_search')?.timeoutMs).toBe(1234)
  232. expect(mounted.ctx.tools.get('session_trace')?.timeoutMs).toBeUndefined()
  233. const parallelArgs: Record<string, unknown> = {
  234. session_trace: {},
  235. session_event_trace: { seq: 0 },
  236. session_event_read: { seq: 0 },
  237. }
  238. for (const [name, args] of Object.entries(parallelArgs)) {
  239. expect(mounted.ctx.tools.get(name)?.isConcurrencySafe?.(args)).toBe(true)
  240. }
  241. expect(mounted.ctx.tools.get('session_search')?.output.render({}, 'rendered'))
  242. .toEqual([{ type: 'text', text: 'rendered' }])
  243. expect(mounted.ctx.tools.get('session_search')?.presentCall?.({ query: 'needle' }))
  244. .toEqual({ card: 'generic', kind: 'search', title: 'Search prior sessions', rawInput: 'needle' })
  245. expect(mounted.ctx.tools.get('session_event_search')?.presentCall?.({ query: 'needle' }))
  246. .toEqual({ card: 'generic', kind: 'search', title: 'Search session events', rawInput: 'needle' })
  247. expect(mounted.ctx.tools.get('session_trace')?.presentCall?.({}))
  248. .toEqual({ card: 'generic', kind: 'read', title: 'Trace current session' })
  249. expect(mounted.ctx.tools.get('session_trace')?.presentCall?.({ session_id: 'other' }))
  250. .toEqual({ card: 'generic', kind: 'read', title: 'Trace session other', rawInput: 'other' })
  251. expect(mounted.ctx.tools.get('session_event_trace')?.presentCall?.({ session_id: 'other', seq: 3 }))
  252. .toEqual({
  253. card: 'generic',
  254. kind: 'read',
  255. title: 'Trace event 3',
  256. rawInput: { session_id: 'other', seq: 3 },
  257. })
  258. expect(mounted.ctx.tools.get('session_event_read')?.presentCall?.({ seq: 4 }))
  259. .toEqual({ card: 'generic', kind: 'read', title: 'Read event 4', rawInput: { seq: 4 } })
  260. const assembly = await mounted.ctx.systemPrompt.assemble()
  261. expect(assembly.sections.find(section => section.name === 'tool:session-query')?.text)
  262. .toContain('prior sessions')
  263. await mounted.fiber.dispose()
  264. expect(mounted.ctx.tools.schemas().map(schema => schema.name)).toEqual([])
  265. expect((await mounted.ctx.systemPrompt.assemble()).sections.map(section => section.name))
  266. .not.toContain('tool:session-query')
  267. })
  268. it('keeps generation-bound searches exclusive while exact observations remain parallel', async () => {
  269. const mounted = await mount()
  270. const classifications = [
  271. ['session_search', { query: 'q' }, 'exclusive'],
  272. ['session_event_search', { query: 'q' }, 'exclusive'],
  273. ['session_trace', {}, 'parallel'],
  274. ['session_event_trace', { seq: 0 }, 'parallel'],
  275. ['session_event_read', { seq: 0 }, 'parallel'],
  276. ] as const
  277. for (const [name, args, kind] of classifications) {
  278. expect(mounted.ctx.tools.executionMode({
  279. name,
  280. arguments: args,
  281. callId: CallId(`mode-${name}`),
  282. signal: new AbortController().signal,
  283. agent: fakeAgent(mounted.caller),
  284. })).toEqual({ kind })
  285. }
  286. })
  287. it('fails invalid direct config before registering anything', async () => {
  288. const mounted = await mount()
  289. for (const maxSearchResults of [0, 1.5, Number.NaN]) {
  290. expect(() => { ToolSessionQuery.apply(mounted.ctx, { maxSearchResults }) })
  291. .toThrow('maxSearchResults')
  292. }
  293. for (const searchTimeoutMs of [0, 1.5, Number.POSITIVE_INFINITY, MAX_TIMER_DELAY_MS + 1]) {
  294. expect(() => { ToolSessionQuery.apply(mounted.ctx, { searchTimeoutMs }) })
  295. .toThrow(`no greater than ${MAX_TIMER_DELAY_MS}`)
  296. }
  297. expect(() => { ToolSessionQuery.apply(new Context(), {}) }).toThrow()
  298. })
  299. it('expresses the complete Node timer range in the Loader config schema', () => {
  300. expect(new ToolSessionQuery.Config({ searchTimeoutMs: MAX_TIMER_DELAY_MS }))
  301. .toEqual({ maxSearchResults: 100, searchTimeoutMs: MAX_TIMER_DELAY_MS })
  302. expect(() => new ToolSessionQuery.Config({ searchTimeoutMs: 1.5 })).toThrow()
  303. expect(() => new ToolSessionQuery.Config({ searchTimeoutMs: MAX_TIMER_DELAY_MS + 1 })).toThrow()
  304. })
  305. })
  306. describe('input validation and translation', () => {
  307. it.each([
  308. [{ query: ' ' }, 'SESSION_QUERY_INVALID_QUERY'],
  309. [{ query: 'bad\0query' }, 'SESSION_QUERY_INVALID_QUERY'],
  310. [{ query: 'q', session_ids: [] }, 'SESSION_QUERY_INVALID_FILTER'],
  311. [{ query: 'q', parent_session_ids: [] }, 'SESSION_QUERY_INVALID_FILTER'],
  312. [{ query: 'q', availability: [] }, 'SESSION_QUERY_INVALID_FILTER'],
  313. [{ query: 'q', availability: ['archived'] }, 'INVALID_ARGS'],
  314. [{ query: 'q', event_types: [] }, 'SESSION_QUERY_INVALID_FILTER'],
  315. [{ query: 'q', event_surfaces: [] }, 'SESSION_QUERY_INVALID_FILTER'],
  316. [{ query: 'q', event_surfaces: ['hidden'] }, 'INVALID_ARGS'],
  317. [{ query: 'q', event_seq_from: -1 }, 'SESSION_QUERY_INVALID_FILTER'],
  318. [{ query: 'q', event_seq_to: Number.MAX_SAFE_INTEGER + 1 }, 'SESSION_QUERY_INVALID_FILTER'],
  319. [{ query: 'q', event_seq_from: 2, event_seq_to: 1 }, 'SESSION_QUERY_INVALID_FILTER'],
  320. [{ query: 'q', created_at_from: '2026-07-24T10:00:00' }, 'SESSION_QUERY_INVALID_FILTER'],
  321. [{ query: 'q', created_at_from: '2026-02-30T10:00:00Z' }, 'SESSION_QUERY_INVALID_FILTER'],
  322. [{ query: 'q', created_at_from: '2100-02-29T10:00:00Z' }, 'SESSION_QUERY_INVALID_FILTER'],
  323. [{ query: 'q', created_at_from: '2026-04-31T10:00:00Z' }, 'SESSION_QUERY_INVALID_FILTER'],
  324. [{ query: 'q', created_at_from: '2026-01-01T24:00:00Z' }, 'SESSION_QUERY_INVALID_FILTER'],
  325. [{ query: 'q', created_at_from: '2026-01-01T00:60:00Z' }, 'SESSION_QUERY_INVALID_FILTER'],
  326. [{ query: 'q', created_at_from: '2026-01-01T00:00:60Z' }, 'SESSION_QUERY_INVALID_FILTER'],
  327. [{ query: 'q', created_at_from: '2026-01-01T00:00:00+24:00' }, 'SESSION_QUERY_INVALID_FILTER'],
  328. [{ query: 'q', created_at_from: '2026-01-01T00:00:00+00:60' }, 'SESSION_QUERY_INVALID_FILTER'],
  329. [{
  330. query: 'q',
  331. created_at_from: '2026-07-25T00:00:00Z',
  332. created_at_to: '2026-07-24T00:00:00Z',
  333. }, 'SESSION_QUERY_INVALID_FILTER'],
  334. ])('rejects invalid search arguments %#', async (args, code) => {
  335. const mounted = await mount()
  336. const result = await mounted.call('session_search', args)
  337. expect(errorCode(result)).toBe(code)
  338. })
  339. it('normalizes the query and compiles inclusive session/event filters with one parent OR clause', async () => {
  340. const mounted = await mount()
  341. createSession(mounted.ctx, 'parent', '/work')
  342. await mounted.call('session_search', {
  343. query: ' alpha beta ',
  344. session_ids: ['a', 'b'],
  345. created_at_from: '2026-07-24T00:00:00+08:00',
  346. created_at_to: '2026-07-24T01:00:00+08:00',
  347. parent_session_ids: ['parent'],
  348. include_root_sessions: true,
  349. availability: ['live'],
  350. event_seq_from: 2,
  351. event_seq_to: 9,
  352. event_time_from: '2026-07-24T00:00:00Z',
  353. event_time_to: '2026-07-24T01:00:00Z',
  354. event_types: ['plugin/open-event'],
  355. event_surfaces: ['shadowed'],
  356. })
  357. expect(FakeQuery.sessionRequests).toHaveLength(1)
  358. expect(FakeQuery.sessionRequests[0]).toEqual({
  359. query: 'alpha beta',
  360. sessionFilters: [
  361. { kind: 'id', values: ['a', 'b'] },
  362. {
  363. kind: 'created-at',
  364. from: Date.parse('2026-07-24T00:00:00+08:00'),
  365. to: Date.parse('2026-07-24T01:00:00+08:00'),
  366. },
  367. { kind: 'availability', values: ['live'] },
  368. { kind: 'parent', values: ['parent', null] },
  369. { kind: 'cwd', values: ['/work'] },
  370. ],
  371. eventFilters: [
  372. { kind: 'seq', from: 2, to: 9 },
  373. {
  374. kind: 'time',
  375. from: Date.parse('2026-07-24T00:00:00Z'),
  376. to: Date.parse('2026-07-24T01:00:00Z'),
  377. },
  378. { kind: 'type', values: ['plugin/open-event'] },
  379. { kind: 'surface', values: ['shadowed'] },
  380. ],
  381. })
  382. })
  383. it.each([
  384. ['one fractional digit', '2026-07-24T00:00:00.1Z', 100],
  385. ['two fractional digits', '2026-07-24T00:00:00.12Z', 120],
  386. ['three fractional digits', '2026-07-24T00:00:00.123Z', 123],
  387. ])('normalizes %s into an exact integer epoch-millisecond filter', async (_case, value, offset) => {
  388. const mounted = await mount()
  389. await mounted.call('session_search', {
  390. query: 'q',
  391. created_at_from: value,
  392. })
  393. const expected = Date.parse('2026-07-24T00:00:00.000Z') + offset
  394. expect(Number.isFinite(expected)).toBe(true)
  395. expect(FakeQuery.sessionRequests[0]?.sessionFilters).toContainEqual({
  396. kind: 'created-at',
  397. from: expected,
  398. })
  399. })
  400. it('maps exact same-millisecond decimal bounds to adjacent numeric values without collapsing the interval', async () => {
  401. const mounted = await mount()
  402. const base = Date.parse('2026-07-24T00:00:00.000Z')
  403. const result = await mounted.call('session_search', {
  404. query: 'q',
  405. created_at_from: '2026-07-24T00:00:00.12300001Z',
  406. created_at_to: '2026-07-24T08:00:00.1239999+08:00',
  407. })
  408. expect(result.isError).toBe(false)
  409. expect(text(result)).toContain('No prior session matches found.')
  410. const range = FakeQuery.sessionRequests[0]?.sessionFilters
  411. ?.find(filter => filter.kind === 'created-at')
  412. expect(range).toBeDefined()
  413. if (range?.kind !== 'created-at' || range.from === undefined || range.to === undefined) {
  414. throw new Error('expected complete created-at range')
  415. }
  416. expect(Number.isFinite(range.from)).toBe(true)
  417. expect(Number.isFinite(range.to)).toBe(true)
  418. expect(range.from).toBeGreaterThan(base + 123)
  419. expect(range.from).toBeLessThan(base + 124)
  420. expect(range.to).toBeGreaterThan(base + 123)
  421. expect(range.to).toBeLessThan(base + 124)
  422. expect(range.from).toBeLessThan(range.to)
  423. })
  424. it('rejects exact bounds reversed only below one millisecond before calling the provider', async () => {
  425. const mounted = await mount()
  426. const result = await mounted.call('session_search', {
  427. query: 'q',
  428. created_at_from: '2026-07-24T00:00:00.12300002Z',
  429. created_at_to: '2026-07-24T00:00:00.12300001Z',
  430. })
  431. expect(errorCode(result)).toBe('SESSION_QUERY_INVALID_FILTER')
  432. expect(FakeQuery.sessionRequests).toEqual([])
  433. })
  434. it('compares unequal-length exact remainders with implicit trailing decimal zeroes', async () => {
  435. const mounted = await mount()
  436. const ordered = await mounted.call('session_search', {
  437. query: 'q',
  438. created_at_from: '2026-07-24T00:00:00.1231Z',
  439. created_at_to: '2026-07-24T00:00:00.12311Z',
  440. })
  441. expect(ordered.isError).toBe(false)
  442. const reversed = await mounted.call('session_search', {
  443. query: 'q',
  444. created_at_from: '2026-07-24T00:00:00.12311Z',
  445. created_at_to: '2026-07-24T00:00:00.1231Z',
  446. })
  447. expect(errorCode(reversed)).toBe('SESSION_QUERY_INVALID_FILTER')
  448. })
  449. it('treats trailing-zero fractional spellings as the same exact instant', async () => {
  450. const mounted = await mount()
  451. const result = await mounted.call('session_search', {
  452. query: 'q',
  453. created_at_from: '2026-07-24T00:00:00.1230000100Z',
  454. created_at_to: '2026-07-24T00:00:00.12300001Z',
  455. })
  456. expect(result.isError).toBe(false)
  457. expect(FakeQuery.sessionRequests).toHaveLength(1)
  458. })
  459. it('maps fractional bounds correctly across zero and for negative pre-epoch milliseconds', async () => {
  460. const mounted = await mount()
  461. await mounted.call('session_search', {
  462. query: 'q',
  463. created_at_from: '1970-01-01T00:00:00.0000001Z',
  464. event_time_to: '1969-12-31T23:59:59.9999999Z',
  465. })
  466. expect(FakeQuery.sessionRequests[0]?.sessionFilters).toContainEqual({
  467. kind: 'created-at',
  468. from: Number.MIN_VALUE,
  469. })
  470. expect(FakeQuery.sessionRequests[0]?.eventFilters).toContainEqual({
  471. kind: 'time',
  472. to: -Number.MIN_VALUE,
  473. })
  474. await mounted.call('session_event_search', {
  475. query: 'q',
  476. time_from: '1969-12-31T23:59:59.87600001Z',
  477. time_to: '1969-12-31T19:59:59.8769999-04:00',
  478. })
  479. const range = FakeQuery.eventRequests[0]?.filters?.find(filter => filter.kind === 'time')
  480. expect(range).toBeDefined()
  481. if (range?.kind !== 'time' || range.from === undefined || range.to === undefined) {
  482. throw new Error('expected complete event time range')
  483. }
  484. expect(range.from).toBeGreaterThan(-124)
  485. expect(range.from).toBeLessThan(-123)
  486. expect(range.to).toBeGreaterThan(-124)
  487. expect(range.to).toBeLessThan(-123)
  488. expect(range.from).toBeLessThan(range.to)
  489. })
  490. it('rejects a normalized timestamp when the platform parser cannot produce a finite value', async () => {
  491. const mounted = await mount()
  492. vi.spyOn(Date, 'parse').mockReturnValueOnce(Number.NaN)
  493. const result = await mounted.call('session_search', {
  494. query: 'q',
  495. created_at_from: '2026-07-24T00:00:00.123456Z',
  496. })
  497. expect(errorCode(result)).toBe('SESSION_QUERY_INVALID_FILTER')
  498. expect(FakeQuery.sessionRequests).toEqual([])
  499. })
  500. it('compiles one-sided timestamps and independent root/parent clauses', async () => {
  501. const mounted = await mount()
  502. createSession(mounted.ctx, 'parent', '/work')
  503. await mounted.call('session_search', {
  504. query: 'q',
  505. created_at_from: '2024-02-29T00:00Z',
  506. include_root_sessions: true,
  507. event_time_to: '2000-02-29T00:00Z',
  508. })
  509. expect(FakeQuery.sessionRequests[0]?.sessionFilters).toContainEqual({
  510. kind: 'created-at',
  511. from: Date.parse('2024-02-29T00:00Z'),
  512. })
  513. expect(FakeQuery.sessionRequests[0]?.sessionFilters).toContainEqual({
  514. kind: 'parent',
  515. values: [null],
  516. })
  517. expect(FakeQuery.sessionRequests[0]?.eventFilters).toContainEqual({
  518. kind: 'time',
  519. to: Date.parse('2000-02-29T00:00Z'),
  520. })
  521. await mounted.call('session_search', {
  522. query: 'q',
  523. parent_session_ids: ['parent'],
  524. })
  525. expect(FakeQuery.sessionRequests[1]?.sessionFilters).toContainEqual({
  526. kind: 'parent',
  527. values: ['parent'],
  528. })
  529. })
  530. })
  531. describe('workspace authority and lineage redaction', () => {
  532. it('fails closed without an agent and for direct cross-workspace targets', async () => {
  533. const mounted = await mount()
  534. createSession(mounted.ctx, 'outside', '/outside')
  535. const missing = await mounted.ctx.tools.execute({
  536. name: 'session_trace',
  537. arguments: {},
  538. callId: CallId('missing-agent'),
  539. signal: new AbortController().signal,
  540. })
  541. expect(errorCode(missing)).toBe('SESSION_QUERY_TOOL_MISSING_AGENT')
  542. const denied = await mounted.call('session_event_read', { session_id: 'outside', seq: 0 })
  543. expect(errorCode(denied)).toBe('SESSION_QUERY_TOOL_UNAUTHORIZED')
  544. expect(text(denied)).not.toContain('session "outside"')
  545. })
  546. it('allows only self for a null-cwd caller and denies cross-session search', async () => {
  547. const mounted = await mount({}, null)
  548. const own = await mounted.call('session_trace', {})
  549. expect(own.isError).toBe(false)
  550. expect(text(own)).toContain('Session caller')
  551. expect(errorCode(await mounted.call('session_search', { query: 'q' })))
  552. .toBe('SESSION_QUERY_TOOL_UNAUTHORIZED')
  553. createSession(mounted.ctx, 'other', undefined)
  554. expect(errorCode(await mounted.call('session_trace', { session_id: 'other' })))
  555. .toBe('SESSION_QUERY_TOOL_UNAUTHORIZED')
  556. })
  557. it('makes hidden and nonexistent parent guesses indistinguishable without calling search', async () => {
  558. const mounted = await mount()
  559. const hiddenParent = createSession(mounted.ctx, 'guessed-hidden-parent-secret', '/outside')
  560. const visibleChild = createSession(
  561. mounted.ctx,
  562. 'visible-child-of-hidden-parent',
  563. '/work',
  564. 20,
  565. hiddenParent.id,
  566. )
  567. FakeQuery.sessionSearch = () => Promise.resolve({
  568. items: [sessionHit(visibleChild.id, '/work', 'must not be discoverable', hiddenParent.id)],
  569. })
  570. const hidden = await mounted.call('session_search', {
  571. query: 'needle',
  572. parent_session_ids: [hiddenParent.id],
  573. })
  574. const missing = await mounted.call('session_search', {
  575. query: 'needle',
  576. parent_session_ids: ['guessed-missing-parent'],
  577. })
  578. expect(hidden).toEqual(missing)
  579. expect(text(hidden)).toBe('No prior session matches found.')
  580. expect(JSON.stringify(hidden)).not.toContain(visibleChild.id)
  581. expect(FakeQuery.sessionRequests).toEqual([])
  582. })
  583. it('deduplicates parent guesses and sends only authorized parents plus the root marker', async () => {
  584. const mounted = await mount()
  585. const visible = createSession(mounted.ctx, 'visible-parent', '/work')
  586. const hidden = createSession(mounted.ctx, 'hidden-parent-filter-secret', '/outside')
  587. await mounted.call('session_search', {
  588. query: 'needle',
  589. parent_session_ids: [visible.id, hidden.id, visible.id, 'missing-parent'],
  590. include_root_sessions: true,
  591. })
  592. await mounted.call('session_search', {
  593. query: 'needle',
  594. parent_session_ids: [hidden.id],
  595. include_root_sessions: true,
  596. })
  597. await mounted.call('session_search', {
  598. query: 'needle',
  599. parent_session_ids: ['missing-parent'],
  600. include_root_sessions: true,
  601. })
  602. const parentValues = FakeQuery.sessionRequests.map(request =>
  603. request.sessionFilters?.find(filter => filter.kind === 'parent'))
  604. expect(parentValues).toEqual([
  605. { kind: 'parent', values: [visible.id, null] },
  606. { kind: 'parent', values: [null] },
  607. { kind: 'parent', values: [null] },
  608. ])
  609. })
  610. it('rejects unrequested or unauthorized records returned during parent preauthorization', async () => {
  611. const mounted = await mount()
  612. const requested = SessionId('requested-parent')
  613. vi.spyOn(mounted.ctx.sessionQuery, 'filterSessions').mockResolvedValueOnce([
  614. { header: header('unrequested-parent', '/work'), live: true, persisted: false },
  615. { header: header(requested, '/outside'), live: true, persisted: false },
  616. ])
  617. const result = await mounted.call('session_search', {
  618. query: 'needle',
  619. parent_session_ids: [requested],
  620. })
  621. expect(text(result)).toBe('No prior session matches found.')
  622. expect(FakeQuery.sessionRequests).toEqual([])
  623. })
  624. it('validates every other search filter before parent preauthorization', async () => {
  625. const mounted = await mount()
  626. const filterSessions = vi.spyOn(mounted.ctx.sessionQuery, 'filterSessions')
  627. const result = await mounted.call('session_search', {
  628. query: 'needle',
  629. parent_session_ids: ['guessed-parent'],
  630. event_seq_from: -1,
  631. })
  632. expect(errorCode(result)).toBe('SESSION_QUERY_INVALID_FILTER')
  633. expect(filterSessions).not.toHaveBeenCalled()
  634. expect(FakeQuery.sessionRequests).toEqual([])
  635. })
  636. it('sanitizes parent preauthorization failures without calling search', async () => {
  637. const mounted = await mount()
  638. const secret = 'conflict at hidden-parent-preauthorization-secret'
  639. vi.spyOn(mounted.ctx.sessionQuery, 'filterSessions').mockRejectedValueOnce(
  640. new SessionQueryError(secret, 'SESSION_QUERY_SOURCE_CONFLICT'),
  641. )
  642. const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined)
  643. const result = await mounted.call('session_search', {
  644. query: 'needle',
  645. parent_session_ids: ['guessed-parent'],
  646. })
  647. expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_FAILED')
  648. expect(text(result)).toBe('Error: session query operation failed')
  649. expect(JSON.stringify(result)).not.toContain(secret)
  650. expect(warn).toHaveBeenCalledWith(expect.stringContaining(secret))
  651. expect(FakeQuery.sessionRequests).toEqual([])
  652. })
  653. it('sanitizes direct-target authorization failures before event search', async () => {
  654. const mounted = await mount()
  655. const target = createSession(mounted.ctx, 'authorization-failure-target', '/work')
  656. const secret = 'conflict with hidden-authorization-session-secret'
  657. vi.spyOn(mounted.ctx.sessionQuery, 'filterSessions').mockRejectedValueOnce(
  658. new SessionQueryError(secret, 'SESSION_QUERY_SOURCE_CONFLICT'),
  659. )
  660. const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined)
  661. const result = await mounted.call('session_event_search', {
  662. session_id: target.id,
  663. query: 'needle',
  664. })
  665. expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_FAILED')
  666. expect(text(result)).toBe('Error: session query operation failed')
  667. expect(JSON.stringify(result)).not.toContain(secret)
  668. expect(warn).toHaveBeenCalledWith(expect.stringContaining(secret))
  669. expect(FakeQuery.eventRequests).toEqual([])
  670. })
  671. it('preserves parent-preauthorization cancellation and waits for cleanup without logging it', async () => {
  672. const mounted = await mount()
  673. const controller = new AbortController()
  674. const cancellation = new SessionQueryError(
  675. 'parent preauthorization cancelled',
  676. 'SESSION_QUERY_ABORTED',
  677. )
  678. const started = Promise.withResolvers<undefined>()
  679. const abortObserved = Promise.withResolvers<undefined>()
  680. const cleanup = Promise.withResolvers<undefined>()
  681. let active = false
  682. vi.spyOn(mounted.ctx.sessionQuery, 'filterSessions')
  683. .mockImplementation(async (_filters, signal) => {
  684. if (signal === undefined) throw new Error('expected parent-authorization signal')
  685. active = true
  686. const aborted = new Promise<void>((resolve) => {
  687. signal.addEventListener('abort', () => { resolve() }, { once: true })
  688. })
  689. started.resolve(undefined)
  690. await aborted
  691. abortObserved.resolve(undefined)
  692. await cleanup.promise
  693. active = false
  694. signal.throwIfAborted()
  695. return []
  696. })
  697. const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined)
  698. const pending = mounted.call('session_search', {
  699. query: 'needle',
  700. parent_session_ids: ['guessed-parent'],
  701. }, { signal: controller.signal })
  702. let settled = false
  703. void pending.then(
  704. () => { settled = true },
  705. () => { settled = true },
  706. )
  707. await started.promise
  708. controller.abort(cancellation)
  709. await abortObserved.promise
  710. expect(settled).toBe(false)
  711. expect(active).toBe(true)
  712. expect(FakeQuery.sessionRequests).toEqual([])
  713. cleanup.resolve(undefined)
  714. const result = await pending
  715. expect(active).toBe(false)
  716. expect(errorCode(result)).toBe('SESSION_QUERY_ABORTED')
  717. expect(text(result)).toBe('Error: parent preauthorization cancelled')
  718. expect(warn).not.toHaveBeenCalled()
  719. })
  720. it('redacts an unauthorized ancestor and prunes unauthorized descendant subtrees without hidden ids', async () => {
  721. const mounted = await mount()
  722. const hiddenParent = createSession(mounted.ctx, 'hidden-parent-secret', '/outside')
  723. const target = createSession(mounted.ctx, 'target', '/work', 20, hiddenParent.id)
  724. const visible = createSession(mounted.ctx, 'visible-child', '/work', 30, target.id)
  725. const hidden = createSession(mounted.ctx, 'hidden-child-secret', '/outside', 40, target.id)
  726. createSession(mounted.ctx, 'hidden-grandchild-secret', '/work', 50, hidden.id)
  727. FakeQuery.titles.set(target.id, 'Target title')
  728. FakeQuery.titles.set(visible.id, 'Visible title')
  729. const result = await mounted.call('session_trace', { session_id: target.id })
  730. const output = text(result)
  731. expect(output).toContain('Target title')
  732. expect(output).toContain('visible-child')
  733. expect(output).toContain('[outside workspace boundary]')
  734. expect(output).toContain('[outside workspace subtree]')
  735. expect(output).not.toContain('hidden-parent-secret')
  736. expect(output).not.toContain('hidden-child-secret')
  737. expect(output).not.toContain('hidden-grandchild-secret')
  738. })
  739. it('sanitizes a real outside-workspace ancestor cycle before the lineage error reaches the model', async () => {
  740. const mounted = await mount()
  741. const hiddenA = SessionId('hidden-cycle-a-secret')
  742. const hiddenB = SessionId('hidden-cycle-b-secret')
  743. createSession(mounted.ctx, hiddenA, '/outside', 2, hiddenB)
  744. createSession(mounted.ctx, hiddenB, '/outside', 3, hiddenA)
  745. const target = createSession(mounted.ctx, 'visible-cycle-target', '/work', 4, hiddenA)
  746. const result = await mounted.call('session_trace', { session_id: target.id })
  747. expect(errorCode(result)).toBe('SESSION_QUERY_INVALID_LINEAGE')
  748. expect(text(result)).toBe('Error: session lineage is invalid')
  749. const presentation = JSON.stringify(result)
  750. expect(presentation).not.toContain(hiddenA)
  751. expect(presentation).not.toContain(hiddenB)
  752. })
  753. it.each([
  754. {
  755. name: 'sensitive source conflict',
  756. makeError: () => new SessionQueryError(
  757. 'conflict with hidden-lineage-session-secret',
  758. 'SESSION_QUERY_SOURCE_CONFLICT',
  759. ),
  760. code: 'SESSION_QUERY_TOOL_FAILED',
  761. message: 'session query operation failed',
  762. secret: 'hidden-lineage-session-secret',
  763. },
  764. {
  765. name: 'typed query error',
  766. makeError: () => new SessionQueryError(
  767. 'unrelated persistence failure',
  768. 'SESSION_QUERY_PERSISTENCE_FAILED',
  769. ),
  770. code: 'SESSION_QUERY_PERSISTENCE_FAILED',
  771. message: 'session history storage is unavailable',
  772. secret: 'unrelated persistence failure',
  773. },
  774. {
  775. name: 'plain error',
  776. makeError: () => new Error('unrelated plain trace failure'),
  777. code: 'SESSION_QUERY_TOOL_FAILED',
  778. message: 'session query operation failed',
  779. secret: 'unrelated plain trace failure',
  780. },
  781. ])('sanitizes an unrelated $name from lineage tracing', async ({ makeError, code, message, secret }) => {
  782. const mounted = await mount()
  783. const target = createSession(mounted.ctx, 'trace-failure-target', '/work')
  784. const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined)
  785. vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockRejectedValueOnce(makeError())
  786. const result = await mounted.call('session_trace', { session_id: target.id })
  787. expect(errorCode(result)).toBe(code)
  788. expect(text(result)).toBe(`Error: ${message}`)
  789. expect(JSON.stringify(result)).not.toContain(secret)
  790. expect(warn).toHaveBeenCalledWith(expect.stringContaining(secret))
  791. })
  792. it.each([
  793. 'session_event_trace',
  794. 'session_event_read',
  795. ] as const)('sanitizes typed service diagnostics from %s', async (toolName) => {
  796. const mounted = await mount()
  797. const target = createSession(mounted.ctx, `${toolName}-failure-target`, '/work')
  798. target.append(
  799. 'user/message',
  800. createUserMessage({
  801. content: [{ type: 'text', text: 'event' }], source: { kind: 'user' },
  802. }),
  803. { surfaceOp: 'append' },
  804. )
  805. const secret = `event missing beside hidden-${toolName}-secret`
  806. const failure = new SessionQueryError(secret, 'SESSION_QUERY_EVENT_NOT_FOUND')
  807. if (toolName === 'session_event_trace') {
  808. vi.spyOn(mounted.ctx.sessionQuery, 'traceEvent').mockRejectedValueOnce(failure)
  809. } else {
  810. vi.spyOn(mounted.ctx.sessionQuery, 'readEvent').mockRejectedValueOnce(failure)
  811. }
  812. const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined)
  813. const result = await mounted.call(toolName, { session_id: target.id, seq: 0 })
  814. expect(errorCode(result)).toBe('SESSION_QUERY_EVENT_NOT_FOUND')
  815. expect(text(result)).toBe('Error: session event was not found')
  816. expect(JSON.stringify(result)).not.toContain(secret)
  817. expect(warn).toHaveBeenCalledWith(expect.stringContaining(secret))
  818. })
  819. it.each([
  820. 'session_trace',
  821. 'session_event_trace',
  822. 'session_event_read',
  823. ] as const)('forwards the exact signal to %s and waits for service cleanup', async (toolName) => {
  824. const mounted = await mount()
  825. const target = createSession(mounted.ctx, `cancelled-${toolName}`, '/work')
  826. target.append(
  827. 'user/message',
  828. createUserMessage({
  829. content: [{ type: 'text', text: 'pending exact read' }], source: { kind: 'user' },
  830. }),
  831. { surfaceOp: 'append' },
  832. )
  833. const controller = new AbortController()
  834. const cancellation = new SessionQueryError(
  835. `${toolName} cancelled`,
  836. 'SESSION_QUERY_ABORTED',
  837. )
  838. const started = Promise.withResolvers<undefined>()
  839. const abortObserved = Promise.withResolvers<undefined>()
  840. const cleanup = Promise.withResolvers<undefined>()
  841. const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined)
  842. let observedSignal: AbortSignal | undefined
  843. let active = false
  844. const holdExactRead = async (signal?: AbortSignal): Promise<never> => {
  845. if (signal === undefined) throw new Error('expected exact tool execution signal')
  846. observedSignal = signal
  847. active = true
  848. const aborted = new Promise<void>((resolve) => {
  849. signal.addEventListener('abort', () => { resolve() }, { once: true })
  850. })
  851. started.resolve(undefined)
  852. await aborted
  853. abortObserved.resolve(undefined)
  854. await cleanup.promise
  855. active = false
  856. signal.throwIfAborted()
  857. throw new Error('unreachable after exact tool cancellation')
  858. }
  859. if (toolName === 'session_trace') {
  860. vi.spyOn(mounted.ctx.sessionQuery, 'traceSession')
  861. .mockImplementation((_sessionId, signal) => holdExactRead(signal))
  862. } else if (toolName === 'session_event_trace') {
  863. vi.spyOn(mounted.ctx.sessionQuery, 'traceEvent')
  864. .mockImplementation((_request, signal) => holdExactRead(signal))
  865. } else {
  866. vi.spyOn(mounted.ctx.sessionQuery, 'readEvent')
  867. .mockImplementation((_request, signal) => holdExactRead(signal))
  868. }
  869. const args = toolName === 'session_trace'
  870. ? { session_id: target.id }
  871. : { session_id: target.id, seq: 0 }
  872. const pending = mounted.call(toolName, args, { signal: controller.signal })
  873. let settled = false
  874. void pending.then(
  875. () => { settled = true },
  876. () => { settled = true },
  877. )
  878. await started.promise
  879. controller.abort(cancellation)
  880. await abortObserved.promise
  881. expect(settled).toBe(false)
  882. expect(active).toBe(true)
  883. expect(observedSignal).toBe(controller.signal)
  884. cleanup.resolve(undefined)
  885. const result = await pending
  886. expect(active).toBe(false)
  887. expect(errorCode(result)).toBe('SESSION_QUERY_ABORTED')
  888. expect(text(result)).toBe(`Error: ${toolName} cancelled`)
  889. expect(warn).not.toHaveBeenCalled()
  890. })
  891. it('preserves caller cancellation while a lineage trace is pending', async () => {
  892. const mounted = await mount()
  893. const target = createSession(mounted.ctx, 'cancelled-trace-target', '/work')
  894. const trace = await mounted.ctx.sessionQuery.traceSession(target.id)
  895. let started!: () => void
  896. const traceStarted = new Promise<void>((resolve) => { started = resolve })
  897. let finish!: (value: typeof trace) => void
  898. vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockImplementation(() => {
  899. started()
  900. return new Promise<typeof trace>((resolve) => { finish = resolve })
  901. })
  902. const controller = new AbortController()
  903. const cancellation = new SessionQueryError('lineage trace cancelled', 'SESSION_QUERY_ABORTED')
  904. const pending = mounted.call(
  905. 'session_trace',
  906. { session_id: target.id },
  907. { signal: controller.signal },
  908. )
  909. await traceStarted
  910. controller.abort(cancellation)
  911. finish(trace)
  912. const result = await pending
  913. expect(errorCode(result)).toBe('SESSION_QUERY_ABORTED')
  914. expect(text(result)).toBe('Error: lineage trace cancelled')
  915. })
  916. it('gives caller cancellation precedence when a pending trace rejects with invalid lineage', async () => {
  917. const mounted = await mount()
  918. const target = createSession(mounted.ctx, 'cancelled-invalid-lineage-target', '/work')
  919. let started!: () => void
  920. const traceStarted = new Promise<void>((resolve) => { started = resolve })
  921. let fail!: (error: SessionQueryError) => void
  922. vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockImplementation(() => {
  923. started()
  924. return new Promise((_resolve, reject) => { fail = reject })
  925. })
  926. const controller = new AbortController()
  927. const cancellation = new SessionQueryError('lineage trace cancelled first', 'SESSION_QUERY_ABORTED')
  928. const pending = mounted.call(
  929. 'session_trace',
  930. { session_id: target.id },
  931. { signal: controller.signal },
  932. )
  933. await traceStarted
  934. controller.abort(cancellation)
  935. fail(new SessionQueryError(
  936. 'session lineage contains a cycle at "hidden-race-secret"',
  937. 'SESSION_QUERY_INVALID_LINEAGE',
  938. ))
  939. const result = await pending
  940. expect(errorCode(result)).toBe('SESSION_QUERY_ABORTED')
  941. expect(text(result)).toBe('Error: lineage trace cancelled first')
  942. expect(JSON.stringify(result)).not.toContain('hidden-race-secret')
  943. })
  944. it('renders branching descendants in source preorder with one indented marker per pruned subtree', async () => {
  945. const mounted = await mount()
  946. const target = createSession(mounted.ctx, 'branch-target', '/work', 20)
  947. const [targetRecord] = await mounted.ctx.sessionQuery.filterSessions([{
  948. kind: 'id',
  949. values: [target.id],
  950. }])
  951. if (targetRecord === undefined) throw new Error('expected target record')
  952. const firstId = SessionId('branch-first')
  953. const nestedId = SessionId('branch-nested')
  954. const hiddenId = SessionId('branch-hidden-secret')
  955. const hiddenDescendantId = SessionId('branch-hidden-descendant-secret')
  956. const lastId = SessionId('branch-last')
  957. const descendants: SessionLineageNode[] = [
  958. {
  959. session: { ...targetRecord, header: header(firstId, '/work', 30) },
  960. descendants: [
  961. {
  962. session: { ...targetRecord, header: header(nestedId, '/work', 40) },
  963. descendants: [],
  964. },
  965. {
  966. session: { ...targetRecord, header: header(hiddenId, '/outside', 50) },
  967. descendants: [{
  968. session: { ...targetRecord, header: header(hiddenDescendantId, '/work', 60) },
  969. descendants: [],
  970. }],
  971. },
  972. ],
  973. },
  974. {
  975. session: { ...targetRecord, header: header(lastId, '/work', 70) },
  976. descendants: [],
  977. },
  978. ]
  979. vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockResolvedValue({
  980. target: targetRecord,
  981. ancestors: [],
  982. descendants,
  983. complete: true,
  984. root: targetRecord,
  985. })
  986. const titleReads: SessionIdValue[] = []
  987. vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots').mockImplementation((sessionIds) => {
  988. titleReads.push(...sessionIds)
  989. return Promise.resolve([...new Set(sessionIds)].map(sessionId => ({
  990. sessionId,
  991. status: 'fulfilled' as const,
  992. value: { session: header(sessionId, '/work') },
  993. })))
  994. })
  995. const output = text(await mounted.call('session_trace', { session_id: target.id }))
  996. expect(output.slice(output.indexOf('Descendants:'))).toBe([
  997. 'Descendants:',
  998. '- branch-first — untitled | 1970-01-01T00:00:00.030Z | live',
  999. ' - branch-nested — untitled | 1970-01-01T00:00:00.040Z | live',
  1000. ' - [outside workspace subtree]',
  1001. '- branch-last — untitled | 1970-01-01T00:00:00.070Z | live',
  1002. ].join('\n'))
  1003. expect(titleReads).toEqual([target.id, firstId, nestedId, lastId])
  1004. })
  1005. it('renders authorized ancestors and an unresolved lineage boundary without leaking it', async () => {
  1006. const mounted = await mount()
  1007. const root = createSession(mounted.ctx, 'visible-root', '/work', 5)
  1008. const target = createSession(mounted.ctx, 'visible-target', '/work', 6, root.id)
  1009. const complete = text(await mounted.call('session_trace', { session_id: target.id }))
  1010. expect(complete).toContain('visible-root')
  1011. const missingParent = SessionId('missing-parent-secret')
  1012. const incomplete = createSession(mounted.ctx, 'incomplete-target', '/work', 7, missingParent)
  1013. const redacted = text(await mounted.call('session_trace', { session_id: incomplete.id }))
  1014. expect(redacted).toContain('[outside workspace boundary]')
  1015. expect(redacted).not.toContain(missingParent)
  1016. })
  1017. it('renders unavailable trace records and keeps a self-id descendant authorized', async () => {
  1018. const mounted = await mount()
  1019. const target = createSession(mounted.ctx, 'trace-unavailable', '/work')
  1020. const [record] = await mounted.ctx.sessionQuery.filterSessions([{ kind: 'id', values: [target.id] }])
  1021. const [callerRecord] = await mounted.ctx.sessionQuery.filterSessions([{
  1022. kind: 'id',
  1023. values: [mounted.caller.id],
  1024. }])
  1025. if (record === undefined || callerRecord === undefined) throw new Error('expected live records')
  1026. const unavailable = { ...record, live: false, persisted: false }
  1027. const persisted = { ...callerRecord, live: false, persisted: true }
  1028. vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockResolvedValue({
  1029. target: unavailable,
  1030. ancestors: [],
  1031. descendants: [{ session: persisted, descendants: [] }],
  1032. complete: true,
  1033. root: unavailable,
  1034. })
  1035. const output = text(await mounted.call('session_trace', { session_id: target.id }))
  1036. expect(output).toContain('Availability: unavailable')
  1037. expect(output).toContain(mounted.caller.id)
  1038. expect(output).toContain('persisted')
  1039. })
  1040. it('rejects every payload observation whose target moved after pre-authorization', async () => {
  1041. const mounted = await mount()
  1042. const target = createSession(mounted.ctx, 'moving-target', '/work')
  1043. target.append(
  1044. 'user/message',
  1045. createUserMessage({
  1046. content: [{ type: 'text', text: 'authorized payload' }], source: { kind: 'user' },
  1047. }),
  1048. { surfaceOp: 'append' },
  1049. )
  1050. const movedHeader = header(target.id, '/outside')
  1051. FakeQuery.eventSearch = () => Promise.resolve({
  1052. session: movedHeader,
  1053. items: [eventHit(target.id, 0, 'secret event hit')],
  1054. })
  1055. const search = await mounted.call('session_event_search', {
  1056. session_id: target.id,
  1057. query: 'secret',
  1058. })
  1059. expect(errorCode(search)).toBe('SESSION_QUERY_TOOL_UNAUTHORIZED')
  1060. expect(text(search)).not.toContain('secret event hit')
  1061. const lineage = await mounted.ctx.sessionQuery.traceSession(target.id)
  1062. vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockResolvedValueOnce({
  1063. ...lineage,
  1064. target: { ...lineage.target, header: movedHeader },
  1065. })
  1066. expect(errorCode(await mounted.call('session_trace', { session_id: target.id })))
  1067. .toBe('SESSION_QUERY_TOOL_UNAUTHORIZED')
  1068. const eventTrace = await mounted.ctx.sessionQuery.traceEvent({ sessionId: target.id, seq: 0 })
  1069. vi.spyOn(mounted.ctx.sessionQuery, 'traceEvent').mockResolvedValueOnce({
  1070. ...eventTrace,
  1071. session: movedHeader,
  1072. })
  1073. expect(errorCode(await mounted.call('session_event_trace', { session_id: target.id, seq: 0 })))
  1074. .toBe('SESSION_QUERY_TOOL_UNAUTHORIZED')
  1075. const eventWindow = await mounted.ctx.sessionQuery.readEvent({ sessionId: target.id, seq: 0 })
  1076. vi.spyOn(mounted.ctx.sessionQuery, 'readEvent').mockResolvedValueOnce({
  1077. ...eventWindow,
  1078. session: movedHeader,
  1079. })
  1080. expect(errorCode(await mounted.call('session_event_read', { session_id: target.id, seq: 0 })))
  1081. .toBe('SESSION_QUERY_TOOL_UNAUTHORIZED')
  1082. FakeQuery.sessionSearch = () => Promise.resolve({
  1083. items: [sessionHit(target.id, '/work', 'safe hit')],
  1084. })
  1085. vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots').mockResolvedValueOnce([{
  1086. sessionId: target.id,
  1087. status: 'fulfilled',
  1088. value: {
  1089. session: movedHeader,
  1090. title: {
  1091. title: 'secret moved title',
  1092. messageSeqs: [],
  1093. source: { kind: 'fallback' },
  1094. eventSeq: 0,
  1095. updatedAt: 1,
  1096. },
  1097. },
  1098. }])
  1099. const titled = await mounted.call('session_search', { query: 'safe' })
  1100. expect(errorCode(titled)).toBe('SESSION_QUERY_TOOL_UNAUTHORIZED')
  1101. expect(text(titled)).not.toContain('secret moved title')
  1102. })
  1103. it('rejects a default self read when its same-id observation moved after caller capture', async () => {
  1104. const mounted = await mount()
  1105. const appendLegacy = mounted.caller.append.bind(mounted.caller) as unknown as (
  1106. type: string,
  1107. data: unknown,
  1108. ) => Session['events'][number]
  1109. const secret = appendLegacy(
  1110. 'context/message',
  1111. {
  1112. content: [{ type: 'text', text: 'same-id moved secret' }],
  1113. source: { kind: 'plugin', plugin: 'test' },
  1114. },
  1115. )
  1116. const window = await mounted.ctx.sessionQuery.readEvent({
  1117. sessionId: mounted.caller.id,
  1118. seq: secret.seq,
  1119. })
  1120. vi.spyOn(mounted.ctx.sessionQuery, 'readEvent').mockResolvedValueOnce({
  1121. ...window,
  1122. session: header(mounted.caller.id, '/outside'),
  1123. })
  1124. const denied = await mounted.call('session_event_read', { seq: secret.seq })
  1125. expect(errorCode(denied)).toBe('SESSION_QUERY_TOOL_UNAUTHORIZED')
  1126. expect(text(denied)).not.toContain('same-id moved secret')
  1127. })
  1128. })
  1129. describe('search paging, prior-history bounds, titles, and cancellation', () => {
  1130. it('drains hidden internal pages to the authorized non-self cap and masks an unauthorized parent id', async () => {
  1131. const mounted = await mount({ maxSearchResults: 2 })
  1132. const outside = createSession(mounted.ctx, 'outside-parent-secret', '/outside')
  1133. const a = createSession(mounted.ctx, 'a', '/work')
  1134. const b = createSession(mounted.ctx, 'b', '/work')
  1135. FakeQuery.titles.set(a.id, 'Alpha')
  1136. FakeQuery.titles.set(b.id, 'Beta')
  1137. const c1 = SessionSearchCursor('c1')
  1138. const c2 = SessionSearchCursor('c2')
  1139. FakeQuery.sessionSearch = (request) => {
  1140. if (request.cursor === undefined) {
  1141. return Promise.resolve({
  1142. items: [
  1143. sessionHit('caller', '/work'),
  1144. sessionHit('unauthorized', '/outside'),
  1145. ],
  1146. nextCursor: c1,
  1147. })
  1148. }
  1149. if (request.cursor === c1) {
  1150. return Promise.resolve({
  1151. items: [sessionHit('a', '/work', 'first', outside.id)],
  1152. nextCursor: c2,
  1153. })
  1154. }
  1155. return Promise.resolve({
  1156. items: [
  1157. sessionHit('b', '/work', 'second'),
  1158. sessionHit('additional-authorized', '/work', 'third'),
  1159. ],
  1160. })
  1161. }
  1162. const result = await mounted.call('session_search', { query: 'needle' })
  1163. const output = text(result)
  1164. expect(FakeQuery.sessionRequests).toHaveLength(3)
  1165. expect(FakeQuery.sessionRequests.every(request => request.limit === undefined)).toBe(true)
  1166. expect(FakeQuery.sessionRequests.map(request => request.cursor)).toEqual([undefined, c1, c2])
  1167. expect(output).toContain('Session a — Alpha')
  1168. expect(output).toContain('Session b — Beta')
  1169. expect(output).toContain('Parent: [outside workspace]')
  1170. expect(output).not.toContain('outside-parent-secret')
  1171. expect(output).toContain('Result cap reached')
  1172. })
  1173. it('does not report a cap when only rejected hits remain after the authorized limit', async () => {
  1174. const mounted = await mount({ maxSearchResults: 1 })
  1175. const cursor = SessionSearchCursor('rejected-tail')
  1176. FakeQuery.sessionSearch = request => request.cursor === undefined
  1177. ? Promise.resolve({
  1178. items: [sessionHit('authorized', '/work')],
  1179. nextCursor: cursor,
  1180. })
  1181. : Promise.resolve({
  1182. items: [
  1183. sessionHit(mounted.caller.id, '/work'),
  1184. sessionHit('outside', '/outside'),
  1185. ],
  1186. })
  1187. const output = text(await mounted.call('session_search', { query: 'needle' }))
  1188. expect(FakeQuery.sessionRequests.map(request => request.cursor)).toEqual([undefined, cursor])
  1189. expect(output).toContain('Session authorized')
  1190. expect(output).not.toContain('Result cap reached')
  1191. })
  1192. it.each([
  1193. {
  1194. toolName: 'session_search',
  1195. args: { query: 'needle' },
  1196. secrets: [
  1197. 'session source conflict at hidden-search-session-secret',
  1198. 'hidden-search-cause-secret',
  1199. ],
  1200. failure: () => new SessionQueryError(
  1201. 'session source conflict at hidden-search-session-secret',
  1202. 'SESSION_QUERY_SOURCE_CONFLICT',
  1203. { cause: new Error('hidden-search-cause-secret') },
  1204. ),
  1205. },
  1206. {
  1207. toolName: 'session_event_search',
  1208. args: { query: 'needle' },
  1209. secrets: [
  1210. 'plain event provider failure at hidden-event-session-secret',
  1211. 'hidden-event-cause-secret',
  1212. ],
  1213. failure: () => new Error(
  1214. 'plain event provider failure at hidden-event-session-secret',
  1215. { cause: 'hidden-event-cause-secret' },
  1216. ),
  1217. },
  1218. ] as const)('sanitizes $toolName provider diagnostics', async ({ toolName, args, secrets, failure }) => {
  1219. const mounted = await mount()
  1220. if (toolName === 'session_search') {
  1221. FakeQuery.sessionSearch = () => Promise.reject(failure())
  1222. } else {
  1223. FakeQuery.eventSearch = () => Promise.reject(failure())
  1224. }
  1225. const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined)
  1226. const result = await mounted.call(toolName, args)
  1227. expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_FAILED')
  1228. expect(text(result)).toBe('Error: session query operation failed')
  1229. for (const secret of secrets) {
  1230. expect(JSON.stringify(result)).not.toContain(secret)
  1231. expect(warn).toHaveBeenCalledWith(expect.stringContaining(secret))
  1232. }
  1233. })
  1234. it.each([
  1235. {
  1236. name: 'a hostile prototype trap',
  1237. secrets: ['proxy payload secret', 'getPrototypeOf secondary secret'],
  1238. diagnostic: '[unprintable session query failure]',
  1239. failure: (): unknown => new Proxy(
  1240. { payload: 'proxy payload secret' },
  1241. {
  1242. getPrototypeOf() {
  1243. throw new Error('getPrototypeOf secondary secret')
  1244. },
  1245. },
  1246. ),
  1247. },
  1248. {
  1249. name: 'a throwing stack getter',
  1250. secrets: ['stack primary secret', 'stack getter secondary secret'],
  1251. diagnostic: '[unprintable session query failure]',
  1252. failure: (): unknown => {
  1253. const error = new Error('stack primary secret')
  1254. Object.defineProperty(error, 'stack', {
  1255. get() {
  1256. throw new Error('stack getter secondary secret')
  1257. },
  1258. })
  1259. return error
  1260. },
  1261. },
  1262. {
  1263. name: 'a throwing cause getter',
  1264. secrets: ['cause primary secret', 'cause getter secondary secret'],
  1265. diagnostic: '[unprintable session query failure]',
  1266. failure: (): unknown => {
  1267. const error = new Error('cause primary secret')
  1268. Object.defineProperty(error, 'cause', {
  1269. get() {
  1270. throw new Error('cause getter secondary secret')
  1271. },
  1272. })
  1273. return error
  1274. },
  1275. },
  1276. {
  1277. name: 'throwing string coercion',
  1278. secrets: ['string payload secret', 'string coercion secondary secret'],
  1279. diagnostic: '[unprintable session query failure]',
  1280. failure: (): unknown => ({
  1281. payload: 'string payload secret',
  1282. [Symbol.toPrimitive]() {
  1283. throw new Error('string coercion secondary secret')
  1284. },
  1285. }),
  1286. },
  1287. {
  1288. name: 'a throwing code getter',
  1289. secrets: ['code primary secret', 'code getter secondary secret'],
  1290. diagnostic: 'code primary secret',
  1291. failure: (): unknown => {
  1292. const error = new SessionQueryError(
  1293. 'code primary secret',
  1294. 'SESSION_QUERY_PERSISTENCE_FAILED',
  1295. )
  1296. Object.defineProperty(error, 'code', {
  1297. get() {
  1298. throw new Error('code getter secondary secret')
  1299. },
  1300. })
  1301. return error
  1302. },
  1303. },
  1304. {
  1305. name: 'an unknown string code',
  1306. secrets: ['unknown code primary secret', '__proto__'],
  1307. diagnostic: 'unknown code primary secret',
  1308. failure: (): unknown => {
  1309. const error = new SessionQueryError(
  1310. 'unknown code primary secret',
  1311. 'SESSION_QUERY_PERSISTENCE_FAILED',
  1312. )
  1313. Object.defineProperty(error, 'code', { value: '__proto__' })
  1314. return error
  1315. },
  1316. },
  1317. {
  1318. name: 'a non-string code',
  1319. secrets: ['non-string code primary secret', 'non-string code secondary secret'],
  1320. diagnostic: 'non-string code primary secret',
  1321. failure: (): unknown => {
  1322. const error = new SessionQueryError(
  1323. 'non-string code primary secret',
  1324. 'SESSION_QUERY_PERSISTENCE_FAILED',
  1325. )
  1326. Object.defineProperty(error, 'code', {
  1327. value: {
  1328. toString() {
  1329. throw new Error('non-string code secondary secret')
  1330. },
  1331. },
  1332. })
  1333. return error
  1334. },
  1335. },
  1336. ])('fails generic when inspecting $name is unsafe', async ({ secrets, diagnostic, failure }) => {
  1337. const mounted = await mount()
  1338. // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- hostile unknown rejection is the scenario
  1339. FakeQuery.sessionSearch = () => Promise.reject(failure())
  1340. const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined)
  1341. const result = await mounted.call('session_search', { query: 'needle' })
  1342. expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_FAILED')
  1343. expect(text(result)).toBe('Error: session query operation failed')
  1344. for (const secret of secrets) expect(JSON.stringify(result)).not.toContain(secret)
  1345. expect(warn).toHaveBeenCalledWith(expect.stringContaining(diagnostic))
  1346. })
  1347. it('retains a fixed safe typed failure when only its nested diagnostic is unprintable', async () => {
  1348. const mounted = await mount()
  1349. const primary = 'typed outer diagnostic secret'
  1350. const nested = 'nested prototype secondary secret'
  1351. const cause = new Proxy(
  1352. {},
  1353. {
  1354. getPrototypeOf() {
  1355. throw new Error(nested)
  1356. },
  1357. },
  1358. )
  1359. FakeQuery.sessionSearch = () => Promise.reject(
  1360. new SessionQueryError(
  1361. primary,
  1362. 'SESSION_QUERY_PERSISTENCE_FAILED',
  1363. { cause },
  1364. ),
  1365. )
  1366. const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined)
  1367. const result = await mounted.call('session_search', { query: 'needle' })
  1368. expect(errorCode(result)).toBe('SESSION_QUERY_PERSISTENCE_FAILED')
  1369. expect(text(result)).toBe('Error: session history storage is unavailable')
  1370. expect(JSON.stringify(result)).not.toContain(primary)
  1371. expect(JSON.stringify(result)).not.toContain(nested)
  1372. expect(warn).toHaveBeenCalledWith(expect.stringContaining('[unprintable session query failure]'))
  1373. })
  1374. it('logs an inspectable cyclic cause chain without exposing it', async () => {
  1375. const mounted = await mount()
  1376. const outer = new Error('cyclic outer secret')
  1377. const inner = new Error('cyclic inner secret')
  1378. Object.defineProperty(outer, 'cause', { value: inner })
  1379. Object.defineProperty(inner, 'cause', { value: outer })
  1380. FakeQuery.sessionSearch = () => Promise.reject(outer)
  1381. const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined)
  1382. const result = await mounted.call('session_search', { query: 'needle' })
  1383. expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_FAILED')
  1384. expect(text(result)).toBe('Error: session query operation failed')
  1385. expect(JSON.stringify(result)).not.toContain('cyclic outer secret')
  1386. expect(JSON.stringify(result)).not.toContain('cyclic inner secret')
  1387. expect(warn).toHaveBeenCalledWith(expect.stringContaining('cyclic outer secret'))
  1388. expect(warn).toHaveBeenCalledWith(expect.stringContaining('cyclic inner secret'))
  1389. expect(warn).toHaveBeenCalledWith(expect.stringContaining('[circular error cause]'))
  1390. })
  1391. it('fails generic when internal warning logging throws', async () => {
  1392. const mounted = await mount()
  1393. const primary = 'typed persistence primary secret'
  1394. const secondary = 'logger warning secondary secret'
  1395. FakeQuery.sessionSearch = () => Promise.reject(
  1396. new SessionQueryError(primary, 'SESSION_QUERY_PERSISTENCE_FAILED'),
  1397. )
  1398. const warn = vi.spyOn(mounted.ctx.logger, 'warn')
  1399. .mockImplementation(() => undefined)
  1400. .mockImplementationOnce(() => {
  1401. throw new Error(secondary)
  1402. })
  1403. const result = await mounted.call('session_search', { query: 'needle' })
  1404. expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_FAILED')
  1405. expect(text(result)).toBe('Error: session query operation failed')
  1406. expect(JSON.stringify(result)).not.toContain(primary)
  1407. expect(JSON.stringify(result)).not.toContain(secondary)
  1408. expect(warn).toHaveBeenCalledTimes(1)
  1409. })
  1410. it('preserves stale-cursor diagnostics without transparently restarting', async () => {
  1411. const mounted = await mount({ maxSearchResults: 2 })
  1412. const cursor = SessionSearchCursor('stale-next')
  1413. FakeQuery.sessionSearch = request => request.cursor === undefined
  1414. ? Promise.resolve({ items: [], nextCursor: cursor })
  1415. : Promise.reject(new SessionQueryError('stale provider generation', 'SESSION_QUERY_STALE_CURSOR'))
  1416. const result = await mounted.call('session_search', { query: 'needle' })
  1417. expect(errorCode(result)).toBe('SESSION_QUERY_STALE_CURSOR')
  1418. expect(text(result)).toContain('retry the complete search call')
  1419. expect(FakeQuery.sessionRequests).toHaveLength(2)
  1420. })
  1421. it('rejects a repeated internal cursor instead of looping', async () => {
  1422. const mounted = await mount()
  1423. const cursor = SessionSearchCursor('repeat')
  1424. FakeQuery.sessionSearch = () => Promise.resolve({ items: [], nextCursor: cursor })
  1425. const result = await mounted.call('session_search', { query: 'needle' })
  1426. expect(errorCode(result)).toBe('SESSION_QUERY_INVALID_CURSOR')
  1427. expect(text(result)).toBe('Error: session-search provider repeated a continuation cursor')
  1428. expect(FakeQuery.sessionRequests).toHaveLength(2)
  1429. })
  1430. it('renders authorized parent ids and all availability states', async () => {
  1431. const mounted = await mount({ maxSearchResults: 3 })
  1432. const parent = createSession(mounted.ctx, 'parent', '/work')
  1433. const child = createSession(mounted.ctx, 'child', '/work', 2, parent.id)
  1434. const callerChild = createSession(mounted.ctx, 'caller-child', '/work', 3, mounted.caller.id)
  1435. FakeQuery.sessionSearch = () => Promise.resolve({
  1436. items: [
  1437. { ...sessionHit(child.id, '/work', 'both', parent.id), live: true, persisted: true },
  1438. { ...sessionHit(callerChild.id, '/work', 'persisted', mounted.caller.id), live: false, persisted: true },
  1439. { ...sessionHit('unavailable', '/work', 'neither'), live: false, persisted: false },
  1440. ],
  1441. })
  1442. const output = text(await mounted.call('session_search', { query: 'needle' }))
  1443. expect(output).toContain('Parent: parent')
  1444. expect(output).toContain(`Parent: ${mounted.caller.id}`)
  1445. expect(output).toContain('Availability: live, persisted')
  1446. expect(output).toContain('Availability: persisted')
  1447. expect(output).toContain('Availability: unavailable')
  1448. })
  1449. it('intersects current-session search with the event before the latest step and leaves other targets unchanged', async () => {
  1450. const mounted = await mount()
  1451. FakeQuery.eventSearch = request => Promise.resolve({
  1452. session: header(request.sessionId, '/work'),
  1453. items: [eventHit(request.sessionId, 1)],
  1454. })
  1455. await mounted.call('session_event_search', {
  1456. query: 'prior',
  1457. seq_from: 0,
  1458. seq_to: 99,
  1459. })
  1460. expect(FakeQuery.eventRequests[0]?.filters).toContainEqual({ kind: 'seq', from: 0, to: 1 })
  1461. const other = createSession(mounted.ctx, 'other', '/work')
  1462. await mounted.call('session_event_search', {
  1463. session_id: other.id,
  1464. query: 'prior',
  1465. seq_from: 0,
  1466. seq_to: 99,
  1467. })
  1468. expect(FakeQuery.eventRequests[1]?.filters).toContainEqual({ kind: 'seq', from: 0, to: 99 })
  1469. })
  1470. it('returns no current-session hits without calling FTS when the user range starts in the active step', async () => {
  1471. const mounted = await mount()
  1472. const result = await mounted.call('session_event_search', {
  1473. query: 'prior',
  1474. seq_from: 2,
  1475. })
  1476. expect(result.isError).toBe(false)
  1477. expect(text(result)).toContain('No prior event matches found.')
  1478. expect(FakeQuery.eventRequests).toEqual([])
  1479. })
  1480. it('requires a current step boundary and drains event pages to a capped result', async () => {
  1481. const mounted = await mount({ maxSearchResults: 2 })
  1482. const noStep = createSession(mounted.ctx, 'no-step', '/work')
  1483. const missing = await mounted.call(
  1484. 'session_event_search',
  1485. { query: 'q' },
  1486. { agent: fakeAgent(noStep) },
  1487. )
  1488. expect(errorCode(missing)).toBe('SESSION_QUERY_TOOL_NO_CURRENT_STEP')
  1489. const other = createSession(mounted.ctx, 'paged-events', '/work')
  1490. const cursor = SessionSearchCursor('events-next')
  1491. FakeQuery.eventSearch = request => request.cursor === undefined
  1492. ? Promise.resolve({
  1493. session: header(other.id, '/work'),
  1494. items: [eventHit(other.id, 1)],
  1495. nextCursor: cursor,
  1496. })
  1497. : Promise.resolve({
  1498. session: header(other.id, '/work'),
  1499. items: [eventHit(other.id, 2), eventHit(other.id, 3)],
  1500. })
  1501. const result = await mounted.call('session_event_search', {
  1502. session_id: other.id,
  1503. query: 'q',
  1504. })
  1505. expect(FakeQuery.eventRequests.map(request => request.cursor)).toEqual([undefined, cursor])
  1506. expect(text(result)).toContain('Result cap reached')
  1507. })
  1508. it('preserves base results when a title read fails, annotates the code, and logs the full error', async () => {
  1509. const mounted = await mount()
  1510. const hit = createSession(mounted.ctx, 'hit', '/work')
  1511. const failure = new HarnessError('title backend failed', 'TITLE_BACKEND')
  1512. FakeQuery.titles.set(hit.id, failure)
  1513. FakeQuery.sessionSearch = () => Promise.resolve({ items: [sessionHit(hit.id, '/work')] })
  1514. const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined)
  1515. const result = await mounted.call('session_search', { query: 'needle' })
  1516. expect(result.isError).toBe(false)
  1517. expect(text(result)).toContain('untitled (title unavailable: SESSION_QUERY_TOOL_FAILED)')
  1518. expect(JSON.stringify(result)).not.toContain('title backend failed')
  1519. expect(warn).toHaveBeenCalledWith(expect.stringContaining('title backend failed'))
  1520. expect(warn).toHaveBeenCalledWith(expect.stringContaining('HarnessError'))
  1521. })
  1522. it('reports unknown title failures and preserves an Error without a stack', async () => {
  1523. const mounted = await mount()
  1524. const first = createSession(mounted.ctx, 'unknown-title', '/work')
  1525. const second = createSession(mounted.ctx, 'second-title-failure', '/work')
  1526. const stackless = new Error('stackless')
  1527. Object.defineProperty(stackless, 'stack', { value: undefined })
  1528. const readTitles = vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots')
  1529. .mockResolvedValueOnce([
  1530. { sessionId: first.id, status: 'rejected', reason: 'string failure' },
  1531. { sessionId: second.id, status: 'rejected', reason: stackless },
  1532. ])
  1533. FakeQuery.sessionSearch = () => Promise.resolve({
  1534. items: [
  1535. sessionHit(first.id, '/work'),
  1536. sessionHit(second.id, '/work'),
  1537. ],
  1538. })
  1539. const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined)
  1540. const result = await mounted.call('session_search', { query: 'needle' })
  1541. expect(text(result)).toContain('title unavailable: SESSION_QUERY_TOOL_FAILED')
  1542. expect(JSON.stringify(result)).not.toContain('string failure')
  1543. expect(JSON.stringify(result)).not.toContain('stackless')
  1544. expect(readTitles).toHaveBeenCalledTimes(1)
  1545. expect(readTitles.mock.calls[0]?.[0]).toEqual([first.id, second.id])
  1546. expect(warn).toHaveBeenCalledWith(expect.stringContaining('string failure'))
  1547. expect(warn).toHaveBeenCalledWith(expect.stringContaining('Error: stackless'))
  1548. })
  1549. it('isolates an unprintable per-title failure behind the generic unavailable marker', async () => {
  1550. const mounted = await mount()
  1551. const hit = createSession(mounted.ctx, 'hostile-title-failure', '/work')
  1552. const primary = 'per-title proxy payload secret'
  1553. const secondary = 'per-title prototype secondary secret'
  1554. const reason = new Proxy(
  1555. { payload: primary },
  1556. {
  1557. getPrototypeOf() {
  1558. throw new Error(secondary)
  1559. },
  1560. },
  1561. )
  1562. FakeQuery.sessionSearch = () => Promise.resolve({ items: [sessionHit(hit.id, '/work')] })
  1563. vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots').mockResolvedValueOnce([{
  1564. sessionId: hit.id,
  1565. status: 'rejected',
  1566. reason,
  1567. }])
  1568. const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined)
  1569. const result = await mounted.call('session_search', { query: 'needle' })
  1570. expect(result.isError).toBe(false)
  1571. expect(text(result)).toContain('untitled (title unavailable: SESSION_QUERY_TOOL_FAILED)')
  1572. expect(JSON.stringify(result)).not.toContain(primary)
  1573. expect(JSON.stringify(result)).not.toContain(secondary)
  1574. expect(warn).toHaveBeenCalledWith(expect.stringContaining('[unprintable session query failure]'))
  1575. })
  1576. it('sanitizes a thrown batch-title service failure instead of rendering its diagnostic', async () => {
  1577. const mounted = await mount()
  1578. const hit = createSession(mounted.ctx, 'thrown-title-failure', '/work')
  1579. const secret = 'title batch failed beside hidden-title-session-secret'
  1580. FakeQuery.sessionSearch = () => Promise.resolve({ items: [sessionHit(hit.id, '/work')] })
  1581. vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots')
  1582. .mockRejectedValueOnce(new Error(secret))
  1583. const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined)
  1584. const result = await mounted.call('session_search', { query: 'needle' })
  1585. expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_FAILED')
  1586. expect(text(result)).toBe('Error: session query operation failed')
  1587. expect(JSON.stringify(result)).not.toContain(secret)
  1588. expect(warn).toHaveBeenCalledWith(expect.stringContaining(secret))
  1589. })
  1590. it('does not downgrade cancellation during title enrichment', async () => {
  1591. const mounted = await mount()
  1592. const hit = createSession(mounted.ctx, 'abort-title', '/work')
  1593. const controller = new AbortController()
  1594. const cancellation = new Error('cancelled title batch')
  1595. FakeQuery.sessionSearch = () => Promise.resolve({ items: [sessionHit(hit.id, '/work')] })
  1596. let started!: () => void
  1597. const batchStarted = new Promise<void>((resolve) => { started = resolve })
  1598. const readTitles = vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots').mockImplementation((_ids, signal) => {
  1599. started()
  1600. return new Promise((_resolve, reject) => {
  1601. signal?.addEventListener('abort', () => { reject(cancellation) }, { once: true })
  1602. })
  1603. })
  1604. const pending = mounted.call('session_search', { query: 'needle' }, { signal: controller.signal })
  1605. await batchStarted
  1606. controller.abort(cancellation)
  1607. const result = await pending
  1608. expect(result.isError).toBe(true)
  1609. expect(text(result)).not.toContain('title unavailable')
  1610. expect(readTitles.mock.calls[0]?.[1]).toBe(controller.signal)
  1611. })
  1612. it('does not downgrade an authorization failure returned by title observation', async () => {
  1613. const mounted = await mount()
  1614. const hit = createSession(mounted.ctx, 'unauthorized-title-error', '/work')
  1615. const failure = new HarnessError(
  1616. 'title observation became unauthorized',
  1617. 'SESSION_QUERY_TOOL_UNAUTHORIZED',
  1618. )
  1619. FakeQuery.sessionSearch = () => Promise.resolve({ items: [sessionHit(hit.id, '/work')] })
  1620. vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots').mockResolvedValueOnce([{
  1621. sessionId: hit.id,
  1622. status: 'rejected',
  1623. reason: failure,
  1624. }])
  1625. const result = await mounted.call('session_search', { query: 'needle' })
  1626. expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_UNAUTHORIZED')
  1627. expect(text(result)).toBe('Error: session target is outside the caller workspace')
  1628. expect(JSON.stringify(result)).not.toContain('title observation became unauthorized')
  1629. expect(text(result)).not.toContain('title unavailable')
  1630. })
  1631. it('forwards caller cancellation into direct-target authorization and waits for cleanup', async () => {
  1632. const mounted = await mount()
  1633. const target = createSession(mounted.ctx, 'stalled-direct-authorization', '/work')
  1634. const controller = new AbortController()
  1635. const cancellation = new SessionQueryError(
  1636. 'direct-target authorization cancelled',
  1637. 'SESSION_QUERY_ABORTED',
  1638. )
  1639. const started = Promise.withResolvers<undefined>()
  1640. const abortObserved = Promise.withResolvers<undefined>()
  1641. const cleanup = Promise.withResolvers<undefined>()
  1642. let active = false
  1643. const filterSessions = vi.spyOn(mounted.ctx.sessionQuery, 'filterSessions')
  1644. .mockImplementation(async (_filters, signal) => {
  1645. if (signal === undefined) throw new Error('expected authorization signal')
  1646. active = true
  1647. const aborted = new Promise<void>((resolve) => {
  1648. signal.addEventListener('abort', () => { resolve() }, { once: true })
  1649. })
  1650. started.resolve(undefined)
  1651. await aborted
  1652. abortObserved.resolve(undefined)
  1653. await cleanup.promise
  1654. active = false
  1655. signal.throwIfAborted()
  1656. return []
  1657. })
  1658. const pending = mounted.call(
  1659. 'session_event_search',
  1660. { session_id: target.id, query: 'needle' },
  1661. { signal: controller.signal },
  1662. )
  1663. let settled = false
  1664. void pending.then(
  1665. () => { settled = true },
  1666. () => { settled = true },
  1667. )
  1668. await started.promise
  1669. controller.abort(cancellation)
  1670. await abortObserved.promise
  1671. expect(settled).toBe(false)
  1672. expect(active).toBe(true)
  1673. expect(filterSessions.mock.calls[0]?.[1]).toBe(controller.signal)
  1674. expect(controller.signal.reason).toBe(cancellation)
  1675. expect(FakeQuery.eventRequests).toEqual([])
  1676. cleanup.resolve(undefined)
  1677. const result = await pending
  1678. expect(active).toBe(false)
  1679. expect(errorCode(result)).toBe('SESSION_QUERY_ABORTED')
  1680. expect(text(result)).toBe('Error: direct-target authorization cancelled')
  1681. expect(FakeQuery.eventRequests).toEqual([])
  1682. })
  1683. it('forwards the search deadline into parent authorization and times out only after cleanup', async () => {
  1684. vi.useFakeTimers()
  1685. const timeoutMs = 1_234
  1686. const mounted = await mount({ searchTimeoutMs: timeoutMs }, '/work', true)
  1687. const parent = createSession(mounted.ctx, 'stalled-parent-authorization', '/work')
  1688. FakeQuery.sessionSearch = () => Promise.resolve({
  1689. items: [sessionHit('authorized-child', '/work', 'needle', parent.id)],
  1690. })
  1691. const upstream = new AbortController()
  1692. const started = Promise.withResolvers<undefined>()
  1693. const abortObserved = Promise.withResolvers<undefined>()
  1694. const cleanup = Promise.withResolvers<undefined>()
  1695. let active = false
  1696. let deadlineSignal: AbortSignal | undefined
  1697. const filterSessions = vi.spyOn(mounted.ctx.sessionQuery, 'filterSessions')
  1698. .mockImplementation(async (_filters, signal) => {
  1699. if (signal === undefined) throw new Error('expected authorization signal')
  1700. deadlineSignal = signal
  1701. active = true
  1702. const aborted = new Promise<void>((resolve) => {
  1703. signal.addEventListener('abort', () => { resolve() }, { once: true })
  1704. })
  1705. started.resolve(undefined)
  1706. await aborted
  1707. abortObserved.resolve(undefined)
  1708. await cleanup.promise
  1709. active = false
  1710. signal.throwIfAborted()
  1711. return []
  1712. })
  1713. const pending = mounted.call(
  1714. 'session_search',
  1715. { query: 'needle' },
  1716. { signal: upstream.signal },
  1717. )
  1718. let settled = false
  1719. void pending.then(
  1720. () => { settled = true },
  1721. () => { settled = true },
  1722. )
  1723. await started.promise
  1724. await vi.advanceTimersByTimeAsync(timeoutMs)
  1725. await abortObserved.promise
  1726. expect(settled).toBe(false)
  1727. expect(active).toBe(true)
  1728. expect(deadlineSignal).toBeDefined()
  1729. expect(deadlineSignal).not.toBe(upstream.signal)
  1730. expect(filterSessions.mock.calls[0]?.[1]).toBe(deadlineSignal)
  1731. expect(FakeQuery.searchSignals).toEqual([deadlineSignal])
  1732. expect(deadlineSignal?.reason).toBeInstanceOf(TimeoutReason)
  1733. expect(deadlineSignal?.reason).toMatchObject({ code: 'TOOL_TIMEOUT', timeoutMs })
  1734. cleanup.resolve(undefined)
  1735. const result = await pending
  1736. expect(active).toBe(false)
  1737. expect(errorCode(result)).toBe('TOOL_TIMEOUT')
  1738. expect(text(result)).toBe(`Error: tool call timed out after ${timeoutMs}ms`)
  1739. })
  1740. it('passes the exact execution signal to every FTS page and stops on cancellation', async () => {
  1741. const mounted = await mount()
  1742. const controller = new AbortController()
  1743. const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined)
  1744. let started!: () => void
  1745. const bodyStarted = new Promise<void>((resolve) => { started = resolve })
  1746. FakeQuery.sessionSearch = (_request, exec) => new Promise((_resolve, reject) => {
  1747. started()
  1748. exec?.signal?.addEventListener('abort', () => {
  1749. reject(new SessionQueryError('aborted', 'SESSION_QUERY_ABORTED'))
  1750. }, { once: true })
  1751. })
  1752. const cancellation = new SessionQueryError('aborted', 'SESSION_QUERY_ABORTED')
  1753. const pending = mounted.call('session_search', { query: 'needle' }, { signal: controller.signal })
  1754. await bodyStarted
  1755. controller.abort(cancellation)
  1756. const result = await pending
  1757. expect(result.isError).toBe(true)
  1758. expect(errorCode(result)).toBe('SESSION_QUERY_ABORTED')
  1759. expect(FakeQuery.searchSignals).toEqual([controller.signal])
  1760. expect(warn).not.toHaveBeenCalled()
  1761. })
  1762. })
  1763. describe('trace and exact read rendering', () => {
  1764. it('renders a deeply nested lineage without recursive consumer traversal', async () => {
  1765. const mounted = await mount()
  1766. const target = createSession(mounted.ctx, 'deep-target', '/work')
  1767. const [targetRecord] = await mounted.ctx.sessionQuery.filterSessions([{
  1768. kind: 'id',
  1769. values: [target.id],
  1770. }])
  1771. if (targetRecord === undefined) throw new Error('expected target record')
  1772. const depth = 3_000
  1773. let descendants: SessionLineageNode[] = []
  1774. for (let index = depth; index >= 1; index -= 1) {
  1775. descendants = [{
  1776. session: {
  1777. ...targetRecord,
  1778. header: header(`deep-${index}`, '/work', index),
  1779. },
  1780. descendants,
  1781. }]
  1782. }
  1783. vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockResolvedValue({
  1784. target: targetRecord,
  1785. ancestors: [],
  1786. descendants,
  1787. complete: true,
  1788. root: targetRecord,
  1789. })
  1790. vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots').mockImplementation(sessionIds => Promise.resolve(
  1791. [...new Set(sessionIds)].map(sessionId => ({
  1792. sessionId,
  1793. status: 'fulfilled' as const,
  1794. value: { session: header(sessionId, '/work') },
  1795. })),
  1796. ))
  1797. const output = text(await mounted.call('session_trace', { session_id: target.id }))
  1798. expect(output).toContain('Descendants:\n- deep-1 —')
  1799. expect(output).toContain(`${' '.repeat(depth - 1)}- deep-${depth} —`)
  1800. })
  1801. it('renders every event relationship sequence and a UTC target timestamp', async () => {
  1802. const mounted = await mount()
  1803. const session = createSession(mounted.ctx, 'relationships', '/work')
  1804. session.append(
  1805. 'user/message',
  1806. createUserMessage({
  1807. content: [{ type: 'text', text: 'source' }], source: { kind: 'user' },
  1808. }),
  1809. { surfaceOp: 'append' },
  1810. )
  1811. session.append(
  1812. 'assistant/message',
  1813. {
  1814. turn: 1,
  1815. step: 1,
  1816. message: createMessage({
  1817. role: 'assistant',
  1818. content: [{ type: 'text', text: 'replacement' }],
  1819. source: {
  1820. kind: 'model',
  1821. ...{ provider: 'test', model: 'test' },
  1822. },
  1823. }),
  1824. },
  1825. { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] },
  1826. )
  1827. const result = await mounted.call('session_event_trace', { session_id: session.id, seq: 0 })
  1828. expect(text(result)).toContain('Replacement chain: 1')
  1829. expect(text(result)).toContain('Events cited directly as sources: none')
  1830. expect(text(result)).toContain('Direct derived events: 1')
  1831. expect(text(result)).toContain(new Date(session.events[0]?.time ?? 0).toISOString())
  1832. })
  1833. it('renders unabridged fenced target JSON and readable semantic or log-only neighbor summaries', async () => {
  1834. const mounted = await mount()
  1835. const session = createSession(mounted.ctx, 'read', '/work')
  1836. session.append(
  1837. 'user/message',
  1838. createUserMessage({
  1839. content: [{ type: 'text', text: 'before semantic text' }], source: { kind: 'user' },
  1840. }),
  1841. { surfaceOp: 'append' },
  1842. )
  1843. session.append(
  1844. 'assistant/message',
  1845. {
  1846. turn: 1,
  1847. step: 1,
  1848. message: createMessage({
  1849. role: 'assistant',
  1850. content: [{ type: 'text', text: 'target full text' }],
  1851. source: {
  1852. kind: 'model',
  1853. ...{ provider: 'test', model: 'test' },
  1854. },
  1855. }),
  1856. },
  1857. { surfaceOp: 'append' },
  1858. )
  1859. const appendLegacy = session.append.bind(session) as unknown as (
  1860. type: string,
  1861. data: unknown,
  1862. ) => Session['events'][number]
  1863. appendLegacy(
  1864. 'context/message',
  1865. { content: [{ type: 'text', text: 'after semantic text' }], source: { kind: 'plugin', plugin: 'test' } },
  1866. )
  1867. const result = await mounted.call('session_event_read', {
  1868. session_id: session.id,
  1869. seq: 1,
  1870. before: 1,
  1871. after: 1,
  1872. })
  1873. const output = text(result)
  1874. expect(output).toContain('```json')
  1875. expect(output).toContain('"text": "target full text"')
  1876. expect(output).toContain('before semantic text')
  1877. expect(output).toContain('seq 2 | context/message')
  1878. expect(output).toContain('(no semantic text)')
  1879. expect(output).not.toContain('after semantic text')
  1880. expect(output).not.toContain('truncated')
  1881. })
  1882. it('renders empty event relationships and neighbors without semantic text', async () => {
  1883. const mounted = await mount()
  1884. const session = createSession(mounted.ctx, 'empty-relations', '/work')
  1885. session.append('step/start', { turn: 1, step: 1 })
  1886. session.append('step/end', { turn: 1, step: 1 })
  1887. const trace = text(await mounted.call('session_event_trace', {
  1888. session_id: session.id,
  1889. seq: 0,
  1890. }))
  1891. expect(trace).toContain('Replaced by: none')
  1892. expect(trace).toContain('Replacement chain: none')
  1893. const onlyAfter = text(await mounted.call('session_event_read', {
  1894. session_id: session.id,
  1895. seq: 0,
  1896. after: 1,
  1897. }))
  1898. expect(onlyAfter).not.toContain('Before:')
  1899. expect(onlyAfter).toContain('(no semantic text)')
  1900. const onlyBefore = text(await mounted.call('session_event_read', {
  1901. session_id: session.id,
  1902. seq: 1,
  1903. before: 1,
  1904. }))
  1905. expect(onlyBefore).toContain('Before:')
  1906. expect(onlyBefore).not.toContain('After:')
  1907. })
  1908. it.each([
  1909. ['session_event_trace', { seq: -1 }],
  1910. ['session_event_read', { seq: Number.MAX_SAFE_INTEGER + 1 }],
  1911. ['session_event_read', { seq: 0, before: -1 }],
  1912. ['session_event_read', { seq: 0, after: 1.5 }, 'INVALID_ARGS'],
  1913. ])('rejects invalid exact-read integers for %s', async (name, args, expected = 'SESSION_QUERY_INVALID_FILTER') => {
  1914. const mounted = await mount()
  1915. expect(errorCode(await mounted.call(name, args))).toBe(expected)
  1916. })
  1917. })