tool-session-query.spec.ts 83 KB

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