shell-env.spec.ts 9.0 KB

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