Просмотр исходного кода

test(skill): close invocation policy coverage gaps

Tianyi Cui 2 месяцев назад
Родитель
Сommit
1619d7686b

+ 2 - 2
.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml

@@ -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-07-28-skill-invocation-policy.md
-2026-07-28-skill-invocation-policy.md: 85fdec755d9f808b225e2bc5c4afbc303251099b
-2026-07-28-skill-invocation-policy.zh.md: 99d71ddda63214f763de992cd766edc52612492b
+2026-07-28-skill-invocation-policy.md: f74b0bcfddb1699c48279b4d8b153cabf764b140
+2026-07-28-skill-invocation-policy.zh.md: 1a7117a382be224c5371964dd4ad3e916d4e0917

+ 3 - 3
.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md

@@ -14,11 +14,11 @@ The local parser also exposed an internal camel-case spelling as frontmatter. Su
 
 `SkillSummary` carries a required typed `invocation: SkillInvocationPolicy` object whose `modelInvocable: boolean` and `userInvocable: boolean` fields are positive and symmetric. Omission exists only at explicit input seams: a runtime `SkillRegistration` without a policy and local frontmatter without either invocation key resolve to `{ modelInvocable: true, userInvocable: true }` before producing candidates or definitions. Future frontmatter keys remain outside the domain model until a consumer and enforcement contract exist; the local provider still parses frontmatter as an open `Record<string, unknown>`, then projects only recognized fields and their defaults into the normalized typed policy.
 
