tools.spec.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
  4. import type { Agent, AgentCancelCause, InboxTarget } from '@deepseek-ai/dsh-agent'
  5. import { CallId } from '@deepseek-ai/dsh-llm'
  6. import type { UserMessage } from '@deepseek-ai/dsh-llm'
  7. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  8. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  9. import ToolRegistry from '@deepseek-ai/dsh-tools'
  10. import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
  11. import { registerScheduleTools } from '../src/tools.ts'
  12. import { runScheduleTransaction } from '../src/transaction.ts'
  13. const signal = new AbortController().signal
  14. const contexts: Context[] = []
  15. interface ToolHarness {
  16. readonly ctx: Context
  17. readonly agent: Agent
  18. readonly flushes: { count: number; outcomes: Array<'resolve' | 'reject' | Promise<'resolve' | 'reject'>> }
  19. readonly changes: { count: number }
  20. readonly disposeTools: () => void
  21. }
  22. function stubAgent(ctx: Context, id: string): Agent {
  23. const session = ctx.sessions.create(SessionId(id))
  24. const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
  25. return {
  26. id: session.id,
  27. options: {},
  28. session,
  29. inbox,
  30. status: 'idle',
  31. ctx: new Context(),
  32. send(_message: UserMessage, _target: InboxTarget, _wakeup: boolean) {},
  33. runMaintenance: task => task(signal),
  34. cancel(_cause: AgentCancelCause) {},
  35. whenIdle: () => Promise.resolve(),
  36. followup(_message: UserMessage) {},
  37. steer(_message: UserMessage) {},
  38. inject(_message: UserMessage) {},
  39. }
  40. }
  41. async function harness(withPersistence = true): Promise<ToolHarness> {
  42. const ctx = new Context()
  43. contexts.push(ctx)
  44. await ctx.plugin(SessionStore)
  45. await ctx.plugin(AgentRegistry)
  46. await ctx.plugin(SystemPrompt, {})
  47. await ctx.plugin(ToolRegistry)
  48. const agent = stubAgent(ctx, `schedule-tools-${Math.random()}`)
  49. ctx.agents.register(agent)
  50. const flushes = { count: 0, outcomes: [] as Array<'resolve' | 'reject' | Promise<'resolve' | 'reject'>> }
  51. if (withPersistence) {
  52. ctx.on('session/flush', async () => {
  53. flushes.count += 1
  54. const outcome = await (flushes.outcomes.shift() ?? 'resolve')
  55. if (outcome === 'reject') return Promise.reject(new Error('disk unavailable'))
  56. })
  57. }
  58. const changes = { count: 0 }
  59. const disposeTools = registerScheduleTools(ctx, ctx, agent, () => { changes.count += 1 })
  60. return { ctx, agent, flushes, changes, disposeTools }
  61. }
  62. async function execute(
  63. test: ToolHarness,
  64. name: string,
  65. args: unknown,
  66. agent: Agent = test.agent,
  67. executionSignal: AbortSignal = signal,
  68. ): Promise<ToolExecutionResult> {
  69. return test.ctx.agents.withInitiator(agent, () => test.ctx.tools.execute({
  70. signal: executionSignal,
  71. callId: CallId(`call-${Math.random()}`),
  72. name,
  73. arguments: args,
  74. agent,
  75. }))
  76. }
  77. function value(result: ToolExecutionResult): unknown {
  78. expect(result.isError).toBe(false)
  79. if (result.isError) throw new Error('expected canonical Schedule value')
  80. const block = result.content[0]
  81. if (block?.type !== 'text') throw new Error('expected deterministic text content')
  82. expect(JSON.parse(block.text)).toEqual(result.value)
  83. return result.value
  84. }
  85. beforeEach(() => {
  86. vi.useFakeTimers()
  87. vi.setSystemTime(new Date('2026-08-05T12:00:00.000Z'))
  88. })
  89. afterEach(async () => {
  90. await Promise.allSettled(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
  91. vi.useRealTimers()
  92. })
  93. describe('Schedule tool protocol', () => {
  94. it('registers three exclusive generic tools and disposes them together', async () => {
  95. const test = await harness()
  96. expect(['schedule_create', 'schedule_list', 'schedule_delete'].map(name => test.ctx.tools.get(name)?.name))
  97. .toEqual(['schedule_create', 'schedule_list', 'schedule_delete'])
  98. const outputSchema = test.ctx.tools.get('schedule_create')?.output.schema as {
  99. oneOf?: Array<{ properties?: { code?: { const?: string }; operation?: { enum?: string[] } } }>
  100. }
  101. const persistenceError = outputSchema.oneOf?.find(schema =>
  102. schema.properties?.code?.const === 'persistence_uncertain')
  103. expect(persistenceError?.properties?.operation?.enum).toEqual(['create', 'list', 'delete'])
  104. for (const name of ['schedule_create', 'schedule_list', 'schedule_delete']) {
  105. expect(test.ctx.tools.executionMode({ signal, callId: CallId(name), name, arguments: {}, agent: test.agent }))
  106. .toEqual({ kind: 'exclusive' })
  107. }
  108. expect(test.ctx.tools.get('schedule_create')?.presentCall?.({ prompt: 'x', after_seconds: 1 }))
  109. .toEqual({ card: 'generic', title: 'Create reminder', kind: 'other', rawInput: 'x' })
  110. expect(test.ctx.tools.get('schedule_list')?.presentCall?.({}))
  111. .toEqual({ card: 'generic', title: 'List reminders', kind: 'read' })
  112. expect(test.ctx.tools.get('schedule_delete')?.presentCall?.({ id: 'schedule-1' }))
  113. .toEqual({ card: 'generic', title: 'Delete reminder', kind: 'other', rawInput: 'schedule-1' })
  114. test.disposeTools()
  115. test.disposeTools()
  116. expect(test.ctx.tools.get('schedule_create')).toBeUndefined()
  117. expect(test.ctx.tools.get('schedule_list')).toBeUndefined()
  118. expect(test.ctx.tools.get('schedule_delete')).toBeUndefined()
  119. })
  120. it('rolls back earlier tool registrations when a later name conflicts', async () => {
  121. const test = await harness()
  122. const list = test.ctx.tools.get('schedule_list')
  123. if (list === undefined) throw new Error('expected registered list tool')
  124. test.disposeTools()
  125. const disposeConflict = test.ctx.tools.register(list)
  126. expect(() => registerScheduleTools(test.ctx, test.ctx, test.agent, () => {})).toThrow()
  127. expect(test.ctx.tools.get('schedule_create')).toBeUndefined()
  128. expect(test.ctx.tools.get('schedule_list')).toBe(list)
  129. expect(test.ctx.tools.get('schedule_delete')).toBeUndefined()
  130. disposeConflict()
  131. })
  132. it('rejects shape-known invalid create input before persistence', async () => {
  133. const test = await harness()
  134. expect(value(await execute(test, 'schedule_create', { prompt: ' ', after_seconds: 1 })))
  135. .toEqual({ code: 'invalid_prompt', message: 'prompt must be non-empty after trimming.' })
  136. expect(value(await execute(test, 'schedule_create', { prompt: 'x', after_seconds: 0 })))
  137. .toEqual({ code: 'invalid_rule', message: 'after_seconds must be a positive safe integer.' })
  138. expect(value(await execute(test, 'schedule_create', { prompt: 'x', after_seconds: 1.5 })))
  139. .toEqual({ code: 'invalid_rule', message: 'after_seconds must be a positive safe integer.' })
  140. expect(value(await execute(test, 'schedule_create', { prompt: 'x', after_seconds: 1, at: 'later' })))
  141. .toEqual({
  142. code: 'invalid_selector',
  143. message: 'schedule_create accepts exactly one of after_seconds, at, or every_seconds.',
  144. })
  145. expect(value(await execute(test, 'schedule_create', { prompt: 'x', every_seconds: 1.5 })))
  146. .toEqual({ code: 'invalid_rule', message: 'every_seconds must be a safe integer.' })
  147. expect(value(await execute(test, 'schedule_create', { prompt: 'x', every_seconds: 299 })))
  148. .toEqual({ code: 'frequency_too_high', message: 'every_seconds must be at least 300.' })
  149. expect(test.flushes.count).toBe(0)
  150. expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([])
  151. })
  152. it('creates, lists, marks overdue, deletes, and never reuses an id', async () => {
  153. const test = await harness()
  154. expect(value(await execute(test, 'schedule_create', {
  155. prompt: ' check logs ', after_seconds: 30,
  156. }))).toEqual({
  157. id: 'schedule-1',
  158. kind: 'after',
  159. prompt: 'check logs',
  160. afterSeconds: 30,
  161. scheduledAt: '2026-08-05T12:00:30.000Z',
  162. state: 'scheduled',
  163. deliveryMode: 'session-local',
  164. })
  165. expect(test.flushes.count).toBe(2)
  166. expect(test.changes.count).toBe(2)
  167. vi.setSystemTime(new Date('2026-08-05T12:00:31.000Z'))
  168. expect(value(await execute(test, 'schedule_list', {}))).toEqual([
  169. expect.objectContaining({ id: 'schedule-1', state: 'overdue' }),
  170. ])
  171. expect(test.flushes.count).toBe(3)
  172. expect(test.changes.count).toBe(3)
  173. expect(value(await execute(test, 'schedule_delete', { id: 'schedule-1' })))
  174. .toEqual({ id: 'schedule-1', deleted: true })
  175. expect(test.flushes.count).toBe(5)
  176. expect(test.changes.count).toBe(5)
  177. expect(value(await execute(test, 'schedule_delete', { id: 'schedule-1' })))
  178. .toEqual({ id: 'schedule-1', deleted: false, code: 'schedule_not_found' })
  179. expect(test.flushes.count).toBe(6)
  180. expect(value(await execute(test, 'schedule_create', { prompt: 'next', after_seconds: 1 })))
  181. .toMatchObject({ id: 'schedule-2' })
  182. })
  183. it('rejects an empty or padded delete id before persistence', async () => {
  184. const test = await harness()
  185. for (const id of ['', ' schedule-1']) {
  186. expect(value(await execute(test, 'schedule_delete', { id }))).toEqual({
  187. code: 'invalid_rule',
  188. message: 'schedule_delete id must be non-empty without surrounding whitespace.',
  189. })
  190. }
  191. expect(test.flushes.count).toBe(0)
  192. })
  193. it('creates offset and explicit-zone at records without persisting their input interpretation', async () => {
  194. const test = await harness()
  195. expect(value(await execute(test, 'schedule_create', {
  196. prompt: 'join meeting', at: '2026-08-06T09:00:00+08:00',
  197. }))).toEqual({
  198. id: 'schedule-1',
  199. kind: 'at',
  200. prompt: 'join meeting',
  201. scheduledAt: '2026-08-06T01:00:00.000Z',
  202. state: 'scheduled',
  203. deliveryMode: 'session-local',
  204. })
  205. expect(value(await execute(test, 'schedule_create', {
  206. prompt: 'local meeting',
  207. at: { date: '2026-08-07', time: '09:30:00', time_zone: 'Asia/Shanghai' },
  208. }))).toMatchObject({
  209. id: 'schedule-2',
  210. kind: 'at',
  211. scheduledAt: '2026-08-07T01:30:00.000Z',
  212. })
  213. expect(value(await execute(test, 'schedule_list', {}))).toEqual([
  214. expect.objectContaining({ id: 'schedule-1', kind: 'at' }),
  215. expect.objectContaining({ id: 'schedule-2', kind: 'at' }),
  216. ])
  217. const changes = test.agent.session.events
  218. .filter(event => event.type === 'schedule/change' && event.data.operation === 'create')
  219. expect(changes.map((change) => {
  220. if (change.type !== 'schedule/change' || change.data.operation !== 'create') {
  221. throw new Error('expected only Schedule create changes')
  222. }
  223. return change.data.schedule
  224. })).toEqual([
  225. {
  226. id: 'schedule-1',
  227. kind: 'at',
  228. prompt: 'join meeting',
  229. scheduledAt: '2026-08-06T01:00:00.000Z',
  230. },
  231. {
  232. id: 'schedule-2',
  233. kind: 'at',
  234. prompt: 'local meeting',
  235. scheduledAt: '2026-08-07T01:30:00.000Z',
  236. },
  237. ])
  238. })
  239. it('creates and lists a fixed-rate record', async () => {
  240. const test = await harness()
  241. expect(value(await execute(test, 'schedule_create', {
  242. prompt: ' check metrics ', every_seconds: 300,
  243. }))).toEqual({
  244. id: 'schedule-1',
  245. kind: 'every',
  246. prompt: 'check metrics',
  247. everySeconds: 300,
  248. scheduledAt: '2026-08-05T12:05:00.000Z',
  249. state: 'scheduled',
  250. deliveryMode: 'session-local',
  251. })
  252. vi.setSystemTime(new Date('2026-08-05T12:06:00.000Z'))
  253. expect(value(await execute(test, 'schedule_list', {}))).toEqual([
  254. expect.objectContaining({
  255. id: 'schedule-1',
  256. kind: 'every',
  257. everySeconds: 300,
  258. state: 'overdue',
  259. }),
  260. ])
  261. })
  262. it('returns stable at validation errors after persistence preflight', async () => {
  263. const test = await harness()
  264. expect(value(await execute(test, 'schedule_create', {
  265. prompt: 'bad instant', at: '2026-08-06T09:00:00',
  266. }))).toEqual({
  267. code: 'invalid_rule',
  268. message: 'at must use YYYY-MM-DDTHH:mm:ss with optional 1-3 digit fractional seconds and an explicit Z or numeric offset.',
  269. })
  270. expect(value(await execute(test, 'schedule_create', {
  271. prompt: 'bad zone', at: { date: '2026-08-06', time: '09:00:00', time_zone: 'CST' },
  272. }))).toEqual({
  273. code: 'invalid_time_zone',
  274. message: 'time_zone must be UTC or a valid IANA Area/Location name.',
  275. })
  276. expect(value(await execute(test, 'schedule_create', {
  277. prompt: 'past', at: '2026-08-05T12:00:00Z',
  278. }))).toEqual({
  279. code: 'not_future',
  280. message: 'The scheduled time must be strictly in the future.',
  281. })
  282. expect(test.flushes.count).toBe(3)
  283. expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([])
  284. })
  285. it('returns a range error only after the create preflight', async () => {
  286. const test = await harness()
  287. expect(value(await execute(test, 'schedule_create', {
  288. prompt: 'far future', after_seconds: Number.MAX_SAFE_INTEGER,
  289. }))).toEqual({
  290. code: 'time_out_of_range',
  291. message: 'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.',
  292. })
  293. expect(test.flushes.count).toBe(1)
  294. expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([])
  295. const internal = await harness()
  296. const now = vi.spyOn(Date, 'now').mockImplementationOnce(() => { throw new Error('clock unavailable') })
  297. expect(value(await execute(internal, 'schedule_create', { prompt: 'clock', after_seconds: 1 })))
  298. .toEqual({ code: 'internal_error', message: 'The schedule operation failed.' })
  299. now.mockRestore()
  300. })
  301. it('contains a projection observer failure after the create barrier', async () => {
  302. const test = await harness()
  303. test.disposeTools()
  304. let calls = 0
  305. const dispose = registerScheduleTools(test.ctx, test.ctx, test.agent, () => {
  306. calls += 1
  307. if (calls === 1) throw new Error('observer failed')
  308. throw 'observer failed again'
  309. })
  310. expect(value(await execute(test, 'schedule_create', { prompt: 'still committed', after_seconds: 1 })))
  311. .toMatchObject({ id: 'schedule-1', state: 'scheduled' })
  312. expect(value(await execute(test, 'schedule_delete', { id: 'schedule-1' })))
  313. .toEqual({ id: 'schedule-1', deleted: true })
  314. dispose()
  315. })
  316. it('treats missing persistence as uncertainty rather than a successful no-op', async () => {
  317. const test = await harness(false)
  318. expect(value(await execute(test, 'schedule_list', {}))).toEqual({
  319. code: 'persistence_uncertain',
  320. message: 'Schedule persistence is uncertain; retry with schedule_list before relying on this result.',
  321. operation: 'list',
  322. })
  323. })
  324. })
  325. describe('Schedule persistence failure boundaries', () => {
  326. it('does not fold an unconfirmed corrupt live suffix before preflight succeeds', async () => {
  327. const test = await harness()
  328. Object.defineProperty(test.agent.session, 'events', {
  329. configurable: true,
  330. value: [{
  331. type: 'schedule/change',
  332. seq: 0,
  333. time: Date.now(),
  334. data: { version: 2, operation: 'create', schedule: {} },
  335. }],
  336. })
  337. test.flushes.outcomes.push('reject', 'resolve')
  338. expect(value(await execute(test, 'schedule_list', {}))).toMatchObject({
  339. code: 'persistence_uncertain', operation: 'list',
  340. })
  341. expect(value(await execute(test, 'schedule_list', {}))).toEqual({
  342. code: 'corrupt_schedule_log', message: 'The session schedule log is corrupt.',
  343. })
  344. })
  345. it('reports a create barrier rejection with the known appended id and recovers on list preflight', async () => {
  346. const test = await harness()
  347. test.flushes.outcomes.push('resolve', 'reject', 'resolve')
  348. expect(value(await execute(test, 'schedule_create', { prompt: 'persist me', after_seconds: 10 })))
  349. .toEqual({
  350. code: 'persistence_uncertain',
  351. message: 'Schedule persistence is uncertain; retry with schedule_list before relying on this result.',
  352. operation: 'create',
  353. id: 'schedule-1',
  354. })
  355. expect(test.changes.count).toBe(1)
  356. expect(value(await execute(test, 'schedule_list', {}))).toEqual([
  357. expect.objectContaining({ id: 'schedule-1' }),
  358. ])
  359. expect(test.changes.count).toBe(2)
  360. })
  361. it('serializes concurrent management transactions across both persistence barriers', async () => {
  362. const test = await harness()
  363. let releaseCreatePreflight: (() => void) | undefined
  364. const createPreflight = new Promise<'resolve'>((resolve) => {
  365. releaseCreatePreflight = () => { resolve('resolve') }
  366. })
  367. test.flushes.outcomes.push(createPreflight, 'reject', 'resolve')
  368. const creating = execute(test, 'schedule_create', { prompt: 'persist me', after_seconds: 10 })
  369. await vi.waitFor(() => { expect(test.flushes.count).toBe(1) })
  370. const listing = execute(test, 'schedule_list', {})
  371. await Promise.resolve()
  372. expect(test.flushes.count).toBe(1)
  373. if (releaseCreatePreflight === undefined) throw new Error('missing create preflight release')
  374. releaseCreatePreflight()
  375. expect(value(await creating)).toMatchObject({
  376. code: 'persistence_uncertain', operation: 'create', id: 'schedule-1',
  377. })
  378. expect(value(await listing)).toEqual([
  379. expect.objectContaining({ id: 'schedule-1', prompt: 'persist me' }),
  380. ])
  381. expect(test.flushes.count).toBe(3)
  382. })
  383. it('does not persist a create cancelled while it waits in the Schedule FIFO', async () => {
  384. const test = await harness()
  385. let releaseOwner: (() => void) | undefined
  386. let markOwnerStarted: (() => void) | undefined
  387. const ownerStarted = new Promise<void>((resolve) => {
  388. markOwnerStarted = resolve
  389. })
  390. const owner = runScheduleTransaction(test.agent, async () => {
  391. markOwnerStarted?.()
  392. await new Promise<void>((resolve) => { releaseOwner = resolve })
  393. })
  394. await ownerStarted
  395. const controller = new AbortController()
  396. const creating = execute(test, 'schedule_create', {
  397. prompt: 'cancelled before its turn', after_seconds: 1,
  398. }, test.agent, controller.signal)
  399. await Promise.resolve()
  400. controller.abort()
  401. if (releaseOwner === undefined) throw new Error('missing owner transaction release')
  402. releaseOwner()
  403. await owner
  404. await expect(creating).resolves.toMatchObject({
  405. isError: true,
  406. error: { info: { name: 'AbortError', code: 'ABORTED' } },
  407. })
  408. expect(test.flushes.count).toBe(0)
  409. expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([])
  410. })
  411. it('does not persist a create cancelled during its first preflight', async () => {
  412. const test = await harness()
  413. let releaseCreate: (() => void) | undefined
  414. const blockedCreate = new Promise<'resolve'>((resolve) => {
  415. releaseCreate = () => { resolve('resolve') }
  416. })
  417. test.flushes.outcomes.push(blockedCreate)
  418. const controller = new AbortController()
  419. const creating = execute(test, 'schedule_create', {
  420. prompt: 'cancelled during preflight', after_seconds: 1,
  421. }, test.agent, controller.signal)
  422. await vi.waitFor(() => { expect(test.flushes.count).toBe(1) })
  423. controller.abort()
  424. if (releaseCreate === undefined) throw new Error('missing create preflight release')
  425. releaseCreate()
  426. await expect(creating).resolves.toMatchObject({
  427. isError: true,
  428. error: { info: { name: 'AbortError', code: 'ABORTED' } },
  429. })
  430. expect(test.flushes.count).toBe(1)
  431. expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([])
  432. })
  433. it('does not persist a delete cancelled during its first preflight', async () => {
  434. const test = await harness()
  435. await execute(test, 'schedule_create', { prompt: 'keep me', after_seconds: 60 })
  436. let releaseDelete: (() => void) | undefined
  437. const blockedDelete = new Promise<'resolve'>((resolve) => {
  438. releaseDelete = () => { resolve('resolve') }
  439. })
  440. test.flushes.outcomes.push(blockedDelete)
  441. const controller = new AbortController()
  442. const deleting = execute(test, 'schedule_delete', { id: 'schedule-1' }, test.agent, controller.signal)
  443. await vi.waitFor(() => { expect(test.flushes.count).toBe(3) })
  444. controller.abort()
  445. if (releaseDelete === undefined) throw new Error('missing delete preflight release')
  446. releaseDelete()
  447. await expect(deleting).resolves.toMatchObject({
  448. isError: true,
  449. error: { info: { name: 'AbortError', code: 'ABORTED' } },
  450. })
  451. expect(test.flushes.count).toBe(3)
  452. expect(test.agent.session.events.filter(event => event.type === 'schedule/change'))
  453. .toHaveLength(1)
  454. expect(value(await execute(test, 'schedule_list', {})))
  455. .toEqual([expect.objectContaining({ id: 'schedule-1' })])
  456. })
  457. it('returns uncertainty before create or delete reads when their preflight rejects', async () => {
  458. const createTest = await harness()
  459. createTest.flushes.outcomes.push('reject')
  460. expect(value(await execute(createTest, 'schedule_create', { prompt: 'later', after_seconds: 1 })))
  461. .toMatchObject({ code: 'persistence_uncertain', operation: 'create' })
  462. expect(createTest.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([])
  463. const deleteTest = await harness()
  464. await execute(deleteTest, 'schedule_create', { prompt: 'keep', after_seconds: 1 })
  465. deleteTest.flushes.outcomes.push('reject')
  466. expect(value(await execute(deleteTest, 'schedule_delete', { id: 'schedule-1' })))
  467. .toMatchObject({ code: 'persistence_uncertain', operation: 'delete', id: 'schedule-1' })
  468. expect(deleteTest.agent.session.events.at(-1)?.data).toMatchObject({ operation: 'create' })
  469. })
  470. it('maps corrupt and unreadable folds for create, list, and delete', async () => {
  471. const corrupt = await harness()
  472. Object.defineProperty(corrupt.agent.session, 'events', {
  473. configurable: true,
  474. value: [{
  475. type: 'schedule/change', seq: 0, time: Date.now(),
  476. data: { version: 9, operation: 'delete', id: 'schedule-1' },
  477. }],
  478. })
  479. expect(value(await execute(corrupt, 'schedule_create', { prompt: 'x', after_seconds: 1 })))
  480. .toMatchObject({ code: 'corrupt_schedule_log' })
  481. expect(value(await execute(corrupt, 'schedule_delete', { id: 'schedule-1' })))
  482. .toMatchObject({ code: 'corrupt_schedule_log' })
  483. const unreadable = await harness()
  484. Object.defineProperty(unreadable.agent.session, 'events', {
  485. configurable: true,
  486. get() { throw 'unreadable log' },
  487. })
  488. expect(value(await execute(unreadable, 'schedule_list', {})))
  489. .toEqual({ code: 'internal_error', message: 'The schedule operation failed.' })
  490. })
  491. it('reports a delete barrier rejection and lets the next preflight clarify the terminal record', async () => {
  492. const test = await harness()
  493. await execute(test, 'schedule_create', { prompt: 'delete me', after_seconds: 10 })
  494. test.flushes.outcomes.push('resolve', 'reject', 'resolve')
  495. expect(value(await execute(test, 'schedule_delete', { id: 'schedule-1' }))).toMatchObject({
  496. code: 'persistence_uncertain', operation: 'delete', id: 'schedule-1',
  497. })
  498. expect(value(await execute(test, 'schedule_list', {}))).toEqual([])
  499. })
  500. it('contains append failures and refuses cross-owner execution', async () => {
  501. const test = await harness()
  502. const stop = test.ctx.on('internal/dispatch', (_mode, eventName, args) => {
  503. if (eventName === 'session/event' && (args as unknown[])[1] !== undefined) throw new Error('append denied')
  504. }, { global: true, prepend: true })
  505. expect(value(await execute(test, 'schedule_create', { prompt: 'x', after_seconds: 1 })))
  506. .toEqual({ code: 'internal_error', message: 'The schedule operation failed.' })
  507. stop()
  508. const other = stubAgent(test.ctx, `other-${Math.random()}`)
  509. test.ctx.agents.register(other)
  510. expect(value(await execute(test, 'schedule_create', { prompt: 'x', after_seconds: 1 }, other)))
  511. .toEqual({ code: 'internal_error', message: 'The schedule operation failed.' })
  512. expect(value(await execute(test, 'schedule_list', {}, other)))
  513. .toEqual({ code: 'internal_error', message: 'The schedule operation failed.' })
  514. expect(value(await execute(test, 'schedule_delete', { id: 'schedule-1' }, other)))
  515. .toEqual({ code: 'internal_error', message: 'The schedule operation failed.' })
  516. })
  517. it('contains a delete append failure after a successful preflight', async () => {
  518. const test = await harness()
  519. await execute(test, 'schedule_create', { prompt: 'x', after_seconds: 1 })
  520. const stop = test.ctx.on('internal/dispatch', (_mode, eventName, args) => {
  521. if (eventName !== 'session/event') return
  522. const event = (args as unknown[])[1] as { type?: string; data?: { operation?: string } } | undefined
  523. if (event?.type === 'schedule/change' && event.data?.operation === 'delete') throw new Error('append denied')
  524. }, { global: true, prepend: true })
  525. expect(value(await execute(test, 'schedule_delete', { id: 'schedule-1' })))
  526. .toEqual({ code: 'internal_error', message: 'The schedule operation failed.' })
  527. stop()
  528. })
  529. })