invariant.spec.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { scopeTarget } from '@deepseek-ai/dsh-scope'
  4. import { CallId } from '@deepseek-ai/dsh-llm'
  5. import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
  6. import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
  7. import * as ToolsInvariant from '@deepseek-ai/dsh-tools/invariant'
  8. import InvariantRegistry from '@deepseek-ai/dsh-invariants'
  9. const testToolSignal = new AbortController().signal
  10. async function setup(): Promise<Context> {
  11. const ctx = new Context()
  12. await ctx.plugin(SessionStore)
  13. await ctx.plugin(InvariantRegistry)
  14. await ctx.plugin(ToolsInvariant)
  15. return ctx
  16. }
  17. const execution = (overrides: Partial<ToolExecution> = {}): ToolExecution => ({
  18. token: Symbol('tool') as ToolExecutionToken,
  19. callId: CallId('call-1'),
  20. name: 'echo',
  21. arguments: Object.freeze({ text: 'hi' }),
  22. ...overrides,
  23. signal: overrides.signal ?? testToolSignal,
  24. rootCallId: overrides.rootCallId ?? overrides.callId ?? CallId('call-1'),
  25. })
  26. const outcome = (): ToolExecutionResult => Object.freeze({
  27. content: Object.freeze([{ type: 'text' as const, text: 'ok' }]) as never,
  28. isError: false,
  29. value: null,
  30. })
  31. function emitResult(ctx: Context, exec: ToolExecution, result: ToolExecutionResult): void {
  32. ctx.emit(scopeTarget(ctx as never, undefined), 'tools/result', exec, result)
  33. }
  34. async function stage(ctx: Context, name: 'tools/pre-execute' | 'tools/execute', exec: ToolExecution): Promise<void> {
  35. if (name === 'tools/pre-execute') {
  36. await ctx.waterfall(ctx as never, name, exec, () => Promise.resolve({ kind: 'allow' as const }))
  37. } else {
  38. await ctx.waterfall(ctx as never, name, exec, () => Promise.resolve(outcome()))
  39. }
  40. }
  41. describe('tool-pipeline invariants', () => {
  42. it('accepts dispatch and denial stage orders with frozen results', async () => {
  43. const ctx = await setup()
  44. const dispatched = execution()
  45. await stage(ctx, 'tools/pre-execute', dispatched)
  46. await stage(ctx, 'tools/execute', dispatched)
  47. await ctx.waterfall(ctx as never, 'tools/post-execute', dispatched, outcome(), () => Promise.resolve({ kind: 'accept' as const }))
  48. Object.freeze(dispatched)
  49. emitResult(ctx, dispatched, outcome())
  50. const denied = execution({ callId: CallId('call-2') })
  51. await stage(ctx, 'tools/pre-execute', denied)
  52. await ctx.waterfall(ctx as never, 'tools/post-execute', denied, outcome(), () => Promise.resolve({ kind: 'accept' as const }))
  53. Object.freeze(denied)
  54. emitResult(ctx, denied, outcome())
  55. ctx.emit('tools/change')
  56. })
  57. it('rejects repeated and out-of-order pipeline stages', async () => {
  58. const ctx = await setup()
  59. const exec = execution()
  60. await stage(ctx, 'tools/pre-execute', exec)
  61. await expect(stage(ctx, 'tools/pre-execute', exec)).rejects.toThrow(/repeated/)
  62. const noPre = execution({ callId: CallId('call-2') })
  63. await expect(stage(ctx, 'tools/execute', noPre)).rejects.toThrow(/must follow tools\/pre-execute/)
  64. expect(() => ctx.waterfall(
  65. ctx as never, 'tools/post-execute', noPre, outcome(),
  66. () => Promise.resolve({ kind: 'accept' as const }),
  67. )).toThrow(/must follow tools\/pre-execute or tools\/execute/)
  68. })
  69. it('rejects mutable or anonymous final snapshots', async () => {
  70. const ctx = await setup()
  71. expect(() => { emitResult(ctx, execution(), outcome()) }).toThrow(/execution must be frozen/)
  72. const exec = Object.freeze(execution())
  73. expect(() => { emitResult(ctx, exec, { content: [], isError: false, value: null }) })
  74. .toThrow(/outcome and content must be frozen/)
  75. const anonymous = Object.freeze(execution({ name: '' }))
  76. expect(() => { emitResult(ctx, anonymous, outcome()) }).toThrow(/non-empty name and callId/)
  77. })
  78. it('requires code-dispatch records to be turn-enclosed', async () => {
  79. const ctx = await setup()
  80. const session = ctx.sessions.create()
  81. const data = {
  82. rootCallId: CallId('parent'),
  83. parentCallId: CallId('parent'),
  84. subCallId: CallId('child'),
  85. name: 'echo',
  86. arguments: {},
  87. }
  88. expect(() => session.append('tool/code-dispatch-start', data)).toThrow(/outside any open turn/)
  89. session.append('turn/start', { turn: 1 })
  90. expect(() => session.append('tool/code-dispatch-start', data)).not.toThrow()
  91. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  92. })
  93. it('does not commit a rejected dispatch edge into the root index', async () => {
  94. const ctx = await setup()
  95. const session = ctx.sessions.create()
  96. expect(() => session.append('tool/code-dispatch-start', {
  97. rootCallId: CallId('rejected-root'),
  98. parentCallId: CallId('rejected-root'),
  99. subCallId: CallId('reused-child'),
  100. name: 'echo',
  101. arguments: {},
  102. })).toThrow(/outside any open turn/)
  103. session.append('turn/start', { turn: 1 })
  104. expect(() => session.append('tool/code-dispatch-start', {
  105. rootCallId: CallId('accepted-root'),
  106. parentCallId: CallId('accepted-root'),
  107. subCallId: CallId('reused-child'),
  108. name: 'echo',
  109. arguments: {},
  110. })).not.toThrow()
  111. })
  112. it('rejects a nested code dispatch that changes its parent chain root before append', async () => {
  113. const ctx = await setup()
  114. const session = ctx.sessions.create()
  115. session.append('turn/start', { turn: 1 })
  116. session.append('tool/code-dispatch-start', {
  117. rootCallId: CallId('root'),
  118. parentCallId: CallId('root'),
  119. subCallId: CallId('child'),
  120. name: 'run_code',
  121. arguments: {},
  122. })
  123. session.append('tool/code-dispatch-start', {
  124. rootCallId: CallId('root'),
  125. parentCallId: CallId('child'),
  126. subCallId: CallId('grandchild'),
  127. name: 'echo',
  128. arguments: {},
  129. })
  130. expect(() => session.append('tool/code-dispatch-start', {
  131. rootCallId: CallId('another-root'),
  132. parentCallId: CallId('child'),
  133. subCallId: CallId('invalid-grandchild'),
  134. name: 'echo',
  135. arguments: {},
  136. })).toThrow(/parentCallId child does not belong to rootCallId another-root/)
  137. expect(session.events.some(event => event.type === 'tool/code-dispatch-start'
  138. && String(event.data.subCallId) === 'invalid-grandchild')).toBe(false)
  139. })
  140. it('requires non-empty dispatch identities and keeps one subcall on one root', async () => {
  141. const ctx = await setup()
  142. const session = ctx.sessions.create()
  143. session.append('turn/start', { turn: 1 })
  144. expect(() => session.append('tool/code-dispatch-start', {
  145. rootCallId: CallId(''),
  146. parentCallId: CallId('root'),
  147. subCallId: CallId('child'),
  148. name: 'echo',
  149. arguments: {},
  150. })).toThrow(/must carry non-empty rootCallId/)
  151. session.append('tool/code-dispatch-start', {
  152. rootCallId: CallId('root'),
  153. parentCallId: CallId('root'),
  154. subCallId: CallId('child'),
  155. name: 'echo',
  156. arguments: {},
  157. })
  158. expect(() => session.append('tool/code-dispatch-start', {
  159. rootCallId: CallId('other-root'),
  160. parentCallId: CallId('other-root'),
  161. subCallId: CallId('child'),
  162. name: 'echo',
  163. arguments: {},
  164. })).toThrow(/changed rootCallId for subCallId child/)
  165. })
  166. it('indexes dispatch records emitted for a bare session', async () => {
  167. const ctx = await setup()
  168. const session = Session.create(SessionId('bare-dispatch-session'))
  169. session.append('turn/start', { turn: 1 })
  170. expect(() => {
  171. ctx.emit('session/event', session as never, {
  172. type: 'tool/code-dispatch-start',
  173. seq: 1,
  174. time: 1,
  175. data: {
  176. rootCallId: CallId('root'),
  177. parentCallId: CallId('root'),
  178. subCallId: CallId('child'),
  179. name: 'echo',
  180. arguments: {},
  181. },
  182. } as never)
  183. }).not.toThrow()
  184. })
  185. it('replays enclosed code-dispatch records on late registration', async () => {
  186. const ctx = new Context()
  187. await ctx.plugin(SessionStore)
  188. const session = ctx.sessions.create()
  189. session.append('turn/start', { turn: 1 })
  190. session.append('tool/code-dispatch', {
  191. rootCallId: CallId('parent'),
  192. parentCallId: CallId('parent'),
  193. subCallId: CallId('child'),
  194. name: 'echo',
  195. arguments: {},
  196. isError: false,
  197. content: [{ type: 'text', text: 'ok' }],
  198. })
  199. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  200. await ctx.plugin(InvariantRegistry)
  201. await expect(ctx.plugin(ToolsInvariant).then(() => undefined)).resolves.toBeUndefined()
  202. })
  203. it('rejects an unenclosed code-dispatch record on late registration', async () => {
  204. const ctx = new Context()
  205. await ctx.plugin(SessionStore)
  206. ctx.sessions.create().append('tool/code-dispatch-start', {
  207. rootCallId: CallId('parent'),
  208. parentCallId: CallId('parent'),
  209. subCallId: CallId('child'),
  210. name: 'echo',
  211. arguments: {},
  212. })
  213. await ctx.plugin(InvariantRegistry)
  214. await expect(ctx.plugin(ToolsInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/)
  215. })
  216. })