bash-env.spec.ts 7.1 KB

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