Bläddra i källkod

feat(host): user-invocable skill listing and skill.invoke injection RPC

skill.list now serves every user-invocable skill and carries modelInvocable
so menus can mark user-only entries; the old model-and-user intersection
hid disable-model-invocation skills from their only legitimate entry point
(issue #1470). skill.invoke enforces user-invocation policy at the host
boundary, renders the canonical <skill_content> body, and injects it as a
user-role message carrying the skill-invocation source before starting a
turn. The connection fixture mirrors both faces for client tests.
Yichen Jiang 2 månader sedan
förälder
incheckning
85422f44dc

+ 20 - 1
packages/client/connection/src/client/fixture.ts

@@ -2449,10 +2449,28 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
         if (missing !== undefined) return missing
         return ok(request, {
           skills: [
-            { name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收' },
+            { name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收', modelInvocable: true },
+            { name: 'fixture-user-only', description: 'fixture 仅用户技能样本', modelInvocable: false },
           ],
         })
       },
+      invoke: (request) => {
+        const missing = requireSession(request)
+        if (missing !== undefined) return missing
+        const { sessionId, name, text: args } = request.payload
+        const body = `<skill_content name="${name}">\n<skill_resources>\nBase directory for this skill: /fixture/skills/${name}\n</skill_resources>\n\n<skill_instructions>\nFixture ${name} instructions.\n</skill_instructions>\n</skill_content>`
+        // Mirror the host: injection is a user-role message carrying the
+        // skill-invocation source, immediately visible in the transcript.
+        // The client program cannot see the host-side MessageSourceMap merge
+        // (sources are opaque wire JSON to the UI), so the fixture stamps the
+        // durable shape through the same assertion the projections read back.
+        const source = { kind: 'skill-invocation', name, ...args === undefined ? {} : { args } } as unknown as MessageSource
+        append(sessionId, {
+          type: 'user/message', surfaceOp: 'append',
+          data: userMessage(text(args === undefined ? body : `${body}\n\n${args}`), source),
+        })
+        return ok(request, { accepted: true as const })
+      },
     },
     goals: {
       // Compatibility face only: old API Proxy payloads and acknowledgements
@@ -2761,6 +2779,7 @@ export class FixtureApiClient extends AbstractApiClient {
       case 'command.list': return this.api.commands.list(request)
       case 'command.execute': return this.api.commands.execute(request, signal)
       case 'skill.list': return this.api.skills.list(request)
+      case 'skill.invoke': return this.api.skills.invoke(request)
       case 'goal.create': return this.api.goals.create(request)
       case 'goal.edit': return this.api.goals.edit(request)
       case 'goal.pause': return this.api.goals.pause(request)

+ 56 - 2
packages/host/apiproxy/src/api-proxy.ts

@@ -18,6 +18,8 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
 import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
 import { SubagentError } from '@deepseek-ai/dsh-subagent'
 import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent'
+import { isSkillName, isUserInvocable, renderSkillContent } from '@deepseek-ai/dsh-skill'
+import type { SkillInvocationSource } from '@deepseek-ai/dsh-skill'
 import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
 import {
   workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
@@ -2359,19 +2361,71 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
           return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} })
         }
         try {
-          const skills = (await skillRegistry.list({ cwd }))
-            .filter(skill => skill.invocation.modelInvocable && skill.invocation.userInvocable)
+          const skills = (await skillRegistry.list({ cwd })).filter(isUserInvocable)
           return ok(request, {
             skills: skills.map(skill => ({
               name: skill.name,
               description: skill.description,
               ...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse },
+              modelInvocable: skill.invocation.modelInvocable,
             })),
           })
         } catch (error: unknown) {
           return err(request, { code: 'internal', message: `skill listing failed: ${String(error)}`, details: {} })
         }
       },
