Merge remote-tracking branch 'origin/master' into worktree/pr823-retarget-latest-20260729

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/skills.i18n.yaml
#	docs/core-data-structures/skills.md
#	docs/core-data-structures/skills.zh.md
#	packages/host/apiproxy/README.i18n.yaml
#	packages/skill/skill-local/README.i18n.yaml
#	packages/skill/skill/README.i18n.yaml
#	packages/skill/skill/README.md
#	packages/skill/skill/README.zh.md
#	packages/skill/skill/src/index.ts
#	packages/skill/skill/tests/skill.spec.ts
#	packages/skill/tool-skill/README.i18n.yaml
#	packages/skill/tool-skill/src/index.ts
#	packages/ui/tui/README.i18n.yaml
#	packages/ui/tui/README.md
#	packages/ui/tui/README.zh.md
#	packages/ui/tui/src/index.ts
#	packages/ui/tui/tests/tui.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-29 23:36:49 +08:00
205 changed files with 5429 additions and 840 deletions

View File

@@ -0,0 +1,370 @@
import { EventEmitter } from 'node:events'
import type { Stats } from 'node:fs'
import { mkdir, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import SkillService from '@deepseek-ai/dsh-skill'
interface FakeWatcherControl {
emitter: EventEmitter
closeCalls: number
options: Record<string, unknown>
}
interface FakeWatchFileControl {
path: string
listener(current: Stats, previous: Stats): void
}
interface FakeStatGate {
started: PromiseWithResolvers<undefined>
release: PromiseWithResolvers<undefined>
}
const watcherHarness = vi.hoisted(() => ({
watchers: [] as FakeWatcherControl[],
startupErrors: [] as Error[],
closeErrors: 0,
deferredReady: 0,
watchFiles: [] as FakeWatchFileControl[],
statGates: [] as FakeStatGate[],
}))
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>()
return {
...actual,
watchFile(path: string, _options: unknown, listener: FakeWatchFileControl['listener']) {
watcherHarness.watchFiles.push({ path, listener })
},
unwatchFile(path: string, listener: FakeWatchFileControl['listener']) {
const index = watcherHarness.watchFiles.findIndex(control => control.path === path && control.listener === listener)
if (index !== -1) watcherHarness.watchFiles.splice(index, 1)
},
}
})
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return {
...actual,
async stat(...args: Parameters<typeof actual.stat>) {
const gate = watcherHarness.statGates.shift()
if (gate !== undefined) {
gate.started.resolve(undefined)
await gate.release.promise
}
return await actual.stat(...args)
},
}
})
vi.mock('chokidar', () => ({
default: {
watch(_path: unknown, options: Record<string, unknown>) {
const emitter = new EventEmitter() as EventEmitter & { close(): Promise<void> }
const control: FakeWatcherControl = { emitter, closeCalls: 0, options }
emitter.close = async () => {
control.closeCalls += 1
if (watcherHarness.closeErrors > 0) {
watcherHarness.closeErrors -= 1
throw new Error('close failed')
}
}
watcherHarness.watchers.push(control)
queueMicrotask(() => {
if (watcherHarness.deferredReady > 0) {
watcherHarness.deferredReady -= 1
return
}
const error = watcherHarness.startupErrors.shift()
if (error === undefined) emitter.emit('ready')
else emitter.emit('error', error)
})
return emitter
},
},
}))
const SkillLocal = await import('../src/index.ts')
async function tempDir(name: string): Promise<string> {
return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
}
async function writeSkill(root: string, name: string): Promise<void> {
const directory = join(root, name)
await mkdir(directory, { recursive: true })
await writeFile(join(directory, 'SKILL.md'), `---\nname: ${name}\ndescription: ${name}\n---\n\nBody.\n`)
}
async function settle(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 0))
}
beforeEach(() => {
watcherHarness.watchers.length = 0
watcherHarness.startupErrors.length = 0
watcherHarness.closeErrors = 0
watcherHarness.deferredReady = 0
watcherHarness.watchFiles.length = 0
watcherHarness.statGates.length = 0
})
describe('skill-local watcher failures', () => {
it('ignores missing-path probes until the observed path actually changes', async () => {
const home = await tempDir('skill-watch-missing-stable')
const ctx = new Context()
await ctx.plugin(SkillService)
const fiber = await ctx.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchPollIntervalMs: 10,
})
expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: true })
expect(watcherHarness.watchFiles).toHaveLength(2)
let invalidations = 0
ctx.on('skills/change', () => { invalidations += 1 })
for (const control of watcherHarness.watchFiles) {
control.listener({} as Stats, {} as Stats)
}
await settle()
expect(invalidations).toBe(0)
expect(watcherHarness.watchFiles).toHaveLength(2)
await fiber.dispose()
})
it('keeps skills loadable across persistent watcher startup failures without caching them', async () => {
const home = await tempDir('skill-watch-start-error')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'retry-skill')
watcherHarness.startupErrors.push(
new Error('watch failed once'),
new Error('watch failed twice'),
new Error('watch failed three times'),
)
watcherHarness.closeErrors = 1
const ctx = new Context()
await ctx.plugin(SkillService)
const fiber = await ctx.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchUsePolling: true,
watchFollowSymlinks: false,
watchPollIntervalMs: 10,
watchStabilityThresholdMs: 20,
})
expect(await ctx.skills.snapshot()).toMatchObject({
skills: [{ name: 'retry-skill' }],
complete: false,
})
expect((await ctx.skills.get('retry-skill'))?.content).toBe('Body.')
expect(await ctx.skills.snapshot()).toMatchObject({
skills: [{ name: 'retry-skill' }],
complete: false,
})
expect(watcherHarness.watchers).toHaveLength(3)
expect(watcherHarness.watchers[0]?.options).toMatchObject({
atomic: true,
depth: 1,
followSymlinks: false,
usePolling: true,
interval: 10,
awaitWriteFinish: {
stabilityThreshold: 20,
pollInterval: 10,
},
})
await fiber.dispose()
})
it('filters events, coalesces invalidation, recovers runtime errors, and contains late callbacks', async () => {
const home = await tempDir('skill-watch-runtime-error')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'watched-skill')
const ctx = new Context()
await ctx.plugin(SkillService)
const fiber = await ctx.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchPollIntervalMs: 10,
watchStabilityThresholdMs: 20,
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['watched-skill'])
let invalidations = 0
ctx.on('skills/change', () => { invalidations += 1 })
const first = watcherHarness.watchers[0]
if (first === undefined) throw new Error('expected a root watcher')
first.emitter.emit('change', join(root, 'notes.txt'))
first.emitter.emit('change', join(home, 'outside.md'))
first.emitter.emit('change', join(root, 'watched-skill/references.md'))
first.emitter.emit('change', join(root, '.system/SKILL.md'))
await settle()
expect(invalidations).toBe(0)
first.emitter.emit('change', join(root, 'watched-skill/SKILL.md'))
first.emitter.emit('change', join(root, 'watched-skill/SKILL.md'))
await settle()
expect(invalidations).toBe(1)
watcherHarness.closeErrors = 1
watcherHarness.startupErrors.push(new Error('runtime rewatch failed'))
first.emitter.emit('error', new Error('runtime watch failed'))
await settle()
await settle()
expect(watcherHarness.watchers.length).toBeGreaterThanOrEqual(2)
expect(invalidations).toBeGreaterThanOrEqual(2)
expect(await ctx.skills.snapshot()).toMatchObject({
skills: [{ name: 'watched-skill' }],
complete: true,
})
await fiber.dispose()
first.emitter.emit('change', join(root, 'watched-skill/SKILL.md'))
first.emitter.emit('error', new Error('late error'))
await settle()
})
it('re-probes a retained root after child unlink and observes immediate recreation', async () => {
const home = await tempDir('skill-watch-root-reprobe')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'old-skill')
const ctx = new Context()
await ctx.plugin(SkillService)
const fiber = await ctx.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchPollIntervalMs: 10,
watchStabilityThresholdMs: 20,
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['old-skill'])
const original = watcherHarness.watchers[0]
if (original === undefined) throw new Error('expected a root watcher')
await rm(root, { recursive: true })
original.emitter.emit('unlink', join(root, 'old-skill/SKILL.md'))
await settle()
expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: true })
const missingRoot = watcherHarness.watchFiles.find(control => control.path === root)
expect(missingRoot).toBeDefined()
await writeSkill(root, 'recreated-skill')
missingRoot!.listener({} as Stats, {} as Stats)
await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(2) })
await settle()
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['recreated-skill'])
await fiber.dispose()
})
it('settles an opening watcher when plugin disposal races its ready event', async () => {
const home = await tempDir('skill-watch-opening-dispose')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'racing-skill')
watcherHarness.deferredReady = 1
const ctx = new Context()
await ctx.plugin(SkillService)
let provider!: InstanceType<typeof SkillLocal.LocalSkillProvider>
const disposeProvider = ctx.skills.registerProvider((control) => {
provider = new SkillLocal.LocalSkillProvider(ctx, control, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchPollIntervalMs: 10,
watchStabilityThresholdMs: 20,
})
return provider
})
const discovery = provider.list({})
await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(1) })
const first = watcherHarness.watchers[0]
if (first === undefined) throw new Error('expected an opening root watcher')
const disposal = provider.dispose()
await expect(discovery).rejects.toThrow('skill-local watcher disposed')
await disposal
disposeProvider()
await settle()
expect(first.closeCalls).toBeGreaterThan(0)
})
it('closes an opening watcher when disposal wins the mode probe', async () => {
const home = await tempDir('skill-watch-probe-dispose')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'racing-skill')
watcherHarness.deferredReady = 1
const statGate: FakeStatGate = {
started: Promise.withResolvers<undefined>(),
release: Promise.withResolvers<undefined>(),
}
watcherHarness.statGates.push(statGate)
const ctx = new Context()
await ctx.plugin(SkillService)
let provider!: InstanceType<typeof SkillLocal.LocalSkillProvider>
const disposeProvider = ctx.skills.registerProvider((control) => {
provider = new SkillLocal.LocalSkillProvider(ctx, control, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchPollIntervalMs: 10,
watchStabilityThresholdMs: 20,
})
return provider
})
const discovery = provider.list({})
await statGate.started.promise
const disposal = provider.dispose()
statGate.release.resolve(undefined)
await expect(discovery).rejects.toThrow('skill-local watcher disposed')
await disposal
expect(watcherHarness.watchers).toHaveLength(1)
expect(watcherHarness.watchers[0]?.closeCalls).toBeGreaterThan(0)
disposeProvider()
})
it('contains an opening watcher rejection during provider teardown', async () => {
const home = await tempDir('skill-watch-opening-reject')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'rejected-skill')
watcherHarness.deferredReady = 1
const ctx = new Context()
await ctx.plugin(SkillService)
let provider!: InstanceType<typeof SkillLocal.LocalSkillProvider>
const disposeProvider = ctx.skills.registerProvider((control) => {
provider = new SkillLocal.LocalSkillProvider(ctx, control, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchPollIntervalMs: 10,
watchStabilityThresholdMs: 20,
})
return provider
})
const discovery = provider.list({})
await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(1) })
const first = watcherHarness.watchers[0]
if (first === undefined) throw new Error('expected an opening root watcher')
first.emitter.emit('error', new Error('opening failed during disposal'))
const disposal = provider.dispose()
await expect(discovery).rejects.toThrow('opening failed during disposal')
await disposal
disposeProvider()
})
})

