integration.spec.ts 2.8 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { SessionId } from '@deepseek-ai/dsh-session'
  4. import type { Agent } from '@deepseek-ai/dsh-agent'
  5. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  6. import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
  7. import * as ToolCordis from '../src/index.ts'
  8. import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
  9. import { REVERSE_TOOL_CODE } from './helpers.ts'
  10. /**
  11. * Full-loop integration: a scripted mock model mounts a plugin that registers
  12. * a NEW tool, calls that tool on the very next step (tool schemas are
  13. * reassembled per step — the real loop proves the self-extension contract),
  14. * and unmounts it again. Only the model is mocked; the sandbox, the fiber
  15. * tree, and the session log are real.
  16. */
  17. async function harness(adapter: MockAdapter): Promise<Context> {
  18. const ctx = new Context()
  19. await mountAgentLoopTestDependencies(ctx)
  20. await ctx.plugin(AgentLoop, { agents: [] })
  21. await ctx.plugin(ToolCordis)
  22. ctx.llm.registerAdapter(['mock'], adapter)
  23. return ctx
  24. }
  25. function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
  26. return new Promise((resolve) => {
  27. const dispose = ctx.on('agent/status', (subject, status) => {
  28. if (subject === agent && status === 'idle') {
  29. dispose()
  30. resolve()
  31. }
  32. })
  33. })
  34. }
  35. describe('cordis tools through the agent loop', () => {
  36. it('mounts a tool, calls it on the next step, and unmounts it — all as real tool/call events', async () => {
  37. const adapter = new MockAdapter([
  38. toolCallResponse('call-1', 'cordis_mount', { code: REVERSE_TOOL_CODE }, 'Extending myself.'),
  39. toolCallResponse('call-2', 'reverse_text', { text: 'harness' }),
  40. toolCallResponse('call-3', 'cordis_unmount', { id: 'dyn-1' }),
  41. textResponse('Done.'),
  42. ])
  43. const ctx = await harness(adapter)
  44. const agent = ctx.agentLoop.create(SessionId('it-cordis'), { provider: 'mock', model: 'mock' })
  45. agent.followup([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }])
  46. await waitForIdle(ctx, agent)
  47. const log = agent.session.events
  48. const calls = log.filter(event => event.type === 'tool/call').map(event => event.data.name)
  49. expect(calls).toEqual(['cordis_mount', 'reverse_text', 'cordis_unmount'])
  50. const results = log.filter(event => event.type === 'tool/result')
  51. expect(results.map(event => event.data.isError)).toEqual([false, false, false])
  52. const reversed = results[1]!.data.content
  53. .filter(block => block.type === 'text')
  54. .map(block => block.text)
  55. .join('')
  56. expect(reversed).toBe('ssenrah')
  57. // After the unmount the self-made tool is gone from the registry.
  58. expect(ctx.tools.get('reverse_text')).toBeUndefined()
  59. })
  60. })