refactor(host)!: retire the skill.invoke RPC for the gesture boundary
Invocation is an ordinary session.prompt again: the pre-step gesture boundary makes it deterministic host-side for every front end, so the dedicated RPC (handler, wire schema, error codes, client face, fixtures) and ui-skill's claim machinery are net deletions. The menu keeps decision 21 exactly — a pick lands literal /name text — plus the user-only marker from skill.list's modelInvocable flag.
This commit is contained in:
@@ -2454,23 +2454,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
],
|
||||
})
|
||||
},
|
||||
invoke: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
const { sessionId, name, text: args } = request.payload
|
||||
const body = `<skill_content name="${name}">\n<skill_resources>\nBase directory for this skill: /fixture/skills/${name}\n</skill_resources>\n\n<skill_instructions>\nFixture ${name} instructions.\n</skill_instructions>\n</skill_content>`
|
||||
// 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)
|
||||
|
||||
@@ -163,8 +163,6 @@ export class FakeApiClient implements IApiClient {
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
|
||||
= () => Promise.resolve(ok({ skills: [] }))
|
||||
|
||||
onSkillInvoke: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>>
|
||||
= () => 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'] = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<ConversationNode, 'kind'>): 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -198,8 +198,6 @@ export class FakeApiClient implements IApiClient {
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
|
||||
= () => Promise.resolve(ok({ skills: [] }))
|
||||
|
||||
onSkillInvoke: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>>
|
||||
= () => 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'] = {
|
||||
|
||||
@@ -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: '<skill_content name="hidden-demo">body</skill_content>\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: '<skill_content name="bare-skill">body</skill_content>' }],
|
||||
source: { kind: 'skill-invocation', name: 'bare-skill' } as never,
|
||||
content: [{ type: 'text', text: '<skill_content name="hidden-demo">body</skill_content>' }],
|
||||
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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 `<skill_content>` 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 (
|
||||
<div className={css.userRow} data-skill-invocation data-time-hover-root>
|
||||
<div className={css.bubble}>
|
||||
<span className={css.refChip} data-ref-chip="skill">{`/${node.name}`}</span>
|
||||
{node.args !== undefined && <MessageText text={` ${node.args}`} />}
|
||||
<details className={css.skillInvocationDetails}>
|
||||
<summary className={css.skillInvocationSummary}>{t('message.skillInvocation.expand')}</summary>
|
||||
<pre className={css.skillInvocationBody}>{text}</pre>
|
||||
</details>
|
||||
</div>
|
||||
<MessageIconActions
|
||||
text={text}
|
||||
time={node.time}
|
||||
clock="start"
|
||||
className={css.actions}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <SkillInvocationRow node={node} t={t} />
|
||||
case 'compaction':
|
||||
return <CompactionItem node={node} t={t} />
|
||||
case 'model-retry':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = '<skill_content name="hidden-demo">instructions</skill_content>\n\ncheck the fixture'
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'skill-invocation', seq: 4, time: 1_000,
|
||||
name: 'hidden-demo', args: 'check the fixture',
|
||||
content: [{ type: 'text', text: body }] as never,
|
||||
source: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
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(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'skill-invocation', seq: 5, time: 1_000,
|
||||
name: 'bare-skill',
|
||||
content: [{ type: 'text', text: '<skill_content name="bare-skill">x</skill_content>' }] 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', () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: '<skill_content name="hidden-demo">x</skill_content>' }] 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 => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<skill>name</skill>` 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 `<skill>name</skill>` 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 `<skill_content>` 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 `<skill_content>` 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 `<skill_content>` 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.
|
||||
|
||||
@@ -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` 扫描派生;旧的 `<skill>name</skill>` 引用 codec 已经移除(决策 21 的移除裁定),`matchSpace` 保持不实现——skill 流程归菜单与回车所有。
|
||||
pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本(决策 21)——本 source 不实现任何裁决钩子,也没有引用 codec(旧的 `<skill>name</skill>` 形式已随移除裁定消失)。确定性在宿主侧:pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token,并为每一种前端注入渲染后的 `<skill_content>`,因此菜单 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_content>` 块——与 `skill` 工具返回的 `renderSkillContent` 输出相同——在一个空行之后追加用户的尾随文本,并把整体作为一条携带 `skill-invocation` 来源的 user 角色消息注入,随即开启一个轮次。加载是确定性的:模型无需被要求调用 `skill` 工具就能收到完整正文,目录(由 `dsh-tool-skill` 渲染)也会告诉它不要重新加载已内联注入的 skill。
|
||||
用户消息原样到达模型,字面文本 `/name` 也包含在内。随后宿主的 pre-step 边界(`dsh-tool-skill`)把规范的 `<skill_content>` 块——与 `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 候选;下一次击键会重新轮询已落定的缓存。
|
||||
|
||||
@@ -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 `<skill>` 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 `<skill>` 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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -46,7 +46,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the
|
||||
|
||||
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
|
||||
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's invocation path: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point this is. `skill.invoke` is the user-explicit loading RPC: it enforces user-invocation policy at this boundary (`skill-not-found` / `skill-not-invocable`), renders the canonical `<skill_content>` body via the shared `renderSkillContent`, appends the optional trailing `text`, injects the whole as a user-role message carrying the `skill-invocation` source, and starts a turn through the same route-served refusal gate as `session.prompt`. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point the slash gesture is. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every front end (web, TUI, ACP, hand-typed text) shares one deterministic path with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
|
||||
|
||||
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的调用路径:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——此处是这类条目唯一的入口。`skill.invoke` 是用户显式加载 RPC:它在此边界强制执行用户调用策略(`skill-not-found`/`skill-not-invocable`),经共享的 `renderSkillContent` 渲染规范的 `<skill_content>` 正文,追加可选的尾随 `text`,把整体作为一条携带 `skill-invocation` 来源的 user 角色消息注入,并经由与 `session.prompt` 相同的「路由是否有适配器在服务」拒绝闸门开启一个轮次。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的入口。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `<skill_content>` 上下文作答,因此每一种前端(web、TUI、ACP、手动键入的文本)共享同一条确定性路径,没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
|
||||
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
|
||||
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -51,8 +51,6 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }),
|
||||
z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
|
||||
z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
|
||||
z.object({ code: z.literal('skill-not-found'), message: z.string(), details: z.object({ name: z.string() }) }),
|
||||
z.object({ code: z.literal('skill-not-invocable'), message: z.string(), details: z.object({ name: z.string() }) }),
|
||||
z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }),
|
||||
z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }),
|
||||
z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -26,18 +26,3 @@ export const skillListRequestSchema = z.object({
|
||||
export const skillListValueSchema = z.object({
|
||||
skills: z.array(skillEntrySchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'skill.list'>>>
|
||||
|
||||
/**
|
||||
* skill.invoke request payload. `text` is the user's trailing message; a
|
||||
* blank one stays off the wire (the boundary, not client courtesy, refuses it).
|
||||
*/
|
||||
export const skillInvokeRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
name: z.string().min(1),
|
||||
text: z.string().min(1).optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'skill.invoke'>>>
|
||||
|
||||
/** skill.invoke response value. */
|
||||
export const skillInvokeValueSchema = z.object({
|
||||
accepted: z.literal(true),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'skill.invoke'>>>
|
||||
|
||||
@@ -20,22 +20,14 @@ export interface SkillEntry {
|
||||
readonly modelInvocable: boolean
|
||||
}
|
||||
|
||||
/** Skill-domain unary methods (the map keys skill.* of RpcMethodMap). */
|
||||
/**
|
||||
* Skill-domain unary methods (the map key skill.* of RpcMethodMap). Listing
|
||||
* is the domain's only RPC: invocation itself is a plain `session.prompt`
|
||||
* whose leading `/name` token the host recognizes at the pre-step boundary
|
||||
* (`dsh-tool-skill` injects the rendered body there), so every client shares
|
||||
* one deterministic path with no dedicated invocation wire.
|
||||
*/
|
||||
export interface SkillsApi {
|
||||
/** Lists the user-invocable skill catalog for the session's project. */
|
||||
list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ skills: readonly SkillEntry[] }>>
|
||||
|
||||
/**
|
||||
* Injects one user-invocable skill into the addressed agent as a user-role
|
||||
* message (the canonical `<skill_content>` rendering, with `text` appended
|
||||
* when present) and starts a turn. The host enforces user-invocation policy
|
||||
* here — on the discovery summary and again on the loaded definition, so a
|
||||
* catalog change between the two lookups cannot slip a user-disabled body
|
||||
* through — a model-only or unknown name is refused regardless of what a
|
||||
* client menu offered. The carrier's request signal aborts the skill
|
||||
* lookup and refuses injection once the caller has given up (`cancelled`).
|
||||
* Session-backed subagents reject with `agent-busy`.
|
||||
*/
|
||||
invoke(request: RpcRequest<{ sessionId: SessionId; name: string; text?: string }>, signal: AbortSignal):
|
||||
Promise<RpcResponse<{ accepted: true }>>
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
workspaceRenameValueSchema,
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts'
|
||||
import { skillInvokeValueSchema, skillListValueSchema } from '../api/skills.schema.ts'
|
||||
import { skillListValueSchema } from '../api/skills.schema.ts'
|
||||
import {
|
||||
goalCreateValueSchema,
|
||||
goalEditValueSchema,
|
||||
@@ -118,7 +118,6 @@ export interface IApiClient {
|
||||
}
|
||||
skills: {
|
||||
list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.list'>>>
|
||||
invoke(payload: RequestPayload<'skill.invoke'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.invoke'>>>
|
||||
}
|
||||
events: {
|
||||
mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
|
||||
@@ -186,7 +185,6 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'command.list': commandListValueSchema,
|
||||
'command.execute': commandExecuteValueSchema,
|
||||
'skill.list': skillListValueSchema,
|
||||
'skill.invoke': skillInvokeValueSchema,
|
||||
'goal.create': goalCreateValueSchema,
|
||||
'goal.edit': goalEditValueSchema,
|
||||
'goal.pause': goalPauseValueSchema,
|
||||
@@ -443,7 +441,6 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
list: (payload, signal) => this.callUnary('skill.list', payload, signal),
|
||||
invoke: (payload, signal) => this.callUnary('skill.invoke', payload, signal),
|
||||
}
|
||||
|
||||
readonly goals: IApiClient['goals'] = {
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -269,207 +269,6 @@ describe('skill.list', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('skill.invoke', () => {
|
||||
/** Provider with one user-only and one model-only skill, both loadable. */
|
||||
function registerInvokeSkills(ctx: Context): void {
|
||||
const summaries = [
|
||||
{
|
||||
name: 'user-only', description: 'User-only',
|
||||
invocation: { modelInvocable: false, userInvocable: true },
|
||||
source: 'custom', provider: 'probe', rank: 0, locator: null,
|
||||
resourceBase: { kind: 'directory', path: '/proj/.agents/skills/user-only' },
|
||||
},
|
||||
{
|
||||
name: 'model-only', description: 'Model-only',
|
||||
invocation: { modelInvocable: true, userInvocable: false },
|
||||
source: 'custom', provider: 'probe', rank: 0, locator: null,
|
||||
},
|
||||
] as const
|
||||
ctx.skills.registerProvider(() => ({
|
||||
name: 'probe',
|
||||
list: () => Promise.resolve(summaries.map(summary => ({ ...summary }))),
|
||||
get: candidate => Promise.resolve({
|
||||
...summaries.find(summary => summary.name === candidate.name)!,
|
||||
content: 'Follow the probe instructions.',
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
/** Agent stub whose session carries a project cwd and whose followup records the injected message. */
|
||||
function invokableAgent(ctx: Context): { agent: Agent; followup: ReturnType<typeof vi.fn> } {
|
||||
const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
|
||||
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
|
||||
const followup = vi.fn()
|
||||
const agent = { id: session.id, session, inbox, status: 'idle', ctx, followup } as unknown as Agent
|
||||
ctx.agents.register(agent)
|
||||
return { agent, followup }
|
||||
}
|
||||
|
||||
const live = () => new AbortController().signal
|
||||
|
||||
it('injects a user-invocable skill as a user message with the invocation source', async () => {
|
||||
const ctx = await harness()
|
||||
registerInvokeSkills(ctx)
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const { agent, followup } = invokableAgent(ctx)
|
||||
const value = expectOk(await api.skills.invoke(request({
|
||||
sessionId: agent.id, name: 'user-only', text: 'and check the fixture',
|
||||
}), live()))
|
||||
expect(value).toEqual({ accepted: true })
|
||||
expect(followup).toHaveBeenCalledTimes(1)
|
||||
const message = followup.mock.calls[0]?.[0] as UserMessage
|
||||
expect(message.source).toEqual({ kind: 'skill-invocation', name: 'user-only', args: 'and check the fixture' })
|
||||
expect(message.content).toHaveLength(1)
|
||||
const text = (message.content[0] as { text: string }).text
|
||||
expect(text).toContain('<skill_content name="user-only">')
|
||||
expect(text).toContain('Base directory for this skill: /proj/.agents/skills/user-only')
|
||||
expect(text).toContain('Follow the probe instructions.')
|
||||
expect(text.endsWith('\n\nand check the fixture')).toBe(true)
|
||||
})
|
||||
|
||||
it('omits args from the source and content when no text rides the invocation', async () => {
|
||||
const ctx = await harness()
|
||||
registerInvokeSkills(ctx)
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const { agent, followup } = invokableAgent(ctx)
|
||||
expectOk(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }), live()))
|
||||
const message = followup.mock.calls[0]?.[0] as UserMessage
|
||||
expect(message.source).toEqual({ kind: 'skill-invocation', name: 'user-only' })
|
||||
const text = (message.content[0] as { text: string }).text
|
||||
expect(text.endsWith('</skill_content>')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a skill the user may not invoke', async () => {
|
||||
const ctx = await harness()
|
||||
registerInvokeSkills(ctx)
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const { agent, followup } = invokableAgent(ctx)
|
||||
const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'model-only' }), live()))
|
||||
expect(error.code).toBe('skill-not-invocable')
|
||||
expect(followup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rechecks user policy on the loaded definition (list/get race)', async () => {
|
||||
const ctx = await harness()
|
||||
// The provider flips the skill user-invocable in list but user-disabled
|
||||
// in get — the window a provider change between the two collects opens.
|
||||
ctx.skills.registerProvider(() => ({
|
||||
name: 'flipping',
|
||||
list: () => Promise.resolve([{
|
||||
name: 'flipper', description: 'Race probe',
|
||||
invocation: { modelInvocable: false, userInvocable: true },
|
||||
source: 'custom', provider: 'flipping', rank: 0, locator: null,
|
||||
}]),
|
||||
get: () => Promise.resolve({
|
||||
name: 'flipper', description: 'Race probe',
|
||||
invocation: { modelInvocable: false, userInvocable: false },
|
||||
source: 'custom', provider: 'flipping',
|
||||
content: 'Must never inject.',
|
||||
}),
|
||||
}))
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const { agent, followup } = invokableAgent(ctx)
|
||||
const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'flipper' }), live()))
|
||||
expect(error.code).toBe('skill-not-invocable')
|
||||
expect(followup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports skill-not-found when the summary wins but the load returns nothing', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.skills.registerProvider(() => ({
|
||||
name: 'vanishing',
|
||||
list: () => Promise.resolve([{
|
||||
name: 'ghost', description: 'Vanishes on load',
|
||||
invocation: { modelInvocable: false, userInvocable: true },
|
||||
source: 'custom', provider: 'vanishing', rank: 0, locator: null,
|
||||
}]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
}))
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const { agent, followup } = invokableAgent(ctx)
|
||||
const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'ghost' }), live()))
|
||||
expect(error.code).toBe('skill-not-found')
|
||||
expect(followup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an unknown or invalid skill name', async () => {
|
||||
const ctx = await harness()
|
||||
registerInvokeSkills(ctx)
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const { agent } = invokableAgent(ctx)
|
||||
const missing = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'absent-skill' }), live()))
|
||||
expect(missing.code).toBe('skill-not-found')
|
||||
const invalid = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'Not A Name' }), live()))
|
||||
expect(invalid.code).toBe('skill-not-found')
|
||||
})
|
||||
|
||||
it('folds a loader failure into a structured internal error', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.skills.registerProvider(() => ({
|
||||
name: 'exploding',
|
||||
list: () => Promise.resolve([{
|
||||
name: 'grenade', description: 'Loader throws',
|
||||
invocation: { modelInvocable: false, userInvocable: true },
|
||||
source: 'custom', provider: 'exploding', rank: 0, locator: null,
|
||||
}]),
|
||||
get: () => Promise.reject(new Error('disk exploded')),
|
||||
}))
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const { agent, followup } = invokableAgent(ctx)
|
||||
const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'grenade' }), live()))
|
||||
expect(error.code).toBe('internal')
|
||||
expect(error.message).toContain('skill invocation failed')
|
||||
expect(followup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to start a turn the caller already abandoned', async () => {
|
||||
const ctx = await harness()
|
||||
registerInvokeSkills(ctx)
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const { agent, followup } = invokableAgent(ctx)
|
||||
const abort = new AbortController()
|
||||
abort.abort()
|
||||
const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }), abort.signal))
|
||||
expect(error.code).toBe('cancelled')
|
||||
expect(followup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a followup refusal as agent-busy', async () => {
|
||||
const ctx = await harness()
|
||||
registerInvokeSkills(ctx)
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const { agent, followup } = invokableAgent(ctx)
|
||||
followup.mockImplementation(() => { throw new Error('inbox closed') })
|
||||
const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' }), live()))
|
||||
expect(error.code).toBe('agent-busy')
|
||||
})
|
||||
|
||||
it('refuses a cwd-less session with the skill.list stance', async () => {
|
||||
const ctx = await harness()
|
||||
registerInvokeSkills(ctx)
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const session = ctx.sessions.create(undefined)
|
||||
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
|
||||
const followup = vi.fn()
|
||||
ctx.agents.register({ id: session.id, session, inbox, status: 'idle', ctx, followup } as unknown as Agent)
|
||||
const error = expectErr(await api.skills.invoke(request({ sessionId: session.id, name: 'user-only' }), live()))
|
||||
expect(error.code).toBe('internal')
|
||||
expect(error.message).toContain('has no project cwd')
|
||||
expect(followup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails loud with internal when the skill registry is not mounted', async () => {
|
||||
const ctx = await harness({ skills: false })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
|
||||
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
|
||||
ctx.agents.register({ id: session.id, session, inbox, status: 'idle', ctx, followup: vi.fn() } as unknown as Agent)
|
||||
const error = expectErr(await api.skills.invoke(request({ sessionId: session.id, name: 'user-only' }), live()))
|
||||
expect(error.code).toBe('internal')
|
||||
expect(error.message).toContain('skill registry is absent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('host/commands-changed frame', () => {
|
||||
it('broadcasts on registry change', async () => {
|
||||
const ctx = await harness()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user