api-proxy-commands.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm'
  2. /**
  3. * Command/skill RPC handlers and the two new frames over createApiProxy:
  4. * command.list serves the addressed agent's effective catalog (missing
  5. * registry = loud internal error), command.execute dispatches through the
  6. * registry with the carrier signal, skill.list resolves cwd from the session
  7. * header (never via the Agent registry), the host stream broadcasts
  8. * commands-changed, and the mux stream carries live queued frames plus the
  9. * open-time queue snapshot.
  10. */
  11. import { describe, expect, it, vi } from 'vitest'
  12. import { Context } from 'cordis'
  13. import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
  14. import type { Agent } from '@deepseek-ai/dsh-agent'
  15. import SessionStore from '@deepseek-ai/dsh-session'
  16. import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session'
  17. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  18. import ToolRegistry from '@deepseek-ai/dsh-tools'
  19. import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
  20. import CommandService from '@deepseek-ai/dsh-commands'
  21. import SkillService from '@deepseek-ai/dsh-skill'
  22. import type { HostFrame } from '../src/api/index.ts'
  23. import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
  24. import { RpcId } from '../src/api/rpc.ts'
  25. import { createApiProxy } from '../src/api-proxy.ts'
  26. const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }
  27. function request<P>(payload: P): RpcRequest<P> {
  28. return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
  29. }
  30. let nextRpc = 1
  31. function expectOk<T>(response: RpcResponse<T>): T {
  32. expect(response.result.ok).toBe(true)
  33. if (!response.result.ok) throw new Error('unreachable')
  34. return response.result.value
  35. }
  36. function expectErr<T>(response: RpcResponse<T>): { code: string; message: string } {
  37. expect(response.result.ok).toBe(false)
  38. if (response.result.ok) throw new Error('unreachable')
  39. return response.result.error
  40. }
  41. /** Composition floor for the command/skill paths (no LLM, no persistence). */
  42. async function harness(options: { commands?: boolean; skills?: boolean } = {}): Promise<Context> {
  43. const ctx = new Context()
  44. await ctx.plugin(SessionStore)
  45. await ctx.plugin(SystemPrompt, { persona: '' })
  46. await ctx.plugin(ToolRegistry)
  47. await ctx.plugin(UserInteractionService)
  48. await ctx.plugin(AgentRegistry)
  49. if (options.skills !== false) await ctx.plugin(SkillService, {})
  50. if (options.commands !== false) await ctx.plugin(CommandService)
  51. // Host-stream opener reads the committed-workspace baseline; the stub
  52. // suffices here — the real workspace composition is api-proxy-workspace.spec's.
  53. ctx.provide('workspace', { list: () => [] } as never)
  54. return ctx
  55. }
  56. /** Register a live structural agent stub (api-proxy-view precedent: only id/session/status/ctx are read). */
  57. function stubAgent(ctx: Context, sessionId?: SessionId): Agent {
  58. const session = ctx.sessions.create(sessionId)
  59. const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
  60. const agent = {
  61. id: session.id,
  62. session,
  63. inbox,
  64. status: 'idle',
  65. ctx,
  66. } as Agent
  67. ctx.agents.register(agent)
  68. return agent
  69. }
  70. /** Drain `count` frames from a stream, then abort it. */
  71. async function collect<F>(iterable: AsyncIterable<RpcRequest<F>>, count: number, abort: AbortController): Promise<F[]> {
  72. const frames: F[] = []
  73. for await (const frame of iterable) {
  74. frames.push(frame.payload)
  75. if (frames.length >= count) abort.abort()
  76. }
  77. return frames
  78. }
  79. /** Read the next payload from an open stream. */
  80. async function nextFrame<F>(iterator: AsyncIterator<RpcRequest<F>>): Promise<F> {
  81. const result = await iterator.next()
  82. if (result.done) throw new Error('stream ended')
  83. return result.value.payload
  84. }
  85. describe('command.list', () => {
  86. it('serves the addressed agent\'s name-sorted catalog', async () => {
  87. const ctx = await harness()
  88. ctx.commands.register({ name: 'zeta', description: 'z', handler: () => ({ kind: 'success' }) })
  89. ctx.commands.register({ name: 'alpha', description: 'a', input: { hint: '<x>' }, handler: () => ({ kind: 'success' }) })
  90. const api = createApiProxy(ctx, DEFAULTS)
  91. const agent = stubAgent(ctx)
  92. const value = expectOk(await api.commands.list(request({ sessionId: agent.id })))
  93. expect(value.commands).toEqual([
  94. { name: 'alpha', description: 'a', input: { hint: '<x>' } },
  95. { name: 'zeta', description: 'z' },
  96. ])
  97. })
  98. it('fails loud with internal when the command registry is not mounted', async () => {
  99. const ctx = await harness({ commands: false })
  100. const api = createApiProxy(ctx, DEFAULTS)
  101. const error = expectErr(await api.commands.list(request({ sessionId: 's' as SessionId })))
  102. expect(error.code).toBe('internal')
  103. expect(error.message).toContain('command registry')
  104. })
  105. })
  106. describe('command.execute', () => {
  107. it('executes a known command against the addressed agent and detaches the result', async () => {
  108. const ctx = await harness()
  109. let received: string | undefined
  110. ctx.commands.register({
  111. name: 'goal',
  112. description: 'set goal',
  113. handler: (invocation) => {
  114. received = invocation.rawInput
  115. return { kind: 'success', text: `goal:${invocation.agent.id}` }
  116. },
  117. })
  118. const api = createApiProxy(ctx, DEFAULTS)
  119. const agent = stubAgent(ctx)
  120. const value = expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/goal ship it' }), new AbortController().signal))
  121. expect(value).toMatchObject({ matched: true })
  122. expect(value.commandId).toBeTruthy()
  123. expect(received).toBe(' ship it')
  124. // Pure admission on the wire: the outcome rides the durably logged
  125. // lifecycle pair instead of the response.
  126. const lifecycle = agent.session.events.filter(e => e.type === 'command/run' || e.type === 'command/done')
  127. expect(lifecycle).toMatchObject([
  128. { type: 'command/run', data: { commandId: value.commandId, name: 'goal', args: ' ship it' } },
  129. { type: 'command/done', data: { commandId: value.commandId, kind: 'success', text: `goal:${agent.id}` } },
  130. ])
  131. })
  132. it('returns matched:false when syntax or name does not resolve', async () => {
  133. const ctx = await harness()
  134. const api = createApiProxy(ctx, DEFAULTS)
  135. const agent = stubAgent(ctx)
  136. const signal = new AbortController().signal
  137. expect(expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/unknown' }), signal))).toEqual({ matched: false })
  138. expect(expectOk(await api.commands.execute(request({ sessionId: agent.id, line: 'not a command' }), signal))).toEqual({ matched: false })
  139. })
  140. it('maps a session miss to session-not-found and a registry gap to internal', async () => {
  141. const ctx = await harness()
  142. const api = createApiProxy(ctx, DEFAULTS)
  143. const missing = expectErr(await api.commands.execute(
  144. request({ sessionId: 'session-nope' as SessionId, line: '/x' }), new AbortController().signal))
  145. expect(missing.code).toBe('internal') // no persistence configured: resume fails loud past the gate
  146. const bare = await harness({ commands: false })
  147. const bareApi = createApiProxy(bare, DEFAULTS)
  148. expect(expectErr(await bareApi.commands.execute(
  149. request({ sessionId: 's' as SessionId, line: '/x' }), new AbortController().signal)).code).toBe('internal')
  150. })
  151. it('reports an aborted handler as cancelled and a throwing handler as internal', async () => {
  152. const ctx = await harness()
  153. ctx.commands.register({
  154. name: 'hang',
  155. description: 'never settles on its own',
  156. handler: () => new Promise(() => { /* settled only by abort */ }),
  157. })
  158. ctx.commands.register({
  159. name: 'boom',
  160. description: 'throws',
  161. handler: () => { throw new Error('kaboom') },
  162. })
  163. const api = createApiProxy(ctx, DEFAULTS)
  164. const agent = stubAgent(ctx)
  165. const controller = new AbortController()
  166. const pending = api.commands.execute(request({ sessionId: agent.id, line: '/hang' }), controller.signal)
  167. controller.abort()
  168. expect(expectErr(await pending).code).toBe('cancelled')
  169. const thrown = expectErr(await api.commands.execute(request({ sessionId: agent.id, line: '/boom' }), new AbortController().signal))
  170. expect(thrown.code).toBe('internal')
  171. expect(thrown.message).toContain('kaboom')
  172. })
  173. })
  174. describe('skill.list', () => {
  175. it('lists skills for the session cwd taken from the header', async () => {
  176. const ctx = await harness()
  177. const seenCwds: (string | undefined)[] = []
  178. ctx.skills.registerProvider(() => ({
  179. name: 'probe',
  180. list: (options) => {
  181. seenCwds.push(options.cwd)
  182. return Promise.resolve([
  183. {
  184. name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing',
  185. invocation: { modelInvocable: true, userInvocable: true },
  186. source: 'custom', provider: 'probe', rank: 0, locator: null,
  187. },
  188. {
  189. name: 'user-only', description: 'User-only',
  190. invocation: { modelInvocable: false, userInvocable: true },
  191. source: 'custom', provider: 'probe', rank: 0, locator: null,
  192. },
  193. {
  194. name: 'model-only', description: 'Model-only',
  195. invocation: { modelInvocable: true, userInvocable: false },
  196. source: 'custom', provider: 'probe', rank: 0, locator: null,
  197. },
  198. {
  199. name: 'trusted-only', description: 'Trusted-only',
  200. invocation: { modelInvocable: false, userInvocable: false },
  201. source: 'custom', provider: 'probe', rank: 0, locator: null,
  202. },
  203. ])
  204. },
  205. get: () => Promise.resolve(undefined),
  206. }))
  207. const api = createApiProxy(ctx, DEFAULTS)
  208. // No agent is registered for this session: header resolution must not
  209. // touch (or resume through) the Agent registry.
  210. const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
  211. const value = expectOk(await api.skills.list(request({ sessionId: session.id })))
  212. expect(value.skills).toEqual([{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' }])
  213. expect(seenCwds).toEqual(['/proj'])
  214. expect(ctx.agents.get(session.id)).toBeUndefined()
  215. })
  216. it('fails loud on an unattached session id (business error, no resume attempt)', async () => {
  217. const ctx = await harness()
  218. const api = createApiProxy(ctx, DEFAULTS)
  219. const error = expectErr(await api.skills.list(request({ sessionId: 'session-cold' as SessionId })))
  220. expect(error.code).toBe('session-not-found')
  221. })
  222. it('fails loud with internal when the skill registry is not mounted', async () => {
  223. const ctx = await harness({ skills: false })
  224. const api = createApiProxy(ctx, DEFAULTS)
  225. const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
  226. const error = expectErr(await api.skills.list(request({ sessionId: session.id })))
  227. expect(error.code).toBe('internal')
  228. expect(error.message).toContain('skill registry is absent')
  229. })
  230. it('folds a provider failure into internal', async () => {
  231. const ctx = await harness()
  232. ctx.skills.registerProvider(() => ({
  233. name: 'broken',
  234. list: () => Promise.reject(new Error('directory exploded')),
  235. get: () => Promise.resolve(undefined),
  236. }))
  237. const api = createApiProxy(ctx, DEFAULTS)
  238. const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
  239. const response = await api.skills.list(request({ sessionId: session.id }))
  240. // dsh-skill contains one provider's failure (logs and serves the rest), so
  241. // this surfaces as an empty ok catalog rather than an error.
  242. const value = expectOk(response)
  243. expect(value.skills).toEqual([])
  244. })
  245. })
  246. describe('host/commands-changed frame', () => {
  247. it('broadcasts on registry change', async () => {
  248. const ctx = await harness()
  249. const api = createApiProxy(ctx, DEFAULTS)
  250. const abort = new AbortController()
  251. const stream = api.events.host({ rpcId: RpcId('t-host'), payload: {} }, abort.signal)
  252. const collected = collect<HostFrame>(stream, 1, abort)
  253. ctx.commands.register({ name: 'late', description: 'l', handler: () => ({ kind: 'success' }) })
  254. expect(await collected).toEqual([{ type: 'host/commands-changed' }])
  255. })
  256. })
  257. /** Build one frozen inbox message. */
  258. function inboxMessage(id: string, text: string, rpcId?: string): UserMessage {
  259. return freezeMessage({
  260. id: MessageId(id),
  261. role: 'user',
  262. content: [{ type: 'text' as const, text }],
  263. source: rpcId === undefined ? { kind: 'user' as const } : { kind: 'user' as const, rpcId: RpcId(rpcId) },
  264. })
  265. }
  266. describe('session.updateQueue', () => {
  267. it('splices a queued message and reports a lost claim race', async () => {
  268. const ctx = await harness()
  269. const agent = stubAgent(ctx)
  270. const present = inboxMessage('present', 'before')
  271. agent.inbox.splice('next-turn', 0, 0, [present])
  272. const api = createApiProxy(ctx, DEFAULTS)
  273. const applied = await api.sessions.updateQueue({
  274. rpcId: RpcId('q-apply'),
  275. payload: {
  276. sessionId: agent.id,
  277. itemId: MessageId('present'),
  278. action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] },
  279. },
  280. })
  281. expect(expectOk(applied)).toEqual({ accepted: true })
  282. const missing = await api.sessions.updateQueue({
  283. rpcId: RpcId('q-missing'),
  284. payload: {
  285. sessionId: agent.id,
  286. itemId: MessageId('claimed'),
  287. action: { kind: 'remove' },
  288. },
  289. })
  290. expect(expectErr(missing)).toMatchObject({ code: 'queue-item-not-found' })
  291. expect(agent.inbox.nextTurn[0]).toMatchObject({
  292. id: 'present',
  293. content: [{ type: 'text', text: 'edited' }],
  294. })
  295. })
  296. it('rejects a stale occurrence without resuming a cold agent', async () => {
  297. const ctx = await harness()
  298. const resume = vi.spyOn(ctx.agents, 'resume')
  299. const api = createApiProxy(ctx, DEFAULTS)
  300. const response = await api.sessions.updateQueue({
  301. rpcId: RpcId('q-cold'),
  302. payload: {
  303. sessionId: 'cold-session' as SessionId,
  304. itemId: MessageId('stale-item'),
  305. action: { kind: 'remove' },
  306. },
  307. })
  308. expect(expectErr(response)).toMatchObject({ code: 'queue-item-not-found' })
  309. expect(resume).not.toHaveBeenCalled()
  310. })
  311. })
  312. describe('session/queue frames', () => {
  313. it('publishes authoritative inbox snapshots without duplicating message identity', async () => {
  314. const ctx = await harness()
  315. const api = createApiProxy(ctx, DEFAULTS)
  316. const agent = stubAgent(ctx)
  317. const queued = inboxMessage('m-1', 'queued prompt')
  318. const edited = inboxMessage('m-1', 'edited prompt')
  319. const steering = inboxMessage('m-2', 'steering prompt')
  320. agent.inbox.splice('next-turn', 0, 0, [queued])
  321. agent.inbox.splice('next-step', 0, 0, [steering])
  322. const abort = new AbortController()
  323. const iterator = api.events.mux({
  324. rpcId: RpcId('t-mux-baseline'),
  325. payload: {},
  326. }, abort.signal)[Symbol.asyncIterator]()
  327. const frames = [
  328. await nextFrame(iterator),
  329. await nextFrame(iterator),
  330. ]
  331. agent.inbox.splice('next-turn', 0, 1, [edited])
  332. frames.push(await nextFrame(iterator), await nextFrame(iterator))
  333. const injected = freezeMessage({
  334. id: MessageId('m-3'),
  335. role: 'user',
  336. content: [{ type: 'text' as const, text: 'injected context' }],
  337. source: { kind: 'plugin' as const, plugin: 'approval' },
  338. })
  339. agent.inbox.splice('next-step', 0, 0, [injected])
  340. frames.push(await nextFrame(iterator), await nextFrame(iterator))
  341. abort.abort()
  342. await iterator.return?.()
  343. expect(frames.filter(frame => frame.type === 'session/queue')).toEqual([
  344. {
  345. type: 'session/queue',
  346. sessionId: agent.id,
  347. items: [
  348. { id: queued.id, placement: 'queued', message: queued },
  349. { id: steering.id, placement: 'steering', message: steering },
  350. ],
  351. },
  352. {
  353. type: 'session/queue',
  354. sessionId: agent.id,
  355. items: [
  356. { id: edited.id, placement: 'queued', message: edited },
  357. { id: steering.id, placement: 'steering', message: steering },
  358. ],
  359. },
  360. {
  361. type: 'session/queue',
  362. sessionId: agent.id,
  363. items: [
  364. { id: edited.id, placement: 'queued', message: edited },
  365. { id: injected.id, placement: 'context', message: injected },
  366. { id: steering.id, placement: 'steering', message: steering },
  367. ],
  368. },
  369. ])
  370. })
  371. })