loader-composition.e2e.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. /**
  2. * Keyless REAL-composition coverage for dynamic child routing and parent cwd
  3. * inheritance across the SDK wire. A test-only patch boots through the
  4. * Loader, a scripted model selects provider/model/reasoning, tool config adds
  5. * maxTokens, and a COMPLETE second harness runtime echoes the effective route
  6. * and cwd. The same path also verifies model-visible child-failure diagnostics
  7. * remain separate from partial output.
  8. */
  9. import { existsSync, realpathSync } from 'node:fs'
  10. import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
  11. import { tmpdir } from 'node:os'
  12. import { join } from 'node:path'
  13. import { fileURLToPath, pathToFileURL } from 'node:url'
  14. import { describe, expect, it } from 'vitest'
  15. import { type SessionEvent } from '@deepseek-ai/dsh-session'
  16. import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
  17. const fixtureDir = new URL('./fixtures/loader/', import.meta.url)
  18. const driver = fileURLToPath(new URL('driver.ts', fixtureDir))
  19. const configPath = fileURLToPath(new URL('dsh-sdk.patch.yml', fixtureDir))
  20. const childConfigPath = fileURLToPath(new URL('child.patch.yml', fixtureDir))
  21. const childMockPath = fileURLToPath(new URL('child-mock-llm.ts', fixtureDir))
  22. const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
  23. async function jsonlFiles(dir: string): Promise<string[]> {
  24. const entries = await readdir(dir, { withFileTypes: true })
  25. const paths = await Promise.all(entries.map(async (entry) => {
  26. const path = join(dir, entry.name)
  27. if (entry.isDirectory()) return jsonlFiles(path)
  28. return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : []
  29. }))
  30. return paths.flat()
  31. }
  32. async function sessionEvents(log: string): Promise<SessionEvent[]> {
  33. const lines = (await readFile(log, 'utf8')).trimEnd().split('\n')
  34. return lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
  35. }
  36. function toolResultText(events: SessionEvent[]): string {
  37. const results = events.filter(event => event.type === 'tool/result')
  38. expect(results).toHaveLength(1)
  39. return results[0]!.data.message.content[0].content
  40. .filter(block => block.type === 'text')
  41. .map(block => block.text)
  42. .join('')
  43. }
  44. async function childLaunch(failure = false): Promise<{
  45. childHome: string
  46. env: Record<string, string>
  47. }> {
  48. const childHome = await mkdtemp(join(tmpdir(), 'dsh-sdk-subagent-home-'))
  49. const childPatch = join(childHome, 'child.patch.yml')
  50. await writeFile(childPatch, (await readFile(childConfigPath, 'utf8'))
  51. .replace("'./child-mock-llm.ts'", JSON.stringify(pathToFileURL(childMockPath).href)))
  52. return {
  53. childHome,
  54. env: {
  55. DSH_TEST_CHILD_PATCHES: JSON.stringify([childPatch]),
  56. DSH_TEST_CHILD_HOME: childHome,
  57. ...(failure ? { DSH_TEST_CHILD_FAILURE: '1' } : {}),
  58. },
  59. }
  60. }
  61. describe('SDK subagent routing and diagnostics through the production profile', () => {
  62. it('runs the selected child route in the parent session workspace', async () => {
  63. const child = await childLaunch()
  64. let events: SessionEvent[] = []
  65. let childEvents: SessionEvent[] = []
  66. let parentResolvedRoutes: string[] = []
  67. let workspace = ''
  68. try {
  69. const { stderr } = await runLoaderSmoke({
  70. label: 'dsh-sdk-subagent cwd composition smoke',
  71. tempDirPrefix: 'dsh-sdk-subagent-cwd-e2e-',
  72. binScript: driver,
  73. libBinScript: driver,
  74. configPath,
  75. tsconfigPath: repoTsconfig,
  76. // Two complete harness runtimes boot in sequence (driver, then the SDK
  77. // child); from-source tsx boots under load need more than the default
  78. // 30s window.
  79. processTimeoutMs: 120_000,
  80. env: {
  81. ...child.env,
  82. DSH_TEST_CHILD_DEFAULT_ROUTE: '1',
  83. DSH_TEST_PARENT_MODEL_RECORD: '.parent-model-routes',
  84. },
  85. inspect: async (cwd) => {
  86. // The child reports realpaths; canonicalize the temp workspace to match.
  87. workspace = realpathSync(cwd)
  88. const parentLogs = await jsonlFiles(join(cwd, '.sessions'))
  89. expect(parentLogs).toHaveLength(1)
  90. events = await sessionEvents(parentLogs[0] as string)
  91. // The child runtime persists under its explicit isolated home.
  92. const childSessions = join(child.childHome, 'sessions')
  93. if (!existsSync(childSessions)) {
  94. const result = events.find(event => event.type === 'tool/result')
  95. throw new Error(`SDK child persisted no session; parent tool result: ${JSON.stringify(result?.data)}`)
  96. }
  97. const childLogs = await jsonlFiles(childSessions)
  98. expect(childLogs).toHaveLength(1)
  99. childEvents = await sessionEvents(childLogs[0] as string)
  100. parentResolvedRoutes = (await readFile(join(cwd, '.parent-model-routes'), 'utf8')).trim().split('\n')
  101. },
  102. })
  103. expect(stderr).not.toContain('UNHANDLED')
  104. // The parent's tool result carries the child model's echo of its real
  105. // process.cwd() — the parent session's workspace, never the harness
  106. // process's launch directory.
  107. const results = events.filter(event => event.type === 'tool/result')
  108. expect(results).toHaveLength(1)
  109. const resultText = results[0]!.data.message.content[0].content
  110. .filter(block => block.type === 'text')
  111. .map(block => block.text)
  112. .join('')
  113. expect(resultText).toBe(`child route: mock/mock-routed/max/777; cwd: ${workspace}`)
  114. expect(parentResolvedRoutes).toContain('mock/mock-routed')
  115. // The child ran a real turn with the model-selected route and tool-configured cap.
  116. expect(childEvents.some(event => event.type === 'user/message')).toBe(true)
  117. const childHeader = childEvents.find(
  118. (event): event is Extract<SessionEvent, { type: 'request/header' }> => event.type === 'request/header',
  119. )
  120. expect(childHeader?.data.header.config).toEqual({
  121. provider: 'mock',
  122. model: 'mock-routed',
  123. reasoningEffort: 'max',
  124. maxTokens: 777,
  125. })
  126. const childAnswers = childEvents.filter(event => event.type === 'assistant/message')
  127. expect(childAnswers.length).toBeGreaterThan(0)
  128. } finally {
  129. await rm(child.childHome, { recursive: true, force: true })
  130. }
  131. // 15s of vitest headroom past the subprocess deadline, mirroring
  132. // LOADER_SMOKE_TEST_TIMEOUT_MS's margin over the default window.
  133. }, 135_000)
  134. it('presents the child error diagnostic separately from partial output', async () => {
  135. const child = await childLaunch(true)
  136. let events: SessionEvent[] = []
  137. try {
  138. const { stderr } = await runLoaderSmoke({
  139. label: 'dsh-sdk-subagent diagnostic composition smoke',
  140. tempDirPrefix: 'dsh-sdk-subagent-diagnostic-e2e-',
  141. binScript: driver,
  142. libBinScript: driver,
  143. configPath,
  144. tsconfigPath: repoTsconfig,
  145. processTimeoutMs: 120_000,
  146. env: child.env,
  147. inspect: async (cwd) => {
  148. const parentLogs = await jsonlFiles(join(cwd, '.sessions'))
  149. expect(parentLogs).toHaveLength(1)
  150. events = await sessionEvents(parentLogs[0] as string)
  151. },
  152. })
  153. expect(stderr).not.toContain('UNHANDLED')
  154. expect(toolResultText(events)).toBe(
  155. 'Error: subagent run failed\n'
  156. + 'Diagnostic: Subagent failure (provider: DSH SDK; stage: session-run; category: child-error)\n'
  157. + 'Partial output before the run ended:\npartial child loader answer',
  158. )
  159. } finally {
  160. await rm(child.childHome, { recursive: true, force: true })
  161. }
  162. }, 135_000)
  163. })