From 75100ad2e5e0d081d3f81a22fdd6bd11bf464631 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 14:22:55 +0800 Subject: [PATCH 1/3] fix(agent-presets): repair Windows preset CI --- .../preset/agent-presets/README.i18n.yaml | 4 +-- packages/preset/agent-presets/README.md | 2 ++ packages/preset/agent-presets/README.zh.md | 2 ++ packages/preset/agent-presets/src/mount.ts | 13 +++++--- .../agent-presets/tests/authoring.spec.ts | 17 ++++++----- .../agent-presets/tests/discovery.spec.ts | 30 +++++++++++++++++-- .../preset/agent-presets/tests/mount.spec.ts | 19 +++++++++++- 7 files changed, 70 insertions(+), 17 deletions(-) diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index cb80d89ad8..65af853308 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md -README.md: ed640cf053ac595dfb9c20c226f3c2ff34db93f6 -README.zh.md: 4e6fc0a4cf0db4b14b136cbad9f73eee64d9c170 +README.md: b6d469b26a0254adc654e5cc49d3df2d10817b2d +README.zh.md: 60c7bc695c27bf2c0169a0e405aa84aa711b9b21 diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index ed640cf053..b6d469b26a 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -55,6 +55,8 @@ A row's **package name** resolves from the host composition, not from the preset A **relative** path still resolves from the preset's own directory, so a preset's own plugin files and skill directories travel with it. +An **absolute** filesystem path keeps its own location. The mount converts it to a `file:` URL before ESM import so POSIX paths and Windows drive-letter or UNC paths use a specifier Node accepts. + ### Display metadata A preset may publish display text in an optional `preset.yml` beside its composition: diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 4e6fc0a4cf..60c7bc695c 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -55,6 +55,8 @@ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有 **相对**路径仍从 preset 自身的目录解析,因此 preset 自带的插件文件与 skill 目录会随它一同迁移。 +**绝对**文件系统路径则保留其自身位置。挂载会先将它转换为 `file:` URL 再交给 ESM 导入,从而使 POSIX 路径和 Windows 盘符或 UNC 路径都采用 Node 能够接受的说明符。 + ### 展示用元信息 preset 可以在组装文件旁的可选 `preset.yml` 里发布展示文本: diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index fac3319fa1..eb890255ca 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -14,6 +14,7 @@ * @module @deepseek-ai/dsh-agent-presets/mount */ +import { isAbsolute } from 'node:path' import { pathToFileURL } from 'node:url' import { Context, type Fiber } from 'cordis' import { Include } from '@cordisjs/plugin-include' @@ -69,21 +70,25 @@ class PresetTree extends Include { * where Node's upward `node_modules` walk never reaches the harness's own * dependencies, so every `@deepseek-ai/dsh-*` row would fail to import. The * mount records the host composition's base instead, which is inside the - * installed harness, and bare names resolve from there. + * installed harness, and bare names resolve from there. An absolute + * filesystem path names neither base and becomes a file URL before Node's + * ESM loader receives it, which is required for drive-letter paths on + * Windows. * @param name - the module specifier from the row. * @param getOuterStack - the loader's stack composer for import diagnostics. * @returns the imported module, or the `cordis:` builtin. */ override import(name: string, getOuterStack?: () => string[]): unknown { + const specifier = isAbsolute(name) ? pathToFileURL(name).href : name const base = harnessBase.get(this.config) /* v8 ignore next -- every PresetTree is constructed by `mountPreset`, which records the base first */ - if (base === undefined) return super.import(name, getOuterStack) + if (base === undefined) return super.import(specifier, getOuterStack) if (name.startsWith('.') || name.startsWith('cordis:')) return super.import(name, getOuterStack) const internal = this.ctx.loader.internal /* v8 ignore next -- Node always supplies the internal module loader; the branch keeps a hypothetical embedder from losing the row's name in a resolution error. */ - if (internal === undefined) return super.import(name, getOuterStack) - return internal.import(name, base, {}) + if (internal === undefined) return super.import(specifier, getOuterStack) + return internal.import(specifier, base, {}) } /** diff --git a/packages/preset/agent-presets/tests/authoring.spec.ts b/packages/preset/agent-presets/tests/authoring.spec.ts index b71776c264..b6f4ef388a 100644 --- a/packages/preset/agent-presets/tests/authoring.spec.ts +++ b/packages/preset/agent-presets/tests/authoring.spec.ts @@ -65,20 +65,23 @@ describe('copying a preset', () => { expect(listed.find(preset => preset.id === 'mine')?.trust).toBe('user') }) - it('copies the whole directory, execute bits kept and group/other stripped', async () => { + it('copies the whole directory and tightens POSIX modes', async () => { await seedPreset(userRoot, 'source', { extras: { 'skills/demo/SKILL.md': '# demo\n', 'skills/demo/run.sh': '#!/bin/sh\n' }, }) - await chmod(join(userRoot, 'source', 'skills', 'demo', 'run.sh'), 0o755) + if (process.platform !== 'win32') { + await chmod(join(userRoot, 'source', 'skills', 'demo', 'run.sh'), 0o755) + } await ctx.agentPresets.copy('source', 'mine') expect(await readFile(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'), 'utf8')).toBe('# demo\n') - // A preset may ship runnable helpers; the copy keeps them runnable for the - // owner while withdrawing the world-readability of the install. - expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'run.sh'))).mode & 0o777).toBe(0o700) - expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'))).mode & 0o777).toBe(0o600) - expect((await stat(join(userRoot, 'mine'))).mode & 0o777).toBe(0o700) + // Windows mode bits are synthetic and cannot represent the inherited DACL. + if (process.platform !== 'win32') { + expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'run.sh'))).mode & 0o777).toBe(0o700) + expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'))).mode & 0o777).toBe(0o600) + expect((await stat(join(userRoot, 'mine'))).mode & 0o777).toBe(0o700) + } }) it('keeps the source description but never its name or order', async () => { diff --git a/packages/preset/agent-presets/tests/discovery.spec.ts b/packages/preset/agent-presets/tests/discovery.spec.ts index 55845c2251..165a4cd17f 100644 --- a/packages/preset/agent-presets/tests/discovery.spec.ts +++ b/packages/preset/agent-presets/tests/discovery.spec.ts @@ -1,14 +1,37 @@ -import { chmod, mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { COMPOSITION_FILE, discoverPresets, scanRoot } from '@deepseek-ai/dsh-agent-presets' +const fsHarness = vi.hoisted(() => ({ + nextReadError: undefined as NodeJS.ErrnoException | undefined, +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readFile: (async (path: unknown, ...rest: never[]) => { + const error = fsHarness.nextReadError + if (error !== undefined) { + fsHarness.nextReadError = undefined + throw error + } + return (actual.readFile as (path: unknown, ...args: never[]) => Promise)(path, ...rest) + }) as typeof actual.readFile, + } +}) + const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') const SYSTEM = { path: join(FIXTURES, 'system'), trust: 'system' as const } const USER = { path: join(FIXTURES, 'user'), trust: 'user' as const } +beforeEach(() => { + fsHarness.nextReadError = undefined +}) + describe('display order', () => { it('puts declared order first, then everything else by id', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-order-')) @@ -177,10 +200,11 @@ describe('composition health', () => { await mkdir(join(root, 'sealed')) const path = join(root, 'sealed', COMPOSITION_FILE) await writeFile(path, '[]\n') - await chmod(path, 0o000) + fsHarness.nextReadError = Object.assign(new Error('EACCES: injected read failure'), { code: 'EACCES' }) const [preset] = await scanRoot({ path: root, trust: 'user' }) + expect(fsHarness.nextReadError).toBeUndefined() expect(preset?.broken).toMatch(/cannot be read/) }) diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index b4988331cc..c84dae525b 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -11,7 +11,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import AgentPresets, { COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, PresetMountError, serviceForAgent, } from '@deepseek-ai/dsh-agent-presets' @@ -84,6 +84,23 @@ beforeEach(async () => { }) describe('composing an agent from a preset', () => { + it('hands an absolute plugin path to Node as a file URL', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-preset-absolute-plugin-')) + const presetDir = join(root, 'absolute') + const plugin = join(FIXTURES, 'plugins', 'contribute.js') + await mkdir(presetDir) + await writeFile( + join(presetDir, COMPOSITION_FILE), + `- id: only\n name: ${plugin}\n config:\n tool: absolute\n`, + ) + const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }] }) + const imported = vi.spyOn(scoped.loader.internal!, 'import') + + await agentOn(scoped, 'sess-absolute-plugin') + + expect(imported).toHaveBeenCalledWith(pathToFileURL(plugin).href, expect.any(String), {}) + }) + it('gives each session only its own preset\'s tools', async () => { const alpha = await agentOn(ctx, 'sess-alpha', 'standard') const beta = await agentOn(ctx, 'sess-beta', 'minimal') From 52fafa012e8616b5587863b0e2fe05221255a982 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 14:30:02 +0800 Subject: [PATCH 2/3] test(agent-presets): stabilize generation race coverage --- .../preset/agent-presets/tests/mount.spec.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index c84dae525b..77c549a689 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -542,6 +542,34 @@ describe('editing a composition file', () => { expect(livePresetMounts().filter(mount => mount.presetId === 'raced')).toHaveLength(2) }) + it('keeps a newer generation pointer when a stale refresh loses the swap race', async () => { + const { scoped, path } = await editable('guarded-refresh') + const preset = await scoped.agentPresets.resolve('guarded-refresh') + await agentOn(scoped, 'sess-guarded-refresh-seed', 'guarded-refresh') + const service = scoped.agentPresets as unknown as { + standing: Map> + ensureStanding(current: typeof preset): Promise + } + const stalePromise = service.standing.get(preset.id)! + const stale = await stalePromise + await writeFile(path, rowFor('afterwards')) + const { mtimeMs, size } = await stat(path) + const newer = { ...stale, stamp: { mtimeMs, size } } + const newerPromise = Promise.resolve(newer) + + // `await pending` yields before the guarded delete, letting the winning + // refresher replace the pointer deterministically instead of by timing. + const refresh = service.ensureStanding(preset) + service.standing.set(preset.id, newerPromise) + + expect(await refresh).toBe(newer) + expect(service.standing.get(preset.id)).toBe(newerPromise) + }) + it('hands a host reader the standing key without starting an agent', async () => { const { scoped } = await editable('cold-read') From 6a830f1779657b901e017984273a354a2ecd4aeb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 14:44:37 +0800 Subject: [PATCH 3/3] test(web): stabilize preset-aware steering snapshots --- apps/web/tests/snapshots/steer-all/mid-steer.expected.md | 2 ++ apps/web/tests/snapshots/steer-all/settled.expected.md | 2 ++ apps/web/tests/steering.e2e.ts | 1 + 3 files changed, 5 insertions(+) diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md index 998ee98129..222097e0a3 100644 --- a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/steer-all/settled.expected.md b/apps/web/tests/snapshots/steer-all/settled.expected.md index a61f57572e..d9f1893344 100644 --- a/apps/web/tests/snapshots/steer-all/settled.expected.md +++ b/apps/web/tests/snapshots/steer-all/settled.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 8a09582b56..a527081fbe 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -314,6 +314,7 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) + await page.getByText('标准模式', { exact: true }).waitFor({ timeout: 10_000 }) }, 120_000) afterAll(async () => {