feat(client): render user skill invocations as dedicated transcript cards

A user/message carrying the skill-invocation source materializes as its
own conversation node (name/args lifted off the source metadata, never
re-parsed from the body) and renders as a right-aligned bubble: the /name
chip plus the user's trailing text, with the injected <skill_content>
collapsed behind a disclosure. A record with an unreadable name degrades
to the injected-context row.
This commit is contained in:
Yichen Jiang
2026-08-08 01:05:08 +08:00
parent 56e9e61749
commit 011e3e4e63
8 changed files with 163 additions and 4 deletions

View File

@@ -49,7 +49,7 @@ export type {
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase,
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
RunningToolCall,
RunningToolCall, SkillInvocationNode,
SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export type {

View File

@@ -129,6 +129,25 @@ 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'
@@ -245,6 +264,7 @@ export type ConversationNode =
| AssistantMessageNode
| SteeringMessageNode
| ContextMessageNode
| SkillInvocationNode
| ModelRetryNode
| TurnErrorNode
| ToolResultNode

View File

@@ -57,7 +57,20 @@ function materializeNode(
stepTimings: ReadonlyMap<string, AssistantStepMetadata>,
): ConversationNode {
switch (event.type) {
case 'user/message':
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).
@@ -80,6 +93,7 @@ function materializeNode(
kind: 'user', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
}
case 'assistant/message':
return {
kind: 'assistant', seq: event.seq, time: event.time,

View File

@@ -164,6 +164,31 @@ describe('TranscriptAdapter', () => {
expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context'])
})
it('materializes a skill-invocation source as its dedicated node', () => {
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,
}) }),
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,
}) }),
])
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')
})
it('skips events core does not call surface-eligible, marker or not', () => {
// The transcript is the append-origin surface, so log-only events (a chunk,
// a turn boundary, a compact/* provenance record) and a future type core