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

@@ -2454,23 +2454,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
],
})
},
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
@@ -2779,7 +2762,6 @@ 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, signal)
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)

View File

@@ -163,8 +163,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)),
@@ -173,7 +171,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

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

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', () => {

View File

@@ -24,7 +24,6 @@
import {
memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
} from 'react'
import { opensUserTurn } from '@deepseek-ai/dsh-client-runtime/client'
import type {
CodeSubCall, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -119,7 +118,7 @@ function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): n
const node = nodes[index]
if (node === undefined) continue
if (node.kind === 'model-retry') return node.retryState === 'cancelled' ? null : node.seq
if (node.kind === 'assistant' || opensUserTurn(node)) return null
if (node.kind === 'assistant' || node.kind === 'user') return null
}
return null
}
@@ -448,11 +447,10 @@ export function ChatView({
return
}
firstSeqRef.current = firstSeq
// Own words must be visible: a new trailing user-turn node (a prompt or an
// explicit skill invocation) force-scrolls (send lives in the composer, so
// arrival is detected here, not armed there).
// Own words must be visible: a new trailing user node force-scrolls
// (send lives in the composer, so arrival is detected here, not armed there).
const appendedUser = lastKey !== lastKeyRef.current
&& lastItem !== undefined && lastItem.kind === 'node' && opensUserTurn(lastItem.node)
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current
const tipMoved = followSigRef.current !== followSig
lastKeyRef.current = lastKey

View File

@@ -256,30 +256,3 @@
white-space: nowrap;
vertical-align: baseline;
}
/* User-explicit skill invocation: the injected body collapses behind a
disclosure inside the user bubble. */
.skillInvocationDetails {
margin-top: 6px;
}
.skillInvocationSummary {
cursor: pointer;
font-size: 0.8em;
color: var(--dsw-alias-label-secondary);
user-select: none;
}
.skillInvocationBody {
margin: 6px 0 0;
padding: 8px;
max-height: 320px;
overflow: auto;
border-radius: 6px;
background: var(--dsw-alias-bg-secondary, rgba(0, 0, 0, 0.06));
font-family: var(--dsw-font-mono, monospace);
font-size: 0.78em;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}

View File

