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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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-flight;scope 创建时的 `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 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。
|
||||
|
||||
@@ -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 候选;下一次击键会重新轮询已落定的缓存。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user