loader-composition.e2e.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  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 { realpathSync } from 'node:fs'
  12. import { readFile, readdir } from 'node:fs/promises'
  13. import { join } from 'node:path'
  14. import { fileURLToPath } from 'node:url'
  15. import { describe, expect, it } from 'vitest'
  16. import { type SessionEvent } from '@deepseek-ai/dsh-session'
  17. import { resolveExampleLaunch, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
  18. const fixtureDir = new URL('../../../../examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/', import.meta.url)
  19. const driver = fileURLToPath(new URL('driver.ts', fixtureDir))
  20. const configPath = fileURLToPath(new URL('cordis.yml', fixtureDir))
  21. const childConfigPath = fileURLToPath(new URL('child.cordis.yml', fixtureDir))
  22. const runtimeBin = fileURLToPath(new URL('../../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url))
  23. const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
  24. async function jsonlFiles(dir: string): Promise<string[]> {
  25. const entries = await readdir(dir, { withFileTypes: true })
  26. const paths = await Promise.all(entries.map(async (entry) => {
  27. const path = join(dir, entry.name)
  28. if (entry.isDirectory()) return jsonlFiles(path)
  29. return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : []
  30. }))
  31. return paths.flat()
  32. }
  33. async function sessionEvents(log: string): Promise<SessionEvent[]> {
  34. const lines = (await readFile(log, 'utf8')).trimEnd().split('\n')
  35. return lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
  36. }
  37. describe('SDK subagent cwd inheritance through a real cordis.yml', () => {
  38. it('runs the child runtime in the parent session workspace', async () => {
  39. // The child launch honors the same src/lib mode as the driving harness,
  40. // per the shared example-launch resolver (testing policy forbids
  41. // hand-written `--import tsx` argv for example subprocesses).
  42. const childLaunch = resolveExampleLaunch({
  43. srcBin: runtimeBin,
  44. configArgs: [childConfigPath],
  45. tsconfigPath: repoTsconfig,
  46. })
  47. let events: SessionEvent[] = []
  48. let childEvents: SessionEvent[] = []
  49. let workspace = ''
  50. const { stderr } = await runLoaderSmoke({
  51. label: 'dsh-sdk-subagent cwd composition smoke',
  52. tempDirPrefix: 'dsh-sdk-subagent-cwd-e2e-',
  53. binScript: driver,
  54. libBinScript: driver,
  55. configPath,
  56. tsconfigPath: repoTsconfig,
  57. // Two complete harness runtimes boot in sequence (driver, then the SDK
  58. // child); from-source tsx boots under load need more than the default
  59. // 30s window.
  60. processTimeoutMs: 120_000,
  61. env: {
  62. DSH_TEST_CHILD_COMMAND: childLaunch.command,
  63. DSH_TEST_CHILD_ARGS: JSON.stringify(childLaunch.args),
  64. DSH_TEST_CHILD_ENV: JSON.stringify({
  65. ...Object.fromEntries(Object.entries(childLaunch.env).filter(([, value]) => value !== undefined)),
  66. }),
  67. },
  68. inspect: async (cwd) => {
  69. // The child reports realpaths; canonicalize the temp workspace to match.
  70. workspace = realpathSync(cwd)
  71. const parentLogs = await jsonlFiles(join(cwd, '.sessions'))
  72. expect(parentLogs).toHaveLength(1)
  73. events = await sessionEvents(parentLogs[0] as string)
  74. // The child runtime persisted its own transcript in ITS cwd — which
  75. // must be the parent session's workspace for the inheritance to hold.
  76. const childLogs = await jsonlFiles(join(cwd, '.child-sessions'))
  77. expect(childLogs).toHaveLength(1)
  78. childEvents = await sessionEvents(childLogs[0] as string)
  79. },
  80. })
  81. expect(stderr).not.toContain('UNHANDLED')
  82. // The parent's tool result carries the child model's echo of its real
  83. // process.cwd() — the parent session's workspace, never the harness
  84. // process's launch directory.
  85. const results = events.filter(event => event.type === 'tool/result')
  86. expect(results).toHaveLength(1)
  87. const resultText = results[0]!.data.message.content[0].content
  88. .filter(block => block.type === 'text')
  89. .map(block => block.text)
  90. .join('')
  91. expect(resultText).toBe(`child cwd: ${workspace}`)
  92. // The child ran a real turn of its own: user message in, assistant out.
  93. expect(childEvents.some(event => event.type === 'user/message')).toBe(true)
  94. const childAnswers = childEvents.filter(event => event.type === 'assistant/message')
  95. expect(childAnswers.length).toBeGreaterThan(0)
  96. // 15s of vitest headroom past the subprocess deadline, mirroring
  97. // LOADER_SMOKE_TEST_TIMEOUT_MS's margin over the default window.
  98. }, 135_000)
  99. })