refactor(host)!: retire the skill.invoke RPC for the gesture boundary

Invocation is an ordinary session.prompt again: the pre-step gesture
boundary makes it deterministic host-side for every front end, so the
dedicated RPC (handler, wire schema, error codes, client face, fixtures)
and ui-skill's claim machinery are net deletions. The menu keeps decision
21 exactly — a pick lands literal /name text — plus the user-only marker
from skill.list's modelInvocable flag.
This commit is contained in:
Yichen Jiang
2026-08-08 13:15:52 +08:00
parent c08fa27e5c
commit 0d53752c49
43 changed files with 143 additions and 663 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 8d7a24b0b8b897d94ed29d5dc9ed6e9efb250fc6
README.zh.md: c988b7540ba719d02e50d6da9595353c93766835
README.md: 5506cbef7b778a870e1e28c3f9fdf1713f89d65f
README.zh.md: de31f653944097e9b47a966f56c418dc9fa9b1b9

View File

@@ -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 `<skill_content>` 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 `<skill_content>` 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.

View File

@@ -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` 渲染规范的 `<skill_content>` 正文,追加可选的尾随 `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,并以注入的 `<skill_content>` 上下文作答,因此每一种前端(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` 错误应答这些领域。

View File

@@ -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']

View File

@@ -51,8 +51,6 @@ export const rpcErrorSchema: z.ZodType<RpcError> = 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() }) }),

View File

@@ -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.

View File

@@ -26,18 +26,3 @@ export const skillListRequestSchema = z.object({
export const skillListValueSchema = z.object({
skills: z.array(skillEntrySchema),
}) satisfies z.ZodType<Wire<ResponseValue<'skill.list'>>>
/**
* 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<Wire<RequestPayload<'skill.invoke'>>>
/** skill.invoke response value. */
export const skillInvokeValueSchema = z.object({
accepted: z.literal(true),
}) satisfies z.ZodType<Wire<ResponseValue<'skill.invoke'>>>

View File

@@ -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<RpcResponse<{ skills: readonly SkillEntry[] }>>
/**
* Injects one user-invocable skill into the addressed agent as a user-role
* message (the canonical `<skill_content>` 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<RpcResponse<{ accepted: true }>>
}

View File

@@ -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<RpcResponse<ResponseValue<'skill.list'>>>
invoke(payload: RequestPayload<'skill.invoke'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.invoke'>>>
}
events: {
mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
@@ -186,7 +185,6 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'command.list': commandListValueSchema,
'command.execute': commandExecuteValueSchema,
'skill.list': skillListValueSchema,
'skill.invoke': skillInvokeValueSchema,
'goal.create': goalCreateValueSchema,
'goal.edit': goalEditValueSchema,
'goal.pause': goalPauseValueSchema,
@@ -443,7 +441,6 @@ export abstract class AbstractApiClient implements IApiClient {
readonly skills: IApiClient['skills'] = {
list: (payload, signal) => this.callUnary('skill.list', payload, signal),
invoke: (payload, signal) => this.callUnary('skill.invoke', payload, signal),
}
readonly goals: IApiClient['goals'] = {

View File

@@ -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) },

View File

@@ -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<typeof vi.fn> } {
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('<skill_content name="user-only">')
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('</skill_content>')).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()

View File

@@ -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,

View File

@@ -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 () => {

View File

@@ -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', () => {