@@ -7,8 +7,8 @@
import { memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type {
CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SkillInvocationNode,
SteeringMessageNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode,
TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
@@ -22,7 +22,6 @@ export interface MessageItemProps {
| UserMessageNode
| SteeringMessageNode
| ContextMessageNode
| SkillInvocationNode
| CompactionSummaryNode
| ModelRetryNode
| TurnErrorNode
@@ -192,38 +191,6 @@ function UserStyleBubble({
)
}
/**
* A user-explicit skill invocation: the right-aligned bubble presents the
* `/name args` gesture from source metadata (never re-parsed from the body),
* and the injected `<skill_content>` collapses behind a disclosure — the
* durable content is model-facing bulk, not conversation prose.
*/
function SkillInvocationRow({ node, t }: {
node: SkillInvocationNode
t: ChatViewSlotProps['t']
}): ReactNode {
const { text } = contentText(node.content)
return (
<div className={css.userRow} data-skill-invocation data-time-hover-root>
<div className={css.bubble}>
<span className={css.refChip} data-ref-chip="skill">{`/${node.name}`}</span>
{node.args !== undefined && <MessageText text={` ${node.args}`} />}
<details className={css.skillInvocationDetails}>
<summary className={css.skillInvocationSummary}>{t('message.skillInvocation.expand')}</summary>
<pre className={css.skillInvocationBody}>{text}</pre>
</details>
</div>
<MessageIconActions
text={text}
time={node.time}
clock="start"
className={css.actions}
t={t}
/>
</div>
)
}
/**
* Render one Host-authoritative pending steering item with the same visual
* language as its eventual durable transcript node.
@@ -285,8 +252,6 @@ export const MessageItem = memo(function MessageItem({
t={t}
/>
)
case 'skill-invocation':
return <SkillInvocationRow node={node} t={t} />
case 'compaction':
return <CompactionItem node={node} t={t} />
case 'model-retry':

View File

@@ -79,7 +79,6 @@ export const zh = {
'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条',
'message.context.recall.truncated': '已截断',
'message.steering': '插话',
'message.skillInvocation.expand': '查看注入的 skill 内容',
'message.compaction': '上下文已压缩',
'message.compaction.expand': '点击查看压缩摘要',
'message.compaction.unavailable': '压缩摘要不可用',
@@ -220,7 +219,6 @@ export const en = {
'message.context.recall.counts': '{retained} kept · {omitted} omitted',
'message.context.recall.truncated': 'truncated',
'message.steering': 'Interjection',
'message.skillInvocation.expand': 'View injected skill content',
'message.compaction': 'Context compacted',
'message.compaction.expand': 'View compaction summary',
'message.compaction.unavailable': 'Compaction summary unavailable',

View File

@@ -865,41 +865,6 @@ describe('MessageItem arms', () => {
expect(view.getByRole('status').textContent).toBe('正在重试模型请求1/2 · 1s')
})
it('skill-invocation renders the /name chip, args, and a collapsed injected body', () => {
const body = '<skill_content name="hidden-demo">instructions</skill_content>\n\ncheck the fixture'
const view = render(
<MessageItem t={t} node={{
kind: 'skill-invocation', seq: 4, time: 1_000,
name: 'hidden-demo', args: 'check the fixture',
content: [{ type: 'text', text: body }] as never,
source: null,
}}
/>,
)
const chip = view.container.querySelector('[data-ref-chip="skill"]')
expect(chip?.textContent).toBe('/hidden-demo')
const details = view.container.querySelector('details')
expect(details).toBeTruthy()
expect(details?.open).toBe(false)
expect(view.getByText('查看注入的 skill 内容')).toBeTruthy()
expect(view.container.querySelector('pre')?.textContent).toBe(body)
expect(view.container.querySelector('[data-skill-invocation]')).toBeTruthy()
})
it('skill-invocation without args renders only the chip line', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'skill-invocation', seq: 5, time: 1_000,
name: 'bare-skill',
content: [{ type: 'text', text: '<skill_content name="bare-skill">x</skill_content>' }] as never,
source: null,
}}
/>,
)
const bubble = view.container.querySelector('[data-skill-invocation]')
expect(bubble?.textContent).toContain('/bare-skill')
expect(bubble?.textContent).not.toContain('undefined')
})
})
describe('formatMessageClock', () => {

View File

@@ -3,7 +3,6 @@
* nodes. Client-only and model-free: the vocabulary is the mutation tools'
* own follow-along `locations`, never the closing prose.
*/
import { opensUserTurn } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -63,7 +62,7 @@ export function producedForClosing(nodes: readonly ConversationNode[], seq: numb
}
continue
}
if (opensUserTurn(node)) {
if (node.kind === 'user') {
turn = undefined
pending = []
seen = new Set()

View File

@@ -73,26 +73,6 @@ describe('producedForClosing derivation', () => {
expect(producedForClosing(nodes, 999)).toEqual([])
})
it('treats a user-explicit skill invocation as a turn boundary', () => {
// The injection opens a user turn exactly like a typed prompt: files
// written before it must not spill into the turn its answer closes.
const skillInvocation = {
kind: 'skill-invocation' as const, seq: 4, time: 4_000,
name: 'hidden-demo',
content: [{ type: 'text', text: '<skill_content name="hidden-demo">x</skill_content>' }] as never,
source: null,
}
const nodes: ConversationNode[] = [
user(1, 'write things'),
assistant(2, 'wrote', 1),
wrote(3, 'a', 'stale.txt'),
skillInvocation,
wrote(5, 'b', 'fresh.txt'),
assistant(6, 'followed the skill', 2),
]
expect(producedForClosing(nodes, 6)).toEqual(['fresh.txt'])
expect(producedForClosing(nodes, 6)).not.toContain('stale.txt')
})
it('counts a generic edit and never spills across the turn boundary', () => {
const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md
README.md: ea3dbf3592995903422ec951e20c911082370dbe
README.zh.md: 5b8886e67973af9a594ff6aa2e9295f112a9f3e3
README.md: bdd772662acda1f8cf1b7d8a7c5532f9b37123dd
README.zh.md: 959ff0ede6d545150fb22710c8af75859966caa9

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`.
A menu pick or an entered `/name [args]` line claims the composer into an args-tolerant `skill.invoke` transaction (`matchEnter` strong-waits the catalog; an unknown name answers undefined and stays a plain prompt). A skill name shared with a host command resolves to the command: adjudication polls sources in registration order and the web bundle mounts ui-command ahead of this source — deliberate precedence, matching peer products. Submit trims the args, keeps blank args off the wire, and folds an RPC refusal into the composer's error outcome; the host renders the skill body and injects it as a user message before starting the turn, so invocation is deterministic for every user-invocable skill. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. Draft chip visuals still derive from the `lexicon` scan; the legacy `<skill>name</skill>` reference codec is gone (decision 21 removal cut) and `matchSpace` stays unimplemented — menu and enter own the skill flows.
A pick lands the literal `/name ` text and the prompt ships the same literal (decision 21) — this source implements no adjudication hooks and no reference codec (the legacy `<skill>name</skill>` form is gone with the removal cut). Determinism lives host-side: the pre-step gesture boundary (`dsh-tool-skill`) recognizes whitespace-bounded `/name` tokens naming user-invocable skills anywhere in a user message and injects the rendered `<skill_content>` for every front end, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. A name shared with a host command still resolves to the command: adjudication claims the line client-side before it ever becomes a prompt — deliberate precedence, matching peer products. The list RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument; draft chip visuals derive from the `lexicon` scan.
A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states.
@@ -20,11 +20,11 @@ The browser plugin also registers a keyed `skill` toolview in `conversation.chat
#### What the model sees
A claimed invocation never ships the `/name` literal. The host (`skill.invoke`) renders the canonical `<skill_content>` block — the same `renderSkillContent` output the `skill` tool returns — appends the user's trailing text after a blank line, and injects the whole as one user-role message carrying the `skill-invocation` source, immediately starting a turn. Loading is deterministic: the model receives the full body without being asked to call the `skill` tool, and the catalog (rendered by `dsh-tool-skill`) tells it not to re-load an inline-injected skill.
The user's message reaches the model verbatim, `/name` literal included. The host's pre-step boundary (`dsh-tool-skill`) then appends the canonical `<skill_content>` block — the same `renderSkillContent` output the `skill` tool returns — as injected instructions context at the end of that step's injections, closest to the model's answer. Loading is deterministic: the model receives the full body without being asked to call the `skill` tool, and the catalog tells it not to re-load an inline-injected skill.
#### Token effect
One invocation adds the rendered skill body plus the trailing text to that turn's user message — the same cost as the model loading the skill through the tool, paid unconditionally instead of at the model's discretion. Menu browsing and the candidate fetch add zero model tokens.
One invocation adds the rendered skill body to that turn as injected context — the same cost as the model loading the skill through the tool, paid unconditionally instead of at the model's discretion. Menu browsing and the candidate fetch add zero model tokens.
#### KV Cache effect
@@ -33,5 +33,5 @@ Append-only: the injected message lands after the reusable history prefix. This
## Known Limitations and Deferred Work
- **Result-only history pages use the generic row** — keyed dispatch needs the paired call in the runtime window; pagination that leaves the call outside has no tool identity. This client presentation feature does not extend the history wire contract to recover it.
- **Enter waits on the catalog once** — `matchEnter` strong-waits the session's first catalog fetch before answering, so an enter racing a cold cache resolves against the settled catalog rather than silently missing. A menu opened before the prewarm settles still shows no skill candidates for that keystroke.
- **Text is the truth** — the reference is plain draft text; a hand-typed identical token is the same reference. Chip visuals derive from the lexicon scan; no occurrence identity or position tracking (componentized chips are a ledger item).
- **Text is the truth** — the reference is plain draft text; a hand-typed identical token is the same reference, and the host gesture boundary judges the sent text, not the menu interaction. Chip visuals derive from the lexicon scan; no occurrence identity, position tracking, or structured reference payload on the prompt wire (both are ledger items).
- **A menu opened before the prewarm settles** shows no skill candidates for that keystroke; the next keystroke re-polls the settled cache.

View File

@@ -4,7 +4,7 @@
skill技能调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill`modelInvocable: false` 的条目(即 `disable-model-invocation` skill此路径是其唯一入口会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flightscope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。
菜单 pick 或回车提交的一行 `/name [args]` 会把 composer 认领进一个容忍参数`skill.invoke` 事务(`matchEnter` 强等目录;未知名称应答 undefined保持为普通提示词。与宿主命令同名的 skill 名解析为命令:裁决按注册顺序轮询各 source而 web bundle 把 ui-command 挂载在本 source 之前——这是有意的优先级,与同行产品一致。提交时会修剪参数、让空白参数不上协议,并把 RPC 拒绝折叠进 composer 的错误结局;宿主在开启轮次之前渲染 skill 正文并将其作为用户消息注入,因此对每一个用户可调用的 skill调用都是确定性的。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务草稿 chip 视觉`lexicon` 扫描派生;旧的 `<skill>name</skill>` 引用 codec 已经移除(决策 21 的移除裁定),`matchSpace` 保持不实现——skill 流程归菜单与回车所有
pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本(决策 21——本 source 不实现任何裁决钩子,也没有引用 codec`<skill>name</skill>` 形式已随移除裁定消失。确定性在宿主侧pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token并为每一种前端注入渲染后的 `<skill_content>`,因此菜单 pick、手动键入的 token 与 TUI/ACP 提示词都以同一种方式加载 skill。与宿主命令同名的名称仍解析为命令裁决在客户端把该行认领走它根本不会成为提示词——这是有意的优先级与同行产品一致。列表 RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务草稿 chip 视觉由 `lexicon` 扫描派生。
`skill.list` 失败时 `candidates` 抛出异常slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pendingready 状态。
@@ -20,11 +20,11 @@ skill技能调用 source 的浏览器端:把 `/` 触发的 `skill` sourc
#### 模型看到的内容
被认领的调用绝不会把字面文本 `/name` 发出去。宿主(`skill.invoke`渲染规范的 `<skill_content>` 块——与 `skill` 工具返回的 `renderSkillContent` 输出相同——在一个空行之后追加用户的尾随文本,并把整体作为一条携带 `skill-invocation` 来源的 user 角色消息注入,随即开启一个轮次。加载是确定性的:模型无需被要求调用 `skill` 工具就能收到完整正文,目录(由 `dsh-tool-skill` 渲染)也会告诉它不要重新加载已内联注入的 skill。
用户消息原样到达模型,字面文本 `/name` 也包含在内。随后宿主的 pre-step 边界(`dsh-tool-skill`规范的 `<skill_content>` 块——与 `skill` 工具返回的 `renderSkillContent` 输出相同——作为注入的指令上下文追加在该步骤各项注入的末尾,最贴近模型的回答。加载是确定性的:模型无需被要求调用 `skill` 工具就能收到完整正文,目录也会告诉它不要重新加载已内联注入的 skill。
#### Token 影响
一次调用会把渲染后的 skill 正文连同尾随文本加进该轮次的用户消息——成本与模型经由工具加载该 skill 相同,只是无条件支付,而非由模型自行裁量。浏览菜单和拉取候选不会增加任何模型 token。
一次调用会把渲染后的 skill 正文作为注入上下文加进该轮次——成本与模型经由工具加载该 skill 相同,只是无条件支付,而非由模型自行裁量。浏览菜单和拉取候选不会增加任何模型 token。
#### KV Cache 影响
@@ -33,5 +33,5 @@ skill技能调用 source 的浏览器端:把 `/` 触发的 `skill` sourc
## 已知限制与暂缓事项
- **仅含结果的 history 页使用通用行**:键控分派要求配对调用位于 runtime 窗口内;分页将调用留在窗口外时,结果没有工具身份。这项客户端呈现功能不会为了恢复该身份而扩展 history 协议契约。
- **回车对目录只等待一次**`matchEnter` 在应答之前强等该会话的首次目录拉取,因此与冷缓存竞速的回车会对照已落定的目录解析,而不是静默错过。预热落定之前打开的菜单,在那次击键下仍不会显示 skill 候选
- **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份或位置跟踪(组件化 chip 是台账事项)
- **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用宿主手势边界评判的是发出的文本而不是菜单交互。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份、位置跟踪,也没有提示词协议上的结构化引用载荷(两者都是台账事项)
- **预热落定之前打开的菜单**:在那次击键下不显示 skill 候选;下一次击键会重新轮询已落定的缓存

View File

@@ -2,15 +2,16 @@
* Skill reference plugin, browser half: registers the '/' skill source —
* candidates from the skill.list RPC addressed by the per-call session
* projection's sessionId (sessions are always agent-backed; the host
* resolves cwd from the session header). A menu pick or an entered `/name
* [args]` line claims into a skill.invoke transaction: the host renders the
* skill body and injects it as a user message, so invocation is
* deterministic for every user-invocable skill — including
* `disable-model-invocation` skills the model-side catalog never lists
* (issue #1470). The RPC rides the plugin's root-context connection
* captured at registration — the source never reads services off a per-call
* argument. Draft chip visuals still derive from the lexicon scan; the
* legacy `<skill>` reference codec is gone (decision 21 removal cut).
* resolves cwd from the session header). A pick lands the literal `/name `
* text and the prompt ships the same literal (decision 21); determinism
* lives host-side — the pre-step boundary (`dsh-tool-skill`) recognizes a
* leading `/name` naming a user-invocable skill and injects the rendered
* body for every front end, including `disable-model-invocation` skills the
* model-side catalog never lists (issue #1470). The RPC rides the plugin's
* root-context connection captured at registration — the source never reads
* services off a per-call argument. Draft chip visuals still derive from
* the lexicon scan; the legacy `<skill>` reference codec is gone (decision
* 21 removal cut).
*
* Catalog fetches are cached per session (the small twin of the ui-command
* directory): the per-keystroke candidates re-poll filters a settled
@@ -27,7 +28,7 @@
*/
import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
import type { PickOutcome, SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { SkillRow } from './SkillRow.tsx'
@@ -125,27 +126,6 @@ export function apply(ctx: ClientContext): void {
// locale service's own fallback ladder; candidate-time reads stay plain text.
const t = ctx.locale.bind(NS)
/**
* Args-tolerant claim for one skill: token `/name ` plus the skill.invoke
* transaction. Blank args stay off the wire; an RPC refusal folds into the
* composer's error outcome (transport failures throw).
*/
const invokeClaim = (session: { readonly sessionId: SessionId }, name: string): PickOutcome => ({
claim: {
token: `/${name} `,
submit: async (args) => {
const trimmed = args.trim()
const { result } = await skills.invoke({
sessionId: session.sessionId,
name,
...trimmed === '' ? {} : { text: trimmed },
})
if (!result.ok) return { kind: 'error', text: `${result.error.code}: ${result.error.message}` }
return { kind: 'success' }
},
},
})
const source: SlashSource = {
trigger: '/',
name: 'skill',
@@ -181,25 +161,14 @@ export function apply(ctx: ClientContext): void {
if (listeners.size === 0) lexiconListeners.delete(key)
}
},
onPick({ candidate, session }) {
return invokeClaim(session, candidate.name)
},
// Adjudication polls sources in registration order and the web bundle
// mounts ui-command first, so a name shared with a host command claims as
// the command — deliberate precedence (commands are explicit host
// features; peer products resolve the collision the same way), not a race.
async matchEnter(session, line, signal) {
const trimmed = line.trim()
if (!trimmed.startsWith('/')) return undefined
const ws = trimmed.search(/\s/)
const name = (ws === -1 ? trimmed : trimmed.slice(0, ws)).slice(1)
if (name === '') return undefined
// Strong-wait the catalog: an unknown name stays a plain prompt (the
// default sink), never a swallowed line.
const catalog = await fetchCatalog(session.sessionId)
if (signal.aborted) return undefined
if (!catalog.some(skill => skill.name === name)) return undefined
return invokeClaim(session, name)
onPick({ candidate }) {
// Decision 21: the pick lands plain text and the prompt ships the same
// literal. Determinism no longer rides the client — the host's
// pre-step boundary (dsh-tool-skill) recognizes the leading /name and
// injects the rendered body for every front end. A name shared with a
// host command still resolves to the command: adjudication claims the
// line client-side before it ever becomes a prompt.
return { text: `/${candidate.name} ` }
},
}
const slash = ctx.get('slash') as SlashServiceContract

View File

@@ -322,10 +322,9 @@ describe('lexicon', () => {
})
})
describe('pick claims into skill.invoke', () => {
it('onPick returns an args-tolerant claim whose submit invokes the skill', async () => {
const invoke = vi.fn(() => Promise.resolve({ result: { ok: true as const, value: { accepted: true as const } } }))
const { source } = await bench(listOk(CATALOG), undefined, invoke)
describe('pick lands plain text (decision 21)', () => {
it('onPick returns the literal /name text with a closing space', async () => {
const { source } = await bench(listOk(CATALOG))
const outcome = source.onPick({
candidate: { name: 'commit-helper', description: 'commit flow' },
session: proj('s1'),
@@ -333,58 +332,16 @@ describe('pick claims into skill.invoke', () => {
via: 'menu',
span: { start: 0, end: 4, draftRev: 7 },
})
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected a claim outcome')
expect(outcome.claim.token).toBe('/commit-helper ')
await expect(outcome.claim.submit('check the fixture', {} as never)).resolves.toEqual({ kind: 'success' })
expect(invoke).toHaveBeenCalledWith({ sessionId: sid('s1'), name: 'commit-helper', text: 'check the fixture' })
expect(outcome).toEqual({ text: '/commit-helper ' })
})
it('submit omits blank args and folds an RPC refusal into an error outcome', async () => {
const invoke = vi.fn(() => Promise.resolve({
result: { ok: false as const, error: { code: 'skill-not-invocable', message: 'nope', details: { name: 'deploy' } } },
}))
const { source } = await bench(listOk(CATALOG), undefined, invoke)
const outcome = source.onPick({
candidate: { name: 'deploy', description: 'deploy flow' },
session: proj('s1'),
position: 'leading',
via: 'menu',
span: { start: 0, end: 4, draftRev: 7 },
})
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected a claim outcome')
await expect(outcome.claim.submit(' ', {} as never))
.resolves.toEqual({ kind: 'error', text: 'skill-not-invocable: nope' })
expect(invoke).toHaveBeenCalledWith({ sessionId: sid('s1'), name: 'deploy' })
})
it('drops the legacy reference codec (decision 21 removal cut)', async () => {
it('keeps the legacy reference codec removed and stays out of adjudication', async () => {
const { source } = await bench(listOk(CATALOG))
// Determinism lives host-side (the pre-step gesture boundary), so the
// source neither claims lines nor serializes reference markup.
expect(source.codec).toBeUndefined()
})
})
describe('adjudication', () => {
it('claims an entered /name line, args-tolerant, once the catalog knows the name', async () => {
const invoke = vi.fn(() => Promise.resolve({ result: { ok: true as const, value: { accepted: true as const } } }))
const { source } = await bench(listOk(CATALOG), undefined, invoke)
const outcome = await source.matchEnter!(proj('s1'), '/deploy run the smoke suite', new AbortController().signal)
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected a claim outcome')
expect(outcome.claim.token).toBe('/deploy ')
await outcome.claim.submit('run the smoke suite', {} as never)
expect(invoke).toHaveBeenCalledWith({ sessionId: sid('s1'), name: 'deploy', text: 'run the smoke suite' })
})
it('answers undefined for unknown names, non-slash lines, and bare "/"', async () => {
const { source } = await bench(listOk(CATALOG))
const signal = new AbortController().signal
await expect(source.matchEnter!(proj('s1'), '/unlisted do it', signal)).resolves.toBeUndefined()
await expect(source.matchEnter!(proj('s1'), 'plain prose', signal)).resolves.toBeUndefined()
await expect(source.matchEnter!(proj('s1'), '/', signal)).resolves.toBeUndefined()
})
it('never claims on space (menu and enter own the skill flows)', async () => {
const { source } = await bench(listOk(CATALOG))
expect(typeof source.matchSpace).toBe('undefined')
expect(typeof source.matchEnter).toBe('undefined')
})
})