fix(client): review fixes for invocation rendering and turn boundaries

The user-turn predicate (opensUserTurn) unifies the three parallel
consumers a new node kind silently missed — produced-files turn reset,
retry liveness, own-words force-scroll — so a skill invocation behaves as
the turn opener it is. The menu marker resolves through ctx.locale.bind
instead of a hand-rolled snapshot lookup; the dead legacy <skill> render
arm goes with the removal cut; command-over-skill name precedence is now
documented at the matchEnter seam; and the emptied replacement catalog
keeps the no-reload sentence, with the never-published residual recorded
in the Agent Note.
This commit is contained in:
Yichen Jiang
2026-08-08 11:30:16 +08:00
parent c4c2355b50
commit 31ed85900d
19 changed files with 77 additions and 31 deletions
@@ -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 .agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md
2026-08-08-user-explicit-skill-invocation.md: 9249ee5c9c712e9c6aa827e97178f352728ed927
2026-08-08-user-explicit-skill-invocation.zh.md: f15975c3b13fbf76e036fcece30253e78e7b417d
2026-08-08-user-explicit-skill-invocation.md: abe6a05283359b81ff1c3cab754d0230e599e4a0
2026-08-08-user-explicit-skill-invocation.zh.md: e72e49236ffd2c6f664e01abbd69665eec8328e9
@@ -34,3 +34,4 @@ Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reaso
- Every user-invocable skill invocation now costs its full rendered body unconditionally — the price of determinism the peer survey showed everyone pays.
- The `skill-invocation` source rides `user/message`, so Model-visible ⟺ logged holds with no new event type, and replay/UI read metadata rather than text markers.
- TUI and ACP can adopt `skill.invoke` later for the same semantics; until then the TUI's client-side expansion remains its own path.
- Accepted residual of dropping the per-injection preamble: the no-reload framing rides only the catalog, and a workspace whose skills are all user-only never publishes a first catalog — an injection can arrive with no framing at all, and the model may redundantly try the `skill` tool once (the replacement catalog's empty arm carries the sentence; the never-published case does not). Publishing a catalog for framing alone was judged worse than that one recoverable error.
@@ -34,3 +34,4 @@ Status: implemented
- 每一次用户可调用 skill 的调用现在都无条件付出其完整渲染正文的成本——这是确定性的代价,同类调研表明所有产品都在支付。
- `skill-invocation` 来源搭乘 `user/message`,因此「模型可见 ⟺ 已记录」在不新增事件类型的情况下继续成立,回放与 UI 读取的是元数据而非文本标记。
- TUI 与 ACP 之后可以为同样的语义采用 `skill.invoke`;在那之前,TUI 的客户端展开仍是它自己的路径。
- 放弃逐次注入前导语后被接受的残余:no-reload framing 只搭乘目录,而 skill 全部为仅用户的工作区永远不会发布首个目录——注入可能在完全没有 framing 的情况下到达,模型可能多余地调用一次 `skill` 工具(替换目录的空臂携带该句;从未发布的情形没有)。仅为 framing 而发布目录被判定比这一次可恢复的错误更糟。
@@ -2779,7 +2779,7 @@ 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)
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)
@@ -45,6 +45,7 @@ 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,
@@ -258,6 +258,20 @@ 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
@@ -24,6 +24,7 @@
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'
@@ -118,7 +119,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' || node.kind === 'user') return null
if (node.kind === 'assistant' || opensUserTurn(node)) return null
}
return null
}
@@ -447,10 +448,11 @@ export function ChatView({
return
}
firstSeqRef.current = firstSeq
// 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).
// 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).
const appendedUser = lastKey !== lastKeyRef.current
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
&& lastItem !== undefined && lastItem.kind === 'node' && opensUserTurn(lastItem.node)
const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current
const tipMoved = followSigRef.current !== followSig
lastKeyRef.current = lastKey
@@ -138,29 +138,27 @@ function TurnErrorItem({ node, t }: {
/**
* Display projection of reference forms in a user bubble (free geometry — no
* textarea alignment constraint here); everything else stays plain text. The
* logged model text remains the single truth; this is presentation only. Two
* shapes decorate: legacy `<skill>name</skill>` spans (pre-decision-21
* history) and plain-text `/name` / `@name` word-boundary tokens (decision
* 21: the sent text IS the reference — the bubble uses the same plainest
* token scan as the composer, minus the lexicon: sent tokens were validated
* at compose time, so shape alone decorates).
* logged model text remains the single truth; this is presentation only.
* Plain-text `/name` / `@name` word-boundary tokens decorate (decision 21:
* the sent text IS the reference — the bubble uses the same plainest token
* scan as the composer, minus the lexicon: sent tokens were validated at
* compose time, so shape alone decorates).
*/
function projectUserText(text: string): ReactNode {
const re = /<skill>([^<]+)<\/skill>|(^|\s)([/@][\w-]+)(?=\s|$)/g
const re = /(^|\s)([/@][\w-]+)(?=\s|$)/g
const parts: ReactNode[] = []
let cursor = 0
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) {
const legacy = m[1] !== undefined
const tokenStart = legacy ? m.index : m.index + (m[2]?.length ?? 0)
const label = legacy ? `/${m[1]}` : m[3] ?? ''
const tokenStart = m.index + (m[1]?.length ?? 0)
const label = m[2] ?? ''
if (tokenStart > cursor) parts.push(<MessageText key={cursor} text={text.slice(cursor, tokenStart)} />)
parts.push(
<span key={tokenStart} className={css.refChip} data-ref-chip={label.startsWith('@') ? 'subagent' : 'skill'}>
{label}
</span>,
)
cursor = legacy ? m.index + m[0].length : tokenStart + label.length
cursor = tokenStart + label.length
}
if (parts.length === 0) return <MessageText text={text} />
if (cursor < text.length) parts.push(<MessageText key={cursor} text={text.slice(cursor)} />)
@@ -3,6 +3,7 @@
* 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'
@@ -62,7 +63,7 @@ export function producedForClosing(nodes: readonly ConversationNode[], seq: numb
}
continue
}
if (node.kind === 'user') {
if (opensUserTurn(node)) {
turn = undefined
pending = []
seen = new Set()
@@ -73,6 +73,27 @@ 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 => ({
...toolResult(seq, callId, 'str_replace_editor'),
+2 -2
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: c888622bc92b038413c7d0ebf63abb61b483f6f5
README.zh.md: 3bbbc90186726356c53f375bb664d678c4926988
README.md: ea3dbf3592995903422ec951e20c911082370dbe
README.zh.md: 5b8886e67973af9a594ff6aa2e9295f112a9f3e3
+1 -1
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). 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 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 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.
+1 -1
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,保持为普通提示词)。提交时会修剪参数、让空白参数不上协议,并把 RPC 拒绝折叠进 composer 的错误结局;宿主在开启轮次之前渲染 skill 正文并将其作为用户消息注入,因此对每一个用户可调用的 skill,调用都是确定性的。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。草稿 chip 视觉仍由 `lexicon` 扫描派生;旧的 `<skill>name</skill>` 引用 codec 已经移除(决策 21 的移除裁定),`matchSpace` 保持不实现——skill 流程归菜单与回车所有。
菜单 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 流程归菜单与回车所有。
`skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。
+8 -3
View File
@@ -121,8 +121,9 @@ export function apply(ctx: ClientContext): void {
for (const key of [...fetches.keys()]) invalidate(key)
}
/** User-only marker in the active language (the menu hint is plain text, resolved at candidate time). */
const userOnlyHint = (): string => ctx.locale.getSnapshot().active === 'zh' ? zh['menu.userOnly'] : en['menu.userOnly']
// The bound translate resolves against the registered dictionaries with the
// 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
@@ -159,7 +160,7 @@ export function apply(ctx: ClientContext): void {
name: skill.name,
// The user-only marker rides the description (the menu's only
// secondary text); `hint` is the claim-state ghost text, not a badge.
description: skill.modelInvocable ? skill.description : `${userOnlyHint()} · ${skill.description}`,
description: skill.modelInvocable ? skill.description : `${t('menu.userOnly')} · ${skill.description}`,
}))
},
warm(session) {
@@ -183,6 +184,10 @@ export function apply(ctx: ClientContext): void {
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
@@ -53,7 +53,8 @@ function providePresentation(ctx: Context): PresentationCapture {
capture.dictionaries.push({ namespace, dictionaries })
return () => { capture.localeDisposed = true }
},
getSnapshot: () => ({ active: 'zh', locales: ['zh', 'en'], revision: 0 }),
// Minimal bound-translate fake: zh dictionary lookup, key passthrough on miss.
bind: () => (key: string) => key === 'menu.userOnly' ? '仅用户' : key,
})
return capture
}
+2 -2
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/skill/tool-skill/README.md
README.md: 5c6e592c670f324eb660dbe1fec168fd77e5b368
README.zh.md: 202a621b1d4047c7d763de3b98c1a69c8c1ee1f7
README.md: 21c3521aeff8b55940b04e804d5b8469850ec6da
README.zh.md: 74137ce7e577a4b5c6d3592b60bac3c5901a9159
+1 -1
View File
@@ -36,7 +36,7 @@ Tool execution does not add a synthetic context message. Its freshly loaded resu
#### What the model sees
If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `<available_skills>` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. The template's closing sentence is the seam rule against double-loading: the host's user-explicit `skill.invoke` injects the same `renderSkillContent` output (shared from `@deepseek-ai/dsh-skill`) inline, and the catalog tells the model to follow that block instead of re-loading the skill through the tool; the replacement-catalog template carries the same sentence.
If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `<available_skills>` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. The template's closing sentence is the seam rule against double-loading: the host's user-explicit `skill.invoke` injects the same `renderSkillContent` output (shared from `@deepseek-ai/dsh-skill`) inline, and the catalog tells the model to follow that block instead of re-loading the skill through the tool; the replacement-catalog template carries the same sentence in both arms, including the emptied catalog.
##### Skill catalog template
+1 -1
View File
@@ -36,7 +36,7 @@
#### 模型看到的内容
如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `<available_skills>` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。模板的结尾一句是防止双重加载的 seam 规则:宿主的用户显式 `skill.invoke` 会把同一份 `renderSkillContent` 输出(共享自 `@deepseek-ai/dsh-skill`)内联注入,目录则告诉模型遵循该块,而不是再经工具重新加载该 skill;替换目录模板携带同一句话。
如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `<available_skills>` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。模板的结尾一句是防止双重加载的 seam 规则:宿主的用户显式 `skill.invoke` 会把同一份 `renderSkillContent` 输出(共享自 `@deepseek-ai/dsh-skill`)内联注入,目录则告诉模型遵循该块,而不是再经工具重新加载该 skill;替换目录模板的两个臂——包括清空后的目录——都携带同一句话。
##### Skill 目录模板
+1
View File
@@ -233,6 +233,7 @@ function renderCatalogUpdate(entries: SkillCatalogSource['entries']): UserMessag
const availability = entries.length === 0
? [
'No skills are currently available through the `skill` tool. Do not use names from earlier skill catalogs.',
'A user may still invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool for it.',
]
: [
'Use only names in this replacement catalog. If the user names a listed skill, or the task clearly matches its description, call the `skill` tool with the exact name before acting.',