agent-tool-presentation.spec.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. /**
  2. * The row an agent preset carries to pick its tool presentation. What it owes
  3. * its caller: the choice reaches THIS agent and no other, it unwinds with the
  4. * agent, and a code mode composed against a deployment with no PTC runtime
  5. * stops at mount — where a preset's activation audit can name it — rather
  6. * than at the first prompt assembly.
  7. */
  8. import { describe, expect, it } from 'vitest'
  9. import { Context } from '@deepseek-ai/cordis'
  10. import { createScope } from '@deepseek-ai/dsh-scope'
  11. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  12. import { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime'
  13. import type { PtcRunRequest, PtcRunResult } from '@deepseek-ai/dsh-ptc-runtime'
  14. import ToolRuntime, { RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools'
  15. import type { Agent } from '@deepseek-ai/dsh-agent'
  16. import { SessionId } from '@deepseek-ai/dsh-session'
  17. import { apply, Config, inject, name } from '@deepseek-ai/dsh-agent-tool-presentation'
  18. /** A runtime that never runs anything: presentation never dispatches. */
  19. class StubRuntime extends PtcRuntime {
  20. resolve(request: import('@deepseek-ai/dsh-ptc-runtime').PtcRunRequest): import('@deepseek-ai/dsh-ptc-runtime').PtcRunSpec { return { ...request, cwd: request.cwd ?? process.cwd(), timeoutMs: request.timeoutMs ?? 120_000 } }
  21. readonly language = 'typescript'
  22. readonly isolation = 'stub'
  23. run(_request: PtcRunRequest): Promise<PtcRunResult> {
  24. return Promise.resolve({ logs: [] })
  25. }
  26. }
  27. /** A host plane with one tool, optionally carrying a PTC runtime. */
  28. async function host(options: { runtime?: boolean } = {}) {
  29. const ctx = new Context()
  30. await ctx.plugin(SystemPrompt, {})
  31. await ctx.plugin(ToolRuntime, {})
  32. if (options.runtime !== false) await ctx.plugin(StubRuntime)
  33. ctx.tools.register(defineTool({
  34. name: 'echo',
  35. description: 'Echo tool.',
  36. parameters: { value: { type: 'string', required: true } },
  37. output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
  38. execute: args => Promise.resolve(args.value),
  39. }))
  40. return ctx
  41. }
  42. /** Mount the row under one agent's scope, as a preset subtree does. */
  43. async function mount(ctx: Context, config: Config, id = 'agent') {
  44. const agent = { id: SessionId(id) } as Agent
  45. let inner!: Context
  46. const fiber = ctx.plugin(Object.assign((host: Context) => {
  47. inner = createScope(host, agent).ctx
  48. }, { inject: ['tools', 'systemPrompt'] }))
  49. await fiber.await()
  50. const row = inner.plugin({ name, inject: [...inject], Config, apply }, config)
  51. await row.await()
  52. return { agent, fiber, row }
  53. }
  54. describe('the tool-presentation row', () => {
  55. it('declares the services it uses without holding a PTC runtime hostage', () => {
  56. // A `native` row must mount where no runtime is composed, so the wait is
  57. // conditional inside apply rather than static metadata.
  58. expect(inject).toEqual(['tools'])
  59. })
  60. it('gives its own agent PTC mode and leaves the rest native', async () => {
  61. const ctx = await host()
  62. const coded = await mount(ctx, { mode: 'ptc' }, 'coded')
  63. const plain = await mount(ctx, { mode: 'native' }, 'plain')
  64. const codedAssembly = await ctx.systemPrompt.assemble({ scope: coded.agent })
  65. const plainAssembly = await ctx.systemPrompt.assemble({ scope: plain.agent })
  66. expect(codedAssembly.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME])
  67. expect(codedAssembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('echo')
  68. expect(plainAssembly.tools.map(tool => tool.name)).toEqual(['echo'])
  69. })
  70. it('presents both forms when asked for both', async () => {
  71. const ctx = await host()
  72. const { agent } = await mount(ctx, { mode: 'both' })
  73. const assembly = await ctx.systemPrompt.assemble({ scope: agent })
  74. expect(assembly.tools.map(tool => tool.name)).toEqual(['echo', RUN_CODE_NAME])
  75. })
  76. it('restores the deployment default when the agent unloads', async () => {
  77. const ctx = await host()
  78. const { agent, row } = await mount(ctx, { mode: 'ptc' })
  79. await row.dispose()
  80. // HMR safety: the preset subtree is torn down with its agent, and the
  81. // presentation must go with it rather than outliving the composition.
  82. const assembly = await ctx.systemPrompt.assemble({ scope: agent })
  83. expect(assembly.tools.map(tool => tool.name)).toEqual(['echo'])
  84. expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false)
  85. })
  86. it('waits for a PTC runtime the deployment does not compose', async () => {
  87. const ctx = await host({ runtime: false })
  88. const { agent, row } = await mount(ctx, { mode: 'ptc' })
  89. // Pending, not applied: `dsh-agent-presets` rejects a mount holding a row
  90. // that never reached a usable state, naming this id — so the preset fails
  91. // where the operator can act, instead of at the first request.
  92. expect(row.ctx.get('ptcRuntime')).toBeUndefined()
  93. const assembly = await ctx.systemPrompt.assemble({ scope: agent })
  94. expect(assembly.tools.map(tool => tool.name)).toEqual(['echo'])
  95. })
  96. it('applies once the runtime arrives', async () => {
  97. const ctx = await host({ runtime: false })
  98. const { agent } = await mount(ctx, { mode: 'ptc' })
  99. await ctx.plugin(StubRuntime)
  100. const assembly = await ctx.systemPrompt.assemble({ scope: agent })
  101. expect(assembly.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME])
  102. })
  103. it('requires a mode rather than defaulting one', () => {
  104. // An omitted value would mean the row was composed for nothing: a preset
  105. // without this row already gets the deployment default.
  106. expect(() => Config({} as never)).toThrow()
  107. })
  108. })