| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330 |
- /**
- * Approval pending registry over the proxy: an ask through `ctx.approval`
- * becomes an answerable `approval/requested` mux frame (stable rpcId, replayed
- * verbatim on a later mux open), `respond` routes by the echoed rpcId and
- * validates the audit correlation, and the ask's abort signal withdraws the
- * question with a broadcast `cancelled`.
- */
- import { describe, expect, it } from 'vitest'
- import { Context } from 'cordis'
- import AgentRegistry from '@deepseek-ai/dsh-agent'
- import type { Agent } from '@deepseek-ai/dsh-agent'
- import SessionStore from '@deepseek-ai/dsh-session'
- import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
- import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
- import ApprovalService from '@deepseek-ai/dsh-user-approval'
- import type { ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
- import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
- import type { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
- import { RpcId as mintRpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
- import { createApiProxy } from '../src/api-proxy.ts'
- async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- await ctx.plugin(SystemPrompt, { persona: '' })
- await ctx.plugin(UserInteractionService)
- await ctx.plugin(AgentRegistry)
- await ctx.plugin(ApprovalService)
- const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
- return { ctx, api }
- }
- /** A minimal agent stand-in inside an open turn (the service only reaches `.session`). */
- function agentOf(ctx: Context): Agent {
- const session = ctx.sessions.create()
- session.append('turn/start', { turn: 1 })
- return { session } as unknown as Agent
- }
- /** Open a mux stream and capture frames into an array (returns an on-demand waiter). */
- function openMux(api: ApiProxy, abort: AbortController): { frames: MuxFrame[]; envelopes: RpcRequest<MuxFrame>[]; waitFor(type: MuxFrame['type']): Promise<MuxFrame> } {
- const frames: MuxFrame[] = []
- const envelopes: RpcRequest<MuxFrame>[] = []
- const waiters: { type: MuxFrame['type']; resolve: (frame: MuxFrame) => void }[] = []
- void (async () => {
- for await (const envelope of api.events.mux({ rpcId: mintRpcId('t-mux'), payload: {} }, abort.signal)) {
- frames.push(envelope.payload)
- envelopes.push(envelope)
- for (let i = waiters.length - 1; i >= 0; i -= 1) {
- const waiter = waiters[i] as (typeof waiters)[number]
- if (waiter.type === envelope.payload.type) {
- waiters.splice(i, 1)
- waiter.resolve(envelope.payload)
- }
- }
- }
- })()
- return {
- frames,
- envelopes,
- waitFor: (type) => {
- const found = frames.find(frame => frame.type === type)
- if (found !== undefined) return Promise.resolve(found)
- return new Promise((resolve) => { waiters.push({ type, resolve }) })
- },
- }
- }
- function requestedOf(frame: MuxFrame): Extract<MuxFrame, { type: 'approval/requested' }> {
- if (frame.type !== 'approval/requested') throw new Error(`expected approval/requested, got ${frame.type}`)
- return frame
- }
- /** Wait until the stream delivered `count` frames of `type` (bounded poll; waitFor only covers the first). */
- async function waitForCount(mux: { frames: MuxFrame[] }, type: MuxFrame['type'], count: number): Promise<void> {
- for (let i = 0; i < 200 && mux.frames.filter(frame => frame.type === type).length < count; i += 1) {
- await new Promise(resolve => setTimeout(resolve, 5))
- }
- expect(mux.frames.filter(frame => frame.type === type).length).toBeGreaterThanOrEqual(count)
- }
- function answer(rpcId: RpcId, sessionId: unknown, approvalId: ApprovalRequestId, outcome: 'allowed-once' | 'rejected'): Parameters<ApiProxy['respond']>[0] {
- return { type: 'client-response', rpcId, result: { ok: true, value: { sessionId, approvalId, outcome } } }
- }
- describe('approval pending registry', () => {
- it('round-trips ask → requested frame → respond → outcome + resolved broadcast', async () => {
- const { ctx, api } = await harness()
- const abort = new AbortController()
- const mux = openMux(api, abort)
- const agent = agentOf(ctx)
- const asked = ctx.approval.request({ agent, toolName: 'bash', reason: 'sandbox escalation' })
- const requested = requestedOf(await mux.waitFor('approval/requested'))
- expect(requested).toMatchObject({ toolName: 'bash', reason: 'sandbox escalation', sessionId: agent.session.id })
- const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest<MuxFrame>
- const receipt = await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'allowed-once'))
- expect(receipt).toEqual({ accepted: true })
- await expect(asked).resolves.toBe('allowed-once')
- const resolved = await mux.waitFor('approval/resolved')
- expect(resolved).toMatchObject({ approvalId: requested.approvalId, outcome: 'allowed-once' })
- // The question settled: a duplicate answer is late, not re-decidable.
- const dup = await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'rejected'))
- expect(dup).toEqual({ accepted: false, reason: 'not-pending' })
- abort.abort()
- })
- it('replays a still-pending requested frame (same rpcId) on a later mux open', async () => {
- const { ctx, api } = await harness()
- const first = new AbortController()
- const firstMux = openMux(api, first)
- const agent = agentOf(ctx)
- const asked = ctx.approval.request({ agent, toolName: 'write' })
- const requested = requestedOf(await firstMux.waitFor('approval/requested'))
- const firstEnvelope = firstMux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest<MuxFrame>
- first.abort()
- // A fresh subscriber (refresh recovery) sees the same stable rpcId.
- const second = new AbortController()
- const secondMux = openMux(api, second)
- const replayed = requestedOf(await secondMux.waitFor('approval/requested'))
- const secondEnvelope = secondMux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest<MuxFrame>
- expect(secondEnvelope.rpcId).toBe(firstEnvelope.rpcId)
- expect(replayed.approvalId).toBe(requested.approvalId)
- const receipt = await api.respond(answer(secondEnvelope.rpcId, replayed.sessionId, replayed.approvalId, 'rejected'))
- expect(receipt).toEqual({ accepted: true })
- await expect(asked).resolves.toBe('rejected')
- second.abort()
- })
- it('rejects malformed and mismatched answers as bad-response, unknown ids as not-pending', async () => {
- const { ctx, api } = await harness()
- const abort = new AbortController()
- const mux = openMux(api, abort)
- const agent = agentOf(ctx)
- void ctx.approval.request({ agent, toolName: 'bash' })
- const requested = requestedOf(await mux.waitFor('approval/requested'))
- const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest<MuxFrame>
- // Unknown rpcId: not routed to any pending entry.
- expect(await api.respond(answer(mintRpcId('ghost'), requested.sessionId, requested.approvalId, 'rejected')))
- .toEqual({ accepted: false, reason: 'not-pending' })
- // Error-branch result: the client can only answer with a value.
- expect(await api.respond({ type: 'client-response', rpcId: envelope.rpcId, result: { ok: false, error: { code: 'internal', message: 'x', details: {} } } }))
- .toEqual({ accepted: false, reason: 'bad-response' })
- // Wrong audit correlation: the rpcId routed, but the payload disagrees.
- expect(await api.respond(answer(envelope.rpcId, requested.sessionId, 'other-approval' as ApprovalRequestId, 'rejected')))
- .toEqual({ accepted: false, reason: 'bad-response' })
- // Malformed payload shape.
- expect(await api.respond({ type: 'client-response', rpcId: envelope.rpcId, result: { ok: true, value: { nonsense: 1 } } }))
- .toEqual({ accepted: false, reason: 'bad-response' })
- abort.abort()
- })
- it('withdraws the question on the ask signal: cancelled outcome, resolved broadcast, late answer not-pending', async () => {
- const { ctx, api } = await harness()
- const abort = new AbortController()
- const mux = openMux(api, abort)
- const agent = agentOf(ctx)
- const cancel = new AbortController()
- const asked = ctx.approval.request({ agent, toolName: 'bash', signal: cancel.signal })
- const requested = requestedOf(await mux.waitFor('approval/requested'))
- const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest<MuxFrame>
- cancel.abort()
- await expect(asked).resolves.toBe('cancelled')
- const resolved = await mux.waitFor('approval/resolved')
- expect(resolved).toMatchObject({ approvalId: requested.approvalId, outcome: 'cancelled' })
- expect(await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'allowed-once')))
- .toEqual({ accepted: false, reason: 'not-pending' })
- abort.abort()
- })
- it('an ask whose signal aborted before dispatch settles cancelled without publishing', async () => {
- // The service checks the signal, then dispatch rides a microtask: an
- // abort in that window must not register a dead listener and strand the
- // entry (zombie frame on every replay). Drive the waterfall directly
- // with a pre-aborted signal to hit the answerer's register-path guard.
- const { ctx, api } = await harness()
- const abort = new AbortController()
- const mux = openMux(api, abort)
- const session = ctx.sessions.create()
- session.append('turn/start', { turn: 1 })
- session.append('approval/asked', { id: 'pre-aborted' as ApprovalRequestId, toolName: 'bash' })
- const agent = { session } as unknown as Agent
- const cancelled = new AbortController()
- cancelled.abort()
- const outcome = await ctx.waterfall(
- 'approval/request',
- { agent, toolName: 'bash', signal: cancelled.signal },
- () => Promise.resolve('unavailable' as const),
- )
- expect(outcome).toBe('cancelled')
- // Nothing was published: a fresh mux open replays no approval frame.
- const abort2 = new AbortController()
- const mux2 = openMux(api, abort2)
- await new Promise(resolve => setTimeout(resolve, 10))
- expect(mux2.envelopes.some(e => e.payload.type === 'approval/requested')).toBe(false)
- abort2.abort()
- abort.abort()
- void mux
- })
- it('gateway teardown settles pending approvals as cancelled (question-provider parity)', async () => {
- // Mount the proxy on its own fiber so disposal exercises the teardown
- // effect while an ask is still pending.
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- await ctx.plugin(SystemPrompt, { persona: '' })
- await ctx.plugin(UserInteractionService)
- await ctx.plugin(AgentRegistry)
- await ctx.plugin(ApprovalService)
- let api!: ApiProxy
- const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => {
- api = createApiProxy(fiberCtx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
- }, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] }))
- await fiber.await()
- const abort = new AbortController()
- const mux = openMux(api, abort)
- const asked = ctx.approval.request({ agent: agentOf(ctx), toolName: 'bash' })
- const requested = requestedOf(await mux.waitFor('approval/requested'))
- await fiber.dispose()
- await expect(asked).resolves.toBe('cancelled')
- const resolved = await mux.waitFor('approval/resolved')
- expect(resolved).toMatchObject({ approvalId: requested.approvalId, outcome: 'cancelled' })
- abort.abort()
- })
- it('carries callId on the frame and ignores a late abort after the answer settled', async () => {
- const { ctx, api } = await harness()
- const abort = new AbortController()
- const mux = openMux(api, abort)
- const agent = agentOf(ctx)
- const cancel = new AbortController()
- const asked = ctx.approval.request({ agent, toolName: 'bash', callId: 'call-9' as never, signal: cancel.signal })
- const requested = requestedOf(await mux.waitFor('approval/requested'))
- expect(requested.callId).toBe('call-9')
- const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest<MuxFrame>
- expect(await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'allowed-once')))
- .toEqual({ accepted: true })
- await expect(asked).resolves.toBe('allowed-once')
- // Late abort: the pending entry is gone; settle's delete-guard returns.
- cancel.abort()
- expect(mux.frames.filter(f => f.type === 'approval/resolved')).toHaveLength(1)
- abort.abort()
- })
- it('pairs parallel asks by callId: each requested frame carries its own audit id', async () => {
- const { ctx, api } = await harness()
- const abort = new AbortController()
- const mux = openMux(api, abort)
- const agent = agentOf(ctx)
- // Both asks append their approval/asked audit events before either
- // answerer's microtask dispatch runs — the parallel tool-call window.
- const askA = ctx.approval.request({ agent, toolName: 'bash', callId: 'call-a' as never })
- const askB = ctx.approval.request({ agent, toolName: 'bash', callId: 'call-b' as never })
- await waitForCount(mux, 'approval/requested', 2)
- const frames = mux.envelopes.filter(e => e.payload.type === 'approval/requested')
- const frameA = frames.find(e => requestedOf(e.payload).callId === 'call-a') as RpcRequest<MuxFrame>
- const frameB = frames.find(e => requestedOf(e.payload).callId === 'call-b') as RpcRequest<MuxFrame>
- // Each frame claimed the asked event with its own callId, not merely the newest.
- const askedIdByCall = new Map(agent.session.events
- .filter(event => event.type === 'approval/asked')
- .map(event => [String(event.data.callId), event.data.id]))
- expect(requestedOf(frameA.payload).approvalId).toBe(askedIdByCall.get('call-a'))
- expect(requestedOf(frameB.payload).approvalId).toBe(askedIdByCall.get('call-b'))
- // Answers route back to the right ask through the pairing.
- expect(await api.respond(answer(frameB.rpcId, agent.session.id, requestedOf(frameB.payload).approvalId, 'rejected')))
- .toEqual({ accepted: true })
- expect(await api.respond(answer(frameA.rpcId, agent.session.id, requestedOf(frameA.payload).approvalId, 'allowed-once')))
- .toEqual({ accepted: true })
- await expect(askA).resolves.toBe('allowed-once')
- await expect(askB).resolves.toBe('rejected')
- abort.abort()
- })
- it('gives parallel callId-less asks distinct audit ids (claimed-entry skip); both stay answerable', async () => {
- const { ctx, api } = await harness()
- const abort = new AbortController()
- const mux = openMux(api, abort)
- const agent = agentOf(ctx)
- const askA = ctx.approval.request({ agent, toolName: 'alpha' })
- const askB = ctx.approval.request({ agent, toolName: 'beta' })
- await waitForCount(mux, 'approval/requested', 2)
- const frames = mux.envelopes.filter(e => e.payload.type === 'approval/requested')
- const frameA = frames.find(e => requestedOf(e.payload).toolName === 'alpha') as RpcRequest<MuxFrame>
- const frameB = frames.find(e => requestedOf(e.payload).toolName === 'beta') as RpcRequest<MuxFrame>
- // Without a callId the pairing is heuristic, but never shared: the second
- // dispatch skips the id the first pending entry already claimed.
- expect(requestedOf(frameA.payload).approvalId).not.toBe(requestedOf(frameB.payload).approvalId)
- expect(await api.respond(answer(frameA.rpcId, agent.session.id, requestedOf(frameA.payload).approvalId, 'allowed-once')))
- .toEqual({ accepted: true })
- expect(await api.respond(answer(frameB.rpcId, agent.session.id, requestedOf(frameB.payload).approvalId, 'rejected')))
- .toEqual({ accepted: true })
- await expect(askA).resolves.toBe('allowed-once')
- await expect(askB).resolves.toBe('rejected')
- abort.abort()
- })
- it('delegates a dispatch whose only asked candidate is already decided (stale re-dispatch)', async () => {
- const { ctx, api } = await harness()
- void api // the answerer is registered; the fake below bypasses the service
- // Bypass ApprovalService: a log whose sole asked event already has its
- // decided partner must not be re-claimed — the answerer delegates.
- const session = ctx.sessions.create()
- session.append('turn/start', { turn: 1 })
- session.append('approval/asked', { id: 'stale-ask' as ApprovalRequestId, toolName: 'bash' })
- session.append('approval/decided', { id: 'stale-ask' as ApprovalRequestId, outcome: 'rejected' })
- const agent = { session } as unknown as Agent
- const outcome = await ctx.waterfall('approval/request', { agent, toolName: 'bash' }, () => Promise.resolve('unavailable' as const))
- expect(outcome).toBe('unavailable')
- })
- it('delegates an ask whose session log carries no asked audit event (foreign channel)', async () => {
- const { ctx, api } = await harness()
- void api // the answerer is registered; the fake below bypasses the audit path
- // Bypass ApprovalService: dispatch the waterfall directly with a session
- // that has no approval/asked event — the proxy answerer must call next().
- const session = ctx.sessions.create()
- session.append('turn/start', { turn: 1 })
- const agent = { session } as unknown as Agent
- const outcome = await ctx.waterfall('approval/request', { agent, toolName: 'x' }, () => Promise.resolve('unavailable' as const))
- expect(outcome).toBe('unavailable')
- })
- })
|