feat(schedule): add durable after reminders
This commit is contained in:
220
packages/host/apiproxy/tests/api-proxy-schedule-view.spec.ts
Normal file
220
packages/host/apiproxy/tests/api-proxy-schedule-view.spec.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* 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>
|
||||
}
|
||||
|
||||
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', {
|
||||
version: 1,
|
||||
operation: 'create',
|
||||
schedule: {
|
||||
id: scheduleId,
|
||||
kind: 'after',
|
||||
prompt,
|
||||
afterSeconds: 1,
|
||||
scheduledAt: '2026-08-05T12:00:01.000Z',
|
||||
},
|
||||
})
|
||||
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, { 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',
|
||||
presentationKey: 'schedule/reminder',
|
||||
view: {
|
||||
scheduleId: 'schedule-1', prompt: 'first',
|
||||
occurrenceAt: '2026-08-05T12:00:01.000Z', deliveryMode: 'session-local',
|
||||
},
|
||||
},
|
||||
{
|
||||
for: 'event',
|
||||
presentationKey: 'schedule/reminder',
|
||||
view: {
|
||||
scheduleId: 'schedule-2', prompt: 'second',
|
||||
occurrenceAt: '2026-08-05T12:00:01.000Z', deliveryMode: 'session-local',
|
||||
},
|
||||
},
|
||||
])
|
||||
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, { 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', presentationKey: 'schedule/reminder',
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schedule history views', () => {
|
||||
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 inspect = (): Promise<{ meta: SessionHeader; events: SessionEvent[] }> => Promise.resolve({
|
||||
meta: session.header,
|
||||
events: [...session.events.slice(0, 1)],
|
||||
})
|
||||
ctx.provide('sessionPersistence', {
|
||||
inspect: () => inspect(),
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { 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()
|
||||
inspect = () => 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', presentationKey: 'schedule/reminder',
|
||||
})
|
||||
inspect = () => 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()
|
||||
inspect = () => Promise.reject(new Error('inspect 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 }),
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { 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', presentationKey: 'schedule/reminder',
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -149,7 +149,8 @@ describe('mux live view computation', () => {
|
||||
|
||||
expect(byCall.get('tool/call:c-gen')?.view).toEqual({ for: 'call', view: { card: 'generic', title: 'gen call' } })
|
||||
expect(byCall.get('tool/call:c-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'echo hi' } })
|
||||
expect(byCall.get('tool/call:c-diff')?.view?.view.card).toBe('diff')
|
||||
const diffView = byCall.get('tool/call:c-diff')?.view
|
||||
expect(diffView?.for === 'call' ? diffView.view.card : undefined).toBe('diff')
|
||||
expect(byCall.get('tool/call:c-call-only')?.view).toEqual({
|
||||
for: 'call',
|
||||
view: { card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' },
|
||||
|
||||
@@ -194,6 +194,25 @@ describe('sessions domain schemas', () => {
|
||||
hasMore: false,
|
||||
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}).hasMore).toBe(false)
|
||||
const presented = {
|
||||
event: { type: 'schedule/change', seq: 2, time: 3, data: { operation: 'dispatch' } },
|
||||
view: {
|
||||
for: 'event',
|
||||
presentationKey: 'schedule/reminder',
|
||||
view: { scheduleId: 'schedule-1' },
|
||||
},
|
||||
}
|
||||
const parsedHistory = sessionHistoryValueSchema.parse({ events: [presented], hasMore: false })
|
||||
expect(parsedHistory.events?.at(0)?.view).toEqual(presented.view)
|
||||
for (const view of [
|
||||
{ for: 'event', presentationKey: '', view: {} },
|
||||
{ for: 'event', presentationKey: 'schedule/reminder' },
|
||||
]) {
|
||||
expect(() => sessionHistoryValueSchema.parse({
|
||||
events: [{ event: presented.event, view }],
|
||||
hasMore: false,
|
||||
})).toThrow()
|
||||
}
|
||||
expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
||||
expect(sessionModelsValueSchema.parse({
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' },
|
||||
@@ -420,6 +439,11 @@ describe('events frame schemas', () => {
|
||||
it('accepts every mux frame branch', () => {
|
||||
const frames = [
|
||||
{ type: 'session/event', sessionId: 's', event: { type: 't', seq: 0, time: 1, data: null } },
|
||||
{
|
||||
type: 'session/event', sessionId: 's',
|
||||
event: { type: 'schedule/change', seq: 1, time: 2, data: { operation: 'dispatch' } },
|
||||
view: { for: 'event', presentationKey: 'schedule/reminder', view: null },
|
||||
},
|
||||
{ type: 'session/subscribed', sessionId: 's', lastSeq: -1 },
|
||||
{ type: 'approval/requested', sessionId: 's', approvalId: 'a', toolName: 'bash', callId: 'c', reason: 'r' },
|
||||
{ type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' },
|
||||
|
||||
Reference in New Issue
Block a user