fold.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. import { describe, expect, it } from 'vitest'
  2. import { SessionId } from '@deepseek-ai/dsh-session'
  3. import type { SessionEvent, SessionEventMap, SessionEventType } from '@deepseek-ai/dsh-session'
  4. import {
  5. applyTeamEvent,
  6. emptyTeamFoldState,
  7. foldTeam,
  8. isTeamEvent,
  9. } from '../src/fold.ts'
  10. import type { TeamFoldState } from '../src/fold.ts'
  11. import { TeamId, TeamMessageId, TeamTaskId } from '../src/types.ts'
  12. import type { TeamMemberSnapshot, TeamMessageSnapshot, TeamTaskSnapshot } from '../src/types.ts'
  13. const ROOT = SessionId('team-root')
  14. const TEAM = TeamId(ROOT)
  15. const CHILD = SessionId('child-a')
  16. function event<T extends SessionEventType>(type: T, data: SessionEventMap[T], seq: number): SessionEvent<T> {
  17. return { type, data, seq, time: seq } as SessionEvent<T>
  18. }
  19. /** Queued-minus-delivered mail, the recovery mailbox the fold is responsible for. */
  20. function pending(state: TeamFoldState): TeamMessageSnapshot[] {
  21. return [...state.messages.values()].filter(message => !state.delivered.has(message.id))
  22. }
  23. /** Whether one fold reached the end of its log without applying any Team record. */
  24. function isEmptyFold(state: TeamFoldState): boolean {
  25. return state.members.size === 0 && state.tasks.size === 0
  26. && state.messages.size === 0 && state.delivered.size === 0
  27. }
  28. function member(overrides: Partial<TeamMemberSnapshot> = {}): TeamMemberSnapshot {
  29. return {
  30. id: CHILD,
  31. name: 'worker-a',
  32. description: 'worker',
  33. provider: 'spawn',
  34. context: 'fresh',
  35. phase: 'provisioning',
  36. ...overrides,
  37. }
  38. }
  39. function task(overrides: Partial<TeamTaskSnapshot> = {}): TeamTaskSnapshot {
  40. return {
  41. id: TeamTaskId('task-1'),
  42. revision: 1,
  43. subject: 'subject',
  44. description: 'description',
  45. status: 'pending',
  46. blockedBy: [],
  47. writeScopes: [],
  48. ...overrides,
  49. }
  50. }
  51. function message(overrides: Partial<TeamMessageSnapshot> = {}): TeamMessageSnapshot {
  52. return {
  53. id: TeamMessageId('message-1'),
  54. senderId: ROOT,
  55. senderName: 'lead',
  56. targetId: CHILD,
  57. delivery: 'quiet',
  58. content: [{ type: 'text', text: 'hello' }],
  59. ...overrides,
  60. }
  61. }
  62. describe('Agent Teams fold', () => {
  63. it('folds current-team records and ignores inherited records', () => {
  64. const records: SessionEvent[] = [
  65. event('team/member', { version: 1, teamId: TeamId('ancestor'), member: member() }, 0),
  66. event('team/member', { version: 1, teamId: TEAM, member: member() }, 1),
  67. event('team/member', {
  68. version: 1,
  69. teamId: TEAM,
  70. member: member({ phase: 'active' }),
  71. }, 2),
  72. event('team/task', { version: 1, teamId: TEAM, task: task({ id: TeamTaskId('task-7') }) }, 3),
  73. event('team/message/queued', { version: 1, teamId: TEAM, message: message() }, 4),
  74. ]
  75. const state = foldTeam(ROOT, records)
  76. expect(state).toMatchObject({ id: TEAM })
  77. expect(state.members.size).toBe(1)
  78. expect(state.tasks.size).toBe(1)
  79. expect(pending(state)).toHaveLength(1)
  80. expect(state.nextTaskNumber).toBe(8)
  81. expect(state.members.get(CHILD)?.name).toBe('worker-a')
  82. expect(isTeamEvent(records[0]!)).toBe(true)
  83. expect(isTeamEvent(event('turn/start', { turn: 1 }, 5))).toBe(false)
  84. })
  85. it('enforces teammate identity and lifecycle', () => {
  86. const base = event('team/member', { version: 1, teamId: TEAM, member: member() }, 0)
  87. expect(() => foldTeam(ROOT, [event('team/member', {
  88. version: 1,
  89. teamId: TEAM,
  90. member: member({ phase: 'active' }),
  91. }, 0)])).toThrow(/must begin provisioning/)
  92. expect(() => foldTeam(ROOT, [base, event('team/member', {
  93. version: 1,
  94. teamId: TEAM,
  95. member: member({ name: 'renamed', phase: 'active' }),
  96. }, 1)])).toThrow(/immutable identity/)
  97. expect(() => foldTeam(ROOT, [base, event('team/member', {
  98. version: 1,
  99. teamId: TEAM,
  100. member: member({ phase: 'active' }),
  101. }, 1), event('team/member', {
  102. version: 1,
  103. teamId: TEAM,
  104. member: member({ phase: 'failed' }),
  105. }, 2)])).toThrow(/invalid active -> failed/)
  106. const duplicateName = member({ id: SessionId('child-b') })
  107. expect(() => foldTeam(ROOT, [base, event('team/member', {
  108. version: 1,
  109. teamId: TEAM,
  110. member: duplicateName,
  111. }, 1)])).toThrow(/name .* reused/)
  112. })
  113. it('enforces task revision continuity', () => {
  114. const first = event('team/task', { version: 1, teamId: TEAM, task: task() }, 0)
  115. expect(() => foldTeam(ROOT, [event('team/task', {
  116. version: 1,
  117. teamId: TEAM,
  118. task: task({ revision: 2 }),
  119. }, 0)])).toThrow(/begin at revision 1/)
  120. expect(() => foldTeam(ROOT, [first, event('team/task', {
  121. version: 1,
  122. teamId: TEAM,
  123. task: task({ revision: 3 }),
  124. }, 1)])).toThrow(/revision is not contiguous/)
  125. })
  126. it('rejects every invalid persisted task dependency relation', () => {
  127. const first = event('team/task', { version: 1, teamId: TEAM, task: task() }, 0)
  128. const second = event('team/task', {
  129. version: 1,
  130. teamId: TEAM,
  131. task: task({
  132. id: TeamTaskId('task-2'),
  133. blockedBy: [TeamTaskId('task-1')],
  134. }),
  135. }, 1)
  136. const invalid: Array<{ records: SessionEvent[]; message: RegExp }> = [
  137. {
  138. records: [event('team/task', {
  139. version: 1,
  140. teamId: TEAM,
  141. task: task({ blockedBy: [TeamTaskId('missing')] }),
  142. }, 0)],
  143. message: /blocker task "missing" .* is missing or deleted/,
  144. },
  145. {
  146. records: [event('team/task', {
  147. version: 1,
  148. teamId: TEAM,
  149. task: task({ blockedBy: [TeamTaskId('task-1')] }),
  150. }, 0)],
  151. message: /cannot block itself/,
  152. },
  153. {
  154. records: [first, event('team/task', {
  155. ...second.data,
  156. task: { ...second.data.task, blockedBy: [TeamTaskId('task-1'), TeamTaskId('task-1')] },
  157. }, 1)],
  158. message: /repeats blocker/,
  159. },
  160. {
  161. records: [first, second, event('team/task', {
  162. version: 1,
  163. teamId: TEAM,
  164. task: task({ revision: 2, blockedBy: [TeamTaskId('task-2')] }),
  165. }, 2)],
  166. message: /dependency cycle/,
  167. },
  168. {
  169. records: [first, second, event('team/task', {
  170. version: 1,
  171. teamId: TEAM,
  172. task: task({ revision: 2, status: 'deleted' }),
  173. }, 2)],
  174. message: /blocker task "task-1" .* is missing or deleted/,
  175. },
  176. ]
  177. for (const { records, message: expected } of invalid) {
  178. expect(() => foldTeam(ROOT, records)).toThrow(expected)
  179. }
  180. })
  181. it('leaves numeric allocation unchanged for a branded nonstandard task id', () => {
  182. const state = foldTeam(ROOT, [event('team/task', {
  183. version: 1,
  184. teamId: TEAM,
  185. task: task({ id: TeamTaskId('external-task') }),
  186. }, 0)])
  187. expect(state.nextTaskNumber).toBe(1)
  188. })
  189. it('rejects a persisted numeric task id outside the safe integer range', () => {
  190. expect(() => foldTeam(ROOT, [event('team/task', {
  191. version: 1,
  192. teamId: TEAM,
  193. task: task({ id: TeamTaskId('task-9007199254740992') }),
  194. }, 0)])).toThrow(/persisted Agent Teams team\/task payload is invalid/)
  195. })
  196. it('enforces mailbox queue and acknowledgement relations', () => {
  197. const queued = event('team/message/queued', { version: 1, teamId: TEAM, message: message() }, 0)
  198. const delivered = event('team/message/delivered', {
  199. version: 1,
  200. teamId: TEAM,
  201. messageId: TeamMessageId('message-1'),
  202. targetId: CHILD,
  203. }, 1)
  204. expect(pending(foldTeam(ROOT, [queued, delivered]))).toEqual([])
  205. expect(() => foldTeam(ROOT, [queued, queued])).toThrow(/queued twice/)
  206. expect(() => foldTeam(ROOT, [delivered])).toThrow(/delivered before queueing/)
  207. expect(() => foldTeam(ROOT, [queued, event('team/message/delivered', {
  208. ...delivered.data,
  209. targetId: SessionId('other'),
  210. }, 1)])).toThrow(/target changed/)
  211. expect(() => foldTeam(ROOT, [queued, delivered, { ...delivered, seq: 2 }])).toThrow(/delivered twice/)
  212. })
  213. it('validates every current-version persisted payload before folding it', () => {
  214. const malformed = [
  215. {
  216. ...event('team/member', { version: 1, teamId: TEAM, member: member() }, 0),
  217. data: { version: 1, teamId: TEAM, member: { ...member(), name: 42 } },
  218. },
  219. {
  220. ...event('team/task', { version: 1, teamId: TEAM, task: task() }, 0),
  221. data: { version: 1, teamId: TEAM, task: { ...task(), blockedBy: [42] } },
  222. },
  223. {
  224. ...event('team/message/queued', { version: 1, teamId: TEAM, message: message() }, 0),
  225. data: {
  226. version: 1,
  227. teamId: TEAM,
  228. message: { ...message(), content: [{ type: 'text', text: 42 }] },
  229. },
  230. },
  231. {
  232. ...event('team/message/delivered', {
  233. version: 1,
  234. teamId: TEAM,
  235. messageId: TeamMessageId('message-1'),
  236. targetId: CHILD,
  237. }, 0),
  238. data: {
  239. version: 1,
  240. teamId: TEAM,
  241. messageId: TeamMessageId('message-1'),
  242. targetId: 42,
  243. },
  244. },
  245. {
  246. ...event('team/member', { version: 1, teamId: TEAM, member: member() }, 0),
  247. data: { version: 1, teamId: TEAM, member: member(), unexpected: true },
  248. },
  249. {
  250. ...event('team/task', { version: 1, teamId: TEAM, task: task() }, 0),
  251. data: { version: 1, teamId: 42, task: task() },
  252. },
  253. ] as unknown as SessionEvent[]
  254. for (const candidate of malformed) {
  255. expect(() => foldTeam(ROOT, [candidate]))
  256. .toThrow(/persisted Agent Teams .* payload is invalid/)
  257. }
  258. })
  259. it('retains merge-extensible content blocks while rejecting malformed core variants', () => {
  260. const extension = { type: 'plugin/custom', payload: { value: 1 } } as never
  261. const state = foldTeam(ROOT, [event('team/message/queued', {
  262. version: 1,
  263. teamId: TEAM,
  264. message: message({ content: [extension] }),
  265. }, 0)])
  266. expect(pending(state)[0]?.content).toEqual([extension])
  267. })
  268. it('rejects unsupported event versions without mutating an empty state', () => {
  269. const state = emptyTeamFoldState(ROOT)
  270. const invalid = event('team/task', {
  271. version: 2 as 1,
  272. teamId: TEAM,
  273. task: task(),
  274. }, 0)
  275. expect(() => { applyTeamEvent(state, invalid) }).toThrow(/unsupported Agent Teams event version 2/)
  276. expect(isEmptyFold(state)).toBe(true)
  277. })
  278. it('ignores unsupported inherited Team records before decoding their version', () => {
  279. const inherited = event('team/task', {
  280. version: 2 as 1,
  281. teamId: TeamId('ancestor'),
  282. task: task(),
  283. }, 0)
  284. expect(isEmptyFold(foldTeam(ROOT, [inherited]))).toBe(true)
  285. })
  286. it('still validates complete current-version records inherited from another Team', () => {
  287. const inherited = {
  288. ...event('team/task', {
  289. version: 1,
  290. teamId: TeamId('ancestor'),
  291. task: task(),
  292. }, 0),
  293. data: {
  294. version: 1,
  295. teamId: TeamId('ancestor'),
  296. task: { ...task(), subject: 42 },
  297. },
  298. } as unknown as SessionEvent
  299. expect(() => foldTeam(ROOT, [inherited]))
  300. .toThrow(/persisted Agent Teams team\/task payload is invalid/)
  301. })
  302. })