-`ctx.skills.list()` returns every winning summary and no longer chooses an invocation surface. `isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field directly. `ctx.skills.get()` remains policy-neutral because trusted internal callers may need any definition, while a public consumer must enforce its own predicate before advertising or loading a skill.
+`ctx.skills.list()` returns every winning summary and no longer chooses an invocation surface. `isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field directly. `ctx.skills.get()` remains policy-neutral because trusted internal callers may need any definition, while a public consumer must enforce its own predicate before advertising or loading a skill. The model tool and TUI check the invocation-neutral summary before calling `get()`, then recheck the loaded definition so a denied name never reaches definition loading and a policy change between discovery and load cannot expose its body.
 
 The local provider accepts the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`. It accepts YAML booleans plus case-insensitive `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`, matching the practical boolean forms accepted by Claude skills. It maps `disable-model-invocation` to the inverse positive field and fills both positive fields from their defaults even when neither key is present. A camel-case external spelling or non-boolean invocation value drops the entire skill from discovery with a targeted warning; this pre-release repository does not keep an on-disk compatibility alias. Invocation data fails closed because ignoring it would default to permission and could expose the skill on a disabled surface, while wrong-typed optional `whenToUse` and `metadata` values are omitted because they do not decide invocation.
 
-The model-facing `dsh-tool-skill` catalog and loader enforce `isModelInvocable`. The TUI `/skill:` autocomplete and exact loader enforce the user field locally, so a user-only skill is visible and loadable there even when it is absent from model discovery, without turning the optional skill peer into a runtime import. The browser `skill.list` RPC serves a user-selected reference that still asks the model to load the skill, so it exposes the intersection of model- and user-invocable skills; no direct browser skill-loading RPC is added.
+The model-facing `dsh-tool-skill` catalog and loader enforce `isModelInvocable`. The TUI `/skill:` autocomplete and exact loader enforce the user field locally, so a user-only skill is visible and loadable there even when it is absent from model discovery, without turning the optional skill peer into a runtime import. The launcher-seeded initial skill used by guided `dsh migrate` and `dsh upgrade` sessions follows this same TUI path and must remain user-invocable. The browser `skill.list` RPC serves a user-selected reference that still asks the model to load the skill, so it exposes the intersection of model- and user-invocable skills; no direct browser skill-loading RPC is added.
 
 These rules permit all four combinations:
 
@@ -47,4 +47,4 @@ This decision extends the [skill system](2026-07-05-skill-system.md) and superse
 
 Providers and runtime registrations expose a small typed invocation contract, while local YAML remains extensible. Every new discovery consumer must consciously choose the model predicate, the user predicate, their intersection, or trusted unfiltered access; forgetting that choice is now review-visible rather than hidden in registry behavior.
 
-The changed model catalog is pinned by the keyless ACP snapshot, which includes a model-only skill and excludes a user-only skill. TUI unit coverage exercises all four policy quadrants, and the real Loader/PTY smoke invokes a user-only local skill through `/skill:`. Registry, local-parser, model-tool, and API-proxy tests cover defaults, supported boolean forms, malformed values, legacy-key rejection, exact-load enforcement, and the browser intersection.
+The changed model catalog is pinned by the keyless ACP snapshot, which includes a model-only skill and excludes a user-only skill. The assembled keyless TUI snapshot discovers and loads a user-only skill by exact name, then rejects a model-only skill before loading its body; the real Loader/PTY smoke proves the same user-only path through the shipped terminal process. The real-host Chromium snapshot pins the browser intersection across all four policy quadrants. TUI unit coverage exercises those quadrants plus disposal races, while registry, local-parser, model-tool, and API-proxy tests cover defaults, supported boolean forms, malformed values, legacy-key rejection, exact-load enforcement, and the browser intersection.

+ 3 - 3
.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md

@@ -14,11 +14,11 @@ skill 注册表最初将发现操作视为模型目录:`ctx.skills.list()` 会
 
 `SkillSummary` 包含一个必填且类型明确的 `invocation: SkillInvocationPolicy` 对象,其 `modelInvocable: boolean` 和 `userInvocable: boolean` 字段为正向且对称。只有显式输入 seam 可以省略它:未提供策略的运行时 `SkillRegistration`,以及两个调用键均未提供的本地 frontmatter,都会在生成候选项或定义前解析为 `{ modelInvocable: true, userInvocable: true }`。未来的 frontmatter 键只有在具备消费方和执行契约后,才会进入领域模型;本地提供方仍将 frontmatter 解析为开放的 `Record<string, unknown>`,然后只把已识别字段及其默认值投影到规范化的类型化策略中。
 
-`ctx.skills.list()` 返回所有胜出的摘要,不再替任何调用接口选择策略。`isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别直接读取对应的正向字段。`ctx.skills.get()` 保持策略无关,因为可信内部调用方可能需要任意定义;对外消费方则必须在展示或加载 skill 之前执行自身对应的判定函数。
+`ctx.skills.list()` 返回所有胜出的摘要,不再替任何调用接口选择策略。`isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别直接读取对应的正向字段。`ctx.skills.get()` 保持策略无关,因为可信内部调用方可能需要任意定义;对外消费方则必须在展示或加载 skill 之前执行自身对应的判定函数。模型工具和 TUI 会在调用 `get()` 前检查与调用策略无关的摘要,随后再次检查已加载的定义:被拒绝的名称绝不会进入定义加载流程,发现与加载之间发生策略变更也无法暴露该 skill 的正文。
 
 本地提供方只接受拼写完全一致的 kebab-case frontmatter 键 `disable-model-invocation` 和 `user-invocable`。它接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`,与 Claude skills 实际支持的布尔写法一致。它将 `disable-model-invocation` 映射为相反的正向字段,即使两个键都不存在,也会根据默认值填充两个正向字段。若使用外部驼峰式拼写或提供非布尔调用值,发现流程会丢弃整个 skill,并给出有针对性的警告;本仓库尚处于发布前阶段,因此不为磁盘格式保留兼容别名。调用数据校验遵循失败时默认拒绝原则,因为忽略这类数据会默认授予权限,可能使 skill 暴露在已禁用的接口上;与之不同,类型错误的可选 `whenToUse` 和 `metadata` 值会被省略,因为它们不参与调用判定。
 
-面向模型的 `dsh-tool-skill` 目录和 loader 执行 `isModelInvocable`。TUI 的 `/skill:` 自动补全与精确名称 loader 在本地执行用户字段,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载,同时不会将可选的 skill peer 变成运行时导入。浏览器的 `skill.list` RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill;本次改动不新增让浏览器直接加载 skill 的 RPC。
+面向模型的 `dsh-tool-skill` 目录和 loader 执行 `isModelInvocable`。TUI 的 `/skill:` 自动补全与精确名称 loader 在本地执行用户字段,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载,同时不会将可选的 skill peer 变成运行时导入。由 launcher 预置、供引导式 `dsh migrate` 和 `dsh upgrade` 会话使用的初始 skill 沿用同一条 TUI 路径,因此必须保持允许用户调用。浏览器的 `skill.list` RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill;本次改动不新增让浏览器直接加载 skill 的 RPC。
 
 这些规则允许以下四种组合:
 
