bash-env.spec.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. import { homedir } from 'node:os'
  2. import { join, resolve } from 'node:path'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import { Context } from 'cordis'
  5. import { CallId } from '@deepseek-ai/dsh-llm'
  6. import type { Agent } from '@deepseek-ai/dsh-agent'
  7. import type { ToolExecution } from '@deepseek-ai/dsh-tools'
  8. import { BashEnvRegistry } from '@deepseek-ai/dsh-tool-bash'
  9. const testToolSignal = new AbortController().signal
  10. afterEach(() => vi.unstubAllEnvs())
  11. function execution(sessionId?: string): ToolExecution {
  12. return {
  13. signal: testToolSignal,
  14. token: Symbol('bash-env-test') as ToolExecution['token'],
  15. callId: CallId('bash-env-call'),
  16. name: 'bash',
  17. arguments: { command: 'true' },
  18. ...(sessionId === undefined
  19. ? {}
  20. : { agent: { session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as Agent }),
  21. }
  22. }
  23. describe('BashEnvRegistry', () => {
  24. it('collects unconditional shell facts and the current agent session id', () => {
  25. const ctx = new Context()
  26. const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
  27. expect(registry.collect(execution())).toEqual({
  28. DSH_HOME: resolve('./test-dsh-home'),
  29. DSH_SHELL: '1',
  30. })
  31. expect(registry.collect(execution('session-a'))).toEqual({
  32. DSH_HOME: resolve('./test-dsh-home'),
  33. DSH_SESSION_ID: 'session-a',
  34. DSH_SHELL: '1',
  35. })
  36. })
  37. it('resolves DSH_HOME from the ambient override or the user-home default', () => {
  38. vi.stubEnv('DSH_HOME', './ambient-dsh-home')
  39. const fromEnvironment = new BashEnvRegistry(new Context())
  40. expect(fromEnvironment.collect(execution()).DSH_HOME).toBe(resolve('./ambient-dsh-home'))
  41. vi.stubEnv('DSH_HOME', undefined)
  42. const fromDefault = new BashEnvRegistry(new Context())
  43. expect(fromDefault.collect(execution()).DSH_HOME).toBe(join(homedir(), '.dsh'))
  44. })
  45. it('collects declared contributor variables and omits unavailable values', () => {
  46. const ctx = new Context()
  47. const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
  48. registry.register({
  49. name: 'optional-session-fact',
  50. variables: {
  51. DSH_SESSION_OPTIONAL: { description: 'Optional session-scoped test fact.' },
  52. },
  53. resolve: exec => exec.agent === undefined ? {} : { DSH_SESSION_OPTIONAL: exec.agent.session.header.id },
  54. })
  55. registry.register({
  56. name: 'always-available-fact',
  57. variables: {
  58. DSH_ALWAYS_AVAILABLE: { description: 'Always-available test fact.' },
  59. },
  60. resolve: () => ({ DSH_ALWAYS_AVAILABLE: 'yes' }),
  61. })
  62. expect(registry.collect(execution())).not.toHaveProperty('DSH_SESSION_OPTIONAL')
  63. expect(registry.collect(execution()).DSH_ALWAYS_AVAILABLE).toBe('yes')
  64. expect(registry.collect(execution('session-b')).DSH_SESSION_OPTIONAL).toBe('session-b')
  65. expect(registry.list()).toEqual([
  66. {
  67. contributor: 'always-available-fact',
  68. description: 'Always-available test fact.',
  69. key: 'DSH_ALWAYS_AVAILABLE',
  70. },
  71. {
  72. contributor: 'optional-session-fact',
  73. description: 'Optional session-scoped test fact.',
  74. key: 'DSH_SESSION_OPTIONAL',
  75. },
  76. ])
  77. })
  78. it('rejects duplicate variable ownership at registration time', () => {
  79. const ctx = new Context()
  80. const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
  81. registry.register({
  82. name: 'first',
  83. variables: { DSH_SHARED: { description: 'First owner.' } },
  84. resolve: () => ({ DSH_SHARED: 'first' }),
  85. })
  86. expect(() => registry.register({
  87. name: 'second',
  88. variables: { DSH_SHARED: { description: 'Second owner.' } },
  89. resolve: () => ({ DSH_SHARED: 'second' }),
  90. })).toThrow(/DSH_SHARED.*first.*second|DSH_SHARED.*second.*first/)
  91. })
  92. it('rejects duplicate contributor names and malformed declarations', () => {
  93. const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' })
  94. registry.register({
  95. name: 'declared',
  96. variables: { DSH_DECLARED: { description: 'Declared fact.' } },
  97. resolve: () => ({}),
  98. })
  99. expect(() => registry.register({
  100. name: 'declared',
  101. variables: { DSH_ANOTHER: { description: 'Another fact.' } },
  102. resolve: () => ({}),
  103. })).toThrow(/already registered/)
  104. expect(() => registry.register({
  105. name: ' ',
  106. variables: { DSH_BLANK_NAME: { description: 'Blank owner.' } },
  107. resolve: () => ({}),
  108. })).toThrow(/name must be non-empty/)
  109. expect(() => registry.register({
  110. name: 'invalid-key',
  111. variables: { dsh_invalid: { description: 'Invalid key.' } } as unknown as Record<'DSH_INVALID', { description: string }>,
  112. resolve: () => ({}),
  113. })).toThrow(/invalid key/)
  114. expect(() => registry.register({
  115. name: 'reserved-key',
  116. variables: { DSH_HOME: { description: 'Reserved key.' } },
  117. resolve: () => ({}),
  118. })).toThrow(/reserved key/)
  119. expect(() => registry.register({
  120. name: 'blank-description',
  121. variables: { DSH_BLANK_DESCRIPTION: { description: ' ' } },
  122. resolve: () => ({}),
  123. })).toThrow(/must describe/)
  124. })
  125. it('rejects undeclared variables returned by a contributor', () => {
  126. const ctx = new Context()
  127. const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
  128. registry.register({
  129. name: 'drifted-provider',
  130. variables: { DSH_DECLARED: { description: 'Declared fact.' } },
  131. resolve: () => ({ DSH_UNDECLARED: 'bad' }),
  132. })
  133. expect(() => registry.collect(execution())).toThrow(/drifted-provider.*DSH_UNDECLARED/)
  134. })
  135. it('rejects non-string values returned by a contributor', () => {
  136. const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' })
  137. registry.register({
  138. name: 'wrong-value-type',
  139. variables: { DSH_STRING: { description: 'String fact.' } },
  140. resolve: () => ({ DSH_STRING: 42 }) as unknown as Record<'DSH_STRING', string>,
  141. })
  142. expect(() => registry.collect(execution())).toThrow(/wrong-value-type.*non-string.*DSH_STRING/)
  143. })
  144. it('removes an effect-scoped contributor when its plugin is disposed', async () => {
  145. const ctx = new Context()
  146. const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
  147. const fiber = await ctx.plugin({
  148. inject: ['bashEnv'],
  149. apply(inner: Context) {
  150. inner.bashEnv.register({
  151. name: 'temporary',
  152. variables: { DSH_TEMPORARY: { description: 'Temporary fact.' } },
  153. resolve: () => ({ DSH_TEMPORARY: 'present' }),
  154. })
  155. },
  156. })
  157. expect(registry.collect(execution()).DSH_TEMPORARY).toBe('present')
  158. await fiber.dispose()
  159. expect(registry.collect(execution())).not.toHaveProperty('DSH_TEMPORARY')
  160. })
  161. it('returns an explicit contributor disposer', () => {
  162. const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' })
  163. const dispose = registry.register({
  164. name: 'explicit-disposal',
  165. variables: { DSH_EXPLICIT_DISPOSAL: { description: 'Explicitly disposed fact.' } },
  166. resolve: () => ({ DSH_EXPLICIT_DISPOSAL: 'present' }),
  167. })
  168. expect(registry.collect(execution()).DSH_EXPLICIT_DISPOSAL).toBe('present')
  169. dispose()
  170. expect(registry.collect(execution())).not.toHaveProperty('DSH_EXPLICIT_DISPOSAL')
  171. })
  172. })