+
+      async invoke(request) {
+        const { sessionId, name, text } = request.payload
+        const found = await agentFor(sessionId)
+        if ('error' in found) return err(request, found.error)
+        const agent = found.agent
+        // Same turn-start refusal boundary as sessions.prompt: injection
+        // starts a turn, so a route no adapter serves is refused while the
+        // composer still shows the draft.
+        const target = targetFor(agent).current
+        if (!routeServed(target.provider)) {
+          return err(request, {
+            code: 'model-unavailable',
+            message: `no adapter serves provider "${target.provider}"; select a model for this session`,
+            details: { provider: target.provider, model: target.model },
+          })
+        }
+        const skillRegistry = ctx.get('skills')
+        if (skillRegistry === undefined) {
+          return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} })
+        }
+        const lookup = { cwd: agent.session.header.cwd }
+        // isSkillName guards the registry contract; an ill-formed name is
+        // indistinguishable from an absent one for the caller.
+        const summary = isSkillName(name)
+          ? (await skillRegistry.list(lookup)).find(skill => skill.name === name)
+          : undefined
+        if (summary === undefined) {
+          return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } })
+        }
+        // The operation boundary owns user-invocation policy: client menus
+        // filtering their candidates is an affordance, not enforcement.
+        if (!isUserInvocable(summary)) {
+          return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } })
+        }
+        const skill = await skillRegistry.get(name, lookup)
+        if (skill === undefined) {
+          return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } })
+        }
+        const body = renderSkillContent(skill)
+        const source: SkillInvocationSource = { kind: 'skill-invocation', name, ...text === undefined ? {} : { args: text } }
+        try {
+          const message: UserMessage = createUserMessage({
+            content: [{ type: 'text', text: text === undefined ? body : `${body}\n\n${text}` }],
+            source,
+          })
+          agent.followup(message)
+        } catch (error: unknown) {
+          return err(request, { code: 'agent-busy', message: 'skill invocation rejected', details: { reason: String(error) } })
+        }
+        return ok(request, { accepted: true as const })
+      },
     },
 
     settings: {

+ 1 - 0
packages/host/apiproxy/src/api/rpc-map.ts

@@ -50,6 +50,7 @@ export interface RpcMethodMap {
   'command.list': CommandsApi['list']
   'command.execute': CommandsApi['execute']
   'skill.list': SkillsApi['list']
+  'skill.invoke': SkillsApi['invoke']
   'goal.create': GoalsApi['create']
   'goal.edit': GoalsApi['edit']
   'goal.pause': GoalsApi['pause']

+ 2 - 0
packages/host/apiproxy/src/api/rpc.schema.ts

@@ -51,6 +51,8 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
   z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }),
   z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
   z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
+  z.object({ code: z.literal('skill-not-found'), message: z.string(), details: z.object({ name: z.string() }) }),
+  z.object({ code: z.literal('skill-not-invocable'), message: z.string(), details: z.object({ name: z.string() }) }),
   z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }),
   z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }),
   z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }),

+ 4 - 0
packages/host/apiproxy/src/api/rpc.ts

