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

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

View File

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

View File

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

View File

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