Merge remote-tracking branch 'origin/master' into fix/web-slash-catalog-follows-preset

This commit is contained in:
Yichen Jiang
2026-08-10 15:07:15 +08:00
10 changed files with 104 additions and 18 deletions

View File

@@ -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"

View File

@@ -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"

View File

@@ -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 () => {

View File

@@ -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

View File

@@ -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:

View File

@@ -55,6 +55,8 @@ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有
**相对**路径仍从 preset 自身的目录解析,因此 preset 自带的插件文件与 skill 目录会随它一同迁移。
**绝对**文件系统路径则保留其自身位置。挂载会先将它转换为 `file:` URL 再交给 ESM 导入,从而使 POSIX 路径和 Windows 盘符或 UNC 路径都采用 Node 能够接受的说明符。
### 展示用元信息
preset 可以在组装文件旁的可选 `preset.yml` 里发布展示文本:

View File

@@ -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, {})
}
/**

View File

@@ -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 () => {

View File

@@ -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<typeof import('node:fs/promises')>()
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<unknown>)(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/)
})

View File

@@ -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'
@@ -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')
@@ -525,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<string, Promise<{
key: unknown
scope: unknown
stamp: { mtimeMs: number; size: number }
}>>
ensureStanding(current: typeof preset): Promise<unknown>
}
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')