mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-09 04:02:35 +00:00
110 lines
5.2 KiB
TypeScript
110 lines
5.2 KiB
TypeScript
/**
|
|
* Keyless REAL-composition coverage for parent-session cwd inheritance across
|
|
* the SDK wire: a test-only cordis.yml boots the headless app through the
|
|
* Loader with the SDK backend's `cwd` omitted, a scripted model delegates
|
|
* once, and the child — a COMPLETE second harness runtime booted from its own
|
|
* cordis.yml and driven over stdio JSON-RPC — echoes where it actually ran.
|
|
* Both the parent's tool result and the child's own persisted session log
|
|
* must carry the parent session's cwd. Mock-only composition, so only this
|
|
* keyless tier applies (the with-key tier lives in subagent-sdk.e2e.ts).
|
|
*/
|
|
|
|
import { existsSync, realpathSync } from 'node:fs'
|
|
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { type SessionEvent } from '@deepseek-ai/dsh-session'
|
|
import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
|
|
|
const fixtureDir = new URL('./fixtures/loader/', import.meta.url)
|
|
const driver = fileURLToPath(new URL('driver.ts', fixtureDir))
|
|
const configPath = fileURLToPath(new URL('cordis.yml', fixtureDir))
|
|
const childConfigPath = fileURLToPath(new URL('child.cordis.yml', fixtureDir))
|
|
const childMockPath = fileURLToPath(new URL('child-mock-llm.ts', fixtureDir))
|
|
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
|
|
|
async function jsonlFiles(dir: string): Promise<string[]> {
|
|
const entries = await readdir(dir, { withFileTypes: true })
|
|
const paths = await Promise.all(entries.map(async (entry) => {
|
|
const path = join(dir, entry.name)
|
|
if (entry.isDirectory()) return jsonlFiles(path)
|
|
return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : []
|
|
}))
|
|
return paths.flat()
|
|
}
|
|
|
|
async function sessionEvents(log: string): Promise<SessionEvent[]> {
|
|
const lines = (await readFile(log, 'utf8')).trimEnd().split('\n')
|
|
return lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
|
|
}
|
|
|
|
describe('SDK subagent cwd inheritance through a real cordis.yml', () => {
|
|
it('runs the child runtime in the parent session workspace', async () => {
|
|
const childHome = await mkdtemp(join(tmpdir(), 'dsh-sdk-subagent-home-'))
|
|
const childPatch = join(childHome, 'child.cordis.yml')
|
|
await writeFile(childPatch, (await readFile(childConfigPath, 'utf8'))
|
|
.replace("'./child-mock-llm.ts'", JSON.stringify(pathToFileURL(childMockPath).href)))
|
|
|
|
let events: SessionEvent[] = []
|
|
let childEvents: SessionEvent[] = []
|
|
let workspace = ''
|
|
try {
|
|
const { stderr } = await runLoaderSmoke({
|
|
label: 'dsh-sdk-subagent cwd composition smoke',
|
|
tempDirPrefix: 'dsh-sdk-subagent-cwd-e2e-',
|
|
binScript: driver,
|
|
libBinScript: driver,
|
|
configPath,
|
|
tsconfigPath: repoTsconfig,
|
|
// Two complete harness runtimes boot in sequence (driver, then the SDK
|
|
// child); from-source tsx boots under load need more than the default
|
|
// 30s window.
|
|
processTimeoutMs: 120_000,
|
|
env: {
|
|
DSH_TEST_CHILD_PATCHES: JSON.stringify([childPatch]),
|
|
DSH_TEST_CHILD_HOME: childHome,
|
|
},
|
|
inspect: async (cwd) => {
|
|
// The child reports realpaths; canonicalize the temp workspace to match.
|
|
workspace = realpathSync(cwd)
|
|
const parentLogs = await jsonlFiles(join(cwd, '.sessions'))
|
|
expect(parentLogs).toHaveLength(1)
|
|
events = await sessionEvents(parentLogs[0] as string)
|
|
// The child runtime persists under its explicit isolated home.
|
|
const childSessions = join(childHome, 'sessions')
|
|
if (!existsSync(childSessions)) {
|
|
const result = events.find(event => event.type === 'tool/result')
|
|
throw new Error(`SDK child persisted no session; parent tool result: ${JSON.stringify(result?.data)}`)
|
|
}
|
|
const childLogs = await jsonlFiles(childSessions)
|
|
expect(childLogs).toHaveLength(1)
|
|
childEvents = await sessionEvents(childLogs[0] as string)
|
|
},
|
|
})
|
|
expect(stderr).not.toContain('UNHANDLED')
|
|
|
|
// The parent's tool result carries the child model's echo of its real
|
|
// process.cwd() — the parent session's workspace, never the harness
|
|
// process's launch directory.
|
|
const results = events.filter(event => event.type === 'tool/result')
|
|
expect(results).toHaveLength(1)
|
|
const resultText = results[0]!.data.message.content[0].content
|
|
.filter(block => block.type === 'text')
|
|
.map(block => block.text)
|
|
.join('')
|
|
expect(resultText).toBe(`child cwd: ${workspace}`)
|
|
|
|
// The child ran a real turn of its own: user message in, assistant out.
|
|
expect(childEvents.some(event => event.type === 'user/message')).toBe(true)
|
|
const childAnswers = childEvents.filter(event => event.type === 'assistant/message')
|
|
expect(childAnswers.length).toBeGreaterThan(0)
|
|
} finally {
|
|
await rm(childHome, { recursive: true, force: true })
|
|
}
|
|
// 15s of vitest headroom past the subprocess deadline, mirroring
|
|
// LOADER_SMOKE_TEST_TIMEOUT_MS's margin over the default window.
|
|
}, 135_000)
|
|
})
|