feat(tool-skill): inject user-invoked skills at the pre-step gesture boundary

A whitespace-bounded /name token anywhere in a claimed user message,
naming a user-invocable skill in the workspace directory, now injects that
skill's renderSkillContent as instructions context appended after every
other injection of the step — the same agent/pre-step seam the catalog,
workspace instructions, and the runtime snapshot ride. Closed-set matching
mirrors the command registry (a miss stays plain prose), only user-source
messages are scanned, the policy check runs on the loaded definition, and
this is the sole entry point for disable-model-invocation skills. The
catalog's no-reload sentence now names the gesture boundary.
This commit is contained in:
Yichen Jiang
2026-08-08 13:14:49 +08:00
parent 7750789c8e
commit c08fa27e5c
10 changed files with 225 additions and 86 deletions
+3 -72
View File
@@ -18,8 +18,7 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
import { SubagentError } from '@deepseek-ai/dsh-subagent'
import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent'
import { isSkillName, isUserInvocable, renderSkillContent } from '@deepseek-ai/dsh-skill'
import type { SkillInvocationSource } from '@deepseek-ai/dsh-skill'
import { isUserInvocable } from '@deepseek-ai/dsh-skill'
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
import {
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
@@ -1254,9 +1253,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
* turn, and letting it try spends the whole pre-step path to fail inside
* the adapter with a message about registration. Refusing here names the
* model the session is pointed at while the draft is still in the composer.
* This is the enforcement boundary shared by `session.prompt` and
* `skill.invoke`: a client that disables its input is an affordance, and
* both methods stay callable regardless.
* This is `session.prompt`'s enforcement boundary: a client that disables
* its input is an affordance, and the method stays callable regardless.
*/
async function turnAgentFor<T>(
request: RpcRequest<unknown>, sessionId: SessionId,
@@ -2389,73 +2387,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return err(request, { code: 'internal', message: `skill listing failed: ${String(error)}`, details: {} })
}
},
async invoke(request, signal) {
const { sessionId, name, text } = request.payload
const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId)
if ('refused' in resolved) return resolved.refused
const agent = resolved.agent
if (agent.session.header.cwd === undefined) {
// Same stance as skill.list: a cwd-less header is a pre-project
// legacy log, and skill discovery has no root to resolve against.
return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} })
}
const skillRegistry = ctx.get('skills')
if (skillRegistry === undefined) {
return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} })
}
const lookup = { cwd: agent.session.header.cwd, signal }
let skill
try {
// isSkillName guards the registry contract; an ill-formed name is
// indistinguishable from an absent one for the caller.
const summary = isSkillName(name)
? (await skillRegistry.list(lookup)).find(candidate => candidate.name === name)
: undefined
if (summary === undefined) {
return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } })
}
// The operation boundary owns user-invocation policy: client menus
// filtering their candidates is an affordance, not enforcement.
if (!isUserInvocable(summary)) {
return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } })
}
const loaded = await skillRegistry.get(name, lookup)
if (loaded === undefined) {
return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } })
}
// Recheck on the loaded definition (the skill-tool execute template):
// list and get collect independently, so a provider change between
// the two awaits can swap the winning candidate for a user-disabled
// one — the boundary must judge what it actually injects.
if (!isUserInvocable(loaded)) {
return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } })
}
skill = loaded
} catch (error: unknown) {
if (signal.aborted) {
return err(request, { code: 'cancelled', message: 'skill invocation cancelled', details: {} })
}
return err(request, { code: 'internal', message: `skill invocation failed: ${String(error)}`, details: {} })
}
if (signal.aborted) {
// The caller already gave up (unary deadline or navigation): a turn
// it will never observe must not start.
return err(request, { code: 'cancelled', message: 'skill invocation cancelled', details: {} })
}
const body = renderSkillContent(skill)
const source: SkillInvocationSource = { kind: 'skill-invocation', name, ...text === undefined ? {} : { args: text } }
try {
const message: UserMessage = createUserMessage({
content: [{ type: 'text', text: text === undefined ? body : `${body}\n\n${text}` }],
source,
})
agent.followup(message)
} catch (error: unknown) {
return err(request, { code: 'agent-busy', message: 'skill invocation rejected', details: { reason: String(error) } })
}
return ok(request, { accepted: true as const })
},
},
settings: {
+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/skill/README.md
README.md: 0c1b2249d8c46ad9ce8097ceeda2bd988c92eb21
README.zh.md: 8fed350d00433206aecdb32819adc81c82745869
README.md: 3dc2bcfa5775736717bdebcb92329d5655198234
README.zh.md: d11f90d5a8356f06df63aa249a1f8b5851f36f5f
+1 -1
View File
@@ -39,7 +39,7 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
### Shared model-facing rendering
`renderSkillContent(skill)` renders one loaded skill as the canonical `<skill_content>` block (escaped `name` attribute, resource hints, verbatim body). It is the single truth for both loading paths: `dsh-tool-skill` returns it as the `skill` tool result, and the host's user-explicit `skill.invoke` injects it as a user message, so the model sees one shape regardless of who initiated the load. `escapeText` is exported beside it for consumers embedding prose in the same markup frame. The package also declares the `skill-invocation` `MessageSource` kind ({ name, args? }) that user-explicit injection stamps on its messages — transcript consumers present the invocation from this metadata instead of re-parsing the body.
`renderSkillContent(skill)` renders one loaded skill as the canonical `<skill_content>` block (escaped `name` attribute, resource hints, verbatim body). It is the single truth for both loading paths: `dsh-tool-skill` returns it as the `skill` tool result and injects it at the user-explicit gesture boundary, so the model sees one shape regardless of who initiated the load. `escapeText` is exported beside it for consumers embedding prose in the same markup frame. The package also declares the `skill-invocation` `MessageSource` kind ({ name, form: 'instructions' }) that user-explicit injection stamps on its messages — transcript consumers present the invocation from this metadata instead of re-parsing the body.
`isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field directly. `ctx.skills.get()` remains the trusted, policy-neutral loading primitive, so every user- or model-facing consumer must enforce the predicate that matches its surface before exposing or loading a skill.
+1 -1
View File
@@ -39,7 +39,7 @@
### 共享的面向模型渲染
`renderSkillContent(skill)` 把一个已加载 skill 渲染为规范的 `<skill_content>` 块(转义后的 `name` 属性、资源提示、原样正文)。它是两条加载路径的唯一真源:`dsh-tool-skill` 将其作为 `skill` 工具结果返回,宿主的用户显式 `skill.invoke` 将其作为用户消息注入,因此无论加载由谁发起,模型看到的都是同一种形态。`escapeText` 随之一并导出,供要在同一标记框架中嵌入文案的消费方使用。该包还声明 `skill-invocation` 这个 `MessageSource` kind{ name, args? }),用户显式注入会把它打在自己的消息上——transcript(文本记录)消费方依据这份元数据呈现该次调用,而不是重新解析正文。
`renderSkillContent(skill)` 把一个已加载 skill 渲染为规范的 `<skill_content>` 块(转义后的 `name` 属性、资源提示、原样正文)。它是两条加载路径的唯一真源:`dsh-tool-skill` 将其作为 `skill` 工具结果返回,并在用户显式的手势边界将其注入,因此无论加载由谁发起,模型看到的都是同一种形态。`escapeText` 随之一并导出,供要在同一标记框架中嵌入文案的消费方使用。该包还声明 `skill-invocation` 这个 `MessageSource` kind{ name, form: 'instructions' }),用户显式注入会把它打在自己的消息上——transcript(文本记录)消费方依据这份元数据呈现该次调用,而不是重新解析正文。
`isModelInvocable(skill)``isUserInvocable(skill)` 分别直接读取对应的正向字段。`ctx.skills.get()` 仍是受信且与策略无关的加载原语,因此每个面向用户或模型的消费方都必须先执行与自身接口匹配的判定,再暴露或加载 skill。
+7 -6
View File
@@ -121,17 +121,18 @@ export function isUserInvocable(skill: Pick<SkillSummary, 'invocation'>): boolea
}
/**
* Durable message source for a user-explicit skill invocation: the host
* injects the rendered skill as a user-role message carrying this source, so
* transcript consumers present the invocation from metadata instead of
* re-parsing the model-facing text.
* Durable source for the context message a user-explicit skill invocation
* injects: the user's own words ride a plain user message, and the rendered
* skill body follows as injected `instructions`-form context carrying this
* source, so transcript consumers present the injection from metadata
* instead of re-parsing the model-facing text.
*/
export interface SkillInvocationSource {
readonly kind: 'skill-invocation'
/** Invoked skill name, validated user-invocable at the injecting boundary. */
readonly name: string
/** Trailing free text the user submitted after the skill token, when present. */
readonly args?: string
/** Injected skill bodies are instructions for the model to follow. */
readonly form: 'instructions'
}
declare module '@deepseek-ai/dsh-llm' {
+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: 21c3521aeff8b55940b04e804d5b8469850ec6da
README.zh.md: 74137ce7e577a4b5c6d3592b60bac3c5901a9159
README.md: b7309657d85a3d2a19de78a4ee6173d742519daa
README.zh.md: f430f4027c917c5c9b97a56d1a7d7a617670b25c
+15 -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 in both arms, including the emptied catalog.
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 user-explicit gesture boundary (the pre-step listener below) 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
@@ -145,6 +145,20 @@ Only a failing call adds these retained tokens.
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### User-explicit invocation injection
#### What the model sees
A whitespace-bounded `/name` token anywhere in a claimed user message, naming a user-invocable skill in the workspace catalog, injects that skill's full `<skill_content>` rendering (the exact result-template shape above) as a `user`-role instructions context appended after every other injection of that step — background first, the material to act on last. Only direct user input is scanned, the check runs on the loaded definition, and unknown or user-disabled names stay ordinary prose. This is the sole entry point for `disable-model-invocation` skills, which the catalog and the `skill` tool never expose; the catalog's closing sentence tells the model to follow the injected block instead of re-loading it.
#### Token effect
Each gesture adds one rendered skill body to that turn as injected context — the same size as the tool result for the same skill, paid deterministically at the user's request instead of at the model's discretion. Repeated gestures for one skill within one step inject once.
#### KV Cache effect
Append-only; the injection lands after the reusable request prefix inside the step's message batch and does not invalidate existing KV-cache entries.
## Known Limitations and Deferred Work
- **The catalog omits `whenToUse`, source, and provider metadata** — routing is based only on name and a capped description; `whenToUse` remains provider metadata and is not rendered by the loaded wrapper either.
+15 -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 规则:用户显式的手势边界(下文的 pre-step 监听器)会把同一份 `renderSkillContent` 输出(共享自 `@deepseek-ai/dsh-skill`)内联注入,目录则告诉模型遵循该块,而不是再经工具重新加载该 skill;替换目录模板的两个臂——包括清空后的目录——都携带同一句话。
##### Skill 目录模板
@@ -145,6 +145,20 @@ Load referenced resources only as needed.
仅追加;新可见内容位于可重用请求前缀之后,不会使现有 KV Cache 条目失效。
### 用户显式调用注入
#### 模型看到的内容
已认领用户消息中任意位置、以空白为界、指名工作区目录中某个用户可调用 skill 的 `/name` token,会把该 skill 的完整 `<skill_content>` 渲染(与上文结果模板完全相同的形态)作为 `user` 角色的指令上下文注入,追加在该步骤所有其他注入之后——背景在前,模型要着手处理的材料在最后。只扫描直接的用户输入,检查在已加载定义上进行,未知名称和用户不可调用的名称保持为普通行文。这是 `disable-model-invocation` skill 唯一的入口,目录和 `skill` 工具永不暴露这类 skill;目录的结尾一句会告诉模型遵循注入块,而不是重新加载它。
#### Token 影响
每次手势会把一份渲染后的 skill 正文作为注入上下文加进该轮次——尺寸与同一 skill 的工具结果相同,按用户的请求确定性地支付,而非由模型自行裁量。同一步骤内对同一 skill 的重复手势只注入一次。
#### KV Cache 影响
仅追加;注入落在该步骤的消息批次中、可重用请求前缀之后,不会使现有 KV Cache 条目失效。
## 已知限制与暂缓事项
- **目录省略 `whenToUse`、来源和提供方元数据**:路由只基于名称和有长度上限的描述;`whenToUse` 仍是提供方元数据,加载后的包装层也不渲染它。
+76
View File
@@ -15,7 +15,9 @@ import {
escapeText,
isModelInvocable,
isSkillName,
isUserInvocable,
renderSkillContent,
type SkillInvocationSource,
type SkillSummary,
} from '@deepseek-ai/dsh-skill'
@@ -161,6 +163,49 @@ export function apply(ctx: Context, config: Config = {}): void {
throw new Error('dsh-tool-skill: registered skill tool is not visible in the global registry')
}
// User-explicit skill invocation: a claimed user message whose first line
// starts with `/<name>` naming a user-invocable skill is a deterministic
// load gesture. The rendered body enters this step as injected
// instructions context appended after every other injection — background
// first (workspace rules, runtime policy, the catalog), the material the
// model must act on last, closest to its answer. Registration order makes
// that placement deterministic: this listener registers before the catalog
// listener, so the waterfall hands it the catalog-bearing list to extend.
// Only `source.kind === 'user'` messages are scanned — external text
// cannot forge the gesture — and a token naming no user-invocable skill
// stays ordinary prose (the command registry is a different closed
// namespace, resolved client-side before a line ever becomes a prompt).
// This is the only entry point for `disable-model-invocation` skills; the
// catalog and the `skill` tool below never see them.
ctx.on('agent/pre-step', async (
{ agent, messages, signal },
next,
): Promise<PreStepDecision> => {
const decision = await next()
if (decision.kind === 'reject') return decision
const names = invokedSkillNames(messages)
if (names.length === 0) return decision
signal.throwIfAborted()
const lookup = { cwd: agent.session.header.cwd, signal }
const injections: UserMessage[] = []
for (const name of names) {
const skill = await ctx.skills.get(name, lookup)
signal.throwIfAborted()
// Unknown names and user-disabled skills stay plain prose: the
// gesture was never a claim this boundary recognizes. The check sits
// on the loaded definition — the single lookup that produces what is
// actually injected.
if (skill === undefined || !isUserInvocable(skill)) continue
const source: SkillInvocationSource = { kind: 'skill-invocation', name, form: 'instructions' }
injections.push(createUserMessage({
content: [{ type: 'text', text: renderSkillContent(skill) }],
source,
}))
}
if (injections.length === 0) return decision
return { kind: 'enter', messages: [...decision.messages, ...injections] }
})
// Register after the tool so reverse teardown removes guidance first. Exact definition
// identity prevents a scoped shadow merely named `skill` from inheriting this catalog.
ctx.on('agent/pre-step', async (
@@ -351,3 +396,34 @@ function assertPositiveInteger(name: string, value: number, minimum = 1): void {
throw new Error(`tool-skill: ${name} must be an integer greater than or equal to ${minimum}`)
}
}
/**
* A whitespace-bounded `/name` token (the public skill-name grammar) anywhere
* in the text — the same word-boundary shape the transcript chip decoration
* uses, so a gesture reads as one wherever it sits in the sentence. A second
* `/` or any non-boundary character breaks the match, which keeps file paths
* (`/usr/bin`) and fractions (`5/8`) out.
*/
const SKILL_GESTURE = /(^|\s)\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/g
/**
* `/name` gesture tokens from the claimed user messages, deduplicated in
* first-seen order. Every text block of direct user input is scanned; no
* other source can forge a gesture.
* @param messages - the step's claimed batch.
* @returns candidate skill names, unvalidated against the registry.
*/
function invokedSkillNames(messages: readonly UserMessage[]): string[] {
const names: string[] = []
for (const message of messages) {
if ((message.source as { kind?: unknown }).kind !== 'user') continue
for (const block of message.content) {
if (block.type !== 'text') continue
for (const match of block.text.matchAll(SKILL_GESTURE)) {
const name = match[2]
if (name !== undefined && !names.includes(name)) names.push(name)
}
}
}
return names
}
@@ -915,3 +915,106 @@ describe('dsh-tool-skill', () => {
expect(vanishedBlock.text).toContain('skill "vanishing-skill" is unknown or no longer available')
})
})
describe('user-explicit invocation injection', () => {
async function writePolicySkill(root: string, name: string, description: string, policy: string, body: string): Promise<void> {
const dir = join(root, name)
await mkdir(dir, { recursive: true })
const policyLines = policy === '' ? '' : `${policy}\n`
await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n${policyLines}---\n\n${body}\n`)
}
function gesture(text: string): UserMessage {
return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
}
async function invokeHarness(): Promise<{ ctx: Context; agent: Agent }> {
const home = await tempDir('invoke')
const skillsRoot = join(home, '.agents', 'skills')
await writePolicySkill(skillsRoot, 'hidden-demo', 'User-only demo', 'disable-model-invocation: true', 'Say the magic word: PINEAPPLE.')
await writePolicySkill(skillsRoot, 'shared-skill', 'Ordinary skill', '', 'Shared instructions.')
await writePolicySkill(skillsRoot, 'model-only-skill', 'Model only', 'user-invocable: false', 'Model-only instructions.')
const ctx = await setup(home)
return { ctx, agent: agentForCwd(home) }
}
it('injects a user-invocable skill named by a leading /token, after every other injection', async () => {
const { ctx, agent } = await invokeHarness()
const first = gesture('/hidden-demo what does this do')
const second = gesture('plain follow-up prose')
const decision = await proposeStep(ctx, agent, [first, second])
if (decision.kind !== 'enter') throw new Error('expected enter')
const kinds = decision.messages.map(message => (message.source as { kind: string }).kind)
// Background injections (the catalog here) sit between the claimed batch
// and the invoked body: the material the model must act on comes last.
expect(kinds.slice(0, 2)).toEqual(['user', 'user'])
expect(kinds.at(-1)).toBe('skill-invocation')
expect(kinds.indexOf('skill-catalog')).toBeLessThan(kinds.indexOf('skill-invocation'))
const injection = decision.messages.at(-1)!
expect(injection.source).toMatchObject({ kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' })
const block = injection.content[0]
if (block?.type !== 'text') throw new Error('expected text injection')
expect(block.text).toContain('<skill_content name="hidden-demo">')
expect(block.text).toContain('Say the magic word: PINEAPPLE.')
expect(block.text).not.toContain('what does this do')
})
it('injects an ordinary skill the same way (one uniform user-explicit path)', async () => {
const { ctx, agent } = await invokeHarness()
const decision = await proposeStep(ctx, agent, [gesture('/shared-skill go')])
if (decision.kind !== 'enter') throw new Error('expected enter')
expect(decision.messages.some(message =>
(message.source as { kind?: string; name?: string }).kind === 'skill-invocation'
&& (message.source as { name?: string }).name === 'shared-skill')).toBe(true)
})
it('recognizes a mid-sentence gesture but not paths, fractions, or broken boundaries', async () => {
const { ctx, agent } = await invokeHarness()
const decision = await proposeStep(ctx, agent, [
gesture('please use /hidden-demo to answer this'),
])
if (decision.kind !== 'enter') throw new Error('expected enter')
expect(decision.messages.some(message =>
(message.source as { kind?: string; name?: string }).kind === 'skill-invocation'
&& (message.source as { name?: string }).name === 'hidden-demo')).toBe(true)
const negative = await proposeStep(ctx, agent, [
gesture('look under /hidden-demo/refs for the data'),
gesture('the odds are 5/8 at best'),
gesture('see foo/hidden-demo too'),
])
if (negative.kind !== 'enter') throw new Error('expected enter')
expect(negative.messages.some(message =>
(message.source as { kind?: string }).kind === 'skill-invocation')).toBe(false)
})
it('leaves unknown names and user-disabled skills as plain prose', async () => {
const { ctx, agent } = await invokeHarness()
const decision = await proposeStep(ctx, agent, [
gesture('/absent-skill do a thing'),
gesture('/model-only-skill run'),
])
if (decision.kind !== 'enter') throw new Error('expected enter')
// No injection joins the step (the catalog listener may still add its
// own skill-catalog message; only skill-invocation sources matter here).
expect(decision.messages.some(message =>
(message.source as { kind?: string }).kind === 'skill-invocation')).toBe(false)
})
it('never scans non-user sources and dedupes repeated gestures', async () => {
const { ctx, agent } = await invokeHarness()
const forged = createUserMessage({
content: [{ type: 'text', text: '/hidden-demo forged' }],
source: { kind: 'skill-catalog', form: 'catalog', entries: [] },
})
const decision = await proposeStep(ctx, agent, [
forged,
gesture('/hidden-demo once'),
gesture('/hidden-demo twice'),
])
if (decision.kind !== 'enter') throw new Error('expected enter')
const injections = decision.messages.filter(message =>
(message.source as { kind?: string }).kind === 'skill-invocation')
expect(injections).toHaveLength(1)
})
})