web-agent-presets.spec.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { fileURLToPath } from 'node:url'
  4. import { join } from 'node:path'
  5. import { Context } from 'cordis'
  6. import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
  7. import { SessionId } from '@deepseek-ai/dsh-session'
  8. import type { Agent } from '@deepseek-ai/dsh-agent'
  9. import type { PatchOptions } from '@cordisjs/plugin-include'
  10. import { beforeAll, describe, expect, it } from 'vitest'
  11. import { settingsNamespace } from '@deepseek-ai/dsh-settings'
  12. import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets'
  13. import type {} from '@deepseek-ai/dsh-tools'
  14. const CONFIG_DIR = fileURLToPath(new URL('../config/', import.meta.url))
  15. const BASE_CONFIG = join(CONFIG_DIR, 'base.cordis.yml')
  16. const WEB_OVERLAY = join(CONFIG_DIR, 'web.cordis.yml')
  17. /**
  18. * Boot the shipped Web composition, minus the rows that would bind a port,
  19. * touch the network, or write outside the test. Everything that decides an
  20. * agent's capabilities is the real thing, including both shipped presets.
  21. */
  22. async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promise<Context> {
  23. const patches: PatchOptions[] = [
  24. ...loadOverlayPatches('dsh-test', WEB_OVERLAY),
  25. // The settings row defaults to `$DSH_HOME/settings.yaml`. Left alone it
  26. // reads the developer's own document — and since the default preset is a
  27. // setting, a stored `agent-presets.default` would decide this file's
  28. // outcome. Point it at a temp file for the same reason the roster below
  29. // names only the shipped root.
  30. { id: 'settings', config: { path: settingsFile, watch: false } },
  31. // Host rows with side effects outside this process: a bound port, a served
  32. // asset tree, a telemetry exporter. `api-gateway` and `directory-picker`
  33. // stay ENABLED on purpose — the api-proxy is the host row that injects
  34. // `subagents`, `workspace`, and the rest of the agent plane, so disabling
  35. // it would hide exactly the breakage this file exists to catch: a service
  36. // moved into the presets that a host row still waits for. The boot audit
  37. // is that assertion.
  38. { id: 'webserver', disabled: true },
  39. { id: 'telemetry-otel', disabled: true },
  40. { id: 'modules', disabled: true },
  41. { id: 'connection', disabled: true },
  42. // The shipped `-auto` chooser resolves its interaction from a running
  43. // host and so waits for the webserver disabled above; the browse variant
  44. // supplies `directoryPicker` without one.
  45. { id: 'directory-picker', disabled: true },
  46. { insert: [{ id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' }] },
  47. // The roster AppCLIEntry would patch in; only the shipped root, so a
  48. // developer's own `~/.dsh/.preset` cannot change this test's outcome.
  49. // `default` here is the COMPOSITION default — the base layer the settings
  50. // document overrides.
  51. {
  52. id: 'agent-presets',
  53. config: { default: 'standard', roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }] },
  54. },
  55. ...extra,
  56. ]
  57. return await boot('dsh-test', BASE_CONFIG, patches)
  58. }
  59. const toolNames = (ctx: Context, agent?: Agent): string[] =>
  60. ctx.tools.schemas(agent).map(schema => schema.name).sort()
  61. let ctx: Context
  62. beforeAll(async () => {
  63. const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-web-presets-')), 'settings.yaml')
  64. await writeFile(settingsFile, '{}\n')
  65. ctx = await bootWeb(settingsFile)
  66. }, 120_000)
  67. describe('the shipped Web composition', () => {
  68. it('leaves the global tool layer empty', () => {
  69. // Every model-facing tool belongs to a preset, `ask_user_question`
  70. // included: a tool in the global layer reaches EVERY agent regardless of
  71. // which preset composed it, so a two-tool benchmark surface would really
  72. // present three. A regression here means an agent-plane row came back to
  73. // the host composition.
  74. expect(toolNames(ctx)).toEqual([])
  75. })
  76. it('supplies both shipped presets, and only those, from the system root', async () => {
  77. const listed = await ctx.agentPresets.list()
  78. expect(listed.map(preset => preset.id).sort()).toEqual(['cordis', 'minimal', 'standard'])
  79. expect(listed.every(preset => preset.trust === 'system')).toBe(true)
  80. expect(ctx.agentPresets.defaultId).toBe('standard')
  81. })
  82. it('composes the full agent from `standard`', async () => {
  83. const handle = await ctx.agents.create({
  84. sessionId: SessionId('preset-standard'),
  85. setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
  86. })
  87. try {
  88. // The EXACT catalog, not a spot-check: an omission is this design's
  89. // quietest failure mode, because a row that registers into the wrong
  90. // layer mounts cleanly and simply contributes nothing. `glob`/`grep` are
  91. // excluded for the reason the TUI composition e2e excludes them — they
  92. // depend on ripgrep being present on the machine.
  93. expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
  94. 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
  95. 'get_goal', 'list_agents', 'ralph', 'read', 'send_message', 'skill',
  96. 'str_replace_editor', 'subagent', 'subagent_fork', 'task_kill',
  97. 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_search',
  98. 'workflow', 'write',
  99. ])
  100. } finally {
  101. await handle.dispose()
  102. }
  103. })
  104. it('composes exactly two tools from `minimal`', async () => {
  105. const handle = await ctx.agents.create({
  106. sessionId: SessionId('preset-minimal'),
  107. setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
  108. })
  109. try {
  110. // Exactly what the preset lists — nothing arrives from the host.
  111. expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
  112. } finally {
  113. await handle.dispose()
  114. }
  115. })
  116. it('keeps two differently composed sessions independent', async () => {
  117. const full = await ctx.agents.create({
  118. sessionId: SessionId('preset-both-full'),
  119. setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
  120. })
  121. const minimal = await ctx.agents.create({
  122. sessionId: SessionId('preset-both-minimal'),
  123. setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
  124. })
  125. try {
  126. expect(toolNames(ctx, minimal.agent)).toEqual(['bash', 'str_replace_editor'])
  127. expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10)
  128. await minimal.dispose()
  129. // Tearing the minimal session down leaves the full one whole.
  130. expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10)
  131. expect(toolNames(ctx)).toEqual([])
  132. } finally {
  133. await full.dispose()
  134. }
  135. })
  136. it('composes the cordis agent with its own toolset', async () => {
  137. const handle = await ctx.agents.create({
  138. sessionId: SessionId('preset-cordis'),
  139. setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'cordis').then(() => undefined),
  140. })
  141. try {
  142. const tools = toolNames(ctx, handle.agent)
  143. // The self-referential toolset is what distinguishes this preset.
  144. expect(tools).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount']))
  145. // And it keeps the standard agent's own tools rather than replacing them.
  146. expect(tools).toEqual(expect.arrayContaining(['bash', 'read', 'edit', 'skill']))
  147. // The skill registry sits in this preset's entry-local realm, so it is
  148. // invisible to the host AND to the agent's own scope — only the rows
  149. // inside that group resolve it, which is what makes `tool-skill` the
  150. // agent's own rather than a shared one.
  151. expect(ctx.get('skills')).toBeUndefined()
  152. } finally {
  153. await handle.dispose()
  154. }
  155. })
  156. it('keeps the self-referential toolset out of every other preset', async () => {
  157. const handle = await ctx.agents.create({
  158. sessionId: SessionId('preset-no-cordis'),
  159. setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
  160. })
  161. try {
  162. // Editing the live runtime is opt-in per session, not ambient.
  163. expect(toolNames(ctx, handle.agent)).not.toContain('cordis_mount')
  164. } finally {
  165. await handle.dispose()
  166. }
  167. })
  168. it('ships the composition-authoring skill inside the preset directory', async () => {
  169. // The preset's skill root is derived from its own `baseUrl`, so the skill
  170. // travels with the directory wherever the preset is installed.
  171. const skill = join(
  172. CONFIG_DIR, 'agent-presets', 'cordis', 'skills', 'editing-cordis-compositions', 'SKILL.md',
  173. )
  174. expect((await readFile(skill, 'utf8')).startsWith('---\nname: editing-cordis-compositions')).toBe(true)
  175. })
  176. it('never rewrites the preset file it composed from', async () => {
  177. // The Loader persists a tree whose plugin self-disposed, and tearing an
  178. // agent down disposes its whole subtree. Inherited, that rewrote the
  179. // shipped composition — truncating it to `[]` the first time a session
  180. // ended — so `PresetTree` refuses to write at all.
  181. const path = join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml')
  182. const before = await readFile(path, 'utf8')
  183. const handle = await ctx.agents.create({
  184. sessionId: SessionId('preset-readonly'),
  185. setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
  186. })
  187. await handle.dispose()
  188. // Slack, not a race the number has to win. The write is driven by the
  189. // Loader's fiber-unload listener, which fires as the subtree's fibers
  190. // settle rather than when `dispose()` resolves, and the Loader exposes no
  191. // flush to await. A regression writes synchronously inside that listener,
  192. // so any wait past settlement fails; a longer one only slows the test.
  193. await new Promise(resolve => setTimeout(resolve, 50))
  194. expect(await readFile(path, 'utf8')).toBe(before)
  195. })
  196. it('gives each session its own persona', async () => {
  197. const handle = await ctx.agents.create({
  198. sessionId: SessionId('preset-persona'),
  199. setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
  200. })
  201. try {
  202. const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent })
  203. expect(assembly.sections.find(section => section.name === 'deployment:persona')?.text)
  204. .toContain('You are a coding agent powered by')
  205. } finally {
  206. await handle.dispose()
  207. }
  208. })
  209. })
  210. describe('a switch survives the session', () => {
  211. it('records the choice so the log states what the agent runs', async () => {
  212. const handle = await ctx.agents.create({
  213. sessionId: SessionId('preset-switch-logged'),
  214. meta: { agentPreset: 'standard' },
  215. setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
  216. })
  217. try {
  218. // The api-proxy's select does exactly this pair while the session is blank.
  219. await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal')
  220. handle.agent.session.append('agent-preset/selected', { agentPreset: 'minimal' })
  221. // The header keeps the creation fact; the log carries what it runs.
  222. expect(handle.agent.session.header.agentPreset).toBe('standard')
  223. expect(resolveSessionPreset(handle.agent.session)).toBe('minimal')
  224. } finally {
  225. await handle.dispose()
  226. }
  227. })
  228. it('rebuilds a switched session from the log, not the creation header', () => {
  229. // The exact shape a resume reads back from disk: the header says standard,
  230. // the log records the switch the user made while the session was blank.
  231. const rebuilt = resolveSessionPreset({
  232. header: { version: 0, id: SessionId('x'), createdAt: 0, agentPreset: 'standard' },
  233. events: [
  234. { type: 'agent-preset/selected', seq: 1, time: 0, data: { agentPreset: 'minimal' } },
  235. { type: 'turn/start', seq: 2, time: 0, data: { turn: 0, trigger: { kind: 'message', source: { kind: 'user' } } } },
  236. ] as never,
  237. })
  238. // Reading the header alone would compose the creation-time preset over a
  239. // history another one produced — the replay the blank-only lock prevents.
  240. expect(rebuilt).toBe('minimal')
  241. })
  242. })
  243. describe('a forked session', () => {
  244. it('inherits the composition its seeded history was produced under', async () => {
  245. const parent = await ctx.agents.create({
  246. sessionId: SessionId('preset-fork-parent'),
  247. meta: { agentPreset: 'minimal' },
  248. setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
  249. })
  250. const inherited = resolveSessionPreset(parent.agent.session)
  251. const child = await ctx.agents.create({
  252. sessionId: SessionId('preset-fork-child'),
  253. meta: {
  254. parentSession: SessionId('preset-fork-parent'),
  255. seedLength: 0,
  256. ...inherited === undefined ? {} : { agentPreset: inherited },
  257. },
  258. setup: agentCtx => ctx.agentPresets.mount(agentCtx, inherited).then(() => undefined),
  259. })
  260. try {
  261. // Composing nothing would leave the child empty: this layer moved every
  262. // model-facing row out of the host plane, so there is nothing to inherit
  263. // for free any more.
  264. expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent))
  265. expect(toolNames(ctx, child.agent).length).toBeGreaterThan(0)
  266. } finally {
  267. await child.dispose()
  268. await parent.dispose()
  269. }
  270. })
  271. })
  272. describe('authoring a preset on the shipped composition', () => {
  273. let authorCtx: Context
  274. let userRoot: string
  275. beforeAll(async () => {
  276. userRoot = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-')), 'presets')
  277. const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-settings-')), 'settings.yaml')
  278. await writeFile(settingsFile, '{}\n')
  279. authorCtx = await bootWeb(settingsFile, [{
  280. id: 'agent-presets',
  281. config: {
  282. default: 'standard',
  283. roots: [
  284. { path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' },
  285. // The root does not exist yet: a deployment whose user has authored
  286. // nothing is the normal first-run state.
  287. { path: userRoot, trust: 'user' },
  288. ],
  289. },
  290. }])
  291. })
  292. it('refuses to overwrite or delete a shipped preset', async () => {
  293. await expect(authorCtx.agentPresets.write('standard', '- id: x\n')).rejects.toThrow(/ships with the deployment/)
  294. await expect(authorCtx.agentPresets.remove('standard')).rejects.toThrow(/ships with the deployment/)
  295. })
  296. it.each(['../escape', 'a/b', '/abs', 'Upper'])('refuses the uncontainable id %j', async (id) => {
  297. // The id becomes a directory name under the user root, so containment is
  298. // checked on the id rather than on the joined path afterwards.
  299. await expect(authorCtx.agentPresets.write(id, '- id: x\n')).rejects.toThrow()
  300. })
  301. it('refuses text that is not a Cordis entry list', async () => {
  302. await expect(authorCtx.agentPresets.write('bad-shape', 'tools: []\n')).rejects.toThrow()
  303. await expect(authorCtx.agentPresets.resolve('bad-shape')).rejects.toThrow()
  304. })
  305. it('writes a preset a session then really composes from', async () => {
  306. const copied = await authorCtx.agentPresets.read('minimal')
  307. await authorCtx.agentPresets.write('my-agent', copied)
  308. // Round-trips through the roster as a `user` row, and the composition the
  309. // editor saved is one the mount actually accepts.
  310. const preset = await authorCtx.agentPresets.resolve('my-agent')
  311. expect(preset.trust).toBe('user')
  312. expect(await authorCtx.agentPresets.read('my-agent')).toBe(copied)
  313. // Owner-only, in an owner-only directory: a composition is executable
  314. // configuration on a machine that may have other users.
  315. expect((await stat(preset.path)).mode & 0o777).toBe(0o600)
  316. const handle = await authorCtx.agents.create({
  317. sessionId: SessionId('preset-authored'),
  318. setup: agentCtx => authorCtx.agentPresets.mount(agentCtx, 'my-agent').then(() => undefined),
  319. })
  320. try {
  321. // The same tools the shipped `minimal` composes, from a file written
  322. // through the service into a root outside the installed harness.
  323. expect(toolNames(authorCtx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
  324. } finally {
  325. await handle.dispose()
  326. }
  327. })
  328. it('deletes what it wrote', async () => {
  329. await authorCtx.agentPresets.write('doomed', '- id: tool-web-search\n name: \'@deepseek-ai/dsh-tool-web-search\'\n')
  330. await authorCtx.agentPresets.remove('doomed')
  331. expect((await authorCtx.agentPresets.list()).map(preset => preset.id)).not.toContain('doomed')
  332. })
  333. })
  334. /**
  335. * Which preset an unnamed session gets is a user setting layered over the
  336. * composition's own default. The package suite proves the layering against a
  337. * hand-built context; this proves it through the shipped `cordis.yml` — that
  338. * the roster and the settings provider are actually wired to each other, and
  339. * that the id the setting names is the one a session composes from.
  340. */
  341. describe('the default preset as a user setting', () => {
  342. it('composes an unnamed session from the stored default, not the composed one', async () => {
  343. expect(ctx.agentPresets.defaultId).toBe('standard')
  344. await ctx.settings.update(settingsNamespace(SETTINGS_NAMESPACE), { default: 'minimal' })
  345. try {
  346. expect(ctx.agentPresets.defaultId).toBe('minimal')
  347. const handle = await ctx.agents.create({
  348. sessionId: SessionId('preset-user-default'),
  349. setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined),
  350. })
  351. try {
  352. // `mount()` with no id resolves the effective default. Two tools, not
  353. // `standard`'s catalog: the setting decided the composition.
  354. expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
  355. } finally {
  356. await handle.dispose()
  357. }
  358. } finally {
  359. // The context is shared with the rest of the file. `replace({})` drops
  360. // the user section wholesale so the field re-inherits the composition
  361. // base; `update` merges, and would leave the override standing.
  362. await ctx.settings.replace(settingsNamespace(SETTINGS_NAMESPACE), {})
  363. }
  364. expect(ctx.agentPresets.defaultId).toBe('standard')
  365. })
  366. })
  367. describe('a session keeps the preset it was created with', () => {
  368. it('refuses to adopt a live session under a different preset', async () => {
  369. const handle = await ctx.agents.create({
  370. sessionId: SessionId('preset-locked'),
  371. meta: { agentPreset: 'minimal' },
  372. setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
  373. })
  374. try {
  375. // The api-proxy guard reads exactly this: the header records what the
  376. // session runs, so naming anything else is a caller error rather than a
  377. // switch. Its history was produced under `minimal`'s two tools.
  378. expect(handle.agent.session.header.agentPreset).toBe('minimal')
  379. } finally {
  380. await handle.dispose()
  381. }
  382. })
  383. })