@@ -51,6 +51,10 @@ export interface RpcErrorDetailsMap {
   'command-error': {}
   /** A leading-/ prompt named no registered command; the message names the token. */
   'unknown-command': {}
+  /** A skill invocation named no skill in the session's workspace (unknown or ill-formed name). */
+  'skill-not-found': { name: string }
+  /** A skill invocation named a skill whose policy forbids user invocation. */
+  'skill-not-invocable': { name: string }
   /**
    * A settings write was refused (schema validation, unknown namespace,
    * read-only provider, or storage failure); the message is the seam's text.

+ 13 - 0
packages/host/apiproxy/src/api/skills.schema.ts

@@ -14,6 +14,7 @@ export const skillEntrySchema = z.object({
   name: z.string().min(1),
   description: z.string(),
   whenToUse: z.string().optional(),
+  modelInvocable: z.boolean(),
 }) satisfies z.ZodType<Wire<SkillEntry>>
 
 /** skill.list request payload. */
@@ -25,3 +26,15 @@ export const skillListRequestSchema = z.object({
 export const skillListValueSchema = z.object({
   skills: z.array(skillEntrySchema),
 }) satisfies z.ZodType<Wire<ResponseValue<'skill.list'>>>
+
+/** skill.invoke request payload. */
+export const skillInvokeRequestSchema = z.object({
+  sessionId: sessionIdSchema,
+  name: z.string().min(1),
+  text: z.string().optional(),
+}) satisfies z.ZodType<Wire<RequestPayload<'skill.invoke'>>>
+
+/** skill.invoke response value. */
+export const skillInvokeValueSchema = z.object({
+  accepted: z.literal(true),
+}) satisfies z.ZodType<Wire<ResponseValue<'skill.invoke'>>>

+ 15 - 3
packages/host/apiproxy/src/api/skills.ts

@@ -10,16 +10,28 @@ import type { RpcRequest, RpcResponse } from './rpc.ts'
 
 /** Skill catalog row (wire projection of the host SkillSummary; provider/source vocabulary stays host-side). */
 export interface SkillEntry {
-  /** Kebab-case identifier referenced as `<skill>name</skill>` in prompts. */
+  /** Kebab-case identifier the user references as `/name` in the composer. */
   readonly name: string
   /** Short routing description. */
   readonly description: string
   /** Optional extra routing guidance. */
   readonly whenToUse?: string
+  /** False marks a user-only skill (`disable-model-invocation`): invocable here, absent from the model catalog. */
+  readonly modelInvocable: boolean
 }
 
-/** Skill-domain unary methods (the map key skill.* of RpcMethodMap). */
+/** Skill-domain unary methods (the map keys skill.* of RpcMethodMap). */
 export interface SkillsApi {
-  /** Lists skills usable by the browser's user-selected model-reference path. */
+  /** Lists the user-invocable skill catalog for the session's project. */
   list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ skills: readonly SkillEntry[] }>>
+
+  /**
+   * Injects one user-invocable skill into the addressed agent as a user-role
+   * message (the canonical `<skill_content>` rendering, with `text` appended
+   * when present) and starts a turn. The host enforces user-invocation policy
+   * here: a model-only or unknown name is refused regardless of what a client
+   * menu offered. Session-backed subagents reject with `agent-busy`.
+   */
+  invoke(request: RpcRequest<{ sessionId: SessionId; name: string; text?: string }>):
+  Promise<RpcResponse<{ accepted: true }>>
 }

+ 4 - 1
packages/host/apiproxy/src/fetch/client.ts

@@ -39,7 +39,7 @@ import {
   workspaceRenameValueSchema,
 } from '../api/workspace.schema.ts'
 import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts'
-import { skillListValueSchema } from '../api/skills.schema.ts'
+import { skillInvokeValueSchema, skillListValueSchema } from '../api/skills.schema.ts'
 import {
   goalCreateValueSchema,
   goalEditValueSchema,
@@ -118,6 +118,7 @@ export interface IApiClient {
   }
   skills: {
     list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.list'>>>
+    invoke(payload: RequestPayload<'skill.invoke'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.invoke'>>>
   }
   events: {
     mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
@@ -185,6 +186,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
   'command.list': commandListValueSchema,
   'command.execute': commandExecuteValueSchema,
   'skill.list': skillListValueSchema,
+  'skill.invoke': skillInvokeValueSchema,
   'goal.create': goalCreateValueSchema,
   'goal.edit': goalEditValueSchema,
   'goal.pause': goalPauseValueSchema,
@@ -441,6 +443,7 @@ export abstract class AbstractApiClient implements IApiClient {
 
   readonly skills: IApiClient['skills'] = {
     list: (payload, signal) => this.callUnary('skill.list', payload, signal),
+    invoke: (payload, signal) => this.callUnary('skill.invoke', payload, signal),
   }
 
   readonly goals: IApiClient['goals'] = {

+ 2 - 1
packages/host/apiproxy/src/fetch/handler.ts

@@ -41,7 +41,7 @@ import {
   workspaceRenameRequestSchema,
 } from '../api/workspace.schema.ts'
 import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts'
-import { skillListRequestSchema } from '../api/skills.schema.ts'
+import { skillInvokeRequestSchema, skillListRequestSchema } from '../api/skills.schema.ts'
 import {
   goalCreateRequestSchema,
   goalEditRequestSchema,
@@ -109,6 +109,7 @@ const UNARY_ROUTES: UnaryRoutes = {
   'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) },
   'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) },
   'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
+  'skill.invoke': { schema: skillInvokeRequestSchema, invoke: (api, r) => api.skills.invoke(r) },
   'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) },
   'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) },
   'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) },

+ 115 - 1
packages/host/apiproxy/tests/api-proxy-commands.spec.ts

@@ -228,7 +228,10 @@ describe('skill.list', () => {
     // touch (or resume through) the Agent registry.
     const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
     const value = expectOk(await api.skills.list(request({ sessionId: session.id })))
-    expect(value.skills).toEqual([{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' }])
+    expect(value.skills).toEqual([
+      { name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', modelInvocable: true },
+      { name: 'user-only', description: 'User-only', modelInvocable: false },
+    ])
     expect(seenCwds).toEqual(['/proj'])
     expect(ctx.agents.get(session.id)).toBeUndefined()
   })
@@ -266,6 +269,117 @@ describe('skill.list', () => {
   })
 })
 
+describe('skill.invoke', () => {
+  /** Provider with one user-only and one model-only skill, both loadable. */
+  function registerInvokeSkills(ctx: Context): void {
+    const summaries = [
+      {
+        name: 'user-only', description: 'User-only',
+        invocation: { modelInvocable: false, userInvocable: true },
+        source: 'custom', provider: 'probe', rank: 0, locator: null,
+        resourceBase: { kind: 'directory', path: '/proj/.agents/skills/user-only' },
+      },
+      {
+        name: 'model-only', description: 'Model-only',
+        invocation: { modelInvocable: true, userInvocable: false },
+        source: 'custom', provider: 'probe', rank: 0, locator: null,
+      },
+    ] as const
+    ctx.skills.registerProvider(() => ({
+      name: 'probe',
+      list: () => Promise.resolve(summaries.map(summary => ({ ...summary }))),
+      get: candidate => Promise.resolve({
+        ...summaries.find(summary => summary.name === candidate.name)!,
+        content: 'Follow the probe instructions.',
+      }),
+    }))
+  }
+
+  /** Agent stub whose session carries a project cwd and whose followup records the injected message. */
+  function invokableAgent(ctx: Context): { agent: Agent; followup: ReturnType<typeof vi.fn> } {
+    const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
+    const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
+    const followup = vi.fn()
+    const agent = { id: session.id, session, inbox, status: 'idle', ctx, followup } as unknown as Agent
+    ctx.agents.register(agent)
+    return { agent, followup }
+  }
+
+  it('injects a user-invocable skill as a user message with the invocation source', async () => {
+    const ctx = await harness()
+    registerInvokeSkills(ctx)
+    const api = createApiProxy(ctx, DEFAULTS)
+    const { agent, followup } = invokableAgent(ctx)
+    const value = expectOk(await api.skills.invoke(request({
+      sessionId: agent.id, name: 'user-only', text: 'and check the fixture',
+    })))
+    expect(value).toEqual({ accepted: true })
+    expect(followup).toHaveBeenCalledTimes(1)
+    const message = followup.mock.calls[0]?.[0] as UserMessage
+    expect(message.source).toEqual({ kind: 'skill-invocation', name: 'user-only', args: 'and check the fixture' })
+    expect(message.content).toHaveLength(1)
+    const text = (message.content[0] as { text: string }).text
+    expect(text).toContain('<skill_content name="user-only">')
+    expect(text).toContain('Base directory for this skill: /proj/.agents/skills/user-only')
+    expect(text).toContain('Follow the probe instructions.')
+    expect(text.endsWith('\n\nand check the fixture')).toBe(true)
+  })
+
+  it('omits args from the source and content when no text rides the invocation', async () => {
+    const ctx = await harness()
+    registerInvokeSkills(ctx)
+    const api = createApiProxy(ctx, DEFAULTS)
+    const { agent, followup } = invokableAgent(ctx)
+    expectOk(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' })))
+    const message = followup.mock.calls[0]?.[0] as UserMessage
+    expect(message.source).toEqual({ kind: 'skill-invocation', name: 'user-only' })
+    const text = (message.content[0] as { text: string }).text
+    expect(text.endsWith('</skill_content>')).toBe(true)
+  })
+
+  it('rejects a skill the user may not invoke', async () => {
+    const ctx = await harness()
+    registerInvokeSkills(ctx)
+    const api = createApiProxy(ctx, DEFAULTS)
+    const { agent, followup } = invokableAgent(ctx)
+    const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'model-only' })))
+    expect(error.code).toBe('skill-not-invocable')
+    expect(followup).not.toHaveBeenCalled()
+  })
+
+  it('rejects an unknown or invalid skill name', async () => {
+    const ctx = await harness()
+    registerInvokeSkills(ctx)
+    const api = createApiProxy(ctx, DEFAULTS)
+    const { agent } = invokableAgent(ctx)
+    const missing = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'absent-skill' })))
+    expect(missing.code).toBe('skill-not-found')
+    const invalid = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'Not A Name' })))
+    expect(invalid.code).toBe('skill-not-found')
+  })
+
+  it('surfaces a followup refusal as agent-busy', async () => {
+    const ctx = await harness()
+    registerInvokeSkills(ctx)
+    const api = createApiProxy(ctx, DEFAULTS)
+    const { agent, followup } = invokableAgent(ctx)
+    followup.mockImplementation(() => { throw new Error('inbox closed') })
+    const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' })))
+    expect(error.code).toBe('agent-busy')
+  })
+
+  it('fails loud with internal when the skill registry is not mounted', async () => {
+    const ctx = await harness({ skills: false })
+    const api = createApiProxy(ctx, DEFAULTS)
+    const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
+    const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
+    ctx.agents.register({ id: session.id, session, inbox, status: 'idle', ctx, followup: vi.fn() } as unknown as Agent)
+    const error = expectErr(await api.skills.invoke(request({ sessionId: session.id, name: 'user-only' })))
+    expect(error.code).toBe('internal')
+    expect(error.message).toContain('skill registry is absent')
+  })
+})
+
 describe('host/commands-changed frame', () => {
   it('broadcasts on registry change', async () => {
     const ctx = await harness()

+ 1 - 1
packages/host/apiproxy/tests/client-handler.spec.ts

@@ -86,7 +86,7 @@ function scriptedApi(overrides: {
       execute: r => ok(r, { matched: false }),
       ...overrides.commands,
     },
-    skills: { list: r => ok(r, { skills: [] }), ...overrides.skills },
+    skills: { list: r => ok(r, { skills: [] }), invoke: r => ok(r, { accepted: true as const }), ...overrides.skills },
     goals: {
       create: err,
       edit: err,

+ 7 - 2
packages/host/apiproxy/tests/fetch-carrier.spec.ts

@@ -196,7 +196,10 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
     },
     skills: {
       async list(request) {
-        return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } } }
+        return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } } }
+      },
+      async invoke(request) {
+        return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
       },
     },
     goals: {
@@ -381,7 +384,9 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
     const miss = await c.commands.execute({ sessionId: 's' as never, line: '/nope' })
     expect(miss.result).toEqual({ ok: true, value: { matched: false } })
     const skills = await c.skills.list({ sessionId: 's' as never })
-    expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } })
+    expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } })
+    const invoked = await c.skills.invoke({ sessionId: 's' as never, name: 'commit-helper', text: 'go' })
+    expect(invoked.result).toEqual({ ok: true, value: { accepted: true } })
   })
 
   it('lets command.execute finish after the 30-second default unary deadline', async () => {

+ 21 - 4
packages/host/apiproxy/tests/rpc-schemas.spec.ts

@@ -31,7 +31,7 @@ import {
   commandDescriptorSchema, commandExecuteRequestSchema, commandExecuteValueSchema,
   commandListRequestSchema, commandListValueSchema,
 } from '../src/api/commands.schema.ts'
-import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts'
+import { skillEntrySchema, skillInvokeRequestSchema, skillInvokeValueSchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts'
 import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts'
 import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts'
 import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts'
@@ -74,6 +74,8 @@ describe('rpcErrorSchema', () => {
     expect(rpcErrorSchema.parse({ code: 'queue-item-not-found', message: 'm', details: { itemId: 'i' } }).code).toBe('queue-item-not-found')
     expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error')
     expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command')
+    expect(rpcErrorSchema.parse({ code: 'skill-not-found', message: 'm', details: { name: 'n' } }).code).toBe('skill-not-found')
+    expect(rpcErrorSchema.parse({ code: 'skill-not-invocable', message: 'm', details: { name: 'n' } }).code).toBe('skill-not-invocable')
     expect(rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: { sessionId: 's' } }).code).toBe('title-invalid')
     expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
   })
@@ -81,6 +83,7 @@ describe('rpcErrorSchema', () => {
   it('rejects a known code with missing details', () => {
     expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow()
     expect(() => rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: {} })).toThrow()
+    expect(() => rpcErrorSchema.parse({ code: 'skill-not-invocable', message: 'm', details: {} })).toThrow()
     expect(() => rpcErrorSchema.parse({ code: 'command-error', message: 'm' })).toThrow()
     expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow()
   })
@@ -395,12 +398,26 @@ describe('skills domain schemas', () => {
     expect(() => skillListRequestSchema.parse({})).toThrow()
     expect(skillListValueSchema.parse({ skills: [] }).skills).toEqual([])
     const value = skillListValueSchema.parse({ skills: [
-      { name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' },
-      { name: 'bare', description: 'No guidance' },
+      { name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', modelInvocable: true },
+      { name: 'bare', description: 'No guidance', modelInvocable: false },
     ] })
     expect(value.skills[0]?.whenToUse).toBe('when committing')
     expect(value.skills[1]?.whenToUse).toBeUndefined()
-    expect(() => skillEntrySchema.parse({ name: '', description: 'd' })).toThrow()
+    expect(value.skills[1]?.modelInvocable).toBe(false)
+    expect(() => skillEntrySchema.parse({ name: '', description: 'd', modelInvocable: true })).toThrow()
+    // modelInvocable is required wire data: an entry without it fails.
+    expect(() => skillEntrySchema.parse({ name: 'n', description: 'd' })).toThrow()
+  })
+
+  it('validates the invoke request/value pair', () => {
+    expect(skillInvokeRequestSchema.parse({ sessionId: 's1', name: 'user-only' }))
+      .toEqual({ sessionId: 's1', name: 'user-only' })
+    expect(skillInvokeRequestSchema.parse({ sessionId: 's1', name: 'user-only', text: 'check it' }).text)
+      .toBe('check it')
+    expect(() => skillInvokeRequestSchema.parse({ sessionId: 's1', name: '' })).toThrow()
+    expect(() => skillInvokeRequestSchema.parse({ name: 'user-only' })).toThrow()
+    expect(skillInvokeValueSchema.parse({ accepted: true })).toEqual({ accepted: true })
+    expect(() => skillInvokeValueSchema.parse({ accepted: false })).toThrow()
   })
 })