api-proxy-schedule-view.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. /**
  2. * Schedule reminder views cross the Host only after persistence proves their
  3. * dispatch prefix. Live append sends raw events; session/flushed replays the
  4. * identical dispatch with a generic sidecar. History independently gates the
  5. * same projection on an identity-matching stored prefix.
  6. */
  7. import { Context } from 'cordis'
  8. import { describe, expect, it } from 'vitest'
  9. import AgentRegistry from '@deepseek-ai/dsh-agent'
  10. import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
  11. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  12. import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
  13. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  14. import ToolRegistry from '@deepseek-ai/dsh-tools'
  15. import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
  16. import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  17. import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
  18. import { ScheduleId } from '@deepseek-ai/dsh-tool-schedule'
  19. interface FlushControl {
  20. handler: () => true | Promise<true>
  21. }
  22. function reminderCreateData(id: string, prompt: string) {
  23. return {
  24. version: 1 as const,
  25. operation: 'create' as const,
  26. schedule: {
  27. id: ScheduleId(id),
  28. kind: 'after' as const,
  29. prompt,
  30. afterSeconds: 1,
  31. scheduledAt: '2026-08-05T12:00:01.000Z',
  32. },
  33. }
  34. }
  35. async function harness(control?: FlushControl): Promise<Context> {
  36. const ctx = new Context()
  37. await ctx.plugin(SessionStore)
  38. await ctx.plugin(SystemPrompt, { persona: '' })
  39. await ctx.plugin(ToolRegistry)
  40. await ctx.plugin(UserInteractionService)
  41. await ctx.plugin(AgentRegistry)
  42. if (control !== undefined) ctx.on('session/flush', () => control.handler())
  43. return ctx
  44. }
  45. function appendReminder(
  46. session: Session,
  47. id: string,
  48. prompt: string,
  49. ): { create: SessionEvent; dispatch: SessionEvent } {
  50. const scheduleId = ScheduleId(id)
  51. const create = session.append('schedule/change', reminderCreateData(id, prompt))
  52. const dispatch = session.append('schedule/change', {
  53. version: 1,
  54. operation: 'dispatch',
  55. id: scheduleId,
  56. })
  57. return { create, dispatch }
  58. }
  59. async function collectEvents(
  60. iterable: AsyncIterable<RpcRequest<MuxFrame>>,
  61. count: number,
  62. abort: AbortController,
  63. ): Promise<Extract<MuxFrame, { type: 'session/event' }>[]> {
  64. const events: Extract<MuxFrame, { type: 'session/event' }>[] = []
  65. for await (const envelope of iterable) {
  66. if (envelope.payload.type !== 'session/event') continue
  67. events.push(envelope.payload)
  68. if (events.length >= count) abort.abort()
  69. }
  70. return events
  71. }
  72. describe('commit-aware Schedule live views', () => {
  73. it('takes the max of reverse flush completion and replays each dispatch once', async () => {
  74. const first = Promise.withResolvers<true>()
  75. let calls = 0
  76. const ctx = await harness({
  77. handler: () => ++calls === 1 ? first.promise : true,
  78. })
  79. const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
  80. const abort = new AbortController()
  81. const collected = collectEvents(
  82. api.events.mux({ rpcId: RpcId('schedule-live'), payload: {} }, abort.signal),
  83. 6,
  84. abort,
  85. )
  86. const session = ctx.sessions.create(SessionId('schedule-live'))
  87. const firstPair = appendReminder(session, 'schedule-1', 'first')
  88. const slow = ctx.sessions.flush(session)
  89. const secondPair = appendReminder(session, 'schedule-2', 'second')
  90. await expect(ctx.sessions.flush(session)).resolves.toBe(true)
  91. first.resolve(true)
  92. await expect(slow).resolves.toBe(true)
  93. const frames = await collected
  94. const raw = frames.filter(frame => frame.view === undefined)
  95. const presented = frames.filter(frame => frame.view?.for === 'event')
  96. expect(raw.map(frame => frame.event.seq)).toEqual([0, 1, 2, 3])
  97. expect(presented.map(frame => frame.event.seq)).toEqual([1, 3])
  98. expect(presented[0]?.event).toBe(firstPair.dispatch)
  99. expect(presented[1]?.event).toBe(secondPair.dispatch)
  100. expect(presented.map(frame => frame.view)).toEqual([
  101. {
  102. for: 'event',
  103. view: {
  104. scheduleId: 'schedule-1', prompt: 'first',
  105. occurrenceAt: '2026-08-05T12:00:01.000Z',
  106. },
  107. },
  108. {
  109. for: 'event',
  110. view: {
  111. scheduleId: 'schedule-2', prompt: 'second',
  112. occurrenceAt: '2026-08-05T12:00:01.000Z',
  113. },
  114. },
  115. ])
  116. expect(firstPair.create.seq).toBe(0)
  117. await ctx.fiber.dispose()
  118. })
  119. it('withholds a view after rejection and publishes it on the next successful checkpoint', async () => {
  120. let calls = 0
  121. const ctx = await harness({
  122. handler: () => ++calls === 1 ? Promise.reject(new Error('disk unavailable')) : true,
  123. })
  124. const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
  125. const abort = new AbortController()
  126. const collected = collectEvents(
  127. api.events.mux({ rpcId: RpcId('schedule-retry'), payload: {} }, abort.signal),
  128. 3,
  129. abort,
  130. )
  131. const session = ctx.sessions.create(SessionId('schedule-retry'))
  132. appendReminder(session, 'schedule-1', 'retry me')
  133. await expect(ctx.sessions.flush(session)).rejects.toThrow('disk unavailable')
  134. await expect(ctx.sessions.flush(session)).resolves.toBe(true)
  135. const frames = await collected
  136. expect(frames.filter(frame => frame.view?.for === 'event')).toHaveLength(1)
  137. expect(frames.at(-1)?.view).toMatchObject({
  138. for: 'event',
  139. })
  140. await ctx.fiber.dispose()
  141. })
  142. })
  143. describe('Schedule history views', () => {
  144. it('presents a resumed ancestor dispatch copied into a fork seed', async () => {
  145. const ctx = await harness()
  146. const scheduleId = ScheduleId('resumed-reminder')
  147. const resumed = ctx.sessions.create(SessionId('schedule-resumed'), {
  148. seed: [{
  149. type: 'schedule/change',
  150. seq: 0,
  151. time: 1,
  152. data: reminderCreateData('resumed-reminder', 'after restart'),
  153. }],
  154. meta: { cwd: '/tmp' },
  155. })
  156. const dispatch = resumed.append('schedule/change', {
  157. version: 1,
  158. operation: 'dispatch',
  159. id: scheduleId,
  160. })
  161. const child = ctx.sessions.fork(resumed, undefined, SessionId('schedule-fork'))
  162. ctx.provide('sessionPersistence', {
  163. readFrom: () => Promise.resolve({ meta: child.header, events: [...child.events] }),
  164. } as never)
  165. const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
  166. const response = await api.sessions.history({
  167. rpcId: RpcId('schedule-resumed-fork'), payload: { sessionId: child.id },
  168. })
  169. if (!response.result.ok) throw new Error(response.result.error.message)
  170. expect(response.result.value.events.find(entry => entry.event.seq === dispatch.seq)?.view).toEqual({
  171. for: 'event',
  172. view: {
  173. scheduleId,
  174. prompt: 'after restart',
  175. occurrenceAt: '2026-08-05T12:00:01.000Z',
  176. },
  177. })
  178. await ctx.fiber.dispose()
  179. })
  180. it('uses only the attached identity-matching stored prefix and fails soft to raw history', async () => {
  181. const ctx = await harness()
  182. const parent = ctx.sessions.create(SessionId('schedule-parent'), { meta: { cwd: '/tmp' } })
  183. appendReminder(parent, 'parent-reminder', 'from parent')
  184. const session = ctx.sessions.create(SessionId('schedule-attached'), {
  185. seed: [...parent.events],
  186. meta: { cwd: '/tmp', parentSession: parent.id, seedLength: 2 },
  187. })
  188. let readFrom = (): Promise<{ meta: SessionHeader; events: SessionEvent[] }> => Promise.resolve({
  189. meta: session.header,
  190. events: [...session.events.slice(0, 1)],
  191. })
  192. ctx.provide('sessionPersistence', {
  193. readFrom: () => readFrom(),
  194. } as never)
  195. const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
  196. const history = async () => {
  197. const response = await api.sessions.history({
  198. rpcId: RpcId('schedule-history'), payload: { sessionId: session.id },
  199. })
  200. if (!response.result.ok) throw new Error(response.result.error.message)
  201. return response.result.value.events
  202. }
  203. expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
  204. readFrom = () => Promise.resolve({
  205. meta: { ...session.header, delegationDepth: 0 },
  206. events: [...session.events.slice(0, 2)],
  207. })
  208. expect((await history()).find(entry => entry.event.seq === 1)?.view).toMatchObject({
  209. for: 'event',
  210. })
  211. readFrom = () => Promise.resolve({
  212. meta: { ...session.header, cwd: '/different', delegationDepth: 0 },
  213. events: [...session.events.slice(0, 2)],
  214. })
  215. expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
  216. readFrom = () => Promise.resolve({
  217. meta: { ...session.header, timeZone: 'UTC', delegationDepth: 0 },
  218. events: [...session.events.slice(0, 2)],
  219. })
  220. expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
  221. readFrom = () => Promise.reject(new Error('physical read unavailable'))
  222. expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
  223. await ctx.fiber.dispose()
  224. })
  225. it('presents every dispatch in detached persisted history', async () => {
  226. const ctx = await harness()
  227. let source: Session | undefined
  228. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  229. source = inner.sessions.create(SessionId('schedule-source'), { meta: { cwd: '/tmp' } })
  230. }, { inject: ['sessions'] }))
  231. if (source === undefined) throw new Error('session owner did not publish its session')
  232. appendReminder(source, 'schedule-1', 'cold reminder')
  233. const meta = source.header
  234. const events = [...source.events]
  235. await owner.dispose()
  236. ctx.provide('sessionPersistence', {
  237. list: () => Promise.resolve([meta]),
  238. inspect: () => Promise.resolve({ meta, events }),
  239. readFrom: () => Promise.resolve({ meta, events }),
  240. } as never)
  241. const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
  242. const response = await api.sessions.history({
  243. rpcId: RpcId('schedule-cold'), payload: { sessionId: meta.id },
  244. })
  245. if (!response.result.ok) throw new Error(response.result.error.message)
  246. expect(response.result.value.events.find(entry => entry.event.seq === 1)?.view).toMatchObject({
  247. for: 'event',
  248. })
  249. await ctx.fiber.dispose()
  250. })
  251. it('withholds a detached view that exists only in a logical inspection', async () => {
  252. const ctx = await harness()
  253. let source: Session | undefined
  254. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  255. source = inner.sessions.create(SessionId('schedule-logical-only'), { meta: { cwd: '/tmp' } })
  256. }, { inject: ['sessions'] }))
  257. if (source === undefined) throw new Error('session owner did not publish its session')
  258. appendReminder(source, 'schedule-logical', 'not physically committed')
  259. const meta = source.header
  260. const events = [...source.events]
  261. await owner.dispose()
  262. let physicalEvents = events.slice(0, 1)
  263. ctx.provide('sessionPersistence', {
  264. list: () => Promise.resolve([meta]),
  265. inspect: () => Promise.resolve({ meta, events }),
  266. readFrom: () => Promise.resolve({ meta, events: physicalEvents }),
  267. } as never)
  268. const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
  269. const history = async () => {
  270. const response = await api.sessions.history({
  271. rpcId: RpcId('schedule-logical-only-history'), payload: { sessionId: meta.id },
  272. })
  273. if (!response.result.ok) throw new Error(response.result.error.message)
  274. return response.result.value.events
  275. }
  276. expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
  277. physicalEvents = events
  278. expect((await history()).find(entry => entry.event.seq === 1)?.view).toMatchObject({ for: 'event' })
  279. await ctx.fiber.dispose()
  280. })
  281. })