| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516 |
- import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm'
- /**
- * Command/skill RPC handlers and the two new frames over createApiProxy:
- * command.list serves the addressed agent's effective catalog (missing
- * registry = loud internal error), command.execute dispatches through the
- * registry with the carrier signal, skill.list resolves cwd from the session
- * header (never via the Agent registry), the host stream broadcasts
- * commands-changed, and the mux stream carries live queued frames plus the
- * open-time queue snapshot.
- */
- import { describe, expect, it, vi } from 'vitest'
- import { Context } from 'cordis'
- import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
- import type { Agent } from '@deepseek-ai/dsh-agent'
- import SessionStore from '@deepseek-ai/dsh-session'
- import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session'
- import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
- import ToolRegistry from '@deepseek-ai/dsh-tools'
- import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
- import CommandService from '@deepseek-ai/dsh-commands'
- import SkillService from '@deepseek-ai/dsh-skill'
- import type { HostFrame } from '../src/api/index.ts'
- import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
- import { RpcId } from '../src/api/rpc.ts'
- import { createApiProxy } from '../src/api-proxy.ts'
- const DEFAULTS = { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
- function request<P>(payload: P): RpcRequest<P> {
- return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
- }
- let nextRpc = 1
- function expectOk<T>(response: RpcResponse<T>): T {
- expect(response.result.ok).toBe(true)
- if (!response.result.ok) throw new Error('unreachable')
- return response.result.value
- }
- function expectErr<T>(response: RpcResponse<T>): { code: string; message: string } {
- expect(response.result.ok).toBe(false)
- if (response.result.ok) throw new Error('unreachable')
- return response.result.error
- }
- /** Composition floor for the command/skill paths (no LLM, no persistence). */
- async function harness(options: { commands?: boolean; skills?: boolean } = {}): Promise<Context> {
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- await ctx.plugin(SystemPrompt, { persona: '' })
- await ctx.plugin(ToolRegistry)
- await ctx.plugin(UserInteractionService)
- await ctx.plugin(AgentRegistry)
- if (options.skills !== false) await ctx.plugin(SkillService, {})
- if (options.commands !== false) await ctx.plugin(CommandService)
- // Host-stream opener reads the committed-workspace baseline; the stub
- // suffices here — the real workspace composition is api-proxy-workspace.spec's.
- ctx.provide('workspace', { list: () => [] } as never)
- return ctx
- }
- /** Register a live structural agent stub (api-proxy-view precedent: only id/session/status/ctx are read). */
- function stubAgent(ctx: Context, sessionId?: SessionId): Agent {
- const session = ctx.sessions.create(sessionId)
- const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
- const agent = {
- id: session.id,
- session,
- inbox,
- status: 'idle',
- ctx,
- } as Agent
- ctx.agents.register(agent)
- return agent
- }
- /** Drain `count` frames from a stream, then abort it. */
- async function collect<F>(iterable: AsyncIterable<RpcRequest<F>>, count: number, abort: AbortController): Promise<F[]> {
- const frames: F[] = []
- for await (const frame of iterable) {
- frames.push(frame.payload)
- if (frames.length >= count) abort.abort()
- }
- return frames
- }
- /** Read the next payload from an open stream. */
- async function nextFrame<F>(iterator: AsyncIterator<RpcRequest<F>>): Promise<F> {
- const result = await iterator.next()
- if (result.done) throw new Error('stream ended')
- return result.value.payload
- }
- describe('command.list', () => {
- it('serves the addressed agent\'s name-sorted catalog', async () => {
- const ctx = await harness()
- ctx.commands.register({ name: 'zeta', description: 'z', handler: () => ({ kind: 'success' }) })
- ctx.commands.register({ name: 'alpha', description: 'a', input: { hint: '<x>' }, handler: () => ({ kind: 'success' }) })
- const api = createApiProxy(ctx, DEFAULTS)
- const agent = stubAgent(ctx)
- const value = expectOk(await api.commands.list(request({ sessionId: agent.id })))
- expect(value.commands).toEqual([
- { name: 'alpha', description: 'a', input: { hint: '<x>' } },
- { name: 'zeta', description: 'z' },
- ])
- })
- it('fails loud with internal when the command registry is not mounted', async () => {
- const ctx = await harness({ commands: false })
- const api = createApiProxy(ctx, DEFAULTS)
- const error = expectErr(await api.commands.list(request({ sessionId: 's' as SessionId })))
- expect(error.code).toBe('internal')
- expect(error.message).toContain('command registry')
- })
- })
- describe('command.execute', () => {
- it('executes a known command against the addressed agent and detaches the result', async () => {
- const ctx = await harness()
- let received: string | undefined
- ctx.commands.register({
- name: 'goal',
- description: 'set goal',
- handler: (invocation) => {
- received = invocation.rawInput
- return { kind: 'success', text: `goal:${invocation.agent.id}` }
- },
- })
- const api = createApiProxy(ctx, DEFAULTS)
- const agent = stubAgent(ctx)
- const value = expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/goal ship it' }), new AbortController().signal))
- expect(value).toMatchObject({ matched: true })
- expect(value.commandId).toBeTruthy()
- expect(received).toBe(' ship it')
- // Pure admission on the wire: the outcome rides the durably logged
- // lifecycle pair instead of the response.
- const lifecycle = agent.session.events.filter(e => e.type === 'command/run' || e.type === 'command/done')
- expect(lifecycle).toMatchObject([
- { type: 'command/run', data: { commandId: value.commandId, name: 'goal', args: ' ship it' } },
- { type: 'command/done', data: { commandId: value.commandId, kind: 'success', text: `goal:${agent.id}` } },
- ])
- })
- it('returns matched:false when syntax or name does not resolve', async () => {
- const ctx = await harness()
- const api = createApiProxy(ctx, DEFAULTS)
- const agent = stubAgent(ctx)
- const signal = new AbortController().signal
- expect(expectOk(await api.commands.execute(request({ sessionId: agent.id, line: '/unknown' }), signal))).toEqual({ matched: false })
- expect(expectOk(await api.commands.execute(request({ sessionId: agent.id, line: 'not a command' }), signal))).toEqual({ matched: false })
- })
- it('maps a session miss to session-not-found and a registry gap to internal', async () => {
- const ctx = await harness()
- const api = createApiProxy(ctx, DEFAULTS)
- const missing = expectErr(await api.commands.execute(
- request({ sessionId: 'session-nope' as SessionId, line: '/x' }), new AbortController().signal))
- expect(missing.code).toBe('internal') // no persistence configured: resume fails loud past the gate
- const bare = await harness({ commands: false })
- const bareApi = createApiProxy(bare, DEFAULTS)
- expect(expectErr(await bareApi.commands.execute(
- request({ sessionId: 's' as SessionId, line: '/x' }), new AbortController().signal)).code).toBe('internal')
- })
- it('reports an aborted handler as cancelled and a throwing handler as internal', async () => {
- const ctx = await harness()
- ctx.commands.register({
- name: 'hang',
- description: 'never settles on its own',
- handler: () => new Promise(() => { /* settled only by abort */ }),
- })
- ctx.commands.register({
- name: 'boom',
- description: 'throws',
- handler: () => { throw new Error('kaboom') },
- })
- const api = createApiProxy(ctx, DEFAULTS)
- const agent = stubAgent(ctx)
- const controller = new AbortController()
- const pending = api.commands.execute(request({ sessionId: agent.id, line: '/hang' }), controller.signal)
- controller.abort()
- expect(expectErr(await pending).code).toBe('cancelled')
- const thrown = expectErr(await api.commands.execute(request({ sessionId: agent.id, line: '/boom' }), new AbortController().signal))
- expect(thrown.code).toBe('internal')
- expect(thrown.message).toContain('kaboom')
- })
- })
- describe('skill.list', () => {
- it('lists skills for the session cwd taken from the header', async () => {
- const ctx = await harness()
- const seenCwds: (string | undefined)[] = []
- ctx.skills.registerProvider(() => ({
- name: 'probe',
- list: (options) => {
- seenCwds.push(options.cwd)
- return Promise.resolve([
- {
- name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing',
- invocation: { modelInvocable: true, userInvocable: true },
- source: 'custom', provider: 'probe', rank: 0, locator: null,
- },
- {
- name: 'user-only', description: 'User-only',
- invocation: { modelInvocable: false, userInvocable: true },
- source: 'custom', provider: 'probe', rank: 0, locator: null,
- },
- {
- name: 'model-only', description: 'Model-only',
- invocation: { modelInvocable: true, userInvocable: false },
- source: 'custom', provider: 'probe', rank: 0, locator: null,
- },
- {
- name: 'trusted-only', description: 'Trusted-only',
- invocation: { modelInvocable: false, userInvocable: false },
- source: 'custom', provider: 'probe', rank: 0, locator: null,
- },
- ])
- },
- get: () => Promise.resolve(undefined),
- }))
- const api = createApiProxy(ctx, DEFAULTS)
- // No agent is registered for this session: header resolution must not
- // touch (or resume through) the Agent registry.
- const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
- const value = expectOk(await api.skills.list(request({ sessionId: session.id })))
- expect(value.skills).toEqual([
- { name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', modelInvocable: true },
- { name: 'user-only', description: 'User-only', modelInvocable: false },
- ])
- expect(seenCwds).toEqual(['/proj'])
- expect(ctx.agents.get(session.id)).toBeUndefined()
- })
- it('fails loud on an unattached session id (business error, no resume attempt)', async () => {
- const ctx = await harness()
- const api = createApiProxy(ctx, DEFAULTS)
- const error = expectErr(await api.skills.list(request({ sessionId: 'session-cold' as SessionId })))
- expect(error.code).toBe('session-not-found')
- })
- it('fails loud with internal when the skill registry is not mounted', async () => {
- const ctx = await harness({ skills: false })
- const api = createApiProxy(ctx, DEFAULTS)
- const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
- const error = expectErr(await api.skills.list(request({ sessionId: session.id })))
- expect(error.code).toBe('internal')
- expect(error.message).toContain('skill registry is absent')
- })
- it('folds a provider failure into internal', async () => {
- const ctx = await harness()
- ctx.skills.registerProvider(() => ({
- name: 'broken',
- list: () => Promise.reject(new Error('directory exploded')),
- get: () => Promise.resolve(undefined),
- }))
- const api = createApiProxy(ctx, DEFAULTS)
- const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
- const response = await api.skills.list(request({ sessionId: session.id }))
- // dsh-skill contains one provider's failure (logs and serves the rest), so
- // this surfaces as an empty ok catalog rather than an error.
- const value = expectOk(response)
- expect(value.skills).toEqual([])
- })
- })
- describe('skill.invoke', () => {
- /** Provider with one user-only and one model-only skill, both loadable. */
- function registerInvokeSkills(ctx: Context): void {
- const summaries = [
- {
- name: 'user-only', description: 'User-only',
- invocation: { modelInvocable: false, userInvocable: true },
- source: 'custom', provider: 'probe', rank: 0, locator: null,
- resourceBase: { kind: 'directory', path: '/proj/.agents/skills/user-only' },
- },
- {
- name: 'model-only', description: 'Model-only',
- invocation: { modelInvocable: true, userInvocable: false },
- source: 'custom', provider: 'probe', rank: 0, locator: null,
- },
- ] as const
- ctx.skills.registerProvider(() => ({
- name: 'probe',
- list: () => Promise.resolve(summaries.map(summary => ({ ...summary }))),
- get: candidate => Promise.resolve({
- ...summaries.find(summary => summary.name === candidate.name)!,
- content: 'Follow the probe instructions.',
- }),
- }))
- }
- /** Agent stub whose session carries a project cwd and whose followup records the injected message. */
- function invokableAgent(ctx: Context): { agent: Agent; followup: ReturnType<typeof vi.fn> } {
- const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
- const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
- const followup = vi.fn()
- const agent = { id: session.id, session, inbox, status: 'idle', ctx, followup } as unknown as Agent
- ctx.agents.register(agent)
- return { agent, followup }
- }
- it('injects a user-invocable skill as a user message with the invocation source', async () => {
- const ctx = await harness()
- registerInvokeSkills(ctx)
- const api = createApiProxy(ctx, DEFAULTS)
- const { agent, followup } = invokableAgent(ctx)
- const value = expectOk(await api.skills.invoke(request({
- sessionId: agent.id, name: 'user-only', text: 'and check the fixture',
- })))
- expect(value).toEqual({ accepted: true })
- expect(followup).toHaveBeenCalledTimes(1)
- const message = followup.mock.calls[0]?.[0] as UserMessage
- expect(message.source).toEqual({ kind: 'skill-invocation', name: 'user-only', args: 'and check the fixture' })
- expect(message.content).toHaveLength(1)
- const text = (message.content[0] as { text: string }).text
- expect(text).toContain('<skill_content name="user-only">')
- expect(text).toContain('Base directory for this skill: /proj/.agents/skills/user-only')
- expect(text).toContain('Follow the probe instructions.')
- expect(text.endsWith('\n\nand check the fixture')).toBe(true)
- })
- it('omits args from the source and content when no text rides the invocation', async () => {
- const ctx = await harness()
- registerInvokeSkills(ctx)
- const api = createApiProxy(ctx, DEFAULTS)
- const { agent, followup } = invokableAgent(ctx)
- expectOk(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' })))
- const message = followup.mock.calls[0]?.[0] as UserMessage
- expect(message.source).toEqual({ kind: 'skill-invocation', name: 'user-only' })
- const text = (message.content[0] as { text: string }).text
- expect(text.endsWith('</skill_content>')).toBe(true)
- })
- it('rejects a skill the user may not invoke', async () => {
- const ctx = await harness()
- registerInvokeSkills(ctx)
- const api = createApiProxy(ctx, DEFAULTS)
- const { agent, followup } = invokableAgent(ctx)
- const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'model-only' })))
- expect(error.code).toBe('skill-not-invocable')
- expect(followup).not.toHaveBeenCalled()
- })
- it('rejects an unknown or invalid skill name', async () => {
- const ctx = await harness()
- registerInvokeSkills(ctx)
- const api = createApiProxy(ctx, DEFAULTS)
- const { agent } = invokableAgent(ctx)
- const missing = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'absent-skill' })))
- expect(missing.code).toBe('skill-not-found')
- const invalid = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'Not A Name' })))
- expect(invalid.code).toBe('skill-not-found')
- })
- it('surfaces a followup refusal as agent-busy', async () => {
- const ctx = await harness()
- registerInvokeSkills(ctx)
- const api = createApiProxy(ctx, DEFAULTS)
- const { agent, followup } = invokableAgent(ctx)
- followup.mockImplementation(() => { throw new Error('inbox closed') })
- const error = expectErr(await api.skills.invoke(request({ sessionId: agent.id, name: 'user-only' })))
- expect(error.code).toBe('agent-busy')
- })
- it('fails loud with internal when the skill registry is not mounted', async () => {
- const ctx = await harness({ skills: false })
- const api = createApiProxy(ctx, DEFAULTS)
- const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
- const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
- ctx.agents.register({ id: session.id, session, inbox, status: 'idle', ctx, followup: vi.fn() } as unknown as Agent)
- const error = expectErr(await api.skills.invoke(request({ sessionId: session.id, name: 'user-only' })))
- expect(error.code).toBe('internal')
- expect(error.message).toContain('skill registry is absent')
- })
- })
- describe('host/commands-changed frame', () => {
- it('broadcasts on registry change', async () => {
- const ctx = await harness()
- const api = createApiProxy(ctx, DEFAULTS)
- const abort = new AbortController()
- const stream = api.events.host({ rpcId: RpcId('t-host'), payload: {} }, abort.signal)
- const collected = collect<HostFrame>(stream, 1, abort)
- ctx.commands.register({ name: 'late', description: 'l', handler: () => ({ kind: 'success' }) })
- expect(await collected).toEqual([{ type: 'host/commands-changed' }])
- })
- })
- /** Build one frozen inbox message. */
- function inboxMessage(id: string, text: string, rpcId?: string): UserMessage {
- return freezeMessage({
- id: MessageId(id),
- role: 'user',
- content: [{ type: 'text' as const, text }],
- source: rpcId === undefined ? { kind: 'user' as const } : { kind: 'user' as const, rpcId: RpcId(rpcId) },
- })
- }
- describe('session.updateQueue', () => {
- it('splices a queued message and reports a lost claim race', async () => {
- const ctx = await harness()
- const agent = stubAgent(ctx)
- const present = inboxMessage('present', 'before')
- agent.inbox.splice('next-turn', 0, 0, [present])
- const api = createApiProxy(ctx, DEFAULTS)
- const applied = await api.sessions.updateQueue({
- rpcId: RpcId('q-apply'),
- payload: {
- sessionId: agent.id,
- itemId: MessageId('present'),
- action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] },
- },
- })
- expect(expectOk(applied)).toEqual({ accepted: true })
- const missing = await api.sessions.updateQueue({
- rpcId: RpcId('q-missing'),
- payload: {
- sessionId: agent.id,
- itemId: MessageId('claimed'),
- action: { kind: 'remove' },
- },
- })
- expect(expectErr(missing)).toMatchObject({ code: 'queue-item-not-found' })
- expect(agent.inbox.nextTurn[0]).toMatchObject({
- id: 'present',
- content: [{ type: 'text', text: 'edited' }],
- })
- })
- it('rejects a stale occurrence without resuming a cold agent', async () => {
- const ctx = await harness()
- const resume = vi.spyOn(ctx.agents, 'resume')
- const api = createApiProxy(ctx, DEFAULTS)
- const response = await api.sessions.updateQueue({
- rpcId: RpcId('q-cold'),
- payload: {
- sessionId: 'cold-session' as SessionId,
- itemId: MessageId('stale-item'),
- action: { kind: 'remove' },
- },
- })
- expect(expectErr(response)).toMatchObject({ code: 'queue-item-not-found' })
- expect(resume).not.toHaveBeenCalled()
- })
- })
- describe('session/queue frames', () => {
- it('publishes authoritative inbox snapshots without duplicating message identity', async () => {
- const ctx = await harness()
- const api = createApiProxy(ctx, DEFAULTS)
- const agent = stubAgent(ctx)
- const queued = inboxMessage('m-1', 'queued prompt')
- const edited = inboxMessage('m-1', 'edited prompt')
- const steering = inboxMessage('m-2', 'steering prompt')
- agent.inbox.splice('next-turn', 0, 0, [queued])
- agent.inbox.splice('next-step', 0, 0, [steering])
- const abort = new AbortController()
- const iterator = api.events.mux({
- rpcId: RpcId('t-mux-baseline'),
- payload: {},
- }, abort.signal)[Symbol.asyncIterator]()
- const frames = [
- await nextFrame(iterator),
- await nextFrame(iterator),
- ]
- agent.inbox.splice('next-turn', 0, 1, [edited])
- frames.push(await nextFrame(iterator), await nextFrame(iterator))
- const injected = freezeMessage({
- id: MessageId('m-3'),
- role: 'user',
- content: [{ type: 'text' as const, text: 'injected context' }],
- source: { kind: 'plugin' as const, plugin: 'approval' },
- })
- agent.inbox.splice('next-step', 0, 0, [injected])
- frames.push(await nextFrame(iterator), await nextFrame(iterator))
- abort.abort()
- await iterator.return?.()
- expect(frames.filter(frame => frame.type === 'session/queue')).toEqual([
- {
- type: 'session/queue',
- sessionId: agent.id,
- items: [
- { id: queued.id, placement: 'queued', message: queued },
- { id: steering.id, placement: 'steering', message: steering },
- ],
- },
- {
- type: 'session/queue',
- sessionId: agent.id,
- items: [
- { id: edited.id, placement: 'queued', message: edited },
- { id: steering.id, placement: 'steering', message: steering },
- ],
- },
- {
- type: 'session/queue',
- sessionId: agent.id,
- items: [
- { id: edited.id, placement: 'queued', message: edited },
- { id: injected.id, placement: 'context', message: injected },
- { id: steering.id, placement: 'steering', message: steering },
- ],
- },
- ])
- })
- })
|