invariant.spec.ts 3.1 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
  4. import * as HookInvariant from '@deepseek-ai/dsh-hook-protocol/invariant'
  5. import InvariantService from '@deepseek-ai/dsh-invariants'
  6. async function setup(): Promise<Context> {
  7. const ctx = new Context()
  8. await ctx.plugin(SessionStore)
  9. await ctx.plugin(InvariantService)
  10. await ctx.plugin(HookInvariant)
  11. return ctx
  12. }
  13. const invoked = (overrides: Record<string, unknown> = {}) => ({
  14. turn: 1,
  15. point: 'PreToolUse',
  16. dialect: 'claude' as const,
  17. handlerId: 'hook-1',
  18. ...overrides,
  19. })
  20. const result = (overrides: Record<string, unknown> = {}) => ({
  21. turn: 1,
  22. point: 'PreToolUse',
  23. handlerId: 'hook-1',
  24. decision: 'pass',
  25. durationMs: 3,
  26. ...overrides,
  27. })
  28. describe('hook-protocol invariants', () => {
  29. it('pairs serial and repeated handler invocations', async () => {
  30. const ctx = await setup()
  31. const session = ctx.sessions.create()
  32. session.append('hook/invoked', invoked())
  33. session.append('hook/invoked', invoked())
  34. session.append('hook/result', result())
  35. session.append('hook/result', result())
  36. })
  37. it('rebuilds pending hook provenance from an existing session', async () => {
  38. const ctx = new Context()
  39. await ctx.plugin(SessionStore)
  40. const session = ctx.sessions.create()
  41. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  42. session.append('hook/invoked', invoked())
  43. await ctx.plugin(InvariantService)
  44. await ctx.plugin(HookInvariant)
  45. expect(() => session.append('hook/result', result())).not.toThrow()
  46. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  47. })
  48. it('adopts a bare session first observed through publication', async () => {
  49. const ctx = await setup()
  50. const session = new Session(SessionId('bare-hook-session'))
  51. expect(() => {
  52. ctx.emit('session/event', session, {
  53. type: 'hook/invoked', seq: 0, time: 0, data: invoked(),
  54. })
  55. ctx.emit('session/event', session, {
  56. type: 'hook/result', seq: 1, time: 1, data: result(),
  57. })
  58. }).not.toThrow()
  59. })
  60. it.each([
  61. [invoked({ point: '' }), /point and handlerId must be non-empty/],
  62. [invoked({ handlerId: '' }), /point and handlerId must be non-empty/],
  63. [invoked({ dialect: 'other' }), /unknown dialect/],
  64. ])('rejects malformed hook invocation %#', async (data, message) => {
  65. const ctx = await setup()
  66. expect(() => ctx.sessions.create().append('hook/invoked', data as never)).toThrow(message)
  67. })
  68. it('rejects unmatched and malformed results', async () => {
  69. const ctx = await setup()
  70. const session = ctx.sessions.create()
  71. expect(() => session.append('hook/result', result())).toThrow(/no matching hook\/invoked/)
  72. session.append('hook/invoked', invoked())
  73. expect(() => session.append('hook/result', result({ durationMs: -1 })))
  74. .toThrow(/durationMs must be a non-negative finite number/)
  75. expect(() => session.append('hook/result', result({ point: 'Stop' })))
  76. .toThrow(/no matching hook\/invoked/)
  77. })
  78. })