@@ -47,4 +47,4 @@ skill 注册表最初将发现操作视为模型目录:`ctx.skills.list()` 会
 
 提供方与运行时注册对外提供小而类型明确的调用契约,同时本地 YAML 仍可扩展。每个新的发现消费方都必须明确选择模型判定函数、用户判定函数、两者的交集,或可信且不过滤的访问方式;如果遗漏这项选择,评审时可以直接看出问题,而不会再被注册表行为掩盖。
 
-无密钥 ACP(Agent Client Protocol)快照固定了模型目录的变更:其中包含仅允许模型调用的 skill,并排除仅允许用户调用的 skill。TUI 单元测试覆盖全部四种策略组合,真实 Loader/PTY 冒烟测试则通过 `/skill:` 调用仅允许用户调用的本地 skill。注册表、本地解析器、模型工具和 API 代理测试覆盖默认值、支持的布尔写法、格式错误的值、旧键拒绝、精确名称加载时的策略执行,以及浏览器侧的策略交集。
+无密钥 ACP(Agent Client Protocol)快照固定了模型目录的变更:其中包含仅允许模型调用的 skill,并排除仅允许用户调用的 skill。组装后的无密钥 TUI 快照按精确名称发现并加载一个仅允许用户调用的 skill,随后在加载正文前拒绝一个仅允许模型调用的 skill;真实 Loader/PTY 冒烟测试通过随产品交付的终端进程证明了同一条仅允许用户调用的路径。真实宿主上的 Chromium 快照固定了浏览器在全部四种策略组合下的交集行为。TUI 单元测试覆盖这些组合以及资源释放竞态;注册表、本地解析器、模型工具和 API 代理测试则覆盖默认值、支持的布尔写法、格式错误的值、旧键拒绝、精确名称加载时的策略执行,以及浏览器侧的策略交集。

+ 4 - 0
examples/tui-agent/tests/snapshots/skill-invocation-policy/session.jsonl

@@ -1 +1,5 @@
 {"type":"session","version":0,"id":"31f63cc0-0198-4ab2-bfde-79a4eb4f1867","createdAt":1783352180000,"cwd":"{{cwd}}","delegationDepth":0}
+{"type":"assistant/chunk","seq":0,"time":1783352180001,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
+{"type":"assistant/chunk","seq":1,"time":1783352180002,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"USER-ONLY SKILL LOADED"}}}
+{"type":"assistant/chunk","seq":2,"time":1783352180003,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"USER-ONLY SKILL LOADED"}}}}
+{"type":"assistant/chunk","seq":3,"time":1783352180004,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}

+ 110 - 8
examples/tui-agent/tests/snapshots/skill-invocation-policy/terminal.expected.txt

@@ -26,30 +26,132 @@ buffer
 7-35| <blank>
 
 
