Merge remote-tracking branch 'origin/master' into worktree/pr823-retarget-latest-20260729
# Conflicts: # packages/client/ui-skill/README.i18n.yaml # packages/client/ui-skill/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/skill/skill-local/README.i18n.yaml # packages/skill/skill-local/README.zh.md # packages/skill/skill/README.i18n.yaml # packages/skill/skill/README.zh.md # packages/skill/tool-skill/README.i18n.yaml
This commit is contained in:
@@ -38,6 +38,12 @@ import { GoalError } from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal'
|
||||
// Type-only edge: resolves `ctx.get('commands')` and the `commands/change` event.
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
|
||||
// Side-effect type import: resolves the `approval/request` waterfall and
|
||||
// `ctx.get('approval')` without a value dependency on the seam (optional composition).
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import { approvalResponsePayloadSchema } from './api/approvals.schema.ts'
|
||||
import { questionResponsePayloadSchema } from './api/questions.schema.ts'
|
||||
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
|
||||
import { RpcId } from './api/rpc.ts'
|
||||
@@ -128,9 +134,9 @@ class FrameQueue<F> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side frame mint: pure pushes get a fresh rpcId per frame (stable ids
|
||||
* for answerable frames belong to the approval/question registry, absent in
|
||||
* this minimal version).
|
||||
* Server-side frame mint: pure pushes get a fresh rpcId per frame (answerable
|
||||
* frames — approval/question requested — mint their stable id in their
|
||||
* pending registries instead).
|
||||
*/
|
||||
function frame<F>(payload: F): RpcRequest<F> {
|
||||
return { rpcId: RpcId(randomUUID()), payload }
|
||||
@@ -217,6 +223,36 @@ export interface ApiProxyDefaults {
|
||||
|
||||
/** The tool/call payload fields the presenter path reads. */
|
||||
interface ToolCallData { callId: string; name: string; arguments: string }
|
||||
/**
|
||||
* One outstanding approval question: the stable server-request id, the frame
|
||||
* material replayed to late mux subscribers, and the resolver that settles the
|
||||
* answerer's promise back into `ctx.approval`.
|
||||
*/
|
||||
interface PendingApproval {
|
||||
rpcId: RpcId
|
||||
sessionId: SessionId
|
||||
approvalId: ApprovalRequestId
|
||||
toolName: string
|
||||
callId?: CallId
|
||||
reason?: string
|
||||
resolve(outcome: ApprovalOutcome): void
|
||||
}
|
||||
|
||||
/** Project a pending entry into its answerable mux frame (initial push and mux-open replay share it). */
|
||||
function requestedFrame(pending: PendingApproval): RpcRequest<MuxFrame> {
|
||||
return {
|
||||
rpcId: pending.rpcId,
|
||||
payload: {
|
||||
type: 'approval/requested',
|
||||
sessionId: pending.sessionId,
|
||||
approvalId: pending.approvalId,
|
||||
toolName: pending.toolName,
|
||||
...pending.callId === undefined ? {} : { callId: pending.callId },
|
||||
...pending.reason === undefined ? {} : { reason: pending.reason },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** One host-owned question wait, addressed by the stable server-request id. */
|
||||
interface PendingQuestion {
|
||||
rpcId: RpcId
|
||||
@@ -416,6 +452,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
/** Serializes path ownership checks with record creation across spellings. */
|
||||
let workspaceCreationChain = Promise.resolve()
|
||||
const pendingQuestions = new Map<RpcId, PendingQuestion>()
|
||||
const pendingApprovals = new Map<RpcId, PendingApproval>()
|
||||
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
|
||||
|
||||
/**
|
||||
@@ -564,6 +601,90 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}
|
||||
}, 'api-proxy: user-interaction provider')
|
||||
|
||||
// --- Approval pending registry ------------------------------------------
|
||||
// The proxy is the approval channel for every agent this host owns: an ask
|
||||
// through `ctx.approval` becomes an answerable server-request on the mux
|
||||
// stream (stable rpcId), settled by POST /api/respond. The entry survives
|
||||
// client disconnects — mux-open replays still-pending requested frames with
|
||||
// the same rpcId (the refresh-recovery baseline) — and withdraws on the
|
||||
// ask's own abort signal (turn cancel), pushing `cancelled` to subscribers.
|
||||
if (ctx.get('approval') !== undefined) {
|
||||
// Teardown parity with the question provider above: a gateway disposed
|
||||
// while approvals are pending settles every entry as 'cancelled' (the
|
||||
// service's fail-closed vocabulary), so no ask promise dangles past the
|
||||
// proxy's lifetime and subscribers see the withdrawal.
|
||||
ctx.effect(() => () => {
|
||||
for (const pending of [...pendingApprovals.values()]) pending.resolve('cancelled')
|
||||
}, 'api-proxy: approval registry teardown')
|
||||
ctx.on('approval/request', (req, next) => {
|
||||
// Dispatch rides a microtask behind the service's own signal check: an
|
||||
// abort landing in that window would register the abort listener AFTER
|
||||
// the signal fired — never invoked, entry pending forever, zombie frame
|
||||
// on every mux replay. Settle synchronously instead of publishing.
|
||||
if (req.signal?.aborted === true) return Promise.resolve<ApprovalOutcome>('cancelled')
|
||||
// The audit pair `approval/asked` is already appended by the service
|
||||
// before dispatch, but dispatch rides a microtask: parallel tool calls
|
||||
// can append several asked events before any answerer runs. THIS
|
||||
// request's event is therefore the newest asked event that is still
|
||||
// undecided, unclaimed by another pending entry, and — when the ask
|
||||
// names a call — carries the same callId.
|
||||
const events = req.agent.session.events
|
||||
const claimed = new Set<ApprovalRequestId>()
|
||||
for (const entry of pendingApprovals.values()) claimed.add(entry.approvalId)
|
||||
const decided = new Set<ApprovalRequestId>()
|
||||
let approvalId: ApprovalRequestId | undefined
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const event = events[i] as SessionEvent
|
||||
if (event.type === 'approval/decided') {
|
||||
decided.add(event.data.id)
|
||||
} else if (event.type === 'approval/asked') {
|
||||
if (decided.has(event.data.id) || claimed.has(event.data.id)) continue
|
||||
// Symmetric pairing: a callId-bearing ask only takes its own call's
|
||||
// record, and a callId-less ask only takes a callId-less record —
|
||||
// so neither shape can steal the other's audit id under parallel
|
||||
// asks. (Today every producer — the tool executor — passes callId;
|
||||
// the callId-less arm guards any future non-tool asker.)
|
||||
if ((req.callId ?? null) !== (event.data.callId ?? null)) continue
|
||||
approvalId = event.data.id
|
||||
break
|
||||
}
|
||||
}
|
||||
// No asked event means the request bypassed the service's audit path —
|
||||
// not this channel's question; delegate to the fail-closed default.
|
||||
if (approvalId === undefined) return next()
|
||||
const id = approvalId
|
||||
return new Promise<ApprovalOutcome>((resolve) => {
|
||||
const settle = (outcome: ApprovalOutcome): void => {
|
||||
/* v8 ignore next 3 -- defensive double-settle guard: respond() routes
|
||||
through the pending table (a settled id is not-pending before it can
|
||||
re-settle) and the first settle removes the abort listener, so no
|
||||
reachable path settles twice; kept against future settle callers. */
|
||||
if (!pendingApprovals.delete(pending.rpcId)) return
|
||||
req.signal?.removeEventListener('abort', onAbort)
|
||||
broadcast({ type: 'approval/resolved', sessionId: pending.sessionId, approvalId: id, outcome })
|
||||
// A cancelled ask was already settled by the service's own signal
|
||||
// race, which discards this late resolution; resolving is a no-op
|
||||
// there and keeps this promise from dangling forever.
|
||||
resolve(outcome)
|
||||
}
|
||||
const onAbort = (): void => { settle('cancelled') }
|
||||
const pending: PendingApproval = {
|
||||
rpcId: RpcId(randomUUID()),
|
||||
sessionId: req.agent.session.id,
|
||||
approvalId: id,
|
||||
toolName: req.toolName,
|
||||
...req.callId === undefined ? {} : { callId: req.callId },
|
||||
...req.reason === undefined ? {} : { reason: req.reason },
|
||||
resolve: settle,
|
||||
}
|
||||
pendingApprovals.set(pending.rpcId, pending)
|
||||
req.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
const envelope = requestedFrame(pending)
|
||||
for (const queue of muxQueues) queue.push(envelope)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate the cold path on the store: an id absent from it, or naming a legacy
|
||||
* log without a cwd (pre-release stance: not served, no compatibility), is
|
||||
@@ -1335,6 +1456,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
})
|
||||
}
|
||||
// Refresh recovery: still-pending approval questions replay with their
|
||||
// stable rpcId so a reconnecting client can still answer them.
|
||||
for (const pending of pendingApprovals.values()) queue.push(requestedFrame(pending))
|
||||
// Queue snapshot baseline (pendingQuestions precedent): frames replayed
|
||||
// in arrival order per session; a reconnecting client rebuilds its
|
||||
// queue view from these alone.
|
||||
@@ -1452,6 +1576,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
|
||||
respond(message: ClientResponse): Promise<RpcReceipt> {
|
||||
// Route by the echoed rpcId (the wire correlation): approvals first,
|
||||
// then questions — the two registries share one id space of UUIDs.
|
||||
const approval = pendingApprovals.get(message.rpcId)
|
||||
if (approval !== undefined) {
|
||||
if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' })
|
||||
const parsed = approvalResponsePayloadSchema.safeParse(message.result.value)
|
||||
// The payload's audit correlation must match the entry the rpcId routed
|
||||
// to — a mismatched answer is malformed, not merely late.
|
||||
if (!parsed.success || parsed.data.approvalId !== approval.approvalId || parsed.data.sessionId !== approval.sessionId) {
|
||||
return Promise.resolve({ accepted: false, reason: 'bad-response' })
|
||||
}
|
||||
approval.resolve(parsed.data.outcome)
|
||||
return Promise.resolve({ accepted: true })
|
||||
}
|
||||
const pending = pendingQuestions.get(message.rpcId)
|
||||
if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' })
|
||||
if (!message.result.ok) {
|
||||
|
||||
@@ -221,4 +221,5 @@ export interface SessionsApi {
|
||||
|
||||
/** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */
|
||||
cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ accepted: true }>>
|
||||
|
||||
}
|
||||
|
||||
330
packages/host/apiproxy/tests/api-proxy-approval.spec.ts
Normal file
330
packages/host/apiproxy/tests/api-proxy-approval.spec.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* 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, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
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, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
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, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
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, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* The summary blank bit means "conversation not started" (no turn has run),
|
||||
* not "log empty": standalone plugin events — command lifecycle records,
|
||||
* plan/mode, session titles — never flip it, so running /plan or /goal on a
|
||||
* plan/mode, permission knob events, session titles — never flip it, so running /plan or /goal on a
|
||||
* fresh session keeps it list-hidden and reusable, while the first accepted
|
||||
* prompt's turn/start clears it. The host/session-added frame shares the
|
||||
* same predicate function (covered by the workspace spec's frame assertion).
|
||||
@@ -15,6 +15,10 @@ import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
// Side-effect type imports: the knob-event SessionEventMap merges.
|
||||
import type {} from '@deepseek-ai/dsh-permission'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type { ApiProxy, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
@@ -48,6 +52,10 @@ function appendStandalone(session: Session): void {
|
||||
session.append('session/title', {
|
||||
title: 'standalone title', messageSeqs: [], source: { kind: 'fallback' },
|
||||
})
|
||||
// The three permission knob events (a /permission switch on a fresh session).
|
||||
session.append('permission/preset', { preset: 'danger-full-access' })
|
||||
session.append('sandbox/mode', { mode: 'danger-full-access' })
|
||||
session.append('approval/policy', { policy: 'never' })
|
||||
}
|
||||
|
||||
async function listBlank(api: ApiProxy, id: string): Promise<boolean | undefined> {
|
||||
|
||||
Reference in New Issue
Block a user