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:
Tianyi Cui
2026-07-29 21:05:09 +08:00
570 changed files with 9233 additions and 2976 deletions

View File

@@ -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) {

View File

@@ -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 }>>
}

View 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')
})
})

View File

@@ -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> {