integration.spec.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import LlmRuntime, { createUserMessage, type StreamChunk } from '@deepseek-ai/dsh-llm'
  4. import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
  5. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  6. import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
  7. import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
  8. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  9. import PlanModeController, { foldPlanMode } from '@deepseek-ai/dsh-plan-mode'
  10. import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
  11. const PLAN_CONFIG = { section: 'Test plan mode instructions.' }
  12. /**
  13. * Full-loop integration: a scripted mock model drives the REAL plan-mode plugin
  14. * through the agent loop — the pending-intent flush at the step boundary, the
  15. * assembly the soft layer shapes (the exit tool + mode section), and the
  16. * `request/header` snapshots every transition leaves.
  17. * Only the model is mocked; the loop, the session log, and the plugin are
  18. * real.
  19. */
  20. async function harness(adapter: MockAdapter): Promise<Context> {
  21. const ctx = new Context()
  22. await ctx.plugin(LlmRuntime)
  23. await ctx.plugin(SessionStore)
  24. await ctx.plugin(SystemPrompt)
  25. await ctx.plugin(ToolRuntime)
  26. await ctx.plugin(AgentRegistry)
  27. await ctx.plugin(AgentLoop, { agents: [] })
  28. await ctx.plugin(PlanModeController, PLAN_CONFIG)
  29. ctx.llm.registerAdapter(['mock'], adapter)
  30. for (const name of ['read', 'write']) {
  31. ctx.tools.register(defineContentToolFixture({
  32. name,
  33. description: `test tool ${name}`,
  34. parameters: {},
  35. execute: () => Promise.resolve([{ type: 'text', text: `ran ${name}` }]),
  36. }))
  37. }
  38. return ctx
  39. }
  40. function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
  41. return new Promise((resolve) => {
  42. const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
  43. if (subject === agent && status === 'idle') {
  44. dispose()
  45. resolve()
  46. }
  47. })
  48. })
  49. }
  50. function findEvent<T extends SessionEvent['type']>(
  51. log: readonly SessionEvent[],
  52. type: T,
  53. position: 'first' | 'last' = 'first',
  54. ): Extract<SessionEvent, { type: T }> {
  55. const found = position === 'first'
  56. ? log.find(event => event.type === type)
  57. : log.findLast(event => event.type === type)
  58. if (!found) throw new Error(`no ${type} event in the session log`)
  59. return found as Extract<SessionEvent, { type: T }>
  60. }
  61. describe('plan mode through the agent loop', () => {
  62. it('a pre-turn set() makes the FIRST header plan-shaped, and a non-shell call is guidance-constrained only', async () => {
  63. const adapter = new MockAdapter([
  64. toolCallResponse('call-1', 'write', {}, 'Writing during plan.'),
  65. textResponse('Noted in the plan.'),
  66. ])
  67. const ctx = await harness(adapter)
  68. const agent = ctx.agentLoop.create(SessionId('it-plan-seed'), { provider: 'mock', model: 'mock' })
  69. // Selected while idle: the mode commits immediately, before the first assembly.
  70. ctx.planMode.set(agent, true)
  71. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'explore the repo' }], source: { kind: 'user' } }))
  72. await waitForIdle(ctx, agent)
  73. const log = agent.session.events
  74. const planMode = findEvent(log, 'plan/mode')
  75. const header = findEvent(log, 'request/header')
  76. expect(planMode.seq).toBeLessThan(header.seq)
  77. expect(header.data.reason).toBe('initial')
  78. expect(header.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write'])
  79. expect(header.data.header.system).toContain('plan mode')
  80. // No tool gate: the write RUNS — plan restrains by the section's
  81. // guidance alone (enforcement lives on the independent sandbox/approval
  82. // axes). The mode itself stays plan throughout.
  83. const result = findEvent(log, 'tool/result')
  84. expect(result.data.message.content[0].isError).toBe(false)
  85. expect(foldPlanMode(log)).toBe(true)
  86. expect(log.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false)
  87. })
  88. it('a user flip between turns lands at the boundary: one notice and a changed header with stable tool schemas', async () => {
  89. const adapter = new MockAdapter([
  90. textResponse('First turn, default mode.'),
  91. textResponse('Second turn, plan mode.'),
  92. ])
  93. const ctx = await harness(adapter)
  94. const agent = ctx.agentLoop.create(SessionId('it-plan-flip'), { provider: 'mock', model: 'mock' })
  95. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }))
  96. await waitForIdle(ctx, agent)
  97. expect(foldPlanMode(agent.session.events)).toBe(false)
  98. const first = findEvent(agent.session.events, 'request/header')
  99. expect(first.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write'])
  100. ctx.planMode.set(agent, true)
  101. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'now plan' }], source: { kind: 'user' } }))
  102. await waitForIdle(ctx, agent)
  103. const log = agent.session.events
  104. expect(foldPlanMode(log)).toBe(true)
  105. const notices = log.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
  106. expect(notices).toHaveLength(1)
  107. expect(notices[0]?.type === 'user/message' && notices[0].data.content).toEqual([
  108. { type: 'text', text: 'The user switched this session to plan mode.' },
  109. ])
  110. // The changed request is logged as a complete snapshot.
  111. const second = findEvent(log, 'request/header', 'last')
  112. expect(second.data.reason).toBe('change')
  113. expect(second.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write'])
  114. expect(second.data.header.tools).toEqual(first.data.header.tools)
  115. expect(second.data.header.system).toContain('plan mode')
  116. })
  117. it('a mode flip at error settlement waits until the step after a same-step retry', async () => {
  118. const failedRequest = [{
  119. type: 'finish',
  120. reason: { kind: 'error', failure: { message: 'temporarily unavailable', code: 'SERVER', status: 503 } },
  121. }] satisfies StreamChunk[]
  122. const adapter = new MockAdapter([
  123. failedRequest,
  124. textResponse('Recovered with the original step assembly.'),
  125. textResponse('Entered plan mode on the next step.'),
  126. ])
  127. const ctx = await harness(adapter)
  128. const agent = ctx.agentLoop.create(SessionId('it-plan-retry-flip'), { provider: 'mock', model: 'mock' })
  129. ctx.on('agent/request-error', async ({ agent: subject }, next) => {
  130. if (subject !== agent) return next()
  131. ctx.planMode.set(agent, true)
  132. return { kind: 'retry' }
  133. })
  134. const idle = waitForIdle(ctx, agent)
  135. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'plan after the transient failure' }], source: { kind: 'user' } }))
  136. await idle
  137. expect(adapter.requests).toHaveLength(2)
  138. expect(adapter.requests[0]?.system).not.toContain(PLAN_CONFIG.section)
  139. expect(adapter.requests[1]?.system).not.toContain(PLAN_CONFIG.section)
  140. expect(adapter.requests[1]?.tools).toEqual(adapter.requests[0]?.tools)
  141. expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
  142. expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
  143. const nextIdle = waitForIdle(ctx, agent)
  144. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'continue with the plan' }], source: { kind: 'user' } }))
  145. await nextIdle
  146. expect(adapter.requests).toHaveLength(3)
  147. expect(adapter.requests[2]?.system).toContain(PLAN_CONFIG.section)
  148. expect(adapter.requests[2]?.tools).toEqual(adapter.requests[0]?.tools)
  149. const log = agent.session.events
  150. const planMode = findEvent(log, 'plan/mode')
  151. const firstEnd = log.find(event => event.type === 'step/end'
  152. && event.data.turn === 1 && event.data.step === 1)
  153. const nextStart = log.find(event => event.type === 'step/start'
  154. && event.data.turn === 2 && event.data.step === 1)
  155. expect(firstEnd?.seq).toBeLessThan(planMode.seq)
  156. expect(planMode.seq).toBeLessThan(nextStart?.seq ?? 0)
  157. expect(findEvent(log, 'request/header', 'last').data.header.system).toContain(PLAN_CONFIG.section)
  158. const notice = log.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
  159. expect(notice?.type === 'user/message' && notice.data.content).toEqual([
  160. { type: 'text', text: 'The user switched this session to plan mode.' },
  161. ])
  162. })
  163. })