loader-composition.spec.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  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 { ToolCallId } from '@deepseek-ai/dsh-llm'
  10. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  11. import AgentRegistry from '@deepseek-ai/dsh-agent'
  12. import type { Agent } from '@deepseek-ai/dsh-agent'
  13. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  14. import ToolRuntime from '@deepseek-ai/dsh-tools'
  15. import TerminalSessionService from '@deepseek-ai/dsh-terminal'
  16. import SandboxProvider from '@deepseek-ai/dsh-sandbox'
  17. import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
  18. import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
  19. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  20. import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
  21. import * as TerminalLocal from '@deepseek-ai/dsh-terminal-bash'
  22. import * as ToolPty from '@deepseek-ai/dsh-tool-terminal'
  23. import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
  24. let root: string | undefined
  25. let context: Context | undefined
  26. afterEach(async () => {
  27. await context?.fiber.dispose()
  28. context = undefined
  29. if (root !== undefined) await rm(root, { recursive: true, force: true })
  30. root = undefined
  31. })
  32. class PassthroughSandbox extends SandboxProvider {
  33. async confine(argv: readonly string[], _policy: SandboxPolicy): Promise<ConfinedArgv> {
  34. return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
  35. }
  36. }
  37. async function agent(ctx: Context): Promise<Agent> {
  38. const scope = ctx.plugin(() => {})
  39. const id = SessionId('pty-loader-agent')
  40. const session = Session.create(id)
  41. const value: Agent = {
  42. id, options: {}, session, inbox: unsupportedInbox(),
  43. status: 'idle',
  44. ctx: scope.ctx,
  45. send: () => {},
  46. followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
  47. runMaintenance: job => job(new AbortController().signal),
  48. whenIdle: () => Promise.resolve(),
  49. }
  50. await ctx.agents.register(value)
  51. return value
  52. }
  53. function resultText(result: { content: { type: string; text?: string }[] }): string {
  54. return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
  55. }
  56. const suite = process.platform === 'linux' || process.platform === 'darwin' ? describe : describe.skip
  57. suite('terminal real Loader composition through cordis.yml', () => {
  58. it('boots cordis.yml and preserves shell state across real tool calls', async () => {
  59. root = await mkdtemp(join(tmpdir(), 'dsh-pty-loader-'))
  60. const configPath = join(root, 'cordis.yml')
  61. await writeFile(configPath, [
  62. "- name: '@deepseek-ai/dsh-agent'",
  63. "- name: '@deepseek-ai/dsh-system-prompt'",
  64. "- name: '@deepseek-ai/dsh-tools'",
  65. "- name: '@deepseek-ai/dsh-terminal'",
  66. "- name: '@deepseek-ai/dsh-test-sandbox'",
  67. "- name: '@deepseek-ai/dsh-session-projection'",
  68. "- name: '@deepseek-ai/dsh-sandbox-policy'",
  69. ' config:',
  70. ' mode: danger-full-access',
  71. ` workspaceRoot: ${JSON.stringify(root)}`,
  72. "- name: '@deepseek-ai/dsh-subprocess-local'",
  73. "- name: '@deepseek-ai/dsh-terminal-bash'",
  74. ' config:',
  75. ' pollIntervalMs: 10',
  76. ' exactProbeAfterMs: 20',
  77. ' idleSilenceMs: 250',
  78. ' handoffGraceMs: 250',
  79. ' timeoutMs: 2000',
  80. ' disposeGraceMs: 500',
  81. "- name: '@deepseek-ai/dsh-tool-terminal'",
  82. '',
  83. ].join('\n'))
  84. context = new Context()
  85. context.baseUrl = pathToFileURL(root).href + '/'
  86. await context.plugin(Loader)
  87. context.loader.builtins.include = Include
  88. const modules = new Map<string, unknown>([
  89. ['@deepseek-ai/dsh-agent', AgentRegistry],
  90. ['@deepseek-ai/dsh-system-prompt', SystemPrompt],
  91. ['@deepseek-ai/dsh-tools', ToolRuntime],
  92. ['@deepseek-ai/dsh-terminal', TerminalSessionService],
  93. ['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox],
  94. ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry],
  95. ['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService],
  96. ['@deepseek-ai/dsh-subprocess-local', LocalSubprocessRuntime],
  97. ['@deepseek-ai/dsh-terminal-bash', TerminalLocal],
  98. ['@deepseek-ai/dsh-tool-terminal', ToolPty],
  99. ])
  100. context.loader.internal = {
  101. version: 'v2',
  102. async import(specifier: string) {
  103. if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
  104. return modules.get(specifier)
  105. },
  106. } as unknown as NonNullable<typeof context.loader.internal>
  107. await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
  108. await context.loader.await()
  109. const owner = await agent(context)
  110. const signal = new AbortController().signal
  111. const spawn = await context.tools.execute({
  112. signal, callId: ToolCallId('spawn'), name: 'terminal_open', arguments: { type: 'shell', name: 'main', cwd: root }, agent: owner,
  113. })
  114. expect(resultText(spawn)).toContain('started terminal session pty-1 (main)')
  115. await context.tools.execute({
  116. signal, callId: ToolCallId('state'), name: 'terminal_send', arguments: { sessionId: 'pty-1', text: 'export KEEP=loader; cd /' }, agent: owner,
  117. })
  118. const read = await context.tools.execute({
  119. signal, callId: ToolCallId('read'), name: 'terminal_send', arguments: { sessionId: 'pty-1', text: 'printf "cwd=%s keep=%s\\n" "$PWD" "$KEEP"' }, agent: owner,
  120. })
  121. expect(resultText(read)).toContain('cwd=/ keep=loader')
  122. expect(context.terminals.list(owner)).toHaveLength(1)
  123. }, 15_000)
  124. })