loader-composition.spec.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. import { mkdtemp, rm, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { pathToFileURL } from 'node:url'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import { Context } from '@deepseek-ai/cordis'
  7. import Loader from '@deepseek-ai/cordis-plugin-loader'
  8. import Include from '@deepseek-ai/cordis-plugin-include'
  9. import { CallId } from '@deepseek-ai/dsh-llm'
  10. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  11. import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent'
  12. import type { Agent } from '@deepseek-ai/dsh-agent'
  13. import TerminalSessionService from '@deepseek-ai/dsh-terminal'
  14. import * as TerminalLocal from '@deepseek-ai/dsh-terminal-bash'
  15. import SandboxProvider from '@deepseek-ai/dsh-sandbox'
  16. import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
  17. import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
  18. import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
  19. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  20. import ToolRuntime from '@deepseek-ai/dsh-tools'
  21. import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent'
  22. let root: string | undefined
  23. let context: Context | undefined
  24. afterEach(async () => {
  25. await context?.fiber.dispose()
  26. context = undefined
  27. if (root !== undefined) await rm(root, { recursive: true, force: true })
  28. root = undefined
  29. })
  30. class PassthroughSandbox extends SandboxProvider {
  31. confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
  32. return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
  33. }
  34. }
  35. function agent(ctx: Context, cwd: string): Agent {
  36. const id = SessionId('persistent-bash-loader-agent')
  37. const scope = ctx.plugin(() => {})
  38. const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd })
  39. const value: Agent = {
  40. id,
  41. options: {},
  42. session,
  43. inbox: undefined as never,
  44. status: 'idle',
  45. ctx: scope.ctx,
  46. send: () => {},
  47. followup: () => {},
  48. steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
  49. inject: () => {},
  50. cancel() {},
  51. runMaintenance: task => task(new AbortController().signal),
  52. whenIdle: () => Promise.resolve(),
  53. }
  54. Object.assign(value, { inbox: new Inbox(value.ctx, value.session, agentEvents(value.ctx, value)) })
  55. ctx.agents.register(value)
  56. return value
  57. }
  58. function text(result: { content: { type: string; text?: string }[] }): string {
  59. return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
  60. }
  61. const suite = process.platform === 'linux' || process.platform === 'darwin' ? describe : describe.skip
  62. suite('persistent Bash through a real cordis.yml Loader composition', () => {
  63. it('preserves cwd and environment across calls', async () => {
  64. root = await mkdtemp(join(tmpdir(), 'dsh-persistent-bash-loader-'))
  65. const configPath = join(root, 'cordis.yml')
  66. await writeFile(configPath, [
  67. "- name: '@deepseek-ai/dsh-agent'",
  68. "- name: '@deepseek-ai/dsh-system-prompt'",
  69. "- name: '@deepseek-ai/dsh-tools'",
  70. "- name: '@deepseek-ai/dsh-terminal'",
  71. "- name: '@deepseek-ai/dsh-test-sandbox'",
  72. "- name: '@deepseek-ai/dsh-sandbox-policy'",
  73. ' config:',
  74. ' mode: danger-full-access',
  75. ` workspaceRoot: ${JSON.stringify(root)}`,
  76. "- name: '@deepseek-ai/dsh-subprocess-local'",
  77. "- name: '@deepseek-ai/dsh-terminal-bash'",
  78. ' config:',
  79. ' pollIntervalMs: 10',
  80. ' exactProbeAfterMs: 20',
  81. // The silence tier is pushed beyond the send bound, so no send below can
  82. // settle as inferred_idle: every case proves the controlled-prompt fast
  83. // path that the production defaults (3.5s silence) would otherwise mask.
  84. ' idleSilenceMs: 30000',
  85. ' handoffGraceMs: 100',
  86. ' scrollbackLines: 20000',
  87. ' timeoutMs: 2000',
  88. ' disposeGraceMs: 500',
  89. "- name: '@deepseek-ai/dsh-tool-bash-persistent'",
  90. ' config:',
  91. ' timeoutMs: 5000',
  92. '',
  93. ].join('\n'))
  94. context = new Context()
  95. context.baseUrl = pathToFileURL(root).href + '/'
  96. await context.plugin(Loader)
  97. context.loader.builtins.include = Include
  98. const modules = new Map<string, unknown>([
  99. ['@deepseek-ai/dsh-agent', AgentRegistry],
  100. ['@deepseek-ai/dsh-system-prompt', SystemPrompt],
  101. ['@deepseek-ai/dsh-tools', ToolRuntime],
  102. ['@deepseek-ai/dsh-terminal', TerminalSessionService],
  103. ['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox],
  104. ['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService],
  105. ['@deepseek-ai/dsh-subprocess-local', LocalSubprocessRuntime],
  106. ['@deepseek-ai/dsh-terminal-bash', TerminalLocal],
  107. ['@deepseek-ai/dsh-tool-bash-persistent', ToolBashPersistent],
  108. ])
  109. context.loader.internal = {
  110. version: 'v2',
  111. async import(specifier: string) {
  112. if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
  113. return modules.get(specifier)
  114. },
  115. } as unknown as NonNullable<typeof context.loader.internal>
  116. await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
  117. await context.loader.await()
  118. const owner = agent(context, root)
  119. const signal = new AbortController().signal
  120. const execute = (id: string, command: string) => context!.tools.execute({
  121. signal,
  122. callId: CallId(id),
  123. name: 'bash',
  124. arguments: { command },
  125. agent: owner,
  126. })
  127. expect(context.tools.schemas().map(schema => schema.name)).toEqual(['bash'])
  128. await execute('state', 'export KEEP=loader; mkdir -p nested; cd nested')
  129. const observed = text(await execute('observe', 'printf "cwd=%s keep=%s\\n" "$PWD" "$KEEP"'))
  130. expect(observed).toContain(`cwd=${join(root, 'nested')} keep=loader`)
  131. expect(observed).not.toContain('DSH_PERSISTENT_BASH')
  132. const multiline = text(await execute(
  133. 'multiline',
  134. 'value="line one"\nprintf "%s:%s\\n" "$value" "it\'s fine"',
  135. ))
  136. expect(multiline).toBe("line one:it's fine")
  137. expect(multiline).not.toContain('DSH_PERSISTENT_BASH')
  138. const heredoc = text(await execute(
  139. 'heredoc',
  140. "cat <<'EOF'\nalpha\nbeta\nEOF",
  141. ))
  142. expect(heredoc).toBe('alpha\nbeta')
  143. const large = text(await execute('large-output', 'seq 1 12050'))
  144. expect(large.startsWith('1\n2\n3\n')).toBe(true)
  145. expect(large).toContain('<response clipped>')
  146. expect(large).not.toContain('beginning of this command output was dropped')
  147. // `exec` replaces the wrapper before its end marker prints; the seam's
  148. // stdin_read readiness is what returns the replacement shell's prompt
  149. // instead of spinning until the tool deadline.
  150. const execed = text(await execute('exec-replacement', 'exec bash --noprofile --norc -i'))
  151. expect(execed).toBe('dsh> ')
  152. const exited = text(await execute('exit', 'exit'))
  153. expect(exited).toContain('next bash call starts from the workspace')
  154. expect(text(await execute('after-exit', 'printf "%s\\n" "$PWD"'))).toBe(root)
  155. }, 20_000)
  156. })