refactor(host)!: retire the skill.invoke RPC for the gesture boundary

Invocation is an ordinary session.prompt again: the pre-step gesture
boundary makes it deterministic host-side for every front end, so the
dedicated RPC (handler, wire schema, error codes, client face, fixtures)
and ui-skill's claim machinery are net deletions. The menu keeps decision
21 exactly — a pick lands literal /name text — plus the user-only marker
from skill.list's modelInvocable flag.
This commit is contained in:
Yichen Jiang
2026-08-08 13:15:52 +08:00
parent c08fa27e5c
commit 0d53752c49
43 changed files with 143 additions and 663 deletions

View File

@@ -198,8 +198,6 @@ 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)),
@@ -208,7 +206,6 @@ 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'] = {

View File

@@ -164,29 +164,26 @@ describe('TranscriptAdapter', () => {
expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context'])
})
it('materializes a skill-invocation source as its dedicated node', () => {
it('materializes a skill-invocation injection as a named instructions context', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '<skill_content name="hidden-demo">body</skill_content>\n\ncheck the fixture' }],
source: { kind: 'skill-invocation', name: 'hidden-demo', args: 'check the fixture' } as never,
content: [{ type: 'text', text: '/hidden-demo check the fixture' }],
source: { kind: 'user' },
}) }),
at(1, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '<skill_content name="bare-skill">body</skill_content>' }],
source: { kind: 'skill-invocation', name: 'bare-skill' } as never,
content: [{ type: 'text', text: '<skill_content name="hidden-demo">body</skill_content>' }],
source: { kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' } as never,
}) }),
])
const nodes = adapter.nodes()
expect(nodes.map(node => node.kind)).toEqual(['skill-invocation', 'skill-invocation'])
expect(nodes[0]).toMatchObject({ name: 'hidden-demo', args: 'check the fixture' })
expect(nodes[1]).toMatchObject({ name: 'bare-skill' })
expect((nodes[1] as { args?: string }).args).toBeUndefined()
// A malformed record (no readable name) degrades to injected context, not a crash.
adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: 'odd' }],
source: { kind: 'skill-invocation' } as never,
}) }))
expect(adapter.nodes().at(-1)?.kind).toBe('context')
// The gesture stays a user bubble; the injected body folds to a context
// row named after the skill, presented as instructions.
expect(nodes.map(node => node.kind)).toEqual(['user', 'context'])
expect(nodes[1]).toMatchObject({
provenance: { role: 'inject', label: 'hidden-demo' },
form: 'instructions',
})
})
it('skips events core does not call surface-eligible, marker or not', () => {