api-proxy-commands.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. /**
  2. * Command/skill RPC handlers and the two new frames over createApiProxy:
  3. * command.list serves the addressed agent's effective catalog (missing
  4. * registry = loud internal error), command.execute dispatches through the
  5. * registry with the carrier signal, skill.list resolves cwd from the session
  6. * header (never via the Agent registry), the host stream broadcasts
  7. * commands-changed, and the mux stream carries live queued frames plus the
  8. * open-time queue snapshot.
  9. */
  10. import { describe, expect, it } from 'vitest'
  11. import { Context } from 'cordis'
  12. import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent'
  13. import type { Agent, AgentMessage } from '@deepseek-ai/dsh-agent'
  14. import SessionStore from '@deepseek-ai/dsh-session'
  15. import type { SessionId } from '@deepseek-ai/dsh-session'
  16. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  17. import ToolRegistry from '@deepseek-ai/dsh-tools'
  18. import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
  19. import CommandService from '@deepseek-ai/dsh-commands'
  20. import SkillService from '@deepseek-ai/dsh-skill'
  21. import type { HostFrame, MuxFrame } from '../src/api/index.ts'
  22. import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
  23. import { RpcId } from '../src/api/rpc.ts'
  24. import { createApiProxy } from '../src/api-proxy.ts'
  25. const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }
  26. function request<P>(payload: P): RpcRequest<P> {
  27. return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
  28. }
  29. let nextRpc = 1
  30. function expectOk<T>(response: RpcResponse<T>): T {
  31. expect(response.result.ok).toBe(true)
  32. if (!response.result.ok) throw new Error('unreachable')
  33. return response.result.value
  34. }
  35. function expectErr<T>(response: RpcResponse<T>): { code: string; message: string } {
  36. expect(response.result.ok).toBe(false)
  37. if (response.result.ok) throw new Error('unreachable')
  38. return response.result.error
  39. }
  40. /** Composition floor for the command/skill paths (no LLM, no persistence). */
  41. async function harness(options: { commands?: boolean; skills?: boolean } = {}): Promise<Context> {
  42. const ctx = new Context()
  43. await ctx.plugin(SessionStore)
  44. await ctx.plugin(SystemPrompt, { persona: '' })
  45. await ctx.plugin(ToolRegistry)
  46. await ctx.plugin(UserInteractionService)
  47. await ctx.plugin(AgentRegistry)
  48. if (options.skills !== false) await ctx.plugin(SkillService, {})
  49. if (options.commands !== false) await ctx.plugin(CommandService)
  50. // Host-stream opener reads the committed-workspace baseline; the stub
  51. // suffices here — the real workspace composition is api-proxy-workspace.spec's.
  52. ctx.provide('workspace', { list: () => [] } as never)
  53. return ctx
  54. }
  55. /** Register a live structural agent stub (api-proxy-view precedent: only id/session/status/ctx are read). */
  56. function stubAgent(ctx: Context, sessionId?: SessionId): Agent {
  57. const session = ctx.sessions.create(sessionId)
  58. const agent = { id: session.id, session, status: 'idle', ctx } as Agent
  59. ctx.agents.register(agent)
  60. return agent
  61. }
  62. /** Drain `count` frames from a stream, then abort it. */
  63. async function collect<F>(iterable: AsyncIterable<RpcRequest<F>>, count: number, abort: AbortController): Promise<F[]> {
  64. const frames: F[] = []
  65. for await (const frame of iterable) {
  66. frames.push(frame.payload)
  67. if (frames.length >= count) abort.abort()
  68. }
  69. return frames
  70. }
  71. describe('command.list', () => {
  72. it('serves the addressed agent\'s name-sorted catalog', async () => {
  73. const ctx = await harness()
  74. ctx.commands.register({ name: 'zeta', description: 'z', handler: () => ({ kind: 'success' }) })
  75. ctx.commands.register({ name: 'alpha', description: 'a', input: { hint: '<x>' }, handler: () => ({ kind: 'success' }) })
  76. const api = createApiProxy(ctx, DEFAULTS)
  77. const agent = stubAgent(ctx)
  78. const value = expectOk(await api.commands.list(request({ sessionId: agent.id })))
  79. expect(value.commands).toEqual([
  80. { name: 'alpha', description: 'a', input: { hint: '<x>' } },
  81. { name: 'zeta', description: 'z' },
  82. ])
  83. })
  84. it('fails loud with internal when the command registry is not mounted', async () => {
  85. const ctx = await harness({ commands: false })
  86. const api = createApiProxy(ctx, DEFAULTS)
  87. const error = expectErr(await api.commands.list(request({ sessionId: 's' as SessionId })))
  88. expect(error.code).toBe('internal')
  89. expect(error.message).toContain('command registry')
  90. })
  91. })
  92. describe('command.execute', () => {
  93. it('executes a known command against the addressed agent and detaches the result', async () => {
  94. const ctx = await harness()
  95. let received: string | undefined
  96. ctx.commands.register({
  97. name: 'goal',
  98. description: 'set goal',
  99. handler: (invocation) => {
  100. received = invocation.rawInput
  101. return { kind: 'success', text: `goal:${invocation.agent.id}` }
  102. },
  103. })
  104. const api = createApiProxy(ctx, DEFAULTS)
  105. const agent = stubAgent(ctx)
  106. const value = expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/goal ship it' }), new AbortController().signal))
  107. expect(value).toEqual({ matched: true, result: { kind: 'success', text: `goal:${agent.id}` } })
  108. expect(received).toBe(' ship it')
  109. })
  110. it('returns matched:false when syntax or name does not resolve', async () => {
  111. const ctx = await harness()
  112. const api = createApiProxy(ctx, DEFAULTS)
  113. const agent = stubAgent(ctx)
  114. const signal = new AbortController().signal
  115. expect(expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/unknown' }), signal))).toEqual({ matched: false })
  116. expect(expectOk(await api.commands.execute(request({ sessionId: agent.id, line: 'not a command' }), signal))).toEqual({ matched: false })
  117. })
  118. it('maps a session miss to session-not-found and a registry gap to internal', async () => {
  119. const ctx = await harness()
  120. const api = createApiProxy(ctx, DEFAULTS)
  121. const missing = expectErr(await api.commands.execute(
  122. request({ sessionId: 'session-nope' as SessionId, line: '/x' }), new AbortController().signal))
  123. expect(missing.code).toBe('internal') // no persistence configured: resume fails loud past the gate
  124. const bare = await harness({ commands: false })
  125. const bareApi = createApiProxy(bare, DEFAULTS)
  126. expect(expectErr(await bareApi.commands.execute(
  127. request({ sessionId: 's' as SessionId, line: '/x' }), new AbortController().signal)).code).toBe('internal')
  128. })
  129. it('reports an aborted handler as cancelled and a throwing handler as internal', async () => {
  130. const ctx = await harness()
  131. ctx.commands.register({
  132. name: 'hang',
  133. description: 'never settles on its own',
  134. handler: () => new Promise(() => { /* settled only by abort */ }),
  135. })
  136. ctx.commands.register({
  137. name: 'boom',
  138. description: 'throws',
  139. handler: () => { throw new Error('kaboom') },
  140. })
  141. const api = createApiProxy(ctx, DEFAULTS)
  142. const agent = stubAgent(ctx)
  143. const controller = new AbortController()
  144. const pending = api.commands.execute(request({ sessionId: agent.id, line: '/hang' }), controller.signal)
  145. controller.abort()
  146. expect(expectErr(await pending).code).toBe('cancelled')
  147. const thrown = expectErr(await api.commands.execute(request({ sessionId: agent.id, line: '/boom' }), new AbortController().signal))
  148. expect(thrown.code).toBe('internal')
  149. expect(thrown.message).toContain('kaboom')
  150. })
  151. })
  152. describe('skill.list', () => {
  153. it('lists skills for the session cwd taken from the header', async () => {
  154. const ctx = await harness()
  155. const seenCwds: (string | undefined)[] = []
  156. ctx.skills.registerProvider({
  157. name: 'probe',
  158. list: (options) => {
  159. seenCwds.push(options.cwd)
  160. return Promise.resolve([{
  161. name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing',
  162. source: 'custom', provider: 'probe', rank: 0, locator: null,
  163. }])
  164. },
  165. get: () => Promise.resolve(undefined),
  166. })
  167. const api = createApiProxy(ctx, DEFAULTS)
  168. // No agent is registered for this session: header resolution must not
  169. // touch (or resume through) the Agent registry.
  170. const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
  171. const value = expectOk(await api.skills.list(request({ sessionId: session.id })))
  172. expect(value.skills).toEqual([{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' }])
  173. expect(seenCwds).toEqual(['/proj'])
  174. expect(ctx.agents.get(session.id)).toBeUndefined()
  175. })
  176. it('fails loud on an unattached session id (business error, no resume attempt)', async () => {
  177. const ctx = await harness()
  178. const api = createApiProxy(ctx, DEFAULTS)
  179. const error = expectErr(await api.skills.list(request({ sessionId: 'session-cold' as SessionId })))
  180. expect(error.code).toBe('session-not-found')
  181. })
  182. it('fails loud with internal when the skill registry is not mounted', async () => {
  183. const ctx = await harness({ skills: false })
  184. const api = createApiProxy(ctx, DEFAULTS)
  185. const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
  186. const error = expectErr(await api.skills.list(request({ sessionId: session.id })))
  187. expect(error.code).toBe('internal')
  188. expect(error.message).toContain('skill registry is absent')
  189. })
  190. it('folds a provider failure into internal', async () => {
  191. const ctx = await harness()
  192. ctx.skills.registerProvider({
  193. name: 'broken',
  194. list: () => Promise.reject(new Error('directory exploded')),
  195. get: () => Promise.resolve(undefined),
  196. })
  197. const api = createApiProxy(ctx, DEFAULTS)
  198. const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
  199. const response = await api.skills.list(request({ sessionId: session.id }))
  200. // dsh-skill contains one provider's failure (logs and serves the rest), so
  201. // this surfaces as an empty ok catalog rather than an error.
  202. const value = expectOk(response)
  203. expect(value.skills).toEqual([])
  204. })
  205. })
  206. describe('host/commands-changed frame', () => {
  207. it('broadcasts on registry change', async () => {
  208. const ctx = await harness()
  209. const api = createApiProxy(ctx, DEFAULTS)
  210. const abort = new AbortController()
  211. const stream = api.events.host({ rpcId: RpcId('t-host'), payload: {} }, abort.signal)
  212. const collected = collect<HostFrame>(stream, 1, abort)
  213. ctx.commands.register({ name: 'late', description: 'l', handler: () => ({ kind: 'success' }) })
  214. expect(await collected).toEqual([{ type: 'host/commands-changed' }])
  215. })
  216. })
  217. /** Build one frozen inbox message for the live `agent/inbox/*` events. */
  218. function inboxMessage(id: string, text: string, rpcId?: string): AgentMessage {
  219. return Object.freeze({
  220. id: AgentMessageId(id),
  221. content: [{ type: 'text' as const, text }],
  222. source: rpcId === undefined ? { kind: 'user' as const } : { kind: 'user' as const, rpcId: RpcId(rpcId) },
  223. })
  224. }
  225. describe('session/queued frames', () => {
  226. it('forwards live enqueue events and replays the snapshot on a later mux open', async () => {
  227. const ctx = await harness()
  228. const api = createApiProxy(ctx, DEFAULTS)
  229. const agent = stubAgent(ctx)
  230. const live = new AbortController()
  231. const liveStream = api.events.mux({ rpcId: RpcId('t-mux-live'), payload: {} }, live.signal)
  232. // subscribed baseline + 2 queued frames
  233. const liveCollected = collect<MuxFrame>(liveStream, 3, live)
  234. const queued = inboxMessage('m-1', 'queued prompt')
  235. const steering = inboxMessage('m-2', 'queued prompt')
  236. ctx.emit('agent/inbox/enqueue', agent, queued, 'queued')
  237. ctx.emit('agent/inbox/enqueue', agent, steering, 'steering')
  238. const liveFrames = (await liveCollected).filter(f => f.type === 'session/queued')
  239. expect(liveFrames).toEqual([
  240. { type: 'session/queued', sessionId: agent.id, content: queued.content, source: { kind: 'user' }, steering: false },
  241. { type: 'session/queued', sessionId: agent.id, content: steering.content, source: { kind: 'user' }, steering: true },
  242. ])
  243. // A fresh mux connection replays the still-pending entries as its baseline.
  244. const replay = new AbortController()
  245. const replayFrames = await collect<MuxFrame>(
  246. api.events.mux({ rpcId: RpcId('t-mux-replay'), payload: {} }, replay.signal), 3, replay)
  247. expect(replayFrames.filter(f => f.type === 'session/queued')).toEqual(liveFrames)
  248. })
  249. it('retires mirror entries on their terminal dequeue', async () => {
  250. const ctx = await harness()
  251. const api = createApiProxy(ctx, DEFAULTS)
  252. const agent = stubAgent(ctx)
  253. const queued = inboxMessage('m-3', 'x')
  254. const steering = inboxMessage('m-4', 'x', 'r-1')
  255. ctx.emit('agent/inbox/enqueue', agent, queued, 'queued')
  256. ctx.emit('agent/inbox/enqueue', agent, steering, 'steering')
  257. ctx.emit('agent/inbox/dequeue', agent, queued)
  258. ctx.emit('agent/inbox/dequeue', agent, steering)
  259. const abort = new AbortController()
  260. const frames = await collect<MuxFrame>(
  261. api.events.mux({ rpcId: RpcId('t-mux-after'), payload: {} }, abort.signal), 1, abort)
  262. expect(frames.filter(f => f.type === 'session/queued')).toHaveLength(0)
  263. })
  264. it('retires mirror entries on a batch discard (cancel path)', async () => {
  265. const ctx = await harness()
  266. const api = createApiProxy(ctx, DEFAULTS)
  267. const agent = stubAgent(ctx)
  268. const doomed = inboxMessage('m-5', 'doomed')
  269. const survivor = inboxMessage('m-6', 'survivor')
  270. ctx.emit('agent/inbox/enqueue', agent, doomed, 'queued')
  271. ctx.emit('agent/inbox/enqueue', agent, survivor, 'queued')
  272. ctx.emit('agent/inbox/discard', agent, [doomed])
  273. const abort = new AbortController()
  274. const frames = await collect<MuxFrame>(
  275. api.events.mux({ rpcId: RpcId('t-mux-swept'), payload: {} }, abort.signal), 2, abort)
  276. const remaining = frames.filter(f => f.type === 'session/queued')
  277. expect(remaining).toHaveLength(1)
  278. expect(remaining[0]).toMatchObject({ content: survivor.content })
  279. })
  280. })