feat(modes): per-session sandbox/approval switching — the session log as the store, ACP config options

effective(session) = findLast(the session own knob events)?.value ?? the
composition-config default. One log-only event per knob, owned by its
domain (bash/sandbox-mode in dsh-bash, approval/policy in dsh-approval),
each exporting the same three-piece kit: the event declaration, a pure
fold, and THE write path — a switch IS its event; no owner service, no
facts map. Restart immunity and multi-session isolation fall out of the
log replay by construction.

Execution follows the fold on both sides: the bash tool stamps
escalation grant > session override > executor default, and the approval
seam prepends the never-gate that auto-rejects before any interactive
answerer. Visibility is two layers per knob: a per-agent prompt section
states the effective value on every request (logged through
request/header*, so what-the-model-was-told replays from the log), and an
agent/pre-step narrator injects at most one coalesced delta notice with
positional attribution (user switch vs operator/config drift). The ACP
bridge advertises one capability-gated select per composable knob with
currentValue folded per session, validates set_config_option against the
closed vocabularies, and anchors idle switches at the next turn
prompt-submit under the turn-enclosure contract.
This commit is contained in:
kingwl
2026-07-09 16:41:03 +08:00
parent 0e49615a3d
commit 3f663c9154
24 changed files with 1149 additions and 43 deletions
+2 -2
View File
@@ -4,6 +4,6 @@ The asking half of permission handling: one seam through which the harness puts
| Package | Role | ctx key |
|---|---|---|
| `approval/` | The `ApprovalService` mechanism (waterfall dispatch, cancellation, audit events) + the vocabulary (`ApprovalRequest`, `ApprovalOutcome`, `ApprovalRequestId`) | `ctx.approval` |
| `approval/` | The `ApprovalService` mechanism (waterfall dispatch, cancellation, audit events) + the vocabulary (`ApprovalRequest`, `ApprovalOutcome`, `ApprovalRequestId`) + the per-session policy tier (`ApprovalPolicy` `'ask'`/`'never'`, the `'approval/policy'` event fold, the prepend gate — [sandbox RFC § Per-session mode switching](../../docs/rfc/proposed/feature/2026-07-06-sandbox.md)) | `ctx.approval` |
Answerers live with their owners, not here: tests answer with inline scripted listeners, and the ACP bridge answerer is the staged first real one. Consumer today: [`core/tools`](../core/tools/) routes `tools/pre-execute`'s `ask` through the seam (degrading to deny when it is not mounted).
Answerers live with their owners, not here: the ACP bridge ([`ui/acp`](../ui/acp/)) answers for the editor sessions it owns (and switches each session's policy over ACP config options); tests answer with inline scripted listeners. Consumers today: [`core/tools`](../core/tools/) routes `tools/pre-execute`'s `ask` through the seam (degrading to deny when it is not mounted), and the bash tool's sandbox escalation gate ([`bash/tool-bash`](../bash/tool-bash/), [sandbox RFC § Escalation](../../docs/rfc/proposed/feature/2026-07-06-sandbox.md)).
+4 -2
View File
@@ -6,6 +6,8 @@ The contract in one line: `ctx.approval.request(req)` puts exactly one question
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. 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.
One seam serves both ask paths of [the sandbox RFC](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md): the `tools/pre-execute` `ask` decision (routed by [`@deepseek-ai/dsh-tools`](../../core/tools/) when this service is mounted; degrading to deny when it is not), and the sandbox post-denial escalated retry that RFC stages (the asker will live in the bash tool layer). The full design: [the approval-seam RFC](../../../docs/rfc/proposed/feature/2026-07-06-approval-seam.md).
The seam also owns the per-session POLICY tier ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md)): `ApprovalPolicy` is `'ask'` (delegate to the answerers — the prior behavior exactly) or `'never'` (deterministically reject without prompting anyone; the strict CI/unattended stance), with `effective = fold(the session's 'approval/policy' events, last one wins) ?? Config.policy` — the session log is the store, written only through `setApprovalPolicy(session, policy)`. The service decides `'never'` inside `request()` itself, before dispatching the waterfall (`'never'` → `'rejected'` with the audit pair still landing; no listener registration, including a later `prepend`, can precede it), states `'never'` — and only `'never'` — in a per-agent prompt section (a "you will be prompted" promise under `'ask'` would overclaim what a headless composition can do; the section scope activates only when `systemPrompt` is composed), and narrates a policy switch to the model in at most one coalesced `agent/pre-step` notice, attributed positionally (an override event after the log's last `request/header*` reads `changed by the user`; a config drift reads `changed by the operator/config`).
No answerer ships in this change — every ask fails closed to `unavailable` until one is composed (the ACP bridge answerer is the staged first one). The audit events are log-only session records — the model only ever sees the tool result the asker derives from the outcome.
One seam serves both ask paths of [the sandbox RFC](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md): the `tools/pre-execute` `ask` decision (routed by [`@deepseek-ai/dsh-tools`](../../core/tools/) when this service is mounted; degrading to deny when it is not), and the sandbox post-denial escalated retry (the bash tool's `sandbox_permissions` gate in [`@deepseek-ai/dsh-tool-bash`](../../bash/tool-bash/) — [the sandbox RFC § Escalation](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md)). The full design: [the approval-seam RFC](../../../docs/rfc/proposed/feature/2026-07-06-approval-seam.md).
Answerers today: the ACP bridge ([`@deepseek-ai/dsh-acp`](../../ui/acp/)) forwards to the editor's `session/request_permission` prompt for agents it owns. The audit events are log-only session records — the model only ever sees the tool result the asker derives from the outcome.
+5
View File
@@ -26,13 +26,18 @@
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}
+185 -2
View File
@@ -20,15 +20,27 @@
* model-visible transcript: the model only ever sees the tool result the
* caller derives from the outcome.
*
* The seam also owns the per-session POLICY tier (the sandbox RFC § Per-session mode switching):
* `effective = fold(the session's 'approval/policy' events, last one wins)
* ?? config.policy` — the session log is the store, so an override survives
* restart by replay. The service resolves `'never'` sessions to
* `'rejected'` inside `request()` before dispatching any answerer (no
* registration order, including a later `prepend`, can precede it); a prompt section states `'never'`
* (and only `'never'` — an availability promise is unknowable without
* asking); an `agent/pre-step` narrator explains a switch to the model in at
* most one coalesced notice per step.
*
* @module @deepseek-ai/dsh-approval
*/
import { randomUUID } from 'node:crypto'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { CallId } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-system-prompt'
declare module 'cordis' {
interface Context {
@@ -78,6 +90,15 @@ declare module '@deepseek-ai/dsh-session' {
id: ApprovalRequestId
outcome: ApprovalOutcome
}
/**
* The session's approval policy was switched — log-only, durable,
* replayable, never in the model transcript (the model learns the policy
* from the prompt section and the narrator's notices). The LAST such
* event is the session's override ({@link effectiveApprovalPolicy});
* who asked for it is derivable from position (an event after the log's
* last `request/header*` was a runtime switch by the user).
*/
'approval/policy': { policy: ApprovalPolicy }
}
}
@@ -113,6 +134,53 @@ export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unava
/** Every {@link ApprovalOutcome}, for runtime normalization of answerer returns. */
const OUTCOMES: readonly ApprovalOutcome[] = ['allowed-once', 'rejected', 'cancelled', 'unavailable']
/**
* A session's approval policy — what happens to an {@link ApprovalService}
* ask BEFORE any interactive answerer sees it:
*
* - `'ask'` (the default) — delegate to the composed answerers; with none
* composed the chain falls through to the fail-closed `'unavailable'`
* (exactly today's behavior).
* - `'never'` — never prompt anyone: every ask resolves `'rejected'`
* deterministically. The strict headless stance (CI, unattended runs) and
* the only policy value stated in the system prompt — unlike `'ask'`, its
* outcome is knowable without asking, so stating it cannot overclaim.
*/
export type ApprovalPolicy = 'ask' | 'never'
/** Every {@link ApprovalPolicy}, for option advertisement and runtime validation of untrusted policy strings. */
export const APPROVAL_POLICIES: readonly ApprovalPolicy[] = ['ask', 'never']
/**
* The prompt sentence stating a `'never'` policy — visibility for the one
* deterministic policy (see {@link ApprovalPolicy}), and the narrator's parse
* candidate for "what was the model last told": a folded `request/header*`
* system text containing it was assembled under `'never'`; one without it
* (but with any header at all) was assembled under `'ask'`, which states
* nothing. The exact-wording compatibility surface (writer and parser) lives
* entirely in this module; the bash tool description's escalation teaching
* additionally defers to the sentence's opening claim by meaning (see
* `dsh-tool-bash`), so keep the sentence opening with the approvals-disabled
* statement.
*/
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`).'
/**
* The session's approval-policy override: the last `approval/policy` event in
* the log, or undefined when the session never switched (callers apply the
* plugin's configured default). The pure fold — resume needs no catch-up
* machinery because replaying the log IS the state.
* @param events - session events in log order (other event types are skipped).
* @returns the policy of the last switch event, or undefined without one.
*/
export function effectiveApprovalPolicy(events: readonly SessionEvent[]): ApprovalPolicy | undefined {
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index] as SessionEvent
if (event.type === 'approval/policy') return event.data.policy
}
return undefined
}
/**
* Whether the log currently sits inside an open turn (a `turn/start` not yet
* closed by a `turn/end`) — the {@link ApprovalService.request} precondition.
@@ -129,6 +197,19 @@ function hasOpenTurn(events: readonly SessionEvent[]): boolean {
return false
}
/**
* THE write path for a session's approval-policy override: appends exactly
* one `approval/policy` event — the switch IS its event; nothing mutates
* policy state out of band. Takes effect on the session's next ask and next
* prompt assembly (the consumers fold on every read).
* @param session - the session the override belongs to.
* @param policy - the policy every subsequent ask for this session resolves
* under (until the next switch).
*/
export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): void {
session.append('approval/policy', { policy })
}
/**
* One concrete permission question. Identifies the action precisely enough
* for an answerer to present it and for the audit events to reconstruct what
@@ -159,15 +240,100 @@ export interface ApprovalRequest {
signal?: AbortSignal
}
/** Plugin config. All optional — `static Config` supplies the defaults. */
export interface Config {
/**
* The deployment's default {@link ApprovalPolicy} for sessions without an
* `approval/policy` override — `'ask'` delegates to the composed answerers
* (fail-closed with none); `'never'` auto-rejects every ask without
* prompting (the deterministic CI/unattended stance).
*/
policy?: ApprovalPolicy
}
/**
* The `ctx.approval` service: dispatches {@link ApprovalRequest}s to the
* `approval/request` waterfall and audits every ask/outcome pair to the
* requesting agent's session log. Stateless between requests — grants are
* returned to the caller, never stored here.
*
* Owns the policy tier too (`effective = fold(the session's 'approval/policy'
* events) ?? config.policy`): a PREPENDED decide-or-delegate gate resolves
* `'never'` sessions to `'rejected'` before any interactive answerer is
* prompted, a per-agent prompt section states a `'never'` policy (and only
* that one — an `'ask'` promise could overclaim an answerer that headless
* compositions do not have), and an `agent/pre-step` narrator injects at most
* one coalesced notice when a session's effective policy moved past what the
* model was last told.
*/
export class ApprovalService extends Service {
constructor(ctx: Context) {
static Config: z<Config> = z.object({
policy: z.union(['ask', 'never'] as const).default('ask'),
})
constructor(ctx: Context, public config: Config) {
super(ctx, 'approval')
const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent)
// Visibility layer 1, scoped on the prompt registry so headless
// compositions mount the seam without it: state the one deterministic
// policy per session. 'ask' renders nothing — stating "you will be
// asked" would overclaim in a composition with no answerer, and absence
// under any logged header is exactly how the narrator reads 'ask' back.
ctx.inject(['systemPrompt'], (scope: Context) => {
scope.systemPrompt.section({
name: 'approval:policy',
order: 115,
text: (context) => {
const agent = context.agent
// A bare assemble() (tests, diagnostics) has no session to state.
if (agent === undefined) return ''
return effective(agent) === 'never' ? NEVER_SENTENCE : ''
},
})
})
// Visibility layer 2: the boundary narrator. pre-step runs after prompt
// assembly but before the request history is derived, so the notice is
// seen by THIS step's request: idle-time flip-flops coalesce at the
// turn's first step (net-zero → nothing), and a mid-turn switch is
// narrated no later than the next step. What each session was last told
// is in-memory with a log-derived fallback (the folded header's system
// text), so restarts lose nothing. Attribution is positional: an
// override event after the log's last `request/header*` was a runtime
// switch by the user; otherwise the configured default moved under the
// session (operator/config).
const narrated = new WeakMap<Agent['session'], ApprovalPolicy>()
ctx.on('agent/pre-step', (agent) => {
const session = agent.session
const events = session.events
let overrideIndex = -1
let headerIndex = -1
for (let index = events.length - 1; index >= 0 && (overrideIndex < 0 || headerIndex < 0); index -= 1) {
const event = events[index] as (typeof events)[number]
if (overrideIndex < 0 && event.type === 'approval/policy') {
overrideIndex = index
} else if (headerIndex < 0 && (event.type === 'request/header' || event.type === 'request/header-delta')) {
headerIndex = index
}
}
// Same fold effectivePolicy performs — override is scanned here anyway
// for POSITIONAL attribution; the default lives once, in the method.
const current = this.effectivePolicy(agent)
const header = session.requestHeader()
const told = narrated.get(session)
?? (header === undefined ? undefined : header.system?.includes(NEVER_SENTENCE) === true ? 'never' : 'ask')
narrated.set(session, current)
// Cold start (nothing ever told) narrates nothing — the section about
// to go out states the truth, and there is no delta to explain.
if (told === undefined || told === current) return
const cause = overrideIndex > headerIndex ? 'changed by the user' : 'changed by the operator/config'
agent.inject(
[{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }],
{ source: { kind: 'plugin', plugin: 'approval' } },
)
})
}
/**
@@ -205,9 +371,26 @@ export class ApprovalService extends Service {
return outcome
}
/**
* The session's effective policy: its own `approval/policy` fold, else the
* configured default (the schema already defaulted an omitted policy to
* `'ask'`; the `??` only narrows the optional-input TYPE).
* @param agent - the agent whose session's policy applies.
* @returns the policy every ask for this agent resolves under right now.
*/
private effectivePolicy(agent: Agent): ApprovalPolicy {
return effectiveApprovalPolicy(agent.session.events) ?? this.config.policy ?? 'ask'
}
/** Dispatch the waterfall, contained and raced against `req.signal`. */
private async decide(req: ApprovalRequest): Promise<ApprovalOutcome> {
if (req.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
// documented promise that 'never' rejects deterministically regardless
// of registration order — only the service's own request path can.
if (this.effectivePolicy(req.agent) === 'never') return 'rejected'
// Enter the promise chain BEFORE dispatching: a listener that throws
// SYNCHRONOUSLY (before its first await) must land in the same rejection
// path as an async one — `Promise.resolve(call())` would let it escape
@@ -1,9 +1,11 @@
import { describe, expect, it } from 'vitest'
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 { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import ApprovalService, { ApprovalOutcome, ApprovalRequest } from '@deepseek-ai/dsh-approval'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ApprovalService, { ApprovalOutcome, ApprovalRequest, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-approval'
/**
* A minimal Agent stand-in — the service only reaches `agent.session.append`
@@ -201,3 +203,169 @@ describe('ApprovalService.request', () => {
})
})
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`).'
/**
* 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 the prompt, per session', 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('')
expect(await sectionFor({ agent: neverAgent })).toBe(NEVER_SENTENCE)
// 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}`)
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 over a sentence-less header (told = ask by absence)', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService, { policy: 'never' })
const { agent, session, injected } = sessionAgent('sess-narr-3')
appendHeader(session, 'persona only')
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')
setApprovalPolicy(session, 'ask')
appendHeader(session, 'persona only')
await preStep(ctx, agent)
expect(injected).toEqual([])
})
})