+=== loaded exact invocation ===
+terminal 100x36 buffer=normal length=36 base=0 viewport=0
+lifecycle started=1 stopped=0 progress=inactive
+title "<skill name=\"user-only-skill\"> Reference — DSH TUI snapshot"
+cursor hidden column=7 viewportRow=30 bufferRow=30
+buffer
+0| " DEEPSEEK HARNESS"
+  style 1-8 fg=bright-magenta bold
+  style 10-16 bold
+1| " <skill name=\"user-only-skill\"> Reference"
+  style 1-40 dim
+2| " main-session"
+  style 1-12 dim
+3| <blank>
+4| "You                                                                                                 "
+  style 0-2 fg=bright-magenta bold underline
+5| "<skill name=\"user-only-skill\">                                                                      "
+6| "References in this skill are relative to                                                            "
+7| "/workspace/project/.agents/skills/user-only-skill.                "
+8| "                                                                                                    "
+9| "USER-ONLY BODY                                                                                      "
+10| "</skill>                                                                                            "
+11| <blank>
+12| "Context · dsh-tool-skill"
+  style 0-23 dim
+13| "A skill is a reusable set of task-specific instructions. The following skills are available in this "
+  style 0-99 dim
+14| "session:                                                                                            "
+  style 0-7 dim
+15| "                                                                                                    "
+16| "<available_skills>                                                                                  "
+  style 0-17 dim
+17| "- `model-only-skill`: Model-only assembled snapshot skill.                                          "
+  style 0-57 dim
+18| "</available_skills>                                                                                 "
+  style 0-18 dim
+19| "                                                                                                    "
+20| "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool "
+  style 0-99 dim
+21| "with the exact skill name before taking task actions. Load all applicable skills, then follow their "
+  style 0-99 dim
+22| "full instructions. This catalog contains summaries only; do not infer or follow a skill's           "
+  style 0-99 dim
+23| "instructions until it has been loaded.                                                              "
+  style 0-37 dim
+24| <blank>
+25| "Assistant                                                                                           "
+  style 0-8 fg=bright-magenta bold underline
+26| "USER-ONLY SKILL LOADED                                                                              "
+27| "Model wait 0.0s · Completed 2026-07-21 12:00:00                                                     "
+  style 0-46 dim
+28| <blank>
+29| "/workspace/project  deepseek-v4-flash  ↑0 ↓0  3% context"
+  style 0-51 fg=bright-magenta bold
+  style 54-70 dim
+  style 73-77 dim
+  style 80-89 dim
+30| " dsh ◍                                                                                              "
+  style 1-3 fg=bright-magenta bold
+  style 5-6 dim
+  style 7-7 inverse
+31-35| <blank>
+
+
 === denied exact invocation ===
 terminal 100x36 buffer=normal length=36 base=0 viewport=0
 lifecycle started=1 stopped=0 progress=inactive
-title "DSH TUI snapshot"
-cursor hidden column=7 viewportRow=7 bufferRow=7
+title "<skill name=\"user-only-skill\"> Reference — DSH TUI snapshot"
+cursor hidden column=7 viewportRow=32 bufferRow=32
 buffer
 0| " DEEPSEEK HARNESS"
   style 1-8 fg=bright-magenta bold
   style 10-16 bold
-1| " Recorded replay: skill-invocation-policy"
+1| " <skill name=\"user-only-skill\"> Reference"
   style 1-40 dim
 2| " main-session"
   style 1-12 dim
 3| <blank>
-4| "Skill \"model-only-skill\" is not available for user invocation.                                      "
+4| "You                                                                                                 "
+  style 0-2 fg=bright-magenta bold underline
+5| "<skill name=\"user-only-skill\">                                                                      "
+6| "References in this skill are relative to                                                            "
+7| "/workspace/project/.agents/skills/user-only-skill.                "
+8| "                                                                                                    "
+9| "USER-ONLY BODY                                                                                      "
+10| "</skill>                                                                                            "
+11| <blank>
+12| "Context · dsh-tool-skill"
+  style 0-23 dim
+13| "A skill is a reusable set of task-specific instructions. The following skills are available in this "
+  style 0-99 dim
+14| "session:                                                                                            "
+  style 0-7 dim
+15| "                                                                                                    "
+16| "<available_skills>                                                                                  "
+  style 0-17 dim
+17| "- `model-only-skill`: Model-only assembled snapshot skill.                                          "
+  style 0-57 dim
+18| "</available_skills>                                                                                 "
+  style 0-18 dim
+19| "                                                                                                    "
+20| "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool "
+  style 0-99 dim
+21| "with the exact skill name before taking task actions. Load all applicable skills, then follow their "
+  style 0-99 dim
+22| "full instructions. This catalog contains summaries only; do not infer or follow a skill's           "
+  style 0-99 dim
+23| "instructions until it has been loaded.                                                              "
+  style 0-37 dim
+24| <blank>
+25| "Assistant                                                                                           "
+  style 0-8 fg=bright-magenta bold underline
+26| "USER-ONLY SKILL LOADED                                                                              "
+27| "Model wait 0.0s · Completed 2026-07-21 12:00:00                                                     "
+  style 0-46 dim
+28| <blank>
+29| "Skill \"model-only-skill\" is not available for user invocation.                                      "
   style 0-61 fg=yellow
