Files
deepseek-harness/packages/ui/user-approval/tests/approval.spec.ts
Tianyi Cui 3529b3c166 fix(scope): close final ownership races
Drain idle injection flushes before agent teardown, snapshot approval and subagent provider inputs, and gate subagent lifecycle events on real child readiness. Align the RFCs and generated contracts with the hardened behavior.
2026-07-12 00:31:46 +08:00

604 lines
27 KiB
TypeScript

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 SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ApprovalService, { ApprovalOutcome, ApprovalRequest, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
/**
* A minimal Agent stand-in — the service only reaches `agent.session.append`
* and folds `.events`. Seeded inside an open turn by default (request()'s
* turn-enclosure precondition); pass `seed` to stage idle/closed logs.
* Returns the recorded audit appends alongside the fake.
*/
function fakeAgent(seed: Array<{ type: string }> = [{ type: 'turn/start' }, { type: 'user/message' }]): { agent: Agent; appended: Array<{ type: string; data: Record<string, unknown> }> } {
const appended: Array<{ type: string; data: Record<string, unknown> }> = []
const agent = {
session: {
events: seed,
append: (type: string, data: Record<string, unknown>) => {
appended.push({ type, data })
return { type, data } as unknown as SessionEvent
},
},
} as unknown as Agent
return { agent, appended }
}
async function mounted(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(ApprovalService)
return ctx
}
function requestOf(agent: Agent, overrides: Partial<ApprovalRequest> = {}): ApprovalRequest {
return { agent, toolName: 'echo', ...overrides }
}
describe('ApprovalService.request', () => {
it('throws before appending anything when no turn has ever opened (idle ask)', async () => {
const ctx = await mounted()
const { agent, appended } = fakeAgent([])
await expect(ctx.approval.request(requestOf(agent))).rejects.toThrow(/outside an open turn/)
expect(appended).toHaveLength(0)
})
it('throws between turns — a closed turn does not satisfy the enclosure precondition', async () => {
const ctx = await mounted()
const { agent, appended } = fakeAgent([{ type: 'turn/start' }, { type: 'turn/end' }])
await expect(ctx.approval.request(requestOf(agent))).rejects.toThrow(/outside an open turn/)
expect(appended).toHaveLength(0)
})
it('fails closed to unavailable when nobody listens, auditing the asked/decided pair', async () => {
const ctx = await mounted()
const { agent, appended } = fakeAgent()
const outcome = await ctx.approval.request(requestOf(agent, { callId: CallId('call-1'), reason: 'hook says ask' }))
expect(outcome).toBe('unavailable')
expect(appended.map(e => e.type)).toEqual(['approval/asked', 'approval/decided'])
const [asked, decided] = appended
expect(asked?.data).toMatchObject({ toolName: 'echo', callId: 'call-1', reason: 'hook says ask' })
expect(decided?.data).toMatchObject({ outcome: 'unavailable' })
expect(decided?.data['id']).toBe(asked?.data['id'])
})
it('omits absent optional fields from the asked audit event', async () => {
const ctx = await mounted()
const { agent, appended } = fakeAgent()
await ctx.approval.request(requestOf(agent))
expect(Object.keys(appended[0]?.data ?? {}).sort()).toEqual(['id', 'toolName'])
})
it('snapshots request identity, scope, payload, and audit before deferred dispatch', 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
let received: ApprovalRequest | undefined
let carrier: unknown
acceptedScope.ctx.on('approval/request', function (req) {
heardBy = 'accepted'
received = req
carrier = carrierKeyOf(this)
dispatchStarted.resolve('started')
return answer.promise
})
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 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,
})
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()
})
it('contains an approval/asked observer throw after append and still completes the pair', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(ApprovalService)
const session = ctx.sessions.create(SessionId('asked-observer-throw'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const agent = { session } as unknown as Agent
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
ctx.on('session/event', (_session, event) => {
if (event.type === 'approval/asked') throw new Error('observer failed after asked append')
})
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('allowed-once')
const audit = session.events.filter(event => event.type.startsWith('approval/'))
const asked = session.events.find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked')
const decided = session.events.find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided')
expect(audit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided'])
expect(decided?.data.id).toBe(asked?.data.id)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('approval/asked observer threw'))
})
it('contains an approval/decided observer throw after append and still resolves', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(ApprovalService)
const session = ctx.sessions.create(SessionId('decided-observer-throw'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const agent = { session } as unknown as Agent
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
ctx.on('session/event', (_session, event) => {
if (event.type === 'approval/decided') throw new Error('observer failed after decided append')
})
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('rejected'))
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('rejected')
const audit = session.events.filter(event => event.type.startsWith('approval/'))
const asked = session.events.find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked')
const decided = session.events.find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided')
expect(audit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided'])
expect(decided?.data).toMatchObject({ id: asked?.data.id, outcome: 'rejected' })
expect(warn).toHaveBeenCalledWith(expect.stringContaining('approval/decided observer threw'))
})
it('does not misclassify a pre-append failure as an observer failure', async () => {
const ctx = await mounted()
const failure = new Error('append failed before log growth')
const agent = {
session: {
events: [{ type: 'turn/start' }],
append: () => { throw failure },
},
} as unknown as Agent
await expect(ctx.approval.request(requestOf(agent))).rejects.toBe(failure)
})
it('returns the first answering listener outcome (single decision slot)', async () => {
const ctx = await mounted()
const { agent } = fakeAgent()
let secondRan = false
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
ctx.on('approval/request', () => {
secondRan = true
return Promise.resolve<ApprovalOutcome>('rejected')
})
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('allowed-once')
expect(secondRan).toBe(false)
})
it('lets a non-owning listener delegate via next() down to the fail-closed default', async () => {
const ctx = await mounted()
const { agent } = fakeAgent()
ctx.on('approval/request', (_req, next) => next())
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
})
it('dispatches to global and matching agent-scoped listeners, never a foreign scope', async () => {
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)
const heard: string[] = []
ctx.on('approval/request', (req, next) => {
heard.push(req.agent === agentA ? 'global:A' : 'global:B')
return next()
})
scopeA.ctx.on('approval/request', (_req, next) => {
heard.push('scoped:A')
return next()
})
scopeB.ctx.on('approval/request', (_req, next) => {
heard.push('scoped:B')
return next()
})
await expect(ctx.approval.request(requestOf(agentA))).resolves.toBe('unavailable')
await expect(ctx.approval.request(requestOf(agentB))).resolves.toBe('unavailable')
expect(heard).toEqual(['global:A', 'scoped:A', 'global:B', 'scoped:B'])
await host.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 seenKey: object | undefined
scope.ctx.on('approval/request', function (req, next) {
seenKey = carrierKeyOf(this)
expect(req.agent).toBe(agent)
return next()
})
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
expect(seenKey).toBe(agent)
await host.dispose()
})
it('contains a throwing answerer as unavailable', async () => {
const ctx = await mounted()
const { agent, appended } = fakeAgent()
ctx.on('approval/request', () => Promise.reject(new Error('transport died')))
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
expect(appended[1]?.data).toMatchObject({ outcome: 'unavailable' })
})
it('normalizes a rogue non-vocabulary answer to unavailable', async () => {
const ctx = await mounted()
const { agent } = fakeAgent()
// A JS answerer can return anything; the seam must not leak it into
// callers' closed-union switches.
ctx.on('approval/request', () => Promise.resolve('yolo' as ApprovalOutcome))
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
})
it('settles cancelled immediately on an already-aborted signal without asking anyone', async () => {
const ctx = await mounted()
const { agent, appended } = fakeAgent()
let asked = false
ctx.on('approval/request', () => {
asked = true
return Promise.resolve<ApprovalOutcome>('allowed-once')
})
const outcome = await ctx.approval.request(requestOf(agent, { signal: AbortSignal.abort() }))
expect(outcome).toBe('cancelled')
expect(asked).toBe(false)
expect(appended.map(e => e.type)).toEqual(['approval/asked', 'approval/decided'])
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()
let settleLate: ((outcome: ApprovalOutcome) => void) | undefined
ctx.on('approval/request', () => new Promise<ApprovalOutcome>((resolve) => { settleLate = resolve }))
const controller = new AbortController()
const pending = ctx.approval.request(requestOf(agent, { signal: controller.signal }))
controller.abort()
await expect(pending).resolves.toBe('cancelled')
// The answerer settles after the fact: no second decided event appears.
settleLate?.('allowed-once')
await Promise.resolve()
expect(appended.filter(e => e.type === 'approval/decided')).toHaveLength(1)
expect(appended[1]?.data).toMatchObject({ outcome: 'cancelled' })
})
it('discards a late REJECTION after abort without an unhandled rejection', async () => {
const ctx = await mounted()
const { agent } = fakeAgent()
let rejectLate: ((error: Error) => void) | undefined
ctx.on('approval/request', () => new Promise<ApprovalOutcome>((_resolve, reject) => { rejectLate = reject }))
const controller = new AbortController()
const pending = ctx.approval.request(requestOf(agent, { signal: controller.signal }))
controller.abort()
await expect(pending).resolves.toBe('cancelled')
rejectLate?.(new Error('answered too late'))
// Drain microtasks: the contained rejection must not escape the seam.
await new Promise((resolve) => { setTimeout(resolve, 0) })
})
it('resolves the answer when the signal never aborts', async () => {
const ctx = await mounted()
const { agent } = fakeAgent()
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('rejected'))
const controller = new AbortController()
await expect(ctx.approval.request(requestOf(agent, { signal: controller.signal }))).resolves.toBe('rejected')
})
it('issues a fresh id per request', async () => {
const ctx = await mounted()
const { agent, appended } = fakeAgent()
await ctx.approval.request(requestOf(agent))
await ctx.approval.request(requestOf(agent))
const ids = appended.filter(e => e.type === 'approval/asked').map(e => e.data['id'])
expect(ids).toHaveLength(2)
expect(ids[0]).not.toBe(ids[1])
})
it('drops a disposed plugin listener from the chain (HMR safety)', async () => {
const ctx = await mounted()
const { agent } = fakeAgent()
const fiber = await ctx.plugin((inner: Context) => {
inner.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
})
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('allowed-once')
await fiber.dispose()
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
})
})
describe('approval policy (the approval/policy fold)', () => {
const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).'
const ASK_MARKER = '<!-- dsh-user-approval-policy:ask -->'
const NEVER_MARKER = '<!-- dsh-user-approval-policy:never -->'
/**
* An agent stand-in over a REAL Session — gate, section, and narrator fold
* real events; the opened turn satisfies request()'s enclosure precondition.
*/
function sessionAgent(id: string): { agent: Agent; session: Session; injected: string[] } {
const session = new Session(SessionId(id))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const injected: string[] = []
const agent = {
id,
session,
inject: (content: { type: string; text: string }[]) => { injected.push(content[0]?.text ?? '') },
} as unknown as Agent
return { agent, session, injected }
}
const preStep = (ctx: Context, agent: Agent): Promise<void> =>
ctx.serial('agent/pre-step', agent, 1, 1, '', [], new AbortController().signal)
/** Append a `request/header` snapshot whose system text is exactly `system`. */
function appendHeader(session: Session, system: string): void {
session.append('request/header', { header: { config: { model: 'mock' }, system }, reason: 'initial' })
}
it('folds to the last event, or undefined without one', () => {
const { session } = sessionAgent('sess-fold')
expect(effectiveApprovalPolicy(session.events)).toBeUndefined()
setApprovalPolicy(session, 'never')
setApprovalPolicy(session, 'ask')
expect(effectiveApprovalPolicy(session.events)).toBe('ask')
expect(session.events.at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } })
})
it('defaults a schema-less construction to ask (the ?? narrows the optional TYPE)', async () => {
// Direct construction bypasses the plugin schema (the SystemPrompt-test
// precedent for covering a defaulted Config field's type-narrowing ??).
const ctx = new Context()
const service = new ApprovalService(ctx, {})
const { agent } = sessionAgent('sess-bare-config')
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
await expect(service.request({ agent, toolName: 'echo' })).resolves.toBe('allowed-once')
})
it('contains an answerer that throws SYNCHRONOUSLY as unavailable', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService)
const { agent } = sessionAgent('sess-syncthrow')
ctx.on('approval/request', () => { throw new Error('sync bug') })
await expect(ctx.approval.request({ agent, toolName: 'echo' })).resolves.toBe('unavailable')
})
it('a never config rejects deterministically without consulting any answerer', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService, { policy: 'never' })
const consulted = vi.fn()
ctx.on('approval/request', (_req, next) => { consulted(); return next() })
const { agent, session } = sessionAgent('sess-gate-1')
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected')
expect(consulted).not.toHaveBeenCalled()
// The audit pair still lands on the session log.
expect(session.events.filter(e => e.type === 'approval/asked')).toHaveLength(1)
expect(session.events.filter(e => e.type === 'approval/decided')).toHaveLength(1)
})
it('the gate decides FIRST even against an answerer registered before the service (prepend)', async () => {
const ctx = new Context()
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
await ctx.plugin(ApprovalService, { policy: 'never' })
const { agent } = sessionAgent('sess-gate-2')
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected')
})
it('never is unbypassable even by an answerer PREPENDED after the service mounts', async () => {
// Cordis prepend unshifts ahead of every existing listener, including
// any gate LISTENER the service could register — which is exactly why
// the 'never' decision lives inside request() instead. The eager grant
// below must never be consulted.
const ctx = new Context()
await ctx.plugin(ApprovalService, { policy: 'never' })
const consulted = vi.fn()
ctx.on('approval/request', () => { consulted(); return Promise.resolve<ApprovalOutcome>('allowed-once') }, { prepend: true })
const { agent, appended } = fakeAgent()
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('rejected')
expect(consulted).not.toHaveBeenCalled()
expect(appended.map(e => e.type)).toEqual(['approval/asked', 'approval/decided'])
})
it('a session override outranks the configured default, in both directions', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService, { policy: 'never' })
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
const { agent, session } = sessionAgent('sess-gate-3')
setApprovalPolicy(session, 'ask')
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('allowed-once')
setApprovalPolicy(session, 'never')
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected')
})
it('states never (and only never) in prose while recording either policy with a source-owned marker', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ApprovalService)
const askAgent = sessionAgent('sess-sect-ask').agent
const { agent: neverAgent, session } = sessionAgent('sess-sect-never')
setApprovalPolicy(session, 'never')
const sectionFor = async (context: object) =>
(await ctx.systemPrompt.assemble(context)).sections.find(s => s.name === 'approval:policy')?.text
expect(await sectionFor({ agent: askAgent })).toBe(ASK_MARKER)
expect(await sectionFor({ agent: neverAgent })).toBe(`${NEVER_SENTENCE}\n${NEVER_MARKER}`)
// A bare assemble (no agent) has no session to state.
expect(await sectionFor({})).toBe('')
})
it('narrates nothing cold, once per coalesced switch (user wording), and idempotently', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService)
const { agent, session, injected } = sessionAgent('sess-narr-1')
await preStep(ctx, agent)
expect(injected).toEqual([])
setApprovalPolicy(session, 'never')
setApprovalPolicy(session, 'ask')
setApprovalPolicy(session, 'never')
await preStep(ctx, agent)
expect(injected).toEqual(['The approval policy changed from "ask" to "never" (changed by the user).'])
await preStep(ctx, agent)
expect(injected).toHaveLength(1)
setApprovalPolicy(session, 'ask')
setApprovalPolicy(session, 'never')
await preStep(ctx, agent)
expect(injected).toHaveLength(1)
})
it('reads what the model was told back from the folded header text after a restart', async () => {
// A session whose last request carried the never sentence resumes under
// an ask default: the narrator attributes the change to the operator.
const ctx = new Context()
await ctx.plugin(ApprovalService)
const { agent, session, injected } = sessionAgent('sess-narr-2')
appendHeader(session, `persona\n\n${NEVER_SENTENCE}\n${NEVER_MARKER}`)
await preStep(ctx, agent)
expect(injected).toEqual(['The approval policy changed from "never" to "ask" (changed by the operator/config).'])
})
it('narrates a config default drift from the logged ask marker', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService, { policy: 'never' })
const { agent, session, injected } = sessionAgent('sess-narr-3')
appendHeader(session, `persona only\n${ASK_MARKER}`)
await preStep(ctx, agent)
expect(injected).toEqual(['The approval policy changed from "ask" to "never" (changed by the operator/config).'])
})
it('a pinned override survives a default change silently', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService, { policy: 'never' })
const { agent, session, injected } = sessionAgent('sess-narr-4')
appendHeader(session, `persona only\n${ASK_MARKER}`)
setApprovalPolicy(session, 'ask')
appendHeader(session, `persona only\n${ASK_MARKER}`)
await preStep(ctx, agent)
expect(injected).toEqual([])
})
it('does not infer never from deployment prose that quotes the never sentence', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService)
const { agent, session, injected } = sessionAgent('sess-narr-spoof-prose')
appendHeader(session, `persona quotes this warning: ${NEVER_SENTENCE}\n${ASK_MARKER}`)
await preStep(ctx, agent)
expect(injected).toEqual([])
})
it('treats a legacy header with no source-owned marker as untold', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService, { policy: 'never' })
const { agent, session, injected } = sessionAgent('sess-narr-unmarked-header')
appendHeader(session, 'legacy persona-only header')
await preStep(ctx, agent)
expect(injected).toEqual([])
})
it('uses the service marker after an earlier persona marker', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService)
const { agent, session, injected } = sessionAgent('sess-narr-spoof-marker')
appendHeader(session, `persona quotes ${NEVER_MARKER}\n${ASK_MARKER}`)
await preStep(ctx, agent)
expect(injected).toEqual([])
})
it('disposes the service prompt section and pre-step narrator together (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
const fiber = await ctx.plugin(ApprovalService)
const live = sessionAgent('sess-hmr-service-live')
const afterDispose = sessionAgent('sess-hmr-service-disposed')
const sectionFor = async () =>
(await ctx.systemPrompt.assemble({ agent: live.agent })).sections.find(section => section.name === 'approval:policy')
expect(await sectionFor()).toBeDefined()
appendHeader(live.session, `persona\n${ASK_MARKER}`)
setApprovalPolicy(live.session, 'never')
await preStep(ctx, live.agent)
expect(live.injected).toEqual(['The approval policy changed from "ask" to "never" (changed by the user).'])
appendHeader(afterDispose.session, `persona\n${ASK_MARKER}`)
setApprovalPolicy(afterDispose.session, 'never')
await fiber.dispose()
expect(await sectionFor()).toBeUndefined()
await preStep(ctx, afterDispose.agent)
expect(afterDispose.injected).toEqual([])
})
})