time-context.spec.ts 17 KB

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