import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { CallId, createUserMessage } 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 }> } { const appended: Array<{ type: string; data: Record }> = [] const agent = { session: { events: seed, append: (type: string, data: Record) => { appended.push({ type, data }) return { type, data } as unknown as SessionEvent }, }, } as unknown as Agent return { agent, appended } } async function mounted(): Promise { const ctx = new Context() await ctx.plugin(ApprovalService) return ctx } function requestOf(agent: Agent, overrides: Partial = {}): 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('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 }) 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('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 }) 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('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('allowed-once')) ctx.on('approval/request', () => { secondRan = true return Promise.resolve('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('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((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((_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('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('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_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.' /** * 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 } { const session = new Session(SessionId(id)) session.append('turn/start', { turn: 1 }) const agent = { id, session, inject: () => { throw new Error('step-boundary narration must not use agent.inject()') }, } as unknown as Agent return { agent, session } } const submitPrompt = async (ctx: Context, agent: Agent): Promise => { const signal = new AbortController().signal const configured = ctx.get('approval')?.config.policy ?? 'ask' const current = effectiveApprovalPolicy(agent.session.events) ?? configured const runtimeContext = createUserMessage({ content: [{ type: 'text', text: current === 'never' ? NEVER_SENTENCE : ASK_SENTENCE }], source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }, }) const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', [], { turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [runtimeContext] }), ) if (decision.kind === 'enter') { for (const message of decision.messages) { agent.session.append('user/message', message, { surfaceOp: 'append' }) } appendHeader(agent.session) } } const narrations = (session: Session): string[] => session.events.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === 'user-approval' ? [event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')] : []) /** Append the stable system header that follows one entered prompt. */ function appendHeader(session: Session): void { session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' }, system: 'persona' }, reason: 'initial' }) } /** Append one model-visible runtime-context snapshot owned by system-prompt. */ function appendToldPolicy(session: Session, policy: 'ask' | 'never'): void { session.append('user/message', createUserMessage({ content: [{ type: 'text', text: policy === 'never' ? NEVER_SENTENCE : ASK_SENTENCE }], source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }, }), { surfaceOp: 'append' }) } 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[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('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('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('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('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('contributes the complete current ask or never policy as cache-safe context', 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 contextFor = async (context: object) => (await ctx.systemPrompt.assemble(context)).contexts.find(entry => entry.name === 'approval:policy')?.text expect(await contextFor({ agent: askAgent })).toBe(ASK_SENTENCE) expect(await contextFor({ agent: neverAgent })).toBe(NEVER_SENTENCE) // A bare assemble (no agent) has no session to state. expect(await contextFor({})).toBe('') }) it('reflects the latest durable switch and stays byte-stable while unchanged', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ApprovalService) const { agent, session } = sessionAgent('sess-narr-1') await submitPrompt(ctx, agent) expect(narrations(session)).toEqual([]) setApprovalPolicy(session, 'never') setApprovalPolicy(session, 'ask') setApprovalPolicy(session, 'never') await submitPrompt(ctx, agent) expect(narrations(session)).toEqual(['The approval policy changed from "ask" to "never" (changed by the user).']) await submitPrompt(ctx, agent) expect(narrations(session)).toHaveLength(1) setApprovalPolicy(session, 'ask') setApprovalPolicy(session, 'never') await submitPrompt(ctx, agent) expect(narrations(session)).toHaveLength(1) }) it('reflects the latest durable switch in cache-safe context and stays byte-stable while unchanged', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ApprovalService) const { agent, session } = sessionAgent('sess-context-switch') const contextFor = async () => (await ctx.systemPrompt.assemble({ agent })).contexts.find(entry => entry.name === 'approval:policy')?.text expect(await contextFor()).toBe(ASK_SENTENCE) expect(await contextFor()).toBe(ASK_SENTENCE) setApprovalPolicy(session, 'never') setApprovalPolicy(session, 'ask') setApprovalPolicy(session, 'never') expect(await contextFor()).toBe(NEVER_SENTENCE) expect(await contextFor()).toBe(NEVER_SENTENCE) }) it('preserves a rejected pre-step without adding policy narration', async () => { const ctx = new Context() await ctx.plugin(ApprovalService) const { agent, session } = sessionAgent('sess-narr-rejected') appendToldPolicy(session, 'ask') appendHeader(session) setApprovalPolicy(session, 'never') const signal = new AbortController().signal const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', [], { turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'reject' as const }), ) expect(decision).toEqual({ kind: 'reject' }) expect(narrations(session)).toEqual([]) }) it('reads what the model was told from visible runtime context after a restart', async () => { // A session whose retained context stated never resumes under // an ask default: the narrator attributes the change to the operator. const ctx = new Context() await ctx.plugin(ApprovalService) const { agent, session } = sessionAgent('sess-narr-2') appendToldPolicy(session, 'never') appendHeader(session) await submitPrompt(ctx, agent) expect(narrations(session)).toEqual(['The approval policy changed from "never" to "ask" (changed by the operator/config).']) }) it('retries narration when an outer pre-step listener throws before entry', async () => { const ctx = new Context() let fail = true ctx.on('agent/pre-step', async (_agent, _messages, _context, next) => { const decision = await next() if (fail) { fail = false throw new Error('outer failure') } return decision }) await ctx.plugin(ApprovalService) const { agent, session } = sessionAgent('sess-narr-retry') appendToldPolicy(session, 'ask') appendHeader(session) setApprovalPolicy(session, 'never') await expect(submitPrompt(ctx, agent)).rejects.toThrow('outer failure') await submitPrompt(ctx, agent) expect(narrations(session)).toEqual(['The approval policy changed from "ask" to "never" (changed by the user).']) }) it('attributes a constructor-seeded policy event to delegation', async () => { const ctx = new Context() await ctx.plugin(ApprovalService) const { agent, session } = sessionAgent('sess-narr-inherited') appendToldPolicy(session, 'ask') appendHeader(session) session.append('approval/policy', { policy: 'never', source: 'delegation' }) await submitPrompt(ctx, agent) expect(narrations(session)).toEqual(['The approval policy changed from "ask" to "never" (inherited from the delegating session).']) }) it('narrates a config default drift from retained runtime context', async () => { const ctx = new Context() await ctx.plugin(ApprovalService, { policy: 'never' }) const { agent, session } = sessionAgent('sess-narr-3') appendToldPolicy(session, 'ask') appendHeader(session) await submitPrompt(ctx, agent) expect(narrations(session)).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 } = sessionAgent('sess-narr-4') appendToldPolicy(session, 'ask') appendHeader(session) setApprovalPolicy(session, 'ask') appendHeader(session) await submitPrompt(ctx, agent) expect(narrations(session)).toEqual([]) }) it('does not infer never from an unowned message that quotes the never sentence', async () => { const ctx = new Context() await ctx.plugin(ApprovalService) const { agent, session } = sessionAgent('sess-narr-spoof-prose') session.append('user/message', createUserMessage({ content: [{ type: 'text', text: NEVER_SENTENCE }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) appendToldPolicy(session, 'ask') appendHeader(session) await submitPrompt(ctx, agent) expect(narrations(session)).toEqual([]) }) it('treats a legacy header with no owned runtime context as untold', async () => { const ctx = new Context() await ctx.plugin(ApprovalService, { policy: 'never' }) const { agent, session } = sessionAgent('sess-narr-unmarked-header') appendHeader(session) await submitPrompt(ctx, agent) expect(narrations(session)).toEqual([]) }) it('uses the latest owned runtime-context snapshot', async () => { const ctx = new Context() await ctx.plugin(ApprovalService) const { agent, session } = sessionAgent('sess-narr-spoof-marker') appendToldPolicy(session, 'never') appendToldPolicy(session, 'ask') appendHeader(session) await submitPrompt(ctx, agent) expect(narrations(session)).toEqual([]) }) it('does not fall through a newer complete runtime-context snapshot', async () => { const ctx = new Context() await ctx.plugin(ApprovalService) const { agent, session } = sessionAgent('sess-narr-latest-context') appendToldPolicy(session, 'never') session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Current runtime context:\n\nUnrelated context only.' }], source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }, }), { surfaceOp: 'append' }) appendHeader(session) await submitPrompt(ctx, agent) expect(narrations(session)).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 contextFor = async () => (await ctx.systemPrompt.assemble({ agent: live.agent })).contexts.find(context => context.name === 'approval:policy') expect(await contextFor()).toBeDefined() appendToldPolicy(live.session, 'ask') appendHeader(live.session) setApprovalPolicy(live.session, 'never') await submitPrompt(ctx, live.agent) expect(narrations(live.session)).toEqual(['The approval policy changed from "ask" to "never" (changed by the user).']) appendToldPolicy(afterDispose.session, 'ask') appendHeader(afterDispose.session) setApprovalPolicy(afterDispose.session, 'never') await fiber.dispose() expect(await contextFor()).toBeUndefined() await submitPrompt(ctx, afterDispose.agent) expect(narrations(afterDispose.session)).toEqual([]) }) })