loader-composition.e2e.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. /**
  2. * Keyless REAL-composition coverage for parent-session cwd inheritance across
  3. * the SDK wire: a test-only cordis.yml boots the headless app through the
  4. * Loader with the SDK backend's `cwd` omitted, a scripted model delegates
  5. * once, and the child — a COMPLETE second harness runtime booted from its own
  6. * cordis.yml and driven over stdio JSON-RPC — echoes where it actually ran.
  7. * Both the parent's tool result and the child's own persisted session log
  8. * must carry the parent session's cwd. Mock-only composition, so only this
  9. * keyless tier applies (the with-key tier lives in subagent-sdk.e2e.ts).
  10. */
  11. import { existsSync, realpathSync } from 'node:fs'
  12. import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
  13. import { tmpdir } from 'node:os'
  14. import { join } from 'node:path'
  15. import { fileURLToPath, pathToFileURL } from 'node:url'
  16. import { describe, expect, it } from 'vitest'
  17. import { type SessionEvent } from '@deepseek-ai/dsh-session'
  18. import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
  19. const fixtureDir = new URL('./fixtures/loader/', import.meta.url)
  20. const driver = fileURLToPath(new URL('driver.ts', fixtureDir))
  21. const configPath = fileURLToPath(new URL('cordis.yml', fixtureDir))
  22. const childConfigPath = fileURLToPath(new URL('child.cordis.yml', fixtureDir))
  23. const childMockPath = fileURLToPath(new URL('child-mock-llm.ts', fixtureDir))
  24. const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
  25. async function jsonlFiles(dir: string): Promise<string[]> {
  26. const entries = await readdir(dir, { withFileTypes: true })
  27. const paths = await Promise.all(entries.map(async (entry) => {
  28. const path = join(dir, entry.name)
  29. if (entry.isDirectory()) return jsonlFiles(path)
  30. return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : []
  31. }))
  32. return paths.flat()
  33. }
  34. async function sessionEvents(log: string): Promise<SessionEvent[]> {
  35. const lines = (await readFile(log, 'utf8')).trimEnd().split('\n')
  36. return lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
  37. }
  38. describe('SDK subagent cwd inheritance through a real cordis.yml', () => {
  39. it('runs the child runtime in the parent session workspace', async () => {
  40. const childHome = await mkdtemp(join(tmpdir(), 'dsh-sdk-subagent-home-'))
  41. const childPatch = join(childHome, 'child.cordis.yml')
  42. await writeFile(childPatch, (await readFile(childConfigPath, 'utf8'))
  43. .replace("'./child-mock-llm.ts'", JSON.stringify(pathToFileURL(childMockPath).href)))
  44. let events: SessionEvent[] = []
  45. let childEvents: SessionEvent[] = []
  46. let workspace = ''
  47. try {
  48. const { stderr } = await runLoaderSmoke({
  49. label: 'dsh-sdk-subagent cwd composition smoke',
  50. tempDirPrefix: 'dsh-sdk-subagent-cwd-e2e-',
  51. binScript: driver,
  52. libBinScript: driver,
  53. configPath,
  54. tsconfigPath: repoTsconfig,
  55. // Two complete harness runtimes boot in sequence (driver, then the SDK
  56. // child); from-source tsx boots under load need more than the default
  57. // 30s window.
  58. processTimeoutMs: 120_000,
  59. env: {
  60. DSH_TEST_CHILD_PATCHES: JSON.stringify([childPatch]),
  61. DSH_TEST_CHILD_HOME: childHome,
  62. },
  63. inspect: async (cwd) => {
  64. // The child reports realpaths; canonicalize the temp workspace to match.
  65. workspace = realpathSync(cwd)
  66. const parentLogs = await jsonlFiles(join(cwd, '.sessions'))
  67. expect(parentLogs).toHaveLength(1)
  68. events = await sessionEvents(parentLogs[0] as string)
  69. // The child runtime persists under its explicit isolated home.
  70. const childSessions = join(childHome, 'sessions')
  71. if (!existsSync(childSessions)) {
  72. const result = events.find(event => event.type === 'tool/result')
  73. throw new Error(`SDK child persisted no session; parent tool result: ${JSON.stringify(result?.data)}`)
  74. }
  75. const childLogs = await jsonlFiles(childSessions)
  76. expect(childLogs).toHaveLength(1)
  77. childEvents = await sessionEvents(childLogs[0] as string)
  78. },
  79. })
  80. expect(stderr).not.toContain('UNHANDLED')
  81. // The parent's tool result carries the child model's echo of its real
  82. // process.cwd() — the parent session's workspace, never the harness
  83. // process's launch directory.
  84. const results = events.filter(event => event.type === 'tool/result')
  85. expect(results).toHaveLength(1)
  86. const resultText = results[0]!.data.message.content[0].content
  87. .filter(block => block.type === 'text')
  88. .map(block => block.text)
  89. .join('')
  90. expect(resultText).toBe(`child cwd: ${workspace}`)
  91. // The child ran a real turn of its own: user message in, assistant out.
  92. expect(childEvents.some(event => event.type === 'user/message')).toBe(true)
  93. const childAnswers = childEvents.filter(event => event.type === 'assistant/message')
  94. expect(childAnswers.length).toBeGreaterThan(0)
  95. } finally {
  96. await rm(childHome, { recursive: true, force: true })
  97. }
  98. // 15s of vitest headroom past the subprocess deadline, mirroring
  99. // LOADER_SMOKE_TEST_TIMEOUT_MS's margin over the default window.
  100. }, 135_000)
  101. })