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:
@@ -45,12 +45,11 @@ export { createSnapshotStore, defineStore, shallowEqual } from './contract/store
|
||||
export type {
|
||||
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
|
||||
} from './contract/store.ts'
|
||||
export { opensUserTurn } from './sessions/conversation.ts'
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
|
||||
AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase,
|
||||
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
|
||||
RunningToolCall, SkillInvocationNode,
|
||||
RunningToolCall,
|
||||
SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
export type {
|
||||
|
||||
@@ -83,6 +83,9 @@ export function contextProvenance(source: unknown): ContextProvenanceView {
|
||||
return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind }
|
||||
case 'plugin':
|
||||
return { role: 'inject', label: readString(record, 'plugin') ?? kind }
|
||||
// A user-explicit skill invocation names the skill it injected.
|
||||
case 'skill-invocation':
|
||||
return { role: 'inject', label: readString(record, 'name') ?? kind }
|
||||
// Documented default arm of the merge-extensible source map: an unknown
|
||||
// producer still identifies itself by its own durable kind.
|
||||
default:
|
||||
|
||||
@@ -129,25 +129,6 @@ export interface ContextMessageNode {
|
||||
form: KnownContextForm | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A user-explicit skill invocation: the host injected the rendered skill as a
|
||||
* user message carrying the `skill-invocation` source, so the card presents
|
||||
* `/name args` from source metadata and collapses the injected body.
|
||||
*/
|
||||
export interface SkillInvocationNode {
|
||||
kind: 'skill-invocation'
|
||||
seq: number
|
||||
/** Unix epoch ms from the source session event. */
|
||||
time: number
|
||||
/** Invoked skill name read off the message source. */
|
||||
name: string
|
||||
/** Trailing user text read off the message source, when recorded. */
|
||||
args?: string
|
||||
/** Full injected model-facing content (collapsed by default in the UI). */
|
||||
content: readonly ContentBlock[]
|
||||
source: unknown
|
||||
}
|
||||
|
||||
/** Durable notice that a closed failed step is waiting for a model-request retry. */
|
||||
export type ModelRetryNode = LlmRetryEventData & {
|
||||
kind: 'model-retry'
|
||||
@@ -258,27 +239,12 @@ export interface CommandNode {
|
||||
outcome: { kind: 'success' | 'error'; text?: string } | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a node opens a user turn on the transcript surface. A direct user
|
||||
* message and a user-explicit skill invocation both start the turn the next
|
||||
* assistant answer closes; parallel consumers (turn boundaries, retry
|
||||
* liveness, own-words scrolling) share this one predicate instead of each
|
||||
* re-encoding the kind list. Steering stays out: an interjection lands
|
||||
* mid-turn and closes nothing.
|
||||
* @param node - any conversation node.
|
||||
* @returns true for the user-turn-opening kinds.
|
||||
*/
|
||||
export function opensUserTurn(node: Pick<ConversationNode, 'kind'>): boolean {
|
||||
return node.kind === 'user' || node.kind === 'skill-invocation'
|
||||
}
|
||||
|
||||
/** Finalized conversation node union (kind discriminates; seq is the React key). */
|
||||
export type ConversationNode =
|
||||
| UserMessageNode
|
||||
| AssistantMessageNode
|
||||
| SteeringMessageNode
|
||||
| ContextMessageNode
|
||||
| SkillInvocationNode
|
||||
| ModelRetryNode
|
||||
| TurnErrorNode
|
||||
| ToolResultNode
|
||||
|
||||
@@ -58,22 +58,10 @@ function materializeNode(
|
||||
): ConversationNode {
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
// A user-explicit skill invocation carries its name (and optional args)
|
||||
// on the source; the dedicated node lets the card render `/name args`
|
||||
// from metadata instead of re-parsing the injected body. A record whose
|
||||
// name is unreadable degrades to injected context below.
|
||||
const source = event.data.source as { kind?: unknown; name?: unknown; args?: unknown }
|
||||
if (source.kind === 'skill-invocation' && typeof source.name === 'string') {
|
||||
return {
|
||||
kind: 'skill-invocation', seq: event.seq, time: event.time,
|
||||
name: source.name,
|
||||
...typeof source.args === 'string' ? { args: source.args } : {},
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
}
|
||||
// Injected context (plugin/goal source) folds to a context node, not a
|
||||
// user message; only a direct human prompt is a user node. A compaction
|
||||
// checkpoint never reaches here (isCompactCheckpoint routes it away).
|
||||
// Injected context (plugin/goal/skill-invocation source) folds to a
|
||||
// context node, not a user message; only a direct human prompt is a
|
||||
// user node. A compaction checkpoint never reaches here
|
||||
// (isCompactCheckpoint routes it away).
|
||||
if (event.data.source.kind !== 'user') {
|
||||
return {
|
||||
kind: 'context', seq: event.seq, time: event.time,
|
||||
|
||||
@@ -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'] = {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user