loader-composition.spec.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. // Proves `allowParallelInProgress` is real configurability and not a constant:
  2. // the flag is set in a cordis.yml booted through the real Loader, and both faces
  3. // it controls — the model-facing description and the accepted input — follow it.
  4. import { mkdtemp, rm, writeFile } from 'node:fs/promises'
  5. import { tmpdir } from 'node:os'
  6. import { join } from 'node:path'
  7. import { pathToFileURL } from 'node:url'
  8. import { afterEach, describe, expect, it } from 'vitest'
  9. import { Context } from '@deepseek-ai/cordis'
  10. import Loader from '@deepseek-ai/cordis-plugin-loader'
  11. import Include from '@deepseek-ai/cordis-plugin-include'
  12. import { ToolCallId } from '@deepseek-ai/dsh-llm'
  13. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  14. import AgentRegistry from '@deepseek-ai/dsh-agent'
  15. import type { Agent } from '@deepseek-ai/dsh-agent'
  16. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  17. import ToolRuntime from '@deepseek-ai/dsh-tools'
  18. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  19. import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
  20. import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
  21. let root: string | undefined
  22. let context: Context | undefined
  23. afterEach(async () => {
  24. await context?.fiber.dispose()
  25. context = undefined
  26. if (root !== undefined) await rm(root, { recursive: true, force: true })
  27. root = undefined
  28. })
  29. async function agent(ctx: Context): Promise<Agent> {
  30. const scope = ctx.plugin(() => {})
  31. const id = SessionId('todo-loader-agent')
  32. const session = Session.create(id)
  33. const value: Agent = {
  34. id, options: {}, session, inbox: unsupportedInbox(),
  35. status: 'idle', ctx: scope.ctx,
  36. followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {},
  37. runMaintenance: task => task(new AbortController().signal),
  38. whenIdle: () => Promise.resolve(),
  39. }
  40. await ctx.agents.register(value)
  41. return value
  42. }
  43. function resultText(result: { content: { type: string; text?: string }[] }): string {
  44. return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
  45. }
  46. /**
  47. * Boot a cordis.yml carrying the given tool-todo config block.
  48. * @param configLines - YAML lines nested under the tool's `config:` key.
  49. * @returns the booted context.
  50. */
  51. async function boot(configLines: readonly string[]): Promise<Context> {
  52. root = await mkdtemp(join(tmpdir(), 'dsh-todo-loader-'))
  53. const configPath = join(root, 'cordis.yml')
  54. await writeFile(configPath, [
  55. "- name: '@deepseek-ai/dsh-agent'",
  56. "- name: '@deepseek-ai/dsh-system-prompt'",
  57. "- name: '@deepseek-ai/dsh-tools'",
  58. "- name: '@deepseek-ai/dsh-session-projection'",
  59. "- name: '@deepseek-ai/dsh-tool-todo'",
  60. ...configLines.length > 0 ? [' config:', ...configLines] : [],
  61. '',
  62. ].join('\n'))
  63. const ctx = new Context()
  64. context = ctx
  65. ctx.baseUrl = pathToFileURL(root).href + '/'
  66. await ctx.plugin(Loader)
  67. ctx.loader.builtins.include = Include
  68. const modules = new Map<string, unknown>([
  69. ['@deepseek-ai/dsh-agent', AgentRegistry],
  70. ['@deepseek-ai/dsh-system-prompt', SystemPrompt],
  71. ['@deepseek-ai/dsh-tools', ToolRuntime],
  72. ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry],
  73. ['@deepseek-ai/dsh-tool-todo', ToolTodo],
  74. ])
  75. ctx.loader.internal = {
  76. version: 'v2',
  77. async import(specifier: string) {
  78. if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
  79. return modules.get(specifier)
  80. },
  81. } as unknown as NonNullable<typeof ctx.loader.internal>
  82. await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
  83. await ctx.loader.await()
  84. for (const entry of ctx.loader.entries()) await entry.fiber?.await()
  85. return ctx
  86. }
  87. const PARALLEL_TODOS = [
  88. { content: 'run subagent a', status: 'in_progress' },
  89. { content: 'run subagent b', status: 'in_progress' },
  90. ]
  91. describe('tool-todo real Loader composition through cordis.yml', () => {
  92. it('allowParallelInProgress: false narrows the description and rejects a parallel write', async () => {
  93. const ctx = await boot([' allowParallelInProgress: false'])
  94. const description = ctx.tools.schemas().find(s => s.name === 'todo_write')?.description ?? ''
  95. expect(description).toContain('Keep AT MOST ONE todo `in_progress`')
  96. expect(description).not.toContain('several at once')
  97. const owner = await agent(ctx)
  98. const result = await ctx.tools.execute({
  99. signal: new AbortController().signal,
  100. callId: ToolCallId('parallel'),
  101. name: 'todo_write',
  102. arguments: { todos: PARALLEL_TODOS },
  103. agent: owner,
  104. })
  105. expect(result.isError).toBe(true)
  106. expect(resultText(result)).toContain('at most one task may be in_progress')
  107. expect(owner.session.snapshotEvents().some(e => e.type === 'todo/write')).toBe(false)
  108. }, 30_000)
  109. it('allowParallelInProgress: true permits a parallel write end to end', async () => {
  110. const ctx = await boot([' allowParallelInProgress: true'])
  111. const description = ctx.tools.schemas().find(s => s.name === 'todo_write')?.description ?? ''
  112. expect(description).toContain('several at once when work genuinely runs in parallel')
  113. const owner = await agent(ctx)
  114. const result = await ctx.tools.execute({
  115. signal: new AbortController().signal,
  116. callId: ToolCallId('parallel-enabled'),
  117. name: 'todo_write',
  118. arguments: { todos: PARALLEL_TODOS },
  119. agent: owner,
  120. })
  121. expect(result.isError).toBe(false)
  122. expect(owner.session.snapshotEvents().findLast(e => e.type === 'todo/write')?.data.todos).toEqual(PARALLEL_TODOS)
  123. }, 30_000)
  124. it.each([
  125. { label: 'is omitted', configLines: [], failure: '$.allowParallelInProgress missing required value' },
  126. { label: 'is not boolean', configLines: [' allowParallelInProgress: "no"'], failure: '$.allowParallelInProgress expected boolean' },
  127. ])('fails loading when allowParallelInProgress $label', async ({ configLines, failure }) => {
  128. // The policy is self-contained, so misconfiguration fails at load: the
  129. // entry's apply rejects and boot never reaches a running tool.
  130. await expect(boot(configLines)).rejects.toThrow(failure)
  131. }, 30_000)
  132. })