Files
deepseek-harness/packages/ui/user-approval/tests/approval.spec.ts
Tianyi Cui cfceb8452b subagent: seed inherited policy events at creation
The parent implementation introduced sandboxMode and approvalPolicy as generic SessionHeader fields, then propagated those fields through both persistence backends, session-query indexes, collision checks, policy-specific seed-boundary folds, catalogs, and a broad test matrix. That storage plane is unnecessary: Session already accepts a validated constructor seed, and persistence captures that seed when the session is announced before committing its first batch.

Capture each parent override synchronously at delegation, append source-tagged sandbox/mode and approval/policy records after the optional fork prefix, and create the child with that combined seed. Keeping header.seedLength at the original fork-prefix length preserves lineage while ordinary last-event-wins folds make the inherited records outrank stale parent history and remain subordinate to later child switches. Unswitched parents still stamp nothing, so children continue to follow deployment defaults.

Remove the generic header fields and every persistence/query/schema branch built around them. Collapse the inheritance suite from ten leaking scenarios to four owned-context cases covering real filesystem confinement, stale fork precedence, delegation-time capture, and the no-override path. The assembled headless snapshot now asserts the persisted inheritance event directly.

This keeps the security behavior while restoring policy ownership to the existing event log and deleting the speculative durability machinery that the original tests did not exercise.
2026-07-28 21:31:17 +08:00

595 lines
26 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
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'
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('borrows the exact readonly request for scoped dispatch and audit', async () => {
const ctx = await mounted()
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
scope.ctx.on('approval/request', function (req) {
received = req
carrier = carrierKeyOf(this)
return Promise.resolve<ApprovalOutcome>('allowed-once')
})
const request = requestOf(agent, {
toolName: 'scoped-tool',
callId: CallId('scoped-call'),
reason: 'scoped reason',
})
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(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 () => {
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('session/event listener threw: Error: observer failed after asked append'))
})
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('session/event listener threw: Error: observer failed after decided append'))
})
it('propagates an append failure that prevented audit log growth', 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()
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')
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 scopesFiber.dispose()
})
it('keys the scoped dispatch carrier to the exact request agent', async () => {
const ctx = await mounted()
const { agent } = fakeAgent()
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)
expect(req.agent).toBe(agent)
return next()
})
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
expect(seenKey).toBe(agent)
await scopeFiber.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: (input: { content: Array<{ type: string; text: string }> }) => {
injected.push(input.content[0]?.text ?? '')
},
} as unknown as Agent
return { agent, session, injected }
}
const preStep = (ctx: Context, agent: Agent): Promise<void> =>
agentEvents(ctx, agent).serial('agent/step', 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: { provider: 'mock', 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('rejects a policy outside the closed vocabulary before appending', () => {
const append = vi.fn()
const session = { append } as unknown as Session
expect(() => { setApprovalPolicy(session, 'sometimes' as Parameters<typeof setApprovalPolicy>[1]) })
.toThrow('approval policy must be one of "ask" or "never"')
expect(append).not.toHaveBeenCalled()
})
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. This eager grant would bypass a listener-based gate and therefore must never run.
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')
expect(ctx.approval.overrideOf(session)).toBeUndefined()
setApprovalPolicy(session, 'ask')
expect(ctx.approval.overrideOf(session)).toBe('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('attributes a constructor-seeded policy event to delegation', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService)
const { agent, session, injected } = sessionAgent('sess-narr-inherited')
appendHeader(session, ASK_MARKER)
session.append('approval/policy', { policy: 'never', source: 'delegation' })
await preStep(ctx, agent)
expect(injected).toEqual(['The approval policy changed from "ask" to "never" (inherited from the delegating session).'])
})
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([])
})
})