integration.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import LlmRuntime, { createUserMessage, type GenerateOptions, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
  4. import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
  5. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  6. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  7. import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
  8. import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
  9. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  10. import PlanModeController from '@deepseek-ai/dsh-plan-mode'
  11. import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
  12. const PLAN_CONFIG = { section: 'Test plan mode instructions.' }
  13. /**
  14. * Full-loop integration: a scripted mock model drives the REAL plan-mode plugin
  15. * through the agent loop — the pending-intent flush at the step boundary, the
  16. * assembly the soft layer shapes (the exit tool + mode section), and the
  17. * `system/message` surface node every prompt transition replaces.
  18. * Only the model is mocked; the loop, the session log, and the plugin are
  19. * real.
  20. */
  21. async function harness(adapter: MockAdapter): Promise<Context> {
  22. const ctx = new Context()
  23. await ctx.plugin(LlmRuntime)
  24. await ctx.plugin(SessionStore)
  25. await ctx.plugin(SessionProjectionRegistry)
  26. await ctx.plugin(SystemPrompt)
  27. await ctx.plugin(ToolRuntime)
  28. await ctx.plugin(AgentRegistry)
  29. await ctx.plugin(AgentLoop, { agents: [] })
  30. await ctx.plugin(PlanModeController, PLAN_CONFIG)
  31. ctx.llm.registerAdapter(['mock'], adapter)
  32. for (const name of ['read', 'write']) {
  33. ctx.tools.register(defineContentToolFixture({
  34. name,
  35. description: `test tool ${name}`,
  36. parameters: {},
  37. execute: () => Promise.resolve([{ type: 'text', text: `ran ${name}` }]),
  38. }))
  39. }
  40. return ctx
  41. }
  42. function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
  43. return new Promise((resolve) => {
  44. const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
  45. if (subject === agent && status === 'idle') {
  46. dispose()
  47. resolve()
  48. }
  49. })
  50. })
  51. }
  52. /** Read the plan unit that backs the service in this full composition. */
  53. function planActive(ctx: Context, agent: Agent): boolean {
  54. const state = ctx.sessionProjections.stateOf(agent.session, 'plan')
  55. if (state === undefined) throw new Error('plan projection is not registered')
  56. return state.active
  57. }
  58. /** Join the text blocks of one message. */
  59. function textOf(message: Message): string {
  60. return message.content.filter(block => block.type === 'text').map(block => block.text).join('')
  61. }
  62. /** Text of the session's current system node: the `system/message` at surface node 0. */
  63. function systemText(agent: Agent): string {
  64. const head = agent.session.deriveMessages()[0]
  65. if (head?.role !== 'system') throw new Error('surface node 0 is not a system message')
  66. return textOf(head)
  67. }
  68. /** Text of the leading system message of one loop-built request. */
  69. function requestSystem(options: GenerateOptions | undefined): string {
  70. const head = options?.messages[0]
  71. if (head?.role !== 'system') throw new Error('the request does not lead with a system message')
  72. return textOf(head)
  73. }
  74. function findEvent<T extends SessionEvent['type']>(
  75. log: readonly SessionEvent[],
  76. type: T,
  77. position: 'first' | 'last' = 'first',
  78. ): Extract<SessionEvent, { type: T }> {
  79. const found = position === 'first'
  80. ? log.find(event => event.type === type)
  81. : log.findLast(event => event.type === type)
  82. if (!found) throw new Error(`no ${type} event in the session log`)
  83. return found as Extract<SessionEvent, { type: T }>
  84. }
  85. describe('plan mode through the agent loop', () => {
  86. it('a pre-turn set() makes the FIRST header plan-shaped, and a non-shell call is guidance-constrained only', async () => {
  87. const adapter = new MockAdapter([
  88. toolCallResponse('call-1', 'write', {}, 'Writing during plan.'),
  89. textResponse('Noted in the plan.'),
  90. ])
  91. const ctx = await harness(adapter)
  92. const agent = await ctx.agentLoop.create(SessionId('it-plan-seed'), { provider: 'mock', model: 'mock' })
  93. // Selected while idle: the mode commits immediately, before the first assembly.
  94. ctx.planMode.set(agent, true)
  95. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'explore the repo' }], source: { kind: 'user' } }))
  96. await waitForIdle(ctx, agent)
  97. const log = agent.session.snapshotEvents()
  98. const planMode = findEvent(log, 'plan/mode')
  99. const header = findEvent(log, 'request/header')
  100. const systemNode = findEvent(log, 'system/message')
  101. expect(planMode.seq).toBeLessThan(header.seq)
  102. expect(header.data.reason).toBe('initial')
  103. expect(header.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write'])
  104. // The first system node already carries the section: appended, never replaced.
  105. expect(systemNode.surfaceOp).toBe('append')
  106. expect(log.filter(event => event.type === 'system/message')).toHaveLength(1)
  107. expect(agent.session.surface.nodes[0]).toBe(systemNode.seq)
  108. expect(systemText(agent)).toContain('plan mode')
  109. // No tool gate: the write RUNS — plan restrains by the section's
  110. // guidance alone (enforcement lives on the independent sandbox/approval
  111. // axes). The mode itself stays plan throughout.
  112. const result = findEvent(log, 'tool/result')
  113. expect(result.data.message.content[0].isError).toBe(false)
  114. expect(planActive(ctx, agent)).toBe(true)
  115. expect(log.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false)
  116. })
  117. it('a user flip between turns lands at the boundary: one notice and a replaced system node with stable tool schemas', async () => {
  118. const adapter = new MockAdapter([
  119. textResponse('First turn, default mode.'),
  120. textResponse('Second turn, plan mode.'),
  121. ])
  122. const ctx = await harness(adapter)
  123. const agent = await ctx.agentLoop.create(SessionId('it-plan-flip'), { provider: 'mock', model: 'mock' })
  124. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }))
  125. await waitForIdle(ctx, agent)
  126. expect(planActive(ctx, agent)).toBe(false)
  127. const first = findEvent(agent.session.snapshotEvents(), 'request/header')
  128. expect(first.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write'])
  129. const firstSystem = findEvent(agent.session.snapshotEvents(), 'system/message')
  130. expect(systemText(agent)).not.toContain(PLAN_CONFIG.section)
  131. ctx.planMode.set(agent, true)
  132. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'now plan' }], source: { kind: 'user' } }))
  133. await waitForIdle(ctx, agent)
  134. const log = agent.session.snapshotEvents()
  135. expect(planActive(ctx, agent)).toBe(true)
  136. const notices = log.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
  137. expect(notices).toHaveLength(1)
  138. expect(notices[0]?.type === 'user/message' && notices[0].data.content).toEqual([
  139. { type: 'text', text: 'The user switched this session to plan mode.' },
  140. ])
  141. // The changed prompt replaces surface node 0 in place; the header is
  142. // re-logged as a series snapshot because tools and config are unchanged.
  143. const systemNodes = log.filter(event => event.type === 'system/message')
  144. expect(systemNodes).toHaveLength(2)
  145. const second = systemNodes[1]
  146. expect(second?.surfaceOp).toEqual({ op: 'replace', startSeq: firstSystem.seq, endSeq: firstSystem.seq })
  147. expect(second?.sourceEventSeqs).toEqual([firstSystem.seq])
  148. expect(agent.session.surface.nodes[0]).toBe(second?.seq)
  149. expect(systemText(agent)).toContain('plan mode')
  150. expect(log.filter(event => event.type === 'request/header').map(event => event.data.reason)).toEqual(['initial', 'series'])
  151. const secondHeader = findEvent(log, 'request/header', 'last')
  152. expect(secondHeader.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write'])
  153. expect(secondHeader.data.header.tools).toEqual(first.data.header.tools)
  154. })
  155. it('a mode flip at error settlement waits until the step after a same-step retry', async () => {
  156. const failedRequest = [{
  157. type: 'finish',
  158. reason: { kind: 'error', failure: { message: 'temporarily unavailable', code: 'SERVER', status: 503 } },
  159. }] satisfies StreamChunk[]
  160. const adapter = new MockAdapter([
  161. failedRequest,
  162. textResponse('Recovered with the original step assembly.'),
  163. textResponse('Entered plan mode on the next step.'),
  164. ])
  165. const ctx = await harness(adapter)
  166. const agent = await ctx.agentLoop.create(SessionId('it-plan-retry-flip'), { provider: 'mock', model: 'mock' })
  167. ctx.on('agent/request-error', async ({ agent: subject }, next) => {
  168. if (subject !== agent) return next()
  169. ctx.planMode.set(agent, true)
  170. return { kind: 'retry' }
  171. })
  172. const idle = waitForIdle(ctx, agent)
  173. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'plan after the transient failure' }], source: { kind: 'user' } }))
  174. await idle
  175. expect(adapter.requests).toHaveLength(2)
  176. expect(requestSystem(adapter.requests[0])).not.toContain(PLAN_CONFIG.section)
  177. expect(requestSystem(adapter.requests[1])).not.toContain(PLAN_CONFIG.section)
  178. expect(adapter.requests[1]?.tools).toEqual(adapter.requests[0]?.tools)
  179. expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
  180. expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false)
  181. const nextIdle = waitForIdle(ctx, agent)
  182. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'continue with the plan' }], source: { kind: 'user' } }))
  183. await nextIdle
  184. expect(adapter.requests).toHaveLength(3)
  185. expect(requestSystem(adapter.requests[2])).toContain(PLAN_CONFIG.section)
  186. expect(adapter.requests[2]?.tools).toEqual(adapter.requests[0]?.tools)
  187. const log = agent.session.snapshotEvents()
  188. const planMode = findEvent(log, 'plan/mode')
  189. const firstEnd = log.find(event => event.type === 'step/end'
  190. && event.data.turn === 1 && event.data.step === 1)
  191. const nextStart = log.find(event => event.type === 'step/start'
  192. && event.data.turn === 2 && event.data.step === 1)
  193. expect(firstEnd?.seq).toBeLessThan(planMode.seq)
  194. expect(planMode.seq).toBeLessThan(nextStart?.seq ?? 0)
  195. const systemNodes = log.filter(event => event.type === 'system/message')
  196. expect(systemNodes).toHaveLength(2)
  197. expect(systemNodes[1]?.sourceEventSeqs).toEqual([systemNodes[0]?.seq])
  198. expect(nextStart?.seq).toBeLessThan(systemNodes[1]?.seq ?? 0)
  199. expect(systemText(agent)).toContain(PLAN_CONFIG.section)
  200. const notice = log.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
  201. expect(notice?.type === 'user/message' && notice.data.content).toEqual([
  202. { type: 'text', text: 'The user switched this session to plan mode.' },
  203. ])
  204. })
  205. })