approval.spec.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
  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 { CallId } from '@deepseek-ai/dsh-llm'
  7. import { type Agent } from '@deepseek-ai/dsh-agent'
  8. import ApprovalService, { type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
  9. import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
  10. import { SessionId } from '@deepseek-ai/dsh-session'
  11. /**
  12. * The bridge's `approval/request` answerer: an ask for an agent the bridge
  13. * owns becomes a `session/request_permission` prompt attached to the tool
  14. * call; foreign or call-less requests delegate down to the fail-closed
  15. * default. Driven through `ctx.approval` — the same path dsh-tools' ask
  16. * routing takes — against the harness's scriptable client.
  17. */
  18. describe('acp bridge — approval answerer', () => {
  19. let storageDir: string
  20. let harness: BridgeHarness | undefined
  21. beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-approval-')) })
  22. afterEach(async () => {
  23. await harness?.dispose()
  24. harness = undefined
  25. await rm(storageDir, { recursive: true, force: true })
  26. })
  27. async function ownedAgentRequest(
  28. h: BridgeHarness, overrides: Partial<ApprovalRequest> = {},
  29. ): Promise<{ agent: Agent; request: ApprovalRequest }> {
  30. await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  31. const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
  32. const agent = h.ctx.agents.get(SessionId(sessionId))
  33. if (agent === undefined) throw new Error('newSession created no agent')
  34. // In production an ask always fires mid-turn (tool execution); open one so
  35. // request()'s turn-enclosure precondition holds for the direct drive below.
  36. agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  37. return { agent, request: { agent, toolName: 'echo', callId: CallId('call-9'), ...overrides } }
  38. }
  39. it('prompts the editor for an owned agent and maps allow-once → allowed-once', async () => {
  40. harness = await makeBridgeHarness({ storageDir })
  41. await harness.ctx.plugin(ApprovalService)
  42. harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } })
  43. const { request } = await ownedAgentRequest(harness)
  44. await expect(harness.ctx.approval.request(request)).resolves.toBe('allowed-once')
  45. expect(harness.permissionRequests).toHaveLength(1)
  46. const wire = harness.permissionRequests[0]
  47. expect(wire?.toolCall).toEqual({ toolCallId: 'call-9' })
  48. expect(wire?.options.map(o => ({ optionId: o.optionId, kind: o.kind }))).toEqual([
  49. { optionId: 'allow-once', kind: 'allow_once' },
  50. { optionId: 'reject-once', kind: 'reject_once' },
  51. ])
  52. })
  53. it('maps reject-once → rejected', async () => {
  54. harness = await makeBridgeHarness({ storageDir })
  55. await harness.ctx.plugin(ApprovalService)
  56. harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'reject-once' } })
  57. const { request } = await ownedAgentRequest(harness)
  58. await expect(harness.ctx.approval.request(request)).resolves.toBe('rejected')
  59. })
  60. it('maps a client cancellation → cancelled', async () => {
  61. harness = await makeBridgeHarness({ storageDir })
  62. await harness.ctx.plugin(ApprovalService)
  63. harness.onPermission = () => ({ outcome: { outcome: 'cancelled' } })
  64. const { request } = await ownedAgentRequest(harness)
  65. await expect(harness.ctx.approval.request(request)).resolves.toBe('cancelled')
  66. })
  67. it('treats an unknown optionId from a non-conforming client as a rejection, never a grant', async () => {
  68. harness = await makeBridgeHarness({ storageDir })
  69. await harness.ctx.plugin(ApprovalService)
  70. harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-always-i-insist' } })
  71. const { request } = await ownedAgentRequest(harness)
  72. await expect(harness.ctx.approval.request(request)).resolves.toBe('rejected')
  73. })
  74. it('delegates a foreign agent down to the fail-closed default', async () => {
  75. harness = await makeBridgeHarness({ storageDir })
  76. await harness.ctx.plugin(ApprovalService)
  77. harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } })
  78. const { agent } = await ownedAgentRequest(harness)
  79. // Even an impostor that claims the bridge-owned session id must delegate:
  80. // ownership requires the exact Agent object stored in the session record.
  81. const foreign = {
  82. session: { id: agent.session.id, events: [{ type: 'turn/start' }], append: () => ({}) },
  83. } as unknown as Agent
  84. await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'echo', callId: CallId('c') }))
  85. .resolves.toBe('unavailable')
  86. expect(harness.permissionRequests).toHaveLength(0)
  87. })
  88. it('delegates a call-less request — the protocol prompt must attach to a tool call', async () => {
  89. harness = await makeBridgeHarness({ storageDir })
  90. await harness.ctx.plugin(ApprovalService)
  91. harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } })
  92. const { agent } = await ownedAgentRequest(harness)
  93. await expect(harness.ctx.approval.request({ agent, toolName: 'echo' })).resolves.toBe('unavailable')
  94. expect(harness.permissionRequests).toHaveLength(0)
  95. })
  96. })