| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298 |
- /**
- * Schedule reminder views cross the Host only after persistence proves their
- * dispatch prefix. Live append sends raw events; session/flushed replays the
- * identical dispatch with a generic sidecar. History independently gates the
- * same projection on an identity-matching stored prefix.
- */
- import { Context } from 'cordis'
- import { describe, expect, it } from 'vitest'
- import AgentRegistry from '@deepseek-ai/dsh-agent'
- import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
- import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
- import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
- import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
- import ToolRegistry from '@deepseek-ai/dsh-tools'
- import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
- import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
- import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
- import { ScheduleId } from '@deepseek-ai/dsh-tool-schedule'
- interface FlushControl {
- handler: () => true | Promise<true>
- }
- function reminderCreateData(id: string, prompt: string) {
- return {
- version: 1 as const,
- operation: 'create' as const,
- schedule: {
- id: ScheduleId(id),
- kind: 'after' as const,
- prompt,
- afterSeconds: 1,
- scheduledAt: '2026-08-05T12:00:01.000Z',
- },
- }
- }
- async function harness(control?: FlushControl): Promise<Context> {
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- await ctx.plugin(SystemPrompt, { persona: '' })
- await ctx.plugin(ToolRegistry)
- await ctx.plugin(UserInteractionService)
- await ctx.plugin(AgentRegistry)
- if (control !== undefined) ctx.on('session/flush', () => control.handler())
- return ctx
- }
- function appendReminder(
- session: Session,
- id: string,
- prompt: string,
- ): { create: SessionEvent; dispatch: SessionEvent } {
- const scheduleId = ScheduleId(id)
- const create = session.append('schedule/change', reminderCreateData(id, prompt))
- const dispatch = session.append('schedule/change', {
- version: 1,
- operation: 'dispatch',
- id: scheduleId,
- })
- return { create, dispatch }
- }
- async function collectEvents(
- iterable: AsyncIterable<RpcRequest<MuxFrame>>,
- count: number,
- abort: AbortController,
- ): Promise<Extract<MuxFrame, { type: 'session/event' }>[]> {
- const events: Extract<MuxFrame, { type: 'session/event' }>[] = []
- for await (const envelope of iterable) {
- if (envelope.payload.type !== 'session/event') continue
- events.push(envelope.payload)
- if (events.length >= count) abort.abort()
- }
- return events
- }
- describe('commit-aware Schedule live views', () => {
- it('takes the max of reverse flush completion and replays each dispatch once', async () => {
- const first = Promise.withResolvers<true>()
- let calls = 0
- const ctx = await harness({
- handler: () => ++calls === 1 ? first.promise : true,
- })
- const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
- const abort = new AbortController()
- const collected = collectEvents(
- api.events.mux({ rpcId: RpcId('schedule-live'), payload: {} }, abort.signal),
- 6,
- abort,
- )
- const session = ctx.sessions.create(SessionId('schedule-live'))
- const firstPair = appendReminder(session, 'schedule-1', 'first')
- const slow = ctx.sessions.flush(session)
- const secondPair = appendReminder(session, 'schedule-2', 'second')
- await expect(ctx.sessions.flush(session)).resolves.toBe(true)
- first.resolve(true)
- await expect(slow).resolves.toBe(true)
- const frames = await collected
- const raw = frames.filter(frame => frame.view === undefined)
- const presented = frames.filter(frame => frame.view?.for === 'event')
- expect(raw.map(frame => frame.event.seq)).toEqual([0, 1, 2, 3])
- expect(presented.map(frame => frame.event.seq)).toEqual([1, 3])
- expect(presented[0]?.event).toBe(firstPair.dispatch)
- expect(presented[1]?.event).toBe(secondPair.dispatch)
- expect(presented.map(frame => frame.view)).toEqual([
- {
- for: 'event',
- view: {
- scheduleId: 'schedule-1', prompt: 'first',
- occurrenceAt: '2026-08-05T12:00:01.000Z',
- },
- },
- {
- for: 'event',
- view: {
- scheduleId: 'schedule-2', prompt: 'second',
- occurrenceAt: '2026-08-05T12:00:01.000Z',
- },
- },
- ])
- expect(firstPair.create.seq).toBe(0)
- await ctx.fiber.dispose()
- })
- it('withholds a view after rejection and publishes it on the next successful checkpoint', async () => {
- let calls = 0
- const ctx = await harness({
- handler: () => ++calls === 1 ? Promise.reject(new Error('disk unavailable')) : true,
- })
- const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
- const abort = new AbortController()
- const collected = collectEvents(
- api.events.mux({ rpcId: RpcId('schedule-retry'), payload: {} }, abort.signal),
- 3,
- abort,
- )
- const session = ctx.sessions.create(SessionId('schedule-retry'))
- appendReminder(session, 'schedule-1', 'retry me')
- await expect(ctx.sessions.flush(session)).rejects.toThrow('disk unavailable')
- await expect(ctx.sessions.flush(session)).resolves.toBe(true)
- const frames = await collected
- expect(frames.filter(frame => frame.view?.for === 'event')).toHaveLength(1)
- expect(frames.at(-1)?.view).toMatchObject({
- for: 'event',
- })
- await ctx.fiber.dispose()
- })
- })
- describe('Schedule history views', () => {
- it('presents a resumed ancestor dispatch copied into a fork seed', async () => {
- const ctx = await harness()
- const scheduleId = ScheduleId('resumed-reminder')
- const resumed = ctx.sessions.create(SessionId('schedule-resumed'), {
- seed: [{
- type: 'schedule/change',
- seq: 0,
- time: 1,
- data: reminderCreateData('resumed-reminder', 'after restart'),
- }],
- meta: { cwd: '/tmp' },
- })
- const dispatch = resumed.append('schedule/change', {
- version: 1,
- operation: 'dispatch',
- id: scheduleId,
- })
- const child = ctx.sessions.fork(resumed, undefined, SessionId('schedule-fork'))
- ctx.provide('sessionPersistence', {
- readFrom: () => Promise.resolve({ meta: child.header, events: [...child.events] }),
- } as never)
- const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
- const response = await api.sessions.history({
- rpcId: RpcId('schedule-resumed-fork'), payload: { sessionId: child.id },
- })
- if (!response.result.ok) throw new Error(response.result.error.message)
- expect(response.result.value.events.find(entry => entry.event.seq === dispatch.seq)?.view).toEqual({
- for: 'event',
- view: {
- scheduleId,
- prompt: 'after restart',
- occurrenceAt: '2026-08-05T12:00:01.000Z',
- },
- })
- await ctx.fiber.dispose()
- })
- it('uses only the attached identity-matching stored prefix and fails soft to raw history', async () => {
- const ctx = await harness()
- const parent = ctx.sessions.create(SessionId('schedule-parent'), { meta: { cwd: '/tmp' } })
- appendReminder(parent, 'parent-reminder', 'from parent')
- const session = ctx.sessions.create(SessionId('schedule-attached'), {
- seed: [...parent.events],
- meta: { cwd: '/tmp', parentSession: parent.id, seedLength: 2 },
- })
- let readFrom = (): Promise<{ meta: SessionHeader; events: SessionEvent[] }> => Promise.resolve({
- meta: session.header,
- events: [...session.events.slice(0, 1)],
- })
- ctx.provide('sessionPersistence', {
- readFrom: () => readFrom(),
- } as never)
- const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
- const history = async () => {
- const response = await api.sessions.history({
- rpcId: RpcId('schedule-history'), payload: { sessionId: session.id },
- })
- if (!response.result.ok) throw new Error(response.result.error.message)
- return response.result.value.events
- }
- expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
- readFrom = () => Promise.resolve({
- meta: { ...session.header, delegationDepth: 0 },
- events: [...session.events.slice(0, 2)],
- })
- expect((await history()).find(entry => entry.event.seq === 1)?.view).toMatchObject({
- for: 'event',
- })
- readFrom = () => Promise.resolve({
- meta: { ...session.header, cwd: '/different', delegationDepth: 0 },
- events: [...session.events.slice(0, 2)],
- })
- expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
- readFrom = () => Promise.resolve({
- meta: { ...session.header, timeZone: 'UTC', delegationDepth: 0 },
- events: [...session.events.slice(0, 2)],
- })
- expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
- readFrom = () => Promise.reject(new Error('physical read unavailable'))
- expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
- await ctx.fiber.dispose()
- })
- it('presents every dispatch in detached persisted history', async () => {
- const ctx = await harness()
- let source: Session | undefined
- const owner = await ctx.plugin(Object.assign((inner: Context) => {
- source = inner.sessions.create(SessionId('schedule-source'), { meta: { cwd: '/tmp' } })
- }, { inject: ['sessions'] }))
- if (source === undefined) throw new Error('session owner did not publish its session')
- appendReminder(source, 'schedule-1', 'cold reminder')
- const meta = source.header
- const events = [...source.events]
- await owner.dispose()
- ctx.provide('sessionPersistence', {
- list: () => Promise.resolve([meta]),
- inspect: () => Promise.resolve({ meta, events }),
- readFrom: () => Promise.resolve({ meta, events }),
- } as never)
- const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
- const response = await api.sessions.history({
- rpcId: RpcId('schedule-cold'), payload: { sessionId: meta.id },
- })
- if (!response.result.ok) throw new Error(response.result.error.message)
- expect(response.result.value.events.find(entry => entry.event.seq === 1)?.view).toMatchObject({
- for: 'event',
- })
- await ctx.fiber.dispose()
- })
- it('withholds a detached view that exists only in a logical inspection', async () => {
- const ctx = await harness()
- let source: Session | undefined
- const owner = await ctx.plugin(Object.assign((inner: Context) => {
- source = inner.sessions.create(SessionId('schedule-logical-only'), { meta: { cwd: '/tmp' } })
- }, { inject: ['sessions'] }))
- if (source === undefined) throw new Error('session owner did not publish its session')
- appendReminder(source, 'schedule-logical', 'not physically committed')
- const meta = source.header
- const events = [...source.events]
- await owner.dispose()
- let physicalEvents = events.slice(0, 1)
- ctx.provide('sessionPersistence', {
- list: () => Promise.resolve([meta]),
- inspect: () => Promise.resolve({ meta, events }),
- readFrom: () => Promise.resolve({ meta, events: physicalEvents }),
- } as never)
- const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
- const history = async () => {
- const response = await api.sessions.history({
- rpcId: RpcId('schedule-logical-only-history'), payload: { sessionId: meta.id },
- })
- if (!response.result.ok) throw new Error(response.result.error.message)
- return response.result.value.events
- }
- expect((await history()).find(entry => entry.event.seq === 1)?.view).toBeUndefined()
- physicalEvents = events
- expect((await history()).find(entry => entry.event.seq === 1)?.view).toMatchObject({ for: 'event' })
- await ctx.fiber.dispose()
- })
- })
|