modes.spec.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  2. import { mkdtemp, rm } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
  6. import { SessionId } from '@deepseek-ai/dsh-session'
  7. import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
  8. /** The `current_mode_update` notifications, in order. */
  9. function modeUpdates(updates: CapturedUpdate[]): string[] {
  10. return updates
  11. .filter(update => update.sessionUpdate === 'current_mode_update')
  12. .map(update => update.currentModeId)
  13. }
  14. describe('acp bridge — plan mode projection', () => {
  15. let storageDir: string
  16. let harness: BridgeHarness | undefined
  17. let loader: BridgeHarness | undefined
  18. beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-modes-')) })
  19. afterEach(async () => {
  20. if (harness) await harness.dispose()
  21. if (loader) await loader.dispose()
  22. harness = loader = undefined
  23. await rm(storageDir, { recursive: true, force: true })
  24. })
  25. it('advertises no mode surface and rejects session/set_mode when plan mode is not composed', async () => {
  26. harness = await makeBridgeHarness({ storageDir })
  27. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  28. const res = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  29. expect(res.modes).toBeUndefined()
  30. await expect(harness.client.setSessionMode({ sessionId: res.sessionId, modeId: 'plan' }))
  31. .rejects.toMatchObject({ message: expect.stringContaining('session modes are not composed') as string })
  32. })
  33. it('advertises availableModes/currentModeId on session/new', async () => {
  34. harness = await makeBridgeHarness({ storageDir, withModes: true })
  35. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  36. const res = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  37. expect(res.modes).toEqual({
  38. availableModes: [
  39. { id: 'default', name: 'default' },
  40. { id: 'plan', name: 'plan' },
  41. ],
  42. currentModeId: 'default',
  43. })
  44. })
  45. it('session/set_mode records the pending intent and echoes one optimistic current_mode_update', async () => {
  46. harness = await makeBridgeHarness({ storageDir, withModes: true })
  47. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  48. const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  49. await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
  50. expect(modeUpdates(harness.updates)).toEqual(['plan'])
  51. const agent = harness.ctx.agents.get(SessionId(sessionId))!
  52. expect(harness.ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
  53. })
  54. it('rejects an unknown ACP mode id at the adapter boundary', async () => {
  55. harness = await makeBridgeHarness({ storageDir, withModes: true })
  56. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  57. const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  58. await expect(harness.client.setSessionMode({ sessionId, modeId: 'nope' }))
  59. .rejects.toMatchObject({ message: expect.stringContaining('unknown session mode "nope"') as string })
  60. expect(modeUpdates(harness.updates)).toEqual([])
  61. })
  62. it('does not re-notify when the boundary flush logs the mode the picker already showed', async () => {
  63. harness = await makeBridgeHarness({ storageDir, withModes: true, script: [textResponse('planning')] })
  64. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  65. const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  66. await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
  67. await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go plan' }] })
  68. const agent = harness.ctx.agents.get(SessionId(sessionId))!
  69. expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(true)
  70. expect(modeUpdates(harness.updates)).toEqual(['plan'])
  71. })
  72. it('re-notifies on a logged flip the picker has not seen (the tool-driven exit shape)', async () => {
  73. harness = await makeBridgeHarness({ storageDir, withModes: true, script: [textResponse('planning')] })
  74. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  75. const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  76. await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
  77. await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go plan' }] })
  78. // A writer other than the picker (exit_plan_mode's execute) appends the
  79. // flip back; the bridge must re-notify the client off the logged event.
  80. const agent = harness.ctx.agents.get(SessionId(sessionId))!
  81. agent.session.append('plan/mode', { active: false })
  82. // The notification crosses the in-memory JSON-RPC transport asynchronously.
  83. await new Promise(resolve => setTimeout(resolve, 20))
  84. expect(modeUpdates(harness.updates)).toEqual(['plan', 'default'])
  85. })
  86. it('advertises the folded mode on session/load', async () => {
  87. harness = await makeBridgeHarness({ storageDir, withModes: true, script: [textResponse('planning')] })
  88. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  89. const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  90. await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
  91. await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go plan' }] })
  92. await harness.dispose()
  93. harness = undefined
  94. loader = await makeBridgeHarness({ storageDir, withModes: true, script: [] })
  95. await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  96. const res = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
  97. expect(res.modes).toEqual({
  98. availableModes: [
  99. { id: 'default', name: 'default' },
  100. { id: 'plan', name: 'plan' },
  101. ],
  102. currentModeId: 'plan',
  103. })
  104. })
  105. })