time-context.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import Loader from '@cordisjs/plugin-loader'
  4. import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
  5. import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
  6. import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
  7. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  8. import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
  9. import type { Agent } from '@deepseek-ai/dsh-agent'
  10. import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
  11. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  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. beforeEach(() => {
  17. process.env['TZ'] = 'UTC'
  18. vi.useFakeTimers()
  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(SystemPrompt)
  30. const fiber = await ctx.plugin(timeContext, config)
  31. return { ctx, fiber }
  32. }
  33. function sessionAgent(session: Session, id = 'agent'): Agent {
  34. return { id: AgentId(id), session } as unknown as Agent
  35. }
  36. async function sectionText(ctx: Context, agent?: Agent): Promise<string | undefined> {
  37. const assembly = await ctx.systemPrompt.assemble(agent === undefined ? {} : { agent })
  38. return assembly.sections.find(section => section.name === 'context:time')?.text
  39. }
  40. function openMessageTurn(session: Session, turn: number): void {
  41. session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
  42. session.append('user/message', {
  43. content: [{ type: 'text', text: `turn ${turn}` }],
  44. source: { kind: 'user' },
  45. }, { surfaceOp: 'append' })
  46. }
  47. function textResponse(text: string): StreamChunk[] {
  48. return [
  49. { type: 'block-start', index: 0, blockType: 'text' },
  50. { type: 'block-end', index: 0, block: { type: 'text', text } },
  51. { type: 'finish', reason: { kind: 'stop' } },
  52. ]
  53. }
  54. function toolCallResponse(): StreamChunk[] {
  55. return [
  56. { type: 'block-start', index: 0, blockType: 'tool-call' },
  57. {
  58. type: 'block-end',
  59. index: 0,
  60. block: { type: 'tool-call', id: CallId('tick-1'), name: 'tick', arguments: '{}' },
  61. },
  62. { type: 'finish', reason: { kind: 'tool-calls' } },
  63. ]
  64. }
  65. class ScriptedAdapter extends LlmAdapter {
  66. readonly requests: GenerateOptions[] = []
  67. constructor(private readonly script: StreamChunk[][]) {
  68. super()
  69. }
  70. override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  71. this.requests.push(options)
  72. const chunks = this.script.shift()
  73. if (chunks === undefined) throw new Error('ScriptedAdapter: script exhausted')
  74. for (const chunk of chunks) yield chunk
  75. }
  76. }
  77. async function loopHarness(adapter: ScriptedAdapter, config: Config = {}): Promise<Context> {
  78. const ctx = new Context()
  79. await ctx.plugin(LlmService)
  80. await ctx.plugin(SessionStore)
  81. await ctx.plugin(SystemPrompt)
  82. await ctx.plugin(ToolRegistry)
  83. await ctx.plugin(AgentRegistry)
  84. await ctx.plugin(AgentLoop, { agents: [] })
  85. await ctx.plugin(timeContext, config)
  86. ctx.llm.registerAdapter(['mock'], adapter)
  87. return ctx
  88. }
  89. describe('temporal section rendering', () => {
  90. it('renders the first turn in UTC with the explicit no-previous-message fallback', async () => {
  91. const { ctx } = await mount()
  92. const session = new Session(SessionId('first'))
  93. openMessageTurn(session, 1)
  94. expect(await sectionText(ctx, sessionAgent(session))).toBe(
  95. 'Current time: 2026-07-14T00:00:00+00:00[UTC]\n'
  96. + 'Time since previous message: unavailable (no earlier message in this session).',
  97. )
  98. })
  99. it('renders a non-UTC numeric offset and all compact duration units', async () => {
  100. const { ctx } = await mount({ timeZone: 'Asia/Shanghai' })
  101. const session = new Session(SessionId('offset'))
  102. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  103. session.append('assistant/message', {
  104. turn: 1,
  105. step: 1,
  106. content: [{ type: 'text', text: 'previous' }],
  107. }, { surfaceOp: 'append' })
  108. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  109. vi.setSystemTime(BASE + 90_061_000)
  110. openMessageTurn(session, 2)
  111. expect(await sectionText(ctx, sessionAgent(session))).toBe(
  112. 'Current time: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n'
  113. + 'Time since previous message: 1d 1h 1m 1s.',
  114. )
  115. })
  116. it('clamps a backward wall-clock adjustment to a zero duration', async () => {
  117. const { ctx } = await mount()
  118. const session = new Session(SessionId('backward-duration'))
  119. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  120. session.append('assistant/message', {
  121. turn: 1,
  122. step: 1,
  123. content: [{ type: 'text', text: 'future by adjusted clock' }],
  124. }, { surfaceOp: 'append' })
  125. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  126. vi.setSystemTime(BASE - 5_000)
  127. openMessageTurn(session, 2)
  128. expect(await sectionText(ctx, sessionAgent(session))).toContain('Time since previous message: 0s.')
  129. })
  130. const previousMessageCases = [
  131. ['user/message', (session: Session): void => {
  132. session.append('user/message', { content: [{ type: 'text', text: 'u' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  133. }],
  134. ['assistant/message', (session: Session): void => {
  135. session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, { surfaceOp: 'append' })
  136. }],
  137. ['tool/result', (session: Session): void => {
  138. session.append('tool/result', {
  139. turn: 1,
  140. step: 1,
  141. callId: CallId('previous'),
  142. content: [{ type: 'text', text: 'r' }],
  143. isError: false,
  144. }, { surfaceOp: 'append' })
  145. }],
  146. ['context/message', (session: Session): void => {
  147. session.append('context/message', {
  148. content: [{ type: 'text', text: 'c' }],
  149. source: { kind: 'plugin', plugin: 'test' },
  150. }, { surfaceOp: 'append' })
  151. }],
  152. ['steering/message', (session: Session): void => {
  153. session.append('steering/message', {
  154. turn: 1,
  155. content: [{ type: 'text', text: 's' }],
  156. source: { kind: 'user' },
  157. }, { surfaceOp: 'append' })
  158. }],
  159. ] as const
  160. it.each(previousMessageCases)('uses a prior %s as the duration baseline', async (_name, appendPrevious) => {
  161. const { ctx } = await mount()
  162. const session = new Session(SessionId(`previous-${_name}`))
  163. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  164. appendPrevious(session)
  165. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  166. vi.setSystemTime(BASE + 5_000)
  167. openMessageTurn(session, 2)
  168. expect(await sectionText(ctx, sessionAgent(session))).toContain('Time since previous message: 5s.')
  169. })
  170. it('contributes empty text without an active agent turn', async () => {
  171. const { ctx } = await mount()
  172. expect(await sectionText(ctx)).toBe('')
  173. const empty = sessionAgent(new Session(SessionId('empty')))
  174. expect(await sectionText(ctx, empty)).toBe('')
  175. const closedSession = new Session(SessionId('closed'))
  176. openMessageTurn(closedSession, 1)
  177. closedSession.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  178. expect(await sectionText(ctx, sessionAgent(closedSession))).toBe('')
  179. })
  180. })
  181. describe('refresh policy', () => {
  182. it('reuses within the interval, refreshes at expiry, and refreshes after a backward clock jump', async () => {
  183. const { ctx } = await mount({ refreshIntervalMs: 60_000 })
  184. const session = new Session(SessionId('interval'))
  185. const agent = sessionAgent(session)
  186. openMessageTurn(session, 1)
  187. const first = await sectionText(ctx, agent)
  188. vi.setSystemTime(BASE + 30_000)
  189. expect(await sectionText(ctx, agent)).toBe(first)
  190. vi.setSystemTime(BASE + 60_000)
  191. const expired = await sectionText(ctx, agent)
  192. expect(expired).toContain('2026-07-14T00:01:00+00:00[UTC]')
  193. vi.setSystemTime(BASE + 59_000)
  194. expect(await sectionText(ctx, agent)).toContain('2026-07-14T00:00:59+00:00[UTC]')
  195. })
  196. it('refreshes every assembly when refreshIntervalMs is zero', async () => {
  197. const { ctx } = await mount({ refreshIntervalMs: 0 })
  198. const session = new Session(SessionId('every-step'))
  199. const agent = sessionAgent(session)
  200. openMessageTurn(session, 1)
  201. const first = await sectionText(ctx, agent)
  202. vi.setSystemTime(BASE + 1_000)
  203. expect(await sectionText(ctx, agent)).not.toBe(first)
  204. })
  205. it('always refreshes for a new turn and keeps the preceding message baseline', async () => {
  206. const { ctx } = await mount({ refreshIntervalMs: 60_000 })
  207. const session = new Session(SessionId('turn-refresh'))
  208. const agent = sessionAgent(session)
  209. openMessageTurn(session, 1)
  210. const first = await sectionText(ctx, agent)
  211. vi.setSystemTime(BASE + 1_000)
  212. session.append('assistant/message', {
  213. turn: 1,
  214. step: 1,
  215. content: [{ type: 'text', text: 'done' }],
  216. }, { surfaceOp: 'append' })
  217. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  218. vi.setSystemTime(BASE + 2_000)
  219. openMessageTurn(session, 2)
  220. const second = await sectionText(ctx, agent)
  221. expect(second).not.toBe(first)
  222. expect(second).toContain('Time since previous message: 1s.')
  223. })
  224. it('keeps refresh caches independent per agent', async () => {
  225. const { ctx } = await mount({ refreshIntervalMs: 60_000 })
  226. const sessionA = new Session(SessionId('agent-a'))
  227. const sessionB = new Session(SessionId('agent-b'))
  228. const agentA = sessionAgent(sessionA, 'a')
  229. const agentB = sessionAgent(sessionB, 'b')
  230. openMessageTurn(sessionA, 1)
  231. openMessageTurn(sessionB, 1)
  232. const aFirst = await sectionText(ctx, agentA)
  233. vi.setSystemTime(BASE + 30_000)
  234. const bFirst = await sectionText(ctx, agentB)
  235. vi.setSystemTime(BASE + 40_000)
  236. expect(await sectionText(ctx, agentA)).toBe(aFirst)
  237. expect(bFirst).toContain('2026-07-14T00:00:30+00:00[UTC]')
  238. })
  239. })
  240. describe('configuration and lifecycle', () => {
  241. it('defaults to the process system zone and retains the zone resolved at plugin load', async () => {
  242. process.env['TZ'] = 'Asia/Shanghai'
  243. const { ctx } = await mount()
  244. process.env['TZ'] = 'America/New_York'
  245. const session = new Session(SessionId('system-zone'))
  246. openMessageTurn(session, 1)
  247. expect(await sectionText(ctx, sessionAgent(session))).toContain(
  248. 'Current time: 2026-07-14T08:00:00+08:00[Asia/Shanghai]',
  249. )
  250. })
  251. it('fails loud for negative, fractional, unsafe, and invalid-zone config', async () => {
  252. for (const refreshIntervalMs of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
  253. const ctx = new Context()
  254. await ctx.plugin(SystemPrompt)
  255. await expect(ctx.plugin(timeContext, { refreshIntervalMs })).rejects.toThrow(/non-negative safe integer/)
  256. }
  257. const ctx = new Context()
  258. await ctx.plugin(SystemPrompt)
  259. await expect(ctx.plugin(timeContext, { timeZone: 'Not/A_Real_Zone' })).rejects.toThrow(/invalid IANA timeZone/)
  260. })
  261. it('fails loud when the process system zone cannot be resolved', async () => {
  262. vi.spyOn(Intl, 'DateTimeFormat').mockImplementationOnce(() => {
  263. throw new RangeError('system zone unavailable')
  264. })
  265. const ctx = new Context()
  266. await ctx.plugin(SystemPrompt)
  267. await expect(ctx.plugin(timeContext, {})).rejects.toThrow(/failed to resolve the system time zone/)
  268. })
  269. it('removes its section when the plugin fiber disposes', async () => {
  270. const { ctx, fiber } = await mount()
  271. const session = new Session(SessionId('dispose'))
  272. const agent = sessionAgent(session)
  273. openMessageTurn(session, 1)
  274. expect(await sectionText(ctx, agent)).toContain('Current time:')
  275. await fiber.dispose()
  276. expect(await sectionText(ctx, agent)).toBeUndefined()
  277. })
  278. })
  279. describe('real agent-loop request logging', () => {
  280. it('refreshes a long turn in the system prompt and records the header delta without context history', async () => {
  281. const adapter = new ScriptedAdapter([toolCallResponse(), textResponse('done'), textResponse('next turn')])
  282. const ctx = await loopHarness(adapter, { refreshIntervalMs: 60_000 })
  283. ctx.tools.register(defineTool({
  284. name: 'tick',
  285. description: 'advance fake time',
  286. parameters: {},
  287. async execute() {
  288. vi.setSystemTime(BASE + 61_000)
  289. return [{ type: 'text' as const, text: 'advanced' }]
  290. },
  291. }))
  292. const agent = ctx.agentLoop.create(AgentId('loop'), { model: 'mock' })
  293. agent.send([{ type: 'text', text: 'start' }])
  294. await agent.whenIdle()
  295. expect(adapter.requests).toHaveLength(2)
  296. expect(adapter.requests[0]!.system).toContain('2026-07-14T00:00:00+00:00[UTC]')
  297. expect(adapter.requests[1]!.system).toContain('2026-07-14T00:01:01+00:00[UTC]')
  298. expect(agent.session.events.some(event => event.type === 'context/message')).toBe(false)
  299. expect(agent.session.events.filter(event => event.type === 'request/header-delta')).toHaveLength(1)
  300. expect(foldRequestHeader(agent.session.events)?.system).toBe(adapter.requests[1]!.system)
  301. vi.setSystemTime(BASE + 361_000)
  302. agent.send([{ type: 'text', text: 'again' }])
  303. await agent.whenIdle()
  304. expect(adapter.requests[2]!.system).toContain('Time since previous message: 5m 0s.')
  305. await ctx.fiber.dispose()
  306. })
  307. })
  308. describe('real Loader export path', () => {
  309. it('keeps the namespace metadata and boots through unwrapExports', async () => {
  310. expect('default' in timeContext).toBe(false)
  311. const loader = Object.create(Loader.prototype) as Loader
  312. const unwrapped = loader.unwrapExports(timeContext) as Record<string, unknown>
  313. expect(unwrapped).toBe(timeContext)
  314. expect(unwrapped.name).toBe('time-context')
  315. expect(unwrapped.inject).toEqual(['systemPrompt'])
  316. expect(unwrapped.Config).toBeDefined()
  317. expect(typeof unwrapped.apply).toBe('function')
  318. const ctx = new Context()
  319. await ctx.plugin(SystemPrompt)
  320. const plugin = loader.unwrapExports(timeContext) as Parameters<Context['plugin']>[0]
  321. await ctx.plugin(plugin)
  322. const session = new Session(SessionId('loader'))
  323. openMessageTurn(session, 1)
  324. expect(await sectionText(ctx, sessionAgent(session))).toContain('Current time:')
  325. })
  326. })