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.
This commit is contained in:
Yichen Jiang
2026-08-08 00:51:24 +08:00
parent 62c308f415
commit 85422f44dc
13 changed files with 261 additions and 16 deletions

View File

@@ -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)