integration.spec.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import LlmService from '@deepseek-ai/dsh-llm'
  4. import SessionStore from '@deepseek-ai/dsh-session'
  5. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  6. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  7. import ToolRegistry from '@deepseek-ai/dsh-tools'
  8. import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
  9. import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
  10. import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
  11. import { BashTaskId } from '@deepseek-ai/dsh-bash'
  12. import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
  13. import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
  14. /**
  15. * Full-loop integration: a scripted mock model drives the REAL bash tool
  16. * through the agent loop, exercising the same seams a live model would
  17. * (tool/call + tool/result session events, agent.inject notifications).
  18. */
  19. async function harness(adapter: MockAdapter) {
  20. const ctx = new Context()
  21. await ctx.plugin(LlmService)
  22. await ctx.plugin(SessionStore)
  23. await ctx.plugin(SystemPrompt)
  24. await ctx.plugin(ToolRegistry)
  25. await ctx.plugin(AgentRegistry)
  26. await ctx.plugin(AgentLoop, { agents: [] })
  27. await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
  28. await ctx.plugin(ToolBash)
  29. ctx.llm.registerAdapter(['mock'], adapter)
  30. return ctx
  31. }
  32. function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
  33. return new Promise((resolve) => {
  34. const dispose = ctx.on('agent/status', (subject, status) => {
  35. if (subject === agent && status === 'idle') {
  36. dispose()
  37. resolve()
  38. }
  39. })
  40. })
  41. }
  42. function events(agent: ReactLoopAgent): SessionEvent[] {
  43. return [...agent.session.events]
  44. }
  45. /** Find a session event by type, narrowed; throws when absent. */
  46. function findEvent<T extends SessionEvent['type']>(
  47. log: SessionEvent[],
  48. type: T,
  49. position: 'first' | 'last' = 'first',
  50. ): Extract<SessionEvent, { type: T }> {
  51. const found = position === 'first'
  52. ? log.find(event => event.type === type)
  53. : log.findLast(event => event.type === type)
  54. if (!found) throw new Error(`no ${type} event in the session log`)
  55. return found as Extract<SessionEvent, { type: T }>
  56. }
  57. function resultText(event: SessionEvent): string {
  58. if (event.type !== 'tool/result') return ''
  59. return event.data.content
  60. .filter(block => block.type === 'text')
  61. .map(block => block.text)
  62. .join('')
  63. }
  64. describe('bash tool through the agent loop', () => {
  65. it('foreground: model calls bash, sees the result, replies', async () => {
  66. const adapter = new MockAdapter([
  67. toolCallResponse('call-1', 'bash', { command: 'echo integration-ok', description: 'test command' }, 'Running it.'),
  68. textResponse('The command printed integration-ok.'),
  69. ])
  70. const ctx = await harness(adapter)
  71. const agent = ctx.agentLoop.create(AgentId('it-fg'), { model: 'mock' })
  72. agent.send([{ type: 'text', text: 'run echo integration-ok' }])
  73. await waitForIdle(ctx, agent)
  74. const log = events(agent)
  75. const toolCall = findEvent(log, 'tool/call')
  76. expect(toolCall.data.name).toBe('bash')
  77. const toolResult = findEvent(log, 'tool/result')
  78. expect(toolResult.data.isError).toBe(false)
  79. expect(resultText(toolResult)).toBe('integration-ok\n')
  80. // The second model call saw the tool result in its derived history.
  81. const lastRequest = adapter.requests.at(-1)
  82. const toolResultBlocks = (lastRequest?.messages ?? [])
  83. .flatMap(message => message.content)
  84. .filter(block => block.type === 'tool-result')
  85. expect(toolResultBlocks).toHaveLength(1)
  86. const finalMessage = findEvent(log, 'assistant/message', 'last')
  87. expect(finalMessage.data.content.some(
  88. block => block.type === 'text' && block.text.includes('integration-ok'),
  89. )).toBe(true)
  90. })
  91. it('foreground: non-zero exit is reported in the result text, not as isError', async () => {
  92. const adapter = new MockAdapter([
  93. toolCallResponse('call-1', 'bash', { command: 'exit 9', description: 'test command' }),
  94. textResponse('It failed with code 9.'),
  95. ])
  96. const ctx = await harness(adapter)
  97. const agent = ctx.agentLoop.create(AgentId('it-exit'), { model: 'mock' })
  98. agent.send([{ type: 'text', text: 'run exit 9' }])
  99. await waitForIdle(ctx, agent)
  100. const toolResult = findEvent(events(agent), 'tool/result')
  101. expect(toolResult.data.isError).toBe(false)
  102. expect(resultText(toolResult)).toContain('[exit code: 9]')
  103. })
  104. it('background: start → poll → completion notice lands as context/message', async () => {
  105. const adapter = new MockAdapter([
  106. toolCallResponse('call-1', 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true }),
  107. toolCallResponse('call-2', 'bash_output', {}, undefined),
  108. textResponse('Background task finished.'),
  109. ])
  110. // The second tool call needs the REAL task id from the first result;
  111. // a tools/execute waterfall listener rewrites the scripted arguments.
  112. let taskId = ''
  113. const ctx = await harness(adapter)
  114. const agent = ctx.agentLoop.create(AgentId('it-bg'), { model: 'mock' })
  115. // Intercept the first tool result to capture the generated task id, then
  116. // rewrite the second scripted call's arguments to use it.
  117. ctx.on('session/event', (_session, event) => {
  118. if (event.type === 'tool/result' && taskId === '') {
  119. const match = /task (bash-\d+)/.exec(resultText(event))
  120. if (match) taskId = match[1]!
  121. }
  122. })
  123. ctx.on('tools/execute', async (exec, next) => {
  124. if (exec.name === 'bash_output') {
  125. exec.arguments = { task_id: taskId }
  126. }
  127. return next()
  128. })
  129. agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }])
  130. await waitForIdle(ctx, agent)
  131. // Wait for the background task itself (completion may race turn end).
  132. const task = ctx.bash.get(BashTaskId(taskId))
  133. if (!task) throw new Error(`task ${taskId} not registered`)
  134. await task.done
  135. const log = events(agent)
  136. const firstResult = findEvent(log, 'tool/result')
  137. expect(resultText(firstResult)).toBe(`started background task ${taskId}`)
  138. const notice = findEvent(log, 'context/message')
  139. expect(notice.data.content.some(
  140. block => block.type === 'text' && block.text.includes(`background bash task ${taskId} finished`),
  141. )).toBe(true)
  142. expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-bash' })
  143. })
  144. })