loader-composition.spec.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. import { spawnSync } from 'node:child_process'
  2. import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { pathToFileURL } from 'node:url'
  6. import { afterEach, describe, expect, it } from 'vitest'
  7. import { Context } from '@deepseek-ai/cordis'
  8. import Loader from '@deepseek-ai/cordis-plugin-loader'
  9. import Include from '@deepseek-ai/cordis-plugin-include'
  10. import { ToolCallId } from '@deepseek-ai/dsh-llm'
  11. import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
  12. import AgentRegistry from '@deepseek-ai/dsh-agent'
  13. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  14. import type { Agent } from '@deepseek-ai/dsh-agent'
  15. import TerminalSessionService from '@deepseek-ai/dsh-terminal'
  16. import * as TerminalBash from '@deepseek-ai/dsh-terminal-bash'
  17. import SandboxProvider from '@deepseek-ai/dsh-sandbox'
  18. import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
  19. import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
  20. import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
  21. import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local/src/resolve.ts'
  22. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  23. import ToolRegistry from '@deepseek-ai/dsh-tools'
  24. import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent'
  25. import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
  26. const hasPwsh = spawnSync(
  27. resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'],
  28. { encoding: 'utf8' },
  29. ).status === 0
  30. let root: string | undefined
  31. let context: Context | undefined
  32. afterEach(async () => {
  33. await context?.fiber.dispose()
  34. context = undefined
  35. if (root !== undefined) await rm(root, { recursive: true, force: true })
  36. root = undefined
  37. })
  38. class PassthroughSandbox extends SandboxProvider {
  39. confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
  40. return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
  41. }
  42. }
  43. function agent(ctx: Context, cwd: string): Agent {
  44. const id = SessionId('persistent-pwsh-loader-agent')
  45. const scope = ctx.plugin(() => {})
  46. const session = Session.create(id, [], {
  47. version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd, isSeeded: false,
  48. })
  49. const value: Agent = {
  50. id,
  51. options: {},
  52. session,
  53. inbox: unsupportedInbox(),
  54. status: 'idle',
  55. ctx: scope.ctx,
  56. send: () => {},
  57. followup: () => {},
  58. steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
  59. inject: () => {},
  60. cancel() {},
  61. runMaintenance: task => task(new AbortController().signal),
  62. whenIdle: () => Promise.resolve(),
  63. }
  64. ctx.agents.register(value)
  65. return value
  66. }
  67. function text(result: { content: { type: string; text?: string }[] }): string {
  68. return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
  69. }
  70. describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader composition', () => {
  71. it('preserves cwd and environment across calls', async () => {
  72. root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-persistent-pwsh-loader-')))
  73. const configPath = join(root, 'cordis.yml')
  74. await writeFile(configPath, [
  75. "- name: '@deepseek-ai/dsh-agent'",
  76. "- name: '@deepseek-ai/dsh-system-prompt'",
  77. "- name: '@deepseek-ai/dsh-tools'",
  78. "- name: '@deepseek-ai/dsh-terminal'",
  79. "- name: '@deepseek-ai/dsh-test-sandbox'",
  80. "- name: '@deepseek-ai/dsh-session-projection'",
  81. "- name: '@deepseek-ai/dsh-sandbox-policy'",
  82. ' config:',
  83. ' mode: danger-full-access',
  84. ` workspaceRoot: ${JSON.stringify(root)}`,
  85. "- name: '@deepseek-ai/dsh-subprocess-local'",
  86. "- name: '@deepseek-ai/dsh-terminal-bash'",
  87. ' config:',
  88. ' shellDialect: pwsh',
  89. ' pollIntervalMs: 10',
  90. ' exactProbeAfterMs: 20',
  91. ' idleSilenceMs: 300',
  92. ' handoffGraceMs: 300',
  93. ' scrollbackLines: 20000',
  94. // The first call pays the full pwsh cold-start latency (spawn + .NET +
  95. // PSReadLine + Defender) inside the tool deadline; a 60s bound on the
  96. // fully loaded self-hosted Windows pool is exceeded often enough to
  97. // reset the session mid-test (2026-09-01, two runs ~62s each). 300s
  98. // matches the dsh-tool-pwsh-persistent product default; the
  99. // dsh-terminal-bash value bounds one send plus the complete startup
  100. // sequence, so it covers the same cold start (its 30s product default
  101. // would not).
  102. ' timeoutMs: 300000',
  103. ' disposeGraceMs: 500',
  104. "- name: '@deepseek-ai/dsh-tool-pwsh-persistent'",
  105. ' config:',
  106. ' timeoutMs: 300000',
  107. '',
  108. ].join('\n'))
  109. context = new Context()
  110. context.baseUrl = pathToFileURL(root).href + '/'
  111. await context.plugin(Loader)
  112. context.loader.builtins.include = Include
  113. const modules = new Map<string, unknown>([
  114. ['@deepseek-ai/dsh-agent', AgentRegistry],
  115. ['@deepseek-ai/dsh-system-prompt', SystemPrompt],
  116. ['@deepseek-ai/dsh-tools', ToolRegistry],
  117. ['@deepseek-ai/dsh-terminal', TerminalSessionService],
  118. ['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox],
  119. ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry],
  120. ['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService],
  121. ['@deepseek-ai/dsh-subprocess-local', LocalSubprocessService],
  122. ['@deepseek-ai/dsh-terminal-bash', TerminalBash],
  123. ['@deepseek-ai/dsh-tool-pwsh-persistent', ToolPwshPersistent],
  124. ])
  125. context.loader.internal = {
  126. version: 'v2',
  127. async import(specifier: string) {
  128. if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
  129. return modules.get(specifier)
  130. },
  131. } as unknown as NonNullable<typeof context.loader.internal>
  132. await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
  133. await context.loader.await()
  134. const owner = agent(context, root)
  135. const signal = new AbortController().signal
  136. const execute = (id: string, command: string) => context!.tools.execute({
  137. signal,
  138. callId: ToolCallId(id),
  139. name: 'pwsh',
  140. arguments: { command },
  141. agent: owner,
  142. })
  143. expect(context.tools.schemas().map(schema => schema.name)).toEqual(['pwsh'])
  144. await execute('state', '$env:KEEP = "loader"; New-Item -ItemType Directory -Force -Path nested | Out-Null; Set-Location nested')
  145. const observed = text(await execute('observe', 'Write-Output "cwd=$PWD keep=$env:KEEP"'))
  146. expect(observed).toContain(`cwd=${join(root, 'nested')} keep=loader`)
  147. expect(observed).not.toContain('DSH_PERSISTENT_PWSH')
  148. const multiline = text(await execute(
  149. 'multiline',
  150. '$value = "line one"\nWrite-Output "${value}:it\'s fine"',
  151. ))
  152. expect(multiline).toBe("line one:it's fine")
  153. expect(multiline).not.toContain('DSH_PERSISTENT_PWSH')
  154. const hereString = text(await execute(
  155. 'here-string',
  156. "$h = @'\nalpha\nbeta\n'@\nWrite-Output $h",
  157. ))
  158. expect(hereString).toBe('alpha\nbeta')
  159. const large = text(await execute('large-output', '1..12050 | ForEach-Object { $_ }'))
  160. expect(large.startsWith('1\n2\n3\n')).toBe(true)
  161. expect(large).toContain('<response clipped>')
  162. expect(large).not.toContain('beginning of this command output was dropped')
  163. const exited = text(await execute('exit', 'exit'))
  164. expect(exited).toContain('next pwsh call starts from the workspace')
  165. expect(text(await execute('after-exit', 'Write-Output "$PWD"'))).toBe(root)
  166. }, 120_000)
  167. })