Merge remote-tracking branch 'origin/master' into worktree/drop-create-by-name
# Conflicts: # .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml # .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml # .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md # .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml # .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md # .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md # apps/cli/reference/README.i18n.yaml # docs/config-catalog.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/api-proxy-approval.spec.ts # packages/host/apiproxy/tests/api-proxy-blank.spec.ts # packages/host/apiproxy/tests/api-proxy-cold.spec.ts # packages/host/apiproxy/tests/api-proxy-commands.spec.ts # packages/host/apiproxy/tests/api-proxy-config.spec.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/host/apiproxy/tests/api-proxy-projections.spec.ts # packages/host/apiproxy/tests/api-proxy-question.spec.ts # packages/host/apiproxy/tests/api-proxy-rename.spec.ts # packages/host/apiproxy/tests/api-proxy-search.spec.ts # packages/host/apiproxy/tests/api-proxy-subagents.spec.ts # packages/host/apiproxy/tests/api-proxy-view.spec.ts # packages/host/apiproxy/tests/api-proxy-workspace.spec.ts # packages/todo/tool-todo/tests/projection.spec.ts # scripts/hero-composer-dom-continuity.mjs
This commit is contained in:
@@ -21,12 +21,16 @@ function exitCode(argv: string[]): number {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
describe('parseDshArgs', () => {
|
||||
it('routes profile boots, one-shot tasks, and the web alias', () => {
|
||||
it('routes profile boots, one-shot runs, and the web alias', () => {
|
||||
expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [] })
|
||||
expect(parse(['--profile', 'headless', 'run', 'the', 'tests']))
|
||||
.toEqual({ mode: 'profile', profile: 'headless', patches: [], task: 'run the tests' })
|
||||
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'] })
|
||||
expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev']))
|
||||
@@ -60,11 +64,18 @@ describe('parseDshArgs', () => {
|
||||
.toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: true, patches: [] })
|
||||
})
|
||||
|
||||
it('rejects missing profile, removed flags, and contradictory inputs', () => {
|
||||
it('rejects missing profile, flags outside the current grammar, 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) // removed
|
||||
expect(exitCode(['-p', 'task'])).toBe(1) // removed
|
||||
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(['--profile', ''])).toBe(1)
|
||||
expect(exitCode(['--profile', 'x', '--patch='])).toBe(1)
|
||||
expect(exitCode(['--dump-config'])).toBe(1)
|
||||
@@ -90,6 +101,7 @@ describe('parseDshArgs', () => {
|
||||
|
||||
it('exits 0 for help and version', () => {
|
||||
expect(exitCode(['--help'])).toBe(0)
|
||||
expect(exitCode(['run', '--help'])).toBe(0)
|
||||
expect(exitCode(['--version'])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
|
||||
import { execa } from 'execa'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
@@ -13,14 +14,21 @@ const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordi
|
||||
|
||||
async function runBuiltBin(
|
||||
args: readonly string[] = [],
|
||||
env: Record<string, string> = {},
|
||||
env: Readonly<Record<string, string | undefined>> = {},
|
||||
cwd?: string,
|
||||
): Promise<{ stdout: string; code: number; stderr: string }> {
|
||||
const childEnv = Object.fromEntries(
|
||||
Object.entries({ ...process.env, ...env })
|
||||
.filter((entry): entry is [string, string] => entry[1] !== undefined),
|
||||
)
|
||||
const result = await execa(process.execPath, [dshBin, ...args], {
|
||||
input: '',
|
||||
timeout: 25_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
env,
|
||||
env: childEnv,
|
||||
extendEnv: false,
|
||||
...cwd === undefined ? {} : { cwd },
|
||||
})
|
||||
if (result.timedOut) {
|
||||
throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
@@ -41,6 +49,7 @@ interface ProfileLifecycleFixture {
|
||||
ready: string
|
||||
settled: string
|
||||
disposed: string
|
||||
interrupt: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,16 +62,23 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture {
|
||||
const ready = join(home, 'ready')
|
||||
const settled = join(home, 'settled')
|
||||
const disposed = join(home, 'disposed')
|
||||
const interrupt = join(home, 'interrupt')
|
||||
const bundleDir = join(home, 'lifecycle-bundle')
|
||||
mkdirSync(bundleDir, { recursive: true })
|
||||
writeFileSync(join(bundleDir, 'plugin.mjs'), [
|
||||
"import { writeFileSync } from 'node:fs'",
|
||||
"import { existsSync, writeFileSync } from 'node:fs'",
|
||||
"import { join } from 'node:path'",
|
||||
"export const name = 'profile-lifecycle-fixture'",
|
||||
'export function apply(ctx, config = {}) {',
|
||||
' let active = true',
|
||||
' // Keep the event loop alive so process lifetime is signal-owned, like a real surface.',
|
||||
' const heartbeat = setInterval(() => {}, 1000)',
|
||||
' // Windows has no deliverable SIGTERM; the marker emits the same process event there.',
|
||||
' let interrupted = false',
|
||||
' const heartbeat = setInterval(() => {',
|
||||
' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return',
|
||||
' interrupted = true',
|
||||
" process.emit('SIGTERM')",
|
||||
' }, 20)',
|
||||
' // Echo the mounted generation so the hot-reload e2e can assert both an',
|
||||
' // applied override and its removal reverting to this bundle default.',
|
||||
" writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
|
||||
@@ -110,7 +126,7 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture {
|
||||
for (const file of ['package.json', 'cordis.patch.yml', 'plugin.mjs']) {
|
||||
writeFileSync(join(linkTarget, file), readFileSync(join(bundleDir, file)))
|
||||
}
|
||||
return { home, ready, settled, disposed }
|
||||
return { home, ready, settled, disposed, interrupt }
|
||||
}
|
||||
|
||||
function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
|
||||
@@ -123,12 +139,63 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
|
||||
RAW_READY_FILE: fixture.ready,
|
||||
RAW_SETTLED_FILE: fixture.settled,
|
||||
RAW_DISPOSED_FILE: fixture.disposed,
|
||||
RAW_INTERRUPT_FILE: fixture.interrupt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function requestProfileShutdown(
|
||||
child: ReturnType<typeof startProfileLifecycle>,
|
||||
fixture: ProfileLifecycleFixture,
|
||||
): void {
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(fixture.interrupt, 'interrupt')
|
||||
return
|
||||
}
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
|
||||
function createEnvironmentProbeProfile(home: string, project: string): void {
|
||||
const pluginFile = join(project, 'environment-probe.mjs')
|
||||
writeFileSync(pluginFile, [
|
||||
"export const name = 'environment-probe'",
|
||||
"export const inject = ['llm']",
|
||||
'export function apply(ctx) {',
|
||||
' void ctx.loader.await().then(async () => {',
|
||||
" let text = ''",
|
||||
' for await (const chunk of ctx.llm.stream({',
|
||||
" provider: 'deepseek-official',",
|
||||
" model: 'deepseek-v4-flash',",
|
||||
' messages: [],',
|
||||
' maxTokens: 32,',
|
||||
' })) {',
|
||||
" if (chunk.type === 'text-delta') text += chunk.text",
|
||||
' }',
|
||||
' process.stdout.write(`${text}\\n`)',
|
||||
" if (process.platform === 'win32') process.emit('SIGTERM')",
|
||||
" else process.kill(process.pid, 'SIGTERM')",
|
||||
' })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
const profileDir = join(home, 'profiles', 'environment-probe')
|
||||
mkdirSync(profileDir, { recursive: true })
|
||||
writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
|
||||
name: 'dsh-profile-environment-probe',
|
||||
private: true,
|
||||
dependencies: {},
|
||||
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
|
||||
}, undefined, 2))
|
||||
writeFileSync(join(profileDir, 'cordis.patch.yml'), [
|
||||
'- insert:',
|
||||
' - id: environment-probe',
|
||||
` name: ${pathToFileURL(pluginFile).href}`,
|
||||
'',
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
it('requires --profile and rejects removed commands', async () => {
|
||||
it('requires --profile and rejects inputs outside the current grammar', async () => {
|
||||
const bare = await runBuiltBin()
|
||||
expect(bare.code).toBe(1)
|
||||
expect(bare.stdout).toBe('')
|
||||
@@ -136,14 +203,67 @@ 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 removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task']]) {
|
||||
const result = await runBuiltBin(removed)
|
||||
for (const outsideGrammar of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['--profile', 'headless', 'task']]) {
|
||||
const result = await runBuiltBin(outsideGrammar)
|
||||
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')
|
||||
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 })
|
||||
}
|
||||
})
|
||||
|
||||
it('runs the default headless profile through the published run command', async () => {
|
||||
const apiKey = 'built-dsh-run-key'
|
||||
const server = await startMockLlmServer({
|
||||
sequence: ['success'],
|
||||
apiKey,
|
||||
successText: 'published dsh run reached the mock',
|
||||
})
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-built-run-'))
|
||||
try {
|
||||
const result = await runBuiltBin(['run', '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.stderr).toBe('')
|
||||
expect(server.requests.length).toBeGreaterThan(0)
|
||||
expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true)
|
||||
expect(JSON.stringify(server.requests.map(request => request.body))).toContain('answer from the published entry')
|
||||
} finally {
|
||||
await server.close()
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('does not load a project environment for --version', async () => {
|
||||
const project = mkdtempSync(join(tmpdir(), 'dsh-version-project-'))
|
||||
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: '' })
|
||||
} finally {
|
||||
rmSync(project, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fails loud on a nonexistent profile with the plugin-command hint', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-missing-profile-'))
|
||||
try {
|
||||
@@ -156,10 +276,51 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('uses the Harness-home environment and managed credential through the published entry', async () => {
|
||||
const apiKey = 'built-home-layer-key'
|
||||
const server = await startMockLlmServer({
|
||||
sequence: ['success'],
|
||||
apiKey,
|
||||
successText: 'home environment reached the mock',
|
||||
})
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-home-environment-'))
|
||||
const project = mkdtempSync(join(tmpdir(), 'dsh-home-project-'))
|
||||
writeFileSync(join(home, '.env'), `DEEPSEEK_BASE_URL=${server.baseURL}\n`)
|
||||
writeFileSync(join(home, '.credentials.yaml'), `DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 })
|
||||
createEnvironmentProbeProfile(home, project)
|
||||
try {
|
||||
const result = await runBuiltBin(
|
||||
['--profile', 'environment-probe'],
|
||||
{
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DEEPSEEK_API_KEY: undefined,
|
||||
DEEPSEEK_BASE_URL: undefined,
|
||||
},
|
||||
project,
|
||||
)
|
||||
expect(
|
||||
result.code,
|
||||
`${result.stderr}\nstdout:\n${result.stdout}\nmock requests: ${String(server.requests.length)}`,
|
||||
).toBe(0)
|
||||
expect(result.stdout).toBe('home environment reached the mock')
|
||||
expect(result.stdout).not.toContain(apiKey)
|
||||
expect(result.stderr).not.toContain(apiKey)
|
||||
expect(server.requests).toHaveLength(1)
|
||||
expect(server.requests[0]?.path).toBe('/chat/completions')
|
||||
expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${apiKey}`)
|
||||
expect(JSON.stringify(server.requests[0]?.body)).not.toContain(apiKey)
|
||||
} finally {
|
||||
await server.close()
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
rmSync(project, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('reports a patch-overlay boot failure without hanging', async () => {
|
||||
// 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
|
||||
// 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
|
||||
// ([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 {
|
||||
@@ -181,9 +342,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
const child = startProfileLifecycle(fixture)
|
||||
try {
|
||||
await waitForFile(fixture.ready)
|
||||
child.kill('SIGTERM')
|
||||
requestProfileShutdown(child, fixture)
|
||||
const result = await child
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
|
||||
expect(result.signal).toBeUndefined()
|
||||
expect(existsSync(fixture.disposed)).toBe(true)
|
||||
} finally {
|
||||
@@ -233,9 +394,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
].join('\n'))
|
||||
await waitForFile(fixture.ready)
|
||||
expect(readFileSync(configFile, 'utf8')).toBe('home')
|
||||
child.kill('SIGTERM')
|
||||
requestProfileShutdown(child, fixture)
|
||||
const result = await child
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
|
||||
expect(result.signal).toBeUndefined()
|
||||
expect(existsSync(fixture.disposed)).toBe(true)
|
||||
} finally {
|
||||
@@ -330,6 +491,20 @@ 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 () => {
|
||||
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.toContain("name: '@deepseek-ai/dsh-web-app'")
|
||||
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-client-")
|
||||
}, 30_000)
|
||||
|
||||
it('composes the profile user layer and a --patch overlay in order', async () => {
|
||||
// Auto-init the web profile first, then write its user layer.
|
||||
const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
|
||||
@@ -364,7 +539,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
expect(code).toBe(0)
|
||||
expect(stdout).toContain('provider: configured-provider')
|
||||
expect(stdout).not.toContain('personal-provider')
|
||||
// Both layers patched the row; provenance lists them in application order.
|
||||
// Both layers patched the row; the comment lists them in application order.
|
||||
expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`)
|
||||
expect(stderr).toContain('patch: entry "absent-row" not found')
|
||||
}, 30_000)
|
||||
|
||||
176
apps/cli/tests/dsh-badge.snapshot.ts
Normal file
176
apps/cli/tests/dsh-badge.snapshot.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
const binScript = fileURLToPath(new URL('./fixtures/dsh-badge/snapshot.ts', import.meta.url))
|
||||
const configPath = fileURLToPath(new URL('./fixtures/dsh-badge/cordis.yml', import.meta.url))
|
||||
const defaultConfigPath = fileURLToPath(new URL('./fixtures/dsh-badge/default.cordis.yml', import.meta.url))
|
||||
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
|
||||
const badgeAssetsPath = fileURLToPath(new URL('../../../packages/skill/skill-badge/assets/', import.meta.url))
|
||||
|
||||
describe('dsh badge assembled snapshot', () => {
|
||||
it('advertises and loads the opt-in bundled skill through the shipped app', async () => {
|
||||
const disabled = await runLoaderSmoke({
|
||||
label: 'disabled dsh badge skill snapshot',
|
||||
tempDirPrefix: 'headless-snapshot-dsh-badge-disabled-',
|
||||
binScript,
|
||||
libBinScript: binScript,
|
||||
configPath: defaultConfigPath,
|
||||
tsconfigPath,
|
||||
})
|
||||
const enabled = await runLoaderSmoke({
|
||||
label: 'dsh badge skill snapshot',
|
||||
tempDirPrefix: 'headless-snapshot-dsh-badge-',
|
||||
binScript,
|
||||
libBinScript: binScript,
|
||||
configPath,
|
||||
tsconfigPath,
|
||||
})
|
||||
const disabledSnapshot = JSON.parse(disabled.stdout) as unknown
|
||||
const enabledSnapshot = JSON.parse(
|
||||
enabled.stdout.replaceAll(badgeAssetsPath, '{{badgeAssetsPath}}'),
|
||||
) as unknown
|
||||
|
||||
expect(disabled.stderr).toBe('')
|
||||
expect(enabled.stderr).toBe('')
|
||||
expect(disabledSnapshot).toMatchInlineSnapshot(`
|
||||
{
|
||||
"catalog": null,
|
||||
"result": {
|
||||
"content": [
|
||||
{
|
||||
"text": "Error: skill "dsh-badge" is unknown or no longer available",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"error": {
|
||||
"message": "skill "dsh-badge" is unknown or no longer available",
|
||||
},
|
||||
"isError": true,
|
||||
},
|
||||
"summary": null,
|
||||
}
|
||||
`)
|
||||
expect(enabledSnapshot).toMatchInlineSnapshot(`
|
||||
{
|
||||
"catalog": [
|
||||
{
|
||||
"text": "<system-reminder>
|
||||
A skill is a reusable set of task-specific instructions. The following skills are available in this session:
|
||||
|
||||
<available_skills>
|
||||
- \`dsh-badge\`: Add the official “powered by dsh” badge to documents, pull requests, merge requests, and other content produced with DeepSeek Harness. Use whenever creating a pull request or merge request. Also use when the user asks for a dsh badge, powered-by-dsh attribution, or a reusable dsh badge asset or snippet.
|
||||
</available_skills>
|
||||
|
||||
If the user names a skill, or the task clearly matches a skill's description, call the \`skill\` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.
|
||||
A user may also invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the \`skill\` tool again for that skill.
|
||||
</system-reminder>",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"result": {
|
||||
"content": [
|
||||
{
|
||||
"text": "<skill_content name="dsh-badge">
|
||||
<skill_resources>
|
||||
Base directory for this skill: {{badgeAssetsPath}}
|
||||
Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.
|
||||
</skill_resources>
|
||||
|
||||
<skill_instructions>
|
||||
# dsh Badge
|
||||
|
||||
Add the official “powered by dsh” badge without recreating or restyling it.
|
||||
|
||||
## Assets
|
||||
|
||||
- Local PNG: [\`dsh-badge.png\`](dsh-badge.png), 726×120 source image; render at 121×20
|
||||
- Shields.io image URL: \`https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white\`
|
||||
- Project URL: \`https://github.com/deepseek-ai/deepseek-harness-sdk\`
|
||||
|
||||
## Markdown
|
||||
|
||||
Use this linked badge in Markdown:
|
||||
|
||||
\`\`\`markdown
|
||||
[](https://github.com/deepseek-ai/deepseek-harness-sdk)
|
||||
\`\`\`
|
||||
|
||||
If attribution should not be linked, use:
|
||||
|
||||
\`\`\`markdown
|
||||

|
||||
\`\`\`
|
||||
|
||||
## Usage rules
|
||||
|
||||
- For GitHub or GitLab Markdown, use the Shields.io URL and link it to the project URL unless the user asks for an unlinked image.
|
||||
- For Feishu and other systems that import remote images unreliably, upload \`dsh-badge.png\` from this skill directory instead of generating another badge.
|
||||
- Preserve the badge's 121×20 dimensions and aspect ratio.
|
||||
- Place the badge at the end of the attributed document or section unless the user specifies another position.
|
||||
- Do not substitute another color, logo, label, or project URL.
|
||||
|
||||
</skill_instructions>
|
||||
</skill_content>",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"isError": false,
|
||||
"value": {
|
||||
"content": "# dsh Badge
|
||||
|
||||
Add the official “powered by dsh” badge without recreating or restyling it.
|
||||
|
||||
## Assets
|
||||
|
||||
- Local PNG: [\`dsh-badge.png\`](dsh-badge.png), 726×120 source image; render at 121×20
|
||||
- Shields.io image URL: \`https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white\`
|
||||
- Project URL: \`https://github.com/deepseek-ai/deepseek-harness-sdk\`
|
||||
|
||||
## Markdown
|
||||
|
||||
Use this linked badge in Markdown:
|
||||
|
||||
\`\`\`markdown
|
||||
[](https://github.com/deepseek-ai/deepseek-harness-sdk)
|
||||
\`\`\`
|
||||
|
||||
If attribution should not be linked, use:
|
||||
|
||||
\`\`\`markdown
|
||||

|
||||
\`\`\`
|
||||
|
||||
## Usage rules
|
||||
|
||||
- For GitHub or GitLab Markdown, use the Shields.io URL and link it to the project URL unless the user asks for an unlinked image.
|
||||
- For Feishu and other systems that import remote images unreliably, upload \`dsh-badge.png\` from this skill directory instead of generating another badge.
|
||||
- Preserve the badge's 121×20 dimensions and aspect ratio.
|
||||
- Place the badge at the end of the attributed document or section unless the user specifies another position.
|
||||
- Do not substitute another color, logo, label, or project URL.
|
||||
",
|
||||
"name": "dsh-badge",
|
||||
"provider": "dsh-badge",
|
||||
"resourceBase": {
|
||||
"kind": "directory",
|
||||
"path": "{{badgeAssetsPath}}",
|
||||
},
|
||||
},
|
||||
},
|
||||
"summary": {
|
||||
"description": "Add the official “powered by dsh” badge to documents, pull requests, merge requests, and other content produced with DeepSeek Harness. Use whenever creating a pull request or merge request. Also use when the user asks for a dsh badge, powered-by-dsh attribution, or a reusable dsh badge asset or snippet.",
|
||||
"invocation": {
|
||||
"modelInvocable": true,
|
||||
"userInvocable": true,
|
||||
},
|
||||
"name": "dsh-badge",
|
||||
"provider": "dsh-badge",
|
||||
"resourceBase": {
|
||||
"kind": "directory",
|
||||
"path": "{{badgeAssetsPath}}",
|
||||
},
|
||||
"source": "bundled",
|
||||
},
|
||||
}
|
||||
`)
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS * 2)
|
||||
})
|
||||
9
apps/cli/tests/fixtures/dsh-badge/cordis.yml
vendored
Normal file
9
apps/cli/tests/fixtures/dsh-badge/cordis.yml
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
- id: skill-badge
|
||||
disabled: false
|
||||
|
||||
- id: skill-local
|
||||
config:
|
||||
watch: false
|
||||
|
||||
- id: telemetry-otel
|
||||
disabled: true
|
||||
6
apps/cli/tests/fixtures/dsh-badge/default.cordis.yml
vendored
Normal file
6
apps/cli/tests/fixtures/dsh-badge/default.cordis.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
- id: skill-local
|
||||
config:
|
||||
watch: false
|
||||
|
||||
- id: telemetry-otel
|
||||
disabled: true
|
||||
56
apps/cli/tests/fixtures/dsh-badge/snapshot.ts
vendored
Normal file
56
apps/cli/tests/fixtures/dsh-badge/snapshot.ts
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Context } from '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'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-skill'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
|
||||
const overlayPath = process.argv[2]
|
||||
if (overlayPath === undefined) throw new Error('dsh-badge snapshot requires an overlay path')
|
||||
const rootConfigPath = fileURLToPath(new URL('../../../../../packages/bundle/base/tests/fixtures/root.cordis.yml', import.meta.url))
|
||||
const basePatchPath = fileURLToPath(new URL('../../../../../packages/bundle/base/cordis.patch.yml', import.meta.url))
|
||||
const ctx = await boot('dsh-badge-snapshot', rootConfigPath, [
|
||||
...loadOverlayPatches('dsh-badge-snapshot', basePatchPath),
|
||||
...loadOverlayPatches('dsh-badge-snapshot', overlayPath),
|
||||
])
|
||||
|
||||
try {
|
||||
const agentId = SessionId('dsh-badge-snapshot')
|
||||
const session = ctx.sessions.create(agentId, { meta: { cwd: process.cwd() } })
|
||||
const agent: Agent = {
|
||||
ctx: new Context(),
|
||||
id: agentId,
|
||||
options: {},
|
||||
session,
|
||||
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
||||
status: 'idle',
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject: () => { throw new Error('dsh-badge snapshot must receive the catalog at the step boundary') },
|
||||
cancel: () => {},
|
||||
runMaintenance: task => task(new AbortController().signal),
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
const decision = await agentEvents(ctx, agent).waterfall(
|
||||
'agent/pre-step',
|
||||
{ messages: [], turn: 1, step: 1, signal: new AbortController().signal },
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
|
||||
)
|
||||
const catalog = decision.kind === 'enter'
|
||||
? decision.messages.find(message => message.role === 'user'
|
||||
&& message.source.kind === 'skill-catalog')?.content
|
||||
: undefined
|
||||
const summary = (await ctx.skills.list()).find(skill => skill.name === 'dsh-badge')
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('dsh-badge-snapshot'),
|
||||
name: 'skill',
|
||||
arguments: { name: 'dsh-badge' },
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
process.stdout.write(`${JSON.stringify({ catalog: catalog ?? null, summary: summary ?? null, result })}\n`)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
10
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/.mcp.json
vendored
Normal file
10
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/.mcp.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"github_repository": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"lib/mcp-server.mjs"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
30
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json
vendored
Normal file
30
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
19
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/mcp-server.ts
vendored
Normal file
19
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/mcp-server.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
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())
|
||||
59
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/plugin.ts
vendored
Normal file
59
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/plugin.ts
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
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 },
|
||||
}
|
||||
})
|
||||
}
|
||||
13
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/tsconfig.json
vendored
Normal file
13
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/tsconfig.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
6
apps/cli/tests/fixtures/github-repository-plugin/skills/github-source-proof/SKILL.md
vendored
Normal file
6
apps/cli/tests/fixtures/github-repository-plugin/skills/github-source-proof/SKILL.md
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
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.
|
||||
1
apps/cli/tests/fixtures/never-dispose.mjs
vendored
1
apps/cli/tests/fixtures/never-dispose.mjs
vendored
@@ -8,6 +8,7 @@ import { existsSync } from 'node:fs'
|
||||
*/
|
||||
export function apply(ctx) {
|
||||
const keepAlive = setInterval(() => {}, 60_000)
|
||||
process.stderr.write('dsh-test: never-dispose ready\n')
|
||||
ctx.effect(() => async () => {
|
||||
clearInterval(keepAlive)
|
||||
const armFile = process.env.DSH_TEST_SHUTDOWN_ARM_FILE
|
||||
|
||||
256
apps/cli/tests/github-repository-plugin.built.e2e.ts
Normal file
256
apps/cli/tests/github-repository-plugin.built.e2e.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
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)
|
||||
})
|
||||
@@ -22,7 +22,7 @@ if pid == 0:
|
||||
os.chdir(cwd)
|
||||
os.execvpe(node, [node, *json.loads(launch_args_json)], env)
|
||||
|
||||
markers = [b"dsh: observing at ", b"dsh-test: never-dispose started"]
|
||||
markers = [b"dsh-test: never-dispose ready", b"dsh-test: never-dispose started"]
|
||||
output = bytearray()
|
||||
marker_index = 0
|
||||
deadline = time.monotonic() + float(timeout_seconds)
|
||||
@@ -66,14 +66,14 @@ async function runHeadlessPtySmoke(): Promise<string> {
|
||||
try {
|
||||
const home = join(cwd, '.dsh')
|
||||
// Pre-initialize the headless profile with the never-dispose row in its
|
||||
// user patch layer (the same file `dsh --profile headless` hot-reloads).
|
||||
// user patch layer (the same file a long-lived profile boot hot-reloads).
|
||||
const profileDir = join(home, 'profiles', 'headless')
|
||||
await mkdir(profileDir, { recursive: true })
|
||||
await writeFile(join(profileDir, 'package.json'), JSON.stringify({
|
||||
name: 'dsh-profile-headless',
|
||||
private: true,
|
||||
dependencies: {},
|
||||
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless'] } },
|
||||
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'] } },
|
||||
}, undefined, 2))
|
||||
await writeFile(join(profileDir, 'cordis.patch.yml'), [
|
||||
'- insert:',
|
||||
@@ -83,7 +83,7 @@ async function runHeadlessPtySmoke(): Promise<string> {
|
||||
].join('\n'))
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: dshBinScript,
|
||||
configArgs: ['--profile', 'headless', 'never complete'],
|
||||
configArgs: ['run', 'never complete'],
|
||||
tsconfigPath,
|
||||
env: {
|
||||
DSH_HOME: home,
|
||||
@@ -124,7 +124,8 @@ async function runHeadlessPtySmoke(): Promise<string> {
|
||||
describe.skipIf(process.platform === 'win32')('headless process shutdown (real Loader tree in a PTY)', () => {
|
||||
it('lets a second Ctrl+C force exit while the first signal is draining', async () => {
|
||||
const output = await runHeadlessPtySmoke()
|
||||
expect(output).toContain('dsh: observing at ')
|
||||
expect(output).not.toContain('dsh: observing at ')
|
||||
expect(output).toContain('dsh-test: never-dispose ready')
|
||||
expect(output).toContain('dsh-test: never-dispose started')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
@@ -20,35 +20,50 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('process shutdown', () => {
|
||||
it('exits once after graceful disposal resolves or rejects', async () => {
|
||||
it('completes naturally after disposal resolves and forces exit when it rejects', async () => {
|
||||
const resolvedExit = vi.fn()
|
||||
const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit)
|
||||
const resolvedComplete = vi.fn()
|
||||
const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit, resolvedComplete)
|
||||
await resolved.shutdown(0)
|
||||
expect(resolvedExit).toHaveBeenCalledOnce()
|
||||
expect(resolvedExit).toHaveBeenCalledWith(0)
|
||||
expect(resolvedComplete).toHaveBeenCalledOnce()
|
||||
expect(resolvedComplete).toHaveBeenCalledWith(0)
|
||||
expect(resolvedExit).not.toHaveBeenCalled()
|
||||
|
||||
const rejectedExit = vi.fn()
|
||||
const rejected = createProcessShutdown(() => Promise.reject(new Error('dispose failed')), rejectedExit)
|
||||
const rejectedComplete = vi.fn()
|
||||
const rejected = createProcessShutdown(
|
||||
() => Promise.reject(new Error('dispose failed')),
|
||||
rejectedExit,
|
||||
rejectedComplete,
|
||||
)
|
||||
await rejected.shutdown(1)
|
||||
expect(rejectedExit).toHaveBeenCalledOnce()
|
||||
expect(rejectedExit).toHaveBeenCalledWith(1)
|
||||
expect(rejectedComplete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses process.exit as the default process boundary', async () => {
|
||||
it('uses process.exitCode for default normal completion', async () => {
|
||||
const exit = vi.spyOn(process, 'exit').mockImplementation(_code => undefined as never)
|
||||
const originalExitCode = process.exitCode
|
||||
process.exitCode = undefined
|
||||
const shutdown = createProcessShutdown(() => Promise.resolve())
|
||||
|
||||
await shutdown.shutdown(7)
|
||||
try {
|
||||
await shutdown.shutdown(7)
|
||||
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledWith(7)
|
||||
expect(process.exitCode).toBe(7)
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
process.exitCode = originalExitCode
|
||||
}
|
||||
})
|
||||
|
||||
it('forces exit when graceful disposal reaches its bound', async () => {
|
||||
vi.useFakeTimers()
|
||||
const disposal = deferred()
|
||||
const exit = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit)
|
||||
const complete = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
|
||||
const pending = shutdown.shutdown(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(PROCESS_SHUTDOWN_TIMEOUT_MS - 1)
|
||||
@@ -60,13 +75,14 @@ describe('process shutdown', () => {
|
||||
disposal.resolve()
|
||||
await pending
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(complete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors a caller-supplied grace period', async () => {
|
||||
vi.useFakeTimers()
|
||||
const disposal = deferred()
|
||||
const exit = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit, 25)
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit, vi.fn(), 25)
|
||||
const pending = shutdown.shutdown(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(24)
|
||||
@@ -81,7 +97,8 @@ describe('process shutdown', () => {
|
||||
it('lets Ctrl+C force a normal shutdown already stuck in disposal', async () => {
|
||||
const disposal = deferred()
|
||||
const exit = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit)
|
||||
const complete = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
|
||||
const pending = shutdown.shutdown(0)
|
||||
|
||||
shutdown.interrupt(130)
|
||||
@@ -91,13 +108,29 @@ describe('process shutdown', () => {
|
||||
disposal.resolve()
|
||||
await pending
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(complete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forces exit after disposal started by a signal', async () => {
|
||||
const disposal = deferred()
|
||||
const exit = vi.fn()
|
||||
const complete = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
|
||||
|
||||
shutdown.interrupt(143)
|
||||
disposal.resolve()
|
||||
await shutdown.shutdown(0)
|
||||
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledWith(143)
|
||||
expect(complete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drains on the first signal and forces on the second signal', async () => {
|
||||
const disposal = deferred()
|
||||
const dispose = vi.fn(() => disposal.promise)
|
||||
const exit = vi.fn()
|
||||
const shutdown = createProcessShutdown(dispose, exit)
|
||||
const shutdown = createProcessShutdown(dispose, exit, vi.fn())
|
||||
|
||||
shutdown.interrupt(143)
|
||||
await Promise.resolve()
|
||||
@@ -116,7 +149,8 @@ describe('process shutdown', () => {
|
||||
it('coalesces normal shutdown calls without treating them as escalation', async () => {
|
||||
const disposal = deferred()
|
||||
const exit = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit)
|
||||
const complete = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
|
||||
|
||||
const first = shutdown.shutdown(0)
|
||||
const second = shutdown.shutdown(1)
|
||||
@@ -125,7 +159,21 @@ describe('process shutdown', () => {
|
||||
|
||||
disposal.resolve()
|
||||
await first
|
||||
expect(complete).toHaveBeenCalledOnce()
|
||||
expect(complete).toHaveBeenCalledWith(0)
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets a signal force exit while natural completion drains remaining handles', async () => {
|
||||
const exit = vi.fn()
|
||||
const complete = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => Promise.resolve(), exit, complete)
|
||||
|
||||
await shutdown.shutdown(0)
|
||||
shutdown.interrupt(130)
|
||||
|
||||
expect(complete).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledWith(0)
|
||||
expect(exit).toHaveBeenCalledWith(130)
|
||||
})
|
||||
})
|
||||
|
||||
547
apps/cli/tests/web-agent-presets.e2e.ts
Normal file
547
apps/cli/tests/web-agent-presets.e2e.ts
Normal file
@@ -0,0 +1,547 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
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 { boot, healProfilesModuleFallback, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
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 { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-skill'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
|
||||
const CONFIG_DIR = fileURLToPath(new URL('../config/', import.meta.url))
|
||||
const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
/** The shipped Web surface: the dsh-base and dsh-web-app bundle patches over an empty preset root. */
|
||||
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')
|
||||
|
||||
/**
|
||||
* Boot the shipped Web composition, minus the rows that would bind a port,
|
||||
* touch the network, or write outside the test. Everything that decides an
|
||||
* agent's capabilities is the real thing, including both shipped presets.
|
||||
*/
|
||||
async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promise<Context> {
|
||||
const storageRoot = join(dirname(settingsFile), 'storages')
|
||||
const patches: PatchOptions[] = [
|
||||
...loadOverlayPatches('dsh-test', BASE_PATCH),
|
||||
...loadOverlayPatches('dsh-test', WEB_PATCH),
|
||||
// The settings row defaults to `$DSH_HOME/settings.yaml`. Left alone it
|
||||
// reads the developer's own document — and since the default preset is a
|
||||
// setting, a stored `agent-presets.default` would decide this file's
|
||||
// outcome. Point it at a temp file for the same reason the roster below
|
||||
// names only the shipped root.
|
||||
{ id: 'settings', config: { path: settingsFile, watch: false } },
|
||||
// storage-json's root is anchored to the real $DSH_HOME. Unpinned, this
|
||||
// file writes the developer's own `~/.dsh/storages/` — and then reads it
|
||||
// back on the next run, so a stored document from any other build decides
|
||||
// this test's boot. Same reason the settings row above is pinned.
|
||||
{ id: 'storage-json', config: { root: storageRoot } },
|
||||
// Host rows with side effects outside this process: a bound port, a served
|
||||
// asset tree, a telemetry exporter. `api-gateway` and `directory-picker`
|
||||
// stay ENABLED on purpose — the api-proxy is the host row that injects
|
||||
// `subagents`, `workspace`, and the rest of the agent plane, so disabling
|
||||
// it would hide exactly the breakage this file exists to catch: a service
|
||||
// moved into the presets that a host row still waits for. The boot audit
|
||||
// is that assertion.
|
||||
{ id: 'webserver', disabled: true },
|
||||
// The web bundle's runtime row injects `httpServer`, so it cannot
|
||||
// activate without the bound port disabled above. It owns dist serving
|
||||
// and the URL prompt line — surface glue, not anything that decides an
|
||||
// agent's capabilities, which is all this file asserts.
|
||||
{ id: 'web-runtime', disabled: true },
|
||||
{ id: 'telemetry-otel', disabled: true },
|
||||
// A deployment-level skill on the host registry's GLOBAL layer — the same
|
||||
// registration shape a repository plugin's skill root uses. The layered
|
||||
// skills test below proves it reaches preset-composed agents.
|
||||
{ id: 'skill-badge', disabled: false },
|
||||
{ id: 'modules', disabled: true },
|
||||
{ id: 'connection', disabled: true },
|
||||
// The shipped `-auto` chooser resolves its interaction from a running
|
||||
// host and so waits for the webserver disabled above; the browse variant
|
||||
// supplies `directoryPicker` without one.
|
||||
{ id: 'directory-picker', disabled: true },
|
||||
{ insert: [{ id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' }] },
|
||||
// The roster AppCLIEntry would patch in; only the shipped root, so a
|
||||
// developer's own `~/.dsh/.preset` cannot change this test's outcome.
|
||||
// `default` here is the COMPOSITION default — the base layer the settings
|
||||
// document overrides.
|
||||
{
|
||||
id: 'agent-presets',
|
||||
config: { default: 'standard', roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }] },
|
||||
},
|
||||
...extra,
|
||||
]
|
||||
// The surface is patch layers over an empty preset root, so the root sits
|
||||
// outside this workspace and bare plugin names cannot resolve by Node's
|
||||
// upward walk. The flat fallback the preset boot maintains is what makes
|
||||
// them resolvable — the same mechanism, not a test-only shim.
|
||||
const home = dirname(settingsFile)
|
||||
healProfilesModuleFallback(INSTALL_ANCHOR, home)
|
||||
const profileDir = join(home, 'profiles', 'spec')
|
||||
await mkdir(profileDir, { recursive: true })
|
||||
const rootConfig = join(profileDir, 'cordis.yml')
|
||||
await writeFile(rootConfig, '[]\n')
|
||||
return await boot('dsh-test', rootConfig, patches)
|
||||
}
|
||||
|
||||
const toolNames = (ctx: Context, agent?: Agent): string[] =>
|
||||
ctx.tools.schemas(agent).map(schema => schema.name).sort()
|
||||
|
||||
let ctx: Context
|
||||
beforeAll(async () => {
|
||||
const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-web-presets-')), 'settings.yaml')
|
||||
await writeFile(settingsFile, '{}\n')
|
||||
ctx = await bootWeb(settingsFile)
|
||||
}, 120_000)
|
||||
|
||||
describe('the shipped Web composition', () => {
|
||||
it('leaves the global tool layer empty', () => {
|
||||
// Every model-facing tool belongs to a preset, `ask_user_question`
|
||||
// included: a tool in the global layer reaches EVERY agent regardless of
|
||||
// which preset composed it, so a two-tool benchmark surface would really
|
||||
// present three. A regression here means an agent-plane row came back to
|
||||
// the host composition.
|
||||
expect(toolNames(ctx)).toEqual([])
|
||||
})
|
||||
|
||||
it('supplies both shipped presets, and only those, from the system root', async () => {
|
||||
const listed = await ctx.agentPresets.list()
|
||||
|
||||
expect(listed.map(preset => preset.id).sort()).toEqual(['code', 'cordis', 'minimal', 'standard'])
|
||||
expect(listed.every(preset => preset.trust === 'system')).toBe(true)
|
||||
expect(ctx.agentPresets.defaultId).toBe('standard')
|
||||
})
|
||||
|
||||
it('composes the full agent from `standard`', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-standard'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// The EXACT catalog, not a spot-check: an omission is this design's
|
||||
// quietest failure mode, because a row that registers into the wrong
|
||||
// layer mounts cleanly and simply contributes nothing. `glob`/`grep` are
|
||||
// excluded for the reason the TUI composition e2e excludes them — they
|
||||
// depend on ripgrep being present on the machine.
|
||||
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', 'send_message', 'skill',
|
||||
'str_replace_editor', 'subagent', 'subagent_fork', 'task_kill',
|
||||
'task_list', 'task_output', 'todo_write', 'update_goal', 'web_search',
|
||||
'workflow', 'write',
|
||||
])
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('composes exactly 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'])
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps two differently composed sessions independent', async () => {
|
||||
const full = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-both-full'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
const minimal = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-both-minimal'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
expect(toolNames(ctx, minimal.agent)).toEqual(['bash', 'str_replace_editor'])
|
||||
expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10)
|
||||
|
||||
await minimal.dispose()
|
||||
|
||||
// Tearing the minimal session down leaves the full one whole.
|
||||
expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10)
|
||||
expect(toolNames(ctx)).toEqual([])
|
||||
} finally {
|
||||
await full.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('composes the cordis agent with its own toolset', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-cordis'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'cordis').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
const tools = toolNames(ctx, handle.agent)
|
||||
// The self-referential toolset is what distinguishes this preset.
|
||||
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']))
|
||||
|
||||
// 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.
|
||||
const scoped = (await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name)
|
||||
expect(scoped).toContain('editing-cordis-compositions')
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).not.toContain('editing-cordis-compositions')
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('presents `code` as Code Mode without disturbing a native session beside it', async () => {
|
||||
const coded = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-code'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'code').then(() => undefined),
|
||||
})
|
||||
const native = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-code-native'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// One tool reaches the MODEL: the transport. The registry's catalog for
|
||||
// this agent is unchanged — a code mode collapses the presentation, not
|
||||
// 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')
|
||||
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? ''
|
||||
expect(sdk).toContain('str_replace_editor')
|
||||
expect(sdk).toContain('web_search')
|
||||
|
||||
// The presentation is this agent's alone: the deployment default is
|
||||
// native, and the session composed from `standard` still sees it.
|
||||
const nativeAssembly = await ctx.systemPrompt.assemble({ scope: native.agent })
|
||||
expect(nativeAssembly.tools.map(tool => tool.name)).toContain('bash')
|
||||
expect(nativeAssembly.tools.map(tool => tool.name)).not.toContain('run_code')
|
||||
expect(nativeAssembly.sections.some(section => section.name === 'tools:sdk')).toBe(false)
|
||||
} finally {
|
||||
await native.dispose()
|
||||
await coded.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the self-referential toolset out of every other preset', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-no-cordis'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// Editing the live runtime is opt-in per session, not ambient.
|
||||
expect(toolNames(ctx, handle.agent)).not.toContain('cordis_mount')
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('ships the composition-authoring skill inside the preset directory', async () => {
|
||||
// The preset's skill root is derived from its own `baseUrl`, so the skill
|
||||
// travels with the directory wherever the preset is installed.
|
||||
const skill = join(
|
||||
CONFIG_DIR, 'agent-presets', 'cordis', 'skills', 'editing-cordis-compositions', 'SKILL.md',
|
||||
)
|
||||
|
||||
expect((await readFile(skill, 'utf8')).startsWith('---\nname: editing-cordis-compositions')).toBe(true)
|
||||
})
|
||||
|
||||
it('merges the global skill layer into a preset agent\'s catalog, keeping local discovery preset-side', async () => {
|
||||
const proj = await mkdtemp(join(tmpdir(), 'dsh-preset-skill-proj-'))
|
||||
await mkdir(join(proj, '.dsh', 'skills', 'project-proof'), { recursive: true })
|
||||
await writeFile(join(proj, '.dsh', 'skills', 'project-proof', 'SKILL.md'), [
|
||||
'---',
|
||||
'name: project-proof',
|
||||
'description: Proves the preset layer discovers project skills beside global ones.',
|
||||
'---',
|
||||
'',
|
||||
'Project proof body.',
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
const handle = await ctx.agents.create({
|
||||
// Unique per run: the composition persists into the ambient DSH home,
|
||||
// and a fixed id would collide with a log an earlier run left there.
|
||||
sessionId: SessionId(`preset-skills-standard-${randomUUID()}`),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// The host (global) view carries the deployment-level provider alone:
|
||||
// local discovery moved behind the presets with `skill-local`.
|
||||
expect((await ctx.skills.list({ cwd: proj })).map(skill => skill.name)).toEqual(['dsh-badge'])
|
||||
|
||||
// The standard agent's view merges the global layer with its preset's
|
||||
// own local discovery over the session cwd.
|
||||
const scoped = (await ctx.skills.list({ cwd: proj, scope: handle.agent })).map(skill => skill.name)
|
||||
expect(scoped).toContain('dsh-badge')
|
||||
expect(scoped).toContain('project-proof')
|
||||
|
||||
// The preset's own loader tool resolves the global-layer skill.
|
||||
const loaded = await ctx.tools.execute({
|
||||
callId: CallId('preset-skills-load'),
|
||||
name: 'skill',
|
||||
arguments: { name: 'dsh-badge' },
|
||||
signal: new AbortController().signal,
|
||||
agent: handle.agent,
|
||||
})
|
||||
expect(loaded.isError).toBe(false)
|
||||
expect(JSON.stringify(loaded.content)).toContain('powered by dsh')
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows a minimal agent the global layer but no loader tool', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId(`preset-skills-minimal-${randomUUID()}`),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// Layer visibility is the registry's; whether an agent can USE skills
|
||||
// stays the preset's choice — minimal mounts no `tool-skill`, so its
|
||||
// tool table has no loader even though the global layer is readable.
|
||||
expect((await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name)).toContain('dsh-badge')
|
||||
expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('never rewrites the preset file it composed from', async () => {
|
||||
// The Loader persists a tree whose plugin self-disposed, and tearing an
|
||||
// agent down disposes its whole subtree. Inherited, that rewrote the
|
||||
// shipped composition — truncating it to `[]` the first time a session
|
||||
// ended — so `PresetTree` refuses to write at all.
|
||||
const path = join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml')
|
||||
const before = await readFile(path, 'utf8')
|
||||
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-readonly'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
await handle.dispose()
|
||||
// Slack, not a race the number has to win. The write is driven by the
|
||||
// Loader's fiber-unload listener, which fires as the subtree's fibers
|
||||
// settle rather than when `dispose()` resolves, and the Loader exposes no
|
||||
// flush to await. A regression writes synchronously inside that listener,
|
||||
// so any wait past settlement fails; a longer one only slows the test.
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
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),
|
||||
})
|
||||
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')
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('a switch survives the session', () => {
|
||||
it('records the choice so the log states what the agent runs', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-switch-logged'),
|
||||
meta: { agentPreset: 'standard' },
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// The api-proxy's select does exactly this pair while the session is blank.
|
||||
await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal')
|
||||
handle.agent.session.append('agent-preset/selected', { agentPreset: 'minimal' })
|
||||
|
||||
// The header keeps the creation fact; the log carries what it runs.
|
||||
expect(handle.agent.session.header.agentPreset).toBe('standard')
|
||||
expect(resolveSessionPreset(handle.agent.session)).toBe('minimal')
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rebuilds a switched session from the log, not the creation header', () => {
|
||||
// The exact shape a resume reads back from disk: the header says standard,
|
||||
// the log records the switch the user made while the session was blank.
|
||||
const rebuilt = resolveSessionPreset({
|
||||
header: { version: 0, id: SessionId('x'), createdAt: 0, agentPreset: 'standard' },
|
||||
events: [
|
||||
{ type: 'agent-preset/selected', seq: 1, time: 0, data: { agentPreset: 'minimal' } },
|
||||
{ type: 'turn/start', seq: 2, time: 0, data: { turn: 0, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
] as never,
|
||||
})
|
||||
|
||||
// Reading the header alone would compose the creation-time preset over a
|
||||
// history another one produced — the replay the blank-only lock prevents.
|
||||
expect(rebuilt).toBe('minimal')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a forked session', () => {
|
||||
it('inherits the composition its seeded history was produced under', async () => {
|
||||
const parent = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-fork-parent'),
|
||||
meta: { agentPreset: 'minimal' },
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
||||
})
|
||||
const inherited = resolveSessionPreset(parent.agent.session)
|
||||
const child = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-fork-child'),
|
||||
meta: {
|
||||
parentSession: SessionId('preset-fork-parent'),
|
||||
seedLength: 0,
|
||||
...inherited === undefined ? {} : { agentPreset: inherited },
|
||||
},
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, inherited).then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// Composing nothing would leave the child empty: this layer moved every
|
||||
// model-facing row out of the host plane, so there is nothing to inherit
|
||||
// for free any more.
|
||||
expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent))
|
||||
expect(toolNames(ctx, child.agent).length).toBeGreaterThan(0)
|
||||
} finally {
|
||||
await child.dispose()
|
||||
await parent.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('authoring a preset on the shipped composition', () => {
|
||||
let authorCtx: Context
|
||||
let userRoot: string
|
||||
|
||||
beforeAll(async () => {
|
||||
userRoot = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-')), 'profiles')
|
||||
const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-settings-')), 'settings.yaml')
|
||||
await writeFile(settingsFile, '{}\n')
|
||||
authorCtx = await bootWeb(settingsFile, [{
|
||||
id: 'agent-presets',
|
||||
config: {
|
||||
default: 'standard',
|
||||
roots: [
|
||||
{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' },
|
||||
// The root does not exist yet: a deployment whose user has authored
|
||||
// nothing is the normal first-run state.
|
||||
{ path: userRoot, trust: 'user' },
|
||||
],
|
||||
},
|
||||
}])
|
||||
})
|
||||
|
||||
it('refuses to copy over or delete a shipped preset', async () => {
|
||||
await expect(authorCtx.agentPresets.copy('minimal', 'standard')).rejects.toThrow(/already exists/)
|
||||
await expect(authorCtx.agentPresets.remove('standard')).rejects.toThrow(/ships with the deployment/)
|
||||
})
|
||||
|
||||
it.each(['../escape', 'a/b', '/abs', 'Upper'])('refuses the uncontainable id %j', async (id) => {
|
||||
// The id becomes a directory name under the user root, so containment is
|
||||
// checked on the id rather than on the joined path afterwards.
|
||||
await expect(authorCtx.agentPresets.copy('minimal', id)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('copies a shipped preset a session then really composes from', async () => {
|
||||
await authorCtx.agentPresets.copy('minimal', 'my-agent', '我的模式')
|
||||
|
||||
// Round-trips through the roster as a `user` row carrying the given name
|
||||
// and the source's description, over the source's own composition text.
|
||||
const preset = await authorCtx.agentPresets.resolve('my-agent')
|
||||
const source = await authorCtx.agentPresets.resolve('minimal')
|
||||
expect(preset.trust).toBe('user')
|
||||
expect(preset.name).toBe('我的模式')
|
||||
expect(preset.description).toBe(source.description)
|
||||
expect(await authorCtx.agentPresets.read('my-agent')).toBe(await authorCtx.agentPresets.read('minimal'))
|
||||
// Owner-only, in an owner-only directory: a composition is executable
|
||||
// configuration on a machine that may have other users.
|
||||
expect((await stat(preset.path)).mode & 0o777).toBe(0o600)
|
||||
const handle = await authorCtx.agents.create({
|
||||
sessionId: SessionId('preset-authored'),
|
||||
setup: agentCtx => authorCtx.agentPresets.mount(agentCtx, 'my-agent').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// The same tools the shipped `minimal` composes, from a directory copied
|
||||
// through the service into a root outside the installed harness.
|
||||
expect(toolNames(authorCtx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('deletes what it copied', async () => {
|
||||
await authorCtx.agentPresets.copy('minimal', 'doomed')
|
||||
|
||||
await authorCtx.agentPresets.remove('doomed')
|
||||
|
||||
expect((await authorCtx.agentPresets.list()).map(preset => preset.id)).not.toContain('doomed')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Which preset an unnamed session gets is a user setting layered over the
|
||||
* composition's own default. The package suite proves the layering against a
|
||||
* hand-built context; this proves it through the shipped `cordis.yml` — that
|
||||
* the roster and the settings provider are actually wired to each other, and
|
||||
* that the id the setting names is the one a session composes from.
|
||||
*/
|
||||
describe('the default preset as a user setting', () => {
|
||||
it('composes an unnamed session from the stored default, not the composed one', async () => {
|
||||
expect(ctx.agentPresets.defaultId).toBe('standard')
|
||||
|
||||
await ctx.settings.update(settingsNamespace(SETTINGS_NAMESPACE), { default: 'minimal' })
|
||||
try {
|
||||
expect(ctx.agentPresets.defaultId).toBe('minimal')
|
||||
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-user-default'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// `mount()` with no id resolves the effective default. Two tools, not
|
||||
// `standard`'s catalog: the setting decided the composition.
|
||||
expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
} finally {
|
||||
// The context is shared with the rest of the file. `replace({})` drops
|
||||
// the user section wholesale so the field re-inherits the composition
|
||||
// base; `update` merges, and would leave the override standing.
|
||||
await ctx.settings.replace(settingsNamespace(SETTINGS_NAMESPACE), {})
|
||||
}
|
||||
|
||||
expect(ctx.agentPresets.defaultId).toBe('standard')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a session keeps the preset it was created with', () => {
|
||||
it('refuses to adopt a live session under a different preset', async () => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('preset-locked'),
|
||||
meta: { agentPreset: 'minimal' },
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// The api-proxy guard reads exactly this: the header records what the
|
||||
// session runs, so naming anything else is a caller error rather than a
|
||||
// switch. Its history was produced under `minimal`'s two tools.
|
||||
expect(handle.agent.session.header.agentPreset).toBe('minimal')
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
139
apps/cli/tests/windows-shell.spec.ts
Normal file
139
apps/cli/tests/windows-shell.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { ProfileLayer } from '@deepseek-ai/dsh-app-boot'
|
||||
import { composeEntries, initProfile, loadProfile, PROFILES_DIR } from '@deepseek-ai/dsh-app-boot'
|
||||
import {
|
||||
BASE_BUNDLE,
|
||||
resolveWindowsShellLayer,
|
||||
WINDOWS_SHELL_PATCH_FILENAME,
|
||||
} from '../src/windows-shell.ts'
|
||||
|
||||
const WINDOWS_PATCH = `- id: bash-sandbox
|
||||
disabled: true
|
||||
- insert:
|
||||
- id: pwsh-sandbox
|
||||
name: '@deepseek-ai/dsh-pwsh-sandbox'
|
||||
`
|
||||
|
||||
/** One fake bundle layer rooted in a temp directory. */
|
||||
function fakeLayer(packageName: string, dir: string): ProfileLayer {
|
||||
return { packageName, packageDir: dir, patchPath: join(dir, 'cordis.patch.yml'), patches: [] }
|
||||
}
|
||||
|
||||
/** A base bundle layer whose package carries the Windows shell patch. */
|
||||
function baseLayerWithPatch(dir: string): ProfileLayer {
|
||||
writeFileSync(join(dir, WINDOWS_SHELL_PATCH_FILENAME), WINDOWS_PATCH)
|
||||
return fakeLayer(BASE_BUNDLE, dir)
|
||||
}
|
||||
|
||||
describe('resolveWindowsShellLayer', () => {
|
||||
let base: string
|
||||
afterEach(() => { if (base !== undefined) rmSync(base, { recursive: true, force: true }) })
|
||||
const tempBase = (): string => {
|
||||
base = mkdtempSync(join(tmpdir(), 'dsh-windows-shell-'))
|
||||
return base
|
||||
}
|
||||
|
||||
it('never applies on POSIX hosts', () => {
|
||||
expect(resolveWindowsShellLayer('linux', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined()
|
||||
expect(resolveWindowsShellLayer('darwin', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('defaults Windows hosts to the pwsh platform layer', () => {
|
||||
const layer = resolveWindowsShellLayer('win32', [baseLayerWithPatch(tempBase())], 'dsh')
|
||||
expect(layer).toBeDefined()
|
||||
expect(layer?.label.endsWith(WINDOWS_SHELL_PATCH_FILENAME)).toBe(true)
|
||||
expect(layer?.patches).toEqual([
|
||||
{ id: 'bash-sandbox', disabled: true },
|
||||
{ insert: [{ id: 'pwsh-sandbox', name: '@deepseek-ai/dsh-pwsh-sandbox' }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('skips custom profiles without a base bundle', () => {
|
||||
const other = fakeLayer('@deepseek-ai/dsh-custom', tempBase())
|
||||
expect(resolveWindowsShellLayer('win32', [other], 'dsh')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails loud when the base bundle ships no Windows shell patch', () => {
|
||||
const base = tempBase()
|
||||
mkdirSync(base, { recursive: true })
|
||||
// The overlay loader owns the fail-loud contract: the caller named this
|
||||
// file, so its absence is a misconfiguration, not "no overlay".
|
||||
expect(() => resolveWindowsShellLayer('win32', [fakeLayer(BASE_BUNDLE, base)], 'dsh'))
|
||||
.toThrow(/dsh: failed to read overlay .*windows\.cordis\.patch\.yml/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the shipped Windows composition (real bundle layers)', () => {
|
||||
let home: string
|
||||
afterEach(() => { if (home !== undefined) rmSync(home, { recursive: true, force: true }) })
|
||||
// The app installation anchor, mirroring profile-boot.ts: the bundle layers
|
||||
// resolve from the REAL dsh-base/dsh-web-app packages through it, so this
|
||||
// suite composes the shipped patch files, not test fixtures.
|
||||
const anchor = fileURLToPath(new URL('../package.json', import.meta.url))
|
||||
|
||||
it('composes the win32 confined roster through the real patch layers', () => {
|
||||
home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-'))
|
||||
initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
|
||||
const profile = loadProfile('dsh', 'web', anchor, home)
|
||||
const warnings: string[] = []
|
||||
const win32 = resolveWindowsShellLayer('win32', profile.layers, 'dsh')
|
||||
expect(win32).toBeDefined()
|
||||
const rows = composeEntries(
|
||||
[...profile.layers.map(layer => layer.patches), win32!.patches],
|
||||
message => warnings.push(message),
|
||||
)
|
||||
const byId = new Map(rows.map(row => [row.id, row]))
|
||||
// Only the POSIX bash stack leaves the roster: the permission surface
|
||||
// (sandbox/sandbox-policy/fs-sandbox, permission, approval) stays enabled
|
||||
// exactly as on POSIX — the confined pwsh executor is what changes.
|
||||
for (const id of ['bash-sandbox', 'tool-bash']) {
|
||||
expect(byId.get(id)?.disabled, `row ${id}`).toBe(true)
|
||||
}
|
||||
for (const id of ['permission', 'ui-permission', 'sandbox', 'sandbox-policy', 'fs-sandbox', 'approval']) {
|
||||
expect(byId.get(id)?.disabled, `row ${id}`).not.toBe(true)
|
||||
}
|
||||
for (const id of ['pwsh-sandbox', 'tool-pwsh']) {
|
||||
expect(byId.has(id), `inserted row ${id}`).toBe(true)
|
||||
}
|
||||
// The launcher's cold-start module fallback BFS-links the apps/cli
|
||||
// dependency closure into the profile's node_modules (the pwsh-local
|
||||
// precedent), so every inserted bare plugin must resolve from there.
|
||||
const cliManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies?: Record<string, string> }
|
||||
for (const name of ['@deepseek-ai/dsh-pwsh-sandbox', '@deepseek-ai/dsh-tool-pwsh']) {
|
||||
expect(cliManifest.dependencies?.[name], `cold-start closure must reach ${name}`).toBeDefined()
|
||||
}
|
||||
// The patch touches only base-owned rows plus inserts, so the full web
|
||||
// profile composes without any no-match warning.
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves POSIX untouched and base-only profiles compose without warnings', () => {
|
||||
home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-'))
|
||||
initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
|
||||
const profile = loadProfile('dsh', 'web', anchor, home)
|
||||
// POSIX: no platform layer, the bash stack stays enabled.
|
||||
const posixRows = composeEntries(profile.layers.map(layer => layer.patches))
|
||||
const posixById = new Map(posixRows.map(row => [row.id, row]))
|
||||
expect(posixById.get('bash-sandbox')?.disabled).not.toBe(true)
|
||||
expect(posixById.has('pwsh-local')).toBe(false)
|
||||
expect(posixById.has('pwsh-sandbox')).toBe(false)
|
||||
|
||||
// A base-only custom profile (the DEFAULT_PROFILE_BUNDLES template): the
|
||||
// patch touches only base-owned rows (bash-sandbox/tool-bash) plus its
|
||||
// inserts, so the composition produces no no-match warning.
|
||||
initProfile(join(home, PROFILES_DIR, 'base-only'), ['@deepseek-ai/dsh-base'])
|
||||
const baseOnly = loadProfile('dsh', 'base-only', anchor, home)
|
||||
const baseWarnings: string[] = []
|
||||
const win32 = resolveWindowsShellLayer('win32', baseOnly.layers, 'dsh')
|
||||
expect(win32).toBeDefined()
|
||||
composeEntries(
|
||||
[...baseOnly.layers.map(layer => layer.patches), win32!.patches],
|
||||
message => baseWarnings.push(message),
|
||||
)
|
||||
expect(baseWarnings).toEqual([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user