From 62c308f4157d1b30cb5be9ad56e70c721c016e10 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 00:40:06 +0800 Subject: [PATCH 01/18] feat(skill): share renderSkillContent and declare the skill-invocation message source The model-facing rendering moves from dsh-tool-skill to the dsh-skill seam so the skill tool result and the upcoming user-explicit invocation injection share one canonical shape. The seam also declares the skill-invocation MessageSource kind that injection will stamp on its user-role messages. --- packages/skill/skill/package.json | 2 + packages/skill/skill/src/index.ts | 91 ++++++++++++++++++++++++ packages/skill/skill/tests/skill.spec.ts | 63 ++++++++++++++++ packages/skill/skill/tsconfig.json | 3 + packages/skill/tool-skill/src/index.ts | 59 +-------------- 5 files changed, 162 insertions(+), 56 deletions(-) diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index 73469b89a7..f77f56f6d1 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -26,6 +26,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -33,6 +34,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index 32f7112542..f44386d51c 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -10,6 +10,7 @@ */ import { Context, Service } from 'cordis' +import { assertNever } from '@deepseek-ai/dsh-llm' import z from 'schemastery' import type Schema from 'schemastery' @@ -119,6 +120,96 @@ export function isUserInvocable(skill: Pick): boolea return skill.invocation.userInvocable } +/** + * Durable message source for a user-explicit skill invocation: the host + * injects the rendered skill as a user-role message carrying this source, so + * transcript consumers present the invocation from metadata instead of + * re-parsing the model-facing text. + */ +export interface SkillInvocationSource { + readonly kind: 'skill-invocation' + /** Invoked skill name, validated user-invocable at the injecting boundary. */ + readonly name: string + /** Trailing free text the user submitted after the skill token, when present. */ + readonly args?: string +} + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + /** A user-explicit skill invocation injected by the host. */ + 'skill-invocation': SkillInvocationSource + } +} + +/** + * Render one loaded skill for the model. The output is shared verbatim by the + * `skill` tool result and the user-explicit invocation injection, so the model + * sees one canonical `` shape on both paths. The name rides an + * escaped attribute; the body is embedded verbatim (skills are trusted local + * content, and user-supplied invocation text stays outside this wrapper). + * @param skill - name, provider, optional resource base, and body to render. + * @returns the complete model-facing `` block. + */ +export function renderSkillContent(skill: Pick): string { + const resourceHint = renderResourceHint(skill) + return [ + ``, + '', + ...resourceHint, + '', + '', + '', + skill.content, + '', + '', + ].join('\n') +} + +function renderResourceHint(skill: Pick): string[] { + const base = skill.resourceBase + if (base === undefined) { + return [ + `Resources for this skill are managed by provider "${escapeText(skill.provider)}".`, + 'Load referenced resources only as needed.', + ] + } + switch (base.kind) { + case 'directory': + return [ + `Base directory for this skill: ${escapeText(base.path)}`, + 'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.', + ] + case 'url': + return [ + `Base URL for this skill: ${escapeText(base.url)}`, + 'Resolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed.', + ] + case 'opaque': + return [ + `Resources for this skill: ${escapeText(base.description)}`, + 'Load referenced resources only as needed.', + ] + /* v8 ignore start -- SkillResourceBase is a closed union; a future kind must fail compilation here. */ + default: + return assertNever(base, 'SkillResourceBase.kind') + /* v8 ignore stop */ + } +} + +function escapeAttr(value: string): string { + return value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<') +} + +/** + * Escape model-facing prose embedded inside skill markup so provider-supplied + * text cannot open or close framing tags. + * @param value - raw prose to embed. + * @returns the escaped text. + */ +export function escapeText(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') +} + /** One catalog observation plus whether discovery completed within a stable catalog revision. */ export interface SkillCatalogSnapshot { /** Sorted invocation-neutral summaries collected in this observation. */ diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index 39ef384f3c..d48263cfe0 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -3,6 +3,7 @@ import { Context } from 'cordis' import SkillService, { isModelInvocable, isUserInvocable, + renderSkillContent, type SkillCandidate, type SkillDefinition, type SkillInvocationPolicy, @@ -1013,3 +1014,65 @@ describe('SkillService registry', () => { expect(await ctx.skills.get('same-skill')).toBeUndefined() }) }) + +describe('renderSkillContent', () => { + it('renders a directory-based skill with the shared wrapper', () => { + const text = renderSkillContent({ + name: 'demo-skill', + provider: 'memory', + resourceBase: { kind: 'directory', path: '/tmp/demo' }, + content: 'Do the thing.', + }) + expect(text).toBe([ + '', + '', + 'Base directory for this skill: /tmp/demo', + 'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.', + '', + '', + '', + 'Do the thing.', + '', + '', + ].join('\n')) + }) + + it('renders url and opaque resource hints', () => { + const url = renderSkillContent({ + name: 'url-skill', + provider: 'memory', + resourceBase: { kind: 'url', url: 'https://example.test/base/' }, + content: 'Body.', + }) + expect(url).toContain('Base URL for this skill: https://example.test/base/') + expect(url).toContain('Resolve relative URLs mentioned by this skill against the base URL before using them.') + + const opaque = renderSkillContent({ + name: 'opaque-skill', + provider: 'memory', + resourceBase: { kind: 'opaque', description: 'archive ' }, + content: 'Body.', + }) + expect(opaque).toContain('Resources for this skill: archive <bundle>') + }) + + it('falls back to the provider hint without a resource base', () => { + const text = renderSkillContent({ + name: 'provider-skill', + provider: 'remote ', + content: 'Body.', + }) + expect(text).toContain('Resources for this skill are managed by provider "remote <hub>".') + }) + + it('escapes hostile attribute names and keeps the body verbatim', () => { + const text = renderSkillContent({ + name: 'x"& and as-is.', + }) + expect(text).toContain('') + expect(text).toContain('Keep and as-is.') + }) +}) diff --git a/packages/skill/skill/tsconfig.json b/packages/skill/skill/tsconfig.json index e882ed2d72..82e62d7c91 100644 --- a/packages/skill/skill/tsconfig.json +++ b/packages/skill/skill/tsconfig.json @@ -15,6 +15,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../llm/llm" + }, { "path": "../../support/invariants" } diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index ddc45d18e9..19e154143d 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -9,12 +9,13 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { defineTool } from '@deepseek-ai/dsh-tools' -import { assertNever, createUserMessage } from '@deepseek-ai/dsh-llm' +import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' import { + escapeText, isModelInvocable, isSkillName, - type SkillDefinition, + renderSkillContent, type SkillSummary, } from '@deepseek-ai/dsh-skill' @@ -203,52 +204,6 @@ export function apply(ctx: Context, config: Config = {}): void { }) } -function renderSkillContent(skill: Pick): string { - const resourceHint = renderResourceHint(skill) - return [ - ``, - '', - ...resourceHint, - '', - '', - '', - skill.content, - '', - '', - ].join('\n') -} - -function renderResourceHint(skill: Pick): string[] { - const base = skill.resourceBase - if (base === undefined) { - return [ - `Resources for this skill are managed by provider "${escapeText(skill.provider)}".`, - 'Load referenced resources only as needed.', - ] - } - switch (base.kind) { - case 'directory': - return [ - `Base directory for this skill: ${escapeText(base.path)}`, - 'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.', - ] - case 'url': - return [ - `Base URL for this skill: ${escapeText(base.url)}`, - 'Resolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed.', - ] - case 'opaque': - return [ - `Resources for this skill: ${escapeText(base.description)}`, - 'Load referenced resources only as needed.', - ] - /* v8 ignore start -- SkillResourceBase is a closed union; a future kind must fail compilation here. */ - default: - return assertNever(base, 'SkillResourceBase.kind') - /* v8 ignore stop */ - } -} - function renderCatalogMessage(entries: SkillCatalogSource['entries']): UserMessage { return createUserMessage({ content: [{ @@ -393,11 +348,3 @@ function assertPositiveInteger(name: string, value: number, minimum = 1): void { throw new Error(`tool-skill: ${name} must be an integer greater than or equal to ${minimum}`) } } - -function escapeAttr(value: string): string { - return value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<') -} - -function escapeText(value: string): string { - return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') -} From 85422f44dc512ee5365f513b0aa5f44e11c62ddf Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 00:51:24 +0800 Subject: [PATCH 02/18] feat(host): user-invocable skill listing and skill.invoke injection RPC skill.list now serves every user-invocable skill and carries modelInvocable so menus can mark user-only entries; the old model-and-user intersection hid disable-model-invocation skills from their only legitimate entry point (issue #1470). skill.invoke enforces user-invocation policy at the host boundary, renders the canonical body, and injects it as a user-role message carrying the skill-invocation source before starting a turn. The connection fixture mirrors both faces for client tests. --- .../client/connection/src/client/fixture.ts | 21 +++- packages/host/apiproxy/src/api-proxy.ts | 58 ++++++++- packages/host/apiproxy/src/api/rpc-map.ts | 1 + packages/host/apiproxy/src/api/rpc.schema.ts | 2 + packages/host/apiproxy/src/api/rpc.ts | 4 + .../host/apiproxy/src/api/skills.schema.ts | 13 ++ packages/host/apiproxy/src/api/skills.ts | 18 ++- packages/host/apiproxy/src/fetch/client.ts | 5 +- packages/host/apiproxy/src/fetch/handler.ts | 3 +- .../apiproxy/tests/api-proxy-commands.spec.ts | 116 +++++++++++++++++- .../apiproxy/tests/client-handler.spec.ts | 2 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 9 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 25 +++- 13 files changed, 261 insertions(+), 16 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 776d21fd46..75653d43e3 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2449,10 +2449,28 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { if (missing !== undefined) return missing return ok(request, { skills: [ - { name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收' }, + { name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收', modelInvocable: true }, + { name: 'fixture-user-only', description: 'fixture 仅用户技能样本', modelInvocable: false }, ], }) }, + invoke: (request) => { + 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 @@ -2761,6 +2779,7 @@ 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) 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/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index e4b715a0c4..6384a4d408 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -18,6 +18,8 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' import { SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent' +import { isSkillName, isUserInvocable, renderSkillContent } from '@deepseek-ai/dsh-skill' +import type { SkillInvocationSource } from '@deepseek-ai/dsh-skill' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, @@ -2359,19 +2361,71 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} }) } try { - const skills = (await skillRegistry.list({ cwd })) - .filter(skill => skill.invocation.modelInvocable && skill.invocation.userInvocable) + const skills = (await skillRegistry.list({ cwd })).filter(isUserInvocable) return ok(request, { skills: skills.map(skill => ({ name: skill.name, description: skill.description, ...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse }, + modelInvocable: skill.invocation.modelInvocable, })), }) } catch (error: unknown) { return err(request, { code: 'internal', message: `skill listing failed: ${String(error)}`, details: {} }) } }, + + async invoke(request) { + const { sessionId, name, text } = request.payload + const found = await agentFor(sessionId) + if ('error' in found) return err(request, found.error) + const agent = found.agent + // Same turn-start refusal boundary as sessions.prompt: injection + // starts a turn, so a route no adapter serves is refused while the + // composer still shows the draft. + const target = targetFor(agent).current + if (!routeServed(target.provider)) { + return err(request, { + code: 'model-unavailable', + message: `no adapter serves provider "${target.provider}"; select a model for this session`, + details: { provider: target.provider, model: target.model }, + }) + } + const skillRegistry = ctx.get('skills') + if (skillRegistry === undefined) { + return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} }) + } + const lookup = { cwd: agent.session.header.cwd } + // isSkillName guards the registry contract; an ill-formed name is + // indistinguishable from an absent one for the caller. + const summary = isSkillName(name) + ? (await skillRegistry.list(lookup)).find(skill => skill.name === name) + : undefined + if (summary === undefined) { + return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } }) + } + // The operation boundary owns user-invocation policy: client menus + // filtering their candidates is an affordance, not enforcement. + if (!isUserInvocable(summary)) { + return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } }) + } + const skill = await skillRegistry.get(name, lookup) + if (skill === undefined) { + return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } }) + } + const body = renderSkillContent(skill) + const source: SkillInvocationSource = { kind: 'skill-invocation', name, ...text === undefined ? {} : { args: text } } + try { + const message: UserMessage = createUserMessage({ + content: [{ type: 'text', text: text === undefined ? body : `${body}\n\n${text}` }], + source, + }) + agent.followup(message) + } catch (error: unknown) { + return err(request, { code: 'agent-busy', message: 'skill invocation rejected', details: { reason: String(error) } }) + } + return ok(request, { accepted: true as const }) + }, }, settings: { diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 9a8750c722..b001d54625 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -50,6 +50,7 @@ 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 2733c6e940..dd3fe7cf57 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -51,6 +51,8 @@ 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 54bbb5a8cc..7bf41a32e1 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -51,6 +51,10 @@ 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 3bf7ad429a..c1ee1024a3 100644 --- a/packages/host/apiproxy/src/api/skills.schema.ts +++ b/packages/host/apiproxy/src/api/skills.schema.ts @@ -14,6 +14,7 @@ export const skillEntrySchema = z.object({ name: z.string().min(1), description: z.string(), whenToUse: z.string().optional(), + modelInvocable: z.boolean(), }) satisfies z.ZodType> /** skill.list request payload. */ @@ -25,3 +26,15 @@ export const skillListRequestSchema = z.object({ export const skillListValueSchema = z.object({ skills: z.array(skillEntrySchema), }) satisfies z.ZodType>> + +/** skill.invoke request payload. */ +export const skillInvokeRequestSchema = z.object({ + sessionId: sessionIdSchema, + name: z.string().min(1), + text: z.string().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 33802dd4c0..2ade72efb9 100644 --- a/packages/host/apiproxy/src/api/skills.ts +++ b/packages/host/apiproxy/src/api/skills.ts @@ -10,16 +10,28 @@ import type { RpcRequest, RpcResponse } from './rpc.ts' /** Skill catalog row (wire projection of the host SkillSummary; provider/source vocabulary stays host-side). */ export interface SkillEntry { - /** Kebab-case identifier referenced as `name` in prompts. */ + /** Kebab-case identifier the user references as `/name` in the composer. */ readonly name: string /** Short routing description. */ readonly description: string /** Optional extra routing guidance. */ readonly whenToUse?: string + /** False marks a user-only skill (`disable-model-invocation`): invocable here, absent from the model catalog. */ + readonly modelInvocable: boolean } -/** Skill-domain unary methods (the map key skill.* of RpcMethodMap). */ +/** Skill-domain unary methods (the map keys skill.* of RpcMethodMap). */ export interface SkillsApi { - /** Lists skills usable by the browser's user-selected model-reference path. */ + /** 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: a model-only or unknown name is refused regardless of what a client + * menu offered. Session-backed subagents reject with `agent-busy`. + */ + invoke(request: RpcRequest<{ sessionId: SessionId; name: string; text?: string }>): + Promise> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 0f54d76dbc..574206458b 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 { skillListValueSchema } from '../api/skills.schema.ts' +import { skillInvokeValueSchema, skillListValueSchema } from '../api/skills.schema.ts' import { goalCreateValueSchema, goalEditValueSchema, @@ -118,6 +118,7 @@ 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> @@ -185,6 +186,7 @@ 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 d41b51ad6d..914c425e91 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 { skillListRequestSchema } from '../api/skills.schema.ts' +import { skillInvokeRequestSchema, skillListRequestSchema } from '../api/skills.schema.ts' import { goalCreateRequestSchema, goalEditRequestSchema, @@ -109,6 +109,7 @@ 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) => api.skills.invoke(r) }, '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 55781a3e77..7d7062023e 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -228,7 +228,10 @@ describe('skill.list', () => { // touch (or resume through) the Agent registry. const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) const value = expectOk(await api.skills.list(request({ sessionId: session.id }))) - expect(value.skills).toEqual([{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' }]) + expect(value.skills).toEqual([ + { name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', modelInvocable: true }, + { name: 'user-only', description: 'User-only', modelInvocable: false }, + ]) expect(seenCwds).toEqual(['/proj']) expect(ctx.agents.get(session.id)).toBeUndefined() }) @@ -266,6 +269,117 @@ 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 } + } + + 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', + }))) + 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' }))) + 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' }))) + expect(error.code).toBe('skill-not-invocable') + 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' }))) + expect(missing.code).toBe('skill-not-found') + const invalid = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'Not A Name' }))) + expect(invalid.code).toBe('skill-not-found') + }) + + 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' }))) + expect(error.code).toBe('agent-busy') + }) + + 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' }))) + 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 ebd56ee551..0a65c817c6 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: [] }), ...overrides.skills }, + skills: { list: r => ok(r, { skills: [] }), invoke: r => ok(r, { accepted: true as const }), ...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 22e1650f5b..09cabdcc7f 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -196,7 +196,10 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra }, skills: { async list(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } } } + 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: { @@ -381,7 +384,9 @@ describe('unary round trip (handler ⇄ client, no network)', () => { const miss = await c.commands.execute({ sessionId: 's' as never, line: '/nope' }) 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' }] } }) + 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 28f9138502..253ac92fdf 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, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' +import { skillEntrySchema, skillInvokeRequestSchema, skillInvokeValueSchema, 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,6 +74,8 @@ 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') }) @@ -81,6 +83,7 @@ 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() }) @@ -395,12 +398,26 @@ describe('skills domain schemas', () => { expect(() => skillListRequestSchema.parse({})).toThrow() expect(skillListValueSchema.parse({ skills: [] }).skills).toEqual([]) const value = skillListValueSchema.parse({ skills: [ - { name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' }, - { name: 'bare', description: 'No guidance' }, + { name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', modelInvocable: true }, + { name: 'bare', description: 'No guidance', modelInvocable: false }, ] }) expect(value.skills[0]?.whenToUse).toBe('when committing') expect(value.skills[1]?.whenToUse).toBeUndefined() - expect(() => skillEntrySchema.parse({ name: '', description: 'd' })).toThrow() + expect(value.skills[1]?.modelInvocable).toBe(false) + expect(() => skillEntrySchema.parse({ name: '', description: 'd', modelInvocable: true })).toThrow() + // 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() + expect(skillInvokeValueSchema.parse({ accepted: true })).toEqual({ accepted: true }) + expect(() => skillInvokeValueSchema.parse({ accepted: false })).toThrow() }) }) From cc0f6e11b9e108c42fd9619bfd115863e937ef5f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 00:55:37 +0800 Subject: [PATCH 03/18] feat(tool-skill): teach the catalog about user-explicit skill injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both catalog renderings now tell the model that a directly invoked skill arrives as an inline block to follow without re-loading it through the skill tool — the seam rule that keeps the user-explicit path and the model-autonomous path from double-injecting one skill. --- examples/acp-agent/tests/snapshots/skill-load/session.jsonl | 2 +- packages/skill/tool-skill/src/index.ts | 2 ++ packages/skill/tool-skill/tests/tool-skill.spec.ts | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index f30dc715cf..ec369b492a 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -5,7 +5,7 @@ {"type":"step/start","seq":3,"time":1785498773754,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498773754,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"0ca31b92-27ac-451d-98d3-d1e5f605454b"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1785498773755,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"3fc7e2f8-90fc-496c-b516-700cef1d86f1"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730426818,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"model-only-skill","description":"Prove user-disabled skills remain available to the model."},{"name":"snapshot-skill","description":"Exercise project skill discovery and loading in snapshot tests."}]},"role":"user","id":"60880315-9799-44c8-8a99-e6fe9ee5bdc5"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1785730426818,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"model-only-skill","description":"Prove user-disabled skills remain available to the model."},{"name":"snapshot-skill","description":"Exercise project skill discovery and loading in snapshot tests."}]},"role":"user","id":"60880315-9799-44c8-8a99-e6fe9ee5bdc5"},"surfaceOp":"append"} {"type":"session/title","seq":7,"time":1785730426818,"data":{"title":"Load the snapshot-skill skill with","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":8,"time":1785498773756,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":9,"time":1785730426819,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 19e154143d..aa9b509206 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -217,6 +217,7 @@ function renderCatalogMessage(entries: SkillCatalogSource['entries']): UserMessa '', '', "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.", + 'A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.', '', ].join('\n'), }], @@ -235,6 +236,7 @@ function renderCatalogUpdate(entries: SkillCatalogSource['entries']): UserMessag ] : [ 'Use only names in this replacement catalog. If the user names a listed skill, or the task clearly matches its description, call the `skill` tool with the exact name before acting.', + 'A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.', ] return createUserMessage({ content: [{ diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 0755e398a0..9543c196af 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -287,6 +287,7 @@ describe('dsh-tool-skill', () => { '', '', "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.", + 'A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.', '', ].join('\n'), }], From 56e9e617498a5ac9bc5a8c4b1878c776bb2c299a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 00:59:55 +0800 Subject: [PATCH 04/18] feat(ui-skill): claim slash skill references into skill.invoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A menu pick or an entered /name line now claims the composer into an args-tolerant skill.invoke transaction instead of shipping the literal text and hoping the model loads the skill. This gives every user-invocable skill a deterministic entry point — including disable-model-invocation skills the catalog never shows the model (issue #1470). Candidates carry a user-only hint, and the unreached legacy reference codec is removed (decision 21 removal cut). --- packages/client/connection/tests/fake-api.ts | 4 + packages/client/runtime/tests/fake-api.ts | 4 + packages/client/ui-skill/src/client/index.ts | 71 ++++++++++---- .../client/ui-skill/src/client/locales.ts | 2 + .../ui-skill/tests/browser-plugin.spec.ts | 92 +++++++++++++++---- 5 files changed, 139 insertions(+), 34 deletions(-) diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index cc1062843e..bd8efaf6a4 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -163,6 +163,9 @@ 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)), execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)), @@ -170,6 +173,7 @@ 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/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index b6f2884837..def535a59a 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -198,6 +198,9 @@ 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)), execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)), @@ -205,6 +208,7 @@ 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/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 9631125801..7d859bf2fb 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -2,13 +2,15 @@ * Skill reference plugin, browser half: registers the '/' skill source — * candidates from the skill.list RPC addressed by the per-call session * projection's sessionId (sessions are always agent-backed; the host - * resolves cwd from the session header), pick inserts the literal `/name ` - * text (decision 21: the draft carries plain text, chip visuals are derived - * by scanning against the source lexicon, and the prompt ships the same - * literal — no `` tag). The RPC rides the plugin's root-context - * connection captured at registration — the source never reads services off - * a per-call argument. No adjudication hooks: skill references ride - * ordinary prompts and never enter command adjudication. + * resolves cwd from the session header). A 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). * * Catalog fetches are cached per session (the small twin of the ui-command * directory): the per-keystroke candidates re-poll filters a settled @@ -25,7 +27,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 { SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' +import type { PickOutcome, 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' @@ -119,6 +121,30 @@ export function apply(ctx: ClientContext): void { for (const key of [...fetches.keys()]) invalidate(key) } + /** User-only marker in the active language (the menu hint is plain text, resolved at candidate time). */ + const userOnlyHint = (): string => ctx.locale.getSnapshot().active === 'zh' ? zh['menu.userOnly'] : en['menu.userOnly'] + + /** + * 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', @@ -129,7 +155,11 @@ export function apply(ctx: ClientContext): void { if (signal.aborted) return [] return skills .filter(skill => skill.name.startsWith(query)) - .map(skill => ({ name: skill.name, description: skill.description })) + .map(skill => ({ + name: skill.name, + description: skill.description, + ...skill.modelInvocable ? {} : { hint: userOnlyHint() }, + })) }, warm(session) { // Fire-and-forget scope-birth prewarm; the shared fetch reports @@ -149,16 +179,21 @@ export function apply(ctx: ClientContext): void { if (listeners.size === 0) lexiconListeners.delete(key) } }, - onPick({ candidate }) { - // Decision 21: plain-text reference — the literal lands in the draft - // and ships to the model verbatim (trailing space closes the token). - // Legacy path (decision 21), retained for the removal cut, no longer reached: - // return { insert: { source: 'skill', ref: candidate.name, label: candidate.name, clipboardText: `/${candidate.name}` } } - return { text: `/${candidate.name} ` } + onPick({ candidate, session }) { + return invokeClaim(session, candidate.name) }, - codec: { - clipboardText: ref => `/${ref}`, - serialize: ref => Promise.resolve(`${ref}`), + 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) }, } const slash = ctx.get('slash') as SlashServiceContract diff --git a/packages/client/ui-skill/src/client/locales.ts b/packages/client/ui-skill/src/client/locales.ts index 53746397bc..40ef78dea5 100644 --- a/packages/client/ui-skill/src/client/locales.ts +++ b/packages/client/ui-skill/src/client/locales.ts @@ -9,6 +9,7 @@ export const zh = { 'row.failed': 'skill 加载失败', 'row.stopped': 'skill 加载已中止', 'row.instructions': '说明', + 'menu.userOnly': '仅用户', } satisfies Record /** The skill namespace key union. */ @@ -20,4 +21,5 @@ export const en = { 'row.failed': 'Skill load failed', 'row.stopped': 'Skill load stopped', 'row.instructions': 'Instructions', + 'menu.userOnly': 'user-only', } satisfies Record diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index 9b047a3713..e38adf7686 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -20,11 +20,15 @@ import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client- import { apply, inject } from '../src/client/index.ts' import { SkillRow as SkillToolRow } from '../src/client/SkillRow.tsx' -type SkillRow = { name: string; description: string; whenToUse?: string } +type SkillRow = { name: string; description: string; whenToUse?: string; modelInvocable?: boolean } type ListResult = | { ok: true; value: { skills: SkillRow[] } } | { ok: false; error: { code: string; message: string; details: object } } type ListFn = (payload: object, signal?: AbortSignal) => Promise<{ result: ListResult }> +type InvokeResult = + | { ok: true; value: { accepted: true } } + | { ok: false; error: { code: string; message: string; details: object } } +type InvokeFn = (payload: object) => Promise<{ result: InvokeResult }> interface PresentationCapture { slots: SlotsService @@ -49,16 +53,18 @@ function providePresentation(ctx: Context): PresentationCapture { capture.dictionaries.push({ namespace, dictionaries }) return () => { capture.localeDisposed = true } }, + getSnapshot: () => ({ active: 'zh', locales: ['zh', 'en'], revision: 0 }), }) return capture } /** Boot the plugin over fake slash/connection faces; returns the captured source and its ctx. */ -async function bench(list: ListFn, addressed?: SessionId) { +async function bench(list: ListFn, addressed?: SessionId, invoke?: InvokeFn) { const ctx = new Context() let captured: SlashSource | undefined ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } }) - ctx.provide('connection', { api: { skills: { list } } }) + const defaultInvoke: InvokeFn = () => Promise.resolve({ result: { ok: true as const, value: { accepted: true as const } } }) + ctx.provide('connection', { api: { skills: { list, invoke: invoke ?? defaultInvoke } } }) ctx.provide('sessions', { subagentAddress: (id: SessionId) => id === addressed ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } @@ -70,9 +76,9 @@ async function bench(list: ListFn, addressed?: SessionId) { } const CATALOG: SkillRow[] = [ - { name: 'commit-helper', description: 'commit flow' }, - { name: 'code-review', description: 'review flow', whenToUse: 'reviews' }, - { name: 'deploy', description: 'deploy flow' }, + { name: 'commit-helper', description: 'commit flow', modelInvocable: true }, + { name: 'code-review', description: 'review flow', whenToUse: 'reviews', modelInvocable: true }, + { name: 'deploy', description: 'deploy flow', modelInvocable: true }, ] const listOk = (skills: SkillRow[]): ListFn => () => Promise.resolve({ result: { ok: true as const, value: { skills } } }) @@ -117,12 +123,14 @@ describe('apply', () => { 'row.failed': 'skill 加载失败', 'row.stopped': 'skill 加载已中止', 'row.instructions': '说明', + 'menu.userOnly': '仅用户', }, en: { 'row.running': 'Loading skill', 'row.failed': 'Skill load failed', 'row.stopped': 'Skill load stopped', 'row.instructions': 'Instructions', + 'menu.userOnly': 'user-only', }, }, }]) @@ -313,9 +321,10 @@ describe('lexicon', () => { }) }) -describe('pick and codec', () => { - it('onPick returns the literal /name text with a closing space (decision 21)', async () => { - const { source } = await bench(listOk(CATALOG)) +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) const outcome = source.onPick({ candidate: { name: 'commit-helper', description: 'commit flow' }, session: proj('s1'), @@ -323,21 +332,72 @@ describe('pick and codec', () => { via: 'menu', span: { start: 0, end: 4, draftRev: 7 }, }) - expect(outcome).toEqual({ text: '/commit-helper ' }) + 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' }) }) - it('codec projects clipboard `/name` and serializes the model form name', async () => { + 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 () => { const { source } = await bench(listOk(CATALOG)) - expect(source.codec!.clipboardText('deploy')).toBe('/deploy') - await expect(source.codec!.serialize('deploy', new AbortController().signal)) - .resolves.toBe('deploy') + expect(source.codec).toBeUndefined() }) }) describe('adjudication', () => { - it('never participates: no matchSpace/matchEnter hooks on the skill source', async () => { + 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') + }) +}) + +describe('user-only marking', () => { + it('carries the user-only hint on candidates the model cannot invoke', async () => { + const rows: SkillRow[] = [ + { name: 'shared-skill', description: 'both surfaces', modelInvocable: true }, + { name: 'user-only-skill', description: 'user surface only', modelInvocable: false }, + ] + const { source } = await bench(listOk(rows)) + const candidates = await source.candidates(proj('s1'), req('')) + expect(candidates).toEqual([ + { name: 'shared-skill', description: 'both surfaces' }, + { name: 'user-only-skill', description: 'user surface only', hint: '仅用户' }, + ]) }) }) From 011e3e4e63f4cae663bf2aa7a4523c92c9786f68 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 01:05:08 +0800 Subject: [PATCH 05/18] feat(client): render user skill invocations as dedicated transcript cards A user/message carrying the skill-invocation source materializes as its own conversation node (name/args lifted off the source metadata, never re-parsed from the body) and renders as a right-aligned bubble: the /name chip plus the user's trailing text, with the injected collapsed behind a disclosure. A record with an unreadable name degrades to the injected-context row. --- packages/client/runtime/src/client/index.ts | 2 +- .../src/client/sessions/conversation.ts | 20 ++++++++++ .../src/client/sessions/transcript-adapter.ts | 16 +++++++- .../runtime/tests/transcript-adapter.spec.ts | 25 ++++++++++++ .../src/client/chat/MessageItem.module.css | 27 +++++++++++++ .../src/client/chat/MessageItem.tsx | 39 ++++++++++++++++++- .../ui-conversation/src/client/locales.ts | 2 + .../tests/chat-branch-tails.spec.tsx | 36 +++++++++++++++++ 8 files changed, 163 insertions(+), 4 deletions(-) diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 5a1677df96..a0aa4df482 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -49,7 +49,7 @@ export type { AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase, ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage, - RunningToolCall, + RunningToolCall, SkillInvocationNode, SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' export type { diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index fb2c281331..d66faf5e95 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -129,6 +129,25 @@ 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' @@ -245,6 +264,7 @@ export type ConversationNode = | 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 d970be596b..4a05afee06 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -57,7 +57,20 @@ function materializeNode( stepTimings: ReadonlyMap, ): ConversationNode { switch (event.type) { - case 'user/message': + 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). @@ -80,6 +93,7 @@ function materializeNode( kind: 'user', seq: event.seq, time: event.time, content: event.data.content, source: event.data.source, } + } case 'assistant/message': return { kind: 'assistant', seq: event.seq, time: event.time, diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index e4ef3b0e1a..e847c2cec7 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -164,6 +164,31 @@ describe('TranscriptAdapter', () => { expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context']) }) + it('materializes a skill-invocation source as its dedicated node', () => { + 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, + }) }), + at(1, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ + content: [{ type: 'text', text: 'body' }], + source: { kind: 'skill-invocation', name: 'bare-skill' } 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') + }) + it('skips events core does not call surface-eligible, marker or not', () => { // The transcript is the append-origin surface, so log-only events (a chunk, // a turn boundary, a compact/* provenance record) and a future type core 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 5c07ace71e..4330cde32c 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -256,3 +256,30 @@ 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 5473c9f8a2..661dd0cda5 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, SteeringMessageNode, - TurnErrorNode, UnknownSurfaceNode, UserMessageNode, + CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SkillInvocationNode, + 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,6 +22,7 @@ export interface MessageItemProps { | UserMessageNode | SteeringMessageNode | ContextMessageNode + | SkillInvocationNode | CompactionSummaryNode | ModelRetryNode | TurnErrorNode @@ -193,6 +194,38 @@ 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. @@ -254,6 +287,8 @@ 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 df107d2cd2..a340a2f634 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -79,6 +79,7 @@ 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': '压缩摘要不可用', @@ -219,6 +220,7 @@ 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 3122b0fdc7..9471461cda 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -864,6 +864,42 @@ describe('MessageItem arms', () => { view.rerender() 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', () => { From 0fb474f67206e87f90ef77968a7c3e240da8038a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 01:27:25 +0800 Subject: [PATCH 06/18] test(web): cover user-only skill invocation end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The policy scenario now expects the user-only quadrant in the menu with its marker (riding the description — the hint field is claim-state ghost text, which the menu never renders), and a new skill-user-invoke scenario drives /name args through the composer against the real host: the claim lands skill.invoke, the transcript shows the dedicated card with the collapsed body, and a paced replay answers the injected turn deterministically. --- apps/web/tests/skill-invocation-policy.e2e.ts | 11 +- apps/web/tests/skill-user-invoke.e2e.ts | 145 ++++++++++++++++++ .../skill-invocation-policy/menu.expected.md | 1 + .../skill-user-invoke/ui.expected.md | 31 ++++ packages/client/ui-skill/src/client/index.ts | 5 +- .../ui-skill/tests/browser-plugin.spec.ts | 4 +- 6 files changed, 189 insertions(+), 8 deletions(-) create mode 100644 apps/web/tests/skill-user-invoke.e2e.ts create mode 100644 apps/web/tests/snapshots/skill-user-invoke/ui.expected.md diff --git a/apps/web/tests/skill-invocation-policy.e2e.ts b/apps/web/tests/skill-invocation-policy.e2e.ts index 143bc0d4db..54cd15bf94 100644 --- a/apps/web/tests/skill-invocation-policy.e2e.ts +++ b/apps/web/tests/skill-invocation-policy.e2e.ts @@ -1,5 +1,6 @@ -// Web e2e scenario: the real host filters skill.list to the model-and-user -// intersection before the browser slash source renders candidates. A real +// Web e2e scenario: the real host serves every user-invocable skill to the +// browser slash source — user-only (disable-model-invocation) entries appear +// with their marker while user-disabled quadrants stay hidden. A real // chromium connects a fresh workspace seeded with all four policy quadrants; // no model call is issued, so a stray stream fails loud on the open LLM seam. import { mkdir, writeFile } from 'node:fs/promises' @@ -92,7 +93,7 @@ describe('web e2e: skill invocation policy through the real host', () => { await scaffold?.close() }) - it('renders only the model-and-user intersection in slash candidates', async () => { + it('renders every user-invocable skill and marks the user-only entry', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-invocation-policy')) const input = page.locator('textarea').first() await input.fill('/policy') @@ -102,8 +103,10 @@ describe('web e2e: skill invocation policy through the real host', () => { { timeout: 10_000 }, ).toBe(1) + // The user-only quadrant is invocable here — its only entry point — and + // wears the user-only marker; both user-disabled quadrants stay hidden. + expect(await menu.getByRole('option', { name: /policy-user-only user-only · / }).count()).toBe(1) expect(await menu.getByRole('option', { name: /policy-model-only/ }).count()).toBe(0) - expect(await menu.getByRole('option', { name: /policy-user-only/ }).count()).toBe(0) expect(await menu.getByRole('option', { name: /policy-trusted-only/ }).count()).toBe(0) const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) diff --git a/apps/web/tests/skill-user-invoke.e2e.ts b/apps/web/tests/skill-user-invoke.e2e.ts new file mode 100644 index 0000000000..f722472ded --- /dev/null +++ b/apps/web/tests/skill-user-invoke.e2e.ts @@ -0,0 +1,145 @@ +// 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. +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' +import { + assertFixtureInventory, + captureStableAria, + compareOrRefreshGolden, + launchWebScaffold, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-user-invoke', import.meta.url)) +const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const MODE = webSnapshotMode() + +const SKILL_NAME = 'user-invoke-demo' +const ARGS_TEXT = 'and confirm the fixture wiring' +const REPLY = 'USER_INVOKE_REPLY acknowledged; following the injected skill.' + +async function seedUserOnlySkill(workspaceCwd: string): Promise { + const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', SKILL_NAME) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'SKILL.md'), [ + '---', + `name: ${SKILL_NAME}`, + 'description: Prove user-explicit invocation of a model-hidden skill', + 'disable-model-invocation: true', + '---', + '', + 'Reply with the fixture acknowledgement line.', + '', + ].join('\n')) +} + +const REPLAY: ReplayOverrideDoc = [{ + kind: 'chunks', + chunks: [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: REPLY }, + { type: 'block-end', index: 0, block: { type: 'text', text: REPLY } }, + { type: 'usage', usage: { inputTokens: 256, outputTokens: 16 } }, + { type: 'finish', reason: { kind: 'stop' } }, + ], +}] + +describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation through the composer', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let replayDir: string + let tripwire: ReturnType + + beforeAll(async () => { + replayDir = await mkdtemp(join(tmpdir(), 'dsh-skill-user-invoke-replay-')) + const replayOverride = join(replayDir, 'replay.override.json') + await writeFile(replayOverride, JSON.stringify(REPLAY)) + scaffold = await launchWebScaffold({ + replayFixture: join(replayDir, 'override-only.jsonl'), + replayOverride, + // Paced replay keeps the timing-derived chrome (TTFT / tok/s) present + // deterministically; instant playback races it in and out of the golden. + paceMs: 10, + }) + await seedUserOnlySkill(scaffold.workspaceCwd) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + const failures: unknown[] = [] + await browser?.close().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (replayDir !== undefined) { + await rm(replayDir, { recursive: true, force: true }) + .catch((error: unknown) => failures.push(error)) + } + if (failures.length === 1) throw failures[0] + 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 () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-user-invoke')) + const composer = page.locator('textarea:enabled').last() + await composer.waitFor({ timeout: 15_000 }) + + // The menu lists the user-only skill (its only entry point) before enter. + await composer.fill(`/${SKILL_NAME}`) + const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) + await expect.poll( + () => menu.getByRole('option', { name: new RegExp(SKILL_NAME) }).count(), + { timeout: 10_000 }, + ).toBe(1) + + 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) + + 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 injection started a turn; the replay seam answers it. + await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 }) + + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md b/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md index 11acc39ad0..ca9230b6f1 100644 --- a/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md +++ b/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md @@ -1,3 +1,4 @@ - listbox "Trigger suggestions": - text: Skills - option "policy-shared Available to both model and user invocation" [selected] + - option "policy-user-only user-only · Available only to user invocation" diff --git a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md new file mode 100644 index 0000000000..b96413f89f --- /dev/null +++ b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md @@ -0,0 +1,31 @@ +- banner: + - navigation "Session hierarchy": + - button "workspace" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: /user-invoke-demo and confirm the fixture wiring +- group: View injected skill content +- text: {{clock}} +- button "Copy": + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "0% of context used" +- button "Send message" [disabled] +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 256 tok · Output 16 tok diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 7d859bf2fb..3e23cc997b 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -157,8 +157,9 @@ export function apply(ctx: ClientContext): void { .filter(skill => skill.name.startsWith(query)) .map(skill => ({ name: skill.name, - description: skill.description, - ...skill.modelInvocable ? {} : { hint: userOnlyHint() }, + // The user-only marker rides the description (the menu's only + // secondary text); `hint` is the claim-state ghost text, not a badge. + description: skill.modelInvocable ? skill.description : `${userOnlyHint()} · ${skill.description}`, })) }, warm(session) { diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index e38adf7686..0e098a0b30 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -388,7 +388,7 @@ describe('adjudication', () => { }) describe('user-only marking', () => { - it('carries the user-only hint on candidates the model cannot invoke', async () => { + it('prefixes the description of candidates the model cannot invoke', async () => { const rows: SkillRow[] = [ { name: 'shared-skill', description: 'both surfaces', modelInvocable: true }, { name: 'user-only-skill', description: 'user surface only', modelInvocable: false }, @@ -397,7 +397,7 @@ describe('user-only marking', () => { const candidates = await source.candidates(proj('s1'), req('')) expect(candidates).toEqual([ { name: 'shared-skill', description: 'both surfaces' }, - { name: 'user-only-skill', description: 'user surface only', hint: '仅用户' }, + { name: 'user-only-skill', description: '仅用户 · user surface only' }, ]) }) }) From 74ba0b532edd355a974541d1e1663a5f7c77f939 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 01:33:56 +0800 Subject: [PATCH 07/18] chore: sync the lockfile for the dsh-skill llm dependency --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd4ff1ea14..045d76aeab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5155,6 +5155,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis From db146f0eba2c4b26987beff5a7a2e243a2c2ebe8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 01:52:42 +0800 Subject: [PATCH 08/18] refactor(host): share the turn-start route refusal between prompt and skill.invoke turnAgentFor owns the addressed-agent resolution and the model-unavailable refusal both turn-starting methods repeat; the duplication gate flagged the copied block. --- packages/host/apiproxy/src/api-proxy.ts | 66 +++++++++++++------------ 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 6384a4d408..3970a801a3 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1248,6 +1248,35 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return llm === undefined || llm.listProviders().some(entry => entry.id === provider) } + /** + * Resolve the addressed agent for a turn-starting method and refuse when no + * adapter serves its current route: a route nothing serves cannot start a + * turn, and letting it try spends the whole pre-step path to fail inside + * the adapter with a message about registration. Refusing here names the + * model the session is pointed at while the draft is still in the composer. + * This is the enforcement boundary shared by `session.prompt` and + * `skill.invoke`: a client that disables its input is an affordance, and + * both methods stay callable regardless. + */ + async function turnAgentFor( + request: RpcRequest, sessionId: SessionId, + ): Promise<{ agent: Agent } | { refused: RpcResponse }> { + const found = await agentFor(sessionId) + if ('error' in found) return { refused: err(request, found.error) } + const agent = found.agent + const target = targetFor(agent).current + if (!routeServed(target.provider)) { + return { + refused: err(request, { + code: 'model-unavailable', + message: `no adapter serves provider "${target.provider}"; select a model for this session`, + details: { provider: target.provider, model: target.model }, + }), + } + } + return { agent } + } + /** Missing-service report shared by the settings domain (skills-domain stance). */ function settingsAbsent(): RpcError { return { code: 'internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-local) in its composition', details: {} } @@ -1784,23 +1813,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async prompt(request) { const { sessionId, mode, content } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - const agent = found.agent - // A route no adapter serves cannot start a turn, and letting it try - // spends the whole pre-step path to fail inside the adapter with a - // message about registration. Refusing here names the model the - // session is pointed at while the draft is still in the composer. - // This is the enforcement boundary: a client that disables its input - // is an affordance, and this method stays callable regardless. - const target = targetFor(agent).current - if (!routeServed(target.provider)) { - return err(request, { - code: 'model-unavailable', - message: `no adapter serves provider "${target.provider}"; select a model for this session`, - details: { provider: target.provider, model: target.model }, - }) - } + const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId) + if ('refused' in resolved) return resolved.refused + const agent = resolved.agent // The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation). const source: MessageSource = { kind: 'user', rpcId: request.rpcId } try { @@ -2377,20 +2392,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async invoke(request) { const { sessionId, name, text } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - const agent = found.agent - // Same turn-start refusal boundary as sessions.prompt: injection - // starts a turn, so a route no adapter serves is refused while the - // composer still shows the draft. - const target = targetFor(agent).current - if (!routeServed(target.provider)) { - return err(request, { - code: 'model-unavailable', - message: `no adapter serves provider "${target.provider}"; select a model for this session`, - details: { provider: target.provider, model: target.model }, - }) - } + const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId) + if ('refused' in resolved) return resolved.refused + const agent = resolved.agent const skillRegistry = ctx.get('skills') if (skillRegistry === undefined) { return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} }) From 3584d8e08804aae652dcaa43ed63052b6cddc50c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 01:52:43 +0800 Subject: [PATCH 09/18] docs(skill): document the user-explicit invocation path Bilingual README updates for the four touched packages (ui-skill's claim flow and deterministic-injection model experience, the apiproxy skills domain, the shared renderSkillContent seam export, the catalog stitch sentence), the implemented Agent Note triplet recording the decision and its peer-product evidence, and the regenerated catalogs/graphs. --- ...8-user-explicit-skill-invocation.i18n.yaml | 6 ++++ ...26-08-08-user-explicit-skill-invocation.md | 36 +++++++++++++++++++ ...08-08-user-explicit-skill-invocation.zh.md | 36 +++++++++++++++++++ docs/config-catalog.md | 4 +-- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- packages/client/ui-skill/README.i18n.yaml | 4 +-- packages/client/ui-skill/README.md | 15 ++++---- packages/client/ui-skill/README.zh.md | 15 ++++---- packages/host/apiproxy/README.i18n.yaml | 4 +-- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/skill/skill/README.i18n.yaml | 4 +-- packages/skill/skill/README.md | 4 +++ packages/skill/skill/README.zh.md | 4 +++ packages/skill/tool-skill/README.i18n.yaml | 4 +-- packages/skill/tool-skill/README.md | 3 +- packages/skill/tool-skill/README.zh.md | 3 +- 19 files changed, 121 insertions(+), 31 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md create mode 100644 .agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md 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 new file mode 100644 index 0000000000..ed9de78dbb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: 9249ee5c9c712e9c6aa827e97178f352728ed927 +2026-08-08-user-explicit-skill-invocation.zh.md: f15975c3b13fbf76e036fcece30253e78e7b417d 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 new file mode 100644 index 0000000000..9249ee5c9c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md @@ -0,0 +1,36 @@ +# Agent Note: User-explicit skill invocation over skill.invoke + +Status: implemented + +English | [中文](2026-08-08-user-explicit-skill-invocation.zh.md) + +## Problem + +A `disable-model-invocation: true` skill is user-only by design: it never enters the model-facing catalog and the `skill` tool refuses to load it. Its only legitimate entry point is an explicit user gesture — yet the web client had none. `skill.list` filtered to the model-and-user intersection (hiding user-only skills from the menu), an entered `/name` line rode into the default prompt sink as plain text, and the model it reached was forbidden to load the skill — so it degraded to `read`-ing the SKILL.md file or ignoring the gesture (issue #1470). Even for ordinary skills, the decision-21 plain-text reference made user invocation a collaboration cue the model could ignore, not a guarantee. + +## Decision + +User-explicit invocation is a deterministic host-side injection, uniform for every user-invocable skill: + +- `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. + +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. + +## 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. +- **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. + +## 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. +- 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. 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 new file mode 100644 index 0000000000..f15975c3b1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md @@ -0,0 +1,36 @@ +# Agent Note: 经 skill.invoke 的用户显式 skill 调用 + +Status: implemented + +[English](2026-08-08-user-explicit-skill-invocation.md) | 中文 + +## 问题 + +`disable-model-invocation: true` 的 skill(技能)在设计上就是仅限用户的:它绝不进入面向模型的目录,`skill` 工具也拒绝加载它。它唯一正当的入口是一次显式的用户手势——而 web 客户端此前没有这个入口。`skill.list` 过滤到模型与用户的交集(把仅限用户的 skill 挡在菜单之外),回车提交的 `/name` 一行以纯文本落入默认提示词 sink,而这行文本到达的模型又被禁止加载该 skill——于是退化为模型去 `read` 那份 SKILL.md 文件,或者干脆无视这次手势(issue #1470)。即使对普通 skill,决策 21 的纯文本引用也让用户调用只是模型可以忽略的协作线索,而不是保证。 + +## 决策 + +用户显式调用是一次确定性的宿主侧注入,对每一个用户可调用的 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 之后的注入块。 + +同类产品调研(Pi、OpenCode、Claude Code、Kimi Code、Codex、DeepSeek-Reasonix——本地检出)结论一致:在每个产品上,用户显式触发都是以 user 角色消息做程序化注入、模型零参与;提示词引导的工具加载只存在于模型自主轨道上;disable-model-invocation 的对应物只把关模型侧表层。Kimi 的来源元数据渲染与 Claude Code/Kimi 的禁止重载提示词规则,可直接平移到 `MessageSource` 与目录那句话上。 + +## 考虑过的替代方案 + +- **`agent.inject()` 上下文注入**——没有同类产品先例;这次手势是一个用户轮次,不是环境通知,而且上下文行呈现、压缩(compaction)与归属全都不匹配。否决。 +- **宿主 `/skill ` 命令**(命令注册表,plan 模式先例)——两 token 的 UX、没有名称补全、仅限用户的 skill 在菜单里仍不可发现;按 cwd 的 skill 目录也与静态命令注册表格格不入。否决。 +- **客户端展开**(拉取正文、拼进提示词)——授权沦为可被绕过的客户端善意,日志失去调用语义,而且 Codex 已删除其等价机制(custom prompts)转向核心注入。否决。 +- **宿主提示词流水线扫描 `/name`**(Codex 的 `$name` core mentions)——重复了裁决层,还有吞掉普通行文中字面斜杠的风险;认领路径已经覆盖了这一需求。否决。 +- **每次注入一条前导语**(Kimi 的 `User activated the skill …`)——弃用,改为一次性的目录句子:同样的上下文、只支付一次,且注入块与工具结果保持逐字节一致。 + +## 后果 + +- 决策 21 的纯文本引用路径在提交处被取代:草稿仍承载纯文本与 lexicon 派生的 chip 视觉,但提交会认领进一次确定性注入,而不是把字面文本发出去再碰运气。模型自主轨道(目录 + `skill` 工具)不变。 +- 每一次用户可调用 skill 的调用现在都无条件付出其完整渲染正文的成本——这是确定性的代价,同类调研表明所有产品都在支付。 +- `skill-invocation` 来源搭乘 `user/message`,因此「模型可见 ⟺ 已记录」在不新增事件类型的情况下继续成立,回放与 UI 读取的是元数据而非文本标记。 +- TUI 与 ACP 之后可以为同样的语义采用 `skill.invoke`;在那之前,TUI 的客户端展开仍是它自己的路径。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 21f38d18e2..9f1bf08f9d 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:170`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:261`](../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:58`](../packages/skill/tool-skill/src/index.ts) +Source: [`packages/skill/tool-skill/src/index.ts:59`](../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 4ad9797262..55952b3591 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:188`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:279`](../../packages/skill/skill/src/index.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4a73dc06ad..4abd00c1fd 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1946,7 +1946,7 @@ async get(name: string, options: SkillLookupOptions = {}): Promisename
` invoked at submit time. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. The source implements no `matchSpace`/`matchEnter` hooks — skill references never enter command adjudication and ride ordinary prompts into the default sink. +Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`. + +A 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). 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 failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states. @@ -14,23 +16,22 @@ The browser plugin also registers a keyed `skill` toolview in `conversation.chat ## Model Experience -### Skill reference text in the user prompt +### User-explicit skill invocation #### What the model sees -A picked candidate lands the literal `/name ` in the draft (decision 21: plain text, no `` tag); the text reaches the model verbatim inside the ordinary user message (`session.prompt`), with no dedicated content block, prompt section, or host-side expansion. The association with the actual skill is model-side and non-deterministic: the session prefix already carries the skill catalog (rendered by `dsh-tool-skill`), and the reference's name matching a catalog entry is what invites the model to load it. +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. #### Token effect -Conditional and tiny: only a pick (or hand-typing the same text) adds the reference's characters to that one user message. Menu browsing and the candidate fetch add zero model tokens. +One invocation adds the rendered skill body 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. #### KV Cache effect -Append-only: the reference is part of a new user message appended after the reusable history prefix. This package never edits earlier request tokens. +Append-only: the injected message lands after the reusable history prefix. This package never edits earlier request tokens. ## Known Limitations and Deferred Work - **Result-only history pages use the generic row** — keyed dispatch needs the paired call in the runtime window; pagination that leaves the call outside has no tool identity. This client presentation feature does not extend the history wire contract to recover it. -- **Non-deterministic skill loading** — the reference is a collaboration cue, not a guarantee; the model may ignore it. The rework path when hit rate proves insufficient (a host-side `context/skill-reference` guidance package, or full-text injection) sits in the design ledger; the wire text shape would not change. -- **First keystroke may race the prewarm** — the scope-birth warm launches the catalog fetch, but a menu opened before it settles shows no skill candidates for that keystroke. Accepted by design: skill references do not participate in enter adjudication, so nothing correctness-bearing waits on the catalog. +- **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). diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 6eb6cbd3ae..3bbbc90186 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -2,7 +2,9 @@ [English](README.md) | 中文 -skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主返回模型可调用与用户可调用 skill 的交集,因为该浏览器路径插入的是模型引用,而不是直接加载正文。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤;pick 一个候选会把字面文本 `/name ` 经 slash 流水线落进草稿(决策 21 的纯文本引用),source 的 `codec` 拥有该引用的两种投影:`clipboardText` → `/name`,`serialize` → 提交时生成的模型形式 `name`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace`/`matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。 +skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。 + +菜单 pick 或回车提交的一行 `/name [args]` 会把 composer 认领进一个容忍参数的 `skill.invoke` 事务(`matchEnter` 强等目录;未知名称应答 undefined,保持为普通提示词)。提交时会修剪参数、让空白参数不上协议,并把 RPC 拒绝折叠进 composer 的错误结局;宿主在开启轮次之前渲染 skill 正文并将其作为用户消息注入,因此对每一个用户可调用的 skill,调用都是确定性的。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。草稿 chip 视觉仍由 `lexicon` 扫描派生;旧的 `name` 引用 codec 已经移除(决策 21 的移除裁定),`matchSpace` 保持不实现——skill 流程归菜单与回车所有。 `skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。 @@ -14,23 +16,22 @@ skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` sourc ## 模型体验 -### 用户提示词中的 skill 引用文本 +### 用户显式 skill 调用 #### 模型看到的内容 -被 pick 的候选会把字面文本 `/name ` 落进草稿(决策 21:纯文本,无 `` 标签);该文本原样进入普通用户消息(`session.prompt`)到达模型,没有专用内容块、提示词 section 或 host 侧展开。与实际 skill 的关联在模型侧建立且具有非确定性:会话前缀已携带 skill 目录(由 `dsh-tool-skill` 渲染),引用名称与目录条目匹配,正是这一点引导模型去加载它。 +被认领的调用绝不会把字面文本 `/name` 发出去。宿主(`skill.invoke`)渲染规范的 `` 块——与 `skill` 工具返回的 `renderSkillContent` 输出相同——在一个空行之后追加用户的尾随文本,并把整体作为一条携带 `skill-invocation` 来源的 user 角色消息注入,随即开启一个轮次。加载是确定性的:模型无需被要求调用 `skill` 工具就能收到完整正文,目录(由 `dsh-tool-skill` 渲染)也会告诉它不要重新加载已内联注入的 skill。 #### Token 影响 -有条件且极小:只有 pick(或手动键入相同文本)会把引用的字符加进那一条用户消息。浏览菜单和拉取候选不会增加任何模型 token。 +一次调用会把渲染后的 skill 正文连同尾随文本加进该轮次的用户消息——成本与模型经由工具加载该 skill 相同,只是无条件支付,而非由模型自行裁量。浏览菜单和拉取候选不会增加任何模型 token。 #### KV Cache 影响 -仅追加:引用是追加在可复用历史前缀之后的新用户消息的一部分。该包绝不改写较早的请求 token。 +仅追加:注入的消息落在可复用历史前缀之后。该包绝不改写较早的请求 token。 ## 已知限制与暂缓事项 - **仅含结果的 history 页使用通用行**:键控分派要求配对调用位于 runtime 窗口内;分页将调用留在窗口外时,结果没有工具身份。这项客户端呈现功能不会为了恢复该身份而扩展 history 协议契约。 -- **skill 加载具有非确定性**:引用是协作线索,不是保证;模型可能忽略它。针对命中率不足情况的返工路径(host 侧 `context/skill-reference` 引导包,或全文注入)记录在设计台账中;协议中的文本形态不会改变。 -- **首次击键可能与预热竞速**:scope 创建时的预热会启动目录拉取,但目录落定之前打开的菜单,在那次击键下不会显示 skill 候选。这是设计上接受的取舍:skill 引用不参与回车裁决,因此没有任何攸关正确性的环节等待目录。 +- **回车对目录只等待一次**:`matchEnter` 在应答之前强等该会话的首次目录拉取,因此与冷缓存竞速的回车会对照已落定的目录解析,而不是静默错过。预热落定之前打开的菜单,在那次击键下仍不会显示 skill 候选。 - **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份或位置跟踪(组件化 chip 是台账事项)。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 0a0131d292..017bd32970 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: 7ac7bdc6db2e2abbc60d1a8813e229c21ed39fe7 -README.zh.md: d6ece5caed752cf0cc59cc97017549ec2b1e66cb +README.md: 8d7a24b0b8b897d94ed29d5dc9ed6e9efb250fc6 +README.zh.md: c988b7540ba719d02e50d6da9595353c93766835 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 7ac7bdc6db..8d7a24b0b8 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 browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `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 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 `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 d6ece5caed..c988b7540b 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` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`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.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` 而不是做差分。 `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/skill/skill/README.i18n.yaml b/packages/skill/skill/README.i18n.yaml index 03d1b13fe8..fe29171cb3 100644 --- a/packages/skill/skill/README.i18n.yaml +++ b/packages/skill/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/skill/skill/README.md -README.md: f538ae668ccff291be86348627d5547150f460df -README.zh.md: d61a242d01df1e22270c1cb049b922536654bbd6 +README.md: 0c1b2249d8c46ad9ce8097ceeda2bd988c92eb21 +README.zh.md: 8fed350d00433206aecdb32819adc81c82745869 diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index f538ae668c..0c1b2249d8 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -37,6 +37,10 @@ This package owns the `ctx.skills` interface. It does not know whether skills co | `{ modelInvocable: false, userInvocable: true }` | excluded | included | | `{ modelInvocable: false, userInvocable: false }` | excluded | excluded | +### Shared model-facing rendering + +`renderSkillContent(skill)` renders one loaded skill as the canonical `` block (escaped `name` attribute, resource hints, verbatim body). It is the single truth for both loading paths: `dsh-tool-skill` returns it as the `skill` tool result, and the host's user-explicit `skill.invoke` injects it as a user message, so the model sees one shape regardless of who initiated the load. `escapeText` is exported beside it for consumers embedding prose in the same markup frame. The package also declares the `skill-invocation` `MessageSource` kind ({ name, args? }) that user-explicit injection stamps on its messages — transcript consumers present the invocation from this metadata instead of re-parsing the body. + `isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field directly. `ctx.skills.get()` remains the trusted, policy-neutral loading primitive, so every user- or model-facing consumer must enforce the predicate that matches its surface before exposing or loading a skill. ## Provider Contract diff --git a/packages/skill/skill/README.zh.md b/packages/skill/skill/README.zh.md index d61a242d01..8fed350d00 100644 --- a/packages/skill/skill/README.zh.md +++ b/packages/skill/skill/README.zh.md @@ -37,6 +37,10 @@ | `{ modelInvocable: false, userInvocable: true }` | 排除 | 包含 | | `{ modelInvocable: false, userInvocable: false }` | 排除 | 排除 | +### 共享的面向模型渲染 + +`renderSkillContent(skill)` 把一个已加载 skill 渲染为规范的 `` 块(转义后的 `name` 属性、资源提示、原样正文)。它是两条加载路径的唯一真源:`dsh-tool-skill` 将其作为 `skill` 工具结果返回,宿主的用户显式 `skill.invoke` 将其作为用户消息注入,因此无论加载由谁发起,模型看到的都是同一种形态。`escapeText` 随之一并导出,供要在同一标记框架中嵌入文案的消费方使用。该包还声明 `skill-invocation` 这个 `MessageSource` kind({ name, args? }),用户显式注入会把它打在自己的消息上——transcript(文本记录)消费方依据这份元数据呈现该次调用,而不是重新解析正文。 + `isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别直接读取对应的正向字段。`ctx.skills.get()` 仍是受信且与策略无关的加载原语,因此每个面向用户或模型的消费方都必须先执行与自身接口匹配的判定,再暴露或加载 skill。 ## 提供方契约 diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index b57689d742..19fa44c67c 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-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/skill/tool-skill/README.md -README.md: 8e0bff5d1c4853092d412b8f7f9528d4b00d9626 -README.zh.md: c6b815bef59eb1f14be0892078694f129366d004 +README.md: 5c6e592c670f324eb660dbe1fec168fd77e5b368 +README.zh.md: 202a621b1d4047c7d763de3b98c1a69c8c1ee1f7 diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index 8e0bff5d1c..5c6e592c67 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -36,7 +36,7 @@ Tool execution does not add a synthetic context message. Its freshly loaded resu #### What the model sees -If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. +If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. The template's closing sentence is the seam rule against double-loading: the host's user-explicit `skill.invoke` injects the same `renderSkillContent` output (shared from `@deepseek-ai/dsh-skill`) inline, and the catalog tells the model to follow that block instead of re-loading the skill through the tool; the replacement-catalog template carries the same sentence. ##### Skill catalog template @@ -49,6 +49,7 @@ A skill is a reusable set of task-specific instructions. The following skills ar If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded. +A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill. ``` diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index c6b815bef5..202a621b1d 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -36,7 +36,7 @@ #### 模型看到的内容 -如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。 +如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。模板的结尾一句是防止双重加载的 seam 规则:宿主的用户显式 `skill.invoke` 会把同一份 `renderSkillContent` 输出(共享自 `@deepseek-ai/dsh-skill`)内联注入,目录则告诉模型遵循该块,而不是再经工具重新加载该 skill;替换目录模板携带同一句话。 ##### Skill 目录模板 @@ -49,6 +49,7 @@ A skill is a reusable set of task-specific instructions. The following skills ar If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded. +A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill. ``` From 69bd00ae76f0cc83b2f3837955cf4463cf53bfd8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 10:28:30 +0800 Subject: [PATCH 10/18] chore(web): register the skill-user-invoke scenario in both typecheck planes The web app project excludes every e2e file (they are host-plane programs) and tsconfig.host.json includes them one by one; the new scenario joins both lists so it keeps typecheck coverage without dragging host sources into the client project. --- apps/web/tsconfig.json | 1 + tsconfig.host.json | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 528714a527..41224d21e4 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -57,6 +57,7 @@ "tests/markdown-inline-code-links.e2e.ts", "tests/queue-actions.e2e.ts", "tests/skill-invocation-policy.e2e.ts", + "tests/skill-user-invoke.e2e.ts", "tests/permission-policy-context.e2e.ts", "tests/access-confirmation.e2e.ts", "tests/shipped-composition.e2e.ts", diff --git a/tsconfig.host.json b/tsconfig.host.json index 6884839536..9a06566fa2 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -44,6 +44,7 @@ "apps/web/tests/markdown-inline-code-links.e2e.ts", "apps/web/tests/queue-actions.e2e.ts", "apps/web/tests/skill-invocation-policy.e2e.ts", + "apps/web/tests/skill-user-invoke.e2e.ts", "apps/web/tests/permission-policy-context.e2e.ts", "apps/web/tests/access-confirmation.e2e.ts", "apps/web/tests/shipped-composition.e2e.ts", From c4c2355b5047675e67b1921591f40eb066fa69a2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 11:30:14 +0800 Subject: [PATCH 11/18] fix(host): harden skill.invoke at the enforcement boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes: recheck isUserInvocable on the loaded definition (list and get collect independently, so a provider change between them could swap in a user-disabled body — the skill-tool execute template's second check); thread the carrier signal through the lookup and refuse an abandoned caller's turn as cancelled; fold lookup/loader failures into the structured internal error the list face already uses; refuse cwd-less sessions with the skill.list stance; and reject blank trailing text at the wire schema instead of relying on client trimming. --- packages/host/apiproxy/src/api-proxy.ts | 60 +++++++--- .../host/apiproxy/src/api/skills.schema.ts | 7 +- packages/host/apiproxy/src/api/skills.ts | 10 +- packages/host/apiproxy/src/fetch/handler.ts | 2 +- .../apiproxy/tests/api-proxy-commands.spec.ts | 104 ++++++++++++++++-- .../host/apiproxy/tests/rpc-schemas.spec.ts | 2 + 6 files changed, 155 insertions(+), 30 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 3970a801a3..0abfb8c9c0 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -2390,32 +2390,58 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } }, - async invoke(request) { + async invoke(request, signal) { const { sessionId, name, text } = request.payload const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId) if ('refused' in resolved) return resolved.refused const agent = resolved.agent + if (agent.session.header.cwd === undefined) { + // Same stance as skill.list: a cwd-less header is a pre-project + // legacy log, and skill discovery has no root to resolve against. + return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} }) + } const skillRegistry = ctx.get('skills') if (skillRegistry === undefined) { return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} }) } - const lookup = { cwd: agent.session.header.cwd } - // isSkillName guards the registry contract; an ill-formed name is - // indistinguishable from an absent one for the caller. - const summary = isSkillName(name) - ? (await skillRegistry.list(lookup)).find(skill => skill.name === name) - : undefined - if (summary === undefined) { - return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } }) + const lookup = { cwd: agent.session.header.cwd, signal } + let skill + try { + // isSkillName guards the registry contract; an ill-formed name is + // indistinguishable from an absent one for the caller. + const summary = isSkillName(name) + ? (await skillRegistry.list(lookup)).find(candidate => candidate.name === name) + : undefined + if (summary === undefined) { + return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } }) + } + // The operation boundary owns user-invocation policy: client menus + // filtering their candidates is an affordance, not enforcement. + if (!isUserInvocable(summary)) { + return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } }) + } + const loaded = await skillRegistry.get(name, lookup) + if (loaded === undefined) { + return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } }) + } + // Recheck on the loaded definition (the skill-tool execute template): + // list and get collect independently, so a provider change between + // the two awaits can swap the winning candidate for a user-disabled + // one — the boundary must judge what it actually injects. + if (!isUserInvocable(loaded)) { + return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } }) + } + skill = loaded + } catch (error: unknown) { + if (signal.aborted) { + return err(request, { code: 'cancelled', message: 'skill invocation cancelled', details: {} }) + } + return err(request, { code: 'internal', message: `skill invocation failed: ${String(error)}`, details: {} }) } - // The operation boundary owns user-invocation policy: client menus - // filtering their candidates is an affordance, not enforcement. - if (!isUserInvocable(summary)) { - return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } }) - } - const skill = await skillRegistry.get(name, lookup) - if (skill === undefined) { - return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } }) + if (signal.aborted) { + // The caller already gave up (unary deadline or navigation): a turn + // it will never observe must not start. + return err(request, { code: 'cancelled', message: 'skill invocation cancelled', details: {} }) } const body = renderSkillContent(skill) const source: SkillInvocationSource = { kind: 'skill-invocation', name, ...text === undefined ? {} : { args: text } } diff --git a/packages/host/apiproxy/src/api/skills.schema.ts b/packages/host/apiproxy/src/api/skills.schema.ts index c1ee1024a3..1741a93a46 100644 --- a/packages/host/apiproxy/src/api/skills.schema.ts +++ b/packages/host/apiproxy/src/api/skills.schema.ts @@ -27,11 +27,14 @@ export const skillListValueSchema = z.object({ skills: z.array(skillEntrySchema), }) satisfies z.ZodType>> -/** skill.invoke request payload. */ +/** + * 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().optional(), + text: z.string().min(1).optional(), }) satisfies z.ZodType>> /** skill.invoke response value. */ diff --git a/packages/host/apiproxy/src/api/skills.ts b/packages/host/apiproxy/src/api/skills.ts index 2ade72efb9..698a9f0190 100644 --- a/packages/host/apiproxy/src/api/skills.ts +++ b/packages/host/apiproxy/src/api/skills.ts @@ -29,9 +29,13 @@ export interface SkillsApi { * 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: a model-only or unknown name is refused regardless of what a client - * menu offered. Session-backed subagents reject with `agent-busy`. + * 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 }>): + invoke(request: RpcRequest<{ sessionId: SessionId; name: string; text?: string }>, signal: AbortSignal): Promise> } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 914c425e91..8e098680fa 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -109,7 +109,7 @@ 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) => api.skills.invoke(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 7d7062023e..5b61011370 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -305,6 +305,8 @@ describe('skill.invoke', () => { 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) @@ -312,7 +314,7 @@ describe('skill.invoke', () => { 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 @@ -330,7 +332,7 @@ describe('skill.invoke', () => { registerInvokeSkills(ctx) const api = createApiProxy(ctx, DEFAULTS) const { agent, followup } = invokableAgent(ctx) - expectOk(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }))) + 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 @@ -342,39 +344,127 @@ describe('skill.invoke', () => { 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' }))) + 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' }))) + 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' }))) + 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' }))) + 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' }))) + 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') }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 253ac92fdf..972ccd3621 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -416,6 +416,8 @@ describe('skills domain schemas', () => { .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() }) From 31ed85900d0707b309e3859484a5b3f4964721fb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 11:30:16 +0800 Subject: [PATCH 12/18] fix(client): review fixes for invocation rendering and turn boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user-turn predicate (opensUserTurn) unifies the three parallel consumers a new node kind silently missed — produced-files turn reset, retry liveness, own-words force-scroll — so a skill invocation behaves as the turn opener it is. The menu marker resolves through ctx.locale.bind instead of a hand-rolled snapshot lookup; the dead legacy render arm goes with the removal cut; command-over-skill name precedence is now documented at the matchEnter seam; and the emptied replacement catalog keeps the no-reload sentence, with the never-published residual recorded in the Agent Note. --- ...8-user-explicit-skill-invocation.i18n.yaml | 4 ++-- ...26-08-08-user-explicit-skill-invocation.md | 1 + ...08-08-user-explicit-skill-invocation.zh.md | 1 + .../client/connection/src/client/fixture.ts | 2 +- packages/client/runtime/src/client/index.ts | 1 + .../src/client/sessions/conversation.ts | 14 +++++++++++++ .../src/client/chat/ChatView.tsx | 10 +++++---- .../src/client/chat/MessageItem.tsx | 20 ++++++++---------- .../src/client/turn-deliverables.ts | 3 ++- .../tests/produced-files.spec.tsx | 21 +++++++++++++++++++ packages/client/ui-skill/README.i18n.yaml | 4 ++-- packages/client/ui-skill/README.md | 2 +- packages/client/ui-skill/README.zh.md | 2 +- packages/client/ui-skill/src/client/index.ts | 11 +++++++--- .../ui-skill/tests/browser-plugin.spec.ts | 3 ++- packages/skill/tool-skill/README.i18n.yaml | 4 ++-- packages/skill/tool-skill/README.md | 2 +- packages/skill/tool-skill/README.zh.md | 2 +- packages/skill/tool-skill/src/index.ts | 1 + 19 files changed, 77 insertions(+), 31 deletions(-) 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 ed9de78dbb..4c36032f35 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: 9249ee5c9c712e9c6aa827e97178f352728ed927 -2026-08-08-user-explicit-skill-invocation.zh.md: f15975c3b13fbf76e036fcece30253e78e7b417d +2026-08-08-user-explicit-skill-invocation.md: abe6a05283359b81ff1c3cab754d0230e599e4a0 +2026-08-08-user-explicit-skill-invocation.zh.md: e72e49236ffd2c6f664e01abbd69665eec8328e9 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 9249ee5c9c..abe6a05283 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 @@ -34,3 +34,4 @@ Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reaso - Every user-invocable skill invocation now costs its full rendered body unconditionally — the price of determinism the peer survey showed everyone pays. - 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 f15975c3b1..e72e49236f 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 @@ -34,3 +34,4 @@ Status: implemented - 每一次用户可调用 skill 的调用现在都无条件付出其完整渲染正文的成本——这是确定性的代价,同类调研表明所有产品都在支付。 - `skill-invocation` 来源搭乘 `user/message`,因此「模型可见 ⟺ 已记录」在不新增事件类型的情况下继续成立,回放与 UI 读取的是元数据而非文本标记。 - TUI 与 ACP 之后可以为同样的语义采用 `skill.invoke`;在那之前,TUI 的客户端展开仍是它自己的路径。 +- 放弃逐次注入前导语后被接受的残余:no-reload framing 只搭乘目录,而 skill 全部为仅用户的工作区永远不会发布首个目录——注入可能在完全没有 framing 的情况下到达,模型可能多余地调用一次 `skill` 工具(替换目录的空臂携带该句;从未发布的情形没有)。仅为 framing 而发布目录被判定比这一次可恢复的错误更糟。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 75653d43e3..23b1931cfc 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2779,7 +2779,7 @@ 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) + 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/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index a0aa4df482..3864338e28 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -45,6 +45,7 @@ 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, diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index d66faf5e95..1ced1b916e 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -258,6 +258,20 @@ 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 diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index b0907f5a80..a841ba6751 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -24,6 +24,7 @@ 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' @@ -118,7 +119,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' || node.kind === 'user') return null + if (node.kind === 'assistant' || opensUserTurn(node)) return null } return null } @@ -447,10 +448,11 @@ export function ChatView({ return } firstSeqRef.current = firstSeq - // 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). + // 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). const appendedUser = lastKey !== lastKeyRef.current - && lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user' + && lastItem !== undefined && lastItem.kind === 'node' && opensUserTurn(lastItem.node) 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.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 661dd0cda5..af2afd9792 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -138,29 +138,27 @@ function TurnErrorItem({ node, t }: { /** * Display projection of reference forms in a user bubble (free geometry — no * textarea alignment constraint here); everything else stays plain text. The - * logged model text remains the single truth; this is presentation only. Two - * shapes decorate: legacy `name` spans (pre-decision-21 - * history) and plain-text `/name` / `@name` word-boundary tokens (decision - * 21: the sent text IS the reference — the bubble uses the same plainest - * token scan as the composer, minus the lexicon: sent tokens were validated - * at compose time, so shape alone decorates). + * logged model text remains the single truth; this is presentation only. + * Plain-text `/name` / `@name` word-boundary tokens decorate (decision 21: + * the sent text IS the reference — the bubble uses the same plainest token + * scan as the composer, minus the lexicon: sent tokens were validated at + * compose time, so shape alone decorates). */ function projectUserText(text: string): ReactNode { - const re = /([^<]+)<\/skill>|(^|\s)([/@][\w-]+)(?=\s|$)/g + const re = /(^|\s)([/@][\w-]+)(?=\s|$)/g const parts: ReactNode[] = [] let cursor = 0 let m: RegExpExecArray | null while ((m = re.exec(text)) !== null) { - const legacy = m[1] !== undefined - const tokenStart = legacy ? m.index : m.index + (m[2]?.length ?? 0) - const label = legacy ? `/${m[1]}` : m[3] ?? '' + const tokenStart = m.index + (m[1]?.length ?? 0) + const label = m[2] ?? '' if (tokenStart > cursor) parts.push() parts.push( {label} , ) - cursor = legacy ? m.index + m[0].length : tokenStart + label.length + cursor = tokenStart + label.length } if (parts.length === 0) return if (cursor < text.length) parts.push() diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index c9754d1da4..b8886be0df 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -3,6 +3,7 @@ * 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' @@ -62,7 +63,7 @@ export function producedForClosing(nodes: readonly ConversationNode[], seq: numb } continue } - if (node.kind === 'user') { + if (opensUserTurn(node)) { 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 49e41ebd86..473defc4e6 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -73,6 +73,27 @@ 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 => ({ ...toolResult(seq, callId, 'str_replace_editor'), diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index cb9eeef56e..5b80baa912 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: c888622bc92b038413c7d0ebf63abb61b483f6f5 -README.zh.md: 3bbbc90186726356c53f375bb664d678c4926988 +README.md: ea3dbf3592995903422ec951e20c911082370dbe +README.zh.md: 5b8886e67973af9a594ff6aa2e9295f112a9f3e3 diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index c888622bc9..ea3dbf3592 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). 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 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 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. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 3bbbc90186..5b8886e679 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,保持为普通提示词)。提交时会修剪参数、让空白参数不上协议,并把 RPC 拒绝折叠进 composer 的错误结局;宿主在开启轮次之前渲染 skill 正文并将其作为用户消息注入,因此对每一个用户可调用的 skill,调用都是确定性的。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。草稿 chip 视觉仍由 `lexicon` 扫描派生;旧的 `name` 引用 codec 已经移除(决策 21 的移除裁定),`matchSpace` 保持不实现——skill 流程归菜单与回车所有。 +菜单 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 流程归菜单与回车所有。 `skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。 diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 3e23cc997b..a73370b8ff 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -121,8 +121,9 @@ export function apply(ctx: ClientContext): void { for (const key of [...fetches.keys()]) invalidate(key) } - /** User-only marker in the active language (the menu hint is plain text, resolved at candidate time). */ - const userOnlyHint = (): string => ctx.locale.getSnapshot().active === 'zh' ? zh['menu.userOnly'] : en['menu.userOnly'] + // The bound translate resolves against the registered dictionaries with the + // locale service's own fallback ladder; candidate-time reads stay plain text. + const t = ctx.locale.bind(NS) /** * Args-tolerant claim for one skill: token `/name ` plus the skill.invoke @@ -159,7 +160,7 @@ export function apply(ctx: ClientContext): void { name: skill.name, // The user-only marker rides the description (the menu's only // secondary text); `hint` is the claim-state ghost text, not a badge. - description: skill.modelInvocable ? skill.description : `${userOnlyHint()} · ${skill.description}`, + description: skill.modelInvocable ? skill.description : `${t('menu.userOnly')} · ${skill.description}`, })) }, warm(session) { @@ -183,6 +184,10 @@ export function apply(ctx: ClientContext): void { 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 diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index 0e098a0b30..da99ed70d3 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -53,7 +53,8 @@ function providePresentation(ctx: Context): PresentationCapture { capture.dictionaries.push({ namespace, dictionaries }) return () => { capture.localeDisposed = true } }, - getSnapshot: () => ({ active: 'zh', locales: ['zh', 'en'], revision: 0 }), + // Minimal bound-translate fake: zh dictionary lookup, key passthrough on miss. + bind: () => (key: string) => key === 'menu.userOnly' ? '仅用户' : key, }) return capture } diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index 19fa44c67c..7094272679 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-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/skill/tool-skill/README.md -README.md: 5c6e592c670f324eb660dbe1fec168fd77e5b368 -README.zh.md: 202a621b1d4047c7d763de3b98c1a69c8c1ee1f7 +README.md: 21c3521aeff8b55940b04e804d5b8469850ec6da +README.zh.md: 74137ce7e577a4b5c6d3592b60bac3c5901a9159 diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index 5c6e592c67..21c3521aef 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -36,7 +36,7 @@ Tool execution does not add a synthetic context message. Its freshly loaded resu #### What the model sees -If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. The template's closing sentence is the seam rule against double-loading: the host's user-explicit `skill.invoke` injects the same `renderSkillContent` output (shared from `@deepseek-ai/dsh-skill`) inline, and the catalog tells the model to follow that block instead of re-loading the skill through the tool; the replacement-catalog template carries the same sentence. +If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. The template's closing sentence is the seam rule against double-loading: the host's user-explicit `skill.invoke` injects the same `renderSkillContent` output (shared from `@deepseek-ai/dsh-skill`) inline, and the catalog tells the model to follow that block instead of re-loading the skill through the tool; the replacement-catalog template carries the same sentence in both arms, including the emptied catalog. ##### Skill catalog template diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index 202a621b1d..74137ce7e5 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -36,7 +36,7 @@ #### 模型看到的内容 -如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。模板的结尾一句是防止双重加载的 seam 规则:宿主的用户显式 `skill.invoke` 会把同一份 `renderSkillContent` 输出(共享自 `@deepseek-ai/dsh-skill`)内联注入,目录则告诉模型遵循该块,而不是再经工具重新加载该 skill;替换目录模板携带同一句话。 +如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。模板的结尾一句是防止双重加载的 seam 规则:宿主的用户显式 `skill.invoke` 会把同一份 `renderSkillContent` 输出(共享自 `@deepseek-ai/dsh-skill`)内联注入,目录则告诉模型遵循该块,而不是再经工具重新加载该 skill;替换目录模板的两个臂——包括清空后的目录——都携带同一句话。 ##### Skill 目录模板 diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index aa9b509206..1d3d26a7c9 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -233,6 +233,7 @@ function renderCatalogUpdate(entries: SkillCatalogSource['entries']): UserMessag const availability = entries.length === 0 ? [ 'No skills are currently available through the `skill` tool. Do not use names from earlier skill catalogs.', + 'A user may still invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool for it.', ] : [ 'Use only names in this replacement catalog. If the user names a listed skill, or the task clearly matches its description, call the `skill` tool with the exact name before acting.', From 7750789c8e9e797718c447e7a9483727aae1b191 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 11:32:28 +0800 Subject: [PATCH 13/18] docs: regenerate the module graph for the dsh-skill llm dependency --- docs/module-graph.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 9cf6f4c895..bbd0af9d8a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -297,7 +297,6 @@ flowchart TD pkg_retention --> pkg_invariants pkg_timeout --> pkg_invariants pkg_scope --> pkg_invariants - pkg_skill --> pkg_invariants pkg_acp_snapshot --> pkg_invariants pkg_llm_mock_server --> pkg_invariants pkg_loader_smoke --> pkg_invariants @@ -367,6 +366,8 @@ flowchart TD pkg_system_prompt --> pkg_invariants pkg_system_prompt --> pkg_llm pkg_system_prompt --> pkg_scope + pkg_skill --> pkg_invariants + pkg_skill --> pkg_llm pkg_web --> pkg_invariants pkg_web --> pkg_llm pkg_api_gateway --> pkg_client_connection @@ -1156,7 +1157,6 @@ flowchart TD | [`retention`](../packages/util/retention) | `util` | [`invariants`](../packages/support/invariants) | | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | -| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | | [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | @@ -1194,6 +1194,7 @@ flowchart TD | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | From c08fa27e5ca3c5bfeb7e3e931a39b8e8249b1f27 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 13:14:49 +0800 Subject: [PATCH 14/18] feat(tool-skill): inject user-invoked skills at the pre-step gesture boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A whitespace-bounded /name token anywhere in a claimed user message, naming a user-invocable skill in the workspace directory, now injects that skill's renderSkillContent as instructions context appended after every other injection of the step — the same agent/pre-step seam the catalog, workspace instructions, and the runtime snapshot ride. Closed-set matching mirrors the command registry (a miss stays plain prose), only user-source messages are scanned, the policy check runs on the loaded definition, and this is the sole entry point for disable-model-invocation skills. The catalog's no-reload sentence now names the gesture boundary. --- packages/host/apiproxy/src/api-proxy.ts | 75 +------------ packages/skill/skill/README.i18n.yaml | 4 +- packages/skill/skill/README.md | 2 +- packages/skill/skill/README.zh.md | 2 +- packages/skill/skill/src/index.ts | 13 ++- packages/skill/tool-skill/README.i18n.yaml | 4 +- packages/skill/tool-skill/README.md | 16 ++- packages/skill/tool-skill/README.zh.md | 16 ++- packages/skill/tool-skill/src/index.ts | 76 +++++++++++++ .../skill/tool-skill/tests/tool-skill.spec.ts | 103 ++++++++++++++++++ 10 files changed, 225 insertions(+), 86 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 0abfb8c9c0..cfcae423bc 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -18,8 +18,7 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' import { SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent' -import { isSkillName, isUserInvocable, renderSkillContent } from '@deepseek-ai/dsh-skill' -import type { SkillInvocationSource } from '@deepseek-ai/dsh-skill' +import { isUserInvocable } from '@deepseek-ai/dsh-skill' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, @@ -1254,9 +1253,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro * turn, and letting it try spends the whole pre-step path to fail inside * the adapter with a message about registration. Refusing here names the * model the session is pointed at while the draft is still in the composer. - * This is the enforcement boundary shared by `session.prompt` and - * `skill.invoke`: a client that disables its input is an affordance, and - * both methods stay callable regardless. + * This is `session.prompt`'s enforcement boundary: a client that disables + * its input is an affordance, and the method stays callable regardless. */ async function turnAgentFor( request: RpcRequest, sessionId: SessionId, @@ -2389,73 +2387,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return err(request, { code: 'internal', message: `skill listing failed: ${String(error)}`, details: {} }) } }, - - async invoke(request, signal) { - const { sessionId, name, text } = request.payload - const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId) - if ('refused' in resolved) return resolved.refused - const agent = resolved.agent - if (agent.session.header.cwd === undefined) { - // Same stance as skill.list: a cwd-less header is a pre-project - // legacy log, and skill discovery has no root to resolve against. - return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} }) - } - const skillRegistry = ctx.get('skills') - if (skillRegistry === undefined) { - return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} }) - } - const lookup = { cwd: agent.session.header.cwd, signal } - let skill - try { - // isSkillName guards the registry contract; an ill-formed name is - // indistinguishable from an absent one for the caller. - const summary = isSkillName(name) - ? (await skillRegistry.list(lookup)).find(candidate => candidate.name === name) - : undefined - if (summary === undefined) { - return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } }) - } - // The operation boundary owns user-invocation policy: client menus - // filtering their candidates is an affordance, not enforcement. - if (!isUserInvocable(summary)) { - return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } }) - } - const loaded = await skillRegistry.get(name, lookup) - if (loaded === undefined) { - return err(request, { code: 'skill-not-found', message: `skill "${name}" is unknown in this workspace`, details: { name } }) - } - // Recheck on the loaded definition (the skill-tool execute template): - // list and get collect independently, so a provider change between - // the two awaits can swap the winning candidate for a user-disabled - // one — the boundary must judge what it actually injects. - if (!isUserInvocable(loaded)) { - return err(request, { code: 'skill-not-invocable', message: `skill "${name}" is not available for user invocation`, details: { name } }) - } - skill = loaded - } catch (error: unknown) { - if (signal.aborted) { - return err(request, { code: 'cancelled', message: 'skill invocation cancelled', details: {} }) - } - return err(request, { code: 'internal', message: `skill invocation failed: ${String(error)}`, details: {} }) - } - if (signal.aborted) { - // The caller already gave up (unary deadline or navigation): a turn - // it will never observe must not start. - return err(request, { code: 'cancelled', message: 'skill invocation cancelled', details: {} }) - } - const body = renderSkillContent(skill) - const source: SkillInvocationSource = { kind: 'skill-invocation', name, ...text === undefined ? {} : { args: text } } - try { - const message: UserMessage = createUserMessage({ - content: [{ type: 'text', text: text === undefined ? body : `${body}\n\n${text}` }], - source, - }) - agent.followup(message) - } catch (error: unknown) { - return err(request, { code: 'agent-busy', message: 'skill invocation rejected', details: { reason: String(error) } }) - } - return ok(request, { accepted: true as const }) - }, }, settings: { diff --git a/packages/skill/skill/README.i18n.yaml b/packages/skill/skill/README.i18n.yaml index fe29171cb3..2ca9cbac01 100644 --- a/packages/skill/skill/README.i18n.yaml +++ b/packages/skill/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/skill/skill/README.md -README.md: 0c1b2249d8c46ad9ce8097ceeda2bd988c92eb21 -README.zh.md: 8fed350d00433206aecdb32819adc81c82745869 +README.md: 3dc2bcfa5775736717bdebcb92329d5655198234 +README.zh.md: d11f90d5a8356f06df63aa249a1f8b5851f36f5f diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 0c1b2249d8..3dc2bcfa57 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -39,7 +39,7 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ### Shared model-facing rendering -`renderSkillContent(skill)` renders one loaded skill as the canonical `` block (escaped `name` attribute, resource hints, verbatim body). It is the single truth for both loading paths: `dsh-tool-skill` returns it as the `skill` tool result, and the host's user-explicit `skill.invoke` injects it as a user message, so the model sees one shape regardless of who initiated the load. `escapeText` is exported beside it for consumers embedding prose in the same markup frame. The package also declares the `skill-invocation` `MessageSource` kind ({ name, args? }) that user-explicit injection stamps on its messages — transcript consumers present the invocation from this metadata instead of re-parsing the body. +`renderSkillContent(skill)` renders one loaded skill as the canonical `` block (escaped `name` attribute, resource hints, verbatim body). It is the single truth for both loading paths: `dsh-tool-skill` returns it as the `skill` tool result and injects it at the user-explicit gesture boundary, so the model sees one shape regardless of who initiated the load. `escapeText` is exported beside it for consumers embedding prose in the same markup frame. The package also declares the `skill-invocation` `MessageSource` kind ({ name, form: 'instructions' }) that user-explicit injection stamps on its messages — transcript consumers present the invocation from this metadata instead of re-parsing the body. `isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field directly. `ctx.skills.get()` remains the trusted, policy-neutral loading primitive, so every user- or model-facing consumer must enforce the predicate that matches its surface before exposing or loading a skill. diff --git a/packages/skill/skill/README.zh.md b/packages/skill/skill/README.zh.md index 8fed350d00..d11f90d5a8 100644 --- a/packages/skill/skill/README.zh.md +++ b/packages/skill/skill/README.zh.md @@ -39,7 +39,7 @@ ### 共享的面向模型渲染 -`renderSkillContent(skill)` 把一个已加载 skill 渲染为规范的 `` 块(转义后的 `name` 属性、资源提示、原样正文)。它是两条加载路径的唯一真源:`dsh-tool-skill` 将其作为 `skill` 工具结果返回,宿主的用户显式 `skill.invoke` 将其作为用户消息注入,因此无论加载由谁发起,模型看到的都是同一种形态。`escapeText` 随之一并导出,供要在同一标记框架中嵌入文案的消费方使用。该包还声明 `skill-invocation` 这个 `MessageSource` kind({ name, args? }),用户显式注入会把它打在自己的消息上——transcript(文本记录)消费方依据这份元数据呈现该次调用,而不是重新解析正文。 +`renderSkillContent(skill)` 把一个已加载 skill 渲染为规范的 `` 块(转义后的 `name` 属性、资源提示、原样正文)。它是两条加载路径的唯一真源:`dsh-tool-skill` 将其作为 `skill` 工具结果返回,并在用户显式的手势边界将其注入,因此无论加载由谁发起,模型看到的都是同一种形态。`escapeText` 随之一并导出,供要在同一标记框架中嵌入文案的消费方使用。该包还声明 `skill-invocation` 这个 `MessageSource` kind({ name, form: 'instructions' }),用户显式注入会把它打在自己的消息上——transcript(文本记录)消费方依据这份元数据呈现该次调用,而不是重新解析正文。 `isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别直接读取对应的正向字段。`ctx.skills.get()` 仍是受信且与策略无关的加载原语,因此每个面向用户或模型的消费方都必须先执行与自身接口匹配的判定,再暴露或加载 skill。 diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index f44386d51c..42478279b4 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -121,17 +121,18 @@ export function isUserInvocable(skill: Pick): boolea } /** - * Durable message source for a user-explicit skill invocation: the host - * injects the rendered skill as a user-role message carrying this source, so - * transcript consumers present the invocation from metadata instead of - * re-parsing the model-facing text. + * Durable source for the context message a user-explicit skill invocation + * injects: the user's own words ride a plain user message, and the rendered + * skill body follows as injected `instructions`-form context carrying this + * source, so transcript consumers present the injection from metadata + * instead of re-parsing the model-facing text. */ export interface SkillInvocationSource { readonly kind: 'skill-invocation' /** Invoked skill name, validated user-invocable at the injecting boundary. */ readonly name: string - /** Trailing free text the user submitted after the skill token, when present. */ - readonly args?: string + /** Injected skill bodies are instructions for the model to follow. */ + readonly form: 'instructions' } declare module '@deepseek-ai/dsh-llm' { diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index 7094272679..b9aa148fd1 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-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/skill/tool-skill/README.md -README.md: 21c3521aeff8b55940b04e804d5b8469850ec6da -README.zh.md: 74137ce7e577a4b5c6d3592b60bac3c5901a9159 +README.md: b7309657d85a3d2a19de78a4ee6173d742519daa +README.zh.md: f430f4027c917c5c9b97a56d1a7d7a617670b25c diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index 21c3521aef..b7309657d8 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -36,7 +36,7 @@ Tool execution does not add a synthetic context message. Its freshly loaded resu #### What the model sees -If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. The template's closing sentence is the seam rule against double-loading: the host's user-explicit `skill.invoke` injects the same `renderSkillContent` output (shared from `@deepseek-ai/dsh-skill`) inline, and the catalog tells the model to follow that block instead of re-loading the skill through the tool; the replacement-catalog template carries the same sentence in both arms, including the emptied catalog. +If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. The template's closing sentence is the seam rule against double-loading: the user-explicit gesture boundary (the pre-step listener below) injects the same `renderSkillContent` output (shared from `@deepseek-ai/dsh-skill`) inline, and the catalog tells the model to follow that block instead of re-loading the skill through the tool; the replacement-catalog template carries the same sentence in both arms, including the emptied catalog. ##### Skill catalog template @@ -145,6 +145,20 @@ Only a failing call adds these retained tokens. Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. +### User-explicit invocation injection + +#### What the model sees + +A whitespace-bounded `/name` token anywhere in a claimed user message, naming a user-invocable skill in the workspace catalog, injects that skill's full `` rendering (the exact result-template shape above) as a `user`-role instructions context appended after every other injection of that step — background first, the material to act on last. Only direct user input is scanned, the check runs on the loaded definition, and unknown or user-disabled names stay ordinary prose. This is the sole entry point for `disable-model-invocation` skills, which the catalog and the `skill` tool never expose; the catalog's closing sentence tells the model to follow the injected block instead of re-loading it. + +#### Token effect + +Each gesture adds one rendered skill body to that turn as injected context — the same size as the tool result for the same skill, paid deterministically at the user's request instead of at the model's discretion. Repeated gestures for one skill within one step inject once. + +#### KV Cache effect + +Append-only; the injection lands after the reusable request prefix inside the step's message batch and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **The catalog omits `whenToUse`, source, and provider metadata** — routing is based only on name and a capped description; `whenToUse` remains provider metadata and is not rendered by the loaded wrapper either. diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index 74137ce7e5..f430f4027c 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -36,7 +36,7 @@ #### 模型看到的内容 -如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。模板的结尾一句是防止双重加载的 seam 规则:宿主的用户显式 `skill.invoke` 会把同一份 `renderSkillContent` 输出(共享自 `@deepseek-ai/dsh-skill`)内联注入,目录则告诉模型遵循该块,而不是再经工具重新加载该 skill;替换目录模板的两个臂——包括清空后的目录——都携带同一句话。 +如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。模板的结尾一句是防止双重加载的 seam 规则:用户显式的手势边界(下文的 pre-step 监听器)会把同一份 `renderSkillContent` 输出(共享自 `@deepseek-ai/dsh-skill`)内联注入,目录则告诉模型遵循该块,而不是再经工具重新加载该 skill;替换目录模板的两个臂——包括清空后的目录——都携带同一句话。 ##### Skill 目录模板 @@ -145,6 +145,20 @@ Load referenced resources only as needed. 仅追加;新可见内容位于可重用请求前缀之后,不会使现有 KV Cache 条目失效。 +### 用户显式调用注入 + +#### 模型看到的内容 + +已认领用户消息中任意位置、以空白为界、指名工作区目录中某个用户可调用 skill 的 `/name` token,会把该 skill 的完整 `` 渲染(与上文结果模板完全相同的形态)作为 `user` 角色的指令上下文注入,追加在该步骤所有其他注入之后——背景在前,模型要着手处理的材料在最后。只扫描直接的用户输入,检查在已加载定义上进行,未知名称和用户不可调用的名称保持为普通行文。这是 `disable-model-invocation` skill 唯一的入口,目录和 `skill` 工具永不暴露这类 skill;目录的结尾一句会告诉模型遵循注入块,而不是重新加载它。 + +#### Token 影响 + +每次手势会把一份渲染后的 skill 正文作为注入上下文加进该轮次——尺寸与同一 skill 的工具结果相同,按用户的请求确定性地支付,而非由模型自行裁量。同一步骤内对同一 skill 的重复手势只注入一次。 + +#### KV Cache 影响 + +仅追加;注入落在该步骤的消息批次中、可重用请求前缀之后,不会使现有 KV Cache 条目失效。 + ## 已知限制与暂缓事项 - **目录省略 `whenToUse`、来源和提供方元数据**:路由只基于名称和有长度上限的描述;`whenToUse` 仍是提供方元数据,加载后的包装层也不渲染它。 diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 1d3d26a7c9..604cd63bcf 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -15,7 +15,9 @@ import { escapeText, isModelInvocable, isSkillName, + isUserInvocable, renderSkillContent, + type SkillInvocationSource, type SkillSummary, } from '@deepseek-ai/dsh-skill' @@ -161,6 +163,49 @@ export function apply(ctx: Context, config: Config = {}): void { throw new Error('dsh-tool-skill: registered skill tool is not visible in the global registry') } + // User-explicit skill invocation: a claimed user message whose first line + // starts with `/` naming a user-invocable skill is a deterministic + // load gesture. The rendered body enters this step as injected + // instructions context appended after every other injection — background + // first (workspace rules, runtime policy, the catalog), the material the + // model must act on last, closest to its answer. Registration order makes + // that placement deterministic: this listener registers before the catalog + // listener, so the waterfall hands it the catalog-bearing list to extend. + // Only `source.kind === 'user'` messages are scanned — external text + // cannot forge the gesture — and a token naming no user-invocable skill + // stays ordinary prose (the command registry is a different closed + // namespace, resolved client-side before a line ever becomes a prompt). + // This is the only entry point for `disable-model-invocation` skills; the + // catalog and the `skill` tool below never see them. + ctx.on('agent/pre-step', async ( + { agent, messages, signal }, + next, + ): Promise => { + const decision = await next() + if (decision.kind === 'reject') return decision + const names = invokedSkillNames(messages) + if (names.length === 0) return decision + signal.throwIfAborted() + const lookup = { cwd: agent.session.header.cwd, signal } + const injections: UserMessage[] = [] + for (const name of names) { + const skill = await ctx.skills.get(name, lookup) + signal.throwIfAborted() + // Unknown names and user-disabled skills stay plain prose: the + // gesture was never a claim this boundary recognizes. The check sits + // on the loaded definition — the single lookup that produces what is + // actually injected. + if (skill === undefined || !isUserInvocable(skill)) continue + const source: SkillInvocationSource = { kind: 'skill-invocation', name, form: 'instructions' } + injections.push(createUserMessage({ + content: [{ type: 'text', text: renderSkillContent(skill) }], + source, + })) + } + if (injections.length === 0) return decision + return { kind: 'enter', messages: [...decision.messages, ...injections] } + }) + // Register after the tool so reverse teardown removes guidance first. Exact definition // identity prevents a scoped shadow merely named `skill` from inheriting this catalog. ctx.on('agent/pre-step', async ( @@ -351,3 +396,34 @@ function assertPositiveInteger(name: string, value: number, minimum = 1): void { throw new Error(`tool-skill: ${name} must be an integer greater than or equal to ${minimum}`) } } + +/** + * A whitespace-bounded `/name` token (the public skill-name grammar) anywhere + * in the text — the same word-boundary shape the transcript chip decoration + * uses, so a gesture reads as one wherever it sits in the sentence. A second + * `/` or any non-boundary character breaks the match, which keeps file paths + * (`/usr/bin`) and fractions (`5/8`) out. + */ +const SKILL_GESTURE = /(^|\s)\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/g + +/** + * `/name` gesture tokens from the claimed user messages, deduplicated in + * first-seen order. Every text block of direct user input is scanned; no + * other source can forge a gesture. + * @param messages - the step's claimed batch. + * @returns candidate skill names, unvalidated against the registry. + */ +function invokedSkillNames(messages: readonly UserMessage[]): string[] { + const names: string[] = [] + for (const message of messages) { + if ((message.source as { kind?: unknown }).kind !== 'user') continue + for (const block of message.content) { + if (block.type !== 'text') continue + for (const match of block.text.matchAll(SKILL_GESTURE)) { + const name = match[2] + if (name !== undefined && !names.includes(name)) names.push(name) + } + } + } + return names +} diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 9543c196af..fe356da5da 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -915,3 +915,106 @@ describe('dsh-tool-skill', () => { expect(vanishedBlock.text).toContain('skill "vanishing-skill" is unknown or no longer available') }) }) + +describe('user-explicit invocation injection', () => { + async function writePolicySkill(root: string, name: string, description: string, policy: string, body: string): Promise { + const dir = join(root, name) + await mkdir(dir, { recursive: true }) + const policyLines = policy === '' ? '' : `${policy}\n` + await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n${policyLines}---\n\n${body}\n`) + } + + function gesture(text: string): UserMessage { + return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }) + } + + async function invokeHarness(): Promise<{ ctx: Context; agent: Agent }> { + const home = await tempDir('invoke') + const skillsRoot = join(home, '.agents', 'skills') + await writePolicySkill(skillsRoot, 'hidden-demo', 'User-only demo', 'disable-model-invocation: true', 'Say the magic word: PINEAPPLE.') + await writePolicySkill(skillsRoot, 'shared-skill', 'Ordinary skill', '', 'Shared instructions.') + await writePolicySkill(skillsRoot, 'model-only-skill', 'Model only', 'user-invocable: false', 'Model-only instructions.') + const ctx = await setup(home) + return { ctx, agent: agentForCwd(home) } + } + + it('injects a user-invocable skill named by a leading /token, after every other injection', async () => { + const { ctx, agent } = await invokeHarness() + const first = gesture('/hidden-demo what does this do') + const second = gesture('plain follow-up prose') + const decision = await proposeStep(ctx, agent, [first, second]) + if (decision.kind !== 'enter') throw new Error('expected enter') + const kinds = decision.messages.map(message => (message.source as { kind: string }).kind) + // Background injections (the catalog here) sit between the claimed batch + // and the invoked body: the material the model must act on comes last. + expect(kinds.slice(0, 2)).toEqual(['user', 'user']) + expect(kinds.at(-1)).toBe('skill-invocation') + expect(kinds.indexOf('skill-catalog')).toBeLessThan(kinds.indexOf('skill-invocation')) + const injection = decision.messages.at(-1)! + expect(injection.source).toMatchObject({ kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' }) + const block = injection.content[0] + if (block?.type !== 'text') throw new Error('expected text injection') + expect(block.text).toContain('') + expect(block.text).toContain('Say the magic word: PINEAPPLE.') + expect(block.text).not.toContain('what does this do') + }) + + it('injects an ordinary skill the same way (one uniform user-explicit path)', async () => { + const { ctx, agent } = await invokeHarness() + const decision = await proposeStep(ctx, agent, [gesture('/shared-skill go')]) + if (decision.kind !== 'enter') throw new Error('expected enter') + expect(decision.messages.some(message => + (message.source as { kind?: string; name?: string }).kind === 'skill-invocation' + && (message.source as { name?: string }).name === 'shared-skill')).toBe(true) + }) + + it('recognizes a mid-sentence gesture but not paths, fractions, or broken boundaries', async () => { + const { ctx, agent } = await invokeHarness() + const decision = await proposeStep(ctx, agent, [ + gesture('please use /hidden-demo to answer this'), + ]) + if (decision.kind !== 'enter') throw new Error('expected enter') + expect(decision.messages.some(message => + (message.source as { kind?: string; name?: string }).kind === 'skill-invocation' + && (message.source as { name?: string }).name === 'hidden-demo')).toBe(true) + + const negative = await proposeStep(ctx, agent, [ + gesture('look under /hidden-demo/refs for the data'), + gesture('the odds are 5/8 at best'), + gesture('see foo/hidden-demo too'), + ]) + if (negative.kind !== 'enter') throw new Error('expected enter') + expect(negative.messages.some(message => + (message.source as { kind?: string }).kind === 'skill-invocation')).toBe(false) + }) + + it('leaves unknown names and user-disabled skills as plain prose', async () => { + const { ctx, agent } = await invokeHarness() + const decision = await proposeStep(ctx, agent, [ + gesture('/absent-skill do a thing'), + gesture('/model-only-skill run'), + ]) + if (decision.kind !== 'enter') throw new Error('expected enter') + // No injection joins the step (the catalog listener may still add its + // own skill-catalog message; only skill-invocation sources matter here). + expect(decision.messages.some(message => + (message.source as { kind?: string }).kind === 'skill-invocation')).toBe(false) + }) + + it('never scans non-user sources and dedupes repeated gestures', async () => { + const { ctx, agent } = await invokeHarness() + const forged = createUserMessage({ + content: [{ type: 'text', text: '/hidden-demo forged' }], + source: { kind: 'skill-catalog', form: 'catalog', entries: [] }, + }) + const decision = await proposeStep(ctx, agent, [ + forged, + gesture('/hidden-demo once'), + gesture('/hidden-demo twice'), + ]) + if (decision.kind !== 'enter') throw new Error('expected enter') + const injections = decision.messages.filter(message => + (message.source as { kind?: string }).kind === 'skill-invocation') + expect(injections).toHaveLength(1) + }) +}) From 0d53752c49975b5210fa20279601d79ad964877c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 13:15:52 +0800 Subject: [PATCH 15/18] refactor(host)!: retire the skill.invoke RPC for the gesture boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...8-user-explicit-skill-invocation.i18n.yaml | 4 +- ...26-08-08-user-explicit-skill-invocation.md | 27 ++- ...08-08-user-explicit-skill-invocation.zh.md | 25 ++- apps/web/tests/skill-user-invoke.e2e.ts | 45 ++-- .../skill-user-invoke/ui.expected.md | 10 +- docs/config-catalog.md | 4 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- .../client/connection/src/client/fixture.ts | 18 -- packages/client/connection/tests/fake-api.ts | 3 - packages/client/runtime/src/client/index.ts | 3 +- .../src/client/sessions/context-provenance.ts | 3 + .../src/client/sessions/conversation.ts | 34 --- .../src/client/sessions/transcript-adapter.ts | 20 +- packages/client/runtime/tests/fake-api.ts | 3 - .../runtime/tests/transcript-adapter.spec.ts | 27 ++- .../src/client/chat/ChatView.tsx | 10 +- .../src/client/chat/MessageItem.module.css | 27 --- .../src/client/chat/MessageItem.tsx | 39 +--- .../ui-conversation/src/client/locales.ts | 2 - .../tests/chat-branch-tails.spec.tsx | 35 --- .../src/client/turn-deliverables.ts | 3 +- .../tests/produced-files.spec.tsx | 20 -- packages/client/ui-skill/README.i18n.yaml | 4 +- packages/client/ui-skill/README.md | 10 +- packages/client/ui-skill/README.zh.md | 10 +- packages/client/ui-skill/src/client/index.ts | 69 ++---- .../ui-skill/tests/browser-plugin.spec.ts | 59 +---- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api/rpc-map.ts | 1 - packages/host/apiproxy/src/api/rpc.schema.ts | 2 - packages/host/apiproxy/src/api/rpc.ts | 4 - .../host/apiproxy/src/api/skills.schema.ts | 15 -- packages/host/apiproxy/src/api/skills.ts | 22 +- packages/host/apiproxy/src/fetch/client.ts | 5 +- packages/host/apiproxy/src/fetch/handler.ts | 3 +- .../apiproxy/tests/api-proxy-commands.spec.ts | 201 ------------------ .../apiproxy/tests/client-handler.spec.ts | 2 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 5 - .../host/apiproxy/tests/rpc-schemas.spec.ts | 18 +- 43 files changed, 143 insertions(+), 663 deletions(-) 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', () => { From 2b6836a6fe2a29dcaa6ae5d78b715ba911e164f1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 13:16:27 +0800 Subject: [PATCH 16/18] fix(vendor): widen include's writeTask for exactOptionalPropertyTypes The debounced writer assigns undefined on flush, which a plain optional NodeJS.Timeout rejects under exactOptionalPropertyTypes; the error had been masked by stale build state until a residue cleanup invalidated it. Logged as local modification 14 in the vendor manifest. --- vendor/README.md | 1 + vendor/include/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/vendor/README.md b/vendor/README.md index 9fa97413c2..4bf1d43f46 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -43,6 +43,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. 13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. +14. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. ## Sync procedure diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 26b9305c52..5eece997c9 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -170,7 +170,7 @@ export class Include extends EntryTree { private readonly: boolean private content?: string private data?: EntryOptions[] - private writeTask?: NodeJS.Timeout + private writeTask?: NodeJS.Timeout | undefined private applyQueue: Promise = Promise.resolve() constructor(ctx: Context, public config: Include.Config) { From 7f138f3f5c2fb4a91871079a10b02c71d699dba9 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 13:52:47 +0800 Subject: [PATCH 17/18] test(examples): re-record catalog stitch sentence in agent-spine inline snapshot --- packages/examples/agent-spine-demo/tests/agent-core.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 87fd17c36b..3224487b60 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -527,6 +527,7 @@ describe('dsh-agent-spine-demo bundle', () => {
If the user names a skill, or the task clearly matches a skill's description, call the \`skill\` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded. + A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the \`skill\` tool again for that skill. ", "type": "user/message", }, From ed492077a5a1bcbda4d97706f9436e2356637f76 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 14:03:03 +0800 Subject: [PATCH 18/18] test(tool-skill): cover reject passthrough and non-text block scanning --- .../skill/tool-skill/tests/tool-skill.spec.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index fe356da5da..5e0fe58855 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -1017,4 +1017,32 @@ describe('user-explicit invocation injection', () => { (message.source as { kind?: string }).kind === 'skill-invocation') expect(injections).toHaveLength(1) }) + + it('passes a downstream reject through both pre-step listeners untouched', async () => { + const { ctx, agent } = await invokeHarness() + const signal = new AbortController().signal + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + { messages: [gesture('/hidden-demo blocked step')], turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'reject' as const }), + ) + expect(decision).toEqual({ kind: 'reject' }) + }) + + it('scans only text blocks of a user message', async () => { + const { ctx, agent } = await invokeHarness() + const mixed = createUserMessage({ + content: [ + { type: 'reasoning', text: '/hidden-demo inside a non-text block' }, + { type: 'text', text: '/shared-skill go' }, + ], + source: { kind: 'user' }, + }) + const decision = await proposeStep(ctx, agent, [mixed]) + if (decision.kind !== 'enter') throw new Error('expected enter') + const invoked = decision.messages + .filter(message => (message.source as { kind?: string }).kind === 'skill-invocation') + .map(message => (message.source as { name: string }).name) + expect(invoked).toEqual(['shared-skill']) + }) })