time-context.spec.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import Loader from '@deepseek-ai/cordis-plugin-loader'
  4. import { createUserMessage, ToolCallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
  5. import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
  6. import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
  7. import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
  8. import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
  9. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  10. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  11. import { unsupportedInbox, mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
  12. import * as timeContext from '@deepseek-ai/dsh-time-context'
  13. import type { Config } from '@deepseek-ai/dsh-time-context'
  14. const BASE = Date.parse('2026-07-14T00:00:00.000Z')
  15. const ORIGINAL_TIME_ZONE = process.env['TZ']
  16. const SIGNAL = new AbortController().signal
  17. beforeEach(() => {
  18. process.env['TZ'] = 'UTC'
  19. vi.useFakeTimers({ toFake: ['Date'] })
  20. vi.setSystemTime(BASE)
  21. })
  22. afterEach(() => {
  23. vi.restoreAllMocks()
  24. vi.useRealTimers()
  25. if (ORIGINAL_TIME_ZONE === undefined) delete process.env['TZ']
  26. else process.env['TZ'] = ORIGINAL_TIME_ZONE
  27. })
  28. async function mount(config: Config = {}) {
  29. const ctx = new Context()
  30. await ctx.plugin(SessionProjectionRegistry)
  31. await ctx.plugin(AgentRegistry)
  32. const fiber = await ctx.plugin(timeContext, config)
  33. return { ctx, fiber }
  34. }
  35. function sessionAgent(session: Session, id = 'agent'): Agent {
  36. const agent: Agent = {
  37. id: SessionId(id),
  38. options: {},
  39. session,
  40. inbox: unsupportedInbox(),
  41. status: 'running',
  42. ctx: new Context(),
  43. send: () => {},
  44. followup: () => {},
  45. steer: () => {},
  46. inject: () => { throw new Error('time-context must append directly to the open step') },
  47. cancel() {},
  48. runMaintenance: task => task(new AbortController().signal),
  49. whenIdle: () => Promise.resolve(),
  50. }
  51. return agent
  52. }
  53. function openMessageTurn(session: Session, turn: number, clientTimeZone?: string): void {
  54. session.append('turn/start', { turn })
  55. session.append('user/message', createUserMessage({
  56. content: [{ type: 'text', text: `turn ${turn}` }],
  57. source: clientTimeZone === undefined
  58. ? { kind: 'user' }
  59. : { kind: 'user', rpcId: `turn-${String(turn)}`, clientTimeZone } as never,
  60. }), { surfaceOp: 'append' })
  61. }
  62. function contextTexts(session: Session): string[] {
  63. const texts: string[] = []
  64. for (const event of session.snapshotEvents()) {
  65. if (event.type === 'user/message'
  66. && event.data.source.kind === 'plugin'
  67. && event.data.source.plugin === 'time-context') {
  68. texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '')
  69. }
  70. }
  71. return texts
  72. }
  73. async function fire(
  74. ctx: Context,
  75. agent: Agent,
  76. turn: number,
  77. step: number,
  78. signal: AbortSignal = SIGNAL,
  79. ): Promise<void> {
  80. const proposed = createUserMessage({
  81. content: [{ type: 'text', text: 'request proposal' }],
  82. source: { kind: 'plugin', plugin: 'time-context-test' },
  83. })
  84. const decision = await agentEvents(ctx, agent).waterfall(
  85. 'agent/pre-step',
  86. { messages: [proposed], turn, step, signal },
  87. () => Promise.resolve({ kind: 'enter' as const, messages: [proposed] }),
  88. )
  89. if (decision.kind === 'enter') {
  90. for (const message of decision.messages) {
  91. if (message === proposed) continue
  92. agent.session.append('user/message', message, { surfaceOp: 'append' })
  93. }
  94. }
  95. }
  96. function textResponse(text: string): StreamChunk[] {
  97. return [
  98. { type: 'block-start', index: 0, blockType: 'text' },
  99. { type: 'block-end', index: 0, block: { type: 'text', text } },
  100. { type: 'finish', reason: { kind: 'stop' } },
  101. ]
  102. }
  103. function toolCallResponse(): StreamChunk[] {
  104. return [
  105. { type: 'block-start', index: 0, blockType: 'tool-call' },
  106. {
  107. type: 'block-end',
  108. index: 0,
  109. block: { type: 'tool-call', id: ToolCallId('tick-1'), name: 'tick', arguments: '{}' },
  110. },
  111. { type: 'finish', reason: { kind: 'tool-calls' } },
  112. ]
  113. }
  114. class ScriptedAdapter extends LlmAdapter {
  115. readonly requests: GenerateOptions[] = []
  116. constructor(private readonly script: StreamChunk[][]) {
  117. super()
  118. }
  119. override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  120. this.requests.push(options)
  121. const chunks = this.script.shift()
  122. if (chunks === undefined) throw new Error('ScriptedAdapter: script exhausted')
  123. for (const chunk of chunks) yield chunk
  124. }
  125. }
  126. async function loopHarness(adapter: ScriptedAdapter, config: Config = {}): Promise<Context> {
  127. const ctx = new Context()
  128. await mountAgentLoopTestDependencies(ctx)
  129. await ctx.plugin(AgentLoop, { agents: [] })
  130. await ctx.plugin(timeContext, config)
  131. ctx.llm.registerAdapter(['mock'], adapter)
  132. return ctx
  133. }
  134. function requestText(request: GenerateOptions): string {
  135. return request.messages
  136. .flatMap(message => message.content)
  137. .filter(block => block.type === 'text')
  138. .map(block => block.text)
  139. .join('\n')
  140. }
  141. describe('durable step context', () => {
  142. it('records turn, step, zoned time, and the preceding model-visible message baseline', async () => {
  143. const { ctx } = await mount({ timeZone: 'Asia/Shanghai' })
  144. const session = Session.create(SessionId('first'))
  145. openMessageTurn(session, 1, 'Asia/Shanghai')
  146. vi.setSystemTime(BASE + 90_061_000)
  147. await fire(ctx, sessionAgent(session), 1, 1)
  148. expect(contextTexts(session)).toEqual([
  149. 'Time sampled while preparing turn 1, step 1: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n'
  150. + 'Browser time zone for this request: Asia/Shanghai. Interpret otherwise-unqualified dates and times in this zone.\n'
  151. + 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.',
  152. ])
  153. const event = session.snapshotEvents().at(-1)
  154. expect(event?.type).toBe('user/message')
  155. if (event?.type !== 'user/message') throw new Error('missing time context')
  156. // The reading is a `snapshot`-form context: one named contribution whose
  157. // text is exactly what the model read, so a consumer attributes it without
  158. // re-splitting prose.
  159. expect(event.data.source).toEqual({
  160. kind: 'plugin',
  161. plugin: 'time-context',
  162. form: 'snapshot',
  163. sections: [{
  164. name: 'time-context',
  165. text: 'Time sampled while preparing turn 1, step 1: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n'
  166. + 'Browser time zone for this request: Asia/Shanghai. Interpret otherwise-unqualified dates and times in this zone.\n'
  167. + 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.',
  168. }],
  169. })
  170. expect(event.surfaceOp).toBe('append')
  171. })
  172. it('reports an unavailable first-step baseline when no model-visible message precedes it', async () => {
  173. const { ctx } = await mount()
  174. const session = Session.create(SessionId('unavailable'))
  175. session.append('turn/start', { turn: 1 })
  176. await fire(ctx, sessionAgent(session), 1, 1)
  177. expect(contextTexts(session)[0]).toContain(
  178. 'Elapsed since the preceding model-visible message: unavailable.',
  179. )
  180. })
  181. it.each([
  182. ['omitted interval', {}],
  183. ['zero interval', { refreshIntervalMs: 0 }],
  184. ] as const)('uses the preceding durable step-context timestamp after step one with %s', async (_label, config) => {
  185. const { ctx } = await mount(config)
  186. const session = Session.create(SessionId('later-step'))
  187. const agent = sessionAgent(session)
  188. openMessageTurn(session, 3)
  189. await fire(ctx, agent, 3, 1)
  190. vi.setSystemTime(BASE + 61_000)
  191. await fire(ctx, agent, 3, 2)
  192. expect(contextTexts(session)[1]).toBe(
  193. 'Time sampled while preparing turn 3, step 2: 2026-07-14T00:01:01+00:00[UTC]\n'
  194. + 'Browser time zone for this request: unavailable. Ask the user to clarify otherwise-unqualified dates and times.\n'
  195. + 'Elapsed since the preceding step context: 1m 1s.',
  196. )
  197. })
  198. it('formats in one browser zone and falls back when steering supplies mixed zones', async () => {
  199. const { ctx } = await mount({ timeZone: 'UTC' })
  200. const resolved = Session.create(SessionId('browser-zone-resolved'))
  201. openMessageTurn(resolved, 1, 'America/New_York')
  202. await fire(ctx, sessionAgent(resolved), 1, 1)
  203. expect(contextTexts(resolved)[0]).toContain(
  204. '2026-07-13T20:00:00-04:00[America/New_York]\n'
  205. + 'Browser time zone for this request: America/New_York. '
  206. + 'Interpret otherwise-unqualified dates and times in this zone.',
  207. )
  208. const mixed = Session.create(SessionId('browser-zone-mixed'))
  209. openMessageTurn(mixed, 1, 'Asia/Shanghai')
  210. mixed.append('user/message', createUserMessage({
  211. content: [{ type: 'text', text: 'steering from another browser' }],
  212. source: {
  213. kind: 'user',
  214. rpcId: 'mixed-steer',
  215. clientTimeZone: 'America/New_York',
  216. } as never,
  217. }), { surfaceOp: 'append' })
  218. await fire(ctx, sessionAgent(mixed), 1, 1)
  219. expect(contextTexts(mixed)[0]).toContain(
  220. '2026-07-14T00:00:00+00:00[UTC]\n'
  221. + 'Browser time zone for this request: mixed ["America/New_York","Asia/Shanghai"]. '
  222. + 'Ask the user to clarify otherwise-unqualified dates and times.',
  223. )
  224. })
  225. it('reports an unavailable later-step baseline at the matching turn boundary', async () => {
  226. const { ctx } = await mount()
  227. const session = Session.create(SessionId('later-step-boundary'))
  228. openMessageTurn(session, 4)
  229. await fire(ctx, sessionAgent(session), 4, 2)
  230. expect(contextTexts(session)[0]).toContain(
  231. 'Elapsed since the preceding step context: unavailable.',
  232. )
  233. })
  234. it('reports an unavailable later-step baseline when event lookup is exhausted', async () => {
  235. const { ctx } = await mount()
  236. const session = Session.create(SessionId('later-step-exhausted'))
  237. await fire(ctx, sessionAgent(session), 1, 2)
  238. expect(contextTexts(session)[0]).toContain(
  239. 'Elapsed since the preceding step context: unavailable.',
  240. )
  241. })
  242. it('injects after backward wall-clock movement and clamps elapsed time to zero', async () => {
  243. const { ctx } = await mount({ refreshIntervalMs: 60_000 })
  244. const session = Session.create(SessionId('backward'))
  245. const agent = sessionAgent(session)
  246. openMessageTurn(session, 1)
  247. await fire(ctx, agent, 1, 1)
  248. vi.setSystemTime(BASE - 5_000)
  249. await fire(ctx, agent, 1, 2)
  250. expect(contextTexts(session)).toHaveLength(2)
  251. expect(contextTexts(session)[1]).toContain('Elapsed since the preceding step context: 0s.')
  252. })
  253. it('uses a shadowed durable injection after resume and injects at the exact threshold', async () => {
  254. const { ctx } = await mount({ refreshIntervalMs: 1_000 })
  255. const original = Session.create(SessionId('seed-source'))
  256. openMessageTurn(original, 1)
  257. await fire(ctx, sessionAgent(original), 1, 1)
  258. const user = original.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'user')
  259. const reading = original.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
  260. if (user === undefined || reading === undefined) throw new Error('missing source surface events')
  261. original.append('user/message', createUserMessage({
  262. content: [{ type: 'text', text: 'compacted history' }],
  263. source: { kind: 'plugin', plugin: 'compaction-basic' },
  264. }), {
  265. surfaceOp: { op: 'replace', startSeq: user.seq, endSeq: reading.seq },
  266. sourceEventSeqs: [user.seq, reading.seq],
  267. })
  268. original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  269. expect(JSON.stringify(original.deriveMessages())).not.toContain('Time sampled while preparing')
  270. const resumed = Session.create(SessionId('resumed'), original.snapshotEvents())
  271. const resumedAgent = sessionAgent(resumed)
  272. vi.setSystemTime(BASE + 999)
  273. openMessageTurn(resumed, 2)
  274. const beforeSkip = resumed.snapshotEvents().length
  275. await fire(ctx, resumedAgent, 2, 1)
  276. expect(resumed.snapshotEvents()).toHaveLength(beforeSkip)
  277. expect(contextTexts(resumed)).toHaveLength(1)
  278. vi.setSystemTime(BASE + 1_000)
  279. await fire(ctx, resumedAgent, 2, 2)
  280. expect(contextTexts(resumed)).toHaveLength(2)
  281. expect(contextTexts(resumed)[1]).toContain(
  282. 'Elapsed since the preceding step context: unavailable.',
  283. )
  284. })
  285. it('applies a positive interval across turns without sharing state between sessions', async () => {
  286. const { ctx } = await mount({ refreshIntervalMs: 1_000 })
  287. const first = Session.create(SessionId('interval-first'))
  288. const firstAgent = sessionAgent(first, 'first-agent')
  289. openMessageTurn(first, 1)
  290. await fire(ctx, firstAgent, 1, 1)
  291. first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  292. vi.setSystemTime(BASE + 500)
  293. openMessageTurn(first, 2)
  294. const beforeSkip = first.snapshotEvents().length
  295. await fire(ctx, firstAgent, 2, 1)
  296. const independent = Session.create(SessionId('interval-independent'))
  297. openMessageTurn(independent, 1)
  298. await fire(ctx, sessionAgent(independent, 'independent-agent'), 1, 1)
  299. expect(first.snapshotEvents()).toHaveLength(beforeSkip)
  300. expect(contextTexts(first)).toHaveLength(1)
  301. expect(contextTexts(independent)).toHaveLength(1)
  302. })
  303. it('skips an already-aborted prompt submission', async () => {
  304. const { ctx } = await mount()
  305. const session = Session.create(SessionId('ordering'))
  306. const agent = sessionAgent(session)
  307. openMessageTurn(session, 1)
  308. await fire(ctx, agent, 1, 1)
  309. const abort = new AbortController()
  310. abort.abort()
  311. await fire(ctx, agent, 1, 2, abort.signal)
  312. expect(contextTexts(session)).toHaveLength(1)
  313. })
  314. })
  315. describe('configuration and lifecycle', () => {
  316. it('defaults to the process system zone and retains the zone resolved at plugin load', async () => {
  317. process.env['TZ'] = 'Asia/Shanghai'
  318. const { ctx } = await mount()
  319. process.env['TZ'] = 'America/New_York'
  320. const session = Session.create(SessionId('system-zone'))
  321. openMessageTurn(session, 1)
  322. await fire(ctx, sessionAgent(session), 1, 1)
  323. expect(contextTexts(session)[0]).toContain('2026-07-14T08:00:00+08:00[Asia/Shanghai]')
  324. })
  325. it('fails loud for an invalid explicit zone or an unavailable process zone', async () => {
  326. const invalid = new Context()
  327. await invalid.plugin(SessionProjectionRegistry)
  328. await invalid.plugin(AgentRegistry)
  329. await expect(invalid.plugin(timeContext, { timeZone: 'Not/A_Real_Zone' })).rejects.toThrow(
  330. /invalid IANA timeZone/,
  331. )
  332. vi.spyOn(Intl, 'DateTimeFormat').mockImplementationOnce(() => {
  333. throw new RangeError('system zone unavailable')
  334. })
  335. const unresolved = new Context()
  336. await unresolved.plugin(SessionProjectionRegistry)
  337. await unresolved.plugin(AgentRegistry)
  338. await expect(unresolved.plugin(timeContext, {})).rejects.toThrow(/failed to resolve the system time zone/)
  339. })
  340. it('rejects invalid refresh intervals at plugin load with one diagnostic', async () => {
  341. const invalid = [-1, 0.5, Number.MAX_SAFE_INTEGER + 1, Number.POSITIVE_INFINITY, Number.NaN]
  342. for (const refreshIntervalMs of invalid) {
  343. await expect(mount({ refreshIntervalMs })).rejects.toThrow(
  344. 'time-context: refreshIntervalMs must be a non-negative safe integer',
  345. )
  346. }
  347. })
  348. it('removes its listener when the plugin fiber disposes', async () => {
  349. const { ctx, fiber } = await mount()
  350. const session = Session.create(SessionId('dispose'))
  351. const agent = sessionAgent(session)
  352. openMessageTurn(session, 1)
  353. await fire(ctx, agent, 1, 1)
  354. await fiber.dispose()
  355. await fire(ctx, agent, 1, 2)
  356. expect(contextTexts(session)).toHaveLength(1)
  357. })
  358. })
  359. describe('time-context projection fold edges', () => {
  360. it('clears the open-turn injection time at the next turn start', async () => {
  361. const { ctx } = await mount()
  362. const session = Session.create(SessionId('same-turn'))
  363. session.append('turn/start', { turn: 1 })
  364. session.append('user/message', createUserMessage({
  365. content: [{ type: 'text', text: 'reading' }],
  366. source: { kind: 'plugin', plugin: 'time-context' },
  367. }), { surfaceOp: 'append' })
  368. expect(typeof ctx.sessionProjections.stateOf(session, 'timeContext')?.lastTurnInjectionTime).toBe('number')
  369. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  370. session.append('turn/start', { turn: 2 })
  371. expect(ctx.sessionProjections.stateOf(session, 'timeContext')).toMatchObject({
  372. lastTurnInjectionTime: null,
  373. })
  374. })
  375. it('keeps the open-turn injection time null when turn/end arrives first', async () => {
  376. const { ctx } = await mount()
  377. const session = Session.create(SessionId('end-without-start'))
  378. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  379. expect(ctx.sessionProjections.stateOf(session, 'timeContext')).toMatchObject({
  380. lastTurnInjectionTime: null,
  381. })
  382. })
  383. })
  384. describe('real agent-loop request history', () => {
  385. it.each([
  386. ['throws'],
  387. ['cancels'],
  388. ] as const)('does not commit a preparation reading when a downstream pre-step listener %s', async (mode) => {
  389. const adapter = new ScriptedAdapter([textResponse('unused')])
  390. const ctx = await loopHarness(adapter)
  391. ctx.on('agent/pre-step', ({ agent: subject }, next) => {
  392. if (mode === 'throws') throw new Error('later pre-step failure')
  393. subject.cancel({ kind: 'user' })
  394. return next()
  395. })
  396. const agent = await ctx.agentLoop.create(SessionId(`late-${mode}`), { provider: 'mock', model: 'mock' })
  397. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'start' }], source: { kind: 'user' } }))
  398. await agent.whenIdle()
  399. expect(contextTexts(agent.session)).toHaveLength(0)
  400. expect(adapter.requests).toHaveLength(0)
  401. expect(agent.session.snapshotEvents().some(event => event.type === 'step/start')).toBe(false)
  402. await ctx.fiber.dispose()
  403. })
  404. it('persists one ordered context per request, accumulates readings, and leaves system headers unchanged', async () => {
  405. const adapter = new ScriptedAdapter([toolCallResponse(), textResponse('done')])
  406. const ctx = await loopHarness(adapter)
  407. ctx.tools.register(defineContentToolFixture({
  408. name: 'tick',
  409. description: 'advance fake time',
  410. parameters: {},
  411. async execute() {
  412. vi.setSystemTime(BASE + 61_000)
  413. return [{ type: 'text' as const, text: 'advanced' }]
  414. },
  415. }))
  416. const agent = await ctx.agentLoop.create(SessionId('loop'), { provider: 'mock', model: 'mock' })
  417. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'start' }], source: { kind: 'user' } }))
  418. await agent.whenIdle()
  419. expect(adapter.requests).toHaveLength(2)
  420. const contexts = agent.session.snapshotEvents().filter(
  421. (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin')
  422. const starts = agent.session.snapshotEvents().filter(event => event.type === 'step/start')
  423. expect(contexts).toHaveLength(adapter.requests.length)
  424. expect(starts).toHaveLength(adapter.requests.length)
  425. for (let index = 0; index < contexts.length; index += 1) {
  426. expect(contexts[index]!.seq).toBeGreaterThan(starts[index]!.seq)
  427. }
  428. expect(contexts.every(event => event.data.source.kind === 'plugin'
  429. && event.data.source.plugin === 'time-context'
  430. && event.surfaceOp === 'append')).toBe(true)
  431. const firstRequestText = requestText(adapter.requests[0]!)
  432. const secondRequestText = requestText(adapter.requests[1]!)
  433. expect(firstRequestText).toContain('Time sampled while preparing turn 1, step 1:')
  434. expect(firstRequestText).toContain('Elapsed since the preceding model-visible message: unavailable.')
  435. expect(firstRequestText).not.toContain('Time sampled while preparing turn 1, step 2:')
  436. expect(secondRequestText).toContain('Time sampled while preparing turn 1, step 1:')
  437. expect(secondRequestText).toContain('Time sampled while preparing turn 1, step 2:')
  438. expect(secondRequestText).toContain('Elapsed since the preceding step context: 1m 1s.')
  439. for (const request of adapter.requests) {
  440. expect(request.system).toBeUndefined()
  441. expect(request.messages[0]?.role).toBe('system')
  442. expect(JSON.stringify(request.messages[0])).not.toContain('Time sampled while preparing')
  443. }
  444. const systemNodes = agent.session.snapshotEvents().filter(event => event.type === 'system/message')
  445. expect(JSON.stringify(systemNodes)).not.toContain('Time sampled while preparing')
  446. await ctx.fiber.dispose()
  447. })
  448. })
  449. describe('real Loader export path', () => {
  450. it('keeps namespace metadata and boots the agent listener through unwrapExports', async () => {
  451. expect('default' in timeContext).toBe(false)
  452. const loader = Object.create(Loader.prototype) as Loader
  453. const unwrapped = loader.unwrapExports(timeContext) as Record<string, unknown>
  454. expect(unwrapped).toBe(timeContext)
  455. expect(unwrapped.name).toBe('time-context')
  456. expect(unwrapped.inject).toEqual(['agents', 'sessionProjections'])
  457. expect(unwrapped.Config).toBeDefined()
  458. expect(typeof unwrapped.apply).toBe('function')
  459. const ctx = new Context()
  460. await ctx.plugin(SessionProjectionRegistry)
  461. await ctx.plugin(AgentRegistry)
  462. const plugin = loader.unwrapExports(timeContext) as Parameters<Context['plugin']>[0]
  463. await ctx.plugin(plugin)
  464. const session = Session.create(SessionId('loader'))
  465. openMessageTurn(session, 1)
  466. await fire(ctx, sessionAgent(session), 1, 1)
  467. expect(contextTexts(session)[0]).toContain('Time sampled while preparing turn 1, step 1:')
  468. })
  469. })