View File

@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest'
import { mkdir, readdir, readFile, stat, symlink, writeFile } from 'node:fs/promises'
import { mkdir, readdir, readFile, rename, rm, stat, symlink, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
import { Context } from 'cordis'
import SkillService from '@deepseek-ai/dsh-skill'
import { FileSystem, FsVersion, type FsDirEntry, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsPathInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs'
import { FileSystem, FsError, FsVersion, type FsDirEntry, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsPathInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs'
import * as SkillLocal from '../src/index.ts'
async function tempDir(name: string): Promise<string> {
@@ -26,19 +26,26 @@ class TestFileSystem extends FileSystem {
listDirCalls = 0
failResolvePaths = new Set<string>()
failStatPaths = new Set<string>()
failListDirPaths = new Set<string>()
errorResolvePaths = new Set<string>()
errorStatPaths = new Set<string>()
errorReadPaths = new Set<string>()
missingReadPaths = new Set<string>()
statOverrides = new Map<string, FsInfo | undefined>()
statSignals: Array<AbortSignal | undefined> = []
readTextSignals: Array<AbortSignal | undefined> = []
readTextOverride?: (target: FsTarget, signal?: AbortSignal) => Promise<string>
override async resolve(path: string): Promise<FsTarget> {
if (this.failResolvePaths.has(path)) throw new Error('resolve failed')
if (this.failResolvePaths.has(path)) throw new FsError('resolve failed', 'FS_NOT_FOUND')
if (this.errorResolvePaths.has(path)) throw new Error('resolve temporarily failed')
return { targetKey: path as never, displayPath: path }
}
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
this.statSignals.push(signal)
if (this.failStatPaths.has(target.displayPath)) throw new Error('stat failed')
if (this.failStatPaths.has(target.displayPath)) throw new FsError('stat failed', 'FS_NOT_FOUND')
if (this.errorStatPaths.has(target.displayPath)) throw new Error('stat temporarily failed')
if (this.statOverrides.has(target.displayPath)) return this.statOverrides.get(target.displayPath)
try {
const fs = await import('node:fs/promises')
@@ -70,8 +77,10 @@ class TestFileSystem extends FileSystem {
override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
this.readTextSignals.push(signal)
if (this.readTextOverride !== undefined) return await this.readTextOverride(target, signal)
if (this.missingReadPaths.has(target.displayPath)) throw new FsError('read failed', 'FS_NOT_FOUND')
if (this.errorReadPaths.has(target.displayPath)) throw new Error('read temporarily failed')
const text = await readFile(target.displayPath, 'utf8')
if (text.includes('\uFFFD')) throw new Error('not text')
if (text.includes('\uFFFD')) throw new FsError('not text', 'FS_NOT_TEXT')
return text
}
@@ -81,6 +90,7 @@ class TestFileSystem extends FileSystem {
override async listDir(target: FsTarget): Promise<FsDirEntry[]> {
this.listDirCalls += 1
if (this.failListDirPaths.has(target.displayPath)) throw new Error('list temporarily failed')
const entries = await readdir(target.displayPath, { withFileTypes: true, encoding: 'utf8' })
const result: FsDirEntry[] = []
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
@@ -122,11 +132,22 @@ async function setupLocal(home: string, config: Partial<SkillLocal.Config> = {})
await ctx.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: false,
...config,
})
return ctx
}
async function waitFor<T>(read: () => Promise<T>, accept: (value: T) => boolean): Promise<T> {
const deadline = Date.now() + 5000
while (true) {
const value = await read()
if (accept(value)) return value
if (Date.now() >= deadline) throw new Error('timed out waiting for watcher state')
await new Promise(resolve => setTimeout(resolve, 20))
}
}
describe('dsh-skill-local plugin exports', () => {
it('declares stable plugin metadata', () => {
expect(SkillLocal.name).toBe('skill-local')
@@ -235,7 +256,7 @@ describe('LocalSkillProvider', () => {
const listedBeforeDelete = await ctx.skills.list()
const flatSummary = listedBeforeDelete.find(skill => skill.name === 'flat-skill')
if (flatSummary === undefined) throw new Error('expected flat-skill')
await writeFile(join(root, 'flat-skill.md'), '')
await rm(join(root, 'flat-skill.md'))
expect(listedBeforeDelete.map(skill => skill.name)).toEqual([
'flat-skill',
@@ -420,7 +441,7 @@ describe('LocalSkillProvider', () => {
size: 0,
})
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false })
expect((await ctx.skills.list({ cwd: nestedCwd })).map(skill => [skill.name, skill.source])).toEqual([
['backend-root', 'project-agents'],
@@ -444,6 +465,92 @@ describe('LocalSkillProvider', () => {
expect((await bundledCtx.skills.get('bundled-host'))?.source).toBe('bundled')
})
it('reports transient root reads as incomplete without caching an empty catalog', async () => {
const home = await tempDir('skill-transient-root')
const root = join(home, '.agents/skills')
await writeSkill(root, 'stable-skill', 'Stable skill')
const ctx = new Context()
await ctx.plugin(TestFileSystem)
const fs = ctx.fs as TestFileSystem
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: false,
})
expect(await ctx.skills.snapshot()).toMatchObject({
skills: [{ name: 'stable-skill' }],
complete: true,
})
fs.failListDirPaths.add(root)
const path = join(root, 'stable-skill/SKILL.md')
ctx.emit(
'fs/observed',
{ targetKey: path as never, displayPath: path },
FsVersion('failed-read'),
{ name: 'edit' },
)
expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: false })
fs.failListDirPaths.clear()
expect(await ctx.skills.snapshot()).toMatchObject({
skills: [{ name: 'stable-skill' }],
complete: true,
})
})
it('distinguishes transient filesystem entry failures from confirmed disappearance', async () => {
const home = await tempDir('skill-transient-entry')
const root = join(home, '.agents/skills')
const path = join(root, 'stable-skill/SKILL.md')
await writeSkill(root, 'stable-skill', 'Stable skill')
const ctx = new Context()
await ctx.plugin(TestFileSystem)
const fs = ctx.fs as TestFileSystem
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: false,
})
const invalidate = (): void => {
ctx.emit(
'fs/observed',
{ targetKey: path as never, displayPath: path },
FsVersion('entry-failure'),
{ name: 'write' },
)
}
expect((await ctx.skills.snapshot()).complete).toBe(true)
for (const failures of [fs.errorResolvePaths, fs.errorStatPaths, fs.errorReadPaths]) {
failures.add(path)
invalidate()
expect((await ctx.skills.snapshot()).complete).toBe(false)
failures.clear()
}
fs.missingReadPaths.add(path)
invalidate()
expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: true })
fs.missingReadPaths.clear()
invalidate()
expect(await ctx.skills.snapshot()).toMatchObject({
skills: [{ name: 'stable-skill' }],
complete: true,
})
})
it('marks an unexpected native skill-file read failure incomplete', async () => {
const home = await tempDir('skill-native-read-failure')
const root = join(home, '.agents/skills')
await mkdir(join(root, 'broken-skill/SKILL.md'), { recursive: true })
const ctx = await setupLocal(home)
expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: false })
})
it('forwards cancellation to filesystem reads while loading a skill', async () => {
const home = await tempDir('skill-read-abort')
await writeSkill(join(home, '.dsh/skills'), 'abortable-skill', 'Abortable skill')
@@ -452,7 +559,7 @@ describe('LocalSkillProvider', () => {
await ctx.plugin(TestFileSystem)
const fs = ctx.fs as TestFileSystem
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false })
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['abortable-skill'])
fs.statSignals = []
@@ -479,6 +586,223 @@ describe('LocalSkillProvider', () => {
expect(fs.readTextSignals).toEqual([controller.signal])
})
it('refreshes additions, metadata changes, deletions, and a recreated missing root', { timeout: 20000 }, async () => {
const home = await tempDir('skill-watch-home')
const agentsRoot = join(home, '.agents/skills')
const ctx = new Context()
await ctx.plugin(SkillService)
const fiber = await ctx.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchStabilityThresholdMs: 20,
watchPollIntervalMs: 10,
})
try {
expect(await ctx.skills.list()).toEqual([])
await writeSkill(agentsRoot, 'watched-skill', 'First description', 'First body.')
const added = await waitFor(
async () => await ctx.skills.list(),
skills => skills.some(skill => skill.name === 'watched-skill'),
)
expect(added.find(skill => skill.name === 'watched-skill')?.description).toBe('First description')
await writeSkill(agentsRoot, 'watched-skill', 'Second description', 'Second body.')
const changed = await waitFor(
async () => await ctx.skills.list(),
skills => skills.find(skill => skill.name === 'watched-skill')?.description === 'Second description',
)
expect(changed).toHaveLength(1)
expect((await ctx.skills.get('watched-skill'))?.content).toBe('Second body.')
await writeFlatSkill(agentsRoot, 'flat-added', 'Flat added')
expect(await waitFor(
async () => (await ctx.skills.list()).map(skill => skill.name),
names => names.includes('flat-added'),
)).toEqual(['flat-added', 'watched-skill'])
await rename(join(agentsRoot, 'watched-skill'), join(agentsRoot, 'renamed-skill'))
await writeSkill(agentsRoot, 'renamed-skill', 'Renamed skill')
expect(await waitFor(
async () => (await ctx.skills.list()).map(skill => skill.name),
names => names.includes('renamed-skill') && !names.includes('watched-skill'),
)).toEqual(['flat-added', 'renamed-skill'])
await rm(join(agentsRoot, 'renamed-skill'), { recursive: true })
expect(await waitFor(
async () => (await ctx.skills.list()).map(skill => skill.name),
names => !names.includes('renamed-skill'),
)).toEqual(['flat-added'])
await rm(join(home, '.agents'), { recursive: true })
expect(await waitFor(
async () => await ctx.skills.list(),
skills => skills.length === 0,
)).toEqual([])
await writeSkill(agentsRoot, 'recreated-skill', 'Recreated')
expect(await waitFor(
async () => (await ctx.skills.list()).map(skill => skill.name),
names => names.includes('recreated-skill'),
)).toEqual(['recreated-skill'])
} finally {
await fiber.dispose()
}
})
it('uses fs/observed as a synchronous first-party invalidation path without a watcher', async () => {
const home = await tempDir('skill-observed-home')
const root = join(home, '.agents/skills')
const ctx = await setupLocal(home)
expect(await ctx.skills.list()).toEqual([])
let invalidations = 0
ctx.on('skills/change', () => { invalidations += 1 })
await writeSkill(root, 'observed-skill', 'Observed skill')
const path = join(root, 'observed-skill/SKILL.md')
const emitObserved = (displayPath: string, actor?: object): void => {
ctx.emit(
'fs/observed',
{ targetKey: displayPath as never, displayPath },
FsVersion('observed'),
actor,
)
}
emitObserved(path)
emitObserved(path, {})
emitObserved(path, { name: 'read' })
emitObserved(join(home, 'outside.md'), { name: 'write' })
emitObserved(root, { name: 'write' })
emitObserved(join(root, 'observed-skill/references/notes.md'), { name: 'write' })
emitObserved(join(home, '.dsh/skills/.system/SKILL.md'), { name: 'write' })
emitObserved(join(root, 'flat-skill.md'), { name: 'write' })
ctx.emit(
'fs/observed',
{ targetKey: path as never, displayPath: path },
FsVersion('observed'),
{ name: 'edit' },
)
expect(invalidations).toBe(2)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['observed-skill'])
})
it('bounds project watchers and re-observes an evicted project on its next lookup', async () => {
const home = await tempDir('skill-watch-lru-home')
const first = await tempDir('skill-watch-lru-first')
const second = await tempDir('skill-watch-lru-second')
await mkdir(join(first, '.git'), { recursive: true })
await mkdir(join(second, '.git'), { recursive: true })
await writeSkill(join(first, '.agents/skills'), 'first-project', 'First project')
await writeSkill(join(second, '.agents/skills'), 'second-project', 'Second project')
const ctx = new Context()
await ctx.plugin(SkillService)
const fiber = await ctx.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
customSkillDirs: [join(first, '.agents/skills')],
watch: true,
watchMaxProjects: 1,
watchStabilityThresholdMs: 20,
watchPollIntervalMs: 10,
})
try {
expect((await ctx.skills.list({ cwd: first })).map(skill => skill.name)).toContain('first-project')
expect((await ctx.skills.list({ cwd: second })).map(skill => skill.name)).toContain('second-project')
await writeSkill(join(first, '.agents/skills'), 'first-project', 'First project refreshed')
expect((await ctx.skills.list({ cwd: first })).find(skill => skill.name === 'first-project')?.description)
.toBe('First project refreshed')
} finally {
await fiber.dispose()
}
const noWatch = new Context()
await noWatch.plugin(SkillService)
await noWatch.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: false,
watchMaxProjects: 1,
})
await noWatch.skills.list({ cwd: first })
await noWatch.skills.list({ cwd: second })
})
it('contains repeated disposal and late first-party observations', async () => {
const home = await tempDir('skill-watch-dispose')
const nonDirectoryRoot = join(home, 'not-a-directory')
await writeFile(nonDirectoryRoot, 'not a skill root')
await writeSkill(join(home, '.agents/skills'), 'disposed-skill', 'Disposed skill')
const ctx = new Context()
await ctx.plugin(SkillService)
let provider!: SkillLocal.LocalSkillProvider
const disposeProvider = ctx.skills.registerProvider((control) => {
provider = new SkillLocal.LocalSkillProvider(ctx, control, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
customSkillDirs: [nonDirectoryRoot],
watch: true,
watchStabilityThresholdMs: 20,
watchPollIntervalMs: 10,
})
return provider
})
const beforeDisposal = await provider.list({})
expect((Array.isArray(beforeDisposal) ? beforeDisposal : beforeDisposal.candidates).map(skill => skill.name))
.toEqual(['disposed-skill'])
await provider.dispose()
await provider.dispose()
provider.observeHostMutation(join(home, '.agents/skills/disposed-skill/SKILL.md'))
const afterDisposal = await provider.list({})
expect((Array.isArray(afterDisposal) ? afterDisposal : afterDisposal.candidates).map(skill => skill.name))
.toEqual(['disposed-skill'])
disposeProvider()
})
it('refreshes frontmatter through a followed skill symlink', { timeout: 10000 }, async () => {
const home = await tempDir('skill-watch-symlink-home')
const external = await tempDir('skill-watch-symlink-external')
const root = join(home, '.dsh/skills')
await writeSkill(external, 'linked-skill', 'First linked description')
await mkdir(root, { recursive: true })
await symlink(join(external, 'linked-skill'), join(root, 'linked-skill'))
const ctx = new Context()
await ctx.plugin(SkillService)
const fiber = await ctx.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchFollowSymlinks: true,
watchStabilityThresholdMs: 20,
watchPollIntervalMs: 10,
})
try {
expect((await ctx.skills.list())[0]?.description).toBe('First linked description')
await writeSkill(external, 'linked-skill', 'Second linked description')
const refreshed = await waitFor(
async () => await ctx.skills.list(),
skills => skills[0]?.description === 'Second linked description',
)
expect(refreshed[0]?.name).toBe('linked-skill')
} finally {
await fiber.dispose()
}
})
it('validates watcher tunables at plugin load', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
await expect(ctx.plugin(SkillLocal, { watchMaxProjects: 0 })).rejects.toThrow('watchMaxProjects')
await expect(ctx.plugin(SkillLocal, { watchPollIntervalMs: 1.5 })).rejects.toThrow('watchPollIntervalMs')
await expect(ctx.plugin(SkillLocal, { watchStabilityThresholdMs: 0 })).rejects.toThrow('watchStabilityThresholdMs')
})
it('uses default home root resolution without exposing builtin skills', async () => {
const previousDshHome = process.env.DSH_HOME
const previousAgentsHome = process.env.DSH_AGENTS_HOME
@@ -493,7 +817,7 @@ describe('LocalSkillProvider', () => {
await writeSkill(bundled, 'env-bundled-skill', 'Env bundled skill')
const ctx = new Context()
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal)
await ctx.plugin(SkillLocal, { watch: false })
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-bundled-skill', 'env-skill'])
process.env.DSH_HOME = join(envHome, 'empty-dsh')
@@ -501,11 +825,14 @@ describe('LocalSkillProvider', () => {
process.env.DSH_AGENTS_HOME = join(envHome, 'empty-agents')
const empty = new Context()
await empty.plugin(SkillService)
SkillLocal.apply(empty, {})
SkillLocal.apply(empty, { watch: false })
expect(await empty.skills.list()).toEqual([])
delete process.env.DSH_AGENTS_HOME
expect(new SkillLocal.LocalSkillProvider(empty, { dshHome: join(envHome, 'empty-dsh') }).name).toBe('local')
expect(new SkillLocal.LocalSkillProvider(empty, {
signal: new AbortController().signal,
invalidate() {},
}, { dshHome: join(envHome, 'empty-dsh') }).name).toBe('local')
} finally {
if (previousDshHome === undefined) {
delete process.env.DSH_HOME