time-context.spec.ts 20 KB

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