Merge remote-tracking branch 'origin/master' into feat/read-image-context
# Conflicts: # apps/cli/tests/web-agent-presets.e2e.ts # packages/fs/tool-fs/package.json
This commit is contained in:
@@ -21,22 +21,28 @@ function exitCode(argv: string[]): number {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
describe('parseDshArgs', () => {
|
||||
it('routes profile boots, one-shot runs, and the web alias', () => {
|
||||
expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [] })
|
||||
it('routes profile boots and the web alias, handing the rest to the app', () => {
|
||||
expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [], args: [] })
|
||||
expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--patch', 'b.yml']))
|
||||
.toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'] })
|
||||
expect(parse(['run', 'run', 'the', 'tests']))
|
||||
.toEqual({ mode: 'run', profile: 'headless', patches: [], task: 'run the tests' })
|
||||
expect(parse(['run', '--profile', 'custom', '--patch', 'a.yml', '--patch', 'b.yml', 'run', 'the', 'tests']))
|
||||
.toEqual({ mode: 'run', profile: 'custom', patches: ['a.yml', 'b.yml'], task: 'run the tests' })
|
||||
expect(parse(['run', '--', '--profile', 'is', 'task', 'text']))
|
||||
.toEqual({ mode: 'run', profile: 'headless', patches: [], task: '--profile is task text' })
|
||||
expect(parse(['web'])).toEqual({ mode: 'web', dev: false, patches: [] })
|
||||
expect(parse(['web', '--patch', 'web.yml'])).toEqual({ mode: 'web', dev: false, patches: ['web.yml'] })
|
||||
.toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'], args: [] })
|
||||
expect(parse(['web'])).toEqual({ mode: 'profile', profile: 'web', patches: [], args: [] })
|
||||
expect(parse(['web', '--patch', 'web.yml']))
|
||||
.toEqual({ mode: 'profile', profile: 'web', patches: ['web.yml'], args: [] })
|
||||
})
|
||||
|
||||
it('ends the launcher flags at the first token it does not own', () => {
|
||||
// App flags, including its -h, and positionals reach the app verbatim.
|
||||
expect(parse(['--profile', 'tui', '--resume', 'abc']))
|
||||
.toEqual({ mode: 'profile', profile: 'tui', patches: [], args: ['--resume', 'abc'] })
|
||||
expect(parse(['--profile', 'web', '-h']))
|
||||
.toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['-h'] })
|
||||
expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev']))
|
||||
.toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, patches: [] })
|
||||
expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9']))
|
||||
.toEqual({ mode: 'web', dev: false, patches: [], trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] })
|
||||
.toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['--host', '0.0.0.0', '--port', '8080', '--dev'] })
|
||||
expect(parse(['--profile', 'headless', 'run', 'the', 'tests']))
|
||||
.toEqual({ mode: 'profile', profile: 'headless', patches: [], args: ['run', 'the', 'tests'] })
|
||||
// Launcher flags placed after that boundary belong to the app too.
|
||||
expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--resume', 'b', '--patch', 'late.yml']))
|
||||
.toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml'], args: ['--resume', 'b', '--patch', 'late.yml'] })
|
||||
})
|
||||
|
||||
it('routes the plugin pnpm forwarder', () => {
|
||||
@@ -44,8 +50,8 @@ describe('parseDshArgs', () => {
|
||||
.toEqual({ mode: 'plugin', profile: 'tui', args: ['add', 'turtle-ui'] })
|
||||
expect(parse(['plugin', '--profile', 'tui', 'remove', 'turtle-ui']))
|
||||
.toEqual({ mode: 'plugin', profile: 'tui', args: ['remove', 'turtle-ui'] })
|
||||
expect(parse(['plugin', '--profile', 'tui', 'why', 'cordis']))
|
||||
.toEqual({ mode: 'plugin', profile: 'tui', args: ['why', 'cordis'] })
|
||||
expect(parse(['plugin', '--profile', 'tui', 'why', '@deepseek-ai/cordis']))
|
||||
.toEqual({ mode: 'plugin', profile: 'tui', args: ['why', '@deepseek-ai/cordis'] })
|
||||
// Unknown pnpm flags forward verbatim.
|
||||
expect(parse(['plugin', '--profile', 'tui', 'add', '--save-dev', 'x']))
|
||||
.toEqual({ mode: 'plugin', profile: 'tui', args: ['add', '--save-dev', 'x'] })
|
||||
@@ -64,18 +70,12 @@ describe('parseDshArgs', () => {
|
||||
.toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: true, patches: [] })
|
||||
})
|
||||
|
||||
it('rejects missing profile, flags outside the current grammar, and contradictory inputs', () => {
|
||||
it('rejects missing profile, removed flags, and contradictory inputs', () => {
|
||||
expect(exitCode([])).toBe(1)
|
||||
expect(exitCode(['tui'])).toBe(1) // a bare word is a task without --profile
|
||||
expect(exitCode(['--config', 'c.yml'])).toBe(1) // outside the current grammar
|
||||
expect(exitCode(['-p', 'task'])).toBe(1) // outside the current grammar
|
||||
expect(exitCode(['--profile', 'headless', 'task'])).toBe(1) // tasks belong to `run`
|
||||
expect(exitCode(['run'])).toBe(1)
|
||||
expect(exitCode(['run', ''])).toBe(1)
|
||||
expect(exitCode(['run', '--profile', '', 'task'])).toBe(1)
|
||||
expect(exitCode(['run', '--patch=', 'task'])).toBe(1)
|
||||
expect(exitCode(['--profile', 'headless', 'run', 'task'])).toBe(1)
|
||||
expect(exitCode(['--patch', 'parent.yml', 'run', 'task'])).toBe(1)
|
||||
expect(exitCode(['tui'])).toBe(1) // an app argument without --profile has no app to reach
|
||||
expect(exitCode(['--config', 'c.yml'])).toBe(1) // removed
|
||||
expect(exitCode(['-p', 'task'])).toBe(1) // removed
|
||||
expect(exitCode(['run', 'task'])).toBe(1) // app-owned task replaced the launcher subcommand
|
||||
expect(exitCode(['--profile', ''])).toBe(1)
|
||||
expect(exitCode(['--profile', 'x', '--patch='])).toBe(1)
|
||||
expect(exitCode(['--dump-config'])).toBe(1)
|
||||
@@ -87,21 +87,20 @@ describe('parseDshArgs', () => {
|
||||
expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1)
|
||||
expect(exitCode(['web', '--dump-default-config', '--patch', 'w.yml'])).toBe(1)
|
||||
expect(exitCode(['web', '--patch='])).toBe(1)
|
||||
// Boot-free dumps derive no flag patches; silently dropping the flags
|
||||
// would print a tree that differs from the same invocation's boot.
|
||||
// A dump never runs app command-line providers, so it cannot show what
|
||||
// those flags would decide; printing a tree that differs from the same
|
||||
// invocation's boot would mislead.
|
||||
expect(exitCode(['web', '--dump-config', '--port', '8080'])).toBe(1)
|
||||
expect(exitCode(['web', '--dump-config', '--dev'])).toBe(1)
|
||||
// A non-numeric port fails at the flag, not deep in the webserver schema.
|
||||
expect(exitCode(['web', '--port', 'abc'])).toBe(1)
|
||||
expect(exitCode(['--profile', 'web', '--dump-config', '-h'])).toBe(1)
|
||||
expect(exitCode(['plugin', 'add', 'x'])).toBe(1) // --profile required
|
||||
expect(exitCode(['plugin', '--profile', 'tui'])).toBe(1) // nothing to forward
|
||||
expect(exitCode(['plugin', '--profile', ''])).toBe(1)
|
||||
expect(exitCode(['--profile', 'x', 'plugin', 'add', 'y'])).toBe(1)
|
||||
})
|
||||
|
||||
it('exits 0 for help and version', () => {
|
||||
it('keeps its own help for an invocation with no app to hand it to', () => {
|
||||
expect(exitCode(['--help'])).toBe(0)
|
||||
expect(exitCode(['run', '--help'])).toBe(0)
|
||||
expect(exitCode(['-h'])).toBe(0)
|
||||
expect(exitCode(['--version'])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,8 +8,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
/** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
// The release version, including a prerelease such as 0.0.1-rc.1: `--version`
|
||||
// prints what this manifest carries, so no test may pin it to a literal.
|
||||
const cliVersion = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version
|
||||
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
|
||||
const coreWebOverlay = fileURLToPath(new URL('../config/core-web.cordis.yml', import.meta.url))
|
||||
const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url))
|
||||
|
||||
async function runBuiltBin(
|
||||
@@ -129,8 +131,8 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture {
|
||||
return { home, ready, settled, disposed, interrupt }
|
||||
}
|
||||
|
||||
function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
|
||||
return execa(process.execPath, [dshBin, '--profile', 'lifecycle'], {
|
||||
function startProfileLifecycle(fixture: ProfileLifecycleFixture, args: readonly string[] = []) {
|
||||
return execa(process.execPath, [dshBin, '--profile', 'lifecycle', ...args], {
|
||||
cwd: fixture.home,
|
||||
input: '',
|
||||
reject: false,
|
||||
@@ -145,8 +147,8 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
|
||||
}
|
||||
|
||||
function requestProfileShutdown(
|
||||
child: ReturnType<typeof startProfileLifecycle>,
|
||||
fixture: ProfileLifecycleFixture,
|
||||
child: Pick<ReturnType<typeof startProfileLifecycle>, 'kill'>,
|
||||
fixture: Pick<ProfileLifecycleFixture, 'interrupt'>,
|
||||
): void {
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(fixture.interrupt, 'interrupt')
|
||||
@@ -194,8 +196,121 @@ function createEnvironmentProbeProfile(home: string, project: string): void {
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
interface StartupFixture {
|
||||
home: string
|
||||
ready: string
|
||||
echo: string
|
||||
interrupt: string
|
||||
/** An always-running row's echo, used to observe that a user patch reload landed. */
|
||||
witness: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom profile whose ordinary provider plugin injects `cmdlineArgs`, plus
|
||||
* a row that reads its app-owned service through a `!!js` config expression.
|
||||
* Both plugin modules resolve
|
||||
* `@deepseek-ai/dsh-cmdline` and `commander` through the profile module
|
||||
* fallback, exactly as an installed out-of-tree bundle does.
|
||||
*/
|
||||
function createStartupFixture(): StartupFixture {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-profile-startup-'))
|
||||
const profileDir = join(home, 'profiles', 'startup')
|
||||
// Written straight into the installed location: a row module resolves its
|
||||
// own imports from where it is installed, and only inside the profile does
|
||||
// Node's parent walk reach the installation fallback these plugins need.
|
||||
const bundleDir = join(profileDir, 'node_modules', 'dsh-startup-bundle')
|
||||
mkdirSync(bundleDir, { recursive: true })
|
||||
writeFileSync(join(bundleDir, 'startup.mjs'), [
|
||||
"import { Command } from 'commander'",
|
||||
"import { parseCmdline } from '@deepseek-ai/dsh-cmdline'",
|
||||
"export const name = 'fixture-startup'",
|
||||
"export const inject = ['cmdlineArgs']",
|
||||
'export function apply(ctx) {',
|
||||
" const program = new Command().name('fixture').option('--generation <value>', 'echoed generation')",
|
||||
' const values = parseCmdline(ctx, program, parsed => ({ generation: parsed.opts().generation }))',
|
||||
' if (values !== undefined) ctx.provide(\'fixtureStartup\', values)',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(bundleDir, 'waiting.mjs'), [
|
||||
"import { existsSync, writeFileSync } from 'node:fs'",
|
||||
"import { join } from 'node:path'",
|
||||
"export const name = 'startup-fixture'",
|
||||
'export function apply(ctx, config = {}) {',
|
||||
' let interrupted = false',
|
||||
' const heartbeat = setInterval(() => {',
|
||||
' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return',
|
||||
' interrupted = true',
|
||||
" process.emit('SIGTERM')",
|
||||
' }, 20)',
|
||||
" writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
|
||||
" writeFileSync(process.env.RAW_READY_FILE, 'ready')",
|
||||
' ctx.effect(() => () => { clearInterval(heartbeat) })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(bundleDir, 'witness.mjs'), [
|
||||
"import { writeFileSync } from 'node:fs'",
|
||||
"import { join } from 'node:path'",
|
||||
"export const name = 'reload-witness'",
|
||||
'export function apply(ctx, config = {}) {',
|
||||
" writeFileSync(join(process.env.DSH_HOME, 'witness'), String(config.generation ?? 'bundle-default'))",
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
|
||||
'- insert:',
|
||||
' - id: startup-fixture',
|
||||
` name: ${pathToFileURL(join(bundleDir, 'waiting.mjs')).href}`,
|
||||
' inject: [fixtureStartup]',
|
||||
' config:',
|
||||
// Lazy interpolation runs only after the provider's service is injected.
|
||||
" generation: !!js ctx.fixtureStartup.generation ?? 'bundle-default'",
|
||||
' - id: fixture-startup',
|
||||
` name: ${pathToFileURL(join(bundleDir, 'startup.mjs')).href}`,
|
||||
' - id: reload-witness',
|
||||
` name: ${pathToFileURL(join(bundleDir, 'witness.mjs')).href}`,
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
|
||||
name: 'dsh-startup-bundle',
|
||||
version: '0.0.0',
|
||||
type: 'module',
|
||||
dsh: { bundle: { patch: './cordis.patch.yml' } },
|
||||
}, undefined, 2))
|
||||
writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
|
||||
name: 'dsh-profile-startup',
|
||||
private: true,
|
||||
dependencies: {},
|
||||
dsh: { profile: { bundles: ['dsh-startup-bundle'] } },
|
||||
}, undefined, 2))
|
||||
writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
|
||||
return {
|
||||
home,
|
||||
ready: join(home, 'ready'),
|
||||
echo: join(home, 'config-echo'),
|
||||
interrupt: join(home, 'interrupt'),
|
||||
witness: join(home, 'witness'),
|
||||
}
|
||||
}
|
||||
|
||||
function startStartupProfile(fixture: StartupFixture, args: readonly string[]) {
|
||||
return execa(process.execPath, [dshBin, '--profile', 'startup', ...args], {
|
||||
cwd: fixture.home,
|
||||
input: '',
|
||||
reject: false,
|
||||
timeout: 25_000,
|
||||
killSignal: 'SIGKILL',
|
||||
env: {
|
||||
DSH_HOME: fixture.home,
|
||||
RAW_READY_FILE: fixture.ready,
|
||||
RAW_INTERRUPT_FILE: fixture.interrupt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
it('requires --profile and rejects inputs outside the current grammar', async () => {
|
||||
it('requires --profile and rejects removed commands', async () => {
|
||||
const bare = await runBuiltBin()
|
||||
expect(bare.code).toBe(1)
|
||||
expect(bare.stdout).toBe('')
|
||||
@@ -203,46 +318,63 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
const help = await runBuiltBin(['--help'])
|
||||
expect(help.code).toBe(0)
|
||||
expect(help.stdout).toContain('dsh --profile web')
|
||||
expect(help.stdout).toContain('dsh run "run the tests"')
|
||||
expect(help.stdout).toContain('dsh plugin --profile')
|
||||
expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu)
|
||||
for (const outsideGrammar of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['--profile', 'headless', 'task']]) {
|
||||
const result = await runBuiltBin(outsideGrammar)
|
||||
for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['run', 'task']]) {
|
||||
const result = await runBuiltBin(removed)
|
||||
expect(result.code).toBe(1)
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('prints run help without initializing the selected profile', async () => {
|
||||
const parent = mkdtempSync(join(tmpdir(), 'dsh-run-help-'))
|
||||
const home = join(parent, 'not-created')
|
||||
it('routes help and usage errors without activating startup-dependent rows', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-app-help-'))
|
||||
try {
|
||||
const result = await runBuiltBin(['run', '--help'], { DSH_HOME: home })
|
||||
expect(result.code).toBe(0)
|
||||
expect(result.stderr).toBe('')
|
||||
expect(result.stdout).toContain('Usage: dsh run [options] <task...>')
|
||||
expect(existsSync(home)).toBe(false)
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
const web = await runBuiltBin(['--profile', 'web', '--help'], {
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
})
|
||||
expect(web.code).toBe(0)
|
||||
expect(web.stderr).toBe('')
|
||||
expect(web.stdout).toContain('Usage: dsh --profile web')
|
||||
expect(web.stdout).toContain('--port <port>')
|
||||
expect(web.stdout).not.toContain('dsh web: http://')
|
||||
|
||||
it('runs the default headless profile through the published run command', async () => {
|
||||
const apiKey = 'built-dsh-run-key'
|
||||
const headlessHelp = await runBuiltBin(['--profile', 'headless', '--help'], {
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
})
|
||||
expect(headlessHelp.code).toBe(0)
|
||||
expect(headlessHelp.stderr).toBe('')
|
||||
expect(headlessHelp.stdout).toContain('Usage: dsh --profile headless')
|
||||
|
||||
const missingTask = await runBuiltBin(['--profile', 'headless'], {
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
})
|
||||
expect(missingTask.code).toBe(1)
|
||||
expect(missingTask.stderr).toContain('a task is required')
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('runs the headless profile through its app-owned task positional', async () => {
|
||||
const apiKey = 'built-dsh-headless-key'
|
||||
const server = await startMockLlmServer({
|
||||
sequence: ['success'],
|
||||
apiKey,
|
||||
successText: 'published dsh run reached the mock',
|
||||
successText: 'published headless profile reached the mock',
|
||||
})
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-built-run-'))
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-built-headless-'))
|
||||
try {
|
||||
const result = await runBuiltBin(['run', 'answer', 'from', 'the', 'published', 'entry'], {
|
||||
const result = await runBuiltBin(['--profile', 'headless', 'answer', 'from', 'the', 'published', 'entry'], {
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DEEPSEEK_API_KEY: apiKey,
|
||||
DEEPSEEK_BASE_URL: server.baseURL,
|
||||
})
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
expect(result.stdout).toBe('published dsh run reached the mock')
|
||||
expect(result.stdout).toBe('published headless profile reached the mock')
|
||||
expect(result.stderr).toBe('')
|
||||
expect(server.requests.length).toBeGreaterThan(0)
|
||||
expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true)
|
||||
@@ -258,7 +390,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
writeFileSync(join(project, '.env'), 'PATH=/project-only-path\n')
|
||||
try {
|
||||
const result = await runBuiltBin(['--version'], {}, project)
|
||||
expect(result).toEqual({ code: 0, stdout: '0.0.1', stderr: '' })
|
||||
expect(result).toEqual({ code: 0, stdout: cliVersion, stderr: '' })
|
||||
} finally {
|
||||
rmSync(project, { recursive: true, force: true })
|
||||
}
|
||||
@@ -318,9 +450,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
}, 30_000)
|
||||
|
||||
it('reports a patch-overlay boot failure without hanging', async () => {
|
||||
// An HMR main-watcher initial scan that refreshes the include
|
||||
// mid-initial-apply deadlocks the failing apply's rollback against the
|
||||
// refresh drain: dsh exits 13 with no diagnostic instead of settling
|
||||
// The HMR main watcher's initial scan once refreshed the include
|
||||
// mid-initial-apply, deadlocking the failing apply's rollback against the
|
||||
// refresh drain: dsh exited 13 with no diagnostic instead of settling
|
||||
// ([Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md)).
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-'))
|
||||
try {
|
||||
@@ -337,9 +469,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('applies a custom profile bundle and disposes it on a startup-time signal', async () => {
|
||||
it('lets a profile without a parser ignore app arguments and dispose on a startup-time signal', async () => {
|
||||
const fixture = createProfileLifecycleFixture()
|
||||
const child = startProfileLifecycle(fixture)
|
||||
const child = startProfileLifecycle(fixture, ['--unclaimed'])
|
||||
try {
|
||||
await waitForFile(fixture.ready)
|
||||
requestProfileShutdown(child, fixture)
|
||||
@@ -405,6 +537,83 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('hands the app arguments to the profile, which applies them before its rows start', async () => {
|
||||
const fixture = createStartupFixture()
|
||||
const child = startStartupProfile(fixture, ['--generation', 'flagged'])
|
||||
try {
|
||||
await waitForFile(fixture.ready)
|
||||
// The consumer started once, already carrying the flag value: the
|
||||
// launcher never saw --generation, and the app provider resolved it first.
|
||||
expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
|
||||
requestProfileShutdown(child, fixture)
|
||||
expect((await child).exitCode).toBe(0)
|
||||
} finally {
|
||||
child.kill('SIGKILL')
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('starts a consumer on its composed value when the invocation carries no app arguments', async () => {
|
||||
const fixture = createStartupFixture()
|
||||
const child = startStartupProfile(fixture, [])
|
||||
try {
|
||||
await waitForFile(fixture.ready)
|
||||
expect(readFileSync(fixture.echo, 'utf8')).toBe('bundle-default')
|
||||
requestProfileShutdown(child, fixture)
|
||||
expect((await child).exitCode).toBe(0)
|
||||
} finally {
|
||||
child.kill('SIGKILL')
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('keeps the app arguments across a user patch reload', async () => {
|
||||
// A live edit recomposes every row while the provider service remains
|
||||
// active, so each config expression reads the same invocation value (a
|
||||
// served port does not move back to its composed fallback).
|
||||
const fixture = createStartupFixture()
|
||||
const profilePatch = join(fixture.home, 'profiles', 'startup', 'cordis.patch.yml')
|
||||
const child = startStartupProfile(fixture, ['--generation', 'flagged'])
|
||||
try {
|
||||
// Both rows: the waiting one carries the flag value, and the witness is
|
||||
// what a reload will re-mount. They start independently, so neither
|
||||
// marker implies the other.
|
||||
await waitForFile(fixture.ready)
|
||||
await waitForFile(fixture.witness)
|
||||
expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
|
||||
// An edit to an unrelated row: the witness re-mounts, which is how this
|
||||
// test knows the whole tree was recomposed.
|
||||
rmSync(fixture.witness)
|
||||
writeFileSync(profilePatch, [
|
||||
'- id: reload-witness',
|
||||
' config:',
|
||||
' generation: reloaded',
|
||||
'',
|
||||
].join('\n'))
|
||||
await waitForFile(fixture.witness)
|
||||
expect(readFileSync(fixture.witness, 'utf8')).toBe('reloaded')
|
||||
expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
|
||||
requestProfileShutdown(child, fixture)
|
||||
expect((await child).exitCode).toBe(0)
|
||||
} finally {
|
||||
child.kill('SIGKILL')
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it("prints the app's own help, starts none of its rows, and exits", async () => {
|
||||
const fixture = createStartupFixture()
|
||||
try {
|
||||
const result = await startStartupProfile(fixture, ['--help'])
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('Usage: fixture')
|
||||
expect(result.stdout).toContain('--generation')
|
||||
expect(existsSync(fixture.ready)).toBe(false)
|
||||
} finally {
|
||||
rmSync(fixture.home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('anchors a relative add spec to the invoking directory, not the profile', async () => {
|
||||
// `dsh plugin --profile x add .` from a plugin checkout must install THAT
|
||||
// checkout — pnpm's cwd is the profile directory, so an un-anchored `.`
|
||||
@@ -491,18 +700,17 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
|
||||
}, 30_000)
|
||||
|
||||
it('prints a headless profile with no Host, HTTP, or browser rows', async () => {
|
||||
it('prints the headless profile without Host or browser layers', async () => {
|
||||
const { stdout, code, stderr } = await runBuiltBin(
|
||||
['--profile', 'headless', '--dump-default-config'],
|
||||
{ DSH_HOME: home },
|
||||
)
|
||||
expect(code).toBe(0)
|
||||
expect(stderr).toBe('')
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-default-model'")
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-headless'")
|
||||
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-host-")
|
||||
expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-host-/)
|
||||
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-web-app'")
|
||||
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-client-")
|
||||
expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-client-/)
|
||||
}, 30_000)
|
||||
|
||||
it('composes the profile user layer and a --patch overlay in order', async () => {
|
||||
@@ -543,16 +751,5 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`)
|
||||
expect(stderr).toContain('patch: entry "absent-row" not found')
|
||||
}, 30_000)
|
||||
|
||||
it('shows the RL Web patch disabling runtime surface context', async () => {
|
||||
const { stdout, code, stderr } = await runBuiltBin(
|
||||
['web', '--patch', coreWebOverlay, '--dump-config'],
|
||||
{ DSH_HOME: home },
|
||||
)
|
||||
expect(code).toBe(0)
|
||||
expect(stderr).toBe('')
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-web-app'")
|
||||
expect(stdout).toContain('surfaceContext: false')
|
||||
}, 30_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"github_repository": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"lib/mcp-server.mjs"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"name": "dsh-github-repository-plugin-e2e-fixture",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": [
|
||||
"lib",
|
||||
"dsh-plugin.mjs",
|
||||
"dsh-plugin-assets"
|
||||
],
|
||||
"scripts": {
|
||||
"prepack": "tsc --noEmit && tsdown src/plugin.ts src/mcp-server.ts --no-config --tsconfig tsconfig.json --out-dir lib --platform node --target es2024 --clean && dsh-plugin-prepare"
|
||||
},
|
||||
"dsh": {
|
||||
"skills": [
|
||||
"../skills"
|
||||
],
|
||||
"mcpServers": "./.mcp.json",
|
||||
"entry": "./lib/plugin.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "1.29.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-repository-plugin": "0.0.1",
|
||||
"cordis": "4.0.0-rc.7",
|
||||
"tsdown": "0.22.2",
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
|
||||
// The repository root's linter cannot resolve this independently installed
|
||||
// Git-package dependency; the package's prepack tsc validates the SDK types.
|
||||
/* oxlint-disable typescript/no-unsafe-assignment, typescript/no-unsafe-call, typescript/no-unsafe-member-access */
|
||||
const server = new McpServer({
|
||||
name: 'github-repository-plugin-e2e',
|
||||
version: '0.0.0',
|
||||
})
|
||||
|
||||
server.registerTool('proof', {
|
||||
description: 'Proves that an MCP server compiled from the exact GitHub repository package is active.',
|
||||
inputSchema: {},
|
||||
}, async () => ({
|
||||
content: [{ type: 'text', text: 'MCP_FROM_GITHUB_REPOSITORY' }],
|
||||
}))
|
||||
|
||||
await server.connect(new StdioServerTransport())
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
const PROOF_TOOL_NAME = 'mcp__github_repository__proof'
|
||||
|
||||
interface TextBlock {
|
||||
readonly type: 'text'
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
interface ToolExecution {
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
interface ToolResult {
|
||||
readonly isError: boolean
|
||||
readonly content: readonly TextBlock[]
|
||||
}
|
||||
|
||||
type PostDecision =
|
||||
| { readonly kind: 'accept'; readonly content?: readonly TextBlock[]; readonly value?: unknown; readonly additionalContexts?: readonly unknown[] }
|
||||
| { readonly kind: 'block'; readonly feedback: readonly TextBlock[] }
|
||||
|
||||
type PostListener = (
|
||||
execution: ToolExecution,
|
||||
result: ToolResult,
|
||||
next: () => Promise<PostDecision>,
|
||||
) => Promise<PostDecision>
|
||||
|
||||
type DshContext = Context & {
|
||||
on(event: 'tools/post-execute', listener: PostListener): () => void
|
||||
}
|
||||
|
||||
/** Cordis plugin name used by the repository acceptance fixture. */
|
||||
export const name = 'github-repository-typescript-proof'
|
||||
|
||||
/** DSH tool registry required by the post-execute contribution. */
|
||||
export const inject = ['tools']
|
||||
|
||||
/**
|
||||
* Append a marker after the repository MCP proof tool succeeds.
|
||||
* @param ctx - trusted DSH Cordis context supplied to the repository package.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const dsh = ctx as DshContext
|
||||
dsh.on('tools/post-execute', async (execution, result, next): Promise<PostDecision> => {
|
||||
const decision = await next()
|
||||
if (execution.name !== PROOF_TOOL_NAME || result.isError || decision.kind !== 'accept' || Object.hasOwn(decision, 'value')) {
|
||||
return decision
|
||||
}
|
||||
return {
|
||||
kind: 'accept',
|
||||
content: [
|
||||
...(decision.content ?? result.content),
|
||||
{ type: 'text', text: 'TS_PLUGIN_FROM_GITHUB_REPOSITORY' },
|
||||
],
|
||||
...decision.additionalContexts === undefined ? {} : { additionalContexts: decision.additionalContexts },
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
name: github-source-proof
|
||||
description: Proves that dsh installed a private repository Plugin from an exact GitHub source.
|
||||
---
|
||||
|
||||
This skill exists only in the GitHub repository source fixture.
|
||||
2
apps/cli/tests/fixtures/never-dispose.mjs
vendored
2
apps/cli/tests/fixtures/never-dispose.mjs
vendored
@@ -4,7 +4,7 @@ import { existsSync } from 'node:fs'
|
||||
|
||||
/**
|
||||
* Register a disposer that keeps process shutdown pending until it is forced.
|
||||
* @param {import('cordis').Context} ctx - loader-mounted test plugin context.
|
||||
* @param {import('@deepseek-ai/cordis').Context} ctx - loader-mounted test plugin context.
|
||||
*/
|
||||
export function apply(ctx) {
|
||||
const keepAlive = setInterval(() => {}, 60_000)
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { cpSync, existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createServer } from 'node:http'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
|
||||
const repositoryPluginPackage = join(repoRoot, 'packages/self-modification/repository-plugin')
|
||||
const releasePackageNames = new Set(globSync([
|
||||
'vendor/*/package.json',
|
||||
'packages/*/*/package.json',
|
||||
'apps/*/package.json',
|
||||
], { cwd: repoRoot }).map((filename) => {
|
||||
const manifest = JSON.parse(readFileSync(join(repoRoot, filename), 'utf8')) as Record<string, unknown>
|
||||
if (typeof manifest.name !== 'string') throw new Error(`workspace package name is missing: ${filename}`)
|
||||
return manifest.name
|
||||
}))
|
||||
const source = process.env.DSH_GITHUB_REPOSITORY_PLUGIN_SOURCE
|
||||
const required = process.env.DSH_REQUIRE_GITHUB_REPOSITORY_PLUGIN_E2E === '1'
|
||||
const enabled = required || source !== undefined
|
||||
|
||||
interface PublishedPackageRegistry {
|
||||
url: string
|
||||
requests: string[]
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
function publishedManifest(): Record<string, unknown> {
|
||||
const manifest = JSON.parse(readFileSync(join(repositoryPluginPackage, 'package.json'), 'utf8')) as Record<string, unknown>
|
||||
const version = manifest.version
|
||||
if (typeof version !== 'string') throw new Error('repository Plugin package version is missing')
|
||||
Reflect.deleteProperty(manifest, 'private')
|
||||
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
|
||||
const dependencies = manifest[field]
|
||||
if (typeof dependencies !== 'object' || dependencies === null || Array.isArray(dependencies)) continue
|
||||
const entries = dependencies as Record<string, unknown>
|
||||
for (const name of Object.keys(entries)) {
|
||||
if (releasePackageNames.has(name)) {
|
||||
entries[name] = version
|
||||
}
|
||||
}
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
async function startPublishedPackageRegistry(root: string): Promise<PublishedPackageRegistry> {
|
||||
const staging = join(root, 'published-repository-plugin')
|
||||
const artifacts = join(root, 'npm-registry-artifacts')
|
||||
mkdirSync(staging)
|
||||
mkdirSync(artifacts)
|
||||
cpSync(join(repositoryPluginPackage, 'lib'), join(staging, 'lib'), { recursive: true })
|
||||
for (const filename of ['README.md', 'README.zh.md', 'README.i18n.yaml']) {
|
||||
cpSync(join(repositoryPluginPackage, filename), join(staging, filename))
|
||||
}
|
||||
cpSync(join(repoRoot, 'LICENSE'), join(staging, 'LICENSE'))
|
||||
const manifest = publishedManifest()
|
||||
writeFileSync(join(staging, 'package.json'), `${JSON.stringify(manifest, undefined, 2)}\n`)
|
||||
const packed = await execa('pnpm', ['pack', '--pack-destination', artifacts], {
|
||||
cwd: staging,
|
||||
reject: false,
|
||||
})
|
||||
if (packed.exitCode !== 0) {
|
||||
throw new Error(`failed to pack the simulated published prepare package:\n${packed.stderr}\n${packed.stdout}`)
|
||||
}
|
||||
const tarballs = readdirSync(artifacts).filter(filename => filename.endsWith('.tgz'))
|
||||
if (tarballs.length !== 1) throw new Error(`expected one simulated published tarball, found ${tarballs.length}`)
|
||||
const tarball = readFileSync(join(artifacts, tarballs[0]!))
|
||||
const name = manifest.name as string
|
||||
const version = manifest.version as string
|
||||
const requests: string[] = []
|
||||
let registryUrl = ''
|
||||
const server = createServer((request, response) => {
|
||||
const path = decodeURIComponent(new URL(request.url ?? '/', registryUrl).pathname)
|
||||
requests.push(`${request.method ?? 'GET'} ${path}`)
|
||||
if (path === `/${name}`) {
|
||||
const metadata = {
|
||||
name,
|
||||
'dist-tags': { latest: version },
|
||||
versions: {
|
||||
[version]: {
|
||||
...manifest,
|
||||
dist: {
|
||||
tarball: `${registryUrl}${name}/-/${name.split('/').at(-1)}-${version}.tgz`,
|
||||
shasum: createHash('sha1').update(tarball).digest('hex'),
|
||||
integrity: `sha512-${createHash('sha512').update(tarball).digest('base64')}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify(metadata))
|
||||
return
|
||||
}
|
||||
if (path === `/${name}/-/${name.split('/').at(-1)}-${version}.tgz`) {
|
||||
response.writeHead(200, {
|
||||
'content-type': 'application/octet-stream',
|
||||
'content-length': String(tarball.length),
|
||||
})
|
||||
response.end(tarball)
|
||||
return
|
||||
}
|
||||
response.writeHead(404, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ error: 'not found' }))
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('simulated npm registry did not expose a TCP address')
|
||||
registryUrl = `http://127.0.0.1:${address.port}/`
|
||||
return {
|
||||
url: registryUrl,
|
||||
requests,
|
||||
close: () => new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => { if (error === undefined) resolve(); else reject(error) })
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () => {
|
||||
it('installs the published prepare dependency, then builds and runs skill, MCP, and TypeScript Plugin contributions from a private exact GitHub source', async () => {
|
||||
expect(existsSync(dshBin), 'the repository Plugin acceptance must run the built dsh entry').toBe(true)
|
||||
expect(source, 'DSH_GITHUB_REPOSITORY_PLUGIN_SOURCE is required by this CI lane').toMatch(
|
||||
/^github:[^/\s#&]+\/[^/\s#&]+#[0-9a-f]{40}&path:\/.*\/\.dsh-plugin$/u,
|
||||
)
|
||||
|
||||
const apiKey = 'github-repository-plugin-e2e-key'
|
||||
const server = await startMockLlmServer({
|
||||
sequence: ['tool_call_success', 'success'],
|
||||
apiKey,
|
||||
toolName: 'mcp__github_repository__proof',
|
||||
toolArguments: '{}',
|
||||
successText: 'trusted GitHub repository package reached dsh run',
|
||||
})
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-github-repository-plugin-'))
|
||||
const registry = await startPublishedPackageRegistry(home)
|
||||
const npmrc = join(home, 'npmrc')
|
||||
writeFileSync(npmrc, `@deepseek-ai:registry=${registry.url}\n`)
|
||||
const hostBin = join(home, 'host-bin')
|
||||
mkdirSync(hostBin)
|
||||
writeFileSync(join(hostBin, 'dsh-plugin-prepare'), [
|
||||
'#!/bin/sh',
|
||||
'echo "host PATH supplied dsh-plugin-prepare instead of the declared npm dependency" >&2',
|
||||
'exit 91',
|
||||
'',
|
||||
].join('\n'), { mode: 0o700 })
|
||||
const patch = join(home, 'github-repository-plugin.cordis.patch.yml')
|
||||
writeFileSync(patch, [
|
||||
'- id: repository-plugins',
|
||||
' config:',
|
||||
' repositories:',
|
||||
` - ${JSON.stringify(source)}`,
|
||||
'- id: session-title-llm',
|
||||
' disabled: true',
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
try {
|
||||
const result = await execa(process.execPath, [
|
||||
dshBin,
|
||||
'run',
|
||||
'--patch',
|
||||
patch,
|
||||
'prove the private GitHub repository Plugin is active',
|
||||
], {
|
||||
cwd: repoRoot,
|
||||
input: '',
|
||||
timeout: 180_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
env: {
|
||||
...process.env,
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DEEPSEEK_API_KEY: apiKey,
|
||||
DEEPSEEK_BASE_URL: server.baseURL,
|
||||
NPM_CONFIG_USERCONFIG: npmrc,
|
||||
// A warm runner cache could satisfy the exact tarball without
|
||||
// contacting this test's registry, which would stop proving the
|
||||
// unpublished package was installed through the simulated release.
|
||||
PNPM_CONFIG_CACHE_DIR: join(home, 'pnpm-cache'),
|
||||
PNPM_CONFIG_STORE_DIR: join(home, 'pnpm-store'),
|
||||
PATH: process.env.PATH === undefined ? hostBin : `${hostBin}${delimiter}${process.env.PATH}`,
|
||||
},
|
||||
})
|
||||
if (result.timedOut) {
|
||||
throw new Error(`dsh GitHub repository Plugin run did not exit within 180s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}`).toBe(0)
|
||||
expect(result.stdout).toBe('trusted GitHub repository package reached dsh run')
|
||||
expect(server.requests).toHaveLength(2)
|
||||
const runtimeDiagnostic = `${result.stderr}\nstdout:\n${result.stdout}`
|
||||
expect(registry.requests, runtimeDiagnostic).toContain('GET /@deepseek-ai/dsh-repository-plugin')
|
||||
expect(registry.requests, runtimeDiagnostic).toContain('GET /@deepseek-ai/dsh-repository-plugin/-/dsh-repository-plugin-0.0.1.tgz')
|
||||
const firstRequest = JSON.stringify(server.requests[0]!.body)
|
||||
const secondRequest = JSON.stringify(server.requests[1]!.body)
|
||||
expect(firstRequest, runtimeDiagnostic).toContain(
|
||||
'Proves that dsh installed a private repository Plugin from an exact GitHub source.',
|
||||
)
|
||||
expect(firstRequest, runtimeDiagnostic).toContain('mcp__github_repository__proof')
|
||||
expect(firstRequest, runtimeDiagnostic).toContain('Proves that an MCP server compiled from the exact GitHub repository package is active.')
|
||||
expect(secondRequest, runtimeDiagnostic).toContain('MCP_FROM_GITHUB_REPOSITORY')
|
||||
expect(secondRequest, runtimeDiagnostic).toContain('TS_PLUGIN_FROM_GITHUB_REPOSITORY')
|
||||
|
||||
const cacheRoot = join(home, 'cache', 'repository-plugins')
|
||||
const generations = readdirSync(cacheRoot, { withFileTypes: true }).filter(entry => entry.isDirectory())
|
||||
expect(generations).toHaveLength(1)
|
||||
const installed = join(cacheRoot, generations[0]!.name, 'node_modules', 'repository')
|
||||
const manifest = JSON.parse(readFileSync(join(installed, 'package.json'), 'utf8')) as Record<string, unknown>
|
||||
expect(manifest).toMatchObject({
|
||||
name: 'dsh-github-repository-plugin-e2e-fixture',
|
||||
private: true,
|
||||
scripts: {
|
||||
prepack: 'tsc --noEmit && tsdown src/plugin.ts src/mcp-server.ts --no-config --tsconfig tsconfig.json --out-dir lib --platform node --target es2024 --clean && dsh-plugin-prepare',
|
||||
},
|
||||
dsh: {
|
||||
skills: ['../skills'],
|
||||
mcpServers: './.mcp.json',
|
||||
entry: './lib/plugin.mjs',
|
||||
},
|
||||
dependencies: {
|
||||
'@modelcontextprotocol/sdk': '1.29.0',
|
||||
},
|
||||
devDependencies: {
|
||||
'@deepseek-ai/dsh-repository-plugin': '0.0.1',
|
||||
cordis: '4.0.0-rc.7',
|
||||
tsdown: '0.22.2',
|
||||
typescript: '6.0.3',
|
||||
},
|
||||
})
|
||||
expect(readFileSync(join(installed, 'dsh-plugin-assets/skills/0/github-source-proof/SKILL.md'), 'utf8'))
|
||||
.toContain('This skill exists only in the GitHub repository source fixture.')
|
||||
expect(readFileSync(join(installed, 'dsh-plugin-assets/.mcp.json'), 'utf8')).toContain('lib/mcp-server.mjs')
|
||||
expect(readFileSync(join(installed, 'lib/plugin.mjs'), 'utf8')).toContain('TS_PLUGIN_FROM_GITHUB_REPOSITORY')
|
||||
expect(readFileSync(join(installed, 'lib/mcp-server.mjs'), 'utf8')).toContain('MCP_FROM_GITHUB_REPOSITORY')
|
||||
expect(existsSync(join(installed, 'src'))).toBe(false)
|
||||
const installedRequire = createRequire(join(installed, 'lib/mcp-server.mjs'))
|
||||
expect(existsSync(installedRequire.resolve('@modelcontextprotocol/sdk/server/mcp.js'))).toBe(true)
|
||||
const wrapper = readFileSync(join(installed, 'dsh-plugin.mjs'), 'utf8')
|
||||
expect(wrapper).toContain('dsh-repository-plugin')
|
||||
expect(wrapper).toContain('await import(manifest.entry)')
|
||||
expect(wrapper).toContain('"entry":"./lib/plugin.mjs"')
|
||||
} finally {
|
||||
await server.close()
|
||||
await registry.close()
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, 190_000)
|
||||
})
|
||||
@@ -83,7 +83,7 @@ async function runHeadlessPtySmoke(): Promise<string> {
|
||||
].join('\n'))
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: dshBinScript,
|
||||
configArgs: ['run', 'never complete'],
|
||||
configArgs: ['--profile', 'headless', 'never complete'],
|
||||
tsconfigPath,
|
||||
env: {
|
||||
DSH_HOME: home,
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
/** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveLanTrust, webSurfaceContextEnabled } from '../src/web.ts'
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
networkInterfaces: () => ({
|
||||
lo0: [
|
||||
{ family: 'IPv4', internal: true, address: '127.0.0.1' },
|
||||
],
|
||||
en0: [
|
||||
{ family: 'IPv6', internal: false, address: 'fe80::1' },
|
||||
{ family: 'IPv4', internal: false, address: '192.168.1.5' },
|
||||
],
|
||||
en1: [
|
||||
{ family: 'IPv4', internal: false, address: '10.0.0.7' },
|
||||
],
|
||||
utun0: undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('resolveLanTrust', () => {
|
||||
it('samples non-internal IPv4 addresses once for an all-interfaces bind: trust and display share them', () => {
|
||||
const { lanAddresses, trustedHosts } = resolveLanTrust('0.0.0.0', ['harness.internal:3080'])
|
||||
expect(lanAddresses).toEqual(['192.168.1.5', '10.0.0.7'])
|
||||
expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080'])
|
||||
})
|
||||
|
||||
it('derives nothing for a loopback or unresolved bind — extras alone stand, no LAN URL to print', () => {
|
||||
expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] })
|
||||
expect(resolveLanTrust(undefined, ['lab.internal'])).toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('webSurfaceContextEnabled', () => {
|
||||
it('defaults to enabled and honors an explicit complete-prompt disable', () => {
|
||||
expect(webSurfaceContextEnabled(new Map())).toBe(true)
|
||||
expect(webSurfaceContextEnabled(new Map([
|
||||
['web-runtime', { config: { mode: 'production' } }],
|
||||
]))).toBe(true)
|
||||
expect(webSurfaceContextEnabled(new Map([
|
||||
['web-runtime', { config: { surfaceContext: false } }],
|
||||
]))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -3,15 +3,18 @@ import { mkdir, mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { boot, healProfilesModuleFallback, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets'
|
||||
import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
|
||||
import type {} from '@deepseek-ai/dsh-skill'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
|
||||
@@ -22,6 +25,15 @@ const BASE_PATCH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml')
|
||||
const WEB_PATCH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml')
|
||||
/** The installation anchor whose dependency surface the preset module fallback mirrors. */
|
||||
const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json')
|
||||
const MINIMAL_PROMPT = 'You are a helpful software engineer assistant.'
|
||||
const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell
|
||||
* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped.
|
||||
* You don't have access to the internet via this tool.
|
||||
* You do have access to a mirror of common linux and python packages via apt and pip.
|
||||
* State is persistent across command calls and discussions with the user.
|
||||
* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.
|
||||
* Please avoid commands that may produce a very large amount of output.
|
||||
* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.`
|
||||
|
||||
/**
|
||||
* Boot the shipped Web composition, minus the rows that would bind a port,
|
||||
@@ -89,12 +101,26 @@ async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promis
|
||||
await mkdir(profileDir, { recursive: true })
|
||||
const rootConfig = join(profileDir, 'cordis.yml')
|
||||
await writeFile(rootConfig, '[]\n')
|
||||
return await boot('dsh-test', rootConfig, patches)
|
||||
return await boot('dsh-test', rootConfig, patches, (bootCtx) => {
|
||||
provideCmdline(bootCtx, { args: [], exit: () => {} })
|
||||
})
|
||||
}
|
||||
|
||||
const toolNames = (ctx: Context, agent?: Agent): string[] =>
|
||||
ctx.tools.schemas(agent).map(schema => schema.name).sort()
|
||||
|
||||
function enablePresetTool(composition: string, id: string): string {
|
||||
const row = ` - id: ${id}\n`
|
||||
const start = composition.indexOf(row)
|
||||
if (start < 0) throw new Error(`missing preset row ${id}`)
|
||||
const end = composition.indexOf('\n - id:', start + row.length)
|
||||
const disabled = composition.indexOf(' disabled: true\n', start)
|
||||
if (disabled < 0 || (end >= 0 && disabled > end)) {
|
||||
throw new Error(`preset row ${id} is not disabled`)
|
||||
}
|
||||
return composition.slice(0, disabled) + composition.slice(disabled + ' disabled: true\n'.length)
|
||||
}
|
||||
|
||||
let ctx: Context
|
||||
beforeAll(async () => {
|
||||
const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-web-presets-')), 'settings.yaml')
|
||||
@@ -134,7 +160,7 @@ describe('the shipped Web composition', () => {
|
||||
expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
|
||||
'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
|
||||
'get_goal', 'interrupt_agent', 'list_agents', 'ralph', 'read', 'read_image', 'send_message', 'skill',
|
||||
'str_replace_editor', 'subagent', 'subagent_fork', 'task_kill',
|
||||
'subagent', 'subagent_fork', 'task_kill',
|
||||
'task_list', 'task_output', 'todo_write', 'update_goal', 'web_search',
|
||||
'workflow', 'write',
|
||||
])
|
||||
@@ -143,14 +169,30 @@ describe('the shipped Web composition', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('composes exactly two tools from `minimal`', async () => {
|
||||
it('composes the exact RL prompt and two tools from `minimal`', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-minimal'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// Exactly what the preset lists — nothing arrives from the host.
|
||||
expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
|
||||
const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent })
|
||||
expect(assembly.sections).toEqual([
|
||||
{ name: 'deployment:persona', text: MINIMAL_PROMPT },
|
||||
])
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['bash', 'str_replace_editor'])
|
||||
expect(assembly.tools.find(tool => tool.name === 'bash')?.description).toBe(MINIMAL_BASH_DESCRIPTION)
|
||||
expect(JSON.stringify(assembly.tools.find(tool => tool.name === 'str_replace_editor')?.parameters))
|
||||
.toContain('Absolute path')
|
||||
const compact = ctx.agentPresets.serviceFor(handle.agent, 'compact')
|
||||
expect(compact).toBeDefined()
|
||||
expect((compact as BasicCompactService).config).toMatchObject({
|
||||
thresholdRatio: 0.8,
|
||||
retainTokens: 20480,
|
||||
summarizationProvider: '',
|
||||
summarizationModel: '',
|
||||
maxTokens: 8192,
|
||||
compactionRetries: 1,
|
||||
})
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
@@ -190,6 +232,7 @@ describe('the shipped Web composition', () => {
|
||||
expect(tools).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount']))
|
||||
// And it keeps the standard agent's own tools rather than replacing them.
|
||||
expect(tools).toEqual(expect.arrayContaining(['bash', 'read', 'edit', 'skill']))
|
||||
expect(tools).not.toContain('str_replace_editor')
|
||||
|
||||
// The preset's own authoring skill registers into ITS layer of the host
|
||||
// registry: the cordis agent's view carries it, the global view does not.
|
||||
@@ -216,9 +259,9 @@ describe('the shipped Web composition', () => {
|
||||
// the capabilities — so the assembly is what carries the claim.
|
||||
const assembly = await ctx.systemPrompt.assemble({ scope: coded.agent })
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code'])
|
||||
expect(toolNames(ctx, coded.agent)).toContain('str_replace_editor')
|
||||
expect(toolNames(ctx, coded.agent)).not.toContain('str_replace_editor')
|
||||
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? ''
|
||||
expect(sdk).toContain('str_replace_editor')
|
||||
expect(sdk).not.toContain('str_replace_editor')
|
||||
expect(sdk).toContain('web_search')
|
||||
|
||||
// The presentation is this agent's alone: the deployment default is
|
||||
@@ -339,18 +382,96 @@ describe('the shipped Web composition', () => {
|
||||
|
||||
expect(await readFile(path, 'utf8')).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
it('gives each session its own persona', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-persona'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
||||
describe('product subagent rows in user presets', () => {
|
||||
let productCtx: Context
|
||||
const ids = ['products-none', 'products-codex', 'products-claude', 'products-both'] as const
|
||||
|
||||
beforeAll(async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-'))
|
||||
const userRoot = join(root, 'presets')
|
||||
const settingsFile = join(root, 'settings.yaml')
|
||||
const standard = await readFile(join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml'), 'utf8')
|
||||
await writeFile(settingsFile, '{}\n')
|
||||
for (const id of ids) {
|
||||
let composition = standard
|
||||
if (id === 'products-codex' || id === 'products-both') {
|
||||
composition = enablePresetTool(composition, 'tool-subagent-codex')
|
||||
}
|
||||
if (id === 'products-claude' || id === 'products-both') {
|
||||
composition = enablePresetTool(composition, 'tool-subagent-claude-code')
|
||||
}
|
||||
const directory = join(userRoot, id)
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(join(directory, 'agent.cordis.yml'), composition)
|
||||
}
|
||||
productCtx = await bootWeb(settingsFile, [{
|
||||
id: 'agent-presets',
|
||||
config: {
|
||||
default: 'standard',
|
||||
roots: [
|
||||
{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' },
|
||||
{ path: userRoot, trust: 'user' },
|
||||
],
|
||||
},
|
||||
}])
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await productCtx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('composes none, either product, or both without changing the shared host registry', async () => {
|
||||
const expected = new Map<string, string[]>([
|
||||
['products-none', []],
|
||||
['products-codex', ['subagent_codex']],
|
||||
['products-claude', ['subagent_claude_code']],
|
||||
['products-both', ['subagent_claude_code', 'subagent_codex']],
|
||||
])
|
||||
expect(productCtx.subagents.list()).toEqual(expect.arrayContaining([
|
||||
'spawn', 'fork', 'codex', 'claude-code',
|
||||
]))
|
||||
|
||||
for (const [id, productTools] of expected) {
|
||||
const handle = await productCtx.agents.create({
|
||||
sessionId: SessionId(`preset-${id}`),
|
||||
setup: agentCtx => productCtx.agentPresets.mount(agentCtx, id).then(() => undefined),
|
||||
})
|
||||
try {
|
||||
const tools = toolNames(productCtx, handle.agent)
|
||||
expect(tools.filter(name => name === 'subagent_codex' || name === 'subagent_claude_code'))
|
||||
.toEqual(productTools)
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('applies a product-row edit only to later sessions on the preset', async () => {
|
||||
const preset = await productCtx.agentPresets.resolve('products-none')
|
||||
const original = await readFile(preset.path, 'utf8')
|
||||
const existing = await productCtx.agents.create({
|
||||
sessionId: SessionId('preset-product-generation-existing'),
|
||||
setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent })
|
||||
expect(assembly.sections.find(section => section.name === 'deployment:persona')?.text)
|
||||
.toContain('You are a coding agent powered by')
|
||||
expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex')
|
||||
await writeFile(preset.path, enablePresetTool(original, 'tool-subagent-codex'))
|
||||
|
||||
const later = await productCtx.agents.create({
|
||||
sessionId: SessionId('preset-product-generation-later'),
|
||||
setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex')
|
||||
expect(toolNames(productCtx, later.agent)).toContain('subagent_codex')
|
||||
} finally {
|
||||
await later.dispose()
|
||||
}
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
await existing.dispose()
|
||||
await writeFile(preset.path, original)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -422,6 +543,59 @@ describe('a forked session', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('a delegated child', () => {
|
||||
it('runs on the composition its parent runs on', async () => {
|
||||
const parent = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-child-parent'),
|
||||
meta: { agentPreset: 'standard' },
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
// Exactly what an in-process subagent driver's creation window does.
|
||||
const child = await parent.agent.ctx.agents.create({
|
||||
sessionId: SessionId('preset-child'),
|
||||
meta: childSessionMeta(parent.agent, 1, 0),
|
||||
setup: (agentCtx) => {
|
||||
applyChildComposition(agentCtx, parent.agent, {})
|
||||
},
|
||||
})
|
||||
try {
|
||||
expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent))
|
||||
// The shipped `standard` preset is the whole coding agent; an empty
|
||||
// child here is the defect, and equality alone would not catch it.
|
||||
expect(toolNames(ctx, child.agent)).toContain('bash')
|
||||
expect(child.agent.session.header.agentPreset).toBe('standard')
|
||||
} finally {
|
||||
await child.dispose()
|
||||
await parent.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('follows a parent that switched preset while blank', async () => {
|
||||
const parent = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-child-switch-parent'),
|
||||
meta: { agentPreset: 'standard' },
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
await ctx.agentPresets.recompose(parent.agent.ctx, 'minimal')
|
||||
const child = await parent.agent.ctx.agents.create({
|
||||
sessionId: SessionId('preset-child-switch'),
|
||||
meta: childSessionMeta(parent.agent, 1, 0),
|
||||
setup: (agentCtx) => {
|
||||
applyChildComposition(agentCtx, parent.agent, {})
|
||||
},
|
||||
})
|
||||
try {
|
||||
// The live scope chain is the authority, not the parent's creation
|
||||
// header — which still names `standard`.
|
||||
expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent))
|
||||
expect(child.agent.session.header.agentPreset).toBe('minimal')
|
||||
} finally {
|
||||
await child.dispose()
|
||||
await parent.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('authoring a preset on the shipped composition', () => {
|
||||
let authorCtx: Context
|
||||
let userRoot: string
|
||||
|
||||
Reference in New Issue
Block a user