refactor(core): simplify tools prompts and trusted services

This commit is contained in:
Tianyi Cui
2026-07-12 22:39:01 +08:00
parent 28e04ff4fb
commit 02ca71db57
24 changed files with 636 additions and 2695 deletions

View File

@@ -2,7 +2,7 @@
User-approval seam. Owns the `ctx.approval` service ([`ApprovalService`](src/index.ts)) and the one-shot permission vocabulary the harness shares: `ApprovalRequest` (agent + tool identity + reason + abort signal), the closed `ApprovalOutcome` union (`allowed-once` / `rejected` / `cancelled` / `unavailable`), the `ApprovalRequestId` brand pairing the two log-only audit events (`approval/asked` / `approval/decided`), and the `approval/request` waterfall the answerers listen on. It lives in the UI group because its purpose is human permission, while remaining channel-neutral: it depends only on Cordis and core vocabulary packages, never on a concrete UI.
The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and its answerer phase always produces an outcome: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. Acceptance is synchronous: the service reads the request fields and `agent.session` binding once, requires object agent/session identities, a string `toolName`, optional string `callId`/`reason`, and an AbortSignal-shaped live capability, then shallow-freezes a detached request record while preserving the exact `agent` and signal identities. A malformed request rejects before any audit append; later caller mutation cannot redirect scope, payload, cancellation, policy lookup, or either audit event. The other precondition is an open turn on the captured session — the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask also rejects before appending. Either audit append may reject before commit because returning an unlogged decision would violate the pair. Session contains post-commit observer failures, so an authoritative audit append cannot reject the request or suppress its matching event.
The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and its answerer phase always produces an outcome: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. `ApprovalRequest` is a readonly same-process contract: the service borrows the exact request, agent, session, and abort signal rather than cloning or freezing them. The request requires an open turn because the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask rejects before appending. Either audit append may reject before commit because returning an unlogged decision would violate the pair. Session contains post-commit observer failures, so an authoritative audit append cannot reject the request or suppress its matching event.
The service is the mechanism, answerers are the policy. Answerers are `approval/request` waterfall listeners occupying a single decision slot: answer for an agent you own by returning an outcome without calling `next()`, or delegate an agent you don't recognize by calling `next()` — the chain's built-in default is `unavailable`, so a deployment with no answerer (headless, CI) fails closed with zero configuration. Dispatch is keyed by `req.agent`: a listener registered through `agent.ctx` receives only that agent's questions, while a plain-context listener receives every agent's. Registration order across sibling plugins is not load-order deterministic; compose one terminal answerer per deployment and use `prepend` listeners only for decide-or-delegate gates.

View File

