shell-env.spec.ts 7.9 KB

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