api-proxy-command.spec.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. /**
  2. * Host-side slash-command dispatch in sessions.prompt: a leading-/
  3. * single-text-block prompt executes through the command registry and never
  4. * reaches the model — symmetric with the ACP adapter. Successful commands
  5. * return ok with the command slot; usage errors and unknown names return RPC
  6. * errors so the client restores the composer's draft. Non-command prompts
  7. * still route to agent.send/steer.
  8. */
  9. import { describe, expect, it } from 'vitest'
  10. import { Context } from 'cordis'
  11. import AgentRegistry from '@deepseek-ai/dsh-agent'
  12. import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent'
  13. import CommandService from '@deepseek-ai/dsh-commands'
  14. import GoalService from '@deepseek-ai/dsh-goal'
  15. import * as commandGoal from '@deepseek-ai/dsh-command-goal'
  16. import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
  17. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  18. import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  19. import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  20. import { createApiProxy } from '../src/api-proxy.ts'
  21. interface Harness {
  22. readonly ctx: Context
  23. readonly agent: Agent
  24. readonly session: Session
  25. /** Content arguments of every agent.send/steer call, in order. */
  26. readonly sent: ContentBlock[][]
  27. readonly steered: ContentBlock[][]
  28. }
  29. /** Number the next balanced injection turn. */
  30. function nextTurn(session: Session): number {
  31. return session.events.reduce(
  32. (maximum, event) => event.type === 'turn/start' ? Math.max(maximum, event.data.turn) : maximum,
  33. 0,
  34. ) + 1
  35. }
  36. /** Build a live idle agent whose send/steer calls are recorded. */
  37. function stubAgent(id: string): { agent: Agent; session: Session; sent: ContentBlock[][]; steered: ContentBlock[][] } {
  38. const session = new Session(SessionId(id))
  39. const sent: ContentBlock[][] = []
  40. const steered: ContentBlock[][] = []
  41. let status: AgentStatus = 'idle'
  42. const agent: Agent = {
  43. id: session.id,
  44. options: {},
  45. session,
  46. ctx: new Context(),
  47. get status() { return status },
  48. send(content) { sent.push(content) },
  49. steer(content) { steered.push(content) },
  50. inject(content: ContentBlock[], options?: InjectOptions) {
  51. const source: MessageSource = options?.source ?? { kind: 'user' }
  52. const turn = nextTurn(session)
  53. session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
  54. session.append('context/message', {
  55. content,
  56. source,
  57. ...options?.meta === undefined ? {} : { meta: options.meta },
  58. }, { surfaceOp: 'append' })
  59. session.append('turn/end', { turn, reason: { kind: 'completed' } })
  60. },
  61. cancel() { status = 'idle' },
  62. whenIdle() { return Promise.resolve() },
  63. }
  64. return { agent, session, sent, steered }
  65. }
  66. /** Mount the real command registry, goal domain, and /goal producer. */
  67. async function harness(): Promise<Harness> {
  68. const ctx = new Context()
  69. await ctx.plugin(CommandService)
  70. await ctx.plugin(AgentRegistry)
  71. await ctx.plugin(GoalService)
  72. await ctx.plugin(commandGoal)
  73. const { agent, session, sent, steered } = stubAgent(`api-proxy-command-${Math.random()}`)
  74. ctx.agents.register(agent)
  75. return { ctx, agent, session, sent, steered }
  76. }
  77. let nextRpc = 1
  78. function request<P>(payload: P): RpcRequest<P> {
  79. return { rpcId: RpcId(`command-${String(nextRpc++)}`), payload }
  80. }
  81. function promptPayload(test: Harness, text: string, mode: 'queue' | 'steer' = 'queue') {
  82. const content: ContentBlock[] = [{ type: 'text', text }]
  83. return request({ sessionId: test.session.id, mode, content })
  84. }
  85. describe('sessions.prompt slash-command dispatch', () => {
  86. it('executes /goal <objective>: goal created, command slot carried, no model turn', async () => {
  87. const test = await harness()
  88. const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
  89. const response = await api.sessions.prompt(promptPayload(test, '/goal fix the flaky test'))
  90. expect(response.result.ok).toBe(true)
  91. if (!response.result.ok) throw new Error('unreachable')
  92. expect(response.result.value.accepted).toBe(true)
  93. expect(response.result.value.command?.kind).toBe('success')
  94. expect(response.result.value.command?.text).toContain('Goal created')
  95. const goal = test.ctx.goals.get(test.agent)
  96. expect(goal?.objective).toBe('fix the flaky test')
  97. // The prompt never reached the model: no send, no user/message event.
  98. expect(test.sent).toEqual([])
  99. expect(test.steered).toEqual([])
  100. expect(test.session.events.filter(event => event.type === 'user/message')).toEqual([])
  101. })
  102. it('dispatches commands regardless of mode (steer prompt never steers)', async () => {
  103. const test = await harness()
  104. const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
  105. const response = await api.sessions.prompt(promptPayload(test, '/goal', 'steer'))
  106. expect(response.result.ok).toBe(true)
  107. if (!response.result.ok) throw new Error('unreachable')
  108. expect(response.result.value.command?.text).toContain('No goal is currently set')
  109. expect(test.sent).toEqual([])
  110. expect(test.steered).toEqual([])
  111. })
  112. it('returns unknown-command for an unregistered name', async () => {
  113. const test = await harness()
  114. const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
  115. const response = await api.sessions.prompt(promptPayload(test, '/bogus do something'))
  116. expect(response.result.ok).toBe(false)
  117. if (response.result.ok) throw new Error('unreachable')
  118. expect(response.result.error.code).toBe('unknown-command')
  119. expect(response.result.error.message).toBe('unknown command: /bogus')
  120. expect(test.sent).toEqual([])
  121. const bare = await api.sessions.prompt(promptPayload(test, '/bogus'))
  122. expect(bare.result.ok).toBe(false)
  123. if (!bare.result.ok) expect(bare.result.error.message).toBe('unknown command: /bogus')
  124. })
  125. it('carries a success without text when the command produced none', async () => {
  126. const test = await harness()
  127. const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
  128. test.ctx.commands.register({ name: 'ping', description: 'test no-text success', handler: () => ({ kind: 'success' }) })
  129. const response = await api.sessions.prompt(promptPayload(test, '/ping'))
  130. expect(response.result.ok).toBe(true)
  131. if (!response.result.ok) throw new Error('unreachable')
  132. expect(response.result.value.command).toEqual({ kind: 'success' })
  133. expect(test.sent).toEqual([])
  134. })
  135. it('returns command-error for a usage error (bare /goal edit)', async () => {
  136. const test = await harness()
  137. const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
  138. const response = await api.sessions.prompt(promptPayload(test, '/goal edit'))
  139. expect(response.result.ok).toBe(false)
  140. if (response.result.ok) throw new Error('unreachable')
  141. expect(response.result.error.code).toBe('command-error')
  142. expect(response.result.error.message).toContain('Goal editing requires a replacement objective')
  143. expect(test.sent).toEqual([])
  144. })
  145. it('routes a non-command prompt to agent.send unchanged', async () => {
  146. const test = await harness()
  147. const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
  148. const response = await api.sessions.prompt(promptPayload(test, 'hello there'))
  149. expect(response.result.ok).toBe(true)
  150. if (!response.result.ok) throw new Error('unreachable')
  151. expect(response.result.value.accepted).toBe(true)
  152. expect('command' in response.result.value).toBe(false)
  153. expect(test.sent).toEqual([[{ type: 'text', text: 'hello there' }]])
  154. })
  155. it('routes multi-block content starting with / to the model (never flattened)', async () => {
  156. const test = await harness()
  157. const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
  158. const content: ContentBlock[] = [{ type: 'text', text: '/goal not a command' }, { type: 'text', text: 'second' }]
  159. const response = await api.sessions.prompt(request({ sessionId: test.session.id, mode: 'queue' as const, content }))
  160. expect(response.result.ok).toBe(true)
  161. expect(test.sent).toEqual([content])
  162. })
  163. it('routes degenerate content shapes to the model (empty array, single non-text block)', async () => {
  164. const test = await harness()
  165. const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
  166. const empty: ContentBlock[] = []
  167. await api.sessions.prompt(request({ sessionId: test.session.id, mode: 'queue' as const, content: empty }))
  168. const nonText: ContentBlock[] = [{ type: 'reasoning', text: '/goal not a command' }]
  169. await api.sessions.prompt(request({ sessionId: test.session.id, mode: 'queue' as const, content: nonText }))
  170. expect(test.sent).toEqual([empty, nonText])
  171. })
  172. })