tool-session-query.spec.ts 83 KB

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