@@ -63,10 +63,8 @@ declare module 'cordis' {
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `req.agent`: a
* listener registered through `agent.ctx` receives only that agent's
* questions, while a plain-context listener receives every agent's.
* `req` is the service's shallow-frozen acceptance snapshot: later caller
* mutation cannot redirect the question, while the `agent` and `signal`
* identity capabilities remain exact.
* @param req - the accepted decision (agent, tool identity, reason, signal).
* `req` is a readonly same-process value borrowed from the caller.
* @param req - the pending decision (agent, tool identity, reason, signal).
* @mode waterfall
*/
'approval/request'(this: Scoped<ApprovalService>, req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome>
@@ -241,11 +239,9 @@ export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): voi
* for an answerer to present it and for the audit events to reconstruct what
* was asked — it deliberately does NOT carry tool arguments: a UI answerer
* attaches the prompt to the already-streamed tool call via `callId` instead
* of re-rendering the call. `request()` synchronously copies and shallow-freezes
* this record before crossing an asynchronous boundary. It reads each field
* and the agent's session binding once, validates the public fixed-field
* contract before audit, and detaches the scalar values; the `agent` and live
* `signal` identity capabilities are preserved rather than cloned or frozen.
* of re-rendering the call. This is a readonly same-process contract:
* `request()` borrows the request and its `agent` and `signal` capabilities
* directly rather than treating them as serialized input.
*/
export interface ApprovalRequest {
/**
@@ -253,28 +249,21 @@ export interface ApprovalRequest {
* UI answerer only answers for agents it owns) and receives the audit
* events on its session log.
*/
agent: Agent
readonly agent: Agent
/** The tool the question is about (presentation and audit). */
toolName: string
readonly toolName: string
/**
* The exact tool call being decided, when the asker has one — lets a UI
* attach the prompt to the tool call it already streamed.
*/
callId?: CallId
readonly callId?: CallId
/** The asker's human-readable explanation of WHY it is asking. */
reason?: string
readonly reason?: string
/**
* Aborting withdraws the question: the request settles `'cancelled'`
* immediately and a late answer from a still-pending answerer is discarded.
*/
signal?: AbortSignal
}
/** Live signal capability accepted at the synchronous request boundary. */
interface AcceptedSignal {
signal: AbortSignal
addEventListener: AbortSignal['addEventListener']
removeEventListener: AbortSignal['removeEventListener']
readonly signal?: AbortSignal
}
/** Plugin config. All optional — `static Config` supplies the defaults. */
@@ -285,7 +274,7 @@ export interface Config {
* (fail-closed with none); `'never'` auto-rejects every ask without
* prompting (the deterministic CI/unattended stance).
*/
policy?: ApprovalPolicy
readonly policy?: ApprovalPolicy
}
/**
@@ -374,104 +363,26 @@ export class ApprovalService extends Service {
}
/**
* Ask the composed answerers to decide one request. Synchronously reads each
* request field and the agent's session binding once, validates the fixed
* agent/session, string, and live-signal contracts, and rejects before any
* audit append when malformed. The signal remains the caller's exact live
* identity capability; it is neither cloned nor frozen. Requires an open
* turn on the accepted session — the audit pair below is turn-enclosed by
* contract (the turn is the log's commit/replay boundary; an idle append
* would be dropped as crash tail on reload) — and likewise throws before
* appending anything when called idle; asking outside a turn is a deferred
* design. The answerer phase always produces an outcome: an aborted signal
* yields `'cancelled'`, a missing or throwing answerer yields `'unavailable'`
* (fail closed), and a rogue non-vocabulary return value is normalized to
* `'unavailable'`. A failure that prevents either audit append from committing
* still rejects; returning an unlogged decision would violate the audit pair.
* The caller-owned request is synchronously
* snapshotted, so later mutation cannot split routing, dispatch payload,
* cancellation, policy lookup, or the audit pair across agents/sessions.
* Appends the
* `approval/asked`/`approval/decided` audit pair (log-only) around the
* decision regardless of outcome. Session contains each post-commit observer
* failure, so an already authoritative audit event cannot make this request
* reject or suppress its matching event.
* Ask the composed answerers to decide one readonly same-process request.
* The service borrows the request, agent, session, and live signal directly.
* The request requires an open turn because the audit pair must be enclosed
* by the durable log's commit/replay boundary; an idle ask rejects before
* appending anything. The answerer phase always produces an outcome: an
* aborted signal yields `'cancelled'`, a missing or throwing answerer yields
* `'unavailable'` (fail closed), and a rogue non-vocabulary return value is
* normalized to `'unavailable'`. A failure that prevents either audit append
* from committing still rejects because returning an unlogged decision would
* violate the pair. Session contains post-commit observer failures, so an
* authoritative append cannot reject the request or suppress its matching
* audit event.
* @param req - the pending decision (agent, tool identity, reason, signal).
* @returns the closed outcome; `'allowed-once'` is the only grant.
* @throws when request acceptance fails, no turn is open, or either audit
* event fails before the session append commit point.
* @throws when no turn is open or either audit event fails before the session
* append commit point.
*/
async request(req: ApprovalRequest): Promise<ApprovalOutcome> {
// Accept one immutable request shape before the first async boundary. The
// caller retains its record and may mutate it as soon as this async method
// returns; identity capabilities stay live, but the record is never reread.
const input: unknown = req
if (typeof input !== 'object' || input === null) {
throw new TypeError('approval.request() requires a request object')
}
const source = input as Record<string, unknown>
const agentInput = source['agent']
const toolName = source['toolName']
const callId = source['callId']
const reason = source['reason']
const signalInput = source['signal']
if (typeof agentInput !== 'object' || agentInput === null) {
throw new TypeError('approval request agent must be an object')
}
if (typeof toolName !== 'string') {
throw new TypeError('approval request toolName must be a string')
}
if (callId !== undefined && typeof callId !== 'string') {
throw new TypeError('approval request callId must be a string when provided')
}
if (reason !== undefined && typeof reason !== 'string') {
throw new TypeError('approval request reason must be a string when provided')
}
let acceptedSignal: AcceptedSignal | undefined
if (signalInput !== undefined) {
if (typeof signalInput !== 'object' || signalInput === null) {
throw new TypeError('approval request signal must be an AbortSignal when provided')
}
const signalRecord = signalInput as unknown as Record<string, unknown>
const aborted = signalRecord['aborted']
const addEventListener = signalRecord['addEventListener']
const removeEventListener = signalRecord['removeEventListener']
if (typeof aborted !== 'boolean'
|| typeof addEventListener !== 'function'
|| typeof removeEventListener !== 'function') {
throw new TypeError('approval request signal must be an AbortSignal when provided')
}
acceptedSignal = {
signal: signalInput as AbortSignal,
addEventListener: addEventListener as AbortSignal['addEventListener'],
removeEventListener: removeEventListener as AbortSignal['removeEventListener'],
}
}
const sessionInput = (agentInput as unknown as Record<string, unknown>)['session']
if (typeof sessionInput !== 'object' || sessionInput === null) {
throw new TypeError('approval request agent session must be an object')
}
const sessionRecord = sessionInput as unknown as Record<string, unknown>
const events = sessionRecord['events']
const append = sessionRecord['append']
if (!Array.isArray(events)) {
throw new TypeError('approval request session events must be an array')
}
if (typeof append !== 'function') {
throw new TypeError('approval request session append must be a function')
}
const agent = agentInput as Agent
const session = sessionInput as Session
const acceptedCallId = callId as CallId | undefined
const signal = signalInput as AbortSignal | undefined
const accepted: Readonly<ApprovalRequest> = Object.freeze({
agent,
toolName,
...acceptedCallId !== undefined ? { callId: acceptedCallId } : {},
...reason !== undefined ? { reason } : {},
...signal !== undefined ? { signal } : {},
})
if (!hasOpenTurn(events)) {
const session = req.agent.session
if (!hasOpenTurn(session.events)) {
throw new Error(
'approval.request() outside an open turn: the approval/asked + approval/decided audit pair '
+ 'must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). '
@@ -479,14 +390,14 @@ export class ApprovalService extends Service {
)
}
const id = ApprovalRequestId(randomUUID())
Reflect.apply(append, session, ['approval/asked', {
session.append('approval/asked', {
id,
toolName: accepted.toolName,
...accepted.callId !== undefined ? { callId: accepted.callId } : {},
...accepted.reason !== undefined ? { reason: accepted.reason } : {},
}])
const outcome = await this.decide(accepted, session, acceptedSignal)
Reflect.apply(append, session, ['approval/decided', { id, outcome }])
toolName: req.toolName,
...req.callId !== undefined ? { callId: req.callId } : {},
...req.reason !== undefined ? { reason: req.reason } : {},
})
const outcome = await this.decide(req, session)
session.append('approval/decided', { id, outcome })
return outcome
}
@@ -502,16 +413,14 @@ export class ApprovalService extends Service {
}
/**
* Dispatch the waterfall, contained and raced against the accepted signal.
* @param req - the detached public request snapshot.
* @param session - the captured session used for policy lookup.
* @param acceptedSignal - the validated live signal capability, if supplied.
* Dispatch the waterfall, contained and raced against the request signal.
* @param req - the borrowed public request.
* @param session - the request agent's session used for policy lookup.
* @returns the normalized closed outcome.
*/
private async decide(
req: Readonly<ApprovalRequest>, session: Session, acceptedSignal: AcceptedSignal | undefined,
): Promise<ApprovalOutcome> {
if (acceptedSignal?.signal.aborted) return 'cancelled'
private async decide(req: ApprovalRequest, session: Session): Promise<ApprovalOutcome> {
const signal = req.signal
if (signal?.aborted) return 'cancelled'
// The 'never' policy is decided HERE, before any dispatch: a listener
// registered with `prepend: true` after this service mounts would sit
// ahead of any gate LISTENER, so a listener-shaped gate cannot keep the
@@ -535,13 +444,18 @@ export class ApprovalService extends Service {
// tool call open — the seam contains its callbacks.
() => 'unavailable',
)
if (acceptedSignal === undefined) return answer
const { signal, addEventListener, removeEventListener } = acceptedSignal
if (signal === undefined) return answer
return await new Promise<ApprovalOutcome>((resolve) => {
const onAbort = () => { resolve('cancelled') }
addEventListener.call(signal, 'abort', onAbort, { once: true })
const onAbort = () => {
signal.removeEventListener('abort', onAbort)
resolve('cancelled')
}
signal.addEventListener('abort', onAbort, { once: true })
// Abort can win after the initial check but before listener installation.
// Recheck at the settlement boundary so that edge still cancels.
if (signal.aborted) onAbort()
void answer.then((outcome) => {
removeEventListener.call(signal, 'abort', onAbort)
signal.removeEventListener('abort', onAbort)
// After an abort won the race this resolve is a settled-promise no-op:
// the late answer is discarded by construction.
resolve(outcome)

View File

@@ -2,7 +2,8 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import { carrierKeyOf, scopeHost } from '@deepseek-ai/dsh-scope'
import { carrierKeyOf, createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -39,158 +40,6 @@ function requestOf(agent: Agent, overrides: Partial<ApprovalRequest> = {}): Appr
}
describe('ApprovalService.request', () => {
it('rejects malformed fixed fields and identities before appending or dispatching', async () => {
const ctx = await mounted()
const consulted = vi.fn()
ctx.on('approval/request', () => {
consulted()
return Promise.resolve<ApprovalOutcome>('allowed-once')
})
const { agent, appended } = fakeAgent()
const badSessionAppends: Array<ReturnType<typeof vi.fn>> = []
const badSession = (events: unknown, append: unknown): Agent => ({
session: { events, append },
}) as unknown as Agent
const appendSpy = (): ReturnType<typeof vi.fn> => {
const append = vi.fn()
badSessionAppends.push(append)
return append
}
const validSignalShape = {
aborted: false,
addEventListener: () => {},
removeEventListener: () => {},
}
const cases: Array<{ request: unknown; message: string }> = [
{ request: null, message: 'requires a request object' },
{ request: 1, message: 'requires a request object' },
{ request: { agent: null, toolName: 'echo' }, message: 'agent must be an object' },
{ request: { agent: 1, toolName: 'echo' }, message: 'agent must be an object' },
{ request: { agent, toolName: 1 }, message: 'toolName must be a string' },
{ request: { agent, toolName: 'echo', callId: 1 }, message: 'callId must be a string' },
{ request: { agent, toolName: 'echo', reason: 1 }, message: 'reason must be a string' },
{ request: { agent, toolName: 'echo', signal: null }, message: 'signal must be an AbortSignal' },
{ request: { agent, toolName: 'echo', signal: 1 }, message: 'signal must be an AbortSignal' },
{
request: { agent, toolName: 'echo', signal: { ...validSignalShape, aborted: 'no' } },
message: 'signal must be an AbortSignal',
},
{
request: { agent, toolName: 'echo', signal: { ...validSignalShape, addEventListener: 1 } },
message: 'signal must be an AbortSignal',
},
{
request: { agent, toolName: 'echo', signal: { ...validSignalShape, removeEventListener: 1 } },
message: 'signal must be an AbortSignal',
},
{
request: { agent: { session: null }, toolName: 'echo' },
message: 'agent session must be an object',
},
{
request: { agent: { session: 1 }, toolName: 'echo' },
message: 'agent session must be an object',
},
{
request: { agent: badSession(null, appendSpy()), toolName: 'echo' },
message: 'session events must be an array',
},
{
request: { agent: badSession([{ type: 'turn/start' }], 1), toolName: 'echo' },
message: 'session append must be a function',
},
]
for (const { request, message } of cases) {
await expect(ctx.approval.request(request as ApprovalRequest)).rejects.toThrow(message)
}
expect(appended).toEqual([])
for (const append of badSessionAppends) expect(append).not.toHaveBeenCalled()
expect(consulted).not.toHaveBeenCalled()
})
it('reads request fields, the agent session, and the session append method once', async () => {
const ctx = await mounted()
const { agent: acceptedSessionOwner, appended: acceptedAudit } = fakeAgent()
const { agent: replacementAgent, appended: replacementAudit } = fakeAgent()
const acceptedSession = acceptedSessionOwner.session
const acceptedAppend = acceptedSession.append.bind(acceptedSession)
const signal = new AbortController().signal
const reads = {
agent: 0,
toolName: 0,
callId: 0,
reason: 0,
signal: 0,
session: 0,
append: 0,
}
const session = {
events: acceptedSession.events,
get append(): Session['append'] {
reads.append += 1
return reads.append === 1 ? acceptedAppend : undefined as unknown as Session['append']
},
} as Session
const agent = Object.defineProperty({}, 'session', {
enumerable: true,
get: () => {
reads.session += 1
return reads.session === 1 ? session : replacementAgent.session
},
}) as Agent
const request = Object.defineProperties({}, {
agent: {
enumerable: true,
get: () => (++reads.agent === 1 ? agent : null),
},
toolName: {
enumerable: true,
get: () => (++reads.toolName === 1 ? 'stable-tool' : 1),
},
callId: {
enumerable: true,
get: () => (++reads.callId === 1 ? CallId('stable-call') : {}),
},
reason: {
enumerable: true,
get: () => (++reads.reason === 1 ? 'stable reason' : {}),
},
signal: {
enumerable: true,
get: () => (++reads.signal === 1 ? signal : {}),
},
}) as ApprovalRequest
let received: ApprovalRequest | undefined
ctx.on('approval/request', (accepted) => {
received = accepted
return Promise.resolve<ApprovalOutcome>('allowed-once')
})
await expect(ctx.approval.request(request)).resolves.toBe('allowed-once')
expect(reads).toEqual({
agent: 1,
toolName: 1,
callId: 1,
reason: 1,
signal: 1,
session: 1,
append: 1,
})
expect(received).toMatchObject({
agent,
toolName: 'stable-tool',
callId: 'stable-call',
reason: 'stable reason',
signal,
})
expect(Object.isFrozen(received)).toBe(true)
expect(acceptedAudit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided'])
expect(replacementAudit).toEqual([])
})
it('throws before appending anything when no turn has ever opened (idle ask)', async () => {
const ctx = await mounted()
const { agent, appended } = fakeAgent([])
@@ -230,77 +79,38 @@ describe('ApprovalService.request', () => {
expect(Object.keys(appended[0]?.data ?? {}).sort()).toEqual(['id', 'toolName'])
})
it('snapshots request identity, scope, payload, and audit before deferred dispatch', async () => {
it('borrows the exact readonly request for scoped dispatch and audit', async () => {
const ctx = await mounted()
const { agent: acceptedAgent, appended: acceptedAudit } = fakeAgent()
const { agent: replacementAgent, appended: replacementAudit } = fakeAgent()
const host = await scopeHost(ctx, ['approval'])
const acceptedScope = host.mint(acceptedAgent)
const replacementScope = host.mint(replacementAgent)
const dispatchStarted = Promise.withResolvers<'started'>()
const answer = Promise.withResolvers<ApprovalOutcome>()
const originalSignal = new AbortController().signal
const replacementSignal = new AbortController().signal
let heardBy: 'accepted' | 'replacement' | undefined
const { agent, appended } = fakeAgent()
let scope!: Scope
const scopeFiber = await ctx.plugin(Object.assign((inner: Context) => {
scope = createScope(inner, agent)
}, { inject: ['approval'] }))
let received: ApprovalRequest | undefined
let carrier: unknown
acceptedScope.ctx.on('approval/request', function (req) {
heardBy = 'accepted'
scope.ctx.on('approval/request', function (req) {
received = req
carrier = carrierKeyOf(this)
dispatchStarted.resolve('started')
return answer.promise
return Promise.resolve<ApprovalOutcome>('allowed-once')
})
replacementScope.ctx.on('approval/request', function (req) {
heardBy = 'replacement'
received = req
carrier = carrierKeyOf(this)
dispatchStarted.resolve('started')
return answer.promise
})
const request = requestOf(acceptedAgent, {
toolName: 'original-tool',
callId: CallId('original-call'),
reason: 'original reason',
signal: originalSignal,
const request = requestOf(agent, {
toolName: 'scoped-tool',
callId: CallId('scoped-call'),
reason: 'scoped reason',
})
const pending = ctx.approval.request(request)
// request() has returned, but the answerer dispatch is deliberately queued
// in a microtask. Mutating the caller-owned record must not redirect it.
request.agent = replacementAgent
request.toolName = 'mutated-before-dispatch'
request.callId = CallId('mutated-call')
request.reason = 'mutated reason'
request.signal = replacementSignal
await dispatchStarted.promise
// Mutation while the answer is pending must not redirect the final audit.
request.toolName = 'mutated-after-dispatch'
request.reason = 'mutated again'
answer.resolve('allowed-once')
await expect(pending).resolves.toBe('allowed-once')
expect(heardBy).toBe('accepted')
expect(carrier).toBe(acceptedAgent)
expect(received).not.toBe(request)
expect(Object.isFrozen(received)).toBe(true)
expect(received).toMatchObject({
agent: acceptedAgent,
toolName: 'original-tool',
callId: 'original-call',
reason: 'original reason',
signal: originalSignal,
await expect(ctx.approval.request(request)).resolves.toBe('allowed-once')
expect(carrier).toBe(agent)
expect(received).toBe(request)
expect(appended).toHaveLength(2)
expect(appended[0]?.data).toMatchObject({
toolName: 'scoped-tool',
callId: 'scoped-call',
reason: 'scoped reason',
})
expect(acceptedAudit).toHaveLength(2)
expect(acceptedAudit[0]?.data).toMatchObject({
toolName: 'original-tool',
callId: 'original-call',
reason: 'original reason',
})
expect(acceptedAudit[1]?.data).toMatchObject({ outcome: 'allowed-once' })
expect(acceptedAudit[1]?.data['id']).toBe(acceptedAudit[0]?.data['id'])
expect(replacementAudit).toEqual([])
await host.dispose()
expect(appended[1]?.data).toMatchObject({ outcome: 'allowed-once' })
expect(appended[1]?.data['id']).toBe(appended[0]?.data['id'])
await scopeFiber.dispose()
})
it('contains an approval/asked observer throw after append and still completes the pair', async () => {
@@ -388,9 +198,12 @@ describe('ApprovalService.request', () => {
const ctx = await mounted()
const { agent: agentA } = fakeAgent()
const { agent: agentB } = fakeAgent()
const host = await scopeHost(ctx, ['approval'])
const scopeA = host.mint(agentA)
const scopeB = host.mint(agentB)
let scopeA!: Scope
let scopeB!: Scope
const scopesFiber = await ctx.plugin(Object.assign((inner: Context) => {
scopeA = createScope(inner, agentA)
scopeB = createScope(inner, agentB)
}, { inject: ['approval'] }))
const heard: string[] = []
ctx.on('approval/request', (req, next) => {
heard.push(req.agent === agentA ? 'global:A' : 'global:B')
@@ -409,14 +222,16 @@ describe('ApprovalService.request', () => {
await expect(ctx.approval.request(requestOf(agentB))).resolves.toBe('unavailable')
expect(heard).toEqual(['global:A', 'scoped:A', 'global:B', 'scoped:B'])
await host.dispose()
await scopesFiber.dispose()
})
it('keys the scoped dispatch carrier to the exact request agent', async () => {
const ctx = await mounted()
const { agent } = fakeAgent()
const host = await scopeHost(ctx, ['approval'])
const scope = host.mint(agent)
let scope!: Scope
const scopeFiber = await ctx.plugin(Object.assign((inner: Context) => {
scope = createScope(inner, agent)
}, { inject: ['approval'] }))
let seenKey: object | undefined
scope.ctx.on('approval/request', function (req, next) {
seenKey = carrierKeyOf(this)
@@ -427,7 +242,7 @@ describe('ApprovalService.request', () => {
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
expect(seenKey).toBe(agent)
await host.dispose()
await scopeFiber.dispose()
})
it('contains a throwing answerer as unavailable', async () => {
@@ -466,6 +281,26 @@ describe('ApprovalService.request', () => {
expect(appended[1]?.data).toMatchObject({ outcome: 'cancelled' })
})
it('does not miss an abort between the initial check and listener installation', async () => {
const ctx = await mounted()
const { agent, appended } = fakeAgent()
const answer = Promise.withResolvers<ApprovalOutcome>()
ctx.on('approval/request', () => answer.promise)
const controller = new AbortController()
const addEventListener = controller.signal.addEventListener.bind(controller.signal)
const add = vi.spyOn(controller.signal, 'addEventListener').mockImplementation((type, listener, options) => {
controller.abort()
addEventListener(type, listener, options)
})
await expect(ctx.approval.request(requestOf(agent, { signal: controller.signal }))).resolves.toBe('cancelled')
answer.resolve('allowed-once')
await Promise.resolve()
expect(add).toHaveBeenCalledOnce()
expect(appended[1]?.data).toMatchObject({ outcome: 'cancelled' })
})
it('resolves cancelled when the signal aborts mid-question and discards the late answer', async () => {
const ctx = await mounted()
const { agent, appended } = fakeAgent()