Merge branch 'master' into worktree/merge-compact-card
This commit is contained in:
@@ -2449,7 +2449,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
if (missing !== undefined) return missing
|
||||
return ok(request, {
|
||||
skills: [
|
||||
{ name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收' },
|
||||
{ name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收', modelInvocable: true },
|
||||
{ name: 'fixture-user-only', description: 'fixture 仅用户技能样本', modelInvocable: false },
|
||||
],
|
||||
})
|
||||
},
|
||||
|
||||
@@ -163,6 +163,7 @@ export class FakeApiClient implements IApiClient {
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
|
||||
= () => Promise.resolve(ok({ skills: [] }))
|
||||
|
||||
|
||||
readonly commands: IApiClient['commands'] = {
|
||||
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
|
||||
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -57,10 +57,11 @@ function materializeNode(
|
||||
stepTimings: ReadonlyMap<string, AssistantStepMetadata>,
|
||||
): ConversationNode {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
// 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).
|
||||
case 'user/message': {
|
||||
// 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,
|
||||
@@ -80,6 +81,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,
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { Context } from 'cordis'
|
||||
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
|
||||
SlotScope, SlotSpec, StoreDecl, StoredEntry, StoreInstanceLike,
|
||||
SlotScope, SlotSpec, StoreDecl, StoreFactory, StoredEntry, StoreInstanceLike,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
@@ -35,16 +35,11 @@ export interface RootOwnerProps { children?: never }
|
||||
/** Instance key for root-scoped store records (session records key by session id, so the literal cannot collide). */
|
||||
const ROOT_INSTANCE_KEY = 'root'
|
||||
|
||||
// FIXME(slot-parity): the engine's arbitrated persist extensions — create()
|
||||
// takes the scope key (per-session localStorage suffix) and instances expose
|
||||
// clearPersisted() — are not yet on ui-slots' StoreHandle/StoreInstanceLike;
|
||||
// these local structural faces bridge until fw-slots lifts them.
|
||||
/** Canonical type-erased store handle used by the runtime lifecycle map. */
|
||||
type EngineStoreHandle = Exclude<StoreDecl, StoreFactory>
|
||||
|
||||
/** Store handle face as the engine actually ships it (scope-key-aware create). */
|
||||
interface EngineStoreHandle { create(scopeKey?: string): EngineStoreInstance }
|
||||
|
||||
/** Engine instance face: the host-contract shape plus persisted-state cleanup. */
|
||||
interface EngineStoreInstance extends StoreInstanceLike { clearPersisted(): void }
|
||||
/** Canonical engine instance derived from the handle's create contract. */
|
||||
type EngineStoreInstance = ReturnType<EngineStoreHandle['create']>
|
||||
|
||||
/** Store axis record: one per live handle, dropped when the last holding entry unloads. */
|
||||
interface StoreAxisRecord {
|
||||
|
||||
@@ -198,6 +198,7 @@ export class FakeApiClient implements IApiClient {
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
|
||||
= () => Promise.resolve(ok({ skills: [] }))
|
||||
|
||||
|
||||
readonly commands: IApiClient['commands'] = {
|
||||
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
|
||||
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
|
||||
|
||||
@@ -164,6 +164,28 @@ describe('TranscriptAdapter', () => {
|
||||
expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context'])
|
||||
})
|
||||
|
||||
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: '/hidden-demo check the fixture' }],
|
||||
source: { kind: 'user' },
|
||||
}) }),
|
||||
at(1, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
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()
|
||||
// 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', () => {
|
||||
// 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
|
||||
|
||||
@@ -137,29 +137,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)} />)
|
||||
|
||||
@@ -871,6 +871,7 @@ describe('MessageItem arms', () => {
|
||||
view.rerender(<MessageItem t={t} node={node} retryActive />)
|
||||
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('formatMessageClock', () => {
|
||||
|
||||
@@ -73,6 +73,7 @@ describe('producedForClosing derivation', () => {
|
||||
expect(producedForClosing(nodes, 999)).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
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,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: f70bd2780f255cd8e0c64acb3da3863e10c4fa9d
|
||||
README.zh.md: 6eb6cbd3ae196a540e161a3a23f9df2136824f2e
|
||||
README.md: bdd772662acda1f8cf1b7d8a7c5532f9b37123dd
|
||||
README.zh.md: 959ff0ede6d545150fb22710c8af75859966caa9
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Skill reference 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 returns the intersection of model-invocable and user-invocable skills because this browser path inserts a model reference rather than loading the body directly. 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)`; picking a candidate lands the literal `/name ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` owns the reference's two projections: `clipboardText` → `/name`, `serialize` → the model form `<skill>name</skill>` invoked at submit time. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. The source implements no `matchSpace`/`matchEnter` hooks — skill references never enter command adjudication and ride ordinary prompts into the default sink.
|
||||
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 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.
|
||||
|
||||
@@ -14,23 +16,22 @@ The browser plugin also registers a keyed `skill` toolview in `conversation.chat
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Skill reference text in the user prompt
|
||||
### User-explicit skill invocation
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A picked candidate lands the literal `/name ` in the draft (decision 21: plain text, no `<skill>` tag); the text reaches the model verbatim inside the ordinary user message (`session.prompt`), with no dedicated content block, prompt section, or host-side expansion. The association with the actual skill is model-side and non-deterministic: the session prefix already carries the skill catalog (rendered by `dsh-tool-skill`), and the reference's name matching a catalog entry is what invites the model to load it.
|
||||
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
|
||||
|
||||
Conditional and tiny: only a pick (or hand-typing the same text) adds the reference's characters to that one user message. 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
|
||||
|
||||
Append-only: the reference is part of a new user message appended after the reusable history prefix. This package never edits earlier request tokens.
|
||||
Append-only: the injected message lands after the reusable history prefix. This package never edits earlier request tokens.
|
||||
|
||||
## 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.
|
||||
- **Non-deterministic skill loading** — the reference is a collaboration cue, not a guarantee; the model may ignore it. The rework path when hit rate proves insufficient (a host-side `context/skill-reference` guidance package, or full-text injection) sits in the design ledger; the wire text shape would not change.
|
||||
- **First keystroke may race the prewarm** — the scope-birth warm launches the catalog fetch, but a menu opened before it settles shows no skill candidates for that keystroke. Accepted by design: skill references do not participate in enter adjudication, so nothing correctness-bearing waits on the catalog.
|
||||
- **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.
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主返回模型可调用与用户可调用 skill 的交集,因为该浏览器路径插入的是模型引用,而不是直接加载正文。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤;pick 一个候选会把字面文本 `/name ` 经 slash 流水线落进草稿(决策 21 的纯文本引用),source 的 `codec` 拥有该引用的两种投影:`clipboardText` → `/name`,`serialize` → 提交时生成的模型形式 `<skill>name</skill>`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace`/`matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。
|
||||
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 `,提示词发出的就是同一段字面文本(决策 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 状态。
|
||||
|
||||
@@ -14,23 +16,22 @@ skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` sourc
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 用户提示词中的 skill 引用文本
|
||||
### 用户显式 skill 调用
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
被 pick 的候选会把字面文本 `/name ` 落进草稿(决策 21:纯文本,无 `<skill>` 标签);该文本原样进入普通用户消息(`session.prompt`)到达模型,没有专用内容块、提示词 section 或 host 侧展开。与实际 skill 的关联在模型侧建立且具有非确定性:会话前缀已携带 skill 目录(由 `dsh-tool-skill` 渲染),引用名称与目录条目匹配,正是这一点引导模型去加载它。
|
||||
用户消息原样到达模型,字面文本 `/name` 也包含在内。随后宿主的 pre-step 边界(`dsh-tool-skill`)把规范的 `<skill_content>` 块——与 `skill` 工具返回的 `renderSkillContent` 输出相同——作为注入的指令上下文追加在该步骤各项注入的末尾,最贴近模型的回答。加载是确定性的:模型无需被要求调用 `skill` 工具就能收到完整正文,目录也会告诉它不要重新加载已内联注入的 skill。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
有条件且极小:只有 pick(或手动键入相同文本)会把引用的字符加进那一条用户消息。浏览菜单和拉取候选不会增加任何模型 token。
|
||||
一次调用会把渲染后的 skill 正文作为注入上下文加进该轮次——成本与模型经由工具加载该 skill 相同,只是无条件支付,而非由模型自行裁量。浏览菜单和拉取候选不会增加任何模型 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加:引用是追加在可复用历史前缀之后的新用户消息的一部分。该包绝不改写较早的请求 token。
|
||||
仅追加:注入的消息落在可复用历史前缀之后。该包绝不改写较早的请求 token。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **仅含结果的 history 页使用通用行**:键控分派要求配对调用位于 runtime 窗口内;分页将调用留在窗口外时,结果没有工具身份。这项客户端呈现功能不会为了恢复该身份而扩展 history 协议契约。
|
||||
- **skill 加载具有非确定性**:引用是协作线索,不是保证;模型可能忽略它。针对命中率不足情况的返工路径(host 侧 `context/skill-reference` 引导包,或全文注入)记录在设计台账中;协议中的文本形态不会改变。
|
||||
- **首次击键可能与预热竞速**:scope 创建时的预热会启动目录拉取,但目录落定之前打开的菜单,在那次击键下不会显示 skill 候选。这是设计上接受的取舍:skill 引用不参与回车裁决,因此没有任何攸关正确性的环节等待目录。
|
||||
- **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份或位置跟踪(组件化 chip 是台账事项)。
|
||||
- **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用,宿主手势边界评判的是发出的文本,而不是菜单交互。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份、位置跟踪,也没有提示词协议上的结构化引用载荷(两者都是台账事项)。
|
||||
- **预热落定之前打开的菜单**:在那次击键下不显示 skill 候选;下一次击键会重新轮询已落定的缓存。
|
||||
|
||||
@@ -2,13 +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), pick inserts the literal `/name `
|
||||
* text (decision 21: the draft carries plain text, chip visuals are derived
|
||||
* by scanning against the source lexicon, and the prompt ships the same
|
||||
* literal — no `<skill>` tag). The RPC rides the plugin's root-context
|
||||
* connection captured at registration — the source never reads services off
|
||||
* a per-call argument. No adjudication hooks: skill references ride
|
||||
* ordinary prompts and never enter command adjudication.
|
||||
* 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
|
||||
@@ -119,6 +122,10 @@ export function apply(ctx: ClientContext): void {
|
||||
for (const key of [...fetches.keys()]) invalidate(key)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
const source: SlashSource = {
|
||||
trigger: '/',
|
||||
name: 'skill',
|
||||
@@ -129,7 +136,12 @@ export function apply(ctx: ClientContext): void {
|
||||
if (signal.aborted) return []
|
||||
return skills
|
||||
.filter(skill => skill.name.startsWith(query))
|
||||
.map(skill => ({ name: skill.name, description: skill.description }))
|
||||
.map(skill => ({
|
||||
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 : `${t('menu.userOnly')} · ${skill.description}`,
|
||||
}))
|
||||
},
|
||||
warm(session) {
|
||||
// Fire-and-forget scope-birth prewarm; the shared fetch reports
|
||||
@@ -150,16 +162,14 @@ export function apply(ctx: ClientContext): void {
|
||||
}
|
||||
},
|
||||
onPick({ candidate }) {
|
||||
// Decision 21: plain-text reference — the literal lands in the draft
|
||||
// and ships to the model verbatim (trailing space closes the token).
|
||||
// Legacy path (decision 21), retained for the removal cut, no longer reached:
|
||||
// return { insert: { source: 'skill', ref: candidate.name, label: candidate.name, clipboardText: `/${candidate.name}` } }
|
||||
// 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} ` }
|
||||
},
|
||||
codec: {
|
||||
clipboardText: ref => `/${ref}`,
|
||||
serialize: ref => Promise.resolve(`<skill>${ref}</skill>`),
|
||||
},
|
||||
}
|
||||
const slash = ctx.get('slash') as SlashServiceContract
|
||||
ctx.on('connection/reset', clearAll)
|
||||
|
||||
@@ -9,6 +9,7 @@ export const zh = {
|
||||
'row.failed': 'skill 加载失败',
|
||||
'row.stopped': 'skill 加载已中止',
|
||||
'row.instructions': '说明',
|
||||
'menu.userOnly': '仅用户',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The skill namespace key union. */
|
||||
@@ -20,4 +21,5 @@ export const en = {
|
||||
'row.failed': 'Skill load failed',
|
||||
'row.stopped': 'Skill load stopped',
|
||||
'row.instructions': 'Instructions',
|
||||
'menu.userOnly': 'user-only',
|
||||
} satisfies Record<SkillKey, string>
|
||||
|
||||
@@ -20,11 +20,15 @@ import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { SkillRow as SkillToolRow } from '../src/client/SkillRow.tsx'
|
||||
|
||||
type SkillRow = { name: string; description: string; whenToUse?: string }
|
||||
type SkillRow = { name: string; description: string; whenToUse?: string; modelInvocable?: boolean }
|
||||
type ListResult =
|
||||
| { ok: true; value: { skills: SkillRow[] } }
|
||||
| { ok: false; error: { code: string; message: string; details: object } }
|
||||
type ListFn = (payload: object, signal?: AbortSignal) => Promise<{ result: ListResult }>
|
||||
type InvokeResult =
|
||||
| { ok: true; value: { accepted: true } }
|
||||
| { ok: false; error: { code: string; message: string; details: object } }
|
||||
type InvokeFn = (payload: object) => Promise<{ result: InvokeResult }>
|
||||
|
||||
interface PresentationCapture {
|
||||
slots: SlotsService
|
||||
@@ -49,16 +53,19 @@ function providePresentation(ctx: Context): PresentationCapture {
|
||||
capture.dictionaries.push({ namespace, dictionaries })
|
||||
return () => { capture.localeDisposed = true }
|
||||
},
|
||||
// Minimal bound-translate fake: zh dictionary lookup, key passthrough on miss.
|
||||
bind: () => (key: string) => key === 'menu.userOnly' ? '仅用户' : key,
|
||||
})
|
||||
return capture
|
||||
}
|
||||
|
||||
/** Boot the plugin over fake slash/connection faces; returns the captured source and its ctx. */
|
||||
async function bench(list: ListFn, addressed?: SessionId) {
|
||||
async function bench(list: ListFn, addressed?: SessionId, invoke?: InvokeFn) {
|
||||
const ctx = new Context()
|
||||
let captured: SlashSource | undefined
|
||||
ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
|
||||
ctx.provide('connection', { api: { skills: { list } } })
|
||||
const defaultInvoke: InvokeFn = () => Promise.resolve({ result: { ok: true as const, value: { accepted: true as const } } })
|
||||
ctx.provide('connection', { api: { skills: { list, invoke: invoke ?? defaultInvoke } } })
|
||||
ctx.provide('sessions', {
|
||||
subagentAddress: (id: SessionId) => id === addressed
|
||||
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
|
||||
@@ -70,9 +77,9 @@ async function bench(list: ListFn, addressed?: SessionId) {
|
||||
}
|
||||
|
||||
const CATALOG: SkillRow[] = [
|
||||
{ name: 'commit-helper', description: 'commit flow' },
|
||||
{ name: 'code-review', description: 'review flow', whenToUse: 'reviews' },
|
||||
{ name: 'deploy', description: 'deploy flow' },
|
||||
{ name: 'commit-helper', description: 'commit flow', modelInvocable: true },
|
||||
{ name: 'code-review', description: 'review flow', whenToUse: 'reviews', modelInvocable: true },
|
||||
{ name: 'deploy', description: 'deploy flow', modelInvocable: true },
|
||||
]
|
||||
|
||||
const listOk = (skills: SkillRow[]): ListFn => () => Promise.resolve({ result: { ok: true as const, value: { skills } } })
|
||||
@@ -117,12 +124,14 @@ describe('apply', () => {
|
||||
'row.failed': 'skill 加载失败',
|
||||
'row.stopped': 'skill 加载已中止',
|
||||
'row.instructions': '说明',
|
||||
'menu.userOnly': '仅用户',
|
||||
},
|
||||
en: {
|
||||
'row.running': 'Loading skill',
|
||||
'row.failed': 'Skill load failed',
|
||||
'row.stopped': 'Skill load stopped',
|
||||
'row.instructions': 'Instructions',
|
||||
'menu.userOnly': 'user-only',
|
||||
},
|
||||
},
|
||||
}])
|
||||
@@ -313,8 +322,8 @@ describe('lexicon', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('pick and codec', () => {
|
||||
it('onPick returns the literal /name text with a closing space (decision 21)', async () => {
|
||||
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' },
|
||||
@@ -326,18 +335,27 @@ describe('pick and codec', () => {
|
||||
expect(outcome).toEqual({ text: '/commit-helper ' })
|
||||
})
|
||||
|
||||
it('codec projects clipboard `/name` and serializes the model form <skill>name</skill>', async () => {
|
||||
const { source } = await bench(listOk(CATALOG))
|
||||
expect(source.codec!.clipboardText('deploy')).toBe('/deploy')
|
||||
await expect(source.codec!.serialize('deploy', new AbortController().signal))
|
||||
.resolves.toBe('<skill>deploy</skill>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('adjudication', () => {
|
||||
it('never participates: no matchSpace/matchEnter hooks on the skill source', 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()
|
||||
expect(typeof source.matchSpace).toBe('undefined')
|
||||
expect(typeof source.matchEnter).toBe('undefined')
|
||||
})
|
||||
})
|
||||
|
||||
describe('user-only marking', () => {
|
||||
it('prefixes the description of candidates the model cannot invoke', async () => {
|
||||
const rows: SkillRow[] = [
|
||||
{ name: 'shared-skill', description: 'both surfaces', modelInvocable: true },
|
||||
{ name: 'user-only-skill', description: 'user surface only', modelInvocable: false },
|
||||
]
|
||||
const { source } = await bench(listOk(rows))
|
||||
const candidates = await source.candidates(proj('s1'), req(''))
|
||||
expect(candidates).toEqual([
|
||||
{ name: 'shared-skill', description: 'both surfaces' },
|
||||
{ name: 'user-only-skill', description: '仅用户 · user surface only' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user