diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 776d21fd46..75653d43e3 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/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 = `\n\nBase directory for this skill: /fixture/skills/${name}\n\n\n\nFixture ${name} instructions.\n\n` + // 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) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index e4b715a0c4..6384a4d408 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/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: { diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 9a8750c722..b001d54625 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/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'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 2733c6e940..dd3fe7cf57 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -51,6 +51,8 @@ export const rpcErrorSchema: z.ZodType = 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() }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 54bbb5a8cc..7bf41a32e1 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/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. diff --git a/packages/host/apiproxy/src/api/skills.schema.ts b/packages/host/apiproxy/src/api/skills.schema.ts index 3bf7ad429a..c1ee1024a3 100644 --- a/packages/host/apiproxy/src/api/skills.schema.ts +++ b/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> /** 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>> + +/** skill.invoke request payload. */ +export const skillInvokeRequestSchema = z.object({ + sessionId: sessionIdSchema, + name: z.string().min(1), + text: z.string().optional(), +}) satisfies z.ZodType>> + +/** skill.invoke response value. */ +export const skillInvokeValueSchema = z.object({ + accepted: z.literal(true), +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/skills.ts b/packages/host/apiproxy/src/api/skills.ts index 33802dd4c0..2ade72efb9 100644 --- a/packages/host/apiproxy/src/api/skills.ts +++ b/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 `name` 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> + + /** + * Injects one user-invocable skill into the addressed agent as a user-role + * message (the canonical `` 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> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 0f54d76dbc..574206458b 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/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>> + invoke(payload: RequestPayload<'skill.invoke'>, signal?: AbortSignal): Promise>> } events: { mux(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> @@ -185,6 +186,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('skill.list', payload, signal), + invoke: (payload, signal) => this.callUnary('skill.invoke', payload, signal), } readonly goals: IApiClient['goals'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index d41b51ad6d..914c425e91 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/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) }, diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 55781a3e77..7d7062023e 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/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 } { + 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('') + 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('')).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() diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index ebd56ee551..0a65c817c6 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/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, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 22e1650f5b..09cabdcc7f 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/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 () => { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 28f9138502..253ac92fdf 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/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() }) })