commands.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. import { afterEach, beforeEach, describe, expect, it, vi } 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 { encodeSessionReferenceUri } from '@deepseek-ai/dsh-session-reference'
  8. import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
  9. function commandUpdates(harness: BridgeHarness, sessionId: string) {
  10. return harness.sessionUpdates.filter(update => update.sessionId === sessionId
  11. && update.update.sessionUpdate === 'available_commands_update')
  12. }
  13. function messageText(harness: BridgeHarness, sessionId: string): string {
  14. return harness.sessionUpdates
  15. .filter(update => update.sessionId === sessionId && update.update.sessionUpdate === 'agent_message_chunk')
  16. .map(({ update }) => update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text'
  17. ? update.content.text : '')
  18. .join('')
  19. }
  20. describe('ACP plugin commands', () => {
  21. let storageDir: string
  22. let harness: BridgeHarness | undefined
  23. beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-command-')) })
  24. afterEach(async () => {
  25. if (harness !== undefined) await harness.dispose()
  26. harness = undefined
  27. await rm(storageDir, { recursive: true, force: true })
  28. })
  29. it('publishes a full command snapshot after session creation and refreshes it dynamically', async () => {
  30. harness = await makeBridgeHarness({ storageDir })
  31. harness.ctx.commands.register({
  32. name: 'inspect',
  33. description: 'Inspect the session',
  34. input: { hint: '<target>' },
  35. handler: () => ({ kind: 'success' }),
  36. })
  37. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  38. const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  39. await vi.waitFor(() => {
  40. expect(commandUpdates(harness!, sessionId).at(-1)?.update).toEqual({
  41. sessionUpdate: 'available_commands_update',
  42. availableCommands: [{
  43. name: 'inspect',
  44. description: 'Inspect the session',
  45. input: { hint: '<target>' },
  46. }],
  47. })
  48. })
  49. const dispose = harness.ctx.commands.register({
  50. name: 'alpha',
  51. description: 'Alpha command',
  52. handler: () => ({ kind: 'success' }),
  53. })
  54. await vi.waitFor(() => {
  55. expect(commandUpdates(harness!, sessionId).at(-1)?.update).toMatchObject({
  56. availableCommands: [{ name: 'alpha' }, { name: 'inspect' }],
  57. })
  58. })
  59. dispose()
  60. await vi.waitFor(() => {
  61. expect(commandUpdates(harness!, sessionId).at(-1)?.update).toMatchObject({
  62. availableCommands: [{ name: 'inspect' }],
  63. })
  64. })
  65. })
  66. it('re-advertises commands after loading a persisted session', async () => {
  67. const live = await makeBridgeHarness({ storageDir, script: [textResponse('persisted')] })
  68. await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  69. const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  70. await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'persist this session' }] })
  71. await live.dispose()
  72. harness = await makeBridgeHarness({ storageDir })
  73. harness.ctx.commands.register({
  74. name: 'loaded', description: 'Loaded command', handler: () => ({ kind: 'success' }),
  75. })
  76. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  77. await harness.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
  78. expect(commandUpdates(harness, sessionId).at(-1)?.update).toMatchObject({
  79. availableCommands: [{ name: 'loaded', description: 'Loaded command' }],
  80. })
  81. })
  82. it('coalesces registry changes before a new session command snapshot is announced', async () => {
  83. harness = await makeBridgeHarness({ storageDir })
  84. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  85. const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  86. harness.ctx.commands.register({
  87. name: 'raced', description: 'Registered after the response', handler: () => ({ kind: 'success' }),
  88. })
  89. await vi.waitFor(() => {
  90. expect(commandUpdates(harness!, sessionId)).toHaveLength(1)
  91. expect(commandUpdates(harness!, sessionId)[0]?.update).toMatchObject({
  92. availableCommands: [{ name: 'raced' }],
  93. })
  94. })
  95. })
  96. it('executes a known single-text command directly and never sends it to the model', async () => {
  97. harness = await makeBridgeHarness({ storageDir })
  98. const seen = vi.fn(() => ({ kind: 'success' as const, text: 'DIRECT RESULT' }))
  99. harness.ctx.commands.register({ name: 'direct', description: 'Run directly', handler: seen })
  100. harness.ctx.commands.register({
  101. name: 'silent', description: 'Return no text', handler: () => ({ kind: 'success' }),
  102. })
  103. harness.ctx.commands.register({
  104. name: 'empty', description: 'Return empty text', handler: () => ({ kind: 'success', text: '' }),
  105. })
  106. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  107. const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  108. const response = await harness.client.prompt({
  109. sessionId,
  110. prompt: [{ type: 'text', text: '/direct raw args ' }],
  111. })
  112. expect(response.stopReason).toBe('end_turn')
  113. expect(seen).toHaveBeenCalledWith(expect.objectContaining({ rawInput: ' raw args ' }))
  114. expect(messageText(harness, sessionId)).toContain('DIRECT RESULT')
  115. const updatesAfterText = harness.sessionUpdates.length
  116. await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/silent' }] })
  117. await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/empty' }] })
  118. expect(harness.sessionUpdates).toHaveLength(updatesAfterText)
  119. expect(harness.adapter.requests).toHaveLength(0)
  120. expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
  121. })
  122. it('renders expected command errors and rejects unknown slash commands without model fallback', async () => {
  123. harness = await makeBridgeHarness({ storageDir })
  124. harness.ctx.commands.register({
  125. name: 'denied',
  126. description: 'Deny directly',
  127. handler: () => ({ kind: 'error', text: 'not allowed now' }),
  128. })
  129. harness.ctx.commands.register({
  130. name: 'throws',
  131. description: 'Throw an ordinary error',
  132. handler: () => { throw new Error('handler exploded') },
  133. })
  134. harness.ctx.commands.register({
  135. name: 'hostile',
  136. description: 'Throw a hostile value',
  137. handler: () => {
  138. throw { toString(): string { throw new Error('coercion exploded') } }
  139. },
  140. })
  141. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  142. const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  143. await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/denied' }] }))
  144. .resolves.toEqual({ stopReason: 'end_turn' })
  145. await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/missing input' }] }))
  146. .resolves.toEqual({ stopReason: 'end_turn' })
  147. await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/throws' }] }))
  148. .resolves.toEqual({ stopReason: 'end_turn' })
  149. await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/hostile' }] }))
  150. .resolves.toEqual({ stopReason: 'end_turn' })
  151. expect(messageText(harness, sessionId)).toContain('Error: not allowed now')
  152. expect(messageText(harness, sessionId)).toContain('Error: unknown command: /missing input')
  153. expect(messageText(harness, sessionId)).toContain('Error: command failed: Error: handler exploded')
  154. expect(messageText(harness, sessionId)).toContain('Error: command failed: <unrenderable thrown value>')
  155. expect(harness.adapter.requests).toHaveLength(0)
  156. })
  157. it('flattens supported command prompt blocks without invoking the model', async () => {
  158. harness = await makeBridgeHarness({ storageDir })
  159. const command = vi.fn(() => ({ kind: 'success' as const, text: 'combined' }))
  160. harness.ctx.commands.register({ name: 'direct', description: 'Direct', handler: command })
  161. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  162. const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  163. await expect(harness.client.prompt({
  164. sessionId,
  165. prompt: [
  166. { type: 'text', text: '/direct' },
  167. { type: 'text', text: ' extra' },
  168. { type: 'resource_link', name: 'input', uri: 'file:///workspace/input.txt' },
  169. ],
  170. })).resolves.toEqual({ stopReason: 'end_turn' })
  171. expect(command).toHaveBeenCalledWith(expect.objectContaining({
  172. rawInput: ' extra\n[resource_link name="input" uri="file:///workspace/input.txt"]\n',
  173. }))
  174. expect(messageText(harness, sessionId)).toContain('combined')
  175. expect(harness.adapter.requests).toHaveLength(0)
  176. })
  177. it('keeps session-reference syntax opaque in direct command arguments', async () => {
  178. harness = await makeBridgeHarness({ storageDir })
  179. const command = vi.fn(() => ({ kind: 'success' as const }))
  180. harness.ctx.commands.register({ name: 'direct', description: 'Direct', handler: command })
  181. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  182. const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  183. const sourceUri = encodeSessionReferenceUri(SessionId('source'))
  184. await expect(harness.client.prompt({
  185. sessionId,
  186. prompt: [
  187. { type: 'text', text: `/direct valid=${sourceUri} malformed=dsh-session:IiJ` },
  188. { type: 'resource_link', name: 'source', uri: sourceUri },
  189. ],
  190. })).resolves.toEqual({ stopReason: 'end_turn' })
  191. expect(command).toHaveBeenCalledWith(expect.objectContaining({
  192. rawInput: ` valid=${sourceUri} malformed=dsh-session:IiJ\n[resource_link name="source" uri=${JSON.stringify(sourceUri)}]\n`,
  193. }))
  194. expect(harness.adapter.requests).toHaveLength(0)
  195. expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
  196. })
  197. it('maps session cancellation to the in-flight command signal and isolates other sessions', async () => {
  198. harness = await makeBridgeHarness({ storageDir })
  199. let started!: () => void
  200. const ready = new Promise<void>((resolve) => { started = resolve })
  201. harness.ctx.commands.register({
  202. name: 'wait',
  203. description: 'Wait for cancellation',
  204. handler: ({ signal }) => {
  205. started()
  206. return new Promise((resolve) => {
  207. signal.addEventListener('abort', () => { resolve({ kind: 'error', text: 'late abort result' }) }, { once: true })
  208. })
  209. },
  210. })
  211. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  212. const a = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  213. const b = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  214. const waiting = harness.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: '/wait' }] })
  215. await ready
  216. await expect(harness.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: '/wait' }] }))
  217. .rejects.toThrow(/already in flight/)
  218. await harness.client.cancel({ sessionId: a.sessionId })
  219. await expect(waiting).resolves.toEqual({ stopReason: 'cancelled' })
  220. await expect(harness.client.prompt({ sessionId: b.sessionId, prompt: [{ type: 'text', text: '/missing' }] }))
  221. .resolves.toEqual({ stopReason: 'end_turn' })
  222. expect(messageText(harness, a.sessionId)).not.toContain('late abort result')
  223. })
  224. it('aborts an in-flight command when the ACP bridge is disposed', async () => {
  225. harness = await makeBridgeHarness({ storageDir })
  226. let started!: () => void
  227. const ready = new Promise<void>((resolve) => { started = resolve })
  228. let commandSignal: AbortSignal | undefined
  229. harness.ctx.commands.register({
  230. name: 'wait-dispose',
  231. description: 'Wait for bridge disposal',
  232. handler: ({ signal }) => {
  233. commandSignal = signal
  234. started()
  235. return new Promise<never>(() => {})
  236. },
  237. })
  238. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  239. const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  240. const waiting = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/wait-dispose' }] })
  241. await ready
  242. await harness.acpFiber.dispose()
  243. expect(commandSignal?.aborted).toBe(true)
  244. await expect(waiting).resolves.toEqual({ stopReason: 'cancelled' })
  245. })
  246. it('resolves scoped command catalogs and execution independently per session', async () => {
  247. harness = await makeBridgeHarness({ storageDir })
  248. await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  249. const a = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  250. const b = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  251. const agentA = harness.ctx.agents.get(SessionId(a.sessionId))
  252. if (agentA === undefined) throw new Error('session A has no agent')
  253. await agentA.ctx.inject(['commands'], (commandCtx) => {
  254. commandCtx.commands.register({
  255. name: 'private', description: 'Only session A',
  256. handler: () => ({ kind: 'success', text: 'A ONLY' }),
  257. })
  258. })
  259. await vi.waitFor(() => {
  260. expect(commandUpdates(harness!, a.sessionId).at(-1)?.update).toMatchObject({ availableCommands: [{ name: 'private' }] })
  261. })
  262. expect(commandUpdates(harness, b.sessionId).at(-1)?.update).toMatchObject({ availableCommands: [] })
  263. await harness.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: '/private' }] })
  264. await harness.client.prompt({ sessionId: b.sessionId, prompt: [{ type: 'text', text: '/private' }] })
  265. expect(messageText(harness, a.sessionId)).toContain('A ONLY')
  266. expect(messageText(harness, b.sessionId)).toContain('unknown command')
  267. })
  268. })