From 9990e7006c4a7f2813e2eecae98ad82a1aa7e1a6 Mon Sep 17 00:00:00 2001 From: Cea <61349137+ceastld@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:04:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(dsh):=20=E5=86=99=E5=8A=A8=E4=BD=9C?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=8A=80=E8=83=BD=E7=9B=AE=E5=BD=95=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E6=8C=89=E9=9C=80=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 不再在每次会话开头注入整份 write-action 说明 - 通过 ctx.skills.register 注册目录卡片,完整正文按需加载 - 验证:python -m unittest discover -s tests -v -p test_dsh*.py 与 test_install_clients.py 通过 --- README.md | 4 +- .../\345\205\274\345\256\271\346\200\247.md" | 4 +- ...67\347\253\257\345\256\211\350\243\205.md" | 2 +- plugins/quicker-dsh/README.md | 8 +- plugins/quicker-dsh/index.js | 109 ++++++------ plugins/quicker-dsh/package.json | 2 +- tests/dsh_inject_reload.mjs | 102 ----------- tests/dsh_plugin.mjs | 158 ++++++++++++++++++ tests/test_dsh_inject.py | 55 ------ tests/test_dsh_plugin.py | 45 +++++ tests/test_install_clients.py | 7 +- 11 files changed, 274 insertions(+), 222 deletions(-) delete mode 100644 tests/dsh_inject_reload.mjs create mode 100644 tests/dsh_plugin.mjs delete mode 100644 tests/test_dsh_inject.py create mode 100644 tests/test_dsh_plugin.py diff --git a/README.md b/README.md index d735257..8522cf8 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ | Cursor | 本地插件;CLI 真实写动作通过 | Windows;技能、草稿创建/保存/预览 | | Claude Code | 原生插件,v0.2.1 | v0.2.0 公开市场安装和真实 MCP 连接通过;模型写动作待验收 | | VS Code / Copilot、Gemini CLI | 配置安装器 | 默认 Windows 用户配置;尚未完成各客户端写动作验收 | -| DeepSeek Harness | DSH bundle 插件,v0.2.3 | Windows;安装包与转接已实现;v0.2.3 修复注入消息缺 id/role 导致历史无法加载。真实 DSH 会话写动作待验收 | +| DeepSeek Harness | DSH bundle 插件,v0.2.4 | Windows;`write-action` 以技能目录卡片按需加载,不再在每次会话开头注入整份编写说明。真实 DSH 会话写动作待验收 | 需要使用设置 → Agent 中带「启用 MCP」入口、并包含默认技能包发现修复的 Quicker 新构建。Release 支持已实现,待包含这些变更的正式版发布;已发布旧版没有该入口时仍不可用。本次已验证 Debug 的草稿编写与预览,Release 配置内核测试和正式前端构建通过,正式安装包端到端仍待验收,详见[兼容性说明](docs/兼容性.md)。 @@ -77,7 +77,7 @@ if ($LASTEXITCODE -eq 0) { 安装器把自包含 bundle 复制到 `%USERPROFILE%/.quicker/agent-integrations/dsh`。PATH 中有 `dsh` 时会再执行 `dsh plugin --profile web add link:<该目录>`;否则把打印出的命令贴到目标 profile。开发检出可直接 `dsh plugin --profile web add link:<仓库>/plugins/quicker-dsh`。 -启用 Quicker 设置 → Agent 中的 MCP 与允许写入,重启 `dsh web` 或 DSH 桌面端。首次连接确认 `dsh-quicker-plugin`。工具名为 `mcp__quicker__skill_load` 这类带命名空间的名称。可以这样开始: +启用 Quicker 设置 → Agent 中的 MCP 与允许写入,重启 `dsh web` 或 DSH 桌面端。首次连接确认 `dsh-quicker-plugin`。工具名为 `mcp__quicker__skill_load` 这类带命名空间的名称。普通对话只会看到技能目录里的 `write-action` 卡片;写动作时模型会按需加载完整说明,也可输入 `/write-action`。可以这样开始: > 用 Quicker 写一个动作,显示“来自 DeepSeek Harness”,保存到暂存区并打开预览,不运行。 diff --git "a/docs/\345\205\274\345\256\271\346\200\247.md" "b/docs/\345\205\274\345\256\271\346\200\247.md" index 1b97484..7f9429e 100644 --- "a/docs/\345\205\274\345\256\271\346\200\247.md" +++ "b/docs/\345\205\274\345\256\271\346\200\247.md" @@ -20,13 +20,13 @@ | Cursor CLI | 2026-09-07:2026.08.11-e8db854;v0.2.0 本地安装包技能加载、实时知识发现、4 步动作创建/保存/预览通过。IDE 3.7.12 的插件设置界面重载尚未单独操作验收 | | Claude Code | 2026-09-07:2.1.263;v0.2.0 插件与市场通过官方 validator,隔离安装及真实用户配置下的公开市场安装成功;识别 write-action 技能与 MCP,mcp list 连接真实 Quicker 成功。模型调用因未登录退出,未验证 Claude 模型写动作 | | VS Code / Gemini CLI | v0.2.0 默认用户配置安装/更新/卸载和隔离传输通过;尚未完成实际客户端写动作验收 | -| DeepSeek Harness | v0.2.3 补齐 `agent.inject` 的 `id` / `role`,避免会话回放报 `lacks an identified message`;bundle 清单、安装器复制、`dsh` Client 转接与隔离传输通过。0.2.0 已写入的旧会话不会自动修好。尚未完成真实 DSH 会话写动作验收 | +| DeepSeek Harness | v0.2.4 将 `write-action` 注册为 DSH 技能目录卡片,完整说明按需加载,不再在 `agent/session-start` 注入整份正文;bundle 清单、安装器复制、`dsh` Client 转接与隔离传输通过。尚未完成真实 DSH 会话写动作验收 | ## 版本记录 每个平台使用自己的插件 manifest 版本。发布记录应包含平台版本、Quicker 版本或构建、操作系统、插件版本与实测结果。尚未实测的最低版本写为未知,不猜测兼容范围。 -当前 Claude Code 安装包版本为 `0.2.1`,补齐安装、认证、诊断与共存指引,MCP 运行配置和共享转接与 `0.2.0` 相同。DeepSeek Harness 安装包为 `0.2.3`,与发布批次 `v0.2.3` 对齐,只修注入消息标识。Codex / Cursor / 通用 MCP 仍为 `0.2.0`。各平台分发方式见[客户端安装](客户端安装.md)。旧个人市场原型使用的 `+codex.<时间戳>` 后缀只用于刷新开发缓存,不代表跨平台发布版本。 +当前 Claude Code 安装包版本为 `0.2.1`,补齐安装、认证、诊断与共存指引,MCP 运行配置和共享转接与 `0.2.0` 相同。DeepSeek Harness 安装包为 `0.2.4`,写动作技能改为目录卡片按需加载。Codex / Cursor / 通用 MCP 仍为 `0.2.0`。各平台分发方式见[客户端安装](客户端安装.md)。旧个人市场原型使用的 `+codex.<时间戳>` 后缀只用于刷新开发缓存,不代表跨平台发布版本。 `0.1.1` 修复 `0.1.0` 安装成功但 MCP 无法启动的问题:当前 Codex legacy MCP 加载器不会展开参数中的 `${PLUGIN_ROOT}`,现改用 `cwd: "."` 与包内相对脚本路径。旧版用户按 README 的市场升级与重新安装步骤更新。隔离 HTTP fixture 用于传输回归,真实 Quicker 验收证据见下。 diff --git "a/docs/\345\256\242\346\210\267\347\253\257\345\256\211\350\243\205.md" "b/docs/\345\256\242\346\210\267\347\253\257\345\256\211\350\243\205.md" index 6343d42..4561aea 100644 --- "a/docs/\345\256\242\346\210\267\347\253\257\345\256\211\350\243\205.md" +++ "b/docs/\345\256\242\346\210\267\347\253\257\345\256\211\350\243\205.md" @@ -51,7 +51,7 @@ VS Code / Gemini / DSH 同样替换 Client。卸载只删除归属匹配且未 安装器复制 bundle 后,若 PATH 中有 `dsh`,会执行 `dsh plugin --profile web add link:<安装目录>`。桌面端或其它 profile 把 `web` 换成实际名称。没有 CLI 时,把安装器打印的命令贴进已初始化的 profile。 -启用 Quicker 设置 → Agent 中的 MCP 与允许写入,重启 `dsh web` 或桌面端。首次连接确认 `dsh-quicker-plugin`。工具名为 `mcp__quicker__<原名>`。 +启用 Quicker 设置 → Agent 中的 MCP 与允许写入,重启 `dsh web` 或桌面端。首次连接确认 `dsh-quicker-plugin`。工具名为 `mcp__quicker__<原名>`。`write-action` 只作为技能目录卡片出现;完整编写说明在 `skill({ name: "write-action" })` 或 `/write-action` 后加载,不会在每次新对话开头注入大段正文。 ```powershell powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$env:USERPROFILE\.quicker\agent-integrations\dsh\scripts\quicker-mcp.ps1" -Check diff --git a/plugins/quicker-dsh/README.md b/plugins/quicker-dsh/README.md index 9e979e3..1382e17 100644 --- a/plugins/quicker-dsh/README.md +++ b/plugins/quicker-dsh/README.md @@ -1,8 +1,8 @@ # Quicker DeepSeek Harness 插件 -版本:0.2.3。需要 Windows PowerShell 5.1、已安装的 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh` CLI 或桌面端),以及设置 → Agent 中带「启用 MCP」入口的本机 Quicker。此目录是完整 DSH bundle:写动作引导、stdio 转接,以及把 `@deepseek-ai/dsh-mcp-client` 接到本机 Quicker 的入口。运行时不依赖开发检出。 +版本:0.2.4。需要 Windows PowerShell 5.1、已安装的 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh` CLI 或桌面端),以及设置 → Agent 中带「启用 MCP」入口的本机 Quicker。此目录是完整 DSH bundle:写动作技能、stdio 转接,以及把 `@deepseek-ai/dsh-mcp-client` 接到本机 Quicker 的入口。运行时不依赖开发检出。 -0.2.3 与发布批次 `v0.2.3` 对齐(`quicker-dsh-0.2.3.zip` 内即为 0.2.3)。功能与误标成 0.2.1 的 v0.2.2 相同:补齐 `agent.inject` 的 `id` / `role`。0.2.0 写入的会话在 DSH 回放时会报 `lacks an identified message` 并整段无法加载;本版只阻止新会话再被写坏,不能修好已经落盘的旧日志。 +0.2.4 把 `write-action` 注册为 DSH 技能目录卡片:会话开头只出现名称和描述,完整写动作说明在模型调用 `skill({ name: "write-action" })` 或用户输入 `/write-action` 后才加载。不再在每次 `agent/session-start` 注入整份技能正文。0.2.3 曾补齐 `agent.inject` 的 `id` / `role`,避免旧注入消息导致会话无法回放;0.2.0 已写入的会话不会自动修好。 ## 安装 @@ -30,11 +30,11 @@ dsh plugin --profile web add link:<本仓库>/plugins/quicker-dsh 启用 Quicker 设置 → Agent 中的 MCP 与允许写入,然后重启 `dsh web` 或 DSH 桌面端。首次连接在 Quicker 中确认 `dsh-quicker-plugin` 客户端。安装本身不授予执行或覆盖权限。 -可以这样开始: +未写动作的对话只会看到技能目录里的 `write-action` 卡片,不会被整份编写协议占用。需要写动作时可以说: > 用 Quicker 写一个动作,显示“来自 DeepSeek Harness”,保存到暂存区并打开预览,不运行。 -Quicker 工具在 DSH 中的名称带命名空间,例如 `mcp__quicker__skill_load`、`mcp__quicker__quicker_create`。默认保存到暂存区;正式保留、覆盖或运行由用户要求和 Quicker 审批决定。 +也可以直接输入 `/write-action`。Quicker 工具在 DSH 中的名称带命名空间,例如 `mcp__quicker__skill_load`、`mcp__quicker__quicker_create`;调用结果走 DSH 自带的工具卡片。默认保存到暂存区;正式保留、覆盖或运行由用户要求和 Quicker 审批决定。 ## 更新、卸载和诊断 diff --git a/plugins/quicker-dsh/index.js b/plugins/quicker-dsh/index.js index ed6776a..4b533d9 100644 --- a/plugins/quicker-dsh/index.js +++ b/plugins/quicker-dsh/index.js @@ -1,14 +1,19 @@ -import { randomUUID } from 'node:crypto' import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' const PACKAGE_ROOT = dirname(fileURLToPath(import.meta.url)) const RELAY_SCRIPT = 'quicker-mcp.ps1' -const SKILL_FILE = join(PACKAGE_ROOT, 'skills', 'write-action', 'SKILL.md') +const SKILL_DIR = join(PACKAGE_ROOT, 'skills', 'write-action') +const SKILL_FILE = join(SKILL_DIR, 'SKILL.md') +const DSH_TOOL_NOTE = [ + 'Quicker MCP tools appear as mcp__quicker__', + '(for example mcp__quicker__skill_load and mcp__quicker__quicker_create).', + 'They edit Quicker virtual slots, not this workspace.', +].join(' ') export const name = 'quicker' -export const inject = ['tools', 'loader'] +export const inject = ['tools', 'loader', 'skills'] export function resolvePackageRoot() { return PACKAGE_ROOT @@ -36,63 +41,56 @@ export function mcpClientConfig(root = PACKAGE_ROOT) { } } -function loadWriteActionSkill() { - try { - return readFileSync(SKILL_FILE, 'utf8').trim() - } catch { - return [ - 'Use Quicker MCP tools to create, edit, save and preview actions.', - 'Tool names are prefixed as mcp__quicker__.', - 'Load action-source with skill_load before editing program.source.yaml.', - ].join(' ') +function unquoteYamlScalar(value) { + if ( + (value.startsWith('"') && value.endsWith('"')) + || (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1) } + return value } -function authoringPreamble() { - return [ - 'Quicker MCP tools are registered as mcp__quicker__', - '(for example mcp__quicker__skill_load and mcp__quicker__quicker_create).', - 'They edit Quicker virtual slots, not the local workspace.', - '', - loadWriteActionSkill(), - ].join('\n') -} - -function assertIdentifiedUserMessage(message) { - if (typeof message !== 'object' || message === null - || typeof message.id !== 'string' || message.id === '') { - throw new Error('dsh-plugin-quicker inject requires an identified message') +export function parseSkillMarkdown(markdown) { + const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(markdown) + if (!match) { + throw new Error('write-action skill is missing YAML frontmatter') } - if (message.role !== 'user') { - throw new Error('dsh-plugin-quicker inject requires role user') + + const fields = {} + for (const line of match[1].split(/\r?\n/)) { + const separator = line.indexOf(':') + if (separator <= 0) continue + const key = line.slice(0, separator).trim() + const value = unquoteYamlScalar(line.slice(separator + 1).trim()) + if (key !== '') fields[key] = value } - if (typeof message.source !== 'object' || message.source === null - || typeof message.source.kind !== 'string' || message.source.kind === '') { - throw new Error('dsh-plugin-quicker inject requires a message source') + + const name = fields.name?.trim() + const description = fields.description?.trim() + if (!name || !description) { + throw new Error('write-action skill frontmatter requires name and description') } - if (!Array.isArray(message.content)) { - throw new Error('dsh-plugin-quicker inject requires content') + + return { + name, + description, + content: match[2].trim(), } } -export function createAuthoringGuideMessage(text) { - const message = { - id: randomUUID(), - role: 'user', - content: [{ type: 'text', text: text ?? authoringPreamble() }], - source: { kind: 'plugin', plugin: 'dsh-plugin-quicker' }, - } - assertIdentifiedUserMessage(message) - return message +export function loadWriteActionSkill() { + return parseSkillMarkdown(readFileSync(SKILL_FILE, 'utf8')) } -function injectAuthoringGuide(agent) { - const message = createAuthoringGuideMessage() - try { - // DSH persist + replay require a complete UserMessage (id + role). - agent.inject(message) - } catch { - // A disposed agent must not fail plugin activation. +export function writeActionSkillRegistration(skill = loadWriteActionSkill()) { + return { + name: skill.name, + description: skill.description, + content: `${DSH_TOOL_NOTE}\n\n${skill.content}`, + source: 'bundled', + path: SKILL_FILE, + resourceBase: { kind: 'directory', path: SKILL_DIR }, } } @@ -104,6 +102,9 @@ export async function apply(ctx) { if (loader == null || typeof loader.create !== 'function') { throw new Error('dsh-plugin-quicker requires the DSH loader to register @deepseek-ai/dsh-mcp-client') } + if (ctx.skills == null || typeof ctx.skills.register !== 'function') { + throw new Error('dsh-plugin-quicker requires ctx.skills to register the write-action skill') + } await ctx.effect(async () => { const id = await loader.create({ @@ -116,7 +117,11 @@ export async function apply(ctx) { } }, 'dsh-plugin-quicker: mcp-client') - ctx.on('agent/session-start', ({ agent }) => { - injectAuthoringGuide(agent) - }) + // Register a catalog card only. DSH's skill tool loads the body on demand + // (`skill({ name: "write-action" })` or `/write-action`). Do not inject the + // full guide into every session — that crowds out unrelated conversations. + await ctx.effect( + () => ctx.skills.register(writeActionSkillRegistration()), + 'dsh-plugin-quicker: write-action', + ) } diff --git a/plugins/quicker-dsh/package.json b/plugins/quicker-dsh/package.json index 40ce65e..f5557cb 100644 --- a/plugins/quicker-dsh/package.json +++ b/plugins/quicker-dsh/package.json @@ -1,6 +1,6 @@ { "name": "dsh-plugin-quicker", - "version": "0.2.3", + "version": "0.2.4", "description": "通过本机 Quicker MCP 编写、保存和预览自动化动作。", "type": "module", "main": "index.js", diff --git a/tests/dsh_inject_reload.mjs b/tests/dsh_inject_reload.mjs deleted file mode 100644 index 714e0eb..0000000 --- a/tests/dsh_inject_reload.mjs +++ /dev/null @@ -1,102 +0,0 @@ -import { readFileSync } from 'node:fs' -import { pathToFileURL } from 'node:url' -import { apply, createAuthoringGuideMessage } from '../plugins/quicker-dsh/index.js' - -const sessionEntry = process.env.DSH_SESSION_ENTRY -if (!sessionEntry) { - throw new Error('DSH_SESSION_ENTRY must point at @deepseek-ai/dsh-session/lib/index.js') -} -const { snapshotSessionEvent } = await import(pathToFileURL(sessionEntry).href) - -function asUserMessageEvent(data, seq = 1) { - return { - type: 'user/message', - seq, - time: Date.now(), - data, - surfaceOp: 'append', - } -} - -function expectThrow(label, fn, needle) { - try { - fn() - } catch (error) { - const text = String(error?.message ?? error) - if (!text.includes(needle)) { - throw new Error(`${label}: expected ${JSON.stringify(needle)}, got ${JSON.stringify(text)}`) - } - return text - } - throw new Error(`${label}: expected DSH to reject the event`) -} - -const broken = { - content: [{ type: 'text', text: 'Quicker MCP tools are registered as mcp__quicker__' }], - source: { kind: 'plugin', plugin: 'dsh-plugin-quicker' }, -} -const brokenError = expectThrow( - '0.2.0 inject payload', - () => snapshotSessionEvent(asUserMessageEvent(broken, 421)), - 'lacks an identified message', -) - -const fixed = createAuthoringGuideMessage('guide') -const adopted = snapshotSessionEvent(asUserMessageEvent(fixed, 1)) -if (adopted.data.id !== fixed.id || adopted.data.role !== 'user') { - throw new Error('fixed inject lost id or role after DSH adopt') -} - -const injected = [] -const handlers = {} -await apply({ - loader: { - create: async () => 'mcp-1', - store: { 'mcp-1': {} }, - remove: async () => {}, - }, - effect: async (fn) => { - await fn() - return async () => {} - }, - on: (event, handler) => { - handlers[event] = handler - }, -}) -handlers['agent/session-start']({ agent: { inject: (message) => injected.push(message) } }) -if (injected.length !== 1) { - throw new Error(`session-start should inject one guide, got ${injected.length}`) -} -snapshotSessionEvent(asUserMessageEvent(injected[0], 10)) - -const sessionPath = process.env.QUICKER_DSH_SESSION_JSONL -const evidence = [] -if (sessionPath) { - for (const line of readFileSync(sessionPath, 'utf8').split(/\r?\n/)) { - if (!line.includes('dsh-plugin-quicker')) continue - let event - try { - event = JSON.parse(line) - } catch { - continue - } - if (event?.type !== 'user/message') continue - const plugin = event.data?.source?.plugin - if (plugin !== 'dsh-plugin-quicker') continue - try { - snapshotSessionEvent(structuredClone(event)) - evidence.push({ seq: event.seq, ok: true }) - } catch (error) { - evidence.push({ seq: event.seq, ok: false, error: String(error?.message ?? error) }) - } - } -} - -process.stdout.write(JSON.stringify({ - ok: true, - brokenError, - fixedId: adopted.data.id, - injectedId: injected[0].id, - evidence, - plugin: new URL('../plugins/quicker-dsh/index.js', import.meta.url).href, -}) + '\n') diff --git a/tests/dsh_plugin.mjs b/tests/dsh_plugin.mjs new file mode 100644 index 0000000..82645c0 --- /dev/null +++ b/tests/dsh_plugin.mjs @@ -0,0 +1,158 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + apply, + loadWriteActionSkill, + parseSkillMarkdown, + writeActionSkillRegistration, +} from '../plugins/quicker-dsh/index.js' + +function assert(condition, message) { + if (!condition) throw new Error(message) +} + +function assertEqual(actual, expected, label) { + if (actual !== expected) { + throw new Error(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`) + } +} + +function assertIncludes(haystack, needle, label) { + if (!String(haystack).includes(needle)) { + throw new Error(`${label}: expected to include ${JSON.stringify(needle)}`) + } +} + +function assertThrows(label, fn, needle) { + try { + fn() + } catch (error) { + const text = String(error?.message ?? error) + if (!text.includes(needle)) { + throw new Error(`${label}: expected ${JSON.stringify(needle)}, got ${JSON.stringify(text)}`) + } + return + } + throw new Error(`${label}: expected to throw`) +} + +async function assertRejects(label, fn, needle) { + try { + await fn() + } catch (error) { + const text = String(error?.message ?? error) + if (!text.includes(needle)) { + throw new Error(`${label}: expected ${JSON.stringify(needle)}, got ${JSON.stringify(text)}`) + } + return + } + throw new Error(`${label}: expected to reject`) +} + +function createContext() { + const skills = [] + const events = [] + const loaderCalls = [] + return { + skills, + events, + loaderCalls, + ctx: { + loader: { + create: async (spec) => { + loaderCalls.push(spec) + return 'mcp-1' + }, + store: { 'mcp-1': {} }, + remove: async () => {}, + }, + effect: async (fn) => { + const dispose = await fn() + return dispose + }, + on: (event) => { + events.push(event) + }, + skills: { + register(skill) { + skills.push(skill) + return () => {} + }, + }, + }, + } +} + +const parsed = parseSkillMarkdown('---\nname: write-action\ndescription: Route to Quicker authoring.\n---\n# Body\n') +assertEqual(parsed.name, 'write-action', 'parsed name') +assertEqual(parsed.description, 'Route to Quicker authoring.', 'parsed description') +assertEqual(parsed.content, '# Body', 'parsed content') +assertThrows( + 'missing frontmatter', + () => parseSkillMarkdown('# Body\n'), + 'missing YAML frontmatter', +) +assertThrows( + 'missing description', + () => parseSkillMarkdown('---\nname: write-action\n---\n# Body\n'), + 'requires name and description', +) + +const skillFile = join(dirname(fileURLToPath(import.meta.url)), '..', 'plugins', 'quicker-dsh', 'skills', 'write-action', 'SKILL.md') +const fromDisk = loadWriteActionSkill() +const source = readFileSync(skillFile, 'utf8') +assertEqual(fromDisk.name, 'write-action', 'disk skill name') +assertIncludes(source, fromDisk.description, 'disk description comes from SKILL.md') +assertIncludes(fromDisk.content, 'quicker_create', 'disk skill body') +assert( + !fromDisk.content.includes('---\nname: write-action'), + 'registered body must not include YAML frontmatter', +) + +const registration = writeActionSkillRegistration(fromDisk) +assertEqual(registration.name, 'write-action', 'registration name') +assertEqual(registration.description, fromDisk.description, 'registration description stays compact') +assertEqual(registration.source, 'bundled', 'registration source') +assertEqual(registration.resourceBase.kind, 'directory', 'resourceBase kind') +assertIncludes(registration.resourceBase.path, 'write-action', 'resourceBase path') +assertIncludes(registration.content, 'mcp__quicker__skill_load', 'DSH tool names') +assertIncludes(registration.content, fromDisk.content.slice(0, 40), 'full skill body is on-demand content') +assert( + registration.description.length < 500, + `catalog card description must stay under DSH's 500-char bound, got ${registration.description.length}`, +) + +const { ctx, skills, events, loaderCalls } = createContext() +await apply(ctx) +assertEqual(loaderCalls.length, 1, 'mcp client registrations') +assertEqual(loaderCalls[0].name, '@deepseek-ai/dsh-mcp-client', 'mcp client package') +assertEqual(loaderCalls[0].config.serverName, 'quicker', 'mcp serverName') +assertEqual(skills.length, 1, 'skill registrations') +assertEqual(skills[0].name, 'write-action', 'registered skill name') +assertEqual(skills[0].description, fromDisk.description, 'registered description is the catalog card') +assertIncludes(skills[0].content, 'mcp__quicker__quicker_create', 'registered body') +assertEqual(events.length, 0, 'no session-start or other conversation inject listeners') +assert( + !JSON.stringify(skills[0].description).includes('# Write a Quicker action'), + 'catalog card must not contain the full skill body', +) + +await assertRejects( + 'missing skills', + () => apply({ + loader: { create: async () => 'mcp-1' }, + effect: async (fn) => fn(), + on() {}, + }), + 'requires ctx.skills', +) + +process.stdout.write(JSON.stringify({ + ok: true, + skill: skills[0].name, + descriptionChars: skills[0].description.length, + contentChars: skills[0].content.length, + mcpClient: loaderCalls[0].name, + injectedEvents: events, +}) + '\n') diff --git a/tests/test_dsh_inject.py b/tests/test_dsh_inject.py deleted file mode 100644 index 7f23901..0000000 --- a/tests/test_dsh_inject.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Replay the DSH session-load failure against the local DeepSeek Harness validator.""" - -from __future__ import annotations - -import json -import os -from pathlib import Path -import shutil -import subprocess -import unittest - -ROOT = Path(__file__).resolve().parents[1] -SCRIPT = Path(__file__).resolve().parent / "dsh_inject_reload.mjs" -EVIDENCE = Path(r"C:\Users\ldy\Downloads\session.v3.jsonl") - - -def resolve_dsh_node_modules() -> Path | None: - override = os.environ.get("DSH_NODE_MODULES") - if override: - root = Path(override) - if (root / "@deepseek-ai" / "dsh-session").is_dir(): - return root - cache = Path(os.environ.get("LOCALAPPDATA", "")) / "npm-cache" / "_npx" - if cache.is_dir(): - matches = sorted(cache.glob("*/node_modules/@deepseek-ai/dsh-session"), key=lambda p: p.stat().st_mtime, reverse=True) - if matches: - return matches[0].parents[1] - return None - - -@unittest.skipUnless(shutil.which("node"), "Node is required to load the DSH plugin") -class DshInjectReloadTests(unittest.TestCase): - def test_broken_inject_fails_dsh_reload_and_fixed_inject_passes(self) -> None: - node_modules = resolve_dsh_node_modules() - if node_modules is None: - self.skipTest("local @deepseek-ai/dsh-session was not found") - env = os.environ.copy() - env["DSH_SESSION_ENTRY"] = str(node_modules / "@deepseek-ai" / "dsh-session" / "lib" / "index.js") - if EVIDENCE.is_file(): - env["QUICKER_DSH_SESSION_JSONL"] = str(EVIDENCE) - result = subprocess.run( - ["node", str(SCRIPT)], - cwd=str(ROOT), - env=env, - capture_output=True, - text=True, - timeout=30, - check=False, - ) - self.assertEqual(result.returncode, 0, result.stderr + result.stdout) - payload = json.loads(result.stdout) - self.assertTrue(payload["ok"]) - self.assertIn("lacks an identified message", payload["brokenError"]) - self.assertTrue(payload["fixedId"]) - self.assertTrue(payload["injectedId"]) diff --git a/tests/test_dsh_plugin.py b/tests/test_dsh_plugin.py new file mode 100644 index 0000000..d41e027 --- /dev/null +++ b/tests/test_dsh_plugin.py @@ -0,0 +1,45 @@ +"""Register the DSH write-action skill as a catalog card instead of injecting it.""" + +from __future__ import annotations + +import json +from pathlib import Path +import shutil +import subprocess +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = Path(__file__).resolve().parent / "dsh_plugin.mjs" +INDEX = ROOT / "plugins/quicker-dsh/index.js" + + +@unittest.skipUnless(shutil.which("node"), "Node is required to load the DSH plugin") +class DshPluginTests(unittest.TestCase): + def test_write_action_skill_registers_without_session_inject(self) -> None: + result = subprocess.run( + ["node", str(SCRIPT)], + cwd=str(ROOT), + capture_output=True, + text=True, + timeout=30, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + payload = json.loads(result.stdout) + self.assertTrue(payload["ok"]) + self.assertEqual(payload["skill"], "write-action") + self.assertGreater(payload["descriptionChars"], 20) + self.assertLess(payload["descriptionChars"], 500) + self.assertGreater(payload["contentChars"], payload["descriptionChars"]) + self.assertEqual(payload["mcpClient"], "@deepseek-ai/dsh-mcp-client") + self.assertEqual(payload["injectedEvents"], []) + + def test_plugin_source_does_not_inject_authoring_guide(self) -> None: + index = INDEX.read_text(encoding="utf-8") + self.assertIn("ctx.skills.register", index) + self.assertIn("write-action", index) + self.assertIn("'skills'", index) + self.assertNotIn("agent/session-start", index) + self.assertNotIn("agent.inject", index) + self.assertNotIn("randomUUID", index) + self.assertNotIn("createAuthoringGuideMessage", index) diff --git a/tests/test_install_clients.py b/tests/test_install_clients.py index 6cbc867..7cac43b 100644 --- a/tests/test_install_clients.py +++ b/tests/test_install_clients.py @@ -29,11 +29,12 @@ def test_shared_sources_and_manifests(self): index = (ROOT / 'plugins/quicker-dsh/index.js').read_text(encoding='utf-8') self.assertIn("@deepseek-ai/dsh-mcp-client", index) self.assertIn("'dsh'", index) - self.assertIn('agent/session-start', index) + self.assertIn('ctx.skills.register', index) + self.assertIn("'skills'", index) self.assertIn('loader.create', index) self.assertIn("'loader'", index) - self.assertIn('randomUUID', index) - self.assertIn("role: 'user'", index) + self.assertNotIn('agent/session-start', index) + self.assertNotIn('agent.inject', index) self.assertNotIn("ctx.plugin('@deepseek-ai/dsh-mcp-client'", index)