present.spec.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. /** Explicit deliveries commit only after a successful final tool result. */
  2. import { mkdtemp, rm, writeFile, symlink } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join, relative } from 'node:path'
  5. import { afterEach, describe, expect, it, vi } from 'vitest'
  6. import { Context } from '@deepseek-ai/cordis'
  7. import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
  8. import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
  9. import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
  10. import { createScope, type Scope } from '@deepseek-ai/dsh-scope'
  11. import { ToolCallId } from '@deepseek-ai/dsh-llm'
  12. import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
  13. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  14. import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
  15. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  16. import ToolRuntime, { defineTool } from '@deepseek-ai/dsh-tools'
  17. import type { PresentedFile } from '../src/types.ts'
  18. import * as Present from '../src/index.ts'
  19. const cleanups: Array<() => Promise<unknown>> = []
  20. let callNumber = 0
  21. afterEach(async () => {
  22. for (const cleanup of cleanups.reverse()) await cleanup()
  23. cleanups.length = 0
  24. vi.restoreAllMocks()
  25. })
  26. async function agent(ctx: Context, cwd: string | undefined): Promise<Agent> {
  27. const id = SessionId(`present-owner-${++callNumber}`)
  28. let scope: Scope
  29. const session = Session.create(id, [], {
  30. version: SESSION_FORMAT_VERSION, id, createdAt: 0, ...cwd === undefined ? {} : { cwd }, isSeeded: false,
  31. })
  32. const value: Agent = {
  33. id,
  34. options: {},
  35. session,
  36. inbox: unsupportedInbox(),
  37. status: 'idle',
  38. get ctx() { return scope.ctx },
  39. send: () => {},
  40. followup: () => {},
  41. steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
  42. inject: () => {},
  43. cancel() {},
  44. runMaintenance: task => task(new AbortController().signal),
  45. whenIdle: () => Promise.resolve(),
  46. }
  47. await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, value) }, { inject: ['tools'] }))
  48. ctx.agents.register(value)
  49. return value
  50. }
  51. async function setup() {
  52. const root = await mkdtemp(join(tmpdir(), 'dsh-present-minimal-'))
  53. cleanups.push(() => rm(root, { recursive: true, force: true }))
  54. const ctx = new Context()
  55. cleanups.push(() => ctx.fiber.dispose())
  56. await ctx.plugin(SystemPrompt)
  57. await ctx.plugin(ToolRuntime)
  58. await ctx.plugin(AgentRegistry)
  59. await ctx.plugin(LocalFileSystem, { cwd: root })
  60. await ctx.plugin(SessionProjectionRegistry)
  61. ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
  62. const fiber = ctx.plugin(Present, { maxFiles: 2 })
  63. await fiber
  64. const owner = await agent(ctx, root)
  65. owner.session.append('turn/start', { turn: 1 })
  66. const execute = (files: unknown) => ctx.tools.execute({
  67. signal: new AbortController().signal, callId: ToolCallId(`call-${++callNumber}`),
  68. name: 'present', arguments: { files }, agent: owner,
  69. })
  70. return { ctx, owner, root, fiber, execute }
  71. }
  72. describe('present', () => {
  73. it('declares binary files without reading or copying contents, and records one delivery', async () => {
  74. const { ctx, owner, root, execute, fiber } = await setup()
  75. const data = Uint8Array.of(80, 75, 0, 255)
  76. await writeFile(join(root, '报告.docx'), data)
  77. const read = vi.spyOn(ctx.fs, 'readBytes')
  78. const result = await execute([{ path: '报告.docx', description: 'Report' }])
  79. expect(result.isError).toBe(false)
  80. if (result.isError) throw new Error('present failed')
  81. const files = (result.value as unknown as { files: PresentedFile[] }).files
  82. expect(files).toHaveLength(1)
  83. expect(owner.session.snapshotEvents().find(event => event.type === 'deliverables/presented')?.data.files).toEqual(files)
  84. expect(files).toEqual([{ path: '报告.docx', description: 'Report' }])
  85. expect(read).not.toHaveBeenCalled()
  86. expect(ctx.get('attachments')).toBeUndefined()
  87. await fiber.dispose()
  88. expect(ctx.tools.get('present', owner)).toBeUndefined()
  89. })
  90. it('ignores a different present definition in the calling agent scope', async () => {
  91. const { owner, execute } = await setup()
  92. owner.ctx.tools.register(defineTool({
  93. name: 'present', description: 'Scoped replacement.', parameters: {},
  94. output: {
  95. schema: {
  96. type: 'object', additionalProperties: false,
  97. properties: {
  98. turn: { type: 'integer', required: true },
  99. files: { type: 'array', required: true, items: { type: 'string' } },
  100. },
  101. },
  102. render: () => [],
  103. },
  104. execute: async () => ({ turn: 1, files: [] }),
  105. }))
  106. expect((await execute([])).isError).toBe(false)
  107. expect(owner.session.snapshotEvents().filter(event => event.type === 'deliverables/presented')).toEqual([])
  108. })
  109. it('records once when ancestor and agent scopes both mount present', async () => {
  110. const { owner, root, execute } = await setup()
  111. await owner.ctx.plugin(Present, { maxFiles: 2 })
  112. await writeFile(join(root, 'a'), 'a')
  113. expect((await execute([{ path: 'a' }])).isError).toBe(false)
  114. const deliveries = owner.session.snapshotEvents().filter(event => event.type === 'deliverables/presented')
  115. expect(deliveries).toHaveLength(1)
  116. expect(deliveries[0]?.data.files[0]?.path).toBe('a')
  117. })
  118. it('does not publish deliveries after post-execute blocks a successful declaration', async () => {
  119. const { ctx, root, owner, execute } = await setup()
  120. await writeFile(join(root, 'a'), 'a')
  121. ctx.on('tools/post-execute', async (_exec, _result, next) => {
  122. await next()
  123. return { kind: 'block', feedback: [{ type: 'text', text: 'blocked' }] }
  124. })
  125. expect((await execute([{ path: 'a' }])).isError).toBe(true)
  126. expect(owner.session.snapshotEvents().some(event => event.type === 'deliverables/presented')).toBe(false)
  127. })
  128. it('rejects missing, non-file, empty, and excessive inputs', async () => {
  129. const { root, owner, execute } = await setup()
  130. await writeFile(join(root, 'large'), 'four')
  131. await symlink(tmpdir(), join(root, 'outside'))
  132. for (const files of [[], [{ path: '' }], [{ path: 'missing' }], [{ path: '.' }], [{ path: 'outside' }], [{ path: 'large' }, { path: 'large' }, { path: 'large' }]]) {
  133. const result = await execute(files)
  134. expect(result.isError, JSON.stringify(files)).toBe(true)
  135. }
  136. expect(owner.session.snapshotEvents().some(event => event.type === 'deliverables/presented')).toBe(false)
  137. })
  138. })
  139. it('validates deployment limits before registering the tool', () => {
  140. for (const config of [{ maxFiles: 0 }, { maxFiles: 1.5 }, { maxFiles: Number.POSITIVE_INFINITY }]) {
  141. expect(() => { Present.apply(new Context(), config) }).toThrow('positive integer maxFiles')
  142. }
  143. })
  144. it('requires an agent, an open turn, and a workspace', async () => {
  145. const { ctx, owner, execute } = await setup()
  146. const detached = await ctx.tools.execute({ signal: new AbortController().signal, callId: ToolCallId('detached'), name: 'present', arguments: { files: [{ path: 'a' }] } })
  147. expect(detached.isError).toBe(true)
  148. owner.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  149. expect((await execute([{ path: 'a' }])).isError).toBe(true)
  150. const noWorkspace = await agent(ctx, undefined)
  151. noWorkspace.session.append('turn/start', { turn: 1 })
  152. const absent = await ctx.tools.execute({ signal: new AbortController().signal, callId: ToolCallId('no-workspace'), name: 'present', arguments: { files: [{ path: 'a' }] }, agent: noWorkspace })
  153. expect(absent.isError).toBe(true)
  154. })
  155. it('declares readable files outside the Session directory using absolute and relative paths', async () => {
  156. const { root, execute, owner } = await setup()
  157. const outside = await mkdtemp(join(tmpdir(), 'dsh-present-external-'))
  158. cleanups.push(() => rm(outside, { recursive: true, force: true }))
  159. const file = join(outside, 'report.txt')
  160. await writeFile(file, 'external report')
  161. const files = [{ path: file }, { path: relative(root, file) }]
  162. expect((await execute(files)).isError).toBe(false)
  163. expect(owner.session.snapshotEvents().find(event => event.type === 'deliverables/presented')?.data.files).toEqual(files)
  164. })
  165. it('refuses a final symlink to an ordinary file', async () => {
  166. const { root, execute } = await setup()
  167. await writeFile(join(root, 'source'), 'source')
  168. await symlink(join(root, 'source'), join(root, 'link'))
  169. expect((await execute([{ path: 'link' }])).isError).toBe(true)
  170. })
  171. it('refuses a file replaced by a directory after inspecting its final component', async () => {
  172. const { ctx, root, execute } = await setup()
  173. await writeFile(join(root, 'source'), 'source')
  174. const directory = await ctx.fs.stat(await ctx.fs.resolve(root))
  175. vi.spyOn(ctx.fs, 'stat').mockResolvedValueOnce(directory)
  176. expect((await execute([{ path: 'source' }])).isError).toBe(true)
  177. })