fix(skill): forward cancellation to local reads

This commit is contained in:
Yichen Jiang
2026-07-10 17:47:28 +08:00
parent 0b20353424
commit c2c238d36d
3 changed files with 66 additions and 14 deletions
@@ -27,13 +27,17 @@ class TestFileSystem extends FileSystem {
failResolvePaths = new Set<string>()
failStatPaths = 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')
return { targetKey: path as never, displayPath: path }
}
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
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.statOverrides.has(target.displayPath)) return this.statOverrides.get(target.displayPath)
try {
@@ -49,7 +53,9 @@ class TestFileSystem extends FileSystem {
}
}
override async readText(target: FsTarget): Promise<string> {
override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
this.readTextSignals.push(signal)
if (this.readTextOverride !== undefined) return await this.readTextOverride(target, signal)
const text = await readFile(target.displayPath, 'utf8')
if (text.includes('\uFFFD')) throw new Error('not text')
return text
@@ -317,6 +323,41 @@ describe('LocalSkillProvider', () => {
expect(await ctx.skills.get('binary-skill')).toBeUndefined()
})
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')
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') })
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['abortable-skill'])
fs.statSignals = []
fs.readTextSignals = []
const started = Promise.withResolvers<undefined>()
fs.readTextOverride = async (_target, signal) => {
if (signal === undefined) throw new Error('expected the skill lookup signal')
started.resolve(undefined)
return await new Promise<string>((_resolve, reject) => {
signal.addEventListener('abort', () => {
const abortReason = signal.reason as unknown
reject(abortReason instanceof Error ? abortReason : new Error(String(abortReason)))
}, { once: true })
})
}
const controller = new AbortController()
const reason = new Error('turn cancelled')
const loading = ctx.skills.get('abortable-skill', { signal: controller.signal })
await started.promise
controller.abort(reason)
await expect(loading).rejects.toBe(reason)
expect(fs.statSignals).toEqual([controller.signal])
expect(fs.readTextSignals).toEqual([controller.signal])
})
it('uses default home root resolution without exposing builtin skills', async () => {
const previousDshHome = process.env.DSH_HOME
const previousAgentsHome = process.env.DSH_AGENTS_HOME