-5| <blank>
-6| "/workspace/project  deepseek-v4-flash  ↑0 ↓0  0% context"
+30| <blank>
+31| "/workspace/project  deepseek-v4-flash  ↑0 ↓0  3% context"
   style 0-51 fg=bright-magenta bold
   style 54-70 dim
   style 73-77 dim
   style 80-89 dim
-7| " dsh >                                                                                              "
+32| " dsh ◍                                                                                              "
   style 1-3 fg=bright-magenta bold
   style 5-6 dim
   style 7-7 inverse
-8-35| <blank>
+33-35| <blank>

+ 0 - 1
examples/tui-agent/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/model-only-skill/SKILL.md

@@ -1,7 +1,6 @@
 ---
 name: model-only-skill
 description: Model-only assembled snapshot skill.
-disable-model-invocation: false
 user-invocable: false
 ---
 

+ 0 - 1
examples/tui-agent/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/user-only-skill/SKILL.md

@@ -2,7 +2,6 @@
 name: user-only-skill
 description: User-only assembled snapshot skill.
 disable-model-invocation: true
-user-invocable: true
 ---
 
 USER-ONLY BODY

+ 5 - 5
examples/tui-agent/tests/tui-keyless-smoke.e2e.ts

@@ -217,11 +217,12 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 
   it('loads a local skill via /skill: and delivers its body to the model as a user turn', async () => {
-    // The whole manual-invocation path in one keyless boot: `ctx.get('skills')`
+    // The whole user-only invocation path in one keyless boot: `ctx.get('skills')`
     // resolves in the shipped tree, the client-side `/skill:` command parses,
-    // the local provider loads `scripted-skill` from the agents home, and the
-    // rendered `<skill name="…">` block reaches the model — proven by the
-    // scripted adapter echoing the fixture's body marker only when it arrives.
+    // and the local provider admits a model-disabled skill by the omitted
+    // `user-invocable` default. The rendered `<skill name="…">` block reaches
+    // the model — proven by the scripted adapter echoing the fixture's body
+    // marker only when it arrives.
     const output = await smoke({
       label: 'tui-agent skill',
       tempDirPrefix: 'tui-agent-skill-',
@@ -233,7 +234,6 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
             'name: scripted-skill',
             'description: Keyless PTY proof that the skill command loads a local skill into the conversation.',
             'disable-model-invocation: true',
-            'user-invocable: true',
             '---',
             '',
             'SCRIPTED SKILL BODY MARKER',

+ 33 - 13
examples/tui-agent/tests/tui.snapshot.ts

@@ -41,6 +41,7 @@ const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
 
 type SnapshotMode = 'replay' | 'record' | 'refresh'
 type Composition = 'native' | 'code' | 'advanced'
+type ScenarioInteraction = 'skill-invocation-policy'
 
 interface Scenario {
   name: string
@@ -65,8 +66,8 @@ interface Scenario {
    * preview + locator while the program value stays whole.
    */
   spillMaxInlineBytes?: number
-  /** Exercise user-facing skill discovery and exact policy denial without opening a model turn. */
-  skillInvocationPolicy?: boolean
+  /** Run scenario-specific terminal input instead of replaying recorded user prompts. */
+  interaction?: ScenarioInteraction
 }
 
 const SCENARIOS: Scenario[] = [
@@ -106,7 +107,7 @@ const SCENARIOS: Scenario[] = [
     expectedTools: [],
     recorded: false,
     seedWorkspace: true,
-    skillInvocationPolicy: true,
+    interaction: 'skill-invocation-policy',
   },
   {
     name: 'code-mode',
@@ -280,7 +281,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
   const fixtureFile = join(dir, 'session.jsonl')
   const childFiles = childFixturePaths(scenario)
   const prompts = userPrompts(await readFile(fixtureFile, 'utf8'))
-  if (scenario.skillInvocationPolicy !== true) {
+  if (scenario.interaction === undefined) {
     expect(prompts.length, `${scenario.name} must carry at least one recorded user prompt`).toBeGreaterThan(0)
   }
 
@@ -321,8 +322,8 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
     })
     await settleTerminal(terminal)
 
-    let skillPolicySnapshot: string | undefined
-    if (scenario.skillInvocationPolicy === true) {
+    let interactionSnapshot: string | undefined
+    if (scenario.interaction === 'skill-invocation-policy') {
       terminal.send('/skill')
       await settleTerminal(terminal)
       const discovery = normalizeTerminalSnapshot(
@@ -335,6 +336,25 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
 
       terminal.send('\x03')
       await settleTerminal(terminal)
+      const skillTurnEnded = new Promise<void>((resolve) => {
+        const detach = ctx.on('session/event', (session, event) => {
+          if (session !== agent.session || event.type !== 'turn/end') return
+          detach()
+          resolve()
+        })
+      })
+      terminal.send('/skill:user-only-skill')
+      terminal.send('\r')
+      await skillTurnEnded
+      await agent.whenIdle()
+      await settleTerminal(terminal)
+      const loaded = normalizeTerminalSnapshot(
+        await terminal.snapshot({ includeScrollback: true }),
+        cwd,
+        displayCwd,
+      )
+      expect(loaded).toContain('USER-ONLY SKILL LOADED')
+
       terminal.send('/skill:model-only-skill')
       terminal.send('\r')
       await settleTerminal(terminal)
@@ -346,10 +366,13 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
       expect(denied).toContain('model-only-skill')
       expect(denied).toContain('not available for user invocation.')
       expect(denied).not.toContain('MODEL-ONLY BODY MUST NOT LOAD')
-      skillPolicySnapshot = [
+      interactionSnapshot = [
         '=== skill autocomplete ===',
         discovery,
         '',
+        '=== loaded exact invocation ===',
+        loaded,
+        '',
         '=== denied exact invocation ===',
         denied,
       ].join('\n')
@@ -380,10 +403,8 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
 
     const events: SessionEvent[] = [...agent.session.events]
     const firstHeader = events.find(event => event.type === 'request/header')
-    if (scenario.skillInvocationPolicy !== true) {
-      expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system)
-        .toContain(FILE_REFERENCE_PROMPT)
-    }
+    expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system)
+      .toContain(FILE_REFERENCE_PROMPT)
     expect(events.filter(event => event.type === 'tool/call').map(event => event.data.name)).toEqual(scenario.expectedTools)
     for (const [type, count] of Object.entries(scenario.expectedEventCounts ?? {})) {
       expect(events.filter(event => event.type === type), `${scenario.name} must emit ${type}`).toHaveLength(count)
@@ -439,12 +460,11 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
     }
 
     expect(terminal.themeViolations(), `${scenario.name} must remain theme-agnostic`).toEqual([])
-    const terminalSnapshot = normalizeTerminalSnapshot(
+    const snapshot = interactionSnapshot ?? normalizeTerminalSnapshot(
       await terminal.snapshot({ includeScrollback: true }),
       cwd,
       displayCwd,
     )
-    const snapshot = skillPolicySnapshot ?? terminalSnapshot
     await handle.dispose()
     const children = disposedSessions
       .filter(session => session !== agent.session)

+ 8 - 2
packages/ui/tui/tests/tui.spec.ts

@@ -4013,9 +4013,10 @@ describe('skill slash command', () => {
           list: () => {
             listCalls += 1
             if (listCalls === 1) return Promise.resolve<SkillSummary[]>([])
-            if (listCalls === 2) {
+            if (listCalls === 2 || listCalls === 3) {
+              const name = listCalls === 2 ? 'demo-skill' : 'error-skill'
               return Promise.resolve<SkillSummary[]>([{
-                name: 'demo-skill',
+                name,
                 description: 'demo',
                 invocation: { modelInvocable: true, userInvocable: true },
                 source: 'runtime',
@@ -4032,6 +4033,9 @@ describe('skill slash command', () => {
     result.terminal.send('/skill:demo-skill')
     result.terminal.send('\r')
     await tick()
+    result.terminal.send('/skill:error-skill')
+    result.terminal.send('\r')
+    await tick()
     result.terminal.send('/skill:other-skill')
     result.terminal.send('\r')
     await tick()
@@ -4052,9 +4056,11 @@ describe('skill slash command', () => {
       provider: 'runtime',
       content: 'late body',
     })
+    pendingGet[1]?.reject(new Error('late failure'))
     await tick()
     expect(result.agent.sent).toEqual([])
     expect(result.terminal.output).not.toContain('late body')
+    expect(result.terminal.output).not.toContain('late failure')
   })
 })