import { describe, expect, it } from 'vitest' import { mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { Context } from 'cordis' import { createUserMessage, CallId, type Message } from '@deepseek-ai/dsh-llm' import { createScope, type Scope } from '@deepseek-ai/dsh-scope' import { Session, SessionId, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { agentEvents, Inbox, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent' import SkillService from '@deepseek-ai/dsh-skill' import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' const testToolSignal = new AbortController().signal async function tempDir(name: string): Promise { return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`))) } async function writeSkill(root: string, name: string, description: string, body: string): Promise { const dir = join(root, name) await mkdir(dir, { recursive: true }) await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`) } async function setup(home: string, config: toolSkill.Config = {}): Promise { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(SkillService) await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false }) await ctx.plugin(toolSkill, config) return ctx } function agentForCwd(cwd: string): Agent { const id = SessionId(`tool-skill-${cwd}`) const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd }) return { ctx: new Context(), id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', send: () => {}, followup: () => {}, steer: () => {}, inject: () => { throw new Error('step-boundary catalog must not use agent.inject()') }, cancel() {}, runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } } function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent { return { id: SessionId(id), options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'running', ctx: new Context(), send: () => {}, followup: () => {}, steer: () => {}, inject: () => { throw new Error('step-boundary catalog must not use agent.inject()') }, cancel() {}, runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } } function openMessageTurn(session: Session, turn = 1): void { session.append('turn/start', { turn }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: `turn ${turn}` }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) } async function fireStep(ctx: Context, agent: Agent, turn: number, step: number): Promise { const signal = new AbortController().signal const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', [], { turn, step, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) if (decision.kind === 'enter') { for (const message of decision.messages) { agent.session.append('user/message', message, { surfaceOp: 'append' }) } } } async function proposeStep( ctx: Context, agent: Agent, messages: UserMessage[], ): Promise { const signal = new AbortController().signal return await agentEvents(ctx, agent).waterfall( 'agent/pre-step', messages, { turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter' as const, messages }), ) } function catalogMessages(session: Session): Extract[] { return session.events.filter((event): event is Extract => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === 'dsh-tool-skill') } function catalogContent(entries: string[]): Message['content'] { return [{ type: 'text', text: ['', '', ...entries, '', ''].join('\n'), }] } async function composePrefix(ctx: Context, cwd: string, signal = new AbortController().signal): Promise { return await composePrefixForAgent(ctx, agentForCwd(cwd), signal) } async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new AbortController().signal): Promise { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', [], { turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) if (decision.kind === 'enter') { for (const message of decision.messages) { agent.session.append('user/message', message, { surfaceOp: 'append' }) } } return agent.session.deriveMessages() } async function mintAgentScope(ctx: Context, subject: string | Agent): Promise<{ agent: Agent; scope: Scope }> { const agent = typeof subject === 'string' ? agentForCwd(subject) : subject let scope!: Scope await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['tools'], })) return { agent, scope } } describe('dsh-tool-skill', () => { it('registers the skill tool schema and removes it on dispose', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) const home = await tempDir('tool-schema') await ctx.plugin(SkillService) await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false }) ctx.skills.register({ name: 'lifecycle-skill', description: 'Lifecycle', source: 'runtime', content: 'body' }) const fiber = await ctx.plugin(toolSkill) expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['skill']) expect(await composePrefix(ctx, '/workspace')).toHaveLength(1) expect(ctx.tools.get('skill')?.presentCall?.({ name: 'project-skill' })).toEqual({ card: 'generic', title: 'Load skill project-skill', kind: 'read', rawInput: 'project-skill', }) await fiber.dispose() expect(ctx.tools.schemas()).toEqual([]) expect(await composePrefix(ctx, '/workspace')).toEqual([]) toolSkill.apply(ctx) expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['skill']) }) it('forwards the step abort signal to skill discovery', async () => { const home = await tempDir('tool-prefix-signal') const ctx = await setup(home) let seenSignal: AbortSignal | undefined ctx.skills.registerProvider(() => ({ name: 'signal-probe', async list(options) { seenSignal = options.signal return [] }, async get() { return undefined }, })) const controller = new AbortController() await composePrefix(ctx, '/workspace', controller.signal) expect(seenSignal).toBe(controller.signal) }) it('injects a stable durable name-and-description catalog at the first step', async () => { const home = await tempDir('tool-catalog') const ctx = await setup(home, { catalogDescriptionMaxLength: 50 }) ctx.skills.register({ name: 'z-skill', description: 'Long description '.repeat(5), whenToUse: 'Never render this routing hint.', source: 'secret-source', provider: 'runtime', resourceBase: { kind: 'directory', path: '/secret/path' }, content: 'Secret body.', }) ctx.skills.register({ name: 'a-skill', description: 'Use {{placeholder}} & carefully.', source: 'runtime', provider: 'runtime', content: 'A body.', }) ctx.skills.register({ name: 'model-only-skill', description: 'Model-only skill.', invocation: { modelInvocable: true, userInvocable: false }, source: 'runtime', content: 'Model-only body.', }) ctx.skills.register({ name: 'user-only-skill', description: 'User-only skill.', invocation: { modelInvocable: false, userInvocable: true }, source: 'runtime', content: 'User-only body.', }) ctx.on('agent/pre-step', async (_agent, _messages, _context, next) => { const decision = await next() if (decision.kind === 'reject') return decision return { ...decision, messages: [ ...decision.messages, createUserMessage({ content: [{ type: 'text', text: 'later contribution' }], source: { kind: 'plugin', plugin: 'later-contribution' }, }), ], } }) const prefix = await composePrefix(ctx, '/workspace') expect(prefix).toEqual([ { id: expect.any(String) as unknown, role: 'user', content: [{ type: 'text', text: 'later contribution' }], source: { kind: 'plugin', plugin: 'later-contribution' }, }, { id: expect.any(String) as unknown, role: 'user', source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, content: [{ type: 'text', text: [ '', 'A skill is a reusable set of task-specific instructions. The following skills are available in this session:', '', '', '- `a-skill`: Use {{placeholder}} <safely> & carefully.', '- `model-only-skill`: Model-only skill.', '- `z-skill`: Long description Long description Long descript...', '', '', "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.", '', ].join('\n'), }], }, ]) const rendered = JSON.stringify(prefix[1]) expect(rendered).not.toContain('whenToUse') expect(rendered).not.toContain('secret-source') expect(rendered).not.toContain('/secret/path') expect(rendered).not.toContain('Secret body') expect(rendered).not.toContain('user-only-skill') expect(renderPrompt(await ctx.systemPrompt.assemble({ agent: agentForCwd('/workspace') }))).not.toContain('') }) it('does not inject a catalog when no model-invocable skills are available', async () => { const home = await tempDir('tool-empty-catalog') const ctx = await setup(home) ctx.skills.register({ name: 'user-only-skill', description: 'User-only skill', invocation: { modelInvocable: false, userInvocable: true }, source: 'runtime', content: 'User-only body.', }) const agent = agentForCwd('/workspace') expect(await composePrefixForAgent(ctx, agent)).toEqual([]) expect(await composePrefixForAgent(ctx, agent)).toEqual([]) }) it('omits an incomplete initial catalog and retries on a later request boundary', async () => { const home = await tempDir('tool-incomplete-prefix') const ctx = await setup(home) let failing = true const provider = { name: 'recovering', async list() { if (failing) throw new Error('temporarily unavailable') return [] }, async get() { return undefined }, } let invalidate = (): void => {} ctx.skills.registerProvider((control) => { invalidate = control.invalidate return provider }) const session = Session.create(SessionId('incomplete-prefix')) const agent = sessionAgent(session) openMessageTurn(session) await composePrefixForAgent(ctx, agent) expect(catalogMessages(session)).toEqual([]) failing = false invalidate() await fireStep(ctx, agent, 1, 1) expect(catalogMessages(session)).toEqual([]) }) it('records an empty baseline across repeated step observations', async () => { const home = await tempDir('tool-empty-step') const ctx = await setup(home) const session = Session.create(SessionId('empty-step')) const agent = sessionAgent(session) openMessageTurn(session) await fireStep(ctx, agent, 1, 1) await fireStep(ctx, agent, 1, 2) expect(catalogMessages(session)).toEqual([]) }) it('deduplicates or replaces a catalog already proposed for the same step', async () => { const home = await tempDir('tool-proposed-catalog') const ctx = await setup(home) const disposeFirst = ctx.skills.register({ name: 'first-skill', description: 'First skill', source: 'runtime', content: 'First body.', }) const session = Session.create(SessionId('proposed-catalog')) const agent = sessionAgent(session) openMessageTurn(session) await fireStep(ctx, agent, 1, 1) const initial = catalogMessages(session)[0]?.data if (initial === undefined) throw new Error('expected initial catalog') const duplicate = await proposeStep(ctx, agent, [initial]) expect(duplicate).toEqual({ kind: 'enter', messages: [] }) ctx.skills.register({ name: 'second-skill', description: 'Second skill', source: 'runtime', content: 'Second body.', }) const companion = createUserMessage({ content: [{ type: 'text', text: 'keep this message' }], source: { kind: 'user' }, }) const replaced = await proposeStep(ctx, agent, [companion, initial]) expect(replaced.kind).toBe('enter') if (replaced.kind === 'reject') throw new Error('expected catalog replacement') expect(replaced.messages).toHaveLength(2) expect(replaced.messages[0]).toBe(companion) expect(replaced.messages[1]?.id).not.toBe(initial.id) expect(JSON.stringify(replaced.messages[1]?.content)).toContain('second-skill') disposeFirst() }) it('removes a stale proposed catalog before the first empty baseline', async () => { const home = await tempDir('tool-proposed-empty-catalog') const ctx = await setup(home) const session = Session.create(SessionId('proposed-empty-catalog')) const stale = createUserMessage({ content: catalogContent(['- `stale-skill`: Stale skill']), source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, }) const decision = await proposeStep(ctx, sessionAgent(session), [stale]) expect(decision).toEqual({ kind: 'enter', messages: [] }) }) it('keeps a proposed catalog that already matches the current snapshot', async () => { const home = await tempDir('tool-matching-proposal') const ctx = await setup(home) ctx.skills.register({ name: 'first-skill', description: 'First skill', source: 'runtime', content: 'First body.', }) const session = Session.create(SessionId('matching-proposal')) const proposed = createUserMessage({ content: catalogContent(['- `first-skill`: First skill']), source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, }) const decision = await proposeStep(ctx, sessionAgent(session), [proposed]) expect(decision).toEqual({ kind: 'enter', messages: [proposed] }) }) it('injects complete replacement catalogs for additions and an empty tombstone for removals', async () => { const home = await tempDir('tool-dynamic-catalog') const ctx = await setup(home) const disposeFirst = ctx.skills.register({ name: 'first-skill', description: 'First skill', source: 'runtime', content: 'First body.', }) const session = Session.create(SessionId('dynamic-catalog')) const agent = sessionAgent(session) openMessageTurn(session) expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('first-skill') await fireStep(ctx, agent, 1, 1) expect(catalogMessages(session)).toHaveLength(1) const disposeSecond = ctx.skills.register({ name: 'second-skill', description: 'Second skill', source: 'runtime', content: 'Second body.', }) await fireStep(ctx, agent, 1, 2) const addition = catalogMessages(session)[1] if (addition?.type !== 'user/message') throw new Error('expected catalog addition') expect(JSON.stringify(addition.data.content)).toContain('first-skill') expect(JSON.stringify(addition.data.content)).toContain('second-skill') disposeSecond() disposeFirst() await fireStep(ctx, agent, 1, 3) const removal = catalogMessages(session)[2] if (removal?.type !== 'user/message') throw new Error('expected catalog removal') expect(JSON.stringify(removal.data.content)).toContain('No skills are currently available') expect(JSON.stringify(removal.data.content)).not.toContain('first-skill') expect(JSON.stringify(removal.data.content)).not.toContain('second-skill') await fireStep(ctx, agent, 1, 4) expect(catalogMessages(session)).toHaveLength(3) }) it('resumes from the latest valid visible catalog content', async () => { const home = await tempDir('tool-catalog-resume') const ctx = await setup(home) ctx.skills.register({ name: 'resumed-skill', description: 'Resumed skill', source: 'runtime', content: 'Resumed body.', }) const session = Session.create(SessionId('catalog-resume')) const agent = sessionAgent(session) openMessageTurn(session) session.append('user/message', createUserMessage({ content: catalogContent(['- `old-skill`: Old skill']), source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, }), { surfaceOp: 'append' }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'missing catalog markers' }], source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, }), { surfaceOp: 'append' }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: '\nmissing closing marker' }], source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, }), { surfaceOp: 'append' }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'first block' }, { type: 'text', text: 'second block' }], source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, }), { surfaceOp: 'append' }) session.append('user/message', createUserMessage({ content: [{ type: 'reasoning', text: 'not a user-role catalog block' }], source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, }), { surfaceOp: 'append' }) await fireStep(ctx, agent, 1, 1) expect(catalogMessages(session)).toHaveLength(6) expect(JSON.stringify(catalogMessages(session).at(-1)?.data.content)).toContain('resumed-skill') }) it('re-establishes the current catalog after compaction hides its durable message', async () => { const home = await tempDir('tool-catalog-compaction') const ctx = await setup(home) ctx.skills.register({ name: 'first-skill', description: 'First skill', source: 'runtime', content: 'First body.', }) const session = Session.create(SessionId('catalog-compaction')) const agent = sessionAgent(session) openMessageTurn(session) expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('first-skill') const initial = catalogMessages(session)[0] if (initial === undefined) throw new Error('expected initial catalog') session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'compacted history' }], source: { kind: 'plugin', plugin: 'compact' }, }), { surfaceOp: { op: 'replace', start: initial.seq, end: initial.seq }, sourceEventSeqs: [initial.seq], }) await fireStep(ctx, agent, 1, 1) expect(catalogMessages(session)).toHaveLength(2) expect(JSON.stringify(catalogMessages(session).at(-1)?.data.content)).toContain('first-skill') }) it('keeps body-only edits out of the catalog and loads the latest body on demand', async () => { const home = await tempDir('tool-body-refresh') const root = join(home, '.dsh/skills') await writeSkill(root, 'body-skill', 'Stable description', 'First body.') const ctx = await setup(home) const session = Session.create(SessionId('body-refresh')) const agent = sessionAgent(session) openMessageTurn(session) expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('Stable description') await writeSkill(root, 'body-skill', 'Stable description', 'Second body.') await fireStep(ctx, agent, 1, 1) expect(catalogMessages(session)).toHaveLength(1) const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('body-refresh'), name: 'skill', arguments: { name: 'body-skill' }, agent, }) expect(result.isError).toBe(false) expect(JSON.stringify(result.content)).toContain('Second body.') expect(JSON.stringify(result.content)).not.toContain('First body.') }) it('retains the last-good catalog while any provider discovery is incomplete', async () => { const home = await tempDir('tool-incomplete-catalog') const ctx = await setup(home) const disposeStable = ctx.skills.register({ name: 'stable-skill', description: 'Stable skill', source: 'runtime', content: 'Stable body.', }) const session = Session.create(SessionId('incomplete-catalog')) const agent = sessionAgent(session) openMessageTurn(session) expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('stable-skill') ctx.skills.registerProvider(() => ({ name: 'failing', async list() { throw new Error('temporarily unavailable') }, async get() { return undefined }, })) disposeStable() await fireStep(ctx, agent, 1, 1) expect(catalogMessages(session)).toHaveLength(1) }) it('omits catalog guidance when the calling agent restricts away the shipped skill tool', async () => { const home = await tempDir('tool-restricted-catalog') const ctx = await setup(home) ctx.skills.register({ name: 'listed-skill', description: 'Listed', source: 'runtime', content: 'body' }) const session = Session.create(SessionId('restricted-catalog')) const agent = sessionAgent(session) openMessageTurn(session) const { scope } = await mintAgentScope(ctx, agent) scope.ctx.tools.restrict({ deny: ['skill'] }) expect(ctx.tools.get('skill', agent)).toBeUndefined() await composePrefixForAgent(ctx, agent) expect(catalogMessages(session)).toEqual([]) await fireStep(ctx, agent, 1, 1) expect(catalogMessages(session)).toEqual([]) expect(await composePrefix(ctx, '/workspace')).toHaveLength(1) await scope.dispose() }) it('does not attach shipped catalog guidance to a scoped same-name tool shadow', async () => { const home = await tempDir('tool-shadowed-catalog') const ctx = await setup(home) ctx.skills.register({ name: 'listed-skill', description: 'Listed', source: 'runtime', content: 'body' }) const { agent, scope } = await mintAgentScope(ctx, '/workspace') scope.ctx.tools.register(defineContentToolFixture({ name: 'skill', description: 'A scoped tool with unrelated semantics.', parameters: {}, execute() { return Promise.resolve([{ type: 'text', text: 'shadow' }]) }, })) expect(ctx.tools.get('skill', agent)).not.toBe(ctx.tools.get('skill')) expect(await composePrefixForAgent(ctx, agent)).toEqual([]) expect(await composePrefix(ctx, '/workspace')).toHaveLength(1) await scope.dispose() }) it('validates the catalog description cap', async () => { const home = await tempDir('tool-invalid-catalog-cap') const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(SkillService) await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false }) await expect(ctx.plugin(toolSkill, { catalogDescriptionMaxLength: 2 })).rejects.toThrow('greater than or equal to 3') }) it('loads a skill for the calling agent cwd', async () => { const home = await tempDir('tool-load') const project = await tempDir('tool-project') await mkdir(join(project, '.git'), { recursive: true }) await writeSkill(join(project, '.dsh/skills'), 'project-skill', 'Project skill', 'Project instructions.') const ctx = await setup(home) const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'skill', arguments: { name: 'project-skill' }, agent: { session: { header: { cwd: project } } } as never, }) expect(result.isError).toBe(false) if (result.isError) throw new Error('expected skill success') expect(result.value).toEqual({ name: 'project-skill', provider: 'local', resourceBase: { kind: 'directory', path: join(project, '.dsh/skills/project-skill') }, content: 'Project instructions.', }) const block = result.content[0] expect(block?.type).toBe('text') if (block?.type !== 'text') throw new Error('expected text skill result') expect(block.text).toBe([ '', '', `Base directory for this skill: ${join(project, '.dsh/skills/project-skill')}`, 'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.', '', '', '', 'Project instructions.', '', '', ].join('\n')) expect(block.text).not.toContain('# Skill:') }) it('renders provider-managed resource hints for non-local skills', async () => { const home = await tempDir('tool-resource-hints') const ctx = await setup(home) ctx.skills.register({ name: 'opaque-skill', description: 'Opaque skill', source: 'runtime', provider: 'runtime', resourceBase: { kind: 'opaque', description: 'runtime memory' }, content: 'Opaque instructions.', }) ctx.skills.register({ name: 'url-skill', description: 'URL skill', source: 'runtime', provider: 'runtime', resourceBase: { kind: 'url', url: 'https://skills.example.test/url-skill' }, content: 'URL instructions.', }) ctx.skills.register({ name: 'provider-skill', description: 'Provider skill', source: 'runtime', provider: 'runtime', content: 'Provider instructions.', }) const opaque = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c2'), name: 'skill', arguments: { name: 'opaque-skill' } }) const url = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c3'), name: 'skill', arguments: { name: 'url-skill' } }) const provider = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c4'), name: 'skill', arguments: { name: 'provider-skill' } }) if (opaque.content[0]?.type !== 'text' || url.content[0]?.type !== 'text' || provider.content[0]?.type !== 'text') { throw new Error('expected text tool results') } expect(opaque.content[0].text).toContain('\nResources for this skill: runtime memory\nLoad referenced resources only as needed.\n') expect(url.content[0].text).toContain('\nBase URL for this skill: https://skills.example.test/url-skill\nResolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed.\n') expect(provider.content[0].text).toContain('\nResources for this skill are managed by provider "runtime".\nLoad referenced resources only as needed.\n') }) it('rejects an unknown resource-base kind at the canonical output boundary', async () => { const home = await tempDir('tool-resource-assert-never') const ctx = await setup(home) ctx.skills.register({ name: 'rogue-resource-skill', description: 'Rogue resource skill', source: 'runtime', provider: 'runtime', resourceBase: { kind: 'future' } as never, content: 'Rogue instructions.', }) const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c5'), name: 'skill', arguments: { name: 'rogue-resource-skill' } }) expect(result.isError).toBe(true) expect(result.error?.info?.code).toBe('INVALID_TOOL_OUTPUT') const block = result.content[0] if (block?.type !== 'text') throw new Error('expected text tool result') expect(block.text).toContain('value.resourceBase') }) it('returns isError for unknown, invalid, and model-disabled skills', async () => { const home = await tempDir('tool-errors') await writeSkill(join(home, '.dsh/skills'), 'hidden-skill', 'Hidden skill', 'Hidden instructions.') await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: Hidden skill\ndisable-model-invocation: true\n---\n\nHidden instructions.\n') const ctx = await setup(home) ctx.skills.register({ name: 'model-only-skill', description: 'Model-only skill', invocation: { modelInvocable: true, userInvocable: false }, source: 'runtime', content: 'Model-only instructions.', }) const unknown = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'skill', arguments: { name: 'missing' } }) const invalid = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c2'), name: 'skill', arguments: { name: 'Bad_Name' } }) const disabled = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c3'), name: 'skill', arguments: { name: 'hidden-skill' } }) const modelOnly = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c4'), name: 'skill', arguments: { name: 'model-only-skill' } }) expect(unknown.isError).toBe(true) expect(invalid.isError).toBe(true) expect(disabled.isError).toBe(true) expect(modelOnly.isError).toBe(false) const unknownBlock = unknown.content[0] if (unknownBlock?.type !== 'text') throw new Error('expected text tool result') expect(unknownBlock.text).toContain('skill "missing" is unknown or no longer available') }) it('checks model policy before provider loading and rechecks the loaded definition', async () => { const home = await tempDir('tool-policy-before-load') const ctx = await setup(home) const getCalls: string[] = [] ctx.skills.registerProvider(() => ({ name: 'policy-probe', async list() { return [ { name: 'denied-skill', description: 'Denied skill', invocation: { modelInvocable: false, userInvocable: true }, provider: 'policy-probe', source: 'test', rank: 1, locator: 'denied-skill', }, { name: 'policy-race-skill', description: 'Policy race skill', invocation: { modelInvocable: true, userInvocable: true }, provider: 'policy-probe', source: 'test', rank: 1, locator: 'policy-race-skill', }, { name: 'vanishing-skill', description: 'Vanishing skill', invocation: { modelInvocable: true, userInvocable: true }, provider: 'policy-probe', source: 'test', rank: 1, locator: 'vanishing-skill', }, ] }, async get(candidate) { getCalls.push(candidate.name) if (candidate.name === 'vanishing-skill') return undefined return { ...candidate, invocation: { modelInvocable: false, userInvocable: true }, content: 'Instructions must not be disclosed.', } }, })) const denied = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c6'), name: 'skill', arguments: { name: 'denied-skill' } }) const raced = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c7'), name: 'skill', arguments: { name: 'policy-race-skill' } }) const vanished = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c8'), name: 'skill', arguments: { name: 'vanishing-skill' } }) expect(denied.isError).toBe(true) expect(raced.isError).toBe(true) expect(vanished.isError).toBe(true) expect(getCalls).toEqual(['policy-race-skill', 'vanishing-skill']) for (const result of [denied, raced]) { const block = result.content[0] if (block?.type !== 'text') throw new Error('expected text tool result') expect(block.text).toContain('is not available for model invocation') expect(block.text).not.toContain('Instructions must not be disclosed.') } const vanishedBlock = vanished.content[0] if (vanishedBlock?.type !== 'text') throw new Error('expected text tool result') expect(vanishedBlock.text).toContain('skill "vanishing-skill" is unknown or no longer available') }) })