diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml index 4c36032f35..3774ba6e69 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.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-08-08-user-explicit-skill-invocation.md -2026-08-08-user-explicit-skill-invocation.md: abe6a05283359b81ff1c3cab754d0230e599e4a0 -2026-08-08-user-explicit-skill-invocation.zh.md: e72e49236ffd2c6f664e01abbd69665eec8328e9 +2026-08-08-user-explicit-skill-invocation.md: d925938279923282170dc99934f4fa44d8ecf2b4 +2026-08-08-user-explicit-skill-invocation.zh.md: 64e23be0b42519fb9681adefcd0f05074d3aa35e diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md index abe6a05283..d925938279 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md @@ -1,4 +1,4 @@ -# Agent Note: User-explicit skill invocation over skill.invoke +# Agent Note: User-explicit skill invocation at the pre-step gesture boundary Status: implemented @@ -10,28 +10,27 @@ A `disable-model-invocation: true` skill is user-only by design: it never enters ## Decision -User-explicit invocation is a deterministic host-side injection, uniform for every user-invocable skill: +User-explicit invocation is a host-side pre-step injection, uniform for every user-invocable skill and every front end: -- `skill.invoke { sessionId, name, text? }` (host apiproxy) enforces user-invocation policy at the operation boundary (`skill-not-found` / `skill-not-invocable`), renders the skill with the shared `renderSkillContent`, appends the optional trailing text after a blank line, and injects the whole as one user-role message carrying the new `skill-invocation` `MessageSource` kind (`{ name, args? }`) before starting a turn through the same route-served gate as `session.prompt`. -- `renderSkillContent` moved from `dsh-tool-skill` to the `dsh-skill` seam: the `skill` tool result and the injection share one verbatim `` shape, and the catalog text gained the seam rule — an inline-injected skill must be followed, not re-loaded through the tool. -- `skill.list` serves every user-invocable skill and carries `modelInvocable`, so the browser menu lists user-only skills with a marker (description prefix — the `hint` field is claim-state ghost text the menu never renders). -- ui-skill claims a menu pick or an entered `/name [args]` into the invoke transaction (`matchEnter` strong-waits the catalog; unknown names stay plain prompts). The unreached legacy `name` reference codec is removed. -- The transcript materializes the injection as a dedicated `skill-invocation` node from source metadata (never re-parsed from the body) and renders a right-aligned bubble: `/name` chip, trailing text, and the injected block collapsed behind a disclosure. +- `dsh-tool-skill` registers a second `agent/pre-step` listener (beside its catalog listener, the same seam `workspace-instructions` and the runtime-context snapshot ride): it scans the step's claimed messages for whitespace-bounded `/name` tokens — anywhere in the text, the same word-boundary shape the transcript chip decoration uses — collects first-seen-deduplicated names, loads each through `ctx.skills.get`, checks `isUserInvocable` on the loaded definition (the single lookup that produces what is injected), renders it with the shared `renderSkillContent`, and appends the injections after every other injection of the step: background first (workspace rules, runtime policy, catalog), the material the model must act on last, closest to its answer. Registration order pins the placement — the gesture listener registers before the catalog listener, so the waterfall hands it the catalog-bearing list to extend. +- Precision is closed-set matching, exactly like slash commands: `/goal` resolves against the command registry, `/name` against the workspace's user-invocable skill directory; a miss stays ordinary prose, so nothing is ever guessed. Only `source.kind === 'user'` messages are scanned — external text cannot forge a gesture. Paths (`/usr/bin`), fractions (`5/8`), and prefixed tokens (`foo/name`) all break the boundary. +- The client stays decision 21: a menu pick lands the literal `/name ` and the prompt ships it verbatim; ui-skill implements no adjudication hooks and no reference codec. `skill.list` (now the domain's only RPC) serves every user-invocable skill with `modelInvocable` so menus mark user-only entries. A name shared with a host command resolves to the command — adjudication claims the line client-side before it becomes a prompt. +- The injection is a `user`-role message carrying the `skill-invocation` source (`{ name, form: 'instructions' }`), so `user/message` logging, the context-injection transcript row (labelled with the skill name), and replay all come free; `renderSkillContent` lives in the `dsh-skill` seam, shared verbatim with the `skill` tool result, and the catalog's closing sentence tells the model to follow an injected block instead of re-loading it. -Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reasonix — local checkouts) was unanimous: user-explicit triggering is programmatic injection as a user-role message with zero model participation on every product, prompt-guided tool loading exists only on the model-autonomous track, and the disable-model-invocation equivalents gate only the model-side surfaces. Kimi's origin-metadata rendering and the Claude Code/Kimi no-reload prompt rule translate directly onto `MessageSource` and the catalog sentence. +Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reasonix — local checkouts) was unanimous that user-explicit triggering is programmatic injection with zero model participation; the final shape is closest to Codex's core-side `$name` mention scanning, which likewise frees every front end from implementing recognition. ## Alternatives considered -- **`agent.inject()` context injection** — no peer precedent; the gesture is a user turn, not an environment notice, and context-row presentation, compaction, and attribution all mismatch. Rejected. +- **`skill.invoke` RPC (host injects, client claims)** — implemented first, in two iterations: a single mixed message (user text folded into the body), then a gesture prompt plus injection delivered through inbox primitives. Rejected after real-session testing: the mixed message polluted the injection with user prose; the two-message form depended on wake-ordering subtleties (`followup` claims the whole next-turn queue synchronously inside the first waking call, stranding any later message in the next turn — reproduced live), and the dedicated RPC duplicated a path `session.prompt` already provides while leaving TUI/ACP to reimplement recognition. The pre-step seam removes the RPC, the claim machinery, and the ordering hazard outright. +- **`agent.inject()` from the RPC handler** — the inject queue (`next-step`, wake-free) is claimed ahead of the next-turn prompt, putting the injection above the gesture in the log; and pairing it with a waking `followup` reintroduces the same ordering coupling. The pre-step listener injects inside the step assembly, where ordering is explicit. - **A host `/skill ` command** (command registry, plan-mode precedent) — two-token UX, no name completion, and user-only skills stay undiscoverable in the menu; the per-cwd skill catalog also fits the static command registry poorly. Rejected. - **Client-side expansion** (fetch body, splice into the prompt) — authorization becomes bypassable client courtesy, the log loses the invocation semantics, and Codex deleted its equivalent mechanism (custom prompts) in favor of core injection. Rejected. -- **Host prompt-pipeline scanning for `/name`** (Codex `$name` core mentions) — duplicates the adjudication layer and risks swallowing literal slashes in prose; the claim path already covers the need. Rejected. -- **Per-injection preamble line** (Kimi's `User activated the skill …`) — dropped in favor of a one-time catalog sentence: same context, paid once, and the injected block stays byte-identical with the tool result. +- **Structured reference payload on the prompt wire** (Codex's `UserInput::Skill` analogue: the client ships `{skills: [...]}` beside the text and the boundary prefers it over scanning) — considered and deferred: the existing slash-command system is itself line-text on the wire, and closed-set directory matching already removes the guesswork; recorded as a ledger item should gesture precision ever need client intent. +- **Per-injection preamble line** (Kimi's `User activated the skill …`) — dropped in favor of the one-time catalog sentence: same context, paid once, and the injected block stays byte-identical with the tool result. ## Consequences -- Decision 21's plain-text reference path is superseded at submission: the draft still carries plain text and lexicon-derived chip visuals, but submit claims into a deterministic injection instead of shipping the literal and hoping. The model-autonomous track (catalog + `skill` tool) is unchanged. -- Every user-invocable skill invocation now costs its full rendered body unconditionally — the price of determinism the peer survey showed everyone pays. +- Decision 21's plain-text reference is now the whole client story: the draft carries plain text, chip visuals derive from the lexicon, and the sent text is judged by the host boundary — a hand-typed gesture, a menu pick, and a TUI prompt are indistinguishable and equally deterministic. +- Every user-invocable skill invocation costs its full rendered body unconditionally — the price of determinism the peer survey showed everyone pays. Mentioning a known skill name mid-sentence loads it; that is the Codex mention semantic, accepted deliberately. - The `skill-invocation` source rides `user/message`, so Model-visible ⟺ logged holds with no new event type, and replay/UI read metadata rather than text markers. -- TUI and ACP can adopt `skill.invoke` later for the same semantics; until then the TUI's client-side expansion remains its own path. - Accepted residual of dropping the per-injection preamble: the no-reload framing rides only the catalog, and a workspace whose skills are all user-only never publishes a first catalog — an injection can arrive with no framing at all, and the model may redundantly try the `skill` tool once (the replacement catalog's empty arm carries the sentence; the never-published case does not). Publishing a catalog for framing alone was judged worse than that one recoverable error. diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md index e72e49236f..64e23be0b4 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 经 skill.invoke 的用户显式 skill 调用 +# Agent Note: pre-step 手势边界上的用户显式 skill 调用 Status: implemented @@ -10,28 +10,27 @@ Status: implemented ## 决策 -用户显式调用是一次确定性的宿主侧注入,对每一个用户可调用的 skill 一致: +用户显式调用是一次宿主侧的 pre-step 注入,对每一个用户可调用的 skill 和每一种前端一致: -- `skill.invoke { sessionId, name, text? }`(宿主 apiproxy)在操作边界强制执行用户调用策略(`skill-not-found`/`skill-not-invocable`),用共享的 `renderSkillContent` 渲染该 skill,在一个空行之后追加可选的尾随文本,并把整体作为一条携带新增 `skill-invocation` `MessageSource` kind(`{ name, args? }`)的 user 角色消息注入,随后经由与 `session.prompt` 相同的「路由是否有适配器在服务」闸门开启一个轮次。 -- `renderSkillContent` 从 `dsh-tool-skill` 移入 `dsh-skill` seam:`skill` 工具结果与注入共享同一份逐字一致的 `` 形态,目录文本则新增了这条 seam 规则——已内联注入的 skill 必须被遵循,而不是再经工具重新加载。 -- `skill.list` 提供每一个用户可调用的 skill 并携带 `modelInvocable`,因此浏览器菜单会带标记地列出仅限用户的 skill(描述前缀——`hint` 字段是认领态的 ghost text,菜单从不渲染它)。 -- ui-skill 把菜单 pick 或回车提交的 `/name [args]` 认领进 invoke 事务(`matchEnter` 强等目录;未知名称保持为普通提示词)。已不可达的旧 `name` 引用 codec 被移除。 -- transcript(文本记录)依据来源元数据把这次注入物化为专用的 `skill-invocation` 节点(绝不从正文重新解析),并渲染为一个右对齐气泡:`/name` chip、尾随文本,以及收在 disclosure 之后的注入块。 +- `dsh-tool-skill` 注册第二个 `agent/pre-step` 监听器(与其目录监听器并列,也是 `workspace-instructions` 与运行时上下文快照搭乘的同一 seam):它在该步骤已认领的消息中扫描以空白为界的 `/name` token——文本中任意位置均可,与 transcript(文本记录)chip 装饰所用的词边界形状相同——收集按首见去重的名称,逐个经 `ctx.skills.get` 加载,在已加载定义上检查 `isUserInvocable`(产生注入内容的正是这同一次查找),用共享的 `renderSkillContent` 渲染,并把注入追加在该步骤所有其他注入之后:背景在前(工作区规则、运行时策略、目录),模型必须着手处理的材料在最后、最贴近它的回答。注册顺序钉住了这一位置——手势监听器先于目录监听器注册,因此 waterfall 会把携带目录的列表交给它来扩展。 +- 精确性来自封闭集合匹配,与斜杠命令完全一致:`/goal` 对照命令注册表解析,`/name` 对照工作区的用户可调用 skill 目录解析;未命中即保持为普通行文,因此绝不猜测。只扫描 `source.kind === 'user'` 的消息——外部文本无法伪造手势。路径(`/usr/bin`)、分数(`5/8`)与带前缀的 token(`foo/name`)都会破坏该边界。 +- 客户端停留在决策 21:菜单 pick 落下字面文本 `/name `,提示词将其原样发出;ui-skill 不实现任何裁决钩子,也没有引用 codec。`skill.list`(现在是该领域唯一的 RPC)提供每一个用户可调用的 skill 并携带 `modelInvocable`,供菜单标出仅限用户的条目。与宿主命令同名的名称解析为命令——裁决在客户端把该行认领走,它尚未成为提示词。 +- 注入是一条携带 `skill-invocation` 来源(`{ name, form: 'instructions' }`)的 `user` 角色消息,因此 `user/message` 落账、上下文注入的 transcript 行(以 skill 名称标注)与回放全部免费获得;`renderSkillContent` 位于 `dsh-skill` seam,与 `skill` 工具结果逐字共享,目录的结尾一句会告诉模型遵循注入块而不是重新加载。 -同类产品调研(Pi、OpenCode、Claude Code、Kimi Code、Codex、DeepSeek-Reasonix——本地检出)结论一致:在每个产品上,用户显式触发都是以 user 角色消息做程序化注入、模型零参与;提示词引导的工具加载只存在于模型自主轨道上;disable-model-invocation 的对应物只把关模型侧表层。Kimi 的来源元数据渲染与 Claude Code/Kimi 的禁止重载提示词规则,可直接平移到 `MessageSource` 与目录那句话上。 +同类产品调研(Pi、OpenCode、Claude Code、Kimi Code、Codex、DeepSeek-Reasonix——本地检出)一致表明:用户显式触发都是模型零参与的程序化注入;最终形态最接近 Codex 核心侧的 `$name` mention 扫描——它同样让每一种前端免于自行实现识别。 ## 考虑过的替代方案 -- **`agent.inject()` 上下文注入**——没有同类产品先例;这次手势是一个用户轮次,不是环境通知,而且上下文行呈现、压缩(compaction)与归属全都不匹配。否决。 +- **`skill.invoke` RPC(宿主注入、客户端认领)**——最先实现,共两轮迭代:先是单条混合消息(用户文本折进正文),后是经 inbox 原语投递的手势提示词加注入两条消息。经真实会话测试后否决:混合消息让用户行文污染了注入;两条消息的形态依赖唤醒顺序的微妙之处(`followup` 在第一个唤醒调用内同步认领整个 next-turn 队列,把之后的消息滞留到下一轮次——已实际复现),而专设 RPC 复制了 `session.prompt` 已提供的路径,还让 TUI/ACP 不得不各自重新实现识别。pre-step seam 把 RPC、认领机制与顺序隐患一并干净移除。 +- **从 RPC 处理器调用 `agent.inject()`**——inject 队列(`next-step`,不唤醒)会在 next-turn 提示词之前被认领,使注入在日志中排到手势之上;而与会唤醒的 `followup` 搭配又会重新引入同样的顺序耦合。pre-step 监听器在步骤组装内部注入,那里的顺序是显式的。 - **宿主 `/skill ` 命令**(命令注册表,plan 模式先例)——两 token 的 UX、没有名称补全、仅限用户的 skill 在菜单里仍不可发现;按 cwd 的 skill 目录也与静态命令注册表格格不入。否决。 - **客户端展开**(拉取正文、拼进提示词)——授权沦为可被绕过的客户端善意,日志失去调用语义,而且 Codex 已删除其等价机制(custom prompts)转向核心注入。否决。 -- **宿主提示词流水线扫描 `/name`**(Codex 的 `$name` core mentions)——重复了裁决层,还有吞掉普通行文中字面斜杠的风险;认领路径已经覆盖了这一需求。否决。 +- **提示词协议上的结构化引用载荷**(Codex `UserInput::Skill` 的类似物:客户端在文本旁附带 `{skills: [...]}`,边界优先采用它而不是扫描)——考虑过并暂缓:现有斜杠命令体系在协议上本身就是行文本,封闭集合的目录匹配已经消除了猜测;已记为台账事项,以备手势精确性某天需要客户端意图。 - **每次注入一条前导语**(Kimi 的 `User activated the skill …`)——弃用,改为一次性的目录句子:同样的上下文、只支付一次,且注入块与工具结果保持逐字节一致。 ## 后果 -- 决策 21 的纯文本引用路径在提交处被取代:草稿仍承载纯文本与 lexicon 派生的 chip 视觉,但提交会认领进一次确定性注入,而不是把字面文本发出去再碰运气。模型自主轨道(目录 + `skill` 工具)不变。 -- 每一次用户可调用 skill 的调用现在都无条件付出其完整渲染正文的成本——这是确定性的代价,同类调研表明所有产品都在支付。 +- 决策 21 的纯文本引用如今就是客户端的全部故事:草稿承载纯文本,chip 视觉由 lexicon 派生,发出的文本由宿主边界评判——手动键入的手势、菜单 pick 与 TUI 提示词无从区分,也同等确定。 +- 每一次用户可调用 skill 的调用都无条件付出其完整渲染正文的成本——这是确定性的代价,同类调研表明所有产品都在支付。在句子中间提到一个已知 skill 名称也会加载它;这就是 Codex 的 mention 语义,属于有意接受。 - `skill-invocation` 来源搭乘 `user/message`,因此「模型可见 ⟺ 已记录」在不新增事件类型的情况下继续成立,回放与 UI 读取的是元数据而非文本标记。 -- TUI 与 ACP 之后可以为同样的语义采用 `skill.invoke`;在那之前,TUI 的客户端展开仍是它自己的路径。 - 放弃逐次注入前导语后被接受的残余:no-reload framing 只搭乘目录,而 skill 全部为仅用户的工作区永远不会发布首个目录——注入可能在完全没有 framing 的情况下到达,模型可能多余地调用一次 `skill` 工具(替换目录的空臂携带该句;从未发布的情形没有)。仅为 framing 而发布目录被判定比这一次可恢复的错误更糟。 diff --git a/apps/web/tests/skill-user-invoke.e2e.ts b/apps/web/tests/skill-user-invoke.e2e.ts index f722472ded..2d5a039623 100644 --- a/apps/web/tests/skill-user-invoke.e2e.ts +++ b/apps/web/tests/skill-user-invoke.e2e.ts @@ -1,9 +1,9 @@ // Web e2e scenario: a user invokes a disable-model-invocation skill through // the composer (issue #1470). The entered `/name args` line claims into -// skill.invoke: the real host renders the skill body, injects it as a -// user-role message carrying the skill-invocation source, and starts a turn -// answered by the replay seam. The transcript shows the dedicated invocation -// card (chip + args, body collapsed) and the model's reply. +// skill.invoke: the real host forwards the gesture as an ordinary user +// prompt, injects the rendered body as instructions context named after the +// skill, and starts a turn answered by the replay seam. The transcript shows +// the gesture bubble, the collapsed context-injection row, and the reply. import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' @@ -96,7 +96,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro if (failures.length > 1) throw new AggregateError(failures, 'skill-user-invoke e2e cleanup failed') }) - it('claims /name args into an injection card and a replayed answer', async () => { + it('claims /name args into a gesture bubble, an injection row, and a replayed answer', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-user-invoke')) const composer = page.locator('textarea:enabled').last() await composer.waitFor({ timeout: 15_000 }) @@ -112,23 +112,26 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro await composer.fill(`/${SKILL_NAME} ${ARGS_TEXT}`) await composer.press('Enter') - // The injection card presents the gesture from source metadata: chip plus - // args, with the rendered collapsed behind a disclosure. - const card = page.locator('[data-skill-invocation]') - await card.waitFor({ timeout: 15_000 }) - const chip = card.locator('[data-ref-chip="skill"]') - expect(await chip.textContent()).toBe(`/${SKILL_NAME}`) - expect(await card.textContent()).toContain(ARGS_TEXT) + // The gesture stays an ordinary user bubble (decorated /name token plus + // the trailing text), ahead of the injected context. + const bubble = page.locator('[data-ref-chip="skill"]').first() + await bubble.waitFor({ timeout: 15_000 }) + expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`) - const disclosure = card.locator('details') - expect(await disclosure.getAttribute('open')).toBeNull() - await card.locator('summary').click() - const body = card.locator('pre') - await body.waitFor() - expect(await body.textContent()).toContain(``) - expect(await body.textContent()).toContain('Reply with the fixture acknowledgement line.') - expect(await body.textContent()).toContain(ARGS_TEXT) - await card.locator('summary').click() + // The rendered body arrives as a context-injection row named after the + // skill; expanding it reveals the canonical block, and + // the user's text is NOT folded into it. + const injectionRow = page.getByRole('button', { name: `Context injection ${SKILL_NAME}` }) + await injectionRow.waitFor({ timeout: 15_000 }) + await injectionRow.click() + const injectionBody = page + .locator('[data-context-injection-body]') + .filter({ hasText: `` }) + await injectionBody.waitFor({ timeout: 10_000 }) + const injected = await injectionBody.textContent() + expect(injected).toContain('Reply with the fixture acknowledgement line.') + expect(injected).not.toContain(ARGS_TEXT) + await injectionRow.click() // The injection started a turn; the replay seam answers it. await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 }) diff --git a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md index b96413f89f..c77081584a 100644 --- a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md @@ -1,18 +1,20 @@ - banner: - navigation "Session hierarchy": - - button "workspace" [disabled] + - button "/user-invoke-demo and confirm the fixtur" [disabled] - tablist: - tab "Chat" [selected] - tab "Trajectory" -- text: /user-invoke-demo and confirm the fixture wiring -- group: View injected skill content -- text: {{clock}} +- text: /user-invoke-demo and confirm the fixture wiring {{clock}} - button "Copy": - img - button "Context injection @deepseek-ai/dsh-system-prompt": - img - img - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Context injection user-invoke-demo": + - img + - img + - text: Context injection user-invoke-demo - paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. - button "Copy": - img diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9f1bf08f9d..470e1d4816 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1471,7 +1471,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:261`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:262`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` @@ -2063,7 +2063,7 @@ export interface Config { } ``` -Source: [`packages/skill/tool-skill/src/index.ts:59`](../packages/skill/tool-skill/src/index.ts) +Source: [`packages/skill/tool-skill/src/index.ts:61`](../packages/skill/tool-skill/src/index.ts) ## `@deepseek-ai/dsh-tool-str-replace-editor` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 55952b3591..6b79018c22 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -677,7 +677,7 @@ A skill provider, runtime contribution, or provider-backed catalog may have chan 'skills/change'(): void ``` -Source: [`packages/skill/skill/src/index.ts:279`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:280`](../../packages/skill/skill/src/index.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4abd00c1fd..8820a0329c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1946,7 +1946,7 @@ async get(name: string, options: SkillLookupOptions = {}): Promise { - const missing = requireSession(request) - if (missing !== undefined) return missing - const { sessionId, name, text: args } = request.payload - const body = `\n\nBase directory for this skill: /fixture/skills/${name}\n\n\n\nFixture ${name} instructions.\n\n` - // Mirror the host: injection is a user-role message carrying the - // skill-invocation source, immediately visible in the transcript. - // The client program cannot see the host-side MessageSourceMap merge - // (sources are opaque wire JSON to the UI), so the fixture stamps the - // durable shape through the same assertion the projections read back. - const source = { kind: 'skill-invocation', name, ...args === undefined ? {} : { args } } as unknown as MessageSource - append(sessionId, { - type: 'user/message', surfaceOp: 'append', - data: userMessage(text(args === undefined ? body : `${body}\n\n${args}`), source), - }) - return ok(request, { accepted: true as const }) - }, }, goals: { // Compatibility face only: old API Proxy payloads and acknowledgements @@ -2779,7 +2762,6 @@ export class FixtureApiClient extends AbstractApiClient { case 'command.list': return this.api.commands.list(request) case 'command.execute': return this.api.commands.execute(request, signal) case 'skill.list': return this.api.skills.list(request) - case 'skill.invoke': return this.api.skills.invoke(request, signal) case 'goal.create': return this.api.goals.create(request) case 'goal.edit': return this.api.goals.edit(request) case 'goal.pause': return this.api.goals.pause(request) diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index bd8efaf6a4..cc4504e538 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -163,8 +163,6 @@ export class FakeApiClient implements IApiClient { onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) - onSkillInvoke: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ accepted: true as const })) readonly commands: IApiClient['commands'] = { list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), @@ -173,7 +171,6 @@ export class FakeApiClient implements IApiClient { readonly skills: IApiClient['skills'] = { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), - invoke: (payload: unknown) => this.record('skill.invoke', payload, this.onSkillInvoke(payload)), } readonly goals: IApiClient['goals'] = { diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 3864338e28..5a1677df96 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -45,12 +45,11 @@ export { createSnapshotStore, defineStore, shallowEqual } from './contract/store export type { EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, } from './contract/store.ts' -export { opensUserTurn } from './sessions/conversation.ts' export type { AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase, ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage, - RunningToolCall, SkillInvocationNode, + RunningToolCall, SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' export type { diff --git a/packages/client/runtime/src/client/sessions/context-provenance.ts b/packages/client/runtime/src/client/sessions/context-provenance.ts index 5d231b6bd8..6f46a510c1 100644 --- a/packages/client/runtime/src/client/sessions/context-provenance.ts +++ b/packages/client/runtime/src/client/sessions/context-provenance.ts @@ -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: diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 1ced1b916e..fb2c281331 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -129,25 +129,6 @@ export interface ContextMessageNode { form: KnownContextForm | null } -/** - * A user-explicit skill invocation: the host injected the rendered skill as a - * user message carrying the `skill-invocation` source, so the card presents - * `/name args` from source metadata and collapses the injected body. - */ -export interface SkillInvocationNode { - kind: 'skill-invocation' - seq: number - /** Unix epoch ms from the source session event. */ - time: number - /** Invoked skill name read off the message source. */ - name: string - /** Trailing user text read off the message source, when recorded. */ - args?: string - /** Full injected model-facing content (collapsed by default in the UI). */ - content: readonly ContentBlock[] - source: unknown -} - /** Durable notice that a closed failed step is waiting for a model-request retry. */ export type ModelRetryNode = LlmRetryEventData & { kind: 'model-retry' @@ -258,27 +239,12 @@ export interface CommandNode { outcome: { kind: 'success' | 'error'; text?: string } | null } -/** - * Whether a node opens a user turn on the transcript surface. A direct user - * message and a user-explicit skill invocation both start the turn the next - * assistant answer closes; parallel consumers (turn boundaries, retry - * liveness, own-words scrolling) share this one predicate instead of each - * re-encoding the kind list. Steering stays out: an interjection lands - * mid-turn and closes nothing. - * @param node - any conversation node. - * @returns true for the user-turn-opening kinds. - */ -export function opensUserTurn(node: Pick): boolean { - return node.kind === 'user' || node.kind === 'skill-invocation' -} - /** Finalized conversation node union (kind discriminates; seq is the React key). */ export type ConversationNode = | UserMessageNode | AssistantMessageNode | SteeringMessageNode | ContextMessageNode - | SkillInvocationNode | ModelRetryNode | TurnErrorNode | ToolResultNode diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts index 4a05afee06..8b77807c96 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -58,22 +58,10 @@ function materializeNode( ): ConversationNode { switch (event.type) { case 'user/message': { - // A user-explicit skill invocation carries its name (and optional args) - // on the source; the dedicated node lets the card render `/name args` - // from metadata instead of re-parsing the injected body. A record whose - // name is unreadable degrades to injected context below. - const source = event.data.source as { kind?: unknown; name?: unknown; args?: unknown } - if (source.kind === 'skill-invocation' && typeof source.name === 'string') { - return { - kind: 'skill-invocation', seq: event.seq, time: event.time, - name: source.name, - ...typeof source.args === 'string' ? { args: source.args } : {}, - content: event.data.content, source: event.data.source, - } - } - // 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). + // 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, diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index def535a59a..2f4299ce6c 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -198,8 +198,6 @@ export class FakeApiClient implements IApiClient { onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) - onSkillInvoke: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ accepted: true as const })) readonly commands: IApiClient['commands'] = { list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), @@ -208,7 +206,6 @@ export class FakeApiClient implements IApiClient { readonly skills: IApiClient['skills'] = { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), - invoke: (payload: unknown) => this.record('skill.invoke', payload, this.onSkillInvoke(payload)), } readonly goals: IApiClient['goals'] = { diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index e847c2cec7..a5b423c58d 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -164,29 +164,26 @@ describe('TranscriptAdapter', () => { expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context']) }) - it('materializes a skill-invocation source as its dedicated node', () => { + 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: 'body\n\ncheck the fixture' }], - source: { kind: 'skill-invocation', name: 'hidden-demo', args: 'check the fixture' } as never, + 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: 'body' }], - source: { kind: 'skill-invocation', name: 'bare-skill' } as never, + content: [{ type: 'text', text: 'body' }], + source: { kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' } as never, }) }), ]) const nodes = adapter.nodes() - expect(nodes.map(node => node.kind)).toEqual(['skill-invocation', 'skill-invocation']) - expect(nodes[0]).toMatchObject({ name: 'hidden-demo', args: 'check the fixture' }) - expect(nodes[1]).toMatchObject({ name: 'bare-skill' }) - expect((nodes[1] as { args?: string }).args).toBeUndefined() - // A malformed record (no readable name) degrades to injected context, not a crash. - adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ - content: [{ type: 'text', text: 'odd' }], - source: { kind: 'skill-invocation' } as never, - }) })) - expect(adapter.nodes().at(-1)?.kind).toBe('context') + // 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', () => { diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index a841ba6751..b0907f5a80 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -24,7 +24,6 @@ import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, } from 'react' -import { opensUserTurn } from '@deepseek-ai/dsh-client-runtime/client' import type { CodeSubCall, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode, } from '@deepseek-ai/dsh-client-runtime/client' @@ -119,7 +118,7 @@ function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): n const node = nodes[index] if (node === undefined) continue if (node.kind === 'model-retry') return node.retryState === 'cancelled' ? null : node.seq - if (node.kind === 'assistant' || opensUserTurn(node)) return null + if (node.kind === 'assistant' || node.kind === 'user') return null } return null } @@ -448,11 +447,10 @@ export function ChatView({ return } firstSeqRef.current = firstSeq - // Own words must be visible: a new trailing user-turn node (a prompt or an - // explicit skill invocation) force-scrolls (send lives in the composer, so - // arrival is detected here, not armed there). + // Own words must be visible: a new trailing user node force-scrolls + // (send lives in the composer, so arrival is detected here, not armed there). const appendedUser = lastKey !== lastKeyRef.current - && lastItem !== undefined && lastItem.kind === 'node' && opensUserTurn(lastItem.node) + && lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user' const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current const tipMoved = followSigRef.current !== followSig lastKeyRef.current = lastKey diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 4330cde32c..5c07ace71e 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -256,30 +256,3 @@ white-space: nowrap; vertical-align: baseline; } - -/* User-explicit skill invocation: the injected body collapses behind a - disclosure inside the user bubble. */ -.skillInvocationDetails { - margin-top: 6px; -} - -.skillInvocationSummary { - cursor: pointer; - font-size: 0.8em; - color: var(--dsw-alias-label-secondary); - user-select: none; -} - -.skillInvocationBody { - margin: 6px 0 0; - padding: 8px; - max-height: 320px; - overflow: auto; - border-radius: 6px; - background: var(--dsw-alias-bg-secondary, rgba(0, 0, 0, 0.06)); - font-family: var(--dsw-font-mono, monospace); - font-size: 0.78em; - line-height: 1.5; - white-space: pre-wrap; - word-break: break-word; -} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index af2afd9792..30b51b3870 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -7,8 +7,8 @@ import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { - CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SkillInvocationNode, - SteeringMessageNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode, + CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode, + TurnErrorNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' @@ -22,7 +22,6 @@ export interface MessageItemProps { | UserMessageNode | SteeringMessageNode | ContextMessageNode - | SkillInvocationNode | CompactionSummaryNode | ModelRetryNode | TurnErrorNode @@ -192,38 +191,6 @@ function UserStyleBubble({ ) } -/** - * A user-explicit skill invocation: the right-aligned bubble presents the - * `/name args` gesture from source metadata (never re-parsed from the body), - * and the injected `` collapses behind a disclosure — the - * durable content is model-facing bulk, not conversation prose. - */ -function SkillInvocationRow({ node, t }: { - node: SkillInvocationNode - t: ChatViewSlotProps['t'] -}): ReactNode { - const { text } = contentText(node.content) - return ( -
-
- {`/${node.name}`} - {node.args !== undefined && } -
- {t('message.skillInvocation.expand')} -
{text}
-
-
- -
- ) -} - /** * Render one Host-authoritative pending steering item with the same visual * language as its eventual durable transcript node. @@ -285,8 +252,6 @@ export const MessageItem = memo(function MessageItem({ t={t} /> ) - case 'skill-invocation': - return case 'compaction': return case 'model-retry': diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index a340a2f634..df107d2cd2 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -79,7 +79,6 @@ export const zh = { 'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条', 'message.context.recall.truncated': '已截断', 'message.steering': '插话', - 'message.skillInvocation.expand': '查看注入的 skill 内容', 'message.compaction': '上下文已压缩', 'message.compaction.expand': '点击查看压缩摘要', 'message.compaction.unavailable': '压缩摘要不可用', @@ -220,7 +219,6 @@ export const en = { 'message.context.recall.counts': '{retained} kept · {omitted} omitted', 'message.context.recall.truncated': 'truncated', 'message.steering': 'Interjection', - 'message.skillInvocation.expand': 'View injected skill content', 'message.compaction': 'Context compacted', 'message.compaction.expand': 'View compaction summary', 'message.compaction.unavailable': 'Compaction summary unavailable', diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 9471461cda..28b0501141 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -865,41 +865,6 @@ describe('MessageItem arms', () => { expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') }) - it('skill-invocation renders the /name chip, args, and a collapsed injected body', () => { - const body = 'instructions\n\ncheck the fixture' - const view = render( - , - ) - const chip = view.container.querySelector('[data-ref-chip="skill"]') - expect(chip?.textContent).toBe('/hidden-demo') - const details = view.container.querySelector('details') - expect(details).toBeTruthy() - expect(details?.open).toBe(false) - expect(view.getByText('查看注入的 skill 内容')).toBeTruthy() - expect(view.container.querySelector('pre')?.textContent).toBe(body) - expect(view.container.querySelector('[data-skill-invocation]')).toBeTruthy() - }) - - it('skill-invocation without args renders only the chip line', () => { - const view = render( - x
' }] as never, - source: null, - }} - />, - ) - const bubble = view.container.querySelector('[data-skill-invocation]') - expect(bubble?.textContent).toContain('/bare-skill') - expect(bubble?.textContent).not.toContain('undefined') - }) }) describe('formatMessageClock', () => { diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index b8886be0df..c9754d1da4 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -3,7 +3,6 @@ * nodes. Client-only and model-free: the vocabulary is the mutation tools' * own follow-along `locations`, never the closing prose. */ -import { opensUserTurn } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -63,7 +62,7 @@ export function producedForClosing(nodes: readonly ConversationNode[], seq: numb } continue } - if (opensUserTurn(node)) { + if (node.kind === 'user') { turn = undefined pending = [] seen = new Set() diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index 473defc4e6..845e6099c8 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -73,26 +73,6 @@ describe('producedForClosing derivation', () => { expect(producedForClosing(nodes, 999)).toEqual([]) }) - it('treats a user-explicit skill invocation as a turn boundary', () => { - // The injection opens a user turn exactly like a typed prompt: files - // written before it must not spill into the turn its answer closes. - const skillInvocation = { - kind: 'skill-invocation' as const, seq: 4, time: 4_000, - name: 'hidden-demo', - content: [{ type: 'text', text: 'x' }] as never, - source: null, - } - const nodes: ConversationNode[] = [ - user(1, 'write things'), - assistant(2, 'wrote', 1), - wrote(3, 'a', 'stale.txt'), - skillInvocation, - wrote(5, 'b', 'fresh.txt'), - assistant(6, 'followed the skill', 2), - ] - expect(producedForClosing(nodes, 6)).toEqual(['fresh.txt']) - expect(producedForClosing(nodes, 6)).not.toContain('stale.txt') - }) it('counts a generic edit and never spills across the turn boundary', () => { const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({ diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 5b80baa912..a1d1a2c9d5 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/README.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 packages/client/ui-skill/README.md -README.md: ea3dbf3592995903422ec951e20c911082370dbe -README.zh.md: 5b8886e67973af9a594ff6aa2e9295f112a9f3e3 +README.md: bdd772662acda1f8cf1b7d8a7c5532f9b37123dd +README.zh.md: 959ff0ede6d545150fb22710c8af75859966caa9 diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index ea3dbf3592..bdd772662a 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -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 `name` 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 `name` 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 `` 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 `` 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 `` 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. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 5b8886e679..959ff0ede6 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -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` 扫描派生;旧的 `name` 引用 codec 已经移除(决策 21 的移除裁定),`matchSpace` 保持不实现——skill 流程归菜单与回车所有。 +pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本(决策 21)——本 source 不实现任何裁决钩子,也没有引用 codec(旧的 `name` 形式已随移除裁定消失)。确定性在宿主侧:pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token,并为每一种前端注入渲染后的 ``,因此菜单 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` 工具返回的 `renderSkillContent` 输出相同——在一个空行之后追加用户的尾随文本,并把整体作为一条携带 `skill-invocation` 来源的 user 角色消息注入,随即开启一个轮次。加载是确定性的:模型无需被要求调用 `skill` 工具就能收到完整正文,目录(由 `dsh-tool-skill` 渲染)也会告诉它不要重新加载已内联注入的 skill。 +用户消息原样到达模型,字面文本 `/name` 也包含在内。随后宿主的 pre-step 边界(`dsh-tool-skill`)把规范的 `` 块——与 `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 候选;下一次击键会重新轮询已落定的缓存。 diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index a73370b8ff..4e23be06be 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -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 `` 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 `` 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 diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index da99ed70d3..f73a8d8bda 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -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') }) }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 017bd32970..961b48dd0b 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.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 packages/host/apiproxy/README.md -README.md: 8d7a24b0b8b897d94ed29d5dc9ed6e9efb250fc6 -README.zh.md: c988b7540ba719d02e50d6da9595353c93766835 +README.md: 5506cbef7b778a870e1e28c3f9fdf1713f89d65f +README.zh.md: de31f653944097e9b47a966f56c418dc9fa9b1b9 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 8d7a24b0b8..5506cbef7b 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -46,7 +46,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the `host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. -The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's invocation path: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point this is. `skill.invoke` is the user-explicit loading RPC: it enforces user-invocation policy at this boundary (`skill-not-found` / `skill-not-invocable`), renders the canonical `` body via the shared `renderSkillContent`, appends the optional trailing `text`, injects the whole as a user-role message carrying the `skill-invocation` source, and starts a turn through the same route-served refusal gate as `session.prompt`. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. +The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point the slash gesture is. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `` context, so every front end (web, TUI, ACP, hand-typed text) shares one deterministic path with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index c988b7540b..de31f65394 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -46,7 +46,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 -`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的调用路径:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——此处是这类条目唯一的入口。`skill.invoke` 是用户显式加载 RPC:它在此边界强制执行用户调用策略(`skill-not-found`/`skill-not-invocable`),经共享的 `renderSkillContent` 渲染规范的 `` 正文,追加可选的尾随 `text`,把整体作为一条携带 `skill-invocation` 来源的 user 角色消息注入,并经由与 `session.prompt` 相同的「路由是否有适配器在服务」拒绝闸门开启一个轮次。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 +`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的入口。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `` 上下文作答,因此每一种前端(web、TUI、ACP、手动键入的文本)共享同一条确定性路径,没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 `settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index b001d54625..9a8750c722 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -50,7 +50,6 @@ export interface RpcMethodMap { 'command.list': CommandsApi['list'] 'command.execute': CommandsApi['execute'] 'skill.list': SkillsApi['list'] - 'skill.invoke': SkillsApi['invoke'] 'goal.create': GoalsApi['create'] 'goal.edit': GoalsApi['edit'] 'goal.pause': GoalsApi['pause'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index dd3fe7cf57..2733c6e940 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -51,8 +51,6 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }), z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }), - z.object({ code: z.literal('skill-not-found'), message: z.string(), details: z.object({ name: z.string() }) }), - z.object({ code: z.literal('skill-not-invocable'), message: z.string(), details: z.object({ name: z.string() }) }), z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }), z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }), z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 7bf41a32e1..54bbb5a8cc 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -51,10 +51,6 @@ export interface RpcErrorDetailsMap { 'command-error': {} /** A leading-/ prompt named no registered command; the message names the token. */ 'unknown-command': {} - /** A skill invocation named no skill in the session's workspace (unknown or ill-formed name). */ - 'skill-not-found': { name: string } - /** A skill invocation named a skill whose policy forbids user invocation. */ - 'skill-not-invocable': { name: string } /** * A settings write was refused (schema validation, unknown namespace, * read-only provider, or storage failure); the message is the seam's text. diff --git a/packages/host/apiproxy/src/api/skills.schema.ts b/packages/host/apiproxy/src/api/skills.schema.ts index 1741a93a46..747bf19bad 100644 --- a/packages/host/apiproxy/src/api/skills.schema.ts +++ b/packages/host/apiproxy/src/api/skills.schema.ts @@ -26,18 +26,3 @@ export const skillListRequestSchema = z.object({ export const skillListValueSchema = z.object({ skills: z.array(skillEntrySchema), }) satisfies z.ZodType>> - -/** - * skill.invoke request payload. `text` is the user's trailing message; a - * blank one stays off the wire (the boundary, not client courtesy, refuses it). - */ -export const skillInvokeRequestSchema = z.object({ - sessionId: sessionIdSchema, - name: z.string().min(1), - text: z.string().min(1).optional(), -}) satisfies z.ZodType>> - -/** skill.invoke response value. */ -export const skillInvokeValueSchema = z.object({ - accepted: z.literal(true), -}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/skills.ts b/packages/host/apiproxy/src/api/skills.ts index 698a9f0190..3b3e711a93 100644 --- a/packages/host/apiproxy/src/api/skills.ts +++ b/packages/host/apiproxy/src/api/skills.ts @@ -20,22 +20,14 @@ export interface SkillEntry { readonly modelInvocable: boolean } -/** Skill-domain unary methods (the map keys skill.* of RpcMethodMap). */ +/** + * Skill-domain unary methods (the map key skill.* of RpcMethodMap). Listing + * is the domain's only RPC: invocation itself is a plain `session.prompt` + * whose leading `/name` token the host recognizes at the pre-step boundary + * (`dsh-tool-skill` injects the rendered body there), so every client shares + * one deterministic path with no dedicated invocation wire. + */ export interface SkillsApi { /** Lists the user-invocable skill catalog for the session's project. */ list(request: RpcRequest<{ sessionId: SessionId }>): Promise> - - /** - * Injects one user-invocable skill into the addressed agent as a user-role - * message (the canonical `` rendering, with `text` appended - * when present) and starts a turn. The host enforces user-invocation policy - * here — on the discovery summary and again on the loaded definition, so a - * catalog change between the two lookups cannot slip a user-disabled body - * through — a model-only or unknown name is refused regardless of what a - * client menu offered. The carrier's request signal aborts the skill - * lookup and refuses injection once the caller has given up (`cancelled`). - * Session-backed subagents reject with `agent-busy`. - */ - invoke(request: RpcRequest<{ sessionId: SessionId; name: string; text?: string }>, signal: AbortSignal): - Promise> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 574206458b..0f54d76dbc 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -39,7 +39,7 @@ import { workspaceRenameValueSchema, } from '../api/workspace.schema.ts' import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts' -import { skillInvokeValueSchema, skillListValueSchema } from '../api/skills.schema.ts' +import { skillListValueSchema } from '../api/skills.schema.ts' import { goalCreateValueSchema, goalEditValueSchema, @@ -118,7 +118,6 @@ export interface IApiClient { } skills: { list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise>> - invoke(payload: RequestPayload<'skill.invoke'>, signal?: AbortSignal): Promise>> } events: { mux(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> @@ -186,7 +185,6 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('skill.list', payload, signal), - invoke: (payload, signal) => this.callUnary('skill.invoke', payload, signal), } readonly goals: IApiClient['goals'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 8e098680fa..d41b51ad6d 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -41,7 +41,7 @@ import { workspaceRenameRequestSchema, } from '../api/workspace.schema.ts' import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts' -import { skillInvokeRequestSchema, skillListRequestSchema } from '../api/skills.schema.ts' +import { skillListRequestSchema } from '../api/skills.schema.ts' import { goalCreateRequestSchema, goalEditRequestSchema, @@ -109,7 +109,6 @@ const UNARY_ROUTES: UnaryRoutes = { 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, - 'skill.invoke': { schema: skillInvokeRequestSchema, invoke: (api, r, signal) => api.skills.invoke(r, signal) }, 'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) }, 'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) }, 'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) }, diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 5b61011370..09526c5a87 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -269,207 +269,6 @@ describe('skill.list', () => { }) }) -describe('skill.invoke', () => { - /** Provider with one user-only and one model-only skill, both loadable. */ - function registerInvokeSkills(ctx: Context): void { - const summaries = [ - { - name: 'user-only', description: 'User-only', - invocation: { modelInvocable: false, userInvocable: true }, - source: 'custom', provider: 'probe', rank: 0, locator: null, - resourceBase: { kind: 'directory', path: '/proj/.agents/skills/user-only' }, - }, - { - name: 'model-only', description: 'Model-only', - invocation: { modelInvocable: true, userInvocable: false }, - source: 'custom', provider: 'probe', rank: 0, locator: null, - }, - ] as const - ctx.skills.registerProvider(() => ({ - name: 'probe', - list: () => Promise.resolve(summaries.map(summary => ({ ...summary }))), - get: candidate => Promise.resolve({ - ...summaries.find(summary => summary.name === candidate.name)!, - content: 'Follow the probe instructions.', - }), - })) - } - - /** Agent stub whose session carries a project cwd and whose followup records the injected message. */ - function invokableAgent(ctx: Context): { agent: Agent; followup: ReturnType } { - const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) - const followup = vi.fn() - const agent = { id: session.id, session, inbox, status: 'idle', ctx, followup } as unknown as Agent - ctx.agents.register(agent) - return { agent, followup } - } - - const live = () => new AbortController().signal - - it('injects a user-invocable skill as a user message with the invocation source', async () => { - const ctx = await harness() - registerInvokeSkills(ctx) - const api = createApiProxy(ctx, DEFAULTS) - const { agent, followup } = invokableAgent(ctx) - const value = expectOk(await api.skills.invoke(request({ - sessionId: agent.id, name: 'user-only', text: 'and check the fixture', - }), live())) - expect(value).toEqual({ accepted: true }) - expect(followup).toHaveBeenCalledTimes(1) - const message = followup.mock.calls[0]?.[0] as UserMessage - expect(message.source).toEqual({ kind: 'skill-invocation', name: 'user-only', args: 'and check the fixture' }) - expect(message.content).toHaveLength(1) - const text = (message.content[0] as { text: string }).text - expect(text).toContain('') - expect(text).toContain('Base directory for this skill: /proj/.agents/skills/user-only') - expect(text).toContain('Follow the probe instructions.') - expect(text.endsWith('\n\nand check the fixture')).toBe(true) - }) - - it('omits args from the source and content when no text rides the invocation', async () => { - const ctx = await harness() - registerInvokeSkills(ctx) - const api = createApiProxy(ctx, DEFAULTS) - const { agent, followup } = invokableAgent(ctx) - expectOk(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }), live())) - const message = followup.mock.calls[0]?.[0] as UserMessage - expect(message.source).toEqual({ kind: 'skill-invocation', name: 'user-only' }) - const text = (message.content[0] as { text: string }).text - expect(text.endsWith('')).toBe(true) - }) - - it('rejects a skill the user may not invoke', async () => { - const ctx = await harness() - registerInvokeSkills(ctx) - const api = createApiProxy(ctx, DEFAULTS) - const { agent, followup } = invokableAgent(ctx) - const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'model-only' }), live())) - expect(error.code).toBe('skill-not-invocable') - expect(followup).not.toHaveBeenCalled() - }) - - it('rechecks user policy on the loaded definition (list/get race)', async () => { - const ctx = await harness() - // The provider flips the skill user-invocable in list but user-disabled - // in get — the window a provider change between the two collects opens. - ctx.skills.registerProvider(() => ({ - name: 'flipping', - list: () => Promise.resolve([{ - name: 'flipper', description: 'Race probe', - invocation: { modelInvocable: false, userInvocable: true }, - source: 'custom', provider: 'flipping', rank: 0, locator: null, - }]), - get: () => Promise.resolve({ - name: 'flipper', description: 'Race probe', - invocation: { modelInvocable: false, userInvocable: false }, - source: 'custom', provider: 'flipping', - content: 'Must never inject.', - }), - })) - const api = createApiProxy(ctx, DEFAULTS) - const { agent, followup } = invokableAgent(ctx) - const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'flipper' }), live())) - expect(error.code).toBe('skill-not-invocable') - expect(followup).not.toHaveBeenCalled() - }) - - it('reports skill-not-found when the summary wins but the load returns nothing', async () => { - const ctx = await harness() - ctx.skills.registerProvider(() => ({ - name: 'vanishing', - list: () => Promise.resolve([{ - name: 'ghost', description: 'Vanishes on load', - invocation: { modelInvocable: false, userInvocable: true }, - source: 'custom', provider: 'vanishing', rank: 0, locator: null, - }]), - get: () => Promise.resolve(undefined), - })) - const api = createApiProxy(ctx, DEFAULTS) - const { agent, followup } = invokableAgent(ctx) - const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'ghost' }), live())) - expect(error.code).toBe('skill-not-found') - expect(followup).not.toHaveBeenCalled() - }) - - it('rejects an unknown or invalid skill name', async () => { - const ctx = await harness() - registerInvokeSkills(ctx) - const api = createApiProxy(ctx, DEFAULTS) - const { agent } = invokableAgent(ctx) - const missing = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'absent-skill' }), live())) - expect(missing.code).toBe('skill-not-found') - const invalid = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'Not A Name' }), live())) - expect(invalid.code).toBe('skill-not-found') - }) - - it('folds a loader failure into a structured internal error', async () => { - const ctx = await harness() - ctx.skills.registerProvider(() => ({ - name: 'exploding', - list: () => Promise.resolve([{ - name: 'grenade', description: 'Loader throws', - invocation: { modelInvocable: false, userInvocable: true }, - source: 'custom', provider: 'exploding', rank: 0, locator: null, - }]), - get: () => Promise.reject(new Error('disk exploded')), - })) - const api = createApiProxy(ctx, DEFAULTS) - const { agent, followup } = invokableAgent(ctx) - const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'grenade' }), live())) - expect(error.code).toBe('internal') - expect(error.message).toContain('skill invocation failed') - expect(followup).not.toHaveBeenCalled() - }) - - it('refuses to start a turn the caller already abandoned', async () => { - const ctx = await harness() - registerInvokeSkills(ctx) - const api = createApiProxy(ctx, DEFAULTS) - const { agent, followup } = invokableAgent(ctx) - const abort = new AbortController() - abort.abort() - const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }), abort.signal)) - expect(error.code).toBe('cancelled') - expect(followup).not.toHaveBeenCalled() - }) - - it('surfaces a followup refusal as agent-busy', async () => { - const ctx = await harness() - registerInvokeSkills(ctx) - const api = createApiProxy(ctx, DEFAULTS) - const { agent, followup } = invokableAgent(ctx) - followup.mockImplementation(() => { throw new Error('inbox closed') }) - const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }), live())) - expect(error.code).toBe('agent-busy') - }) - - it('refuses a cwd-less session with the skill.list stance', async () => { - const ctx = await harness() - registerInvokeSkills(ctx) - const api = createApiProxy(ctx, DEFAULTS) - const session = ctx.sessions.create(undefined) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) - const followup = vi.fn() - ctx.agents.register({ id: session.id, session, inbox, status: 'idle', ctx, followup } as unknown as Agent) - const error = expectErr(await api.skills.invoke(request({ sessionId: session.id, name: 'user-only' }), live())) - expect(error.code).toBe('internal') - expect(error.message).toContain('has no project cwd') - expect(followup).not.toHaveBeenCalled() - }) - - it('fails loud with internal when the skill registry is not mounted', async () => { - const ctx = await harness({ skills: false }) - const api = createApiProxy(ctx, DEFAULTS) - const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) - ctx.agents.register({ id: session.id, session, inbox, status: 'idle', ctx, followup: vi.fn() } as unknown as Agent) - const error = expectErr(await api.skills.invoke(request({ sessionId: session.id, name: 'user-only' }), live())) - expect(error.code).toBe('internal') - expect(error.message).toContain('skill registry is absent') - }) -}) - describe('host/commands-changed frame', () => { it('broadcasts on registry change', async () => { const ctx = await harness() diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 0a65c817c6..ebd56ee551 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -86,7 +86,7 @@ function scriptedApi(overrides: { execute: r => ok(r, { matched: false }), ...overrides.commands, }, - skills: { list: r => ok(r, { skills: [] }), invoke: r => ok(r, { accepted: true as const }), ...overrides.skills }, + skills: { list: r => ok(r, { skills: [] }), ...overrides.skills }, goals: { create: err, edit: err, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 09cabdcc7f..6481d75837 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -198,9 +198,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async list(request) { return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } } } }, - async invoke(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } - }, }, goals: { async create(request) { @@ -385,8 +382,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(miss.result).toEqual({ ok: true, value: { matched: false } }) const skills = await c.skills.list({ sessionId: 's' as never }) expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } }) - const invoked = await c.skills.invoke({ sessionId: 's' as never, name: 'commit-helper', text: 'go' }) - expect(invoked.result).toEqual({ ok: true, value: { accepted: true } }) }) it('lets command.execute finish after the 30-second default unary deadline', async () => { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 972ccd3621..75b6dff3f7 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -31,7 +31,7 @@ import { commandDescriptorSchema, commandExecuteRequestSchema, commandExecuteValueSchema, commandListRequestSchema, commandListValueSchema, } from '../src/api/commands.schema.ts' -import { skillEntrySchema, skillInvokeRequestSchema, skillInvokeValueSchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' +import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts' import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts' import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts' @@ -74,8 +74,6 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'queue-item-not-found', message: 'm', details: { itemId: 'i' } }).code).toBe('queue-item-not-found') expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error') expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command') - expect(rpcErrorSchema.parse({ code: 'skill-not-found', message: 'm', details: { name: 'n' } }).code).toBe('skill-not-found') - expect(rpcErrorSchema.parse({ code: 'skill-not-invocable', message: 'm', details: { name: 'n' } }).code).toBe('skill-not-invocable') expect(rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: { sessionId: 's' } }).code).toBe('title-invalid') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') }) @@ -83,7 +81,6 @@ describe('rpcErrorSchema', () => { it('rejects a known code with missing details', () => { expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: {} })).toThrow() - expect(() => rpcErrorSchema.parse({ code: 'skill-not-invocable', message: 'm', details: {} })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'command-error', message: 'm' })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow() }) @@ -408,19 +405,6 @@ describe('skills domain schemas', () => { // modelInvocable is required wire data: an entry without it fails. expect(() => skillEntrySchema.parse({ name: 'n', description: 'd' })).toThrow() }) - - it('validates the invoke request/value pair', () => { - expect(skillInvokeRequestSchema.parse({ sessionId: 's1', name: 'user-only' })) - .toEqual({ sessionId: 's1', name: 'user-only' }) - expect(skillInvokeRequestSchema.parse({ sessionId: 's1', name: 'user-only', text: 'check it' }).text) - .toBe('check it') - expect(() => skillInvokeRequestSchema.parse({ sessionId: 's1', name: '' })).toThrow() - expect(() => skillInvokeRequestSchema.parse({ name: 'user-only' })).toThrow() - // A blank trailing text is refused at the wire boundary, not by client courtesy. - expect(() => skillInvokeRequestSchema.parse({ sessionId: 's1', name: 'user-only', text: '' })).toThrow() - expect(skillInvokeValueSchema.parse({ accepted: true })).toEqual({ accepted: true }) - expect(() => skillInvokeValueSchema.parse({ accepted: false })).toThrow() - }) }) describe('goals domain schemas', () => {