/** * 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[]; waitFor(type: MuxFrame['type']): Promise } { const frames: MuxFrame[] = [] const envelopes: RpcRequest[] = [] 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 { 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 { 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[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 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 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 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 // 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 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 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 const frameB = frames.find(e => requestedOf(e.payload).callId === 'call-b') as RpcRequest // 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 const frameB = frames.find(e => requestedOf(e.payload).toolName === 'beta') as RpcRequest // 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') }) })