Bladeren bron

feat(ui-skill): claim slash skill references into skill.invoke

A menu pick or an entered /name line now claims the composer into an
args-tolerant skill.invoke transaction instead of shipping the literal
text and hoping the model loads the skill. This gives every user-invocable
skill a deterministic entry point — including disable-model-invocation
skills the catalog never shows the model (issue #1470). Candidates carry
a user-only hint, and the unreached legacy <skill> reference codec is
removed (decision 21 removal cut).
Yichen Jiang 2 maanden geleden
bovenliggende
commit
56e9e61749

+ 4 - 0
packages/client/connection/tests/fake-api.ts

@@ -163,6 +163,9 @@ export class FakeApiClient implements IApiClient {
   onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
     = () => Promise.resolve(ok({ skills: [] }))
 
+  onSkillInvoke: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>>
+    = () => Promise.resolve(ok({ accepted: true as const }))
+
   readonly commands: IApiClient['commands'] = {
     list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
     execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
@@ -170,6 +173,7 @@ export class FakeApiClient implements IApiClient {
 
   readonly skills: IApiClient['skills'] = {
     list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
+    invoke: (payload: unknown) => this.record('skill.invoke', payload, this.onSkillInvoke(payload)),
   }
 
   readonly goals: IApiClient['goals'] = {

+ 4 - 0
packages/client/runtime/tests/fake-api.ts

@@ -198,6 +198,9 @@ export class FakeApiClient implements IApiClient {
   onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
     = () => Promise.resolve(ok({ skills: [] }))
 
+  onSkillInvoke: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>>
+    = () => Promise.resolve(ok({ accepted: true as const }))
+
   readonly commands: IApiClient['commands'] = {
     list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
     execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
@@ -205,6 +208,7 @@ export class FakeApiClient implements IApiClient {
 
   readonly skills: IApiClient['skills'] = {
     list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
+    invoke: (payload: unknown) => this.record('skill.invoke', payload, this.onSkillInvoke(payload)),
   }
 
   readonly goals: IApiClient['goals'] = {

+ 53 - 18
packages/client/ui-skill/src/client/index.ts

@@ -2,13 +2,15 @@
  * Skill reference plugin, browser half: registers the '/' skill source —
  * candidates from the skill.list RPC addressed by the per-call session
  * projection's sessionId (sessions are always agent-backed; the host
- * resolves cwd from the session header), pick inserts the literal `/name `
- * text (decision 21: the draft carries plain text, chip visuals are derived
- * by scanning against the source lexicon, and the prompt ships the same
- * literal — no `<skill>` tag). The RPC rides the plugin's root-context
- * connection captured at registration — the source never reads services off
- * a per-call argument. No adjudication hooks: skill references ride
- * ordinary prompts and never enter command adjudication.
+ * resolves cwd from the session header). A menu pick or an entered `/name
+ * [args]` line claims into a skill.invoke transaction: the host renders the
+ * skill body and injects it as a user message, so invocation is
+ * deterministic for every user-invocable skill — including
+ * `disable-model-invocation` skills the model-side catalog never lists
+ * (issue #1470). The RPC rides the plugin's root-context connection
+ * captured at registration — the source never reads services off a per-call
+ * argument. Draft chip visuals still derive from the lexicon scan; the
+ * legacy `<skill>` reference codec is gone (decision 21 removal cut).
  *
  * Catalog fetches are cached per session (the small twin of the ui-command
  * directory): the per-keystroke candidates re-poll filters a settled
@@ -25,7 +27,7 @@
  */
 import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-client-connection/client'
 import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
-import type { SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
+import type { PickOutcome, SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
 // Type-only: pulls the locale plugin's Context merge (ctx.locale).
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 import { SkillRow } from './SkillRow.tsx'
@@ -119,6 +121,30 @@ export function apply(ctx: ClientContext): void {
     for (const key of [...fetches.keys()]) invalidate(key)
   }
 
+  /** User-only marker in the active language (the menu hint is plain text, resolved at candidate time). */
+  const userOnlyHint = (): string => ctx.locale.getSnapshot().active === 'zh' ? zh['menu.userOnly'] : en['menu.userOnly']
+
+  /**
+   * Args-tolerant claim for one skill: token `/name ` plus the skill.invoke
+   * transaction. Blank args stay off the wire; an RPC refusal folds into the
+   * composer's error outcome (transport failures throw).
+   */
+  const invokeClaim = (session: { readonly sessionId: SessionId }, name: string): PickOutcome => ({
+    claim: {
+      token: `/${name} `,
+      submit: async (args) => {
+        const trimmed = args.trim()
+        const { result } = await skills.invoke({
+          sessionId: session.sessionId,
+          name,
+          ...trimmed === '' ? {} : { text: trimmed },
+        })
+        if (!result.ok) return { kind: 'error', text: `${result.error.code}: ${result.error.message}` }
+        return { kind: 'success' }
+      },
+    },
+  })
+
   const source: SlashSource = {
     trigger: '/',
     name: 'skill',
@@ -129,7 +155,11 @@ export function apply(ctx: ClientContext): void {
       if (signal.aborted) return []
       return skills
         .filter(skill => skill.name.startsWith(query))
-        .map(skill => ({ name: skill.name, description: skill.description }))
+        .map(skill => ({
+          name: skill.name,
+          description: skill.description,
+          ...skill.modelInvocable ? {} : { hint: userOnlyHint() },
+        }))
     },
     warm(session) {
       // Fire-and-forget scope-birth prewarm; the shared fetch reports
@@ -149,16 +179,21 @@ export function apply(ctx: ClientContext): void {
         if (listeners.size === 0) lexiconListeners.delete(key)
       }
     },
-    onPick({ candidate }) {
-      // Decision 21: plain-text reference — the literal lands in the draft
-      // and ships to the model verbatim (trailing space closes the token).
-      // Legacy path (decision 21), retained for the removal cut, no longer reached:
-      // return { insert: { source: 'skill', ref: candidate.name, label: candidate.name, clipboardText: `/${candidate.name}` } }
-      return { text: `/${candidate.name} ` }
+    onPick({ candidate, session }) {
+      return invokeClaim(session, candidate.name)
     },
-    codec: {
-      clipboardText: ref => `/${ref}`,
-      serialize: ref => Promise.resolve(`<skill>${ref}</skill>`),
+    async matchEnter(session, line, signal) {
+      const trimmed = line.trim()
+      if (!trimmed.startsWith('/')) return undefined
+      const ws = trimmed.search(/\s/)
+      const name = (ws === -1 ? trimmed : trimmed.slice(0, ws)).slice(1)
+      if (name === '') return undefined
+      // Strong-wait the catalog: an unknown name stays a plain prompt (the
+      // default sink), never a swallowed line.
+      const catalog = await fetchCatalog(session.sessionId)
+      if (signal.aborted) return undefined
+      if (!catalog.some(skill => skill.name === name)) return undefined
+      return invokeClaim(session, name)
     },
   }
   const slash = ctx.get('slash') as SlashServiceContract

+ 2 - 0
packages/client/ui-skill/src/client/locales.ts

@@ -9,6 +9,7 @@ export const zh = {
   'row.failed': 'skill 加载失败',
   'row.stopped': 'skill 加载已中止',
   'row.instructions': '说明',
+  'menu.userOnly': '仅用户',
 } satisfies Record<string, string>
 
 /** The skill namespace key union. */
@@ -20,4 +21,5 @@ export const en = {
   'row.failed': 'Skill load failed',
   'row.stopped': 'Skill load stopped',
   'row.instructions': 'Instructions',
+  'menu.userOnly': 'user-only',
 } satisfies Record<SkillKey, string>

+ 76 - 16
packages/client/ui-skill/tests/browser-plugin.spec.ts

@@ -20,11 +20,15 @@ import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-
 import { apply, inject } from '../src/client/index.ts'
 import { SkillRow as SkillToolRow } from '../src/client/SkillRow.tsx'
 
-type SkillRow = { name: string; description: string; whenToUse?: string }
+type SkillRow = { name: string; description: string; whenToUse?: string; modelInvocable?: boolean }
 type ListResult =
   | { ok: true; value: { skills: SkillRow[] } }
   | { ok: false; error: { code: string; message: string; details: object } }
 type ListFn = (payload: object, signal?: AbortSignal) => Promise<{ result: ListResult }>
+type InvokeResult =
+  | { ok: true; value: { accepted: true } }
+  | { ok: false; error: { code: string; message: string; details: object } }
+type InvokeFn = (payload: object) => Promise<{ result: InvokeResult }>
 
 interface PresentationCapture {
   slots: SlotsService
@@ -49,16 +53,18 @@ function providePresentation(ctx: Context): PresentationCapture {
       capture.dictionaries.push({ namespace, dictionaries })
       return () => { capture.localeDisposed = true }
     },
+    getSnapshot: () => ({ active: 'zh', locales: ['zh', 'en'], revision: 0 }),
   })
   return capture
 }
 
 /** Boot the plugin over fake slash/connection faces; returns the captured source and its ctx. */
-async function bench(list: ListFn, addressed?: SessionId) {
+async function bench(list: ListFn, addressed?: SessionId, invoke?: InvokeFn) {
   const ctx = new Context()
   let captured: SlashSource | undefined
   ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
-  ctx.provide('connection', { api: { skills: { list } } })
+  const defaultInvoke: InvokeFn = () => Promise.resolve({ result: { ok: true as const, value: { accepted: true as const } } })
+  ctx.provide('connection', { api: { skills: { list, invoke: invoke ?? defaultInvoke } } })
   ctx.provide('sessions', {
     subagentAddress: (id: SessionId) => id === addressed
       ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
@@ -70,9 +76,9 @@ async function bench(list: ListFn, addressed?: SessionId) {
 }
 
 const CATALOG: SkillRow[] = [
-  { name: 'commit-helper', description: 'commit flow' },
-  { name: 'code-review', description: 'review flow', whenToUse: 'reviews' },
-  { name: 'deploy', description: 'deploy flow' },
+  { name: 'commit-helper', description: 'commit flow', modelInvocable: true },
+  { name: 'code-review', description: 'review flow', whenToUse: 'reviews', modelInvocable: true },
+  { name: 'deploy', description: 'deploy flow', modelInvocable: true },
 ]
 
 const listOk = (skills: SkillRow[]): ListFn => () => Promise.resolve({ result: { ok: true as const, value: { skills } } })
@@ -117,12 +123,14 @@ describe('apply', () => {
           'row.failed': 'skill 加载失败',
           'row.stopped': 'skill 加载已中止',
           'row.instructions': '说明',
+          'menu.userOnly': '仅用户',
         },
         en: {
           'row.running': 'Loading skill',
           'row.failed': 'Skill load failed',
           'row.stopped': 'Skill load stopped',
           'row.instructions': 'Instructions',
+          'menu.userOnly': 'user-only',
         },
       },
     }])
@@ -313,9 +321,10 @@ describe('lexicon', () => {
   })
 })
 
-describe('pick and codec', () => {
-  it('onPick returns the literal /name text with a closing space (decision 21)', async () => {
-    const { source } = await bench(listOk(CATALOG))
+describe('pick claims into skill.invoke', () => {
+  it('onPick returns an args-tolerant claim whose submit invokes the skill', async () => {
+    const invoke = vi.fn(() => Promise.resolve({ result: { ok: true as const, value: { accepted: true as const } } }))
+    const { source } = await bench(listOk(CATALOG), undefined, invoke)
     const outcome = source.onPick({
       candidate: { name: 'commit-helper', description: 'commit flow' },
       session: proj('s1'),
@@ -323,21 +332,72 @@ describe('pick and codec', () => {
       via: 'menu',
       span: { start: 0, end: 4, draftRev: 7 },
     })
-    expect(outcome).toEqual({ text: '/commit-helper ' })
+    if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected a claim outcome')
+    expect(outcome.claim.token).toBe('/commit-helper ')
+    await expect(outcome.claim.submit('check the fixture', {} as never)).resolves.toEqual({ kind: 'success' })
+    expect(invoke).toHaveBeenCalledWith({ sessionId: sid('s1'), name: 'commit-helper', text: 'check the fixture' })
+  })
+
+  it('submit omits blank args and folds an RPC refusal into an error outcome', async () => {
+    const invoke = vi.fn(() => Promise.resolve({
+      result: { ok: false as const, error: { code: 'skill-not-invocable', message: 'nope', details: { name: 'deploy' } } },
+    }))
+    const { source } = await bench(listOk(CATALOG), undefined, invoke)
+    const outcome = source.onPick({
+      candidate: { name: 'deploy', description: 'deploy flow' },
+      session: proj('s1'),
+      position: 'leading',
+      via: 'menu',
+      span: { start: 0, end: 4, draftRev: 7 },
+    })
+    if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected a claim outcome')
+    await expect(outcome.claim.submit('   ', {} as never))
+      .resolves.toEqual({ kind: 'error', text: 'skill-not-invocable: nope' })
+    expect(invoke).toHaveBeenCalledWith({ sessionId: sid('s1'), name: 'deploy' })
   })
 
-  it('codec projects clipboard `/name` and serializes the model form <skill>name</skill>', async () => {
+  it('drops the legacy reference codec (decision 21 removal cut)', async () => {
     const { source } = await bench(listOk(CATALOG))
-    expect(source.codec!.clipboardText('deploy')).toBe('/deploy')
-    await expect(source.codec!.serialize('deploy', new AbortController().signal))
-      .resolves.toBe('<skill>deploy</skill>')
+    expect(source.codec).toBeUndefined()
   })
 })
 
 describe('adjudication', () => {
-  it('never participates: no matchSpace/matchEnter hooks on the skill source', async () => {
+  it('claims an entered /name line, args-tolerant, once the catalog knows the name', async () => {
+    const invoke = vi.fn(() => Promise.resolve({ result: { ok: true as const, value: { accepted: true as const } } }))
+    const { source } = await bench(listOk(CATALOG), undefined, invoke)
+    const outcome = await source.matchEnter!(proj('s1'), '/deploy run the smoke suite', new AbortController().signal)
+    if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected a claim outcome')
+    expect(outcome.claim.token).toBe('/deploy ')
+    await outcome.claim.submit('run the smoke suite', {} as never)
+    expect(invoke).toHaveBeenCalledWith({ sessionId: sid('s1'), name: 'deploy', text: 'run the smoke suite' })
+  })
+
+  it('answers undefined for unknown names, non-slash lines, and bare "/"', async () => {
+    const { source } = await bench(listOk(CATALOG))
+    const signal = new AbortController().signal
+    await expect(source.matchEnter!(proj('s1'), '/unlisted do it', signal)).resolves.toBeUndefined()
+    await expect(source.matchEnter!(proj('s1'), 'plain prose', signal)).resolves.toBeUndefined()
+    await expect(source.matchEnter!(proj('s1'), '/', signal)).resolves.toBeUndefined()
+  })
+
+  it('never claims on space (menu and enter own the skill flows)', async () => {
     const { source } = await bench(listOk(CATALOG))
     expect(typeof source.matchSpace).toBe('undefined')
-    expect(typeof source.matchEnter).toBe('undefined')
+  })
+})
+
+describe('user-only marking', () => {
+  it('carries the user-only hint on candidates the model cannot invoke', async () => {
+    const rows: SkillRow[] = [
+      { name: 'shared-skill', description: 'both surfaces', modelInvocable: true },
+      { name: 'user-only-skill', description: 'user surface only', modelInvocable: false },
+    ]
+    const { source } = await bench(listOk(rows))
+    const candidates = await source.candidates(proj('s1'), req(''))
+    expect(candidates).toEqual([
+      { name: 'shared-skill', description: 'both surfaces' },
+      { name: 'user-only-skill', description: 'user surface only', hint: '仅用户' },
+    ])
   })
 })