tool-session-query.spec.ts 83 KB

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