From 13dc18afa017aaba2882a3e857b484a1a07cd78b Mon Sep 17 00:00:00 2001 From: Nicholas023 Date: Wed, 4 Nov 2026 23:41:08 +0800 Subject: [PATCH] fix(commands): register /review with the host CommandDefinition contract The slash command never appeared. Two contract violations, both of which throw inside the plugin and get swallowed by its own try/catch: 1. `input` was a string. dsh-commands requires an object with a `hint` string, so register() threw 'input hint must be a string'. 2. handler returned a bare string. The registry requires a CommandResult ({ kind: success|error, text? }), which would have thrown on invoke. Also sets definitionId so the host can pair registration with disposal. Verified against the host rules transcribed from dsh-commands: both normalizeDefinition and normalizeResult now pass on the real registration. --- src/plugin/index.ts | 13 +++++++++++-- test/plugin/apply.test.ts | 36 ++++++++++++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 560198f..717c6b6 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -390,15 +390,24 @@ export function apply(ctx: Ctx, rawConfig?: unknown): () => void { const commands = sctx.commands if (commands === undefined) return disposers.push(commands.register({ + // 命令定义的身份:DSH 用它配对注册与卸载(运行时就是普通字符串) + definitionId: 'dsh-code-review/review', name: 'review', description: '发起一次代码评审(等于 code_review 工具)', - input: 'repo_path [mode] [focus]', + // 契约:input 若提供,必须是 { hint: string }。 + // 传字符串会让 register 直接抛 TypeError,而异常会被下面的 try/catch 吞掉—— + // 表现就是「斜杠命令死活不出现、日志里也没有任何东西」。实机踩过。 + input: { hint: 'repo_path [mode] [focus]' }, handler: async (invocation: { agent?: { session?: { id?: string; header?: { cwd?: string } } } }) => { const raw = invocation?.agent?.session const cwd = raw?.header?.cwd const sid = typeof raw?.id === 'string' ? raw.id : null const r = await doSubmit({ repo_path: cwd }, ctx, cwd, sid, config) - return r.text + // 契约:handler 必须返回 { kind: 'success' | 'error', text? }。 + // 返回纯字符串会被判为非法结果,命令看起来「执行了但什么也没发生」。 + return r.ok + ? { kind: 'success' as const, text: r.text } + : { kind: 'error' as const, text: r.text === '' ? '评审未提交' : r.text } }, })) } catch (error) { diff --git a/test/plugin/apply.test.ts b/test/plugin/apply.test.ts index ce7a383..d86fcf5 100644 --- a/test/plugin/apply.test.ts +++ b/test/plugin/apply.test.ts @@ -50,7 +50,8 @@ const ABS_REPO = process.platform === 'win32' ? 'D:/some/repo' : '/tmp/some/repo /** 模拟 Cordis 上下文,记录注册了什么。 */ function makeCtx() { const tools: Array<{ name: string; execute?: (...args: unknown[]) => Promise }> = [] - const commands: Array<{ name: string }> = [] + // 保留完整定义:斜杠命令的契约(input 形状、handler 返回值)必须能被断言 + const commands: Array> = [] const warnings: string[] = [] // 宿主的 configEditor(假):0.2.0 起配置由它写入,所以挂载测试必须提供它, // 否则 code_review_set_config 只能诚实地回「profile 不可写」。 @@ -74,7 +75,7 @@ function makeCtx() { }, commands: { register: (cmd: unknown) => { - commands.push(cmd as { name: string }) + commands.push(cmd as Record) return () => { /* dispose */ } }, }, @@ -202,3 +203,34 @@ describe('工具可调用', () => { dispose() }) }) + +describe('斜杠命令契约', () => { + it('定义带 definitionId 且 input 是 { hint }(不是字符串)', () => { + // 回归:曾经把 input 写成字符串 'repo_path [mode]',宿主 normalizeDefinition 直接抛 + // TypeError,被 try/catch 吞掉 —— 表现为「/review 死活不出现,日志里什么都没有」。 + const { ctx, commands } = makeCtx() + const dispose = apply(ctx as never) + const cmd = commands.find((c) => c.name === 'review')! + expect(cmd.definitionId).toBe('dsh-code-review/review') + expect(typeof cmd.input).toBe('object') + expect(cmd.input).not.toBeNull() + expect(typeof cmd.input.hint).toBe('string') + expect(cmd.input.hint.trim().length).toBeGreaterThan(0) + dispose() + }) + + it('handler 返回 CommandResult({ kind, text }),不是裸字符串', async () => { + // 回归:宿主 normalizeResult 要求 kind 字段,返回字符串同样会被判非法。 + const { ctx, commands } = makeCtx() + const dispose = apply(ctx as never) + const cmd = commands.find((c) => c.name === 'review')! + const out = (await cmd.handler({ agent: { session: { id: 's1', header: { cwd: ABS_REPO } } } })) as { + kind?: string + text?: string + } + expect(typeof out).toBe('object') + expect(['success', 'error']).toContain(out.kind) + expect(typeof out.text).toBe('string') + dispose() + }) +})