tool-skill.spec.ts 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111
  1. import { afterEach, describe, expect, it } from 'vitest'
  2. import { mkdir, rm, writeFile } from 'node:fs/promises'
  3. import { join } from 'node:path'
  4. import { tmpdir } from 'node:os'
  5. import { Context } from '@deepseek-ai/cordis'
  6. import { createUserMessage, ToolCallId, type Message } from '@deepseek-ai/dsh-llm'
  7. import { createScope, type Scope } from '@deepseek-ai/dsh-scope'
  8. import {
  9. SESSION_FORMAT_VERSION, Session, SessionId, type SessionEvent, type UserMessage,
  10. } from '@deepseek-ai/dsh-session'
  11. import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
  12. import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
  13. import AgentRegistry, { agentEvents, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent'
  14. import SkillRegistry from '@deepseek-ai/dsh-skill'
  15. import * as SkillFileSystem from '@deepseek-ai/dsh-skill-filesystem'
  16. import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
  17. import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
  18. const testToolSignal = new AbortController().signal
  19. /** Every temp dir created by this file, removed after each test. */
  20. const tempDirs: string[] = []
  21. afterEach(async () => {
  22. for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true })
  23. })
  24. async function tempDir(name: string): Promise<string> {
  25. const dir = await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
  26. tempDirs.push(dir)
  27. return dir
  28. }
  29. async function writeSkill(root: string, name: string, description: string, body: string): Promise<void> {
  30. const dir = join(root, name)
  31. await mkdir(dir, { recursive: true })
  32. await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
  33. }
  34. async function setup(home: string, config: toolSkill.Config = {}): Promise<Context> {
  35. const ctx = new Context()
  36. await ctx.plugin(SystemPrompt)
  37. await ctx.plugin(ToolRuntime)
  38. await ctx.plugin(AgentRegistry)
  39. await ctx.plugin(SkillRegistry)
  40. await ctx.plugin(SkillFileSystem, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false })
  41. await ctx.plugin(toolSkill, config)
  42. return ctx
  43. }
  44. function agentForCwd(cwd: string): Agent {
  45. const id = SessionId(`tool-skill-${cwd}`)
  46. const session = Session.create(id, [], {
  47. version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd, isSeeded: false,
  48. })
  49. return {
  50. ctx: new Context(),
  51. id,
  52. options: {},
  53. session,
  54. inbox: unsupportedInbox(),
  55. status: 'idle',
  56. send: () => {},
  57. followup: () => {},
  58. steer: () => {},
  59. inject: () => { throw new Error('step-boundary catalog must not use agent.inject()') },
  60. cancel() {},
  61. runMaintenance: task => task(new AbortController().signal),
  62. whenIdle: () => Promise.resolve(),
  63. }
  64. }
  65. function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent {
  66. const agent: Agent = {
  67. id: SessionId(id),
  68. options: {},
  69. session,
  70. inbox: unsupportedInbox(),
  71. status: 'running',
  72. ctx: new Context(),
  73. send: () => {},
  74. followup: () => {},
  75. steer: () => {},
  76. inject: () => { throw new Error('step-boundary catalog must not use agent.inject()') },
  77. cancel() {},
  78. runMaintenance: task => task(new AbortController().signal),
  79. whenIdle: () => Promise.resolve(),
  80. }
  81. return agent
  82. }
  83. function openMessageTurn(session: Session, turn = 1): void {
  84. session.append('turn/start', { turn })
  85. session.append('user/message', createUserMessage({
  86. content: [{ type: 'text', text: `turn ${turn}` }],
  87. source: { kind: 'user' },
  88. }), { surfaceOp: 'append' })
  89. }
  90. async function fireStep(ctx: Context, agent: Agent, turn: number, step: number): Promise<void> {
  91. const signal = new AbortController().signal
  92. const decision = await agentEvents(ctx, agent).waterfall(
  93. 'agent/pre-step',
  94. { messages: [], turn, step, signal },
  95. () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
  96. )
  97. if (decision.kind === 'enter') {
  98. for (const message of decision.messages) {
  99. agent.session.append('user/message', message, { surfaceOp: 'append' })
  100. }
  101. }
  102. }
  103. async function proposeStep(
  104. ctx: Context,
  105. agent: Agent,
  106. messages: UserMessage[],
  107. ): Promise<PreStepDecision> {
  108. const signal = new AbortController().signal
  109. return await agentEvents(ctx, agent).waterfall(
  110. 'agent/pre-step',
  111. { messages, turn: 1, step: 1, signal },
  112. () => Promise.resolve({ kind: 'enter' as const, messages }),
  113. )
  114. }
  115. function catalogMessages(session: Session): Extract<SessionEvent, { type: 'user/message' }>[] {
  116. return session.snapshotEvents().filter((event): event is Extract<SessionEvent, { type: 'user/message' }> => event.type === 'user/message'
  117. && event.data.source.kind === 'skill-catalog')
  118. }
  119. function readableCatalog(event: Extract<SessionEvent, { type: 'user/message' }>): boolean {
  120. const entries = (event.data.source as { entries?: unknown }).entries
  121. return Array.isArray(entries)
  122. && entries.every(entry => typeof entry === 'object' && entry !== null
  123. && typeof (entry as { name?: unknown }).name === 'string'
  124. && typeof (entry as { description?: unknown }).description === 'string')
  125. }
  126. function catalogContent(entries: string[]): Message['content'] {
  127. return [{
  128. type: 'text',
  129. text: ['<system-reminder>', '<available_skills>', ...entries, '</available_skills>', '</system-reminder>'].join('\n'),
  130. }]
  131. }
  132. async function composePrefix(ctx: Context, cwd: string, signal = new AbortController().signal): Promise<Message[]> {
  133. return await composePrefixForAgent(ctx, agentForCwd(cwd), signal)
  134. }
  135. async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new AbortController().signal): Promise<Message[]> {
  136. const decision = await agentEvents(ctx, agent).waterfall(
  137. 'agent/pre-step',
  138. { messages: [], turn: 1, step: 1, signal },
  139. () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
  140. )
  141. if (decision.kind === 'enter') {
  142. for (const message of decision.messages) {
  143. agent.session.append('user/message', message, { surfaceOp: 'append' })
  144. }
  145. }
  146. return agent.session.deriveMessages()
  147. }
  148. async function mintAgentScope(ctx: Context, subject: string | Agent): Promise<{ agent: Agent; scope: Scope }> {
  149. const agent = typeof subject === 'string' ? agentForCwd(subject) : subject
  150. let scope!: Scope
  151. await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, {
  152. inject: ['tools'],
  153. }))
  154. return { agent, scope }
  155. }
  156. describe('dsh-tool-skill', () => {
  157. it('registers the skill tool schema and removes it on dispose', async () => {
  158. const ctx = new Context()
  159. await ctx.plugin(SystemPrompt)
  160. await ctx.plugin(ToolRuntime)
  161. await ctx.plugin(AgentRegistry)
  162. const home = await tempDir('tool-schema')
  163. await ctx.plugin(SkillRegistry)
  164. await ctx.plugin(SkillFileSystem, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false })
  165. ctx.skills.register({ name: 'lifecycle-skill', description: 'Lifecycle', source: 'runtime', content: 'body' })
  166. const fiber = await ctx.plugin(toolSkill)
  167. expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['skill'])
  168. expect(await composePrefix(ctx, '/workspace')).toHaveLength(1)
  169. expect(ctx.tools.get('skill')?.presentCall?.({ name: 'project-skill' })).toEqual({
  170. card: 'generic',
  171. title: 'Load skill project-skill',
  172. kind: 'read',
  173. rawInput: 'project-skill',
  174. })
  175. await fiber.dispose()
  176. expect(ctx.tools.schemas()).toEqual([])
  177. expect(await composePrefix(ctx, '/workspace')).toEqual([])
  178. toolSkill.apply(ctx)
  179. expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['skill'])
  180. })
  181. it('forwards the step abort signal to skill discovery', async () => {
  182. const home = await tempDir('tool-prefix-signal')
  183. const ctx = await setup(home)
  184. let seenSignal: AbortSignal | undefined
  185. ctx.skills.registerProvider(() => ({
  186. name: 'signal-probe',
  187. async list(options) {
  188. seenSignal = options.signal
  189. return []
  190. },
  191. async get() {
  192. return undefined
  193. },
  194. }))
  195. const controller = new AbortController()
  196. await composePrefix(ctx, '/workspace', controller.signal)
  197. expect(seenSignal).toBe(controller.signal)
  198. })
  199. it('injects a stable durable name-and-description catalog at the first step', async () => {
  200. const home = await tempDir('tool-catalog')
  201. const ctx = await setup(home, { catalogDescriptionMaxLength: 50 })
  202. ctx.skills.register({
  203. name: 'z-skill',
  204. description: 'Long description '.repeat(5),
  205. whenToUse: 'Never render this routing hint.',
  206. source: 'secret-source',
  207. provider: 'runtime',
  208. resourceBase: { kind: 'directory', path: '/secret/path' },
  209. content: 'Secret body.',
  210. })
  211. ctx.skills.register({
  212. name: 'a-skill',
  213. description: 'Use {{placeholder}} <safely> & carefully.',
  214. source: 'runtime',
  215. provider: 'runtime',
  216. content: 'A body.',
  217. })
  218. ctx.skills.register({
  219. name: 'model-only-skill',
  220. description: 'Model-only skill.',
  221. invocation: { modelInvocable: true, userInvocable: false },
  222. source: 'runtime',
  223. content: 'Model-only body.',
  224. })
  225. ctx.skills.register({
  226. name: 'user-only-skill',
  227. description: 'User-only skill.',
  228. invocation: { modelInvocable: false, userInvocable: true },
  229. source: 'runtime',
  230. content: 'User-only body.',
  231. })
  232. ctx.on('agent/pre-step', async (_payload, next) => {
  233. const decision = await next()
  234. if (decision.kind === 'reject') return decision
  235. return {
  236. ...decision,
  237. messages: [
  238. ...decision.messages,
  239. createUserMessage({
  240. content: [{ type: 'text', text: 'later contribution' }],
  241. source: { kind: 'plugin', plugin: 'later-contribution' },
  242. }),
  243. ],
  244. }
  245. })
  246. const prefix = await composePrefix(ctx, '/workspace')
  247. expect(prefix).toEqual([
  248. {
  249. id: expect.any(String) as unknown,
  250. role: 'user',
  251. content: [{ type: 'text', text: 'later contribution' }],
  252. source: { kind: 'plugin', plugin: 'later-contribution' },
  253. },
  254. {
  255. id: expect.any(String) as unknown,
  256. role: 'user',
  257. source: {
  258. kind: 'skill-catalog',
  259. form: 'catalog',
  260. entries: [
  261. { name: 'a-skill', description: 'Use {{placeholder}} <safely> & carefully.' },
  262. { name: 'model-only-skill', description: 'Model-only skill.' },
  263. { name: 'z-skill', description: 'Long description Long description Long descript...' },
  264. ],
  265. },
  266. content: [{
  267. type: 'text',
  268. text: [
  269. '<system-reminder>',
  270. 'A skill is a reusable set of task-specific instructions. The following skills are available in this session:',
  271. '',
  272. '<available_skills>',
  273. '- `a-skill`: Use {{placeholder}} &lt;safely&gt; &amp; carefully.',
  274. '- `model-only-skill`: Model-only skill.',
  275. '- `z-skill`: Long description Long description Long descript...',
  276. '</available_skills>',
  277. '',
  278. "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.",
  279. 'A user may also invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.',
  280. '</system-reminder>',
  281. ].join('\n'),
  282. }],
  283. },
  284. ])
  285. const rendered = JSON.stringify(prefix[1])
  286. expect(rendered).not.toContain('whenToUse')
  287. expect(rendered).not.toContain('secret-source')
  288. expect(rendered).not.toContain('/secret/path')
  289. expect(rendered).not.toContain('Secret body')
  290. expect(rendered).not.toContain('user-only-skill')
  291. expect(renderPrompt(await ctx.systemPrompt.assemble({ agent: agentForCwd('/workspace') }))).not.toContain('<available_skills>')
  292. })
  293. it('does not inject a catalog when no model-invocable skills are available', async () => {
  294. const home = await tempDir('tool-empty-catalog')
  295. const ctx = await setup(home)
  296. ctx.skills.register({
  297. name: 'user-only-skill',
  298. description: 'User-only skill',
  299. invocation: { modelInvocable: false, userInvocable: true },
  300. source: 'runtime',
  301. content: 'User-only body.',
  302. })
  303. const agent = agentForCwd('/workspace')
  304. expect(await composePrefixForAgent(ctx, agent)).toEqual([])
  305. expect(await composePrefixForAgent(ctx, agent)).toEqual([])
  306. })
  307. it('omits an incomplete initial catalog and retries on a later request boundary', async () => {
  308. const home = await tempDir('tool-incomplete-prefix')
  309. const ctx = await setup(home)
  310. let failing = true
  311. const provider = {
  312. name: 'recovering',
  313. async list() {
  314. if (failing) throw new Error('temporarily unavailable')
  315. return []
  316. },
  317. async get() {
  318. return undefined
  319. },
  320. }
  321. let invalidate = (): void => {}
  322. ctx.skills.registerProvider((control) => {
  323. invalidate = control.invalidate
  324. return provider
  325. })
  326. const session = Session.create(SessionId('incomplete-prefix'))
  327. const agent = sessionAgent(session)
  328. openMessageTurn(session)
  329. await composePrefixForAgent(ctx, agent)
  330. expect(catalogMessages(session)).toEqual([])
  331. failing = false
  332. invalidate()
  333. await fireStep(ctx, agent, 1, 1)
  334. expect(catalogMessages(session)).toEqual([])
  335. })
  336. it('records an empty baseline across repeated step observations', async () => {
  337. const home = await tempDir('tool-empty-step')
  338. const ctx = await setup(home)
  339. const session = Session.create(SessionId('empty-step'))
  340. const agent = sessionAgent(session)
  341. openMessageTurn(session)
  342. await fireStep(ctx, agent, 1, 1)
  343. await fireStep(ctx, agent, 1, 2)
  344. expect(catalogMessages(session)).toEqual([])
  345. })
  346. it('deduplicates or replaces a catalog already proposed for the same step', async () => {
  347. const home = await tempDir('tool-proposed-catalog')
  348. const ctx = await setup(home)
  349. const disposeFirst = ctx.skills.register({
  350. name: 'first-skill',
  351. description: 'First skill',
  352. source: 'runtime',
  353. content: 'First body.',
  354. })
  355. const session = Session.create(SessionId('proposed-catalog'))
  356. const agent = sessionAgent(session)
  357. openMessageTurn(session)
  358. await fireStep(ctx, agent, 1, 1)
  359. const initial = catalogMessages(session)[0]?.data
  360. if (initial === undefined) throw new Error('expected initial catalog')
  361. const duplicate = await proposeStep(ctx, agent, [initial])
  362. expect(duplicate).toEqual({ kind: 'enter', messages: [] })
  363. ctx.skills.register({
  364. name: 'second-skill',
  365. description: 'Second skill',
  366. source: 'runtime',
  367. content: 'Second body.',
  368. })
  369. const companion = createUserMessage({
  370. content: [{ type: 'text', text: 'keep this message' }],
  371. source: { kind: 'user' },
  372. })
  373. const replaced = await proposeStep(ctx, agent, [companion, initial])
  374. expect(replaced.kind).toBe('enter')
  375. if (replaced.kind === 'reject') throw new Error('expected catalog replacement')
  376. expect(replaced.messages).toHaveLength(2)
  377. expect(replaced.messages[0]).toBe(companion)
  378. expect(replaced.messages[1]?.id).not.toBe(initial.id)
  379. expect(JSON.stringify(replaced.messages[1]?.content)).toContain('second-skill')
  380. disposeFirst()
  381. })
  382. it('removes a stale proposed catalog before the first empty baseline', async () => {
  383. const home = await tempDir('tool-proposed-empty-catalog')
  384. const ctx = await setup(home)
  385. const session = Session.create(SessionId('proposed-empty-catalog'))
  386. const malformed = createUserMessage({
  387. content: [{ type: 'text', text: 'preserve unreadable claimed context' }],
  388. source: { kind: 'skill-catalog', form: 'catalog' } as never,
  389. })
  390. const stale = createUserMessage({
  391. content: catalogContent(['- `stale-skill`: Stale skill']),
  392. source: {
  393. kind: 'skill-catalog',
  394. form: 'catalog',
  395. entries: [{ name: 'stale-skill', description: 'Stale skill' }],
  396. },
  397. })
  398. const decision = await proposeStep(ctx, sessionAgent(session), [malformed, stale])
  399. expect(decision).toEqual({ kind: 'enter', messages: [malformed] })
  400. })
  401. it('keeps a proposed catalog that already matches the current snapshot', async () => {
  402. const home = await tempDir('tool-matching-proposal')
  403. const ctx = await setup(home)
  404. ctx.skills.register({
  405. name: 'first-skill',
  406. description: 'First skill',
  407. source: 'runtime',
  408. content: 'First body.',
  409. })
  410. const session = Session.create(SessionId('matching-proposal'))
  411. const proposed = createUserMessage({
  412. content: catalogContent(['- `first-skill`: First skill']),
  413. source: {
  414. kind: 'skill-catalog',
  415. form: 'catalog',
  416. entries: [{ name: 'first-skill', description: 'First skill' }],
  417. },
  418. })
  419. const decision = await proposeStep(ctx, sessionAgent(session), [proposed])
  420. expect(decision).toEqual({ kind: 'enter', messages: [proposed] })
  421. })
  422. it('injects complete replacement catalogs for additions and an empty tombstone for removals', async () => {
  423. const home = await tempDir('tool-dynamic-catalog')
  424. const ctx = await setup(home)
  425. const disposeFirst = ctx.skills.register({
  426. name: 'first-skill',
  427. description: 'First skill',
  428. source: 'runtime',
  429. content: 'First body.',
  430. })
  431. const session = Session.create(SessionId('dynamic-catalog'))
  432. const agent = sessionAgent(session)
  433. openMessageTurn(session)
  434. expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('first-skill')
  435. await fireStep(ctx, agent, 1, 1)
  436. expect(catalogMessages(session)).toHaveLength(1)
  437. const disposeSecond = ctx.skills.register({
  438. name: 'second-skill',
  439. description: 'Second skill',
  440. source: 'runtime',
  441. content: 'Second body.',
  442. })
  443. await fireStep(ctx, agent, 1, 2)
  444. const addition = catalogMessages(session)[1]
  445. if (addition?.type !== 'user/message') throw new Error('expected catalog addition')
  446. expect(JSON.stringify(addition.data.content)).toContain('first-skill')
  447. expect(JSON.stringify(addition.data.content)).toContain('second-skill')
  448. disposeSecond()
  449. disposeFirst()
  450. await fireStep(ctx, agent, 1, 3)
  451. const removal = catalogMessages(session)[2]
  452. if (removal?.type !== 'user/message') throw new Error('expected catalog removal')
  453. expect(JSON.stringify(removal.data.content)).toContain('No skills are currently available')
  454. expect(JSON.stringify(removal.data.content)).not.toContain('first-skill')
  455. expect(JSON.stringify(removal.data.content)).not.toContain('second-skill')
  456. await fireStep(ctx, agent, 1, 4)
  457. expect(catalogMessages(session)).toHaveLength(3)
  458. })
  459. it('resumes from the durable entries of the latest visible catalog', async () => {
  460. // Catalog identity lives on `source.entries`: the model-facing prose does
  461. // not decide whether a republish is needed, so a seeded message is
  462. // recognized by its source alone and malformed prose cannot hide (or fake)
  463. // a published catalog. A foreign-sourced message is not this plugin's
  464. // catalog at all.
  465. const home = await tempDir('tool-catalog-resume')
  466. const ctx = await setup(home)
  467. ctx.skills.register({
  468. name: 'resumed-skill',
  469. description: 'Resumed skill',
  470. source: 'runtime',
  471. content: 'Resumed body.',
  472. })
  473. const session = Session.create(SessionId('catalog-resume'))
  474. const agent = sessionAgent(session)
  475. openMessageTurn(session)
  476. session.append('user/message', createUserMessage({
  477. content: [{ type: 'text', text: 'prose a reader cannot rely on' }],
  478. source: {
  479. kind: 'skill-catalog',
  480. form: 'catalog',
  481. entries: [{ name: 'old-skill', description: 'Old skill' }],
  482. },
  483. }), { surfaceOp: 'append' })
  484. session.append('user/message', createUserMessage({
  485. content: catalogContent(['- `resumed-skill`: Resumed skill']),
  486. source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
  487. }), { surfaceOp: 'append' })
  488. await fireStep(ctx, agent, 1, 1)
  489. // The seeded entries differ from the live snapshot, so one replacement
  490. // lands; the foreign-sourced lookalike neither counts as published nor
  491. // suppresses it.
  492. expect(catalogMessages(session)).toHaveLength(2)
  493. const latest = catalogMessages(session).at(-1)
  494. expect(latest?.data.source).toMatchObject({
  495. kind: 'skill-catalog',
  496. form: 'catalog',
  497. update: true,
  498. entries: [{ name: 'resumed-skill', description: 'Resumed skill' }],
  499. })
  500. expect(JSON.stringify(latest?.data.content)).toContain('resumed-skill')
  501. // A second step over unchanged entries republishes nothing.
  502. await fireStep(ctx, agent, 1, 2)
  503. expect(catalogMessages(session)).toHaveLength(2)
  504. })
  505. it('treats a malformed durable catalog as unrecognizable instead of failing the step', async () => {
  506. // Seeds reach `agent.session.snapshotEvents()` from persistence on resume or fork,
  507. // and seed validation only guarantees a source object with a non-empty
  508. // `kind`. A catalog whose entries are missing or wrongly shaped must be
  509. // skipped like any foreign record; throwing here would fail every later
  510. // step of that session at the latest possible point.
  511. const home = await tempDir('tool-catalog-malformed')
  512. const ctx = await setup(home)
  513. ctx.skills.register({
  514. name: 'live-skill',
  515. description: 'Live skill',
  516. source: 'runtime',
  517. content: 'Live body.',
  518. })
  519. const session = Session.create(SessionId('catalog-malformed'))
  520. const agent = sessionAgent(session)
  521. openMessageTurn(session)
  522. for (const source of [
  523. { kind: 'skill-catalog', form: 'catalog' },
  524. { kind: 'skill-catalog', form: 'catalog', entries: null },
  525. { kind: 'skill-catalog', form: 'catalog', entries: 'not-an-array' },
  526. { kind: 'skill-catalog', form: 'catalog', entries: [null] },
  527. { kind: 'skill-catalog', form: 'catalog', entries: [{ name: 'x' }] },
  528. { kind: 'skill-catalog', form: 'catalog', entries: [{ description: 'no name' }] },
  529. ]) {
  530. session.append('user/message', createUserMessage({
  531. content: [{ type: 'text', text: 'unreadable catalog' }],
  532. source: source as never,
  533. }), { surfaceOp: 'append' })
  534. }
  535. await expect(fireStep(ctx, agent, 1, 1)).resolves.toBeUndefined()
  536. // None of the six counted as published, so the live catalog lands as a
  537. // first publication rather than a replacement.
  538. const published = catalogMessages(session).filter(event => readableCatalog(event))
  539. expect(published).toHaveLength(1)
  540. expect(published[0]?.data.source).toMatchObject({ kind: 'skill-catalog', form: 'catalog' })
  541. expect(published[0]?.data.source).not.toHaveProperty('update')
  542. expect(JSON.stringify(published[0]?.data.content)).toContain('live-skill')
  543. })
  544. it('rejects a missing event below the current Session length', async () => {
  545. const home = await tempDir('tool-catalog-missing-event')
  546. const ctx = await setup(home)
  547. const session = Session.create(SessionId('catalog-missing-event'))
  548. const agent = sessionAgent(session)
  549. openMessageTurn(session)
  550. Object.defineProperty(session, 'eventAt', { value: () => undefined })
  551. await expect(fireStep(ctx, agent, 1, 1))
  552. .rejects.toThrow('skill catalog cannot read seq 1 below the current Session length')
  553. })
  554. it('re-establishes the current catalog after compaction hides its durable message', async () => {
  555. const home = await tempDir('tool-catalog-compaction')
  556. const ctx = await setup(home)
  557. ctx.skills.register({
  558. name: 'first-skill',
  559. description: 'First skill',
  560. source: 'runtime',
  561. content: 'First body.',
  562. })
  563. const session = Session.create(SessionId('catalog-compaction'))
  564. const agent = sessionAgent(session)
  565. openMessageTurn(session)
  566. expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('first-skill')
  567. const initial = catalogMessages(session)[0]
  568. if (initial === undefined) throw new Error('expected initial catalog')
  569. session.append('user/message', createUserMessage({
  570. content: [{ type: 'text', text: 'compacted history' }],
  571. source: { kind: 'plugin', plugin: 'compact' },
  572. }), {
  573. surfaceOp: { op: 'replace', startSeq: initial.seq, endSeq: initial.seq },
  574. sourceEventSeqs: [initial.seq],
  575. })
  576. await fireStep(ctx, agent, 1, 1)
  577. expect(catalogMessages(session)).toHaveLength(2)
  578. expect(JSON.stringify(catalogMessages(session).at(-1)?.data.content)).toContain('first-skill')
  579. })
  580. it('keeps body-only edits out of the catalog and loads the latest body on demand', async () => {
  581. const home = await tempDir('tool-body-refresh')
  582. const root = join(home, '.dsh/skills')
  583. await writeSkill(root, 'body-skill', 'Stable description', 'First body.')
  584. const ctx = await setup(home)
  585. const session = Session.create(SessionId('body-refresh'))
  586. const agent = sessionAgent(session)
  587. openMessageTurn(session)
  588. expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('Stable description')
  589. await writeSkill(root, 'body-skill', 'Stable description', 'Second body.')
  590. await fireStep(ctx, agent, 1, 1)
  591. expect(catalogMessages(session)).toHaveLength(1)
  592. const result = await ctx.tools.execute({
  593. signal: testToolSignal,
  594. callId: ToolCallId('body-refresh'),
  595. name: 'skill',
  596. arguments: { name: 'body-skill' },
  597. agent,
  598. })
  599. expect(result.isError).toBe(false)
  600. expect(JSON.stringify(result.content)).toContain('Second body.')
  601. expect(JSON.stringify(result.content)).not.toContain('First body.')
  602. })
  603. it('resolves the layered registry as the calling agent sees it', async () => {
  604. const home = await tempDir('tool-scoped-layer')
  605. const ctx = await setup(home)
  606. const { agent, scope } = await mintAgentScope(ctx, '/workspace/scoped')
  607. const scopedSkills = scope.ctx.get('skills')
  608. if (scopedSkills === undefined) throw new Error('skills service missing')
  609. scopedSkills.register({
  610. name: 'preset-only-skill',
  611. description: 'Visible to the scoped agent alone',
  612. source: 'preset',
  613. content: 'Preset-only body.',
  614. })
  615. expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('preset-only-skill')
  616. expect(JSON.stringify(await composePrefix(ctx, '/workspace/other'))).not.toContain('preset-only-skill')
  617. const scoped = await ctx.tools.execute({
  618. signal: testToolSignal,
  619. callId: ToolCallId('scoped-load'),
  620. name: 'skill',
  621. arguments: { name: 'preset-only-skill' },
  622. agent,
  623. })
  624. expect(scoped.isError).toBe(false)
  625. expect(JSON.stringify(scoped.content)).toContain('Preset-only body.')
  626. const foreign = await ctx.tools.execute({
  627. signal: testToolSignal,
  628. callId: ToolCallId('foreign-load'),
  629. name: 'skill',
  630. arguments: { name: 'preset-only-skill' },
  631. agent: agentForCwd('/workspace/other'),
  632. })
  633. expect(foreign.isError).toBe(true)
  634. await scope.dispose()
  635. })
  636. it('retains the last-good catalog while any provider discovery is incomplete', async () => {
  637. const home = await tempDir('tool-incomplete-catalog')
  638. const ctx = await setup(home)
  639. const disposeStable = ctx.skills.register({
  640. name: 'stable-skill',
  641. description: 'Stable skill',
  642. source: 'runtime',
  643. content: 'Stable body.',
  644. })
  645. const session = Session.create(SessionId('incomplete-catalog'))
  646. const agent = sessionAgent(session)
  647. openMessageTurn(session)
  648. expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('stable-skill')
  649. ctx.skills.registerProvider(() => ({
  650. name: 'failing',
  651. async list() {
  652. throw new Error('temporarily unavailable')
  653. },
  654. async get() {
  655. return undefined
  656. },
  657. }))
  658. disposeStable()
  659. await fireStep(ctx, agent, 1, 1)
  660. expect(catalogMessages(session)).toHaveLength(1)
  661. })
  662. it('omits catalog guidance when the calling agent restricts away the shipped skill tool', async () => {
  663. const home = await tempDir('tool-restricted-catalog')
  664. const ctx = await setup(home)
  665. ctx.skills.register({ name: 'listed-skill', description: 'Listed', source: 'runtime', content: 'body' })
  666. const session = Session.create(SessionId('restricted-catalog'))
  667. const agent = sessionAgent(session)
  668. openMessageTurn(session)
  669. const { scope } = await mintAgentScope(ctx, agent)
  670. scope.ctx.tools.restrict({ deny: ['skill'] })
  671. expect(ctx.tools.get('skill', agent)).toBeUndefined()
  672. await composePrefixForAgent(ctx, agent)
  673. expect(catalogMessages(session)).toEqual([])
  674. await fireStep(ctx, agent, 1, 1)
  675. expect(catalogMessages(session)).toEqual([])
  676. expect(await composePrefix(ctx, '/workspace')).toHaveLength(1)
  677. await scope.dispose()
  678. })
  679. it('does not attach shipped catalog guidance to a scoped same-name tool shadow', async () => {
  680. const home = await tempDir('tool-shadowed-catalog')
  681. const ctx = await setup(home)
  682. ctx.skills.register({ name: 'listed-skill', description: 'Listed', source: 'runtime', content: 'body' })
  683. const { agent, scope } = await mintAgentScope(ctx, '/workspace')
  684. scope.ctx.tools.register(defineContentToolFixture({
  685. name: 'skill',
  686. description: 'A scoped tool with unrelated semantics.',
  687. parameters: {},
  688. execute() {
  689. return Promise.resolve([{ type: 'text', text: 'shadow' }])
  690. },
  691. }))
  692. expect(ctx.tools.get('skill', agent)).not.toBe(ctx.tools.get('skill'))
  693. expect(await composePrefixForAgent(ctx, agent)).toEqual([])
  694. expect(await composePrefix(ctx, '/workspace')).toHaveLength(1)
  695. await scope.dispose()
  696. })
  697. it('validates the catalog description cap', async () => {
  698. const home = await tempDir('tool-invalid-catalog-cap')
  699. const ctx = new Context()
  700. await ctx.plugin(SystemPrompt)
  701. await ctx.plugin(ToolRuntime)
  702. await ctx.plugin(AgentRegistry)
  703. await ctx.plugin(SkillRegistry)
  704. await ctx.plugin(SkillFileSystem, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false })
  705. await expect(ctx.plugin(toolSkill, { catalogDescriptionMaxLength: 2 })).rejects.toThrow('greater than or equal to 3')
  706. })
  707. it('loads a skill for the calling agent cwd', async () => {
  708. const home = await tempDir('tool-load')
  709. const project = await tempDir('tool-project')
  710. await mkdir(join(project, '.git'), { recursive: true })
  711. await writeSkill(join(project, '.dsh/skills'), 'project-skill', 'Project skill', 'Project instructions.')
  712. const ctx = await setup(home)
  713. const result = await ctx.tools.execute({
  714. signal: testToolSignal,
  715. callId: ToolCallId('c1'),
  716. name: 'skill',
  717. arguments: { name: 'project-skill' },
  718. agent: { session: { header: { cwd: project } } } as never,
  719. })
  720. expect(result.isError).toBe(false)
  721. if (result.isError) throw new Error('expected skill success')
  722. expect(result.value).toEqual({
  723. name: 'project-skill',
  724. provider: 'filesystem',
  725. resourceBase: { kind: 'directory', path: join(project, '.dsh/skills/project-skill') },
  726. content: 'Project instructions.',
  727. })
  728. const block = result.content[0]
  729. expect(block?.type).toBe('text')
  730. if (block?.type !== 'text') throw new Error('expected text skill result')
  731. expect(block.text).toBe([
  732. '<skill_content name="project-skill">',
  733. '<skill_resources>',
  734. `Base directory for this skill: ${join(project, '.dsh/skills/project-skill')}`,
  735. 'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.',
  736. '</skill_resources>',
  737. '',
  738. '<skill_instructions>',
  739. 'Project instructions.',
  740. '</skill_instructions>',
  741. '</skill_content>',
  742. ].join('\n'))
  743. expect(block.text).not.toContain('# Skill:')
  744. })
  745. it('renders provider-managed resource hints for non-local skills', async () => {
  746. const home = await tempDir('tool-resource-hints')
  747. const ctx = await setup(home)
  748. ctx.skills.register({
  749. name: 'opaque-skill',
  750. description: 'Opaque skill',
  751. source: 'runtime',
  752. provider: 'runtime',
  753. resourceBase: { kind: 'opaque', description: 'runtime memory' },
  754. content: 'Opaque instructions.',
  755. })
  756. ctx.skills.register({
  757. name: 'url-skill',
  758. description: 'URL skill',
  759. source: 'runtime',
  760. provider: 'runtime',
  761. resourceBase: { kind: 'url', url: 'https://skills.example.test/url-skill' },
  762. content: 'URL instructions.',
  763. })
  764. ctx.skills.register({
  765. name: 'provider-skill',
  766. description: 'Provider skill',
  767. source: 'runtime',
  768. provider: 'runtime',
  769. content: 'Provider instructions.',
  770. })
  771. const opaque = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c2'), name: 'skill', arguments: { name: 'opaque-skill' } })
  772. const url = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c3'), name: 'skill', arguments: { name: 'url-skill' } })
  773. const provider = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c4'), name: 'skill', arguments: { name: 'provider-skill' } })
  774. if (opaque.content[0]?.type !== 'text' || url.content[0]?.type !== 'text' || provider.content[0]?.type !== 'text') {
  775. throw new Error('expected text tool results')
  776. }
  777. expect(opaque.content[0].text).toContain('<skill_resources>\nResources for this skill: runtime memory\nLoad referenced resources only as needed.\n</skill_resources>')
  778. expect(url.content[0].text).toContain('<skill_resources>\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</skill_resources>')
  779. expect(provider.content[0].text).toContain('<skill_resources>\nResources for this skill are managed by provider "runtime".\nLoad referenced resources only as needed.\n</skill_resources>')
  780. })
  781. it('rejects an unknown resource-base kind at the canonical output boundary', async () => {
  782. const home = await tempDir('tool-resource-assert-never')
  783. const ctx = await setup(home)
  784. ctx.skills.register({
  785. name: 'rogue-resource-skill',
  786. description: 'Rogue resource skill',
  787. source: 'runtime',
  788. provider: 'runtime',
  789. resourceBase: { kind: 'future' } as never,
  790. content: 'Rogue instructions.',
  791. })
  792. const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c5'), name: 'skill', arguments: { name: 'rogue-resource-skill' } })
  793. expect(result.isError).toBe(true)
  794. expect(result.error?.info?.code).toBe('INVALID_TOOL_OUTPUT')
  795. const block = result.content[0]
  796. if (block?.type !== 'text') throw new Error('expected text tool result')
  797. expect(block.text).toContain('value.resourceBase')
  798. })
  799. it('returns isError for unknown, invalid, and model-disabled skills', async () => {
  800. const home = await tempDir('tool-errors')
  801. await writeSkill(join(home, '.dsh/skills'), 'hidden-skill', 'Hidden skill', 'Hidden instructions.')
  802. 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')
  803. const ctx = await setup(home)
  804. ctx.skills.register({
  805. name: 'model-only-skill',
  806. description: 'Model-only skill',
  807. invocation: { modelInvocable: true, userInvocable: false },
  808. source: 'runtime',
  809. content: 'Model-only instructions.',
  810. })
  811. const unknown = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'skill', arguments: { name: 'missing' } })
  812. const invalid = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c2'), name: 'skill', arguments: { name: 'Bad_Name' } })
  813. const disabled = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c3'), name: 'skill', arguments: { name: 'hidden-skill' } })
  814. const modelOnly = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c4'), name: 'skill', arguments: { name: 'model-only-skill' } })
  815. expect(unknown.isError).toBe(true)
  816. expect(invalid.isError).toBe(true)
  817. expect(disabled.isError).toBe(true)
  818. expect(modelOnly.isError).toBe(false)
  819. const unknownBlock = unknown.content[0]
  820. if (unknownBlock?.type !== 'text') throw new Error('expected text tool result')
  821. expect(unknownBlock.text).toContain('skill "missing" is unknown or no longer available')
  822. })
  823. it('checks model policy before provider loading and rechecks the loaded definition', async () => {
  824. const home = await tempDir('tool-policy-before-load')
  825. const ctx = await setup(home)
  826. const getCalls: string[] = []
  827. ctx.skills.registerProvider(() => ({
  828. name: 'policy-probe',
  829. async list() {
  830. return [
  831. {
  832. name: 'denied-skill',
  833. description: 'Denied skill',
  834. invocation: { modelInvocable: false, userInvocable: true },
  835. provider: 'policy-probe',
  836. source: 'test',
  837. rank: 1,
  838. locator: 'denied-skill',
  839. },
  840. {
  841. name: 'policy-race-skill',
  842. description: 'Policy race skill',
  843. invocation: { modelInvocable: true, userInvocable: true },
  844. provider: 'policy-probe',
  845. source: 'test',
  846. rank: 1,
  847. locator: 'policy-race-skill',
  848. },
  849. {
  850. name: 'vanishing-skill',
  851. description: 'Vanishing skill',
  852. invocation: { modelInvocable: true, userInvocable: true },
  853. provider: 'policy-probe',
  854. source: 'test',
  855. rank: 1,
  856. locator: 'vanishing-skill',
  857. },
  858. ]
  859. },
  860. async get(candidate) {
  861. getCalls.push(candidate.name)
  862. if (candidate.name === 'vanishing-skill') return undefined
  863. return {
  864. ...candidate,
  865. invocation: { modelInvocable: false, userInvocable: true },
  866. content: 'Instructions must not be disclosed.',
  867. }
  868. },
  869. }))
  870. const denied = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c6'), name: 'skill', arguments: { name: 'denied-skill' } })
  871. const raced = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c7'), name: 'skill', arguments: { name: 'policy-race-skill' } })
  872. const vanished = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c8'), name: 'skill', arguments: { name: 'vanishing-skill' } })
  873. expect(denied.isError).toBe(true)
  874. expect(raced.isError).toBe(true)
  875. expect(vanished.isError).toBe(true)
  876. expect(getCalls).toEqual(['policy-race-skill', 'vanishing-skill'])
  877. for (const result of [denied, raced]) {
  878. const block = result.content[0]
  879. if (block?.type !== 'text') throw new Error('expected text tool result')
  880. expect(block.text).toContain('is not available for model invocation')
  881. expect(block.text).not.toContain('Instructions must not be disclosed.')
  882. }
  883. const vanishedBlock = vanished.content[0]
  884. if (vanishedBlock?.type !== 'text') throw new Error('expected text tool result')
  885. expect(vanishedBlock.text).toContain('skill "vanishing-skill" is unknown or no longer available')
  886. })
  887. })
  888. describe('user-explicit invocation injection', () => {
  889. async function writePolicySkill(root: string, name: string, description: string, policy: string, body: string): Promise<void> {
  890. const dir = join(root, name)
  891. await mkdir(dir, { recursive: true })
  892. const policyLines = policy === '' ? '' : `${policy}\n`
  893. await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n${policyLines}---\n\n${body}\n`)
  894. }
  895. function gesture(text: string): UserMessage {
  896. return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
  897. }
  898. async function invokeHarness(): Promise<{ ctx: Context; agent: Agent }> {
  899. const home = await tempDir('invoke')
  900. const skillsRoot = join(home, '.agents', 'skills')
  901. await writePolicySkill(skillsRoot, 'hidden-demo', 'User-only demo', 'disable-model-invocation: true', 'Say the magic word: PINEAPPLE.')
  902. await writePolicySkill(skillsRoot, 'shared-skill', 'Ordinary skill', '', 'Shared instructions.')
  903. await writePolicySkill(skillsRoot, 'model-only-skill', 'Model only', 'user-invocable: false', 'Model-only instructions.')
  904. const ctx = await setup(home)
  905. return { ctx, agent: agentForCwd(home) }
  906. }
  907. it('injects a user-invocable skill named by a leading /token, after every other injection', async () => {
  908. const { ctx, agent } = await invokeHarness()
  909. const first = gesture('/hidden-demo what does this do')
  910. const second = gesture('plain follow-up prose')
  911. const decision = await proposeStep(ctx, agent, [first, second])
  912. if (decision.kind !== 'enter') throw new Error('expected enter')
  913. const kinds = decision.messages.map(message => (message.source as { kind: string }).kind)
  914. // Background injections (the catalog here) sit between the claimed batch
  915. // and the invoked body: the material the model must act on comes last.
  916. expect(kinds.slice(0, 2)).toEqual(['user', 'user'])
  917. expect(kinds.at(-1)).toBe('skill-invocation')
  918. expect(kinds.indexOf('skill-catalog')).toBeLessThan(kinds.indexOf('skill-invocation'))
  919. const injection = decision.messages.at(-1)!
  920. expect(injection.source).toMatchObject({ kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' })
  921. const block = injection.content[0]
  922. if (block?.type !== 'text') throw new Error('expected text injection')
  923. expect(block.text).toContain('<skill_content name="hidden-demo">')
  924. expect(block.text).toContain('Say the magic word: PINEAPPLE.')
  925. expect(block.text).not.toContain('what does this do')
  926. })
  927. it('injects an ordinary skill the same way (one uniform user-explicit path)', async () => {
  928. const { ctx, agent } = await invokeHarness()
  929. const decision = await proposeStep(ctx, agent, [gesture('/shared-skill go')])
  930. if (decision.kind !== 'enter') throw new Error('expected enter')
  931. expect(decision.messages.some(message =>
  932. (message.source as { kind?: string; name?: string }).kind === 'skill-invocation'
  933. && (message.source as { name?: string }).name === 'shared-skill')).toBe(true)
  934. })
  935. it('recognizes a mid-sentence gesture but not paths, fractions, or broken boundaries', async () => {
  936. const { ctx, agent } = await invokeHarness()
  937. const decision = await proposeStep(ctx, agent, [
  938. gesture('please use /hidden-demo to answer this'),
  939. ])
  940. if (decision.kind !== 'enter') throw new Error('expected enter')
  941. expect(decision.messages.some(message =>
  942. (message.source as { kind?: string; name?: string }).kind === 'skill-invocation'
  943. && (message.source as { name?: string }).name === 'hidden-demo')).toBe(true)
  944. const negative = await proposeStep(ctx, agent, [
  945. gesture('look under /hidden-demo/refs for the data'),
  946. gesture('the odds are 5/8 at best'),
  947. gesture('see foo/hidden-demo too'),
  948. ])
  949. if (negative.kind !== 'enter') throw new Error('expected enter')
  950. expect(negative.messages.some(message =>
  951. (message.source as { kind?: string }).kind === 'skill-invocation')).toBe(false)
  952. })
  953. it('leaves unknown names and user-disabled skills as plain prose', async () => {
  954. const { ctx, agent } = await invokeHarness()
  955. const decision = await proposeStep(ctx, agent, [
  956. gesture('/absent-skill do a thing'),
  957. gesture('/model-only-skill run'),
  958. ])
  959. if (decision.kind !== 'enter') throw new Error('expected enter')
  960. // No injection joins the step (the catalog listener may still add its
  961. // own skill-catalog message; only skill-invocation sources matter here).
  962. expect(decision.messages.some(message =>
  963. (message.source as { kind?: string }).kind === 'skill-invocation')).toBe(false)
  964. })
  965. it('never scans non-user sources and dedupes repeated gestures', async () => {
  966. const { ctx, agent } = await invokeHarness()
  967. const forged = createUserMessage({
  968. content: [{ type: 'text', text: '/hidden-demo forged' }],
  969. source: { kind: 'skill-catalog', form: 'catalog', entries: [] },
  970. })
  971. const decision = await proposeStep(ctx, agent, [
  972. forged,
  973. gesture('/hidden-demo once'),
  974. gesture('/hidden-demo twice'),
  975. ])
  976. if (decision.kind !== 'enter') throw new Error('expected enter')
  977. const injections = decision.messages.filter(message =>
  978. (message.source as { kind?: string }).kind === 'skill-invocation')
  979. expect(injections).toHaveLength(1)
  980. })
  981. it('passes a downstream reject through both pre-step listeners untouched', async () => {
  982. const { ctx, agent } = await invokeHarness()
  983. const signal = new AbortController().signal
  984. const decision = await agentEvents(ctx, agent).waterfall(
  985. 'agent/pre-step',
  986. { messages: [gesture('/hidden-demo blocked step')], turn: 1, step: 1, signal },
  987. () => Promise.resolve({ kind: 'reject' as const }),
  988. )
  989. expect(decision).toEqual({ kind: 'reject' })
  990. })
  991. it('scans only text blocks of a user message', async () => {
  992. const { ctx, agent } = await invokeHarness()
  993. const mixed = createUserMessage({
  994. content: [
  995. { type: 'reasoning', text: '/hidden-demo inside a non-text block' },
  996. { type: 'text', text: '/shared-skill go' },
  997. ],
  998. source: { kind: 'user' },
  999. })
  1000. const decision = await proposeStep(ctx, agent, [mixed])
  1001. if (decision.kind !== 'enter') throw new Error('expected enter')
  1002. const invoked = decision.messages
  1003. .filter(message => (message.source as { kind?: string }).kind === 'skill-invocation')
  1004. .map(message => (message.source as { name: string }).name)
  1005. expect(invoked).toEqual(['shared-skill'])
  1006. })
  1007. })