bash-env.spec.ts 8.9 KB

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